chipgraph 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. chipgraph/__init__.py +7 -0
  2. chipgraph/adapters/__init__.py +1 -0
  3. chipgraph/adapters/format/__init__.py +6 -0
  4. chipgraph/adapters/format/__main__.py +16 -0
  5. chipgraph/adapters/format/_yaml_lines.py +54 -0
  6. chipgraph/adapters/format/chip_yaml.py +323 -0
  7. chipgraph/adapters/format/qsoc_contract.py +246 -0
  8. chipgraph/adapters/llm/__init__.py +58 -0
  9. chipgraph/adapters/llm/_budget.py +173 -0
  10. chipgraph/adapters/llm/_errors.py +50 -0
  11. chipgraph/adapters/llm/_nda.py +30 -0
  12. chipgraph/adapters/llm/_retry.py +85 -0
  13. chipgraph/adapters/llm/anthropic.py +247 -0
  14. chipgraph/adapters/llm/decide_backend.py +179 -0
  15. chipgraph/adapters/llm/fake.py +87 -0
  16. chipgraph/adapters/llm/guard.py +68 -0
  17. chipgraph/adapters/parser/__init__.py +22 -0
  18. chipgraph/adapters/parser/cocotb.py +304 -0
  19. chipgraph/adapters/parser/generic_regex.py +75 -0
  20. chipgraph/adapters/parser/verible.py +55 -0
  21. chipgraph/adapters/parser/verilator.py +46 -0
  22. chipgraph/adapters/review/__init__.py +7 -0
  23. chipgraph/adapters/review/file.py +123 -0
  24. chipgraph/adapters/review/github.py +886 -0
  25. chipgraph/adapters/runner/__init__.py +7 -0
  26. chipgraph/adapters/runner/local.py +122 -0
  27. chipgraph/adapters/runtime/__init__.py +5 -0
  28. chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
  29. chipgraph/adapters/runtime/claude_code/agents.py +158 -0
  30. chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
  31. chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
  32. chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
  33. chipgraph/adapters/runtime/claude_code/loop.py +342 -0
  34. chipgraph/adapters/runtime/claude_code/previous.py +419 -0
  35. chipgraph/adapters/runtime/claude_code/review.py +492 -0
  36. chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
  37. chipgraph/adapters/runtime/claude_code/service.py +2285 -0
  38. chipgraph/adapters/tool/__init__.py +7 -0
  39. chipgraph/adapters/tool/cmd.py +229 -0
  40. chipgraph/adapters/tool/edalize.py +773 -0
  41. chipgraph/adapters/tool/filelist.py +259 -0
  42. chipgraph/adapters/tool/pyslang.py +485 -0
  43. chipgraph/adapters/vcs/__init__.py +1 -0
  44. chipgraph/adapters/vcs/git.py +521 -0
  45. chipgraph/app/__init__.py +10 -0
  46. chipgraph/app/baseline.py +339 -0
  47. chipgraph/app/build.py +332 -0
  48. chipgraph/app/checks.py +202 -0
  49. chipgraph/app/context.py +191 -0
  50. chipgraph/app/errors.py +11 -0
  51. chipgraph/app/executors.py +79 -0
  52. chipgraph/app/findings.py +203 -0
  53. chipgraph/app/ingest.py +523 -0
  54. chipgraph/checks/__init__.py +52 -0
  55. chipgraph/checks/_cdc_analyze.py +272 -0
  56. chipgraph/checks/_cdc_elaborate.py +455 -0
  57. chipgraph/checks/_common.py +268 -0
  58. chipgraph/checks/_cross.py +96 -0
  59. chipgraph/checks/_model.py +64 -0
  60. chipgraph/checks/_naming_pyslang.py +235 -0
  61. chipgraph/checks/_naming_rules.py +127 -0
  62. chipgraph/checks/_xref_syntax.py +260 -0
  63. chipgraph/checks/cdc_struct.py +235 -0
  64. chipgraph/checks/connect.py +353 -0
  65. chipgraph/checks/cross_chip.py +454 -0
  66. chipgraph/checks/duplicate.py +153 -0
  67. chipgraph/checks/filelist.py +323 -0
  68. chipgraph/checks/format.py +268 -0
  69. chipgraph/checks/generated.py +151 -0
  70. chipgraph/checks/hardcode.py +233 -0
  71. chipgraph/checks/layout.py +155 -0
  72. chipgraph/checks/naming.py +311 -0
  73. chipgraph/checks/plan_check.py +901 -0
  74. chipgraph/checks/ports_diff.py +203 -0
  75. chipgraph/checks/spec_schema.py +277 -0
  76. chipgraph/checks/style.py +910 -0
  77. chipgraph/checks/tb_static.py +599 -0
  78. chipgraph/checks/trace.py +224 -0
  79. chipgraph/checks/vendor_guard.py +369 -0
  80. chipgraph/checks/wrap_check.py +486 -0
  81. chipgraph/cli/__init__.py +1688 -0
  82. chipgraph/cli/next_steps.py +137 -0
  83. chipgraph/core/__init__.py +1 -0
  84. chipgraph/core/config/__init__.py +93 -0
  85. chipgraph/core/config/defaults.py +14 -0
  86. chipgraph/core/config/errors.py +32 -0
  87. chipgraph/core/config/loader.py +766 -0
  88. chipgraph/core/config/models.py +845 -0
  89. chipgraph/core/config/templates.py +340 -0
  90. chipgraph/core/contracts/__init__.py +75 -0
  91. chipgraph/core/contracts/_paths.py +26 -0
  92. chipgraph/core/contracts/agent.py +49 -0
  93. chipgraph/core/contracts/artifact.py +68 -0
  94. chipgraph/core/contracts/check.py +76 -0
  95. chipgraph/core/contracts/event.py +70 -0
  96. chipgraph/core/contracts/export.py +128 -0
  97. chipgraph/core/contracts/finding.py +171 -0
  98. chipgraph/core/contracts/gate.py +50 -0
  99. chipgraph/core/contracts/rule.py +177 -0
  100. chipgraph/core/contracts/types.py +45 -0
  101. chipgraph/core/engine/__init__.py +70 -0
  102. chipgraph/core/engine/agent_rule.py +1362 -0
  103. chipgraph/core/engine/baseline.py +283 -0
  104. chipgraph/core/engine/decide.py +462 -0
  105. chipgraph/core/engine/fanout.py +1358 -0
  106. chipgraph/core/engine/gate.py +170 -0
  107. chipgraph/core/engine/graph.py +647 -0
  108. chipgraph/core/engine/records.py +60 -0
  109. chipgraph/core/engine/rules.py +92 -0
  110. chipgraph/core/engine/scheduler.py +637 -0
  111. chipgraph/core/engine/select.py +1239 -0
  112. chipgraph/core/engine/targets.py +342 -0
  113. chipgraph/core/model/__init__.py +98 -0
  114. chipgraph/core/model/entities.py +244 -0
  115. chipgraph/core/model/export.py +16 -0
  116. chipgraph/core/model/ingest.py +693 -0
  117. chipgraph/core/model/json_value.py +4 -0
  118. chipgraph/core/model/keys.py +95 -0
  119. chipgraph/core/model/model.py +153 -0
  120. chipgraph/core/model/provenance.py +27 -0
  121. chipgraph/core/model/query.py +583 -0
  122. chipgraph/core/model/registry.py +80 -0
  123. chipgraph/core/model/relations.py +61 -0
  124. chipgraph/core/model/store.py +279 -0
  125. chipgraph/core/plugin_api/__init__.py +81 -0
  126. chipgraph/core/plugin_api/local.py +167 -0
  127. chipgraph/core/plugin_api/pack.py +159 -0
  128. chipgraph/core/plugin_api/protocols.py +177 -0
  129. chipgraph/core/plugin_api/registry.py +111 -0
  130. chipgraph/core/plugin_api/types.py +94 -0
  131. chipgraph/core/runtime/__init__.py +41 -0
  132. chipgraph/core/runtime/executor.py +79 -0
  133. chipgraph/core/runtime/queue.py +565 -0
  134. chipgraph/core/runtime/roles/__init__.py +91 -0
  135. chipgraph/core/runtime/roles/data/author.md +41 -0
  136. chipgraph/core/runtime/roles/data/critic.md +56 -0
  137. chipgraph/core/runtime/roles/data/planner.md +38 -0
  138. chipgraph/core/runtime/roles/data/researcher.md +37 -0
  139. chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
  140. chipgraph/core/runtime/roles/data/tb-author.md +48 -0
  141. chipgraph/core/runtime/roles/data/triage.md +34 -0
  142. chipgraph/core/runtime/roles/policy.py +299 -0
  143. chipgraph/core/runtime/roles/registry.py +52 -0
  144. chipgraph/core/runtime/roles/review.py +275 -0
  145. chipgraph/core/runtime/roles/schema.py +82 -0
  146. chipgraph/core/runtime/roles/skills.py +134 -0
  147. chipgraph/core/runtime/roles/spec.py +232 -0
  148. chipgraph/core/state/__init__.py +1 -0
  149. chipgraph/core/state/artifacts.py +156 -0
  150. chipgraph/core/state/backend.py +117 -0
  151. chipgraph/core/state/findings.py +151 -0
  152. chipgraph/core/state/handoff.py +411 -0
  153. chipgraph/core/state/idempotency.py +59 -0
  154. chipgraph/core/state/journal.py +238 -0
  155. chipgraph/core/state/layout.py +82 -0
  156. chipgraph/core/state/lock.py +195 -0
  157. chipgraph/core/state/trace.py +225 -0
  158. chipgraph/learn/__init__.py +96 -0
  159. chipgraph/learn/draft.py +187 -0
  160. chipgraph/learn/infer.py +580 -0
  161. chipgraph/learn/models.py +145 -0
  162. chipgraph/learn/try_run.py +228 -0
  163. chipgraph/mcp/__init__.py +12 -0
  164. chipgraph/mcp/model_tools.py +167 -0
  165. chipgraph/mcp/server.py +787 -0
  166. chipgraph/orgs/qnsc/naming-v1.yml +113 -0
  167. chipgraph/orgs/qnsc/profile.yml +5 -0
  168. chipgraph/packs/__init__.py +8 -0
  169. chipgraph/packs/assist/__init__.py +16 -0
  170. chipgraph/packs/assist/ask/__init__.py +58 -0
  171. chipgraph/packs/assist/ask/_db.py +57 -0
  172. chipgraph/packs/assist/ask/_project.py +230 -0
  173. chipgraph/packs/assist/ask/answer.py +266 -0
  174. chipgraph/packs/assist/ask/check.py +182 -0
  175. chipgraph/packs/assist/ask/contract.py +115 -0
  176. chipgraph/packs/assist/ask/documents.py +157 -0
  177. chipgraph/packs/assist/ask/fts.py +80 -0
  178. chipgraph/packs/assist/ask/retrieve.py +331 -0
  179. chipgraph/packs/assist/audit/__init__.py +383 -0
  180. chipgraph/packs/assist/pack.yml +13 -0
  181. chipgraph/packs/assist/triage/__init__.py +64 -0
  182. chipgraph/packs/assist/triage/contract.py +94 -0
  183. chipgraph/packs/assist/triage/parse.py +371 -0
  184. chipgraph/packs/assist/triage/question.py +384 -0
  185. chipgraph/packs/assist/triage/report.py +160 -0
  186. chipgraph/packs/assist/triage/rules.py +447 -0
  187. chipgraph/packs/assist/triage/run.py +224 -0
  188. chipgraph/packs/digital_rtl/__init__.py +19 -0
  189. chipgraph/packs/digital_rtl/layout.py +144 -0
  190. chipgraph/packs/digital_rtl/pack.yml +25 -0
  191. chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
  192. chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
  193. chipgraph/packs/digital_rtl/plan/context.py +82 -0
  194. chipgraph/packs/digital_rtl/plan/expand.py +94 -0
  195. chipgraph/packs/digital_rtl/plan/model.py +150 -0
  196. chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
  197. chipgraph/packs/digital_rtl/plan/show.py +176 -0
  198. chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
  199. chipgraph/packs/digital_rtl/pr/body.py +165 -0
  200. chipgraph/packs/digital_rtl/pr/gate.py +110 -0
  201. chipgraph/packs/digital_rtl/pr/open.py +397 -0
  202. chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
  203. chipgraph/packs/digital_rtl/rtl/context.py +272 -0
  204. chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
  205. chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
  206. chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
  207. chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
  208. chipgraph/packs/digital_rtl/rules/review.yml +31 -0
  209. chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
  210. chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
  211. chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
  212. chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
  213. chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
  214. chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
  215. chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
  216. chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
  217. chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
  218. chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
  219. chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
  220. chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
  221. chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
  222. chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
  223. chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
  224. chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
  225. chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
  226. chipgraph/packs/dv/__init__.py +15 -0
  227. chipgraph/packs/dv/declaration.py +392 -0
  228. chipgraph/packs/dv/interface.py +596 -0
  229. chipgraph/packs/dv/pack.yml +14 -0
  230. chipgraph/packs/dv/rules/tb_module.yml +42 -0
  231. chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
  232. chipgraph/packs/spec_core/__init__.py +9 -0
  233. chipgraph/packs/spec_core/extract/__init__.py +12 -0
  234. chipgraph/packs/spec_core/extract/_markdown.py +371 -0
  235. chipgraph/packs/spec_core/extract/_text.py +116 -0
  236. chipgraph/packs/spec_core/extract/mas.py +975 -0
  237. chipgraph/packs/spec_core/gen/__init__.py +11 -0
  238. chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
  239. chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
  240. chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
  241. chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
  242. chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
  243. chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
  244. chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
  245. chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
  246. chipgraph/packs/spec_core/pack.yml +6 -0
  247. chipgraph/py.typed +0 -0
  248. chipgraph-0.1.0.dist-info/METADATA +53 -0
  249. chipgraph-0.1.0.dist-info/RECORD +252 -0
  250. chipgraph-0.1.0.dist-info/WHEEL +4 -0
  251. chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
  252. chipgraph-0.1.0.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,238 @@
1
+ """The run journal: an append-only JSONL log of `Event`s, and the state replayed from it.
2
+
3
+ DESIGN.md 6.2: "Journal chi ghi them. State duoc dung lai tu journal cong voi hash cua
4
+ artifact." (The journal is append-only. State is rebuilt from the journal plus artifact
5
+ hashes.) This module implements the journal itself and replay into a `RunState`; it does
6
+ not know about artifact hashes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import os
13
+ from pathlib import Path
14
+ from typing import Literal
15
+
16
+ from pydantic import BaseModel, ConfigDict, Field
17
+
18
+ from chipgraph.core.contracts.event import Event, RunManifest
19
+ from chipgraph.core.contracts.types import FailureLabel
20
+ from chipgraph.core.state.layout import StateLayout
21
+
22
+ RuleStatus = Literal["pending", "running", "done", "failed", "waiting_gate"]
23
+ """The status of a single rule instance, as derived from the journal."""
24
+
25
+ _EVENTS_WITHOUT_RULE_INSTANCE = frozenset({"run_start", "run_stop", "tool_call"})
26
+
27
+
28
+ class JournalError(Exception):
29
+ """The journal is inconsistent: a bad sequence number, run id, or a corrupt line."""
30
+
31
+
32
+ class RunState(BaseModel):
33
+ """The state of a run, rebuilt by replaying its journal."""
34
+
35
+ model_config = ConfigDict(frozen=True)
36
+
37
+ run_id: str
38
+ started: bool = False
39
+ stopped: bool = False
40
+ rules: dict[str, RuleStatus] = Field(default_factory=dict)
41
+ failures: dict[str, FailureLabel | None] = Field(default_factory=dict)
42
+ last_seq: int = -1
43
+
44
+
45
+ class JournalRead(BaseModel):
46
+ """The result of reading a journal file: the events found, and whether it was truncated."""
47
+
48
+ model_config = ConfigDict(frozen=True)
49
+
50
+ events: tuple[Event, ...] = ()
51
+ truncated: bool = False
52
+
53
+
54
+ class Journal:
55
+ """An append-only JSONL journal of `Event`s for a single run.
56
+
57
+ Each line is one `Event`, written with `model_dump_json()`. `append` fsyncs after every
58
+ write, so a killed process loses at most the in-flight line. On construction, `repair()`
59
+ removes any trailing partial line left by a previous crash, so the next `append` sees a
60
+ clean, complete file.
61
+ """
62
+
63
+ def __init__(self, path: Path) -> None:
64
+ self.path = path
65
+ self.path.parent.mkdir(parents=True, exist_ok=True)
66
+ self._run_id: str | None = None
67
+ self._last_seq: int = -1
68
+ self.repair()
69
+ self._load_tail_state()
70
+
71
+ def _load_tail_state(self) -> None:
72
+ result = read(self.path)
73
+ if result.events:
74
+ self._run_id = result.events[-1].run_id
75
+ self._last_seq = result.events[-1].seq
76
+
77
+ def repair(self) -> None:
78
+ """Truncate the file to the end of its last complete, valid JSON line, if any.
79
+
80
+ This undoes a crash mid-write: a missing trailing newline, or a last line that is
81
+ not valid JSON. Complete lines before that point are untouched.
82
+ """
83
+ if not self.path.exists():
84
+ return
85
+ raw = self.path.read_bytes()
86
+ if not raw:
87
+ return
88
+ lines = raw.split(b"\n")
89
+ # split(b"\n") on a file ending in "\n" yields a trailing empty element.
90
+ if lines and lines[-1] == b"":
91
+ lines.pop()
92
+ if not lines:
93
+ return
94
+ last = lines[-1]
95
+ ends_with_newline = raw.endswith(b"\n")
96
+ valid_last = False
97
+ if ends_with_newline:
98
+ try:
99
+ json.loads(last)
100
+ valid_last = True
101
+ except json.JSONDecodeError:
102
+ valid_last = False
103
+ if valid_last:
104
+ return
105
+ # Truncate off the partial trailing line, keeping all prior complete lines.
106
+ good_len = sum(len(line) + 1 for line in lines[:-1])
107
+ with open(self.path, "r+b") as f:
108
+ f.truncate(good_len)
109
+ f.flush()
110
+ os.fsync(f.fileno())
111
+
112
+ def next_seq(self) -> int:
113
+ """The `seq` value the next appended event must use."""
114
+ return self._last_seq + 1
115
+
116
+ def append(self, event: Event) -> None:
117
+ """Append one event, enforcing strictly increasing `seq` and a stable `run_id`."""
118
+ if self._run_id is not None and event.run_id != self._run_id:
119
+ raise JournalError(
120
+ f"journal {self.path}: run_id mismatch: expected {self._run_id!r}, "
121
+ f"got {event.run_id!r}"
122
+ )
123
+ expected_seq = self.next_seq()
124
+ if event.seq != expected_seq:
125
+ raise JournalError(f"journal {self.path}: expected seq {expected_seq}, got {event.seq}")
126
+ line = event.model_dump_json()
127
+ with open(self.path, "a", encoding="utf-8") as f:
128
+ f.write(line + "\n")
129
+ f.flush()
130
+ os.fsync(f.fileno())
131
+ self._run_id = event.run_id
132
+ self._last_seq = event.seq
133
+
134
+
135
+ def read(path: Path) -> JournalRead:
136
+ """Read every complete event line from a journal file.
137
+
138
+ A truncated last line (no trailing newline, or invalid JSON, on the final line only)
139
+ is ignored and reported via `JournalRead.truncated`. An invalid line that is not the
140
+ last one raises `JournalError` naming the 1-based line number.
141
+ """
142
+ if not path.exists():
143
+ return JournalRead()
144
+ raw = path.read_text(encoding="utf-8")
145
+ if raw == "":
146
+ return JournalRead()
147
+ ends_with_newline = raw.endswith("\n")
148
+ lines = raw.split("\n")
149
+ if lines and lines[-1] == "":
150
+ lines.pop()
151
+ if not lines:
152
+ return JournalRead()
153
+
154
+ events: list[Event] = []
155
+ truncated = False
156
+ n = len(lines)
157
+ for i, line in enumerate(lines):
158
+ line_no = i + 1
159
+ is_last = i == n - 1
160
+ try:
161
+ event = Event.model_validate(json.loads(line))
162
+ except Exception as exc:
163
+ if is_last:
164
+ truncated = True
165
+ break
166
+ raise JournalError(f"journal {path}: invalid line at line {line_no}") from exc
167
+ if is_last and not ends_with_newline:
168
+ # Valid JSON, but the file was cut off before its trailing newline: treat as
169
+ # a partial write and drop it too, so repair() and append() agree on the cut.
170
+ truncated = True
171
+ break
172
+ events.append(event)
173
+ return JournalRead(events=tuple(events), truncated=truncated)
174
+
175
+
176
+ def replay(events: list[Event] | tuple[Event, ...]) -> RunState:
177
+ """Rebuild a `RunState` by folding a sequence of events in order."""
178
+ run_id = ""
179
+ started = False
180
+ stopped = False
181
+ rules: dict[str, RuleStatus] = {}
182
+ failures: dict[str, FailureLabel | None] = {}
183
+ last_seq = -1
184
+
185
+ for event in events:
186
+ run_id = event.run_id
187
+ last_seq = event.seq
188
+
189
+ if event.type == "run_start":
190
+ started = True
191
+ continue
192
+ if event.type == "run_stop":
193
+ stopped = True
194
+ continue
195
+ if event.type == "tool_call":
196
+ continue
197
+
198
+ rule_instance = event.rule_instance
199
+ if rule_instance is None:
200
+ # Lenient: ignore events that should carry a rule_instance but don't.
201
+ continue
202
+
203
+ if event.type == "rule_start":
204
+ rules[rule_instance] = "running"
205
+ elif event.type == "rule_done":
206
+ rules[rule_instance] = "done"
207
+ elif event.type == "rule_fail":
208
+ rules[rule_instance] = "failed"
209
+ failures[rule_instance] = event.failure_label
210
+ elif event.type == "gate_wait":
211
+ rules[rule_instance] = "waiting_gate"
212
+ elif event.type == "gate_decision":
213
+ rules[rule_instance] = "pending"
214
+ # check_result, agent_turn: recorded in the journal but do not change rule status.
215
+
216
+ return RunState(
217
+ run_id=run_id,
218
+ started=started,
219
+ stopped=stopped,
220
+ rules=rules,
221
+ failures=failures,
222
+ last_seq=last_seq,
223
+ )
224
+
225
+
226
+ def write_manifest(layout: StateLayout, manifest: RunManifest) -> None:
227
+ """Atomically write a run's manifest: write to a tmp file, then `os.replace`."""
228
+ path = layout.manifest(manifest.run_id)
229
+ path.parent.mkdir(parents=True, exist_ok=True)
230
+ tmp_path = path.with_suffix(path.suffix + ".tmp")
231
+ tmp_path.write_text(manifest.model_dump_json(indent=2), encoding="utf-8")
232
+ os.replace(tmp_path, path)
233
+
234
+
235
+ def read_manifest(layout: StateLayout, run_id: str) -> RunManifest:
236
+ """Read a run's manifest."""
237
+ path = layout.manifest(run_id)
238
+ return RunManifest.model_validate_json(path.read_text(encoding="utf-8"))
@@ -0,0 +1,82 @@
1
+ """Where run state lives on disk (DESIGN.md 6.1): ``<repo>/.chipgraph/state/``.
2
+
3
+ This is machine-local, gitignored state: journals, run manifests, locks, cache and
4
+ idempotency results. It is distinct from ``.chipgraph/decisions/``, which holds
5
+ human-auditable, per-decision files that a team may choose to commit (DESIGN.md 6.1).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import secrets
11
+ from dataclasses import dataclass
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ _RUN_ID_RANDOM_HEX_CHARS = 6
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class StateLayout:
20
+ """Paths for a project's local run state, rooted at the project repo root."""
21
+
22
+ root: Path
23
+
24
+ @property
25
+ def state_dir(self) -> Path:
26
+ """The root of all machine-local run state: ``<root>/.chipgraph/state``."""
27
+ return self.root / ".chipgraph" / "state"
28
+
29
+ @property
30
+ def runs_dir(self) -> Path:
31
+ """Where each run gets its own subdirectory."""
32
+ return self.state_dir / "runs"
33
+
34
+ def run_dir(self, run_id: str) -> Path:
35
+ """The directory for a single run's journal, manifest and results."""
36
+ return self.runs_dir / run_id
37
+
38
+ def journal(self, run_id: str) -> Path:
39
+ """The append-only JSONL journal file for a run."""
40
+ return self.run_dir(run_id) / "journal.jsonl"
41
+
42
+ def manifest(self, run_id: str) -> Path:
43
+ """The manifest file for a run."""
44
+ return self.run_dir(run_id) / "manifest.json"
45
+
46
+ @property
47
+ def locks_dir(self) -> Path:
48
+ """Where per-block lock files live."""
49
+ return self.state_dir / "locks"
50
+
51
+ @property
52
+ def cache_dir(self) -> Path:
53
+ """Where cached, regenerable data lives (not the idempotency store)."""
54
+ return self.state_dir / "cache"
55
+
56
+ @property
57
+ def tmp_dir(self) -> Path:
58
+ """Scratch space for disposable work (e.g. fan-out workspaces): ``<state>/tmp``.
59
+
60
+ Under the local backend this is inside the repo's working tree, so callers that
61
+ must never write there (fan-out) use the system temp directory instead.
62
+ """
63
+ return self.state_dir / "tmp"
64
+
65
+ @property
66
+ def decisions_dir(self) -> Path:
67
+ """Human-auditable gate/waiver decisions: ``<root>/.chipgraph/decisions``.
68
+
69
+ This is *not* part of run state: it is meant to be readable, hash-pinned, and
70
+ optionally committed to the project repo (DESIGN.md 6.1).
71
+ """
72
+ return self.root / ".chipgraph" / "decisions"
73
+
74
+
75
+ def new_run_id() -> str:
76
+ """A sortable, unique run id: UTC timestamp plus a short random suffix.
77
+
78
+ Format: ``YYYYMMDDTHHMMSSZ-<6 hex chars>``, e.g. ``20260928T120000Z-a1b2c3``.
79
+ """
80
+ ts = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
81
+ suffix = secrets.token_hex(_RUN_ID_RANDOM_HEX_CHARS // 2)
82
+ return f"{ts}-{suffix}"
@@ -0,0 +1,195 @@
1
+ """Per-block locks: DESIGN.md 6.2, "một block chỉ có một run ghi tại một thời điểm".
2
+
3
+ A lock is a small JSON file created atomically (`O_CREAT | O_EXCL`) under
4
+ `StateLayout.locks_dir`. It records the owner, the process id and host that took it, and
5
+ when. A lock whose owning process is no longer alive on the same host — or that has been
6
+ held past `stale_after_s` — is considered abandoned and may be taken over.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import contextlib
12
+ import json
13
+ import os
14
+ import re
15
+ import socket
16
+ from dataclasses import dataclass
17
+ from datetime import UTC, datetime
18
+ from pathlib import Path
19
+ from types import TracebackType
20
+ from typing import Self
21
+
22
+ from chipgraph.core.state.layout import StateLayout
23
+
24
+ _BLOCK_NAME_RE = re.compile(r"^[a-z0-9][a-z0-9_-]*$")
25
+ _DEFAULT_STALE_AFTER_S = 12 * 60 * 60
26
+
27
+
28
+ class LockHeldError(Exception):
29
+ """Another live owner already holds this block's lock."""
30
+
31
+ def __init__(self, block: str, owner: str) -> None:
32
+ super().__init__(f"block {block!r} is locked by {owner!r}")
33
+ self.block = block
34
+ self.owner = owner
35
+
36
+
37
+ @dataclass(frozen=True, slots=True)
38
+ class _LockRecord:
39
+ owner: str
40
+ pid: int
41
+ host: str
42
+ since: str
43
+
44
+
45
+ def _pid_alive(pid: int) -> bool:
46
+ try:
47
+ os.kill(pid, 0)
48
+ except ProcessLookupError:
49
+ return False
50
+ except PermissionError:
51
+ # Process exists but we can't signal it: treat as alive.
52
+ return True
53
+ except OSError:
54
+ return False
55
+ return True
56
+
57
+
58
+ def _read_record(path: Path) -> _LockRecord:
59
+ data = json.loads(path.read_text(encoding="utf-8"))
60
+ return _LockRecord(owner=data["owner"], pid=data["pid"], host=data["host"], since=data["since"])
61
+
62
+
63
+ def _write_record_atomic(path: Path, record: _LockRecord) -> None:
64
+ fd = os.open(str(path), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o644)
65
+ try:
66
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
67
+ json.dump(
68
+ {
69
+ "owner": record.owner,
70
+ "pid": record.pid,
71
+ "host": record.host,
72
+ "since": record.since,
73
+ },
74
+ f,
75
+ )
76
+ except BaseException:
77
+ with contextlib.suppress(OSError):
78
+ path.unlink()
79
+ raise
80
+
81
+
82
+ class BlockLock:
83
+ """A context manager that holds an exclusive lock on `block` for the duration of a `with`.
84
+
85
+ Usage::
86
+
87
+ with BlockLock(layout, block="timer", owner="run-123") as lock:
88
+ ... # only one live owner may be inside this block at a time
89
+ lock.took_over_stale # True if a dead/stale lock was reclaimed
90
+ """
91
+
92
+ def __init__(
93
+ self,
94
+ layout: StateLayout,
95
+ block: str,
96
+ owner: str,
97
+ *,
98
+ stale_after_s: float = _DEFAULT_STALE_AFTER_S,
99
+ ) -> None:
100
+ if not _BLOCK_NAME_RE.match(block):
101
+ raise ValueError(f"invalid block name: {block!r}")
102
+ self.layout = layout
103
+ self.block = block
104
+ self.owner = owner
105
+ self.stale_after_s = stale_after_s
106
+ self.took_over_stale = False
107
+ self._path = layout.locks_dir / f"{block}.lock"
108
+ self._acquired = False
109
+
110
+ @property
111
+ def path(self) -> Path:
112
+ return self._path
113
+
114
+ def acquire(self) -> None:
115
+ self.layout.locks_dir.mkdir(parents=True, exist_ok=True)
116
+ record = _LockRecord(
117
+ owner=self.owner,
118
+ pid=os.getpid(),
119
+ host=socket.gethostname(),
120
+ since=datetime.now(UTC).isoformat(),
121
+ )
122
+ try:
123
+ _write_record_atomic(self._path, record)
124
+ except FileExistsError:
125
+ self._resolve_conflict(record)
126
+ return
127
+ self._acquired = True
128
+ self.took_over_stale = False
129
+
130
+ def _resolve_conflict(self, new_record: _LockRecord) -> None:
131
+ try:
132
+ existing = _read_record(self._path)
133
+ except (OSError, json.JSONDecodeError, KeyError):
134
+ # Unreadable/corrupt lock file: treat as stale and take it over.
135
+ self._take_over(new_record)
136
+ return
137
+
138
+ same_host = existing.host == new_record.host
139
+ alive = same_host and _pid_alive(existing.pid)
140
+ if alive:
141
+ stale = self._is_stale(existing)
142
+ if not stale:
143
+ raise LockHeldError(self.block, existing.owner)
144
+ self._take_over(new_record)
145
+
146
+ def _is_stale(self, existing: _LockRecord) -> bool:
147
+ try:
148
+ since = datetime.fromisoformat(existing.since)
149
+ except ValueError:
150
+ return True
151
+ age_s = (datetime.now(UTC) - since).total_seconds()
152
+ return age_s > self.stale_after_s
153
+
154
+ def _take_over(self, new_record: _LockRecord) -> None:
155
+ tmp_path = self._path.with_suffix(".lock.tmp")
156
+ with open(tmp_path, "w", encoding="utf-8") as f:
157
+ json.dump(
158
+ {
159
+ "owner": new_record.owner,
160
+ "pid": new_record.pid,
161
+ "host": new_record.host,
162
+ "since": new_record.since,
163
+ },
164
+ f,
165
+ )
166
+ os.replace(tmp_path, self._path)
167
+ self._acquired = True
168
+ self.took_over_stale = True
169
+
170
+ def release(self) -> None:
171
+ if not self._acquired:
172
+ return
173
+ try:
174
+ current = _read_record(self._path)
175
+ except (OSError, json.JSONDecodeError, KeyError):
176
+ return
177
+ if current.owner == self.owner and current.pid == os.getpid():
178
+ with contextlib.suppress(FileNotFoundError):
179
+ self._path.unlink()
180
+ self._acquired = False
181
+
182
+ def __enter__(self) -> Self:
183
+ self.acquire()
184
+ return self
185
+
186
+ def __exit__(
187
+ self,
188
+ exc_type: type[BaseException] | None,
189
+ exc: BaseException | None,
190
+ tb: TracebackType | None,
191
+ ) -> None:
192
+ self.release()
193
+
194
+
195
+ __all__ = ["BlockLock", "LockHeldError"]