|
| 1 | +/** |
| 2 | + * Durable attempt store for the harness (B03.2). |
| 3 | + * |
| 4 | + * B03.2 requires request identity to be persisted in ignored test runtime |
| 5 | + * state before submission. An in-memory map cannot satisfy that: it proves |
| 6 | + * concurrency inside one process and disappears on restart, which is exactly |
| 7 | + * the boundary `.agents/skills/oneshot-failure-injection/SKILL.md` requires be |
| 8 | + * exercised ("restart services between durable transitions and external |
| 9 | + * responses"). |
| 10 | + * |
| 11 | + * This writes to a JSON file under an ignored `tmp/` directory. It is a stand-in |
| 12 | + * for the PostgreSQL state Coder A owns, not a production store, and it exists |
| 13 | + * so restart recovery can actually be tested rather than assumed. |
| 14 | + */ |
| 15 | + |
| 16 | +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; |
| 17 | +import { dirname } from 'node:path'; |
| 18 | + |
| 19 | +export interface PersistedAttempt { |
| 20 | + readonly businessIntentId: string; |
| 21 | + readonly payloadFingerprint: string; |
| 22 | + readonly idempotencyKey: string; |
| 23 | + readonly referenceId: string; |
| 24 | + /** Set before the external boundary is crossed, never after. */ |
| 25 | + submissionAttempted: boolean; |
| 26 | +} |
| 27 | + |
| 28 | +export interface AttemptStore { |
| 29 | + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean }; |
| 30 | + get(businessIntentId: string): PersistedAttempt | undefined; |
| 31 | + readonly size: number; |
| 32 | +} |
| 33 | + |
| 34 | +/** In-memory store. Fine for single-process tests; cannot survive a restart. */ |
| 35 | +export class MemoryAttemptStore implements AttemptStore { |
| 36 | + protected readonly entries = new Map<string, PersistedAttempt>(); |
| 37 | + |
| 38 | + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { |
| 39 | + const existing = this.entries.get(attempt.businessIntentId); |
| 40 | + if (existing) return { entry: existing, isNew: false }; |
| 41 | + this.entries.set(attempt.businessIntentId, attempt); |
| 42 | + return { entry: attempt, isNew: true }; |
| 43 | + } |
| 44 | + |
| 45 | + get(businessIntentId: string): PersistedAttempt | undefined { |
| 46 | + return this.entries.get(businessIntentId); |
| 47 | + } |
| 48 | + |
| 49 | + get size(): number { |
| 50 | + return this.entries.size; |
| 51 | + } |
| 52 | +} |
| 53 | + |
| 54 | +/** |
| 55 | + * File-backed store. |
| 56 | + * |
| 57 | + * Every mutation is written through immediately. Buffering writes would |
| 58 | + * reintroduce the crash window the store exists to close: an attempt that was |
| 59 | + * recorded but not yet flushed is, after a kill, indistinguishable from one |
| 60 | + * that never happened. |
| 61 | + */ |
| 62 | +export class FileAttemptStore implements AttemptStore { |
| 63 | + constructor(private readonly filePath: string) { |
| 64 | + mkdirSync(dirname(filePath), { recursive: true }); |
| 65 | + } |
| 66 | + |
| 67 | + private read(): Record<string, PersistedAttempt> { |
| 68 | + try { |
| 69 | + const raw = readFileSync(this.filePath, 'utf8'); |
| 70 | + return JSON.parse(raw) as Record<string, PersistedAttempt>; |
| 71 | + } catch { |
| 72 | + // A missing or unreadable file means no attempts recorded yet. It must |
| 73 | + // never be treated as "no attempt exists" for an intent we have not |
| 74 | + // checked; callers only reach this through recordOrGet, which writes. |
| 75 | + return {}; |
| 76 | + } |
| 77 | + } |
| 78 | + |
| 79 | + private write(state: Record<string, PersistedAttempt>): void { |
| 80 | + writeFileSync(this.filePath, JSON.stringify(state, null, 2), 'utf8'); |
| 81 | + } |
| 82 | + |
| 83 | + recordOrGet(attempt: PersistedAttempt): { entry: PersistedAttempt; isNew: boolean } { |
| 84 | + const state = this.read(); |
| 85 | + const existing = state[attempt.businessIntentId]; |
| 86 | + if (existing) return { entry: existing, isNew: false }; |
| 87 | + |
| 88 | + state[attempt.businessIntentId] = attempt; |
| 89 | + this.write(state); |
| 90 | + return { entry: attempt, isNew: true }; |
| 91 | + } |
| 92 | + |
| 93 | + /** Persist a mutation made to an entry the caller already holds. */ |
| 94 | + update(attempt: PersistedAttempt): void { |
| 95 | + const state = this.read(); |
| 96 | + state[attempt.businessIntentId] = attempt; |
| 97 | + this.write(state); |
| 98 | + } |
| 99 | + |
| 100 | + get(businessIntentId: string): PersistedAttempt | undefined { |
| 101 | + return this.read()[businessIntentId]; |
| 102 | + } |
| 103 | + |
| 104 | + get size(): number { |
| 105 | + return Object.keys(this.read()).length; |
| 106 | + } |
| 107 | +} |
0 commit comments