@xenonbyte/req-2-plan 0.4.3 → 0.4.5
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.
- package/package.json +1 -1
- package/tools/workflow_cli/agent_shortcuts.py +15 -5
- package/tools/workflow_cli/artifact.py +5 -2
- package/tools/workflow_cli/atomic.py +45 -0
- package/tools/workflow_cli/cli.py +19 -13
- package/tools/workflow_cli/install.py +161 -19
- package/tools/workflow_cli/link_expander.py +9 -25
- package/tools/workflow_cli/state.py +5 -6
- package/tools/workflow_cli/tier.py +1 -1
- package/tools/workflow_cli/version.py +1 -1
package/package.json
CHANGED
|
@@ -14,6 +14,7 @@ import sys
|
|
|
14
14
|
from datetime import datetime, timezone
|
|
15
15
|
from pathlib import Path
|
|
16
16
|
|
|
17
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
17
18
|
from tools.workflow_cli.models import RunStatus, WorkId
|
|
18
19
|
from tools.workflow_cli.output import EXIT_CONFLICT
|
|
19
20
|
|
|
@@ -53,7 +54,7 @@ def write_active_pointer(base_path: Path, work_id: str, reason: str = "workflow_
|
|
|
53
54
|
f"updated_at: {updated_at}\n"
|
|
54
55
|
f"reason: {reason}\n"
|
|
55
56
|
)
|
|
56
|
-
path
|
|
57
|
+
atomic_write_text(path, content)
|
|
57
58
|
|
|
58
59
|
|
|
59
60
|
def _validate_work_id(raw: str) -> str:
|
|
@@ -122,7 +123,9 @@ def generate_work_id(
|
|
|
122
123
|
words = [f"run-{h}"]
|
|
123
124
|
|
|
124
125
|
candidate = "-".join(words[:5])
|
|
125
|
-
|
|
126
|
+
# Truncate first, then strip dashes: stripping before truncation can leave a
|
|
127
|
+
# trailing "-" at the slice boundary, producing an invalid WorkId.
|
|
128
|
+
candidate = re.sub(r"-+", "-", candidate)[:max_slug_len].strip("-")
|
|
126
129
|
if len(candidate) < 2:
|
|
127
130
|
import hashlib
|
|
128
131
|
h = hashlib.md5(requirement.encode()).hexdigest()[:8]
|
|
@@ -138,7 +141,8 @@ def generate_work_id(
|
|
|
138
141
|
|
|
139
142
|
for n in range(2, 100):
|
|
140
143
|
suffix = f"-{n}"
|
|
141
|
-
|
|
144
|
+
alt_candidate = candidate[:max_slug_len - len(suffix)].rstrip("-")
|
|
145
|
+
alt = f"{prefix}{alt_candidate}{suffix}"
|
|
142
146
|
if not (base_path / ".req-to-plan" / alt).exists():
|
|
143
147
|
return alt
|
|
144
148
|
|
|
@@ -234,12 +238,18 @@ def _prev_stage(stage):
|
|
|
234
238
|
def _seed_for_stage(stage, tier, upstream_summary: str = "", context_summary: str = "") -> str:
|
|
235
239
|
"""Build the seed text for a stage content file: template + upstream summary + context pack."""
|
|
236
240
|
from tools.workflow_cli.stage_templates import template_for
|
|
241
|
+
from tools.workflow_cli.markdown import strip_readonly_sections
|
|
237
242
|
base = tier.base if tier is not None else None
|
|
238
243
|
text = template_for(stage, base) if base is not None else ""
|
|
239
|
-
|
|
244
|
+
# Upstream artifacts persist the read-only blocks they were seeded with
|
|
245
|
+
# (nothing strips them at store time). Strip them here, like every other
|
|
246
|
+
# consumer (gates/trace), so the freshly injected Upstream Summary / Project
|
|
247
|
+
# Context wrappers below are not duplicated or accumulated across stages.
|
|
248
|
+
upstream_summary = strip_readonly_sections(upstream_summary).strip()
|
|
249
|
+
if upstream_summary:
|
|
240
250
|
text += (
|
|
241
251
|
"\n## Upstream Summary (read-only)\n"
|
|
242
|
-
+ upstream_summary
|
|
252
|
+
+ upstream_summary
|
|
243
253
|
+ "\n<!-- /r2p-read-only -->\n"
|
|
244
254
|
)
|
|
245
255
|
if context_summary.strip():
|
|
@@ -9,6 +9,7 @@ from __future__ import annotations
|
|
|
9
9
|
from datetime import datetime, timezone
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
12
13
|
from tools.workflow_cli.models import Stage, STAGE_ARTIFACT_MAP
|
|
13
14
|
|
|
14
15
|
|
|
@@ -98,7 +99,9 @@ def write_artifact(
|
|
|
98
99
|
fm, _ = _parse_frontmatter(path.read_text(encoding="utf-8"))
|
|
99
100
|
created_at = fm.get("r2p_created_at", now)
|
|
100
101
|
full_text = _frontmatter(stage, version, status, created_at, now) + content
|
|
101
|
-
|
|
102
|
+
# Atomic write: a crash mid-write must not leave a truncated artifact that
|
|
103
|
+
# fails to parse on the next load. Write a unique sibling temp then replace.
|
|
104
|
+
atomic_write_text(path, full_text)
|
|
102
105
|
return path
|
|
103
106
|
|
|
104
107
|
|
|
@@ -225,4 +228,4 @@ class ArtifactManager:
|
|
|
225
228
|
f"r2p_replaced_by: {replaced_by}\n"
|
|
226
229
|
f"---\n\n"
|
|
227
230
|
) + body
|
|
228
|
-
path
|
|
231
|
+
atomic_write_text(path, content)
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Atomic filesystem write helpers."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import os
|
|
5
|
+
import secrets
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def atomic_write_text(path: Path, content: str, *, encoding: str = "utf-8") -> None:
|
|
10
|
+
"""Write text via a unique sibling temp file, then atomically replace path."""
|
|
11
|
+
tmp_path, fd = _open_unique_sibling_tmp(path)
|
|
12
|
+
try:
|
|
13
|
+
with os.fdopen(fd, "w", encoding=encoding) as tmp:
|
|
14
|
+
fd = -1
|
|
15
|
+
tmp.write(content)
|
|
16
|
+
os.replace(tmp_path, path)
|
|
17
|
+
tmp_path = None
|
|
18
|
+
finally:
|
|
19
|
+
if fd != -1:
|
|
20
|
+
os.close(fd)
|
|
21
|
+
if tmp_path is not None:
|
|
22
|
+
try:
|
|
23
|
+
tmp_path.unlink()
|
|
24
|
+
except FileNotFoundError:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _open_unique_sibling_tmp(path: Path) -> tuple[Path, int]:
|
|
29
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
30
|
+
if hasattr(os, "O_CLOEXEC"):
|
|
31
|
+
flags |= os.O_CLOEXEC
|
|
32
|
+
if hasattr(os, "O_NOFOLLOW"):
|
|
33
|
+
flags |= os.O_NOFOLLOW
|
|
34
|
+
|
|
35
|
+
last_error: FileExistsError | None = None
|
|
36
|
+
for _ in range(100):
|
|
37
|
+
candidate = path.with_name(
|
|
38
|
+
f".{path.name}.{os.getpid()}.{secrets.token_hex(8)}.tmp"
|
|
39
|
+
)
|
|
40
|
+
try:
|
|
41
|
+
return candidate, os.open(candidate, flags, 0o666)
|
|
42
|
+
except FileExistsError as exc:
|
|
43
|
+
last_error = exc
|
|
44
|
+
|
|
45
|
+
raise FileExistsError(f"Could not create unique temp file for {path}") from last_error
|
|
@@ -231,8 +231,8 @@ def _cmd_run_start(args):
|
|
|
231
231
|
link_results = []
|
|
232
232
|
if repo_path is not None:
|
|
233
233
|
from tools.workflow_cli.link_expander import expand_links
|
|
234
|
-
# Local relative links expand; HTTP
|
|
235
|
-
link_results = expand_links(requirement, base_path=repo_path
|
|
234
|
+
# Local relative links expand; HTTP URLs are recorded as external references only.
|
|
235
|
+
link_results = expand_links(requirement, base_path=repo_path)
|
|
236
236
|
|
|
237
237
|
# Tier estimation
|
|
238
238
|
tier_estimate, evidence = estimate_tier(requirement, repo_path=repo_path, link_results=link_results)
|
|
@@ -473,6 +473,19 @@ def _cmd_run_reopen(args):
|
|
|
473
473
|
if cp_stage_idx < target_idx:
|
|
474
474
|
new_record.approved_checkpoints.append(cp)
|
|
475
475
|
|
|
476
|
+
# Repopulate active_artifacts for copied stages so the reopened record
|
|
477
|
+
# matches the on-disk artifacts and approved checkpoints.
|
|
478
|
+
for cp in new_record.approved_checkpoints:
|
|
479
|
+
artifact_name = STAGE_ARTIFACT_MAP.get(cp.stage)
|
|
480
|
+
if artifact_name and (new_run_dir / artifact_name).exists():
|
|
481
|
+
upsert_active_artifact(
|
|
482
|
+
new_record,
|
|
483
|
+
stage=cp.stage,
|
|
484
|
+
artifact=cp.artifact,
|
|
485
|
+
version=cp.version,
|
|
486
|
+
status="approved",
|
|
487
|
+
)
|
|
488
|
+
|
|
476
489
|
new_mgr = RunStateManager(new_run_dir)
|
|
477
490
|
new_mgr.save(new_record)
|
|
478
491
|
|
|
@@ -723,14 +736,6 @@ def _cmd_tier_lock(args):
|
|
|
723
736
|
record.tier_estimate = TierEstimate(base=TierBase.LIGHT, modifiers=frozenset())
|
|
724
737
|
|
|
725
738
|
if args.override_floor:
|
|
726
|
-
if not args.confirm:
|
|
727
|
-
print_and_exit(
|
|
728
|
-
format_error(
|
|
729
|
-
"Overriding floor requires --confirm flag as well",
|
|
730
|
-
exit_code=EXIT_CLI_ERR,
|
|
731
|
-
),
|
|
732
|
-
EXIT_CLI_ERR,
|
|
733
|
-
)
|
|
734
739
|
from tools.workflow_cli.models import TierEstimate
|
|
735
740
|
record.tier_locked = TierEstimate(base=base, modifiers=modifiers)
|
|
736
741
|
else:
|
|
@@ -808,11 +813,12 @@ def _cmd_tier_escalate(args):
|
|
|
808
813
|
active_item=record.current_stage.value,
|
|
809
814
|
)
|
|
810
815
|
|
|
811
|
-
# Revoke affected bundle authorizations that cover high-tier stages
|
|
816
|
+
# Revoke affected bundle authorizations that cover high-tier stages.
|
|
817
|
+
# Reuse the gates definition so bundle revocation stays in lockstep with
|
|
818
|
+
# forced-review behavior if the high-risk modifier set ever changes.
|
|
812
819
|
from tools.workflow_cli.gates import _FORCED_REVIEW_MODIFIERS
|
|
813
|
-
high_modifiers = {TierModifier.MIGRATION, TierModifier.SAFETY, TierModifier.CROSS_PROJECT}
|
|
814
820
|
from datetime import datetime, timezone
|
|
815
|
-
if modifier in
|
|
821
|
+
if modifier in _FORCED_REVIEW_MODIFIERS:
|
|
816
822
|
revoke_ts = datetime.now(timezone.utc).isoformat()
|
|
817
823
|
from tools.workflow_cli.models import STAGE_REQUIRED_UPSTREAM_CHECKPOINTS
|
|
818
824
|
affected_stages = {Stage.DESIGN, Stage.SPEC, Stage.PLAN}
|
|
@@ -10,6 +10,8 @@ import json
|
|
|
10
10
|
import hashlib
|
|
11
11
|
import shutil
|
|
12
12
|
import shlex
|
|
13
|
+
import stat
|
|
14
|
+
from dataclasses import dataclass
|
|
13
15
|
from datetime import datetime, timezone
|
|
14
16
|
from pathlib import Path
|
|
15
17
|
from typing import Any
|
|
@@ -40,6 +42,21 @@ KNOWN_OBSOLETE_PLATFORM_TARGETS = {
|
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
|
|
45
|
+
@dataclass
|
|
46
|
+
class _FileSnapshot:
|
|
47
|
+
path: Path
|
|
48
|
+
content: bytes
|
|
49
|
+
mode: int
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class _InstallSnapshot:
|
|
54
|
+
manifest_path: Path
|
|
55
|
+
manifest_text: str
|
|
56
|
+
files: list[_FileSnapshot]
|
|
57
|
+
backup_files: list[_FileSnapshot]
|
|
58
|
+
|
|
59
|
+
|
|
43
60
|
def _now_ts() -> str:
|
|
44
61
|
"""Return UTC timestamp for backup filenames."""
|
|
45
62
|
return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S")
|
|
@@ -84,17 +101,24 @@ class InstallService:
|
|
|
84
101
|
)
|
|
85
102
|
|
|
86
103
|
manifest_path = self._manifest_path(platform)
|
|
87
|
-
|
|
88
|
-
|
|
104
|
+
backup_dir = self.manifest_root / "install" / "backups" / platform
|
|
105
|
+
self._validate_install_plan(platform, manifest_path, backup_dir)
|
|
106
|
+
prior_install = (
|
|
107
|
+
self._capture_install_snapshot(platform, manifest_path)
|
|
108
|
+
if manifest_path.exists()
|
|
109
|
+
else None
|
|
110
|
+
)
|
|
89
111
|
|
|
90
112
|
installed_paths: list[str] = []
|
|
91
113
|
backups: list[dict[str, str]] = []
|
|
92
114
|
written: list[Path] = []
|
|
93
|
-
|
|
115
|
+
manifest_written = False
|
|
94
116
|
|
|
95
117
|
try:
|
|
118
|
+
if prior_install is not None:
|
|
119
|
+
self.uninstall(platform)
|
|
120
|
+
|
|
96
121
|
bin_dir = self.manifest_root / "bin"
|
|
97
|
-
bin_dir.mkdir(parents=True, exist_ok=True)
|
|
98
122
|
|
|
99
123
|
# Copy bin scripts
|
|
100
124
|
for src in sorted(self.repo_root.glob("tools/r2p-*")):
|
|
@@ -104,7 +128,7 @@ class InstallService:
|
|
|
104
128
|
src.read_text(encoding="utf-8"),
|
|
105
129
|
self.repo_root,
|
|
106
130
|
)
|
|
107
|
-
|
|
131
|
+
self._write_managed_file(
|
|
108
132
|
dest, content, backups, installed_paths, written, backup_dir
|
|
109
133
|
)
|
|
110
134
|
shutil.copymode(str(src), str(dest))
|
|
@@ -120,7 +144,7 @@ class InstallService:
|
|
|
120
144
|
skill_src = template_dir / "SKILL.md"
|
|
121
145
|
skill_dest = platform_home / "skills" / "r2p" / "SKILL.md"
|
|
122
146
|
content = _render(skill_src.read_text(), R2P_VERSION, str(bin_dir))
|
|
123
|
-
|
|
147
|
+
self._write_managed_file(
|
|
124
148
|
skill_dest, content, backups, installed_paths, written, backup_dir
|
|
125
149
|
)
|
|
126
150
|
|
|
@@ -129,7 +153,7 @@ class InstallService:
|
|
|
129
153
|
for src in sorted(cmd_dir.glob("r2p-*.md")):
|
|
130
154
|
dest = platform_home / "commands" / src.name
|
|
131
155
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
132
|
-
|
|
156
|
+
self._write_managed_file(
|
|
133
157
|
dest, content, backups, installed_paths, written, backup_dir
|
|
134
158
|
)
|
|
135
159
|
|
|
@@ -139,7 +163,7 @@ class InstallService:
|
|
|
139
163
|
for src in sorted(skills_dir.glob("r2p-*/SKILL.md")):
|
|
140
164
|
dest = platform_home / "skills" / src.parent.name / "SKILL.md"
|
|
141
165
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
142
|
-
|
|
166
|
+
self._write_managed_file(
|
|
143
167
|
dest, content, backups, installed_paths, written, backup_dir
|
|
144
168
|
)
|
|
145
169
|
|
|
@@ -149,7 +173,7 @@ class InstallService:
|
|
|
149
173
|
for src in sorted(cmd_dir.glob("r2p-*.toml")):
|
|
150
174
|
dest = platform_home / "commands" / src.name
|
|
151
175
|
content = _render(src.read_text(), R2P_VERSION, str(bin_dir))
|
|
152
|
-
|
|
176
|
+
self._write_managed_file(
|
|
153
177
|
dest, content, backups, installed_paths, written, backup_dir
|
|
154
178
|
)
|
|
155
179
|
|
|
@@ -162,8 +186,16 @@ class InstallService:
|
|
|
162
186
|
"r2p_version": R2P_VERSION,
|
|
163
187
|
"schema_version": SCHEMA_VERSION,
|
|
164
188
|
}
|
|
189
|
+
self._validate_install_path(manifest_path, field="manifest")
|
|
165
190
|
manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
-
manifest_path.
|
|
191
|
+
tmp = manifest_path.with_name(manifest_path.name + ".tmp")
|
|
192
|
+
# The temp sibling shares the (validated) parent, but its own path is
|
|
193
|
+
# untrusted: reject a planted symlink so the atomic write cannot be
|
|
194
|
+
# redirected outside the manifest dir.
|
|
195
|
+
self._validate_install_path(tmp, field="manifest")
|
|
196
|
+
tmp.write_text(_dump_manifest(manifest), encoding="utf-8")
|
|
197
|
+
tmp.replace(manifest_path)
|
|
198
|
+
manifest_written = True
|
|
167
199
|
|
|
168
200
|
# Remove obsolete managed shared wrappers (e.g. a 0.1.2 r2p-adapt) that
|
|
169
201
|
# are no longer part of the current template set, across all manifests.
|
|
@@ -172,6 +204,12 @@ class InstallService:
|
|
|
172
204
|
|
|
173
205
|
except Exception:
|
|
174
206
|
# Rollback: remove written files, restore backups
|
|
207
|
+
if manifest_written:
|
|
208
|
+
# A pre-existing manifest is impossible here: a fresh install
|
|
209
|
+
# never had one, and a reinstall's uninstall already removed
|
|
210
|
+
# it — prior-manifest restoration is owned by
|
|
211
|
+
# _restore_install_snapshot below.
|
|
212
|
+
manifest_path.unlink(missing_ok=True)
|
|
175
213
|
for path in reversed(written):
|
|
176
214
|
try:
|
|
177
215
|
path.unlink(missing_ok=True)
|
|
@@ -183,6 +221,9 @@ class InstallService:
|
|
|
183
221
|
if backup_path.exists():
|
|
184
222
|
target_path.parent.mkdir(parents=True, exist_ok=True)
|
|
185
223
|
shutil.copy2(str(backup_path), str(target_path))
|
|
224
|
+
backup_path.unlink(missing_ok=True)
|
|
225
|
+
if prior_install is not None:
|
|
226
|
+
self._restore_install_snapshot(prior_install)
|
|
186
227
|
raise
|
|
187
228
|
|
|
188
229
|
def uninstall(self, platform: str) -> dict:
|
|
@@ -488,31 +529,39 @@ class InstallService:
|
|
|
488
529
|
if platform not in SUPPORTED_PLATFORMS:
|
|
489
530
|
raise ValueError(f"unsafe_manifest: unsupported_platform {platform!r}")
|
|
490
531
|
|
|
532
|
+
targets = set(self._install_targets(platform))
|
|
533
|
+
bin_dir = self.manifest_root / "bin"
|
|
534
|
+
targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
|
|
535
|
+
|
|
536
|
+
platform_home = self.platform_homes[platform]
|
|
537
|
+
for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
|
|
538
|
+
targets.add(platform_home.joinpath(*parts))
|
|
539
|
+
|
|
540
|
+
return targets
|
|
541
|
+
|
|
542
|
+
def _install_targets(self, platform: str) -> list[Path]:
|
|
491
543
|
bin_dir = self.manifest_root / "bin"
|
|
492
|
-
targets =
|
|
544
|
+
targets = [
|
|
493
545
|
bin_dir / src.name
|
|
494
546
|
for src in sorted(self.repo_root.glob("tools/r2p-*"))
|
|
495
547
|
if src.is_file()
|
|
496
|
-
|
|
497
|
-
targets.update(bin_dir / name for name in KNOWN_OBSOLETE_SHARED_WRAPPERS)
|
|
548
|
+
]
|
|
498
549
|
|
|
499
550
|
template_dir = (
|
|
500
551
|
self.repo_root / "tools" / "workflow_cli" / "agent_templates" / platform
|
|
501
552
|
)
|
|
502
553
|
platform_home = self.platform_homes[platform]
|
|
503
|
-
for parts in KNOWN_OBSOLETE_PLATFORM_TARGETS.get(platform, ()):
|
|
504
|
-
targets.add(platform_home.joinpath(*parts))
|
|
505
554
|
|
|
506
555
|
if platform == "claude":
|
|
507
|
-
targets.
|
|
556
|
+
targets.append(platform_home / "skills" / "r2p" / "SKILL.md")
|
|
508
557
|
for src in sorted((template_dir / "commands").glob("r2p-*.md")):
|
|
509
|
-
targets.
|
|
558
|
+
targets.append(platform_home / "commands" / src.name)
|
|
510
559
|
elif platform == "codex":
|
|
511
560
|
for src in sorted((template_dir / "skills").glob("r2p-*/SKILL.md")):
|
|
512
|
-
targets.
|
|
561
|
+
targets.append(platform_home / "skills" / src.parent.name / "SKILL.md")
|
|
513
562
|
elif platform == "gemini":
|
|
514
563
|
for src in sorted((template_dir / "commands").glob("r2p-*.toml")):
|
|
515
|
-
targets.
|
|
564
|
+
targets.append(platform_home / "commands" / src.name)
|
|
516
565
|
|
|
517
566
|
return targets
|
|
518
567
|
|
|
@@ -703,6 +752,99 @@ class InstallService:
|
|
|
703
752
|
except (OSError, UnicodeDecodeError, ValueError):
|
|
704
753
|
return None
|
|
705
754
|
|
|
755
|
+
def _capture_install_snapshot(
|
|
756
|
+
self,
|
|
757
|
+
platform: str,
|
|
758
|
+
manifest_path: Path,
|
|
759
|
+
) -> _InstallSnapshot:
|
|
760
|
+
manifest = _load_manifest(manifest_path)
|
|
761
|
+
self._validate_manifest_for_uninstall(platform, manifest)
|
|
762
|
+
files: list[_FileSnapshot] = []
|
|
763
|
+
backup_files: list[_FileSnapshot] = []
|
|
764
|
+
|
|
765
|
+
for path_str in manifest.get("installed_paths", []):
|
|
766
|
+
snapshot = self._snapshot_file(Path(path_str))
|
|
767
|
+
if snapshot is not None:
|
|
768
|
+
files.append(snapshot)
|
|
769
|
+
|
|
770
|
+
for backup in manifest.get("backups", []):
|
|
771
|
+
snapshot = self._snapshot_file(Path(backup["backup"]))
|
|
772
|
+
if snapshot is not None:
|
|
773
|
+
backup_files.append(snapshot)
|
|
774
|
+
|
|
775
|
+
return _InstallSnapshot(
|
|
776
|
+
manifest_path=manifest_path,
|
|
777
|
+
manifest_text=manifest_path.read_text(encoding="utf-8"),
|
|
778
|
+
files=files,
|
|
779
|
+
backup_files=backup_files,
|
|
780
|
+
)
|
|
781
|
+
|
|
782
|
+
def _snapshot_file(self, path: Path) -> _FileSnapshot | None:
|
|
783
|
+
if not path.is_file():
|
|
784
|
+
return None
|
|
785
|
+
return _FileSnapshot(
|
|
786
|
+
path=path,
|
|
787
|
+
content=path.read_bytes(),
|
|
788
|
+
mode=stat.S_IMODE(path.stat().st_mode),
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
def _restore_install_snapshot(self, snapshot: _InstallSnapshot) -> None:
|
|
792
|
+
for file_snapshot in snapshot.files + snapshot.backup_files:
|
|
793
|
+
file_snapshot.path.parent.mkdir(parents=True, exist_ok=True)
|
|
794
|
+
file_snapshot.path.write_bytes(file_snapshot.content)
|
|
795
|
+
try:
|
|
796
|
+
file_snapshot.path.chmod(file_snapshot.mode)
|
|
797
|
+
except OSError:
|
|
798
|
+
pass
|
|
799
|
+
snapshot.manifest_path.parent.mkdir(parents=True, exist_ok=True)
|
|
800
|
+
snapshot.manifest_path.write_text(snapshot.manifest_text, encoding="utf-8")
|
|
801
|
+
|
|
802
|
+
def _validate_install_path(self, path: Path, *, field: str) -> None:
|
|
803
|
+
"""Reject install writes that would follow untrusted symlinks."""
|
|
804
|
+
if path.is_symlink():
|
|
805
|
+
raise ValueError(f"unsafe_install: {field}_is_symlink")
|
|
806
|
+
normalized = self._normalize_without_resolving_symlinks(path)
|
|
807
|
+
try:
|
|
808
|
+
self._reject_symlinked_ancestors(normalized, field=field)
|
|
809
|
+
except ValueError as exc:
|
|
810
|
+
raise ValueError(
|
|
811
|
+
str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
|
|
812
|
+
) from exc
|
|
813
|
+
|
|
814
|
+
def _validate_install_backup_dir(self, backup_dir: Path) -> None:
|
|
815
|
+
probe = backup_dir / "__r2p_probe__"
|
|
816
|
+
normalized = self._normalize_without_resolving_symlinks(probe)
|
|
817
|
+
try:
|
|
818
|
+
self._reject_symlinked_ancestors(normalized, field="backup_dir")
|
|
819
|
+
except ValueError as exc:
|
|
820
|
+
raise ValueError(
|
|
821
|
+
str(exc).replace("unsafe_manifest:", "unsafe_install:", 1)
|
|
822
|
+
) from exc
|
|
823
|
+
|
|
824
|
+
def _validate_install_plan(
|
|
825
|
+
self,
|
|
826
|
+
platform: str,
|
|
827
|
+
manifest_path: Path,
|
|
828
|
+
backup_dir: Path,
|
|
829
|
+
) -> None:
|
|
830
|
+
for target in self._install_targets(platform):
|
|
831
|
+
self._validate_install_path(target, field="install_destination")
|
|
832
|
+
self._validate_install_path(manifest_path, field="manifest")
|
|
833
|
+
self._validate_install_backup_dir(backup_dir)
|
|
834
|
+
|
|
835
|
+
def _write_managed_file(
|
|
836
|
+
self,
|
|
837
|
+
dest: Path,
|
|
838
|
+
content: str,
|
|
839
|
+
backups: list[dict[str, str]],
|
|
840
|
+
installed_paths: list[str],
|
|
841
|
+
written: list[Path],
|
|
842
|
+
backup_dir: Path,
|
|
843
|
+
) -> None:
|
|
844
|
+
self._validate_install_path(dest, field="install_destination")
|
|
845
|
+
self._validate_install_backup_dir(backup_dir)
|
|
846
|
+
_safe_write(dest, content, backups, installed_paths, written, backup_dir)
|
|
847
|
+
|
|
706
848
|
|
|
707
849
|
# ---------------------------------------------------------------------------
|
|
708
850
|
# Internal helpers
|
|
@@ -2,14 +2,11 @@ from __future__ import annotations
|
|
|
2
2
|
from dataclasses import dataclass
|
|
3
3
|
from enum import Enum
|
|
4
4
|
from pathlib import Path
|
|
5
|
-
from urllib import request, error as urllib_error
|
|
6
5
|
import re
|
|
7
6
|
|
|
8
7
|
|
|
9
8
|
class LinkStatus(str, Enum):
|
|
10
|
-
|
|
11
|
-
UNREACHABLE = "unreachable"
|
|
12
|
-
REQUIRES_AUTH = "requires_auth"
|
|
9
|
+
EXTERNAL = "external" # http(s) link recorded as an external reference; not fetched
|
|
13
10
|
LOCAL_FOUND = "local_found"
|
|
14
11
|
LOCAL_MISSING = "local_missing"
|
|
15
12
|
|
|
@@ -45,20 +42,6 @@ def extract_links(text: str) -> list[str]:
|
|
|
45
42
|
return result
|
|
46
43
|
|
|
47
44
|
|
|
48
|
-
def _fetch_url(url: str) -> LinkExpansionResult:
|
|
49
|
-
try:
|
|
50
|
-
req = request.Request(url, headers={"User-Agent": "r2p-link-expander/1.0"})
|
|
51
|
-
with request.urlopen(req, timeout=5) as resp:
|
|
52
|
-
content = resp.read(500).decode("utf-8", errors="replace")
|
|
53
|
-
return LinkExpansionResult(url=url, status=LinkStatus.REACHABLE, content_preview=content)
|
|
54
|
-
except urllib_error.HTTPError as e:
|
|
55
|
-
if e.code in (401, 403):
|
|
56
|
-
return LinkExpansionResult(url=url, status=LinkStatus.REQUIRES_AUTH, error=str(e))
|
|
57
|
-
return LinkExpansionResult(url=url, status=LinkStatus.UNREACHABLE, error=str(e))
|
|
58
|
-
except Exception as e:
|
|
59
|
-
return LinkExpansionResult(url=url, status=LinkStatus.UNREACHABLE, error=str(e))
|
|
60
|
-
|
|
61
|
-
|
|
62
45
|
def _local_preview_block_reason(path_str: str, candidate: Path, base: Path | None) -> str | None:
|
|
63
46
|
try:
|
|
64
47
|
relative = candidate.relative_to(base) if base is not None else Path(path_str)
|
|
@@ -109,8 +92,14 @@ def _expand_local(path_str: str, base_path: Path | None) -> LinkExpansionResult:
|
|
|
109
92
|
def expand_links(
|
|
110
93
|
text: str,
|
|
111
94
|
base_path: Path | None = None,
|
|
112
|
-
fetch_urls: bool = True,
|
|
113
95
|
) -> list[LinkExpansionResult]:
|
|
96
|
+
"""Expand links found in requirement text.
|
|
97
|
+
|
|
98
|
+
Local relative links are read for context. http(s) URLs are recorded as
|
|
99
|
+
external references only: r2p never makes outbound requests for URLs found in
|
|
100
|
+
(untrusted) requirement text, so a link cannot drive the tool to reach
|
|
101
|
+
cloud-metadata endpoints, localhost, or internal services.
|
|
102
|
+
"""
|
|
114
103
|
results = []
|
|
115
104
|
links = extract_links(text)
|
|
116
105
|
seen: set[str] = set()
|
|
@@ -119,12 +108,7 @@ def expand_links(
|
|
|
119
108
|
continue
|
|
120
109
|
seen.add(link)
|
|
121
110
|
if link.startswith("http://") or link.startswith("https://"):
|
|
122
|
-
|
|
123
|
-
results.append(_fetch_url(link))
|
|
124
|
-
else:
|
|
125
|
-
results.append(LinkExpansionResult(
|
|
126
|
-
url=link, status=LinkStatus.UNREACHABLE, error="URL fetching disabled"
|
|
127
|
-
))
|
|
111
|
+
results.append(LinkExpansionResult(url=link, status=LinkStatus.EXTERNAL))
|
|
128
112
|
else:
|
|
129
113
|
results.append(_expand_local(link, base_path))
|
|
130
114
|
return results
|
|
@@ -8,6 +8,7 @@ from datetime import datetime, timezone
|
|
|
8
8
|
from pathlib import Path
|
|
9
9
|
from typing import Optional
|
|
10
10
|
|
|
11
|
+
from tools.workflow_cli.atomic import atomic_write_text
|
|
11
12
|
from tools.workflow_cli.models import (
|
|
12
13
|
ActiveArtifact,
|
|
13
14
|
BundleAuthorization,
|
|
@@ -227,11 +228,6 @@ def _escape_cell(val: str) -> str:
|
|
|
227
228
|
return val.replace("\\", "\\\\").replace("|", "\\|")
|
|
228
229
|
|
|
229
230
|
|
|
230
|
-
def _unescape_cell(val: str) -> str:
|
|
231
|
-
"""Unescape backslashes and pipes in a parsed markdown table cell."""
|
|
232
|
-
return val.replace("\\|", "|").replace("\\\\", "\\")
|
|
233
|
-
|
|
234
|
-
|
|
235
231
|
def _split_cells(row_text: str) -> list[str]:
|
|
236
232
|
"""Split pipe-delimited row, honouring \\| and \\\\ escape sequences."""
|
|
237
233
|
cells: list[str] = []
|
|
@@ -608,7 +604,10 @@ class RunStateManager:
|
|
|
608
604
|
|
|
609
605
|
def save(self, record: RunRecord) -> None:
|
|
610
606
|
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
611
|
-
|
|
607
|
+
text = run_record_to_markdown(record)
|
|
608
|
+
# Atomic write: a crash mid-write must not leave a truncated run.md that
|
|
609
|
+
# bricks the run. Write a unique sibling temp then atomically replace.
|
|
610
|
+
atomic_write_text(self.run_path, text)
|
|
612
611
|
|
|
613
612
|
def load(self) -> RunRecord:
|
|
614
613
|
if not self.run_path.exists():
|
|
@@ -106,7 +106,7 @@ def compute_floor(
|
|
|
106
106
|
or repo.is_monorepo
|
|
107
107
|
or repo.module_count > module_threshold
|
|
108
108
|
or _has_multi_repo_refs(requirement_text)
|
|
109
|
-
or any(r.status
|
|
109
|
+
or any(r.status == LinkStatus.EXTERNAL for r in link_results)
|
|
110
110
|
):
|
|
111
111
|
base = TierBase.STANDARD
|
|
112
112
|
|
|
@@ -1 +1 @@
|
|
|
1
|
-
R2P_VERSION = "0.4.
|
|
1
|
+
R2P_VERSION = "0.4.5"
|