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,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
|
+
]
|