import { randomId } from "../crypto"; /** A claim is advisory: it never blocks git, but overlapping claims are refused unless forced. */ export type LeaseKind = "task" | "file"; export type Lease = { id: string; kind: LeaseKind; key: string; holder: string; agent: string; user: string; note: string | null; expiresAt: number; }; export type ClaimRequest = { kind: LeaseKind; keys: string[]; holder: string; agent: string; user: string; note: string | null; ttlMs: number; force: boolean; }; export type ClaimConflict = { key: string; lease: Lease }; /** `renewed` lists granted lease ids the holder already had before this claim. */ export type ClaimResult = { granted: Lease[]; conflicts: ClaimConflict[]; renewed: string[] }; /** `data` is JSON text so events cross Durable Object RPC without deep generic types. */ export type RepoEvent = { seq: number; type: string; at: number; data: string }; const MAX_EVENTS = 200; const MAX_LEASES = 2000; const MAX_LEASES_PER_HOLDER = 200; export const MAX_SUBSCRIBERS = 200; /** One token (or user) may hold only a few of a repo's subscriber slots, so a reader cannot starve the rest (INT-A13). */ export const MAX_SUBSCRIBERS_PER_OWNER = 10; type LeaseRow = { id: string; kind: LeaseKind; key: string; holder: string; agent: string; user: string; note: string | null; expires_at: number; }; function toLease(row: LeaseRow): Lease { return { id: row.id, kind: row.kind, key: row.key, holder: row.holder, agent: row.agent, user: row.user, note: row.note, expiresAt: row.expires_at, }; } /** Directory claims end in `/` and cover everything beneath them; file claims overlap only themselves. */ export function overlaps(kind: LeaseKind, left: string, right: string): boolean { if (kind === "task") return left === right; if (left === right || left === "/" || right === "/") return true; if (left.endsWith("/") && right.startsWith(left)) return true; return right.endsWith("/") && left.startsWith(right); } export function normalizeClaimPath(raw: string): string | null { const directory = raw.endsWith("/"); const segments = raw.split("/").filter((segment) => segment !== "" && segment !== "."); if (segments.length === 0) return raw.trim() === "/" || raw.trim() === "" ? "/" : null; if (segments.some((segment) => segment === "..")) return null; return `${segments.join("/")}${directory ? "/" : ""}`; } /** Per-repository coordination state, stored in the hub object's SQLite database. */ export class Coordination { constructor(private readonly sql: SqlStorage) { sql.exec(`CREATE TABLE IF NOT EXISTS leases (id TEXT PRIMARY KEY, kind TEXT NOT NULL, key TEXT NOT NULL, holder TEXT NOT NULL, agent TEXT NOT NULL, user TEXT NOT NULL, note TEXT, expires_at INTEGER NOT NULL, created_at INTEGER NOT NULL)`); sql.exec("CREATE INDEX IF NOT EXISTS leases_kind ON leases(kind, expires_at)"); sql.exec(`CREATE TABLE IF NOT EXISTS subscribers (name TEXT PRIMARY KEY, events TEXT NOT NULL, expires_at INTEGER NOT NULL, owner TEXT)`); try { sql.exec("ALTER TABLE subscribers ADD COLUMN owner TEXT"); } catch { // column already present } sql.exec(`CREATE TABLE IF NOT EXISTS events (seq INTEGER PRIMARY KEY AUTOINCREMENT, type TEXT NOT NULL, data TEXT NOT NULL, at INTEGER NOT NULL)`); } private purge(now: number): void { this.sql.exec("DELETE FROM leases WHERE expires_at <= ?", now); this.sql.exec("DELETE FROM subscribers WHERE expires_at <= ?", now); } leases(kind?: LeaseKind): Lease[] { this.purge(Date.now()); const rows = kind ? this.sql.exec("SELECT * FROM leases WHERE kind = ? ORDER BY key LIMIT ?", kind, MAX_LEASES) : this.sql.exec("SELECT * FROM leases ORDER BY kind, key LIMIT ?", MAX_LEASES); return rows.toArray().map(toLease); } /** * Grants every key that no other holder overlaps; conflicting keys are reported, not granted, so * the second agent learns about overlapping work before it writes anything. `force` shares the key. */ claim(request: ClaimRequest): ClaimResult { const now = Date.now(); const active = this.leases(request.kind); const granted: Lease[] = []; const conflicts: ClaimConflict[] = []; const renewed: string[] = []; if (active.length + request.keys.length > MAX_LEASES) throw new Error("Too many active claims in this repository"); const held = active.filter((lease) => lease.holder === request.holder).length; if (held + request.keys.length > MAX_LEASES_PER_HOLDER) throw new Error("Too many claims held by this agent"); for (const key of request.keys) { const clash = active.find((lease) => lease.holder !== request.holder && overlaps(request.kind, lease.key, key)); if (clash) { conflicts.push({ key, lease: clash }); if (!request.force) continue; } const mine = active.find((lease) => lease.holder === request.holder && lease.key === key); const lease: Lease = { id: mine?.id ?? randomId(), kind: request.kind, key, holder: request.holder, agent: request.agent, user: request.user, note: request.note, expiresAt: now + request.ttlMs, }; this.sql.exec( `INSERT INTO leases (id, kind, key, holder, agent, user, note, expires_at, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (id) DO UPDATE SET expires_at = excluded.expires_at, note = excluded.note`, lease.id, lease.kind, lease.key, lease.holder, lease.agent, lease.user, lease.note, lease.expiresAt, now, ); if (mine) renewed.push(mine.id); else active.push(lease); granted.push(lease); } return { granted, conflicts, renewed }; } release(holder: string, ids: string[] | null): number { if (ids === null) return this.sql.exec("DELETE FROM leases WHERE holder = ?", holder).rowsWritten; let removed = 0; for (const id of ids) removed += this.sql.exec("DELETE FROM leases WHERE holder = ? AND id = ?", holder, id).rowsWritten; return removed; } subscribe(name: string, events: string[], ttlMs: number, owner: string | null = null): number { this.purge(Date.now()); const count = this.sql.exec<{ count: number }>("SELECT COUNT(*) AS count FROM subscribers").one().count; const exists = this.sql.exec("SELECT 1 FROM subscribers WHERE name = ?", name).toArray().length > 0; if (!exists && count >= MAX_SUBSCRIBERS) throw new Error("Too many subscribers on this repository"); if (!exists && owner !== null) { const owned = this.sql .exec<{ count: number }>("SELECT COUNT(*) AS count FROM subscribers WHERE owner = ?", owner) .one().count; if (owned >= MAX_SUBSCRIBERS_PER_OWNER) { throw new Error(`This token already holds ${MAX_SUBSCRIBERS_PER_OWNER} subscriptions on this repository`); } } const previous = this.sql .exec<{ events: string }>("SELECT events FROM subscribers WHERE name = ?", name) .toArray()[0]; const merged = [...new Set([...(previous ? (JSON.parse(previous.events) as string[]) : []), ...events])]; this.sql.exec( `INSERT INTO subscribers (name, events, expires_at, owner) VALUES (?, ?, ?, ?) ON CONFLICT (name) DO UPDATE SET events = excluded.events, expires_at = excluded.expires_at, owner = COALESCE(excluded.owner, subscribers.owner)`, name, JSON.stringify(merged), Date.now() + ttlMs, owner, ); return this.latestSeq(); } unsubscribe(name: string): void { this.sql.exec("DELETE FROM subscribers WHERE name = ?", name); } allSubscribers(): string[] { this.purge(Date.now()); return this.sql .exec<{ name: string }>("SELECT name FROM subscribers") .toArray() .map((row) => row.name); } subscribersFor(type: string): string[] { this.purge(Date.now()); return this.sql .exec<{ name: string; events: string }>("SELECT name, events FROM subscribers") .toArray() .filter((row) => { const events: string[] = JSON.parse(row.events); return events.includes("*") || events.includes(type); }) .map((row) => row.name); } record(type: string, data: string): RepoEvent { const at = Date.now(); const seq = this.sql .exec<{ seq: number }>("INSERT INTO events (type, data, at) VALUES (?, ?, ?) RETURNING seq", type, data, at) .one().seq; this.sql.exec("DELETE FROM events WHERE seq <= ?", seq - MAX_EVENTS); return { seq, type, at, data }; } latestSeq(): number { return this.sql.exec<{ seq: number | null }>("SELECT MAX(seq) AS seq FROM events").one().seq ?? 0; } eventsSince(after: number, limit: number, types: string[] | null): RepoEvent[] { return this.sql .exec<{ seq: number; type: string; data: string; at: number }>( "SELECT seq, type, data, at FROM events WHERE seq > ? ORDER BY seq LIMIT ?", after, MAX_EVENTS, ) .toArray() .filter((row) => !types || types.includes("*") || types.includes(row.type)) .slice(0, limit) .map((row) => ({ seq: row.seq, type: row.type, at: row.at, data: row.data })); } }