{"version":3,"file":"lease.d.ts","sourceRoot":"","sources":["../../../src/core/safety/lease.ts"],"names":[],"mappings":"AAKA,MAAM,WAAW,WAAW;IAC3B,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,MAAM,CAAC;IACpB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,MAAM,CAAC;IACtB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,CAAC,EAAE,OAAO,CAAC;CACpB;AAED,MAAM,MAAM,WAAW,GACpB;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,GAChC;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,YAAY,CAAC;IAAC,KAAK,EAAE,WAAW,CAAA;CAAE,GACvD;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,UAAU,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD,MAAM,MAAM,WAAW,GAAG,WAAW,GAAG,IAAI,CAAC;AAE7C,MAAM,WAAW,iBAAiB;IACjC,yDAAyD;IACzD,UAAU,EAAE,MAAM,CAAC;IACnB,uEAAuE;IACvE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,0BAA0B;IAC1B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,gDAAgD;IAChD,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;IACnB,2EAA2E;IAC3E,cAAc,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,OAAO,CAAC;CAC1C;AAED,qBAAa,mBAAoB,SAAQ,KAAK;IAC7C,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,WAAW,CAAC;IAC7B,YAAY,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,WAAW,EAK7D;CACD;AAaD;;;;;;;;GAQG;AACH,qBAAa,mBAAmB;IAC/B,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAS;IACrC,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAe;IACnC,OAAO,CAAC,QAAQ,CAAC,cAAc,CAA2B;IAC1D,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAE5B,YAAY,OAAO,EAAE,iBAAiB,EAMrC;IAED,OAAO,CAAC,SAAS;IAKX,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAoC3E;IAEK,SAAS,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAOlD;IAED,4EAA4E;IACtE,OAAO,CAAC,WAAW,EAAE,MAAM,EAAE,UAAU,EAAE,MAAM,GAAG,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAO3E;IAEK,MAAM,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,CAAC,CAEtD;IAED,yDAAyD;IACnD,cAAc,CAAC,WAAW,EAAE,MAAM,EAAE,WAAW,EAAE,MAAM,GAAG,OAAO,CAAC,WAAW,GAAG,IAAI,CAAC,CAW1F;YAEa,UAAU;CAQxB;AAED,mFAAmF;AACnF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEzD","sourcesContent":["import { randomUUID } from \"node:crypto\";\nimport fs from \"node:fs/promises\";\nimport path from \"node:path\";\nimport { workspaceIdFromRoot } from \"./policy.js\";\n\nexport interface LeaseRecord {\n\tleaseId: string;\n\tworkspaceId: string;\n\townerRunId: string;\n\tprocessId: number;\n\tacquiredAt: number;\n\tlastHeartbeat: number;\n\texpiresAt: number;\n\ttimeoutMs: number;\n\trecovered?: boolean;\n}\n\nexport type LeaseResult =\n\t| { ok: true; lease: LeaseRecord }\n\t| { ok: false; reason: \"lease_held\"; lease: LeaseRecord }\n\t| { ok: false; reason: \"io_error\"; message: string };\n\nexport type LeaseStatus = LeaseRecord | null;\n\nexport interface LeaseStoreOptions {\n\t/** Absolute directory where lease records are stored. */\n\tstorageDir: string;\n\t/** Timeout after which a lease is considered stale and recoverable. */\n\ttimeoutMs?: number;\n\t/** Heartbeat interval. */\n\theartbeatMs?: number;\n\t/** Injectable now() for deterministic tests. */\n\tnow?: () => number;\n\t/** Injectable process-liveness checker (default: process.kill(pid, 0)). */\n\tisProcessAlive?: (pid: number) => boolean;\n}\n\nexport class WorkspaceLeaseError extends Error {\n\treadonly code: string;\n\treadonly lease?: LeaseRecord;\n\tconstructor(code: string, message: string, lease?: LeaseRecord) {\n\t\tsuper(message);\n\t\tthis.code = code;\n\t\tthis.lease = lease;\n\t\tthis.name = \"WorkspaceLeaseError\";\n\t}\n}\n\nfunction defaultLiveness(pid: number): boolean {\n\tif (!pid || pid <= 0) return false;\n\ttry {\n\t\tprocess.kill(pid, 0);\n\t\treturn true;\n\t} catch (err) {\n\t\t// ESRCH means the process is gone; EPERM means it exists.\n\t\treturn (err as NodeJS.ErrnoException).code === \"EPERM\";\n\t}\n}\n\n/**\n * Exclusive workspace mutation lease.\n *\n * Uses an atomic exclusive-create lockfile so that only one authoritative\n * mutating transaction may hold the workspace lease. Read-only work can remain\n * concurrent. A crashed owner does not permanently lock the workspace: stale\n * leases are recovered only after a positive liveness check. Release is\n * idempotent. Unrelated workspaces are independent (per-workspace key).\n */\nexport class WorkspaceLeaseStore {\n\tprivate readonly timeoutMs: number;\n\tprivate readonly heartbeatMs: number;\n\tprivate readonly now: () => number;\n\tprivate readonly isProcessAlive: (pid: number) => boolean;\n\treadonly storageDir: string;\n\n\tconstructor(options: LeaseStoreOptions) {\n\t\tthis.storageDir = options.storageDir;\n\t\tthis.timeoutMs = options.timeoutMs ?? 30_000;\n\t\tthis.heartbeatMs = options.heartbeatMs ?? 5_000;\n\t\tthis.now = options.now ?? (() => Date.now());\n\t\tthis.isProcessAlive = options.isProcessAlive ?? defaultLiveness;\n\t}\n\n\tprivate leasePath(workspaceId: string): string {\n\t\tconst key = workspaceId.replace(/[^a-z0-9._-]/g, \"_\");\n\t\treturn path.join(this.storageDir, \"leases\", `${key}.lease.json`);\n\t}\n\n\tasync acquire(workspaceId: string, ownerRunId: string): Promise<LeaseResult> {\n\t\tconst now = this.now();\n\t\tconst existing = await this.readRecord(workspaceId);\n\t\tif (existing) {\n\t\t\tconst stale = now - existing.lastHeartbeat > this.timeoutMs;\n\t\t\tconst ownerAlive = this.isProcessAlive(existing.processId);\n\t\t\tif (!(stale && !ownerAlive)) {\n\t\t\t\treturn { ok: false, reason: \"lease_held\", lease: existing };\n\t\t\t}\n\t\t\t// Recover the stale lease.\n\t\t\tawait fs.unlink(this.leasePath(workspaceId)).catch(() => {});\n\t\t}\n\t\tconst record: LeaseRecord = {\n\t\t\tleaseId: randomUUID(),\n\t\t\tworkspaceId,\n\t\t\townerRunId,\n\t\t\tprocessId: process.pid,\n\t\t\tacquiredAt: now,\n\t\t\tlastHeartbeat: now,\n\t\t\texpiresAt: now + this.timeoutMs,\n\t\t\ttimeoutMs: this.timeoutMs,\n\t\t};\n\t\ttry {\n\t\t\tawait fs.mkdir(path.dirname(this.leasePath(workspaceId)), { recursive: true });\n\t\t\tawait fs.writeFile(this.leasePath(workspaceId), JSON.stringify(record), {\n\t\t\t\tflag: \"wx\",\n\t\t\t\tmode: 0o600,\n\t\t\t});\n\t\t\treturn { ok: true, lease: record };\n\t\t} catch {\n\t\t\t// Another process won the race. Re-read and report the holder.\n\t\t\tconst winner = await this.readRecord(workspaceId);\n\t\t\treturn winner\n\t\t\t\t? { ok: false, reason: \"lease_held\", lease: winner }\n\t\t\t\t: { ok: false, reason: \"io_error\", message: \"lease file write failed\" };\n\t\t}\n\t}\n\n\tasync heartbeat(workspaceId: string): Promise<void> {\n\t\tconst rec = await this.readRecord(workspaceId);\n\t\tif (!rec) return;\n\t\tif (rec.processId === process.pid) {\n\t\t\trec.lastHeartbeat = this.now();\n\t\t\tawait fs.writeFile(this.leasePath(workspaceId), JSON.stringify(rec), { mode: 0o600 }).catch(() => {});\n\t\t}\n\t}\n\n\t/** Idempotent release. Only the owner (or recovery) can clear the lease. */\n\tasync release(workspaceId: string, ownerRunId: string | null): Promise<void> {\n\t\tconst rec = await this.readRecord(workspaceId);\n\t\tif (!rec) return;\n\t\tif (ownerRunId !== null && rec.ownerRunId !== ownerRunId) {\n\t\t\tthrow new WorkspaceLeaseError(\"not_owner\", \"lease not owned by this run; refusing to release\", rec);\n\t\t}\n\t\tawait fs.unlink(this.leasePath(workspaceId)).catch(() => {});\n\t}\n\n\tasync status(workspaceId: string): Promise<LeaseStatus> {\n\t\treturn this.readRecord(workspaceId);\n\t}\n\n\t/** Stale-lease recovery with positive liveness check. */\n\tasync recoverIfStale(workspaceId: string, _ownerRunId: string): Promise<LeaseRecord | null> {\n\t\tconst rec = await this.readRecord(workspaceId);\n\t\tif (!rec) return null;\n\t\tconst stale = this.now() - rec.lastHeartbeat > this.timeoutMs;\n\t\tconst ownerAlive = this.isProcessAlive(rec.processId);\n\t\tif (!(stale && !ownerAlive)) {\n\t\t\t// The current owner is alive: never steal a live lease.\n\t\t\tthrow new WorkspaceLeaseError(\"lease_active\", \"lease owner is alive; cannot steal\", rec);\n\t\t}\n\t\tawait fs.unlink(this.leasePath(workspaceId)).catch(() => {});\n\t\treturn rec;\n\t}\n\n\tprivate async readRecord(workspaceId: string): Promise<LeaseRecord | null> {\n\t\ttry {\n\t\t\tconst raw = await fs.readFile(this.leasePath(workspaceId), \"utf-8\");\n\t\t\treturn JSON.parse(raw) as LeaseRecord;\n\t\t} catch {\n\t\t\treturn null;\n\t\t}\n\t}\n}\n\n/** Convenience to derive a lease when the owner also wants a checkout of state. */\nexport function workspaceKeyFromRoot(root: string): string {\n\treturn workspaceIdFromRoot(root);\n}\n"]}