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,419 @@
1
+ """Outputs moved aside for authors without a read tool, and put back when a task fails.
2
+
3
+ Claude Code's `Write` refuses to overwrite a file the agent has not read first ("File has
4
+ not been read yet"). A role with no `read_files` capability (`tb-author`, `rtl-author`:
5
+ no Read, on purpose) could therefore never rewrite its own rejected output, nor replace
6
+ a file that existed before its task. So at every dispatch of such a role, `next_task`
7
+ moves each of the task's outputs that exists out of the working tree into the state
8
+ backend: the next `Write` creates a new file, which needs no prior Read. `get_context`
9
+ shows the author what was moved (`previous_outputs`), and a file that existed before
10
+ the task is put back, byte for byte and with its mode, when the task ends without being
11
+ accepted. Roles with a Read tool are not touched: they edit in place.
12
+
13
+ On disk, per task (the key is the task id's sha256, as in the queue)::
14
+
15
+ <state>/runtime/previous/<key>/cycle.json the budget cycle: since which
16
+ dispatch, the files that existed
17
+ before it (`before_task`), the
18
+ last attempt's file per path
19
+ <state>/runtime/previous/<key>/d<N>.json what dispatch N moved: path,
20
+ origin, sha256, mode, size
21
+ <state>/runtime/previous/<key>/d<N>/<path> a moved file, byte for byte
22
+ <state>/runtime/previous/<key>/e<N>.json at an ending after dispatch N:
23
+ <state>/runtime/previous/<key>/e<N>/<path> the last attempt a restore replaced
24
+
25
+ A slot name already taken (a crash after a move, before the dispatch was recorded) gets
26
+ a suffix: `d<N>.2`, `d<N>.3`. Nothing under `previous/` is ever deleted: a person can
27
+ read every version. A cycle opens at the first dispatch after the last ending and
28
+ closes when the task is accepted (the new files stay), stops (`budget_exhausted`,
29
+ `needs_human`: the `before_task` files are restored), is rewound while not dispatched
30
+ (restored), or when the dispatch that moved the files fails in the engine (every moved
31
+ file goes back as it was).
32
+
33
+ Only the task's own outputs (`allowed_writes`) are ever moved, written or restored; a
34
+ path that is absolute, climbs out of the project, or is a symlink is left alone.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import hashlib
40
+ import os
41
+ import stat
42
+ from collections.abc import Callable, Iterable, Sequence
43
+ from datetime import UTC, datetime
44
+ from pathlib import Path, PurePosixPath
45
+ from typing import Literal
46
+
47
+ from pydantic import AwareDatetime, BaseModel, ConfigDict, Field
48
+
49
+ from chipgraph.core.runtime import TaskQueue
50
+ from chipgraph.core.runtime.queue import state_key
51
+ from chipgraph.core.runtime.roles import RoleSpec
52
+
53
+ MAX_FILE_CHARS = 20_000
54
+ """Longest text of one moved file `get_context` shows; longer ones are cut."""
55
+
56
+ MAX_TOTAL_CHARS = 60_000
57
+ """Most text of moved files `get_context` shows for one task, over every file."""
58
+
59
+ Origin = Literal["before_task", "attempt"]
60
+ """Where a moved file came from: it existed before the task's cycle began, or an
61
+ earlier attempt of the task wrote it."""
62
+
63
+ Ending = Literal["accepted", "restored", "undone"]
64
+ """How a cycle closed: the task was accepted (the new files stay), it ended without
65
+ being accepted (the `before_task` files were put back), or the dispatch that opened it
66
+ failed in the engine (every moved file was put back)."""
67
+
68
+
69
+ def _now() -> datetime:
70
+ return datetime.now(UTC)
71
+
72
+
73
+ def moves_outputs(role: RoleSpec | None) -> bool:
74
+ """Whether a role's outputs are moved aside at dispatch: it writes files and has no
75
+ `read_files` capability (its harness cannot overwrite a file it did not read)."""
76
+ return role is not None and "write_outputs" in role.tools and "read_files" not in role.tools
77
+
78
+
79
+ class MovedFile(BaseModel):
80
+ """One file moved out of the working tree (or saved before a restore replaced it)."""
81
+
82
+ model_config = ConfigDict(extra="forbid", frozen=True)
83
+
84
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
85
+ path: str = Field(min_length=1, description="Repo-relative path of the file.")
86
+ origin: Origin = Field(description="'before_task' or 'attempt' (see `Origin`).")
87
+ slot: str = Field(min_length=1, description="The slot directory holding its bytes.")
88
+ sha256: str = Field(min_length=64, max_length=64, description="Content hash.")
89
+ mode: int = Field(ge=0, description="Permission bits (`stat.S_IMODE`).")
90
+ size: int = Field(ge=0, description="Size in bytes.")
91
+
92
+
93
+ class SlotRecord(BaseModel):
94
+ """`d<N>.json` or `e<N>.json`: the files one dispatch moved, or one ending saved."""
95
+
96
+ model_config = ConfigDict(extra="forbid", frozen=True)
97
+
98
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
99
+ task_id: str = Field(min_length=1, description="The task the files belong to.")
100
+ dispatch: int = Field(ge=0, description="The task's dispatch count it was taken at.")
101
+ kind: Literal["dispatch", "ending"] = Field(description="Moved at dispatch, or at an end.")
102
+ at: AwareDatetime = Field(default_factory=_now, description="When.")
103
+ files: tuple[MovedFile, ...] = Field(default=(), description="The files.")
104
+
105
+
106
+ class Cycle(BaseModel):
107
+ """`cycle.json`: the files moved in the task's current (or last) budget cycle."""
108
+
109
+ model_config = ConfigDict(extra="forbid", frozen=True)
110
+
111
+ schema_version: Literal[1] = Field(default=1, description="Schema version of this model.")
112
+ task_id: str = Field(min_length=1, description="The task.")
113
+ since: int = Field(ge=1, description="The dispatch that opened the cycle.")
114
+ originals: tuple[MovedFile, ...] = Field(
115
+ default=(), description="Files that existed before the cycle (`before_task`)."
116
+ )
117
+ last_attempts: tuple[MovedFile, ...] = Field(
118
+ default=(), description="Per path, the last attempt's file that was moved."
119
+ )
120
+ ended: Ending | None = Field(default=None, description="How it closed; None while open.")
121
+ ended_at: AwareDatetime | None = Field(default=None, description="When it closed.")
122
+
123
+ @property
124
+ def open(self) -> bool:
125
+ return self.ended is None
126
+
127
+
128
+ class MoveToken(BaseModel):
129
+ """What `PreviousOutputs.undo` needs to reverse one `move_aside`."""
130
+
131
+ model_config = ConfigDict(extra="forbid", frozen=True)
132
+
133
+ before: Cycle | None = Field(default=None, description="The cycle before the move.")
134
+ moved: tuple[MovedFile, ...] = Field(default=(), description="The files it moved.")
135
+
136
+
137
+ def _safe_rel(rel: str) -> bool:
138
+ """A repo-relative path with no `..`, not absolute, not empty."""
139
+ pure = PurePosixPath(rel)
140
+ return (
141
+ bool(rel) and not pure.is_absolute() and ".." not in pure.parts and rel == pure.as_posix()
142
+ )
143
+
144
+
145
+ def _write_bytes_atomic(path: Path, data: bytes, mode: int | None = None) -> None:
146
+ """Write `data` to `path` through a temporary file next to it, then set `mode`."""
147
+ path.parent.mkdir(parents=True, exist_ok=True)
148
+ tmp = path.with_name(f".{path.name}.{os.getpid()}.chipgraph-tmp")
149
+ try:
150
+ tmp.write_bytes(data)
151
+ if mode is not None:
152
+ os.chmod(tmp, mode)
153
+ os.replace(tmp, path)
154
+ except BaseException:
155
+ tmp.unlink(missing_ok=True)
156
+ raise
157
+
158
+
159
+ def _write_json(path: Path, model: BaseModel) -> None:
160
+ _write_bytes_atomic(path, (model.model_dump_json(indent=2) + "\n").encode("utf-8"))
161
+
162
+
163
+ class PreviousOutputs:
164
+ """The moved-aside outputs of one task (see the module docstring)."""
165
+
166
+ def __init__(self, queue: TaskQueue, task_id: str, root: Path | None = None) -> None:
167
+ self.task_id = task_id
168
+ self.root = (root or queue.layout.root).resolve()
169
+ self.dir = queue.runtime_dir / "previous" / state_key(task_id)
170
+
171
+ # --- reading -------------------------------------------------------------------
172
+
173
+ def cycle(self) -> Cycle | None:
174
+ """The task's current or last cycle, or None if nothing was ever moved for it."""
175
+ path = self.dir / "cycle.json"
176
+ if not path.is_file():
177
+ return None
178
+ return Cycle.model_validate_json(path.read_text(encoding="utf-8"))
179
+
180
+ def open_cycle(self) -> Cycle | None:
181
+ """The task's open cycle, or None."""
182
+ cycle = self.cycle()
183
+ return cycle if cycle is not None and cycle.open else None
184
+
185
+ def slot_file(self, moved: MovedFile) -> Path:
186
+ """Where the bytes of `moved` are kept."""
187
+ return self.dir / moved.slot / moved.path
188
+
189
+ def read_text(self, moved: MovedFile) -> str:
190
+ """The text of a moved file (undecodable bytes replaced)."""
191
+ return self.slot_file(moved).read_bytes().decode("utf-8", errors="replace")
192
+
193
+ # --- helpers -------------------------------------------------------------------
194
+
195
+ def _tree_path(self, rel: str) -> Path | None:
196
+ """The working-tree path of an output, or None if it must not be touched."""
197
+ if not _safe_rel(rel):
198
+ return None
199
+ full = self.root / rel
200
+ try:
201
+ full.parent.resolve().relative_to(self.root)
202
+ except (OSError, ValueError):
203
+ return None
204
+ return full
205
+
206
+ def _new_slot(self, prefix: str, dispatch: int) -> str:
207
+ name = f"{prefix}{dispatch}"
208
+ k = 1
209
+ while (self.dir / f"{name}.json").exists() or (self.dir / name).exists():
210
+ k += 1
211
+ name = f"{prefix}{dispatch}.{k}"
212
+ return name
213
+
214
+ def _save(self, full: Path, rel: str, slot: str, origin: Origin) -> MovedFile:
215
+ """Copy a working-tree file into `slot`, checked against its hash."""
216
+ data = full.read_bytes()
217
+ mode = stat.S_IMODE(full.stat().st_mode)
218
+ digest = hashlib.sha256(data).hexdigest()
219
+ target = self.dir / slot / rel
220
+ _write_bytes_atomic(target, data)
221
+ if hashlib.sha256(target.read_bytes()).hexdigest() != digest: # pragma: no cover
222
+ raise OSError(f"copy of {rel} in {target} does not match the original")
223
+ return MovedFile(
224
+ path=rel, origin=origin, slot=slot, sha256=digest, mode=mode, size=len(data)
225
+ )
226
+
227
+ def _put_back(self, moved: MovedFile) -> None:
228
+ """Write a kept file back to the working tree, byte for byte, with its mode."""
229
+ full = self._tree_path(moved.path)
230
+ if full is None: # pragma: no cover - never moved, then
231
+ return
232
+ data = self.slot_file(moved).read_bytes()
233
+ if hashlib.sha256(data).hexdigest() != moved.sha256:
234
+ raise OSError(f"the kept copy of {moved.path} ({self.slot_file(moved)}) is damaged")
235
+ _write_bytes_atomic(full, data, moved.mode)
236
+
237
+ def _close(self, cycle: Cycle, ending: Ending) -> None:
238
+ _write_json(
239
+ self.dir / "cycle.json", cycle.model_copy(update={"ended": ending, "ended_at": _now()})
240
+ )
241
+
242
+ # --- dispatch ------------------------------------------------------------------
243
+
244
+ def move_aside(self, outputs: Sequence[str], *, dispatch: int) -> MoveToken:
245
+ """Move each of `outputs` that is a file out of the tree, for dispatch `dispatch`.
246
+
247
+ Opens a cycle when none is open (the files found are `before_task`); otherwise
248
+ the files found are earlier attempts'. Every file is first copied into the state
249
+ backend and checked against its hash, the records are written, and only then
250
+ are the files removed. On any error the files already removed go back and the
251
+ cycle is as before. Returns what `undo` needs.
252
+ """
253
+ before = self.cycle()
254
+ origin: Origin
255
+ if before is not None and before.open:
256
+ cycle, origin = before, "attempt"
257
+ else:
258
+ cycle, origin = Cycle(task_id=self.task_id, since=dispatch), "before_task"
259
+ slot = self._new_slot("d", dispatch)
260
+ moved: list[MovedFile] = []
261
+ removed: list[MovedFile] = []
262
+ try:
263
+ found: list[tuple[Path, str]] = []
264
+ for rel in dict.fromkeys(outputs):
265
+ full = self._tree_path(rel)
266
+ if full is not None and full.is_file() and not full.is_symlink():
267
+ found.append((full, rel))
268
+ moved = [self._save(full, rel, slot, origin) for full, rel in found]
269
+ _write_json(
270
+ self.dir / f"{slot}.json",
271
+ SlotRecord(
272
+ task_id=self.task_id, dispatch=dispatch, kind="dispatch", files=tuple(moved)
273
+ ),
274
+ )
275
+ if origin == "before_task":
276
+ cycle = cycle.model_copy(update={"originals": tuple(moved)})
277
+ else:
278
+ paths = {m.path for m in moved}
279
+ last = tuple(m for m in cycle.last_attempts if m.path not in paths)
280
+ cycle = cycle.model_copy(update={"last_attempts": (*last, *moved)})
281
+ _write_json(self.dir / "cycle.json", cycle)
282
+ for (full, _), kept in zip(found, moved, strict=True):
283
+ full.unlink()
284
+ removed.append(kept)
285
+ except BaseException:
286
+ for kept in removed:
287
+ self._put_back(kept)
288
+ if before is not None:
289
+ _write_json(self.dir / "cycle.json", before)
290
+ else:
291
+ (self.dir / "cycle.json").unlink(missing_ok=True)
292
+ raise
293
+ return MoveToken(before=before, moved=tuple(moved))
294
+
295
+ def undo(self, token: MoveToken) -> None:
296
+ """The dispatch that moved `token`'s files failed: put them all back as they were.
297
+
298
+ The copies stay in the state backend; the cycle is as it was before the move
299
+ (a cycle this move opened is closed as `undone`).
300
+ """
301
+ for kept in token.moved:
302
+ self._put_back(kept)
303
+ if token.before is not None:
304
+ _write_json(self.dir / "cycle.json", token.before)
305
+ else:
306
+ cycle = self.cycle()
307
+ if cycle is not None:
308
+ self._close(cycle, "undone")
309
+
310
+ # --- endings -------------------------------------------------------------------
311
+
312
+ def accept(self) -> None:
313
+ """The task was accepted: close the cycle; the new files stay."""
314
+ cycle = self.open_cycle()
315
+ if cycle is not None:
316
+ self._close(cycle, "accepted")
317
+
318
+ def restore(self, *, dispatch: int) -> list[str]:
319
+ """The task ended without being accepted: put every `before_task` file back.
320
+
321
+ A working-tree file the restore replaces (the last attempt's) is kept first, in
322
+ an `e<dispatch>` slot. Returns the paths restored. A no-op without an open cycle.
323
+ """
324
+ cycle = self.open_cycle()
325
+ if cycle is None:
326
+ return []
327
+ replaced: list[MovedFile] = []
328
+ slot = self._new_slot("e", dispatch)
329
+ restored: list[str] = []
330
+ for original in cycle.originals:
331
+ full = self._tree_path(original.path)
332
+ if full is None: # pragma: no cover - never moved, then
333
+ continue
334
+ if (
335
+ full.is_file()
336
+ and not full.is_symlink()
337
+ and hashlib.sha256(full.read_bytes()).hexdigest() != original.sha256
338
+ ):
339
+ replaced.append(self._save(full, original.path, slot, "attempt"))
340
+ self._put_back(original)
341
+ restored.append(original.path)
342
+ if replaced:
343
+ _write_json(
344
+ self.dir / f"{slot}.json",
345
+ SlotRecord(
346
+ task_id=self.task_id, dispatch=dispatch, kind="ending", files=tuple(replaced)
347
+ ),
348
+ )
349
+ self._close(cycle, "restored")
350
+ return restored
351
+
352
+ # --- the context ---------------------------------------------------------------
353
+
354
+ def context(
355
+ self, outputs: Iterable[str], denied: Callable[[str], bool]
356
+ ) -> tuple[list[dict[str, object]], list[str]]:
357
+ """`get_context`'s `previous_outputs`, and the paths withheld from the role.
358
+
359
+ Per output of the open cycle: the last attempt's text (`content`) and, for a
360
+ file that existed before the task, its text (`existing_output`), each capped
361
+ (`MAX_FILE_CHARS`, `MAX_TOTAL_CHARS` over all, `truncated` when cut). A path
362
+ `denied` for the role is listed without any text.
363
+ """
364
+ cycle = self.open_cycle()
365
+ if cycle is None:
366
+ return [], []
367
+ attempts = {m.path: m for m in cycle.last_attempts}
368
+ originals = {m.path: m for m in cycle.originals}
369
+ budget = MAX_TOTAL_CHARS
370
+ entries: list[dict[str, object]] = []
371
+ withheld: list[str] = []
372
+
373
+ def text_of(moved: MovedFile) -> dict[str, object]:
374
+ nonlocal budget
375
+ text = self.read_text(moved)
376
+ cap = max(min(MAX_FILE_CHARS, budget), 0)
377
+ cut = len(text) > cap
378
+ text = text[:cap]
379
+ budget -= len(text)
380
+ return {"content": text, "truncated": cut}
381
+
382
+ for rel in dict.fromkeys(outputs):
383
+ attempt, original = attempts.get(rel), originals.get(rel)
384
+ if attempt is None and original is None:
385
+ continue
386
+ if denied(rel):
387
+ withheld.append(rel)
388
+ entries.append(
389
+ {
390
+ "path": rel,
391
+ "withheld": (
392
+ "a file of a kind your role must not see; its text is not shown"
393
+ ),
394
+ }
395
+ )
396
+ continue
397
+ entry: dict[str, object] = {"path": rel}
398
+ if attempt is not None:
399
+ entry["label"] = "your previous attempt (moved aside; write the file anew)"
400
+ entry.update(text_of(attempt))
401
+ if original is not None:
402
+ entry["existing_output"] = {
403
+ "label": "the file as it was before this task (put back if it fails)",
404
+ **text_of(original),
405
+ }
406
+ entries.append(entry)
407
+ return entries, withheld
408
+
409
+
410
+ __all__ = [
411
+ "MAX_FILE_CHARS",
412
+ "MAX_TOTAL_CHARS",
413
+ "Cycle",
414
+ "MoveToken",
415
+ "MovedFile",
416
+ "PreviousOutputs",
417
+ "SlotRecord",
418
+ "moves_outputs",
419
+ ]