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,482 @@
1
+ """Isolated workspaces for agent tasks in the claude-code runtime (M2-11c).
2
+
3
+ When `fanout.isolate: true` (profile) or a rule sets `isolate: true`, each agent task
4
+ gets its own workspace outside the user's tree: a worktree at HEAD with the task's
5
+ declared inputs copied in. The subagent writes there, not the user's files. On
6
+ acceptance, only the task's outputs are copied back; on any other ending, the workspace
7
+ is removed with nothing touching the user's tree.
8
+
9
+ This module provides:
10
+ - `create_workspace`: create and seed a workspace for a task
11
+ - `remove_workspace`: remove a task's workspace
12
+ - `sweep_workspaces`: remove stale workspaces from killed runs
13
+ - `should_isolate`: whether a task should run isolated (profile or rule)
14
+ - `diff_workspace`: get files changed in workspace vs seed
15
+ - `copy_outputs_back`: copy outputs from workspace to user's tree (with refusal)
16
+ - `check_overlapping_writes`: check if two tasks' write sets overlap
17
+ - Best-of-N support: `candidate_id`, `parse_candidate_id`, `best_of_n`
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import contextlib
23
+ import hashlib
24
+ import os
25
+ import re
26
+ import shutil
27
+ from pathlib import Path
28
+ from typing import TYPE_CHECKING
29
+
30
+ from pydantic import BaseModel, ConfigDict, Field
31
+
32
+ from chipgraph.core.engine.fanout import (
33
+ fanout_tmp_root,
34
+ seed_workspace,
35
+ sweep_stale,
36
+ )
37
+ from chipgraph.core.model.store import default_model_db_path
38
+ from chipgraph.core.state.artifacts import hash_file
39
+
40
+ if TYPE_CHECKING:
41
+ from chipgraph.adapters.vcs.git import GitVcs
42
+ from chipgraph.app.context import AppContext
43
+ from chipgraph.core.config.models import Profile
44
+ from chipgraph.core.contracts import RuleInstance, RuleSpec
45
+
46
+
47
+ # --- Copy-back result types --------------------------------------------------------------
48
+
49
+
50
+ class CopyBackResult(BaseModel):
51
+ """Result of copying outputs from workspace back to user's tree."""
52
+
53
+ model_config = ConfigDict(extra="forbid", frozen=True)
54
+
55
+ ok: bool = Field(description="Whether all outputs were copied successfully.")
56
+ copied: tuple[str, ...] = Field(default=(), description="Repo-relative paths of files copied.")
57
+ refused: tuple[str, ...] = Field(
58
+ default=(),
59
+ description="Files refused (user's copy changed since seed).",
60
+ )
61
+ symlinks: tuple[str, ...] = Field(
62
+ default=(),
63
+ description="Symlinks on the output paths (not copied).",
64
+ )
65
+ outside: tuple[str, ...] = Field(
66
+ default=(),
67
+ description="Files that exist in workspace but outside allowed_writes.",
68
+ )
69
+ reason: str = Field(default="", description="Why copy-back failed, if not ok.")
70
+
71
+
72
+ def should_isolate(profile: Profile, rule: RuleSpec | None = None) -> bool:
73
+ """Whether a task should run in an isolated workspace.
74
+
75
+ True when `fanout.isolate` is true in the profile, or (eventually) when the rule
76
+ sets `isolate: true` as a parameter. Default is false: existing behavior.
77
+ """
78
+ # Future: also check rule.params.get("isolate", False)
79
+ return profile.fanout.isolate
80
+
81
+
82
+ def workspace_tmp_root(ctx: AppContext) -> Path:
83
+ """Where task workspaces go: `<state>/tmp/fanout` if outside the repo, else system temp."""
84
+ return fanout_tmp_root(ctx.root, ctx.layout)
85
+
86
+
87
+ def workspace_dir_for(ctx: AppContext, run_id: str, task_id: str) -> Path:
88
+ """The directory for a task's workspace: `<tmp>/<run_id>/<safe name>/`.
89
+
90
+ The name is a lowercase slug of the task id plus a short hash of the exact id, so two
91
+ ids never share a directory and every name passes the VCS adapter's name rule.
92
+ """
93
+ slug = re.sub(r"[^a-z0-9]+", "-", task_id.lower()).strip("-")[:40] or "task"
94
+ digest = hashlib.sha256(task_id.encode("utf-8")).hexdigest()[:8]
95
+ return workspace_tmp_root(ctx) / run_id / f"{slug}-{digest}"
96
+
97
+
98
+ def create_workspace(
99
+ ctx: AppContext,
100
+ vcs: GitVcs,
101
+ run_id: str,
102
+ task_id: str,
103
+ inputs: tuple[str, ...],
104
+ outputs: tuple[str, ...] = (),
105
+ ) -> tuple[Path, dict[str, str]]:
106
+ """Create and seed a workspace for a task.
107
+
108
+ Returns (workspace_path, seed_hashes): the hash of every file the workspace holds that
109
+ differs from HEAD or that the task reads or writes, as it was when the task started
110
+ (`""` for a file that HEAD has and the user's tree does not).
111
+
112
+ The workspace is a git worktree at HEAD, with the task's inputs and outputs copied from
113
+ the user's tree (so it sees what the user sees). The user's working tree, index and HEAD
114
+ are never touched.
115
+ """
116
+ workspace_dir = workspace_dir_for(ctx, run_id, task_id)
117
+ workspace_dir.parent.mkdir(parents=True, exist_ok=True)
118
+ base = vcs.head(ctx.root)
119
+ workspace = vcs.create_branch_workspace(
120
+ ctx.root, base, workspace_dir.name, workspace_dir.parent
121
+ ).resolve()
122
+ try:
123
+ seed_workspace(ctx.root, workspace, (*inputs, *outputs, *_user_delta(ctx.root, vcs)))
124
+ _copy_model(ctx.root, workspace)
125
+ seed_hashes: dict[str, str] = {}
126
+ for rel in vcs.status_all(workspace):
127
+ seed_hashes[rel] = _hash_or_empty(workspace / rel)
128
+ for rel in (*inputs, *outputs):
129
+ top = workspace / rel
130
+ if top.is_dir() and not top.is_symlink():
131
+ for dirpath, dirnames, filenames in os.walk(top):
132
+ dirnames[:] = [d for d in dirnames if d != ".git"]
133
+ for name in filenames:
134
+ full = Path(dirpath) / name
135
+ seed_hashes[full.relative_to(workspace).as_posix()] = _hash_or_empty(full)
136
+ else:
137
+ seed_hashes[rel] = _hash_or_empty(top)
138
+ except BaseException:
139
+ remove_workspace(ctx, vcs, workspace)
140
+ raise
141
+ return workspace, seed_hashes
142
+
143
+
144
+ MAX_DELTA_FILES = 5000
145
+ """More uncommitted files than this in the user's tree: no workspace (something is wrong)."""
146
+
147
+
148
+ def _user_delta(root: Path, vcs: GitVcs) -> tuple[str, ...]:
149
+ """What the user's tree has that HEAD does not: modified, deleted and untracked files.
150
+
151
+ The workspace is a worktree at HEAD, so the accepted outputs of the tasks this one builds
152
+ on (uncommitted in the user's tree) would be missing from it: a lint or a testbench check
153
+ in the workspace could not find them. They are copied in, as the user sees them. Engine
154
+ state (`.chipgraph/`) is not part of the project.
155
+ """
156
+ paths = [
157
+ p for p in vcs.status_all(root) if p != ".chipgraph" and not p.startswith(".chipgraph/")
158
+ ]
159
+ if len(paths) > MAX_DELTA_FILES:
160
+ raise OSError(f"{len(paths)} uncommitted files in the tree: commit or stash them first")
161
+ return tuple(paths)
162
+
163
+
164
+ def _copy_model(root: Path, workspace: Path) -> None:
165
+ """A read-only copy of the Design Model's index, so the checks run in the workspace see it.
166
+
167
+ The index is a cache under `.chipgraph/state` (not tracked); the workspace gets its own
168
+ copy, so nothing a check does to it reaches the user's state.
169
+ """
170
+ source = default_model_db_path(root)
171
+ if source.is_file() and not source.is_symlink():
172
+ target = default_model_db_path(workspace)
173
+ target.parent.mkdir(parents=True, exist_ok=True)
174
+ shutil.copy2(source, target)
175
+
176
+
177
+ def _hash_or_empty(path: Path) -> str:
178
+ """The content hash of a regular file; `""` for anything else (missing, link, directory)."""
179
+ if path.is_symlink() or not path.is_file():
180
+ return ""
181
+ return hash_file(path)
182
+
183
+
184
+ def remove_workspace(ctx: AppContext, vcs: GitVcs, workspace: str | Path) -> None:
185
+ """Remove a task's workspace. A no-op if it is already gone."""
186
+ _ = ctx # unused but kept for API consistency
187
+ workspace_path = Path(workspace)
188
+ if not workspace_path.exists() and not workspace_path.is_symlink():
189
+ return
190
+ with contextlib.suppress(Exception):
191
+ vcs.remove(workspace_path)
192
+ # Fallback: remove the directory tree
193
+ if workspace_path.exists() or workspace_path.is_symlink():
194
+ shutil.rmtree(workspace_path, ignore_errors=True)
195
+
196
+
197
+ def sweep_workspaces(ctx: AppContext, vcs: GitVcs) -> int:
198
+ """Remove stale workspaces from killed runs. Returns count of runs swept."""
199
+ stale = sweep_stale(ctx.root, vcs, layout=ctx.layout)
200
+ return len(stale)
201
+
202
+
203
+ def input_paths_for(instance: RuleInstance) -> tuple[str, ...]:
204
+ """The repo-relative paths of a task's inputs (files, not model keys)."""
205
+ return tuple(ref.path for ref in instance.inputs if ref.path is not None)
206
+
207
+
208
+ # --- Step 2: diff workspace, copy-back, write-set checks ---------------------------------
209
+
210
+
211
+ def _has_symlink_in_path(path: Path, base: Path) -> bool:
212
+ """Check if any component of `path` relative to `base` is a symlink."""
213
+ current = base
214
+ for part in path.relative_to(base).parts:
215
+ current = current / part
216
+ if current.is_symlink():
217
+ return True
218
+ return False
219
+
220
+
221
+ def _is_safe_output(workspace: Path, rel: str) -> tuple[bool, str]:
222
+ """Check if a workspace output path is safe to copy (no symlinks anywhere on path).
223
+
224
+ Returns (is_safe, reason) where reason explains why not if is_safe is False.
225
+ """
226
+ path = workspace / rel
227
+ if not path.exists() and not path.is_symlink():
228
+ return True, "" # Missing files are not our concern here
229
+ # Check for symlinks anywhere in the path
230
+ if _has_symlink_in_path(path, workspace):
231
+ return False, f"symlink in path: {rel}"
232
+ if path.is_symlink():
233
+ return False, f"output is a symlink: {rel}"
234
+ return True, ""
235
+
236
+
237
+ def diff_workspace(
238
+ workspace: Path,
239
+ allowed_writes: tuple[str, ...],
240
+ seed_hashes: dict[str, str],
241
+ vcs: GitVcs | None = None,
242
+ ) -> tuple[tuple[str, ...], tuple[str, ...]]:
243
+ """Get files changed in workspace since seed.
244
+
245
+ Returns (changed_in_allowed, changed_outside) where:
246
+ - changed_in_allowed: files in allowed_writes that changed or were added
247
+ - changed_outside: files outside allowed_writes that changed (should be rejected); found
248
+ with `git status` of the worktree, so a file that HEAD has and nobody touched is not one.
249
+ Anything under `.chipgraph/` is the engine's own (check scratch) and ignored.
250
+ """
251
+ changed_in_allowed: list[str] = []
252
+ changed_outside: list[str] = []
253
+ allowed_set = set(allowed_writes)
254
+ for rel in allowed_writes:
255
+ path = workspace / rel
256
+ present = path.is_file() or path.is_symlink()
257
+ # a link is always a change: `copy_outputs_back` refuses it by name
258
+ if present and (
259
+ path.is_symlink() or rel not in seed_hashes or _hash_or_empty(path) != seed_hashes[rel]
260
+ ):
261
+ changed_in_allowed.append(rel)
262
+ if vcs is None:
263
+ from chipgraph.adapters.vcs.git import GitVcs as _GitVcs
264
+
265
+ vcs = _GitVcs()
266
+ for rel in vcs.status_all(workspace):
267
+ if rel in allowed_set or rel == ".chipgraph" or rel.startswith(".chipgraph/"):
268
+ continue
269
+ if _hash_or_empty(workspace / rel) != seed_hashes.get(rel):
270
+ changed_outside.append(rel)
271
+ return tuple(sorted(changed_in_allowed)), tuple(sorted(changed_outside))
272
+
273
+
274
+ def copy_outputs_back(
275
+ workspace: Path,
276
+ user_root: Path,
277
+ allowed_writes: tuple[str, ...],
278
+ seed_hashes: dict[str, str],
279
+ ) -> CopyBackResult:
280
+ """Copy allowed outputs from workspace to user's tree, atomically.
281
+
282
+ Refuses to copy if:
283
+ - The user's copy changed since the seed (concurrent edit protection)
284
+ - There's a symlink anywhere on the output path (security)
285
+
286
+ Returns CopyBackResult with details of what was copied or refused.
287
+ """
288
+ copied: list[str] = []
289
+ refused: list[str] = []
290
+ symlinks: list[str] = []
291
+
292
+ # First pass: check all outputs for safety and conflicts
293
+ outputs_to_copy: list[str] = []
294
+ for rel in allowed_writes:
295
+ ws_path = workspace / rel
296
+ if not ws_path.exists() and not ws_path.is_symlink():
297
+ continue # Output doesn't exist in workspace - skip
298
+
299
+ # Check for symlinks (on the workspace side and on the user's side) and for anything
300
+ # that is not a regular file
301
+ safe, _reason = _is_safe_output(workspace, rel)
302
+ if safe and (user_root / rel).parent.exists():
303
+ safe, _reason = _is_safe_output(user_root, rel)
304
+ if safe and not ws_path.is_file():
305
+ safe = False
306
+ if not safe:
307
+ symlinks.append(rel)
308
+ continue
309
+
310
+ # Check if user's tree changed since seed
311
+ user_path = user_root / rel
312
+ if seed_hashes.get(rel):
313
+ # File existed at seed time - check if user changed it
314
+ if user_path.is_file() and not user_path.is_symlink():
315
+ current_user_hash = hash_file(user_path)
316
+ if current_user_hash != seed_hashes[rel]:
317
+ refused.append(rel)
318
+ continue
319
+ elif user_path.exists() or user_path.is_symlink():
320
+ # User's file type changed (became dir/symlink)
321
+ refused.append(rel)
322
+ continue
323
+ elif not user_path.exists():
324
+ # User deleted the file - that counts as a change
325
+ refused.append(rel)
326
+ continue
327
+ else:
328
+ # File didn't exist at seed time
329
+ if user_path.exists() or user_path.is_symlink():
330
+ # User created a file there concurrently
331
+ refused.append(rel)
332
+ continue
333
+
334
+ outputs_to_copy.append(rel)
335
+
336
+ # If anything was refused, don't copy anything (atomic)
337
+ if refused or symlinks:
338
+ reason_parts = []
339
+ if refused:
340
+ reason_parts.append(f"user's tree changed since seed for: {', '.join(refused)}")
341
+ if symlinks:
342
+ reason_parts.append(f"symlinks in output paths: {', '.join(symlinks)}")
343
+ return CopyBackResult(
344
+ ok=False,
345
+ copied=(),
346
+ refused=tuple(refused),
347
+ symlinks=tuple(symlinks),
348
+ reason="; ".join(reason_parts),
349
+ )
350
+
351
+ # Second pass: stage every output next to its destination, then rename them into place
352
+ # (a rename is atomic per file; a failure while staging leaves the user's tree untouched)
353
+ staged: list[tuple[Path, Path, str]] = []
354
+ try:
355
+ for rel in outputs_to_copy:
356
+ user_path = user_root / rel
357
+ user_path.parent.mkdir(parents=True, exist_ok=True)
358
+ temp = user_path.with_name(f".{user_path.name}.chipgraph-new")
359
+ temp.unlink(missing_ok=True)
360
+ shutil.copy2(workspace / rel, temp)
361
+ staged.append((temp, user_path, rel))
362
+ except OSError as exc:
363
+ for temp, _dest, _rel in staged:
364
+ temp.unlink(missing_ok=True)
365
+ return CopyBackResult(ok=False, reason=f"could not stage the outputs: {exc}")
366
+ for temp, dest, rel in staged:
367
+ os.replace(temp, dest)
368
+ copied.append(rel)
369
+
370
+ return CopyBackResult(ok=True, copied=tuple(copied))
371
+
372
+
373
+ def check_overlapping_writes(
374
+ write_sets: dict[str, tuple[str, ...]],
375
+ ) -> dict[str, list[str]]:
376
+ """Check if any two tasks' write sets overlap.
377
+
378
+ Returns a dict mapping each overlapping path to the list of task IDs that write it.
379
+ Empty dict means no overlaps.
380
+ """
381
+ # Map each path to the tasks that write it
382
+ path_to_tasks: dict[str, list[str]] = {}
383
+ for task_id, writes in write_sets.items():
384
+ for path in writes:
385
+ key = path.casefold() # Case-insensitive for filesystem safety
386
+ path_to_tasks.setdefault(key, []).append(task_id)
387
+
388
+ # Find overlaps
389
+ overlaps: dict[str, list[str]] = {}
390
+ for path, tasks in path_to_tasks.items():
391
+ if len(tasks) > 1:
392
+ overlaps[path] = sorted(tasks)
393
+
394
+ return overlaps
395
+
396
+
397
+ def max_parallel_limit(profile: Profile) -> int:
398
+ """Get the max parallel tasks limit from the profile."""
399
+ return profile.fanout.effective_max_parallel()
400
+
401
+
402
+ # --- Step 3: Best-of-N support -----------------------------------------------------------
403
+
404
+
405
+ def candidate_id(task_id: str, candidate: int, round_num: int = 1) -> str:
406
+ """The task id suffix for a best-of-N candidate: `<task_id>#r<round>-<candidate>`.
407
+
408
+ For round 1, this produces `<task_id>#r1-1`, `<task_id>#r1-2`, etc.
409
+ """
410
+ return f"{task_id}#r{round_num}-{candidate}"
411
+
412
+
413
+ def parse_candidate_id(candidate_task_id: str) -> tuple[str, int, int]:
414
+ """Parse a candidate task id into (base_task_id, candidate_number, round_number).
415
+
416
+ Supports both old format `<task_id>#<n>` (round 1 assumed) and new format
417
+ `<task_id>#r<round>-<candidate>`.
418
+
419
+ Raises ValueError if not a candidate id.
420
+ """
421
+ if "#" not in candidate_task_id:
422
+ raise ValueError(f"{candidate_task_id!r} is not a candidate id (no #)")
423
+ base, suffix = candidate_task_id.rsplit("#", 1)
424
+
425
+ # Try new format: r<round>-<candidate>
426
+ if suffix.startswith("r") and "-" in suffix:
427
+ try:
428
+ round_part, cand_part = suffix[1:].split("-", 1)
429
+ return base, int(cand_part), int(round_part)
430
+ except ValueError:
431
+ pass
432
+
433
+ # Fall back to old format: just <candidate> (round 1 assumed)
434
+ try:
435
+ return base, int(suffix), 1
436
+ except ValueError:
437
+ raise ValueError(f"{candidate_task_id!r} is not a candidate id (invalid format)") from None
438
+
439
+
440
+ def is_candidate_id(task_id: str) -> bool:
441
+ """Whether a task id is a candidate id (contains #)."""
442
+ return "#" in task_id
443
+
444
+
445
+ def best_of_n(profile: Profile, rule_id: str, instance_params: dict[str, object]) -> int | None:
446
+ """Get the best-of-N value for a rule instance, or None if not opted in.
447
+
448
+ A rule opts in by:
449
+ 1. Instance parameter `best_of: <n>`
450
+ 2. Profile's `select.rules[rule_id]`
451
+ """
452
+ # Check instance param first
453
+ raw = instance_params.get("best_of")
454
+ if raw is not None:
455
+ try:
456
+ n = int(str(raw))
457
+ except (ValueError, TypeError):
458
+ return None # Invalid value, not opted in
459
+ return n if n >= 1 else None
460
+
461
+ # Check profile's select.rules
462
+ return profile.select.rules.get(rule_id)
463
+
464
+
465
+ __all__ = [
466
+ "CopyBackResult",
467
+ "best_of_n",
468
+ "candidate_id",
469
+ "check_overlapping_writes",
470
+ "copy_outputs_back",
471
+ "create_workspace",
472
+ "diff_workspace",
473
+ "input_paths_for",
474
+ "is_candidate_id",
475
+ "max_parallel_limit",
476
+ "parse_candidate_id",
477
+ "remove_workspace",
478
+ "should_isolate",
479
+ "sweep_workspaces",
480
+ "workspace_dir_for",
481
+ "workspace_tmp_root",
482
+ ]