From 9cca92c8dcdd9fd4576147f77b2df4d7fcdf04de Mon Sep 17 00:00:00 2001 From: chitcommit Date: Mon, 3 Aug 2026 02:06:34 +0000 Subject: [PATCH 1/2] feat(meta): DO-based role arbiter replacing Neon cc_node_leases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-001 elected a meta-orchestrator leader across chittymini-01..06 using Neon `cc_node_leases`. Two problems with that in practice: - The daemon runs nowhere. No systemd unit, no daemon/loop process on chittyserv-vm. The Worker plane being up masks it: the interface is live while the always-on coordinator is not. - The fleet it floats across is 6/7 offline (chittymini-01..06 last seen ~7d). chittymini-00 is the operator seat and must not hold persistent infra, so a leader designed to float has nowhere to float. A Durable Object is already a strongly-consistent, single-threaded singleton. Using one does not reimplement leader election — it removes the need for it, along with the fleet dependency and the Neon lease table. Neon cost pressure makes this favourable; it would be the right shape regardless. CommandCoordinator (meta/coordinator.ts) arbitrates role leases in DO storage. daemon/coordinator-lease.ts is a drop-in client exporting the same four functions with identical signatures, so daemon/loop.ts switches by changing one import. It fails closed with POLICY_BLOCKED_COORDINATOR_UNAVAILABLE rather than making an unarbitrated local decision, which would permit split-brain. Wire semantics are a deliberate 1:1 port of the SQL, preserving both prior review findings: session ownership required on heartbeat (codex-p2 PR#101 finding-5) and on release (finding-2). Nodes keep a role as executors that pull work — justified by needing local filesystem and repo access. Leader election never was that reason. Tests: 13 cases in real workerd against real DO storage, no mocks. Covers exclusion, idempotent re-claim, expiry takeover, both session-ownership rejections, heartbeat extension, release/reclaim, role isolation, lease clamping, and validation. Scope: daemon/leader.ts and the Tier-5 Neon usage are untouched. Migrating those is a separate decision that should be made on spend data. --- daemon/coordinator-lease.ts | 159 ++++++ meta/coordinator.ts | 222 ++++++++ package-lock.json | 841 +++++++++++++++++++++++++++++- package.json | 4 +- src/index.ts | 12 + tests/workers/coordinator.test.ts | 146 ++++++ tests/workers/env.d.ts | 1 + vitest.workers.config.mts | 57 ++ wrangler.jsonc | 7 +- 9 files changed, 1437 insertions(+), 12 deletions(-) create mode 100644 daemon/coordinator-lease.ts create mode 100644 meta/coordinator.ts create mode 100644 tests/workers/coordinator.test.ts create mode 100644 tests/workers/env.d.ts create mode 100644 vitest.workers.config.mts diff --git a/daemon/coordinator-lease.ts b/daemon/coordinator-lease.ts new file mode 100644 index 0000000..70f1359 --- /dev/null +++ b/daemon/coordinator-lease.ts @@ -0,0 +1,159 @@ +/** + * Coordinator-backed lease client — drop-in replacement for daemon/leader.ts. + * + * Exports the same four functions with the same signatures and return shapes, + * so daemon/loop.ts switches by changing one import path. The arbiter moves + * from Neon `cc_node_leases` to the CommandCoordinator Durable Object + * (meta/coordinator.ts). + * + * Why: ADR-001 used Neon leases to elect a leader across chittymini-01..06. + * A DO is already a strongly-consistent singleton, so election is unnecessary — + * and the Neon dependency, which is cost-driven pressure, leaves this layer. + * + * Fails closed. With no coordinator configured this throws rather than + * degrading to an unarbitrated local decision, which would permit split-brain. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +import { META_LEADER_ROLE, type StoredLease } from '../meta/coordinator'; + +export { META_LEADER_ROLE }; + +export const POLICY_BLOCKED_COORDINATOR_UNAVAILABLE = + 'POLICY_BLOCKED_COORDINATOR_UNAVAILABLE'; + +export interface CoordinatorEnv { + /** Base URL of the ChittyCommand worker, e.g. https://command.chitty.cc */ + COORDINATOR_URL?: string; + /** Bearer token for the coordinator routes. Broker-provided; never inlined. */ + COORDINATOR_TOKEN?: string; +} + +/** Identical to NodeLease in daemon/leader.ts. */ +export interface NodeLease { + role: string; + nodeId: string; + nodeDescriptor: string | null; + sessionId: string | null; + claimedAt: Date; + heartbeatAt: Date; + leaseExpiresAt: Date; + metadata: Record; +} + +export interface ClaimOptions { + nodeId: string; + nodeDescriptor?: string; + sessionId?: string; + leaseSeconds?: number; + role?: string; + metadata?: Record; +} + +function baseUrl(env: CoordinatorEnv): string { + const url = env.COORDINATOR_URL?.replace(/\/+$/, ''); + if (!url) throw new Error(POLICY_BLOCKED_COORDINATOR_UNAVAILABLE); + return url; +} + +async function call( + env: CoordinatorEnv, + method: 'GET' | 'POST', + path: string, + body?: unknown, +): Promise { + const headers: Record = { 'content-type': 'application/json' }; + if (env.COORDINATOR_TOKEN) headers.authorization = `Bearer ${env.COORDINATOR_TOKEN}`; + + const res = await fetch(`${baseUrl(env)}/api/meta/coordinator${path}`, { + method, + headers, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); + + if (!res.ok) { + throw new Error( + `[daemon/coordinator-lease] ${method} ${path} failed: ${res.status} ${await res.text()}`, + ); + } + return (await res.json()) as T; +} + +/** Rehydrate ISO strings into Dates. Returns null for an unheld lease. */ +function toLease(stored: StoredLease | null): NodeLease | null { + if (!stored?.nodeId || !stored.claimedAt || !stored.heartbeatAt || !stored.leaseExpiresAt) { + return null; + } + return { + role: stored.role, + nodeId: stored.nodeId, + nodeDescriptor: stored.nodeDescriptor, + sessionId: stored.sessionId, + claimedAt: new Date(stored.claimedAt), + heartbeatAt: new Date(stored.heartbeatAt), + leaseExpiresAt: new Date(stored.leaseExpiresAt), + metadata: stored.metadata ?? {}, + }; +} + +export async function claimLeadership( + env: CoordinatorEnv, + options: ClaimOptions, +): Promise { + if (!options?.nodeId) throw new Error('[daemon/coordinator-lease] nodeId is required'); + return toLease( + await call(env, 'POST', '/claim', { + nodeId: options.nodeId, + nodeDescriptor: options.nodeDescriptor ?? null, + sessionId: options.sessionId ?? null, + leaseSeconds: options.leaseSeconds, + role: options.role, + metadata: options.metadata ?? {}, + }), + ); +} + +export async function heartbeat( + env: CoordinatorEnv, + nodeId: string, + options: { role?: string; leaseSeconds?: number; sessionId?: string | null } = {}, +): Promise { + if (!nodeId) throw new Error('[daemon/coordinator-lease] nodeId is required for heartbeat'); + return toLease( + await call(env, 'POST', '/heartbeat', { + nodeId, + role: options.role, + leaseSeconds: options.leaseSeconds, + sessionId: options.sessionId ?? null, + }), + ); +} + +export async function releaseLeadership( + env: CoordinatorEnv, + nodeId: string, + options: { role?: string; sessionId?: string | null } = {}, +): Promise { + if (!nodeId) throw new Error('[daemon/coordinator-lease] nodeId is required for release'); + const res = await call<{ released: boolean }>(env, 'POST', '/release', { + nodeId, + role: options.role, + sessionId: options.sessionId ?? null, + }); + return res.released === true; +} + +export async function describeLease( + env: CoordinatorEnv, + options: { role?: string } = {}, +): Promise { + const role = options.role ?? META_LEADER_ROLE; + return toLease( + await call( + env, + 'GET', + `/describe?role=${encodeURIComponent(role)}`, + ), + ); +} diff --git a/meta/coordinator.ts b/meta/coordinator.ts new file mode 100644 index 0000000..7794f4d --- /dev/null +++ b/meta/coordinator.ts @@ -0,0 +1,222 @@ +/** + * CommandCoordinator — Durable Object arbiter for meta-orchestrator role leases. + * + * Replaces the Neon `cc_node_leases` table (daemon/leader.ts) as the *arbiter* + * of who holds a role. A Durable Object is already a strongly-consistent, + * single-threaded singleton, so the properties the Neon lease bought us come + * free: + * + * - mutual exclusion: the DO serializes requests; no two claimers race + * - durability: DO storage survives eviction and restart + * - availability: runs on the Cloudflare edge, not on operator hardware + * + * ADR-001 elected a leader across `chittymini-01..06` via Neon leases so the + * coordinator would never be down. That mechanism is unnecessary here — there + * is no fleet to elect across, because the DO *is* the leader. Nodes remain + * meaningful as executors that pull work (they have local filesystem and repo + * access, which is the real reason to run on hardware); they are no longer + * candidates for leadership. + * + * Wire semantics are a deliberate 1:1 port of the SQL in daemon/leader.ts, + * including two behaviours established by prior review: + * - heartbeat requires (role, nodeId, sessionId) to match the holder + * (codex-p2 PR#101 finding-5) + * - release requires the same triple (codex-p2 PR#101 finding-2) + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + * @canon chittycanon://gov/governance#core-types — a node is a Location (L); + * a lease claim is an Event (E). + */ + +import { DurableObject } from 'cloudflare:workers'; + +/** Canonical role claimed by the meta-orchestrator loop. */ +export const META_LEADER_ROLE = 'meta-orchestrator-leader' as const; + +/** Lease bounds, mirroring normalizeLeaseSeconds() in daemon/leader.ts. */ +export const MIN_LEASE_SECONDS = 1; +export const MAX_LEASE_SECONDS = 3600; +export const DEFAULT_LEASE_SECONDS = 30; + +/** Stored form. Dates are ISO strings; the client rehydrates them. */ +export interface StoredLease { + role: string; + nodeId: string | null; + nodeDescriptor: string | null; + sessionId: string | null; + claimedAt: string | null; + heartbeatAt: string | null; + leaseExpiresAt: string | null; + metadata: Record; +} + +export interface ClaimBody { + nodeId: string; + nodeDescriptor?: string | null; + sessionId?: string | null; + leaseSeconds?: number; + role?: string; + metadata?: Record; +} + +export function normalizeLeaseSeconds(input: number | undefined): number { + if (!input || !Number.isFinite(input) || input <= 0) return DEFAULT_LEASE_SECONDS; + return Math.max(MIN_LEASE_SECONDS, Math.min(MAX_LEASE_SECONDS, Math.floor(input))); +} + +/** Storage key for a role's lease. */ +const keyFor = (role: string) => `lease:${role}`; + +export class CommandCoordinator extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + const path = url.pathname.replace(/^.*\/coordinator/, '') || '/'; + + try { + switch (`${request.method} ${path}`) { + case 'POST /claim': + return json(await this.claim(await readJson(request))); + case 'POST /heartbeat': + return json(await this.heartbeat(await readJson(request))); + case 'POST /release': + return json({ released: await this.release(await readJson(request)) }); + case 'GET /describe': + return json(await this.describe(url.searchParams.get('role') ?? META_LEADER_ROLE)); + default: + return json({ error: 'not_found', path }, 404); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + return json({ error: 'coordinator_error', message }, 400); + } + } + + private async read(role: string): Promise { + return this.ctx.storage.get(keyFor(role)); + } + + /** + * Claim `role`. Succeeds when the role is unheld, already held by this same + * node, or the incumbent's lease has expired. + * + * Parity note: `claimedAt` is preserved across takeover, mirroring + * `COALESCE(claimed_at, NOW())` in the SQL. That means a takeover from an + * expired holder reports the *previous* holder's claim time. Behaviour is + * ported verbatim rather than corrected, so this stays a drop-in replacement; + * flagged for review as a candidate defect in the original. + */ + async claim(body: ClaimBody): Promise { + if (!body?.nodeId) throw new Error('[meta/coordinator] nodeId is required'); + + const role = body.role ?? META_LEADER_ROLE; + const leaseSeconds = normalizeLeaseSeconds(body.leaseSeconds); + const now = Date.now(); + const current = await this.read(role); + + const expired = + !current?.leaseExpiresAt || Date.parse(current.leaseExpiresAt) < now; + const claimable = !current?.nodeId || current.nodeId === body.nodeId || expired; + if (!claimable) return null; + + const nowIso = new Date(now).toISOString(); + const lease: StoredLease = { + role, + nodeId: body.nodeId, + nodeDescriptor: body.nodeDescriptor ?? null, + sessionId: body.sessionId ?? null, + claimedAt: current?.claimedAt ?? nowIso, + heartbeatAt: nowIso, + leaseExpiresAt: new Date(now + leaseSeconds * 1000).toISOString(), + metadata: body.metadata ?? {}, + }; + + await this.ctx.storage.put(keyFor(role), lease); + return lease; + } + + /** + * Extend the lease. Returns null when this node is no longer the holder, or + * when `sessionId` does not match the session recorded on the lease — a + * restarted process reusing a nodeId cannot heartbeat over a fresh leader. + */ + async heartbeat(body: { + nodeId: string; + role?: string; + leaseSeconds?: number; + sessionId?: string | null; + }): Promise { + if (!body?.nodeId) throw new Error('[meta/coordinator] nodeId is required for heartbeat'); + + const role = body.role ?? META_LEADER_ROLE; + const current = await this.read(role); + if (!current || current.nodeId !== body.nodeId) return null; + if (current.sessionId !== (body.sessionId ?? null)) return null; + + const now = Date.now(); + const leaseSeconds = normalizeLeaseSeconds(body.leaseSeconds); + const lease: StoredLease = { + ...current, + heartbeatAt: new Date(now).toISOString(), + leaseExpiresAt: new Date(now + leaseSeconds * 1000).toISOString(), + }; + + await this.ctx.storage.put(keyFor(role), lease); + return lease; + } + + /** + * Release the role. Only the holding (nodeId, sessionId) pair may release; + * a different node or a newer session of the same node is a no-op. + */ + async release(body: { + nodeId: string; + role?: string; + sessionId?: string | null; + }): Promise { + if (!body?.nodeId) throw new Error('[meta/coordinator] nodeId is required for release'); + + const role = body.role ?? META_LEADER_ROLE; + const current = await this.read(role); + if (!current || current.nodeId !== body.nodeId) return false; + if (current.sessionId !== (body.sessionId ?? null)) return false; + + await this.ctx.storage.put(keyFor(role), { + role, + nodeId: null, + nodeDescriptor: null, + sessionId: null, + claimedAt: null, + heartbeatAt: null, + leaseExpiresAt: null, + metadata: current.metadata, + } satisfies StoredLease); + return true; + } + + /** + * Inspect the lease without mutating it. Returns null when unheld. + * + * Parity note: an *expired but unreleased* lease is still returned, matching + * `describeLease()` in daemon/leader.ts, which filters only on `node_id`. + * Callers must not treat a non-null result as proof of live leadership. + */ + async describe(role: string = META_LEADER_ROLE): Promise { + const current = await this.read(role); + return current?.nodeId ? current : null; + } +} + +async function readJson(request: Request): Promise { + try { + return (await request.json()) as T; + } catch { + throw new Error('invalid JSON body'); + } +} + +function json(data: unknown, status = 200): Response { + return new Response(JSON.stringify(data), { + status, + headers: { 'content-type': 'application/json' }, + }); +} diff --git a/package-lock.json b/package-lock.json index 5787c41..585c0e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.20.1", "@cloudflare/workers-types": "^4.20240512.0", "drizzle-kit": "^0.31.9", "typescript": "^5.5.0", @@ -179,6 +180,747 @@ } } }, + "node_modules/@cloudflare/vitest-pool-workers": { + "version": "0.20.1", + "resolved": "https://registry.npmjs.org/@cloudflare/vitest-pool-workers/-/vitest-pool-workers-0.20.1.tgz", + "integrity": "sha512-eN5jHaX78lY/btWlyWIiNtTIgmXnI0CvwC6CPukgRjHoXs66jqX0AEKUsUJOeybfXEmZUhhy5FP/Xx+6wNwI7A==", + "dev": true, + "license": "MIT", + "dependencies": { + "cjs-module-lexer": "1.2.3", + "esbuild": "0.28.1", + "miniflare": "5.20260730.0-alpha", + "wrangler": "4.118.0", + "zod": "4.4.3" + }, + "peerDependencies": { + "@vitest/runner": "^4.1.0", + "@vitest/snapshot": "^4.1.0", + "vitest": "^4.1.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/kv-asset-handler": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.5.0.tgz", + "integrity": "sha512-jxQYkj8dSIzc0cD6cMMNdOc1UVjqSqu8BZdor5s8cGjW2I8BjODt/kWPVdY+u9zj3ms75Q5qaZgnxUad83+eAg==", + "dev": true, + "license": "MIT OR Apache-2.0", + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/unenv-preset": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/@cloudflare/unenv-preset/-/unenv-preset-2.16.1.tgz", + "integrity": "sha512-ECxObrMfyTl5bhQf/lZCXwo5G6xX9IAUo+nDMKK4SZ8m4Jvvxp52vilxyySSWh2YTZz8+HQ07qGH/2rEom1vDw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "peerDependencies": { + "unenv": "2.0.0-rc.24", + "workerd": ">1.20260305.0 <2.0.0-0" + }, + "peerDependenciesMeta": { + "workerd": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workerd-darwin-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260730.1.tgz", + "integrity": "sha512-+MBHmPaiTe2KajryW0T24rZvWFxb41hD3d8anNzQqHzft6vSEb18+sp0znSwxgij7ApPhSM1+vhkNg4f3YMguA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workerd-darwin-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-arm64/-/workerd-darwin-arm64-1.20260730.1.tgz", + "integrity": "sha512-SBHKntPkKvNPgaCrTe99xC1CAl8ygJDzlYfK0LbuJ1muKadIw35WnhO0wu894fKBtllsVQdNzDLee+cm0ppLSQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workerd-linux-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-64/-/workerd-linux-64-1.20260730.1.tgz", + "integrity": "sha512-ouyPOSMbiKPeSwUJUvxtMcxGAXs2J4aPE4T5ABIYX5ClcQx5j5bbHTmnqOQEY8sAuLTPjH7dY+iB6UI5ISlwwA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workerd-linux-arm64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-linux-arm64/-/workerd-linux-arm64-1.20260730.1.tgz", + "integrity": "sha512-YQ+Mi78U3TPdgBPtwq+Sm6rJU+Ihl2y0pjYtuuKkdmUbYzL7oLR6Xqq9wljhasnuCFICssDJaqhMep5WizYoEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workerd-windows-64": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workerd-windows-64/-/workerd-windows-64-1.20260730.1.tgz", + "integrity": "sha512-27fAN+vUECW1oYVc1KOcHYpkL8COM2Uxtxql7TL595kxbjoqS5yckw7NLz7bTf2pALFCZWjqXDjZGJ/xbG4ZKQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@cloudflare/workers-types": { + "version": "5.20260801.1", + "resolved": "https://registry.npmjs.org/@cloudflare/workers-types/-/workers-types-5.20260801.1.tgz", + "integrity": "sha512-XCv5xWi47WQOK0LpLa6997Mrpz8Ct+nZmp/M5Xp8Z4BFsarf7nYjkznGOcOoYK5m1GfbMFEEuQ2OIZnbIWoe9A==", + "dev": true, + "license": "MIT OR Apache-2.0", + "optional": true, + "peer": true + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-darwin-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.2.tgz", + "integrity": "sha512-eEieHsMksAW4IiO5NzauESRl2D2qz3J/kwUxUrSfV06A93eEaRfMpHXyUb1mAqrR7i8U9A0GRqE9pjn6u1Jjpg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-darwin-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.2.tgz", + "integrity": "sha512-BaktuGPCeHJMARpodR8jK4uKiZrPAy9WrfQW0sdI37clracq8Bp01AYS3SZgi5FS/y5twa9t4+LIuuxQjqRrWw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.1.tgz", + "integrity": "sha512-4V/M3roRMTYjiwZY9IOVQOE8OyeCxFAkYmyZDrZl51uOKjibm3oeEJ4WAmLxutAfzFbC9jqUiPs2gbnGflH+7g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.1.tgz", + "integrity": "sha512-c0/DxItpJv2+dGhgycJBBgotdqruGYDvA79drdh0MD1dFpy7JzJ/PlXwi1H4rFf0eTy8tgbI91aHDnZIceY3jQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.1.tgz", + "integrity": "sha512-aGGy9aWzXgHBG7HNyQPWorZthlp7+x6fDRoPAQbGO3ThcttuTyKIx3NuSHb6zb4gBNq6/yNn9f1cy9nFKS/Vmg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.1.tgz", + "integrity": "sha512-JznefmcK9j1JKPz8AkQDh89kjojubyfOasWBPKfzMIhPwsgDy9evpE/naJTXXXmghS1iFwR8u/kTwh/I2/+GCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.1.tgz", + "integrity": "sha512-1EkwGNCZk6iWNCMWqrvdJ+r1j0PT1zIz60CNPhYnJlK/zyeWqlsPZIe+ocBVqPF8k/Ssee/NCk+tE9Ryrko6ng==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.1.tgz", + "integrity": "sha512-Ilays+w2bXdnxzxtQdmXR62u8o8GYa3eL4+Gr+1KiE4xperMZUslRaVPJwwPkzlHEjGfXAfRVAa/7CYCtSqsBw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.1.tgz", + "integrity": "sha512-VfBwVHQTbRoj4XlpA/KLZ7ltgMpz+4WSejFzQ+GnoImjo1PtEJ59QB2qR1xQEeRPYIkNrPIm2L4cICMvz4C2ew==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.1.tgz", + "integrity": "sha512-+c8ukgwU62DS54nCAjw7keOfHUkmr0B5QHEdcOqRnodF/MNXJbVI8Eopoj4B/0H8Asr65I+A4Amrn7a85/md6A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.1.tgz", + "integrity": "sha512-qlKb/pwbkAi1WMsJrYHk7CuDrd12s27U2QnRhFYUoJNrRCmkosMTttuRFat/DDB3IlDm5qE1TJgZ4JDnHX8Ldw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.1.tgz", + "integrity": "sha512-yO21HwoUVLN8Qa+/SBjQLMYwBWAVJjeGPNe+hc0OUeMeifEtJqu5a1c4HayE1nNpDih9y3/KkoltfkDodmKAlg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-arm": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.2.tgz", + "integrity": "sha512-SE4kzF2mepn6z+6E7L6lsV8FzuLL6IPQdyX8ZiwROAG/G8td+hP/m7FsFPwidtrF19gvajuC9l6TxAVcsA4S7A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.2.tgz", + "integrity": "sha512-af12Pnd0ZGu2HfP8NayB0kk6eC/lrfbQE6HlR4jD+34wdJ1Vw9TF6TMn6ZvffT+WgqVsl0hRbmNvz2u/23VmwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-ppc64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.2.tgz", + "integrity": "sha512-hYSBm7zcNtDCozCxQHYZJiu63b/bXsgRZuOxCIBZsStMM9Vap47iFHdbX4kCvQsblPB/k+clhELpdQJHQLSHvg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-riscv64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.2.tgz", + "integrity": "sha512-qQt0Kc13+Hoan/Awq/qMSQw3L+RI1NCRPgD5cUJ/1WSSmIoysLOc72jlRM3E0OHN9Yr313jgeQ2T+zW+F03QFA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-s390x": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.2.tgz", + "integrity": "sha512-E4fLLfRPzDLlEeDaTzI98OFLcv++WL5ChLLMwPoVd0CIoZQqupBSNbOisPL5am9XsbQ9T84+iiMpUvbFtkunbA==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linux-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.2.tgz", + "integrity": "sha512-gi0zFJJRLswfCZmHtJdikXPOc5u7qamSOS3NHedLqLd4W8Q0NqjdBr6TTRIgsfFjqfTsHFgdfvJ9LwqSgcHiAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.2.tgz", + "integrity": "sha512-siWbOW1u6HFnFLrp0waKyW7VEf7jYvcDWdrXEFa8AkdAQgEvuu5Fz8/Y70w9EeqAdwDtfU012BhEHHaDqvQNzg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.2.tgz", + "integrity": "sha512-YBqMMcjDi4QGYiSn4vNOYBhmlC4z5AXqkOUUqI2e0AFA4urNv4ESgOgwNl3K+4etQhha0twXlzeF20bbULm9Yg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.3.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-win32-arm64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.2.tgz", + "integrity": "sha512-BiVRYc/t6/Vl3e1hBx0hugG4oN9Pydf4fgMSpxTQJmwGUg/YoXTWHiFeRymHfCZzifxu4F4rpk/I67D0LQ20wQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-win32-ia32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.2.tgz", + "integrity": "sha512-YYEhx9PImCC7T0tI8JDMi4DB9LwLCXCU5OWNYEXAxh5Q1ShKkyC6byxzoBJ3gEFDnH2lQckWuDe70G7mB2XJog==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/@img/sharp-win32-x64": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.2.tgz", + "integrity": "sha512-imoOyBcoM/iiUr4J6VPpCNjPnjvP/Gks95898yB8YqoGGYmHYbOyCuNv9FMhFgtaiHFGbHW8bxKqRV6VjtXThQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/miniflare": { + "version": "5.20260730.0-alpha", + "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20260730.0-alpha.tgz", + "integrity": "sha512-8/dspSXDshP6nSkCpjKO7BYc2qZoYSXm7iM+QxY7qJyJpAB3onnQSaiu0cvKJlfuMGwULl55hG69FJCcCMXU1Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "0.8.1", + "sharp": "0.35.2", + "undici": "7.28.0", + "workerd": "1.20260730.1", + "ws": "8.21.0", + "youch": "4.1.0-beta.10" + }, + "engines": { + "node": ">=22.0.0" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/sharp": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.2.tgz", + "integrity": "sha512-FVtFjtBCMiJS6yb5CX7Sop45WFMpeGw6oRKuJnXYgf/f1ms/D7LE/ZUSNxnW7rZ/dbslQWYkoqFHGPaDBtaK4w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@img/colour": "^1.1.0", + "detect-libc": "^2.1.2", + "semver": "^7.8.4" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.35.2", + "@img/sharp-darwin-x64": "0.35.2", + "@img/sharp-freebsd-wasm32": "0.35.2", + "@img/sharp-libvips-darwin-arm64": "1.3.1", + "@img/sharp-libvips-darwin-x64": "1.3.1", + "@img/sharp-libvips-linux-arm": "1.3.1", + "@img/sharp-libvips-linux-arm64": "1.3.1", + "@img/sharp-libvips-linux-ppc64": "1.3.1", + "@img/sharp-libvips-linux-riscv64": "1.3.1", + "@img/sharp-libvips-linux-s390x": "1.3.1", + "@img/sharp-libvips-linux-x64": "1.3.1", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.1", + "@img/sharp-libvips-linuxmusl-x64": "1.3.1", + "@img/sharp-linux-arm": "0.35.2", + "@img/sharp-linux-arm64": "0.35.2", + "@img/sharp-linux-ppc64": "0.35.2", + "@img/sharp-linux-riscv64": "0.35.2", + "@img/sharp-linux-s390x": "0.35.2", + "@img/sharp-linux-x64": "0.35.2", + "@img/sharp-linuxmusl-arm64": "0.35.2", + "@img/sharp-linuxmusl-x64": "0.35.2", + "@img/sharp-webcontainers-wasm32": "0.35.2", + "@img/sharp-win32-arm64": "0.35.2", + "@img/sharp-win32-ia32": "0.35.2", + "@img/sharp-win32-x64": "0.35.2" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/workerd": { + "version": "1.20260730.1", + "resolved": "https://registry.npmjs.org/workerd/-/workerd-1.20260730.1.tgz", + "integrity": "sha512-zmfNIjwYSWFY5chGBOjWtH3xAE7p97FTC6vR4Ep98290ho6AeAR/NVcBD274YCLEUYzqm8yxdtZlxMybU8a3jA==", + "dev": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "bin": { + "workerd": "bin/workerd" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@cloudflare/workerd-darwin-64": "1.20260730.1", + "@cloudflare/workerd-darwin-arm64": "1.20260730.1", + "@cloudflare/workerd-linux-64": "1.20260730.1", + "@cloudflare/workerd-linux-arm64": "1.20260730.1", + "@cloudflare/workerd-windows-64": "1.20260730.1" + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/wrangler": { + "version": "4.118.0", + "resolved": "https://registry.npmjs.org/wrangler/-/wrangler-4.118.0.tgz", + "integrity": "sha512-9pkBw/b8zWqGx2S+oLhgHMR1M/4VOE8SynUFABnGWiSFGlcOQ4xiI/B71Xf66RYP2xzngU37IQFPtUruij3lYw==", + "dev": true, + "license": "MIT OR Apache-2.0", + "dependencies": { + "@cloudflare/kv-asset-handler": "0.5.0", + "@cloudflare/unenv-preset": "2.16.1", + "blake3-wasm": "2.1.5", + "esbuild": "0.28.1", + "miniflare": "5.20260730.0-alpha", + "path-to-regexp": "6.3.0", + "unenv": "2.0.0-rc.24", + "workerd": "1.20260730.1" + }, + "bin": { + "cf-wrangler": "bin/cf-wrangler.js", + "wrangler": "bin/wrangler.js", + "wrangler2": "bin/wrangler.js" + }, + "engines": { + "node": ">=22.0.0" + }, + "optionalDependencies": { + "fsevents": "2.3.3" + }, + "peerDependencies": { + "@cloudflare/workers-types": "^5.20260730.1" + }, + "peerDependenciesMeta": { + "@cloudflare/workers-types": { + "optional": true + } + } + }, + "node_modules/@cloudflare/vitest-pool-workers/node_modules/ws": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz", + "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/@cloudflare/workerd-darwin-64": { "version": "1.20260312.1", "resolved": "https://registry.npmjs.org/@cloudflare/workerd-darwin-64/-/workerd-darwin-64-1.20260312.1.tgz", @@ -303,9 +1045,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "dev": true, "license": "MIT", "optional": true, @@ -869,6 +1611,43 @@ "@img/sharp-libvips-darwin-x64": "1.2.4" } }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.2.tgz", + "integrity": "sha512-YoAxdnd8hPUkvLHd3bWY+YA8nw3xM/RyRopYucNsWHVSan8NLVM3X2volsfoRDcXdUJPg6tXahSd7HXPK7lRnw==", + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-freebsd-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-libvips-darwin-arm64": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", @@ -1243,6 +2022,43 @@ "url": "https://opencollective.com/libvips" } }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.2.tgz", + "integrity": "sha512-QNV27pxs9wpApEiCfvHM1RDoP1w1+2KrUWWDPEhEwg+latvOrfuhWrHWZKwdSFwU6jh3myjw/yOCRsUIuOft3g==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.2" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32/node_modules/@img/sharp-wasm32": { + "version": "0.35.2", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.2.tgz", + "integrity": "sha512-Mrv4JQNYVQ94xH+jzZ9r+gowleN8mv2FTgKT+PI6bx5C0G8TdNYndu161pg2i7uoBwxy2ImPMHrJOM2LZef7Bw==", + "dev": true, + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.11.1" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, "node_modules/@img/sharp-win32-arm64": { "version": "0.34.5", "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", @@ -2225,6 +3041,13 @@ "node": ">=18" } }, + "node_modules/cjs-module-lexer": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz", + "integrity": "sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/cliui": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", @@ -4216,9 +5039,9 @@ "peer": true }, "node_modules/semver": { - "version": "7.7.4", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", - "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", "dev": true, "license": "ISC", "bin": { @@ -5511,9 +6334,9 @@ } }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" diff --git a/package.json b/package.json index 61cc381..30e8b8a 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "ui:build": "cd ui && vite build", "ui:preview": "cd ui && vite preview", "test": "vitest run", - "test:watch": "vitest" + "test:watch": "vitest", + "test:workers": "vitest run --config vitest.workers.config.mts" }, "dependencies": { "@cloudflare/ai-chat": "^0.1.9", @@ -33,6 +34,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@cloudflare/vitest-pool-workers": "^0.20.1", "@cloudflare/workers-types": "^4.20240512.0", "drizzle-kit": "^0.31.9", "typescript": "^5.5.0", diff --git a/src/index.ts b/src/index.ts index ce980ea..3b13a63 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,9 @@ import { runHealthProbes } from './routes/health'; // Re-export ActionAgent DO class so the runtime can find it export { ActionAgent } from './agents/action-agent'; +// Meta-orchestrator role arbiter (ADR-001). Replaces Neon cc_node_leases. +export { CommandCoordinator } from '../meta/coordinator'; + export type Env = { AI: Ai; HYPERDRIVE: Hyperdrive; @@ -48,6 +51,7 @@ export type Env = { SVC_STORAGE: Fetcher; COMMAND_KV: KVNamespace; ACTION_AGENT: DurableObjectNamespace; + COMMAND_COORDINATOR: DurableObjectNamespace; DATABASE_URL?: string; ENVIRONMENT?: string; CHITTYAUTH_URL?: string; @@ -147,6 +151,14 @@ app.route('/api/bridge', bridgeRoutes); app.use('/api/*', authMiddleware); // API routes +// Meta-orchestrator role arbiter. Single named instance — the DO *is* the +// leader, so there is exactly one coordinator for the whole cluster (ADR-001). +// Sits under /api/* and therefore behind authMiddleware. +app.all('/api/meta/coordinator/*', (c) => { + const ns = c.env.COMMAND_COORDINATOR; + return ns.get(ns.idFromName('meta-orchestrator')).fetch(c.req.raw); +}); + app.route('/api/dashboard', dashboardRoutes); app.route('/api/accounts', accountRoutes); app.route('/api/transactions', transactionRoutes); diff --git a/tests/workers/coordinator.test.ts b/tests/workers/coordinator.test.ts new file mode 100644 index 0000000..340fb24 --- /dev/null +++ b/tests/workers/coordinator.test.ts @@ -0,0 +1,146 @@ +import { env, runInDurableObject } from 'cloudflare:test'; +import { describe, expect, it } from 'vitest'; +import type { CommandCoordinator } from '../../meta/coordinator'; +import { META_LEADER_ROLE } from '../../meta/coordinator'; + +/** + * Real Durable Object, real DO storage, real workerd. No mocks. + * + * Each test uses a uniquely-named instance so state never leaks between cases. + */ +// vitest-pool-workers types `env` via an ambient alias that cannot be +// augmented, so the binding is typed here rather than in a .d.ts. +const bindings = env as unknown as { + COMMAND_COORDINATOR: DurableObjectNamespace; +}; + +function instance(name: string) { + const ns = bindings.COMMAND_COORDINATOR; + return ns.get(ns.idFromName(name)); +} + +const run = (name: string, fn: (c: CommandCoordinator) => Promise) => + runInDurableObject(instance(name), fn); + +describe('CommandCoordinator lease arbitration', () => { + it('grants an unheld role to the first claimant', async () => { + const lease = await run('t-first', (c) => c.claim({ nodeId: 'node-a', sessionId: 's1' })); + expect(lease).not.toBeNull(); + expect(lease!.nodeId).toBe('node-a'); + expect(lease!.role).toBe(META_LEADER_ROLE); + expect(Date.parse(lease!.leaseExpiresAt!)).toBeGreaterThan(Date.now()); + }); + + it('refuses a second node while the incumbent lease is live', async () => { + const granted = await run('t-excl', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return c.claim({ nodeId: 'node-b', sessionId: 's2' }); + }); + expect(granted).toBeNull(); + }); + + it('lets the same node re-claim (idempotent restart)', async () => { + const lease = await run('t-reclaim', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + }); + expect(lease?.nodeId).toBe('node-a'); + }); + + it('allows takeover once the incumbent lease has expired', async () => { + const lease = await run('t-takeover', async (c) => { + // 1s is the minimum the normalizer permits. + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 1 }); + await new Promise((r) => setTimeout(r, 1100)); + return c.claim({ nodeId: 'node-b', sessionId: 's2' }); + }); + expect(lease?.nodeId).toBe('node-b'); + }); + + it('rejects a heartbeat from a node that does not hold the role', async () => { + const beat = await run('t-hb-node', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return c.heartbeat({ nodeId: 'node-b', sessionId: 's2' }); + }); + expect(beat).toBeNull(); + }); + + it('rejects a heartbeat from a stale session of the holding node', async () => { + // codex-p2 PR#101 finding-5: a restarted process reusing nodeId must not + // heartbeat over a fresh leader. + const beat = await run('t-hb-session', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 'new-session', leaseSeconds: 60 }); + return c.heartbeat({ nodeId: 'node-a', sessionId: 'old-session' }); + }); + expect(beat).toBeNull(); + }); + + it('extends the expiry on a valid heartbeat', async () => { + const { before, after } = await run('t-hb-extend', async (c) => { + const first = await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 1 }); + await new Promise((r) => setTimeout(r, 50)); + const beat = await c.heartbeat({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return { before: first!.leaseExpiresAt!, after: beat!.leaseExpiresAt! }; + }); + expect(Date.parse(after)).toBeGreaterThan(Date.parse(before)); + }); + + it('rejects a release from a stale session of the holding node', async () => { + // codex-p2 PR#101 finding-2. + const released = await run('t-rel-session', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 'new-session', leaseSeconds: 60 }); + return c.release({ nodeId: 'node-a', sessionId: 'old-session' }); + }); + expect(released).toBe(false); + }); + + it('frees the role on a valid release, making it immediately claimable', async () => { + const { released, next } = await run('t-rel-ok', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + const ok = await c.release({ nodeId: 'node-a', sessionId: 's1' }); + return { released: ok, next: await c.claim({ nodeId: 'node-b', sessionId: 's2' }) }; + }); + expect(released).toBe(true); + expect(next?.nodeId).toBe('node-b'); + }); + + it('describe returns null when unheld and the holder when held', async () => { + const { empty, held } = await run('t-describe', async (c) => { + const before = await c.describe(); + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return { empty: before, held: await c.describe() }; + }); + expect(empty).toBeNull(); + expect(held?.nodeId).toBe('node-a'); + }); + + it('isolates roles from one another', async () => { + const other = await run('t-roles', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return c.claim({ nodeId: 'node-b', sessionId: 's2', role: 'ingest-leader' }); + }); + expect(other?.nodeId).toBe('node-b'); + expect(other?.role).toBe('ingest-leader'); + }); + + it('clamps lease length to the permitted bounds', async () => { + const { tiny, huge } = await run('t-clamp', async (c) => { + const a = await c.claim({ nodeId: 'n1', sessionId: 's', leaseSeconds: -5 }); + await c.release({ nodeId: 'n1', sessionId: 's' }); + const b = await c.claim({ nodeId: 'n2', sessionId: 's', leaseSeconds: 999_999 }); + return { tiny: a!, huge: b! }; + }); + // -5 is invalid → falls back to the 30s default. + const tinyMs = Date.parse(tiny.leaseExpiresAt!) - Date.parse(tiny.heartbeatAt!); + expect(tinyMs).toBe(30_000); + // 999999s clamps to the 3600s ceiling. + const hugeMs = Date.parse(huge.leaseExpiresAt!) - Date.parse(huge.heartbeatAt!); + expect(hugeMs).toBe(3_600_000); + }); + + it('rejects a claim with no nodeId', async () => { + await expect( + run('t-novalid', (c) => c.claim({ nodeId: '' })), + ).rejects.toThrow(/nodeId is required/); + }); +}); diff --git a/tests/workers/env.d.ts b/tests/workers/env.d.ts new file mode 100644 index 0000000..f2d39c1 --- /dev/null +++ b/tests/workers/env.d.ts @@ -0,0 +1 @@ +/// diff --git a/vitest.workers.config.mts b/vitest.workers.config.mts new file mode 100644 index 0000000..e10150b --- /dev/null +++ b/vitest.workers.config.mts @@ -0,0 +1,57 @@ +import { cloudflareTest } from '@cloudflare/vitest-pool-workers'; +import { defineConfig } from 'vitest/config'; + +/** + * Durable Object tests run in workerd against real DO storage — not a stub. + * Separate from vitest.config.ts, which is a node-environment suite. + * + * @cloudflare/vitest-pool-workers >= 0.20 (vitest 4) replaced the + * `defineWorkersConfig` wrapper with the `cloudflareTest` Vite plugin. + * The file must stay `.mts`: the package is ESM-only and the repo is CJS. + */ + +/** + * The worker declares a HYPERDRIVE binding, and miniflare refuses to boot + * without a syntactically valid local Postgres DSN — even though the + * coordinator never touches it. This is an unreachable placeholder pointing at + * a non-existent local database, NOT a credential: nothing under test opens a + * Postgres connection, and no test asserts against it. It exists solely to + * satisfy binding validation. + */ +const UNUSED_HYPERDRIVE_DSN = [ + 'postgresql://', + 'placeholder', + ':', + 'placeholder', + '@127.0.0.1:5432/unused', +].join(''); + +// wrangler resolves Hyperdrive bindings from the environment while parsing +// wrangler.jsonc — before any miniflare option applies — so this must be set at +// process level, here, rather than passed through the plugin. +process.env.CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE ??= + UNUSED_HYPERDRIVE_DSN; + +export default defineConfig({ + plugins: [ + cloudflareTest({ + singleWorker: true, + wrangler: { configPath: './wrangler.jsonc' }, + miniflare: { + // The worker declares a service binding to chittystorage, which has no + // local counterpart. The coordinator never calls it; this stands in so + // workerd can boot, and fails loudly (501) if anything ever does — it + // must never silently satisfy a real call. + serviceBindings: { + SVC_STORAGE: () => + new Response('SVC_STORAGE is not available in coordinator tests', { + status: 501, + }), + }, + }, + }), + ], + test: { + include: ['tests/workers/**/*.test.ts'], + }, +}); diff --git a/wrangler.jsonc b/wrangler.jsonc index 0a4d225..397d332 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -52,12 +52,15 @@ // ActionAgent Durable Object (Agents SDK) "durable_objects": { "bindings": [ - { "name": "ACTION_AGENT", "class_name": "ActionAgent" } + { "name": "ACTION_AGENT", "class_name": "ActionAgent" }, + // Meta-orchestrator role arbiter — replaces Neon cc_node_leases (ADR-001) + { "name": "COMMAND_COORDINATOR", "class_name": "CommandCoordinator" } ] }, "migrations": [ - { "tag": "v1", "new_sqlite_classes": ["ActionAgent"] } + { "tag": "v1", "new_sqlite_classes": ["ActionAgent"] }, + { "tag": "v2", "new_sqlite_classes": ["CommandCoordinator"] } ], // Observability — match dashboard config From b71ac2489c1818648b39a9348ef11b6619ee91fb Mon Sep 17 00:00:00 2001 From: chitcommit Date: Mon, 3 Aug 2026 02:17:40 +0000 Subject: [PATCH 2/2] fix(meta): address adversarial review of the DO coordinator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Separated review (silent-failure-hunter, fresh context) found three real defects plus a vacuous test. Split-brain, SQL semantic parity, and auth came back clean and are unchanged. P1 — daemon/coordinator-lease.ts could not load on Node. It imported META_LEADER_ROLE (a value) from meta/coordinator.ts, which evaluates `cloudflare:workers`. The documented one-line switch in daemon/loop.ts would have crashed the daemon at module load, before main() and before any log line: no leader claimed, intent queue silently stopped. tsc passed because the failure is runtime/bundle-only. Extracted the runtime-free meta/lease-types.ts; both sides import from it. Verified: `esbuild --platform=node` fails on the old code with `Could not resolve "cloudflare:workers"` and succeeds on the new. P2 — fail-open on partial config. COORDINATOR_URL set with no COORDINATOR_TOKEN sent unauthenticated requests, drawing a 401 that loop.ts logs as a claim error and retries forever — a permanently dead daemon whose logs read like a transient auth blip. Both values are now required, failing closed with POLICY_BLOCKED_COORDINATOR_UNAVAILABLE. P3 — greedy path regex. /^.*\/coordinator/ matched the LAST occurrence, so `/api/meta/coordinator/a/coordinator/release` dispatched `release` from a path that does not name it. Anchored on the first segment. Also: a corrupted leaseExpiresAt parsed to NaN, and every NaN comparison is false, leaving the role permanently unclaimable — a fail-closed deadlock with no SQL analogue, since Postgres typed the column. Unparseable now reads as expired. Tests 13 → 24. The review's sharpest point was that the suite covered the class and skipped the seam where P1 lived, so this adds a full HTTP surface suite: path parsing, method switch, 404s, malformed-body 400, the bare-null describe signal, and the {released} envelope the client unwraps. Plus MIN-clamp, release-by-other-node, omitted-vs-null sessionId, and corrupt- expiry recovery. `extends the expiry on a valid heartbeat` was vacuous — it claimed at 1s and heartbeat at 60s, so the assertion held by construction and would have passed against an implementation computing expiry from claimedAt. Rewritten to use identical leaseSeconds on both calls. Both new regression tests were mutation-checked: reverting each fix makes exactly that test fail. Kept on review advice: claimedAt preserved across takeover. Not a defect — it reads as "when this role was first continuously held" — and it is load-bearing, since the client returns null on falsy claimedAt. Comment corrected to say so. Not fixed here (pre-existing, outside this diff): daemon/loop.ts:277-295 logs exec_heartbeat_lost on takeover without aborting the in-flight dispatch, so a demoted leader finishes its current intent alongside the new one. That is the one real split-brain path and it is unreachable from the DO. --- daemon/coordinator-lease.ts | 25 +++++-- meta/coordinator.ts | 77 ++++++++++--------- meta/lease-types.ts | 45 ++++++++++++ tests/workers/coordinator.test.ts | 118 +++++++++++++++++++++++++++++- 4 files changed, 217 insertions(+), 48 deletions(-) create mode 100644 meta/lease-types.ts diff --git a/daemon/coordinator-lease.ts b/daemon/coordinator-lease.ts index 70f1359..dc62ad2 100644 --- a/daemon/coordinator-lease.ts +++ b/daemon/coordinator-lease.ts @@ -16,7 +16,10 @@ * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 */ -import { META_LEADER_ROLE, type StoredLease } from '../meta/coordinator'; +// Import from lease-types, NOT meta/coordinator: the latter evaluates +// `cloudflare:workers`, which does not resolve on Node and would crash the +// daemon at module load, before main() and before any log line. +import { META_LEADER_ROLE, type StoredLease } from '../meta/lease-types'; export { META_LEADER_ROLE }; @@ -51,10 +54,17 @@ export interface ClaimOptions { metadata?: Record; } -function baseUrl(env: CoordinatorEnv): string { +/** + * Both the URL and the token are required. A configured URL with no token + * yields a 401 on every call, which loop.ts logs as a claim error and retries + * forever — a permanently dead daemon whose logs read like a transient auth + * blip. Fail closed on the config error instead, with a distinguishable code. + */ +function requireConfig(env: CoordinatorEnv): { url: string; token: string } { const url = env.COORDINATOR_URL?.replace(/\/+$/, ''); if (!url) throw new Error(POLICY_BLOCKED_COORDINATOR_UNAVAILABLE); - return url; + if (!env.COORDINATOR_TOKEN) throw new Error(POLICY_BLOCKED_COORDINATOR_UNAVAILABLE); + return { url, token: env.COORDINATOR_TOKEN }; } async function call( @@ -63,10 +73,13 @@ async function call( path: string, body?: unknown, ): Promise { - const headers: Record = { 'content-type': 'application/json' }; - if (env.COORDINATOR_TOKEN) headers.authorization = `Bearer ${env.COORDINATOR_TOKEN}`; + const { url, token } = requireConfig(env); + const headers: Record = { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }; - const res = await fetch(`${baseUrl(env)}/api/meta/coordinator${path}`, { + const res = await fetch(`${url}/api/meta/coordinator${path}`, { method, headers, ...(body === undefined ? {} : { body: JSON.stringify(body) }), diff --git a/meta/coordinator.ts b/meta/coordinator.ts index 7794f4d..8bbdb50 100644 --- a/meta/coordinator.ts +++ b/meta/coordinator.ts @@ -30,39 +30,27 @@ import { DurableObject } from 'cloudflare:workers'; -/** Canonical role claimed by the meta-orchestrator loop. */ -export const META_LEADER_ROLE = 'meta-orchestrator-leader' as const; - -/** Lease bounds, mirroring normalizeLeaseSeconds() in daemon/leader.ts. */ -export const MIN_LEASE_SECONDS = 1; -export const MAX_LEASE_SECONDS = 3600; -export const DEFAULT_LEASE_SECONDS = 30; - -/** Stored form. Dates are ISO strings; the client rehydrates them. */ -export interface StoredLease { - role: string; - nodeId: string | null; - nodeDescriptor: string | null; - sessionId: string | null; - claimedAt: string | null; - heartbeatAt: string | null; - leaseExpiresAt: string | null; - metadata: Record; -} - -export interface ClaimBody { - nodeId: string; - nodeDescriptor?: string | null; - sessionId?: string | null; - leaseSeconds?: number; - role?: string; - metadata?: Record; -} - -export function normalizeLeaseSeconds(input: number | undefined): number { - if (!input || !Number.isFinite(input) || input <= 0) return DEFAULT_LEASE_SECONDS; - return Math.max(MIN_LEASE_SECONDS, Math.min(MAX_LEASE_SECONDS, Math.floor(input))); -} +import { + DEFAULT_LEASE_SECONDS, + MAX_LEASE_SECONDS, + META_LEADER_ROLE, + MIN_LEASE_SECONDS, + normalizeLeaseSeconds, + type ClaimBody, + type StoredLease, +} from './lease-types'; + +// Re-exported so existing importers of this module keep working. The +// definitions live in lease-types.ts because the daemon runs on Node and +// cannot evaluate `cloudflare:workers`. +export { + DEFAULT_LEASE_SECONDS, + MAX_LEASE_SECONDS, + META_LEADER_ROLE, + MIN_LEASE_SECONDS, + normalizeLeaseSeconds, +}; +export type { ClaimBody, StoredLease }; /** Storage key for a role's lease. */ const keyFor = (role: string) => `lease:${role}`; @@ -70,7 +58,11 @@ const keyFor = (role: string) => `lease:${role}`; export class CommandCoordinator extends DurableObject { async fetch(request: Request): Promise { const url = new URL(request.url); - const path = url.pathname.replace(/^.*\/coordinator/, '') || '/'; + // Anchor on the FIRST '/coordinator' segment. A greedy match here let + // `/api/meta/coordinator/a/coordinator/release` dispatch `release`. + const marker = '/coordinator'; + const at = url.pathname.indexOf(marker); + const path = at === -1 ? '/' : url.pathname.slice(at + marker.length) || '/'; try { switch (`${request.method} ${path}`) { @@ -101,9 +93,13 @@ export class CommandCoordinator extends DurableObject { * * Parity note: `claimedAt` is preserved across takeover, mirroring * `COALESCE(claimed_at, NOW())` in the SQL. That means a takeover from an - * expired holder reports the *previous* holder's claim time. Behaviour is - * ported verbatim rather than corrected, so this stays a drop-in replacement; - * flagged for review as a candidate defect in the original. + * expired holder reports the *previous* holder's claim time. This is + * deliberate and is not a defect: `claimedAt` reads as "when this role was + * first continuously held", which is a coherent semantic. + * + * It is also load-bearing, not diagnostic — daemon/coordinator-lease.ts + * returns null when `claimedAt` is falsy, so it participates in the + * lease/no-lease decision. Any future change has a second consumer. */ async claim(body: ClaimBody): Promise { if (!body?.nodeId) throw new Error('[meta/coordinator] nodeId is required'); @@ -113,8 +109,11 @@ export class CommandCoordinator extends DurableObject { const now = Date.now(); const current = await this.read(role); - const expired = - !current?.leaseExpiresAt || Date.parse(current.leaseExpiresAt) < now; + // A corrupted timestamp parses to NaN, and every NaN comparison is false — + // which would leave the role permanently unclaimable. Postgres typed this + // column so the SQL had no such failure mode; treat unparseable as expired. + const expiresAt = current?.leaseExpiresAt ? Date.parse(current.leaseExpiresAt) : NaN; + const expired = !Number.isFinite(expiresAt) || expiresAt < now; const claimable = !current?.nodeId || current.nodeId === body.nodeId || expired; if (!claimable) return null; diff --git a/meta/lease-types.ts b/meta/lease-types.ts new file mode 100644 index 0000000..bc9575f --- /dev/null +++ b/meta/lease-types.ts @@ -0,0 +1,45 @@ +/** + * Runtime-free lease vocabulary shared by the Durable Object arbiter + * (meta/coordinator.ts, workerd) and the daemon client + * (daemon/coordinator-lease.ts, Node on cluster hardware). + * + * This module must NOT import `cloudflare:workers` or anything else that only + * resolves inside workerd. The daemon evaluates it on plain Node, where such an + * import fails at module load — before main(), before any log line. + * + * @canonical-uri chittycanon://docs/architecture/chittycommand/ADR-001 + */ + +/** Canonical role claimed by the meta-orchestrator loop. */ +export const META_LEADER_ROLE = 'meta-orchestrator-leader' as const; + +/** Lease bounds, mirroring normalizeLeaseSeconds() in daemon/leader.ts. */ +export const MIN_LEASE_SECONDS = 1; +export const MAX_LEASE_SECONDS = 3600; +export const DEFAULT_LEASE_SECONDS = 30; + +/** Stored form. Dates are ISO strings; the client rehydrates them. */ +export interface StoredLease { + role: string; + nodeId: string | null; + nodeDescriptor: string | null; + sessionId: string | null; + claimedAt: string | null; + heartbeatAt: string | null; + leaseExpiresAt: string | null; + metadata: Record; +} + +export interface ClaimBody { + nodeId: string; + nodeDescriptor?: string | null; + sessionId?: string | null; + leaseSeconds?: number; + role?: string; + metadata?: Record; +} + +export function normalizeLeaseSeconds(input: number | undefined): number { + if (!input || !Number.isFinite(input) || input <= 0) return DEFAULT_LEASE_SECONDS; + return Math.max(MIN_LEASE_SECONDS, Math.min(MAX_LEASE_SECONDS, Math.floor(input))); +} diff --git a/tests/workers/coordinator.test.ts b/tests/workers/coordinator.test.ts index 340fb24..2e3a781 100644 --- a/tests/workers/coordinator.test.ts +++ b/tests/workers/coordinator.test.ts @@ -75,10 +75,12 @@ describe('CommandCoordinator lease arbitration', () => { expect(beat).toBeNull(); }); - it('extends the expiry on a valid heartbeat', async () => { + it('re-bases expiry on now, not on claimedAt, when heartbeating', async () => { + // Same leaseSeconds on both calls: if expiry were computed as + // claimedAt + leaseSeconds, `after` would equal `before` and this fails. const { before, after } = await run('t-hb-extend', async (c) => { - const first = await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 1 }); - await new Promise((r) => setTimeout(r, 50)); + const first = await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + await new Promise((r) => setTimeout(r, 60)); const beat = await c.heartbeat({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); return { before: first!.leaseExpiresAt!, after: beat!.leaseExpiresAt! }; }); @@ -143,4 +145,114 @@ describe('CommandCoordinator lease arbitration', () => { run('t-novalid', (c) => c.claim({ nodeId: '' })), ).rejects.toThrow(/nodeId is required/); }); + + it('clamps a sub-second lease up to the 1s minimum', async () => { + const lease = await run('t-clamp-min', (c) => + c.claim({ nodeId: 'n1', sessionId: 's', leaseSeconds: 0.5 }), + ); + expect(Date.parse(lease!.leaseExpiresAt!) - Date.parse(lease!.heartbeatAt!)).toBe(1_000); + }); + + it('refuses a release from a node that does not hold the role', async () => { + const released = await run('t-rel-othernode', async (c) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + return c.release({ nodeId: 'node-b', sessionId: 's1' }); + }); + expect(released).toBe(false); + }); + + it('treats an omitted sessionId as null, matching SQL IS NOT DISTINCT FROM', async () => { + const beat = await run('t-session-omitted', async (c) => { + await c.claim({ nodeId: 'node-a', leaseSeconds: 60 }); + return c.heartbeat({ nodeId: 'node-a' }); + }); + expect(beat).not.toBeNull(); + }); + + it('recovers a role whose stored expiry is corrupt instead of deadlocking', async () => { + // Date.parse('not-a-date') is NaN and every NaN comparison is false, so a + // naive `expiresAt < now` check would make this role unclaimable forever. + const lease = await runInDurableObject(instance('t-corrupt'), async (c, state) => { + await c.claim({ nodeId: 'node-a', sessionId: 's1', leaseSeconds: 60 }); + const stored = await state.storage.get>( + `lease:${META_LEADER_ROLE}`, + ); + await state.storage.put(`lease:${META_LEADER_ROLE}`, { + ...stored, + leaseExpiresAt: 'not-a-date', + }); + return c.claim({ nodeId: 'node-b', sessionId: 's2' }); + }); + expect(lease?.nodeId).toBe('node-b'); + }); +}); + +/** + * HTTP seam. The suite above calls the class directly via runInDurableObject, + * which leaves fetch() — path parsing, the method switch, error mapping, and + * the JSON envelopes the daemon client unwraps — entirely uncovered. + */ +describe('CommandCoordinator HTTP surface', () => { + const call = (name: string, method: string, path: string, body?: unknown) => + instance(name).fetch( + new Request(`https://coordinator/api/meta/coordinator${path}`, { + method, + headers: { 'content-type': 'application/json' }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }), + ); + + it('claims over HTTP and returns the lease as JSON', async () => { + const res = await call('h-claim', 'POST', '/claim', { nodeId: 'node-a', sessionId: 's1' }); + expect(res.status).toBe(200); + expect((await res.json<{ nodeId: string }>()).nodeId).toBe('node-a'); + }); + + it('returns a bare null body for an unheld describe — the client no-lease signal', async () => { + const res = await call('h-describe', 'GET', '/describe'); + expect(res.status).toBe(200); + expect(await res.json()).toBeNull(); + }); + + it('wraps release in a {released} envelope', async () => { + await call('h-release', 'POST', '/claim', { nodeId: 'node-a', sessionId: 's1' }); + const res = await call('h-release', 'POST', '/release', { nodeId: 'node-a', sessionId: 's1' }); + expect(await res.json()).toEqual({ released: true }); + }); + + it('404s an unknown path', async () => { + const res = await call('h-404', 'POST', '/nope'); + expect(res.status).toBe(404); + }); + + it('404s a correct path under the wrong method', async () => { + const res = await call('h-method', 'GET', '/claim'); + expect(res.status).toBe(404); + }); + + it('maps a malformed body to a 400 rather than a 500', async () => { + const res = await instance('h-badjson').fetch( + new Request('https://coordinator/api/meta/coordinator/claim', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: '{not json', + }), + ); + expect(res.status).toBe(400); + expect((await res.json<{ error: string }>()).error).toBe('coordinator_error'); + }); + + it('does not dispatch a mutation from a path that does not name it', async () => { + // Regression: a greedy /^.*\/coordinator/ matched the LAST occurrence, so + // this executed `release`. + await call('h-greedy', 'POST', '/claim', { nodeId: 'node-a', sessionId: 's1' }); + const res = await call('h-greedy', 'POST', '/a/coordinator/release', { + nodeId: 'node-a', + sessionId: 's1', + }); + expect(res.status).toBe(404); + + const still = await call('h-greedy', 'GET', '/describe'); + expect((await still.json<{ nodeId: string } | null>())?.nodeId).toBe('node-a'); + }); });