agentflow-proof 1.0.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.
- agentflow/__init__.py +5 -0
- agentflow/__main__.py +5 -0
- agentflow/aggregate.py +1015 -0
- agentflow/artifacts.py +331 -0
- agentflow/capabilities.py +124 -0
- agentflow/cli.py +2108 -0
- agentflow/cli_contract.py +238 -0
- agentflow/contracts.py +263 -0
- agentflow/coverage.py +438 -0
- agentflow/draft_plan.py +402 -0
- agentflow/events.py +201 -0
- agentflow/execution.py +1027 -0
- agentflow/execution_coverage.py +646 -0
- agentflow/git.py +89 -0
- agentflow/handoff.py +112 -0
- agentflow/hunks.py +245 -0
- agentflow/locks.py +71 -0
- agentflow/mcp_server.py +710 -0
- agentflow/packs.py +398 -0
- agentflow/porcelain.py +1014 -0
- agentflow/proof.py +1062 -0
- agentflow/receipts.py +604 -0
- agentflow/recommend.py +299 -0
- agentflow/review.py +924 -0
- agentflow/review_runner.py +516 -0
- agentflow/risk.py +328 -0
- agentflow/runtime.py +863 -0
- agentflow/stuck.py +344 -0
- agentflow/validation.py +696 -0
- agentflow/versioning.py +120 -0
- agentflow/viewer.py +391 -0
- agentflow/workflow_contract.py +164 -0
- agentflow_proof-1.0.0.dist-info/METADATA +524 -0
- agentflow_proof-1.0.0.dist-info/RECORD +38 -0
- agentflow_proof-1.0.0.dist-info/WHEEL +5 -0
- agentflow_proof-1.0.0.dist-info/entry_points.txt +3 -0
- agentflow_proof-1.0.0.dist-info/licenses/LICENSE +21 -0
- agentflow_proof-1.0.0.dist-info/top_level.txt +1 -0
agentflow/git.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Git helpers used by drift auditing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Dict, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def run_git(root: Path, args: List[str]) -> Tuple[int, str, str]:
|
|
11
|
+
proc = subprocess.run(
|
|
12
|
+
["git", "-C", str(root), *args],
|
|
13
|
+
check=False,
|
|
14
|
+
stdout=subprocess.PIPE,
|
|
15
|
+
stderr=subprocess.PIPE,
|
|
16
|
+
text=True,
|
|
17
|
+
)
|
|
18
|
+
return proc.returncode, proc.stdout.rstrip(), proc.stderr.rstrip()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_git_repo(root: Path) -> bool:
|
|
22
|
+
code, _, _ = run_git(root, ["rev-parse", "--is-inside-work-tree"])
|
|
23
|
+
return code == 0
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def changed_files(root: Path) -> List[str]:
|
|
27
|
+
code, stdout, stderr = run_git(root, ["status", "--porcelain=v1", "-uall"])
|
|
28
|
+
if code != 0:
|
|
29
|
+
raise RuntimeError(stderr or "git status failed")
|
|
30
|
+
|
|
31
|
+
files = []
|
|
32
|
+
for line in stdout.splitlines():
|
|
33
|
+
if not line:
|
|
34
|
+
continue
|
|
35
|
+
path = line[3:].strip()
|
|
36
|
+
if " -> " in path:
|
|
37
|
+
path = path.split(" -> ", 1)[1]
|
|
38
|
+
files.append(path)
|
|
39
|
+
return sorted(set(files))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def changed_file_records(root: Path) -> List[Dict[str, str]]:
|
|
43
|
+
code, stdout, stderr = run_git(root, ["status", "--porcelain=v1", "-uall"])
|
|
44
|
+
if code != 0:
|
|
45
|
+
raise RuntimeError(stderr or "git status failed")
|
|
46
|
+
|
|
47
|
+
records: List[Dict[str, str]] = []
|
|
48
|
+
for line in stdout.splitlines():
|
|
49
|
+
if not line:
|
|
50
|
+
continue
|
|
51
|
+
status = line[:2]
|
|
52
|
+
raw_path = line[3:].strip()
|
|
53
|
+
previous_path = ""
|
|
54
|
+
path = raw_path
|
|
55
|
+
change_kind = "modified"
|
|
56
|
+
if " -> " in raw_path:
|
|
57
|
+
previous_path, path = raw_path.split(" -> ", 1)
|
|
58
|
+
change_kind = "renamed"
|
|
59
|
+
elif status == "??" or "A" in status:
|
|
60
|
+
change_kind = "added"
|
|
61
|
+
elif "D" in status:
|
|
62
|
+
change_kind = "deleted"
|
|
63
|
+
records.append(
|
|
64
|
+
{
|
|
65
|
+
"path": path,
|
|
66
|
+
"previous_path": previous_path,
|
|
67
|
+
"status": status,
|
|
68
|
+
"change_kind": change_kind,
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
return sorted(records, key=lambda item: item["path"])
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def git_blob_for_head(root: Path, path: str) -> Optional[str]:
|
|
75
|
+
code, stdout, _ = run_git(root, ["rev-parse", f"HEAD:{path}"])
|
|
76
|
+
if code != 0:
|
|
77
|
+
return None
|
|
78
|
+
return stdout.strip()
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def current_branch(root: Path) -> Optional[str]:
|
|
82
|
+
"""Return the current branch name, or None outside a repo / on detached HEAD."""
|
|
83
|
+
code, stdout, _ = run_git(root, ["rev-parse", "--abbrev-ref", "HEAD"])
|
|
84
|
+
if code != 0:
|
|
85
|
+
return None
|
|
86
|
+
branch = stdout.strip()
|
|
87
|
+
if not branch or branch == "HEAD":
|
|
88
|
+
return None
|
|
89
|
+
return branch
|
agentflow/handoff.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Provider-neutral handoff rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from typing import Any, Dict, List, Union
|
|
7
|
+
|
|
8
|
+
from .contracts import PROVIDER_NEUTRAL_DENYLIST
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _step(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
|
|
12
|
+
for item in plan.get("steps", []):
|
|
13
|
+
if isinstance(item, dict) and item.get("id") == step_id:
|
|
14
|
+
return item
|
|
15
|
+
raise ValueError(f"unknown step id: {step_id}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def lint_handoff_text(text: str) -> List[Dict[str, str]]:
|
|
19
|
+
lower = text.lower()
|
|
20
|
+
findings: List[Dict[str, str]] = []
|
|
21
|
+
for token in PROVIDER_NEUTRAL_DENYLIST:
|
|
22
|
+
if token in lower:
|
|
23
|
+
findings.append(
|
|
24
|
+
{
|
|
25
|
+
"severity": "error",
|
|
26
|
+
"message": f"provider-specific token is not allowed in handoff: {token}",
|
|
27
|
+
}
|
|
28
|
+
)
|
|
29
|
+
return findings
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _json_handoff(plan: Dict[str, Any], step_id: str) -> Dict[str, Any]:
|
|
33
|
+
step = _step(plan, step_id)
|
|
34
|
+
return {
|
|
35
|
+
"contract": "agentflow_shell_handoff",
|
|
36
|
+
"objective": plan.get("objective", ""),
|
|
37
|
+
"step": step,
|
|
38
|
+
"allowed_files": step.get("files", []),
|
|
39
|
+
"blocked_files": plan.get("blocked_files", []),
|
|
40
|
+
"non_goals": plan.get("non_goals", []),
|
|
41
|
+
"invariants": plan.get("invariants", []),
|
|
42
|
+
"expected_diff": step.get("expected_diff", []),
|
|
43
|
+
"validation": step.get("validation", []),
|
|
44
|
+
"commands": {
|
|
45
|
+
"claim": f"agentflow claim-step {step_id} --agent $AGENT_ID",
|
|
46
|
+
"run": f"agentflow run --step {step_id} -- <command>",
|
|
47
|
+
"record_file_change": f"agentflow record-file-change --step {step_id} --path <path>",
|
|
48
|
+
"verify": f"agentflow verify-step {step_id}",
|
|
49
|
+
"complete": f"agentflow complete-step {step_id}",
|
|
50
|
+
"block": f"agentflow block-step {step_id} --reason <reason>",
|
|
51
|
+
"fail": f"agentflow fail-step {step_id} --reason <reason>",
|
|
52
|
+
},
|
|
53
|
+
"exit_code_contract": {
|
|
54
|
+
"0": "no error-severity findings",
|
|
55
|
+
"1": "one or more error-severity findings",
|
|
56
|
+
"2": "command usage error",
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _markdown_handoff(payload: Dict[str, Any]) -> str:
|
|
62
|
+
step = payload["step"]
|
|
63
|
+
lines = [
|
|
64
|
+
"# Agentflow Work Packet",
|
|
65
|
+
"",
|
|
66
|
+
"## Objective",
|
|
67
|
+
"",
|
|
68
|
+
payload["objective"],
|
|
69
|
+
"",
|
|
70
|
+
"## Step",
|
|
71
|
+
"",
|
|
72
|
+
f"{step['id']}: {step['action']}",
|
|
73
|
+
"",
|
|
74
|
+
"## Allowed Files",
|
|
75
|
+
"",
|
|
76
|
+
*[f"- {item}" for item in payload["allowed_files"]],
|
|
77
|
+
"",
|
|
78
|
+
"## Blocked Files",
|
|
79
|
+
"",
|
|
80
|
+
*[f"- {item}" for item in payload["blocked_files"]],
|
|
81
|
+
"",
|
|
82
|
+
"## Expected Diff",
|
|
83
|
+
"",
|
|
84
|
+
*[f"- {item}" for item in payload["expected_diff"]],
|
|
85
|
+
"",
|
|
86
|
+
"## Validation",
|
|
87
|
+
"",
|
|
88
|
+
*[f"- {item}" for item in payload["validation"]],
|
|
89
|
+
"",
|
|
90
|
+
"## Commands",
|
|
91
|
+
"",
|
|
92
|
+
*[f"- `{name}`: `{command}`" for name, command in payload["commands"].items()],
|
|
93
|
+
"",
|
|
94
|
+
]
|
|
95
|
+
return "\n".join(lines)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def export_handoff(plan: Dict[str, Any], step_id: str, output_format: str) -> Union[Dict[str, Any], str]:
|
|
99
|
+
payload = _json_handoff(plan, step_id)
|
|
100
|
+
if output_format == "json":
|
|
101
|
+
text = json.dumps(payload, sort_keys=True)
|
|
102
|
+
findings = lint_handoff_text(text)
|
|
103
|
+
if findings:
|
|
104
|
+
raise ValueError(findings[0]["message"])
|
|
105
|
+
return payload
|
|
106
|
+
if output_format == "markdown":
|
|
107
|
+
markdown = _markdown_handoff(payload)
|
|
108
|
+
findings = lint_handoff_text(markdown)
|
|
109
|
+
if findings:
|
|
110
|
+
raise ValueError(findings[0]["message"])
|
|
111
|
+
return markdown
|
|
112
|
+
raise ValueError("format must be json or markdown")
|
agentflow/hunks.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
"""Hunk-level attribution: shared diff parsing, identity, and coverage.
|
|
2
|
+
|
|
3
|
+
This module is the only place that shells ``git diff`` and computes hunk
|
|
4
|
+
identity hashes. It imports only stdlib plus ``artifacts`` and ``contracts`` to
|
|
5
|
+
stay free of import cycles (``receipts``/``validation``/``execution_coverage``
|
|
6
|
+
all import this module). It deliberately does not use ``git.run_git`` because
|
|
7
|
+
that helper strips stdout, and hunk patches / HEAD blobs need exact output.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import hashlib
|
|
13
|
+
import re
|
|
14
|
+
import subprocess
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from tempfile import NamedTemporaryFile
|
|
17
|
+
from typing import Any, Dict, List, Optional, Tuple
|
|
18
|
+
|
|
19
|
+
from .artifacts import read_jsonl, try_read_json
|
|
20
|
+
from .contracts import DIFF_COMMAND_VERSION, EXECUTION_ARTIFACT_PATHS, HUNK_ATTRIBUTION_POLICIES
|
|
21
|
+
|
|
22
|
+
_HUNK_HEADER = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@")
|
|
23
|
+
# Number of lines before each hunk to include in the context anchor window.
|
|
24
|
+
_CONTEXT_ANCHOR_LINES = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _git_text(root: Path, args: List[str]) -> Tuple[int, str, str]:
|
|
28
|
+
proc = subprocess.run(
|
|
29
|
+
["git", "-C", str(root), *args],
|
|
30
|
+
check=False,
|
|
31
|
+
stdout=subprocess.PIPE,
|
|
32
|
+
stderr=subprocess.PIPE,
|
|
33
|
+
text=True,
|
|
34
|
+
encoding="utf-8",
|
|
35
|
+
errors="surrogateescape",
|
|
36
|
+
)
|
|
37
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _git_bytes(root: Path, args: List[str]) -> Tuple[int, bytes, bytes]:
|
|
41
|
+
proc = subprocess.run(
|
|
42
|
+
["git", "-C", str(root), *args],
|
|
43
|
+
check=False,
|
|
44
|
+
stdout=subprocess.PIPE,
|
|
45
|
+
stderr=subprocess.PIPE,
|
|
46
|
+
)
|
|
47
|
+
return proc.returncode, proc.stdout, proc.stderr
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _base_flags(no_index: bool) -> List[str]:
|
|
51
|
+
flags = [
|
|
52
|
+
"-c",
|
|
53
|
+
"core.autocrlf=false",
|
|
54
|
+
"diff",
|
|
55
|
+
"--no-color",
|
|
56
|
+
"--no-ext-diff",
|
|
57
|
+
"--no-textconv",
|
|
58
|
+
"--unified=0",
|
|
59
|
+
"--diff-algorithm=myers",
|
|
60
|
+
]
|
|
61
|
+
flags.append("--no-index" if no_index else "--no-renames")
|
|
62
|
+
return flags
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hunk_identity(posix_path: str, change_kind: str, changed_lines: List[str]) -> str:
|
|
66
|
+
payload = "\n".join([DIFF_COMMAND_VERSION, posix_path, change_kind, *changed_lines])
|
|
67
|
+
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _normalize_path(path: str) -> str:
|
|
71
|
+
return path.replace("\\", "/")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _head_lines(root: Path, path: str) -> Optional[List[str]]:
|
|
75
|
+
code, stdout, _ = _git_text(root, ["show", f"HEAD:{path}"])
|
|
76
|
+
if code != 0:
|
|
77
|
+
return None
|
|
78
|
+
# CRLF→LF normalization is deliberate: ensures cross-platform identity stability.
|
|
79
|
+
return stdout.replace("\r\n", "\n").split("\n")
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _context_sha256(head_lines: Optional[List[str]], old_start: int) -> Tuple[Optional[str], str]:
|
|
83
|
+
if not head_lines or old_start <= 0:
|
|
84
|
+
return None, "none"
|
|
85
|
+
start = max(0, old_start - 1 - _CONTEXT_ANCHOR_LINES)
|
|
86
|
+
anchors = head_lines[start : old_start - 1]
|
|
87
|
+
if not anchors:
|
|
88
|
+
return None, "none"
|
|
89
|
+
digest = hashlib.sha256("\n".join(anchors).encode("utf-8")).hexdigest()
|
|
90
|
+
return digest, "head-baseline"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _parse_patch(text: str, posix_path: str, change_kind: str, head_lines: Optional[List[str]]) -> List[Dict[str, Any]]:
|
|
94
|
+
hunks: List[Dict[str, Any]] = []
|
|
95
|
+
# CRLF→LF normalization is deliberate: ensures cross-platform identity stability.
|
|
96
|
+
lines = text.replace("\r\n", "\n").split("\n")
|
|
97
|
+
index = 0
|
|
98
|
+
while index < len(lines):
|
|
99
|
+
match = _HUNK_HEADER.match(lines[index])
|
|
100
|
+
if not match:
|
|
101
|
+
index += 1
|
|
102
|
+
continue
|
|
103
|
+
old_start = int(match.group(1))
|
|
104
|
+
old_count = int(match.group(2) or "1")
|
|
105
|
+
new_start = int(match.group(3))
|
|
106
|
+
new_count = int(match.group(4) or "1")
|
|
107
|
+
index += 1
|
|
108
|
+
body: List[str] = []
|
|
109
|
+
# Safe under --unified=0: no context lines are emitted, so every
|
|
110
|
+
# added/removed content line carries a '+'/'-' prefix; a literal "@@ "
|
|
111
|
+
# sequence in file content cannot appear without a prefix and cannot be
|
|
112
|
+
# misread as a hunk header.
|
|
113
|
+
while index < len(lines) and not lines[index].startswith("@@ "):
|
|
114
|
+
line = lines[index]
|
|
115
|
+
if line.startswith(("+", "-")):
|
|
116
|
+
body.append(line)
|
|
117
|
+
index += 1
|
|
118
|
+
context_hash, context_source = _context_sha256(head_lines, old_start)
|
|
119
|
+
hunks.append(
|
|
120
|
+
{
|
|
121
|
+
"hash": hunk_identity(posix_path, change_kind, body),
|
|
122
|
+
"old_start": old_start,
|
|
123
|
+
"old_count": old_count,
|
|
124
|
+
"new_start": new_start,
|
|
125
|
+
"new_count": new_count,
|
|
126
|
+
"context_sha256": context_hash,
|
|
127
|
+
"context_source": context_source,
|
|
128
|
+
}
|
|
129
|
+
)
|
|
130
|
+
return hunks
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _is_binary(text: str) -> bool:
|
|
134
|
+
return "Binary files " in text or "GIT binary patch" in text
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def compute_hunks(root: Path, record: Dict[str, Any]) -> Tuple[str, List[Dict[str, Any]]]:
|
|
138
|
+
"""Return ``(attribution, hunks)`` for one changed-file record.
|
|
139
|
+
|
|
140
|
+
``attribution`` is ``"hunked"`` for a parsed text diff or
|
|
141
|
+
``"whole_file_fallback"`` for binary / unparseable / unreconstructable
|
|
142
|
+
inputs. The ``"disabled"`` state is set by the caller, never here.
|
|
143
|
+
"""
|
|
144
|
+
path = record["path"]
|
|
145
|
+
posix_path = _normalize_path(path)
|
|
146
|
+
change_kind = record.get("change_kind", "modified")
|
|
147
|
+
head_lines = _head_lines(root, record.get("previous_path") or path)
|
|
148
|
+
|
|
149
|
+
if change_kind == "added":
|
|
150
|
+
code, stdout, _ = _git_text(root, [*_base_flags(no_index=True), "--", "/dev/null", path])
|
|
151
|
+
if code > 1:
|
|
152
|
+
return "whole_file_fallback", []
|
|
153
|
+
if _is_binary(stdout):
|
|
154
|
+
return "whole_file_fallback", []
|
|
155
|
+
return "hunked", _parse_patch(stdout, posix_path, change_kind, None)
|
|
156
|
+
|
|
157
|
+
if change_kind == "renamed":
|
|
158
|
+
previous_path = record.get("previous_path") or ""
|
|
159
|
+
if not previous_path:
|
|
160
|
+
return "whole_file_fallback", []
|
|
161
|
+
code, blob, _ = _git_bytes(root, ["show", f"HEAD:{previous_path}"])
|
|
162
|
+
if code != 0:
|
|
163
|
+
return "whole_file_fallback", []
|
|
164
|
+
with NamedTemporaryFile("wb", suffix=".afhunk", delete=True) as handle:
|
|
165
|
+
handle.write(blob)
|
|
166
|
+
handle.flush()
|
|
167
|
+
code, stdout, _ = _git_text(
|
|
168
|
+
root, [*_base_flags(no_index=True), "--", handle.name, path]
|
|
169
|
+
)
|
|
170
|
+
if code > 1 or _is_binary(stdout):
|
|
171
|
+
return "whole_file_fallback", []
|
|
172
|
+
return "hunked", _parse_patch(stdout, posix_path, change_kind, head_lines)
|
|
173
|
+
|
|
174
|
+
# modified / deleted: working tree vs HEAD
|
|
175
|
+
code, stdout, _ = _git_text(root, [*_base_flags(no_index=False), "HEAD", "--", path])
|
|
176
|
+
if code != 0:
|
|
177
|
+
return "whole_file_fallback", []
|
|
178
|
+
if _is_binary(stdout):
|
|
179
|
+
return "whole_file_fallback", []
|
|
180
|
+
return "hunked", _parse_patch(stdout, posix_path, change_kind, head_lines)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def effective_hunk_policy(root: Path) -> str:
|
|
184
|
+
contract_path = root / EXECUTION_ARTIFACT_PATHS["execution-contract"]
|
|
185
|
+
if not contract_path.exists():
|
|
186
|
+
return "off"
|
|
187
|
+
# A malformed contract must not crash the proof/CI gate; degrade to the
|
|
188
|
+
# contract-present default ("enforce") rather than raising.
|
|
189
|
+
contract, _ = try_read_json(contract_path)
|
|
190
|
+
if not isinstance(contract, dict):
|
|
191
|
+
return "enforce"
|
|
192
|
+
policy = contract.get("proof_policy", {})
|
|
193
|
+
value = policy.get("hunk_attribution", "enforce") if isinstance(policy, dict) else "enforce"
|
|
194
|
+
return value if value in HUNK_ATTRIBUTION_POLICIES else "enforce"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _receipts_by_path(root: Path) -> Tuple[Dict[str, Dict[str, Any]], Dict[str, List[Dict[str, Any]]]]:
|
|
198
|
+
latest: Dict[str, Dict[str, Any]] = {}
|
|
199
|
+
grouped: Dict[str, List[Dict[str, Any]]] = {}
|
|
200
|
+
for receipt in read_jsonl(root / EXECUTION_ARTIFACT_PATHS["file-receipts"]):
|
|
201
|
+
path = receipt.get("path")
|
|
202
|
+
if not isinstance(path, str):
|
|
203
|
+
continue
|
|
204
|
+
latest[path] = receipt # append-only ledger: last write wins
|
|
205
|
+
grouped.setdefault(path, []).append(receipt)
|
|
206
|
+
return latest, grouped
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def unmapped_hunks(root: Path, records: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
|
210
|
+
latest, grouped = _receipts_by_path(root)
|
|
211
|
+
out: List[Dict[str, Any]] = []
|
|
212
|
+
for record in records:
|
|
213
|
+
path = record.get("path")
|
|
214
|
+
latest_receipt = latest.get(path)
|
|
215
|
+
if latest_receipt is None:
|
|
216
|
+
continue # missing-receipt policy handles this upstream
|
|
217
|
+
path_receipts = grouped.get(path, [])
|
|
218
|
+
if "hunks" not in latest_receipt:
|
|
219
|
+
continue # latest legacy 0.3.0 receipt -> whole-file coverage
|
|
220
|
+
if latest_receipt.get("hunk_attribution") != "hunked":
|
|
221
|
+
continue # whole_file_fallback / disabled
|
|
222
|
+
recorded = {
|
|
223
|
+
hunk["hash"]
|
|
224
|
+
for receipt in path_receipts
|
|
225
|
+
if receipt.get("hunk_attribution") == "hunked"
|
|
226
|
+
for hunk in receipt.get("hunks", [])
|
|
227
|
+
if isinstance(hunk.get("hash"), str)
|
|
228
|
+
}
|
|
229
|
+
attribution, current = compute_hunks(root, record)
|
|
230
|
+
if attribution != "hunked":
|
|
231
|
+
continue # became binary/unparseable -> after_sha256 governs
|
|
232
|
+
for hunk in current:
|
|
233
|
+
if hunk["hash"] not in recorded:
|
|
234
|
+
out.append(
|
|
235
|
+
{
|
|
236
|
+
"path": path,
|
|
237
|
+
"hash": hunk["hash"],
|
|
238
|
+
"old_start": hunk["old_start"],
|
|
239
|
+
"old_count": hunk["old_count"],
|
|
240
|
+
"new_start": hunk["new_start"],
|
|
241
|
+
"new_count": hunk["new_count"],
|
|
242
|
+
"reason": "no_matching_hunk",
|
|
243
|
+
}
|
|
244
|
+
)
|
|
245
|
+
return out
|
agentflow/locks.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Cross-platform advisory file locking for serializing ledger writes.
|
|
2
|
+
|
|
3
|
+
Standard library only, to preserve Agentflow's no-runtime-dependency invariant.
|
|
4
|
+
Uses ``fcntl`` on POSIX and ``msvcrt`` on Windows. If neither is available (no
|
|
5
|
+
known platform), the lock degrades to a no-op, which preserves prior behavior.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import contextlib
|
|
11
|
+
import time
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Iterator
|
|
14
|
+
|
|
15
|
+
# Poll interval for the Windows retry loop (POSIX flock blocks in the kernel and
|
|
16
|
+
# needs no polling).
|
|
17
|
+
_MSVCRT_RETRY_SECONDS = 0.05
|
|
18
|
+
|
|
19
|
+
try: # POSIX
|
|
20
|
+
import fcntl
|
|
21
|
+
|
|
22
|
+
_HAVE_FCNTL = True
|
|
23
|
+
except ImportError: # pragma: no cover - non-POSIX
|
|
24
|
+
_HAVE_FCNTL = False
|
|
25
|
+
|
|
26
|
+
try: # Windows
|
|
27
|
+
import msvcrt
|
|
28
|
+
|
|
29
|
+
_HAVE_MSVCRT = True
|
|
30
|
+
except ImportError: # pragma: no cover - non-Windows
|
|
31
|
+
_HAVE_MSVCRT = False
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@contextlib.contextmanager
|
|
35
|
+
def file_lock(lock_path: Path) -> Iterator[None]:
|
|
36
|
+
"""Hold an exclusive advisory lock on ``lock_path`` for the block's duration.
|
|
37
|
+
|
|
38
|
+
The lock file is created if needed and never truncated; only its kernel lock
|
|
39
|
+
state is used. The lock is released and the descriptor closed on exit, even if
|
|
40
|
+
the body raises. ``fcntl`` locks are released by the kernel if the process
|
|
41
|
+
dies, so a crash cannot leave a stale lock behind.
|
|
42
|
+
"""
|
|
43
|
+
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
44
|
+
handle = open(lock_path, "a+")
|
|
45
|
+
acquired = False
|
|
46
|
+
try:
|
|
47
|
+
if _HAVE_FCNTL:
|
|
48
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_EX)
|
|
49
|
+
acquired = True
|
|
50
|
+
elif _HAVE_MSVCRT: # pragma: no cover - Windows only
|
|
51
|
+
# LK_LOCK gives up with OSError after ~10 retries, so a long-held
|
|
52
|
+
# lock would make concurrent writers fail. Poll a non-blocking lock
|
|
53
|
+
# until acquired to match POSIX flock's indefinite blocking.
|
|
54
|
+
handle.seek(0)
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
|
|
58
|
+
acquired = True
|
|
59
|
+
break
|
|
60
|
+
except OSError:
|
|
61
|
+
time.sleep(_MSVCRT_RETRY_SECONDS)
|
|
62
|
+
yield
|
|
63
|
+
finally:
|
|
64
|
+
try:
|
|
65
|
+
if acquired and _HAVE_FCNTL:
|
|
66
|
+
fcntl.flock(handle.fileno(), fcntl.LOCK_UN)
|
|
67
|
+
elif acquired and _HAVE_MSVCRT: # pragma: no cover - Windows only
|
|
68
|
+
handle.seek(0)
|
|
69
|
+
msvcrt.locking(handle.fileno(), msvcrt.LK_UNLCK, 1)
|
|
70
|
+
finally:
|
|
71
|
+
handle.close()
|