{"version":3,"file":"store.d.ts","sourceRoot":"","sources":["../../../src/core/mission/store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAKH,OAAO,KAAK,EACX,kBAAkB,EAClB,sBAAsB,EACtB,oBAAoB,EACpB,cAAc,EACd,eAAe,EACf,MAAM,YAAY,CAAC;AAEpB,MAAM,WAAW,mBAAmB;IACnC,IAAI,EAAE,MAAM,CAAC;CACb;AASD,qBAAa,YAAY;IACxB,OAAO,CAAC,QAAQ,CAAC,IAAI,CAAS;IAE9B,YAAY,OAAO,EAAE,mBAAmB,EAEvC;IAED,OAAO,CAAC,OAAO;IAIT,UAAU,IAAI,OAAO,CAAC,IAAI,CAAC,CAEhC;IAMK,SAAS,CAAC,QAAQ,EAAE,sBAAsB,GAAG,OAAO,CAAC,IAAI,CAAC,CAc/D;IAEK,SAAS,IAAI,OAAO,CAAC,sBAAsB,GAAG,SAAS,CAAC,CAU7D;IAMK,WAAW,CAAC,MAAM,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAS3D;IAEK,UAAU,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAajE;IAED;;;;OAIG;IACG,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,kBAAkB,EAAE,CAAC,CAUnE;IAMK,UAAU,CAAC,MAAM,EAAE,eAAe,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAMzD;IAEK,UAAU,IAAI,OAAO,CAAC,eAAe,EAAE,CAAC,CAS7C;IAEK,YAAY,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,SAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAY/E;IAEK,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC,CAIjD;IAEK,cAAc,CAAC,KAAK,EAAE,eAAe,EAAE,KAAK,SAAa,GAAG,OAAO,CAAC,OAAO,CAAC,CAKjF;IAMK,cAAc,CAAC,MAAM,EAAE,cAAc,GAAG,OAAO,CAAC,IAAI,CAAC,CAiB1D;IAEK,YAAY,CAAC,SAAS,EAAE,MAAM,GAAG,OAAO,CAAC,cAAc,EAAE,CAAC,CAS/D;YAEa,YAAY;IAY1B,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAEhC;CACD;AAED;;;;;;GAMG;AACH,wBAAsB,2BAA2B,CAChD,KAAK,EAAE,YAAY,EACnB,iBAAiB,EAAE,MAAM,EACzB,kBAAkB,EAAE,OAAO,EAC3B,KAAK,SAAa,GAChB,OAAO,CAAC;IAAE,MAAM,EAAE,oBAAoB,CAAC;IAAC,OAAO,EAAE,MAAM,EAAE,CAAA;CAAE,CAAC,CA4B9D","sourcesContent":["/**\n * Durable Mission Graph — atomic durable store, event log, leases and\n * checkpoints (2.0.0).\n *\n * All mission state is persisted atomically (write-temp-then-rename) so a\n * reboot never leaves a partially written document. Event records are\n * append-only and replayable with zero effects on creation. Leases are\n * repository-scoped and expire on a deadline; a reboot that terminates the\n * holder makes any recorded process `missing` during reconciliation instead of\n * trusting stale authority.\n */\n\nimport * as fs from \"node:fs\";\nimport * as fsp from \"node:fs/promises\";\nimport * as path from \"node:path\";\nimport type {\n\tMissionEventRecord,\n\tMissionGraphDocumentV1,\n\tReconciliationStatus,\n\tRecoveryRecord,\n\tRepositoryLease,\n} from \"./types.js\";\n\nexport interface MissionStoreOptions {\n\troot: string;\n}\n\nconst GRAPH_FILE = \"mission-graph.json\";\nconst EVENTS_FILE = \"events.jsonl\";\nconst LEASES_FILE = \"leases.json\";\nconst RECOVERY_FILE = \"recovery.json\";\n\nconst ATOMIC_SUFFIX = \".tmp\";\n\nexport class MissionStore {\n\tprivate readonly root: string;\n\n\tconstructor(options: MissionStoreOptions) {\n\t\tthis.root = options.root;\n\t}\n\n\tprivate resolve(name: string): string {\n\t\treturn path.join(this.root, name);\n\t}\n\n\tasync initialize(): Promise<void> {\n\t\tawait fsp.mkdir(this.root, { recursive: true });\n\t}\n\n\t// =========================================================================\n\t// Atomic graph document persistence\n\t// =========================================================================\n\n\tasync saveGraph(document: MissionGraphDocumentV1): Promise<void> {\n\t\tawait fsp.mkdir(this.root, { recursive: true });\n\t\tconst target = this.resolve(GRAPH_FILE);\n\t\tconst tmp = `${target}${ATOMIC_SUFFIX}`;\n\t\tconst data = JSON.stringify(document, null, 2);\n\t\tawait fsp.writeFile(tmp, data, \"utf8\");\n\t\t// fsync the temp file, then atomically rename over the target.\n\t\tconst fh = await fsp.open(tmp, \"r\");\n\t\ttry {\n\t\t\tawait fh.sync();\n\t\t} finally {\n\t\t\tawait fh.close();\n\t\t}\n\t\tawait fsp.rename(tmp, target);\n\t}\n\n\tasync loadGraph(): Promise<MissionGraphDocumentV1 | undefined> {\n\t\tconst target = this.resolve(GRAPH_FILE);\n\t\ttry {\n\t\t\tconst data = await fsp.readFile(target, \"utf8\");\n\t\t\tconst doc = JSON.parse(data) as MissionGraphDocumentV1;\n\t\t\tif (doc.schemaVersion !== 1) return undefined;\n\t\t\treturn doc;\n\t\t} catch {\n\t\t\treturn undefined;\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Append-only event log (replayable, zero effects on create)\n\t// =========================================================================\n\n\tasync appendEvent(record: MissionEventRecord): Promise<void> {\n\t\tawait fsp.mkdir(this.root, { recursive: true });\n\t\tconst target = this.resolve(EVENTS_FILE);\n\t\t// Append to a temp copy, then rename to keep the log atomic per append.\n\t\tconst content = `${JSON.stringify(record)}\\n`;\n\t\tconst existing = await this.readFileSafe(this.resolve(EVENTS_FILE));\n\t\tconst tmp = `${target}${ATOMIC_SUFFIX}`;\n\t\tawait fsp.writeFile(tmp, existing + content, \"utf8\");\n\t\tawait fsp.rename(tmp, target);\n\t}\n\n\tasync readEvents(missionId: string): Promise<MissionEventRecord[]> {\n\t\tconst data = await this.readFileSafe(this.resolve(EVENTS_FILE));\n\t\tconst records: MissionEventRecord[] = [];\n\t\tfor (const line of data.split(\"\\n\")) {\n\t\t\tif (!line.trim()) continue;\n\t\t\ttry {\n\t\t\t\tconst r = JSON.parse(line) as MissionEventRecord;\n\t\t\t\tif (r.missionId === missionId) records.push(r);\n\t\t\t} catch {\n\t\t\t\t// Skip malformed tail lines (partial append after crash).\n\t\t\t}\n\t\t}\n\t\treturn records;\n\t}\n\n\t/**\n\t * Replay the event log for a mission deterministically. Because events are\n\t * only appended (never mutated) and idempotent keys are embedded, replay\n\t * has zero effects: it never re-mutates state.\n\t */\n\tasync replayEvents(missionId: string): Promise<MissionEventRecord[]> {\n\t\tconst records = await this.readEvents(missionId);\n\t\tconst seen = new Set<string>();\n\t\tconst unique: MissionEventRecord[] = [];\n\t\tfor (const r of records) {\n\t\t\tif (seen.has(r.id)) continue; // skip duplicate event ids\n\t\t\tseen.add(r.id);\n\t\t\tunique.push(r);\n\t\t}\n\t\treturn unique;\n\t}\n\n\t// =========================================================================\n\t// Repository-scoped leases\n\t// =========================================================================\n\n\tasync saveLeases(leases: RepositoryLease[]): Promise<void> {\n\t\tawait fsp.mkdir(this.root, { recursive: true });\n\t\tconst target = this.resolve(LEASES_FILE);\n\t\tconst tmp = `${target}${ATOMIC_SUFFIX}`;\n\t\tawait fsp.writeFile(tmp, JSON.stringify(leases, null, 2), \"utf8\");\n\t\tawait fsp.rename(tmp, target);\n\t}\n\n\tasync loadLeases(): Promise<RepositoryLease[]> {\n\t\tconst data = await this.readFileSafe(this.resolve(LEASES_FILE));\n\t\tif (!data) return [];\n\t\ttry {\n\t\t\tconst parsed = JSON.parse(data) as RepositoryLease[];\n\t\t\treturn Array.isArray(parsed) ? parsed : [];\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tasync acquireLease(lease: RepositoryLease, nowMs = Date.now()): Promise<boolean> {\n\t\tconst leases = await this.loadLeases();\n\t\t// A repository-scoped lease is exclusive: reject if an unexpired lease\n\t\t// for the same repository is held by someone else.\n\t\tconst existing = leases.find((l) => l.repositoryId === lease.repositoryId);\n\t\tif (existing && existing.expiresAtMs > nowMs && existing.holder !== lease.holder) {\n\t\t\treturn false;\n\t\t}\n\t\tconst filtered = leases.filter((l) => !(l.repositoryId === lease.repositoryId && l.holder === lease.holder));\n\t\tfiltered.push(lease);\n\t\tawait this.saveLeases(filtered);\n\t\treturn true;\n\t}\n\n\tasync releaseLease(leaseId: string): Promise<void> {\n\t\tconst leases = await this.loadLeases();\n\t\tconst filtered = leases.filter((l) => l.leaseId !== leaseId);\n\t\tawait this.saveLeases(filtered);\n\t}\n\n\tasync isLeaseCurrent(lease: RepositoryLease, nowMs = Date.now()): Promise<boolean> {\n\t\tconst leases = await this.loadLeases();\n\t\tconst found = leases.find((l) => l.leaseId === lease.leaseId);\n\t\tif (!found) return false;\n\t\treturn found.repositoryId === lease.repositoryId && found.expiresAtMs > nowMs;\n\t}\n\n\t// =========================================================================\n\t// Recovery records\n\t// =========================================================================\n\n\tasync recordRecovery(record: RecoveryRecord): Promise<void> {\n\t\tawait fsp.mkdir(this.root, { recursive: true });\n\t\tconst target = this.resolve(RECOVERY_FILE);\n\t\tlet records: RecoveryRecord[] = [];\n\t\tconst existing = await this.readFileSafe(target);\n\t\tif (existing) {\n\t\t\ttry {\n\t\t\t\trecords = JSON.parse(existing) as RecoveryRecord[];\n\t\t\t} catch {\n\t\t\t\trecords = [];\n\t\t\t}\n\t\t}\n\t\trecords = records.filter((r) => r.recoveryId !== record.recoveryId);\n\t\trecords.push(record);\n\t\tconst tmp = `${target}${ATOMIC_SUFFIX}`;\n\t\tawait fsp.writeFile(tmp, JSON.stringify(records, null, 2), \"utf8\");\n\t\tawait fsp.rename(tmp, target);\n\t}\n\n\tasync loadRecovery(missionId: string): Promise<RecoveryRecord[]> {\n\t\tconst existing = await this.readFileSafe(this.resolve(RECOVERY_FILE));\n\t\tif (!existing) return [];\n\t\ttry {\n\t\t\tconst all = JSON.parse(existing) as RecoveryRecord[];\n\t\t\treturn all.filter((r) => r.missionId === missionId);\n\t\t} catch {\n\t\t\treturn [];\n\t\t}\n\t}\n\n\tprivate async readFileSafe(file: string): Promise<string> {\n\t\ttry {\n\t\t\treturn await fsp.readFile(file, \"utf8\");\n\t\t} catch {\n\t\t\treturn \"\";\n\t\t}\n\t}\n\n\t// =========================================================================\n\t// Sync helpers (for CLI one-shot reads)\n\t// =========================================================================\n\n\tsyncExists(name: string): boolean {\n\t\treturn fs.existsSync(this.resolve(name));\n\t}\n}\n\n/**\n * Reconcile a recorded process against reality after a reboot.\n *\n * If durable state says a process is `running` but it no longer exists, the\n * record is reclassified as `missing` (never reconciled to still-running), and\n * cleanup idempotently releases any leases the dead process held.\n */\nexport async function reconcileProcessAfterReboot(\n\tstore: MissionStore,\n\trecordedProcessId: string,\n\tprocessStillExists: boolean,\n\tnowMs = Date.now(),\n): Promise<{ status: ReconciliationStatus; actions: string[] }> {\n\tconst actions: string[] = [];\n\tconst leases = await store.loadLeases();\n\tlet changed = false;\n\n\tif (!processStillExists) {\n\t\tconst deadLeases = leases.filter((l) => l.holder === recordedProcessId);\n\t\tif (deadLeases.length > 0) {\n\t\t\tconst remaining = leases.filter((l) => l.holder !== recordedProcessId);\n\t\t\tawait store.saveLeases(remaining);\n\t\t\tfor (const l of deadLeases) actions.push(`released stale lease ${l.leaseId} (repo ${l.repositoryId})`);\n\t\t\tchanged = true;\n\t\t}\n\t\tactions.push(`process '${recordedProcessId}' marked missing after reboot`);\n\t} else {\n\t\tactions.push(`process '${recordedProcessId}' still alive; no reconciliation applied`);\n\t}\n\n\t// Reject reusing stale lease/resource authority: any lease that has expired\n\t// is treated as not current regardless of holder.\n\tconst expired = leases.filter((l) => l.expiresAtMs <= nowMs);\n\tfor (const l of expired) {\n\t\tactions.push(`lease ${l.leaseId} expired at ${l.expiresAtMs}`);\n\t\tchanged = true;\n\t}\n\n\t// Idempotent: when no lease state changed, report CLEAN.\n\treturn { status: changed ? \"RECONCILED\" : \"CLEAN\", actions: [...new Set(actions)] };\n}\n"]}