Skip to content

Commit e551ab7

Browse files
authored
Merge pull request #11 from baskduf/refactor/shared-state-ci-validation
refactor: share FableCodex state helpers
2 parents 503b991 + f180b82 commit e551ab7

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)