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.
- chipgraph/__init__.py +7 -0
- chipgraph/adapters/__init__.py +1 -0
- chipgraph/adapters/format/__init__.py +6 -0
- chipgraph/adapters/format/__main__.py +16 -0
- chipgraph/adapters/format/_yaml_lines.py +54 -0
- chipgraph/adapters/format/chip_yaml.py +323 -0
- chipgraph/adapters/format/qsoc_contract.py +246 -0
- chipgraph/adapters/llm/__init__.py +58 -0
- chipgraph/adapters/llm/_budget.py +173 -0
- chipgraph/adapters/llm/_errors.py +50 -0
- chipgraph/adapters/llm/_nda.py +30 -0
- chipgraph/adapters/llm/_retry.py +85 -0
- chipgraph/adapters/llm/anthropic.py +247 -0
- chipgraph/adapters/llm/decide_backend.py +179 -0
- chipgraph/adapters/llm/fake.py +87 -0
- chipgraph/adapters/llm/guard.py +68 -0
- chipgraph/adapters/parser/__init__.py +22 -0
- chipgraph/adapters/parser/cocotb.py +304 -0
- chipgraph/adapters/parser/generic_regex.py +75 -0
- chipgraph/adapters/parser/verible.py +55 -0
- chipgraph/adapters/parser/verilator.py +46 -0
- chipgraph/adapters/review/__init__.py +7 -0
- chipgraph/adapters/review/file.py +123 -0
- chipgraph/adapters/review/github.py +886 -0
- chipgraph/adapters/runner/__init__.py +7 -0
- chipgraph/adapters/runner/local.py +122 -0
- chipgraph/adapters/runtime/__init__.py +5 -0
- chipgraph/adapters/runtime/claude_code/__init__.py +24 -0
- chipgraph/adapters/runtime/claude_code/agents.py +158 -0
- chipgraph/adapters/runtime/claude_code/decisions.py +280 -0
- chipgraph/adapters/runtime/claude_code/guarded.py +310 -0
- chipgraph/adapters/runtime/claude_code/isolation.py +482 -0
- chipgraph/adapters/runtime/claude_code/loop.py +342 -0
- chipgraph/adapters/runtime/claude_code/previous.py +419 -0
- chipgraph/adapters/runtime/claude_code/review.py +492 -0
- chipgraph/adapters/runtime/claude_code/runtime.py +192 -0
- chipgraph/adapters/runtime/claude_code/service.py +2285 -0
- chipgraph/adapters/tool/__init__.py +7 -0
- chipgraph/adapters/tool/cmd.py +229 -0
- chipgraph/adapters/tool/edalize.py +773 -0
- chipgraph/adapters/tool/filelist.py +259 -0
- chipgraph/adapters/tool/pyslang.py +485 -0
- chipgraph/adapters/vcs/__init__.py +1 -0
- chipgraph/adapters/vcs/git.py +521 -0
- chipgraph/app/__init__.py +10 -0
- chipgraph/app/baseline.py +339 -0
- chipgraph/app/build.py +332 -0
- chipgraph/app/checks.py +202 -0
- chipgraph/app/context.py +191 -0
- chipgraph/app/errors.py +11 -0
- chipgraph/app/executors.py +79 -0
- chipgraph/app/findings.py +203 -0
- chipgraph/app/ingest.py +523 -0
- chipgraph/checks/__init__.py +52 -0
- chipgraph/checks/_cdc_analyze.py +272 -0
- chipgraph/checks/_cdc_elaborate.py +455 -0
- chipgraph/checks/_common.py +268 -0
- chipgraph/checks/_cross.py +96 -0
- chipgraph/checks/_model.py +64 -0
- chipgraph/checks/_naming_pyslang.py +235 -0
- chipgraph/checks/_naming_rules.py +127 -0
- chipgraph/checks/_xref_syntax.py +260 -0
- chipgraph/checks/cdc_struct.py +235 -0
- chipgraph/checks/connect.py +353 -0
- chipgraph/checks/cross_chip.py +454 -0
- chipgraph/checks/duplicate.py +153 -0
- chipgraph/checks/filelist.py +323 -0
- chipgraph/checks/format.py +268 -0
- chipgraph/checks/generated.py +151 -0
- chipgraph/checks/hardcode.py +233 -0
- chipgraph/checks/layout.py +155 -0
- chipgraph/checks/naming.py +311 -0
- chipgraph/checks/plan_check.py +901 -0
- chipgraph/checks/ports_diff.py +203 -0
- chipgraph/checks/spec_schema.py +277 -0
- chipgraph/checks/style.py +910 -0
- chipgraph/checks/tb_static.py +599 -0
- chipgraph/checks/trace.py +224 -0
- chipgraph/checks/vendor_guard.py +369 -0
- chipgraph/checks/wrap_check.py +486 -0
- chipgraph/cli/__init__.py +1688 -0
- chipgraph/cli/next_steps.py +137 -0
- chipgraph/core/__init__.py +1 -0
- chipgraph/core/config/__init__.py +93 -0
- chipgraph/core/config/defaults.py +14 -0
- chipgraph/core/config/errors.py +32 -0
- chipgraph/core/config/loader.py +766 -0
- chipgraph/core/config/models.py +845 -0
- chipgraph/core/config/templates.py +340 -0
- chipgraph/core/contracts/__init__.py +75 -0
- chipgraph/core/contracts/_paths.py +26 -0
- chipgraph/core/contracts/agent.py +49 -0
- chipgraph/core/contracts/artifact.py +68 -0
- chipgraph/core/contracts/check.py +76 -0
- chipgraph/core/contracts/event.py +70 -0
- chipgraph/core/contracts/export.py +128 -0
- chipgraph/core/contracts/finding.py +171 -0
- chipgraph/core/contracts/gate.py +50 -0
- chipgraph/core/contracts/rule.py +177 -0
- chipgraph/core/contracts/types.py +45 -0
- chipgraph/core/engine/__init__.py +70 -0
- chipgraph/core/engine/agent_rule.py +1362 -0
- chipgraph/core/engine/baseline.py +283 -0
- chipgraph/core/engine/decide.py +462 -0
- chipgraph/core/engine/fanout.py +1358 -0
- chipgraph/core/engine/gate.py +170 -0
- chipgraph/core/engine/graph.py +647 -0
- chipgraph/core/engine/records.py +60 -0
- chipgraph/core/engine/rules.py +92 -0
- chipgraph/core/engine/scheduler.py +637 -0
- chipgraph/core/engine/select.py +1239 -0
- chipgraph/core/engine/targets.py +342 -0
- chipgraph/core/model/__init__.py +98 -0
- chipgraph/core/model/entities.py +244 -0
- chipgraph/core/model/export.py +16 -0
- chipgraph/core/model/ingest.py +693 -0
- chipgraph/core/model/json_value.py +4 -0
- chipgraph/core/model/keys.py +95 -0
- chipgraph/core/model/model.py +153 -0
- chipgraph/core/model/provenance.py +27 -0
- chipgraph/core/model/query.py +583 -0
- chipgraph/core/model/registry.py +80 -0
- chipgraph/core/model/relations.py +61 -0
- chipgraph/core/model/store.py +279 -0
- chipgraph/core/plugin_api/__init__.py +81 -0
- chipgraph/core/plugin_api/local.py +167 -0
- chipgraph/core/plugin_api/pack.py +159 -0
- chipgraph/core/plugin_api/protocols.py +177 -0
- chipgraph/core/plugin_api/registry.py +111 -0
- chipgraph/core/plugin_api/types.py +94 -0
- chipgraph/core/runtime/__init__.py +41 -0
- chipgraph/core/runtime/executor.py +79 -0
- chipgraph/core/runtime/queue.py +565 -0
- chipgraph/core/runtime/roles/__init__.py +91 -0
- chipgraph/core/runtime/roles/data/author.md +41 -0
- chipgraph/core/runtime/roles/data/critic.md +56 -0
- chipgraph/core/runtime/roles/data/planner.md +38 -0
- chipgraph/core/runtime/roles/data/researcher.md +37 -0
- chipgraph/core/runtime/roles/data/rtl-author.md +64 -0
- chipgraph/core/runtime/roles/data/tb-author.md +48 -0
- chipgraph/core/runtime/roles/data/triage.md +34 -0
- chipgraph/core/runtime/roles/policy.py +299 -0
- chipgraph/core/runtime/roles/registry.py +52 -0
- chipgraph/core/runtime/roles/review.py +275 -0
- chipgraph/core/runtime/roles/schema.py +82 -0
- chipgraph/core/runtime/roles/skills.py +134 -0
- chipgraph/core/runtime/roles/spec.py +232 -0
- chipgraph/core/state/__init__.py +1 -0
- chipgraph/core/state/artifacts.py +156 -0
- chipgraph/core/state/backend.py +117 -0
- chipgraph/core/state/findings.py +151 -0
- chipgraph/core/state/handoff.py +411 -0
- chipgraph/core/state/idempotency.py +59 -0
- chipgraph/core/state/journal.py +238 -0
- chipgraph/core/state/layout.py +82 -0
- chipgraph/core/state/lock.py +195 -0
- chipgraph/core/state/trace.py +225 -0
- chipgraph/learn/__init__.py +96 -0
- chipgraph/learn/draft.py +187 -0
- chipgraph/learn/infer.py +580 -0
- chipgraph/learn/models.py +145 -0
- chipgraph/learn/try_run.py +228 -0
- chipgraph/mcp/__init__.py +12 -0
- chipgraph/mcp/model_tools.py +167 -0
- chipgraph/mcp/server.py +787 -0
- chipgraph/orgs/qnsc/naming-v1.yml +113 -0
- chipgraph/orgs/qnsc/profile.yml +5 -0
- chipgraph/packs/__init__.py +8 -0
- chipgraph/packs/assist/__init__.py +16 -0
- chipgraph/packs/assist/ask/__init__.py +58 -0
- chipgraph/packs/assist/ask/_db.py +57 -0
- chipgraph/packs/assist/ask/_project.py +230 -0
- chipgraph/packs/assist/ask/answer.py +266 -0
- chipgraph/packs/assist/ask/check.py +182 -0
- chipgraph/packs/assist/ask/contract.py +115 -0
- chipgraph/packs/assist/ask/documents.py +157 -0
- chipgraph/packs/assist/ask/fts.py +80 -0
- chipgraph/packs/assist/ask/retrieve.py +331 -0
- chipgraph/packs/assist/audit/__init__.py +383 -0
- chipgraph/packs/assist/pack.yml +13 -0
- chipgraph/packs/assist/triage/__init__.py +64 -0
- chipgraph/packs/assist/triage/contract.py +94 -0
- chipgraph/packs/assist/triage/parse.py +371 -0
- chipgraph/packs/assist/triage/question.py +384 -0
- chipgraph/packs/assist/triage/report.py +160 -0
- chipgraph/packs/assist/triage/rules.py +447 -0
- chipgraph/packs/assist/triage/run.py +224 -0
- chipgraph/packs/digital_rtl/__init__.py +19 -0
- chipgraph/packs/digital_rtl/layout.py +144 -0
- chipgraph/packs/digital_rtl/pack.yml +25 -0
- chipgraph/packs/digital_rtl/plan/__init__.py +34 -0
- chipgraph/packs/digital_rtl/plan/__main__.py +16 -0
- chipgraph/packs/digital_rtl/plan/context.py +82 -0
- chipgraph/packs/digital_rtl/plan/expand.py +94 -0
- chipgraph/packs/digital_rtl/plan/model.py +150 -0
- chipgraph/packs/digital_rtl/plan/resolver.py +400 -0
- chipgraph/packs/digital_rtl/plan/show.py +176 -0
- chipgraph/packs/digital_rtl/pr/__init__.py +11 -0
- chipgraph/packs/digital_rtl/pr/body.py +165 -0
- chipgraph/packs/digital_rtl/pr/gate.py +110 -0
- chipgraph/packs/digital_rtl/pr/open.py +397 -0
- chipgraph/packs/digital_rtl/rtl/__init__.py +9 -0
- chipgraph/packs/digital_rtl/rtl/context.py +272 -0
- chipgraph/packs/digital_rtl/rules/open_pr.yml +30 -0
- chipgraph/packs/digital_rtl/rules/plan.yml +28 -0
- chipgraph/packs/digital_rtl/rules/plan_expand.yml +29 -0
- chipgraph/packs/digital_rtl/rules/pr_gate.yml +27 -0
- chipgraph/packs/digital_rtl/rules/review.yml +31 -0
- chipgraph/packs/digital_rtl/rules/rtl_module.yml +53 -0
- chipgraph/packs/digital_rtl/skills/lang-sv-rtl.md +58 -0
- chipgraph/packs/digital_rtl/skills/org-naming.md +29 -0
- chipgraph/packs/digital_rtl/skills/plan-modules.md +63 -0
- chipgraph/packs/digital_rtl/skills/review-diff.md +41 -0
- chipgraph/packs/digital_rtl/targets/plan.yml +11 -0
- chipgraph/packs/digital_rtl/targets/rtl.yml +20 -0
- chipgraph/packs/digital_rtl/wrapper/__init__.py +24 -0
- chipgraph/packs/digital_rtl/wrapper/common.py +171 -0
- chipgraph/packs/digital_rtl/wrapper/context.py +222 -0
- chipgraph/packs/digital_rtl/wrapper/emacs.py +626 -0
- chipgraph/packs/digital_rtl/wrapper/expansion.py +304 -0
- chipgraph/packs/digital_rtl/wrapper/lisp.py +504 -0
- chipgraph/packs/digital_rtl/wrapper/resolver.py +60 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper.yml +41 -0
- chipgraph/packs/digital_rtl/wrapper/rules/wrapper_gen.yml +35 -0
- chipgraph/packs/digital_rtl/wrapper/skills/wrapper-auto.md +120 -0
- chipgraph/packs/dv/__init__.py +15 -0
- chipgraph/packs/dv/declaration.py +392 -0
- chipgraph/packs/dv/interface.py +596 -0
- chipgraph/packs/dv/pack.yml +14 -0
- chipgraph/packs/dv/rules/tb_module.yml +42 -0
- chipgraph/packs/dv/skills/dv/cocotb.md +72 -0
- chipgraph/packs/spec_core/__init__.py +9 -0
- chipgraph/packs/spec_core/extract/__init__.py +12 -0
- chipgraph/packs/spec_core/extract/_markdown.py +371 -0
- chipgraph/packs/spec_core/extract/_text.py +116 -0
- chipgraph/packs/spec_core/extract/mas.py +975 -0
- chipgraph/packs/spec_core/gen/__init__.py +11 -0
- chipgraph/packs/spec_core/gen/agents_md/__init__.py +148 -0
- chipgraph/packs/spec_core/gen/agents_md/_context.py +239 -0
- chipgraph/packs/spec_core/gen/agents_md/_markers.py +131 -0
- chipgraph/packs/spec_core/gen/agents_md/agents_md.md.j2 +178 -0
- chipgraph/packs/spec_core/gen/diagram/__init__.py +110 -0
- chipgraph/packs/spec_core/gen/diagram/__main__.py +44 -0
- chipgraph/packs/spec_core/gen/diagram/_diagrams.py +403 -0
- chipgraph/packs/spec_core/gen/diagram/_scene.py +163 -0
- chipgraph/packs/spec_core/pack.yml +6 -0
- chipgraph/py.typed +0 -0
- chipgraph-0.1.0.dist-info/METADATA +53 -0
- chipgraph-0.1.0.dist-info/RECORD +252 -0
- chipgraph-0.1.0.dist-info/WHEEL +4 -0
- chipgraph-0.1.0.dist-info/entry_points.txt +61 -0
- 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"]
|