@hupan56/wlkj 3.1.25 → 3.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/cli.js +1516 -1499
- package/package.json +1 -1
- package/templates/qoder/scripts/README.md +102 -102
- package/templates/qoder/scripts/deployment/setup/init_doctor.py +1 -1
- package/templates/qoder/scripts/deployment/setup/setup.py +1 -1
- package/templates/qoder/scripts/domain/report/add_session.py +2 -2
- package/templates/qoder/scripts/domain/task/task.py +1 -1
- package/templates/qoder/scripts/foundation/core/paths.py +705 -705
- package/templates/qoder/scripts/foundation/data/task_utils.py +427 -427
- package/templates/qoder/scripts/foundation/integrations/active_task.py +244 -244
- package/templates/root/AGENTS.md +1 -1
|
@@ -1,428 +1,428 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
# -*- coding: utf-8 -*-
|
|
3
|
-
"""
|
|
4
|
-
QODER Pipeline - 任务工具函数
|
|
5
|
-
|
|
6
|
-
提供:
|
|
7
|
-
- resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
|
|
8
|
-
- run_task_hooks: 运行任务生命周期钩子
|
|
9
|
-
- load_task_json: 加载 task.json
|
|
10
|
-
- write_task_json: 写入 task.json
|
|
11
|
-
|
|
12
|
-
参考:
|
|
13
|
-
"""
|
|
14
|
-
|
|
15
|
-
from __future__ import annotations
|
|
16
|
-
|
|
17
|
-
import json
|
|
18
|
-
import os
|
|
19
|
-
import subprocess
|
|
20
|
-
import sys
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
|
|
23
|
-
from foundation.core.paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
|
|
24
|
-
|
|
25
|
-
# 原子写 (解决半写损坏)
|
|
26
|
-
try:
|
|
27
|
-
from foundation.io.atomicio import atomic_write_json
|
|
28
|
-
except ImportError:
|
|
29
|
-
def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
|
|
30
|
-
import json as _j
|
|
31
|
-
Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
|
|
32
|
-
sort_keys=sort_keys) + "\n", encoding="utf-8")
|
|
33
|
-
|
|
34
|
-
# 安全读 (区分缺失/损坏, 损坏备份 .corrupt) — 与 atomic_write_json 同源
|
|
35
|
-
try:
|
|
36
|
-
from foundation.io.atomicio import safe_read_json, AtomicIOError
|
|
37
|
-
except ImportError:
|
|
38
|
-
# 兜底: 退化回旧的裸读 (损坏静默返回 None, 不备份)
|
|
39
|
-
def safe_read_json(path, default=None, required=False, backup_corrupt=True):
|
|
40
|
-
if not os.path.isfile(path):
|
|
41
|
-
return default
|
|
42
|
-
try:
|
|
43
|
-
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
44
|
-
except (ValueError, json.JSONDecodeError, OSError, IOError):
|
|
45
|
-
return default
|
|
46
|
-
|
|
47
|
-
class AtomicIOError(Exception):
|
|
48
|
-
"""兜底类型 (仅当 atomicio 不可用时定义, 不会真正抛出)。"""
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
# =============================================================================
|
|
52
|
-
# 任务目录解析
|
|
53
|
-
# =============================================================================
|
|
54
|
-
|
|
55
|
-
def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
|
|
56
|
-
"""解析任务目录路径。
|
|
57
|
-
|
|
58
|
-
支持三种输入:
|
|
59
|
-
1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
|
|
60
|
-
2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
|
|
61
|
-
3. 绝对路径: 直接使用
|
|
62
|
-
|
|
63
|
-
安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
|
|
64
|
-
"06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
|
|
65
|
-
|
|
66
|
-
Args:
|
|
67
|
-
task_input: 任务标识 (名称或路径)。
|
|
68
|
-
repo_root: 项目根目录, 默认自动检测。
|
|
69
|
-
|
|
70
|
-
Returns:
|
|
71
|
-
任务目录的绝对路径。
|
|
72
|
-
|
|
73
|
-
Raises:
|
|
74
|
-
FileNotFoundError: 找不到任务。
|
|
75
|
-
ValueError: 名称歧义 (多个候选)。
|
|
76
|
-
"""
|
|
77
|
-
if repo_root is None:
|
|
78
|
-
repo_root = get_repo_root()
|
|
79
|
-
|
|
80
|
-
input_path = Path(task_input)
|
|
81
|
-
|
|
82
|
-
# 已经是绝对路径且存在
|
|
83
|
-
if input_path.is_absolute() and input_path.is_dir():
|
|
84
|
-
return input_path.resolve()
|
|
85
|
-
|
|
86
|
-
# 相对路径
|
|
87
|
-
full_from_root = (repo_root / task_input).resolve()
|
|
88
|
-
if full_from_root.is_dir():
|
|
89
|
-
return full_from_root
|
|
90
|
-
|
|
91
|
-
# 纯名称: 在 tasks 目录下精确匹配
|
|
92
|
-
tasks_dir = get_tasks_dir(repo_root)
|
|
93
|
-
direct_match = tasks_dir / task_input
|
|
94
|
-
if direct_match.is_dir():
|
|
95
|
-
return direct_match.resolve()
|
|
96
|
-
|
|
97
|
-
# 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
|
|
98
|
-
if tasks_dir.is_dir():
|
|
99
|
-
candidates = [
|
|
100
|
-
d for d in tasks_dir.iterdir()
|
|
101
|
-
if d.is_dir() and (
|
|
102
|
-
d.name == task_input or # 全名精确
|
|
103
|
-
d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
|
|
104
|
-
d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
|
|
105
|
-
)
|
|
106
|
-
]
|
|
107
|
-
if len(candidates) == 1:
|
|
108
|
-
return candidates[0].resolve()
|
|
109
|
-
if len(candidates) > 1:
|
|
110
|
-
names = ", ".join(d.name for d in candidates[:5])
|
|
111
|
-
raise ValueError(
|
|
112
|
-
"任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
|
|
113
|
-
% (task_input, len(candidates), names)
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
# 不存在 —— 返回原始路径 (调用方决定怎么报错)
|
|
117
|
-
return tasks_dir / task_input
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
# =============================================================================
|
|
121
|
-
# 任务 JSON 读写
|
|
122
|
-
# =============================================================================
|
|
123
|
-
|
|
124
|
-
def load_task_json(task_dir: Path) -> dict | None:
|
|
125
|
-
"""加载 task.json 文件。
|
|
126
|
-
|
|
127
|
-
Args:
|
|
128
|
-
task_dir: 任务目录路径。
|
|
129
|
-
|
|
130
|
-
Returns:
|
|
131
|
-
解析后的 dict, 文件不存在或解析失败返回 None。
|
|
132
|
-
|
|
133
|
-
健壮性 (v3.1.2): 改走 safe_read_json —— JSON 损坏时备份为
|
|
134
|
-
task.json.corrupt.<ts> 并 stderr 告警, 而非静默吞成 None (与"文件
|
|
135
|
-
缺失"无法区分 → 数据丢失无恢复路径)。调用方行为不变 (仍返回 None)。
|
|
136
|
-
"""
|
|
137
|
-
task_json = task_dir / FILE_TASK_JSON
|
|
138
|
-
try:
|
|
139
|
-
# safe_read_json: 缺失返回 default(None); 损坏备份 .corrupt 后抛 AtomicIOError。
|
|
140
|
-
# 复用文件顶部已 import 的 atomicio (相对导入, 与 atomic_write_json 同源)。
|
|
141
|
-
return safe_read_json(str(task_json), default=None)
|
|
142
|
-
except AtomicIOError as e:
|
|
143
|
-
# 损坏已备份, 告警但保持向后兼容 (返回 None, 调用方按"无任务"处理)
|
|
144
|
-
print('Warning: %s' % e, file=sys.stderr)
|
|
145
|
-
return None
|
|
146
|
-
except (OSError, IOError):
|
|
147
|
-
return None
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def write_task_json(task_dir: Path, data: dict) -> bool:
|
|
151
|
-
"""原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
|
|
152
|
-
|
|
153
|
-
Args:
|
|
154
|
-
task_dir: 任务目录路径。
|
|
155
|
-
data: 要写入的数据。
|
|
156
|
-
|
|
157
|
-
Returns:
|
|
158
|
-
成功返回 True。
|
|
159
|
-
"""
|
|
160
|
-
task_json = task_dir / FILE_TASK_JSON
|
|
161
|
-
try:
|
|
162
|
-
# 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
|
|
163
|
-
atomic_write_json(
|
|
164
|
-
str(task_json), data,
|
|
165
|
-
indent=2, ensure_ascii=False, sort_keys=False,
|
|
166
|
-
)
|
|
167
|
-
return True
|
|
168
|
-
except (OSError, IOError) as e:
|
|
169
|
-
print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
|
|
170
|
-
return False
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
|
|
174
|
-
"""在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
|
|
175
|
-
|
|
176
|
-
解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
|
|
177
|
-
锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
|
|
178
|
-
|
|
179
|
-
Args:
|
|
180
|
-
task_dir: 任务目录。
|
|
181
|
-
mutator: 接收 data dict, 就地修改它 (返回值忽略)。
|
|
182
|
-
timeout: 获取锁的等待秒数。
|
|
183
|
-
|
|
184
|
-
Returns:
|
|
185
|
-
成功 (修改并写入) 返回 True; 加载失败返回 False。
|
|
186
|
-
"""
|
|
187
|
-
try:
|
|
188
|
-
from foundation.io.filelock import FileLock, LockTimeoutError
|
|
189
|
-
except ImportError:
|
|
190
|
-
# filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
|
|
191
|
-
data = load_task_json(task_dir)
|
|
192
|
-
if data is None:
|
|
193
|
-
return False
|
|
194
|
-
mutator(data)
|
|
195
|
-
return write_task_json(task_dir, data)
|
|
196
|
-
|
|
197
|
-
lock_path = task_dir / ".task.lock"
|
|
198
|
-
lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
|
|
199
|
-
|
|
200
|
-
# 锁竞争超时: 退避重试一次。绝不降级为无锁写 —— modify_task_json 存在的意义
|
|
201
|
-
# 就是防并发 read-modify-write 丢更新, 两个并发方都超时降级 = 必然丢一个的更新。
|
|
202
|
-
acquired = False
|
|
203
|
-
for attempt in range(2):
|
|
204
|
-
try:
|
|
205
|
-
lock.acquire()
|
|
206
|
-
acquired = True
|
|
207
|
-
break
|
|
208
|
-
except LockTimeoutError as e:
|
|
209
|
-
if attempt == 0:
|
|
210
|
-
# 第一次超时: 短暂退避后重试 (锁持有方通常很快释放)
|
|
211
|
-
import time as _time
|
|
212
|
-
_time.sleep(0.5)
|
|
213
|
-
continue
|
|
214
|
-
# 重试仍超时: 明确失败, 不写 (宁可这次改不了, 也不丢更新)
|
|
215
|
-
print(f"ERROR: task lock busy after retry ({task_dir.name}): {e}", file=sys.stderr)
|
|
216
|
-
print(f" 另一个进程正在修改此任务, 请稍后重试。改动未生效 (未丢数据)。", file=sys.stderr)
|
|
217
|
-
return False
|
|
218
|
-
if not acquired:
|
|
219
|
-
return False
|
|
220
|
-
|
|
221
|
-
try:
|
|
222
|
-
data = load_task_json(task_dir)
|
|
223
|
-
if data is None:
|
|
224
|
-
return False
|
|
225
|
-
mutator(data)
|
|
226
|
-
return write_task_json(task_dir, data)
|
|
227
|
-
finally:
|
|
228
|
-
lock.release()
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
# =============================================================================
|
|
232
|
-
# ACL: 任务操作权限校验 (零信任)
|
|
233
|
-
# =============================================================================
|
|
234
|
-
|
|
235
|
-
def assert_can_modify_task(
|
|
236
|
-
task_dir: Path,
|
|
237
|
-
action: str,
|
|
238
|
-
repo_root: Path | None = None,
|
|
239
|
-
) -> str:
|
|
240
|
-
"""校验当前开发者是否有权修改该任务。
|
|
241
|
-
|
|
242
|
-
零信任 ACL: 只有 creator / assignee / admin 角色能修改。
|
|
243
|
-
其他人 start/finish/archive 别人的任务会被拒绝。
|
|
244
|
-
|
|
245
|
-
Args:
|
|
246
|
-
task_dir: 任务目录。
|
|
247
|
-
action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
|
|
248
|
-
repo_root: 项目根。
|
|
249
|
-
|
|
250
|
-
Returns:
|
|
251
|
-
当前开发者名 (校验通过)。
|
|
252
|
-
|
|
253
|
-
Raises:
|
|
254
|
-
PermissionError: 无权操作。
|
|
255
|
-
"""
|
|
256
|
-
if repo_root is None:
|
|
257
|
-
repo_root = get_repo_root()
|
|
258
|
-
repo_root = Path(repo_root) # 统一为 Path
|
|
259
|
-
|
|
260
|
-
# 读当前开发者
|
|
261
|
-
try:
|
|
262
|
-
from foundation.core.paths import get_developer
|
|
263
|
-
except ImportError:
|
|
264
|
-
from paths import get_developer # type: ignore
|
|
265
|
-
dev = get_developer(repo_root)
|
|
266
|
-
if not dev:
|
|
267
|
-
raise PermissionError(
|
|
268
|
-
"[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
|
|
269
|
-
)
|
|
270
|
-
|
|
271
|
-
task = load_task_json(task_dir)
|
|
272
|
-
if not task:
|
|
273
|
-
# 任务不存在 —— 让上层处理, 这里放行 (创建场景)
|
|
274
|
-
return dev
|
|
275
|
-
|
|
276
|
-
creator = (task.get("creator") or "").strip()
|
|
277
|
-
assignee = (task.get("assignee") or "").strip()
|
|
278
|
-
authorized = {creator, assignee}
|
|
279
|
-
authorized.discard("")
|
|
280
|
-
|
|
281
|
-
# admin 角色可操作任意任务
|
|
282
|
-
try:
|
|
283
|
-
from foundation.identity.identity import get_member
|
|
284
|
-
m = get_member(dev, repo_root)
|
|
285
|
-
if m and m.get("role") == "admin":
|
|
286
|
-
return dev
|
|
287
|
-
except Exception:
|
|
288
|
-
pass # identity 模块不可用则退化为只看 creator/assignee
|
|
289
|
-
|
|
290
|
-
if dev not in authorized:
|
|
291
|
-
raise PermissionError(
|
|
292
|
-
"[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
|
|
293
|
-
"只有任务负责人或 admin 可操作。"
|
|
294
|
-
% (action, task_dir.name, dev, creator or "?", assignee or "?")
|
|
295
|
-
)
|
|
296
|
-
return dev
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
# =============================================================================
|
|
300
|
-
# 生命周期钩子
|
|
301
|
-
# =============================================================================
|
|
302
|
-
|
|
303
|
-
def run_task_hooks(
|
|
304
|
-
hook_name: str,
|
|
305
|
-
task_json_path: Path,
|
|
306
|
-
repo_root: Path | None = None,
|
|
307
|
-
) -> None:
|
|
308
|
-
"""运行任务生命周期钩子。
|
|
309
|
-
|
|
310
|
-
从 config.yaml 读取钩子配置并执行。
|
|
311
|
-
|
|
312
|
-
Args:
|
|
313
|
-
hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
|
|
314
|
-
task_json_path: task.json 的路径。
|
|
315
|
-
repo_root: 项目根目录, 默认自动检测。
|
|
316
|
-
"""
|
|
317
|
-
if repo_root is None:
|
|
318
|
-
repo_root = get_repo_root()
|
|
319
|
-
|
|
320
|
-
config_path = repo_root / ".qoder" / "config.yaml"
|
|
321
|
-
if not config_path.is_file():
|
|
322
|
-
return
|
|
323
|
-
|
|
324
|
-
# 简单的 YAML 解析 (不依赖 PyYAML)
|
|
325
|
-
try:
|
|
326
|
-
import yaml
|
|
327
|
-
with open(config_path, "r", encoding="utf-8") as f:
|
|
328
|
-
config = yaml.safe_load(f)
|
|
329
|
-
except ImportError:
|
|
330
|
-
# 没有 PyYAML, 用简单解析
|
|
331
|
-
config = _simple_yaml_parse(config_path)
|
|
332
|
-
except Exception as e:
|
|
333
|
-
# config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
|
|
334
|
-
# (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
|
|
335
|
-
print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
|
|
336
|
-
return
|
|
337
|
-
|
|
338
|
-
if not config:
|
|
339
|
-
return
|
|
340
|
-
|
|
341
|
-
hooks = config.get("hooks", {})
|
|
342
|
-
commands = hooks.get(hook_name, [])
|
|
343
|
-
if not commands:
|
|
344
|
-
return
|
|
345
|
-
|
|
346
|
-
env = os.environ.copy()
|
|
347
|
-
env["TASK_JSON_PATH"] = str(task_json_path)
|
|
348
|
-
|
|
349
|
-
for cmd in commands:
|
|
350
|
-
try:
|
|
351
|
-
print(f" Running hook [{hook_name}]: {cmd}")
|
|
352
|
-
result = subprocess.run(
|
|
353
|
-
cmd,
|
|
354
|
-
shell=True,
|
|
355
|
-
cwd=str(repo_root),
|
|
356
|
-
env=env,
|
|
357
|
-
capture_output=True,
|
|
358
|
-
text=True,
|
|
359
|
-
timeout=30,
|
|
360
|
-
)
|
|
361
|
-
if result.returncode != 0:
|
|
362
|
-
print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
|
|
363
|
-
except subprocess.TimeoutExpired:
|
|
364
|
-
print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
|
|
365
|
-
except Exception as e:
|
|
366
|
-
print(f" Warning: Hook error: {e}", file=sys.stderr)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _simple_yaml_parse(path: Path) -> dict:
|
|
370
|
-
"""简单的 YAML 解析器 (不依赖外部库)。
|
|
371
|
-
|
|
372
|
-
只支持两级嵌套和列表。
|
|
373
|
-
|
|
374
|
-
Args:
|
|
375
|
-
path: YAML 文件路径。
|
|
376
|
-
|
|
377
|
-
Returns:
|
|
378
|
-
解析后的 dict。
|
|
379
|
-
"""
|
|
380
|
-
result = {}
|
|
381
|
-
current_key = None
|
|
382
|
-
current_list = None
|
|
383
|
-
|
|
384
|
-
try:
|
|
385
|
-
lines = path.read_text(encoding="utf-8").splitlines()
|
|
386
|
-
except (OSError, IOError):
|
|
387
|
-
return result
|
|
388
|
-
|
|
389
|
-
for line in lines:
|
|
390
|
-
stripped = line.strip()
|
|
391
|
-
if not stripped or stripped.startswith("#"):
|
|
392
|
-
continue
|
|
393
|
-
|
|
394
|
-
indent = len(line) - len(line.lstrip())
|
|
395
|
-
|
|
396
|
-
if indent == 0 and ":" in stripped and not stripped.startswith("-"):
|
|
397
|
-
# 顶层 key
|
|
398
|
-
if current_key and current_list is not None:
|
|
399
|
-
result[current_key] = current_list
|
|
400
|
-
key, _, value = stripped.partition(":")
|
|
401
|
-
current_key = key.strip()
|
|
402
|
-
current_list = None
|
|
403
|
-
if value.strip():
|
|
404
|
-
result[current_key] = value.strip()
|
|
405
|
-
current_key = None
|
|
406
|
-
|
|
407
|
-
elif indent > 0 and stripped.startswith("- "):
|
|
408
|
-
# 列表项
|
|
409
|
-
if current_list is None:
|
|
410
|
-
current_list = []
|
|
411
|
-
item = stripped[2:].strip().strip("'\"")
|
|
412
|
-
current_list.append(item)
|
|
413
|
-
|
|
414
|
-
elif indent > 0 and ":" in stripped and current_key:
|
|
415
|
-
# 子键值对
|
|
416
|
-
if current_list is not None:
|
|
417
|
-
result[current_key] = current_list
|
|
418
|
-
current_list = None
|
|
419
|
-
key, _, value = stripped.partition(":")
|
|
420
|
-
if isinstance(result.get(current_key), dict):
|
|
421
|
-
result[current_key][key.strip()] = value.strip().strip("'\"")
|
|
422
|
-
else:
|
|
423
|
-
result[current_key] = {key.strip(): value.strip().strip("'\"")}
|
|
424
|
-
|
|
425
|
-
if current_key and current_list is not None:
|
|
426
|
-
result[current_key] = current_list
|
|
427
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""
|
|
4
|
+
QODER Pipeline - 任务工具函数
|
|
5
|
+
|
|
6
|
+
提供:
|
|
7
|
+
- resolve_task_dir: 解析任务目录 (支持名称/相对路径/绝对路径)
|
|
8
|
+
- run_task_hooks: 运行任务生命周期钩子
|
|
9
|
+
- load_task_json: 加载 task.json
|
|
10
|
+
- write_task_json: 写入 task.json
|
|
11
|
+
|
|
12
|
+
参考: common/task_utils.py 设计
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from foundation.core.paths import DIR_TASKS, FILE_TASK_JSON, get_repo_root, get_tasks_dir
|
|
24
|
+
|
|
25
|
+
# 原子写 (解决半写损坏)
|
|
26
|
+
try:
|
|
27
|
+
from foundation.io.atomicio import atomic_write_json
|
|
28
|
+
except ImportError:
|
|
29
|
+
def atomic_write_json(path, data, indent=2, ensure_ascii=False, sort_keys=True):
|
|
30
|
+
import json as _j
|
|
31
|
+
Path(path).write_text(_j.dumps(data, indent=indent, ensure_ascii=ensure_ascii,
|
|
32
|
+
sort_keys=sort_keys) + "\n", encoding="utf-8")
|
|
33
|
+
|
|
34
|
+
# 安全读 (区分缺失/损坏, 损坏备份 .corrupt) — 与 atomic_write_json 同源
|
|
35
|
+
try:
|
|
36
|
+
from foundation.io.atomicio import safe_read_json, AtomicIOError
|
|
37
|
+
except ImportError:
|
|
38
|
+
# 兜底: 退化回旧的裸读 (损坏静默返回 None, 不备份)
|
|
39
|
+
def safe_read_json(path, default=None, required=False, backup_corrupt=True):
|
|
40
|
+
if not os.path.isfile(path):
|
|
41
|
+
return default
|
|
42
|
+
try:
|
|
43
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
44
|
+
except (ValueError, json.JSONDecodeError, OSError, IOError):
|
|
45
|
+
return default
|
|
46
|
+
|
|
47
|
+
class AtomicIOError(Exception):
|
|
48
|
+
"""兜底类型 (仅当 atomicio 不可用时定义, 不会真正抛出)。"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# =============================================================================
|
|
52
|
+
# 任务目录解析
|
|
53
|
+
# =============================================================================
|
|
54
|
+
|
|
55
|
+
def resolve_task_dir(task_input: str, repo_root: Path | None = None) -> Path:
|
|
56
|
+
"""解析任务目录路径。
|
|
57
|
+
|
|
58
|
+
支持三种输入:
|
|
59
|
+
1. 纯名称/全名: "my-task" / "06-10-my-task" -> workspace/tasks/<name>
|
|
60
|
+
2. 相对路径: "workspace/tasks/06-10-my-task" -> 完整路径
|
|
61
|
+
3. 绝对路径: 直接使用
|
|
62
|
+
|
|
63
|
+
安全变更 (零信任): 不再做模糊子串匹配 (旧版 "a" 可能匹配到他人的
|
|
64
|
+
"06-10-alpha" 导致误操作)。现在要求精确名或唯一前缀。
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
task_input: 任务标识 (名称或路径)。
|
|
68
|
+
repo_root: 项目根目录, 默认自动检测。
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
任务目录的绝对路径。
|
|
72
|
+
|
|
73
|
+
Raises:
|
|
74
|
+
FileNotFoundError: 找不到任务。
|
|
75
|
+
ValueError: 名称歧义 (多个候选)。
|
|
76
|
+
"""
|
|
77
|
+
if repo_root is None:
|
|
78
|
+
repo_root = get_repo_root()
|
|
79
|
+
|
|
80
|
+
input_path = Path(task_input)
|
|
81
|
+
|
|
82
|
+
# 已经是绝对路径且存在
|
|
83
|
+
if input_path.is_absolute() and input_path.is_dir():
|
|
84
|
+
return input_path.resolve()
|
|
85
|
+
|
|
86
|
+
# 相对路径
|
|
87
|
+
full_from_root = (repo_root / task_input).resolve()
|
|
88
|
+
if full_from_root.is_dir():
|
|
89
|
+
return full_from_root
|
|
90
|
+
|
|
91
|
+
# 纯名称: 在 tasks 目录下精确匹配
|
|
92
|
+
tasks_dir = get_tasks_dir(repo_root)
|
|
93
|
+
direct_match = tasks_dir / task_input
|
|
94
|
+
if direct_match.is_dir():
|
|
95
|
+
return direct_match.resolve()
|
|
96
|
+
|
|
97
|
+
# 精确匹配失败 —— 查唯一前缀匹配 (不允许歧义)
|
|
98
|
+
if tasks_dir.is_dir():
|
|
99
|
+
candidates = [
|
|
100
|
+
d for d in tasks_dir.iterdir()
|
|
101
|
+
if d.is_dir() and (
|
|
102
|
+
d.name == task_input or # 全名精确
|
|
103
|
+
d.name.endswith("-" + task_input) or # "06-10-my-task".endswith("-my-task")
|
|
104
|
+
d.name.startswith(task_input + "-") # "06-10".startswith("06-10-...")
|
|
105
|
+
)
|
|
106
|
+
]
|
|
107
|
+
if len(candidates) == 1:
|
|
108
|
+
return candidates[0].resolve()
|
|
109
|
+
if len(candidates) > 1:
|
|
110
|
+
names = ", ".join(d.name for d in candidates[:5])
|
|
111
|
+
raise ValueError(
|
|
112
|
+
"任务名 '%s' 歧义, 匹配到 %d 个: %s。请用完整任务名。"
|
|
113
|
+
% (task_input, len(candidates), names)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# 不存在 —— 返回原始路径 (调用方决定怎么报错)
|
|
117
|
+
return tasks_dir / task_input
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# =============================================================================
|
|
121
|
+
# 任务 JSON 读写
|
|
122
|
+
# =============================================================================
|
|
123
|
+
|
|
124
|
+
def load_task_json(task_dir: Path) -> dict | None:
|
|
125
|
+
"""加载 task.json 文件。
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
task_dir: 任务目录路径。
|
|
129
|
+
|
|
130
|
+
Returns:
|
|
131
|
+
解析后的 dict, 文件不存在或解析失败返回 None。
|
|
132
|
+
|
|
133
|
+
健壮性 (v3.1.2): 改走 safe_read_json —— JSON 损坏时备份为
|
|
134
|
+
task.json.corrupt.<ts> 并 stderr 告警, 而非静默吞成 None (与"文件
|
|
135
|
+
缺失"无法区分 → 数据丢失无恢复路径)。调用方行为不变 (仍返回 None)。
|
|
136
|
+
"""
|
|
137
|
+
task_json = task_dir / FILE_TASK_JSON
|
|
138
|
+
try:
|
|
139
|
+
# safe_read_json: 缺失返回 default(None); 损坏备份 .corrupt 后抛 AtomicIOError。
|
|
140
|
+
# 复用文件顶部已 import 的 atomicio (相对导入, 与 atomic_write_json 同源)。
|
|
141
|
+
return safe_read_json(str(task_json), default=None)
|
|
142
|
+
except AtomicIOError as e:
|
|
143
|
+
# 损坏已备份, 告警但保持向后兼容 (返回 None, 调用方按"无任务"处理)
|
|
144
|
+
print('Warning: %s' % e, file=sys.stderr)
|
|
145
|
+
return None
|
|
146
|
+
except (OSError, IOError):
|
|
147
|
+
return None
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def write_task_json(task_dir: Path, data: dict) -> bool:
|
|
151
|
+
"""原子写入 task.json 文件 (temp + os.replace, 写失败原文件不变)。
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
task_dir: 任务目录路径。
|
|
155
|
+
data: 要写入的数据。
|
|
156
|
+
|
|
157
|
+
Returns:
|
|
158
|
+
成功返回 True。
|
|
159
|
+
"""
|
|
160
|
+
task_json = task_dir / FILE_TASK_JSON
|
|
161
|
+
try:
|
|
162
|
+
# 原子写: sort_keys=False 保留插入顺序 (task.json 字段顺序有意义)
|
|
163
|
+
atomic_write_json(
|
|
164
|
+
str(task_json), data,
|
|
165
|
+
indent=2, ensure_ascii=False, sort_keys=False,
|
|
166
|
+
)
|
|
167
|
+
return True
|
|
168
|
+
except (OSError, IOError) as e:
|
|
169
|
+
print(f"Warning: Failed to write task.json: {e}", file=sys.stderr)
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def modify_task_json(task_dir: Path, mutator, timeout: float = 15.0):
|
|
174
|
+
"""在文件锁内 load → mutator(data) → write, 保证并发 read-modify-write 原子。
|
|
175
|
+
|
|
176
|
+
解决两个成员同时改同一任务 (一个改 status, 一个改 due) 的丢更新问题。
|
|
177
|
+
锁是每任务粒度 (task_dir/.task.lock), 不同任务互不阻塞。
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
task_dir: 任务目录。
|
|
181
|
+
mutator: 接收 data dict, 就地修改它 (返回值忽略)。
|
|
182
|
+
timeout: 获取锁的等待秒数。
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
成功 (修改并写入) 返回 True; 加载失败返回 False。
|
|
186
|
+
"""
|
|
187
|
+
try:
|
|
188
|
+
from foundation.io.filelock import FileLock, LockTimeoutError
|
|
189
|
+
except ImportError:
|
|
190
|
+
# filelock 不可用 → 降级为无锁 (保留旧行为, 不阻塞功能)
|
|
191
|
+
data = load_task_json(task_dir)
|
|
192
|
+
if data is None:
|
|
193
|
+
return False
|
|
194
|
+
mutator(data)
|
|
195
|
+
return write_task_json(task_dir, data)
|
|
196
|
+
|
|
197
|
+
lock_path = task_dir / ".task.lock"
|
|
198
|
+
lock = FileLock(str(lock_path), timeout=timeout, stale_seconds=300)
|
|
199
|
+
|
|
200
|
+
# 锁竞争超时: 退避重试一次。绝不降级为无锁写 —— modify_task_json 存在的意义
|
|
201
|
+
# 就是防并发 read-modify-write 丢更新, 两个并发方都超时降级 = 必然丢一个的更新。
|
|
202
|
+
acquired = False
|
|
203
|
+
for attempt in range(2):
|
|
204
|
+
try:
|
|
205
|
+
lock.acquire()
|
|
206
|
+
acquired = True
|
|
207
|
+
break
|
|
208
|
+
except LockTimeoutError as e:
|
|
209
|
+
if attempt == 0:
|
|
210
|
+
# 第一次超时: 短暂退避后重试 (锁持有方通常很快释放)
|
|
211
|
+
import time as _time
|
|
212
|
+
_time.sleep(0.5)
|
|
213
|
+
continue
|
|
214
|
+
# 重试仍超时: 明确失败, 不写 (宁可这次改不了, 也不丢更新)
|
|
215
|
+
print(f"ERROR: task lock busy after retry ({task_dir.name}): {e}", file=sys.stderr)
|
|
216
|
+
print(f" 另一个进程正在修改此任务, 请稍后重试。改动未生效 (未丢数据)。", file=sys.stderr)
|
|
217
|
+
return False
|
|
218
|
+
if not acquired:
|
|
219
|
+
return False
|
|
220
|
+
|
|
221
|
+
try:
|
|
222
|
+
data = load_task_json(task_dir)
|
|
223
|
+
if data is None:
|
|
224
|
+
return False
|
|
225
|
+
mutator(data)
|
|
226
|
+
return write_task_json(task_dir, data)
|
|
227
|
+
finally:
|
|
228
|
+
lock.release()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
# =============================================================================
|
|
232
|
+
# ACL: 任务操作权限校验 (零信任)
|
|
233
|
+
# =============================================================================
|
|
234
|
+
|
|
235
|
+
def assert_can_modify_task(
|
|
236
|
+
task_dir: Path,
|
|
237
|
+
action: str,
|
|
238
|
+
repo_root: Path | None = None,
|
|
239
|
+
) -> str:
|
|
240
|
+
"""校验当前开发者是否有权修改该任务。
|
|
241
|
+
|
|
242
|
+
零信任 ACL: 只有 creator / assignee / admin 角色能修改。
|
|
243
|
+
其他人 start/finish/archive 别人的任务会被拒绝。
|
|
244
|
+
|
|
245
|
+
Args:
|
|
246
|
+
task_dir: 任务目录。
|
|
247
|
+
action: 动作名 (start/finish/archive/add-subtask/remove-subtask)。
|
|
248
|
+
repo_root: 项目根。
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
当前开发者名 (校验通过)。
|
|
252
|
+
|
|
253
|
+
Raises:
|
|
254
|
+
PermissionError: 无权操作。
|
|
255
|
+
"""
|
|
256
|
+
if repo_root is None:
|
|
257
|
+
repo_root = get_repo_root()
|
|
258
|
+
repo_root = Path(repo_root) # 统一为 Path
|
|
259
|
+
|
|
260
|
+
# 读当前开发者
|
|
261
|
+
try:
|
|
262
|
+
from foundation.core.paths import get_developer
|
|
263
|
+
except ImportError:
|
|
264
|
+
from paths import get_developer # type: ignore
|
|
265
|
+
dev = get_developer(repo_root)
|
|
266
|
+
if not dev:
|
|
267
|
+
raise PermissionError(
|
|
268
|
+
"[authz] 拒绝 %s: 未设置开发者身份。先 /wl-init。" % action
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
task = load_task_json(task_dir)
|
|
272
|
+
if not task:
|
|
273
|
+
# 任务不存在 —— 让上层处理, 这里放行 (创建场景)
|
|
274
|
+
return dev
|
|
275
|
+
|
|
276
|
+
creator = (task.get("creator") or "").strip()
|
|
277
|
+
assignee = (task.get("assignee") or "").strip()
|
|
278
|
+
authorized = {creator, assignee}
|
|
279
|
+
authorized.discard("")
|
|
280
|
+
|
|
281
|
+
# admin 角色可操作任意任务
|
|
282
|
+
try:
|
|
283
|
+
from foundation.identity.identity import get_member
|
|
284
|
+
m = get_member(dev, repo_root)
|
|
285
|
+
if m and m.get("role") == "admin":
|
|
286
|
+
return dev
|
|
287
|
+
except Exception:
|
|
288
|
+
pass # identity 模块不可用则退化为只看 creator/assignee
|
|
289
|
+
|
|
290
|
+
if dev not in authorized:
|
|
291
|
+
raise PermissionError(
|
|
292
|
+
"[authz] 拒绝 %s 任务 '%s': 当前开发者 '%s' 不是 creator(%s)/assignee(%s)。"
|
|
293
|
+
"只有任务负责人或 admin 可操作。"
|
|
294
|
+
% (action, task_dir.name, dev, creator or "?", assignee or "?")
|
|
295
|
+
)
|
|
296
|
+
return dev
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# =============================================================================
|
|
300
|
+
# 生命周期钩子
|
|
301
|
+
# =============================================================================
|
|
302
|
+
|
|
303
|
+
def run_task_hooks(
|
|
304
|
+
hook_name: str,
|
|
305
|
+
task_json_path: Path,
|
|
306
|
+
repo_root: Path | None = None,
|
|
307
|
+
) -> None:
|
|
308
|
+
"""运行任务生命周期钩子。
|
|
309
|
+
|
|
310
|
+
从 config.yaml 读取钩子配置并执行。
|
|
311
|
+
|
|
312
|
+
Args:
|
|
313
|
+
hook_name: 钩子名称 (after_create/after_start/after_finish/after_archive)。
|
|
314
|
+
task_json_path: task.json 的路径。
|
|
315
|
+
repo_root: 项目根目录, 默认自动检测。
|
|
316
|
+
"""
|
|
317
|
+
if repo_root is None:
|
|
318
|
+
repo_root = get_repo_root()
|
|
319
|
+
|
|
320
|
+
config_path = repo_root / ".qoder" / "config.yaml"
|
|
321
|
+
if not config_path.is_file():
|
|
322
|
+
return
|
|
323
|
+
|
|
324
|
+
# 简单的 YAML 解析 (不依赖 PyYAML)
|
|
325
|
+
try:
|
|
326
|
+
import yaml
|
|
327
|
+
with open(config_path, "r", encoding="utf-8") as f:
|
|
328
|
+
config = yaml.safe_load(f)
|
|
329
|
+
except ImportError:
|
|
330
|
+
# 没有 PyYAML, 用简单解析
|
|
331
|
+
config = _simple_yaml_parse(config_path)
|
|
332
|
+
except Exception as e:
|
|
333
|
+
# config.yaml 语法错误等: 不阻塞任务操作, 只是跳过 hooks
|
|
334
|
+
# (用户可能手编 config.yaml 引入了语法错误, 不该让整个任务系统崩溃)
|
|
335
|
+
print(f"Warning: .qoder/config.yaml 解析失败, 跳过 hooks ({type(e).__name__})", file=sys.stderr)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
if not config:
|
|
339
|
+
return
|
|
340
|
+
|
|
341
|
+
hooks = config.get("hooks", {})
|
|
342
|
+
commands = hooks.get(hook_name, [])
|
|
343
|
+
if not commands:
|
|
344
|
+
return
|
|
345
|
+
|
|
346
|
+
env = os.environ.copy()
|
|
347
|
+
env["TASK_JSON_PATH"] = str(task_json_path)
|
|
348
|
+
|
|
349
|
+
for cmd in commands:
|
|
350
|
+
try:
|
|
351
|
+
print(f" Running hook [{hook_name}]: {cmd}")
|
|
352
|
+
result = subprocess.run(
|
|
353
|
+
cmd,
|
|
354
|
+
shell=True,
|
|
355
|
+
cwd=str(repo_root),
|
|
356
|
+
env=env,
|
|
357
|
+
capture_output=True,
|
|
358
|
+
text=True,
|
|
359
|
+
timeout=30,
|
|
360
|
+
)
|
|
361
|
+
if result.returncode != 0:
|
|
362
|
+
print(f" Warning: Hook returned non-zero: {result.stderr}", file=sys.stderr)
|
|
363
|
+
except subprocess.TimeoutExpired:
|
|
364
|
+
print(f" Warning: Hook timed out: {cmd}", file=sys.stderr)
|
|
365
|
+
except Exception as e:
|
|
366
|
+
print(f" Warning: Hook error: {e}", file=sys.stderr)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _simple_yaml_parse(path: Path) -> dict:
|
|
370
|
+
"""简单的 YAML 解析器 (不依赖外部库)。
|
|
371
|
+
|
|
372
|
+
只支持两级嵌套和列表。
|
|
373
|
+
|
|
374
|
+
Args:
|
|
375
|
+
path: YAML 文件路径。
|
|
376
|
+
|
|
377
|
+
Returns:
|
|
378
|
+
解析后的 dict。
|
|
379
|
+
"""
|
|
380
|
+
result = {}
|
|
381
|
+
current_key = None
|
|
382
|
+
current_list = None
|
|
383
|
+
|
|
384
|
+
try:
|
|
385
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
386
|
+
except (OSError, IOError):
|
|
387
|
+
return result
|
|
388
|
+
|
|
389
|
+
for line in lines:
|
|
390
|
+
stripped = line.strip()
|
|
391
|
+
if not stripped or stripped.startswith("#"):
|
|
392
|
+
continue
|
|
393
|
+
|
|
394
|
+
indent = len(line) - len(line.lstrip())
|
|
395
|
+
|
|
396
|
+
if indent == 0 and ":" in stripped and not stripped.startswith("-"):
|
|
397
|
+
# 顶层 key
|
|
398
|
+
if current_key and current_list is not None:
|
|
399
|
+
result[current_key] = current_list
|
|
400
|
+
key, _, value = stripped.partition(":")
|
|
401
|
+
current_key = key.strip()
|
|
402
|
+
current_list = None
|
|
403
|
+
if value.strip():
|
|
404
|
+
result[current_key] = value.strip()
|
|
405
|
+
current_key = None
|
|
406
|
+
|
|
407
|
+
elif indent > 0 and stripped.startswith("- "):
|
|
408
|
+
# 列表项
|
|
409
|
+
if current_list is None:
|
|
410
|
+
current_list = []
|
|
411
|
+
item = stripped[2:].strip().strip("'\"")
|
|
412
|
+
current_list.append(item)
|
|
413
|
+
|
|
414
|
+
elif indent > 0 and ":" in stripped and current_key:
|
|
415
|
+
# 子键值对
|
|
416
|
+
if current_list is not None:
|
|
417
|
+
result[current_key] = current_list
|
|
418
|
+
current_list = None
|
|
419
|
+
key, _, value = stripped.partition(":")
|
|
420
|
+
if isinstance(result.get(current_key), dict):
|
|
421
|
+
result[current_key][key.strip()] = value.strip().strip("'\"")
|
|
422
|
+
else:
|
|
423
|
+
result[current_key] = {key.strip(): value.strip().strip("'\"")}
|
|
424
|
+
|
|
425
|
+
if current_key and current_list is not None:
|
|
426
|
+
result[current_key] = current_list
|
|
427
|
+
|
|
428
428
|
return result
|