oc_orchestrator
Loading...
Searching...
No Matches
plan_inventory.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_orchestrator/plan_inventory.py
4
5"""
6Plan inventory storage abstraction.
7
8Purpose
9-------
10Single source of truth for plan state and metadata. A small JSON file
11at ``oc_chat/plans/.inventory.json`` accessed through the stable
12:class:`PlanInventory` interface. Future SQLite/Postgres backends are
13sibling classes; nothing in ``plans.py`` or ``commands.py`` changes.
14
15Responsibilities
16----------------
17- :class:`PlanRecord` — frozen dataclass holding plan_id, status,
18 description, timestamps, and originating session_name.
19- :class:`PlanInventory` (ABC) — register/get/update_status/remove/all/
20 list_by_status/next_prefix.
21- :class:`JsonPlanInventory` — concrete JSON-file implementation with
22 atomic write-temp-then-rename semantics; tolerates missing file by
23 auto-creating an empty inventory.
24- :func:`get_inventory` — process-wide singleton factory.
25- :func:`reconcile` — heal disk/inventory drift (inventory → filesystem
26 direction). Filenames get renamed to mirror inventory.status; orphan
27 files (on disk but not in inventory) get absorbed.
28
29Diagnostics
30-----------
31Domain: OC_CHAT.MAIN
32Levels:
33 L2 — inventory lifecycle events (load, save, reconcile actions)
34 L3 — per-record semantic details (status changes, prefix allocation)
35 L4 — deep tracing (every read/write)
36
37Contracts
38---------
39- All public mutators are atomic at the inventory level (write-to-temp +
40 rename). Crash mid-op leaves the on-disk inventory untouched.
41- ``PlanRecord`` is frozen; updates produce new records via
42 :meth:`PlanRecord.with_status` / :meth:`with_completed_at`.
43- ``status`` is one of ``{"active", "paused", "complete", "aborted"}``.
44- ``next_prefix`` raises :class:`RuntimeError` when 999 is taken
45 (archive-at-999 deferred to a future phase).
46- ``reconcile`` is idempotent.
47
48Dependencies
49------------
50- oc_diagnostics (structured debug logging via _dbg)
51"""
52from __future__ import annotations
53
54import json
55import os
56import re
57import tempfile
58from abc import ABC, abstractmethod
59from dataclasses import asdict, dataclass, field, replace
60from datetime import datetime
61from pathlib import Path
62from typing import Any
63
64from oc_diagnostics import _dbg
65
66VALID_STATUSES = ("active", "paused", "complete", "aborted")
67TERMINAL_STATUSES = ("complete", "aborted")
68
69#: Where plan markdown files live, relative to the process cwd. Plan 257
70#: P3: this and :data:`DEFAULT_INVENTORY_NAME` are the layout assumption,
71#: stated once. It lives in this module rather than in ``plans.py``
72#: because ``plans.py`` imports this one, and the inventory path is
73#: derived from it -- two literals encoding the same layout is exactly
74#: what the A2 residue was.
75DEFAULT_PLANS_DIR = Path("oc_chat/plans")
76
77#: The inventory file's name inside the plans directory.
78DEFAULT_INVENTORY_NAME = ".inventory.json"
79
80#: Typed receipts from the owner atomic op (plan 152 R11-05). They tell a
81#: caller what is TRUE of disk and memory after a durable mutation attempt,
82#: so a failure can be reported precisely instead of guessed at.
83NOT_APPLIED = "NOT_APPLIED" # durable write failed; memory AND disk are OLD
84APPLIED = "APPLIED" # disk committed and memory published; they agree
85UNKNOWN = "UNKNOWN" # durable write landed, publish did not complete
86
87# Filename regex: NNN-stem.md or NNN-stem_<state>.md
88_FILENAME_RE = re.compile(
89 # OCT-LINT: disable=no_hardcoded_secrets reason="plan-filename regex (NNN-stem[_state].md); a parser pattern, no credential content"
90 r"^(?P<stem>\d{3}-[a-z0-9-]+?)(?:_(?P<state>paused|complete|aborted))?\.md$"
91)
92
93
94@dataclass(frozen=True)
96 """All caller-controlled filesystem inputs to one plan operation.
97
98 Plan 257 P3, in the ``DispatchRequest`` shape the dispatch layer
99 already uses: a frozen dataclass whose defaults are today's values,
100 constructed at the boundary and passed whole.
101
102 ``plans_dir`` is late-bound through a default factory rather than
103 captured at class-creation time, so a caller that monkeypatches
104 :data:`DEFAULT_PLANS_DIR` is still honoured -- the same reason
105 ``PrepareRequest.interpreter`` uses a factory in
106 ``worktree_env.py``.
107 """
108
109 plans_dir: Path = field(default_factory=lambda: DEFAULT_PLANS_DIR)
110 #: Overrides the derived ``<plans_dir>/.inventory.json`` when set.
111 inventory_path_override: Path | None = None
112
113 @property
114 def inventory_path(self) -> Path:
115 """The inventory file: the override, else derived from *plans_dir*."""
116 if self.inventory_path_override is not None:
117 return self.inventory_path_override
118 return self.plans_dir / DEFAULT_INVENTORY_NAME
119
120 def plan_body_relpath(self, plan_id: str) -> Path:
121 """Where *plan_id*'s markdown file lives, relative to a repo root.
122
123 Plan 270 P3. ``plan_status.py`` and ``plan_manage.py`` each built
124 this by hand from a literal ``Path("oc_chat") / "plans"``, which
125 is a CONSUMER's layout and cannot be hard-coded in a standalone
126 package. Both now ask here, so the layout is stated once beside
127 the inventory path it sits next to, and a consumer with a
128 different tree overrides it by constructing a :class:`PlanPaths`
129 with its own ``plans_dir``.
130
131 The value is deliberately RELATIVE: it is recorded in registry
132 rows and in manifests as the plan's identity on disk, where an
133 absolute path would differ per machine and per worktree.
134 """
135 return self.plans_dir / f"{plan_id}.md"
136
137
138def _now_iso() -> str:
139 # OCT-LINT: disable=no_hardcoded_secrets reason="English docstring prose; no credential content"
140 """Return a local-with-offset ISO 8601 timestamp.
141
142 Form: ``YYYY-MM-DDTHH:MM:SS±HH:MM`` (e.g. ``2026-05-17T00:25:42+02:00``).
143 Local wall-clock time + explicit zone offset per the project-wide
144 convention in :doc:`CHANGELOG-format.md` §"Timestamps".
145 """
146 return datetime.now().astimezone().replace(microsecond=0).isoformat()
147
148
149@dataclass(frozen=True)
151 """A single plan's metadata and current status.
152
153 Status values: ``active``, ``paused``, ``complete``, ``aborted``.
154 ``plan_id`` is the filename stem without ``.md`` and without state
155 suffix — e.g., ``032-sunny-weaving-honey``.
156 """
157 plan_id: str
158 description: str
159 status: str = "active"
160 created_at: str = field(default_factory=_now_iso)
161 completed_at: str | None = None
162 aborted_at: str | None = None
163 session_name: str | None = None
164
165 def to_dict(self) -> dict[str, Any]:
166 """Serialise for JSON storage. Omits ``plan_id`` (it's the dict key)."""
167 d = asdict(self)
168 d.pop("plan_id")
169 return d
170
171 @classmethod
172 def from_dict(cls, plan_id: str, data: dict[str, Any]) -> PlanRecord:
173 """Reconstruct from JSON. Missing optional fields get defaults."""
174 return cls(
175 plan_id=plan_id,
176 description=data.get("description", ""),
177 status=data.get("status", "active"),
178 created_at=data.get("created_at") or _now_iso(),
179 completed_at=data.get("completed_at"),
180 aborted_at=data.get("aborted_at"),
181 session_name=data.get("session_name"),
182 )
183
184
185class PlanInventory(ABC):
186 """Backend-agnostic interface for plan storage."""
187
188 @abstractmethod
190 self,
191 plan_id: str,
192 description: str,
193 session_name: str | None = None,
194 ) -> PlanRecord:
195 """Insert a new active record. Raises if plan_id already exists."""
196
197 @abstractmethod
198 def get(self, plan_id: str) -> PlanRecord | None:
199 """Return the record for plan_id, or None if missing."""
200
201 @abstractmethod
202 def update_status(self, plan_id: str, new_status: str) -> PlanRecord:
203 """Transition a record's status. Sets completed_at/aborted_at as
204 appropriate. Raises if plan_id is unknown or status is invalid."""
205
206 @abstractmethod
207 def remove(self, plan_id: str) -> None:
208 """Delete a record. No-op if plan_id is unknown."""
209
210 @abstractmethod
211 def all(self) -> list[PlanRecord]:
212 """Return all records, unsorted."""
213
214 @abstractmethod
215 def list_by_status(self, status: str) -> list[PlanRecord]:
216 """Return records with the given status."""
217
218 @abstractmethod
219 def next_prefix(self) -> int:
220 """Return the next available 3-digit prefix (1-based). Considers
221 BOTH inventory records and on-disk plan files. Raises
222 :class:`RuntimeError` if 999 is taken."""
223
224
225class JsonPlanInventory(PlanInventory):
226 """JSON-file-backed plan inventory.
227
228 File format (version 1)::
229
230 {
231 "version": 1,
232 "plans": {
233 "<plan_id>": { "description": str, "status": str,
234 "created_at": str, "completed_at": str|None,
235 "aborted_at": str|None,
236 "session_name": str|None },
237 ...
238 }
239 }
240
241 Writes are atomic: a fresh temp file is written and then renamed
242 over the inventory path. A corrupt or missing file is recovered
243 transparently with an empty inventory.
244 """
245
246 def __init__(self, path: Path, plans_dir: Path | None = None) -> None:
247 """Open (or create) the inventory at *path*.
248
249 ``plans_dir`` defaults to the parent of *path* and is used to
250 check existing on-disk filenames when allocating new prefixes.
251 """
252 func = "JsonPlanInventory.__init__"
253 self._path = Path(path)
254 self._plans_dir = Path(plans_dir) if plans_dir is not None else self._path.parent
255 self._records: dict[str, PlanRecord] = {}
256 self._load_or_init()
257 _dbg("OC_CHAT.MAIN", func,
258 f"opened {self._path} ({len(self._records)} record(s))", 2)
259
260 # ── Internal IO ────────────────────────────────────────────────────
261
262 def _load_or_init(self) -> None:
263 """Load the inventory file. Missing or corrupt → empty inventory."""
264 func = "JsonPlanInventory._load_or_init"
265 if not self._path.exists():
266 _dbg("OC_CHAT.MAIN", func, f"no inventory at {self._path}; starting empty", 2)
267 self._records = {}
268 return
269 try:
270 raw = self._path.read_bytes()
271 if raw.startswith(b"\xef\xbb\xbf"):
272 raw = raw[3:]
273 data = json.loads(raw.decode("utf-8"))
274 except (OSError, json.JSONDecodeError) as exc:
275 _dbg("OC_CHAT.MAIN", func,
276 # OCT-LINT: disable=no_hardcoded_secrets reason="diagnostic message string emitted via _dbg; no credential content"
277 f"SYSTEM_ERROR: inventory unreadable, starting empty: {exc}",
278 1, override=True)
279 self._records = {}
280 return
281
282 plans = data.get("plans", {})
283 self._records = {
284 pid: PlanRecord.from_dict(pid, rec)
285 for pid, rec in plans.items()
286 }
287
288 def _write(self) -> None:
289 """Atomically persist the current records to disk."""
290 func = "JsonPlanInventory._write"
291 self._commit_records(dict(self._records))
292 _dbg("OC_CHAT.MAIN", func, f"wrote {len(self._records)} record(s)", 4)
293
294 def _commit_records(self, candidate: dict) -> None:
295 """Durably write *candidate* to disk. Does NOT publish it in memory.
296
297 The durable half of the owner atomic op (plan 152 R11-05). Splitting it
298 out is what lets callers write BEFORE publishing: previously ``register``
299 and ``update_status`` mutated the live ``_records`` map and only then
300 called this, so a serialization / temp-write / replace failure left
301 memory NEW while disk stayed OLD — and a compensating second write is
302 itself fallible, so wrapping the call sites could not fix it.
303
304 The temp file is flushed AND fsynced before the replace. Without the
305 fsync, ``Path.replace`` can be durable while the temp file's contents
306 are still only in the page cache, so a crash at the wrong moment leaves
307 the inventory pointing at a truncated or empty file — atomic in name
308 only. Temp cleanup runs in a ``finally`` so a failure never strews
309 ``.inventory_*.tmp`` files.
310 """
311 self._path.parent.mkdir(parents=True, exist_ok=True)
312 payload = {
313 "version": 1,
314 "plans": {pid: r.to_dict() for pid, r in candidate.items()},
315 }
316 tmp_path: Path | None = None
317 try:
318 with tempfile.NamedTemporaryFile(
319 mode="w", encoding="utf-8", dir=self._path.parent,
320 prefix=".inventory_", suffix=".tmp", delete=False,
321 ) as tmp:
322 tmp_path = Path(tmp.name)
323 json.dump(payload, tmp, indent=2, ensure_ascii=False)
324 tmp.flush()
325 os.fsync(tmp.fileno())
326 tmp_path.replace(self._path) # <- the DURABLE COMMIT POINT
327 tmp_path = None # replaced; nothing left to clean up
328 finally:
329 if tmp_path is not None and tmp_path.exists():
330 try:
331 tmp_path.unlink()
332 except OSError:
333 pass
334
335 def commit_atomic(self, candidate: dict) -> str:
336 """Durably commit *candidate*, publishing in memory ONLY after it lands.
337
338 The owner atomic op. Returns a typed receipt:
339
340 - ``NOT_APPLIED`` — the durable write failed; memory AND disk are OLD, so
341 the caller may retry or report cleanly.
342 - ``APPLIED`` — disk committed and memory published; they agree.
343 - ``UNKNOWN`` — the durable write landed but publishing did not complete.
344 Fail closed: the caller must report an indeterminate state rather than
345 claim success.
346
347 Ordering is the whole point: disk first, memory second. The reverse — the
348 pre-existing shape — could leave the in-process view claiming a plan
349 exists that no restart would ever see.
350 """
351 func = "JsonPlanInventory.commit_atomic"
352 try:
353 self._commit_records(candidate)
354 except Exception as exc:
355 _dbg("OC_CHAT.MAIN", func,
356 # OCT-LINT: disable=no_hardcoded_secrets reason="diagnostic message string emitted via _dbg; no credential content"
357 f"EXTERNAL_ERROR: durable write failed, nothing published: "
358 f"{type(exc).__name__}: {exc}", 2, override=True)
359 return NOT_APPLIED
360 try:
361 self._records = candidate
362 except Exception as exc: # pragma: no cover - defensive
363 _dbg("OC_CHAT.MAIN", func,
364 f"SYSTEM_ERROR: durable write landed but publish failed: {exc}",
365 1, override=True)
366 return UNKNOWN
367 return APPLIED
368
369 # ── Public API ─────────────────────────────────────────────────────
370
372 self,
373 plan_id: str,
374 description: str,
375 session_name: str | None = None,
376 ) -> PlanRecord:
377 func = "JsonPlanInventory.register"
378 if plan_id in self._records:
379 raise ValueError(f"plan_id already registered: {plan_id!r}")
380 rec = PlanRecord(
381 plan_id=plan_id,
382 description=description,
383 status="active",
384 session_name=session_name,
385 )
386 # Build the candidate map WITHOUT publishing, commit it durably, and
387 # publish in memory only after the durable commit point (R11-05).
388 candidate = dict(self._records)
389 candidate[plan_id] = rec
390 receipt = self.commit_atomic(candidate)
391 if receipt != APPLIED:
392 raise OSError(
393 f"could not durably register plan {plan_id!r} (receipt={receipt})"
394 )
395 _dbg("OC_CHAT.MAIN", func, f"registered {plan_id!r}", 3)
396 return rec
397
398 def get(self, plan_id: str) -> PlanRecord | None:
399 return self._records.get(plan_id)
400
401 def update_status(self, plan_id: str, new_status: str) -> PlanRecord:
402 func = "JsonPlanInventory.update_status"
403 if new_status not in VALID_STATUSES:
404 raise ValueError(
405 f"invalid status {new_status!r}; expected one of {VALID_STATUSES}"
406 )
407 existing = self._records.get(plan_id)
408 if existing is None:
409 raise KeyError(f"unknown plan_id: {plan_id!r}")
410
411 updates: dict[str, Any] = {"status": new_status}
412 if new_status == "complete" and existing.completed_at is None:
413 updates["completed_at"] = _now_iso()
414 elif new_status == "aborted" and existing.aborted_at is None:
415 updates["aborted_at"] = _now_iso()
416
417 new_rec = replace(existing, **updates)
418 candidate = dict(self._records)
419 candidate[plan_id] = new_rec
420 receipt = self.commit_atomic(candidate)
421 if receipt != APPLIED:
422 raise OSError(
423 f"could not durably update plan {plan_id!r} (receipt={receipt})"
424 )
425 _dbg("OC_CHAT.MAIN", func,
426 f"{plan_id!r}: {existing.status} → {new_status}", 3)
427 return new_rec
428
429 def remove(self, plan_id: str) -> None:
430 func = "JsonPlanInventory.remove"
431 if plan_id in self._records:
432 del self._records[plan_id]
433 self._write()
434 _dbg("OC_CHAT.MAIN", func, f"removed {plan_id!r}", 3)
435
436 def all(self) -> list[PlanRecord]:
437 return list(self._records.values())
438
439 def list_by_status(self, status: str) -> list[PlanRecord]:
440 return [r for r in self._records.values() if r.status == status]
441
442 def next_prefix(self) -> int:
443 """Return the next prefix not used by inventory or on-disk files.
444
445 TODO(phase-future): archive-and-reset when 999 is reached. For
446 now, raise loudly so we fail rather than silently overflow.
447 """
448 used: set[int] = set()
449 for plan_id in self._records:
450 try:
451 used.add(int(plan_id.split("-", 1)[0]))
452 except ValueError:
453 continue
454 if self._plans_dir.is_dir():
455 for f in self._plans_dir.glob("*.md"):
456 m = _FILENAME_RE.match(f.name)
457 if m:
458 try:
459 used.add(int(m.group("stem").split("-", 1)[0]))
460 except ValueError:
461 continue
462 for n in range(1, 1000):
463 if n not in used:
464 return n
465 raise RuntimeError(
466 "Plan directory full (999 prefixes used); manual archive required"
467 )
468
469
470# ── Process-wide singleton factory ─────────────────────────────────────
471
472_default_inventory: PlanInventory | None = None
473
474
476 path: Path | None = None,
477 *,
478 paths: PlanPaths | None = None,
479) -> PlanInventory:
480 """Return the process-wide default inventory.
481
482 First call may pass an explicit ``path`` (used by tests). Subsequent
483 calls return the cached instance regardless of ``path``. Pass
484 ``path`` only on first call; pass ``None`` thereafter.
485
486 Plan 257 P3: the default location is now read off :class:`PlanPaths`
487 rather than from a second literal, and the unused
488 ``persistence.DEFAULT_SESSIONS_DIR`` import that used to sit on the
489 line above it is gone. That one dead line pulled 43 modules --
490 session, config, defaults, startup and the rest -- into this
491 module's import closure, and importing them ran ``load_dotenv`` and
492 created a log directory as a side effect of asking for a plan list.
493 """
494 global _default_inventory
495 if _default_inventory is None:
496 if path is None:
497 path = (paths or PlanPaths()).inventory_path
498 _default_inventory = JsonPlanInventory(path)
499 return _default_inventory
500
501
503 """Test-only helper to clear the cached singleton."""
504 global _default_inventory
505 _default_inventory = None
506
507
508# ── Reconcile: inventory → filesystem ──────────────────────────────────
509
510
511def _expected_filename(plan_id: str, status: str) -> str:
512 """Compute the expected on-disk filename for a record."""
513 if status == "active":
514 return f"{plan_id}.md"
515 return f"{plan_id}_{status}.md"
516
517
518def reconcile(inventory: PlanInventory, plans_dir: Path) -> dict[str, list[str]]:
519 """Heal disk/inventory drift. Inventory wins.
520
521 Two operations:
522
523 1. For every inventory record, ensure exactly one file exists at the
524 expected filename. If the file is at a different suffix, rename
525 it. If no file exists for the plan_id, log a warning (we do not
526 create empty files — the agent may not have written the plan
527 yet).
528
529 2. For every on-disk file matching the plan-filename regex that
530 isn't in the inventory, absorb it: register a new record with
531 status derived from the suffix and an empty description.
532
533 Returns a dict with three lists for callers/tests to inspect:
534 ``renamed``, ``orphans_absorbed``, ``missing_files``.
535 """
536 func = "reconcile"
537 result: dict[str, list[str]] = {
538 "renamed": [],
539 "orphans_absorbed": [],
540 "missing_files": [],
541 }
542 plans_dir = Path(plans_dir)
543 if not plans_dir.is_dir():
544 _dbg("OC_CHAT.MAIN", func, f"plans_dir missing: {plans_dir}", 2)
545 return result
546
547 # Pass 1: inventory → filesystem (rename mismatched files)
548 for rec in inventory.all():
549 expected = _expected_filename(rec.plan_id, rec.status)
550 expected_path = plans_dir / expected
551 if expected_path.exists():
552 continue
553 # Search for any file matching this plan_id with any suffix
554 candidates = [
555 p for p in plans_dir.glob(f"{rec.plan_id}*.md")
556 if _FILENAME_RE.match(p.name)
557 and _FILENAME_RE.match(p.name).group("stem") == rec.plan_id
558 ]
559 if not candidates:
560 result["missing_files"].append(rec.plan_id)
561 _dbg("OC_CHAT.MAIN", func,
562 f"inventory has {rec.plan_id!r} but no file on disk", 2)
563 continue
564 src = candidates[0]
565 try:
566 src.rename(expected_path)
567 result["renamed"].append(f"{src.name} → {expected}")
568 _dbg("OC_CHAT.MAIN", func, f"renamed {src.name} → {expected}", 3)
569 except OSError as exc:
570 _dbg("OC_CHAT.MAIN", func,
571 f"SYSTEM_ERROR: rename failed for {src}: {exc}",
572 1, override=True)
573
574 # Pass 2: filesystem → inventory (absorb orphans)
575 known_ids = {r.plan_id for r in inventory.all()}
576 for f in plans_dir.glob("*.md"):
577 m = _FILENAME_RE.match(f.name)
578 if not m:
579 continue
580 stem = m.group("stem")
581 if stem in known_ids:
582 continue
583 state = m.group("state") or "active"
584 try:
585 inventory.register(stem, description="", session_name=None)
586 if state != "active":
587 inventory.update_status(stem, state)
588 result["orphans_absorbed"].append(f.name)
589 _dbg("OC_CHAT.MAIN", func,
590 f"absorbed orphan {f.name} (status={state})", 3)
591 except ValueError:
592 # Race: another process registered between checks. Skip.
593 continue
594
595 return result
None __init__(self, Path path, Path|None plans_dir=None)
list[PlanRecord] list_by_status(self, str status)
PlanRecord update_status(self, str plan_id, str new_status)
PlanRecord|None get(self, str plan_id)
PlanRecord register(self, str plan_id, str description, str|None session_name=None)
list[PlanRecord] list_by_status(self, str status)
PlanRecord|None get(self, str plan_id)
PlanRecord register(self, str plan_id, str description, str|None session_name=None)
PlanRecord update_status(self, str plan_id, str new_status)
PlanRecord from_dict(cls, str plan_id, dict[str, Any] data)
dict[str, list[str]] reconcile(PlanInventory inventory, Path plans_dir)
PlanInventory get_inventory(Path|None path=None, *, PlanPaths|None paths=None)
str _expected_filename(str plan_id, str status)