workflow-loop 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.
- workflow_loop/__init__.py +6 -0
- workflow_loop/acceptance_records.py +338 -0
- workflow_loop/artifact_paths.py +278 -0
- workflow_loop/artifact_validation.py +1738 -0
- workflow_loop/bug_record.py +203 -0
- workflow_loop/cli.py +3257 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
- workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
- workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
- workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
- workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
- workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
- workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
- workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
- workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
- workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
- workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
- workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
- workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
- workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
- workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
- workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
- workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
- workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
- workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
- workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
- workflow_loop/data/Template_Repository/impl/impl.md +112 -0
- workflow_loop/data/Template_Repository/qa/test.md +102 -0
- workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
- workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
- workflow_loop/data/Template_Repository/spec/spec.md +222 -0
- workflow_loop/data/Template_Repository/spike/spike.md +135 -0
- workflow_loop/installer.py +632 -0
- workflow_loop/journal.py +78 -0
- workflow_loop/path_composer.py +152 -0
- workflow_loop/process_runner.py +176 -0
- workflow_loop/project.py +397 -0
- workflow_loop/role_doc.py +133 -0
- workflow_loop/rollback.py +1738 -0
- workflow_loop/spike_validation.py +379 -0
- workflow_loop/stage_materials.py +169 -0
- workflow_loop/stages/__init__.py +45 -0
- workflow_loop/stages/base.py +164 -0
- workflow_loop/stages/stages.py +1191 -0
- workflow_loop/state.py +582 -0
- workflow_loop/test_entry.py +123 -0
- workflow_loop/test_execution.py +619 -0
- workflow_loop/test_mapping.py +568 -0
- workflow_loop/test_runner.py +134 -0
- workflow_loop/topic.py +114 -0
- workflow_loop/topic_relations.py +202 -0
- workflow_loop/traceability.py +533 -0
- workflow_loop/verification.py +971 -0
- workflow_loop-0.1.0.dist-info/METADATA +187 -0
- workflow_loop-0.1.0.dist-info/RECORD +60 -0
- workflow_loop-0.1.0.dist-info/WHEEL +5 -0
- workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
- workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
- workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1738 @@
|
|
|
1
|
+
"""按文件保存实施前内容,并在整个工作流中止时恢复。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import tempfile
|
|
11
|
+
from pathlib import PurePosixPath
|
|
12
|
+
|
|
13
|
+
from . import artifact_paths as artifact_paths_mod
|
|
14
|
+
from . import project as project_mod
|
|
15
|
+
from . import state as state_mod
|
|
16
|
+
from . import test_entry as test_entry_mod
|
|
17
|
+
from . import verification as verification_mod
|
|
18
|
+
from .topic import topic_paths
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ROLLBACK_ROOT = ".workflow_loop/rollback"
|
|
22
|
+
MANIFEST_VERSION = 1
|
|
23
|
+
PROCESS_ROOTS = {"spec", "acceptance", "qa", "impl", "bug", ".workflow_loop", ".git"}
|
|
24
|
+
GLOB_CHARS = set("*?[]{}")
|
|
25
|
+
WORKFLOW_ID_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,159}$")
|
|
26
|
+
ABORT_ITEM_STATES = {"pending", "restoring", "restored"}
|
|
27
|
+
WINDOWS_RESERVED_NAMES = {
|
|
28
|
+
"CON",
|
|
29
|
+
"PRN",
|
|
30
|
+
"AUX",
|
|
31
|
+
"NUL",
|
|
32
|
+
*{f"COM{number}" for number in range(1, 10)},
|
|
33
|
+
*{f"LPT{number}" for number in range(1, 10)},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# 开工基线:受管正式文档目录与根级文件(清场范围与作废恢复范围共用同一份约定)
|
|
37
|
+
MANAGED_DOC_DIRS = ["spec", "acceptance", "qa", "impl", "bug"]
|
|
38
|
+
MANAGED_DOC_FILES = [artifact_paths_mod.TRACEABILITY_DOC]
|
|
39
|
+
# 清场开工事务记录:未完成时任何日常命令都不得继续正常流程
|
|
40
|
+
START_TRANSACTION_FILE = ".workflow_loop/start_transaction.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _sha256_bytes(content: bytes) -> str:
|
|
44
|
+
return hashlib.sha256(content).hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _sha256_file(path: str) -> str:
|
|
48
|
+
digest = hashlib.sha256()
|
|
49
|
+
with open(path, "rb") as stream:
|
|
50
|
+
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
|
51
|
+
digest.update(chunk)
|
|
52
|
+
return digest.hexdigest()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _copy_file(source: str, destination: str) -> None:
|
|
56
|
+
with open(source, "rb") as source_stream, open(destination, "wb") as destination_stream:
|
|
57
|
+
shutil.copyfileobj(source_stream, destination_stream, length=1024 * 1024)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _validated_workflow_id(workflow_id: str) -> str:
|
|
61
|
+
"""校验工作流编号,禁止它影响回退目录之外的路径。"""
|
|
62
|
+
if (
|
|
63
|
+
not isinstance(workflow_id, str)
|
|
64
|
+
or workflow_id in {".", ".."}
|
|
65
|
+
or WORKFLOW_ID_PATTERN.fullmatch(workflow_id) is None
|
|
66
|
+
):
|
|
67
|
+
raise ValueError(f"工作流编号不安全:{workflow_id!r}")
|
|
68
|
+
return workflow_id
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _safe_project_relative_path(
|
|
72
|
+
project_root: str,
|
|
73
|
+
raw_path: str,
|
|
74
|
+
*,
|
|
75
|
+
purpose: str,
|
|
76
|
+
allow_directory: bool = False,
|
|
77
|
+
) -> str:
|
|
78
|
+
"""把清单路径限制为项目内、不经过符号链接的相对路径。"""
|
|
79
|
+
if not isinstance(raw_path, str):
|
|
80
|
+
raise ValueError(f"{purpose}不是字符串路径:{raw_path!r}")
|
|
81
|
+
value = raw_path.strip().strip("`").replace("\\", "/")
|
|
82
|
+
if (
|
|
83
|
+
not value
|
|
84
|
+
or "\x00" in value
|
|
85
|
+
or re.match(r"^[A-Za-z]:", value)
|
|
86
|
+
or any(character in value for character in GLOB_CHARS)
|
|
87
|
+
):
|
|
88
|
+
raise ValueError(f"{purpose}不是安全的项目内相对路径:{raw_path!r}")
|
|
89
|
+
path = PurePosixPath(value)
|
|
90
|
+
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
|
91
|
+
raise ValueError(f"{purpose}不是安全的项目内相对路径:{raw_path!r}")
|
|
92
|
+
for part in path.parts:
|
|
93
|
+
windows_base = part.split(".", 1)[0].upper()
|
|
94
|
+
if (
|
|
95
|
+
any(character in part for character in '<>:"|')
|
|
96
|
+
or part.endswith((" ", "."))
|
|
97
|
+
or windows_base in WINDOWS_RESERVED_NAMES
|
|
98
|
+
):
|
|
99
|
+
raise ValueError(f"{purpose}不能在 Windows 上安全使用:{raw_path!r}")
|
|
100
|
+
|
|
101
|
+
normalized = path.as_posix()
|
|
102
|
+
project_real = os.path.realpath(project_root)
|
|
103
|
+
full_path = os.path.join(project_root, *path.parts)
|
|
104
|
+
parent_real = os.path.realpath(os.path.dirname(full_path) or project_root)
|
|
105
|
+
try:
|
|
106
|
+
inside_project = os.path.commonpath([project_real, parent_real]) == project_real
|
|
107
|
+
except ValueError:
|
|
108
|
+
inside_project = False
|
|
109
|
+
if not inside_project:
|
|
110
|
+
raise ValueError(f"{purpose}超出项目目录:{normalized}")
|
|
111
|
+
|
|
112
|
+
current = project_root
|
|
113
|
+
for part in path.parts[:-1]:
|
|
114
|
+
current = os.path.join(current, part)
|
|
115
|
+
if os.path.lexists(current) and os.path.islink(current):
|
|
116
|
+
raise ValueError(f"{purpose}经过符号链接:{normalized}")
|
|
117
|
+
if os.path.lexists(full_path):
|
|
118
|
+
if os.path.islink(full_path):
|
|
119
|
+
raise ValueError(f"{purpose}不能是符号链接:{normalized}")
|
|
120
|
+
if not allow_directory and not os.path.isfile(full_path):
|
|
121
|
+
raise ValueError(f"{purpose}必须指向普通文件:{normalized}")
|
|
122
|
+
return normalized
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _atomic_write_bytes(path: str, content: bytes) -> None:
|
|
126
|
+
directory = os.path.dirname(path) or "."
|
|
127
|
+
os.makedirs(directory, exist_ok=True)
|
|
128
|
+
descriptor, temp_path = tempfile.mkstemp(prefix=".workflow-write-", dir=directory)
|
|
129
|
+
try:
|
|
130
|
+
with os.fdopen(descriptor, "wb") as stream:
|
|
131
|
+
stream.write(content)
|
|
132
|
+
stream.flush()
|
|
133
|
+
os.fsync(stream.fileno())
|
|
134
|
+
os.replace(temp_path, path)
|
|
135
|
+
finally:
|
|
136
|
+
if os.path.exists(temp_path):
|
|
137
|
+
os.remove(temp_path)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _atomic_write_json(path: str, data: dict) -> bytes:
|
|
141
|
+
raw = json.dumps(data, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
|
|
142
|
+
_atomic_write_bytes(path, raw)
|
|
143
|
+
return raw
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _atomic_restore_file(source: str, destination: str, mode: int | None) -> None:
|
|
147
|
+
destination_dir = os.path.dirname(destination) or "."
|
|
148
|
+
os.makedirs(destination_dir, exist_ok=True)
|
|
149
|
+
descriptor, temp_path = tempfile.mkstemp(prefix=".workflow-rollback-", dir=destination_dir)
|
|
150
|
+
os.close(descriptor)
|
|
151
|
+
try:
|
|
152
|
+
_copy_file(source, temp_path)
|
|
153
|
+
if isinstance(mode, int):
|
|
154
|
+
os.chmod(temp_path, mode)
|
|
155
|
+
os.replace(temp_path, destination)
|
|
156
|
+
finally:
|
|
157
|
+
if os.path.exists(temp_path):
|
|
158
|
+
os.remove(temp_path)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _safe_backup_path(manifest_dir: str, raw_path: str, code_path: str) -> str:
|
|
162
|
+
if not isinstance(raw_path, str):
|
|
163
|
+
raise ValueError(f"回退清单缺少有效的文件副本路径:{code_path}")
|
|
164
|
+
value = raw_path.replace("\\", "/")
|
|
165
|
+
path = PurePosixPath(value)
|
|
166
|
+
if (
|
|
167
|
+
not value
|
|
168
|
+
or "\x00" in value
|
|
169
|
+
or re.match(r"^[A-Za-z]:", value)
|
|
170
|
+
or any(character in value for character in GLOB_CHARS)
|
|
171
|
+
or path.is_absolute()
|
|
172
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
173
|
+
):
|
|
174
|
+
raise ValueError(f"回退清单中的文件副本路径不安全:{code_path}")
|
|
175
|
+
full_path = os.path.join(manifest_dir, *path.parts)
|
|
176
|
+
manifest_real = os.path.realpath(manifest_dir)
|
|
177
|
+
backup_real = os.path.realpath(full_path)
|
|
178
|
+
try:
|
|
179
|
+
inside_manifest = os.path.commonpath([manifest_real, backup_real]) == manifest_real
|
|
180
|
+
except ValueError:
|
|
181
|
+
inside_manifest = False
|
|
182
|
+
if not inside_manifest:
|
|
183
|
+
raise ValueError(f"回退清单中的文件副本超出回退目录:{code_path}")
|
|
184
|
+
current = manifest_dir
|
|
185
|
+
for part in path.parts[:-1]:
|
|
186
|
+
current = os.path.join(current, part)
|
|
187
|
+
if os.path.lexists(current) and os.path.islink(current):
|
|
188
|
+
raise ValueError(f"回退清单中的文件副本路径经过符号链接:{code_path}")
|
|
189
|
+
if os.path.islink(full_path):
|
|
190
|
+
raise ValueError(f"实施前文件副本不能是符号链接:{code_path}")
|
|
191
|
+
return full_path
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _manifest_rel_path(workflow_id: str) -> str:
|
|
195
|
+
return f"{ROLLBACK_ROOT}/{_validated_workflow_id(workflow_id)}/impl/manifest.json"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _manifest_full_path(project_root: str, workflow_id: str) -> str:
|
|
199
|
+
return os.path.join(project_root, _manifest_rel_path(workflow_id))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _normalized_relative_path(project_root: str, raw_path: str) -> str:
|
|
203
|
+
if not isinstance(raw_path, str):
|
|
204
|
+
raise ValueError(f"代码修改计划包含无法定位的文件路径:{raw_path!r}")
|
|
205
|
+
value = raw_path.strip().strip("`").replace("\\", "/")
|
|
206
|
+
if not value or value in {"新增", "暂无", "无", "相关文件"}:
|
|
207
|
+
raise ValueError(f"代码修改计划包含无法定位的文件路径:{raw_path!r}")
|
|
208
|
+
if any(character in value for character in GLOB_CHARS):
|
|
209
|
+
raise ValueError(f"代码修改计划不能使用通配符:{value}")
|
|
210
|
+
path = PurePosixPath(value)
|
|
211
|
+
if path.is_absolute() or any(part in {"", ".", ".."} for part in path.parts):
|
|
212
|
+
raise ValueError(f"代码修改计划必须使用项目内相对路径:{value}")
|
|
213
|
+
if path.parts[0] in PROCESS_ROOTS or value in MANAGED_DOC_FILES:
|
|
214
|
+
raise ValueError(f"代码修改计划不能把工作流过程文档当成实施代码:{value}")
|
|
215
|
+
|
|
216
|
+
return _safe_project_relative_path(
|
|
217
|
+
project_root,
|
|
218
|
+
value,
|
|
219
|
+
purpose="代码修改计划路径",
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _section(content: str, heading: str) -> str:
|
|
224
|
+
match = re.search(
|
|
225
|
+
rf"^###\s+{re.escape(heading)}\s*$\n(.*?)(?=^###\s+|^##\s+|\Z)",
|
|
226
|
+
content,
|
|
227
|
+
re.MULTILINE | re.DOTALL,
|
|
228
|
+
)
|
|
229
|
+
if match is None:
|
|
230
|
+
raise ValueError(f"实施文档缺少“{heading}”")
|
|
231
|
+
return match.group(1).strip()
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _table_file_paths(section: str) -> list[str]:
|
|
235
|
+
lines = section.splitlines()
|
|
236
|
+
for index, line in enumerate(lines):
|
|
237
|
+
if not line.strip().startswith("|"):
|
|
238
|
+
continue
|
|
239
|
+
headers = [cell.strip() for cell in line.strip().strip("|").split("|")]
|
|
240
|
+
if "文件" not in headers:
|
|
241
|
+
continue
|
|
242
|
+
file_index = headers.index("文件")
|
|
243
|
+
paths: list[str] = []
|
|
244
|
+
for row in lines[index + 1 :]:
|
|
245
|
+
stripped = row.strip()
|
|
246
|
+
if not stripped.startswith("|"):
|
|
247
|
+
if paths:
|
|
248
|
+
break
|
|
249
|
+
continue
|
|
250
|
+
cells = [cell.strip() for cell in stripped.strip("|").split("|")]
|
|
251
|
+
if all(re.fullmatch(r"[-:]+", cell) for cell in cells):
|
|
252
|
+
continue
|
|
253
|
+
if len(cells) != len(headers):
|
|
254
|
+
raise ValueError("代码修改计划表的数据列数与表头不一致")
|
|
255
|
+
paths.append(cells[file_index])
|
|
256
|
+
if not paths:
|
|
257
|
+
raise ValueError("代码修改计划表没有任何文件")
|
|
258
|
+
return paths
|
|
259
|
+
raise ValueError("代码修改计划缺少包含“文件”列的表格")
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def planned_code_paths(project_root: str, topics: list[str]) -> list[str]:
|
|
263
|
+
paths: list[str] = []
|
|
264
|
+
for topic in topics:
|
|
265
|
+
relative_path = topic_paths(project_root, topic)["impl_doc"]
|
|
266
|
+
full_path = os.path.join(project_root, relative_path)
|
|
267
|
+
if not os.path.isfile(full_path):
|
|
268
|
+
raise ValueError(f"缺少主题实施文档:{relative_path}")
|
|
269
|
+
with open(full_path, "r", encoding="utf-8") as stream:
|
|
270
|
+
content = stream.read()
|
|
271
|
+
for raw_path in _table_file_paths(_section(content, "2.2 代码修改计划")):
|
|
272
|
+
paths.append(_normalized_relative_path(project_root, raw_path))
|
|
273
|
+
return sorted(set(paths))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def compute_plan_hash(project_root: str, topics: list[str]) -> str:
|
|
277
|
+
payload: list[str] = []
|
|
278
|
+
for topic in topics:
|
|
279
|
+
relative_path = topic_paths(project_root, topic)["impl_doc"]
|
|
280
|
+
full_path = os.path.join(project_root, relative_path)
|
|
281
|
+
with open(full_path, "r", encoding="utf-8") as stream:
|
|
282
|
+
section = _section(stream.read(), "2.2 代码修改计划")
|
|
283
|
+
payload.append(f"{topic}\n{section}")
|
|
284
|
+
return hashlib.sha256("\n\n".join(payload).encode("utf-8")).hexdigest()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _read_manifest(project_root: str, relative_path: str) -> tuple[dict, bytes]:
|
|
288
|
+
normalized = _safe_project_relative_path(
|
|
289
|
+
project_root,
|
|
290
|
+
relative_path,
|
|
291
|
+
purpose="回退清单路径",
|
|
292
|
+
)
|
|
293
|
+
full_path = os.path.join(project_root, normalized)
|
|
294
|
+
if not os.path.isfile(full_path):
|
|
295
|
+
raise ValueError(f"回退清单不存在:{normalized}")
|
|
296
|
+
with open(full_path, "rb") as stream:
|
|
297
|
+
raw = stream.read()
|
|
298
|
+
try:
|
|
299
|
+
data = json.loads(raw.decode("utf-8"))
|
|
300
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
301
|
+
raise ValueError(f"回退清单无法读取:{exc}") from exc
|
|
302
|
+
if not isinstance(data, dict):
|
|
303
|
+
raise ValueError(f"回退清单顶层必须是对象:{normalized}")
|
|
304
|
+
return data, raw
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _validated_manifest_entries(
|
|
308
|
+
project_root: str,
|
|
309
|
+
manifest: dict,
|
|
310
|
+
manifest_dir: str,
|
|
311
|
+
*,
|
|
312
|
+
allow_process_documents: bool,
|
|
313
|
+
) -> dict[str, dict]:
|
|
314
|
+
entries = manifest.get("entries")
|
|
315
|
+
if not isinstance(entries, dict):
|
|
316
|
+
raise ValueError("回退清单缺少文件记录")
|
|
317
|
+
normalized_entries: dict[str, dict] = {}
|
|
318
|
+
normalized_keys: set[str] = set()
|
|
319
|
+
for raw_path, entry in entries.items():
|
|
320
|
+
if not isinstance(raw_path, str) or not isinstance(entry, dict):
|
|
321
|
+
raise ValueError("回退清单包含无效文件记录")
|
|
322
|
+
if allow_process_documents:
|
|
323
|
+
path = _safe_project_relative_path(
|
|
324
|
+
project_root,
|
|
325
|
+
raw_path,
|
|
326
|
+
purpose="受管文件路径",
|
|
327
|
+
)
|
|
328
|
+
else:
|
|
329
|
+
path = _normalized_relative_path(project_root, raw_path)
|
|
330
|
+
comparison_key = path.casefold()
|
|
331
|
+
if comparison_key in normalized_keys:
|
|
332
|
+
raise ValueError(f"回退清单包含重复文件路径:{path}")
|
|
333
|
+
normalized_keys.add(comparison_key)
|
|
334
|
+
original_exists = entry.get("original_exists")
|
|
335
|
+
if not isinstance(original_exists, bool):
|
|
336
|
+
raise ValueError(f"回退清单缺少明确的原文件存在状态:{path}")
|
|
337
|
+
if original_exists:
|
|
338
|
+
backup_path = entry.get("backup_path")
|
|
339
|
+
if not backup_path:
|
|
340
|
+
raise ValueError(f"回退清单缺少文件副本位置:{path}")
|
|
341
|
+
full_backup = _safe_backup_path(manifest_dir, backup_path, path)
|
|
342
|
+
if not os.path.isfile(full_backup):
|
|
343
|
+
raise ValueError(f"实施前文件副本缺失:{path}")
|
|
344
|
+
content_hash = _sha256_file(full_backup)
|
|
345
|
+
if content_hash != entry.get("content_hash"):
|
|
346
|
+
raise ValueError(f"实施前文件副本内容已损坏:{path}")
|
|
347
|
+
elif entry.get("backup_path") is not None or entry.get("content_hash") is not None:
|
|
348
|
+
raise ValueError(f"原本不存在的文件不能带有内容副本:{path}")
|
|
349
|
+
normalized_entries[path] = dict(entry)
|
|
350
|
+
return normalized_entries
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def _validate_backup_entries(project_root: str, manifest: dict) -> None:
|
|
354
|
+
workflow_id = _validated_workflow_id(manifest.get("workflow_id", ""))
|
|
355
|
+
manifest_dir = os.path.dirname(_manifest_full_path(project_root, workflow_id))
|
|
356
|
+
_validated_manifest_entries(
|
|
357
|
+
project_root,
|
|
358
|
+
manifest,
|
|
359
|
+
manifest_dir,
|
|
360
|
+
allow_process_documents=False,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def validate_prepared(
|
|
365
|
+
project_root: str,
|
|
366
|
+
wf_state: state_mod.WorkflowState,
|
|
367
|
+
*,
|
|
368
|
+
require_current_plan: bool = True,
|
|
369
|
+
) -> tuple[bool, str, dict | None]:
|
|
370
|
+
rollback = wf_state.rollback
|
|
371
|
+
if not rollback.manifest_path or not rollback.manifest_hash:
|
|
372
|
+
return False, "尚未执行 workflow gate impl --prepare-code 保存实施前文件内容", None
|
|
373
|
+
try:
|
|
374
|
+
manifest, raw = _read_manifest(project_root, rollback.manifest_path)
|
|
375
|
+
if _sha256_bytes(raw) != rollback.manifest_hash:
|
|
376
|
+
raise ValueError("实施前回退清单哈希与 state.json 不一致")
|
|
377
|
+
if manifest.get("version") != MANIFEST_VERSION:
|
|
378
|
+
raise ValueError("实施前回退清单版本不受支持")
|
|
379
|
+
if manifest.get("workflow_id") != wf_state.workflow_id:
|
|
380
|
+
raise ValueError("实施前回退清单不属于当前工作流")
|
|
381
|
+
_validate_backup_entries(project_root, manifest)
|
|
382
|
+
if require_current_plan:
|
|
383
|
+
paths = planned_code_paths(project_root, wf_state.topics)
|
|
384
|
+
plan_hash = compute_plan_hash(project_root, wf_state.topics)
|
|
385
|
+
latest = manifest.get("prepares", [])[-1] if manifest.get("prepares") else {}
|
|
386
|
+
if rollback.plan_hash != plan_hash or latest.get("plan_hash") != plan_hash:
|
|
387
|
+
raise ValueError("实施前计划已经变化,必须重新执行 workflow gate impl --prepare-code")
|
|
388
|
+
if rollback.planned_paths != paths or latest.get("planned_paths") != paths:
|
|
389
|
+
raise ValueError("实施前回退清单与当前代码修改计划不一致")
|
|
390
|
+
except (OSError, ValueError) as exc:
|
|
391
|
+
return False, str(exc), None
|
|
392
|
+
return True, "实施前回退清单和文件副本完整", manifest
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _backup_entry(project_root: str, manifest_dir: str, relative_path: str) -> dict:
|
|
396
|
+
full_path = os.path.join(project_root, relative_path)
|
|
397
|
+
if not os.path.exists(full_path):
|
|
398
|
+
return {
|
|
399
|
+
"original_exists": False,
|
|
400
|
+
"backup_path": None,
|
|
401
|
+
"content_hash": None,
|
|
402
|
+
"mode": None,
|
|
403
|
+
}
|
|
404
|
+
backup_name = hashlib.sha256(relative_path.encode("utf-8")).hexdigest() + ".bin"
|
|
405
|
+
backup_rel_path = os.path.join("files", backup_name)
|
|
406
|
+
backup_full_path = os.path.join(manifest_dir, backup_rel_path)
|
|
407
|
+
os.makedirs(os.path.dirname(backup_full_path), exist_ok=True)
|
|
408
|
+
_copy_file(full_path, backup_full_path)
|
|
409
|
+
return {
|
|
410
|
+
"original_exists": True,
|
|
411
|
+
"backup_path": backup_rel_path.replace(os.sep, "/"),
|
|
412
|
+
"content_hash": _sha256_file(backup_full_path),
|
|
413
|
+
"mode": os.stat(full_path).st_mode & 0o777,
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def prepare_impl(
|
|
418
|
+
project_root: str,
|
|
419
|
+
wf_state: state_mod.WorkflowState,
|
|
420
|
+
) -> tuple[str, list[str]]:
|
|
421
|
+
stage_state = wf_state.stages.get("impl")
|
|
422
|
+
if stage_state is None or not stage_state.gate.discussion_complete:
|
|
423
|
+
raise ValueError("必须先通过 workflow gate impl --discuss-done")
|
|
424
|
+
if stage_state.code_baseline_hash is None:
|
|
425
|
+
raise ValueError("缺少实施计划确认时的代码基线")
|
|
426
|
+
|
|
427
|
+
paths = planned_code_paths(project_root, wf_state.topics)
|
|
428
|
+
plan_hash = compute_plan_hash(project_root, wf_state.topics)
|
|
429
|
+
# 每次准备必须匹配用户最后确认的实施计划;计划调整后先重新确认再准备
|
|
430
|
+
if (
|
|
431
|
+
stage_state.plan_confirmed_hash is not None
|
|
432
|
+
and plan_hash != stage_state.plan_confirmed_hash
|
|
433
|
+
):
|
|
434
|
+
raise ValueError(
|
|
435
|
+
"当前实施计划与用户最后确认的版本不一致;"
|
|
436
|
+
"先更新实施前计划并重新通过 workflow gate impl --discuss-done"
|
|
437
|
+
)
|
|
438
|
+
manifest_path = _manifest_rel_path(wf_state.workflow_id)
|
|
439
|
+
manifest_full_path = os.path.join(project_root, manifest_path)
|
|
440
|
+
manifest_dir = os.path.dirname(manifest_full_path)
|
|
441
|
+
os.makedirs(manifest_dir, exist_ok=True)
|
|
442
|
+
|
|
443
|
+
manifest: dict
|
|
444
|
+
if os.path.isfile(manifest_full_path):
|
|
445
|
+
# 再次准备:允许已登记路径按计划变化;首次原内容始终保留,不被覆盖
|
|
446
|
+
manifest, raw = _read_manifest(project_root, manifest_path)
|
|
447
|
+
if manifest.get("workflow_id") != wf_state.workflow_id:
|
|
448
|
+
raise ValueError("现有回退清单不属于当前工作流,不能覆盖")
|
|
449
|
+
_validate_backup_entries(project_root, manifest)
|
|
450
|
+
initial_inventory = manifest.get("initial_inventory", {})
|
|
451
|
+
entries = manifest.setdefault("entries", {})
|
|
452
|
+
current_inventory = verification_mod.compute_project_file_hashes(project_root)
|
|
453
|
+
for path in paths:
|
|
454
|
+
if path in entries:
|
|
455
|
+
continue
|
|
456
|
+
# 新加入计划的路径:只有当前内容仍等于第一次准备时的内容才允许补副本,
|
|
457
|
+
# 否则没有可信的原内容
|
|
458
|
+
if current_inventory.get(path) != initial_inventory.get(path):
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f"计划新增的路径已经被修改,没有可信的实施前原内容:{path};"
|
|
461
|
+
"先恢复该文件到实施前内容,或返回计划重新讨论"
|
|
462
|
+
)
|
|
463
|
+
else:
|
|
464
|
+
# 第一次准备:代码必须仍等于讨论确认时的基线,不能把修改后的内容当成原内容
|
|
465
|
+
current_code_hash = verification_mod.compute_non_test_code_snapshot_hash(project_root)
|
|
466
|
+
if current_code_hash != stage_state.code_baseline_hash:
|
|
467
|
+
raise ValueError("代码已经在回退基线保存前发生变化,不能把修改后的内容当成原内容")
|
|
468
|
+
manifest = {
|
|
469
|
+
"version": MANIFEST_VERSION,
|
|
470
|
+
"workflow_id": wf_state.workflow_id,
|
|
471
|
+
"created_at": state_mod.now_iso(),
|
|
472
|
+
"initial_inventory": verification_mod.compute_project_file_hashes(project_root),
|
|
473
|
+
"entries": {},
|
|
474
|
+
"prepares": [],
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
entries = manifest.setdefault("entries", {})
|
|
478
|
+
for path in paths:
|
|
479
|
+
if path not in entries:
|
|
480
|
+
entries[path] = _backup_entry(project_root, manifest_dir, path)
|
|
481
|
+
|
|
482
|
+
prepare_record = {
|
|
483
|
+
"prepared_at": state_mod.now_iso(),
|
|
484
|
+
"plan_hash": plan_hash,
|
|
485
|
+
"code_baseline_hash": stage_state.code_baseline_hash,
|
|
486
|
+
"planned_paths": paths,
|
|
487
|
+
"inventory_before": manifest.get("initial_inventory", {}),
|
|
488
|
+
}
|
|
489
|
+
prepares = manifest.setdefault("prepares", [])
|
|
490
|
+
if not prepares or any(
|
|
491
|
+
prepares[-1].get(key) != prepare_record.get(key)
|
|
492
|
+
for key in ("plan_hash", "code_baseline_hash", "planned_paths")
|
|
493
|
+
):
|
|
494
|
+
prepares.append(prepare_record)
|
|
495
|
+
else:
|
|
496
|
+
prepares[-1] = prepare_record
|
|
497
|
+
|
|
498
|
+
raw = _atomic_write_json(manifest_full_path, manifest)
|
|
499
|
+
|
|
500
|
+
wf_state.rollback.manifest_path = manifest_path
|
|
501
|
+
wf_state.rollback.manifest_hash = _sha256_bytes(raw)
|
|
502
|
+
wf_state.rollback.prepared_at = prepare_record["prepared_at"]
|
|
503
|
+
wf_state.rollback.plan_hash = plan_hash
|
|
504
|
+
wf_state.rollback.code_baseline_hash = stage_state.code_baseline_hash
|
|
505
|
+
wf_state.rollback.planned_paths = paths
|
|
506
|
+
|
|
507
|
+
valid, detail, _ = validate_prepared(project_root, wf_state)
|
|
508
|
+
if not valid:
|
|
509
|
+
raise ValueError(detail)
|
|
510
|
+
return detail, paths
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _write_manifest(project_root: str, wf_state: state_mod.WorkflowState, manifest: dict) -> None:
|
|
514
|
+
if not wf_state.rollback.manifest_path:
|
|
515
|
+
raise ValueError("当前工作流还没有实施前回退清单")
|
|
516
|
+
normalized = _safe_project_relative_path(
|
|
517
|
+
project_root,
|
|
518
|
+
wf_state.rollback.manifest_path,
|
|
519
|
+
purpose="实施回退清单路径",
|
|
520
|
+
)
|
|
521
|
+
manifest_full_path = os.path.join(project_root, normalized)
|
|
522
|
+
raw = _atomic_write_json(manifest_full_path, manifest)
|
|
523
|
+
wf_state.rollback.manifest_hash = _sha256_bytes(raw)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def prepare_test_code_baseline(
|
|
527
|
+
project_root: str,
|
|
528
|
+
wf_state: state_mod.WorkflowState,
|
|
529
|
+
) -> list[str]:
|
|
530
|
+
"""测试代码开始前保存已有测试文件和测试配置。"""
|
|
531
|
+
valid, detail, manifest = validate_prepared(
|
|
532
|
+
project_root,
|
|
533
|
+
wf_state,
|
|
534
|
+
require_current_plan=False,
|
|
535
|
+
)
|
|
536
|
+
if not valid or manifest is None:
|
|
537
|
+
raise ValueError(detail)
|
|
538
|
+
test_files = verification_mod.compute_test_related_file_hashes(project_root)
|
|
539
|
+
manifest_dir = os.path.dirname(os.path.join(project_root, wf_state.rollback.manifest_path or ""))
|
|
540
|
+
entries = manifest.setdefault("entries", {})
|
|
541
|
+
for path in sorted(test_files):
|
|
542
|
+
if path not in entries:
|
|
543
|
+
entries[path] = _backup_entry(project_root, manifest_dir, path)
|
|
544
|
+
manifest["test_code_prepared_at"] = state_mod.now_iso()
|
|
545
|
+
manifest["test_code_inventory_before"] = test_files
|
|
546
|
+
_write_manifest(project_root, wf_state, manifest)
|
|
547
|
+
return sorted(test_files)
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def finalize_test_code_changes(
|
|
551
|
+
project_root: str,
|
|
552
|
+
wf_state: state_mod.WorkflowState,
|
|
553
|
+
) -> list[str]:
|
|
554
|
+
"""登记 test_code 阶段新建的测试文件,供中止时删除。"""
|
|
555
|
+
valid, detail, manifest = validate_prepared(
|
|
556
|
+
project_root,
|
|
557
|
+
wf_state,
|
|
558
|
+
require_current_plan=False,
|
|
559
|
+
)
|
|
560
|
+
if not valid or manifest is None:
|
|
561
|
+
raise ValueError(detail)
|
|
562
|
+
before = manifest.get("test_code_inventory_before")
|
|
563
|
+
if not isinstance(before, dict):
|
|
564
|
+
raise ValueError("缺少 test_code 开始前的测试文件基线")
|
|
565
|
+
current = verification_mod.compute_test_related_file_hashes(project_root)
|
|
566
|
+
changed = sorted(
|
|
567
|
+
path
|
|
568
|
+
for path in set(before) | set(current)
|
|
569
|
+
if before.get(path) != current.get(path)
|
|
570
|
+
)
|
|
571
|
+
entries = manifest.setdefault("entries", {})
|
|
572
|
+
for path in changed:
|
|
573
|
+
if path not in before and path not in entries:
|
|
574
|
+
entries[path] = {
|
|
575
|
+
"original_exists": False,
|
|
576
|
+
"backup_path": None,
|
|
577
|
+
"content_hash": None,
|
|
578
|
+
"mode": None,
|
|
579
|
+
}
|
|
580
|
+
elif path in before and path not in entries:
|
|
581
|
+
raise ValueError(f"测试文件修改前没有保存真实内容:{path}")
|
|
582
|
+
manifest["test_code_changed_paths"] = changed
|
|
583
|
+
_write_manifest(project_root, wf_state, manifest)
|
|
584
|
+
return changed
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
def accept_test_code_inventory(
|
|
588
|
+
project_root: str,
|
|
589
|
+
wf_state: state_mod.WorkflowState,
|
|
590
|
+
) -> list[str]:
|
|
591
|
+
"""在测试代码经用户确认时保存当时状态,供后续返回实施时区分旧修改。"""
|
|
592
|
+
valid, detail, manifest = validate_prepared(
|
|
593
|
+
project_root,
|
|
594
|
+
wf_state,
|
|
595
|
+
require_current_plan=False,
|
|
596
|
+
)
|
|
597
|
+
if not valid or manifest is None:
|
|
598
|
+
raise ValueError(detail)
|
|
599
|
+
before = manifest.get("test_code_inventory_before")
|
|
600
|
+
if not isinstance(before, dict):
|
|
601
|
+
raise ValueError("缺少 test_code 开始前的测试文件基线")
|
|
602
|
+
current = verification_mod.compute_test_related_file_hashes(project_root)
|
|
603
|
+
previous = manifest.get("test_code_inventory_after")
|
|
604
|
+
previous_paths = set(previous) if isinstance(previous, dict) else set()
|
|
605
|
+
all_paths = set(before) | set(current) | previous_paths
|
|
606
|
+
manifest["test_code_inventory_after"] = {
|
|
607
|
+
path: current.get(path)
|
|
608
|
+
for path in sorted(all_paths)
|
|
609
|
+
}
|
|
610
|
+
manifest["test_code_accepted_at"] = state_mod.now_iso()
|
|
611
|
+
_write_manifest(project_root, wf_state, manifest)
|
|
612
|
+
return sorted(all_paths)
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def _accepted_test_code_inventory(manifest: dict) -> dict[str, str | None]:
|
|
616
|
+
"""读取最后确认的测试文件状态,并兼容确认时零变化的旧清单。"""
|
|
617
|
+
raw = manifest.get("test_code_inventory_after")
|
|
618
|
+
if not isinstance(raw, dict):
|
|
619
|
+
if manifest.get("test_code_changed_paths") != []:
|
|
620
|
+
return {}
|
|
621
|
+
raw = manifest.get("test_code_inventory_before")
|
|
622
|
+
if not isinstance(raw, dict):
|
|
623
|
+
return {}
|
|
624
|
+
return {
|
|
625
|
+
path: content_hash
|
|
626
|
+
for path, content_hash in raw.items()
|
|
627
|
+
if isinstance(path, str)
|
|
628
|
+
and (content_hash is None or isinstance(content_hash, str))
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def changed_paths_since_prepare(project_root: str, manifest: dict) -> list[str]:
|
|
633
|
+
"""实际变化始终和第一次项目清单比较,早期修改不会因再次准备消失。"""
|
|
634
|
+
prepares = manifest.get("prepares", [])
|
|
635
|
+
if not prepares:
|
|
636
|
+
raise ValueError("实施前回退清单没有准备记录")
|
|
637
|
+
raw_before = manifest.get("initial_inventory") or prepares[0].get("inventory_before", {})
|
|
638
|
+
before = {
|
|
639
|
+
path: content_hash
|
|
640
|
+
for path, content_hash in raw_before.items()
|
|
641
|
+
if verification_mod.is_implementation_related_path(path)
|
|
642
|
+
}
|
|
643
|
+
current = verification_mod.compute_project_file_hashes(project_root)
|
|
644
|
+
changed = {
|
|
645
|
+
path
|
|
646
|
+
for path in set(before) | set(current)
|
|
647
|
+
if before.get(path) != current.get(path)
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
# 项目清单只扫描代码、脚本和配置;计划明确列出的其它资源文件仍逐项
|
|
651
|
+
# 与第一次副本比较,避免文档型产品或二进制资源的计划内修改被漏掉。
|
|
652
|
+
for path, entry in manifest.get("entries", {}).items():
|
|
653
|
+
full_path = os.path.join(project_root, path)
|
|
654
|
+
if entry.get("original_exists"):
|
|
655
|
+
current_hash = (
|
|
656
|
+
_sha256_file(full_path)
|
|
657
|
+
if os.path.isfile(full_path) and not os.path.islink(full_path)
|
|
658
|
+
else None
|
|
659
|
+
)
|
|
660
|
+
if current_hash != entry.get("content_hash"):
|
|
661
|
+
changed.add(path)
|
|
662
|
+
elif os.path.lexists(full_path):
|
|
663
|
+
changed.add(path)
|
|
664
|
+
return sorted(changed)
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def validate_implementation_changes(
|
|
668
|
+
project_root: str,
|
|
669
|
+
wf_state: state_mod.WorkflowState,
|
|
670
|
+
) -> tuple[bool, str]:
|
|
671
|
+
valid, detail, manifest = validate_prepared(project_root, wf_state)
|
|
672
|
+
if not valid or manifest is None:
|
|
673
|
+
return False, detail
|
|
674
|
+
changed = changed_paths_since_prepare(project_root, manifest)
|
|
675
|
+
current = verification_mod.compute_project_file_hashes(project_root)
|
|
676
|
+
accepted_tests = _accepted_test_code_inventory(manifest)
|
|
677
|
+
unchanged_accepted_tests = {
|
|
678
|
+
path
|
|
679
|
+
for path in changed
|
|
680
|
+
if path in accepted_tests and current.get(path) == accepted_tests[path]
|
|
681
|
+
}
|
|
682
|
+
implementation_changes = sorted(set(changed) - unchanged_accepted_tests)
|
|
683
|
+
planned = set(wf_state.rollback.planned_paths)
|
|
684
|
+
unexpected = sorted(set(implementation_changes) - planned)
|
|
685
|
+
if unexpected:
|
|
686
|
+
return False, f"发现实施计划外的文件变化:{unexpected}"
|
|
687
|
+
if not implementation_changes:
|
|
688
|
+
return False, "实施计划列出的文件没有相对回退基线发生变化"
|
|
689
|
+
detail = f"实施前回退副本完整,实际变化文件均在计划内:{implementation_changes}"
|
|
690
|
+
if unchanged_accepted_tests:
|
|
691
|
+
detail += (
|
|
692
|
+
";已确认且返回实施后未再变的测试文件已保留:"
|
|
693
|
+
f"{sorted(unchanged_accepted_tests)}"
|
|
694
|
+
)
|
|
695
|
+
return True, detail
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def restore(project_root: str, wf_state: state_mod.WorkflowState) -> list[str]:
|
|
699
|
+
valid, detail, manifest = validate_prepared(
|
|
700
|
+
project_root,
|
|
701
|
+
wf_state,
|
|
702
|
+
require_current_plan=False,
|
|
703
|
+
)
|
|
704
|
+
if not valid or manifest is None:
|
|
705
|
+
raise ValueError(detail)
|
|
706
|
+
|
|
707
|
+
initial_inventory = manifest.get("initial_inventory", {})
|
|
708
|
+
current_inventory = verification_mod.compute_project_file_hashes(project_root)
|
|
709
|
+
allowed = set(manifest.get("entries", {}))
|
|
710
|
+
unexpected = sorted(
|
|
711
|
+
path
|
|
712
|
+
for path in set(initial_inventory) | set(current_inventory)
|
|
713
|
+
if initial_inventory.get(path) != current_inventory.get(path) and path not in allowed
|
|
714
|
+
)
|
|
715
|
+
if unexpected:
|
|
716
|
+
raise ValueError(
|
|
717
|
+
"存在没有实施前副本的文件变化,不能安全中止:" + str(unexpected)
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
manifest_dir = os.path.dirname(os.path.join(project_root, wf_state.rollback.manifest_path or ""))
|
|
721
|
+
restored: list[str] = []
|
|
722
|
+
try:
|
|
723
|
+
for relative_path, entry in manifest.get("entries", {}).items():
|
|
724
|
+
_normalized_relative_path(project_root, relative_path)
|
|
725
|
+
full_path = os.path.join(project_root, relative_path)
|
|
726
|
+
if entry.get("original_exists"):
|
|
727
|
+
backup_path = _safe_backup_path(
|
|
728
|
+
manifest_dir,
|
|
729
|
+
entry["backup_path"],
|
|
730
|
+
relative_path,
|
|
731
|
+
)
|
|
732
|
+
destination_dir = os.path.dirname(full_path) or project_root
|
|
733
|
+
os.makedirs(destination_dir, exist_ok=True)
|
|
734
|
+
temp_handle = tempfile.NamedTemporaryFile(
|
|
735
|
+
prefix=".workflow-rollback-",
|
|
736
|
+
dir=destination_dir,
|
|
737
|
+
delete=False,
|
|
738
|
+
)
|
|
739
|
+
temp_path = temp_handle.name
|
|
740
|
+
temp_handle.close()
|
|
741
|
+
try:
|
|
742
|
+
_copy_file(backup_path, temp_path)
|
|
743
|
+
os.replace(temp_path, full_path)
|
|
744
|
+
finally:
|
|
745
|
+
if os.path.exists(temp_path):
|
|
746
|
+
os.remove(temp_path)
|
|
747
|
+
mode = entry.get("mode")
|
|
748
|
+
if isinstance(mode, int):
|
|
749
|
+
os.chmod(full_path, mode)
|
|
750
|
+
elif os.path.lexists(full_path):
|
|
751
|
+
if not os.path.isfile(full_path) or os.path.islink(full_path):
|
|
752
|
+
raise ValueError(f"计划新增路径现在不是普通文件,不能安全删除:{relative_path}")
|
|
753
|
+
os.remove(full_path)
|
|
754
|
+
restored.append(relative_path)
|
|
755
|
+
except OSError as exc:
|
|
756
|
+
raise ValueError(f"恢复文件时发生系统错误:{exc}") from exc
|
|
757
|
+
|
|
758
|
+
_validate_restored(project_root, manifest)
|
|
759
|
+
return restored
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def _validate_restored(project_root: str, manifest: dict) -> None:
|
|
763
|
+
for relative_path, entry in manifest.get("entries", {}).items():
|
|
764
|
+
full_path = os.path.join(project_root, relative_path)
|
|
765
|
+
if entry.get("original_exists"):
|
|
766
|
+
if not os.path.isfile(full_path):
|
|
767
|
+
raise ValueError(f"回退后文件缺失:{relative_path}")
|
|
768
|
+
content_hash = _sha256_file(full_path)
|
|
769
|
+
if content_hash != entry.get("content_hash"):
|
|
770
|
+
raise ValueError(f"回退后文件内容不正确:{relative_path}")
|
|
771
|
+
elif os.path.lexists(full_path):
|
|
772
|
+
raise ValueError(f"回退后计划新增文件仍然存在:{relative_path}")
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
def cleanup(project_root: str, workflow_id: str) -> list[str]:
|
|
776
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
777
|
+
relative_path = f"{ROLLBACK_ROOT}/{workflow_id}"
|
|
778
|
+
normalized = _safe_project_relative_path(
|
|
779
|
+
project_root,
|
|
780
|
+
relative_path,
|
|
781
|
+
purpose="待清理回退目录",
|
|
782
|
+
allow_directory=True,
|
|
783
|
+
)
|
|
784
|
+
full_path = os.path.join(project_root, normalized)
|
|
785
|
+
rollback_root = os.path.realpath(os.path.join(project_root, ROLLBACK_ROOT))
|
|
786
|
+
full_real = os.path.realpath(full_path)
|
|
787
|
+
if os.path.commonpath([rollback_root, full_real]) != rollback_root or full_real == rollback_root:
|
|
788
|
+
raise ValueError("待清理路径超出当前工作流的回退目录")
|
|
789
|
+
if not os.path.exists(full_path):
|
|
790
|
+
return []
|
|
791
|
+
if os.path.islink(full_path) or not os.path.isdir(full_path):
|
|
792
|
+
raise ValueError("待清理回退路径不是普通目录")
|
|
793
|
+
shutil.rmtree(full_path)
|
|
794
|
+
parent = os.path.dirname(full_path)
|
|
795
|
+
if os.path.isdir(parent) and not os.listdir(parent):
|
|
796
|
+
os.rmdir(parent)
|
|
797
|
+
return [normalized]
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
# ─────────────────────────────────────────────
|
|
801
|
+
# 开工基线与清场开工事务
|
|
802
|
+
# 新轮次写入前保存受管正式文档、旧 state.json 和将修改的项目字段;
|
|
803
|
+
# 从零清场只在副本完整后执行;失败时恢复旧内容。
|
|
804
|
+
# ─────────────────────────────────────────────
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def managed_document_paths(project_root: str) -> list[str]:
|
|
808
|
+
"""兼容旧调用:列出正式文档目录中当前存在的普通文件。"""
|
|
809
|
+
paths: list[str] = []
|
|
810
|
+
for dir_name in MANAGED_DOC_DIRS:
|
|
811
|
+
dir_path = os.path.join(project_root, dir_name)
|
|
812
|
+
if not os.path.isdir(dir_path):
|
|
813
|
+
continue
|
|
814
|
+
if os.path.islink(dir_path):
|
|
815
|
+
raise ValueError(f"受管文档目录不能是符号链接:{dir_name}")
|
|
816
|
+
for root, dirs, files in os.walk(dir_path):
|
|
817
|
+
for directory in dirs:
|
|
818
|
+
if os.path.islink(os.path.join(root, directory)):
|
|
819
|
+
raise ValueError(
|
|
820
|
+
"受管文档目录不能经过符号链接:"
|
|
821
|
+
+ os.path.relpath(os.path.join(root, directory), project_root)
|
|
822
|
+
)
|
|
823
|
+
for file_name in files:
|
|
824
|
+
full_path = os.path.join(root, file_name)
|
|
825
|
+
relative_path = os.path.relpath(full_path, project_root).replace(os.sep, "/")
|
|
826
|
+
paths.append(
|
|
827
|
+
_safe_project_relative_path(
|
|
828
|
+
project_root,
|
|
829
|
+
relative_path,
|
|
830
|
+
purpose="受管文档路径",
|
|
831
|
+
)
|
|
832
|
+
)
|
|
833
|
+
for file_name in MANAGED_DOC_FILES:
|
|
834
|
+
if os.path.isfile(os.path.join(project_root, file_name)):
|
|
835
|
+
paths.append(file_name)
|
|
836
|
+
return sorted(paths)
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
def _start_manifest_rel_path(workflow_id: str) -> str:
|
|
840
|
+
return f"{ROLLBACK_ROOT}/{_validated_workflow_id(workflow_id)}/start/manifest.json"
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _abort_manifest_rel_path(workflow_id: str) -> str:
|
|
844
|
+
return f"{ROLLBACK_ROOT}/{_validated_workflow_id(workflow_id)}/abort/manifest.json"
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _state_from_raw(raw: str | None):
|
|
848
|
+
if not raw:
|
|
849
|
+
return None
|
|
850
|
+
try:
|
|
851
|
+
data = json.loads(raw)
|
|
852
|
+
if not isinstance(data, dict) or "workflow_id" not in data or "intent" not in data:
|
|
853
|
+
return None
|
|
854
|
+
return state_mod.state_from_dict(data)
|
|
855
|
+
except (KeyError, TypeError, ValueError, json.JSONDecodeError):
|
|
856
|
+
return None
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _expanded_clean_file_paths(
|
|
860
|
+
project_root: str,
|
|
861
|
+
clean_paths: list[str] | None,
|
|
862
|
+
) -> list[str]:
|
|
863
|
+
"""展开用户明确确认的清场范围,确保其中每个旧文件都有副本。"""
|
|
864
|
+
result: set[str] = set()
|
|
865
|
+
for raw_path in clean_paths or []:
|
|
866
|
+
normalized = _safe_project_relative_path(
|
|
867
|
+
project_root,
|
|
868
|
+
raw_path,
|
|
869
|
+
purpose="清场路径",
|
|
870
|
+
allow_directory=True,
|
|
871
|
+
)
|
|
872
|
+
first_part = PurePosixPath(normalized).parts[0]
|
|
873
|
+
if first_part not in MANAGED_DOC_DIRS and normalized not in MANAGED_DOC_FILES:
|
|
874
|
+
raise ValueError(f"清场路径不属于受管正式文档范围:{normalized}")
|
|
875
|
+
full_path = os.path.join(project_root, normalized)
|
|
876
|
+
if not os.path.lexists(full_path):
|
|
877
|
+
continue
|
|
878
|
+
if os.path.isfile(full_path):
|
|
879
|
+
result.add(
|
|
880
|
+
_safe_project_relative_path(
|
|
881
|
+
project_root,
|
|
882
|
+
normalized,
|
|
883
|
+
purpose="清场文件路径",
|
|
884
|
+
)
|
|
885
|
+
)
|
|
886
|
+
continue
|
|
887
|
+
if not os.path.isdir(full_path):
|
|
888
|
+
raise ValueError(f"清场路径既不是普通文件也不是目录:{normalized}")
|
|
889
|
+
for root, dirs, files in os.walk(full_path):
|
|
890
|
+
for directory in dirs:
|
|
891
|
+
directory_path = os.path.join(root, directory)
|
|
892
|
+
if os.path.islink(directory_path):
|
|
893
|
+
relative = os.path.relpath(directory_path, project_root).replace(os.sep, "/")
|
|
894
|
+
raise ValueError(f"清场目录不能经过符号链接:{relative}")
|
|
895
|
+
for file_name in files:
|
|
896
|
+
relative = os.path.relpath(
|
|
897
|
+
os.path.join(root, file_name),
|
|
898
|
+
project_root,
|
|
899
|
+
).replace(os.sep, "/")
|
|
900
|
+
result.add(
|
|
901
|
+
_safe_project_relative_path(
|
|
902
|
+
project_root,
|
|
903
|
+
relative,
|
|
904
|
+
purpose="清场文件路径",
|
|
905
|
+
)
|
|
906
|
+
)
|
|
907
|
+
return sorted(result)
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _official_managed_paths(project_root: str, wf_state=None) -> list[str]:
|
|
911
|
+
project = project_mod.load_project(project_root)
|
|
912
|
+
collector = getattr(artifact_paths_mod, "managed_artifact_paths", None)
|
|
913
|
+
if collector is None:
|
|
914
|
+
raise ValueError("正式产物路径模块缺少 managed_artifact_paths() 受管范围接口")
|
|
915
|
+
raw_paths = collector(project, wf_state, project_root=project_root)
|
|
916
|
+
if not isinstance(raw_paths, (list, tuple, set)):
|
|
917
|
+
raise ValueError("正式产物受管范围必须是路径列表")
|
|
918
|
+
paths: set[str] = set()
|
|
919
|
+
for raw_path in raw_paths:
|
|
920
|
+
paths.add(
|
|
921
|
+
_safe_project_relative_path(
|
|
922
|
+
project_root,
|
|
923
|
+
raw_path,
|
|
924
|
+
purpose="正式产物路径",
|
|
925
|
+
)
|
|
926
|
+
)
|
|
927
|
+
return sorted(paths)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _validated_entry_script_path(project_root: str, raw_path: str) -> str:
|
|
931
|
+
"""校验项目测试入口引用的脚本路径,并排除工作流内部产物。"""
|
|
932
|
+
normalized = _safe_project_relative_path(
|
|
933
|
+
project_root,
|
|
934
|
+
raw_path,
|
|
935
|
+
purpose="入口脚本路径",
|
|
936
|
+
)
|
|
937
|
+
first_part = PurePosixPath(normalized).parts[0]
|
|
938
|
+
if first_part in PROCESS_ROOTS or normalized in MANAGED_DOC_FILES:
|
|
939
|
+
raise ValueError(f"入口脚本不能放在工作流过程或内部目录:{normalized}")
|
|
940
|
+
return normalized
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _configured_start_entry_scripts(
|
|
944
|
+
project_root: str,
|
|
945
|
+
project_fields: dict,
|
|
946
|
+
) -> list[str]:
|
|
947
|
+
"""从开工时的平台参数数组中取得需要保存原内容的项目脚本。"""
|
|
948
|
+
raw_config = project_fields["test_entry"]
|
|
949
|
+
if isinstance(raw_config, str):
|
|
950
|
+
# 旧字符串没有可靠的参数边界;受控迁移负责转换,这里不能猜路径。
|
|
951
|
+
return []
|
|
952
|
+
|
|
953
|
+
config = test_entry_mod.normalized_entry_config(raw_config)
|
|
954
|
+
scripts_by_key: dict[str, str] = {}
|
|
955
|
+
for raw_path in test_entry_mod.referenced_project_scripts(config):
|
|
956
|
+
normalized = _validated_entry_script_path(project_root, raw_path)
|
|
957
|
+
comparison_key = normalized.casefold()
|
|
958
|
+
existing = scripts_by_key.get(comparison_key)
|
|
959
|
+
if existing is not None and existing != normalized:
|
|
960
|
+
raise ValueError(
|
|
961
|
+
"入口脚本包含在大小写不敏感文件系统上冲突的路径:"
|
|
962
|
+
f"{existing!r} 和 {normalized!r}"
|
|
963
|
+
)
|
|
964
|
+
scripts_by_key[comparison_key] = normalized
|
|
965
|
+
return sorted(scripts_by_key.values())
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def prepare_start_baseline(
|
|
969
|
+
project_root: str,
|
|
970
|
+
workflow_id: str,
|
|
971
|
+
project_fields: dict,
|
|
972
|
+
previous_state_raw: str | None,
|
|
973
|
+
clean_paths: list[str] | None = None,
|
|
974
|
+
) -> dict:
|
|
975
|
+
"""新轮次第一次持久写入前,保存受管文档、入口脚本和项目字段。
|
|
976
|
+
|
|
977
|
+
副本保存在 `.workflow_loop/rollback/<workflow_id>/start/`,
|
|
978
|
+
并入本轮整轮作废的回退依据;开工失败时按它恢复。
|
|
979
|
+
"""
|
|
980
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
981
|
+
if not isinstance(project_fields, dict):
|
|
982
|
+
raise ValueError("开工基线中的项目受管字段必须是对象")
|
|
983
|
+
project_mod._validate_managed_fields(project_fields)
|
|
984
|
+
entry_scripts = _configured_start_entry_scripts(project_root, project_fields)
|
|
985
|
+
manifest_rel = _start_manifest_rel_path(workflow_id)
|
|
986
|
+
manifest_full = os.path.join(project_root, manifest_rel)
|
|
987
|
+
manifest_dir = os.path.dirname(manifest_full)
|
|
988
|
+
if os.path.lexists(manifest_full):
|
|
989
|
+
raise ValueError("当前工作流编号已经存在开工基线,不能覆盖第一次原内容")
|
|
990
|
+
os.makedirs(manifest_dir, exist_ok=True)
|
|
991
|
+
|
|
992
|
+
previous_state = _state_from_raw(previous_state_raw)
|
|
993
|
+
official_paths = _official_managed_paths(project_root, previous_state)
|
|
994
|
+
clean_file_paths = _expanded_clean_file_paths(project_root, clean_paths)
|
|
995
|
+
baseline_paths = sorted(
|
|
996
|
+
set(official_paths) | set(clean_file_paths) | set(entry_scripts)
|
|
997
|
+
)
|
|
998
|
+
entries: dict[str, dict] = {}
|
|
999
|
+
for relative_path in baseline_paths:
|
|
1000
|
+
entries[relative_path] = _backup_entry(project_root, manifest_dir, relative_path)
|
|
1001
|
+
|
|
1002
|
+
manifest = {
|
|
1003
|
+
"version": MANIFEST_VERSION,
|
|
1004
|
+
"workflow_id": workflow_id,
|
|
1005
|
+
"baseline_complete": True,
|
|
1006
|
+
"created_at": state_mod.now_iso(),
|
|
1007
|
+
"project_fields": project_fields,
|
|
1008
|
+
"previous_state_raw": previous_state_raw,
|
|
1009
|
+
"managed_paths": official_paths,
|
|
1010
|
+
"clean_paths": sorted(set(clean_paths or [])),
|
|
1011
|
+
"entry_scripts": entry_scripts,
|
|
1012
|
+
"entries": entries,
|
|
1013
|
+
}
|
|
1014
|
+
_atomic_write_json(manifest_full, manifest)
|
|
1015
|
+
return manifest
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
def read_start_baseline(project_root: str, workflow_id: str) -> dict | None:
|
|
1019
|
+
try:
|
|
1020
|
+
manifest_rel = _start_manifest_rel_path(workflow_id)
|
|
1021
|
+
except ValueError:
|
|
1022
|
+
return None
|
|
1023
|
+
manifest_full = os.path.join(project_root, manifest_rel)
|
|
1024
|
+
if not os.path.isfile(manifest_full):
|
|
1025
|
+
return None
|
|
1026
|
+
manifest, _raw = _read_manifest(project_root, manifest_rel)
|
|
1027
|
+
return manifest
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
def _write_start_baseline(project_root: str, workflow_id: str, manifest: dict) -> None:
|
|
1031
|
+
manifest_full = os.path.join(project_root, _start_manifest_rel_path(workflow_id))
|
|
1032
|
+
_atomic_write_json(manifest_full, manifest)
|
|
1033
|
+
|
|
1034
|
+
|
|
1035
|
+
def register_start_entry_script(
|
|
1036
|
+
project_root: str,
|
|
1037
|
+
workflow_id: str,
|
|
1038
|
+
relative_path: str,
|
|
1039
|
+
project_fields: dict | None = None,
|
|
1040
|
+
) -> str:
|
|
1041
|
+
"""在修改测试入口脚本前,把它的第一次原内容加入完整开工基线。"""
|
|
1042
|
+
_ = project_fields # 保留旧调用签名;禁止用当前字段补造开工基线。
|
|
1043
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
1044
|
+
normalized = _validated_entry_script_path(project_root, relative_path)
|
|
1045
|
+
manifest_relative = _start_manifest_rel_path(workflow_id)
|
|
1046
|
+
if not os.path.isfile(os.path.join(project_root, manifest_relative)):
|
|
1047
|
+
raise ValueError("缺少完整开工基线,不能用当前内容补造入口脚本的开工前状态")
|
|
1048
|
+
manifest, _raw, validated_entries, manifest_dir = _load_source_manifest(
|
|
1049
|
+
project_root,
|
|
1050
|
+
manifest_relative,
|
|
1051
|
+
workflow_id,
|
|
1052
|
+
allow_process_documents=True,
|
|
1053
|
+
require_complete_start=True,
|
|
1054
|
+
)
|
|
1055
|
+
project_mod._validate_managed_fields(manifest.get("project_fields"))
|
|
1056
|
+
entries = manifest.setdefault("entries", {})
|
|
1057
|
+
if normalized in entries:
|
|
1058
|
+
return "该路径已有第一次原内容记录,未覆盖"
|
|
1059
|
+
if normalized in validated_entries:
|
|
1060
|
+
raise ValueError(f"入口脚本路径与开工基线中的另一种路径写法冲突:{normalized}")
|
|
1061
|
+
entries[normalized] = _backup_entry(project_root, manifest_dir, normalized)
|
|
1062
|
+
if entries[normalized]["original_exists"]:
|
|
1063
|
+
detail = "已保存现有脚本的真实原内容"
|
|
1064
|
+
else:
|
|
1065
|
+
detail = "已登记为“原本不存在”,整轮作废时删除"
|
|
1066
|
+
scripts = manifest.setdefault("entry_scripts", [])
|
|
1067
|
+
if normalized not in scripts:
|
|
1068
|
+
scripts.append(normalized)
|
|
1069
|
+
scripts.sort()
|
|
1070
|
+
_write_start_baseline(project_root, workflow_id, manifest)
|
|
1071
|
+
return detail
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _restore_file_entry(
|
|
1075
|
+
project_root: str,
|
|
1076
|
+
relative_path: str,
|
|
1077
|
+
entry: dict,
|
|
1078
|
+
source_manifest_dir: str | None,
|
|
1079
|
+
) -> None:
|
|
1080
|
+
normalized = _safe_project_relative_path(
|
|
1081
|
+
project_root,
|
|
1082
|
+
relative_path,
|
|
1083
|
+
purpose="待恢复文件路径",
|
|
1084
|
+
)
|
|
1085
|
+
full_path = os.path.join(project_root, normalized)
|
|
1086
|
+
if entry.get("original_exists") is True:
|
|
1087
|
+
if source_manifest_dir is None:
|
|
1088
|
+
raise ValueError(f"待恢复旧文件缺少副本来源:{normalized}")
|
|
1089
|
+
backup_path = _safe_backup_path(
|
|
1090
|
+
source_manifest_dir,
|
|
1091
|
+
entry.get("backup_path"),
|
|
1092
|
+
normalized,
|
|
1093
|
+
)
|
|
1094
|
+
if not os.path.isfile(backup_path):
|
|
1095
|
+
raise ValueError(f"待恢复旧文件的副本缺失:{normalized}")
|
|
1096
|
+
expected_hash = entry.get("content_hash")
|
|
1097
|
+
if _sha256_file(backup_path) != expected_hash:
|
|
1098
|
+
raise ValueError(f"待恢复旧文件的副本内容已损坏:{normalized}")
|
|
1099
|
+
_atomic_restore_file(backup_path, full_path, entry.get("mode"))
|
|
1100
|
+
if not os.path.isfile(full_path) or _sha256_file(full_path) != expected_hash:
|
|
1101
|
+
raise ValueError(f"文件恢复后内容校验失败:{normalized}")
|
|
1102
|
+
return
|
|
1103
|
+
|
|
1104
|
+
if entry.get("original_exists") is not False:
|
|
1105
|
+
raise ValueError(f"待恢复文件缺少明确的原文件存在状态:{normalized}")
|
|
1106
|
+
if os.path.lexists(full_path):
|
|
1107
|
+
if os.path.islink(full_path) or not os.path.isfile(full_path):
|
|
1108
|
+
raise ValueError(f"本轮新建路径现在不是普通文件,不能安全删除:{normalized}")
|
|
1109
|
+
os.remove(full_path)
|
|
1110
|
+
if os.path.lexists(full_path):
|
|
1111
|
+
raise ValueError(f"本轮新建文件删除后仍然存在:{normalized}")
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _load_source_manifest(
|
|
1115
|
+
project_root: str,
|
|
1116
|
+
relative_path: str,
|
|
1117
|
+
workflow_id: str,
|
|
1118
|
+
*,
|
|
1119
|
+
allow_process_documents: bool,
|
|
1120
|
+
require_complete_start: bool,
|
|
1121
|
+
expected_hash: str | None = None,
|
|
1122
|
+
) -> tuple[dict, bytes, dict[str, dict], str]:
|
|
1123
|
+
manifest, raw = _read_manifest(project_root, relative_path)
|
|
1124
|
+
if manifest.get("version") != MANIFEST_VERSION:
|
|
1125
|
+
raise ValueError(f"回退清单版本不受支持:{relative_path}")
|
|
1126
|
+
if manifest.get("workflow_id") != workflow_id:
|
|
1127
|
+
raise ValueError(f"回退清单不属于当前工作流:{relative_path}")
|
|
1128
|
+
if require_complete_start and manifest.get("baseline_complete") is not True:
|
|
1129
|
+
raise ValueError(f"开工基线不是完整开工快照:{relative_path}")
|
|
1130
|
+
if expected_hash is not None and _sha256_bytes(raw) != expected_hash:
|
|
1131
|
+
raise ValueError(f"回退清单内容与工作流状态中的哈希不一致:{relative_path}")
|
|
1132
|
+
manifest_dir = os.path.dirname(os.path.join(project_root, relative_path))
|
|
1133
|
+
entries = _validated_manifest_entries(
|
|
1134
|
+
project_root,
|
|
1135
|
+
manifest,
|
|
1136
|
+
manifest_dir,
|
|
1137
|
+
allow_process_documents=allow_process_documents,
|
|
1138
|
+
)
|
|
1139
|
+
return manifest, raw, entries, manifest_dir
|
|
1140
|
+
|
|
1141
|
+
|
|
1142
|
+
def _abort_manifest_full_path(project_root: str, workflow_id: str) -> str:
|
|
1143
|
+
return os.path.join(project_root, _abort_manifest_rel_path(workflow_id))
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _write_abort_manifest(
|
|
1147
|
+
project_root: str,
|
|
1148
|
+
workflow_id: str,
|
|
1149
|
+
manifest: dict,
|
|
1150
|
+
) -> None:
|
|
1151
|
+
_atomic_write_json(_abort_manifest_full_path(project_root, workflow_id), manifest)
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def _read_abort_manifest(project_root: str, workflow_id: str) -> dict | None:
|
|
1155
|
+
relative_path = _abort_manifest_rel_path(workflow_id)
|
|
1156
|
+
full_path = os.path.join(project_root, relative_path)
|
|
1157
|
+
if not os.path.isfile(full_path):
|
|
1158
|
+
return None
|
|
1159
|
+
manifest, _raw = _read_manifest(project_root, relative_path)
|
|
1160
|
+
return manifest
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def _snapshot_abort_item_state(project_root: str, item: dict) -> dict:
|
|
1164
|
+
"""读取一个恢复项此刻的可比较状态,不保存文件正文。"""
|
|
1165
|
+
if item.get("kind") == "project_fields":
|
|
1166
|
+
return {
|
|
1167
|
+
"kind": "project_fields",
|
|
1168
|
+
"fields": project_mod.snapshot_managed_fields(project_root),
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
path = _safe_project_relative_path(
|
|
1172
|
+
project_root,
|
|
1173
|
+
item.get("path"),
|
|
1174
|
+
purpose="作废恢复文件路径",
|
|
1175
|
+
)
|
|
1176
|
+
full_path = os.path.join(project_root, path)
|
|
1177
|
+
if not os.path.lexists(full_path):
|
|
1178
|
+
return {
|
|
1179
|
+
"kind": "file",
|
|
1180
|
+
"exists": False,
|
|
1181
|
+
"content_hash": None,
|
|
1182
|
+
"mode": None,
|
|
1183
|
+
}
|
|
1184
|
+
return {
|
|
1185
|
+
"kind": "file",
|
|
1186
|
+
"exists": True,
|
|
1187
|
+
"content_hash": _sha256_file(full_path),
|
|
1188
|
+
"mode": os.stat(full_path).st_mode & 0o777,
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
|
|
1192
|
+
def _abort_item_target_state(item: dict) -> dict:
|
|
1193
|
+
"""根据不可变源清单得到恢复项应该达到的目标状态。"""
|
|
1194
|
+
if item.get("kind") == "project_fields":
|
|
1195
|
+
return {
|
|
1196
|
+
"kind": "project_fields",
|
|
1197
|
+
"fields": item.get("fields"),
|
|
1198
|
+
}
|
|
1199
|
+
if item.get("original_exists") is True:
|
|
1200
|
+
return {
|
|
1201
|
+
"kind": "file",
|
|
1202
|
+
"exists": True,
|
|
1203
|
+
"content_hash": item.get("content_hash"),
|
|
1204
|
+
"mode": item.get("mode"),
|
|
1205
|
+
}
|
|
1206
|
+
return {
|
|
1207
|
+
"kind": "file",
|
|
1208
|
+
"exists": False,
|
|
1209
|
+
"content_hash": None,
|
|
1210
|
+
"mode": None,
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
|
|
1214
|
+
def _abort_state_matches_target(current: dict, target: dict) -> bool:
|
|
1215
|
+
"""比较当前状态和恢复目标;旧清单没有权限时只比较内容。"""
|
|
1216
|
+
if current.get("kind") != target.get("kind"):
|
|
1217
|
+
return False
|
|
1218
|
+
if target.get("kind") == "project_fields":
|
|
1219
|
+
return current.get("fields") == target.get("fields")
|
|
1220
|
+
if current.get("exists") != target.get("exists"):
|
|
1221
|
+
return False
|
|
1222
|
+
if target.get("exists") is False:
|
|
1223
|
+
return True
|
|
1224
|
+
if current.get("content_hash") != target.get("content_hash"):
|
|
1225
|
+
return False
|
|
1226
|
+
target_mode = target.get("mode")
|
|
1227
|
+
return not isinstance(target_mode, int) or current.get("mode") == target_mode
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
def _validate_observed_abort_state(item: dict, observed: dict) -> None:
|
|
1231
|
+
"""校验可变进度中的恢复前观察状态,拒绝无意义或不完整值。"""
|
|
1232
|
+
if not isinstance(observed, dict) or observed.get("kind") != item.get("kind"):
|
|
1233
|
+
raise ValueError(f"作废恢复项目的恢复前观察状态无效:{item.get('id')}")
|
|
1234
|
+
if item.get("kind") == "project_fields":
|
|
1235
|
+
project_mod._validate_managed_fields(observed.get("fields"))
|
|
1236
|
+
return
|
|
1237
|
+
|
|
1238
|
+
exists = observed.get("exists")
|
|
1239
|
+
if not isinstance(exists, bool):
|
|
1240
|
+
raise ValueError(f"作废恢复文件的恢复前存在状态无效:{item.get('path')}")
|
|
1241
|
+
if exists:
|
|
1242
|
+
content_hash = observed.get("content_hash")
|
|
1243
|
+
mode = observed.get("mode")
|
|
1244
|
+
if (
|
|
1245
|
+
not isinstance(content_hash, str)
|
|
1246
|
+
or re.fullmatch(r"[0-9a-f]{64}", content_hash) is None
|
|
1247
|
+
or not isinstance(mode, int)
|
|
1248
|
+
or isinstance(mode, bool)
|
|
1249
|
+
):
|
|
1250
|
+
raise ValueError(f"作废恢复文件的恢复前内容状态无效:{item.get('path')}")
|
|
1251
|
+
elif observed.get("content_hash") is not None or observed.get("mode") is not None:
|
|
1252
|
+
raise ValueError(f"作废恢复文件原本不存在时不能带内容状态:{item.get('path')}")
|
|
1253
|
+
|
|
1254
|
+
|
|
1255
|
+
def _abort_item_result_name(item: dict) -> str:
|
|
1256
|
+
if item.get("kind") == "file":
|
|
1257
|
+
return item["path"]
|
|
1258
|
+
return ".workflow_loop/project.json 受管字段"
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def _validate_abort_items(
|
|
1262
|
+
project_root: str,
|
|
1263
|
+
workflow_id: str,
|
|
1264
|
+
manifest: dict,
|
|
1265
|
+
) -> list[dict]:
|
|
1266
|
+
if manifest.get("version") != MANIFEST_VERSION:
|
|
1267
|
+
raise ValueError("作废进度清单版本不受支持")
|
|
1268
|
+
if manifest.get("workflow_id") != workflow_id:
|
|
1269
|
+
raise ValueError("作废进度清单不属于当前工作流")
|
|
1270
|
+
items = manifest.get("items")
|
|
1271
|
+
if not isinstance(items, list) or not items:
|
|
1272
|
+
raise ValueError("作废进度清单没有恢复项目")
|
|
1273
|
+
item_ids: set[str] = set()
|
|
1274
|
+
normalized_paths: set[str] = set()
|
|
1275
|
+
validated: list[dict] = []
|
|
1276
|
+
allowed_sources = {
|
|
1277
|
+
_start_manifest_rel_path(workflow_id),
|
|
1278
|
+
_manifest_rel_path(workflow_id),
|
|
1279
|
+
None,
|
|
1280
|
+
}
|
|
1281
|
+
for raw_item in items:
|
|
1282
|
+
if not isinstance(raw_item, dict):
|
|
1283
|
+
raise ValueError("作废进度清单包含无效恢复项目")
|
|
1284
|
+
item = raw_item
|
|
1285
|
+
item_id = item.get("id")
|
|
1286
|
+
if not isinstance(item_id, str) or not item_id or item_id in item_ids:
|
|
1287
|
+
raise ValueError(f"作废进度清单包含重复或无效项目编号:{item_id!r}")
|
|
1288
|
+
item_ids.add(item_id)
|
|
1289
|
+
status = item.get("status", "pending")
|
|
1290
|
+
if status not in ABORT_ITEM_STATES:
|
|
1291
|
+
raise ValueError(f"作废恢复项目状态无效:{item_id}={status!r}")
|
|
1292
|
+
kind = item.get("kind")
|
|
1293
|
+
if kind == "file":
|
|
1294
|
+
path = _safe_project_relative_path(
|
|
1295
|
+
project_root,
|
|
1296
|
+
item.get("path"),
|
|
1297
|
+
purpose="作废恢复文件路径",
|
|
1298
|
+
)
|
|
1299
|
+
comparison_key = path.casefold()
|
|
1300
|
+
if comparison_key in normalized_paths:
|
|
1301
|
+
raise ValueError(f"作废进度清单重复恢复同一文件:{path}")
|
|
1302
|
+
normalized_paths.add(comparison_key)
|
|
1303
|
+
source_manifest = item.get("source_manifest")
|
|
1304
|
+
if source_manifest not in allowed_sources:
|
|
1305
|
+
raise ValueError(f"作废恢复项目引用了未知来源清单:{path}")
|
|
1306
|
+
original_exists = item.get("original_exists")
|
|
1307
|
+
if not isinstance(original_exists, bool):
|
|
1308
|
+
raise ValueError(f"作废恢复项目缺少原文件存在状态:{path}")
|
|
1309
|
+
if original_exists and (
|
|
1310
|
+
not isinstance(item.get("backup_path"), str)
|
|
1311
|
+
or not isinstance(item.get("content_hash"), str)
|
|
1312
|
+
):
|
|
1313
|
+
raise ValueError(f"作废恢复项目缺少旧文件副本信息:{path}")
|
|
1314
|
+
if not original_exists and (
|
|
1315
|
+
item.get("backup_path") is not None
|
|
1316
|
+
or item.get("content_hash") is not None
|
|
1317
|
+
):
|
|
1318
|
+
raise ValueError(f"作废恢复项目的新文件记录不能包含副本:{path}")
|
|
1319
|
+
elif kind == "project_fields":
|
|
1320
|
+
if item_id != "project_fields" or not isinstance(item.get("fields"), dict):
|
|
1321
|
+
raise ValueError("作废进度清单中的项目受管字段记录无效")
|
|
1322
|
+
else:
|
|
1323
|
+
raise ValueError(f"作废进度清单包含未知恢复项目类型:{kind!r}")
|
|
1324
|
+
observed = item.get("observed_before_restore")
|
|
1325
|
+
if status == "pending" and observed is not None:
|
|
1326
|
+
raise ValueError(f"待恢复项目不能提前包含恢复前观察状态:{item_id}")
|
|
1327
|
+
if status == "restoring" and observed is None:
|
|
1328
|
+
raise ValueError(
|
|
1329
|
+
f"正在恢复的项目缺少恢复前观察状态,不能安全重试:{item_id}"
|
|
1330
|
+
)
|
|
1331
|
+
if observed is not None:
|
|
1332
|
+
_validate_observed_abort_state(item, observed)
|
|
1333
|
+
validated.append(item)
|
|
1334
|
+
return validated
|
|
1335
|
+
|
|
1336
|
+
|
|
1337
|
+
def _source_manifest_hashes(
|
|
1338
|
+
project_root: str,
|
|
1339
|
+
wf_state: state_mod.WorkflowState,
|
|
1340
|
+
) -> tuple[dict[str, str], dict[str, tuple[dict, dict[str, dict], str]]]:
|
|
1341
|
+
"""读取并完整校验开工清单和可选实施清单。"""
|
|
1342
|
+
workflow_id = _validated_workflow_id(wf_state.workflow_id)
|
|
1343
|
+
hashes: dict[str, str] = {}
|
|
1344
|
+
sources: dict[str, tuple[dict, dict[str, dict], str]] = {}
|
|
1345
|
+
|
|
1346
|
+
start_relative = _start_manifest_rel_path(workflow_id)
|
|
1347
|
+
start_manifest, start_raw, start_entries, start_dir = _load_source_manifest(
|
|
1348
|
+
project_root,
|
|
1349
|
+
start_relative,
|
|
1350
|
+
workflow_id,
|
|
1351
|
+
allow_process_documents=True,
|
|
1352
|
+
require_complete_start=True,
|
|
1353
|
+
)
|
|
1354
|
+
if not isinstance(start_manifest.get("project_fields"), dict):
|
|
1355
|
+
raise ValueError("开工基线缺少项目受管字段快照")
|
|
1356
|
+
project_mod._validate_managed_fields(start_manifest["project_fields"])
|
|
1357
|
+
hashes[start_relative] = _sha256_bytes(start_raw)
|
|
1358
|
+
sources[start_relative] = (start_manifest, start_entries, start_dir)
|
|
1359
|
+
|
|
1360
|
+
expected_impl_relative = _manifest_rel_path(workflow_id)
|
|
1361
|
+
expected_impl_full = os.path.join(project_root, expected_impl_relative)
|
|
1362
|
+
configured_impl = wf_state.rollback.manifest_path
|
|
1363
|
+
if configured_impl is not None:
|
|
1364
|
+
configured_impl = _safe_project_relative_path(
|
|
1365
|
+
project_root,
|
|
1366
|
+
configured_impl,
|
|
1367
|
+
purpose="实施回退清单路径",
|
|
1368
|
+
)
|
|
1369
|
+
if configured_impl != expected_impl_relative:
|
|
1370
|
+
raise ValueError("工作流状态中的实施回退清单路径不属于当前工作流")
|
|
1371
|
+
if configured_impl is not None or os.path.isfile(expected_impl_full):
|
|
1372
|
+
expected_hash = wf_state.rollback.manifest_hash
|
|
1373
|
+
if configured_impl is not None and not expected_hash:
|
|
1374
|
+
raise ValueError("工作流状态缺少实施回退清单哈希")
|
|
1375
|
+
impl_manifest, impl_raw, impl_entries, impl_dir = _load_source_manifest(
|
|
1376
|
+
project_root,
|
|
1377
|
+
expected_impl_relative,
|
|
1378
|
+
workflow_id,
|
|
1379
|
+
allow_process_documents=False,
|
|
1380
|
+
require_complete_start=False,
|
|
1381
|
+
expected_hash=expected_hash,
|
|
1382
|
+
)
|
|
1383
|
+
hashes[expected_impl_relative] = _sha256_bytes(impl_raw)
|
|
1384
|
+
sources[expected_impl_relative] = (impl_manifest, impl_entries, impl_dir)
|
|
1385
|
+
return hashes, sources
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
def _validate_abort_against_sources(
|
|
1389
|
+
project_root: str,
|
|
1390
|
+
workflow_id: str,
|
|
1391
|
+
abort_manifest: dict,
|
|
1392
|
+
sources: dict[str, tuple[dict, dict[str, dict], str]],
|
|
1393
|
+
) -> None:
|
|
1394
|
+
"""确认可变进度清单没有改写不可变的恢复事实或漏掉登记项。"""
|
|
1395
|
+
items = _validate_abort_items(project_root, workflow_id, abort_manifest)
|
|
1396
|
+
start_relative = _start_manifest_rel_path(workflow_id)
|
|
1397
|
+
impl_relative = _manifest_rel_path(workflow_id)
|
|
1398
|
+
start_manifest, start_entries, _start_dir = sources[start_relative]
|
|
1399
|
+
impl_entries = sources.get(impl_relative, ({}, {}, ""))[1]
|
|
1400
|
+
|
|
1401
|
+
derived_paths_raw = abort_manifest.get("derived_managed_paths", [])
|
|
1402
|
+
if not isinstance(derived_paths_raw, list):
|
|
1403
|
+
raise ValueError("作废进度清单中的本轮新正式产物范围无效")
|
|
1404
|
+
derived_paths = {
|
|
1405
|
+
_safe_project_relative_path(
|
|
1406
|
+
project_root,
|
|
1407
|
+
path,
|
|
1408
|
+
purpose="本轮新正式产物路径",
|
|
1409
|
+
)
|
|
1410
|
+
for path in derived_paths_raw
|
|
1411
|
+
}
|
|
1412
|
+
expected_paths = set(start_entries) | set(impl_entries) | derived_paths
|
|
1413
|
+
file_items = {
|
|
1414
|
+
item["path"]: item
|
|
1415
|
+
for item in items
|
|
1416
|
+
if item.get("kind") == "file"
|
|
1417
|
+
}
|
|
1418
|
+
if set(file_items) != expected_paths:
|
|
1419
|
+
missing = sorted(expected_paths - set(file_items))
|
|
1420
|
+
extra = sorted(set(file_items) - expected_paths)
|
|
1421
|
+
raise ValueError(f"作废进度清单文件范围与源清单不一致:缺少 {missing},多出 {extra}")
|
|
1422
|
+
|
|
1423
|
+
for path, item in file_items.items():
|
|
1424
|
+
if path in start_entries:
|
|
1425
|
+
expected_entry = start_entries[path]
|
|
1426
|
+
expected_source = start_relative
|
|
1427
|
+
elif path in impl_entries:
|
|
1428
|
+
expected_entry = impl_entries[path]
|
|
1429
|
+
expected_source = impl_relative
|
|
1430
|
+
else:
|
|
1431
|
+
expected_entry = {
|
|
1432
|
+
"original_exists": False,
|
|
1433
|
+
"backup_path": None,
|
|
1434
|
+
"content_hash": None,
|
|
1435
|
+
"mode": None,
|
|
1436
|
+
}
|
|
1437
|
+
expected_source = None
|
|
1438
|
+
if item.get("source_manifest") != expected_source:
|
|
1439
|
+
raise ValueError(f"作废恢复项目没有使用最早原内容:{path}")
|
|
1440
|
+
for key in ("original_exists", "backup_path", "content_hash", "mode"):
|
|
1441
|
+
if item.get(key) != expected_entry.get(key):
|
|
1442
|
+
raise ValueError(f"作废恢复项目的原内容事实与源清单不一致:{path}")
|
|
1443
|
+
|
|
1444
|
+
project_items = [item for item in items if item.get("kind") == "project_fields"]
|
|
1445
|
+
if len(project_items) != 1 or project_items[0].get("fields") != start_manifest["project_fields"]:
|
|
1446
|
+
raise ValueError("作废进度清单中的项目受管字段与开工基线不一致")
|
|
1447
|
+
|
|
1448
|
+
|
|
1449
|
+
def preflight_abort(
|
|
1450
|
+
project_root: str,
|
|
1451
|
+
wf_state: state_mod.WorkflowState,
|
|
1452
|
+
) -> tuple[bool, list[str], dict | None]:
|
|
1453
|
+
"""完整预检整轮恢复依据,并生成或复用独立的逐项作废进度清单。
|
|
1454
|
+
|
|
1455
|
+
返回(是否可恢复、问题列表、作废进度清单)。当前旧轮次缺少完整开工
|
|
1456
|
+
基线时明确失败,绝不使用当前项目内容补造开工前状态。
|
|
1457
|
+
"""
|
|
1458
|
+
problems: list[str] = []
|
|
1459
|
+
try:
|
|
1460
|
+
workflow_id = _validated_workflow_id(wf_state.workflow_id)
|
|
1461
|
+
if wf_state.run_status != "active":
|
|
1462
|
+
raise ValueError("只有仍在进行的工作流可以预检整轮作废")
|
|
1463
|
+
source_hashes, sources = _source_manifest_hashes(project_root, wf_state)
|
|
1464
|
+
existing = _read_abort_manifest(project_root, workflow_id)
|
|
1465
|
+
if existing is not None:
|
|
1466
|
+
if existing.get("source_hashes") != source_hashes:
|
|
1467
|
+
raise ValueError("源回退清单在作废恢复开始后发生变化")
|
|
1468
|
+
_validate_abort_against_sources(
|
|
1469
|
+
project_root,
|
|
1470
|
+
workflow_id,
|
|
1471
|
+
existing,
|
|
1472
|
+
sources,
|
|
1473
|
+
)
|
|
1474
|
+
return True, [], existing
|
|
1475
|
+
|
|
1476
|
+
start_relative = _start_manifest_rel_path(workflow_id)
|
|
1477
|
+
start_manifest, start_entries, _start_dir = sources[start_relative]
|
|
1478
|
+
merged: dict[str, tuple[dict, str | None]] = {
|
|
1479
|
+
path: (dict(entry), start_relative)
|
|
1480
|
+
for path, entry in start_entries.items()
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
impl_relative = _manifest_rel_path(workflow_id)
|
|
1484
|
+
if impl_relative in sources:
|
|
1485
|
+
_impl_manifest, impl_entries, _impl_dir = sources[impl_relative]
|
|
1486
|
+
for path, entry in impl_entries.items():
|
|
1487
|
+
# 同一路径以开工时最早原内容为准;实施清单仍已独立校验。
|
|
1488
|
+
merged.setdefault(path, (dict(entry), impl_relative))
|
|
1489
|
+
|
|
1490
|
+
# 开工时还不知道名称、但当前已能从稳定映射或正式保留命名识别的
|
|
1491
|
+
# 本轮新正式产物,作为“原本不存在”写入独立作废清单。
|
|
1492
|
+
derived_managed_paths: list[str] = []
|
|
1493
|
+
for path in _official_managed_paths(project_root, wf_state):
|
|
1494
|
+
if path not in merged and os.path.lexists(os.path.join(project_root, path)):
|
|
1495
|
+
merged[path] = (
|
|
1496
|
+
{
|
|
1497
|
+
"original_exists": False,
|
|
1498
|
+
"backup_path": None,
|
|
1499
|
+
"content_hash": None,
|
|
1500
|
+
"mode": None,
|
|
1501
|
+
},
|
|
1502
|
+
None,
|
|
1503
|
+
)
|
|
1504
|
+
derived_managed_paths.append(path)
|
|
1505
|
+
|
|
1506
|
+
items: list[dict] = []
|
|
1507
|
+
for path in sorted(merged):
|
|
1508
|
+
entry, source_manifest = merged[path]
|
|
1509
|
+
items.append(
|
|
1510
|
+
{
|
|
1511
|
+
"id": f"file:{path}",
|
|
1512
|
+
"kind": "file",
|
|
1513
|
+
"path": path,
|
|
1514
|
+
"original_exists": entry.get("original_exists"),
|
|
1515
|
+
"source_manifest": source_manifest,
|
|
1516
|
+
"backup_path": entry.get("backup_path"),
|
|
1517
|
+
"content_hash": entry.get("content_hash"),
|
|
1518
|
+
"mode": entry.get("mode"),
|
|
1519
|
+
"status": "pending",
|
|
1520
|
+
"attempts": 0,
|
|
1521
|
+
"started_at": None,
|
|
1522
|
+
"restored_at": None,
|
|
1523
|
+
"last_error": None,
|
|
1524
|
+
"observed_before_restore": None,
|
|
1525
|
+
}
|
|
1526
|
+
)
|
|
1527
|
+
items.append(
|
|
1528
|
+
{
|
|
1529
|
+
"id": "project_fields",
|
|
1530
|
+
"kind": "project_fields",
|
|
1531
|
+
"fields": start_manifest["project_fields"],
|
|
1532
|
+
"status": "pending",
|
|
1533
|
+
"attempts": 0,
|
|
1534
|
+
"started_at": None,
|
|
1535
|
+
"restored_at": None,
|
|
1536
|
+
"last_error": None,
|
|
1537
|
+
"observed_before_restore": None,
|
|
1538
|
+
}
|
|
1539
|
+
)
|
|
1540
|
+
abort_manifest = {
|
|
1541
|
+
"version": MANIFEST_VERSION,
|
|
1542
|
+
"workflow_id": workflow_id,
|
|
1543
|
+
"created_at": state_mod.now_iso(),
|
|
1544
|
+
"source_hashes": source_hashes,
|
|
1545
|
+
"derived_managed_paths": sorted(derived_managed_paths),
|
|
1546
|
+
"items": items,
|
|
1547
|
+
"restored_at": None,
|
|
1548
|
+
}
|
|
1549
|
+
_validate_abort_against_sources(
|
|
1550
|
+
project_root,
|
|
1551
|
+
workflow_id,
|
|
1552
|
+
abort_manifest,
|
|
1553
|
+
sources,
|
|
1554
|
+
)
|
|
1555
|
+
_write_abort_manifest(project_root, workflow_id, abort_manifest)
|
|
1556
|
+
return True, [], abort_manifest
|
|
1557
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1558
|
+
problems.append(str(exc))
|
|
1559
|
+
return False, problems, None
|
|
1560
|
+
|
|
1561
|
+
|
|
1562
|
+
def _abort_item_source_dir(
|
|
1563
|
+
project_root: str,
|
|
1564
|
+
workflow_id: str,
|
|
1565
|
+
item: dict,
|
|
1566
|
+
) -> str | None:
|
|
1567
|
+
source_manifest = item.get("source_manifest")
|
|
1568
|
+
if source_manifest is None:
|
|
1569
|
+
return None
|
|
1570
|
+
if source_manifest not in {
|
|
1571
|
+
_start_manifest_rel_path(workflow_id),
|
|
1572
|
+
_manifest_rel_path(workflow_id),
|
|
1573
|
+
}:
|
|
1574
|
+
raise ValueError(f"恢复项目引用了未知来源清单:{item.get('id')}")
|
|
1575
|
+
return os.path.dirname(os.path.join(project_root, source_manifest))
|
|
1576
|
+
|
|
1577
|
+
|
|
1578
|
+
def restore_full_run(
|
|
1579
|
+
project_root: str,
|
|
1580
|
+
wf_state: state_mod.WorkflowState,
|
|
1581
|
+
) -> tuple[list[str], list[str]]:
|
|
1582
|
+
"""按独立作废清单逐项恢复;失败保留进度,重试跳过 restored 项。"""
|
|
1583
|
+
workflow_id = _validated_workflow_id(wf_state.workflow_id)
|
|
1584
|
+
manifest = _read_abort_manifest(project_root, workflow_id)
|
|
1585
|
+
if manifest is None:
|
|
1586
|
+
return [], ["缺少作废进度清单;必须先完整预检,不能直接开始恢复"]
|
|
1587
|
+
try:
|
|
1588
|
+
items = _validate_abort_items(project_root, workflow_id, manifest)
|
|
1589
|
+
current_hashes, sources = _source_manifest_hashes(project_root, wf_state)
|
|
1590
|
+
if manifest.get("source_hashes") != current_hashes:
|
|
1591
|
+
raise ValueError("源回退清单在逐项恢复期间发生变化")
|
|
1592
|
+
_validate_abort_against_sources(
|
|
1593
|
+
project_root,
|
|
1594
|
+
workflow_id,
|
|
1595
|
+
manifest,
|
|
1596
|
+
sources,
|
|
1597
|
+
)
|
|
1598
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1599
|
+
return [], [str(exc)]
|
|
1600
|
+
|
|
1601
|
+
restored_now: list[str] = []
|
|
1602
|
+
failures: list[str] = []
|
|
1603
|
+
for item in items:
|
|
1604
|
+
if item.get("status") == "restored":
|
|
1605
|
+
continue
|
|
1606
|
+
item["attempts"] = int(item.get("attempts") or 0) + 1
|
|
1607
|
+
item["started_at"] = state_mod.now_iso()
|
|
1608
|
+
item["last_error"] = None
|
|
1609
|
+
try:
|
|
1610
|
+
if item.get("status") == "pending":
|
|
1611
|
+
item["observed_before_restore"] = _snapshot_abort_item_state(
|
|
1612
|
+
project_root,
|
|
1613
|
+
item,
|
|
1614
|
+
)
|
|
1615
|
+
item["status"] = "restoring"
|
|
1616
|
+
_write_abort_manifest(project_root, workflow_id, manifest)
|
|
1617
|
+
current = _snapshot_abort_item_state(project_root, item)
|
|
1618
|
+
target = _abort_item_target_state(item)
|
|
1619
|
+
if not _abort_state_matches_target(current, target):
|
|
1620
|
+
if current != item["observed_before_restore"]:
|
|
1621
|
+
raise ValueError(
|
|
1622
|
+
f"{_abort_item_result_name(item)} 在恢复中断后发生了新的修改;"
|
|
1623
|
+
"当前内容既不是恢复前状态,也不是恢复目标,已停止以避免覆盖"
|
|
1624
|
+
)
|
|
1625
|
+
if item["kind"] == "file":
|
|
1626
|
+
_restore_file_entry(
|
|
1627
|
+
project_root,
|
|
1628
|
+
item["path"],
|
|
1629
|
+
item,
|
|
1630
|
+
_abort_item_source_dir(project_root, workflow_id, item),
|
|
1631
|
+
)
|
|
1632
|
+
else:
|
|
1633
|
+
project_mod.restore_managed_fields(project_root, item["fields"])
|
|
1634
|
+
restored_state = _snapshot_abort_item_state(project_root, item)
|
|
1635
|
+
if not _abort_state_matches_target(restored_state, target):
|
|
1636
|
+
raise ValueError(
|
|
1637
|
+
f"{_abort_item_result_name(item)} 恢复后的状态与目标不一致"
|
|
1638
|
+
)
|
|
1639
|
+
result_name = _abort_item_result_name(item)
|
|
1640
|
+
item["status"] = "restored"
|
|
1641
|
+
item["restored_at"] = state_mod.now_iso()
|
|
1642
|
+
item["last_error"] = None
|
|
1643
|
+
_write_abort_manifest(project_root, workflow_id, manifest)
|
|
1644
|
+
restored_now.append(result_name)
|
|
1645
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1646
|
+
item["last_error"] = str(exc)
|
|
1647
|
+
try:
|
|
1648
|
+
_write_abort_manifest(project_root, workflow_id, manifest)
|
|
1649
|
+
except OSError as progress_exc:
|
|
1650
|
+
failures.append(f"{item['id']}:恢复失败且进度无法保存({progress_exc})")
|
|
1651
|
+
break
|
|
1652
|
+
failures.append(f"{item['id']}:{exc}")
|
|
1653
|
+
break
|
|
1654
|
+
|
|
1655
|
+
pending = [
|
|
1656
|
+
item["id"]
|
|
1657
|
+
for item in items
|
|
1658
|
+
if item.get("status") != "restored"
|
|
1659
|
+
]
|
|
1660
|
+
if not failures and pending:
|
|
1661
|
+
failures.append("仍有未完成恢复项目:" + str(pending))
|
|
1662
|
+
if not failures:
|
|
1663
|
+
manifest["restored_at"] = state_mod.now_iso()
|
|
1664
|
+
try:
|
|
1665
|
+
_write_abort_manifest(project_root, workflow_id, manifest)
|
|
1666
|
+
except OSError as exc:
|
|
1667
|
+
failures.append(f"全部项目已恢复,但无法保存完成进度:{exc}")
|
|
1668
|
+
return restored_now, failures
|
|
1669
|
+
|
|
1670
|
+
|
|
1671
|
+
def write_start_transaction(project_root: str, workflow_id: str, clean_paths: list[str]) -> None:
|
|
1672
|
+
"""清场前写入开工事务记录;提交或恢复完成后删除。"""
|
|
1673
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
1674
|
+
payload = {
|
|
1675
|
+
"workflow_id": workflow_id,
|
|
1676
|
+
"created_at": state_mod.now_iso(),
|
|
1677
|
+
"clean_paths": clean_paths,
|
|
1678
|
+
"status": "prepared",
|
|
1679
|
+
}
|
|
1680
|
+
full_path = os.path.join(project_root, START_TRANSACTION_FILE)
|
|
1681
|
+
_atomic_write_json(full_path, payload)
|
|
1682
|
+
|
|
1683
|
+
|
|
1684
|
+
def read_start_transaction(project_root: str) -> dict | None:
|
|
1685
|
+
full_path = os.path.join(project_root, START_TRANSACTION_FILE)
|
|
1686
|
+
if not os.path.isfile(full_path):
|
|
1687
|
+
return None
|
|
1688
|
+
try:
|
|
1689
|
+
with open(full_path, "r", encoding="utf-8") as stream:
|
|
1690
|
+
return json.load(stream)
|
|
1691
|
+
except (OSError, json.JSONDecodeError):
|
|
1692
|
+
return {"workflow_id": None, "status": "unreadable"}
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def mark_start_transaction_committed(project_root: str, workflow_id: str) -> None:
|
|
1696
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
1697
|
+
payload = {
|
|
1698
|
+
"workflow_id": workflow_id,
|
|
1699
|
+
"created_at": state_mod.now_iso(),
|
|
1700
|
+
"status": "committed",
|
|
1701
|
+
}
|
|
1702
|
+
full_path = os.path.join(project_root, START_TRANSACTION_FILE)
|
|
1703
|
+
_atomic_write_json(full_path, payload)
|
|
1704
|
+
|
|
1705
|
+
|
|
1706
|
+
def clear_start_transaction(project_root: str) -> None:
|
|
1707
|
+
full_path = os.path.join(project_root, START_TRANSACTION_FILE)
|
|
1708
|
+
if os.path.isfile(full_path):
|
|
1709
|
+
os.remove(full_path)
|
|
1710
|
+
|
|
1711
|
+
|
|
1712
|
+
def restore_start_baseline(project_root: str, workflow_id: str) -> tuple[list[str], list[str]]:
|
|
1713
|
+
"""按开工基线恢复受管文档;返回(已恢复路径, 失败说明)。
|
|
1714
|
+
|
|
1715
|
+
只恢复清单内的文件:写回原内容;开工后新建、不在清单中的受管文档不在
|
|
1716
|
+
这里处理(开工失败场景中新建内容由调用方按新旧清单差异删除)。
|
|
1717
|
+
"""
|
|
1718
|
+
try:
|
|
1719
|
+
workflow_id = _validated_workflow_id(workflow_id)
|
|
1720
|
+
manifest_relative = _start_manifest_rel_path(workflow_id)
|
|
1721
|
+
manifest, _raw, entries, manifest_dir = _load_source_manifest(
|
|
1722
|
+
project_root,
|
|
1723
|
+
manifest_relative,
|
|
1724
|
+
workflow_id,
|
|
1725
|
+
allow_process_documents=True,
|
|
1726
|
+
require_complete_start=True,
|
|
1727
|
+
)
|
|
1728
|
+
except (OSError, TypeError, ValueError, json.JSONDecodeError) as exc:
|
|
1729
|
+
return [], [str(exc)]
|
|
1730
|
+
restored: list[str] = []
|
|
1731
|
+
failures: list[str] = []
|
|
1732
|
+
for relative_path, entry in entries.items():
|
|
1733
|
+
try:
|
|
1734
|
+
_restore_file_entry(project_root, relative_path, entry, manifest_dir)
|
|
1735
|
+
restored.append(relative_path)
|
|
1736
|
+
except (OSError, TypeError, ValueError) as exc:
|
|
1737
|
+
failures.append(f"{relative_path}({exc})")
|
|
1738
|
+
return restored, failures
|