From 31549da76c7540715fcf7a01fe4f9d106ad86707 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 02:22:13 +0200 Subject: [PATCH 1/8] feat(cli): add machine-readable doctor report MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lunora doctor` was the only project-preflight command with no options and no identifier on its findings, so an agent or CI job had to scrape English prose off stderr to learn what was wrong — and the prose is not a contract. Every finding now carries a stable kebab-case `code` drawn from a single `DOCTOR_CODES` const, and `--format json` emits the findings as one JSON document on stdout (`{ ok, code, summary, findings }`) with the human report routed to stderr, the same envelope `deploy` and `verify` already use. Exit codes and the default pretty output are unchanged. `pass`-level findings are included so the document describes everything that was checked, not only what failed. Adds a `cli-shadowed` check: a globally-installed `lunora` running against a project with its own pinned install makes every other finding describe a project this CLI may be the wrong version for, and no existing check can see it — the version-skew check reads the manifest, which is exactly the file the shadowing binary ignores. The comparison asks whether the running module lives inside the project's installed CLI package rather than comparing bin paths, because pnpm writes `node_modules/.bin/*` as a shell shim rather than a symlink, and path equality would warn on every pnpm project. The code table in the CLI docs is asserted against `DOCTOR_CODES` by a test, so a new or renamed code cannot ship undocumented. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- .../cli/__tests__/commands/doctor.test.ts | 199 ++++++++++++++- packages/cli/docs/index.mdx | 57 ++++- packages/cli/src/commands/doctor/handler.ts | 195 ++++++++++++-- packages/cli/src/commands/doctor/index.ts | 13 +- plans/307-doctor-machine-readable.md | 241 ++++++++++++++++++ 5 files changed, 681 insertions(+), 24 deletions(-) create mode 100644 plans/307-doctor-machine-readable.md diff --git a/packages/cli/__tests__/commands/doctor.test.ts b/packages/cli/__tests__/commands/doctor.test.ts index 023cd19ea6..ca613021f5 100644 --- a/packages/cli/__tests__/commands/doctor.test.ts +++ b/packages/cli/__tests__/commands/doctor.test.ts @@ -1,12 +1,31 @@ -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { runDoctor } from "../../src/commands/doctor/handler"; +import { DOCTOR_CODES, runDoctor, runDoctorCommand } from "../../src/commands/doctor/handler"; import type { Logger } from "../../src/util/logger"; +/** Run async `body` while capturing everything written to `process.stdout`. */ +const captureStdout = async (body: () => Promise): Promise => { + let captured = ""; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + captured += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + + return true; + }); + + try { + await body(); + } finally { + spy.mockRestore(); + } + + return captured; +}; + const makeLogger = (): { lines: string[]; logger: Logger } => { const lines: string[] = []; const push = @@ -335,4 +354,176 @@ describe("runDoctor", () => { expect(versionFinding(result.findings)).toBeUndefined(); }); }); + + /** + * A globally-installed `lunora` shadowing the project's pinned one makes every + * other finding describe a project this CLI may be the wrong version for — + * and `checkVersionSkew` cannot see it, because the manifest it reads is + * exactly the file the shadowing binary ignores. + */ + describe("cli shadowing", () => { + /** Install `@lunora/cli` the way pnpm does: a symlink into a store directory. */ + const seedLocalCli = (): string => { + const store = join(workdir, "node_modules", ".pnpm", "@lunora+cli@1.0.0", "node_modules", "@lunora", "cli", "dist"); + + mkdirSync(store, { recursive: true }); + writeFileSync(join(store, "bin.mjs"), "// bin\n", "utf8"); + mkdirSync(join(workdir, "node_modules", "@lunora"), { recursive: true }); + symlinkSync(dirname(store), join(workdir, "node_modules", "@lunora", "cli"), "dir"); + + return join(store, "bin.mjs"); + }; + + const shadowFinding = (findings: ReadonlyArray<{ code: string; level: string }>) => findings.find((finding) => finding.code === "cli-shadowed"); + + it("stays clean when the running binary is the project's own pnpm-linked install", async () => { + expect.assertions(1); + + seed(workdir, CLEAN_WRANGLER); + + const localEntry = seedLocalCli(); + const result = await runDoctor({ cwd: workdir, executablePath: localEntry, logger: makeLogger().logger }); + + // The bin is reached through a symlinked package directory, which is + // the layout that a naive path-equality check reports as a mismatch. + expect(shadowFinding(result.findings)).toBeUndefined(); + }); + + it("warns exactly once when the running binary lives outside the project", async () => { + expect.assertions(3); + + seed(workdir, CLEAN_WRANGLER); + seedLocalCli(); + + const globalDir = mkdtempSync(join(tmpdir(), "lunora-cli-global-")); + const globalEntry = join(globalDir, "bin.mjs"); + + writeFileSync(globalEntry, "// bin\n", "utf8"); + + const result = await runDoctor({ cwd: workdir, executablePath: globalEntry, logger: makeLogger().logger }); + + rmSync(globalDir, { force: true, recursive: true }); + + expect(result.findings.filter((finding) => finding.code === "cli-shadowed")).toHaveLength(1); + expect(shadowFinding(result.findings)?.level).toBe("warn"); + // A wrong binary is never a hard failure — it is often deliberate. + expect(result.code).toBe(0); + }); + + it("skips silently when the project has no local install", async () => { + expect.assertions(1); + + seed(workdir, CLEAN_WRANGLER); + + const result = await runDoctor({ cwd: workdir, executablePath: join(tmpdir(), "somewhere", "bin.mjs"), logger: makeLogger().logger }); + + expect(shadowFinding(result.findings)).toBeUndefined(); + }); + }); + + describe("--format json", () => { + it("puts one JSON document on stdout and the human report on stderr", async () => { + expect.assertions(5); + + seed(workdir, PLACEHOLDER_WRANGLER); + + const { logger } = makeLogger(); + let stderr = ""; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation((chunk: string | Uint8Array): boolean => { + stderr += typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + + return true; + }); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, format: "json", logger }); + }); + + stderrSpy.mockRestore(); + + const parsed = JSON.parse(stdout) as { code: number; findings: { code: string; level: string }[]; ok: boolean; summary: Record }; + + expect(parsed.ok).toBe(false); + expect(parsed.code).toBe(1); + expect(parsed.findings.some((finding) => finding.code === "d1-placeholder-id" && finding.level === "fail")).toBe(true); + expect(parsed.summary.fail).toBe(1); + // The report is still rendered — on stderr, so stdout stays pipeable. + expect(stderr).toContain("lunora doctor — project preflight"); + }); + + it("counts every level in the summary and keeps pass findings in the document", async () => { + expect.assertions(2); + + seed(workdir, CLEAN_WRANGLER); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, format: "json", logger: makeLogger().logger }); + }); + + const parsed = JSON.parse(stdout) as { findings: { level: string }[]; summary: Record<"fail" | "info" | "pass" | "warn", number> }; + + expect(parsed.summary.pass).toBeGreaterThan(0); + expect(parsed.findings.filter((finding) => finding.level === "pass")).toHaveLength(parsed.summary.pass); + }); + + it("renders the human report on the caller's logger in pretty mode", async () => { + expect.assertions(2); + + seed(workdir, CLEAN_WRANGLER); + + const { lines, logger } = makeLogger(); + + const stdout = await captureStdout(async () => { + await runDoctorCommand({ cwd: workdir, logger }); + }); + + expect(stdout).toBe(""); + expect(lines.some((line) => line.includes("lunora doctor — project preflight"))).toBe(true); + }); + + it("rejects an unknown --format the same way the other commands do", async () => { + expect.assertions(3); + + seed(workdir, CLEAN_WRANGLER); + + const { lines, logger } = makeLogger(); + + const stdout = await captureStdout(async () => { + const result = await runDoctorCommand({ cwd: workdir, format: "yaml", logger }); + + expect(result.code).toBe(1); + }); + + expect(stdout).toBe(""); + expect(lines.some((line) => line.includes('unknown --format "yaml" — expected pretty | json'))).toBe(true); + }); + }); + + /** + * The codes are the machine-readable contract, so adding or renaming one has + * to be a deliberate act rather than a side effect of editing a check. The + * docs table is the committed fixture: it is the artefact consumers read, so + * asserting against it keeps the contract and its documentation in one place + * instead of two that can drift. + */ + describe("finding codes", () => { + const DOCS_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "docs", "index.mdx"); + + it("is a sorted, duplicate-free list", () => { + expect.assertions(2); + + expect([...DOCTOR_CODES]).toStrictEqual([...DOCTOR_CODES].toSorted((left, right) => left.localeCompare(right))); + expect(new Set(DOCTOR_CODES).size).toBe(DOCTOR_CODES.length); + }); + + it("documents exactly the codes the doctor can emit", () => { + expect.assertions(1); + + const documented = [...readFileSync(DOCS_PATH, "utf8").matchAll(/^\| `(?[a-z\d-]+)` +\| +(?:fail|info|pass|warn) /gmu)].map( + (match) => match.groups?.code ?? "", + ); + + expect(documented).toStrictEqual([...DOCTOR_CODES]); + }); + }); }); diff --git a/packages/cli/docs/index.mdx b/packages/cli/docs/index.mdx index 7e14ebba00..e9520f5e91 100644 --- a/packages/cli/docs/index.mdx +++ b/packages/cli/docs/index.mdx @@ -18,7 +18,7 @@ lunora add # add a feature to the current project lunora view [--remote] # open the Lunora studio in your browser lunora docs [section] # open the docs site in your browser lunora info [--json] # print versions, wrangler summary, schema overview -lunora doctor # preflight the project (bindings, placeholders, secrets) +lunora doctor [--format ] # preflight the project (bindings, placeholders, secrets) lunora registry # component registry lunora rules # install the AI agent skills into .agents/skills/ @@ -180,13 +180,68 @@ A read-only preflight over the current project. It reports pass / warn / fail fo - **Version skew** — `@lunora/*` packages spanning different versions, or mixing release channels (e.g. stable + alpha). +- **CLI shadowing** — a globally-installed `lunora` running instead of the + project's own, so the report describes a project this CLI is not pinned to. + ```bash lunora doctor +lunora doctor --format json # machine-readable; one JSON document on stdout ``` It writes nothing and exits non-zero when any hard check fails, so it works as a CI gate. See [Debugging](/docs/concepts/debugging) for how to act on each finding. +#### Machine-readable output + +`--format json` prints exactly one JSON document on stdout; the human report is +still rendered, but on stderr, so the document stays pipeable. The exit code is +identical in both formats. + +```json +{ + "code": 1, + "findings": [ + { + "code": "d1-placeholder-id", + "fix": "Run `wrangler d1 create ` …", + "level": "fail", + "message": "D1 binding \"DB\" has a placeholder database_id …" + } + ], + "ok": false, + "summary": { "fail": 1, "info": 1, "pass": 1, "warn": 0 } +} +``` + +`ok` is redundant with `code` on purpose — it is the field a consumer without a +shell reaches for first. `pass`-level findings are included, so the document +describes everything that was checked, not only what went wrong. + +#### Finding codes + +Branch on `finding.code`, never on `message` — the codes are the contract, the +prose is not. New codes are added over time; treat an unknown one as advisory. + +| Code | Level | Meaning | +| -------------------------------- | ----- | ---------------------------------------------------------------------------- | +| `admin-token-missing` | info | `LUNORA_ADMIN_TOKEN` is not set (studio / admin RPCs stay disabled). | +| `admin-token-set` | pass | `LUNORA_ADMIN_TOKEN` is set. | +| `cli-shadowed` | warn | The running `lunora` is not the project's own install. | +| `d1-placeholder-id` | fail | A D1 binding still carries a scaffold `database_id`. | +| `declared-export-missing` | fail | A declared container / workflow / agent is not exported by the worker entry. | +| `declared-export-ok` | pass | A declared container / workflow / agent is exported by the worker entry. | +| `dev-vars-missing-secret` | warn | `.dev.vars` has secret-looking keys left at placeholder values. | +| `email-destination-placeholder` | warn | A `send_email` binding has a placeholder `destination_address`. | +| `vector-metadata-index-required` | info | A declared Vectorize metadata filter needs its metadata index created. | +| `vector-metadata-unfilterable` | warn | A metadata property has a type Vectorize cannot filter on. | +| `version-counter-spread` | info | Same-channel `@lunora/*` pre-release counters differ (normal, worth noting). | +| `version-skew-channels` | warn | `@lunora/*` packages mix release channels (stable + alpha). | +| `version-skew-cores` | warn | `@lunora/*` packages span different `major.minor.patch` versions. | +| `wrangler-missing` | fail | No `wrangler.jsonc` was found. | +| `wrangler-shard-binding-missing` | fail | `wrangler.jsonc` is missing the `SHARD` durable-object binding. | +| `wrangler-shard-binding-ok` | pass | `wrangler.jsonc` declares the `SHARD` durable-object binding. | +| `wrangler-unparseable` | fail | `wrangler.jsonc` was found but is not valid JSONC. | + ### `lunora dev` Starts three concurrent processes: `wrangler dev` (Worker), the embedded diff --git a/packages/cli/src/commands/doctor/handler.ts b/packages/cli/src/commands/doctor/handler.ts index 13efd0db73..7e6e5ac478 100644 --- a/packages/cli/src/commands/doctor/handler.ts +++ b/packages/cli/src/commands/doctor/handler.ts @@ -1,5 +1,5 @@ -import { existsSync, readFileSync } from "node:fs"; -import { join } from "node:path"; +import { existsSync, readFileSync, realpathSync } from "node:fs"; +import { join, sep } from "node:path"; import { DEV_VARS_FILE, discoverSchemaInfo, inferLunoraBindings, isPlaceholderValue, parseDevVariableEntries } from "@lunora/config"; import type { WranglerConfig } from "@lunora/config/cloudflare"; @@ -8,13 +8,48 @@ import { collectExportGaps, findWranglerFile, readWranglerJsonc, validateWrangle import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import type { Logger } from "../../util/logger"; +import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import { createMetadataIndexArgs, metadataTypeFor } from "../../util/vectorize-metadata"; import type { DoctorOptions } from "./index"; /** Severity of a single doctor check. `fail` drives a non-zero exit; `warn`/`info`/`pass` don't. */ type FindingLevel = "fail" | "info" | "pass" | "warn"; +/** + * Every diagnostic code `lunora doctor` can emit, sorted. + * + * The codes — not the English messages — are the contract behind `--format + * json`: an agent or CI job branches on `finding.code`, so a copy-edit to a + * message must never rename a diagnostic. Adding, renaming or removing an entry + * here is a public-API change; the docs table in `packages/cli/docs/index.mdx` + * is asserted against this list, so the two cannot drift. + */ +const DOCTOR_CODES = [ + "admin-token-missing", + "admin-token-set", + "cli-shadowed", + "d1-placeholder-id", + "declared-export-missing", + "declared-export-ok", + "dev-vars-missing-secret", + "email-destination-placeholder", + "vector-metadata-index-required", + "vector-metadata-unfilterable", + "version-counter-spread", + "version-skew-channels", + "version-skew-cores", + "wrangler-missing", + "wrangler-shard-binding-missing", + "wrangler-shard-binding-ok", + "wrangler-unparseable", +] as const; + +/** A stable identifier for one doctor diagnostic. See {@link DOCTOR_CODES}. */ +type DoctorCode = (typeof DOCTOR_CODES)[number]; + interface Finding { + /** Stable machine-readable identifier for this diagnostic. */ + code: DoctorCode; /** Optional remediation hint printed under a non-pass finding. */ fix?: string; level: FindingLevel; @@ -26,10 +61,21 @@ interface DoctorResult { /** Process exit code: 1 when any finding is `fail`, else 0. */ code: number; findings: ReadonlyArray; + /** `true` when nothing failed — redundant with `code`, and the field a shell-free consumer reaches for first. */ + ok: boolean; + /** Finding count per level. */ + summary: Record; } interface RunDoctorOptions { cwd?: string; + + /** + * Path of the running `lunora` executable (defaults to `process.argv[1]`). + * Overridable so the CLI-shadow check is testable without re-launching the + * process, the same seam `cwd` provides for the filesystem checks. + */ + executablePath?: string; logger: Logger; } @@ -70,6 +116,7 @@ const readWrangler = (cwd: string): { parsed: WranglerConfig | undefined; path: const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefined, findings: Finding[]): void => { if (path === undefined) { findings.push({ + code: "wrangler-missing", fix: "Run `lunora init` (or `lunora dev`) to scaffold and reconcile wrangler.jsonc.", level: "fail", message: "wrangler.jsonc not found.", @@ -79,7 +126,7 @@ const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefi } if (parsed === undefined) { - findings.push({ fix: `Check ${path} is valid JSONC.`, level: "fail", message: `Could not parse ${path}.` }); + findings.push({ code: "wrangler-unparseable", fix: `Check ${path} is valid JSONC.`, level: "fail", message: `Could not parse ${path}.` }); return; } @@ -88,9 +135,14 @@ const checkWrangler = (parsed: WranglerConfig | undefined, path: string | undefi const shardError = report.errors.find((error) => error.includes("SHARD")); if (shardError === undefined) { - findings.push({ level: "pass", message: "wrangler.jsonc present with a SHARD durable-object binding." }); + findings.push({ code: "wrangler-shard-binding-ok", level: "pass", message: "wrangler.jsonc present with a SHARD durable-object binding." }); } else { - findings.push({ fix: "Run `lunora dev` to auto-reconcile, or add the binding manually.", level: "fail", message: shardError }); + findings.push({ + code: "wrangler-shard-binding-missing", + fix: "Run `lunora dev` to auto-reconcile, or add the binding manually.", + level: "fail", + message: shardError, + }); } }; @@ -111,6 +163,7 @@ const checkVectorMetadataIndexes = (cwd: string, findings: Finding[]): void => { if (type === undefined) { findings.push({ + code: "vector-metadata-unfilterable", fix: "Filter on a string, number or boolean column, or drop it from `metadata`.", level: "warn", message: `vector index "${declaration.index}" declares metadata "${declaration.property}", whose type Vectorize cannot filter on.`, @@ -120,6 +173,7 @@ const checkVectorMetadataIndexes = (cwd: string, findings: Finding[]): void => { } findings.push({ + code: "vector-metadata-index-required", fix: `wrangler ${createMetadataIndexArgs({ index: declaration.index, property: declaration.property, type }).join(" ")}`, level: "info", message: `vector index "${declaration.index}" filters on metadata "${declaration.property}" — that needs a Vectorize metadata index (\`lunora deploy\` creates it).`, @@ -142,6 +196,7 @@ const checkD1Placeholders = (parsed: WranglerConfig | undefined, findings: Findi const label = typeof database.binding === "string" && database.binding.length > 0 ? database.binding : ""; findings.push({ + code: "d1-placeholder-id", fix: "Run `wrangler d1 create ` and paste the returned database_id into wrangler.jsonc.", level: "fail", message: `D1 binding "${label}" has a placeholder database_id ("${databaseId || ""}").`, @@ -165,6 +220,7 @@ const checkEmailDestination = (parsed: WranglerConfig | undefined, findings: Fin const label = typeof binding.name === "string" && binding.name.length > 0 ? binding.name : "send_email"; findings.push({ + code: "email-destination-placeholder", fix: "Set destination_address to a verified Cloudflare Email Routing address.", level: "warn", message: `send_email binding "${label}" has a placeholder destination_address ("${destination}").`, @@ -195,6 +251,7 @@ const checkDevVariables = (cwd: string, findings: Finding[]): void => { if (unfilled.length > 0) { findings.push({ + code: "dev-vars-missing-secret", fix: "Run `lunora dev` to auto-generate secrets, or fill them in by hand.", level: "warn", message: `${DEV_VARS_FILE} has unfilled secret value(s): ${unfilled.join(", ")}.`, @@ -208,12 +265,13 @@ const checkAdminToken = (findings: Finding[]): void => { if (token === undefined || token.trim() === "") { findings.push({ + code: "admin-token-missing", fix: "Set LUNORA_ADMIN_TOKEN (env or `.dev.vars`) to enable admin RPCs / studio.", level: "info", message: "LUNORA_ADMIN_TOKEN is not set.", }); } else { - findings.push({ level: "pass", message: "LUNORA_ADMIN_TOKEN is set." }); + findings.push({ code: "admin-token-set", level: "pass", message: "LUNORA_ADMIN_TOKEN is set." }); } }; @@ -253,11 +311,12 @@ const checkDeclaredExports = async (cwd: string, findings: Finding[]): Promise candidate.exported)) { - findings.push({ level: "pass", message: `${entry.kind} "${entry.exportName}" is exported by the worker entry.` }); + findings.push({ code: "declared-export-ok", level: "pass", message: `${entry.kind} "${entry.exportName}" is exported by the worker entry.` }); } for (const gap of gaps) { findings.push({ + code: "declared-export-missing", fix: `Add \`export * from "./lunora/_generated/${gap.module}"\` to your worker entry (or re-run \`vis generate lunora-${gap.kind}\`).`, level: "fail", message: `${gap.kind} "${gap.exportName}" is declared but ${gap.className} is not exported by the worker entry.`, @@ -355,6 +414,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { if (cores.size > 1) { findings.push({ + code: "version-skew-cores", fix: 'Run `pnpm update "@lunora/*" lunorash` (or `npm`/`yarn` equivalent) so every Lunora package moves to the same release.', level: "warn", message: `Lunora packages span ${String(cores.size)} different versions: ${describe(parsed)}.`, @@ -365,6 +425,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { if (channels.size > 1) { findings.push({ + code: "version-skew-channels", fix: "Pick one channel (all stable, or all alpha/beta) and update the odd package out.", level: "warn", message: `Lunora packages mix release channels (${[...channels].map((channel) => (channel === "" ? "stable" : channel)).join(" + ")}): ${describe(parsed)}.`, @@ -383,6 +444,7 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { // defect — so this is INFO with the spread, not a warning. It's still the // number to check first when behavior disagrees with the docs. findings.push({ + code: "version-counter-spread", level: "info", message: counters.length === parsed.length && lowest === highest @@ -392,6 +454,70 @@ const checkVersionSkew = (cwd: string, findings: Finding[]): void => { } }; +/** The two packages that can install a project-local `lunora` binary. */ +const LOCAL_CLI_PACKAGES = [join("@lunora", "cli"), "lunorash"]; + +/** + * A `lunora` from somewhere other than the project's own install → WARN. + * + * A globally-installed binary shadowing the project's pinned one produces a + * report about a project the running CLI may be the wrong version for, and no + * other check can see it: `checkVersionSkew` reads the manifest, which is + * exactly the file the shadowing binary is ignoring. + * + * The comparison is *containment* — is the running module inside the project's + * installed CLI package? — rather than a path equality against + * `node_modules/.bin/lunora`. pnpm writes that bin as a shell shim, not a + * symlink, so its `realpath` is the shim itself and never equals the running + * `dist/bin.mjs`; equality would warn on every pnpm project. Containment holds + * for pnpm's symlinked package dirs, npm/yarn's hoisted ones, and a launch + * through the bin shim alike. + */ +const checkCliShadow = (cwd: string, executablePath: string | undefined, findings: Finding[]): void => { + if (executablePath === undefined) { + return; + } + + const roots: string[] = []; + + for (const packageName of LOCAL_CLI_PACKAGES) { + const packagePath = join(cwd, "node_modules", packageName); + + if (!existsSync(packagePath)) { + continue; + } + + try { + roots.push(realpathSync(packagePath)); + } catch { + // A dangling link is an install problem, not a shadowing one. + } + } + + if (roots.length === 0) { + return; // No project-local install: a global-only project, not a defect. + } + + let running: string; + + try { + running = realpathSync(executablePath); + } catch { + return; + } + + if (roots.some((root) => running === root || running.startsWith(`${root}${sep}`))) { + return; + } + + findings.push({ + code: "cli-shadowed", + fix: "Run the project's own CLI: `pnpm exec lunora …` (or `npx lunora …`).", + level: "warn", + message: `the running lunora (${running}) is not the project's own install (${roots.join(", ")}).`, + }); +}; + /** * Pure, testable preflight core: run the read-only project checks against `cwd` * and return the aggregated findings + the exit code (1 if any hard FAIL). Does @@ -411,11 +537,18 @@ const runDoctor = async (options: RunDoctorOptions): Promise => { checkAdminToken(findings); checkVersionSkew(cwd, findings); checkVectorMetadataIndexes(cwd, findings); + checkCliShadow(cwd, options.executablePath ?? process.argv[1], findings); await checkDeclaredExports(cwd, findings); - const code = findings.some((finding) => finding.level === "fail") ? 1 : 0; + const summary: Record = { fail: 0, info: 0, pass: 0, warn: 0 }; + + for (const finding of findings) { + summary[finding.level] += 1; + } - return { code, findings }; + const code = summary.fail > 0 ? 1 : 0; + + return { code, findings, ok: code === 0, summary }; }; const LEVEL_LABEL: Record = { fail: "FAIL", info: "INFO", pass: "PASS", warn: "WARN" }; @@ -440,8 +573,7 @@ const renderReport = (result: DoctorResult, logger: Logger): void => { } } - const fails = result.findings.filter((finding) => finding.level === "fail").length; - const warns = result.findings.filter((finding) => finding.level === "warn").length; + const { fail: fails, warn: warns } = result.summary; if (fails > 0) { logger.error(`${String(fails)} failure(s), ${String(warns)} warning(s).`); @@ -452,14 +584,45 @@ const renderReport = (result: DoctorResult, logger: Logger): void => { } }; -/** `lunora doctor` handler (lazy-loaded via the command's `loader`). */ -const execute: CommandHandler = defineHandler(async ({ cwd, logger }) => { - const result = await runDoctor({ cwd, logger }); +interface DoctorCommandOptions extends RunDoctorOptions { + /** Output format: `pretty` (default) or `json`. */ + format?: string; +} + +/** + * Run the preflight and emit it in the requested format. `pretty` prints the + * human report exactly as before; `json` routes that same report to stderr (via + * {@link loggerForFormat}) and puts a single {@link DoctorResult} document on + * stdout, so `lunora doctor --format json | …` stays pipeable. The exit code is + * the same in both formats. + */ +const runDoctorCommand = async (options: DoctorCommandOptions): Promise => { + const formatError = validateOutputFormat("doctor", options.format); + + if (formatError !== undefined) { + options.logger.error(formatError); + + return { code: 1, findings: [], ok: false, summary: { fail: 0, info: 0, pass: 0, warn: 0 } }; + } + + const logger = loggerForFormat(options.format, options.logger); + const result = await runDoctor({ ...options, logger }); renderReport(result, logger); + if (isJsonFormat(options.format)) { + printJson(result); + } + + return result; +}; + +/** `lunora doctor` handler (lazy-loaded via the command's `loader`). */ +const execute: CommandHandler = defineHandler(async ({ cwd, logger, options }) => { + const result = await runDoctorCommand({ cwd, format: options.format, logger }); + return { code: result.code }; }); -export { execute, runDoctor }; -export type { DoctorResult, Finding, FindingLevel, RunDoctorOptions }; +export { DOCTOR_CODES, execute, runDoctor, runDoctorCommand }; +export type { DoctorCode, DoctorCommandOptions, DoctorResult, Finding, FindingLevel, RunDoctorOptions }; diff --git a/packages/cli/src/commands/doctor/index.ts b/packages/cli/src/commands/doctor/index.ts index 8dfe6a4228..9b74a83c38 100644 --- a/packages/cli/src/commands/doctor/index.ts +++ b/packages/cli/src/commands/doctor/index.ts @@ -5,19 +5,26 @@ import type { Command, CommandExecute, CreateOptions, Toolbox } from "@visulima/ * wrangler config (SHARD DO binding, placeholder D1 ids), the `send_email` * destination, `.dev.vars` secrets, and `LUNORA_ADMIN_TOKEN`, then prints a * pass/warn/fail report. Exits 1 when any hard check FAILs so it's CI-friendly. + * + * `--format json` emits the same findings as one JSON document on stdout, each + * carrying a stable `code`, so an agent or CI job can branch on the diagnostic + * instead of scraping the prose. */ const doctorCommand: Command = { description: "Preflight the current Lunora project (wrangler bindings, placeholders, dev secrets)", - examples: [["lunora doctor", "Run the project preflight checks"]], + examples: [ + ["lunora doctor", "Run the project preflight checks"], + ["lunora doctor --format json", "Emit the findings as a machine-readable JSON document"], + ], group: "Project", loader: () => import("./handler").then((m) => { return { default: m.execute as CommandExecute }; }), name: "doctor", - options: [], + options: [{ description: "Output format: pretty (default) or json", name: "format", type: String }], }; export { doctorCommand }; -export type DoctorOptions = CreateOptions>; +export type DoctorOptions = CreateOptions<{ format: string | undefined }>; diff --git a/plans/307-doctor-machine-readable.md b/plans/307-doctor-machine-readable.md new file mode 100644 index 0000000000..3dc25a5ebc --- /dev/null +++ b/plans/307-doctor-machine-readable.md @@ -0,0 +1,241 @@ +# Plan 307 — `lunora doctor` emits a stable machine-readable report + +**Baseline:** `370994075` (2026-08-08) +**Status:** DONE (branch `feat/plan-307-doctor-json`) + +## 0. Headline finding + +`lunora doctor` is the only project-preflight command with **no options at all** +(`packages/cli/src/commands/doctor/index.ts:19` — `options: []`) and findings +that carry **no identifier** (`Finding` is `{ level, message, fix? }`, +`handler.ts:17-23`). Every other gate in the CLI — `deploy`, `verify`, `build`, +`logs`, `codegen`, `insights` — already takes `--format json`. So the one +command whose entire job is "tell me what is wrong with this project" is the one +an agent or CI job cannot consume: it must scrape prose from stderr, and the +prose is not a contract. + +## 1. Current state (audit) + +- `runDoctor` (`handler.ts:401-420`) runs 8 checks and returns + `{ code, findings }`; `code` is 1 iff any finding is `fail`. +- `execute` (`handler.ts:456-462`) calls `renderReport`, which prints + `[FAIL] ` / `fix: ` lines through the logger. Nothing else is + emitted; `DoctorResult` never leaves the process. +- The 8 checks are `checkWrangler`, `checkD1Placeholders`, + `checkEmailDestination`, `checkDevVariables`, `checkAdminToken`, + `checkVersionSkew`, `checkVectorMetadataIndexes`, `checkDeclaredExports` + (`handler.ts:70`, `:131`, `:154`, `:177`, `:206`, `:318`, `:106`, `:233`). + Between them they push ~20 distinct findings, each identified only by its + English sentence. +- `checkVersionSkew` (`handler.ts:318`) already detects **dependency** version + drift across `@lunora/*` + `lunorash`. It does not detect the _other_ skew + that bites in practice: a globally-installed `lunora` binary shadowing the + project's pinned one, so the report describes a project the running CLI is not + the right version for. +- No check has a fix that the command can apply itself; `fix` is always prose + for a human to execute. + +## 2. Existing seams (do not reinvent) + +- **`packages/cli/src/util/output-format.ts:46`** — `validateOutputFormat`, + `isJsonFormat`, `loggerForFormat`, `printJson`. This is the whole `--format +json` contract, already used by `deploy` (`deploy/handler.ts:1384-1400`): + validate the flag, route human logging to stderr, print exactly one JSON + document to stdout. Reuse it verbatim — do not invent a second JSON path. +- **`runDoctor`** is already a pure, logger-free core returning a structured + result. The only change it needs is a field per finding; the rendering split + is already correct. +- **`packages/cli/src/util/logger.ts`** — the `Logger` interface `renderReport` + writes through. +- `TARGET_OPTION` (`packages/cli/src/util/deploy-target.ts`) if the checks ever + need to differ per platform target; not required by this plan. + +## 3. The behavioural contract to preserve + +- Exit code stays 1 iff any finding is `fail`, 0 otherwise — in both formats. +- Default (`pretty`) output is byte-identical to today. This plan adds a format, + it does not restyle the existing report. +- `--format json` puts **exactly one** JSON document on stdout and nothing else; + all progress/human text goes to stderr (the rule `deploy` already follows). +- `runDoctor` stays pure and exported (`handler.ts:464`) — the doctor core is + consumed by tests directly and must not gain a logger dependency. + +## 4. Design decisions + +- **A `code` field on `Finding`, not a code table keyed by message.** Chosen + over deriving stable ids from message text (fragile: a copy-edit silently + renames a diagnostic) and over a central registry object (a second place to + forget to update). The code lives at the push site, next to the message it + names. +- **`kebab-case` string codes namespaced by check** (`wrangler-missing`, + `d1-placeholder-id`, `dev-vars-missing-secret`, `version-skew-cores`, …), + not numbers. Numbers imply an ordering and get renumbered; strings survive + reordering and read in a diff. +- **Codes are a public contract, snapshot-tested.** A committed fixture listing + every code the doctor can emit, asserted by a test. Chosen over documenting + them in Markdown only — prose drifts, a failing test does not. This is what + makes the codes safe for an agent to branch on. +- **No `--fix` in this plan.** Applying fixes means writing to `wrangler.jsonc` + and `.dev.vars`, which `lunora add` / `env generate` / `init` already own. + Deferred as an open question rather than half-built here. +- **CLI-shadow detection is a new check, not an extension of + `checkVersionSkew`.** Different failure (wrong binary vs wrong dependency + tree), different fix, so it gets its own code and its own function. + +## 5. Workstreams + +**S — `Finding.code`.** Add a required `code: string` to `Finding` +(`handler.ts:17`); fill it at all ~20 push sites. Type-level: make it a union of +the literal codes so a typo fails `lint:types` rather than shipping. + +**Done.** 17 codes in a `DOCTOR_CODES` `as const` array; `DoctorCode` is +`(typeof DOCTOR_CODES)[number]`, so the union has exactly one source. Filled at +all 16 pre-existing push sites plus the new `cli-shadowed`. + +**S — `--format json`.** Declare the option in `doctor/index.ts` (copy the +description string from `deploy/index.ts`), thread it through `execute`, and +gate `renderReport` behind `isJsonFormat`. Document shape: + +```jsonc +{ + "ok": false, + "code": 1, + "summary": { "fail": 1, "warn": 2, "info": 3, "pass": 4 }, + "findings": [{ "code": "d1-placeholder-id", "level": "fail", "message": "…", "fix": "…" }], +} +``` + +`ok` is redundant with `code` on purpose — it is the field a shell-free consumer +reaches for first. + +**Done.** `ok` and `summary` were added to `DoctorResult` itself rather than to +a second JSON-only type — one shape means `printJson(result)` needs no mapping +layer, and `renderReport`'s summary line now reads `result.summary` instead of +re-filtering the findings. The format plumbing follows `verify` exactly: an +exported `runDoctorCommand({ cwd, format, logger })` validates the flag, picks +the logger via `loggerForFormat`, renders, then `printJson` in json mode; +`execute` is a three-line wrapper. Rendering happens in **both** formats — in +json mode it lands on stderr, which is what makes the phase-1 gate assertable. + +**S — code snapshot test.** A test that collects every code from the union type +(or a `DOCTOR_CODES` const the union derives from) and asserts it against a +committed sorted list. Adding a code is then a deliberate one-line fixture +update; removing or renaming one fails loudly. + +**Done, with one deviation.** The committed fixture _is the docs table_ — the +test parses the code column out of `packages/cli/docs/index.mdx` and asserts it +equals `DOCTOR_CODES`. A separate fixture file would have made three artefacts +to keep in step (const, fixture, docs table) where the plan explicitly wanted +two; asserting the docs directly collapses it to two and makes the docs the +thing that fails, which is the one that was going to rot. A second test asserts +`DOCTOR_CODES` is itself sorted and duplicate-free. Verified to bite: adding a +code locally without touching the table fails the suite. + +**S — `checkCliShadow`.** Compare the resolved `lunora` executable against the +project's `node_modules/.bin/lunora`, resolving symlinks on both sides +(`node:fs.realpathSync`) so a pnpm-linked bin does not read as a mismatch. Emit +`warn` + code `cli-shadowed` when they differ, with the fix naming the +project-local invocation (`pnpm exec lunora …`). Skip silently when the project +has no local install — that is a global-only project, not a defect. + +**Done — but the plan's comparison is wrong and was not used.** pnpm does not +symlink `node_modules/.bin/*`; it writes a **POSIX shell shim** (verified: +`file node_modules/.bin/vitest` → "POSIX shell script text executable"). So +`realpathSync("node_modules/.bin/lunora")` resolves to the shim script itself, +never to the `dist/bin.mjs` that `process.argv[1]` names — path equality would +have emitted `cli-shadowed` on **every pnpm project**, which is precisely the +false positive §8 warns about. The shipped check tests _containment_ instead: +realpath the project's installed CLI package dirs (`node_modules/@lunora/cli`, +`node_modules/lunorash`) and ask whether the running module lives inside one. +That holds for pnpm's symlinked package dirs, npm/yarn's hoisted ones, and a +launch through the bin shim alike. `RunDoctorOptions` gained an optional +`executablePath` (defaulting to `process.argv[1]`) — the same test seam `cwd` +already provides, since a test cannot relocate the running process. Three +fixtures: pnpm-symlinked layout reports clean, an outside binary warns exactly +once and never fails, no local install skips silently. + +**S — docs.** The CLI reference page for `doctor` gains the `--format json` +example and the code table. The table is generated from the same const the +snapshot test reads, or it is a third place to forget. + +**Done.** `packages/cli/docs/index.mdx` gained the CLI-shadow bullet, the +`--format json` invocation, a "Machine-readable output" section with the +document shape, and the 17-row code table the snapshot test asserts against. + +## 6. Platform parity + +Not applicable. This plan touches no `ctx.*` surface, no provider binding, and +no deploy/runtime capability — `lunora doctor` is a local, read-only CLI report, +and every check it performs is already target-agnostic or reads `wrangler.jsonc` +directly. + +## 7. Phasing & ordering + +| Phase | Work | Gate | +| ----- | -------------------------------- | ------------------------------------------------------------------------------------------------- | +| 0 | `Finding.code` + literal union | `pnpm --filter "@lunora/cli" run lint:types` green with the union in place (a missing code fails) | +| 1 | `--format json` + `ok`/`summary` | New test: `--format json` stdout parses as one document and stderr carries the human lines | +| 2 | Code snapshot fixture | Test fails when a code is added without updating the fixture (assert by adding one locally) | +| 3 | `checkCliShadow` | Test with a fixture cwd whose local bin realpath differs → exactly one `cli-shadowed` warn | +| 4 | Docs | `pnpm run lint:prettier` clean; the code table matches the fixture | + +**All five gates met.** `pnpm --filter "@lunora/cli" run test` → 87 files, 1181 +tests passed (24 in `doctor.test.ts`, up from 13). `lint:types` clean. Prettier +then ESLint clean on all four touched files. `pnpm run api:check` green with no +snapshot delta. Manual smoke on a scratch project confirms the two formats: +`--format json` emits one document on stdout with the human report on stderr, +plain `lunora doctor` is unchanged. + +## 8. Risks & STOP conditions + +- **STOP** if `Finding` turns out to be re-exported and consumed outside the CLI + (`handler.ts:464` exports the type) — a required `code` would then be a + breaking change for that consumer. Check `api-snapshots/cli.api.md` first; if + `Finding` is in the public surface, `pnpm run api:update` after a fresh build + is part of this plan, not an afterthought. +- **Risk:** `checkCliShadow` false-positives under pnpm's symlinked bins and + makes every run warn. Mitigate: compare `realpathSync` on both sides, and add + the pnpm-linked layout as an explicit test fixture that must report clean. +- **Risk:** the JSON document grows a field later and breaks a consumer. + Mitigate: additive-only changes; the snapshot fixture makes a removal visible. + +## 9. Open questions (answered during execution) + +1. **Does `Finding` appear in `api-snapshots/cli.api.md`? — No.** Neither + `Finding`, `DoctorResult`, nor `runDoctor` is in the CLI's public surface; + the package exports the binary plus `runCli`/`COMMANDS`, and the doctor + handler is reached only through the lazy command loader. `pnpm run api:check` + after a fresh `pnpm --filter "@lunora/cli" run build` reports "Public API + surface matches all 47 committed snapshots" with no snapshot edit. The §8 + STOP condition therefore does not apply, and the required `code` breaks no + external consumer. (The doctor's own contract is still guarded — by the + docs-table test, not by the api snapshot.) +2. **Should `pass` findings be in the JSON document? — Yes, include them.** + The document then describes everything that was checked rather than only what + went wrong, which is what lets a consumer distinguish "the export check passed" + from "the export check did not run" — a distinction `summary.pass` alone + cannot make, and one that matters because most checks skip silently when their + input is absent. Cost is a handful of extra objects. A test asserts + `summary.pass` equals the number of `pass` findings actually present, so the + two cannot disagree. +3. **Is `--fix` worth a follow-up plan? — Yes, but a small one, and only for + three codes.** `d1-placeholder-id` cannot be fixed offline (it needs + `wrangler d1 create`). `wrangler-missing` / `wrangler-shard-binding-missing` + are already `lunora init` / `lunora dev`'s job, and `dev-vars-missing-secret` + is already `lunora dev`'s. That leaves `declared-export-missing` (append one + `export * from …` line to the worker entry), `vector-metadata-index-required` + (shell out to the wrangler command the finding already prints verbatim), and + `cli-shadowed` (re-exec through the local install). Only the first is a file + write nothing else owns; the honest scope of a `--fix` plan is that one + check, which makes it hard to justify as its own flag rather than as a step + in the generators. Recommendation: skip the flag, and instead have + `declared-export-missing` name the exact line to paste (it already does). +4. **Should `lunora verify` embed the doctor findings? — No, keep them + separate.** They fail for different reasons and on different inputs: `verify` + is a build gate (codegen + `tsc`) whose failure means the code is wrong, while + `doctor` is a configuration gate whose failures are mostly `warn`/`info` and + frequently deliberate. Embedding would either promote doctor warnings into + verify's exit code (blocking CI on a mixed alpha channel) or bury them in a + result nobody reads. Both already emit the same `--format json` envelope, so + an agent wanting one answer runs two commands and merges two documents — a + cheaper coupling than a shared exit code. From 20e86a4888bce11147b3279b454b8162e4f989ed Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 02:27:44 +0200 Subject: [PATCH 2/8] feat(cli): report the deployed url from deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `lunora deploy --format json` returned a document that could not tell a caller where the thing it just deployed lives, and `--preview` told the operator to "see the preview URL in the wrangler output above" — a machine-readable command handing its answer back to a human to read with their eyes. The URL parser already existed; the gate that enabled it was off in exactly those two cases. - `DeployCommandResult.deployment` carries `{ deployedAt, dryRun, env?, preview, url?, workerName? }` on every run that reached wrangler. No version id: the pinned wrangler has no structured deploy output, and the id only appears in prose. - Capture wrangler's stdout on every publishing run — with `captureStdoutSilently` in json mode (replayed to stderr afterwards), so stdout stays exactly one JSON document. - Re-check the link on every real deploy instead of only the first: a changed URL used to leave a stale link that `run`/`logs`/`--migrate` silently targeted. An existing link is never rewritten — a mismatch warns and names the `lunora link --url` to run. `--temporary` writes no link (that account is gone in an hour). - New `--health-check`: after a live deploy, probe `/_lunora/health/ready` (falling back to the aggregate route), 5 attempts 2s apart, before any `--migrate`. A red probe exits non-zero and says the deploy succeeded and the probe did not. - Extract the probe to `util/health-probe.ts`; `verify --health-url` now shares it, unchanged in behaviour. BREAKING CHANGE: `autoLinkFromDeployOutput` takes the parsed `url` instead of raw wrangler `output`, and `HealthFetch` moved from `commands/verify/handler` to `util/health-probe`. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- api-snapshots/cli.api.md | 22 ++ .../cli/__tests__/commands/deploy.test.ts | 253 +++++++++++++++- packages/cli/__tests__/util/auto-link.test.ts | 74 ++++- .../cli/__tests__/util/health-probe.test.ts | 100 +++++++ packages/cli/docs/index.mdx | 75 ++++- packages/cli/src/commands/deploy/handler.ts | 278 +++++++++++++++--- packages/cli/src/commands/deploy/index.ts | 7 + packages/cli/src/commands/verify/handler.ts | 50 +--- packages/cli/src/index.ts | 2 +- packages/cli/src/util/auto-link.ts | 62 ++-- packages/cli/src/util/deploy-summary.ts | 13 +- packages/cli/src/util/health-probe.ts | 128 ++++++++ ...-deploy-result-identity-and-health-gate.md | 278 ++++++++++++++++++ 13 files changed, 1216 insertions(+), 126 deletions(-) create mode 100644 packages/cli/__tests__/util/health-probe.test.ts create mode 100644 packages/cli/src/util/health-probe.ts create mode 100644 plans/308-deploy-result-identity-and-health-gate.md diff --git a/api-snapshots/cli.api.md b/api-snapshots/cli.api.md index 7e15308b38..98459600fd 100644 --- a/api-snapshots/cli.api.md +++ b/api-snapshots/cli.api.md @@ -115,6 +115,9 @@ interface DeployCommandOptions { env?: string; fetchImpl?: FetchLike; format?: string; + healthCheck?: boolean; + healthFetch?: HealthFetch; + healthSleep?: (ms: number) => Promise; interactive?: boolean; logger: Logger; migrate?: boolean; @@ -140,8 +143,14 @@ interface DeployCommandOptions { ```ts interface DeployCommandResult { code: number; + deployment?: DeployedIdentity; descriptor: SpawnDescriptor | undefined; error?: string; + healthCheck?: { + error?: string; + ok: boolean; + url: string; + }; mintedSecretsFile?: string; schemaDrift?: { blocked: boolean; @@ -154,6 +163,19 @@ interface DeployCommandResult { } ``` +### `DeployedIdentity` (interface) + +```ts +interface DeployedIdentity { + deployedAt: string; + dryRun: boolean; + env?: string; + preview: boolean; + url?: string; + workerName?: string; +} +``` + ### `DevCommandOptions` (interface) ```ts diff --git a/packages/cli/__tests__/commands/deploy.test.ts b/packages/cli/__tests__/commands/deploy.test.ts index 5ffd2bce60..9b1558ee8d 100644 --- a/packages/cli/__tests__/commands/deploy.test.ts +++ b/packages/cli/__tests__/commands/deploy.test.ts @@ -1,4 +1,4 @@ -import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -10,7 +10,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { runDeployCommand } from "../../src/commands/deploy/handler"; import type { FetchLike } from "../../src/commands/run/handler"; +import type { HealthFetch } from "../../src/util/health-probe"; import type { Logger } from "../../src/util/logger"; +import type { RecordedSpawn, Spawner } from "../../src/util/spawn"; import { createRecordingSpawner } from "../../src/util/spawn"; // A pass-through wrapper around the real `runCodegen` — every existing test in @@ -61,6 +63,33 @@ const captureStdout = async (body: () => Promise): Promise => { return captured; }; +/** What a real `wrangler deploy` prints once the Worker is live. */ +const WRANGLER_DEPLOY_OUTPUT = `Total Upload: 12.34 KiB / gzip: 4.56 KiB +Uploaded lunora-app (2.21 sec) +Deployed lunora-app triggers (0.85 sec) + https://lunora-app.acme.workers.dev +Current Version ID: 1f2e3d4c-5b6a-7089-9a0b-1c2d3e4f5a6b +`; + +/** + * A recording spawner that behaves like wrangler: when the descriptor asked for + * stdout (either capture mode), it resolves with real-looking deploy output so + * the URL parser has something to read. + */ +const deployingSpawner = (stdout: string = WRANGLER_DEPLOY_OUTPUT, exitCode = 0): { calls: RecordedSpawn[]; spawner: Spawner } => { + const calls: RecordedSpawn[] = []; + + const spawner: Spawner = (descriptor) => { + calls.push({ descriptor }); + + const captured = descriptor.captureStdout === true || descriptor.captureStdoutSilently === true; + + return Promise.resolve({ code: exitCode, stdout: captured ? stdout : undefined }); + }; + + return { calls, spawner }; +}; + const here = dirname(fileURLToPath(import.meta.url)); const fixtureRoot = join(here, "..", "..", "..", "codegen", "__tests__", "fixtures", "simple"); @@ -893,29 +922,36 @@ export const backfillNames = defineMigration({ expect(parsed.descriptor?.args).toContain("deploy"); }); - it("routes the spawned wrangler's stdout to stderr so it can't corrupt the JSON document", async () => { - // Regression: `wrangler deploy` inherits stdio; without redirection - // its progress + deployed-URL output interleaves with the JSON on - // stdout and breaks `lunora deploy --format json | jq`. - expect.assertions(2); + it("captures the spawned wrangler's stdout SILENTLY so it can't corrupt the JSON document", async () => { + // Regression: `wrangler deploy`'s progress + deployed-URL output + // must never interleave with the JSON on stdout, or + // `lunora deploy --format json | jq` breaks. json mode therefore + // captures without teeing (`captureStdoutSilently`); the plain + // `captureStdout` used in pretty mode WOULD tee, and corrupt it. + expect.assertions(5); writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); - const { calls, spawner } = createRecordingSpawner(); + const { calls, spawner } = deployingSpawner(); const { logger } = silentLogger(); - await captureStdout(async () => { + const stdout = await captureStdout(async () => { await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, format: "json", logger, spawner }); }); - expect(calls[0]?.descriptor.stdoutToStderr).toBe(true); + expect(calls[0]?.descriptor.captureStdoutSilently).toBe(true); + expect(calls[0]?.descriptor.captureStdout).toBe(false); - // Pretty mode keeps wrangler's output inherited on stdout. - const pretty = createRecordingSpawner(); + // Exactly one JSON document, with no wrangler text mixed in. + expect(stdout).not.toContain("Total Upload"); + expect(JSON.parse(stdout)).toHaveProperty("deployment"); + + // Pretty mode tees, so the user still watches live progress. + const pretty = deployingSpawner(); await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner: pretty.spawner }); - expect(pretty.calls[0]?.descriptor.stdoutToStderr).toBe(false); + expect(pretty.calls[0]?.descriptor.captureStdout).toBe(true); }); it("routes a postcodegen script's stdout to stderr in json mode", async () => { @@ -971,6 +1007,69 @@ export const backfillNames = defineMigration({ expect(parsed.error).toBeDefined(); }); + it("reports the deployed URL in the document, so an automation never has to read it with its eyes", async () => { + expect.assertions(5); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, env: undefined, secretLister: noRemoteSecrets, format: "json", logger, spawner }); + }); + + const parsed = JSON.parse(stdout) as { + deployment?: { deployedAt: string; dryRun: boolean; preview: boolean; url?: string; workerName?: string }; + }; + + expect(parsed.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + expect(parsed.deployment?.workerName).toBe("lunora-app"); + expect(parsed.deployment?.dryRun).toBe(false); + expect(parsed.deployment?.preview).toBe(false); + expect(Number.isNaN(Date.parse(parsed.deployment?.deployedAt ?? ""))).toBe(false); + }); + + it("--preview --format json reports the preview URL", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner("Worker Version ID: abc\n https://preview-abc-lunora-app.acme.workers.dev\n"); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, format: "json", logger, preview: true, spawner }); + }); + + const parsed = JSON.parse(stdout) as { deployment?: { preview: boolean; url?: string } }; + + expect(parsed.deployment?.url).toBe("https://preview-abc-lunora-app.acme.workers.dev"); + expect(parsed.deployment?.preview).toBe(true); + // A preview never becomes the checkout's recorded target. + expect(existsSync(join(workdir, ".lunora", "project.json"))).toBe(false); + }); + + it("--dry-run reports the discriminator and no URL — nothing was published", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { calls, spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const stdout = await captureStdout(async () => { + await runDeployCommand({ cwd: workdir, dryRun: true, secretLister: noRemoteSecrets, format: "json", logger, spawner }); + }); + + const parsed = JSON.parse(stdout) as { deployment?: { dryRun: boolean; url?: string } }; + + expect(parsed.deployment?.dryRun).toBe(true); + expect(parsed.deployment?.url).toBeUndefined(); + // Nothing to read → wrangler's stdout is not captured at all. + expect(calls[0]?.descriptor.captureStdoutSilently).toBe(false); + }); + it("rejects an unknown --format the same way logs does", async () => { expect.assertions(5); @@ -992,6 +1091,136 @@ export const backfillNames = defineMigration({ }); }); + describe("link capture", () => { + it("records the deployed URL after a real deploy", async () => { + expect.assertions(2); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const result = await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner }); + const written = JSON.parse(readFileSync(join(workdir, ".lunora", "project.json"), "utf8")) as { workerUrl?: string }; + + expect(result.code).toBe(0); + expect(written.workerUrl).toBe("https://lunora-app.acme.workers.dev"); + }); + + it("--temporary reports the URL but never records it as the checkout's target", async () => { + expect.assertions(2); + + // The account is deleted in ~60 minutes; a link pointing at it + // would silently misroute `run` / `logs` / `--migrate` afterwards. + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + + const result = await runDeployCommand({ cwd: workdir, secretLister: noRemoteSecrets, logger, spawner, temporary: true }); + + expect(result.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + expect(existsSync(join(workdir, ".lunora", "project.json"))).toBe(false); + }); + }); + + describe("--health-check", () => { + it("passes when the new version answers", async () => { + expect.assertions(4); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ cwd: workdir, healthCheck: true, healthFetch, secretLister: noRemoteSecrets, logger, spawner }); + + expect(result.code).toBe(0); + expect(result.healthCheck?.ok).toBe(true); + // Probed at the URL THIS run published to, readiness gate first. + expect(healthFetch).toHaveBeenCalledWith("https://lunora-app.acme.workers.dev/_lunora/health/ready"); + expect(result.healthCheck?.error).toBeUndefined(); + }); + + it("fails the command when the probe never goes green, and says the deploy itself succeeded", async () => { + expect.assertions(4); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { errors, logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await runDeployCommand({ + cwd: workdir, + healthCheck: true, + healthFetch, + healthSleep: async () => {}, + secretLister: noRemoteSecrets, + logger, + spawner, + }); + + expect(result.code).toBe(1); + expect(result.healthCheck?.error).toContain("returned HTTP 503"); + // The deploy succeeded and the probe did not — different facts. + expect(errors.join("\n")).toContain("the deploy succeeded, but the new version did not answer"); + // The identity is still reported: the version IS out there. + expect(result.deployment?.url).toBe("https://lunora-app.acme.workers.dev"); + }); + + it("is skipped (with a warning) on a dry run, which publishes nothing to probe", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + const { spawner } = deployingSpawner(); + const { logger, warns } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ + cwd: workdir, + dryRun: true, + healthCheck: true, + healthFetch, + secretLister: noRemoteSecrets, + logger, + spawner, + }); + + expect(result.code).toBe(0); + expect(healthFetch).not.toHaveBeenCalled(); + expect(warns.join("\n")).toContain("--health-check skipped"); + }); + + it("refuses rather than guessing an origin when no URL can be resolved", async () => { + expect.assertions(3); + + writeFileSync(join(workdir, "wrangler.jsonc"), VALID_WRANGLER, "utf8"); + + // wrangler printed no URL (custom-route-only worker) and the + // checkout has no link → nothing safe to probe. + const { spawner } = deployingSpawner("Total Upload: 1 KiB\nDeployed lunora-app triggers\n"); + const { errors, logger } = silentLogger(); + const healthFetch = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await runDeployCommand({ cwd: workdir, healthCheck: true, healthFetch, secretLister: noRemoteSecrets, logger, spawner }); + + expect(result.code).toBe(1); + expect(healthFetch).not.toHaveBeenCalled(); + expect(errors.join("\n")).toContain("no URL to probe could be resolved"); + }); + }); + describe("missing-secret gate", () => { it("mints a missing secret, records it in .dev.vars, and never logs the value", async () => { expect.assertions(6); diff --git a/packages/cli/__tests__/util/auto-link.test.ts b/packages/cli/__tests__/util/auto-link.test.ts index 676c0f9217..7a148f4034 100644 --- a/packages/cli/__tests__/util/auto-link.test.ts +++ b/packages/cli/__tests__/util/auto-link.test.ts @@ -8,8 +8,15 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { autoLinkFromDeployOutput, parseDeployedUrl } from "../../src/util/auto-link"; import type { Logger } from "../../src/util/logger"; -const silentLogger = (): Logger => { - return { error: () => {}, info: () => {}, success: () => {}, warn: () => {} }; +const recordingLogger = (): { logger: Logger; successes: string[]; warns: string[] } => { + const successes: string[] = []; + const warns: string[] = []; + + return { + logger: { error: () => {}, info: () => {}, success: (message) => successes.push(message), warn: (message) => warns.push(message) }, + successes, + warns, + }; }; describe("parseDeployedUrl", () => { @@ -32,6 +39,12 @@ describe("parseDeployedUrl", () => { expect(parseDeployedUrl("Total Upload: 1 KiB\nNo URL here")).toBeUndefined(); }); + + it("returns undefined when stdout was not captured at all", () => { + expect.assertions(1); + + expect(parseDeployedUrl(undefined)).toBeUndefined(); + }); }); describe("autoLinkFromDeployOutput", () => { @@ -46,15 +59,15 @@ describe("autoLinkFromDeployOutput", () => { rmSync(workdir, { force: true, recursive: true }); }); - it("writes the link from captured deploy output", () => { + it("writes the link when the checkout has none", () => { expect.assertions(2); autoLinkFromDeployOutput({ cwd: workdir, env: "production", - logger: silentLogger(), + logger: recordingLogger().logger, now: () => "2026-01-01T00:00:00.000Z", - output: " https://my-worker.acme.workers.dev\n", + url: "https://my-worker.acme.workers.dev", }); const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); @@ -63,23 +76,64 @@ describe("autoLinkFromDeployOutput", () => { expect(written.workerName).toBe("my-worker"); }); - it("does nothing when output was not captured", () => { + it("does nothing when the deploy output carried no URL", () => { expect.assertions(1); - autoLinkFromDeployOutput({ cwd: workdir, logger: silentLogger(), output: undefined }); + autoLinkFromDeployOutput({ cwd: workdir, logger: recordingLogger().logger, url: undefined }); expect(existsSync(join(workdir, LINKED_PROJECT_FILE))).toBe(false); }); - it("never overwrites an existing link", () => { - expect.assertions(1); + it("is a silent no-op when the recorded link already matches", () => { + expect.assertions(3); + + writeLinkedProject(workdir, { env: "production", linkedAt: "2020-01-01T00:00:00.000Z", workerUrl: "https://auto.acme.workers.dev" }); + + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, env: "production", logger: recorded.logger, url: "https://auto.acme.workers.dev" }); + + const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); + + // Untouched — same stamp, no re-write. + expect(written.linkedAt).toBe("2020-01-01T00:00:00.000Z"); + expect(recorded.warns).toEqual([]); + expect(recorded.successes).toEqual([]); + }); + it("warns and keeps the recorded value when the deployed URL differs", () => { + expect.assertions(4); + + // The stale-link failure this exists to surface: `run` / `logs` / + // `--migrate` would otherwise keep targeting a URL this deploy no longer + // publishes to — but rewriting an explicit `lunora link` is equally wrong. writeLinkedProject(workdir, { workerUrl: "https://manual.workers.dev" }); - autoLinkFromDeployOutput({ cwd: workdir, logger: silentLogger(), output: "https://auto.acme.workers.dev" }); + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, logger: recorded.logger, url: "https://auto.acme.workers.dev" }); const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); expect(written.workerUrl).toBe("https://manual.workers.dev"); + expect(recorded.warns).toHaveLength(1); + // Names both URLs and the one command that resolves the disagreement. + expect(recorded.warns[0]).toContain("https://manual.workers.dev"); + expect(recorded.warns[0]).toContain("lunora link --url https://auto.acme.workers.dev"); + }); + + it("treats a link recorded for another --env as a mismatch, not a target to clobber", () => { + expect.assertions(2); + + writeLinkedProject(workdir, { env: "production", workerUrl: "https://prod.acme.workers.dev" }); + + const recorded = recordingLogger(); + + autoLinkFromDeployOutput({ cwd: workdir, env: "staging", logger: recorded.logger, url: "https://staging.acme.workers.dev" }); + + const written = JSON.parse(readFileSync(join(workdir, LINKED_PROJECT_FILE), "utf8")); + + expect(written.workerUrl).toBe("https://prod.acme.workers.dev"); + expect(recorded.warns).toHaveLength(1); }); }); diff --git a/packages/cli/__tests__/util/health-probe.test.ts b/packages/cli/__tests__/util/health-probe.test.ts new file mode 100644 index 0000000000..52c337ebeb --- /dev/null +++ b/packages/cli/__tests__/util/health-probe.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { HealthFetch } from "../../src/util/health-probe"; +import { HEALTH_PATH, HEALTH_READY_PATH, joinHealthUrl, probeHealth } from "../../src/util/health-probe"; + +/** No real waiting between retries — the delay is injected in every test here. */ +const noSleep = async (): Promise => {}; + +describe("joinHealthUrl", () => { + it("does not double the slash on a base URL that ends in one", () => { + expect.assertions(2); + + expect(joinHealthUrl("https://app.workers.dev/")).toBe("https://app.workers.dev/_lunora/health"); + expect(joinHealthUrl("https://app.workers.dev", HEALTH_READY_PATH)).toBe("https://app.workers.dev/_lunora/health/ready"); + }); +}); + +describe("probeHealth", () => { + it("is green on a 2xx and asks only once by default", async () => { + expect.assertions(3); + + const fetchImpl = vi.fn(async () => { + return { ok: true, status: 200 }; + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl }); + + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://app.workers.dev/_lunora/health"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("retries up to the attempt budget and passes once the deploy propagates", async () => { + expect.assertions(2); + + // The reason the budget exists: a version that isn't serving yet answers + // 503 on the first probe and 200 moments later. + const responses = [ + { ok: false, status: 503 }, + { ok: false, status: 503 }, + { ok: true, status: 200 }, + ]; + const fetchImpl = vi.fn(async () => responses.shift() ?? { ok: true, status: 200 }); + + const result = await probeHealth({ attempts: 5, baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toBeUndefined(); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("reports the last failure after exhausting the budget", async () => { + expect.assertions(2); + + const fetchImpl = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await probeHealth({ attempts: 3, baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toContain("returned HTTP 503"); + expect(fetchImpl).toHaveBeenCalledTimes(3); + }); + + it("falls back to the next path when the first 404s (an older deployment has no readiness gate)", async () => { + expect.assertions(3); + + const fetchImpl = vi.fn(async (url: string) => (url.endsWith("/ready") ? { ok: false, status: 404 } : { ok: true, status: 200 })); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, paths: [HEALTH_READY_PATH, HEALTH_PATH] }); + + expect(result.error).toBeUndefined(); + expect(result.url).toBe("https://app.workers.dev/_lunora/health"); + expect(fetchImpl).toHaveBeenCalledTimes(2); + }); + + it("does not fall through on a non-404 — a 503 on the readiness gate is the answer", async () => { + expect.assertions(2); + + const fetchImpl = vi.fn(async () => { + return { ok: false, status: 503 }; + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, paths: [HEALTH_READY_PATH, HEALTH_PATH] }); + + expect(result.error).toContain("/_lunora/health/ready returned HTTP 503"); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it("reports a transport failure as red without throwing", async () => { + expect.assertions(1); + + const fetchImpl = vi.fn(async () => { + throw new Error("getaddrinfo ENOTFOUND"); + }); + + const result = await probeHealth({ baseUrl: "https://app.workers.dev", fetchImpl, sleep: noSleep }); + + expect(result.error).toContain("could not reach https://app.workers.dev/_lunora/health (getaddrinfo ENOTFOUND)"); + }); +}); diff --git a/packages/cli/docs/index.mdx b/packages/cli/docs/index.mdx index 7e14ebba00..2f020d0dbc 100644 --- a/packages/cli/docs/index.mdx +++ b/packages/cli/docs/index.mdx @@ -48,6 +48,7 @@ lunora deploy [--env ] # codegen, validate wrangler, then wrang [--migrate] [--prebuilt] # --preview uploads a version (no live traffic) [--preview] [--dry-run] # --dry-run validates + bundles, never publishes [--temporary] # --temporary deploys with no account (~60min, then claim) + [--health-check] # --health-check probes /_lunora/health/ready after deploying lunora link --url [--env ] # link this checkout to its deployed worker lunora deployments # history + traffic control [--env ] [--yes] @@ -235,8 +236,9 @@ immediately after a successful deploy. lunora deploy lunora deploy --env staging lunora deploy --migrate --migrate-token $LUNORA_ADMIN_TOKEN -lunora deploy --temporary # no Cloudflare account needed -lunora deploy --dry-run # run every pre-deploy gate, publish nothing +lunora deploy --temporary # no Cloudflare account needed +lunora deploy --dry-run # run every pre-deploy gate, publish nothing +lunora deploy --health-check # …then prove the new version answers ``` You don't need a Cloudflare account to try a deploy. `--temporary` ships to a @@ -248,13 +250,72 @@ authenticated, so drop `--temporary` once you've signed in.) `--dry-run` runs the full pre-deploy pipeline — codegen, the schema-drift gate, `wrangler.jsonc` validation, and the wrangler bundle — without publishing. -A successful first deploy auto-writes `.lunora/project.json` (see `lunora link`) -from the deployed URL, so follow-up commands don't need `--url`. +A successful deploy auto-writes `.lunora/project.json` (see `lunora link`) from +the deployed URL, so follow-up commands don't need `--url`. Every real deploy +re-checks it: if the URL you just published to disagrees with the recorded one +(a custom domain added, the worker renamed), the deploy **warns and keeps the +recorded value** rather than rewriting an explicit `lunora link` — run +`lunora link --url ` to accept the change. `--temporary` never writes a +link, since that account is gone in an hour. `--preview` uploads a new Worker **version** (`wrangler versions upload`) and -prints a preview URL instead of going live — production traffic is untouched, and -the post-deploy steps (migrations, baseline re-bless, auto-link) are skipped. The -`--ci` pipelines use it to deploy a preview on every pull / merge request. +reports its preview URL instead of going live — production traffic is untouched, +and the post-deploy steps (migrations, baseline re-bless, link write) are +skipped. The `--ci` pipelines use it to deploy a preview on every pull / merge +request. + +#### `--health-check` + +After a live deploy, probe the new version's health route +(`/_lunora/health/ready`, falling back to `/_lunora/health` on deployments +without a readiness gate) and fail the command when it never answers. Five +attempts, two seconds apart — a fresh version takes a moment to propagate, and a +fixed ceiling is what a CI timeout can be set against. + +It is opt-in on purpose: a worker whose health route is admin-gated or +unreachable from the runner must still be deployable, and a default-on network +step would turn a good deploy into a red build for an unrelated reason. + +A red probe exits non-zero and says which half failed — the deploy succeeded, +the probe did not. The probe runs **before** `--migrate`, so a worker that can't +serve is never migrated. + +#### Machine-readable output + +`--format json` writes exactly one JSON document to stdout (every human line, +including wrangler's own output, goes to stderr). It carries a `deployment` +object describing what this run put where: + +```jsonc +{ + "code": 0, + "deployment": { + "deployedAt": "2026-08-08T09:12:33.417Z", + "dryRun": false, + "env": "production", + "preview": false, + "url": "https://my-app.acme.workers.dev", + "workerName": "my-app", + }, + "healthCheck": { "ok": true, "url": "https://my-app.acme.workers.dev/_lunora/health/ready" }, + // …validation, schemaDrift, mintedSecretsFile +} +``` + +`dryRun` and `preview` are always present, so a consumer can tell "nothing went +live" from "went live" without inferring it from a missing `url`. There is no +version id: the pinned wrangler has no structured deploy output, and +`lunora deployments list` is the supported way to read one. + +A release pipeline reads the URL out of the same document it already checks the +exit code of: + +```bash +result=$(lunora deploy --env production --format json --health-check) +url=$(echo "$result" | jq -r '.deployment.url') + +curl -fsS "$url/api/smoke" +``` ### `lunora build` diff --git a/packages/cli/src/commands/deploy/handler.ts b/packages/cli/src/commands/deploy/handler.ts index 45853eea67..066e38357f 100644 --- a/packages/cli/src/commands/deploy/handler.ts +++ b/packages/cli/src/commands/deploy/handler.ts @@ -12,7 +12,6 @@ import { isMintableSecretKey, packageNamesFromBindings, parseDevVariableEntries, - readLinkedProject, requiredSecrets, resolveDeployDriver, upsertDevVariableLine, @@ -32,7 +31,7 @@ import { Project } from "ts-morph"; import { evaluateAdvisoryGate, resolveStrictAdvisories } from "../../util/advisory-gate"; import type { ApiSpec } from "../../util/api-spec"; import { parseApiSpec } from "../../util/api-spec"; -import { autoLinkFromDeployOutput } from "../../util/auto-link"; +import { autoLinkFromDeployOutput, parseDeployedUrl } from "../../util/auto-link"; import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import { renderDeploySummary } from "../../util/deploy-summary"; @@ -40,6 +39,8 @@ import { resolveTargetOrError } from "../../util/deploy-target"; import { detectPackageManager, execArgsFor } from "../../util/detect-package-manager"; import type { DockerProbe } from "../../util/docker"; import { isDockerAvailable } from "../../util/docker"; +import type { HealthFetch } from "../../util/health-probe"; +import { HEALTH_PATH, HEALTH_READY_PATH, probeHealth } from "../../util/health-probe"; import type { Logger } from "../../util/logger"; import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import reportPlatformDiagnostics from "../../util/platform-diagnostics"; @@ -52,6 +53,7 @@ import { defaultSpawner } from "../../util/spawn"; import { createTuiConfirm } from "../../util/tui-prompts"; import type { VectorMetadataIndex } from "../../util/vectorize-metadata"; import { ensureVectorMetadataIndexes, metadataTypeFor } from "../../util/vectorize-metadata"; +import readWranglerName from "../../util/wrangler-name"; import type { ListRemoteSecretsInputs, ListRemoteSecretsResult } from "../../util/wrangler-secrets"; import { listRemoteSecrets } from "../../util/wrangler-secrets"; import { validateWrangler } from "../../util/wrangler-validator"; @@ -83,6 +85,21 @@ interface DeployCommandOptions { fetchImpl?: FetchLike; /** Output format: `pretty` (default) or `json`. */ format?: string; + + /** + * After a successful live deploy, probe the new version's health route + * (`/_lunora/health/ready`, falling back to `/_lunora/health`) and fail the + * command when it never answers. Opt-in, not default-on: a worker whose + * health route is admin-gated or unreachable from CI must still be + * deployable, and a default network step would turn a successful deploy + * into a red build for an unrelated reason. + */ + healthCheck?: boolean; + + /** Injectable fetch for `--health-check`; defaults to the global `fetch`. */ + healthFetch?: HealthFetch; + /** Injectable inter-attempt delay for `--health-check`; injected in tests to skip the real wait. */ + healthSleep?: (ms: number) => Promise; /** Set to `false` to disable interactive spinners (test injection). */ interactive?: boolean; logger: Logger; @@ -176,12 +193,50 @@ interface DeployCommandOptions { updateSchemaBaseline?: boolean; } +/** + * What this run put where — the identity of the thing that was just deployed. + * + * Present on every run that reached (and completed) the wrangler invocation, + * including `--dry-run` and `--preview`, so a consumer can tell "nothing went + * live" from "went live" without inferring it from a missing `url`. A dry run + * publishes nothing and therefore never carries a `url`. + * + * No `versionId`: the pinned wrangler (4.114.0) has no structured deploy output + * and no flag that returns the version id — it only prints it in prose, and + * scraping a second value out of prose is exactly what this shouldn't do. The + * id is available from `lunora deployments list` after the fact. + */ +interface DeployedIdentity { + /** ISO-8601 stamp taken when the wrangler invocation returned. */ + deployedAt: string; + /** True when `--dry-run` validated + bundled without publishing. */ + dryRun: boolean; + /** The Cloudflare environment this run targeted, when `--env` named one. */ + env?: string; + /** True when `--preview` uploaded a version instead of shifting live traffic. */ + preview: boolean; + /** The URL wrangler reported publishing to; absent on a dry run, or when the output carried no URL. */ + url?: string; + /** The Worker name from the project's wrangler config. */ + workerName?: string; +} + interface DeployCommandResult { code: number; + /** What was deployed and where — set once the wrangler invocation completed. */ + deployment?: DeployedIdentity; descriptor: SpawnDescriptor | undefined; /** Set when the run aborted before reaching the wrangler invocation. */ error?: string; + /** + * The `--health-check` probe's verdict, when the flag was set and the probe + * ran. A red probe fails the command (`code` is non-zero) — but the deploy + * itself still succeeded, which is why the reason is reported separately + * from `error`. + */ + healthCheck?: { error?: string; ok: boolean; url: string }; + /** * The `.dev.vars`-shaped filename (never a full path, never a value) a * secret minted during this run was recorded into, when the missing- @@ -840,14 +895,18 @@ const validateMigrateDeployPreflight = (options: DeployCommandOptions): string | return undefined; } - // `wrangler deploy`'s published URL is never captured here, so without an - // explicit `--migrate-url` the downstream migration would default to + // The deployed URL is only known AFTER wrangler runs, and this gate runs + // before it — so there is nothing to default to here, and without an + // explicit `--migrate-url` the downstream migration would fall back to // `http://localhost:8787` (the dev worker) and apply against LOCAL state — // and ship the production admin bearer to whatever listens on that port. // Refuse before deploying rather than silently targeting localhost later. + // (A linked checkout satisfies this without the flag: the caller resolves + // `migrateUrl` through `resolveWorkerUrl`, which reads the link this + // deploy's predecessor recorded.) if (options.migrateUrl === undefined) { const message = - "--migrate requires --migrate-url — the deploy target URL is not captured automatically, refusing to default to localhost"; + "--migrate requires --migrate-url — the deploy target URL is only known after wrangler runs, and this gate runs before it; refusing to default to localhost"; options.logger.error(message); @@ -1065,6 +1124,62 @@ const provisionVectorMetadataIndexes = async (options: DeployCommandOptions, cwd } }; +/** Attempt budget + spacing for `--health-check`: a fresh version takes seconds to propagate, and a predictable ceiling is what a CI timeout is set against. */ +const HEALTH_CHECK_ATTEMPTS = 5; +const HEALTH_CHECK_DELAY_MS = 2000; + +/** + * The opt-in `--health-check` step: prove the version just deployed actually + * answers. Probes the readiness gate first and falls back to the aggregate route + * (older deployments have no `/ready`), retrying on a bounded budget because a + * single immediate probe of a still-propagating deploy is a coin flip. + * + * Returns `undefined` when the flag wasn't set. The URL comes from the deploy + * that just ran, falling back to the recorded link for THIS environment; with + * neither, the step refuses rather than guessing an origin. + */ +const runHealthCheckStep = async (options: DeployCommandOptions, cwd: string, deployedUrl: string | undefined): Promise => { + if (options.healthCheck !== true) { + return undefined; + } + + const baseUrl = deployedUrl ?? resolveWorkerUrl({ cwd, env: options.env }); + + if (baseUrl === undefined) { + const message = + "--health-check: the deploy succeeded, but no URL to probe could be resolved — wrangler's output carried none and this checkout has no link for this environment. Run `lunora link --url ` and re-deploy, or drop --health-check."; + + options.logger.error(message); + + return { error: message, ok: false, url: "" }; + } + + const probe = await probeHealth({ + attempts: HEALTH_CHECK_ATTEMPTS, + baseUrl, + delayMs: HEALTH_CHECK_DELAY_MS, + fetchImpl: options.healthFetch, + // The readiness gate answers "can this version serve"; the aggregate is + // the one that exists on older deployments. + paths: [HEALTH_READY_PATH, HEALTH_PATH], + sleep: options.healthSleep, + }); + + if (probe.error === undefined) { + options.logger.success(`health check ok (${probe.url})`); + + return { ok: true, url: probe.url }; + } + + // The deploy SUCCEEDED and the probe did not — different facts, and the + // message has to say which one failed or it reads as a broken deploy. + options.logger.error( + `--health-check: the deploy succeeded, but the new version did not answer after ${String(HEALTH_CHECK_ATTEMPTS)} attempt(s) — ${probe.error}`, + ); + + return { error: probe.error, ok: false, url: probe.url }; +}; + /** * After a successful `wrangler deploy`, run any requested data migrations and — * only when the whole operation succeeded — advance the committed schema @@ -1212,6 +1327,111 @@ const reportWranglerProblems = (validation: { problems: ReadonlyArray }, return true; }; +/** + * Assemble the wrangler {@link SpawnDescriptor}, including how its stdout is + * handled — the one decision that has to be right for `--format json` to stay + * pipeable: + * + * Pretty + publishing uses `captureStdout` (buffered AND teed, so the URL can be + * read while the user still watches live progress). Json + publishing uses + * `captureStdoutSilently` (buffered, never teed — the caller replays it to + * stderr), because `captureStdout` there would interleave with the single JSON + * document on stdout and corrupt it. A dry run has nothing to read, so its + * stdout is left alone (mapped to stderr in json mode). + */ +const buildDeploySpawn = (cwd: string, options: DeployCommandOptions, target: string): SpawnDescriptor => { + const jsonFormat = isJsonFormat(options.format); + // Read the deployed URL off wrangler's stdout on EVERY publishing run — a + // preview and a `--format json` deploy need to report where the thing went + // just as much as a first pretty deploy does, and a re-deploy is how a + // CHANGED url gets noticed. + const publishes = options.dryRun !== true; + + const deployCommand = buildDeployCommand(cwd, options, target); + const exec = execArgsFor(detectPackageManager(cwd), deployCommand.tool, deployCommand.args); + + return { + args: exec.args, + captureStdout: publishes && !jsonFormat, + captureStdoutSilently: publishes && jsonFormat, + command: exec.command, + cwd, + stdoutToStderr: jsonFormat && !publishes, + }; +}; + +interface CompleteDeployInputs { + cwd: string; + descriptor: SpawnDescriptor; + mintedSecretsFile: string | undefined; + options: DeployCommandOptions; + reblessSchemaBaseline: (() => void) | undefined; + /** Wrangler's captured stdout, or `undefined` when this run didn't capture it. */ + stdout: string | undefined; + validation: DeployCommandResult["validation"]; +} + +/** + * Everything that happens once `wrangler` has exited 0: name what was deployed, + * record the link, prove the new version answers, then finalize (migrations + + * baseline re-bless). Extracted from {@link executeDeploy} to keep both + * functions' cognitive complexity within the 15-node budget. + */ +const completeDeploy = async ({ + cwd, + descriptor, + mintedSecretsFile, + options, + reblessSchemaBaseline, + stdout, + validation, +}: CompleteDeployInputs): Promise => { + // A dry run publishes nothing, so it reports no URL — the discriminators say + // so explicitly rather than leaving a consumer to infer it from the absence. + const deployment: DeployedIdentity = { + deployedAt: new Date().toISOString(), + dryRun: options.dryRun === true, + env: options.env, + preview: options.preview === true, + url: options.dryRun === true ? undefined : parseDeployedUrl(stdout), + workerName: readWranglerName(cwd), + }; + + // A dry run published nothing, and a preview uploaded a Version without + // going live — either way, skip the post-deploy finalize (migrations / + // baseline re-bless), the link write, and the health probe, which only apply + // to a live deploy. The URL is still reported. + if (options.dryRun === true || options.preview === true) { + if (options.healthCheck === true) { + options.logger.warn( + `--health-check skipped: ${options.dryRun === true ? "a dry run publishes nothing" : "a preview version serves no live traffic"}`, + ); + } + + return { code: 0, deployment, descriptor, mintedSecretsFile, validation }; + } + + // Zero-effort linking: record the deployed URL, warn instead of clobbering + // when an existing link disagrees. Skipped for `--temporary`: that account + // is deleted in ~60 minutes, so its URL must never become the checkout's + // recorded target. + if (options.temporary !== true) { + autoLinkFromDeployOutput({ cwd, env: options.env, logger: options.logger, url: deployment.url }); + } + + // Prove the new version answers BEFORE running migrations against it — a + // worker that can't serve is not one to migrate. + const healthCheck = await runHealthCheckStep(options, cwd, deployment.url); + + if (healthCheck?.error !== undefined) { + return { code: 1, deployment, descriptor, healthCheck, mintedSecretsFile, validation }; + } + + const finalized = await finalizeSuccessfulDeploy(options, cwd, descriptor, validation, reblessSchemaBaseline, mintedSecretsFile); + + return { ...finalized, deployment, healthCheck }; +}; + const executeDeploy = async (options: DeployCommandOptions): Promise => { const cwd = options.cwd ?? process.cwd(); const interactive = isInteractive(options); @@ -1334,45 +1554,25 @@ const executeDeploy = async (options: DeployCommandOptions): Promise = defineHandler(asyn dryRun: options.dryRun === true, env: options.env, format: options.format, + healthCheck: options.healthCheck === true, logger, migrate: options.migrate === true, migrateToken: options.migrateToken, @@ -1449,5 +1655,5 @@ const execute: CommandHandler = defineHandler(asyn }); export { execute }; -export type { DeployCommandOptions, DeployCommandResult }; +export type { DeployCommandOptions, DeployCommandResult, DeployedIdentity }; export { runDeployCommand }; diff --git a/packages/cli/src/commands/deploy/index.ts b/packages/cli/src/commands/deploy/index.ts index 036e21388e..a889ff0733 100644 --- a/packages/cli/src/commands/deploy/index.ts +++ b/packages/cli/src/commands/deploy/index.ts @@ -23,6 +23,12 @@ const deployCommand: Command = { { description: "Validate, bundle, and run pre-deploy gates without publishing (wrangler deploy --dry-run)", name: "dry-run", type: Boolean }, { description: "Cloudflare environment name", name: "env", type: String }, { description: "Output format: pretty (default) or json", name: "format", type: String }, + { + description: + "After the deploy, probe the new version's health route (/_lunora/health/ready, falling back to /_lunora/health) and fail if it never answers", + name: "health-check", + type: Boolean, + }, { description: "After a successful deploy, run pending data migrations against the live worker", name: "migrate", type: Boolean }, { description: @@ -85,6 +91,7 @@ export type DeployOptions = CreateOptions<{ "dry-run": boolean | undefined; env: string | undefined; format: string | undefined; + "health-check": boolean | undefined; migrate: boolean | undefined; "migrate-token": string | undefined; "migrate-url": string | undefined; diff --git a/packages/cli/src/commands/verify/handler.ts b/packages/cli/src/commands/verify/handler.ts index 3283d41b8f..f13f2276ed 100644 --- a/packages/cli/src/commands/verify/handler.ts +++ b/packages/cli/src/commands/verify/handler.ts @@ -10,6 +10,8 @@ import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import { resolveTargetOrError } from "../../util/deploy-target"; import { detectPackageManager, execArgsFor } from "../../util/detect-package-manager"; +import type { HealthFetch } from "../../util/health-probe"; +import { joinHealthUrl, probeHealth } from "../../util/health-probe"; import type { Logger } from "../../util/logger"; import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import { runSchemaDriftGate } from "../../util/schema-drift-gate"; @@ -18,12 +20,6 @@ import { defaultSpawner } from "../../util/spawn"; import { validateWrangler } from "../../util/wrangler-validator"; import type { VerifyOptions } from "./index"; -/** - * Minimal fetch surface the optional health probe needs — a subset of the global - * `fetch`, injectable so a test can feed a canned response without a network. - */ -type HealthFetch = (url: string) => Promise<{ ok: boolean; status: number }>; - interface VerifyCommandOptions { /** Override the schema-drift gate — report breaking drift as a warning instead of an error. */ allowSchemaDrift?: boolean; @@ -81,51 +77,23 @@ const runTypecheckStep = async (cwd: string, spawner: Spawner): Promise<{ error? return result.code === 0 ? {} : { error: `type errors: tsc --noEmit exited ${String(result.code)}` }; }; -/** The aggregate health route probed by the optional `--health-url` step. */ -const HEALTH_PATH = "/_lunora/health"; - -/** Join a base URL and the health path without doubling the slash. */ -const joinHealthUrl = (base: string): string => (base.endsWith("/") ? base.slice(0, -1) : base) + HEALTH_PATH; - -/** - * Probe a deployment's `GET /_lunora/health` when a `healthUrl` is supplied - * (opt-in — the step is skipped otherwise, keeping `verify` offline-safe by - * default). A `2xx` is green; a `503` (a critical dependency down) or any other - * non-`2xx`, and a transport failure, are red. Returns `{ error }` on red, an - * empty object on green. - */ -const runHealthProbeStep = async (healthUrl: string, healthFetch: HealthFetch): Promise<{ error?: string }> => { - const url = joinHealthUrl(healthUrl); - - let response: { ok: boolean; status: number }; - - try { - response = await healthFetch(url); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : String(error); - - return { error: `health probe failed: could not reach ${url} (${message})` }; - } - - if (response.ok) { - return {}; - } - - return { error: `health probe failed: ${url} returned HTTP ${String(response.status)}` }; -}; - /** * Run the opt-in health probe when a `healthUrl` is supplied, logging a success * line on green. Returns the probe error on red, else `undefined`. Kept separate * from {@link runVerifyCommand} so its branching doesn't inflate that function's * cognitive complexity; the skip (no `healthUrl`) keeps `verify` offline-safe. + * + * Verify probes the AGGREGATE route once, with no retry: it validates a + * checkout rather than gating a release, so "is this deployment healthy right + * now" is the whole question — `deploy --health-check` is the one that waits + * for a fresh version to propagate. */ const probeHealthIfRequested = async (options: VerifyCommandOptions, logger: Logger): Promise => { if (options.healthUrl === undefined || options.healthUrl === "") { return undefined; } - const probe = await runHealthProbeStep(options.healthUrl, options.healthFetch ?? ((url) => fetch(url))); + const probe = await probeHealth({ baseUrl: options.healthUrl, fetchImpl: options.healthFetch }); if (probe.error === undefined) { logger.success(`verify: health probe ok (${joinHealthUrl(options.healthUrl)})`); @@ -277,5 +245,5 @@ const execute: CommandHandler = defineHandler(asyn }); export { execute }; -export type { HealthFetch, VerifyCommandOptions, VerifyCommandResult }; +export type { VerifyCommandOptions, VerifyCommandResult }; export { runVerifyCommand }; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index ee79cfabba..b88b69a532 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,7 +3,7 @@ export { COMMANDS, runCli, VERSION } from "./cli"; export { runCodegenCommand } from "./commands/codegen/handler"; export type { ExportCommandOptions, ExportCommandResult, ImportCommandOptions, ImportCommandResult, StreamingFetchLike } from "./commands/data-transfer"; export { DEFAULT_IMPORT_BATCH_SIZE, runExportCommand, runImportCommand } from "./commands/data-transfer"; -export type { DeployCommandOptions, DeployCommandResult } from "./commands/deploy/handler"; +export type { DeployCommandOptions, DeployCommandResult, DeployedIdentity } from "./commands/deploy/handler"; export { runDeployCommand } from "./commands/deploy/handler"; export type { DevCommandOptions, DevCommandPlan } from "./commands/dev/handler"; export { planDevCommand, runDevCommand } from "./commands/dev/handler"; diff --git a/packages/cli/src/util/auto-link.ts b/packages/cli/src/util/auto-link.ts index cbfaccc118..c99148a114 100644 --- a/packages/cli/src/util/auto-link.ts +++ b/packages/cli/src/util/auto-link.ts @@ -1,11 +1,17 @@ /** * Auto-link a checkout to its deployed Worker by parsing the URL out of * `wrangler deploy` output and writing `.lunora/project.json` — the zero-effort - * equivalent of running `lunora link` after the first deploy. + * equivalent of running `lunora link` after a deploy. * - * It only writes when the checkout is NOT already linked, so it never clobbers - * an explicit `lunora link`, and subsequent deploys keep wrangler's full TTY - * output (the caller only captures stdout for the first, unlinked deploy). + * Every real deploy re-checks the link, not just the first one: a URL that + * CHANGED (custom domain added, worker renamed, environment repointed) otherwise + * leaves a stale link that `run` / `logs` / `insights` / `deploy --migrate` then + * silently target. But an existing link is never silently rewritten — `lunora + * link` is an explicit user act — so a mismatch warns and keeps the recorded + * value, naming both URLs and the command that resolves it. + * + * The parser is Cloudflare-shaped (`*.workers.dev`, `wrangler deploy` stdout) + * and lives behind the Cloudflare deploy path; it is not a target-neutral seam. */ import { readLinkedProject, writeLinkedProject } from "@lunora/config"; @@ -22,7 +28,11 @@ const ANY_HTTPS_URL = /https:\/\/[^\s"'<>]+/u; * `*.workers.dev` origin, else the first https URL. Returns `undefined` when no * URL is present. */ -const parseDeployedUrl = (output: string): string | undefined => { +const parseDeployedUrl = (output: string | undefined): string | undefined => { + if (output === undefined) { + return undefined; + } + const workersDev = WORKERS_DEV_URL.exec(output); if (workersDev) { @@ -39,27 +49,45 @@ interface AutoLinkInputs { logger: Logger; /** Stamp written as `linkedAt`; injected in tests. */ now?: () => string; - /** Captured `wrangler deploy` stdout, or `undefined` when not captured. */ - output: string | undefined; + /** The deployed URL this run published to, or `undefined` when it couldn't be read. */ + url: string | undefined; } +/** `--env staging` → ` (--env staging)`; the top-level config has no suffix. */ +const environmentLabel = (env: string | undefined): string => (env === undefined ? "" : ` (--env ${env})`); + /** - * Write `.lunora/project.json` from a successful deploy's output, unless the - * checkout is already linked. Best-effort — never throws (a cosmetic - * convenience must not affect the deploy's exit code). + * Record the deployed URL in `.lunora/project.json`: + * + * - no link yet → write it; + * - a link recording the same URL for the same `--env` → nothing to do; + * - a link recording something else → WARN and keep the existing value. + * + * Best-effort — never throws (a convenience must not affect the deploy's exit + * code) and never called for a dry run, a preview, or a `--temporary` deploy, + * none of which have a durable URL worth recording. */ -const autoLinkFromDeployOutput = ({ cwd, env, logger, now, output }: AutoLinkInputs): void => { - if (output === undefined || readLinkedProject(cwd) !== undefined) { - return; - } - - const url = parseDeployedUrl(output); - +const autoLinkFromDeployOutput = ({ cwd, env, logger, now, url }: AutoLinkInputs): void => { if (url === undefined) { return; } try { + const existing = readLinkedProject(cwd); + + if (existing !== undefined) { + if (existing.workerUrl === url && existing.env === env) { + return; + } + + logger.warn( + `link: .lunora/project.json records ${existing.workerUrl ?? "(no url)"}${environmentLabel(existing.env)}, but this deploy published ${url}${environmentLabel(env)}. ` + + `Keeping the recorded value — run \`lunora link --url ${url}${env === undefined ? "" : ` --env ${env}`}\` to update it.`, + ); + + return; + } + const stamp = (now ?? (() => new Date().toISOString()))(); writeLinkedProject(cwd, { env, linkedAt: stamp, workerName: readWranglerName(cwd), workerUrl: url }); diff --git a/packages/cli/src/util/deploy-summary.ts b/packages/cli/src/util/deploy-summary.ts index 009355fac9..eae8b88129 100644 --- a/packages/cli/src/util/deploy-summary.ts +++ b/packages/cli/src/util/deploy-summary.ts @@ -27,6 +27,14 @@ interface DeploySummaryInputs { * scrollback from a log line that printed minutes earlier. */ mintedSecretsFile?: string; + + /** + * The URL this deploy published to, when it was read from wrangler's output. + * Preferred over the recorded link: the link can be stale (a custom domain + * added, the worker renamed) or absent entirely on a first deploy, while + * this value comes from the run that just finished. + */ + url?: string; } /** @@ -40,6 +48,7 @@ const renderDeploySummary = (inputs: DeploySummaryInputs): void => { try { const link = readLinkedProject(cwd); const workerName = link?.workerName ?? readWranglerName(cwd); + const url = inputs.url ?? link?.workerUrl; logger.success("deploy complete"); logger.info(` worker: ${workerName ?? "(see wrangler output above)"}`); @@ -48,10 +57,10 @@ const renderDeploySummary = (inputs: DeploySummaryInputs): void => { logger.info(` env: ${env}`); } - if (link?.workerUrl === undefined) { + if (url === undefined) { logger.info(" url: run `lunora link --url ` to record it"); } else { - logger.info(` url: ${link.workerUrl}`); + logger.info(` url: ${url}`); } if (migrated) { diff --git a/packages/cli/src/util/health-probe.ts b/packages/cli/src/util/health-probe.ts new file mode 100644 index 0000000000..f0bcba10cd --- /dev/null +++ b/packages/cli/src/util/health-probe.ts @@ -0,0 +1,128 @@ +/** + * The shared `/_lunora/health` probe used by `lunora verify --health-url` and + * `lunora deploy --health-check`. + * + * Both commands ask the same question — "does this deployment answer?" — so + * they ask it through one implementation with one error-message shape. The + * runtime auto-registers both routes (`packages/runtime/src/health-routes.ts`): + * `/_lunora/health/ready` is the readiness gate ("can this version serve"), and + * `/_lunora/health` is the aggregate that also exists on older deployments. + * + * The probe is transport-only: it never throws, and reports its verdict as an + * `{ error }` message the caller decides what to do with. + */ + +/** + * Minimal fetch surface the probe needs — a subset of the global `fetch`, + * injectable so a test can feed a canned response without a network. + */ +type HealthFetch = (url: string) => Promise<{ ok: boolean; status: number }>; + +/** The aggregate health route: reports every critical dependency (503 when one is down). */ +const HEALTH_PATH = "/_lunora/health"; + +/** The readiness gate: "can this version serve traffic". Absent on older deployments (404). */ +const HEALTH_READY_PATH = "/_lunora/health/ready"; + +/** Join a base URL and a health path without doubling the slash. */ +const joinHealthUrl = (base: string, path: string = HEALTH_PATH): string => (base.endsWith("/") ? base.slice(0, -1) : base) + path; + +interface HealthProbeInputs { + /** + * How many times to ask before giving up. Defaults to a single attempt (the + * `verify` behaviour); a fresh deploy passes more, because propagation makes + * one immediate probe a coin flip. + */ + attempts?: number; + /** The deployment's origin (with or without a trailing slash). */ + baseUrl: string; + /** Fixed delay between attempts, in ms. Not exponential — a predictable ceiling is what a CI timeout is set against. */ + delayMs?: number; + /** Injectable fetch; defaults to the global `fetch`. */ + fetchImpl?: HealthFetch; + + /** + * Health paths to try in order. A `404` on one falls through to the next + * (the route doesn't exist on that deployment); any other non-2xx is a real + * failure. Defaults to the aggregate route alone. + */ + paths?: ReadonlyArray; + /** Injectable clock for the inter-attempt delay; defaults to a real timer. */ + sleep?: (ms: number) => Promise; +} + +interface HealthProbeResult { + /** The failure reason, absent when the probe is green. */ + error?: string; + /** The URL the verdict came from — the last one tried. */ + url: string; +} + +/** Try each path once, in order. A 404 falls through to the next path; anything else is the verdict. */ +const probeOnce = async (baseUrl: string, paths: ReadonlyArray, fetchImpl: HealthFetch): Promise => { + let last: HealthProbeResult = { error: `health probe failed: no health path configured for ${baseUrl}`, url: baseUrl }; + + for (const [index, path] of paths.entries()) { + const url = joinHealthUrl(baseUrl, path); + let response: { ok: boolean; status: number }; + + try { + // eslint-disable-next-line no-await-in-loop -- sequential fallback: the next path is only tried when this one 404s + response = await fetchImpl(url); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + + return { error: `health probe failed: could not reach ${url} (${message})`, url }; + } + + if (response.ok) { + return { url }; + } + + last = { error: `health probe failed: ${url} returned HTTP ${String(response.status)}`, url }; + + // A 404 means this deployment doesn't mount that route (an older + // runtime has no readiness gate) — fall through to the next path. + if (response.status !== 404 || index === paths.length - 1) { + return last; + } + } + + return last; +}; + +const realSleep = async (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * Probe a deployment's health route(s), retrying up to `attempts` times with a + * fixed `delayMs` between tries. A `2xx` is green; a `503` (a critical + * dependency down), any other non-2xx, and a transport failure are red. Returns + * the last verdict — `{ url }` on green, `{ error, url }` on red. + */ +const probeHealth = async ({ + attempts = 1, + baseUrl, + delayMs = 2000, + fetchImpl, + paths = [HEALTH_PATH], + sleep = realSleep, +}: HealthProbeInputs): Promise => { + const doFetch = fetchImpl ?? ((url: string) => fetch(url)); + const budget = Math.max(1, attempts); + let result = await probeOnce(baseUrl, paths, doFetch); + + for (let attempt = 1; attempt < budget && result.error !== undefined; attempt += 1) { + // eslint-disable-next-line no-await-in-loop -- a retry is sequential by definition + await sleep(delayMs); + // eslint-disable-next-line no-await-in-loop -- same + result = await probeOnce(baseUrl, paths, doFetch); + } + + return result; +}; + +export type { HealthFetch, HealthProbeInputs, HealthProbeResult }; +export { HEALTH_PATH, HEALTH_READY_PATH, joinHealthUrl, probeHealth }; diff --git a/plans/308-deploy-result-identity-and-health-gate.md b/plans/308-deploy-result-identity-and-health-gate.md new file mode 100644 index 0000000000..7497cf570b --- /dev/null +++ b/plans/308-deploy-result-identity-and-health-gate.md @@ -0,0 +1,278 @@ +# Plan 308 — `lunora deploy` reports what it deployed, and proves it answers + +**Baseline:** `370994075` (2026-08-08) +**Status:** DONE — shipped on `feat/plan-308-deploy-identity` + +## 0. Headline finding + +`lunora deploy` already parses the deployed URL out of wrangler's output +(`packages/cli/src/util/auto-link.ts`) — but the gate that enables it turns it +**off in exactly the two cases that need it most**: + +```ts +// packages/cli/src/commands/deploy/handler.ts:1340 +const shouldAutoLink = !isJsonFormat(options.format) && options.dryRun !== true && options.preview !== true && readLinkedProject(cwd) === undefined; +``` + +`--format json` — the machine-readable path an automation uses — never captures +the URL, and `DeployCommandResult` (`handler.ts:179-201`) has no field to carry +one anyway. So `lunora deploy --format json` returns a document that cannot tell +a caller _where the thing it just deployed lives_. `--preview` is likewise +excluded, and the code says so out loud: "see the preview URL in the wrangler +output above" (`handler.ts:1413`) — i.e. the CLI hands a machine-readable +command back to a human to read with their eyes. + +Separately: nothing after a successful deploy proves the new version answers. +The probe exists (`runHealthProbeStep`, `verify/handler.ts:97`, against +`/_lunora/health`), but only as an opt-in flag on a _different_ command. + +## 1. Current state (audit) + +**URL capture (partially built, gated off where it counts):** + +- `autoLinkFromDeployOutput` (`util/auto-link.ts`) parses a `*.workers.dev` + origin (falling back to the first https URL) out of captured stdout and writes + `.lunora/project.json` via `writeLinkedProject`. Best-effort; never throws. +- `handler.ts:1346` sets `captureStdout: shouldAutoLink` and `:1373` calls the + linker with `result.stdout`. +- `shouldAutoLink` (`:1340`) is false when: `--format json`, `--dry-run`, + `--preview`, **or the checkout is already linked**. +- `SpawnDescriptor.captureStdoutSilently` (`util/spawn.ts`) exists precisely for + "capture without teeing to stdout, so `--format json` stays one document" — + and is not used by the deploy path. + +**What the result document carries** (`handler.ts:179-201`): `code`, +`descriptor`, `error?`, `mintedSecretsFile?`, `schemaDrift?`, `validation`. No +`url`, no version id, no timestamp, no `dryRun`/`preview` discriminator. + +**Downstream consequences of the missing identity:** + +- `deploy --migrate` refuses rather than defaulting: "`--migrate requires +--migrate-url — the deploy target URL is not captured +automatically, refusing to default to localhost`" (`handler.ts:843-850`). It + resolves from the _link file_ (`resolveWorkerUrl`, `util/resolve-target.ts:37`), + not from the deploy that just ran — so a first deploy in a fresh CI checkout + has no URL at the moment it needs one. +- The deploy summary asks the human to type it back in: "`url: run \`lunora link + --url \` to record it`" (`util/deploy-summary.ts:52`). +- A checkout that is already linked never re-captures (`readLinkedProject(cwd) +=== undefined` in the gate), so a URL that _changed_ — custom domain added, + worker renamed, environment repointed — leaves a stale link that + `run` / `logs` / `insights` / `--migrate` then silently target. + +**Health:** `/_lunora/health` (aggregate, 503 on a downed critical dependency) +and `/_lunora/health/ready` (readiness gate) are auto-registered by the runtime +(`packages/runtime/src/health-routes.ts:32-33`, `create-worker.ts:4433`). +`lunora verify --health-url` probes the first one once, no retry +(`verify/handler.ts:97-116`). `lunora deploy` never probes anything. + +## 2. Existing seams (do not reinvent) + +- **`autoLinkFromDeployOutput` + `parseDeployedUrl`** (`util/auto-link.ts`) — + the URL extractor. This plan changes _when_ it runs and _what else_ consumes + its result; it should not grow a second parser. +- **`SpawnDescriptor.captureStdoutSilently`** (`util/spawn.ts`) — capture in + JSON mode without corrupting the single stdout document. Already documented + for this exact hazard. +- **`runHealthProbeStep`** (`verify/handler.ts:97`) — the probe. Lift it to a + shared util (`util/health-probe.ts`) so `verify` and `deploy` share one + implementation and one error-message shape. +- **`readLinkedProject` / `writeLinkedProject` / `LinkedProject`** + (`packages/config/src/linked-project.ts`) — the `.lunora/project.json` record. + It already carries `env`, `linkedAt`, `workerName`, `workerUrl`. +- **`isJsonFormat` / `printJson`** (`util/output-format.ts:46`) — the JSON + contract. +- **`resolveWorkerUrl`'s environment guard** (`util/resolve-target.ts:37-49`) — + a link recorded for one `--env` must never stand in for another. Any new write + path must preserve that invariant, not route around it. + +## 3. The behavioural contract to preserve + +- `--format json` emits **exactly one** JSON document on stdout. Capturing + wrangler's stdout must use `captureStdoutSilently`, never `captureStdout`. +- Link writes stay **best-effort**: a failed capture, parse, or write must never + change the deploy's exit code (`auto-link.ts` is explicit about this). +- The `resolveWorkerUrl` env guard holds: a `production` link never supplies its + URL to a `--env staging` command. +- `--dry-run` publishes nothing, so it must never write a link, never report a + URL, and must be distinguishable in the JSON document from a real deploy. +- Additive JSON fields only — existing keys keep their names and meanings. + +## 4. Design decisions + +- **Capture on every successful real deploy, not only unlinked ones.** Chosen + over today's first-deploy-only rule because the failure it prevents (a stale + link silently misrouting `--migrate` at a decommissioned URL) is worse than + the one it causes (overwriting a hand-set link). +- **…but never silently overwrite a hand-written link.** When a link already + exists for this `env` and the parsed URL differs, **warn and keep the existing + value**; do not rewrite. Chosen over overwrite (surprising, and `lunora link` + is an explicit user act) and over silence (the stale-link failure above). The + warning names both URLs and the one command that resolves it. +- **A `deployment` object in the result document, not loose top-level keys.** + `{ url, workerName, env, versionId?, dryRun, preview, deployedAt }` — one + nested object keeps the discriminators next to the identity they qualify, and + leaves room for a version id without another top-level field per release. +- **`dryRun` and `preview` are reported as booleans in the document, always.** + A consumer must be able to tell "nothing went live" from "went live" without + inferring it from a missing `url`. +- **Health probe is opt-in via `--health-check`, not on by default.** Chosen + over always-on: `deploy` must stay usable against a worker whose health route + is admin-gated or unreachable from CI, and a default-on network step turns a + successful deploy into a red build for an unrelated reason. The flag is what + a release pipeline opts into deliberately. +- **The probe retries with a bounded budget; it does not poll forever.** A + fresh deploy propagates, so a single immediate probe is a coin flip. Fixed + attempt budget with a fixed delay, not exponential backoff — the wait is + seconds, and a predictable ceiling is what a CI timeout can be set against. +- **Probe `/_lunora/health/ready`, falling back to `/_lunora/health`.** The + readiness gate is the one that answers "can this version serve"; the aggregate + is the one that exists on older deployments. `verify` keeps its current + aggregate-only behaviour unless the shared util makes both trivial. + +## 5. Workstreams + +**S — result identity.** Add `deployment?: { deployedAt, dryRun, env?, preview, +url?, versionId?, workerName? }` to `DeployCommandResult` (`handler.ts:179`). +Populate from the captured output + `readWranglerName`. Emit in the JSON +document; the pretty summary keeps its current shape but reads the URL from the +result rather than from the link file. + +**Done.** `DeployedIdentity` (exported from `handler.ts` and the package index) +carries `{ deployedAt, dryRun, env?, preview, url?, workerName? }` — **no +`versionId`**, see Q1. Built in `completeDeploy` once wrangler exits 0, present +on dry runs and previews too. `renderDeploySummary` grew a `url?` input that +wins over the link file. Snapshot delta committed in `api-snapshots/cli.api.md`. + +**S — capture in JSON and preview modes.** Split `shouldAutoLink` into two +decisions: _should we capture_ (yes on any successful non-dry-run wrangler +invocation, silently when `isJsonFormat`) and _should we write the link_ (the +existing rules, plus the mismatch warning from §4). This is the core fix — +`--format json` and `--preview` both start reporting a URL. + +**Done.** `buildDeploySpawn` owns the split: `captureStdout` (tees) in pretty +mode, `captureStdoutSilently` in json mode, neither on a dry run. In json mode +the buffered output is replayed to **stderr** after the spawn, so `--format +json` still shows the wrangler log in CI without touching the document — +`stdoutToStderr` no longer applies there (nothing is inherited to redirect). + +**M — link refresh + mismatch warning.** Rework `autoLinkFromDeployOutput` to +take the existing link into account: write when absent, warn-and-keep when +present-and-different, no-op when present-and-equal. Keep it best-effort and +keep the `env` scoping. + +**Done.** It now takes the parsed `url` (the handler needs it for `deployment` +anyway, so `parseDeployedUrl` runs once) instead of raw `output`. A link +recorded for a DIFFERENT `--env` counts as a mismatch, not a target to +overwrite — the file holds one link, and clobbering the production one with a +staging URL is the failure `resolveWorkerUrl`'s guard exists to prevent. + +**M — `--health-check`.** Lift `runHealthProbeStep` into +`util/health-probe.ts`, give it an attempt budget + delay + injectable fetch and +clock, and call it from `deploy` after a successful real deploy when the flag is +set, using the URL this run just captured (falling back to the link, then +refusing with a clear message when neither exists). A failed probe fails the +deploy command's exit code — that is the point of the flag — and the reason +lands in the JSON document. `verify` switches to the shared util. + +**Done.** `probeHealth` in `util/health-probe.ts`: ordered `paths` (a 404 falls +through to the next, anything else is the verdict), `attempts`/`delayMs`, +injectable `fetchImpl` + `sleep`. Deploy probes `[ready, aggregate]` 5× at 2s; +`verify` keeps its single aggregate probe by taking the defaults, so its message +shape and call signature are byte-identical. The probe runs BEFORE `--migrate` +— a worker that can't serve is not one to migrate — and its verdict lands in +`result.healthCheck { error?, ok, url }`. + +**S — docs.** CLI reference for `deploy`: the new flag, the `deployment` object, +and one worked CI example (`deploy --format json --health-check`, then read +`.deployment.url` for the smoke step). + +**Done.** `packages/cli/docs/index.mdx` — cheat-sheet line, a `--health-check` +subsection, a "Machine-readable output" subsection with the annotated document +and the `jq -r '.deployment.url'` smoke-step example, plus the rewritten +link/preview paragraphs. + +## 6. Platform parity + +Not applicable to the `ctx.*` matrix — this plan adds no runtime surface and no +provider binding. One target-facing note: the URL parser and the health probe +are Cloudflare-shaped (`*.workers.dev`, `wrangler deploy` stdout). The +`@lunora/platform-node` deploy driver in `@lunora/config` has its own notion of +a deployed endpoint; this plan does not extend to it, and the parser must stay +behind the Cloudflare deploy path rather than being presented as target-neutral. +`/_lunora/health` itself is runtime-level and therefore available on any host +that mounts the runtime. + +## 7. Phasing & ordering + +| Phase | Work | Gate | +| ----- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | +| 0 | `deployment` on the result | Test: `--format json` on a stubbed spawner yields one parseable document containing `deployment.url` | +| 1 | Silent capture in JSON mode | Test: stdout is exactly one JSON document (no wrangler text interleaved) while `deployment.url` is still populated | +| 2 | Preview capture | Test: `--preview --format json` reports the preview URL; `handler.ts:1413`'s "read it above" message is gone | +| 3 | Link refresh + mismatch warning | Three tests: absent → written; equal → no write, no warning; different → warning, original value preserved on disk | +| 4 | `--health-check` + shared util | Tests: probe passes → exit 0; probe 503s on every attempt → non-zero exit + reason in the document; `verify` green | +| 5 | Docs | `pnpm run lint:prettier` clean | + +**All phases done.** Gates: `pnpm --filter "@lunora/cli" run test` → 88 files / +1191 tests passed; `lint:types`, `lint:eslint` (`--max-warnings=0`), +`lint:prettier` clean; `pnpm run api:check` green after `api:update`. + +One plan correction: §7 phase 2 expects `handler.ts:1413`'s "read it above" +message to be **gone**. It is replaced, not deleted — a preview whose output +carried no URL still needs a success line, so it now reads `preview version +uploaded — ` and falls back to a bare `preview version uploaded`. + +## 8. Risks & STOP conditions + +- **STOP** if wrangler's deploy output stops containing a URL in a supported + version (or moves it behind its own machine-readable flag). Prefer wrangler's + own structured output over regex-scraping the moment it is available on the + pinned version — re-scope rather than hardening the regex further. +- **Risk:** capturing stdout changes wrangler's TTY behaviour (progress + rendering, colour) for the non-JSON path. Mitigate: keep `captureStdout` + (which tees) for pretty mode and `captureStdoutSilently` only for JSON mode — + the split the spawn util already anticipates. +- **Risk:** the mismatch warning fires on every deploy for projects whose URL + legitimately varies (preview/temporary origins). Mitigate: only compare for + real, non-preview, non-temporary deploys of the same `env`. +- **Risk:** `--health-check` flakes on cold propagation and reads as a broken + deploy. Mitigate: bounded retry (§4), and the failure message must state that + the deploy _succeeded_ and the probe did not — those are different facts. + +## 9. Open questions (answered during execution) + +1. **No — shipped without `versionId`.** `wrangler deploy --help` on the pinned + 4.114.0 offers no `--format`/`--json` and no structured deploy output; the + only version id is the `Current Version ID: ` line in the human prose, + and scraping a second value out of prose is exactly what §8's STOP condition + warns against. `DeployedIdentity` therefore has no `versionId`, and the docs + point at `lunora deployments list` for it. Revisit if wrangler ships + structured output. +2. **No.** `--health-check` probes the origin the deploy just published to (then + the link for this `--env`, then refuses). A public origin that differs from + the deploy origin is already served by `lunora verify --health-url `, + which now runs the same probe — a second URL flag on `deploy` would be a + config knob with one existing caller. +3. **Parseable, but no link.** `--temporary` prints the same `*.workers.dev` + origin, so `deployment.url` reports it — but the account is deleted in ~60 + minutes, so writing it as the checkout's recorded target would silently + misroute `run` / `logs` / `--migrate` afterwards. The link write is skipped + for `--temporary` (tested). +4. **No `--relink`.** The mismatch warning names the exact `lunora link --url + [--env ]` to run. A flag for it would be a second way to do the + same thing, on the rare path. +5. **Verify keeps the aggregate probe.** It validates a checkout rather than + gating a release, so "is this deployment healthy right now" is the whole + question — one attempt, one route, unchanged message shape. The shared util + makes both trivially available if that ever changes. + +### Follow-up not taken here + +`deploy --migrate` still requires `--migrate-url` (or a link) rather than +defaulting to the URL this run captured: the preflight refusal runs _before_ +wrangler, so the captured URL does not exist yet at the point of the gate. +Deferring that gate until after the spawn is a real change to when a `--migrate` +run can abort, and is outside §5's workstreams. The stale message ("the deploy +target URL is not captured automatically") was corrected to say why the gate +cannot use it. From abba5bed094723d395545069cc169bde834e0d4f Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 02:28:29 +0200 Subject: [PATCH 3/8] feat(cli): weigh the bundled worker and gate on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Nothing measured how large the Worker a user deploys is, so the first signal of a size regression would have been Cloudflare rejecting someone's deploy for a dependency added weeks earlier. `lunora build` now reports what it wrote — raw and gzipped, counting only the files that are uploaded (the sourcemap and the esbuild metafile in the same out-dir are not) — as a line of pretty output and a `bundle` field in the `--format json` document. Measuring never changes the exit code. An out-dir with nothing uploadable in it warns instead of reporting 0 bytes, since a silent zero is what a changed wrangler layout looks like. `scripts/check-worker-size.js` builds `templates/standalone` against the workspace and fails when it exceeds the ceiling committed in `worker-size.json` (422,840 B gzipped + a 50 KiB allowance), naming the delta and pointing at `pnpm run worker-size:update`. It runs as its own `worker-size` job in test.yml, not from postinstall. BREAKING CHANGE: `lunora build --format json` now prints the build result rather than the deploy result — same fields, plus `bundle`. Measured for the baseline: 1684.9 KiB raw / 412.9 KiB gzipped, against Cloudflare's 3 MB (Free) and 10 MB (Paid) compressed script limits. --- .github/workflows/test.yml | 46 ++- apps/docs/src/content/docs/deployment.mdx | 34 +++ package.json | 4 +- packages/cli/__tests__/commands/build.test.ts | 100 ++++++- .../cli/src/commands/build/bundle-size.ts | 69 +++++ packages/cli/src/commands/build/handler.ts | 84 +++++- plans/310-worker-size-budget.md | 267 ++++++++++++++++++ scripts/check-worker-size.js | 169 +++++++++++ worker-size.json | 7 + 9 files changed, 763 insertions(+), 17 deletions(-) create mode 100644 packages/cli/src/commands/build/bundle-size.ts create mode 100644 plans/310-worker-size-budget.md create mode 100644 scripts/check-worker-size.js create mode 100644 worker-size.json diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index dae81db532..95766da098 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -307,11 +307,55 @@ "retention-days": 7 "if-no-files-found": "ignore" + # Weighs the Worker a `templates/standalone` app deploys against the committed + # ceiling in `worker-size.json`. Nothing else in CI measures bytes, and the + # cost of noticing late is a user's deploy rejected by Cloudflare for a + # dependency this repo added weeks earlier. + # + # Its own job, NOT the root `postinstall`: a failing postinstall gate turns + # every job red in its setup step and the cause is invisible in the job that + # reports it. + "worker-size": + "name": "Worker size budget" + "if": "needs.files-changed.outputs.packages == 'true' || needs.files-changed.outputs.templates == 'true'" + "needs": "files-changed" + "runs-on": "ubuntu-latest" + "timeout-minutes": 30 + "steps": + - "name": "Harden Runner" + "uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4 + "with": + "egress-policy": "audit" + + - "name": "Git checkout" + "uses": "anolilab/workflows/step/checkout-with-retry@6c16895f4c3b5273b373656988505d1a8264e78b" # main + + - "name": "Setup resources and environment" + "uses": "anolilab/workflows/step/node@acba5fb15ac98ad23dec1ad233e40e1bb79c1dae" # v19.0.4 + "with": + "node-version": "22.15" + "cache-prefix": "worker-size" + "install-node-gyp": "true" + "skip-playwright": "true" + "enable-nx-cache": "false" + "run-npm-audit": "false" + "run-signatures-audit": "false" + + # `build:packages:prod`, not `build:packages`: the reference app + # bundles the workspace `dist/` directories, and users install the + # production build. The development build measures ~25% heavier — a + # number nobody deploys, and a baseline nobody could reproduce. + - "name": "Build packages (production artifacts)" + "run": "pnpm run build:packages:prod" + + - "name": "Weigh the reference Worker" + "run": "pnpm run worker-size:check" + # Single required check: green when every dependent job passed or was # skipped (so a no-package-change PR with all jobs skipped still passes). "test-required-check": "name": "Check Test Run" - "needs": ["files-changed", "test", "test-workerd", "e2e", "templates"] + "needs": ["files-changed", "test", "test-workerd", "e2e", "templates", "worker-size"] "if": "always()" "runs-on": "ubuntu-latest" "timeout-minutes": 5 diff --git a/apps/docs/src/content/docs/deployment.mdx b/apps/docs/src/content/docs/deployment.mdx index ccf869c98a..6f778bdb68 100644 --- a/apps/docs/src/content/docs/deployment.mdx +++ b/apps/docs/src/content/docs/deployment.mdx @@ -145,6 +145,40 @@ CI should run `lunora verify` (or `lunora prepare`) first so codegen drift and a stale `_generated/` are a build failure, not a deploy that silently uses old types. +## Worker size + +Cloudflare caps the size of a Worker script +[after gzip compression](https://developers.cloudflare.com/workers/platform/limits/): +**3 MB on the Workers Free plan and 10 MB on Workers Paid**. The limit is +enforced at upload, so an over-budget bundle is a rejected deploy rather than a +slow one. + +`lunora build` weighs what it wrote: + +```bash +pnpm lunora build +# … bundle: 1684.9 KiB raw, 412.9 KiB gzipped across 1 file(s) + +pnpm lunora build --format json | jq .bundle +# { "files": 1, "gzipBytes": 422840, "rawBytes": 1725313 } +``` + +Only the uploaded files are counted — the sourcemap and the esbuild metafile +sitting in the same out-dir are not part of the script, and counting them would +roughly triple the number. The gzip figure is the one to compare against the +limit; it matches what `wrangler deploy` reports as `Total Upload: … / gzip: …`. + +A starter app is around **410 KiB gzipped**, so most projects have a lot of +room. If yours is approaching the limit: + +- **Drop add-ons you no longer import.** Every `@lunora/*` add-on your Worker + entry reaches is bundled, whether or not a request ever uses it. +- **Check for a dev-only import reaching the Worker entry.** A seed script, a + test helper, or a Node-only utility imported from `lunora/` pulls its whole + dependency tree into the deployed bundle. +- **Look at what is actually heavy.** `lunora analyze` bundles the Worker and + prints the largest modules, which is usually enough to name the culprit. + ## Streaming logs Tail a deployed Worker's live logs with: diff --git a/package.json b/package.json index f344f893c5..68889f8250 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,9 @@ "test:affected:coverage": "vis affected test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"", "test:clean-machine": "./scripts/clean-machine-smoke.sh", "test:coverage": "vis run test:coverage --query \"project!=lunora-e2e&&project!=studio&&project!=lunora-playground\"", - "test:templates": "./scripts/template-build-smoke.sh" + "test:templates": "./scripts/template-build-smoke.sh", + "worker-size:check": "node scripts/check-worker-size.js", + "worker-size:update": "node scripts/check-worker-size.js --update" }, "devDependencies": { "@anolilab/commitlint-config": "catalog:prod", diff --git a/packages/cli/__tests__/commands/build.test.ts b/packages/cli/__tests__/commands/build.test.ts index c930d15ff4..82bf5e392f 100644 --- a/packages/cli/__tests__/commands/build.test.ts +++ b/packages/cli/__tests__/commands/build.test.ts @@ -1,12 +1,15 @@ -import { cpSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { gzipSync } from "node:zlib"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { BuildCommandResult } from "../../src/commands/build/handler"; import { runBuildCommand } from "../../src/commands/build/handler"; import type { Logger } from "../../src/util/logger"; +import type { Spawner } from "../../src/util/spawn"; import { createRecordingSpawner } from "../../src/util/spawn"; const here = dirname(fileURLToPath(import.meta.url)); @@ -24,22 +27,46 @@ const VALID_WRANGLER = `{ } `; -const silentLogger = (): { logger: Logger; successes: string[] } => { +const silentLogger = (): { logger: Logger; successes: string[]; warnings: string[] } => { const successes: string[] = []; + const warnings: string[] = []; return { logger: { error: () => {}, info: () => {}, success: (message) => successes.push(message), - warn: () => {}, + warn: (message) => warnings.push(message), }, successes, + warnings, }; }; +/** Worker script the fake wrangler "bundles" — big enough that gzip is a real number. */ +const SCRIPT = `export default { fetch() { return new Response(${JSON.stringify("ok".repeat(4096))}); } };\n`; + let workdir: string; +/** + * A spawner that writes what `wrangler deploy --outdir` writes: the script, its + * sourcemap, the esbuild metafile, and wrangler's explanatory README — so the + * measurement is exercised against the layout it actually has to filter. + */ +const bundlingSpawner = + (outDirectory: string): Spawner => + async (descriptor) => { + const directory = join(workdir, outDirectory); + + mkdirSync(directory, { recursive: true }); + writeFileSync(join(directory, "server.js"), SCRIPT, "utf8"); + writeFileSync(join(directory, "server.js.map"), "x".repeat(50_000), "utf8"); + writeFileSync(join(directory, "bundle-meta.json"), "y".repeat(50_000), "utf8"); + writeFileSync(join(directory, "README.md"), "wrangler wrote this\n", "utf8"); + + return { code: 0, descriptor, stderr: "", stdout: "" }; + }; + describe("lunora build", () => { beforeEach(() => { workdir = mkdtempSync(join(tmpdir(), "lunora-build-")); @@ -104,6 +131,71 @@ describe("lunora build", () => { expect(successes.join("\n")).toContain("binding manifest written to"); }); + it("weighs the bundle it wrote, counting only what Cloudflare uploads", async () => { + expect.assertions(4); + + const { logger } = silentLogger(); + + const result = await runBuildCommand({ cwd: workdir, logger, outDir: "dist-worker", spawner: bundlingSpawner("dist-worker") }); + + // The sourcemap, the metafile and wrangler's README are all in the + // out-dir and none of them ship — counting them would report a bundle + // roughly three times its real weight. + expect(result.bundle?.files).toBe(1); + expect(result.bundle?.rawBytes).toBe(Buffer.byteLength(SCRIPT)); + expect(result.bundle?.gzipBytes).toBe(gzipSync(Buffer.from(SCRIPT)).byteLength); + expect(result.bundle?.gzipBytes).toBeGreaterThan(0); + }); + + it("reports the size in the --format json document without failing on it", async () => { + expect.assertions(3); + + const { logger } = silentLogger(); + const written: string[] = []; + const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk) => { + written.push(String(chunk)); + + return true; + }); + + let result: BuildCommandResult; + + try { + result = await runBuildCommand({ + cwd: workdir, + format: "json", + logger, + outDir: "dist-worker", + spawner: bundlingSpawner("dist-worker"), + }); + } finally { + spy.mockRestore(); + } + + // Measuring is reporting: a size never changes the exit code. + expect(result.code).toBe(0); + + const document = JSON.parse(written.join("")) as BuildCommandResult; + + expect(written).toHaveLength(1); + expect(document.bundle?.gzipBytes).toBeGreaterThan(0); + }); + + it("says so rather than reporting zero when there is nothing to weigh", async () => { + expect.assertions(2); + + const { logger, warnings } = silentLogger(); + + // The recording spawner writes no out-dir — which is what a changed + // wrangler layout would also look like. A 0-byte bundle would read as + // the healthiest possible result, so it must not be reported at all. + const { spawner } = createRecordingSpawner(); + const result = await runBuildCommand({ cwd: workdir, logger, spawner }); + + expect(result.bundle).toBeUndefined(); + expect(warnings.join("\n")).toContain("could not weigh the bundle"); + }); + it("--emit-bindings fails rather than describing a Worker that needs nothing", async () => { expect.assertions(2); diff --git a/packages/cli/src/commands/build/bundle-size.ts b/packages/cli/src/commands/build/bundle-size.ts new file mode 100644 index 0000000000..13a71a7bbb --- /dev/null +++ b/packages/cli/src/commands/build/bundle-size.ts @@ -0,0 +1,69 @@ +import { readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +/** Raw and compressed size of the bundle `lunora build` wrote to disk. */ +interface BundleSize { + /** How many files were counted — 0 never reaches a caller (see {@link measureBundle}). */ + files: number; + + /** + * Sum of the per-file gzip sizes. Cloudflare states its script-size limit + * against the compressed script, and gzip at zlib's default level is what + * wrangler itself reports ("Total Upload: … / gzip: …") — measuring the same + * way keeps the two numbers comparable instead of inviting a "which is + * right?" question. Summing per file rather than gzipping the concatenation + * is the conservative direction: separate streams share no dictionary. + */ + gzipBytes: number; + rawBytes: number; +} + +/** + * True for files that are part of the uploaded Worker. + * + * The out-dir also carries artifacts Cloudflare never sees: sourcemaps (which + * are larger than the script itself — counting them would roughly triple the + * number), the esbuild metafile `lunora deploy --outdir` writes alongside the + * bundle, and the README wrangler drops in to explain the directory. + */ +const isUploaded = (name: string): boolean => !name.endsWith(".map") && name !== "bundle-meta.json" && name !== "README.md"; + +/** + * Weigh the bundle in `outDir`, raw and gzipped. + * + * Returns `undefined` when the directory is missing or holds nothing uploadable + * — a caller must treat that as "not measured" and never as "zero bytes": a + * silent 0 is what a changed wrangler out-dir layout would look like, and it + * would read as the healthiest possible result. + */ +const measureBundle = (outDirectory: string): BundleSize | undefined => { + let entries; + + try { + entries = readdirSync(outDirectory, { recursive: true, withFileTypes: true }); + } catch { + return undefined; + } + + let files = 0; + let gzipBytes = 0; + let rawBytes = 0; + + for (const entry of entries) { + if (!entry.isFile() || !isUploaded(entry.name)) { + continue; + } + + const bytes = readFileSync(join(entry.parentPath, entry.name)); + + files += 1; + rawBytes += bytes.byteLength; + gzipBytes += gzipSync(bytes).byteLength; + } + + return files === 0 ? undefined : { files, gzipBytes, rawBytes }; +}; + +export type { BundleSize }; +export { measureBundle }; diff --git a/packages/cli/src/commands/build/handler.ts b/packages/cli/src/commands/build/handler.ts index 3a3565543b..87f3977774 100644 --- a/packages/cli/src/commands/build/handler.ts +++ b/packages/cli/src/commands/build/handler.ts @@ -9,9 +9,13 @@ import { parseApiSpec } from "../../util/api-spec"; import type { CommandHandler } from "../../util/command"; import { defineHandler } from "../../util/command"; import type { Logger } from "../../util/logger"; +import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import type { Spawner } from "../../util/spawn"; +import { defaultSpawner } from "../../util/spawn"; import type { DeployCommandResult } from "../deploy/handler"; import { runDeployCommand } from "../deploy/handler"; +import type { BundleSize } from "./bundle-size"; +import { measureBundle } from "./bundle-size"; import type { BuildOptions } from "./index"; /** Default artifact directory — gitignored alongside the other `.lunora/` state. */ @@ -43,6 +47,28 @@ interface BuildCommandOptions { target?: string; } +/** + * A build result is the deploy result plus the weight of what was produced. + * `bundle` is absent when the out-dir could not be measured (see + * {@link measureBundle}) and on a failed build, where nothing was written. + */ +interface BuildCommandResult extends DeployCommandResult { + bundle?: BundleSize; +} + +/** + * `defaultSpawner` with every child's stdout folded into stderr. + * + * `build` takes over its own `--format json` document (below), which means the + * `format` deploy sees is `undefined` — and `format` is what deploy would + * otherwise have used to keep wrangler's chatter off stdout. Forcing it here + * keeps stdout carrying exactly one JSON document. + */ +const stderrOnlySpawner: Spawner = async (descriptor) => defaultSpawner({ ...descriptor, stdoutToStderr: true }); + +/** Bundle sizes are always kilobytes-and-up; one unit keeps the two halves comparable at a glance. */ +const kib = (bytes: number): string => `${(bytes / 1024).toFixed(1)} KiB`; + /** * Write the binding manifest for the project at `projectRoot`. * @@ -93,37 +119,73 @@ const writeBindingManifest = (projectRoot: string, target: string, logger: Logge * schema-drift gate, binding provisioning, container preflight, wrangler * validation) and then emits the bundle to disk instead of publishing. */ -const runBuildCommand = async (options: BuildCommandOptions): Promise => { +const runBuildCommand = async (options: BuildCommandOptions): Promise => { const outDirectory = options.outDir ?? DEFAULT_OUT_DIR; + const jsonMode = isJsonFormat(options.format); + + // `build` owns its `--format json` document instead of delegating to + // deploy's: the bundle measurement below is what a CI consumer runs this + // command for, and the deploy result has no field to carry it. Everything + // human therefore goes to stderr from here on. + const logger = loggerForFormat(options.format, options.logger); + const emit = (result: BuildCommandResult): BuildCommandResult => { + if (jsonMode) { + printJson(result); + } + + return result; + }; + + const formatError = validateOutputFormat("build", options.format); + + if (formatError !== undefined) { + options.logger.error(formatError); + + return { code: 1, descriptor: undefined, error: formatError, validation: { problems: [], wranglerPath: undefined } }; + } const result = await runDeployCommand({ apiSpec: options.apiSpec, cwd: options.cwd, dryRun: true, - format: options.format, - logger: options.logger, + format: undefined, + interactive: jsonMode ? false : undefined, + logger, outDir: outDirectory, - spawner: options.spawner, + spawner: options.spawner ?? (jsonMode ? stderrOnlySpawner : undefined), target: options.target, }); if (result.code !== 0) { - return result; + return emit(result); } - options.logger.success(`build complete — bundle written to ${outDirectory}`); + logger.success(`build complete — bundle written to ${outDirectory}`); + + const bundle = measureBundle(resolve(options.cwd ?? process.cwd(), outDirectory)); + + if (bundle === undefined) { + // Never report 0 bytes for this: an out-dir layout we no longer + // recognise would measure as the healthiest possible bundle. + logger.warn(`could not weigh the bundle — nothing uploadable was found in ${outDirectory}`); + } else { + logger.info( + `bundle: ${kib(bundle.rawBytes)} raw, ${kib(bundle.gzipBytes)} gzipped across ${String(bundle.files)} file(s) — ` + + `Cloudflare's Worker size limit (3 MB Free, 10 MB Paid) applies to the gzipped number`, + ); + } if (options.emitBindings !== undefined) { - const { error } = writeBindingManifest(options.cwd ?? process.cwd(), options.emitBindings, options.logger); + const { error } = writeBindingManifest(options.cwd ?? process.cwd(), options.emitBindings, logger); if (error !== undefined) { - options.logger.error(error); + logger.error(error); - return { ...result, code: 1 }; + return emit({ ...result, bundle, code: 1 }); } } - return result; + return emit({ ...result, bundle }); }; /** `lunora build` handler (lazy-loaded via the command's `loader`). */ @@ -142,5 +204,5 @@ const execute: CommandHandler = defineHandler(async }); export { execute }; -export type { BuildCommandOptions }; +export type { BuildCommandOptions, BuildCommandResult }; export { runBuildCommand }; diff --git a/plans/310-worker-size-budget.md b/plans/310-worker-size-budget.md new file mode 100644 index 0000000000..e08989c2bc --- /dev/null +++ b/plans/310-worker-size-budget.md @@ -0,0 +1,267 @@ +# Plan 310 — Measure the deployed Worker's size, and gate on it + +**Baseline:** `370994075` (2026-08-08) +**Status:** DONE (2026-08-08) — gate shipped, user-facing warning dropped per the §8 STOP condition. + +## Phase 0 — the measurement (done first, as required) + +`templates/standalone` scaffolded into a scratch dir, workspace `dist/` +symlinked in, built with `lunora build` → `wrangler deploy --dry-run --outdir` +(wrangler 4.114.0) against a **production** package build: + +| | raw | gzip | +| -------------------------------------------- | ---------------------------- | ------------------------- | +| **`templates/standalone` (production dist)** | **1,725,313 B — 1684.9 KiB** | **422,840 B — 412.9 KiB** | +| same, development dist (`build:packages`) | 2,190,091 B — 2138.8 KiB | 533,849 B — 521.3 KiB | + +One uploaded file (`server.js`). `node:zlib`'s `gzipSync` at its default level +reproduces wrangler's own `Total Upload: … / gzip: …` line to the byte, so the +two numbers are directly comparable. The out-dir also holds a 3.0 MB sourcemap +and a 1.2 MB metafile, neither of which is uploaded. + +**412.9 KiB is 13.4% of the Free plan's 3 MB ceiling and 4.0% of Paid's 10 MB.** + +Heaviest inputs of that bundle (esbuild metafile, bytes-in-output): + +| KiB | input | +| ----- | ----------------------- | +| 606.1 | `compromise@14.15.1` | +| 242.5 | `drizzle-orm@0.45.2` | +| 197.5 | `@lunora/shard-engine` | +| 140.2 | `@lunora/runtime` | +| 131.3 | `@lunora/do` | +| 56.4 | `@lunora/observability` | + +**Finding (separate from this plan): 35% of a hello-world Worker is an English +NLP library.** `compromise` is not a Lunora dependency — it arrives via +`@visulima/redact`'s `stringAnonymize`, imported by +`packages/observability/src/request-log.ts` for log redaction. Every Lunora app +carries 606 KiB raw for it. Worth its own plan: either import redact's +rule-based path without the NLP entity detector, or lazy-load it. + +## 0. Headline finding + +Nothing in this repo measures how large the Worker a user actually deploys is. +There is no size budget in any CI job (`.github/workflows/` — 19 workflows, none +size-related), no check in `scripts/`, and `dist:check` +(`scripts/check-dist-production.js`) audits _production-cleanliness_ of package +`dist/`, not bytes. Cloudflare enforces a hard compressed-script limit; the +first time anyone learns this framework's floor is when a user's deploy is +rejected — and at that point the cause is a dependency added weeks earlier, +across 55 packages, with no per-commit signal to bisect against. + +Per-package measurement won't answer it either: package entrypoints are +re-export shims (`packages/runtime/dist/index.mjs` is 2 KiB; the code lives in +`dist/packem_shared/`, 348 KiB for `@lunora/runtime` alone). Only the bundled +Worker is a real number. + +## 1. Current state (audit) + +- **`lunora build`** (`packages/cli/src/commands/build/index.ts`) already + produces exactly the artifact to measure: "Codegen + validate + bundle the + Worker to disk without deploying", default out-dir `.lunora/build`, via + `wrangler deploy --dry-run --outdir`. It exists so CI can build once and ship + with `deploy --prebuilt`. +- It already emits machine-readable output (`--format json`) and a bindings + manifest (`--emit-bindings `) — so it is the natural place for a size + field, and nothing new needs to run to obtain the bundle. +- `lunora deploy --dry-run` runs the same bundle step (`handler.ts`), so the + number is available on the deploy path too, before anything is published. +- No consumer of either measures or reports bytes. +- `tests/vis-templates` and `pnpm run test:templates` already build the + `templates/*` starters in CI — the hook where a per-template number could be + recorded without inventing a new build. + +## 2. Existing seams (do not reinvent) + +- **`lunora build`'s out-dir + `--format json` result** — measure the emitted + files there; do not add a second bundling path. +- **`packages/cli/src/util/output-format.ts`** — `printJson` / `isJsonFormat`, + for reporting the number in the existing document rather than a new one. +- **`node:zlib`'s `gzipSync`** — the compressed size Cloudflare's limit is + stated against. No dependency needed; this is a stdlib call over a file the + build already wrote. +- **`scripts/check-*.js`** — the established shape for a repo gate run from CI + (and, for some, from `postinstall`). A size gate follows that shape. +- **`tests/vis-templates`** — the existing template-build harness. + +## 3. The behavioural contract to preserve + +- `lunora build` and `lunora deploy` keep their exit codes: measuring is + reporting, and **a user-facing size check warns, never fails**. A framework + that refuses to deploy a bundle Cloudflare would have accepted is worse than + the problem it prevents. +- The repo-internal CI gate is the opposite: it **fails**, because a regression + there is ours to fix before it reaches a user. +- `--format json` stays one document; the size lands as a field inside it. +- No new dependency (`node:zlib` is stdlib). + +## 4. Design decisions + +- **Two different mechanisms, deliberately.** A _user-facing warning_ in + `build`/`deploy` (informational, near the real limit) and a _repo CI gate_ + over a fixed reference app (fails on regression). Chosen over one shared + threshold: the user's number depends on their app and their plan; ours is a + regression signal about the framework's own floor. +- **The CI gate measures a fixed reference app, not the playground.** + `apps/playground` accumulates feature demos (it depends on `@lunora/studio`, + `auth-ui`, `db`, `queue`, `workflow`, …), so its size tracks demo churn rather + than framework weight. A `templates/*` starter is the honest baseline — it is + what a new user actually deploys. +- **Gzip, not raw or brotli.** Cloudflare states its limit against the + compressed script; gzip is the conservative, reproducible choice from stdlib. + Recorded alongside the raw number so a compression-ratio change is visible. +- **The ceiling is a committed number with headroom, not a computed + percentage.** A fixture file (like `api-snapshots/`) holding the current + measured size plus an explicit allowance. Chosen over "fail if it grew at + all" (every legitimate feature turns the gate red) and over a percentage of + Cloudflare's limit (which changes under us). +- **Phase 0 is measurement, and the ceiling is not written until it produces a + number.** Guessing a budget from package `dist/` sizes would be wrong for the + reason in §0. + +## 5. Workstreams + +**S — measure in `lunora build`.** After the bundle is written, sum the emitted +JS (and any inlined assets wrangler wrote) raw and gzipped; add +`bundle: { rawBytes, gzipBytes, files }` to the `--format json` result and one +line to the pretty output. + +**Done.** `packages/cli/src/commands/build/bundle-size.ts` (`measureBundle`) + +`handler.ts`. Sourcemaps, `bundle-meta.json` and wrangler's README are excluded +— counting them would report ~3× the real weight. An unmeasurable out-dir +returns `undefined` and warns rather than reporting 0 bytes, since a silent 0 is +what a changed wrangler layout looks like and it would read as the healthiest +possible result. + +One wrinkle worth recording: `--format json` used to be deploy's document, and +deploy prints it before `build` regains control, so there was nowhere to put the +field. `build` now owns its own document (validating `--format` itself, routing +human output to stderr, and forcing spawned stdout to stderr so the document +stays alone on stdout). `packages/cli/src/commands/deploy/*` is untouched. + +**S — user-facing warning.** ~~When `gzipBytes` crosses a "getting close" +threshold, warn…~~ + +**Dropped — the §8 STOP condition fired.** A starter Worker is 412.9 KiB +gzipped: 13.4% of the Free plan's ceiling, 4.0% of Paid's. A threshold warning +would be a speculative alarm nobody would ever legitimately see, and picking its +trigger point would be inventing a number to defend. `build` reports the size +unconditionally instead (reporting is not warning), and the docs say what the +limit is and which levers to pull. The CI gate is the whole value here. + +**M — repo CI gate.** `scripts/check-worker-size.js`: build a reference template +worker, measure it, compare against the committed ceiling fixture, fail with the +delta and the previous value on regression. Wire it as its own job in +`test.yml` (**not** into the root `postinstall` — a failing postinstall gate +turns every CI job red in its setup step and the cause is invisible in the job +that reports the failure). + +**Done.** `scripts/check-worker-size.js` + the `worker-size.json` fixture +(422,840 B baseline, 51,200 B allowance → 462.9 KiB ceiling). It scaffolds +`templates/standalone` into a temp dir, links the workspace `dist/` directories +(a symlink farm, not an install — `pnpm install` would try to fetch +`lunorash@^0.0.0` from the registry), and reads the number back out of +`lunora build --format json`, so the gate and the user see one measurement from +one code path. Wired as the `worker-size` job in `test.yml` and added to +`test-required-check`'s `needs`; **not** in `postinstall`. Verified failing on a +hand-lowered fixture and passing on the committed one. + +§8's "reuse `test:templates`' build" mitigation does not apply: that harness +packs tarballs and runs each template's `build` script, and `standalone` has no +`build` script — it is explicitly skipped there, so no bundle exists to reuse. +The gate builds its own (~40 s after the package build) and the job is +path-gated on `packages`/`templates` changes. + +**S — an accept path.** `pnpm run worker-size:update` (mirroring +`api:update`) rewrites the fixture after an intentional increase, so the gate is +a conversation in review rather than an obstacle. + +**Done.** `pnpm run worker-size:check` / `worker-size:update`; the update path +prints the signed delta against the previous baseline. + +**S — docs.** A short section in the deployment docs: what the limit is, how to +read the number `lunora build` prints, what to do when it is close. + +**Done.** "Worker size" in `apps/docs/src/content/docs/deployment.mdx`. + +## 6. Platform parity + +Not applicable to the `ctx.*` matrix — no runtime surface, no binding. The +measurement is Cloudflare-specific by nature (it is a Workers script-size +limit), so the check must live behind the Cloudflare build path and must not +present itself as a target-neutral gate; `@lunora/platform-node` has no +equivalent ceiling and must not inherit a spurious warning. + +## 7. Phasing & ordering + +| Phase | Work | Gate | +| ----- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | +| 0 | Measure and record | The actual raw + gzip numbers for a `templates/*` worker, written into this plan before the ceiling is chosen | +| 1 | `bundle` in the build result | Test: `lunora build --format json` yields `bundle.gzipBytes` > 0 against a fixture out-dir | +| 2 | User-facing warning | Test: a stubbed oversize measurement warns and still exits 0 | +| 3 | CI gate + fixture | The job fails when the fixture is lowered by hand, and passes on `alpha` unchanged | +| 4 | `worker-size:update` + docs | Running it after an intentional bump turns the gate green; `pnpm run lint:prettier` clean | + +## 8. Risks & STOP conditions + +- **STOP** if phase 0 shows a starter worker sitting comfortably under the limit + with no upward trend — then the CI gate is the whole value and the user-facing + warning is speculative; ship the gate, drop the warning, and say so here. +- **Risk:** the ceiling fixture becomes a rubber stamp that every PR bumps. + Mitigate: the update script prints the delta and the gate's failure message + names the previous value, so the increase is visible in review rather than + buried in a fixture diff. +- **Risk:** wrangler's out-dir layout changes and the measurement silently sums + the wrong files (or zero). Mitigate: assert a non-zero size and a minimum + file count; a measurement of 0 must fail loudly, never pass. +- **Risk:** the reference-template build makes CI meaningfully slower. + Mitigate: reuse `test:templates`' existing build if it already produces the + artifact; only build separately if it does not. + +## 9. Open questions (answered) + +1. **What is Cloudflare's current compressed-script limit per plan tier?** + 3 MB on Workers Free, 10 MB on Workers Paid, stated "after compression + (gzip)" — read from + on 2026-08-08. + The same page pairs it with a 1-second startup CPU budget for top-level code, + which a large bundle can breach before the size limit does (`Script startup +exceeded CPU time limit`, error 10021). Both numbers live in the docs section + and the fixture comment; neither is repeated in code as a threshold. +2. **Which template is the reference?** `standalone` — it is the smallest + starter and the only one whose deployed Worker is Lunora and nothing else + (the meta-framework templates bundle their own SSR runtime, which would make + the number track Next/Nuxt rather than Lunora). +3. **Does the deployed Worker ever include `@lunora/studio` assets?** No. The + reference bundle's esbuild metafile has **zero** inputs from + `packages/studio` — the studio is only ever loaded by the dev-time hosts + (`@lunora/vite`'s studio plugin, `@lunora/cli`'s studio server), which + `require.resolve` + `readFileSync` its prebuilt assets on Node. Nothing on + the Worker path imports it. Two notes, neither a bug: `apps/playground` + declares `@lunora/studio` under `dependencies` while every template puts it + in `devDependencies` (it is dev tooling — the playground entry is the odd one + out, and it costs install weight, not bundle weight); and the templates + carrying it at all is what lets `lunora dev` serve the studio offline. +4. **Should `--emit-bindings` output carry the size too?** No. `--emit-bindings` + answers "what must be provisioned", which is a different document with a + different consumer (an IaC program). The size is already in the + `--format json` result an external deployer reads anyway, and duplicating it + into a second file creates two things to keep honest. Revisit only if a real + deployer asks. +5. **Is a per-add-on breakdown feasible from wrangler's output?** Yes, and it + needs no bundler-level report: `lunora deploy --outdir` already passes + `--metafile`, so `/bundle-meta.json` holds esbuild's per-input + `bytesInOutput`. Grouping those paths by `packages//` produced the phase-0 + table above in a few lines. Not built here — nothing consumes it yet, and + `lunora analyze` already covers the "what is heavy?" question interactively. + +## 10. Follow-ups this work surfaced + +- **`compromise` (606 KiB raw) is in every Worker** via `@visulima/redact` ← + `@lunora/observability`. See phase 0. The single largest lever on this number. +- **`lunora analyze` over-reports.** Its `totalBytes` walks the whole out-dir, + so it counts the sourcemap and the metafile as bundle weight — for the + reference app that is 1.6 MiB reported as ~6.9 MiB. `measureBundle` is the + correct filter; `analyze` should use it (left alone here to keep this change + inside `commands/build/*`). diff --git a/scripts/check-worker-size.js b/scripts/check-worker-size.js new file mode 100644 index 0000000000..6ccb41264d --- /dev/null +++ b/scripts/check-worker-size.js @@ -0,0 +1,169 @@ +#!/usr/bin/env node +/** + * Weighs the Worker a new user actually deploys, and fails when it grows past a + * committed ceiling. + * + * Nothing else in this repo measures bytes. `dist:check` audits whether a built + * `dist/` is production-clean, and per-package sizes answer the wrong question + * entirely: every entrypoint is a re-export shim (`packages/runtime/dist/index.mjs` + * is 2 KiB while its code sits in `dist/packem_shared/`). Only the bundled Worker + * is a real number, and the first person to learn this framework's floor should + * not be a user whose deploy Cloudflare rejected for a dependency added weeks ago. + * + * The reference app is `templates/standalone` — the smallest starter, and what a + * new project is. Deliberately NOT `apps/playground`, which accumulates feature + * demos (auth-ui, db, queue, workflow, studio…) and would track demo churn rather + * than the framework's own weight. + * + * The measurement comes from `lunora build --format json`, so the gate and the + * number a user sees are produced by the same code path. + * + * Usage: + * pnpm run worker-size:check # fail when the bundle exceeds the ceiling + * pnpm run worker-size:update # re-baseline after an intentional increase + * + * PREREQUISITE: `pnpm run build:packages:prod`. The reference app resolves the + * workspace `dist/` directories, and users install the PRODUCTION build — a plain + * `build:packages` measures a development bundle roughly 25% heavier, which is a + * number nobody deploys. + * + * Runs as its own CI job, NOT from `postinstall`: a failing postinstall gate turns + * every job red in its setup step, and the cause is invisible in the job that + * reports it. + */ +import { execFileSync } from "node:child_process"; +import { cpSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const rootDir = join(dirname(fileURLToPath(import.meta.url)), ".."); +const fixturePath = join(rootDir, "worker-size.json"); +const update = process.argv.includes("--update"); + +const fail = (message) => { + process.stderr.write(`${message}\n`); + process.exit(1); +}; + +const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`; + +/** + * Materialize the reference template as a project whose dependencies resolve. + * + * A symlink farm rather than an install: every `@lunora/*` dependency is a + * workspace package that already has its own `node_modules`, so linking the + * package directories is enough for esbuild to resolve the whole graph — and it + * keeps the gate offline and quick. `pnpm install` here would try to fetch + * `lunorash@^0.0.0` from the registry instead. + */ +const materialize = (appDirectory) => { + cpSync(join(rootDir, "templates", "standalone"), appDirectory, { recursive: true }); + + for (const file of ["package.json", "wrangler.jsonc"]) { + const path = join(appDirectory, file); + + writeFileSync(path, readFileSync(path, "utf8").replaceAll("{{name}}", "lunora-worker-size-reference"), "utf8"); + } + + // The CLI runs wrangler through the project's package manager. A pnpm-shaped + // project makes `pnpm exec` verify the (absent) lockfile first and abort; + // `npx --` runs the linked binary directly, so the reference app declares + // itself npm-shaped with an empty lockfile. + writeFileSync(join(appDirectory, "package-lock.json"), '{ "lockfileVersion": 3 }\n', "utf8"); + + const modules = join(appDirectory, "node_modules"); + + mkdirSync(join(modules, "@lunora"), { recursive: true }); + + for (const directory of readdirSync(join(rootDir, "packages"))) { + const source = join(rootDir, "packages", directory); + const manifest = join(source, "package.json"); + + if (existsSync(manifest)) { + symlinkSync(source, join(modules, JSON.parse(readFileSync(manifest, "utf8")).name)); + } + } + + // wrangler and the Workers types come from a workspace package that already + // depends on them, so the gate needs no dependencies of its own. + const host = join(rootDir, "packages", "runtime", "node_modules"); + + symlinkSync(join(host, "wrangler"), join(modules, "wrangler")); + symlinkSync(join(host, "@cloudflare"), join(modules, "@cloudflare")); + mkdirSync(join(modules, ".bin"), { recursive: true }); + symlinkSync(join(host, ".bin", "wrangler"), join(modules, ".bin", "wrangler")); +}; + +/** Build the reference app and return `lunora build`'s `bundle` measurement. */ +const measure = (appDirectory) => { + const cli = join(rootDir, "packages", "cli", "dist", "bin.mjs"); + + if (!existsSync(cli)) { + fail(`check-worker-size: ${cli} is missing — run \`pnpm run build:packages:prod\` first.`); + } + + let stdout; + + try { + stdout = execFileSync(process.execPath, [cli, "build", "--format", "json", "--out-dir", "out"], { + cwd: appDirectory, + encoding: "utf8", + env: { ...process.env, CI: "1", WRANGLER_SEND_METRICS: "false" }, + maxBuffer: 64 * 1024 * 1024, + stdio: ["ignore", "pipe", "inherit"], + }); + } catch { + fail("check-worker-size: `lunora build` failed on the reference template (its output is above)."); + } + + const result = JSON.parse(stdout); + + // A measurement of zero must never pass as a healthy result: that is exactly + // what a changed wrangler out-dir layout would look like. + if (!result.bundle || result.bundle.files < 1 || result.bundle.gzipBytes < 1) { + fail("check-worker-size: `lunora build` reported no bundle — the wrangler out-dir layout may have changed."); + } + + return result.bundle; +}; + +const baseline = JSON.parse(readFileSync(fixturePath, "utf8")); +const appDirectory = join(mkdtempSync(join(tmpdir(), "lunora-worker-size-")), "app"); + +let bundle; + +try { + materialize(appDirectory); + bundle = measure(appDirectory); +} finally { + rmSync(join(appDirectory, ".."), { force: true, recursive: true }); +} + +const ceiling = baseline.gzipBytes + baseline.allowanceBytes; + +process.stdout.write( + `worker size (${baseline.template}): ${kib(bundle.rawBytes)} raw, ${kib(bundle.gzipBytes)} gzipped — ` + + `baseline ${kib(baseline.gzipBytes)}, ceiling ${kib(ceiling)}\n`, +); + +if (update) { + const delta = bundle.gzipBytes - baseline.gzipBytes; + + writeFileSync(fixturePath, `${JSON.stringify({ ...baseline, gzipBytes: bundle.gzipBytes, rawBytes: bundle.rawBytes }, undefined, 4)}\n`, "utf8"); + process.stdout.write(`worker-size.json updated: ${delta >= 0 ? "+" : ""}${kib(delta)} gzipped against the previous baseline.\n`); + + process.exit(0); +} + +if (bundle.gzipBytes > ceiling) { + fail( + `The reference Worker (templates/${baseline.template}) grew past its ceiling.\n` + + ` now: ${kib(bundle.gzipBytes)} gzipped (${kib(bundle.rawBytes)} raw)\n` + + ` baseline: ${kib(baseline.gzipBytes)} gzipped, + ${kib(baseline.allowanceBytes)} allowance = ${kib(ceiling)}\n` + + ` delta: +${kib(bundle.gzipBytes - baseline.gzipBytes)} against the baseline\n` + + `Every Lunora app carries this. Find what arrived (\`lunora analyze\` prints the heaviest modules),\n` + + `and if the growth is intended, accept it with \`pnpm run worker-size:update\` so the increase is\n` + + `visible in review rather than buried.`, + ); +} diff --git a/worker-size.json b/worker-size.json new file mode 100644 index 0000000000..da8ce2078f --- /dev/null +++ b/worker-size.json @@ -0,0 +1,7 @@ +{ + "$comment": "Committed size baseline for the Worker a `templates/standalone` app deploys, measured by scripts/check-worker-size.js against a production build. Cloudflare's Worker script limit is 3 MB gzipped on the Free plan and 10 MB on Paid, so this is a framework-weight regression signal, not a proximity alarm. `pnpm run worker-size:update` re-baselines an intentional increase.", + "template": "standalone", + "gzipBytes": 422840, + "rawBytes": 1725313, + "allowanceBytes": 51200 +} From e26f846dc2fc760841f01b2dcaae755cd10cbe22 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 02:28:40 +0200 Subject: [PATCH 4/8] feat(mcp): add observability read tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An agent could call a deployment but not see what happened: no logs, no grouped errors, no advisories, no query insights, no migration status. Every one already existed as an admin RPC backing the Studio and the CLI; only the MCP surface over them was missing. Adds five read tools — lunora_get_logs, lunora_get_issues, lunora_get_advisories, lunora_get_query_insights, lunora_get_migration_status — as a third tier. They are read-only but PRIVILEGED (production log lines and error messages reach the model's provider), so they are exposed only when an admin token resolved: omitted from ListTools without one, and refused at dispatch, the same omit-don't-refuse rule the write gate uses. The tier is independent of --allow-writes, which is about changing data, not reading operational data. In the composed local server the advertised list is a build-time snapshot of the resolved deployment's token (fail-closed), while dispatch re-checks the live one. Op paths come from ADMIN_FUNCTIONS rather than hand-written "__lunora_admin__:" literals, so a renamed op cannot ship a 404 to one consumer and not another; @lunora/shard-engine becomes a dependency for that constant. The reads go through client.query, since these ops ride the ordinary /_lunora/rpc envelope — the bearer, error envelope and wire decode are all the client's existing behaviour. Tool results now carry structuredContent described by each tool's outputSchema, alongside the existing text block so clients on an MCP revision older than 2025-06-18 are unaffected. structuredContent is serialized by the transport, so it goes through the same bigint -> string and bytes -> base64 mapping the text block already used; an unmapped bigint there would fail the whole response rather than one field. BREAKING CHANGE: toolDefinitions and callTool take an additional hasAdminToken argument (defaulting to false, so existing callers keep the read-only surface they had). Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- api-snapshots/mcp.api.md | 14 +- packages/cli/src/commands/mcp/index.ts | 8 +- packages/mcp/README.md | 42 ++- packages/mcp/__tests__/local.test.ts | 26 ++ .../mcp/__tests__/observability-tools.test.ts | 323 ++++++++++++++++++ packages/mcp/__tests__/server.test.ts | 61 ++++ packages/mcp/__tests__/tools.test.ts | 2 +- packages/mcp/package.json | 1 + packages/mcp/src/index.ts | 10 +- packages/mcp/src/local.ts | 14 +- packages/mcp/src/observability-tools.ts | 319 +++++++++++++++++ packages/mcp/src/server.ts | 13 +- packages/mcp/src/tool-result.ts | 98 ++++++ packages/mcp/src/tool-types.ts | 16 + packages/mcp/src/tools.ts | 119 +++---- plans/309-mcp-observability-tools.md | 296 ++++++++++++++++ pnpm-lock.yaml | 3 + 17 files changed, 1270 insertions(+), 95 deletions(-) create mode 100644 packages/mcp/__tests__/observability-tools.test.ts create mode 100644 packages/mcp/src/observability-tools.ts create mode 100644 packages/mcp/src/tool-result.ts create mode 100644 plans/309-mcp-observability-tools.md diff --git a/api-snapshots/mcp.api.md b/api-snapshots/mcp.api.md index 96546ec3d2..c7a62ee09c 100644 --- a/api-snapshots/mcp.api.md +++ b/api-snapshots/mcp.api.md @@ -125,6 +125,12 @@ interface McpTool { const NO_DEPLOYMENT_MESSAGE = "no Lunora dev server is running for this project — start one with `lunora dev`, then call this tool again (call lunora_dev_status to check)."; ``` +### `OBSERVABILITY_TOOL_DEFINITIONS` (const) + +```ts +const OBSERVABILITY_TOOL_DEFINITIONS: ReadonlyArray; +``` + ### `PaidMcpChargeConfig` (type) ```ts @@ -186,6 +192,7 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + outputSchema?: ToolInputSchema; } ``` @@ -214,6 +221,7 @@ interface ToolResult { type: "text"; }[]; isError?: boolean; + structuredContent?: Record; } ``` @@ -238,7 +246,7 @@ const callAgentTool: (client: LunoraClient, name: string, input: Record, allowWrites?: boolean) => Promise; +const callTool: (client: LunoraClient, name: string, input: Record, allowWrites?: boolean, hasAdminToken?: boolean) => Promise; ``` ### `connectLocalStdio` (const) @@ -304,7 +312,7 @@ const serveStateless: (server: Server, request: Request, options?: HandleRequest ### `toolDefinitions` (const) ```ts -const toolDefinitions: (allowWrites: boolean) => ReadonlyArray; +const toolDefinitions: (allowWrites: boolean, hasAdminToken?: boolean) => ReadonlyArray; ``` ## `@lunora/mcp/docs` @@ -470,6 +478,7 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + outputSchema?: ToolInputSchema; } ``` @@ -492,6 +501,7 @@ interface ToolResult { type: "text"; }[]; isError?: boolean; + structuredContent?: Record; } ``` diff --git a/packages/cli/src/commands/mcp/index.ts b/packages/cli/src/commands/mcp/index.ts index c14c60307e..87b8b87f7a 100644 --- a/packages/cli/src/commands/mcp/index.ts +++ b/packages/cli/src/commands/mcp/index.ts @@ -20,6 +20,7 @@ const mcpCommand: Command = { ["lunora mcp uninstall --print", "Show what would be removed, without removing it"], ["lunora mcp serve", "Run the stdio MCP server (this is what your editor spawns)"], ["lunora mcp serve --allow-writes", "Also expose the mutation/action tools"], + ["lunora mcp serve --token $LUNORA_ADMIN_TOKEN", "Expose the observability tools (logs, issues, advisories, insights, migrations) too"], ], group: "Develop", loader: () => @@ -43,7 +44,12 @@ const mcpCommand: Command = { { description: "serve: skip the documentation tools", name: "no-docs", type: Boolean }, { description: "Docs site origin backing the documentation tools (default https://lunora.sh)", name: "docs-url", type: String }, { description: "serve: deployment URL to expose (default: the running dev server)", name: "url", type: String }, - { description: "serve: bearer token (default: LUNORA_ADMIN_TOKEN from the environment or .dev.vars)", name: "token", type: String }, + { + description: + "serve: bearer token (default: LUNORA_ADMIN_TOKEN from the environment or .dev.vars). Also gates the observability tools (logs, issues, advisories, query insights, migration status) — without a token they are not exposed at all", + name: "token", + type: String, + }, ], }; diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 18a27cc388..64c045efca 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -45,16 +45,21 @@ Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-sa ## Tools -| Tool | Description | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds. | -| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts. | -| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object. | -| `lunora_run_query` | Run a query and return its result. Read-only. | -| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care. | -| `lunora_run_action` | Run an action and return its result. May call external services. | -| `agent_` | Start a durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) run and await its answer. One tool per exposed agent. Requires agents enabled. | -| `lunora_agent_status` | Poll a running agent by `threadKey` and return its answer once finished. Requires agents enabled. | +| Tool | Description | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `lunora_list_functions` | List the deployment's public functions (queries, mutations, actions) with their kinds. | +| `lunora_list_tables` | List the deployment's `.global()` tables with their row counts. | +| `lunora_get_function_schema` | Return a function's argument descriptors and kind by path, so a caller can construct a valid arguments object. | +| `lunora_run_query` | Run a query and return its result. Read-only. | +| `lunora_run_mutation` | Run a mutation and return its result. Writes data — use with care. | +| `lunora_run_action` | Run an action and return its result. May call external services. | +| `lunora_get_logs` | Read the deployment's recent log entries (newest first). Requires an admin token. | +| `lunora_get_issues` | List errors grouped into Issues by fingerprint, with counts and triage status. Requires an admin token. | +| `lunora_get_advisories` | List the deployment's schema/query advisories. Requires an admin token. | +| `lunora_get_query_insights` | Per-statement execution counts and latency over a recent window. Requires an admin token. | +| `lunora_get_migration_status` | Which migrations are applied and which are pending. Requires an admin token. | +| `agent_` | Start a durable [`@lunora/agent`](https://www.npmjs.com/package/@lunora/agent) run and await its answer. One tool per exposed agent. Requires agents enabled. | +| `lunora_agent_status` | Poll a running agent by `threadKey` and return its answer once finished. Requires agents enabled. | ### Recommended agent flow @@ -65,6 +70,23 @@ Part of the [Lunora](https://github.com/anolilab/lunora) framework — a type-sa → call the function with a well-formed arguments object ``` +### Observability tools (privileged) + +The five `lunora_get_*` observability tools are read-only, but they surface the +deployment's **operational data** — log lines, request metadata, and grouped +error messages, all of which may contain user data, and all of which land in the +model's context (and therefore at its provider). They are exposed **only when an +admin token resolved**: without one they are omitted from `ListTools` entirely +and refused at dispatch, the same omit-don't-refuse rule the write tools use. +They are independent of `--allow-writes`, which is about changing data, not +reading operational data. + +They return `structuredContent` alongside the usual text block, described by each +tool's `outputSchema` (MCP revision `2025-06-18` and later; older clients keep +reading the text block). Each takes a `limit` clamped server-side, and an +optional `shardKey` — on a `.shardBy()`-partitioned deployment these reads are +**per-shard**, not deployment-wide. + `lunora_get_function_schema` returns a JSON object with three fields: - `path` — the function path (e.g. `"messages:send"`) diff --git a/packages/mcp/__tests__/local.test.ts b/packages/mcp/__tests__/local.test.ts index 7df6fb9abc..52a08047ea 100644 --- a/packages/mcp/__tests__/local.test.ts +++ b/packages/mcp/__tests__/local.test.ts @@ -106,6 +106,32 @@ describe("localTools", () => { expect(namesOf(localTools({ allowWrites: true, deployment: () => undefined, docs: false }))).toContain("lunora_run_mutation"); }); + it("hides the observability tools unless the resolved deployment carries an admin token", () => { + expect.assertions(3); + + expect(namesOf(localTools({ deployment: () => undefined, docs: false }))).not.toContain("lunora_get_logs"); + expect(namesOf(localTools({ deployment: { url: "https://worker.example" }, docs: false }))).not.toContain("lunora_get_logs"); + expect(namesOf(localTools({ deployment: { token: "admin-token", url: "https://worker.example" }, docs: false }))).toContain("lunora_get_logs"); + }); + + it("refuses an observability tool at dispatch when the resolved deployment has no token", async () => { + expect.assertions(2); + + const { asFetch, urls } = stubFetch(); + // Listed because a token was present at build time, then withdrawn — the + // dispatch-side check is what still refuses the call. + let deployment: LocalDeployment | undefined = { token: "admin-token", url: "https://worker.example" }; + const tools = localTools({ deployment: () => deployment, docs: false, fetch: asFetch }); + const logs = tools.find((tool) => tool.definition.name === "lunora_get_logs"); + + deployment = { url: "https://worker.example" }; + + const result = await logs?.handle({}); + + expect(result?.isError).toBe(true); + expect(urls).toStrictEqual([]); + }); + it("tells the caller to start the dev server when a deployment tool is used with none running", async () => { expect.assertions(2); diff --git a/packages/mcp/__tests__/observability-tools.test.ts b/packages/mcp/__tests__/observability-tools.test.ts new file mode 100644 index 0000000000..fc98308c1f --- /dev/null +++ b/packages/mcp/__tests__/observability-tools.test.ts @@ -0,0 +1,323 @@ +import type { FunctionDescriptor, LunoraClient } from "@lunora/client"; +import { ADMIN_FUNCTIONS } from "@lunora/shard-engine"; +import { describe, expect, it, vi } from "vitest"; + +import { DEFAULT_LIMIT, MAX_LIMIT, OBSERVABILITY_TOOL_DEFINITIONS } from "../src/observability-tools"; +import { callTool, toolDefinitions } from "../src/tools"; + +const OBSERVABILITY_NAMES = ["lunora_get_logs", "lunora_get_issues", "lunora_get_advisories", "lunora_get_query_insights", "lunora_get_migration_status"]; + +/** A log ring entry, as `__lunora_admin__:getLogs` returns it (newest first). */ +const logEntry = (index: number, level: string): Record => { + return { level, message: `line ${index.toString()}`, timestamp: 1000 + index }; +}; + +/** + * Mock client whose `query` answers per admin op path, so a test asserts the + * op the tool chose rather than a positional call index. + */ +const mockClient = ( + results: Record = {}, +): { + asClient: LunoraClient; + query: ReturnType; +} => { + const query = vi.fn<(reference: { __lunoraRef: string }) => Promise>(async (reference) => results[reference.__lunoraRef] ?? {}); + const listFunctions = vi.fn<() => Promise>(async () => []); + + return { asClient: { listFunctions, query } as unknown as LunoraClient, query }; +}; + +describe("observability tool definitions", () => { + it("declares five tools, each read-only and carrying an outputSchema", () => { + expect.assertions(3); + + expect(OBSERVABILITY_TOOL_DEFINITIONS.map((tool) => tool.name)).toStrictEqual(OBSERVABILITY_NAMES); + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.annotations?.readOnlyHint === true)).toBe(true); + // The MCP spec requires an output schema to be an object at the root. + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.outputSchema?.type === "object")).toBe(true); + }); + + it("gives every tool a title and a description that says when to call it", () => { + expect.assertions(2); + + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => (tool.annotations?.title ?? "").length > 0)).toBe(true); + expect(OBSERVABILITY_TOOL_DEFINITIONS.every((tool) => tool.description.length > 40)).toBe(true); + }); +}); + +describe("token-tier gating", () => { + it("omits the observability tools from the advertised list when no admin token resolved", () => { + expect.assertions(2); + + const names = toolDefinitions(false).map((tool) => tool.name); + + expect(names).toStrictEqual(["lunora_list_functions", "lunora_list_tables", "lunora_get_function_schema", "lunora_run_query"]); + expect(names.some((name) => OBSERVABILITY_NAMES.includes(name))).toBe(false); + }); + + it("advertises them once a token resolved, without disturbing the write tier", () => { + expect.assertions(2); + + expect(toolDefinitions(false, true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + ...OBSERVABILITY_NAMES, + ]); + expect(toolDefinitions(true, true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + ...OBSERVABILITY_NAMES, + "lunora_run_mutation", + "lunora_run_action", + ]); + }); + + it("keeps the token tier independent of the write tier", () => { + expect.assertions(1); + + // `--allow-writes` must not smuggle in the privileged reads. + expect(toolDefinitions(true).map((tool) => tool.name)).toStrictEqual([ + "lunora_list_functions", + "lunora_list_tables", + "lunora_get_function_schema", + "lunora_run_query", + "lunora_run_mutation", + "lunora_run_action", + ]); + }); + + it.each(OBSERVABILITY_NAMES)("refuses %s at dispatch when no admin token resolved, without touching the client", async (name) => { + expect.assertions(3); + + const mock = mockClient(); + const result = await callTool(mock.asClient, name, {}); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("admin token"); + expect(mock.query).not.toHaveBeenCalled(); + }); + + it("refuses at dispatch even when writes are enabled but no token resolved", async () => { + expect.assertions(2); + + const mock = mockClient(); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, true); + + expect(result.isError).toBe(true); + expect(mock.query).not.toHaveBeenCalled(); + }); +}); + +describe("lunora_get_logs", () => { + it("reads the getLogs admin op and returns the newest entries", async () => { + expect.assertions(4); + + const entries = [logEntry(1, "error"), logEntry(2, "info"), logEntry(3, "warn")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { limit: 2 }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getLogs }, {}, {}); + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toStrictEqual({ entries: [entries[0], entries[1]], total: 3 }); + // The text block stays, for a client on a pre-2025-06-18 revision. + expect(JSON.parse(result.content[0]!.text)).toStrictEqual(result.structuredContent); + }); + + it("filters by level before limiting, and reports the pre-limit total", async () => { + expect.assertions(2); + + const entries = [logEntry(1, "info"), logEntry(2, "error"), logEntry(3, "error")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { level: "error", limit: 1 }, false, true); + + expect(result.structuredContent).toStrictEqual({ entries: [entries[1]], total: 2 }); + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(1); + }); + + it("ignores an unrecognized level rather than filtering everything out", async () => { + expect.assertions(1); + + const entries = [logEntry(1, "info")]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { level: "LOUD" }, false, true); + + expect(result.structuredContent).toStrictEqual({ entries, total: 1 }); + }); + + it("forwards a shardKey, since these reads are per-shard", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: [] } }); + + await callTool(mock.asClient, "lunora_get_logs", { shardKey: "room-1" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getLogs }, {}, { shardKey: "room-1" }); + }); + + it("survives a deployment that answers with no entries array", async () => { + expect.assertions(2); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: {} }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBeUndefined(); + expect(result.structuredContent).toStrictEqual({ entries: [], total: 0 }); + }); + + it("maps a bigint / bytes leaf so structuredContent survives the transport's JSON.stringify", async () => { + expect.assertions(3); + + // `LunoraClient` decodes every response, so a `v.int64()` field logged + // into `fields` reaches the tool as a real bigint. Left unmapped it + // would throw when the transport serializes `structuredContent`. + const entries = [{ fields: { blob: new Uint8Array([1, 2, 3]), id: 9_007_199_254_740_993n }, level: "info", message: "m", timestamp: 1 }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBeUndefined(); + expect(() => JSON.stringify(result.structuredContent)).not.toThrow(); + expect(result.structuredContent).toStrictEqual({ + entries: [{ fields: { blob: "AQID", id: "9007199254740993" }, level: "info", message: "m", timestamp: 1 }], + total: 1, + }); + }); +}); + +describe("limit clamping", () => { + const manyEntries = Array.from({ length: MAX_LIMIT + 25 }, (_, index) => logEntry(index, "info")); + + it("defaults a missing limit", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: manyEntries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(DEFAULT_LIMIT); + }); + + it("clamps a limit above the ceiling", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getLogs]: { entries: manyEntries } }); + const result = await callTool(mock.asClient, "lunora_get_logs", { limit: 100_000 }, false, true); + + expect((result.structuredContent as { entries: unknown[] }).entries).toHaveLength(MAX_LIMIT); + }); + + it("clamps a zero/negative limit up to one", async () => { + expect.assertions(2); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { limit: 0 }, false, true); + await callTool(mock.asClient, "lunora_get_issues", { limit: -5 }, false, true); + + expect(mock.query).toHaveBeenNthCalledWith(1, { __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: 1 }, {}); + expect(mock.query).toHaveBeenNthCalledWith(2, { __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: 1 }, {}); + }); + + it("falls back to the default for a non-numeric limit", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { limit: "lots" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: DEFAULT_LIMIT }, {}); + }); +}); + +describe("lunora_get_issues", () => { + it("pushes limit/status/functionPathPrefix down to the RPC so grouping sees the right rows", async () => { + expect.assertions(2); + + const issues = [{ count: 3, hash: "abc", title: "boom" }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues } }); + const result = await callTool(mock.asClient, "lunora_get_issues", { functionPathPrefix: "messages:", limit: 10, status: "open" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { functionPathPrefix: "messages:", limit: 10, status: "open" }, {}); + expect(result.structuredContent).toStrictEqual({ issues }); + }); + + it("drops an unrecognized status instead of forwarding it", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getIssues]: { issues: [] } }); + + await callTool(mock.asClient, "lunora_get_issues", { status: "on-fire" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getIssues }, { limit: DEFAULT_LIMIT }, {}); + }); +}); + +describe("the remaining reads", () => { + it("lunora_get_advisories limits and reports the total", async () => { + expect.assertions(2); + + const advisories = [{ id: "a" }, { id: "b" }, { id: "c" }]; + const mock = mockClient({ [ADMIN_FUNCTIONS.getAdvisories]: { advisories } }); + const result = await callTool(mock.asClient, "lunora_get_advisories", { limit: 2 }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getAdvisories }, {}, {}); + expect(result.structuredContent).toStrictEqual({ advisories: [{ id: "a" }, { id: "b" }], total: 3 }); + }); + + it("lunora_get_query_insights forwards a known range and passes the series through", async () => { + expect.assertions(2); + + const insights = { buckets: [{ bucketMs: 1 }], capped: true, entries: [{ sql: "select 1" }], trackedStatements: 42 }; + const mock = mockClient({ [ADMIN_FUNCTIONS.getQueryInsights]: insights }); + const result = await callTool(mock.asClient, "lunora_get_query_insights", { range: "1h" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getQueryInsights }, { range: "1h" }, {}); + expect(result.structuredContent).toStrictEqual({ + buckets: insights.buckets, + capped: true, + entries: insights.entries, + total: 1, + trackedStatements: 42, + }); + }); + + it("lunora_get_query_insights drops an unknown range so the RPC applies its own default", async () => { + expect.assertions(1); + + const mock = mockClient({ [ADMIN_FUNCTIONS.getQueryInsights]: { buckets: [], entries: [] } }); + + await callTool(mock.asClient, "lunora_get_query_insights", { range: "forever" }, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.getQueryInsights }, {}, {}); + }); + + it("lunora_get_migration_status returns every migration, untruncated", async () => { + expect.assertions(2); + + const migrations = Array.from({ length: DEFAULT_LIMIT + 10 }, (_, index) => { + return { applied: index % 2 === 0, id: index.toString() }; + }); + const mock = mockClient({ [ADMIN_FUNCTIONS.migrationStatus]: { migrations } }); + const result = await callTool(mock.asClient, "lunora_get_migration_status", {}, false, true); + + expect(mock.query).toHaveBeenCalledWith({ __lunoraRef: ADMIN_FUNCTIONS.migrationStatus }, {}, {}); + // Truncating this list would hide exactly the pending migration asked about. + expect((result.structuredContent as { migrations: unknown[] }).migrations).toHaveLength(migrations.length); + }); + + it("surfaces a failed admin read as an error result rather than rejecting", async () => { + expect.assertions(2); + + const mock = mockClient(); + + mock.query.mockRejectedValueOnce(new Error("ADMIN_FORBIDDEN")); + + const result = await callTool(mock.asClient, "lunora_get_logs", {}, false, true); + + expect(result.isError).toBe(true); + expect(result.content[0]!.text).toContain("ADMIN_FORBIDDEN"); + }); +}); diff --git a/packages/mcp/__tests__/server.test.ts b/packages/mcp/__tests__/server.test.ts index 1e78e30f8c..7770ed0091 100644 --- a/packages/mcp/__tests__/server.test.ts +++ b/packages/mcp/__tests__/server.test.ts @@ -104,6 +104,67 @@ describe("createLunoraMcpServer request handlers", () => { ]); }); + it("listTools omits the observability tools when no admin token was configured", async () => { + expect.assertions(1); + + const server = createLunoraMcpServer({ allowWrites: true, client: mockClient().asClient }); + const result = (await handlerFor(server, ListToolsRequestSchema.shape.method.value)({})) as ListToolsResult; + + // Privileged reads must not even be advertised on an unauthenticated + // server — `--allow-writes` is a separate axis and must not imply them. + expect(result.tools.some((tool) => tool.name.startsWith("lunora_get_") && tool.name !== "lunora_get_function_schema")).toBe(false); + }); + + it("listTools includes the observability tools once a token is configured", async () => { + expect.assertions(2); + + const server = createLunoraMcpServer({ client: mockClient().asClient, token: "admin-token" }); + const result = (await handlerFor(server, ListToolsRequestSchema.shape.method.value)({})) as ListToolsResult; + + expect(result.tools.map((tool) => tool.name)).toContain("lunora_get_logs"); + // The SDK's Tool schema carries `outputSchema`, so the declaration + // survives the handler rather than being dropped as an unknown key. + expect(result.tools.find((tool) => tool.name === "lunora_get_logs")?.outputSchema).toBeDefined(); + }); + + it("refuses an observability call fail-closed when no token was configured", async () => { + expect.assertions(2); + + const mock = mockClient(); + // The tool isn't advertised, but a client could still name it — dispatch must refuse. + const server = createLunoraMcpServer({ client: mock.asClient }); + + const result = (await handlerFor( + server, + CallToolRequestSchema.shape.method.value, + )({ + params: { arguments: {}, name: "lunora_get_logs" }, + })) as CallToolResult; + + expect(result.isError).toBe(true); + expect(mock.query).not.toHaveBeenCalled(); + }); + + it("dispatches an observability call and returns structuredContent alongside the text block", async () => { + expect.assertions(3); + + const mock = mockClient(); + + mock.query.mockResolvedValueOnce({ entries: [{ level: "info", message: "hello", timestamp: 1 }] }); + + const server = createLunoraMcpServer({ client: mock.asClient, token: "admin-token" }); + const result = (await handlerFor( + server, + CallToolRequestSchema.shape.method.value, + )({ + params: { arguments: {}, name: "lunora_get_logs" }, + })) as CallToolResult; + + expect(mock.query).toHaveBeenCalledTimes(1); + expect(result.structuredContent).toStrictEqual({ entries: [{ level: "info", message: "hello", timestamp: 1 }], total: 1 }); + expect(JSON.parse((result.content[0] as { text: string }).text)).toStrictEqual(result.structuredContent); + }); + it("callTool dispatches through callTool against the injected client", async () => { expect.assertions(2); diff --git a/packages/mcp/__tests__/tools.test.ts b/packages/mcp/__tests__/tools.test.ts index fcb444fd1e..9ffa07b594 100644 --- a/packages/mcp/__tests__/tools.test.ts +++ b/packages/mcp/__tests__/tools.test.ts @@ -54,7 +54,7 @@ const mockClient = (): { }; describe("toolDefinitions", () => { - it("exposes only the four read-only tools by default (writes disabled)", () => { + it("exposes only the four read-only tools by default (writes disabled, no admin token)", () => { expect.assertions(2); const names = toolDefinitions(false).map((tool) => tool.name); diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 1c2d168a08..5741298af5 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -69,6 +69,7 @@ "dependencies": { "@lunora/client": "1.0.0-alpha.43", "@lunora/errors": "1.0.0-alpha.16", + "@lunora/shard-engine": "1.0.0-alpha.15", "@modelcontextprotocol/sdk": "catalog:mcp" }, "devDependencies": { diff --git a/packages/mcp/src/index.ts b/packages/mcp/src/index.ts index 7ecfdc4feb..1603b1cbb7 100644 --- a/packages/mcp/src/index.ts +++ b/packages/mcp/src/index.ts @@ -6,8 +6,12 @@ * The deployment server: It registers tools for introspecting a deployment * (`lunora_list_functions`, `lunora_list_tables`) and invoking its functions * (`lunora_run_query`, plus `lunora_run_mutation` and `lunora_run_action` when - * writes are enabled), each backed by `LunoraClient` over HTTP RPC. The server - * is read-only by default — the write tools are exposed only when `allowWrites` + * writes are enabled), each backed by `LunoraClient` over HTTP RPC. It also + * exposes the deployment's observability reads (`lunora_get_logs`, + * `lunora_get_issues`, `lunora_get_advisories`, `lunora_get_query_insights`, + * `lunora_get_migration_status`) whenever an admin token resolved — read-only, + * but privileged, so they are omitted entirely without one. The server is + * read-only by default — the write tools are exposed only when `allowWrites` * (or the `LUNORA_MCP_ALLOW_WRITES` env) is set, and every run tool is * allowlisted against the deployment's discovered public functions. It can also * front durable `@lunora/agent` runs as `agent_` tools when `allowAgents` @@ -31,4 +35,4 @@ export { createPaidMcpServer } from "./paid"; export type { LunoraMcpServerOptions } from "./server"; export { connectStdio, createLunoraMcpServer } from "./server"; export type { ToolDefinition, ToolInputSchema, ToolResult } from "./tools"; -export { callTool, READ_ONLY_TOOL_DEFINITIONS, toolDefinitions, WRITE_TOOL_DEFINITIONS } from "./tools"; +export { callTool, OBSERVABILITY_TOOL_DEFINITIONS, READ_ONLY_TOOL_DEFINITIONS, toolDefinitions, WRITE_TOOL_DEFINITIONS } from "./tools"; diff --git a/packages/mcp/src/local.ts b/packages/mcp/src/local.ts index 8d210a0abd..7b9caaa2af 100644 --- a/packages/mcp/src/local.ts +++ b/packages/mcp/src/local.ts @@ -148,6 +148,9 @@ const createClientCache = (fetchImplementation: typeof fetch | undefined): ((dep }; }; +/** True when `deployment` carries a usable admin bearer — the observability tools' gate. */ +const hasAdminToken = (deployment: LocalDeployment | undefined): boolean => deployment?.token !== undefined && deployment.token.length > 0; + /** * The deployment tools, resolving their target at dispatch time. * @@ -156,6 +159,13 @@ const createClientCache = (fetchImplementation: typeof fetch | undefined): ((dep * happened to be up at startup would stay invisible for the rest of the * session. Calling one while nothing is running returns an actionable error * instead. + * + * The observability tools are the ONE exception: they read the deployment's + * logs and grouped errors, so an unauthenticated server must not advertise that + * they exist. Their gate is therefore a snapshot taken here, at build time — + * fail-closed, at the cost of a session that started before the dev server not + * seeing them. Dispatch re-checks the token as freshly resolved, so a listed + * tool whose token has since gone is still refused. */ const lazyDeploymentTools = ( source: LocalDeploymentSource, @@ -164,7 +174,7 @@ const lazyDeploymentTools = ( ): ReadonlyArray => { const resolve = typeof source === "function" ? source : (): LocalDeployment => source; - return toolDefinitions(allowWrites).map((definition) => { + return toolDefinitions(allowWrites, hasAdminToken(resolve())).map((definition) => { return { definition, handle: async (input: Record): Promise => { @@ -174,7 +184,7 @@ const lazyDeploymentTools = ( return { content: [{ text: NO_DEPLOYMENT_MESSAGE, type: "text" }], isError: true }; } - return callTool(clientFor(deployment), definition.name, input, allowWrites); + return callTool(clientFor(deployment), definition.name, input, allowWrites, hasAdminToken(deployment)); }, }; }); diff --git a/packages/mcp/src/observability-tools.ts b/packages/mcp/src/observability-tools.ts new file mode 100644 index 0000000000..c98abd4d66 --- /dev/null +++ b/packages/mcp/src/observability-tools.ts @@ -0,0 +1,319 @@ +/** + * The observability tool surface: the reads an agent needs to answer "did my + * change work, and what broke" — recent logs, grouped error Issues, schema + * advisories, query insights, and migration status. + * + * A THIRD tier, distinct from the always-on read tools and the `allowWrites` + * write tools. These are read-only in the `readOnlyHint` sense, but they + * surface production logs, request metadata, and grouped errors, so they are + * advertised ONLY when an admin token resolved — the same omit-don't-refuse + * rule the write gate uses, plus a refusal at dispatch. See `./tools`, which + * owns both halves of that gate. + * + * Every read is an existing `__lunora_admin__:*` RPC, reached through the same + * `LunoraClient` (and therefore the same `/_lunora/rpc` transport and bearer) + * the other tools use. The op paths come from `ADMIN_FUNCTIONS`; a hand-written + * `"__lunora_admin__:…"` literal is how a renamed op ships a 404 to one + * consumer and not another. + */ +import type { FunctionReference, LunoraClient } from "@lunora/client"; +import { LunoraError } from "@lunora/errors"; +import { ADMIN_FUNCTIONS } from "@lunora/shard-engine"; + +import { okStructured } from "./tool-result"; +import type { ToolDefinition, ToolInputSchema, ToolResult } from "./tool-types"; + +/** Introspection reads touch no state; every call goes to the deployment. */ +const READ_ONLY_ANNOTATIONS = { destructiveHint: false, idempotentHint: true, openWorldHint: true, readOnlyHint: true } as const; + +/** + * Rows returned when the caller passes no `limit`, and the ceiling a larger one + * is clamped to. + * + * Deliberately far below the admin RPCs' own bounds (`getIssues` clamps to + * 10000; the log ring holds 500): every row lands in a model's context window + * and is paid for on every subsequent turn, so the binding constraint here is + * context, not the datastore. + */ +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 500; + +/** The `range` tokens `getQueryInsights` understands; anything else falls back to its 15m default. */ +const INSIGHT_RANGES = ["1m", "5m", "15m", "1h"] as const; + +/** Triage states `getIssues` filters on. */ +const ISSUE_STATUSES = ["open", "resolved", "ignored"] as const; + +/** Log severities `ctx.log.*` records. */ +const LOG_LEVELS = ["trace", "debug", "log", "info", "warn", "error", "fatal"] as const; + +/** + * A shard key every observability tool accepts. The reads are served by the + * shard that handled the traffic, so on a `.shardBy()`-partitioned deployment + * "the logs" are per-shard: reading one shard while presenting it as the whole + * deployment would be actively misleading. + */ +const SHARD_KEY_PROPERTY = { + description: + "Shard to read from on a .shardBy()-partitioned deployment. Omit for the default (unsharded) shard — these reads are PER-SHARD, not deployment-wide.", + type: "string", +} as const; + +const LIMIT_PROPERTY = { + description: `Maximum rows to return (default ${DEFAULT_LIMIT.toString()}, clamped to ${MAX_LIMIT.toString()}).`, + type: "number", +} as const; + +/** Clamp a caller-supplied `limit` into `[1, MAX_LIMIT]`, defaulting a missing/invalid one. */ +const readLimit = (raw: unknown): number => { + const value = typeof raw === "number" ? raw : Number.NaN; + + if (!Number.isFinite(value)) { + return DEFAULT_LIMIT; + } + + return Math.max(1, Math.min(Math.floor(value), MAX_LIMIT)); +}; + +/** Pass an input string through only when it is one of `allowed`; anything else is dropped (the RPC's own default applies). */ +const readEnum = (raw: unknown, allowed: ReadonlyArray): T | undefined => (allowed.includes(raw as T) ? (raw as T) : undefined); + +/** A non-empty input string, or `undefined`. */ +const readText = (raw: unknown): string | undefined => (typeof raw === "string" && raw.length > 0 ? raw : undefined); + +const LOGS_INPUT_SCHEMA: ToolInputSchema = { + properties: { + level: { description: `Keep only entries at this severity. One of: ${LOG_LEVELS.join(", ")}.`, type: "string" }, + limit: LIMIT_PROPERTY, + shardKey: SHARD_KEY_PROPERTY, + }, + type: "object", +}; + +const ISSUES_INPUT_SCHEMA: ToolInputSchema = { + properties: { + functionPathPrefix: { description: 'Keep only Issues whose function path starts with this, e.g. "messages:".', type: "string" }, + limit: LIMIT_PROPERTY, + shardKey: SHARD_KEY_PROPERTY, + status: { description: `Triage status to keep. One of: ${ISSUE_STATUSES.join(", ")}. Default: all.`, type: "string" }, + }, + type: "object", +}; + +const ADVISORIES_INPUT_SCHEMA: ToolInputSchema = { + properties: { limit: LIMIT_PROPERTY, shardKey: SHARD_KEY_PROPERTY }, + type: "object", +}; + +const INSIGHTS_INPUT_SCHEMA: ToolInputSchema = { + properties: { + limit: LIMIT_PROPERTY, + range: { description: `Time window to report over. One of: ${INSIGHT_RANGES.join(", ")}. Default: 15m.`, type: "string" }, + shardKey: SHARD_KEY_PROPERTY, + }, + type: "object", +}; + +const MIGRATION_STATUS_INPUT_SCHEMA: ToolInputSchema = { + properties: { shardKey: SHARD_KEY_PROPERTY }, + type: "object", +}; + +/** + * `outputSchema`s are shallow on purpose: they tell a client what the top-level + * envelope is (and let it validate `structuredContent`), without pinning row + * shapes that the admin RPCs are free to extend. A row schema copied here is a + * second source of truth that goes stale silently. + */ +const LOGS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + entries: { description: "Recent log entries, NEWEST FIRST: { level, message, timestamp, functionPath?, fields? }.", type: "array" }, + total: { description: "Entries available before `limit`/`level` narrowed them.", type: "number" }, + }, + required: ["entries", "total"], + type: "object", +}; + +const ISSUES_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + issues: { description: "Grouped error Issues, newest first: { hash, title, count, status, functionPath, lastSeen, … }.", type: "array" }, + }, + required: ["issues"], + type: "object", +}; + +const ADVISORIES_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + advisories: { description: "Schema/query advisories: { id, level, title, detail, … }.", type: "array" }, + total: { description: "Advisories available before `limit` narrowed them.", type: "number" }, + }, + required: ["advisories", "total"], + type: "object", +}; + +const INSIGHTS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + buckets: { description: "Combined throughput/latency series across the range.", type: "array" }, + capped: { description: "True when the deployment's tracked-statement cap was reached, so coverage is partial.", type: "boolean" }, + entries: { description: "Per-statement activity in the range, hottest first.", type: "array" }, + total: { description: "Statements available before `limit` narrowed them.", type: "number" }, + trackedStatements: { description: "Distinct statements the deployment is tracking.", type: "number" }, + }, + required: ["entries", "buckets"], + type: "object", +}; + +const MIGRATION_STATUS_OUTPUT_SCHEMA: ToolInputSchema = { + properties: { + migrations: { description: "Every declared migration with its applied/pending state.", type: "array" }, + }, + required: ["migrations"], + type: "object", +}; + +/** + * The observability tools. Descriptions say WHEN to call the tool, not just + * what it returns — an agent picking between five similar reads needs the + * trigger, and it pays for these strings on every turn. + */ +const OBSERVABILITY_TOOL_DEFINITIONS: ReadonlyArray = [ + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read recent logs" }, + description: + "Read the deployment's recent log entries (newest first) after running a function, to see what it printed and where it failed. In-memory and per-shard: resets when the shard hibernates.", + inputSchema: LOGS_INPUT_SCHEMA, + name: "lunora_get_logs", + outputSchema: LOGS_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "List grouped error Issues" }, + description: + "List errors grouped into Issues by fingerprint, with occurrence counts and triage status — the first call when asking what is currently broken, rather than reading raw logs.", + inputSchema: ISSUES_INPUT_SCHEMA, + name: "lunora_get_issues", + outputSchema: ISSUES_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "List schema and query advisories" }, + description: "List the deployment's schema/query advisories (missing indexes, unsafe policies, and similar lints) before or after changing the schema.", + inputSchema: ADVISORIES_INPUT_SCHEMA, + name: "lunora_get_advisories", + outputSchema: ADVISORIES_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read query insights" }, + description: "Read per-statement execution counts and latency over a recent time window, to find which query is slow or hot before optimizing one.", + inputSchema: INSIGHTS_INPUT_SCHEMA, + name: "lunora_get_query_insights", + outputSchema: INSIGHTS_OUTPUT_SCHEMA, + }, + { + annotations: { ...READ_ONLY_ANNOTATIONS, title: "Read migration status" }, + description: "Read which migrations have been applied and which are pending, to check whether a schema change has actually landed on the deployment.", + inputSchema: MIGRATION_STATUS_INPUT_SCHEMA, + name: "lunora_get_migration_status", + outputSchema: MIGRATION_STATUS_OUTPUT_SCHEMA, + }, +]; + +/** Names of the observability tools — used to gate them out of a server with no admin token. */ +const OBSERVABILITY_TOOL_NAMES: ReadonlySet = new Set(OBSERVABILITY_TOOL_DEFINITIONS.map((tool) => tool.name)); + +/** + * Call one admin RPC. `client.query` is the right seam and not a workaround: + * the `__lunora_admin__:*` paths travel the same `POST /_lunora/rpc` envelope + * as an ordinary function (the shard intercepts them before user dispatch), so + * the bearer, the error envelope, and the response decode are all the client's + * existing behaviour. + */ +const adminRead = async (client: LunoraClient, op: string, args: Record, shardKey: string | undefined): Promise => { + const reference = { __lunoraRef: op } as FunctionReference; + + return client.query(reference, args, { ...(shardKey === undefined ? {} : { shardKey }) }); +}; + +/** The array under `key` in an admin result, or `[]` when the deployment returned something else. */ +const rowsOf = (result: unknown, key: string): unknown[] => { + const value = (result as Record | null | undefined)?.[key]; + + return Array.isArray(value) ? value : []; +}; + +/** `getLogs` returns the whole ring; only `level` is read here, so the row stays open. */ +interface LogRow { + level?: unknown; +} + +/** + * Dispatch an observability tool. Throws on an unknown name or a failed RPC — + * `callTool` owns the try/catch that turns either into an `isError` result, so + * every tool family reports failures the same way. + */ +const callObservabilityTool = async (client: LunoraClient, name: string, input: Record): Promise => { + const shardKey = readText(input.shardKey); + const limit = readLimit(input.limit); + + switch (name) { + case "lunora_get_advisories": { + const result = await adminRead(client, ADMIN_FUNCTIONS.getAdvisories, {}, shardKey); + const advisories = rowsOf(result, "advisories"); + + return okStructured({ advisories: advisories.slice(0, limit), total: advisories.length }); + } + case "lunora_get_issues": { + const status = readEnum(input.status, ISSUE_STATUSES); + const functionPathPrefix = readText(input.functionPathPrefix); + // The RPC applies `limit`/`status`/`functionPathPrefix` itself (and + // clamps the limit), so grouping happens over the right row set + // rather than over a page this tool already truncated. + const result = await adminRead( + client, + ADMIN_FUNCTIONS.getIssues, + { + limit, + ...(status === undefined ? {} : { status }), + ...(functionPathPrefix === undefined ? {} : { functionPathPrefix }), + }, + shardKey, + ); + + return okStructured({ issues: rowsOf(result, "issues") }); + } + case "lunora_get_logs": { + const level = readEnum(input.level, LOG_LEVELS); + // `getLogs` takes no arguments and returns the entire in-memory ring + // (up to 500 entries), so the narrowing happens here. Entries arrive + // newest-first, so slicing keeps the most recent. + const result = await adminRead(client, ADMIN_FUNCTIONS.getLogs, {}, shardKey); + const entries = rowsOf(result, "entries").filter((entry) => level === undefined || (entry as LogRow).level === level); + + return okStructured({ entries: entries.slice(0, limit), total: entries.length }); + } + case "lunora_get_migration_status": { + const result = await adminRead(client, ADMIN_FUNCTIONS.migrationStatus, {}, shardKey); + + // No limit: the list is one row per declared migration, and dropping + // its tail would hide exactly the pending one the caller is asking about. + return okStructured({ migrations: rowsOf(result, "migrations") }); + } + case "lunora_get_query_insights": { + const range = readEnum(input.range, INSIGHT_RANGES); + const result = await adminRead(client, ADMIN_FUNCTIONS.getQueryInsights, { ...(range === undefined ? {} : { range }) }, shardKey); + const entries = rowsOf(result, "entries"); + const { buckets, capped, trackedStatements } = (result ?? {}) as { buckets?: unknown; capped?: unknown; trackedStatements?: unknown }; + + return okStructured({ + buckets: Array.isArray(buckets) ? buckets : [], + capped: capped === true, + entries: entries.slice(0, limit), + total: entries.length, + trackedStatements: typeof trackedStatements === "number" ? trackedStatements : entries.length, + }); + } + default: { + throw new LunoraError("INTERNAL", `unknown observability tool: ${name}`); + } + } +}; + +export { callObservabilityTool, DEFAULT_LIMIT, MAX_LIMIT, OBSERVABILITY_TOOL_DEFINITIONS, OBSERVABILITY_TOOL_NAMES }; diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts index ffd799e322..2034a632af 100644 --- a/packages/mcp/src/server.ts +++ b/packages/mcp/src/server.ts @@ -103,6 +103,10 @@ interface LunoraMcpServerOptions { * therefore NOT enforced by the token's scope; it is enforced in-process via * `allowWrites: false` (the default), which omits the write tools from the * advertised list and refuses them at dispatch. + * + * Its presence is also what gates the observability tools (logs, Issues, + * advisories, query insights, migration status): without a token they are + * omitted from `ListTools` and refused at dispatch. */ token?: string; /** Base URL of the deployed Lunora Worker. Required unless `client` is given. */ @@ -142,10 +146,15 @@ const createLunoraMcpServer = (options: LunoraMcpServerOptions): Server => { const allowWrites = options.allowWrites ?? false; const allowAgents = options.allowAgents ?? false; const agents = options.agents ?? []; + // The observability tools' gate. Read off `options.token` rather than the + // client, because a caller that injects a pre-built `client` has not told + // this server what that client can reach — and the fail-closed reading of + // "unknown" is "no privileged tools". + const hasAdminToken = typeof options.token === "string" && options.token.length > 0; const server = new Server(SERVER_INFO, { capabilities: { tools: {} } }); server.setRequestHandler(ListToolsRequestSchema, () => { - return { tools: [...toolDefinitions(allowWrites), ...agentToolDefinitions(agents, allowAgents)] }; + return { tools: [...toolDefinitions(allowWrites, hasAdminToken), ...agentToolDefinitions(agents, allowAgents)] }; }); server.setRequestHandler(CallToolRequestSchema, async (request): Promise => { @@ -161,7 +170,7 @@ const createLunoraMcpServer = (options: LunoraMcpServerOptions): Server => { ...(options.agentMaxWaitMs === undefined ? {} : { maxWaitMs: options.agentMaxWaitMs }), ...(options.agentPollIntervalMs === undefined ? {} : { pollIntervalMs: options.agentPollIntervalMs }), }) - : await callTool(client, name, input, allowWrites); + : await callTool(client, name, input, allowWrites, hasAdminToken); return result as CallToolResult; }); diff --git a/packages/mcp/src/tool-result.ts b/packages/mcp/src/tool-result.ts new file mode 100644 index 0000000000..675bf6cc0a --- /dev/null +++ b/packages/mcp/src/tool-result.ts @@ -0,0 +1,98 @@ +/** + * Building a `ToolResult` from a value that came back over the wire. + * + * Its own module (rather than `./tools`) because two tool families need the + * same JSON-safety guarantee: the deployment tools (`./tools`) and the + * observability tools (`./observability-tools`). Keeping it here also keeps + * `./observability-tools` off `./tools`, which would be an import cycle. + */ +import type { ToolResult } from "./tool-types"; + +/** + * Base64-encode bytes for the model-visible JSON, chunking to stay under + * `String.fromCharCode`'s argument-count ceiling on large buffers (mirrors the + * wire codec's own encoder). + */ +const bytesToBase64 = (bytes: Uint8Array): string => { + let binary = ""; + const chunk = 0x80_00; + + for (let index = 0; index < bytes.length; index += chunk) { + // eslint-disable-next-line unicorn/prefer-code-point -- byte values 0-255 -> latin1; fromCharCode is correct and faster here + binary += String.fromCharCode(...bytes.subarray(index, index + chunk)); + } + + return btoa(binary); +}; + +/** + * `JSON.stringify` replacer for a decoded RPC result. `LunoraClient` decodes + * EVERY response (`rpc()` ends in `decodeWire(body.result)`), which revives + * `v.int64()` leaves as real `bigint` and `v.bytes()`/typed-array columns as + * `ArrayBuffer`/typed arrays — and that holds for admin reads too, which are + * served WITHOUT a matching `encodeWire` but ride the same decode on the way + * back. Raw `JSON.stringify` THROWS on a bigint (turning a successful call into + * a tool error) and serializes an `ArrayBuffer` to `{}` / a `Uint8Array` to an + * index-keyed object (silent corruption). Map every bigint → its decimal string + * and every bytes leaf → base64 so the model sees a faithful value instead. + */ +const jsonResultReplacer = (_key: string, value: unknown): unknown => { + if (typeof value === "bigint") { + return value.toString(); + } + + if (value instanceof ArrayBuffer) { + return bytesToBase64(new Uint8Array(value)); + } + + if (ArrayBuffer.isView(value)) { + const view = value; + + return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); + } + + return value; +}; + +/** + * The JSON-safe projection of a decoded result — the same mapping + * {@link jsonResultReplacer} applies to the text block, materialized as a + * value. `structuredContent` is serialized by the transport, so an unconverted + * bigint there would throw at the protocol layer instead of at the tool, taking + * the whole response down rather than one field. + * + * Takes an object (never a bare value), which is both what MCP requires of + * `structuredContent` and what makes `JSON.stringify` total here. + */ +const toJsonSafe = (value: Record): Record => + JSON.parse(JSON.stringify(value, jsonResultReplacer)) as Record; + +/** A text-only success result: the value as pretty JSON in one text block. */ +const ok = (value: unknown): ToolResult => { + // A void-returning mutation/action resolves to `undefined`, and + // `JSON.stringify(undefined)` yields the JS value `undefined` (not a + // string), which violates both `ToolResult.content[].text: string` and the + // MCP `TextContent` contract. Emit the JSON `null` literal in that case. + const text = value === undefined ? "null" : JSON.stringify(value, jsonResultReplacer, 2); + + return { content: [{ text, type: "text" }] }; +}; + +/** + * A success result carrying BOTH the text block and `structuredContent`. + * + * The text block is not redundant: `structuredContent` arrived in MCP revision + * `2025-06-18`, and a client that negotiates an older revision (the SDK still + * supports `2025-03-26` and `2024-11-05`) simply ignores the field. Emitting + * both means one result shape works on every revision. + */ +const okStructured = (value: Record): ToolResult => { + return { ...ok(value), structuredContent: toJsonSafe(value) }; +}; + +/** An error result: the message as tool output, per the MCP convention (never a rejection). */ +const errorResult = (message: string): ToolResult => { + return { content: [{ text: message, type: "text" }], isError: true }; +}; + +export { bytesToBase64, errorResult, jsonResultReplacer, ok, okStructured, toJsonSafe }; diff --git a/packages/mcp/src/tool-types.ts b/packages/mcp/src/tool-types.ts index cec19c80e3..f5a7068670 100644 --- a/packages/mcp/src/tool-types.ts +++ b/packages/mcp/src/tool-types.ts @@ -43,12 +43,28 @@ interface ToolDefinition { description: string; inputSchema: ToolInputSchema; name: string; + + /** + * JSON Schema for the tool's `structuredContent`. Optional: a tool that + * emits only a text block declares none, and a client that negotiated an + * MCP revision older than `2025-06-18` ignores it either way. + */ + outputSchema?: ToolInputSchema; } /** The MCP `CallToolResult` shape this package's tools return. */ interface ToolResult { content: { text: string; type: "text" }[]; isError?: boolean; + + /** + * The machine-readable result, mirroring the text block. Present only for + * tools that declare an `outputSchema`; per the MCP spec it must be a JSON + * OBJECT (never a bare array) and must survive `JSON.stringify` — the + * transport serializes it, so an un-mapped `bigint` here fails the whole + * response rather than one field. + */ + structuredContent?: Record; } export type { ToolAnnotations, ToolDefinition, ToolInputSchema, ToolResult }; diff --git a/packages/mcp/src/tools.ts b/packages/mcp/src/tools.ts index 202731f987..2da6f4dafc 100644 --- a/packages/mcp/src/tools.ts +++ b/packages/mcp/src/tools.ts @@ -1,6 +1,8 @@ import type { FunctionDescriptor, FunctionReference, LunoraClient } from "@lunora/client"; import { LunoraError } from "@lunora/errors"; +import { callObservabilityTool, OBSERVABILITY_TOOL_DEFINITIONS, OBSERVABILITY_TOOL_NAMES } from "./observability-tools"; +import { errorResult, ok } from "./tool-result"; import type { ToolDefinition, ToolInputSchema, ToolResult } from "./tool-types"; /** @@ -92,17 +94,26 @@ const WRITE_TOOL_DEFINITIONS: ReadonlyArray = [ const WRITE_TOOL_NAMES: ReadonlySet = new Set(WRITE_TOOL_DEFINITIONS.map((tool) => tool.name)); /** - * The tools this server advertises. When `allowWrites` is false (the default), - * only the read-only surface is exposed — the mutation/action tools are omitted - * from `ListTools` entirely, so an AI agent can't invoke a write it can't see. + * The tools this server advertises, in three tiers: + * + * - the read-only surface, always exposed; + * - the observability surface, exposed only when an admin token resolved — + * read-only, but it surfaces production logs and grouped errors, so an + * unauthenticated server must not even advertise that it exists; + * - the write surface, exposed only when `allowWrites` is set. + * + * Both gates OMIT rather than refuse: an AI agent can't invoke what it can't + * see. Dispatch re-checks both in {@link callTool}, so the guarantee does not + * depend on a client honouring the advertised list. */ -const toolDefinitions = (allowWrites: boolean): ReadonlyArray => +const toolDefinitions = (allowWrites: boolean, hasAdminToken = false): ReadonlyArray => // Fail closed: only the boolean `true` opts in. These are exported helpers, so // an env-plumbed/JS caller could pass a truthy string like `"false"`/`"0"` — // the explicit `=== true` guards that despite the declared `boolean` type. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers - allowWrites === true ? [...READ_ONLY_TOOL_DEFINITIONS, ...WRITE_TOOL_DEFINITIONS] : READ_ONLY_TOOL_DEFINITIONS; + /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers */ + [...READ_ONLY_TOOL_DEFINITIONS, ...(hasAdminToken === true ? OBSERVABILITY_TOOL_DEFINITIONS : []), ...(allowWrites === true ? WRITE_TOOL_DEFINITIONS : [])]; +/* eslint-enable @typescript-eslint/no-unnecessary-boolean-literal-compare */ /** Extract and validate `functionPath` from an MCP `arguments` bag. */ const readFunctionPath = (input: Record): string => { const { functionPath } = input; @@ -175,60 +186,6 @@ const reference = (functionPath: string): FunctionReference => { return { __lunoraRef: functionPath }; }; -/** - * Base64-encode bytes for the model-visible JSON, chunking to stay under - * `String.fromCharCode`'s argument-count ceiling on large buffers (mirrors the - * wire codec's own encoder). - */ -const bytesToBase64 = (bytes: Uint8Array): string => { - let binary = ""; - const chunk = 0x80_00; - - for (let index = 0; index < bytes.length; index += chunk) { - // eslint-disable-next-line unicorn/prefer-code-point -- byte values 0-255 -> latin1; fromCharCode is correct and faster here - binary += String.fromCharCode(...bytes.subarray(index, index + chunk)); - } - - return btoa(binary); -}; - -/** - * `JSON.stringify` replacer for a decoded RPC result. `client.query`/`mutation`/ - * `action` return `decodeWire(...)`, which revives `v.int64()` leaves as real - * `bigint` and `v.bytes()`/typed-array columns as `ArrayBuffer`/typed arrays. - * Raw `JSON.stringify` THROWS on a bigint (turning a successful call into a tool - * error) and serializes an `ArrayBuffer` to `{}` / a `Uint8Array` to an - * index-keyed object (silent corruption). Map every bigint → its decimal string - * and every bytes leaf → base64 so the model sees a faithful value instead. - */ -const jsonResultReplacer = (_key: string, value: unknown): unknown => { - if (typeof value === "bigint") { - return value.toString(); - } - - if (value instanceof ArrayBuffer) { - return bytesToBase64(new Uint8Array(value)); - } - - if (ArrayBuffer.isView(value)) { - const view = value; - - return bytesToBase64(new Uint8Array(view.buffer, view.byteOffset, view.byteLength)); - } - - return value; -}; - -const ok = (value: unknown): ToolResult => { - // A void-returning mutation/action resolves to `undefined`, and - // `JSON.stringify(undefined)` yields the JS value `undefined` (not a - // string), which violates both `ToolResult.content[].text: string` and the - // MCP `TextContent` contract. Emit the JSON `null` literal in that case. - const text = value === undefined ? "null" : JSON.stringify(value, jsonResultReplacer, 2); - - return { content: [{ text, type: "text" }] }; -}; - /** * The deployment's public-function registry is static per deploy, but every run * tool (via {@link assertRunnable}) and `lunora_get_function_schema` needs it — @@ -296,21 +253,34 @@ const assertRunnable = async (client: LunoraClient, functionPath: string, expect * returned as `isError` results (rather than rejections) so the calling model * sees the failure as tool output, per the MCP convention. * - * `allowWrites` gates the mutation/action tools: when false (the default) a call - * to a write tool is refused even if the client somehow names it, so the - * read-only guarantee holds at dispatch, not just in the advertised tool list. + * `allowWrites` gates the mutation/action tools and `hasAdminToken` gates the + * observability tools: when either is false a call to the gated tool is refused + * even if the client somehow names it, so both guarantees hold at dispatch, not + * just in the advertised tool list. */ -const callTool = async (client: LunoraClient, name: string, input: Record, allowWrites = false): Promise => { +const callTool = async ( + client: LunoraClient, + name: string, + input: Record, + allowWrites = false, + hasAdminToken = false, +): Promise => { try { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers + /* eslint-disable @typescript-eslint/no-unnecessary-boolean-literal-compare -- intentional runtime guard at an exported API boundary against non-boolean callers */ if (allowWrites !== true && WRITE_TOOL_NAMES.has(name)) { - return { - content: [ - { text: `tool "${name}" is disabled: this MCP server is read-only. Enable writes with the LUNORA_MCP_ALLOW_WRITES env var.`, type: "text" }, - ], - isError: true, - }; + return errorResult(`tool "${name}" is disabled: this MCP server is read-only. Enable writes with the LUNORA_MCP_ALLOW_WRITES env var.`); + } + + if (OBSERVABILITY_TOOL_NAMES.has(name)) { + if (hasAdminToken !== true) { + return errorResult( + `tool "${name}" is unavailable: it reads the deployment's logs and errors, which needs an admin token. Set LUNORA_ADMIN_TOKEN (or pass --token) and reconnect.`, + ); + } + + return await callObservabilityTool(client, name, input); } + /* eslint-enable @typescript-eslint/no-unnecessary-boolean-literal-compare */ switch (name) { case "lunora_get_function_schema": { @@ -319,7 +289,7 @@ const callTool = async (client: LunoraClient, name: string, input: Record function_.path === functionPath); if (descriptor === undefined) { - return { content: [{ text: `function not found: ${functionPath}`, type: "text" }], isError: true }; + return errorResult(`function not found: ${functionPath}`); } return ok({ args: descriptor.args ?? [], kind: descriptor.kind, path: descriptor.path }); @@ -352,16 +322,17 @@ const callTool = async (client: LunoraClient, name: string, input: Record **Correction (execution).** This is wrong, and it is what §5's "shared admin + > caller" workstream was sized around. The `__lunora_admin__:*` ops are not a + > separate endpoint: they ride the ordinary `POST /_lunora/rpc` envelope + > (`{ args, functionPath, shardKey }`) and the shard intercepts them before + > user dispatch — which is exactly what `LunoraClient.rpc` sends, and what the + > studio already does through `useAdminQuery`. So `client.query({ __lunoraRef: +ADMIN_FUNCTIONS.getLogs }, args, { shardKey })` reaches every one of these + > reads today, with the bearer, the `{ error }` envelope and the response + > decode all being the client's existing behaviour. No new caller module was + > written; `adminRead` in `observability-tools.ts` is a three-line wrapper over + > `client.query`. + +## 2. Existing seams (do not reinvent) + +- **`ADMIN_FUNCTIONS`** (`packages/shard-engine/src/introspect.ts:50`) — the + canonical op-path table. Import it; never re-type `"__lunora_admin__:…"` + string literals (the CLI's copies are the anti-pattern, not the model). +- **`toolDefinitions(allowWrites)` + the dispatch switch** (`tools.ts`, + `server.ts:142-164`) — the existing two-tier gate. Observability tools join it + as a third tier, they do not fork it. +- **`READ_ONLY_ANNOTATIONS`** (`tools.ts:36`) — reuse verbatim. +- **`packages/observability/src/*`** — the read models behind the admin RPCs + (`request-log.ts`, `issue-state.ts`, `query-metrics.ts`, `function-metrics.ts`). + Tools shape their output; they do not re-aggregate. +- **`@lunora/advisor`'s finding shape** — for the advisories tool. +- **`packages/mcp/src/docs`** — the documentation tool surface already shows how + a second tool family composes into this server without touching the first. + +## 3. The behavioural contract to preserve + +- `allowWrites: false` (the default) keeps every write tool **out of + `ListTools`**, not merely refused at dispatch (`server.ts:104`). Adding tools + must not change which names appear at each tier. +- Existing tool names, input schemas, and text-content output stay unchanged. + `structuredContent` is **additive** — clients that read `content[0].text` + today keep working, because the text block is still emitted. +- The server stays usable with no admin token (dev-server default), which means + the new tools must degrade rather than crash the whole server. + +## 4. Design decisions + +- **A third tier: read-only-but-privileged.** Observability tools are read-only + in the `readOnlyHint` sense, but they surface production logs, request + payload metadata, and grouped errors. They are exposed **only when an admin + token resolved**, and omitted from `ListTools` otherwise — the same + omit-don't-refuse rule the write gate already uses. Chosen over folding them + into the always-on read tier (leaks the existence of privileged data on an + unauthenticated server) and over putting them behind `--allow-writes` + (conflates "may change data" with "may read operational data"). +- **Import `ADMIN_FUNCTIONS`; do not copy op strings.** Chosen over the CLI's + existing hardcoded-literal pattern, which is how a renamed op ships a 404 to + one consumer and not another. +- **A small shared admin-RPC caller inside `@lunora/mcp`, not a new public + surface on `@lunora/client`.** Chosen because widening the browser client with + admin RPC would put a privileged path into every app bundle. If a second + non-CLI consumer appears, promote it then — not now. +- **`outputSchema` + `structuredContent`, text block retained.** Chosen over + replacing the text content (breaks existing clients) and over structured-only + for new tools (two result conventions in one server is worse than one + slightly redundant one). +- **Scope: logs, issues, advisories, insights, migration status.** These are the + five an agent actually needs to answer "did my change work, and what broke". + Traces, metric history, subscriptions, queue/workflow inspection are + deliberately deferred — each is a bigger output shape, and an unused tool is + context an agent pays for on every turn. +- **Every tool takes a bounded `limit` with a server-side clamp**, mirroring + `lunora logs --limit` (clamped 1–10000). An unbounded log read is how an agent + burns its whole context on one call. + +## 5. Workstreams + +**S — shared admin caller.** One module in `@lunora/mcp` that takes +`{ url, token }` and an `ADMIN_FUNCTIONS` key, POSTs the RPC, and returns the +parsed result or a `LunoraError`. Reused by all five tools. + +**Done.** — as a three-line wrapper, not a module (see the §1 correction). +`adminRead(client, op, args, shardKey)` in `packages/mcp/src/observability-tools.ts` +calls `client.query`, so the bearer, the error envelope and the wire decode are +the client's, not a second implementation. `@lunora/shard-engine` is now a +dependency of `@lunora/mcp` so `ADMIN_FUNCTIONS` is imported rather than copied +(the cost: `@lunora/errors` + `@lunora/platform` + `drizzle-orm` join the MCP +install tree; the alternative — studio's deliberate copy of the table — is the +drift the plan set out to avoid). + +**M — the five tools.** `lunora_get_logs`, `lunora_get_issues`, +`lunora_get_advisories`, `lunora_get_query_insights`, +`lunora_get_migration_status`. Each: a narrow input schema (`limit`, plus the +filters its RPC already supports — level/function-prefix for logs, status for +issues), `READ_ONLY_ANNOTATIONS` with a real `title`, and a description that +tells the agent _when_ to call it, not just what it returns. + +**Done.** — with three deviations worth the record: + +1. **`getLogs` takes no arguments.** It returns the whole in-memory ring + (≤500 entries), so `limit`/`level` are applied in the tool. `getIssues` does + accept `limit`/`status`/`functionPathPrefix`, so those are pushed down to the + RPC — grouping has to happen over the right row set, not over a page the tool + already truncated. +2. **The clamp is 1–500, not 1–10000.** The binding constraint here is the + model's context window (every row is re-read on every subsequent turn), not + the datastore's. Default 50. +3. **`lunora_get_migration_status` takes no `limit`.** Its list is one row per + declared migration, and dropping the tail would hide exactly the pending + migration the caller is asking about. + +**S — token-tier gating.** `toolDefinitions` learns a third argument (or the +options object grows a resolved-token flag); tools are omitted from `ListTools` +and refused at dispatch when no token resolved. Test both halves — the omission +and the refusal — since the write gate's own tests establish that pattern. + +**Done.** `toolDefinitions(allowWrites, hasAdminToken)` and +`callTool(client, name, input, allowWrites, hasAdminToken)`. `server.ts` derives +the flag from `options.token` (an injected `client` carries no token this server +knows about, and the fail-closed reading of "unknown" is "no privileged tools"). + +`local.ts` — the surface `lunora mcp serve` actually mounts — needed a decision +the plan did not anticipate: its deployment is a **resolver** called per tool +call, so no token is known when the list is built. The advertised list therefore +uses a **snapshot** taken at build time (fail-closed: an editor that spawned the +server before `lunora dev` does not see the observability tools that session), +while dispatch re-checks the freshly resolved token. Both halves are tested, +including the withdrawn-token case where a listed tool is still refused. + +**M — structured output.** Add `outputSchema?: ToolInputSchema` to +`ToolDefinition` and `structuredContent?: unknown` to `ToolResult` +(`tool-types.ts`); populate both for the five new tools; leave the six existing +tools text-only for now (a follow-up can schema them once the shape proves out). + +**Done.** — with one type correction: `structuredContent` is +`Record`, not `unknown`. The MCP schema is +`z.record(z.string(), z.unknown())` — a bare array is invalid there, so the type +has to say so, or a tool returning one would only fail at the wire. + +The sharper part is serialization, and it is the plan-300 STOP condition in +disguise: `structuredContent` is serialized by the **transport**, so an unmapped +`bigint` in it throws at the protocol layer and takes the whole response down +rather than one field. `toJsonSafe` (in the new +`packages/mcp/src/tool-result.ts`) runs it through the same bigint→string / +bytes→base64 replacer the text block already used. Pinned by a test that logs a +`bigint` and a `Uint8Array` through a log entry's `fields`. + +**S — docs + CLI help.** `mcp serve`'s help text says which tools require a +token. The MCP package docs gain the tool table. + +**Done.** `--token`'s description in `packages/cli/src/commands/mcp/index.ts` +now says it gates the observability tools, plus an example line; +`packages/mcp/README.md` gains the five rows and a "privileged" section stating +plainly what they expose (log lines and error messages reaching the model's +provider) and that they are independent of `--allow-writes`. + +## 6. Platform parity + +Not applicable to the `ctx.*` matrix — this plan adds no runtime surface and no +provider binding. It reads existing admin RPCs, which are served by whichever +host mounts the shard engine; nothing here is Cloudflare-specific, so no +capability row changes. + +## 7. Phasing & ordering + +| Phase | Work | Gate | +| ----- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| 0 | Shared admin caller | Unit test against a mock fetch: op path comes from `ADMIN_FUNCTIONS`, bearer header set, error maps to `LunoraError` | +| 1 | Token-tier gating | Two tests: no token → the five names absent from `ListTools`; token → present. Dispatch refuses when absent | +| 2 | The five tools | Per-tool test against a mock admin response, including the `limit` clamp at both bounds | +| 3 | `outputSchema`/`structuredContent` | Test: a new tool's result validates against its own declared `outputSchema`, and `content[0].text` is still present | +| 4 | Docs | `pnpm run lint:prettier` clean; `pnpm run api:check` (or `api:update` after a fresh build) for the widened types | + +## 8. Risks & STOP conditions + +- **STOP** on the admin-RPC wire encoding before shaping any output. The admin + RPC path does **not** run results through `encodeWire` while the client still + decodes — see the `admin-rpc-does-not-encodewire` finding and plan **300** + (`plans/300-decode-doc-read-paths.md`). A tool that decodes an admin result + for display will 500 on any row carrying a `bigint` or `ArrayBuffer`. Read 300 + first and follow whatever convention it lands; do not invent a third one here. + + **Resolved — the convention was followed, not reinvented.** Plan 300 landed on + this: the client decodes EVERY response (`rpc()` ends in + `decodeWire(body.result)`), so a server-side decode is both wrong (it makes + `jsonResponse`'s `JSON.stringify` throw on a bigint → a redacted 500) and + unnecessary. 300's S1 was reverted for exactly that, and `shard-engine`'s + introspect test now pins it. These tools sit on the **client** side of that + boundary, so they receive already-decoded values — real `bigint`/`ArrayBuffer` + — and the correct handling is the one `tools.ts` already had: map them at the + JSON boundary rather than decode or re-encode anything. Nothing here decodes an + admin result, and nothing hands one to `decodeWire` a second time. + +- **Risk:** log/issue payloads carry user data straight into an agent's context + (and thus into a model provider). Mitigate: the token tier is the control, and + the docs must say plainly what these tools expose. If field-level redaction is + wanted, that is its own plan — do not half-redact here. +- **Risk:** five more always-listed tools crowd the agent's tool budget. + Mitigate: keep the scope at five, and keep descriptions one sentence. +- **Risk:** `ToolDefinition`/`ToolResult` are exported types; widening them is + an API-surface change. Mitigate: both new fields are optional, and + `api:update` runs after a fresh build (a stale `dist/` writes a wrong snapshot). + +## 9. Open questions (answered during execution) + +1. Which MCP protocol revision does the server advertise, and does it match the + one that introduced `structuredContent`/`outputSchema`? If not, bump the + advertised version in the same change or the field is ignored. +2. Do the admin reads accept a shard key, and should the tools expose it? A + sharded deployment's logs are per-shard; a tool that silently reads one shard + would be misleading. +3. Is `getRequestLog` (single request detail) worth a sixth tool, paired with + `getLogs` for drill-down? +4. Should `lunora_get_advisories` reuse the `lunora advisor` CLI's severity + filtering, or return everything and let the agent filter? +5. Does the hosted/stateless server (`serve-stateless.ts`, `paid.ts`) resolve an + admin token the same way, or does the token tier need a different rule there? + +### Answers + +1. **No revision is advertised by us, and none needs bumping.** `server.ts` + passes only `{ name, version }` (an `Implementation`, not a protocol + revision); the SDK's `Server` negotiates the protocol itself. At + `@modelcontextprotocol/sdk` 1.29.0 that is `SUPPORTED_PROTOCOL_VERSIONS = +["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]`, and + `structuredContent` / `outputSchema` arrived in **2025-06-18** — inside the + supported set, and both are first-class fields on the SDK's `ToolSchema` / + `CallToolResultSchema`, so they survive rather than being stripped as unknown + keys. A client that negotiates `2025-03-26` (also the SDK's + `DEFAULT_NEGOTIATED_PROTOCOL_VERSION` when a client asks for something + unknown) simply ignores both fields — which is the whole reason the text block + is still emitted. Server-level tests assert `outputSchema` survives + `ListTools` and `structuredContent` survives a `CallTool` round trip. +2. **Yes, and they expose it.** Every one of these reads is served by the shard + that handled the traffic — the studio passes `shardKey` to exactly these RPCs + — so on a `.shardBy()` deployment "the logs" are per-shard. All five tools + take an optional `shardKey`, and its description says the reads are per-shard + rather than deployment-wide, because silently reading the default shard and + presenting it as the deployment is the misleading outcome the question names. +3. **Not now.** `getIssues` already carries the sample message and the grouping + hash, which is the drill-down an agent acts on; a sixth tool costs context on + every turn for a detail view that mainly pays off in a UI. Left deferred with + traces/metrics, per §4's scope decision. +4. **Return everything (bounded by `limit`), no severity filter.** The advisory + set is small and each finding already carries its `level`, so the agent can + filter what it has; a severity knob would be a second filtering vocabulary to + keep in sync with the CLI's, for no new capability. +5. **Same rule, and it lands correctly by construction.** `serve-stateless.ts` + is a transport helper with no token resolution, and `paid.ts` composes tools + rather than building the deployment server, so both reach the gate through + `createLunoraMcpServer` / `callTool`. A hosted server started without a + `token` option therefore advertises no observability tools and refuses them at + dispatch — the fail-closed default, which is the right one for the surface + most likely to be public. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3ec8ab8ed4..6c85c865fb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3442,6 +3442,9 @@ importers: '@lunora/errors': specifier: workspace:* version: link:../errors + '@lunora/shard-engine': + specifier: workspace:* + version: link:../shard-engine '@modelcontextprotocol/sdk': specifier: catalog:mcp version: 1.29.0(supports-color@10.2.2)(zod@4.4.3) From ebef816f9941b9755ba69809f6e85cc6ecb870dd Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 07:17:40 +0200 Subject: [PATCH 5/8] docs(plans): record wave 20 outcomes and retire the plans All four wave-20 plans shipped. Per the plans/ convention the plan files are removed once complete; the index carries what shipped, what each plan got wrong, and the follow-ups the wave opened. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- plans/307-doctor-machine-readable.md | 241 -------------- ...-deploy-result-identity-and-health-gate.md | 278 ---------------- plans/309-mcp-observability-tools.md | 296 ------------------ plans/310-worker-size-budget.md | 267 ---------------- plans/README.md | 79 +++++ 5 files changed, 79 insertions(+), 1082 deletions(-) delete mode 100644 plans/307-doctor-machine-readable.md delete mode 100644 plans/308-deploy-result-identity-and-health-gate.md delete mode 100644 plans/309-mcp-observability-tools.md delete mode 100644 plans/310-worker-size-budget.md diff --git a/plans/307-doctor-machine-readable.md b/plans/307-doctor-machine-readable.md deleted file mode 100644 index 3dc25a5ebc..0000000000 --- a/plans/307-doctor-machine-readable.md +++ /dev/null @@ -1,241 +0,0 @@ -# Plan 307 — `lunora doctor` emits a stable machine-readable report - -**Baseline:** `370994075` (2026-08-08) -**Status:** DONE (branch `feat/plan-307-doctor-json`) - -## 0. Headline finding - -`lunora doctor` is the only project-preflight command with **no options at all** -(`packages/cli/src/commands/doctor/index.ts:19` — `options: []`) and findings -that carry **no identifier** (`Finding` is `{ level, message, fix? }`, -`handler.ts:17-23`). Every other gate in the CLI — `deploy`, `verify`, `build`, -`logs`, `codegen`, `insights` — already takes `--format json`. So the one -command whose entire job is "tell me what is wrong with this project" is the one -an agent or CI job cannot consume: it must scrape prose from stderr, and the -prose is not a contract. - -## 1. Current state (audit) - -- `runDoctor` (`handler.ts:401-420`) runs 8 checks and returns - `{ code, findings }`; `code` is 1 iff any finding is `fail`. -- `execute` (`handler.ts:456-462`) calls `renderReport`, which prints - `[FAIL] ` / `fix: ` lines through the logger. Nothing else is - emitted; `DoctorResult` never leaves the process. -- The 8 checks are `checkWrangler`, `checkD1Placeholders`, - `checkEmailDestination`, `checkDevVariables`, `checkAdminToken`, - `checkVersionSkew`, `checkVectorMetadataIndexes`, `checkDeclaredExports` - (`handler.ts:70`, `:131`, `:154`, `:177`, `:206`, `:318`, `:106`, `:233`). - Between them they push ~20 distinct findings, each identified only by its - English sentence. -- `checkVersionSkew` (`handler.ts:318`) already detects **dependency** version - drift across `@lunora/*` + `lunorash`. It does not detect the _other_ skew - that bites in practice: a globally-installed `lunora` binary shadowing the - project's pinned one, so the report describes a project the running CLI is not - the right version for. -- No check has a fix that the command can apply itself; `fix` is always prose - for a human to execute. - -## 2. Existing seams (do not reinvent) - -- **`packages/cli/src/util/output-format.ts:46`** — `validateOutputFormat`, - `isJsonFormat`, `loggerForFormat`, `printJson`. This is the whole `--format -json` contract, already used by `deploy` (`deploy/handler.ts:1384-1400`): - validate the flag, route human logging to stderr, print exactly one JSON - document to stdout. Reuse it verbatim — do not invent a second JSON path. -- **`runDoctor`** is already a pure, logger-free core returning a structured - result. The only change it needs is a field per finding; the rendering split - is already correct. -- **`packages/cli/src/util/logger.ts`** — the `Logger` interface `renderReport` - writes through. -- `TARGET_OPTION` (`packages/cli/src/util/deploy-target.ts`) if the checks ever - need to differ per platform target; not required by this plan. - -## 3. The behavioural contract to preserve - -- Exit code stays 1 iff any finding is `fail`, 0 otherwise — in both formats. -- Default (`pretty`) output is byte-identical to today. This plan adds a format, - it does not restyle the existing report. -- `--format json` puts **exactly one** JSON document on stdout and nothing else; - all progress/human text goes to stderr (the rule `deploy` already follows). -- `runDoctor` stays pure and exported (`handler.ts:464`) — the doctor core is - consumed by tests directly and must not gain a logger dependency. - -## 4. Design decisions - -- **A `code` field on `Finding`, not a code table keyed by message.** Chosen - over deriving stable ids from message text (fragile: a copy-edit silently - renames a diagnostic) and over a central registry object (a second place to - forget to update). The code lives at the push site, next to the message it - names. -- **`kebab-case` string codes namespaced by check** (`wrangler-missing`, - `d1-placeholder-id`, `dev-vars-missing-secret`, `version-skew-cores`, …), - not numbers. Numbers imply an ordering and get renumbered; strings survive - reordering and read in a diff. -- **Codes are a public contract, snapshot-tested.** A committed fixture listing - every code the doctor can emit, asserted by a test. Chosen over documenting - them in Markdown only — prose drifts, a failing test does not. This is what - makes the codes safe for an agent to branch on. -- **No `--fix` in this plan.** Applying fixes means writing to `wrangler.jsonc` - and `.dev.vars`, which `lunora add` / `env generate` / `init` already own. - Deferred as an open question rather than half-built here. -- **CLI-shadow detection is a new check, not an extension of - `checkVersionSkew`.** Different failure (wrong binary vs wrong dependency - tree), different fix, so it gets its own code and its own function. - -## 5. Workstreams - -**S — `Finding.code`.** Add a required `code: string` to `Finding` -(`handler.ts:17`); fill it at all ~20 push sites. Type-level: make it a union of -the literal codes so a typo fails `lint:types` rather than shipping. - -**Done.** 17 codes in a `DOCTOR_CODES` `as const` array; `DoctorCode` is -`(typeof DOCTOR_CODES)[number]`, so the union has exactly one source. Filled at -all 16 pre-existing push sites plus the new `cli-shadowed`. - -**S — `--format json`.** Declare the option in `doctor/index.ts` (copy the -description string from `deploy/index.ts`), thread it through `execute`, and -gate `renderReport` behind `isJsonFormat`. Document shape: - -```jsonc -{ - "ok": false, - "code": 1, - "summary": { "fail": 1, "warn": 2, "info": 3, "pass": 4 }, - "findings": [{ "code": "d1-placeholder-id", "level": "fail", "message": "…", "fix": "…" }], -} -``` - -`ok` is redundant with `code` on purpose — it is the field a shell-free consumer -reaches for first. - -**Done.** `ok` and `summary` were added to `DoctorResult` itself rather than to -a second JSON-only type — one shape means `printJson(result)` needs no mapping -layer, and `renderReport`'s summary line now reads `result.summary` instead of -re-filtering the findings. The format plumbing follows `verify` exactly: an -exported `runDoctorCommand({ cwd, format, logger })` validates the flag, picks -the logger via `loggerForFormat`, renders, then `printJson` in json mode; -`execute` is a three-line wrapper. Rendering happens in **both** formats — in -json mode it lands on stderr, which is what makes the phase-1 gate assertable. - -**S — code snapshot test.** A test that collects every code from the union type -(or a `DOCTOR_CODES` const the union derives from) and asserts it against a -committed sorted list. Adding a code is then a deliberate one-line fixture -update; removing or renaming one fails loudly. - -**Done, with one deviation.** The committed fixture _is the docs table_ — the -test parses the code column out of `packages/cli/docs/index.mdx` and asserts it -equals `DOCTOR_CODES`. A separate fixture file would have made three artefacts -to keep in step (const, fixture, docs table) where the plan explicitly wanted -two; asserting the docs directly collapses it to two and makes the docs the -thing that fails, which is the one that was going to rot. A second test asserts -`DOCTOR_CODES` is itself sorted and duplicate-free. Verified to bite: adding a -code locally without touching the table fails the suite. - -**S — `checkCliShadow`.** Compare the resolved `lunora` executable against the -project's `node_modules/.bin/lunora`, resolving symlinks on both sides -(`node:fs.realpathSync`) so a pnpm-linked bin does not read as a mismatch. Emit -`warn` + code `cli-shadowed` when they differ, with the fix naming the -project-local invocation (`pnpm exec lunora …`). Skip silently when the project -has no local install — that is a global-only project, not a defect. - -**Done — but the plan's comparison is wrong and was not used.** pnpm does not -symlink `node_modules/.bin/*`; it writes a **POSIX shell shim** (verified: -`file node_modules/.bin/vitest` → "POSIX shell script text executable"). So -`realpathSync("node_modules/.bin/lunora")` resolves to the shim script itself, -never to the `dist/bin.mjs` that `process.argv[1]` names — path equality would -have emitted `cli-shadowed` on **every pnpm project**, which is precisely the -false positive §8 warns about. The shipped check tests _containment_ instead: -realpath the project's installed CLI package dirs (`node_modules/@lunora/cli`, -`node_modules/lunorash`) and ask whether the running module lives inside one. -That holds for pnpm's symlinked package dirs, npm/yarn's hoisted ones, and a -launch through the bin shim alike. `RunDoctorOptions` gained an optional -`executablePath` (defaulting to `process.argv[1]`) — the same test seam `cwd` -already provides, since a test cannot relocate the running process. Three -fixtures: pnpm-symlinked layout reports clean, an outside binary warns exactly -once and never fails, no local install skips silently. - -**S — docs.** The CLI reference page for `doctor` gains the `--format json` -example and the code table. The table is generated from the same const the -snapshot test reads, or it is a third place to forget. - -**Done.** `packages/cli/docs/index.mdx` gained the CLI-shadow bullet, the -`--format json` invocation, a "Machine-readable output" section with the -document shape, and the 17-row code table the snapshot test asserts against. - -## 6. Platform parity - -Not applicable. This plan touches no `ctx.*` surface, no provider binding, and -no deploy/runtime capability — `lunora doctor` is a local, read-only CLI report, -and every check it performs is already target-agnostic or reads `wrangler.jsonc` -directly. - -## 7. Phasing & ordering - -| Phase | Work | Gate | -| ----- | -------------------------------- | ------------------------------------------------------------------------------------------------- | -| 0 | `Finding.code` + literal union | `pnpm --filter "@lunora/cli" run lint:types` green with the union in place (a missing code fails) | -| 1 | `--format json` + `ok`/`summary` | New test: `--format json` stdout parses as one document and stderr carries the human lines | -| 2 | Code snapshot fixture | Test fails when a code is added without updating the fixture (assert by adding one locally) | -| 3 | `checkCliShadow` | Test with a fixture cwd whose local bin realpath differs → exactly one `cli-shadowed` warn | -| 4 | Docs | `pnpm run lint:prettier` clean; the code table matches the fixture | - -**All five gates met.** `pnpm --filter "@lunora/cli" run test` → 87 files, 1181 -tests passed (24 in `doctor.test.ts`, up from 13). `lint:types` clean. Prettier -then ESLint clean on all four touched files. `pnpm run api:check` green with no -snapshot delta. Manual smoke on a scratch project confirms the two formats: -`--format json` emits one document on stdout with the human report on stderr, -plain `lunora doctor` is unchanged. - -## 8. Risks & STOP conditions - -- **STOP** if `Finding` turns out to be re-exported and consumed outside the CLI - (`handler.ts:464` exports the type) — a required `code` would then be a - breaking change for that consumer. Check `api-snapshots/cli.api.md` first; if - `Finding` is in the public surface, `pnpm run api:update` after a fresh build - is part of this plan, not an afterthought. -- **Risk:** `checkCliShadow` false-positives under pnpm's symlinked bins and - makes every run warn. Mitigate: compare `realpathSync` on both sides, and add - the pnpm-linked layout as an explicit test fixture that must report clean. -- **Risk:** the JSON document grows a field later and breaks a consumer. - Mitigate: additive-only changes; the snapshot fixture makes a removal visible. - -## 9. Open questions (answered during execution) - -1. **Does `Finding` appear in `api-snapshots/cli.api.md`? — No.** Neither - `Finding`, `DoctorResult`, nor `runDoctor` is in the CLI's public surface; - the package exports the binary plus `runCli`/`COMMANDS`, and the doctor - handler is reached only through the lazy command loader. `pnpm run api:check` - after a fresh `pnpm --filter "@lunora/cli" run build` reports "Public API - surface matches all 47 committed snapshots" with no snapshot edit. The §8 - STOP condition therefore does not apply, and the required `code` breaks no - external consumer. (The doctor's own contract is still guarded — by the - docs-table test, not by the api snapshot.) -2. **Should `pass` findings be in the JSON document? — Yes, include them.** - The document then describes everything that was checked rather than only what - went wrong, which is what lets a consumer distinguish "the export check passed" - from "the export check did not run" — a distinction `summary.pass` alone - cannot make, and one that matters because most checks skip silently when their - input is absent. Cost is a handful of extra objects. A test asserts - `summary.pass` equals the number of `pass` findings actually present, so the - two cannot disagree. -3. **Is `--fix` worth a follow-up plan? — Yes, but a small one, and only for - three codes.** `d1-placeholder-id` cannot be fixed offline (it needs - `wrangler d1 create`). `wrangler-missing` / `wrangler-shard-binding-missing` - are already `lunora init` / `lunora dev`'s job, and `dev-vars-missing-secret` - is already `lunora dev`'s. That leaves `declared-export-missing` (append one - `export * from …` line to the worker entry), `vector-metadata-index-required` - (shell out to the wrangler command the finding already prints verbatim), and - `cli-shadowed` (re-exec through the local install). Only the first is a file - write nothing else owns; the honest scope of a `--fix` plan is that one - check, which makes it hard to justify as its own flag rather than as a step - in the generators. Recommendation: skip the flag, and instead have - `declared-export-missing` name the exact line to paste (it already does). -4. **Should `lunora verify` embed the doctor findings? — No, keep them - separate.** They fail for different reasons and on different inputs: `verify` - is a build gate (codegen + `tsc`) whose failure means the code is wrong, while - `doctor` is a configuration gate whose failures are mostly `warn`/`info` and - frequently deliberate. Embedding would either promote doctor warnings into - verify's exit code (blocking CI on a mixed alpha channel) or bury them in a - result nobody reads. Both already emit the same `--format json` envelope, so - an agent wanting one answer runs two commands and merges two documents — a - cheaper coupling than a shared exit code. diff --git a/plans/308-deploy-result-identity-and-health-gate.md b/plans/308-deploy-result-identity-and-health-gate.md deleted file mode 100644 index 7497cf570b..0000000000 --- a/plans/308-deploy-result-identity-and-health-gate.md +++ /dev/null @@ -1,278 +0,0 @@ -# Plan 308 — `lunora deploy` reports what it deployed, and proves it answers - -**Baseline:** `370994075` (2026-08-08) -**Status:** DONE — shipped on `feat/plan-308-deploy-identity` - -## 0. Headline finding - -`lunora deploy` already parses the deployed URL out of wrangler's output -(`packages/cli/src/util/auto-link.ts`) — but the gate that enables it turns it -**off in exactly the two cases that need it most**: - -```ts -// packages/cli/src/commands/deploy/handler.ts:1340 -const shouldAutoLink = !isJsonFormat(options.format) && options.dryRun !== true && options.preview !== true && readLinkedProject(cwd) === undefined; -``` - -`--format json` — the machine-readable path an automation uses — never captures -the URL, and `DeployCommandResult` (`handler.ts:179-201`) has no field to carry -one anyway. So `lunora deploy --format json` returns a document that cannot tell -a caller _where the thing it just deployed lives_. `--preview` is likewise -excluded, and the code says so out loud: "see the preview URL in the wrangler -output above" (`handler.ts:1413`) — i.e. the CLI hands a machine-readable -command back to a human to read with their eyes. - -Separately: nothing after a successful deploy proves the new version answers. -The probe exists (`runHealthProbeStep`, `verify/handler.ts:97`, against -`/_lunora/health`), but only as an opt-in flag on a _different_ command. - -## 1. Current state (audit) - -**URL capture (partially built, gated off where it counts):** - -- `autoLinkFromDeployOutput` (`util/auto-link.ts`) parses a `*.workers.dev` - origin (falling back to the first https URL) out of captured stdout and writes - `.lunora/project.json` via `writeLinkedProject`. Best-effort; never throws. -- `handler.ts:1346` sets `captureStdout: shouldAutoLink` and `:1373` calls the - linker with `result.stdout`. -- `shouldAutoLink` (`:1340`) is false when: `--format json`, `--dry-run`, - `--preview`, **or the checkout is already linked**. -- `SpawnDescriptor.captureStdoutSilently` (`util/spawn.ts`) exists precisely for - "capture without teeing to stdout, so `--format json` stays one document" — - and is not used by the deploy path. - -**What the result document carries** (`handler.ts:179-201`): `code`, -`descriptor`, `error?`, `mintedSecretsFile?`, `schemaDrift?`, `validation`. No -`url`, no version id, no timestamp, no `dryRun`/`preview` discriminator. - -**Downstream consequences of the missing identity:** - -- `deploy --migrate` refuses rather than defaulting: "`--migrate requires ---migrate-url — the deploy target URL is not captured -automatically, refusing to default to localhost`" (`handler.ts:843-850`). It - resolves from the _link file_ (`resolveWorkerUrl`, `util/resolve-target.ts:37`), - not from the deploy that just ran — so a first deploy in a fresh CI checkout - has no URL at the moment it needs one. -- The deploy summary asks the human to type it back in: "`url: run \`lunora link - --url \` to record it`" (`util/deploy-summary.ts:52`). -- A checkout that is already linked never re-captures (`readLinkedProject(cwd) -=== undefined` in the gate), so a URL that _changed_ — custom domain added, - worker renamed, environment repointed — leaves a stale link that - `run` / `logs` / `insights` / `--migrate` then silently target. - -**Health:** `/_lunora/health` (aggregate, 503 on a downed critical dependency) -and `/_lunora/health/ready` (readiness gate) are auto-registered by the runtime -(`packages/runtime/src/health-routes.ts:32-33`, `create-worker.ts:4433`). -`lunora verify --health-url` probes the first one once, no retry -(`verify/handler.ts:97-116`). `lunora deploy` never probes anything. - -## 2. Existing seams (do not reinvent) - -- **`autoLinkFromDeployOutput` + `parseDeployedUrl`** (`util/auto-link.ts`) — - the URL extractor. This plan changes _when_ it runs and _what else_ consumes - its result; it should not grow a second parser. -- **`SpawnDescriptor.captureStdoutSilently`** (`util/spawn.ts`) — capture in - JSON mode without corrupting the single stdout document. Already documented - for this exact hazard. -- **`runHealthProbeStep`** (`verify/handler.ts:97`) — the probe. Lift it to a - shared util (`util/health-probe.ts`) so `verify` and `deploy` share one - implementation and one error-message shape. -- **`readLinkedProject` / `writeLinkedProject` / `LinkedProject`** - (`packages/config/src/linked-project.ts`) — the `.lunora/project.json` record. - It already carries `env`, `linkedAt`, `workerName`, `workerUrl`. -- **`isJsonFormat` / `printJson`** (`util/output-format.ts:46`) — the JSON - contract. -- **`resolveWorkerUrl`'s environment guard** (`util/resolve-target.ts:37-49`) — - a link recorded for one `--env` must never stand in for another. Any new write - path must preserve that invariant, not route around it. - -## 3. The behavioural contract to preserve - -- `--format json` emits **exactly one** JSON document on stdout. Capturing - wrangler's stdout must use `captureStdoutSilently`, never `captureStdout`. -- Link writes stay **best-effort**: a failed capture, parse, or write must never - change the deploy's exit code (`auto-link.ts` is explicit about this). -- The `resolveWorkerUrl` env guard holds: a `production` link never supplies its - URL to a `--env staging` command. -- `--dry-run` publishes nothing, so it must never write a link, never report a - URL, and must be distinguishable in the JSON document from a real deploy. -- Additive JSON fields only — existing keys keep their names and meanings. - -## 4. Design decisions - -- **Capture on every successful real deploy, not only unlinked ones.** Chosen - over today's first-deploy-only rule because the failure it prevents (a stale - link silently misrouting `--migrate` at a decommissioned URL) is worse than - the one it causes (overwriting a hand-set link). -- **…but never silently overwrite a hand-written link.** When a link already - exists for this `env` and the parsed URL differs, **warn and keep the existing - value**; do not rewrite. Chosen over overwrite (surprising, and `lunora link` - is an explicit user act) and over silence (the stale-link failure above). The - warning names both URLs and the one command that resolves it. -- **A `deployment` object in the result document, not loose top-level keys.** - `{ url, workerName, env, versionId?, dryRun, preview, deployedAt }` — one - nested object keeps the discriminators next to the identity they qualify, and - leaves room for a version id without another top-level field per release. -- **`dryRun` and `preview` are reported as booleans in the document, always.** - A consumer must be able to tell "nothing went live" from "went live" without - inferring it from a missing `url`. -- **Health probe is opt-in via `--health-check`, not on by default.** Chosen - over always-on: `deploy` must stay usable against a worker whose health route - is admin-gated or unreachable from CI, and a default-on network step turns a - successful deploy into a red build for an unrelated reason. The flag is what - a release pipeline opts into deliberately. -- **The probe retries with a bounded budget; it does not poll forever.** A - fresh deploy propagates, so a single immediate probe is a coin flip. Fixed - attempt budget with a fixed delay, not exponential backoff — the wait is - seconds, and a predictable ceiling is what a CI timeout can be set against. -- **Probe `/_lunora/health/ready`, falling back to `/_lunora/health`.** The - readiness gate is the one that answers "can this version serve"; the aggregate - is the one that exists on older deployments. `verify` keeps its current - aggregate-only behaviour unless the shared util makes both trivial. - -## 5. Workstreams - -**S — result identity.** Add `deployment?: { deployedAt, dryRun, env?, preview, -url?, versionId?, workerName? }` to `DeployCommandResult` (`handler.ts:179`). -Populate from the captured output + `readWranglerName`. Emit in the JSON -document; the pretty summary keeps its current shape but reads the URL from the -result rather than from the link file. - -**Done.** `DeployedIdentity` (exported from `handler.ts` and the package index) -carries `{ deployedAt, dryRun, env?, preview, url?, workerName? }` — **no -`versionId`**, see Q1. Built in `completeDeploy` once wrangler exits 0, present -on dry runs and previews too. `renderDeploySummary` grew a `url?` input that -wins over the link file. Snapshot delta committed in `api-snapshots/cli.api.md`. - -**S — capture in JSON and preview modes.** Split `shouldAutoLink` into two -decisions: _should we capture_ (yes on any successful non-dry-run wrangler -invocation, silently when `isJsonFormat`) and _should we write the link_ (the -existing rules, plus the mismatch warning from §4). This is the core fix — -`--format json` and `--preview` both start reporting a URL. - -**Done.** `buildDeploySpawn` owns the split: `captureStdout` (tees) in pretty -mode, `captureStdoutSilently` in json mode, neither on a dry run. In json mode -the buffered output is replayed to **stderr** after the spawn, so `--format -json` still shows the wrangler log in CI without touching the document — -`stdoutToStderr` no longer applies there (nothing is inherited to redirect). - -**M — link refresh + mismatch warning.** Rework `autoLinkFromDeployOutput` to -take the existing link into account: write when absent, warn-and-keep when -present-and-different, no-op when present-and-equal. Keep it best-effort and -keep the `env` scoping. - -**Done.** It now takes the parsed `url` (the handler needs it for `deployment` -anyway, so `parseDeployedUrl` runs once) instead of raw `output`. A link -recorded for a DIFFERENT `--env` counts as a mismatch, not a target to -overwrite — the file holds one link, and clobbering the production one with a -staging URL is the failure `resolveWorkerUrl`'s guard exists to prevent. - -**M — `--health-check`.** Lift `runHealthProbeStep` into -`util/health-probe.ts`, give it an attempt budget + delay + injectable fetch and -clock, and call it from `deploy` after a successful real deploy when the flag is -set, using the URL this run just captured (falling back to the link, then -refusing with a clear message when neither exists). A failed probe fails the -deploy command's exit code — that is the point of the flag — and the reason -lands in the JSON document. `verify` switches to the shared util. - -**Done.** `probeHealth` in `util/health-probe.ts`: ordered `paths` (a 404 falls -through to the next, anything else is the verdict), `attempts`/`delayMs`, -injectable `fetchImpl` + `sleep`. Deploy probes `[ready, aggregate]` 5× at 2s; -`verify` keeps its single aggregate probe by taking the defaults, so its message -shape and call signature are byte-identical. The probe runs BEFORE `--migrate` -— a worker that can't serve is not one to migrate — and its verdict lands in -`result.healthCheck { error?, ok, url }`. - -**S — docs.** CLI reference for `deploy`: the new flag, the `deployment` object, -and one worked CI example (`deploy --format json --health-check`, then read -`.deployment.url` for the smoke step). - -**Done.** `packages/cli/docs/index.mdx` — cheat-sheet line, a `--health-check` -subsection, a "Machine-readable output" subsection with the annotated document -and the `jq -r '.deployment.url'` smoke-step example, plus the rewritten -link/preview paragraphs. - -## 6. Platform parity - -Not applicable to the `ctx.*` matrix — this plan adds no runtime surface and no -provider binding. One target-facing note: the URL parser and the health probe -are Cloudflare-shaped (`*.workers.dev`, `wrangler deploy` stdout). The -`@lunora/platform-node` deploy driver in `@lunora/config` has its own notion of -a deployed endpoint; this plan does not extend to it, and the parser must stay -behind the Cloudflare deploy path rather than being presented as target-neutral. -`/_lunora/health` itself is runtime-level and therefore available on any host -that mounts the runtime. - -## 7. Phasing & ordering - -| Phase | Work | Gate | -| ----- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------ | -| 0 | `deployment` on the result | Test: `--format json` on a stubbed spawner yields one parseable document containing `deployment.url` | -| 1 | Silent capture in JSON mode | Test: stdout is exactly one JSON document (no wrangler text interleaved) while `deployment.url` is still populated | -| 2 | Preview capture | Test: `--preview --format json` reports the preview URL; `handler.ts:1413`'s "read it above" message is gone | -| 3 | Link refresh + mismatch warning | Three tests: absent → written; equal → no write, no warning; different → warning, original value preserved on disk | -| 4 | `--health-check` + shared util | Tests: probe passes → exit 0; probe 503s on every attempt → non-zero exit + reason in the document; `verify` green | -| 5 | Docs | `pnpm run lint:prettier` clean | - -**All phases done.** Gates: `pnpm --filter "@lunora/cli" run test` → 88 files / -1191 tests passed; `lint:types`, `lint:eslint` (`--max-warnings=0`), -`lint:prettier` clean; `pnpm run api:check` green after `api:update`. - -One plan correction: §7 phase 2 expects `handler.ts:1413`'s "read it above" -message to be **gone**. It is replaced, not deleted — a preview whose output -carried no URL still needs a success line, so it now reads `preview version -uploaded — ` and falls back to a bare `preview version uploaded`. - -## 8. Risks & STOP conditions - -- **STOP** if wrangler's deploy output stops containing a URL in a supported - version (or moves it behind its own machine-readable flag). Prefer wrangler's - own structured output over regex-scraping the moment it is available on the - pinned version — re-scope rather than hardening the regex further. -- **Risk:** capturing stdout changes wrangler's TTY behaviour (progress - rendering, colour) for the non-JSON path. Mitigate: keep `captureStdout` - (which tees) for pretty mode and `captureStdoutSilently` only for JSON mode — - the split the spawn util already anticipates. -- **Risk:** the mismatch warning fires on every deploy for projects whose URL - legitimately varies (preview/temporary origins). Mitigate: only compare for - real, non-preview, non-temporary deploys of the same `env`. -- **Risk:** `--health-check` flakes on cold propagation and reads as a broken - deploy. Mitigate: bounded retry (§4), and the failure message must state that - the deploy _succeeded_ and the probe did not — those are different facts. - -## 9. Open questions (answered during execution) - -1. **No — shipped without `versionId`.** `wrangler deploy --help` on the pinned - 4.114.0 offers no `--format`/`--json` and no structured deploy output; the - only version id is the `Current Version ID: ` line in the human prose, - and scraping a second value out of prose is exactly what §8's STOP condition - warns against. `DeployedIdentity` therefore has no `versionId`, and the docs - point at `lunora deployments list` for it. Revisit if wrangler ships - structured output. -2. **No.** `--health-check` probes the origin the deploy just published to (then - the link for this `--env`, then refuses). A public origin that differs from - the deploy origin is already served by `lunora verify --health-url `, - which now runs the same probe — a second URL flag on `deploy` would be a - config knob with one existing caller. -3. **Parseable, but no link.** `--temporary` prints the same `*.workers.dev` - origin, so `deployment.url` reports it — but the account is deleted in ~60 - minutes, so writing it as the checkout's recorded target would silently - misroute `run` / `logs` / `--migrate` afterwards. The link write is skipped - for `--temporary` (tested). -4. **No `--relink`.** The mismatch warning names the exact `lunora link --url - [--env ]` to run. A flag for it would be a second way to do the - same thing, on the rare path. -5. **Verify keeps the aggregate probe.** It validates a checkout rather than - gating a release, so "is this deployment healthy right now" is the whole - question — one attempt, one route, unchanged message shape. The shared util - makes both trivially available if that ever changes. - -### Follow-up not taken here - -`deploy --migrate` still requires `--migrate-url` (or a link) rather than -defaulting to the URL this run captured: the preflight refusal runs _before_ -wrangler, so the captured URL does not exist yet at the point of the gate. -Deferring that gate until after the spawn is a real change to when a `--migrate` -run can abort, and is outside §5's workstreams. The stale message ("the deploy -target URL is not captured automatically") was corrected to say why the gate -cannot use it. diff --git a/plans/309-mcp-observability-tools.md b/plans/309-mcp-observability-tools.md deleted file mode 100644 index 2d14b004f0..0000000000 --- a/plans/309-mcp-observability-tools.md +++ /dev/null @@ -1,296 +0,0 @@ -# Plan 309 — MCP exposes the observability reads an agent needs to debug - -**Baseline:** `370994075` (2026-08-08) -**Status:** DONE — `feat/plan-309-mcp-observability` - -## 0. Headline finding - -The MCP server exposes **six** tools (`packages/mcp/src/tools.ts:39-89`): -`list_functions`, `list_tables`, `get_function_schema`, `run_query`, and — only -behind `allowWrites` — `run_mutation`, `run_action`. An agent can therefore -_call_ a deployment but cannot _see what happened_: no logs, no grouped issues, -no advisor findings, no query insights, no migration status. Every one of those -already exists as an admin RPC (`packages/shard-engine/src/introspect.ts:50` — -`getLogs`, `getIssues`, `getTraces`, `getAdvisories`, `getQueryInsights`, -`getFunctionStats`, `migrationStatus`, …) and already backs the Studio and the -`lunora logs` / `insights` / `advisor` commands. The data is built; only the -MCP surface over it is missing. - -Second finding, smaller and mechanical: every tool returns -`{ content: [{ type: "text", text }] }` (`tool-types.ts:47-50`). There is no -`outputSchema` on `ToolDefinition` and no `structuredContent` on the result, so -a client receives JSON stringified inside a text block and must re-parse it -without a schema to validate against. - -## 1. Current state (audit) - -- **Tool surface** (`packages/mcp/src/tools.ts`): four read-only definitions - (`:39-64`), two write definitions (`:68-89`), gated by `allowWrites` in - `toolDefinitions()` (`:96+`) and re-checked at dispatch - (`server.ts:142-164`). The gate is sound and fails closed — this plan does not - touch it. -- **Result shape** (`tool-types.ts:47-50`): `ToolResult` is - `{ content: { text, type: "text" }[]; isError?: boolean }`. `ToolDefinition` - (`:41-46`) carries `annotations`, `description`, `inputSchema`, `name` — no - `outputSchema`. -- **Transport/auth** (`packages/cli/src/commands/mcp/index.ts:42-46`): `serve` - takes `--allow-writes`, `--url` (defaults to the running dev server), and - `--token` (defaults to `LUNORA_ADMIN_TOKEN` from the environment or - `.dev.vars`). So an admin bearer token is already resolved and in hand. -- **How the CLI reaches admin RPC today**: by hardcoding the op string per - command — `const GET_FUNCTION_STATS_OP = "__lunora_admin__:getFunctionStats"` - (`packages/cli/src/commands/insights/handler.ts:10`), with the bearer token - attached at the call site. There is no shared admin-RPC client; each consumer - re-derives the path and the fetch. -- **`@lunora/client`** exposes no admin surface (`grep admin packages/client/src/index.ts` - is empty), so the MCP server's existing `LunoraClient` dependency cannot reach - these reads as-is. - - > **Correction (execution).** This is wrong, and it is what §5's "shared admin - > caller" workstream was sized around. The `__lunora_admin__:*` ops are not a - > separate endpoint: they ride the ordinary `POST /_lunora/rpc` envelope - > (`{ args, functionPath, shardKey }`) and the shard intercepts them before - > user dispatch — which is exactly what `LunoraClient.rpc` sends, and what the - > studio already does through `useAdminQuery`. So `client.query({ __lunoraRef: -ADMIN_FUNCTIONS.getLogs }, args, { shardKey })` reaches every one of these - > reads today, with the bearer, the `{ error }` envelope and the response - > decode all being the client's existing behaviour. No new caller module was - > written; `adminRead` in `observability-tools.ts` is a three-line wrapper over - > `client.query`. - -## 2. Existing seams (do not reinvent) - -- **`ADMIN_FUNCTIONS`** (`packages/shard-engine/src/introspect.ts:50`) — the - canonical op-path table. Import it; never re-type `"__lunora_admin__:…"` - string literals (the CLI's copies are the anti-pattern, not the model). -- **`toolDefinitions(allowWrites)` + the dispatch switch** (`tools.ts`, - `server.ts:142-164`) — the existing two-tier gate. Observability tools join it - as a third tier, they do not fork it. -- **`READ_ONLY_ANNOTATIONS`** (`tools.ts:36`) — reuse verbatim. -- **`packages/observability/src/*`** — the read models behind the admin RPCs - (`request-log.ts`, `issue-state.ts`, `query-metrics.ts`, `function-metrics.ts`). - Tools shape their output; they do not re-aggregate. -- **`@lunora/advisor`'s finding shape** — for the advisories tool. -- **`packages/mcp/src/docs`** — the documentation tool surface already shows how - a second tool family composes into this server without touching the first. - -## 3. The behavioural contract to preserve - -- `allowWrites: false` (the default) keeps every write tool **out of - `ListTools`**, not merely refused at dispatch (`server.ts:104`). Adding tools - must not change which names appear at each tier. -- Existing tool names, input schemas, and text-content output stay unchanged. - `structuredContent` is **additive** — clients that read `content[0].text` - today keep working, because the text block is still emitted. -- The server stays usable with no admin token (dev-server default), which means - the new tools must degrade rather than crash the whole server. - -## 4. Design decisions - -- **A third tier: read-only-but-privileged.** Observability tools are read-only - in the `readOnlyHint` sense, but they surface production logs, request - payload metadata, and grouped errors. They are exposed **only when an admin - token resolved**, and omitted from `ListTools` otherwise — the same - omit-don't-refuse rule the write gate already uses. Chosen over folding them - into the always-on read tier (leaks the existence of privileged data on an - unauthenticated server) and over putting them behind `--allow-writes` - (conflates "may change data" with "may read operational data"). -- **Import `ADMIN_FUNCTIONS`; do not copy op strings.** Chosen over the CLI's - existing hardcoded-literal pattern, which is how a renamed op ships a 404 to - one consumer and not another. -- **A small shared admin-RPC caller inside `@lunora/mcp`, not a new public - surface on `@lunora/client`.** Chosen because widening the browser client with - admin RPC would put a privileged path into every app bundle. If a second - non-CLI consumer appears, promote it then — not now. -- **`outputSchema` + `structuredContent`, text block retained.** Chosen over - replacing the text content (breaks existing clients) and over structured-only - for new tools (two result conventions in one server is worse than one - slightly redundant one). -- **Scope: logs, issues, advisories, insights, migration status.** These are the - five an agent actually needs to answer "did my change work, and what broke". - Traces, metric history, subscriptions, queue/workflow inspection are - deliberately deferred — each is a bigger output shape, and an unused tool is - context an agent pays for on every turn. -- **Every tool takes a bounded `limit` with a server-side clamp**, mirroring - `lunora logs --limit` (clamped 1–10000). An unbounded log read is how an agent - burns its whole context on one call. - -## 5. Workstreams - -**S — shared admin caller.** One module in `@lunora/mcp` that takes -`{ url, token }` and an `ADMIN_FUNCTIONS` key, POSTs the RPC, and returns the -parsed result or a `LunoraError`. Reused by all five tools. - -**Done.** — as a three-line wrapper, not a module (see the §1 correction). -`adminRead(client, op, args, shardKey)` in `packages/mcp/src/observability-tools.ts` -calls `client.query`, so the bearer, the error envelope and the wire decode are -the client's, not a second implementation. `@lunora/shard-engine` is now a -dependency of `@lunora/mcp` so `ADMIN_FUNCTIONS` is imported rather than copied -(the cost: `@lunora/errors` + `@lunora/platform` + `drizzle-orm` join the MCP -install tree; the alternative — studio's deliberate copy of the table — is the -drift the plan set out to avoid). - -**M — the five tools.** `lunora_get_logs`, `lunora_get_issues`, -`lunora_get_advisories`, `lunora_get_query_insights`, -`lunora_get_migration_status`. Each: a narrow input schema (`limit`, plus the -filters its RPC already supports — level/function-prefix for logs, status for -issues), `READ_ONLY_ANNOTATIONS` with a real `title`, and a description that -tells the agent _when_ to call it, not just what it returns. - -**Done.** — with three deviations worth the record: - -1. **`getLogs` takes no arguments.** It returns the whole in-memory ring - (≤500 entries), so `limit`/`level` are applied in the tool. `getIssues` does - accept `limit`/`status`/`functionPathPrefix`, so those are pushed down to the - RPC — grouping has to happen over the right row set, not over a page the tool - already truncated. -2. **The clamp is 1–500, not 1–10000.** The binding constraint here is the - model's context window (every row is re-read on every subsequent turn), not - the datastore's. Default 50. -3. **`lunora_get_migration_status` takes no `limit`.** Its list is one row per - declared migration, and dropping the tail would hide exactly the pending - migration the caller is asking about. - -**S — token-tier gating.** `toolDefinitions` learns a third argument (or the -options object grows a resolved-token flag); tools are omitted from `ListTools` -and refused at dispatch when no token resolved. Test both halves — the omission -and the refusal — since the write gate's own tests establish that pattern. - -**Done.** `toolDefinitions(allowWrites, hasAdminToken)` and -`callTool(client, name, input, allowWrites, hasAdminToken)`. `server.ts` derives -the flag from `options.token` (an injected `client` carries no token this server -knows about, and the fail-closed reading of "unknown" is "no privileged tools"). - -`local.ts` — the surface `lunora mcp serve` actually mounts — needed a decision -the plan did not anticipate: its deployment is a **resolver** called per tool -call, so no token is known when the list is built. The advertised list therefore -uses a **snapshot** taken at build time (fail-closed: an editor that spawned the -server before `lunora dev` does not see the observability tools that session), -while dispatch re-checks the freshly resolved token. Both halves are tested, -including the withdrawn-token case where a listed tool is still refused. - -**M — structured output.** Add `outputSchema?: ToolInputSchema` to -`ToolDefinition` and `structuredContent?: unknown` to `ToolResult` -(`tool-types.ts`); populate both for the five new tools; leave the six existing -tools text-only for now (a follow-up can schema them once the shape proves out). - -**Done.** — with one type correction: `structuredContent` is -`Record`, not `unknown`. The MCP schema is -`z.record(z.string(), z.unknown())` — a bare array is invalid there, so the type -has to say so, or a tool returning one would only fail at the wire. - -The sharper part is serialization, and it is the plan-300 STOP condition in -disguise: `structuredContent` is serialized by the **transport**, so an unmapped -`bigint` in it throws at the protocol layer and takes the whole response down -rather than one field. `toJsonSafe` (in the new -`packages/mcp/src/tool-result.ts`) runs it through the same bigint→string / -bytes→base64 replacer the text block already used. Pinned by a test that logs a -`bigint` and a `Uint8Array` through a log entry's `fields`. - -**S — docs + CLI help.** `mcp serve`'s help text says which tools require a -token. The MCP package docs gain the tool table. - -**Done.** `--token`'s description in `packages/cli/src/commands/mcp/index.ts` -now says it gates the observability tools, plus an example line; -`packages/mcp/README.md` gains the five rows and a "privileged" section stating -plainly what they expose (log lines and error messages reaching the model's -provider) and that they are independent of `--allow-writes`. - -## 6. Platform parity - -Not applicable to the `ctx.*` matrix — this plan adds no runtime surface and no -provider binding. It reads existing admin RPCs, which are served by whichever -host mounts the shard engine; nothing here is Cloudflare-specific, so no -capability row changes. - -## 7. Phasing & ordering - -| Phase | Work | Gate | -| ----- | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------- | -| 0 | Shared admin caller | Unit test against a mock fetch: op path comes from `ADMIN_FUNCTIONS`, bearer header set, error maps to `LunoraError` | -| 1 | Token-tier gating | Two tests: no token → the five names absent from `ListTools`; token → present. Dispatch refuses when absent | -| 2 | The five tools | Per-tool test against a mock admin response, including the `limit` clamp at both bounds | -| 3 | `outputSchema`/`structuredContent` | Test: a new tool's result validates against its own declared `outputSchema`, and `content[0].text` is still present | -| 4 | Docs | `pnpm run lint:prettier` clean; `pnpm run api:check` (or `api:update` after a fresh build) for the widened types | - -## 8. Risks & STOP conditions - -- **STOP** on the admin-RPC wire encoding before shaping any output. The admin - RPC path does **not** run results through `encodeWire` while the client still - decodes — see the `admin-rpc-does-not-encodewire` finding and plan **300** - (`plans/300-decode-doc-read-paths.md`). A tool that decodes an admin result - for display will 500 on any row carrying a `bigint` or `ArrayBuffer`. Read 300 - first and follow whatever convention it lands; do not invent a third one here. - - **Resolved — the convention was followed, not reinvented.** Plan 300 landed on - this: the client decodes EVERY response (`rpc()` ends in - `decodeWire(body.result)`), so a server-side decode is both wrong (it makes - `jsonResponse`'s `JSON.stringify` throw on a bigint → a redacted 500) and - unnecessary. 300's S1 was reverted for exactly that, and `shard-engine`'s - introspect test now pins it. These tools sit on the **client** side of that - boundary, so they receive already-decoded values — real `bigint`/`ArrayBuffer` - — and the correct handling is the one `tools.ts` already had: map them at the - JSON boundary rather than decode or re-encode anything. Nothing here decodes an - admin result, and nothing hands one to `decodeWire` a second time. - -- **Risk:** log/issue payloads carry user data straight into an agent's context - (and thus into a model provider). Mitigate: the token tier is the control, and - the docs must say plainly what these tools expose. If field-level redaction is - wanted, that is its own plan — do not half-redact here. -- **Risk:** five more always-listed tools crowd the agent's tool budget. - Mitigate: keep the scope at five, and keep descriptions one sentence. -- **Risk:** `ToolDefinition`/`ToolResult` are exported types; widening them is - an API-surface change. Mitigate: both new fields are optional, and - `api:update` runs after a fresh build (a stale `dist/` writes a wrong snapshot). - -## 9. Open questions (answered during execution) - -1. Which MCP protocol revision does the server advertise, and does it match the - one that introduced `structuredContent`/`outputSchema`? If not, bump the - advertised version in the same change or the field is ignored. -2. Do the admin reads accept a shard key, and should the tools expose it? A - sharded deployment's logs are per-shard; a tool that silently reads one shard - would be misleading. -3. Is `getRequestLog` (single request detail) worth a sixth tool, paired with - `getLogs` for drill-down? -4. Should `lunora_get_advisories` reuse the `lunora advisor` CLI's severity - filtering, or return everything and let the agent filter? -5. Does the hosted/stateless server (`serve-stateless.ts`, `paid.ts`) resolve an - admin token the same way, or does the token tier need a different rule there? - -### Answers - -1. **No revision is advertised by us, and none needs bumping.** `server.ts` - passes only `{ name, version }` (an `Implementation`, not a protocol - revision); the SDK's `Server` negotiates the protocol itself. At - `@modelcontextprotocol/sdk` 1.29.0 that is `SUPPORTED_PROTOCOL_VERSIONS = -["2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05", "2024-10-07"]`, and - `structuredContent` / `outputSchema` arrived in **2025-06-18** — inside the - supported set, and both are first-class fields on the SDK's `ToolSchema` / - `CallToolResultSchema`, so they survive rather than being stripped as unknown - keys. A client that negotiates `2025-03-26` (also the SDK's - `DEFAULT_NEGOTIATED_PROTOCOL_VERSION` when a client asks for something - unknown) simply ignores both fields — which is the whole reason the text block - is still emitted. Server-level tests assert `outputSchema` survives - `ListTools` and `structuredContent` survives a `CallTool` round trip. -2. **Yes, and they expose it.** Every one of these reads is served by the shard - that handled the traffic — the studio passes `shardKey` to exactly these RPCs - — so on a `.shardBy()` deployment "the logs" are per-shard. All five tools - take an optional `shardKey`, and its description says the reads are per-shard - rather than deployment-wide, because silently reading the default shard and - presenting it as the deployment is the misleading outcome the question names. -3. **Not now.** `getIssues` already carries the sample message and the grouping - hash, which is the drill-down an agent acts on; a sixth tool costs context on - every turn for a detail view that mainly pays off in a UI. Left deferred with - traces/metrics, per §4's scope decision. -4. **Return everything (bounded by `limit`), no severity filter.** The advisory - set is small and each finding already carries its `level`, so the agent can - filter what it has; a severity knob would be a second filtering vocabulary to - keep in sync with the CLI's, for no new capability. -5. **Same rule, and it lands correctly by construction.** `serve-stateless.ts` - is a transport helper with no token resolution, and `paid.ts` composes tools - rather than building the deployment server, so both reach the gate through - `createLunoraMcpServer` / `callTool`. A hosted server started without a - `token` option therefore advertises no observability tools and refuses them at - dispatch — the fail-closed default, which is the right one for the surface - most likely to be public. diff --git a/plans/310-worker-size-budget.md b/plans/310-worker-size-budget.md deleted file mode 100644 index e08989c2bc..0000000000 --- a/plans/310-worker-size-budget.md +++ /dev/null @@ -1,267 +0,0 @@ -# Plan 310 — Measure the deployed Worker's size, and gate on it - -**Baseline:** `370994075` (2026-08-08) -**Status:** DONE (2026-08-08) — gate shipped, user-facing warning dropped per the §8 STOP condition. - -## Phase 0 — the measurement (done first, as required) - -`templates/standalone` scaffolded into a scratch dir, workspace `dist/` -symlinked in, built with `lunora build` → `wrangler deploy --dry-run --outdir` -(wrangler 4.114.0) against a **production** package build: - -| | raw | gzip | -| -------------------------------------------- | ---------------------------- | ------------------------- | -| **`templates/standalone` (production dist)** | **1,725,313 B — 1684.9 KiB** | **422,840 B — 412.9 KiB** | -| same, development dist (`build:packages`) | 2,190,091 B — 2138.8 KiB | 533,849 B — 521.3 KiB | - -One uploaded file (`server.js`). `node:zlib`'s `gzipSync` at its default level -reproduces wrangler's own `Total Upload: … / gzip: …` line to the byte, so the -two numbers are directly comparable. The out-dir also holds a 3.0 MB sourcemap -and a 1.2 MB metafile, neither of which is uploaded. - -**412.9 KiB is 13.4% of the Free plan's 3 MB ceiling and 4.0% of Paid's 10 MB.** - -Heaviest inputs of that bundle (esbuild metafile, bytes-in-output): - -| KiB | input | -| ----- | ----------------------- | -| 606.1 | `compromise@14.15.1` | -| 242.5 | `drizzle-orm@0.45.2` | -| 197.5 | `@lunora/shard-engine` | -| 140.2 | `@lunora/runtime` | -| 131.3 | `@lunora/do` | -| 56.4 | `@lunora/observability` | - -**Finding (separate from this plan): 35% of a hello-world Worker is an English -NLP library.** `compromise` is not a Lunora dependency — it arrives via -`@visulima/redact`'s `stringAnonymize`, imported by -`packages/observability/src/request-log.ts` for log redaction. Every Lunora app -carries 606 KiB raw for it. Worth its own plan: either import redact's -rule-based path without the NLP entity detector, or lazy-load it. - -## 0. Headline finding - -Nothing in this repo measures how large the Worker a user actually deploys is. -There is no size budget in any CI job (`.github/workflows/` — 19 workflows, none -size-related), no check in `scripts/`, and `dist:check` -(`scripts/check-dist-production.js`) audits _production-cleanliness_ of package -`dist/`, not bytes. Cloudflare enforces a hard compressed-script limit; the -first time anyone learns this framework's floor is when a user's deploy is -rejected — and at that point the cause is a dependency added weeks earlier, -across 55 packages, with no per-commit signal to bisect against. - -Per-package measurement won't answer it either: package entrypoints are -re-export shims (`packages/runtime/dist/index.mjs` is 2 KiB; the code lives in -`dist/packem_shared/`, 348 KiB for `@lunora/runtime` alone). Only the bundled -Worker is a real number. - -## 1. Current state (audit) - -- **`lunora build`** (`packages/cli/src/commands/build/index.ts`) already - produces exactly the artifact to measure: "Codegen + validate + bundle the - Worker to disk without deploying", default out-dir `.lunora/build`, via - `wrangler deploy --dry-run --outdir`. It exists so CI can build once and ship - with `deploy --prebuilt`. -- It already emits machine-readable output (`--format json`) and a bindings - manifest (`--emit-bindings `) — so it is the natural place for a size - field, and nothing new needs to run to obtain the bundle. -- `lunora deploy --dry-run` runs the same bundle step (`handler.ts`), so the - number is available on the deploy path too, before anything is published. -- No consumer of either measures or reports bytes. -- `tests/vis-templates` and `pnpm run test:templates` already build the - `templates/*` starters in CI — the hook where a per-template number could be - recorded without inventing a new build. - -## 2. Existing seams (do not reinvent) - -- **`lunora build`'s out-dir + `--format json` result** — measure the emitted - files there; do not add a second bundling path. -- **`packages/cli/src/util/output-format.ts`** — `printJson` / `isJsonFormat`, - for reporting the number in the existing document rather than a new one. -- **`node:zlib`'s `gzipSync`** — the compressed size Cloudflare's limit is - stated against. No dependency needed; this is a stdlib call over a file the - build already wrote. -- **`scripts/check-*.js`** — the established shape for a repo gate run from CI - (and, for some, from `postinstall`). A size gate follows that shape. -- **`tests/vis-templates`** — the existing template-build harness. - -## 3. The behavioural contract to preserve - -- `lunora build` and `lunora deploy` keep their exit codes: measuring is - reporting, and **a user-facing size check warns, never fails**. A framework - that refuses to deploy a bundle Cloudflare would have accepted is worse than - the problem it prevents. -- The repo-internal CI gate is the opposite: it **fails**, because a regression - there is ours to fix before it reaches a user. -- `--format json` stays one document; the size lands as a field inside it. -- No new dependency (`node:zlib` is stdlib). - -## 4. Design decisions - -- **Two different mechanisms, deliberately.** A _user-facing warning_ in - `build`/`deploy` (informational, near the real limit) and a _repo CI gate_ - over a fixed reference app (fails on regression). Chosen over one shared - threshold: the user's number depends on their app and their plan; ours is a - regression signal about the framework's own floor. -- **The CI gate measures a fixed reference app, not the playground.** - `apps/playground` accumulates feature demos (it depends on `@lunora/studio`, - `auth-ui`, `db`, `queue`, `workflow`, …), so its size tracks demo churn rather - than framework weight. A `templates/*` starter is the honest baseline — it is - what a new user actually deploys. -- **Gzip, not raw or brotli.** Cloudflare states its limit against the - compressed script; gzip is the conservative, reproducible choice from stdlib. - Recorded alongside the raw number so a compression-ratio change is visible. -- **The ceiling is a committed number with headroom, not a computed - percentage.** A fixture file (like `api-snapshots/`) holding the current - measured size plus an explicit allowance. Chosen over "fail if it grew at - all" (every legitimate feature turns the gate red) and over a percentage of - Cloudflare's limit (which changes under us). -- **Phase 0 is measurement, and the ceiling is not written until it produces a - number.** Guessing a budget from package `dist/` sizes would be wrong for the - reason in §0. - -## 5. Workstreams - -**S — measure in `lunora build`.** After the bundle is written, sum the emitted -JS (and any inlined assets wrangler wrote) raw and gzipped; add -`bundle: { rawBytes, gzipBytes, files }` to the `--format json` result and one -line to the pretty output. - -**Done.** `packages/cli/src/commands/build/bundle-size.ts` (`measureBundle`) + -`handler.ts`. Sourcemaps, `bundle-meta.json` and wrangler's README are excluded -— counting them would report ~3× the real weight. An unmeasurable out-dir -returns `undefined` and warns rather than reporting 0 bytes, since a silent 0 is -what a changed wrangler layout looks like and it would read as the healthiest -possible result. - -One wrinkle worth recording: `--format json` used to be deploy's document, and -deploy prints it before `build` regains control, so there was nowhere to put the -field. `build` now owns its own document (validating `--format` itself, routing -human output to stderr, and forcing spawned stdout to stderr so the document -stays alone on stdout). `packages/cli/src/commands/deploy/*` is untouched. - -**S — user-facing warning.** ~~When `gzipBytes` crosses a "getting close" -threshold, warn…~~ - -**Dropped — the §8 STOP condition fired.** A starter Worker is 412.9 KiB -gzipped: 13.4% of the Free plan's ceiling, 4.0% of Paid's. A threshold warning -would be a speculative alarm nobody would ever legitimately see, and picking its -trigger point would be inventing a number to defend. `build` reports the size -unconditionally instead (reporting is not warning), and the docs say what the -limit is and which levers to pull. The CI gate is the whole value here. - -**M — repo CI gate.** `scripts/check-worker-size.js`: build a reference template -worker, measure it, compare against the committed ceiling fixture, fail with the -delta and the previous value on regression. Wire it as its own job in -`test.yml` (**not** into the root `postinstall` — a failing postinstall gate -turns every CI job red in its setup step and the cause is invisible in the job -that reports the failure). - -**Done.** `scripts/check-worker-size.js` + the `worker-size.json` fixture -(422,840 B baseline, 51,200 B allowance → 462.9 KiB ceiling). It scaffolds -`templates/standalone` into a temp dir, links the workspace `dist/` directories -(a symlink farm, not an install — `pnpm install` would try to fetch -`lunorash@^0.0.0` from the registry), and reads the number back out of -`lunora build --format json`, so the gate and the user see one measurement from -one code path. Wired as the `worker-size` job in `test.yml` and added to -`test-required-check`'s `needs`; **not** in `postinstall`. Verified failing on a -hand-lowered fixture and passing on the committed one. - -§8's "reuse `test:templates`' build" mitigation does not apply: that harness -packs tarballs and runs each template's `build` script, and `standalone` has no -`build` script — it is explicitly skipped there, so no bundle exists to reuse. -The gate builds its own (~40 s after the package build) and the job is -path-gated on `packages`/`templates` changes. - -**S — an accept path.** `pnpm run worker-size:update` (mirroring -`api:update`) rewrites the fixture after an intentional increase, so the gate is -a conversation in review rather than an obstacle. - -**Done.** `pnpm run worker-size:check` / `worker-size:update`; the update path -prints the signed delta against the previous baseline. - -**S — docs.** A short section in the deployment docs: what the limit is, how to -read the number `lunora build` prints, what to do when it is close. - -**Done.** "Worker size" in `apps/docs/src/content/docs/deployment.mdx`. - -## 6. Platform parity - -Not applicable to the `ctx.*` matrix — no runtime surface, no binding. The -measurement is Cloudflare-specific by nature (it is a Workers script-size -limit), so the check must live behind the Cloudflare build path and must not -present itself as a target-neutral gate; `@lunora/platform-node` has no -equivalent ceiling and must not inherit a spurious warning. - -## 7. Phasing & ordering - -| Phase | Work | Gate | -| ----- | ---------------------------- | ------------------------------------------------------------------------------------------------------------- | -| 0 | Measure and record | The actual raw + gzip numbers for a `templates/*` worker, written into this plan before the ceiling is chosen | -| 1 | `bundle` in the build result | Test: `lunora build --format json` yields `bundle.gzipBytes` > 0 against a fixture out-dir | -| 2 | User-facing warning | Test: a stubbed oversize measurement warns and still exits 0 | -| 3 | CI gate + fixture | The job fails when the fixture is lowered by hand, and passes on `alpha` unchanged | -| 4 | `worker-size:update` + docs | Running it after an intentional bump turns the gate green; `pnpm run lint:prettier` clean | - -## 8. Risks & STOP conditions - -- **STOP** if phase 0 shows a starter worker sitting comfortably under the limit - with no upward trend — then the CI gate is the whole value and the user-facing - warning is speculative; ship the gate, drop the warning, and say so here. -- **Risk:** the ceiling fixture becomes a rubber stamp that every PR bumps. - Mitigate: the update script prints the delta and the gate's failure message - names the previous value, so the increase is visible in review rather than - buried in a fixture diff. -- **Risk:** wrangler's out-dir layout changes and the measurement silently sums - the wrong files (or zero). Mitigate: assert a non-zero size and a minimum - file count; a measurement of 0 must fail loudly, never pass. -- **Risk:** the reference-template build makes CI meaningfully slower. - Mitigate: reuse `test:templates`' existing build if it already produces the - artifact; only build separately if it does not. - -## 9. Open questions (answered) - -1. **What is Cloudflare's current compressed-script limit per plan tier?** - 3 MB on Workers Free, 10 MB on Workers Paid, stated "after compression - (gzip)" — read from - on 2026-08-08. - The same page pairs it with a 1-second startup CPU budget for top-level code, - which a large bundle can breach before the size limit does (`Script startup -exceeded CPU time limit`, error 10021). Both numbers live in the docs section - and the fixture comment; neither is repeated in code as a threshold. -2. **Which template is the reference?** `standalone` — it is the smallest - starter and the only one whose deployed Worker is Lunora and nothing else - (the meta-framework templates bundle their own SSR runtime, which would make - the number track Next/Nuxt rather than Lunora). -3. **Does the deployed Worker ever include `@lunora/studio` assets?** No. The - reference bundle's esbuild metafile has **zero** inputs from - `packages/studio` — the studio is only ever loaded by the dev-time hosts - (`@lunora/vite`'s studio plugin, `@lunora/cli`'s studio server), which - `require.resolve` + `readFileSync` its prebuilt assets on Node. Nothing on - the Worker path imports it. Two notes, neither a bug: `apps/playground` - declares `@lunora/studio` under `dependencies` while every template puts it - in `devDependencies` (it is dev tooling — the playground entry is the odd one - out, and it costs install weight, not bundle weight); and the templates - carrying it at all is what lets `lunora dev` serve the studio offline. -4. **Should `--emit-bindings` output carry the size too?** No. `--emit-bindings` - answers "what must be provisioned", which is a different document with a - different consumer (an IaC program). The size is already in the - `--format json` result an external deployer reads anyway, and duplicating it - into a second file creates two things to keep honest. Revisit only if a real - deployer asks. -5. **Is a per-add-on breakdown feasible from wrangler's output?** Yes, and it - needs no bundler-level report: `lunora deploy --outdir` already passes - `--metafile`, so `/bundle-meta.json` holds esbuild's per-input - `bytesInOutput`. Grouping those paths by `packages//` produced the phase-0 - table above in a few lines. Not built here — nothing consumes it yet, and - `lunora analyze` already covers the "what is heavy?" question interactively. - -## 10. Follow-ups this work surfaced - -- **`compromise` (606 KiB raw) is in every Worker** via `@visulima/redact` ← - `@lunora/observability`. See phase 0. The single largest lever on this number. -- **`lunora analyze` over-reports.** Its `totalBytes` walks the whole out-dir, - so it counts the sourcemap and the metafile as bundle weight — for the - reference app that is 1.6 MiB reported as ~6.9 MiB. `measureBundle` is the - correct filter; `analyze` should use it (left alone here to keep this change - inside `commands/build/*`). diff --git a/plans/README.md b/plans/README.md index 4fc916c4ea..64b86df068 100644 --- a/plans/README.md +++ b/plans/README.md @@ -1462,6 +1462,85 @@ explicit non-goal. scope for 304 and recorded as an open question; the Convex export format is symmetric, so the gap is small if a future wave wants it. +## Wave 20 — agent/automation surface gap wave (baseline `370994075`, 2026-08-08) + +Competitive gap analysis of the CLI-and-tooling surface an automated consumer +(CI job, coding agent, deploy pipeline) talks to. The headline was not that +capabilities were missing — logs, env sync, previews, temporary deploys, +seeding, MCP write-gating, `.lunora/dev.json` and the arg-validator path were +all already here. It was that the **machine-readable edges were inconsistent**: +the one command whose whole job is reporting project health had no JSON mode and +no stable identifiers, and the deploy command's JSON mode was the one path that +discarded the deployed URL it already knew how to parse. + +All four shipped. Plan files removed; the record is here and in git history. + +| Plan | Title | Category | Pkg | Pri | Effort | Risk | Status | +| ---- | ------------------------------------------------ | ---------- | ------ | --- | ------ | ---- | --------------------------------------------------------------------------- | +| 307 | `lunora doctor` machine-readable report + codes | dx/cli | cli | P2 | S | LOW | DONE & REMOVED — `--format json`, 17 stable `DoctorCode`s, `cli-shadowed` | +| 308 | Deploy result identity + post-deploy health gate | dx/deploy | cli | P1 | M | MED | DONE & REMOVED — `deployment` in the result, `--health-check`, link refresh | +| 309 | MCP observability read tools + structured output | dx/agents | mcp | P2 | M | MED | DONE & REMOVED — 5 token-gated read tools, `structuredContent` | +| 310 | Worker size measurement + CI budget | build/perf | cli/ci | P2 | S | LOW | DONE & REMOVED — CI gate only; the warning was dropped per its STOP | + +### What the plans got wrong (the reason to read this table) + +- **307's `checkCliShadow` design would have warned on every pnpm project.** It + prescribed comparing `realpathSync` of `node_modules/.bin/lunora` against the + running executable — but pnpm writes a POSIX shell shim there, not a symlink, + so the realpath is the shim and never matches `dist/bin.mjs`. Shipped instead: + realpath the installed CLI _package_ dirs and test containment, which is + correct for pnpm symlinks, npm/yarn hoisting, and shim launches. +- **308's §1 was wrong that capturing the URL fixes `--migrate`'s refusal.** That + gate runs _before_ wrangler, so the captured URL does not exist yet at the + point of refusal. The misleading error text was corrected; moving the gate is + recorded as a follow-up rather than silently relaxing when `--migrate` aborts. +- **309's §1 claim that `LunoraClient` "cannot reach these reads as-is" was + false**, and it is what sized the whole "shared admin caller" workstream. The + `__lunora_admin__:*` ops ride the ordinary RPC envelope and the shard + intercepts them before user dispatch — `client.query({ __lunoraRef: … })` + already works, which is what the studio does. The module collapsed to three + lines over `client.query`. +- **310's STOP condition fired and was honoured.** A `templates/standalone` + worker measures 412.9 KiB gzipped (1684.9 KiB raw, production build) against + Cloudflare's 3 MB Free / 10 MB Paid compressed limit — 13.4% of the smaller + ceiling. The user-facing warning would have been an alarm nobody could + legitimately trip at a threshold nobody could justify, so only the CI gate + shipped. `build` still _reports_ the size unconditionally. + +### Follow-ups this wave opened + +- **`compromise` is 35% of a hello-world Worker.** `@visulima/redact` hard-depends + on `compromise@^14.15.1` (an English NLP library, 606 KiB raw in the bundle — + larger than any first-party package), pulled in by + `packages/observability/src/request-log.ts:30`. Every Lunora app carries it. + Worth its own plan: use redact's rule-based path, or lazy-load it. +- **`--migrate`'s URL gate** could move to after the wrangler invocation so a + first deploy in a fresh CI checkout can migrate without `--migrate-url`. +- **`lunora analyze` counts unuploaded files** — it sums the sourcemap and + metafile alongside the bundle, reporting ~6.9 MB where the upload is 1.6 MB. +- **A per-package size breakdown needs no new tooling**: `/bundle-meta.json` + already carries esbuild's `bytesInOutput`. + +### Considered and not filed + +Verified already shipped, and in most cases further along than the comparison +set: snapshot import/export incl. blobs (plans 304/305), `lunora seed`, `env +diff`/`push`/`doctor`, `logs --ndjson` + `--durable` archive reads, preview + +temporary deploys with `--format json`, MCP fail-closed write gating with tool +annotations, `.lunora/dev.json` agent status + background dev, `.lunora/project.json` +linking, per-function argument validation at the builder (so every entrypoint +inherits it), Cloudflare Access identity, log/metric retention, cron metadata in +codegen output, and the capability matrix's `platform_unsupported_feature` +diagnostic. + +**Rejected:** _automatic shard scale-out_ conflicts with `.shardBy` being an +explicit, reviewable topology decision (cross-shard work is plan 168). _A +browser DevTools extension_ duplicates the Studio. _A public-env-var count gate_ +is process for its own sake. _Running the project's test suite inside `lunora +verify`_ is YAGNI — an agent can run `pnpm test` itself; the half with real +value (live function-spec parity against the deployed worker) is a candidate for +a later wave. + ## Notes for executors (carried from prior waves) - `dist/` is gitignored and built on demand. Build deps first: From d5ac5fb3ae86d7101aace14269e207121813a42b Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 07:32:12 +0200 Subject: [PATCH 6/8] fix(cli): bound the health probe and close the size gate hole MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review fixes on the wave-20 branch. - health-probe: Node's `fetch` has no timeout, so a worker that accepts the connection and goes quiet would hang `deploy --health-check` until CI's job timeout killed it — defeating the fixed attempt budget. Each attempt now carries an `AbortSignal.timeout`; an injected `fetchImpl` still owns its own deadline. - worker-size: the gate was reachable only via the `packages`/`templates` filters, so a PR touching just the checker, the baseline, or the job skipped it — the gate could be switched off by editing the gate. Adds a `worker_size` filter, the matching `files-changed` output, and the `if` clause. - worker-size: Cloudflare enforces a 64 MB pre-compression limit as well as the per-plan gzip one. A bundle that compresses unusually well can pass a gzip baseline check and still be rejected at upload, so the raw size gets its own absolute check. Documented both limits. - docs: the doctor JSON example counted three findings and showed one; the CI snippet had no exit-code guard, so a failed deploy sent `curl` at `null/api/smoke` and failed at the wrong step. - mcp: `lunora_get_migration_status` takes no `limit`, unlike the other four. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- .github/file-filters.yml | 8 +++++++ .github/workflows/test.yml | 3 ++- apps/docs/src/content/docs/deployment.mdx | 17 ++++++++------ .../cli/__tests__/util/health-probe.test.ts | 23 +++++++++++++++++++ packages/cli/docs/index.mdx | 19 +++++++++++---- packages/cli/src/util/health-probe.ts | 16 ++++++++++++- packages/mcp/README.md | 8 ++++--- scripts/check-worker-size.js | 22 ++++++++++++++++++ 8 files changed, 100 insertions(+), 16 deletions(-) diff --git a/.github/file-filters.yml b/.github/file-filters.yml index 19d39d3aea..e0daca7783 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -78,6 +78,14 @@ registry_sync: # own tsconfig. Both regressions it has caught (the CLI entry point, and an # unlisted `vue-demi` build script that broke every scaffold's first install) came # from packages/registry changes, not from the templates. +# Worker size budget. Listed separately from `packages` so the gate cannot be +# switched off by editing the gate: a PR touching only the checker, the +# committed baseline, or the job itself still runs it. +worker_size: + - "scripts/check-worker-size.js" + - "worker-size.json" + - ".github/workflows/test.yml" + templates: - "templates/**" - "registry/**" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 95766da098..d62f18265b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -29,6 +29,7 @@ "e2e": "${{ steps.changes.outputs.e2e }}" "codecov": "${{ steps.changes.outputs.codecov }}" "templates": "${{ steps.changes.outputs.templates }}" + "worker_size": "${{ steps.changes.outputs.worker_size }}" "steps": - "name": "Harden Runner" "uses": "step-security/harden-runner@9af89fc71515a100421586dfdb3dc9c984fbf411" # v2.19.4 @@ -317,7 +318,7 @@ # reports it. "worker-size": "name": "Worker size budget" - "if": "needs.files-changed.outputs.packages == 'true' || needs.files-changed.outputs.templates == 'true'" + "if": "needs.files-changed.outputs.packages == 'true' || needs.files-changed.outputs.templates == 'true' || needs.files-changed.outputs.worker_size == 'true'" "needs": "files-changed" "runs-on": "ubuntu-latest" "timeout-minutes": 30 diff --git a/apps/docs/src/content/docs/deployment.mdx b/apps/docs/src/content/docs/deployment.mdx index 6f778bdb68..ea6cf7a111 100644 --- a/apps/docs/src/content/docs/deployment.mdx +++ b/apps/docs/src/content/docs/deployment.mdx @@ -147,11 +147,13 @@ types. ## Worker size -Cloudflare caps the size of a Worker script -[after gzip compression](https://developers.cloudflare.com/workers/platform/limits/): -**3 MB on the Workers Free plan and 10 MB on Workers Paid**. The limit is -enforced at upload, so an over-budget bundle is a rejected deploy rather than a -slow one. +Cloudflare caps a Worker script +[in two places](https://developers.cloudflare.com/workers/platform/limits/): +**3 MB on Workers Free and 10 MB on Workers Paid after gzip compression**, and +**64 MB before compression** on both plans. Both are enforced at upload, so an +over-budget bundle is a rejected deploy rather than a slow one — and a bundle +that compresses unusually well (a large generated table, say) can sit under the +gzip limit while breaching the raw one. Check both numbers. `lunora build` weighs what it wrote: @@ -165,8 +167,9 @@ pnpm lunora build --format json | jq .bundle Only the uploaded files are counted — the sourcemap and the esbuild metafile sitting in the same out-dir are not part of the script, and counting them would -roughly triple the number. The gzip figure is the one to compare against the -limit; it matches what `wrangler deploy` reports as `Total Upload: … / gzip: …`. +roughly triple the number. Compare `gzipBytes` against your plan's compressed +limit and `rawBytes` against the 64 MB one; together they match what +`wrangler deploy` reports as `Total Upload: … / gzip: …`. A starter app is around **410 KiB gzipped**, so most projects have a lot of room. If yours is approaching the limit: diff --git a/packages/cli/__tests__/util/health-probe.test.ts b/packages/cli/__tests__/util/health-probe.test.ts index 52c337ebeb..925fa8e01e 100644 --- a/packages/cli/__tests__/util/health-probe.test.ts +++ b/packages/cli/__tests__/util/health-probe.test.ts @@ -97,4 +97,27 @@ describe("probeHealth", () => { expect(result.error).toContain("could not reach https://app.workers.dev/_lunora/health (getaddrinfo ENOTFOUND)"); }); + + it("gives the default fetch a per-attempt deadline so a silent worker cannot hang the probe", async () => { + expect.assertions(3); + + // The real hazard: Node's `fetch` never times out on its own, so a + // connection that is accepted and then goes quiet would block forever. + // Assert the abort signal reaches `fetch` rather than the wall-clock + // behaviour, which would mean actually waiting for a timeout. + const globalFetch = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("The operation was aborted due to timeout")); + + try { + const result = await probeHealth({ baseUrl: "https://app.workers.dev", sleep: noSleep, timeoutMs: 25 }); + + expect(result.error).toContain("could not reach"); + + const [, init] = globalFetch.mock.calls[0] as [string, RequestInit | undefined]; + + expect(init?.signal).toBeInstanceOf(AbortSignal); + expect(init?.signal?.aborted).toBe(false); + } finally { + globalFetch.mockRestore(); + } + }); }); diff --git a/packages/cli/docs/index.mdx b/packages/cli/docs/index.mdx index dec13457bc..29aedac90e 100644 --- a/packages/cli/docs/index.mdx +++ b/packages/cli/docs/index.mdx @@ -198,7 +198,7 @@ CI gate. See [Debugging](/docs/concepts/debugging) for how to act on each findin still rendered, but on stderr, so the document stays pipeable. The exit code is identical in both formats. -```json +```jsonc { "code": 1, "findings": [ @@ -206,11 +206,12 @@ identical in both formats. "code": "d1-placeholder-id", "fix": "Run `wrangler d1 create ` …", "level": "fail", - "message": "D1 binding \"DB\" has a placeholder database_id …" - } + "message": "D1 binding \"DB\" has a placeholder database_id …", + }, + // …the info- and pass-level findings the summary counts ], "ok": false, - "summary": { "fail": 1, "info": 1, "pass": 1, "warn": 0 } + "summary": { "fail": 1, "info": 1, "pass": 1, "warn": 0 }, } ``` @@ -366,9 +367,19 @@ A release pipeline reads the URL out of the same document it already checks the exit code of: ```bash +set -euo pipefail + result=$(lunora deploy --env production --format json --health-check) url=$(echo "$result" | jq -r '.deployment.url') +# The document is printed on failure too, and a failed deploy carries no URL — +# without this guard `jq` yields "null" and the smoke test requests null/api/smoke, +# so the pipeline fails at the wrong step. +if [ -z "$url" ] || [ "$url" = "null" ]; then + echo "deploy reported no URL" >&2 + exit 1 +fi + curl -fsS "$url/api/smoke" ``` diff --git a/packages/cli/src/util/health-probe.ts b/packages/cli/src/util/health-probe.ts index f0bcba10cd..1fe3ac02f5 100644 --- a/packages/cli/src/util/health-probe.ts +++ b/packages/cli/src/util/health-probe.ts @@ -49,6 +49,14 @@ interface HealthProbeInputs { paths?: ReadonlyArray; /** Injectable clock for the inter-attempt delay; defaults to a real timer. */ sleep?: (ms: number) => Promise; + + /** + * Per-attempt timeout, in ms. Applies only to the default fetch — an + * injected `fetchImpl` owns its own deadline. Without it a stalled + * connection would hang the probe indefinitely, since Node's `fetch` has no + * timeout of its own. + */ + timeoutMs?: number; } interface HealthProbeResult { @@ -109,8 +117,14 @@ const probeHealth = async ({ fetchImpl, paths = [HEALTH_PATH], sleep = realSleep, + timeoutMs = 10_000, }: HealthProbeInputs): Promise => { - const doFetch = fetchImpl ?? ((url: string) => fetch(url)); + // Node's `fetch` never times out on its own: a worker that accepts the + // connection and then goes quiet would hang the probe — and with it + // `deploy --health-check` — until CI's job timeout killed it. That defeats + // the whole point of a fixed attempt budget, so bound every attempt. An + // abort surfaces as a transport error, which `probeOnce` already reds. + const doFetch = fetchImpl ?? ((url: string) => fetch(url, { signal: AbortSignal.timeout(timeoutMs) })); const budget = Math.max(1, attempts); let result = await probeOnce(baseUrl, paths, doFetch); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 64c045efca..7a0eeed2d8 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -83,9 +83,11 @@ reading operational data. They return `structuredContent` alongside the usual text block, described by each tool's `outputSchema` (MCP revision `2025-06-18` and later; older clients keep -reading the text block). Each takes a `limit` clamped server-side, and an -optional `shardKey` — on a `.shardBy()`-partitioned deployment these reads are -**per-shard**, not deployment-wide. +reading the text block). All but `lunora_get_migration_status` take a `limit` +clamped server-side; migration status takes only `shardKey` and returns every +migration, because truncating that list would hide the pending one. Each also +takes an optional `shardKey` — on a `.shardBy()`-partitioned deployment these +reads are **per-shard**, not deployment-wide. `lunora_get_function_schema` returns a JSON object with three fields: diff --git a/scripts/check-worker-size.js b/scripts/check-worker-size.js index 6ccb41264d..d8e82c2226 100644 --- a/scripts/check-worker-size.js +++ b/scripts/check-worker-size.js @@ -48,6 +48,13 @@ const fail = (message) => { const kib = (bytes) => `${(bytes / 1024).toFixed(1)} KiB`; +/** + * Cloudflare's pre-compression Worker script limit — 64 MB, the same on Free and + * Paid, alongside the per-plan gzip limit (3 MB / 10 MB). + * https://developers.cloudflare.com/workers/platform/limits/ + */ +const RAW_LIMIT_BYTES = 64 * 1024 * 1024; + /** * Materialize the reference template as a project whose dependencies resolve. * @@ -156,6 +163,21 @@ if (update) { process.exit(0); } +// Cloudflare enforces two ceilings, not one: the plan's gzip limit AND a 64 MB +// pre-compression limit on both plans. The baseline check below is a regression +// signal measured in gzip, so it cannot see a bundle that compresses extremely +// well — a large generated lookup table is tiny gzipped and enormous raw. That +// bundle would pass the gate and be rejected at upload, so the raw limit gets +// its own absolute check. +if (bundle.rawBytes > RAW_LIMIT_BYTES) { + fail( + `The reference Worker (templates/${baseline.template}) exceeds Cloudflare's pre-compression limit.\n` + + ` raw: ${kib(bundle.rawBytes)}\n` + + ` limit: ${kib(RAW_LIMIT_BYTES)} before compression (both plans)\n` + + `This is an upload-time rejection, not a budget — accepting it with \`worker-size:update\` will not help.`, + ); +} + if (bundle.gzipBytes > ceiling) { fail( `The reference Worker (templates/${baseline.template}) grew past its ceiling.\n` + From bf34e65fdc3278add42d765a8a778045ac7d636b Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 07:34:37 +0200 Subject: [PATCH 7/8] fix(mcp): match the released shard-engine version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every other consumer moved to @lunora/shard-engine@1.0.0-alpha.16 in the release; this pin was left at alpha.15 because the dependency did not exist when the release ran. The workspace `overrides:` block forces `workspace:*` locally, so the stale pin is invisible in the repo — but it is what the published manifest would carry. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- packages/mcp/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/mcp/package.json b/packages/mcp/package.json index 5741298af5..276df242e8 100644 --- a/packages/mcp/package.json +++ b/packages/mcp/package.json @@ -69,7 +69,7 @@ "dependencies": { "@lunora/client": "1.0.0-alpha.43", "@lunora/errors": "1.0.0-alpha.16", - "@lunora/shard-engine": "1.0.0-alpha.15", + "@lunora/shard-engine": "1.0.0-alpha.16", "@modelcontextprotocol/sdk": "catalog:mcp" }, "devDependencies": { From a2f54ddb6d0f511a662762a4103f86a17dac4e17 Mon Sep 17 00:00:00 2001 From: Daniel Bannert Date: Sat, 8 Aug 2026 08:17:09 +0200 Subject: [PATCH 8/8] fix(cli): close the size-gate hole one level up MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second round of review fixes. - file-filters: the worker_size filter did not list the filter file itself, so a PR editing the filter definition still skipped the gate — and test-required-check passes on skipped jobs. Same hole as the one the filter was added to close, one level up. - verify: report the URL probeHealth returned instead of rebuilding it. The two agree only while verify keeps the default paths; passing more would name a URL the verdict did not come from. Drops the joinHealthUrl import. - docs: a stray blank line rendered one doctor-check bullet as a loose list item. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01KBeSX2o4sTCPjVDRDWkVQG --- .github/file-filters.yml | 4 ++++ packages/cli/docs/index.mdx | 1 - packages/cli/src/commands/verify/handler.ts | 7 +++++-- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/file-filters.yml b/.github/file-filters.yml index e0daca7783..03b889413d 100644 --- a/.github/file-filters.yml +++ b/.github/file-filters.yml @@ -85,6 +85,10 @@ worker_size: - "scripts/check-worker-size.js" - "worker-size.json" - ".github/workflows/test.yml" + # This file too, or the hole reopens one level up: a PR that edits the + # filter itself would not match it, the gate would skip, and + # `test-required-check` passes on skipped jobs. + - ".github/file-filters.yml" templates: - "templates/**" diff --git a/packages/cli/docs/index.mdx b/packages/cli/docs/index.mdx index 29aedac90e..730b08fb29 100644 --- a/packages/cli/docs/index.mdx +++ b/packages/cli/docs/index.mdx @@ -180,7 +180,6 @@ A read-only preflight over the current project. It reports pass / warn / fail fo by the worker entry. - **Version skew** — `@lunora/*` packages spanning different versions, or mixing release channels (e.g. stable + alpha). - - **CLI shadowing** — a globally-installed `lunora` running instead of the project's own, so the report describes a project this CLI is not pinned to. diff --git a/packages/cli/src/commands/verify/handler.ts b/packages/cli/src/commands/verify/handler.ts index f13f2276ed..6cdd84fdd7 100644 --- a/packages/cli/src/commands/verify/handler.ts +++ b/packages/cli/src/commands/verify/handler.ts @@ -11,7 +11,7 @@ import { defineHandler } from "../../util/command"; import { resolveTargetOrError } from "../../util/deploy-target"; import { detectPackageManager, execArgsFor } from "../../util/detect-package-manager"; import type { HealthFetch } from "../../util/health-probe"; -import { joinHealthUrl, probeHealth } from "../../util/health-probe"; +import { probeHealth } from "../../util/health-probe"; import type { Logger } from "../../util/logger"; import { isJsonFormat, loggerForFormat, printJson, validateOutputFormat } from "../../util/output-format"; import { runSchemaDriftGate } from "../../util/schema-drift-gate"; @@ -96,7 +96,10 @@ const probeHealthIfRequested = async (options: VerifyCommandOptions, logger: Log const probe = await probeHealth({ baseUrl: options.healthUrl, fetchImpl: options.healthFetch }); if (probe.error === undefined) { - logger.success(`verify: health probe ok (${joinHealthUrl(options.healthUrl)})`); + // Report the URL the verdict actually came from. Rebuilding it here + // would agree with the probe only while `verify` keeps the default + // `paths` — the moment it passes more, the message names the wrong one. + logger.success(`verify: health probe ok (${probe.url})`); return undefined; }