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.
Files changed (60) hide show
  1. workflow_loop/__init__.py +6 -0
  2. workflow_loop/acceptance_records.py +338 -0
  3. workflow_loop/artifact_paths.py +278 -0
  4. workflow_loop/artifact_validation.py +1738 -0
  5. workflow_loop/bug_record.py +203 -0
  6. workflow_loop/cli.py +3257 -0
  7. workflow_loop/data/Standardized_Repository/acceptance/acceptance.md +119 -0
  8. workflow_loop/data/Standardized_Repository/acceptance/acceptance_plan.md +105 -0
  9. workflow_loop/data/Standardized_Repository/code_design/code_design.md +204 -0
  10. workflow_loop/data/Standardized_Repository/code_design/project_design_init.md +152 -0
  11. workflow_loop/data/Standardized_Repository/code_design/revise_code_design.md +32 -0
  12. workflow_loop/data/Standardized_Repository/code_design/update_code_design.md +94 -0
  13. workflow_loop/data/Standardized_Repository/global/document_writing.md +77 -0
  14. workflow_loop/data/Standardized_Repository/global/workflow_lifecycle.md +91 -0
  15. workflow_loop/data/Standardized_Repository/impl/code_implementation.md +85 -0
  16. workflow_loop/data/Standardized_Repository/impl/impl.md +164 -0
  17. workflow_loop/data/Standardized_Repository/qa/test.md +167 -0
  18. workflow_loop/data/Standardized_Repository/qa/test_code.md +121 -0
  19. workflow_loop/data/Standardized_Repository/qa/test_code_implementation.md +67 -0
  20. workflow_loop/data/Standardized_Repository/qa/test_plan.md +160 -0
  21. workflow_loop/data/Standardized_Repository/reproduce/reproduce.md +60 -0
  22. workflow_loop/data/Standardized_Repository/spec/spec.md +138 -0
  23. workflow_loop/data/Standardized_Repository/spike/spike.md +236 -0
  24. workflow_loop/data/Template_Repository/acceptance/acceptance_plan.md +142 -0
  25. workflow_loop/data/Template_Repository/acceptance/acceptance_result.md +108 -0
  26. workflow_loop/data/Template_Repository/code_design/code_design.md +260 -0
  27. workflow_loop/data/Template_Repository/code_design/project_design_init_evidence.md +39 -0
  28. workflow_loop/data/Template_Repository/impl/impl.md +112 -0
  29. workflow_loop/data/Template_Repository/qa/test.md +102 -0
  30. workflow_loop/data/Template_Repository/qa/test_plan.md +100 -0
  31. workflow_loop/data/Template_Repository/reproduce/reproduce.md +82 -0
  32. workflow_loop/data/Template_Repository/spec/spec.md +222 -0
  33. workflow_loop/data/Template_Repository/spike/spike.md +135 -0
  34. workflow_loop/installer.py +632 -0
  35. workflow_loop/journal.py +78 -0
  36. workflow_loop/path_composer.py +152 -0
  37. workflow_loop/process_runner.py +176 -0
  38. workflow_loop/project.py +397 -0
  39. workflow_loop/role_doc.py +133 -0
  40. workflow_loop/rollback.py +1738 -0
  41. workflow_loop/spike_validation.py +379 -0
  42. workflow_loop/stage_materials.py +169 -0
  43. workflow_loop/stages/__init__.py +45 -0
  44. workflow_loop/stages/base.py +164 -0
  45. workflow_loop/stages/stages.py +1191 -0
  46. workflow_loop/state.py +582 -0
  47. workflow_loop/test_entry.py +123 -0
  48. workflow_loop/test_execution.py +619 -0
  49. workflow_loop/test_mapping.py +568 -0
  50. workflow_loop/test_runner.py +134 -0
  51. workflow_loop/topic.py +114 -0
  52. workflow_loop/topic_relations.py +202 -0
  53. workflow_loop/traceability.py +533 -0
  54. workflow_loop/verification.py +971 -0
  55. workflow_loop-0.1.0.dist-info/METADATA +187 -0
  56. workflow_loop-0.1.0.dist-info/RECORD +60 -0
  57. workflow_loop-0.1.0.dist-info/WHEEL +5 -0
  58. workflow_loop-0.1.0.dist-info/entry_points.txt +2 -0
  59. workflow_loop-0.1.0.dist-info/licenses/LICENSE +21 -0
  60. workflow_loop-0.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,632 @@
1
+ import hashlib
2
+ import json
3
+ import os
4
+ import re
5
+ import shutil
6
+ from datetime import datetime, timezone
7
+
8
+ # importlib.resources 用于访问包内打包的数据文件(Template_Repository 等)
9
+ # Python 3.9+ 自带 importlib.resources.files;旧版本需要 importlib_resources 后备包
10
+ try:
11
+ from importlib.resources import files as resource_files
12
+ except ImportError:
13
+ from importlib_resources import files as resource_files
14
+
15
+ from . import PRODUCT_NAME, __version__ as PRODUCT_VERSION
16
+ from .project import (
17
+ AGENTS_MD_FILENAME,
18
+ STANDARDIZED_DIRNAME,
19
+ TEMPLATE_DIRNAME,
20
+ WORKFLOW_LOOP_DIRNAME,
21
+ check_skeleton,
22
+ create_project,
23
+ )
24
+
25
+ # AGENTS.md 的固定内容(最小契约 + 核心表达要求)
26
+ # 唯一契约文件名,告诉 AI "调 workflow start,跟着 stdout 走",并约束聊天表达
27
+ # 安装时直接整份覆盖,不询问、不合并、不备份(CONTEXT.md "Agent Contract File")
28
+ AGENTS_MD_CONTENT = """# Agent 契约
29
+
30
+ 本项目由 workflow_loop 管理。
31
+
32
+ ## workflow 入口
33
+
34
+ - 用户只需要提出需求,不需要知道或手动执行任何 `workflow` 命令。
35
+ - 用户提出需求后,由 AI 调 `workflow start`,之后严格按每条命令 stdout 打印的"下一步"执行。
36
+ - 日常 `workflow` 命令全部由 AI 执行。AI 每次执行或转述命令时,必须先用直白中文说明:
37
+ 这条命令要解决什么、会做什么和不会做什么、执行成功后进入哪一步、用户现在是否需要
38
+ 确认或操作;不能只把 stdout 中的英文命令原样转发给用户。
39
+
40
+ ## 表达要求
41
+
42
+ AI 回复用户和编写正式文档时:
43
+
44
+ - 输出前先弄清实际问题、已知事实、限制和目标。
45
+ - 能用直白话就不用抽象词;必须使用专业词时,马上说明它具体指什么。
46
+ - 写清谁在什么情况下做什么,以及会得到什么结果。
47
+ - 删除空泛、重复,或者没有增加事实、决定、行动和理由的话。
48
+ """
49
+
50
+ # 一次性安装事务目录:固定放在项目根下,安装脚本在确认前向用户披露
51
+ # 进程被强制终止时目录保留;下次安装先识别并恢复,再重新开始
52
+ TRANSACTION_DIRNAME = ".workflow_loop_install_tx"
53
+ # 事务清单文件名(记录状态、允许写入路径和备份项)
54
+ MANIFEST_FILENAME = "transaction.json"
55
+ # 原内容备份子目录
56
+ BACKUP_DIRNAME = "backup"
57
+ # 临时骨架准备子目录(先在这里搭好完整骨架,校验后才替换进项目)
58
+ STAGING_DIRNAME = "staging"
59
+
60
+ # 本次安装允许写入的项目相对路径(必须与安装脚本让用户确认的清单一致)
61
+ PROJECT_WRITE_PATHS = (
62
+ AGENTS_MD_FILENAME,
63
+ WORKFLOW_LOOP_DIRNAME,
64
+ )
65
+ SHA256_PATTERN = re.compile(r"^[0-9a-f]{64}$")
66
+
67
+
68
+ def _now_iso() -> str:
69
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
70
+
71
+
72
+ # 递归复制包内资源树到目标文件系统路径
73
+ # importlib.resources 的 Traversable 对象不能直接用 shutil.copytree
74
+ # 所以手动递归遍历,遇到目录就递归,遇到文件就复制
75
+ def _copy_resource_tree(src_root, dst_root: str) -> None:
76
+ # 确保目标目录存在
77
+ os.makedirs(dst_root, exist_ok=True)
78
+ # 遍历源目录下的每个子项
79
+ for child in src_root.iterdir():
80
+ # 拼出目标路径
81
+ dst_path = os.path.join(dst_root, child.name)
82
+ # 子项是目录 → 递归复制
83
+ if child.is_dir():
84
+ _copy_resource_tree(child, dst_path)
85
+ # 子项是文件 → 复制文件内容
86
+ else:
87
+ # 打开包内资源文件(二进制)
88
+ with child.open("rb") as src_f:
89
+ # 写到目标路径
90
+ with open(dst_path, "wb") as dst_f:
91
+ shutil.copyfileobj(src_f, dst_f)
92
+
93
+
94
+ def _transaction_dir(project_root: str) -> str:
95
+ return os.path.join(project_root, TRANSACTION_DIRNAME)
96
+
97
+
98
+ def _manifest_path(project_root: str) -> str:
99
+ return os.path.join(_transaction_dir(project_root), MANIFEST_FILENAME)
100
+
101
+
102
+ def _load_manifest(project_root: str) -> dict:
103
+ path = _manifest_path(project_root)
104
+ if not os.path.lexists(path):
105
+ raise ValueError(f"缺少事务清单 {MANIFEST_FILENAME}")
106
+ if os.path.islink(path) or not os.path.isfile(path):
107
+ raise ValueError(f"事务清单 {MANIFEST_FILENAME} 不是普通文件")
108
+ try:
109
+ with open(path, "r", encoding="utf-8") as f:
110
+ manifest = json.load(f)
111
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
112
+ raise ValueError(f"事务清单 {MANIFEST_FILENAME} 无法读取: {exc}") from exc
113
+ if not isinstance(manifest, dict):
114
+ raise ValueError(f"事务清单 {MANIFEST_FILENAME} 的顶层必须是对象")
115
+ return manifest
116
+
117
+
118
+ def _save_manifest(project_root: str, manifest: dict) -> None:
119
+ path = _manifest_path(project_root)
120
+ os.makedirs(os.path.dirname(path), exist_ok=True)
121
+ temp_path = path + ".tmp"
122
+ with open(temp_path, "w", encoding="utf-8") as f:
123
+ json.dump(manifest, f, ensure_ascii=False, indent=2)
124
+ f.write("\n")
125
+ f.flush()
126
+ os.fsync(f.fileno())
127
+ os.replace(temp_path, path)
128
+
129
+
130
+ def _sha256_file(path: str) -> str:
131
+ digest = hashlib.sha256()
132
+ with open(path, "rb") as f:
133
+ for chunk in iter(lambda: f.read(1024 * 1024), b""):
134
+ digest.update(chunk)
135
+ return digest.hexdigest()
136
+
137
+
138
+ def _normalize_relative_path(value: object, label: str) -> str:
139
+ if not isinstance(value, str) or not value:
140
+ raise ValueError(f"{label}必须是非空相对路径")
141
+ if "\x00" in value:
142
+ raise ValueError(f"{label}包含空字符")
143
+
144
+ normalized = value.replace("\\", "/")
145
+ if normalized.startswith("/") or re.match(r"^[A-Za-z]:", normalized):
146
+ raise ValueError(f"{label}不能是绝对路径: {value!r}")
147
+ parts = normalized.split("/")
148
+ if any(part in ("", ".", "..") for part in parts):
149
+ raise ValueError(f"{label}包含不安全路径片段: {value!r}")
150
+ return "/".join(parts)
151
+
152
+
153
+ def _same_path(left: str, right: str) -> bool:
154
+ return os.path.normcase(os.path.normpath(left)) == os.path.normcase(os.path.normpath(right))
155
+
156
+
157
+ def _resolve_inside(base: str, value: object, label: str) -> tuple[str, str]:
158
+ normalized = _normalize_relative_path(value, label)
159
+ base_real = os.path.realpath(base)
160
+ candidate = os.path.abspath(os.path.join(base, *normalized.split("/")))
161
+ parent_real = os.path.realpath(os.path.dirname(candidate))
162
+ try:
163
+ if not _same_path(os.path.commonpath((base_real, parent_real)), base_real):
164
+ raise ValueError(f"{label}逃出了允许目录: {value!r}")
165
+ except ValueError as exc:
166
+ raise ValueError(f"{label}逃出了允许目录: {value!r}") from exc
167
+ return normalized, candidate
168
+
169
+
170
+ # 校验安装脚本生成的一次性事务文件。
171
+ # 事务不存在、已经使用、产品身份或版本不符、项目路径不一致、允许写入路径与本次
172
+ # 安装的实际写入范围不同时,都在任何写入前失败。
173
+ def _validate_transaction_token(project_root: str, token_path: str) -> tuple[dict | None, str]:
174
+ if not token_path:
175
+ return None, "缺少一次性安装事务文件路径"
176
+ if not os.path.isfile(token_path):
177
+ return None, f"一次性安装事务文件不存在: {token_path}"
178
+ try:
179
+ with open(token_path, "r", encoding="utf-8") as f:
180
+ token = json.load(f)
181
+ except (OSError, UnicodeError, json.JSONDecodeError) as exc:
182
+ return None, f"一次性安装事务文件无法读取: {exc}"
183
+ if not isinstance(token, dict):
184
+ return None, "一次性安装事务文件的顶层必须是对象"
185
+
186
+ if token.get("product") != PRODUCT_NAME:
187
+ return None, f"事务产品标识是 {token.get('product')!r},本安装器只接受 {PRODUCT_NAME!r}"
188
+ if token.get("version") != PRODUCT_VERSION:
189
+ return None, f"事务版本是 {token.get('version')!r},当前产品固定为 {PRODUCT_VERSION!r}"
190
+ if token.get("used") is not False:
191
+ return None, "这份安装事务已经使用过,不能再次写入项目"
192
+
193
+ token_root = token.get("project_root", "")
194
+ if (
195
+ not isinstance(token_root, str)
196
+ or not token_root
197
+ or not _same_path(os.path.realpath(token_root), os.path.realpath(project_root))
198
+ ):
199
+ return None, (
200
+ f"事务登记的项目路径是 {token_root!r},与当前目录 {project_root!r} 不一致"
201
+ )
202
+
203
+ allowed = token.get("allowed_paths")
204
+ if (
205
+ not isinstance(allowed, list)
206
+ or not all(isinstance(path, str) for path in allowed)
207
+ or len(allowed) != len(PROJECT_WRITE_PATHS)
208
+ or sorted(allowed) != sorted(PROJECT_WRITE_PATHS)
209
+ ):
210
+ return None, (
211
+ f"事务允许写入的路径 {allowed!r} 与本次安装的实际写入范围 "
212
+ f"{sorted(PROJECT_WRITE_PATHS)!r} 不一致"
213
+ )
214
+ return token, ""
215
+
216
+
217
+ # 把事务文件标记为已使用:从此这份事务不能再驱动任何项目写入
218
+ def _mark_token_used(token_path: str, token: dict) -> None:
219
+ token["used"] = True
220
+ token["used_at"] = _now_iso()
221
+ with open(token_path, "w", encoding="utf-8") as f:
222
+ json.dump(token, f, ensure_ascii=False, indent=2)
223
+
224
+
225
+ # 在事务目录内准备完整项目骨架并校验;此时项目本身未被修改
226
+ def _prepare_staging(project_root: str) -> tuple[str, list[str]]:
227
+ staging_root = os.path.join(_transaction_dir(project_root), STAGING_DIRNAME)
228
+ if os.path.exists(staging_root):
229
+ shutil.rmtree(staging_root)
230
+ os.makedirs(staging_root)
231
+
232
+ # 定位包内 data/ 目录(importlib.resources)
233
+ pkg_root = resource_files("workflow_loop")
234
+ data_root = pkg_root.joinpath("data")
235
+
236
+ wf_staging = os.path.join(staging_root, WORKFLOW_LOOP_DIRNAME)
237
+ _copy_resource_tree(data_root.joinpath(TEMPLATE_DIRNAME), os.path.join(wf_staging, TEMPLATE_DIRNAME))
238
+ _copy_resource_tree(
239
+ data_root.joinpath(STANDARDIZED_DIRNAME), os.path.join(wf_staging, STANDARDIZED_DIRNAME)
240
+ )
241
+ with open(os.path.join(staging_root, AGENTS_MD_FILENAME), "w", encoding="utf-8") as f:
242
+ f.write(AGENTS_MD_CONTENT)
243
+
244
+ # 校验临时骨架完整:两个仓库非空、契约存在
245
+ problems: list[str] = []
246
+ for dirname in (TEMPLATE_DIRNAME, STANDARDIZED_DIRNAME):
247
+ staged = os.path.join(wf_staging, dirname)
248
+ if not os.path.isdir(staged) or not os.listdir(staged):
249
+ problems.append(f"临时骨架缺少 {dirname}/ 或内容为空")
250
+ if not os.path.isfile(os.path.join(staging_root, AGENTS_MD_FILENAME)):
251
+ problems.append(f"临时骨架缺少 {AGENTS_MD_FILENAME}")
252
+ return staging_root, problems
253
+
254
+
255
+ # 第一次持久写入前,把将被覆盖的原内容和"原本不存在"记录保存进事务清单
256
+ def _record_backups(project_root: str) -> list[dict]:
257
+ backup_root = os.path.join(_transaction_dir(project_root), BACKUP_DIRNAME)
258
+ os.makedirs(backup_root, exist_ok=True)
259
+ entries: list[dict] = []
260
+
261
+ agents_path = os.path.join(project_root, AGENTS_MD_FILENAME)
262
+ if os.path.lexists(agents_path):
263
+ if os.path.islink(agents_path) or not os.path.isfile(agents_path):
264
+ raise ValueError(f"{AGENTS_MD_FILENAME} 不是可安全覆盖的普通文件")
265
+ backup_rel = f"{BACKUP_DIRNAME}/{AGENTS_MD_FILENAME}"
266
+ backup_path = os.path.join(backup_root, AGENTS_MD_FILENAME)
267
+ shutil.copy2(agents_path, backup_path)
268
+ entries.append(
269
+ {
270
+ "path": AGENTS_MD_FILENAME,
271
+ "existed": True,
272
+ "backup": backup_rel,
273
+ "sha256": _sha256_file(backup_path),
274
+ }
275
+ )
276
+ else:
277
+ entries.append(
278
+ {
279
+ "path": AGENTS_MD_FILENAME,
280
+ "existed": False,
281
+ "backup": None,
282
+ "sha256": None,
283
+ }
284
+ )
285
+
286
+ # 未安装项目 .workflow_loop/ 不应存在;记录"原本不存在"以便失败时整目录删除
287
+ workflow_path = os.path.join(project_root, WORKFLOW_LOOP_DIRNAME)
288
+ if os.path.lexists(workflow_path):
289
+ raise ValueError(f"{WORKFLOW_LOOP_DIRNAME} 在安装准备期间出现,已停止以免覆盖")
290
+ entries.append(
291
+ {
292
+ "path": WORKFLOW_LOOP_DIRNAME,
293
+ "existed": False,
294
+ "backup": None,
295
+ "sha256": None,
296
+ }
297
+ )
298
+ return entries
299
+
300
+
301
+ def _validate_recovery_manifest(project_root: str, manifest: dict) -> None:
302
+ if not isinstance(manifest, dict):
303
+ raise ValueError("事务清单顶层必须是对象")
304
+
305
+ tx_dir = _transaction_dir(project_root)
306
+ if not os.path.lexists(tx_dir):
307
+ raise ValueError("事务目录不存在")
308
+ if os.path.islink(tx_dir) or not os.path.isdir(tx_dir):
309
+ raise ValueError("事务目录不是项目根下的普通目录")
310
+
311
+ project_real = os.path.realpath(project_root)
312
+ tx_real = os.path.realpath(tx_dir)
313
+ if not _same_path(os.path.dirname(tx_real), project_real):
314
+ raise ValueError("事务目录实际位置不在项目根目录下")
315
+
316
+ if manifest.get("product") != PRODUCT_NAME:
317
+ raise ValueError(
318
+ f"事务产品标识是 {manifest.get('product')!r},期望 {PRODUCT_NAME!r}"
319
+ )
320
+ if manifest.get("version") != PRODUCT_VERSION:
321
+ raise ValueError(
322
+ f"事务版本是 {manifest.get('version')!r},期望 {PRODUCT_VERSION!r}"
323
+ )
324
+
325
+ manifest_root = manifest.get("project_root")
326
+ if (
327
+ not isinstance(manifest_root, str)
328
+ or not manifest_root
329
+ or not _same_path(os.path.realpath(manifest_root), project_real)
330
+ ):
331
+ raise ValueError(
332
+ f"事务登记的项目路径 {manifest_root!r} 与当前项目 {project_root!r} 不一致"
333
+ )
334
+ if not isinstance(manifest.get("created_at"), str) or not manifest["created_at"]:
335
+ raise ValueError("事务缺少创建时间")
336
+
337
+ status = manifest.get("status")
338
+ if status not in ("prepared", "committed"):
339
+ raise ValueError(f"事务状态 {status!r} 不允许恢复或清理")
340
+
341
+ allowed = manifest.get("allowed_paths")
342
+ if (
343
+ not isinstance(allowed, list)
344
+ or not all(isinstance(path, str) for path in allowed)
345
+ or len(allowed) != len(PROJECT_WRITE_PATHS)
346
+ or sorted(allowed) != sorted(PROJECT_WRITE_PATHS)
347
+ ):
348
+ raise ValueError(
349
+ f"事务允许路径 {allowed!r} 与固定范围 {sorted(PROJECT_WRITE_PATHS)!r} 不一致"
350
+ )
351
+ for path in allowed:
352
+ if _normalize_relative_path(path, "事务允许路径") != path:
353
+ raise ValueError(f"事务允许路径不是规范相对路径: {path!r}")
354
+
355
+ entries = manifest.get("backups")
356
+ if not isinstance(entries, list) or len(entries) != len(PROJECT_WRITE_PATHS):
357
+ raise ValueError("事务备份清单必须逐项覆盖全部固定写入路径")
358
+
359
+ backup_root = os.path.join(tx_dir, BACKUP_DIRNAME)
360
+ targets: dict[str, str] = {}
361
+ seen: set[str] = set()
362
+ for entry in entries:
363
+ if not isinstance(entry, dict):
364
+ raise ValueError("事务备份项必须是对象")
365
+
366
+ raw_path = entry.get("path")
367
+ normalized_path, target = _resolve_inside(project_root, raw_path, "事务备份目标")
368
+ if normalized_path != raw_path or normalized_path not in PROJECT_WRITE_PATHS:
369
+ raise ValueError(f"事务备份目标不在固定写入范围内: {raw_path!r}")
370
+ if normalized_path in seen:
371
+ raise ValueError(f"事务备份目标重复: {normalized_path}")
372
+ seen.add(normalized_path)
373
+ targets[normalized_path] = target
374
+
375
+ existed = entry.get("existed")
376
+ if not isinstance(existed, bool):
377
+ raise ValueError(f"{normalized_path} 的 existed 必须是布尔值")
378
+ if "backup" not in entry or "sha256" not in entry:
379
+ raise ValueError(f"{normalized_path} 的备份路径或 SHA-256 缺失")
380
+
381
+ if normalized_path == WORKFLOW_LOOP_DIRNAME and existed:
382
+ raise ValueError(f"{WORKFLOW_LOOP_DIRNAME} 在本类安装事务中不应登记为原本存在")
383
+
384
+ if existed:
385
+ if normalized_path != AGENTS_MD_FILENAME:
386
+ raise ValueError(f"{normalized_path} 不允许从文件备份恢复")
387
+ expected_backup = f"{BACKUP_DIRNAME}/{AGENTS_MD_FILENAME}"
388
+ backup_relative, backup_path = _resolve_inside(
389
+ tx_dir, entry.get("backup"), f"{normalized_path} 的备份路径"
390
+ )
391
+ if backup_relative != expected_backup:
392
+ raise ValueError(
393
+ f"{normalized_path} 的备份路径 {backup_relative!r} 不是 {expected_backup!r}"
394
+ )
395
+ if os.path.islink(backup_root):
396
+ raise ValueError("事务备份目录不能是符号链接")
397
+ expected_backup_root_real = os.path.join(tx_real, BACKUP_DIRNAME)
398
+ if not _same_path(os.path.realpath(backup_root), expected_backup_root_real):
399
+ raise ValueError("事务备份目录实际位置异常")
400
+ if os.path.islink(backup_path) or not os.path.isfile(backup_path):
401
+ raise ValueError(f"{normalized_path} 的备份不是普通文件")
402
+ expected_backup_real = os.path.join(
403
+ expected_backup_root_real, AGENTS_MD_FILENAME
404
+ )
405
+ if not _same_path(os.path.realpath(backup_path), expected_backup_real):
406
+ raise ValueError(f"{normalized_path} 的备份实际位置异常")
407
+
408
+ expected_hash = entry.get("sha256")
409
+ if not isinstance(expected_hash, str) or not SHA256_PATTERN.fullmatch(
410
+ expected_hash
411
+ ):
412
+ raise ValueError(f"{normalized_path} 的备份 SHA-256 格式错误")
413
+ actual_hash = _sha256_file(backup_path)
414
+ if actual_hash != expected_hash:
415
+ raise ValueError(
416
+ f"{normalized_path} 的备份 SHA-256 不一致,备份可能已损坏或被替换"
417
+ )
418
+ elif entry.get("backup") is not None or entry.get("sha256") is not None:
419
+ raise ValueError(f"{normalized_path} 原本不存在,却登记了备份文件或 SHA-256")
420
+
421
+ if seen != set(PROJECT_WRITE_PATHS):
422
+ raise ValueError("事务备份清单没有完整覆盖固定写入路径")
423
+
424
+ # prepared 会恢复项目,所以先一次性检查所有目标类型和实际位置。
425
+ # committed 只删除已校验的事务目录,不接触这些目标。
426
+ if status == "prepared":
427
+ for relative_path, target in targets.items():
428
+ if not os.path.lexists(target):
429
+ continue
430
+ if os.path.islink(target):
431
+ raise ValueError(f"{relative_path} 是符号链接,拒绝恢复")
432
+ expected_real = os.path.join(project_real, *relative_path.split("/"))
433
+ if not _same_path(os.path.realpath(target), expected_real):
434
+ raise ValueError(f"{relative_path} 的实际位置逃出了项目固定路径")
435
+ if relative_path == AGENTS_MD_FILENAME and not os.path.isfile(target):
436
+ raise ValueError(f"{AGENTS_MD_FILENAME} 不是普通文件,拒绝恢复")
437
+ if relative_path == WORKFLOW_LOOP_DIRNAME and not os.path.isdir(target):
438
+ raise ValueError(f"{WORKFLOW_LOOP_DIRNAME} 不是普通目录,拒绝恢复")
439
+
440
+
441
+ # 按事务清单恢复项目:删除本次新建内容、写回原内容。
442
+ # 返回未能恢复的路径列表;为空表示恢复完整。
443
+ def _restore_from_manifest(project_root: str, manifest: dict) -> list[str]:
444
+ try:
445
+ _validate_recovery_manifest(project_root, manifest)
446
+ except (OSError, ValueError) as exc:
447
+ return [f"事务清单未通过恢复前校验({exc})"]
448
+
449
+ failures: list[str] = []
450
+ tx_dir = _transaction_dir(project_root)
451
+ for entry in manifest["backups"]:
452
+ relative_path = entry["path"]
453
+ target = os.path.join(project_root, relative_path)
454
+ try:
455
+ if entry["existed"]:
456
+ backup = os.path.join(tx_dir, *entry["backup"].split("/"))
457
+ if os.path.islink(target) or (
458
+ os.path.lexists(target) and not os.path.isfile(target)
459
+ ):
460
+ failures.append(f"{relative_path}(目标不再是可安全恢复的普通文件)")
461
+ continue
462
+ shutil.copy2(backup, target)
463
+ else:
464
+ # 原本不存在 → 删除本次安装新建的文件或目录
465
+ if not os.path.lexists(target):
466
+ continue
467
+ if os.path.islink(target):
468
+ failures.append(f"{relative_path}(目标变成了符号链接)")
469
+ continue
470
+ if relative_path == WORKFLOW_LOOP_DIRNAME:
471
+ if not os.path.isdir(target):
472
+ failures.append(f"{relative_path}(目标不再是普通目录)")
473
+ continue
474
+ shutil.rmtree(target)
475
+ else:
476
+ if not os.path.isfile(target):
477
+ failures.append(f"{relative_path}(目标不再是普通文件)")
478
+ continue
479
+ os.remove(target)
480
+ except OSError as exc:
481
+ failures.append(f"{relative_path}({exc})")
482
+ return failures
483
+
484
+
485
+ # 识别并处理上一次没有完成的安装事务。
486
+ # 已提交但没来得及清理 → 只清理临时内容;未提交 → 恢复项目后清理。
487
+ # 返回 (是否可以继续本次安装, 说明文字)。
488
+ def recover_pending_transaction(project_root: str) -> tuple[bool, str]:
489
+ tx_dir = _transaction_dir(project_root)
490
+ if not os.path.lexists(tx_dir):
491
+ return True, ""
492
+
493
+ try:
494
+ manifest = _load_manifest(project_root)
495
+ _validate_recovery_manifest(project_root, manifest)
496
+ except (OSError, ValueError) as exc:
497
+ return (
498
+ False,
499
+ "检测到安装事务残留,但恢复前校验失败;项目文件未修改,事务目录已保留:\n"
500
+ f" {exc}",
501
+ )
502
+
503
+ if manifest.get("status") == "committed":
504
+ # 上次安装已成功,只是清理被打断 → 只完成清理,不回退成功的安装
505
+ try:
506
+ shutil.rmtree(tx_dir)
507
+ except OSError as exc:
508
+ return False, f"上一笔安装已成功,但事务临时目录清理失败:\n {exc}"
509
+ return True, "上一笔安装事务已成功,清理了遗留的临时内容。"
510
+
511
+ failures = _restore_from_manifest(project_root, manifest)
512
+ if failures:
513
+ return False, "上一笔未完成安装事务恢复失败,未恢复路径:\n " + "\n ".join(failures)
514
+ try:
515
+ shutil.rmtree(tx_dir)
516
+ except OSError as exc:
517
+ return False, f"上一笔安装事务已恢复,但事务临时目录清理失败:\n {exc}"
518
+ return True, "恢复了上一笔未完成的安装事务,项目已回到安装前状态。"
519
+
520
+
521
+ # 安装当前项目(由官方安装脚本通过内部 _install-project 入口调用,非日常命令)。
522
+ # 一次性事务驱动:校验事务 → 识别遗留事务 → 重复安装零修改 → 残缺骨架停止 →
523
+ # 临时骨架准备 → 保存原内容 → 原子替换 → 复核 → 清理。
524
+ def install_project_transaction(project_root: str, token_path: str) -> int:
525
+ # 先识别遗留事务:未恢复完成前不能开始新安装
526
+ ok, message = recover_pending_transaction(project_root)
527
+ if message:
528
+ print(message)
529
+ if not ok:
530
+ print("请先解决上述问题,再重新运行官方安装脚本。")
531
+ return 1
532
+
533
+ # 校验一次性事务文件
534
+ token, error = _validate_transaction_token(project_root, token_path)
535
+ if token is None:
536
+ print(f"安装事务校验失败:{error}")
537
+ print("项目文件未修改。请通过官方安装脚本重新发起安装。")
538
+ return 1
539
+
540
+ # 骨架状态分流
541
+ skeleton = check_skeleton(project_root)
542
+ if skeleton.state == "installed":
543
+ _mark_token_used(token_path, token)
544
+ print("当前项目已经安装,未修改任何文件。")
545
+ print("启动 Codex/OpenCode 并提出需求即可。")
546
+ return 0
547
+ if skeleton.state == "broken":
548
+ print("当前项目的 workflow 安装骨架不完整或版本异常,安装已停止:")
549
+ for problem in skeleton.problems:
550
+ print(f" - {problem}")
551
+ print("未修改任何文件。请先人工确认这些内容后再处理,不能当作未安装项目覆盖。")
552
+ return 1
553
+
554
+ # 事务生效:从这里开始该事务不能再用于第二次写入
555
+ _mark_token_used(token_path, token)
556
+
557
+ # 在事务目录内准备并校验完整骨架
558
+ staging_root, problems = _prepare_staging(project_root)
559
+ if problems:
560
+ print("安装包内的骨架资源不完整,安装已停止:")
561
+ for problem in problems:
562
+ print(f" - {problem}")
563
+ shutil.rmtree(_transaction_dir(project_root), ignore_errors=True)
564
+ return 1
565
+
566
+ # 先写可恢复的事务清单,再做第一次持久写入
567
+ try:
568
+ backups = _record_backups(project_root)
569
+ manifest = {
570
+ "product": PRODUCT_NAME,
571
+ "version": PRODUCT_VERSION,
572
+ "project_root": os.path.realpath(project_root),
573
+ "created_at": _now_iso(),
574
+ "status": "prepared",
575
+ "allowed_paths": sorted(PROJECT_WRITE_PATHS),
576
+ "backups": backups,
577
+ }
578
+ _save_manifest(project_root, manifest)
579
+ _validate_recovery_manifest(project_root, manifest)
580
+ except (OSError, ValueError) as exc:
581
+ print(f"安装事务准备失败:{exc}")
582
+ shutil.rmtree(_transaction_dir(project_root), ignore_errors=True)
583
+ print("项目文件未修改。")
584
+ return 1
585
+
586
+ try:
587
+ # 原子替换:把临时骨架移动进项目
588
+ wf_dst = os.path.join(project_root, WORKFLOW_LOOP_DIRNAME)
589
+ os.makedirs(wf_dst, exist_ok=True)
590
+ for dirname in (TEMPLATE_DIRNAME, STANDARDIZED_DIRNAME):
591
+ src = os.path.join(staging_root, WORKFLOW_LOOP_DIRNAME, dirname)
592
+ dst = os.path.join(wf_dst, dirname)
593
+ if os.path.exists(dst):
594
+ shutil.rmtree(dst)
595
+ shutil.move(src, dst)
596
+ shutil.move(
597
+ os.path.join(staging_root, AGENTS_MD_FILENAME),
598
+ os.path.join(project_root, AGENTS_MD_FILENAME),
599
+ )
600
+ # 精简项目级状态(installer_version + installed_at + project_design_initialized=false)
601
+ create_project(project_root)
602
+
603
+ # 复核:安装后骨架必须完整
604
+ final = check_skeleton(project_root)
605
+ if final.state != "installed":
606
+ raise RuntimeError("安装后骨架复核失败: " + "; ".join(final.problems))
607
+ except (OSError, RuntimeError) as exc:
608
+ print(f"安装失败:{exc}")
609
+ failures = _restore_from_manifest(project_root, manifest)
610
+ if failures:
611
+ print("回退不完整,以下路径未恢复(事务目录已保留,下次安装会先恢复):")
612
+ for item in failures:
613
+ print(f" - {item}")
614
+ return 1
615
+ shutil.rmtree(_transaction_dir(project_root), ignore_errors=True)
616
+ print("本次安装的全部修改已恢复,项目回到安装前状态。")
617
+ return 1
618
+
619
+ # 事务提交并清理:不留下永久备份
620
+ manifest["status"] = "committed"
621
+ manifest["committed_at"] = _now_iso()
622
+ _save_manifest(project_root, manifest)
623
+ shutil.rmtree(_transaction_dir(project_root), ignore_errors=True)
624
+
625
+ # 打印安装完成信息
626
+ print("项目安装完成。")
627
+ print(f" .workflow_loop/{TEMPLATE_DIRNAME}/")
628
+ print(f" .workflow_loop/{STANDARDIZED_DIRNAME}/")
629
+ print(f" {AGENTS_MD_FILENAME}")
630
+ print(" .workflow_loop/project.json")
631
+ print("启动 Codex/OpenCode 并提出需求即可。")
632
+ return 0