diff --git a/bun.lock b/bun.lock index 3a6fd44..3b983db 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@hasna/loops", "dependencies": { "@aws-sdk/client-secrets-manager": "^3.1083.0", - "@hasna/contracts": "0.5.2", + "@hasna/contracts": "0.10.0", "@hasna/events": "^0.1.9", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "2.9.1", @@ -70,7 +70,7 @@ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="], - "@hasna/contracts": ["@hasna/contracts@0.5.2", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/index.js" } }, "sha512-R7NBm91oMOWQVnfIyh7QTEPuWbVycGaObzKFJd1je5RNjXsLuPASqCfr9heJ7nuugn9xxFBCCRQCxHg9/VeJrg=="], + "@hasna/contracts": ["@hasna/contracts@0.10.0", "", { "dependencies": { "commander": "^13.1.0", "zod": "^3.25.76" }, "bin": { "contracts": "dist/cli/index.js", "contracts-cli": "dist/cli/contracts-cli.js" } }, "sha512-TYu3qumRLtEwW7elFwfyvGjPIo12XNivkooR5QTsXTUUK6D837e2Jv8xJtwiLd9e6FDW+qGcbd58Y5all3s4Cg=="], "@hasna/events": ["@hasna/events@0.1.9", "", { "dependencies": { "commander": "^13.1.0" }, "bin": { "events": "dist/cli/index.js", "hasna-events": "dist/cli/index.js" } }, "sha512-9QSXLESDmuqF1esec0DmbOrwjMZgB/r+PRUwYLb2drECDElF9xNx8FBAUzKAMUw0hyS20rsndOIKvYtc3XNlpA=="], diff --git a/hasna.contract.json b/hasna.contract.json index f41c0b7..954d153 100644 --- a/hasna.contract.json +++ b/hasna.contract.json @@ -5,7 +5,7 @@ "class": "service", "contractVersion": "v1", "kitVersion": "0.5.2", - "description": "Persistent loop and workflow runner for deterministic commands and headless AI coding agents. CLI + MCP + HTTP API (loops-serve) + generated SDK. Canonical deployment modes are local, self_hosted, and cloud; self_hosted runs loops-serve against operator-owned Postgres with API-key auth.", + "description": "Persistent loop and workflow runner for deterministic commands and headless AI coding agents. CLI + MCP + HTTP API (loops-serve) + generated SDK. loops-serve runs against operator-owned PostgreSQL with API-key auth; the CLI client reads either a local SQLite file or the HTTP API.", "bins": [ "loops", "loops-mcp", @@ -13,11 +13,6 @@ "loops-daemon", "loops-runner" ], - "deploymentModes": [ - "local", - "self-hosted", - "cloud" - ], "serviceSurfaces": [ { "name": "http", @@ -25,9 +20,6 @@ "bin": "loops-serve", "mcpBin": "loops-mcp", "authMode": "api-key", - "deploymentModes": [ - "self-hosted" - ], "health": { "method": "GET", "path": "/health", @@ -66,9 +58,17 @@ } ], "storage": { - "mode": "local", + "backend": "postgresql", + "engines": [ + "sqlite", + "postgresql" + ], "envPrefix": "HASNA_LOOPS_", - "sqlitePath": "~/.hasna/loops/loops.db" + "sqlitePath": "~/.hasna/loops/loops.db", + "pgTestGate": { + "envVar": "LOOPS_TEST_DATABASE_URL", + "command": "bun test src/lib/storage/postgres-loop-storage.test.ts" + } }, "metadata": { "service": { @@ -80,10 +80,6 @@ "apiVersion": "v1", "auth": "api-key", "signingSecretSecretRef": "hasna/oss/loops/api-key-signing-secret", - "deploymentModeMapping": { - "contract": "self-hosted", - "runtime": "self_hosted" - }, "databaseDsnBindings": [ { "purpose": "runtime", diff --git a/package.json b/package.json index b4b6431..0b7d55a 100644 --- a/package.json +++ b/package.json @@ -115,7 +115,7 @@ }, "dependencies": { "@aws-sdk/client-secrets-manager": "^3.1083.0", - "@hasna/contracts": "0.5.2", + "@hasna/contracts": "0.10.0", "@hasna/events": "^0.1.9", "@modelcontextprotocol/sdk": "^1.29.0", "@openrouter/ai-sdk-provider": "2.9.1", diff --git a/scripts/check-contract-conformance.test.mjs b/scripts/check-contract-conformance.test.mjs index 83bd42b..39d7c5f 100644 --- a/scripts/check-contract-conformance.test.mjs +++ b/scripts/check-contract-conformance.test.mjs @@ -7,10 +7,6 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { - LOOP_DEPLOYMENT_MODES, - normalizeLoopDeploymentMode, -} from "../src/lib/mode.ts"; import { formatContractConformance, repoRoot, @@ -100,7 +96,7 @@ describe("Loops repository contract conformance", () => { expect(manifest.metadata.conformance?.binCompatibilityWaivers).toBeUndefined(); }); - test("declares the exact package, deployment, and split DSN contracts", () => { + test("declares the exact package, storage, and split DSN contracts", () => { const packageJson = readJson(join(repoRoot, "package.json")); const manifest = readJson(join(repoRoot, "hasna.contract.json")); const httpSurface = manifest.serviceSurfaces.find( @@ -124,18 +120,23 @@ describe("Loops repository contract conformance", () => { types: "./dist/api/index.d.ts", import: "./dist/api/index.js", }); - expect(httpSurface.deploymentModes).toEqual(["self-hosted"]); - expect(serviceMetadata.deploymentModeMapping).toEqual({ - contract: "self-hosted", - runtime: "self_hosted", - }); - expect( - LOOP_DEPLOYMENT_MODES.includes( - normalizeLoopDeploymentMode( - serviceMetadata.deploymentModeMapping.runtime, - ), - ), - ).toBe(true); + // The local|self_hosted|cloud placement axis was removed from the contract + // schema. These assert the dead vocabulary stays gone rather than pinning it. + expect(manifest.deploymentModes).toBeUndefined(); + expect(httpSurface.deploymentModes).toBeUndefined(); + expect(httpSurface.deploymentMode).toBeUndefined(); + expect(serviceMetadata.deploymentModeMapping).toBeUndefined(); + + // The only technical switch is the SERVER data backend. `loops-serve` opens + // Postgres unconditionally — src/serve/index.ts resolves the runtime DSN and + // throws when it is absent, and imports no sqlite storage — so the active + // backend is postgresql. sqlite remains a supported engine for the CLI client, + // which is what `engines` records. + expect(manifest.storage.mode).toBeUndefined(); + expect(manifest.storage.backend).toBe("postgresql"); + expect(manifest.storage.engines).toEqual(["sqlite", "postgresql"]); + expect(serveSource).toContain("HASNA_LOOPS_DATABASE_URL"); + expect(serveSource).not.toContain("storage/sqlite"); expect(manifest.storage.databaseUrlSecretRef).toBeUndefined(); expect(