diff --git a/openapi/loops.json b/openapi/loops.json index 63eb4a7..57c8aaf 100644 --- a/openapi/loops.json +++ b/openapi/loops.json @@ -22,7 +22,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Foundation" + "$ref": "#/components/schemas/HealthFoundation" } } } @@ -3032,8 +3032,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "additionalProperties": true + "$ref": "#/components/schemas/HealthFoundation" } } } @@ -3948,9 +3947,6 @@ "version": { "type": "string" }, - "mode": { - "type": "string" - }, "service": { "type": "string" }, @@ -3958,10 +3954,32 @@ "type": "string" } }, + "required": [ + "status", + "version" + ] + }, + "HealthFoundation": { + "type": "object", + "properties": { + "status": { + "type": "string" + }, + "version": { + "type": "string" + }, + "backend": { + "type": "string", + "enum": [ + "sqlite", + "postgresql" + ] + } + }, "required": [ "status", "version", - "mode" + "backend" ] }, "Loop": { diff --git a/scripts/check-contract-conformance.mjs b/scripts/check-contract-conformance.mjs index 907c684..f8cd706 100644 --- a/scripts/check-contract-conformance.mjs +++ b/scripts/check-contract-conformance.mjs @@ -7,18 +7,14 @@ import { contractHealthResponse } from "../src/api/index.ts"; export const repoRoot = resolve(fileURLToPath(new URL("..", import.meta.url))); -const conformanceEnv = { - HASNA_LOOPS_STORAGE_MODE: "self_hosted", -}; - function readJson(path) { return JSON.parse(readFileSync(path, "utf8")); } export function runRawContractConformance(root = repoRoot) { return runRepoConformance(root, { - env: conformanceEnv, - healthSample: contractHealthResponse(conformanceEnv), + env: {}, + healthSample: contractHealthResponse("postgresql"), }); } diff --git a/scripts/smoke-serve.ts b/scripts/smoke-serve.ts index 485dafa..09bd8ba 100644 --- a/scripts/smoke-serve.ts +++ b/scripts/smoke-serve.ts @@ -83,14 +83,29 @@ function assert(cond: unknown, msg: string) { if (!cond) throw new Error(`SMOKE FAIL: ${msg}`); } +function assertNoRetiredDeploymentModes(value: unknown, label: string) { + const serialized = JSON.stringify(value); + assert(!serialized.includes('"mode"'), `${label} omits mode`); + assert(!serialized.includes("deploymentMode"), `${label} omits deploymentMode`); + assert(!serialized.includes("self_hosted"), `${label} omits self_hosted`); + assert(!serialized.includes("remote"), `${label} omits remote`); + assert(!serialized.includes("hybrid"), `${label} omits hybrid`); +} + // Foundation probes (open) const health = await (await fetch(`${base}/health`)).json(); -assert(health.status === "ok" && health.version && health.mode, "health {status,version,mode}"); +assert( + health.status === "ok" && health.version && health.backend === "postgresql", + "health {status,version,backend:postgresql}", +); +assertNoRetiredDeploymentModes(health, "health"); const ready = await fetch(`${base}/ready`); const readyBody = await ready.json(); assert(ready.status === 200 && readyBody.status === "ready", `ready -> ${ready.status} ${JSON.stringify(readyBody)}`); +assertNoRetiredDeploymentModes(readyBody, "ready"); const version = await (await fetch(`${base}/version`)).json(); -assert(version.version && version.mode, "version {version,mode}"); +assert(version.status === "ok" && version.version, "version {status,version}"); +assertNoRetiredDeploymentModes(version, "version"); // Unauthenticated /v1 must be rejected const noauth = await fetch(`${base}/v1/loops`); diff --git a/src/api/index.test.ts b/src/api/index.test.ts index b27bec4..eff2878 100644 --- a/src/api/index.test.ts +++ b/src/api/index.test.ts @@ -128,7 +128,7 @@ describe("loops-api foundation", () => { expect(JSON.stringify(status)).not.toContain("dbPath"); }); - test("health uses the strict contracts shape and maps self_hosted runtime to cloud storage mode", async () => { + test("health exposes status and version without retired deployment modes", async () => { const mod = await import("./index.js"); const previousMode = process.env.HASNA_LOOPS_STORAGE_MODE; const mutableBun = Bun as unknown as { serve: typeof Bun.serve }; @@ -145,6 +145,7 @@ describe("loops-api foundation", () => { mod.createLoopsApiServer({ host: "127.0.0.1", port: 0, + backend: "sqlite", authenticator: { authenticate: async () => { throw new Error("health must not authenticate"); @@ -158,11 +159,18 @@ describe("loops-api foundation", () => { const response = await fetchHandler( new Request("http://loops.test/health"), ); - expect(await response.json()).toEqual({ + const body = await response.json(); + expect(body).toEqual({ status: "ok", version: packageVersion(), - mode: "cloud", - }); + backend: "sqlite", + }); + const serialized = JSON.stringify(body); + expect(serialized).not.toContain("mode"); + expect(serialized).not.toContain("deploymentMode"); + expect(serialized).not.toContain("self_hosted"); + expect(serialized).not.toContain("remote"); + expect(serialized).not.toContain("hybrid"); } finally { mutableBun.serve = originalServe; if (previousMode === undefined) delete process.env.HASNA_LOOPS_STORAGE_MODE; @@ -170,6 +178,94 @@ describe("loops-api foundation", () => { } }); + test("foundation routes and schema omit retired deployment modes", async () => { + const mod = await import("./index.js"); + const server = createTestServer(mod, { + host: "127.0.0.1", + port: 0, + storage: createSqliteLoopStorage(":memory:"), + readyCheck: async () => ({ ready: true }), + }); + try { + for (const path of ["/health", "/healthz", "/ready", "/readyz", "/version", "/v1/version"]) { + const response = await fetch(apiUrl(server, path)); + expect(response.status).toBe(200); + const body = await response.json() as Record; + expect(typeof body.status).toBe("string"); + expect(body.version).toBe(packageVersion()); + if (path === "/health" || path === "/healthz") { + expect(body.backend).toBe("sqlite"); + } else { + expect(body.backend).toBeUndefined(); + } + const serialized = JSON.stringify(body); + expect(serialized).not.toContain("mode"); + expect(serialized).not.toContain("deploymentMode"); + expect(serialized).not.toContain("self_hosted"); + expect(serialized).not.toContain("remote"); + expect(serialized).not.toContain("hybrid"); + } + + const document = mod.openApiDocument() as { + paths: Record; + }; + }>; + components: { + schemas: { + HealthFoundation: { + properties: Record; + required: string[]; + }; + Foundation: { + properties: Record; + required: string[]; + }; + }; + }; + }; + expect(document.paths["/health"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema?.$ref) + .toBe("#/components/schemas/HealthFoundation"); + expect(document.paths["/healthz"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema?.$ref) + .toBe("#/components/schemas/HealthFoundation"); + expect(document.paths["/status"]?.get?.responses?.["200"]?.content?.["application/json"]?.schema) + .toEqual({ type: "object", additionalProperties: true }); + + const healthFoundation = document.components.schemas.HealthFoundation; + expect(healthFoundation.properties.status).toBeDefined(); + expect(healthFoundation.properties.version).toBeDefined(); + expect(healthFoundation.properties.backend).toBeDefined(); + expect(healthFoundation.required).toContain("status"); + expect(healthFoundation.required).toContain("version"); + expect(healthFoundation.required).toContain("backend"); + expect(JSON.stringify(healthFoundation)).not.toContain("mode"); + expect(JSON.stringify(healthFoundation)).not.toContain("self_hosted"); + expect(JSON.stringify(healthFoundation)).not.toContain("remote"); + expect(JSON.stringify(healthFoundation)).not.toContain("hybrid"); + + const foundation = document.components.schemas.Foundation; + expect(foundation.properties.status).toBeDefined(); + expect(foundation.properties.version).toBeDefined(); + expect(foundation.properties.mode).toBeUndefined(); + expect(foundation.properties.deploymentMode).toBeUndefined(); + expect(foundation.required).toContain("status"); + expect(foundation.required).toContain("version"); + expect(foundation.required).not.toContain("mode"); + expect(JSON.stringify(foundation)).not.toContain("self_hosted"); + expect(JSON.stringify(foundation)).not.toContain("remote"); + expect(JSON.stringify(foundation)).not.toContain("hybrid"); + } finally { + server.stop(true); + } + }); + test("OpenAPI documents actionable but bounded validation failures for create and import", async () => { const mod = await import("./index.js"); const document = mod.openApiDocument() as { diff --git a/src/api/index.ts b/src/api/index.ts index e702716..96dbe4d 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -54,7 +54,6 @@ import { import { buildDeploymentStatus, deploymentStatusLine, - resolveLoopDeploymentMode, } from "../lib/mode.js"; import { dueSlots } from "../lib/recurrence.js"; import { @@ -136,9 +135,12 @@ export interface ApiAuthenticator { ): Promise; } +export type ServerDataBackend = "sqlite" | "postgresql"; + export interface LoopsApiServerOptions { host?: string; port?: number; + backend?: ServerDataBackend; storage?: LoopStorageContract; bodyLimitBytes?: number; evidenceLimitBytes?: number; @@ -168,12 +170,16 @@ export interface LoopsApiServerOptions { }>; } -/** Deployment mode for the general foundation envelopes. */ -function foundationMode(): string { - return buildDeploymentStatus({}).activeDeploymentMode; +function resolveServerDataBackend(opts: LoopsApiServerOptions): ServerDataBackend { + if (!opts.storage) return opts.backend ?? "postgresql"; + const inferred = opts.storage.backend === "sqlite" ? "sqlite" : "postgresql"; + if (opts.backend && opts.backend !== inferred) { + throw new Error(`loops-api backend ${opts.backend} does not match storage backend ${inferred}`); + } + return inferred; } -/** Shared { status, version, mode } envelope for /health, /ready, /version. */ +/** Shared { status, version } envelope for /ready and /version. */ function foundationEnvelope( status: string, extra: Record = {}, @@ -181,20 +187,18 @@ function foundationEnvelope( return { status, version: packageVersion(), - mode: foundationMode(), service: "loops", ...extra, }; } export function contractHealthResponse( - env: Record = process.env, -): { status: "ok"; version: string; mode: "local" | "cloud" } { - const runtimeMode = resolveLoopDeploymentMode(env).deploymentMode; + backend: ServerDataBackend, +): { status: "ok"; version: string; backend: ServerDataBackend } { return { status: "ok", version: packageVersion(), - mode: runtimeMode === "local" ? "local" : "cloud", + backend, }; } @@ -216,6 +220,7 @@ export function createLoopsApiServer(opts: LoopsApiServerOptions = {}) { } const authenticator = opts.authenticator; const withTenantStorage = opts.withTenantStorage; + const backend = resolveServerDataBackend(opts); const defaultReady = async (): Promise<{ ready: boolean; code?: string }> => { if (!opts.storage) return { ready: false, code: "storage_unconfigured" }; try { @@ -232,9 +237,9 @@ export function createLoopsApiServer(opts: LoopsApiServerOptions = {}) { idleTimeout: 60, async fetch(request) { const url = new URL(request.url); - // ── Open foundation probes ({ status, version, mode }) ─────────────── + // ── Open foundation probes ──────────────────────────────────────────── if (request.method === "GET" && (url.pathname === "/health" || url.pathname === "/healthz")) { - return Response.json(contractHealthResponse()); + return Response.json(contractHealthResponse(backend)); } if (request.method === "GET" && (url.pathname === "/version" || url.pathname === "/v1/version")) { return Response.json(foundationEnvelope("ok")); diff --git a/src/sdk/http.ts b/src/sdk/http.ts index c9acb01..00a7d57 100644 --- a/src/sdk/http.ts +++ b/src/sdk/http.ts @@ -33,7 +33,9 @@ export interface StuckRunReconciliationOutcome { "runId": string; "outcome": "re export interface StuckRunReconciliationResponse { "ok": boolean; "reconciliation": { "outcomes": Array } } -export interface Foundation { "status": string; "version": string; "mode": string; "service"?: string; "detail"?: string } +export interface Foundation { "status": string; "version": string; "service"?: string; "detail"?: string } + +export interface HealthFoundation { "status": string; "version": string; "backend": "sqlite" | "postgresql" } export interface Loop { "id": string; "name": string; "description"?: string | null; "labels": Array; "status": "active" | "paused" | "stopped" | "expired"; "schedule"?: Record; "target"?: Record; "nextRunAt"?: string | null; "createdAt"?: string; "updatedAt"?: string } @@ -167,7 +169,7 @@ export class LoopsClient { } /** Liveness probe */ - async healthCheck(init?: RequestInit): Promise { + async healthCheck(init?: RequestInit): Promise { return this.request("GET", `/health`, { body: undefined, query: undefined, @@ -175,7 +177,7 @@ export class LoopsClient { }); } - async healthzProbe(init?: RequestInit): Promise> { + async healthzProbe(init?: RequestInit): Promise { return this.request("GET", `/healthz`, { body: undefined, query: undefined, diff --git a/src/serve/index.test.ts b/src/serve/index.test.ts index df5544c..576b6c8 100644 --- a/src/serve/index.test.ts +++ b/src/serve/index.test.ts @@ -1,7 +1,9 @@ import { describe, expect, test } from "bun:test"; +import { fileURLToPath } from "node:url"; import type { QueryResultRow } from "pg"; import type { PoolQueryClient, TypedQueryClient } from "../generated/storage-kit/query.js"; import type { PostgresStorage } from "../lib/storage/postgres.js"; +import { packageVersion } from "../lib/version.js"; import { assertTenantEnforcementBootstrap, assertTenantEnforcementBootstrapIfPending, @@ -261,4 +263,29 @@ describe("loops-serve database bootstrap", () => { expect(transferCommand).toBeDefined(); expect(transferCommand!.options).toHaveLength(0); }); + + test("version exposes status and version without retired deployment modes", () => { + const result = Bun.spawnSync([ + process.execPath, + fileURLToPath(new URL("./index.ts", import.meta.url)), + "version", + ]); + const stdout = result.stdout.toString().trim(); + + expect(result.exitCode).toBe(0); + expect(result.stderr.toString()).toBe(""); + expect(stdout).toBe(JSON.stringify({ status: "ok", version: packageVersion() })); + expect(JSON.parse(stdout)).toEqual({ + status: "ok", + version: packageVersion(), + }); + expect(stdout).not.toContain("mode"); + expect(stdout).not.toContain("deploymentMode"); + expect(stdout).not.toContain("self_hosted"); + expect(stdout).not.toContain("remote"); + expect(stdout).not.toContain("hybrid"); + + const versionCommand = program.commands.find((command) => command.name() === "version"); + expect(versionCommand?.description()).toBe("print { status, version }"); + }); }); diff --git a/src/serve/index.ts b/src/serve/index.ts index d6f1632..8606d6c 100644 --- a/src/serve/index.ts +++ b/src/serve/index.ts @@ -867,6 +867,7 @@ async function runServe(opts: { host: string; port: number }): Promise { const server = createLoopsApiServer({ host: opts.host, port: opts.port, + backend: "postgresql", authenticator, withTenantStorage: (principal, fn) => executor.withRequestContext(principal, (transactionClient) => @@ -1006,8 +1007,8 @@ program program .command("version") - .description("print { status, version, mode }") - .action(() => console.log(JSON.stringify({ status: "ok", version: packageVersion(), mode: "self_hosted" }))); + .description("print { status, version }") + .action(() => console.log(JSON.stringify({ status: "ok", version: packageVersion() }))); if (import.meta.main) { // Bare `loops-serve` (no subcommand) defaults to `serve`. Commander cannot