diff --git a/daemon/coordinator-lease.ts b/daemon/coordinator-lease.ts new file mode 100644 index 0000000..dc62ad2 --- /dev/null +++ b/daemon/coordinator-lease.ts @@ -0,0 +1,172 @@ +/** + * 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 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 }; + +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; +} + +/** + * 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); + if (!env.COORDINATOR_TOKEN) throw new Error(POLICY_BLOCKED_COORDINATOR_UNAVAILABLE); + return { url, token: env.COORDINATOR_TOKEN }; +} + +async function call( + env: CoordinatorEnv, + method: 'GET' | 'POST', + path: string, + body?: unknown, +): Promise { + const { url, token } = requireConfig(env); + const headers: Record = { + 'content-type': 'application/json', + authorization: `Bearer ${token}`, + }; + + const res = await fetch(`${url}/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..8bbdb50 --- /dev/null +++ b/meta/coordinator.ts @@ -0,0 +1,221 @@ +/** + * 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'; + +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}`; + +export class CommandCoordinator extends DurableObject { + async fetch(request: Request): Promise { + const url = new URL(request.url); + // 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}`) { + 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. 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'); + + const role = body.role ?? META_LEADER_ROLE; + const leaseSeconds = normalizeLeaseSeconds(body.leaseSeconds); + const now = Date.now(); + const current = await this.read(role); + + // 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; + + 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/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/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..2e3a781 --- /dev/null +++ b/tests/workers/coordinator.test.ts @@ -0,0 +1,258 @@ +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('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: 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! }; + }); + 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/); + }); + + 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'); + }); +}); 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