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