Skip to content

Commit f180b82

Browse files
committed
refactor: share FableCodex state helpers
Extract shared state, locking, and JSON helpers for the goals and findings ledgers. Strengthen CI workflow tests so the pinned source fetch and coverage validation steps stay semantically linked.
1 parent 503b991 commit f180b82

9 files changed

Lines changed: 213 additions & 182 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ jobs:
3232
- name: Compile scripts
3333
run: |
3434
python3 -m py_compile \
35+
plugins/codex-fable5/skills/codex-fable5/scripts/codex_fable_state.py \
3536
plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py \
3637
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
3738
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \

CONTRIBUTING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ Before opening a pull request, also run:
2323

2424
```bash
2525
python3 -m py_compile \
26+
plugins/codex-fable5/skills/codex-fable5/scripts/codex_fable_state.py \
2627
plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py \
2728
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
2829
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \

docs/RELEASING.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ This project uses a lightweight release process because it is a small Codex plug
1313
```bash
1414
python3 -m unittest discover -s tests -v
1515
python3 -m py_compile \
16+
plugins/codex-fable5/skills/codex-fable5/scripts/codex_fable_state.py \
1617
plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py \
1718
plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py \
1819
plugins/codex-fable5/skills/codex-fable5/scripts/fable_coverage.py \
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
#!/usr/bin/env python3
2+
"""Shared Codex Fable5 local state helpers."""
3+
4+
from __future__ import annotations
5+
6+
from contextlib import contextmanager
7+
import json
8+
import os
9+
import sys
10+
import tempfile
11+
import time
12+
from datetime import datetime, timezone
13+
from pathlib import Path
14+
from typing import Any, Iterator
15+
16+
try:
17+
import fcntl
18+
except ImportError: # pragma: no cover - Windows fallback only.
19+
fcntl = None
20+
21+
STATE_DIR = Path(".codex-fable5")
22+
GOALS_FILE = STATE_DIR / "goals.json"
23+
FINDINGS_FILE = STATE_DIR / "findings.json"
24+
LEDGER_FILE = STATE_DIR / "ledger.jsonl"
25+
LOCK_FILE = STATE_DIR / "state.lock"
26+
LOCK_TIMEOUT_SECONDS = 30.0
27+
28+
29+
def now() -> str:
30+
return datetime.now(timezone.utc).isoformat()
31+
32+
33+
@contextmanager
34+
def locked_state() -> Iterator[None]:
35+
STATE_DIR.mkdir(exist_ok=True)
36+
if fcntl is None:
37+
fallback = STATE_DIR / "state.lockdir"
38+
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
39+
while True:
40+
try:
41+
fallback.mkdir()
42+
break
43+
except FileExistsError:
44+
if time.monotonic() >= deadline:
45+
sys.exit(f"codex-fable5: timed out waiting for state lock ({fallback}).")
46+
time.sleep(0.05)
47+
try:
48+
yield
49+
finally:
50+
fallback.rmdir()
51+
return
52+
53+
with LOCK_FILE.open("a", encoding="utf-8") as handle:
54+
fcntl.flock(handle, fcntl.LOCK_EX)
55+
try:
56+
yield
57+
finally:
58+
fcntl.flock(handle, fcntl.LOCK_UN)
59+
60+
61+
def read_json(path: Path, label: str) -> Any:
62+
try:
63+
return json.loads(path.read_text(encoding="utf-8"))
64+
except json.JSONDecodeError as exc:
65+
sys.exit(
66+
f"codex-fable5: {label} is not valid JSON "
67+
f"({path}:{exc.lineno}:{exc.colno}: {exc.msg})."
68+
)
69+
70+
71+
def write_json(path: Path, data: dict[str, Any]) -> None:
72+
STATE_DIR.mkdir(exist_ok=True)
73+
tmp_name = ""
74+
with tempfile.NamedTemporaryFile(
75+
"w",
76+
encoding="utf-8",
77+
dir=STATE_DIR,
78+
prefix=f".{path.name}.",
79+
delete=False,
80+
) as handle:
81+
tmp_name = handle.name
82+
handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
83+
handle.flush()
84+
os.fsync(handle.fileno())
85+
Path(tmp_name).replace(path)
86+
87+
88+
def append_event(event: str, **fields: Any) -> None:
89+
STATE_DIR.mkdir(exist_ok=True)
90+
record = {"ts": now(), "event": event, **fields}
91+
with LEDGER_FILE.open("a", encoding="utf-8") as handle:
92+
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
93+
94+
95+
def require_object(value: Any, path: Path, label: str) -> dict[str, Any]:
96+
if not isinstance(value, dict):
97+
sys.exit(f"codex-fable5: {label} must be a JSON object ({path}).")
98+
return value

plugins/codex-fable5/skills/codex-fable5/scripts/codex_findings.py

Lines changed: 15 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -4,107 +4,32 @@
44
from __future__ import annotations
55

66
import argparse
7-
from contextlib import contextmanager
8-
import json
9-
import os
107
import re
118
import sys
12-
import tempfile
13-
import time
14-
from datetime import datetime, timezone
159
from pathlib import Path
16-
from typing import Any, Iterator
10+
from typing import Any
11+
12+
from codex_fable_state import (
13+
FINDINGS_FILE,
14+
GOALS_FILE,
15+
LEDGER_FILE,
16+
LOCK_FILE,
17+
STATE_DIR,
18+
append_event,
19+
locked_state,
20+
now,
21+
read_json,
22+
require_object,
23+
write_json,
24+
)
1725

18-
try:
19-
import fcntl
20-
except ImportError: # pragma: no cover - Windows fallback only.
21-
fcntl = None
22-
23-
STATE_DIR = Path(".codex-fable5")
24-
GOALS_FILE = STATE_DIR / "goals.json"
25-
FINDINGS_FILE = STATE_DIR / "findings.json"
26-
LEDGER_FILE = STATE_DIR / "ledger.jsonl"
27-
LOCK_FILE = STATE_DIR / "state.lock"
2826

2927
OPEN_STATUSES = {"open"}
3028
BLOCKING_STATUSES = {"open", "blocked"}
3129
TERMINAL_STATUSES = {"resolved", "rejected"}
3230
SEVERITY_ORDER = {"critical": 0, "high": 1, "medium": 2, "low": 3}
3331
FINDING_STATUSES = {"open", "blocked", "resolved", "rejected"}
3432
FINDING_REQUIRED_FIELDS = {"id", "goal", "title", "severity", "source", "status", "evidence"}
35-
LOCK_TIMEOUT_SECONDS = 30.0
36-
37-
38-
def now() -> str:
39-
return datetime.now(timezone.utc).isoformat()
40-
41-
42-
@contextmanager
43-
def locked_state() -> Iterator[None]:
44-
STATE_DIR.mkdir(exist_ok=True)
45-
if fcntl is None:
46-
fallback = STATE_DIR / "state.lockdir"
47-
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
48-
while True:
49-
try:
50-
fallback.mkdir()
51-
break
52-
except FileExistsError:
53-
if time.monotonic() >= deadline:
54-
sys.exit(f"codex-fable5: timed out waiting for state lock ({fallback}).")
55-
time.sleep(0.05)
56-
try:
57-
yield
58-
finally:
59-
fallback.rmdir()
60-
return
61-
62-
with LOCK_FILE.open("a", encoding="utf-8") as handle:
63-
fcntl.flock(handle, fcntl.LOCK_EX)
64-
try:
65-
yield
66-
finally:
67-
fcntl.flock(handle, fcntl.LOCK_UN)
68-
69-
70-
def read_json(path: Path, label: str) -> Any:
71-
try:
72-
return json.loads(path.read_text(encoding="utf-8"))
73-
except json.JSONDecodeError as exc:
74-
sys.exit(
75-
f"codex-fable5: {label} is not valid JSON "
76-
f"({path}:{exc.lineno}:{exc.colno}: {exc.msg})."
77-
)
78-
79-
80-
def write_json(path: Path, data: dict[str, Any]) -> None:
81-
STATE_DIR.mkdir(exist_ok=True)
82-
tmp_name = ""
83-
with tempfile.NamedTemporaryFile(
84-
"w",
85-
encoding="utf-8",
86-
dir=STATE_DIR,
87-
prefix=f".{path.name}.",
88-
delete=False,
89-
) as handle:
90-
tmp_name = handle.name
91-
handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
92-
handle.flush()
93-
os.fsync(handle.fileno())
94-
Path(tmp_name).replace(path)
95-
96-
97-
def append_event(event: str, **fields: Any) -> None:
98-
STATE_DIR.mkdir(exist_ok=True)
99-
record = {"ts": now(), "event": event, **fields}
100-
with LEDGER_FILE.open("a", encoding="utf-8") as handle:
101-
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
102-
103-
104-
def require_object(value: Any, path: Path, label: str) -> dict[str, Any]:
105-
if not isinstance(value, dict):
106-
sys.exit(f"codex-fable5: {label} must be a JSON object ({path}).")
107-
return value
10833

10934

11035
def validate_findings(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]:

plugins/codex-fable5/skills/codex-fable5/scripts/codex_goals.py

Lines changed: 16 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -4,112 +4,37 @@
44
from __future__ import annotations
55

66
import argparse
7-
from contextlib import contextmanager
8-
import json
9-
import os
107
import sys
11-
import tempfile
12-
import time
13-
from datetime import datetime, timezone
148
from pathlib import Path
15-
from typing import Any, Iterator
16-
17-
try:
18-
import fcntl
19-
except ImportError: # pragma: no cover - Windows fallback only.
20-
fcntl = None
21-
22-
STATE_DIR = Path(".codex-fable5")
23-
GOALS_FILE = STATE_DIR / "goals.json"
24-
FINDINGS_FILE = STATE_DIR / "findings.json"
25-
LEDGER_FILE = STATE_DIR / "ledger.jsonl"
26-
LOCK_FILE = STATE_DIR / "state.lock"
9+
from typing import Any
10+
11+
from codex_fable_state import (
12+
FINDINGS_FILE,
13+
GOALS_FILE,
14+
LEDGER_FILE,
15+
LOCK_FILE,
16+
STATE_DIR,
17+
append_event,
18+
locked_state,
19+
now,
20+
read_json,
21+
require_object,
22+
write_json,
23+
)
24+
2725
OPEN_STATUSES = {"pending", "in_progress"}
2826
INCOMPLETE_TERMINAL_STATUSES = {"failed", "blocked"}
2927
BLOCKING_FINDING_STATUSES = {"open", "blocked"}
3028
GOAL_STATUSES = {"pending", "in_progress", "complete", "failed", "blocked"}
3129
GOAL_REQUIRED_FIELDS = {"id", "title", "objective", "status", "evidence", "verify_cmd", "verify_evidence"}
3230
FINDING_STATUSES = {"open", "blocked", "resolved", "rejected"}
3331
FINDING_REQUIRED_FIELDS = {"id", "goal", "title", "severity", "source", "status", "evidence"}
34-
LOCK_TIMEOUT_SECONDS = 30.0
35-
36-
37-
def now() -> str:
38-
return datetime.now(timezone.utc).isoformat()
3932

4033

4134
def safe_stamp() -> str:
4235
return now().replace(":", "").replace("+", "Z")
4336

4437

45-
@contextmanager
46-
def locked_state() -> Iterator[None]:
47-
STATE_DIR.mkdir(exist_ok=True)
48-
if fcntl is None:
49-
fallback = STATE_DIR / "state.lockdir"
50-
deadline = time.monotonic() + LOCK_TIMEOUT_SECONDS
51-
while True:
52-
try:
53-
fallback.mkdir()
54-
break
55-
except FileExistsError:
56-
if time.monotonic() >= deadline:
57-
sys.exit(f"codex-fable5: timed out waiting for state lock ({fallback}).")
58-
time.sleep(0.05)
59-
try:
60-
yield
61-
finally:
62-
fallback.rmdir()
63-
return
64-
65-
with LOCK_FILE.open("a", encoding="utf-8") as handle:
66-
fcntl.flock(handle, fcntl.LOCK_EX)
67-
try:
68-
yield
69-
finally:
70-
fcntl.flock(handle, fcntl.LOCK_UN)
71-
72-
73-
def read_json(path: Path, label: str) -> Any:
74-
try:
75-
return json.loads(path.read_text(encoding="utf-8"))
76-
except json.JSONDecodeError as exc:
77-
sys.exit(
78-
f"codex-fable5: {label} is not valid JSON "
79-
f"({path}:{exc.lineno}:{exc.colno}: {exc.msg})."
80-
)
81-
82-
83-
def write_json(path: Path, data: dict[str, Any]) -> None:
84-
STATE_DIR.mkdir(exist_ok=True)
85-
tmp_name = ""
86-
with tempfile.NamedTemporaryFile(
87-
"w",
88-
encoding="utf-8",
89-
dir=STATE_DIR,
90-
prefix=f".{path.name}.",
91-
delete=False,
92-
) as handle:
93-
tmp_name = handle.name
94-
handle.write(json.dumps(data, ensure_ascii=False, indent=2) + "\n")
95-
handle.flush()
96-
os.fsync(handle.fileno())
97-
Path(tmp_name).replace(path)
98-
99-
100-
def append_event(event: str, **fields: Any) -> None:
101-
STATE_DIR.mkdir(exist_ok=True)
102-
record = {"ts": now(), "event": event, **fields}
103-
with LEDGER_FILE.open("a", encoding="utf-8") as handle:
104-
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
105-
106-
107-
def require_object(value: Any, path: Path, label: str) -> dict[str, Any]:
108-
if not isinstance(value, dict):
109-
sys.exit(f"codex-fable5: {label} must be a JSON object ({path}).")
110-
return value
111-
112-
11338
def validate_plan(data: dict[str, Any], path: Path, label: str) -> dict[str, Any]:
11439
if not isinstance(data.get("brief"), str):
11540
sys.exit(f"codex-fable5: {label} field 'brief' must be a string ({path}).")

0 commit comments

Comments
 (0)