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
workflow_loop/journal.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
# journal.jsonl 的相对路径(相对于项目根)
|
|
6
|
+
# 放在 .workflow_loop/ 下,和 state.json、project.json 同级
|
|
7
|
+
JOURNAL_FILE = os.path.join(".workflow_loop", "journal.jsonl")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
# 往 journal.jsonl 追加一条记录
|
|
11
|
+
# journal 是 append-only 的历史记录("发生过啥"),不可改
|
|
12
|
+
# 和 state.json("现在在哪",可重写)分离:崩溃恢复时可以从 journal 重建 state
|
|
13
|
+
def append_entry(project_root: str, action: str, actor: str, **kwargs) -> None:
|
|
14
|
+
# 拼出 journal.jsonl 的完整路径
|
|
15
|
+
path = os.path.join(project_root, JOURNAL_FILE)
|
|
16
|
+
# 确保目录存在(第一次写时 .workflow_loop/ 可能还没建)
|
|
17
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
18
|
+
# 组装一条 journal 记录:时间戳 + 动作类型 + 执行者 + 额外字段
|
|
19
|
+
entry = {
|
|
20
|
+
# ISO 8601 UTC 时间戳,去掉微秒让格式更干净
|
|
21
|
+
"ts": datetime.now(timezone.utc).replace(microsecond=0).isoformat(),
|
|
22
|
+
# 动作类型(中文受控词表:工作流启动/提示词加载/门禁讨论完毕/...)
|
|
23
|
+
"action": action,
|
|
24
|
+
# 执行者:ai / user / workflow.py(谁触发了这个动作)
|
|
25
|
+
"actor": actor,
|
|
26
|
+
# 额外字段(如 stage=xxx, passed=true 等,按 action 类型不同带不同 payload)
|
|
27
|
+
**kwargs,
|
|
28
|
+
}
|
|
29
|
+
# 追加写一行 JSON(不覆盖已有内容),ensure_ascii=False 保留中文
|
|
30
|
+
with open(path, "a", encoding="utf-8") as f:
|
|
31
|
+
# 每条记录一行,jsonl 格式(JSON Lines)
|
|
32
|
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# 读最近 N 条 journal 记录(status 命令用,默认 10 条)
|
|
36
|
+
# 返回 list[dict],每条是一个 JSON 对象
|
|
37
|
+
def read_recent(project_root: str, count: int = 10) -> list[dict]:
|
|
38
|
+
# 拼出 journal.jsonl 的完整路径
|
|
39
|
+
path = os.path.join(project_root, JOURNAL_FILE)
|
|
40
|
+
# 文件不存在说明还没 start 过,返回空列表
|
|
41
|
+
if not os.path.exists(path):
|
|
42
|
+
return []
|
|
43
|
+
# 收集所有记录
|
|
44
|
+
entries = []
|
|
45
|
+
# 逐行读取 jsonl 文件
|
|
46
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
47
|
+
for line in f:
|
|
48
|
+
# 去掉行首行尾空白
|
|
49
|
+
line = line.strip()
|
|
50
|
+
# 跳过空行
|
|
51
|
+
if line:
|
|
52
|
+
# 解析 JSON 行
|
|
53
|
+
entries.append(json.loads(line))
|
|
54
|
+
# 只返回最后 count 条(最近的记录)
|
|
55
|
+
return entries[-count:]
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# 读所有 journal 记录(调试和审计用)
|
|
59
|
+
# 返回 list[dict],按时间顺序(从早到晚)
|
|
60
|
+
def read_all(project_root: str) -> list[dict]:
|
|
61
|
+
# 拼出 journal.jsonl 的完整路径
|
|
62
|
+
path = os.path.join(project_root, JOURNAL_FILE)
|
|
63
|
+
# 文件不存在说明还没 start 过,返回空列表
|
|
64
|
+
if not os.path.exists(path):
|
|
65
|
+
return []
|
|
66
|
+
# 收集所有记录
|
|
67
|
+
entries = []
|
|
68
|
+
# 逐行读取 jsonl 文件
|
|
69
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
70
|
+
for line in f:
|
|
71
|
+
# 去掉行首行尾空白
|
|
72
|
+
line = line.strip()
|
|
73
|
+
# 跳过空行
|
|
74
|
+
if line:
|
|
75
|
+
# 解析 JSON 行
|
|
76
|
+
entries.append(json.loads(line))
|
|
77
|
+
# 返回所有记录(从早到晚)
|
|
78
|
+
return entries
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
from .project import is_project_design_initialized
|
|
2
|
+
from .stages import (
|
|
3
|
+
SpecStage,
|
|
4
|
+
CodeDesignStage,
|
|
5
|
+
SpikeStage,
|
|
6
|
+
ImplStage,
|
|
7
|
+
AcceptancePlanStage,
|
|
8
|
+
TestPlanStage,
|
|
9
|
+
TestCodeStage,
|
|
10
|
+
TestExecutionStage,
|
|
11
|
+
TopicAcceptanceStage,
|
|
12
|
+
RegressionTestStage,
|
|
13
|
+
OverallAcceptanceStage,
|
|
14
|
+
UpdateCodeDesignStage,
|
|
15
|
+
ProjectDesignInitStage,
|
|
16
|
+
ReviseCodeDesignStage,
|
|
17
|
+
ReproduceStage,
|
|
18
|
+
)
|
|
19
|
+
from .stages.base import StageStrategy
|
|
20
|
+
|
|
21
|
+
# from_scratch(从零做)的完整 stage 路径
|
|
22
|
+
# 顺序固定:先产品设计与功能拆分、后初步架构(先定做什么,再定怎么搭)
|
|
23
|
+
# 然后验证技术不确定性 → 验收计划 → 测试计划 → 实施/记录 → 写测试代码 → 执行测试 → 主题验收
|
|
24
|
+
# → 最终全量回归 → 整体验收 → 最终设计同步
|
|
25
|
+
# 共享后半截:acceptance_plan → test_plan → impl → test_code → test_execution
|
|
26
|
+
# → topic_acceptance → regression_test → overall_acceptance → update_code_design
|
|
27
|
+
FROM_SCRATCH_PATH = [
|
|
28
|
+
# 产品设计阶段:产出 spec/product.md + spec/feature_*.md
|
|
29
|
+
SpecStage,
|
|
30
|
+
# 初步架构阶段:产出 spec/architecture_code_design.md(从零做的初步架构)
|
|
31
|
+
CodeDesignStage,
|
|
32
|
+
# 穿刺阶段:验证真实场景中的技术不确定性,写清单和每项结论;临时代码按需进入 spike_tmp/
|
|
33
|
+
# 可选 stage,可通过 gate spike --skip 跳过
|
|
34
|
+
SpikeStage,
|
|
35
|
+
# 验收计划阶段:制定什么算完成,产出 traceability.md + acceptance/index.md + acceptance/<topic>_plan.md
|
|
36
|
+
AcceptancePlanStage,
|
|
37
|
+
# 测试计划阶段:把验收条件转为可执行测试范围,产出 qa/<topic>_plan.md
|
|
38
|
+
TestPlanStage,
|
|
39
|
+
# 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
|
|
40
|
+
ImplStage,
|
|
41
|
+
# 按验收计划编写测试代码;本阶段不执行测试、不产出测试结果
|
|
42
|
+
TestCodeStage,
|
|
43
|
+
# 执行测试代码并记录主题测试结果
|
|
44
|
+
TestExecutionStage,
|
|
45
|
+
# 测试通过后,按主题验收计划核对用户结果
|
|
46
|
+
TopicAcceptanceStage,
|
|
47
|
+
# 全部主题完成后,对合并代码执行最终全量回归
|
|
48
|
+
RegressionTestStage,
|
|
49
|
+
# 最终全量回归通过后,确认整个需求是否完成
|
|
50
|
+
OverallAcceptanceStage,
|
|
51
|
+
# 最终设计同步:更新 spec/architecture_code_design.md 反映产品、架构和真实代码映射
|
|
52
|
+
# 所有意图末环同名,强制不可跳过
|
|
53
|
+
UpdateCodeDesignStage,
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
# product_change(改产品)的基础 stage 路径(不含 project_design_init 前置)
|
|
57
|
+
# 和 from_scratch 的差异:用 ReviseCodeDesignStage 替代 CodeDesignStage
|
|
58
|
+
# spec 之后必须经过 revise_code_design(设计期改架构),不能只改产品不改架构
|
|
59
|
+
PRODUCT_CHANGE_BASE = [
|
|
60
|
+
# 产品设计阶段:基于现状重新设计,可新增/修改/删除功能文档
|
|
61
|
+
SpecStage,
|
|
62
|
+
# 设计期架构修订:按变更后的产品设计改架构图
|
|
63
|
+
# 与末段 update_code_design(详细落地)名称分离,避免同一 Run 内 stage 名冲突
|
|
64
|
+
ReviseCodeDesignStage,
|
|
65
|
+
# 穿刺阶段(可选)
|
|
66
|
+
SpikeStage,
|
|
67
|
+
# 验收计划阶段
|
|
68
|
+
AcceptancePlanStage,
|
|
69
|
+
# 测试计划阶段
|
|
70
|
+
TestPlanStage,
|
|
71
|
+
# 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
|
|
72
|
+
ImplStage,
|
|
73
|
+
# 先编写测试代码,再执行测试,再按主题验收
|
|
74
|
+
TestCodeStage,
|
|
75
|
+
TestExecutionStage,
|
|
76
|
+
TopicAcceptanceStage,
|
|
77
|
+
# 最终全量回归
|
|
78
|
+
RegressionTestStage,
|
|
79
|
+
# 整体验收
|
|
80
|
+
OverallAcceptanceStage,
|
|
81
|
+
# 最终设计同步(强制)
|
|
82
|
+
UpdateCodeDesignStage,
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
# bugfix(修 bug)的基础 stage 路径(不含 project_design_init 前置)
|
|
86
|
+
# 和 from_scratch 的差异:没有 spec/code_design,先 reproduce,再经过可选 spike;
|
|
87
|
+
# 在共享后半截中使用 impl 制定并执行修复实施计划
|
|
88
|
+
# 末段 update_code_design 即使无架构变化也必须走,在门禁中显式确认"架构未变化"
|
|
89
|
+
BUGFIX_BASE = [
|
|
90
|
+
# 复现阶段:复现 bug + 根因分析,并确定一份缺陷记录对应的验收主题
|
|
91
|
+
ReproduceStage,
|
|
92
|
+
# 穿刺阶段:验证修复仍依赖的真实技术不确定性;没有时由用户确认跳过
|
|
93
|
+
SpikeStage,
|
|
94
|
+
# 验收计划阶段
|
|
95
|
+
AcceptancePlanStage,
|
|
96
|
+
# 测试计划阶段
|
|
97
|
+
TestPlanStage,
|
|
98
|
+
# 实施阶段:先确认全部主题计划,再修改真实代码并记录实施结果
|
|
99
|
+
ImplStage,
|
|
100
|
+
# 先编写测试代码,再执行测试,再按主题验收
|
|
101
|
+
TestCodeStage,
|
|
102
|
+
TestExecutionStage,
|
|
103
|
+
TopicAcceptanceStage,
|
|
104
|
+
# 最终全量回归
|
|
105
|
+
RegressionTestStage,
|
|
106
|
+
# 整体验收
|
|
107
|
+
OverallAcceptanceStage,
|
|
108
|
+
# 最终设计同步(强制,无架构变化也要显式确认)
|
|
109
|
+
UpdateCodeDesignStage,
|
|
110
|
+
]
|
|
111
|
+
|
|
112
|
+
# 正式互斥意图列表(start --intent 的合法值)
|
|
113
|
+
# docs_only 暂不作为正式意图
|
|
114
|
+
INTENT_CHOICES = ["from_scratch", "product_change", "bugfix"]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# 根据 intent 和项目事实返回 stage 列表(CONTEXT.md "Path Composer")
|
|
118
|
+
# 取代旧的四个 Scenario 类并行流水线与 SCENARIO_REGISTRY
|
|
119
|
+
# 在 start --intent 时调一次,结果存入 state.stage_path,后续命令读 state.stage_path
|
|
120
|
+
# 不在每次命令调用时重新跑 PathComposer(条件在 start 时就固定)
|
|
121
|
+
def build_stage_path(intent: str, project_root: str) -> list[StageStrategy]:
|
|
122
|
+
# 从零做:直接返回 FROM_SCRATCH_PATH 的实例列表
|
|
123
|
+
# from_scratch 不走 project_design_init(那只有 product_change/bugfix 走)
|
|
124
|
+
# from_scratch 在 spec + code_design 都 --confirmed 后写 project_design_initialized=true
|
|
125
|
+
if intent == "from_scratch":
|
|
126
|
+
# 实例化每个 stage 类
|
|
127
|
+
return [cls() for cls in FROM_SCRATCH_PATH]
|
|
128
|
+
# 改产品:先检查 project_design_initialized
|
|
129
|
+
if intent == "product_change":
|
|
130
|
+
# 收集 stage 实例
|
|
131
|
+
stages = []
|
|
132
|
+
# 如果项目设计未初始化,前置 project_design_init stage
|
|
133
|
+
# 不能用 architecture_code_design.md 是否存在决定跳过
|
|
134
|
+
if not is_project_design_initialized(project_root):
|
|
135
|
+
# 前置项目设计架构初始化 stage
|
|
136
|
+
stages.append(ProjectDesignInitStage())
|
|
137
|
+
# 追加 product_change 的基础路径
|
|
138
|
+
stages.extend(cls() for cls in PRODUCT_CHANGE_BASE)
|
|
139
|
+
return stages
|
|
140
|
+
# 修 bug:先检查 project_design_initialized
|
|
141
|
+
if intent == "bugfix":
|
|
142
|
+
# 收集 stage 实例
|
|
143
|
+
stages = []
|
|
144
|
+
# 如果项目设计未初始化,前置 project_design_init stage
|
|
145
|
+
if not is_project_design_initialized(project_root):
|
|
146
|
+
# 前置项目设计架构初始化 stage
|
|
147
|
+
stages.append(ProjectDesignInitStage())
|
|
148
|
+
# 追加 bugfix 的基础路径
|
|
149
|
+
stages.extend(cls() for cls in BUGFIX_BASE)
|
|
150
|
+
return stages
|
|
151
|
+
# 未知 intent:报错并提示合法值
|
|
152
|
+
raise ValueError(f"未知 intent: {intent},可选值: {INTENT_CHOICES}")
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""跨平台受控进程执行器。
|
|
2
|
+
|
|
3
|
+
主题测试和最终全量回归共用同一套执行行为:只接受命令参数数组和项目内工作目录,
|
|
4
|
+
不经过 Shell;POSIX 新建会话并按进程组先终止后强制结束;Windows 新建进程组并
|
|
5
|
+
使用系统进程树终止能力;所有路径都等待进程完成并收集有界输出。
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import os
|
|
12
|
+
import shutil
|
|
13
|
+
import signal
|
|
14
|
+
import subprocess
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from datetime import datetime, timezone
|
|
19
|
+
|
|
20
|
+
# 输出摘要保留的末尾字节数;完整输出只保存哈希和字节数,不落盘
|
|
21
|
+
OUTPUT_TAIL_BYTES = 8 * 1024
|
|
22
|
+
# 超时后先礼貌终止,再等这些秒数才强制结束
|
|
23
|
+
TERMINATE_GRACE_SECONDS = 5
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass
|
|
27
|
+
class ProcessRequest:
|
|
28
|
+
"""一次受控执行请求。"""
|
|
29
|
+
|
|
30
|
+
argv: list[str]
|
|
31
|
+
# 项目内工作目录(绝对路径;由调用方校验在项目内)
|
|
32
|
+
cwd: str
|
|
33
|
+
timeout_seconds: int
|
|
34
|
+
# 额外环境变量(在当前环境基础上覆盖);None 表示原样继承
|
|
35
|
+
extra_env: dict[str, str] | None = None
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class ProcessResult:
|
|
40
|
+
"""一次受控执行的机器事实。"""
|
|
41
|
+
|
|
42
|
+
status: str # passed / failed / timeout / error
|
|
43
|
+
exit_code: int | None
|
|
44
|
+
started_at: str
|
|
45
|
+
finished_at: str
|
|
46
|
+
duration_seconds: float
|
|
47
|
+
output_tail: str
|
|
48
|
+
output_sha256: str
|
|
49
|
+
output_bytes: int
|
|
50
|
+
platform: str
|
|
51
|
+
# argv[0] 解析出的实际可执行文件;无法解析时保留原样
|
|
52
|
+
executable: str
|
|
53
|
+
error_message: str = ""
|
|
54
|
+
argv: list[str] = field(default_factory=list)
|
|
55
|
+
cwd: str = ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _now_iso() -> str:
|
|
59
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def resolve_executable(command: str) -> str:
|
|
63
|
+
"""返回当前环境实际会执行的程序路径;无法解析时保留原参数。"""
|
|
64
|
+
return shutil.which(command) or command
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _kill_process_tree(process: subprocess.Popen) -> None:
|
|
68
|
+
"""按平台清理整个进程组或进程树,不遗留测试子进程。"""
|
|
69
|
+
if sys.platform.startswith("win"):
|
|
70
|
+
# Windows:使用系统进程树终止能力(taskkill /T /F)
|
|
71
|
+
subprocess.run(
|
|
72
|
+
["taskkill", "/PID", str(process.pid), "/T", "/F"],
|
|
73
|
+
capture_output=True,
|
|
74
|
+
check=False,
|
|
75
|
+
)
|
|
76
|
+
return
|
|
77
|
+
# POSIX:进程在新会话中启动,按进程组先 SIGTERM,宽限后 SIGKILL
|
|
78
|
+
try:
|
|
79
|
+
pgid = os.getpgid(process.pid)
|
|
80
|
+
except ProcessLookupError:
|
|
81
|
+
return
|
|
82
|
+
try:
|
|
83
|
+
os.killpg(pgid, signal.SIGTERM)
|
|
84
|
+
except ProcessLookupError:
|
|
85
|
+
return
|
|
86
|
+
deadline = time.monotonic() + TERMINATE_GRACE_SECONDS
|
|
87
|
+
while time.monotonic() < deadline:
|
|
88
|
+
if process.poll() is not None:
|
|
89
|
+
break
|
|
90
|
+
time.sleep(0.1)
|
|
91
|
+
try:
|
|
92
|
+
os.killpg(pgid, signal.SIGKILL)
|
|
93
|
+
except ProcessLookupError:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def run_process(request: ProcessRequest) -> ProcessResult:
|
|
98
|
+
"""执行一次命令并返回完整机器事实;超时后清理整个进程组或进程树。"""
|
|
99
|
+
started_at = _now_iso()
|
|
100
|
+
start_clock = time.monotonic()
|
|
101
|
+
platform_name = sys.platform
|
|
102
|
+
executable = resolve_executable(request.argv[0])
|
|
103
|
+
|
|
104
|
+
env = None
|
|
105
|
+
if request.extra_env:
|
|
106
|
+
env = dict(os.environ)
|
|
107
|
+
env.update(request.extra_env)
|
|
108
|
+
|
|
109
|
+
popen_kwargs: dict = {
|
|
110
|
+
"args": request.argv,
|
|
111
|
+
"cwd": request.cwd,
|
|
112
|
+
"stdout": subprocess.PIPE,
|
|
113
|
+
"stderr": subprocess.STDOUT,
|
|
114
|
+
"shell": False,
|
|
115
|
+
"env": env,
|
|
116
|
+
}
|
|
117
|
+
if sys.platform.startswith("win"):
|
|
118
|
+
# 新建进程组,供 taskkill /T 清理整棵进程树
|
|
119
|
+
popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
120
|
+
else:
|
|
121
|
+
# 新建会话(同时成为新进程组组长),供 killpg 清理
|
|
122
|
+
popen_kwargs["start_new_session"] = True
|
|
123
|
+
|
|
124
|
+
def _finish(
|
|
125
|
+
status: str,
|
|
126
|
+
exit_code: int | None,
|
|
127
|
+
output: bytes,
|
|
128
|
+
error_message: str = "",
|
|
129
|
+
) -> ProcessResult:
|
|
130
|
+
finished_at = _now_iso()
|
|
131
|
+
tail = output[-OUTPUT_TAIL_BYTES:].decode("utf-8", errors="replace")
|
|
132
|
+
return ProcessResult(
|
|
133
|
+
status=status,
|
|
134
|
+
exit_code=exit_code,
|
|
135
|
+
started_at=started_at,
|
|
136
|
+
finished_at=finished_at,
|
|
137
|
+
duration_seconds=round(time.monotonic() - start_clock, 3),
|
|
138
|
+
output_tail=tail,
|
|
139
|
+
output_sha256=hashlib.sha256(output).hexdigest(),
|
|
140
|
+
output_bytes=len(output),
|
|
141
|
+
platform=platform_name,
|
|
142
|
+
executable=executable,
|
|
143
|
+
error_message=error_message,
|
|
144
|
+
argv=list(request.argv),
|
|
145
|
+
cwd=request.cwd,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
try:
|
|
149
|
+
process = subprocess.Popen(**popen_kwargs)
|
|
150
|
+
except (OSError, ValueError) as exc:
|
|
151
|
+
return _finish("error", None, b"", f"无法启动命令: {exc}")
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
output, _ = process.communicate(timeout=request.timeout_seconds)
|
|
155
|
+
except subprocess.TimeoutExpired as first_timeout:
|
|
156
|
+
_kill_process_tree(process)
|
|
157
|
+
# communicate() 超时后的再次调用会返回从进程启动起的完整累计输出,
|
|
158
|
+
# 不能再与第一次异常中的部分输出拼接,否则会重复计算摘要、哈希和字节数。
|
|
159
|
+
try:
|
|
160
|
+
output, _ = process.communicate(timeout=TERMINATE_GRACE_SECONDS + 5)
|
|
161
|
+
except subprocess.TimeoutExpired as final_timeout:
|
|
162
|
+
output = final_timeout.output
|
|
163
|
+
if output is None:
|
|
164
|
+
output = first_timeout.output
|
|
165
|
+
if isinstance(output, str):
|
|
166
|
+
output = output.encode("utf-8", errors="replace")
|
|
167
|
+
return _finish(
|
|
168
|
+
"timeout",
|
|
169
|
+
None,
|
|
170
|
+
output or b"",
|
|
171
|
+
f"超过 {request.timeout_seconds} 秒未完成,已清理整个进程组或进程树",
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
exit_code = process.returncode
|
|
175
|
+
status = "passed" if exit_code == 0 else "failed"
|
|
176
|
+
return _finish(status, exit_code, output or b"")
|