Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 25 additions & 7 deletions openapi/loops.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Foundation"
"$ref": "#/components/schemas/HealthFoundation"
}
}
}
Expand Down Expand Up @@ -3032,8 +3032,7 @@
"content": {
"application/json": {
"schema": {
"type": "object",
"additionalProperties": true
"$ref": "#/components/schemas/HealthFoundation"
}
}
}
Expand Down Expand Up @@ -3948,20 +3947,39 @@
"version": {
"type": "string"
},
"mode": {
"type": "string"
},
"service": {
"type": "string"
},
"detail": {
"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": {
Expand Down
8 changes: 2 additions & 6 deletions scripts/check-contract-conformance.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
});
}

Expand Down
19 changes: 17 additions & 2 deletions scripts/smoke-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`);
Expand Down
104 changes: 100 additions & 4 deletions src/api/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand All @@ -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");
Expand All @@ -158,18 +159,113 @@ 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;
else process.env.HASNA_LOOPS_STORAGE_MODE = previousMode;
}
});

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<string, unknown>;
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<string, {
get?: {
responses?: Record<string, {
content?: {
"application/json"?: {
schema?: { $ref?: string; type?: string; additionalProperties?: boolean };
};
};
}>;
};
}>;
components: {
schemas: {
HealthFoundation: {
properties: Record<string, unknown>;
required: string[];
};
Foundation: {
properties: Record<string, unknown>;
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 {
Expand Down
29 changes: 17 additions & 12 deletions src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,6 @@ import {
import {
buildDeploymentStatus,
deploymentStatusLine,
resolveLoopDeploymentMode,
} from "../lib/mode.js";
import { dueSlots } from "../lib/recurrence.js";
import {
Expand Down Expand Up @@ -136,9 +135,12 @@ export interface ApiAuthenticator {
): Promise<TenantAuthDecision>;
}

export type ServerDataBackend = "sqlite" | "postgresql";

export interface LoopsApiServerOptions {
host?: string;
port?: number;
backend?: ServerDataBackend;
storage?: LoopStorageContract;
bodyLimitBytes?: number;
evidenceLimitBytes?: number;
Expand Down Expand Up @@ -168,33 +170,35 @@ 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<string, unknown> = {},
): Record<string, unknown> {
return {
status,
version: packageVersion(),
mode: foundationMode(),
service: "loops",
...extra,
};
}

export function contractHealthResponse(
env: Record<string, string | undefined> = 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,
};
}

Expand All @@ -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 {
Expand All @@ -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"));
Expand Down
8 changes: 5 additions & 3 deletions src/sdk/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ export interface StuckRunReconciliationOutcome { "runId": string; "outcome": "re

export interface StuckRunReconciliationResponse { "ok": boolean; "reconciliation": { "outcomes": Array<StuckRunReconciliationOutcome> } }

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<string>; "status": "active" | "paused" | "stopped" | "expired"; "schedule"?: Record<string, unknown>; "target"?: Record<string, unknown>; "nextRunAt"?: string | null; "createdAt"?: string; "updatedAt"?: string }

Expand Down Expand Up @@ -167,15 +169,15 @@ export class LoopsClient {
}

/** Liveness probe */
async healthCheck(init?: RequestInit): Promise<Foundation> {
async healthCheck(init?: RequestInit): Promise<HealthFoundation> {
return this.request("GET", `/health`, {
body: undefined,
query: undefined,
init,
});
}

async healthzProbe(init?: RequestInit): Promise<Record<string, unknown>> {
async healthzProbe(init?: RequestInit): Promise<HealthFoundation> {
return this.request("GET", `/healthz`, {
body: undefined,
query: undefined,
Expand Down
Loading
Loading