{"version":3,"file":"index.cjs","names":["emptyDocument","canonicalizeTasks","resolveStorePath","fs","TASK_EVENT","path","tempPathFor","lockPathFor","isProcessAlive"],"sources":["../../../../src/services/taskStore/index.ts"],"sourcesContent":["import fs from 'node:fs';\nimport path from 'node:path';\n\nimport { emptyDocument, STORE_SCHEMA_VERSION, type Task, type TaskDocument } from '../../models/task';\nimport { TASK_EVENT, type TaskFailureReporter } from '../../types/telemetry';\nimport { canonicalizeTasks } from '../invariants';\nimport { lockPathFor, resolveStorePath, STORE_PATH_ENV, tempPathFor } from '../paths';\nimport { isProcessAlive } from '../processLiveness';\n\nconst LOCK_TIMEOUT_MS = 2000;\nconst LOCK_STALE_MS = 10_000;\nconst LOCK_RETRY_BASE_MS = 10;\nconst LOCK_RETRY_MAX_MS = 60;\nconst UTF8_ENCODING = 'utf8';\nconst MISSING_FILE_CODE = 'ENOENT';\nconst LOCK_EXISTS_CODE = 'EEXIST';\nconst STORE_PATH_ATTRIBUTE = 'store.path';\n\nexport type TaskStoreCommitListener = (previous: TaskDocument, committed: TaskDocument) => void;\n\nexport interface TaskStoreOptions {\n  cwd?: string;\n  env?: Readonly<Record<string, string | undefined>>;\n  storePath?: string;\n  onCommitted?: TaskStoreCommitListener;\n  /** How long to wait for the advisory lock before proceeding lock-free. */\n  lockTimeoutMs?: number;\n  /** Where swallowed failures go, so silent degradation stays visible. */\n  report?: TaskFailureReporter;\n}\n\nfunction sleep(ms: number): Promise<void> {\n  return new Promise((resolve) => setTimeout(resolve, ms));\n}\n\n/**\n * Coerce arbitrary parsed JSON into a valid document.\n *\n * A corrupt or truncated store must not take the session down: the worst\n * acceptable outcome is starting from an empty list, never a crash on startup.\n */\nfunction normalizeDocument(parsed: unknown): TaskDocument {\n  if (!parsed || typeof parsed !== 'object') return emptyDocument();\n  const candidate = parsed as Partial<TaskDocument>;\n  if (!Array.isArray(candidate.tasks)) return emptyDocument();\n\n  const tasks = canonicalizeTasks(\n    candidate.tasks.filter(\n      (task): task is Task => Boolean(task) && typeof task === 'object' && typeof (task as Task).id === 'number',\n    ),\n  );\n  const maxId = tasks.reduce((max, task) => Math.max(max, task.id), 0);\n  return {\n    version: typeof candidate.version === 'number' ? candidate.version : STORE_SCHEMA_VERSION,\n    rev: typeof candidate.rev === 'number' ? candidate.rev : 0,\n    nextId: typeof candidate.nextId === 'number' && candidate.nextId > maxId ? candidate.nextId : maxId + 1,\n    tasks,\n  };\n}\n\n/**\n * File-backed task store shared by one root session and its delegated children.\n *\n * Durability model: the JSON file is the source of truth. Mutations are\n * read-modify-write under an advisory lock so concurrent delegated processes\n * cannot clobber each other, and writes land via temp-file rename so a\n * crash mid-write can never leave a partial document behind.\n */\nexport class TaskStore {\n  storePath: string;\n\n  private readonly cwd: string;\n  private readonly env: Readonly<Record<string, string | undefined>>;\n  private cached: TaskDocument = emptyDocument();\n  private readonly listeners = new Set<(document: TaskDocument) => void>();\n  private readonly lockTimeoutMs: number;\n  private readonly report?: TaskFailureReporter;\n  private readonly onCommitted?: TaskStoreCommitListener;\n\n  constructor(options: TaskStoreOptions = {}) {\n    this.cwd = options.cwd ?? process.cwd();\n    this.env = options.env ?? process.env;\n    this.storePath = options.storePath ?? resolveStorePath(this.cwd, this.env);\n    this.lockTimeoutMs = options.lockTimeoutMs ?? LOCK_TIMEOUT_MS;\n    this.report = options.report;\n    this.onCommitted = options.onCommitted;\n  }\n\n  /** Bind a default store to the current session tree before reading it. */\n  configureSession(sessionKey: string): void {\n    if (this.env[STORE_PATH_ENV]?.trim()) return;\n    this.storePath = resolveStorePath(this.cwd, this.env, sessionKey);\n    this.cached = emptyDocument();\n  }\n\n  /** Last document read from disk, without hitting the filesystem. */\n  get snapshot(): TaskDocument {\n    return this.cached;\n  }\n\n  read(): TaskDocument {\n    try {\n      const raw = fs.readFileSync(this.storePath, UTF8_ENCODING);\n      this.cached = normalizeDocument(JSON.parse(raw));\n    } catch (error) {\n      // A missing file is the normal state before the first write. Anything\n      // else means the task list just silently became empty, which is worth a\n      // record: unreadable or corrupt JSON looks identical to \"no tasks yet\".\n      if ((error as NodeJS.ErrnoException).code !== MISSING_FILE_CODE) {\n        this.report?.error(TASK_EVENT.storeReadFailed, error, { [STORE_PATH_ATTRIBUTE]: this.storePath });\n      }\n      this.cached = emptyDocument();\n    }\n    return this.cached;\n  }\n\n  async readAsync(shouldApply: () => boolean = () => true): Promise<TaskDocument> {\n    const storePath = this.storePath;\n    const document = await this.readDocumentAsync(storePath);\n    if (!shouldApply()) return document;\n    this.cached = document;\n    return document;\n  }\n\n  private async readDocumentAsync(storePath: string = this.storePath): Promise<TaskDocument> {\n    try {\n      const raw = await fs.promises.readFile(storePath, UTF8_ENCODING);\n      return normalizeDocument(JSON.parse(raw));\n    } catch (error) {\n      if ((error as NodeJS.ErrnoException).code !== MISSING_FILE_CODE) {\n        this.report?.error(TASK_EVENT.storeReadFailed, error, { [STORE_PATH_ATTRIBUTE]: storePath });\n      }\n      return emptyDocument();\n    }\n  }\n\n  /**\n   * Apply a mutation under the store lock.\n   *\n   * `mutate` receives the freshest on-disk document and returns either the next\n   * document to commit or `undefined` for a read-only action (list/get), which\n   * skips the write entirely so queries never bump `rev`.\n   */\n  async mutate<T>(\n    mutate: (document: TaskDocument) => { document?: TaskDocument; value: T },\n  ): Promise<{ document: TaskDocument; value: T }> {\n    const release = await this.acquireLock();\n    const outcome = (() => {\n      try {\n        const current = this.read();\n        const mutation = mutate(current);\n        if (!mutation.document) {\n          return { result: { document: current, value: mutation.value } };\n        }\n        const committed = this.write(mutation.document);\n        return {\n          result: { document: committed, value: mutation.value },\n          notification: { previous: current, committed },\n        };\n      } finally {\n        release();\n      }\n    })();\n    if (outcome.notification) {\n      this.notifyCommitted(outcome.notification.previous, outcome.notification.committed);\n    }\n    return outcome.result;\n  }\n\n  private notifyCommitted(previous: TaskDocument, committed: TaskDocument): void {\n    try {\n      this.onCommitted?.(previous, committed);\n    } catch (error) {\n      this.report?.error(TASK_EVENT.storeCommitListenerFailed, error, { [STORE_PATH_ATTRIBUTE]: this.storePath });\n    }\n    for (const listener of this.listeners) {\n      try {\n        listener(committed);\n      } catch (error) {\n        this.report?.error(TASK_EVENT.storeListenerFailed, error, { [STORE_PATH_ATTRIBUTE]: this.storePath });\n      }\n    }\n  }\n\n  private write(document: TaskDocument): TaskDocument {\n    const next: TaskDocument = { ...document, version: STORE_SCHEMA_VERSION, rev: document.rev + 1 };\n    fs.mkdirSync(path.dirname(this.storePath), { recursive: true });\n    const temp = tempPathFor(this.storePath);\n    fs.writeFileSync(temp, `${JSON.stringify(next, undefined, 2)}\\n`, UTF8_ENCODING);\n    fs.renameSync(temp, this.storePath);\n    this.cached = next;\n    return next;\n  }\n\n  private async acquireLock(): Promise<() => void> {\n    const lockPath = lockPathFor(this.storePath);\n    fs.mkdirSync(path.dirname(this.storePath), { recursive: true });\n    const deadline = Date.now() + this.lockTimeoutMs;\n\n    for (;;) {\n      try {\n        const handle = fs.openSync(lockPath, 'wx');\n        fs.writeSync(handle, JSON.stringify({ pid: process.pid, time: Date.now() }));\n        fs.closeSync(handle);\n        return () => fs.rmSync(lockPath, { force: true });\n      } catch (error) {\n        if ((error as NodeJS.ErrnoException).code !== LOCK_EXISTS_CODE) throw error;\n        // The deadline is checked before breaking a stale lock so a peer that\n        // keeps recreating one cannot spin this loop without bound.\n        if (Date.now() >= deadline) {\n          // Proceeding lock-free beats stalling the agent: the rename is atomic,\n          // so the worst case is a lost concurrent update, not a corrupt file.\n          // Reported because a lost update here can strand a delegation.\n          this.report?.warn(\n            TASK_EVENT.storeLockTimeout,\n            new Error(`Task store lock still held after ${this.lockTimeoutMs}ms; proceeding without it`),\n            { [STORE_PATH_ATTRIBUTE]: this.storePath },\n          );\n          return () => {};\n        }\n        // Clearing a dead holder's lock still goes through the backoff. Yielding\n        // on every iteration is what stops a peer that keeps recreating the\n        // lock from turning this into a spin.\n        this.breakStaleLock(lockPath);\n        await sleep(LOCK_RETRY_BASE_MS + Math.random() * LOCK_RETRY_MAX_MS);\n      }\n    }\n  }\n\n  /** Remove a lock left behind by a dead process or one held implausibly long. */\n  private breakStaleLock(lockPath: string): boolean {\n    try {\n      const holder = JSON.parse(fs.readFileSync(lockPath, UTF8_ENCODING)) as { pid?: number; time?: number };\n      const expired = typeof holder.time === 'number' && Date.now() - holder.time > LOCK_STALE_MS;\n      const dead = typeof holder.pid === 'number' && !isProcessAlive(holder.pid);\n      if (!expired && !dead) return false;\n      fs.unlinkSync(lockPath);\n      return true;\n    } catch (error) {\n      // Losing the race to another breaker is normal; a persistent failure here\n      // shows up as the lock timeout above, so this stays a warning.\n      if ((error as NodeJS.ErrnoException).code !== MISSING_FILE_CODE) {\n        this.report?.warn(TASK_EVENT.storeLockBreakFailed, error, { [STORE_PATH_ATTRIBUTE]: lockPath });\n      }\n      return false;\n    }\n  }\n\n  /**\n   * Listen for commits made through this store instance.\n   *\n   * Live cross-process updates use the owning session's direct event bus. This\n   * listener remains for local terminal views and never watches the filesystem.\n   */\n  onExternalChange(listener: (document: TaskDocument) => void): () => void {\n    this.listeners.add(listener);\n    return () => this.listeners.delete(listener);\n  }\n\n  dispose(): void {\n    this.listeners.clear();\n  }\n}\n"],"mappings":"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"}