claude-coding-flow 1.0.0 → 1.1.0
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/flow.js +57 -16
- package/dashboard/db.py +296 -0
- package/dashboard/logger.py +110 -0
- package/dashboard/main.py +253 -0
- package/dashboard/models.py +26 -0
- package/dashboard/requirements.txt +2 -0
- package/dashboard/static/app.js +639 -0
- package/dashboard/static/index.html +190 -0
- package/dashboard/static/style.css +1034 -0
- package/package.json +3 -2
package/bin/flow.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
const fs = require("fs");
|
|
4
4
|
const path = require("path");
|
|
5
|
+
const { spawn } = require("child_process");
|
|
5
6
|
|
|
6
7
|
const PKG_ROOT = path.resolve(__dirname, "..");
|
|
7
8
|
const CWD = process.cwd();
|
|
@@ -25,29 +26,65 @@ function copyRecursive(src, dest) {
|
|
|
25
26
|
function init() {
|
|
26
27
|
console.log(yellow("flow init"));
|
|
27
28
|
|
|
29
|
+
// Copy skill commands
|
|
28
30
|
const commandsSrc = path.join(PKG_ROOT, "commands");
|
|
29
31
|
const commandsDest = path.join(CWD, ".claude", "commands");
|
|
30
32
|
|
|
31
|
-
if (
|
|
32
|
-
|
|
33
|
+
if (fs.existsSync(commandsSrc)) {
|
|
34
|
+
fs.mkdirSync(commandsDest, { recursive: true });
|
|
35
|
+
const files = fs.readdirSync(commandsSrc).filter((f) => f.endsWith(".md"));
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const src = path.join(commandsSrc, file);
|
|
38
|
+
const dest = path.join(commandsDest, file);
|
|
39
|
+
if (fs.existsSync(dest)) {
|
|
40
|
+
console.log(yellow(` skip (exists): .claude/commands/${file}`));
|
|
41
|
+
} else {
|
|
42
|
+
fs.copyFileSync(src, dest);
|
|
43
|
+
console.log(green(` copied: .claude/commands/${file}`));
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Copy dashboard
|
|
49
|
+
const dashSrc = path.join(PKG_ROOT, "dashboard");
|
|
50
|
+
const dashDest = path.join(CWD, "dashboard");
|
|
51
|
+
|
|
52
|
+
if (fs.existsSync(dashSrc)) {
|
|
53
|
+
fs.mkdirSync(dashDest, { recursive: true });
|
|
54
|
+
copyRecursive(dashSrc, dashDest);
|
|
55
|
+
console.log(green(" copied: dashboard/"));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
console.log(green("\ndone!"));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function dashboard() {
|
|
62
|
+
const dashDir = path.join(CWD, "dashboard");
|
|
63
|
+
const pkgDashDir = path.join(PKG_ROOT, "dashboard");
|
|
64
|
+
|
|
65
|
+
const target = fs.existsSync(dashDir) ? dashDir : pkgDashDir;
|
|
66
|
+
|
|
67
|
+
if (!fs.existsSync(path.join(target, "main.py"))) {
|
|
68
|
+
console.log(red("Error: dashboard/main.py not found. Run `flow init` first."));
|
|
33
69
|
process.exit(1);
|
|
34
70
|
}
|
|
35
71
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
} else {
|
|
45
|
-
fs.copyFileSync(src, dest);
|
|
46
|
-
console.log(green(` copied: .claude/commands/${file}`));
|
|
47
|
-
}
|
|
72
|
+
const requirements = path.join(target, "requirements.txt");
|
|
73
|
+
if (fs.existsSync(requirements)) {
|
|
74
|
+
console.log(yellow("Installing dashboard dependencies..."));
|
|
75
|
+
try {
|
|
76
|
+
require("child_process").execSync(`pip3 install -r "${requirements}"`, {
|
|
77
|
+
stdio: "inherit",
|
|
78
|
+
});
|
|
79
|
+
} catch {}
|
|
48
80
|
}
|
|
49
81
|
|
|
50
|
-
console.log(green("
|
|
82
|
+
console.log(green("Starting dashboard on http://localhost:8000"));
|
|
83
|
+
const child = spawn("python3", [path.join(target, "main.py"), "serve"], {
|
|
84
|
+
stdio: "inherit",
|
|
85
|
+
cwd: CWD,
|
|
86
|
+
});
|
|
87
|
+
child.on("exit", (code) => process.exit(code));
|
|
51
88
|
}
|
|
52
89
|
|
|
53
90
|
const args = process.argv.slice(2);
|
|
@@ -57,10 +94,14 @@ switch (command) {
|
|
|
57
94
|
case "init":
|
|
58
95
|
init();
|
|
59
96
|
break;
|
|
97
|
+
case "dashboard":
|
|
98
|
+
dashboard();
|
|
99
|
+
break;
|
|
60
100
|
default:
|
|
61
101
|
console.log(`Usage: flow <command>
|
|
62
102
|
|
|
63
103
|
Commands:
|
|
64
|
-
init
|
|
104
|
+
init Install skills and dashboard to current project
|
|
105
|
+
dashboard Start the dashboard server (http://localhost:8000)`);
|
|
65
106
|
break;
|
|
66
107
|
}
|
package/dashboard/db.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from typing import Optional, List, Dict
|
|
5
|
+
|
|
6
|
+
WORKTREE_DIR = os.path.join(os.getcwd(), ".worktree")
|
|
7
|
+
|
|
8
|
+
PHASE_NAMES_CODE_GEN = {
|
|
9
|
+
1: "需求收集", 2: "信息加载", 3: "方案规划",
|
|
10
|
+
4: "代码生成", 5: "编译校验", 6: "代码反思", 7: "交付",
|
|
11
|
+
}
|
|
12
|
+
PHASE_NAMES_DOC_GEN = {1: "信息收集"}
|
|
13
|
+
PHASE_NAMES_BUG_FIX = {1: "定位", 2: "修复", 3: "验证"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def get_phase_name(task_type: str, phase_num: int) -> str:
|
|
17
|
+
if task_type == "bug-fix":
|
|
18
|
+
return PHASE_NAMES_BUG_FIX.get(phase_num, f"阶段{phase_num}")
|
|
19
|
+
elif task_type == "doc-gen":
|
|
20
|
+
return PHASE_NAMES_DOC_GEN.get(phase_num, f"阶段{phase_num}")
|
|
21
|
+
return PHASE_NAMES_CODE_GEN.get(phase_num, f"阶段{phase_num}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _worktree() -> str:
|
|
25
|
+
os.makedirs(WORKTREE_DIR, exist_ok=True)
|
|
26
|
+
return WORKTREE_DIR
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ── 模块 ──
|
|
30
|
+
|
|
31
|
+
def _scan_module_dirs() -> List[str]:
|
|
32
|
+
"""扫描 worktree 下所有类型子目录中包含 module.json 的模块目录"""
|
|
33
|
+
wt = _worktree()
|
|
34
|
+
dirs = []
|
|
35
|
+
if not os.path.exists(wt):
|
|
36
|
+
return dirs
|
|
37
|
+
for type_dir_name in sorted(os.listdir(wt)):
|
|
38
|
+
type_path = os.path.join(wt, type_dir_name)
|
|
39
|
+
if not os.path.isdir(type_path):
|
|
40
|
+
continue
|
|
41
|
+
for name in sorted(os.listdir(type_path)):
|
|
42
|
+
path = os.path.join(type_path, name)
|
|
43
|
+
if os.path.isdir(path) and os.path.exists(os.path.join(path, "module.json")):
|
|
44
|
+
dirs.append(path)
|
|
45
|
+
return dirs
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def get_modules() -> List[Dict]:
|
|
49
|
+
result = []
|
|
50
|
+
for path in _scan_module_dirs():
|
|
51
|
+
try:
|
|
52
|
+
with open(os.path.join(path, "module.json"), "r", encoding="utf-8") as f:
|
|
53
|
+
mod = json.load(f)
|
|
54
|
+
mod["_path"] = path
|
|
55
|
+
result.append(mod)
|
|
56
|
+
except Exception:
|
|
57
|
+
pass
|
|
58
|
+
return result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def get_module(module_id: str) -> Optional[Dict]:
|
|
62
|
+
for mod in get_modules():
|
|
63
|
+
if mod["id"] == module_id:
|
|
64
|
+
return mod
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _module_dir(module_id: str) -> Optional[str]:
|
|
69
|
+
for path in _scan_module_dirs():
|
|
70
|
+
try:
|
|
71
|
+
with open(os.path.join(path, "module.json"), "r", encoding="utf-8") as f:
|
|
72
|
+
mod = json.load(f)
|
|
73
|
+
if mod["id"] == module_id:
|
|
74
|
+
return path
|
|
75
|
+
except Exception:
|
|
76
|
+
pass
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── 任务 ──
|
|
81
|
+
|
|
82
|
+
def _scan_task_dirs(module_id: str) -> List[str]:
|
|
83
|
+
"""扫描模块目录下所有包含 task.json 的子目录"""
|
|
84
|
+
md = _module_dir(module_id)
|
|
85
|
+
if not md:
|
|
86
|
+
return []
|
|
87
|
+
dirs = []
|
|
88
|
+
for name in sorted(os.listdir(md)):
|
|
89
|
+
path = os.path.join(md, name)
|
|
90
|
+
if os.path.isdir(path) and os.path.exists(os.path.join(path, "task.json")):
|
|
91
|
+
dirs.append(path)
|
|
92
|
+
return dirs
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def get_module_tasks(module_id: str) -> List[Dict]:
|
|
96
|
+
result = []
|
|
97
|
+
for path in _scan_task_dirs(module_id):
|
|
98
|
+
try:
|
|
99
|
+
with open(os.path.join(path, "task.json"), "r", encoding="utf-8") as f:
|
|
100
|
+
task = json.load(f)
|
|
101
|
+
result.append(task)
|
|
102
|
+
except Exception:
|
|
103
|
+
pass
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def get_tasks() -> List[Dict]:
|
|
108
|
+
result = []
|
|
109
|
+
for mod in get_modules():
|
|
110
|
+
result.extend(get_module_tasks(mod["id"]))
|
|
111
|
+
return result
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def get_task(task_id: str) -> Optional[Dict]:
|
|
115
|
+
for task in get_tasks():
|
|
116
|
+
if task["id"] == task_id:
|
|
117
|
+
return task
|
|
118
|
+
return None
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_task_dir(task_id: str) -> Optional[str]:
|
|
122
|
+
"""根据 task_id 查找其所在目录"""
|
|
123
|
+
for mod in get_modules():
|
|
124
|
+
for path in _scan_task_dirs(mod["id"]):
|
|
125
|
+
try:
|
|
126
|
+
with open(os.path.join(path, "task.json"), "r", encoding="utf-8") as f:
|
|
127
|
+
task = json.load(f)
|
|
128
|
+
if task["id"] == task_id:
|
|
129
|
+
return path
|
|
130
|
+
except Exception:
|
|
131
|
+
pass
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# ── 按类型过滤 ──
|
|
136
|
+
|
|
137
|
+
def get_modules_by_type(module_type: str) -> List[Dict]:
|
|
138
|
+
"""按 type 字段过滤模块,未指定 type 的默认为 code-gen"""
|
|
139
|
+
result = []
|
|
140
|
+
for mod in get_modules():
|
|
141
|
+
mod_type = mod.get("type", "code-gen")
|
|
142
|
+
if mod_type == module_type:
|
|
143
|
+
result.append(mod)
|
|
144
|
+
return result
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# ── doc-gen 产物 ──
|
|
148
|
+
|
|
149
|
+
def get_doc_gen_artifacts(task_dir: str) -> Dict:
|
|
150
|
+
"""读取 doc-gen 任务的产物信息"""
|
|
151
|
+
result = {"requirement_doc": None, "images": [], "develop_doc": None}
|
|
152
|
+
|
|
153
|
+
req_dir = os.path.join(task_dir, "requirement")
|
|
154
|
+
if os.path.exists(req_dir):
|
|
155
|
+
files = [f for f in os.listdir(req_dir) if not f.startswith(".")]
|
|
156
|
+
result["requirement_doc"] = files[0] if files else None
|
|
157
|
+
|
|
158
|
+
img_dir = os.path.join(task_dir, "images")
|
|
159
|
+
if os.path.exists(img_dir):
|
|
160
|
+
result["images"] = sorted(
|
|
161
|
+
f for f in os.listdir(img_dir) if not f.startswith(".")
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
for f in os.listdir(task_dir):
|
|
165
|
+
if f.endswith("-develop.md"):
|
|
166
|
+
result["develop_doc"] = f
|
|
167
|
+
break
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── bug-fix 信息 ──
|
|
173
|
+
|
|
174
|
+
def get_bug_fix_info(task_dir: str) -> Dict:
|
|
175
|
+
"""读取 bug-fix 任务的 bug 信息"""
|
|
176
|
+
try:
|
|
177
|
+
with open(os.path.join(task_dir, "task.json"), "r", encoding="utf-8") as f:
|
|
178
|
+
task = json.load(f)
|
|
179
|
+
except Exception:
|
|
180
|
+
return {}
|
|
181
|
+
return {
|
|
182
|
+
"bug_status": task.get("bug_status", "unresolved"),
|
|
183
|
+
"bug_description": task.get("bug_description", ""),
|
|
184
|
+
"source_info": task.get("source_info", {}),
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── 任务链 ──
|
|
189
|
+
|
|
190
|
+
def get_task_chain(module_id: str) -> Dict:
|
|
191
|
+
"""获取模块内的任务链关系"""
|
|
192
|
+
tasks = get_module_tasks(module_id)
|
|
193
|
+
if not tasks:
|
|
194
|
+
return {"chains": [], "task_map": {}}
|
|
195
|
+
|
|
196
|
+
task_map = {t["id"]: t for t in tasks}
|
|
197
|
+
chains = []
|
|
198
|
+
visited = set()
|
|
199
|
+
|
|
200
|
+
# 找到所有起始任务(prev_task_id 为空)
|
|
201
|
+
roots = [t for t in tasks if not t.get("prev_task_id")]
|
|
202
|
+
for root in roots:
|
|
203
|
+
chain = []
|
|
204
|
+
current = root
|
|
205
|
+
while current:
|
|
206
|
+
chain.append(current["id"])
|
|
207
|
+
visited.add(current["id"])
|
|
208
|
+
next_tasks = [t for t in tasks if t.get("prev_task_id") == current["id"]]
|
|
209
|
+
current = next_tasks[0] if next_tasks else None
|
|
210
|
+
chains.append(chain)
|
|
211
|
+
|
|
212
|
+
# 处理未在链中的任务(无 root 且未被访问)
|
|
213
|
+
for t in tasks:
|
|
214
|
+
if t["id"] not in visited:
|
|
215
|
+
chains.append([t["id"]])
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
"chains": chains,
|
|
219
|
+
"task_map": {
|
|
220
|
+
tid: {"title": t["title"], "status": t["status"]}
|
|
221
|
+
for tid, t in task_map.items()
|
|
222
|
+
},
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ── 跨类型关联 ──
|
|
227
|
+
|
|
228
|
+
def get_cross_relations(task_id: str) -> Dict:
|
|
229
|
+
"""查找任务的跨类型关联关系"""
|
|
230
|
+
task = get_task(task_id)
|
|
231
|
+
if not task:
|
|
232
|
+
return {"relations": []}
|
|
233
|
+
|
|
234
|
+
relations = []
|
|
235
|
+
|
|
236
|
+
# code-gen → doc-gen: 通过 dev_doc 反查
|
|
237
|
+
dev_doc = task.get("dev_doc")
|
|
238
|
+
if dev_doc and task.get("type") == "code-gen":
|
|
239
|
+
# 在 doc-gen 任务中查找生成该 develop.md 的任务
|
|
240
|
+
for mod in get_modules():
|
|
241
|
+
if mod.get("type") != "doc-gen":
|
|
242
|
+
continue
|
|
243
|
+
for tp in _scan_task_dirs(mod["id"]):
|
|
244
|
+
try:
|
|
245
|
+
with open(os.path.join(tp, "task.json"), "r", encoding="utf-8") as f:
|
|
246
|
+
dt = json.load(f)
|
|
247
|
+
if dt.get("output_doc") and dev_doc.endswith(dt["output_doc"]):
|
|
248
|
+
relations.append({
|
|
249
|
+
"type": "based-on",
|
|
250
|
+
"label": "基于开发文档",
|
|
251
|
+
"from_task": task_id,
|
|
252
|
+
"to_task": dt["id"],
|
|
253
|
+
"to_task_title": dt.get("title", ""),
|
|
254
|
+
"to_module_id": mod["id"],
|
|
255
|
+
"to_module_name": mod.get("name", ""),
|
|
256
|
+
"artifact": dt.get("output_doc", ""),
|
|
257
|
+
})
|
|
258
|
+
break
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
# bug-fix → code-gen: 通过 related_task_id
|
|
263
|
+
related = task.get("related_task_id")
|
|
264
|
+
if related and task.get("type") == "bug-fix":
|
|
265
|
+
rt = get_task(related)
|
|
266
|
+
if rt:
|
|
267
|
+
relations.append({
|
|
268
|
+
"type": "traces-to",
|
|
269
|
+
"label": "溯源",
|
|
270
|
+
"from_task": task_id,
|
|
271
|
+
"to_task": related,
|
|
272
|
+
"to_task_title": rt.get("title", ""),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
# code-gen ← doc-gen: 反向查找哪些 code-gen 任务基于此 doc-gen 任务
|
|
276
|
+
if task.get("type") == "doc-gen" and task.get("output_doc"):
|
|
277
|
+
for mod in get_modules():
|
|
278
|
+
if mod.get("type") != "code-gen":
|
|
279
|
+
continue
|
|
280
|
+
for tp in _scan_task_dirs(mod["id"]):
|
|
281
|
+
try:
|
|
282
|
+
with open(os.path.join(tp, "task.json"), "r", encoding="utf-8") as f:
|
|
283
|
+
ct = json.load(f)
|
|
284
|
+
dd = ct.get("dev_doc", "")
|
|
285
|
+
if dd and task["output_doc"] and dd.endswith(task["output_doc"]):
|
|
286
|
+
relations.append({
|
|
287
|
+
"type": "used-by",
|
|
288
|
+
"label": "被引用",
|
|
289
|
+
"from_task": ct["id"],
|
|
290
|
+
"from_task_title": ct.get("title", ""),
|
|
291
|
+
"to_task": task_id,
|
|
292
|
+
})
|
|
293
|
+
except Exception:
|
|
294
|
+
pass
|
|
295
|
+
|
|
296
|
+
return {"relations": relations}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
from typing import Optional, List, Dict
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def _jsonl_path(task_dir: str) -> str:
|
|
7
|
+
return os.path.join(task_dir, "log.jsonl")
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _text_log_path(task_dir: str) -> str:
|
|
11
|
+
return os.path.join(task_dir, "log")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _snapshot_base(task_dir: str) -> str:
|
|
15
|
+
return os.path.join(task_dir, "snapshots")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
# ── 执行日志 ──
|
|
19
|
+
|
|
20
|
+
def get_logs(task_dir: str) -> List[Dict]:
|
|
21
|
+
path = _jsonl_path(task_dir)
|
|
22
|
+
if not os.path.exists(path):
|
|
23
|
+
return []
|
|
24
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
25
|
+
return [json.loads(line) for line in f if line.strip()]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def get_token_summary(task_dir: str) -> Dict:
|
|
29
|
+
logs = get_logs(task_dir)
|
|
30
|
+
total = sum(e.get("tokens", 0) or 0 for e in logs)
|
|
31
|
+
phases = {}
|
|
32
|
+
for e in logs:
|
|
33
|
+
p = e.get("phase", 0)
|
|
34
|
+
t = e.get("tokens", 0) or 0
|
|
35
|
+
phases[p] = phases.get(p, 0) + t
|
|
36
|
+
return {"total_tokens": total, "tokens_by_phase": phases}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def get_text_log(task_dir: str) -> Optional[str]:
|
|
40
|
+
# 优先读 log.md,兼容旧 log 文件
|
|
41
|
+
md_path = os.path.join(task_dir, "log.md")
|
|
42
|
+
if os.path.exists(md_path):
|
|
43
|
+
with open(md_path, "r", encoding="utf-8") as f:
|
|
44
|
+
return f.read()
|
|
45
|
+
path = _text_log_path(task_dir)
|
|
46
|
+
if os.path.exists(path):
|
|
47
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
48
|
+
return f.read()
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Plan 存储 ──
|
|
53
|
+
|
|
54
|
+
def get_plan(task_dir: str) -> Optional[str]:
|
|
55
|
+
plan_path = os.path.join(task_dir, "plan.md")
|
|
56
|
+
if not os.path.exists(plan_path):
|
|
57
|
+
return None
|
|
58
|
+
with open(plan_path, "r", encoding="utf-8") as f:
|
|
59
|
+
return f.read()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── 代码快照 ──
|
|
63
|
+
|
|
64
|
+
def list_snapshots(task_dir: str) -> List[Dict]:
|
|
65
|
+
snap_dir = _snapshot_base(task_dir)
|
|
66
|
+
if not os.path.exists(snap_dir):
|
|
67
|
+
return []
|
|
68
|
+
|
|
69
|
+
files = []
|
|
70
|
+
for name in sorted(os.listdir(snap_dir)):
|
|
71
|
+
path = os.path.join(snap_dir, name)
|
|
72
|
+
if os.path.isfile(path) and not name.startswith("."):
|
|
73
|
+
files.append(name)
|
|
74
|
+
|
|
75
|
+
# 检查子目录(兼容旧结构)
|
|
76
|
+
sub_dirs = []
|
|
77
|
+
for name in sorted(os.listdir(snap_dir)):
|
|
78
|
+
path = os.path.join(snap_dir, name)
|
|
79
|
+
if os.path.isdir(path):
|
|
80
|
+
sub_files = []
|
|
81
|
+
for root, _, fs in os.walk(path):
|
|
82
|
+
for fname in fs:
|
|
83
|
+
if fname == "_summary.json":
|
|
84
|
+
continue
|
|
85
|
+
rel = os.path.relpath(os.path.join(root, fname), path)
|
|
86
|
+
sub_files.append(rel)
|
|
87
|
+
if sub_files:
|
|
88
|
+
sub_dirs.append({"name": name, "files": sub_files})
|
|
89
|
+
|
|
90
|
+
result = []
|
|
91
|
+
if files:
|
|
92
|
+
result.append({"name": "files", "files": files})
|
|
93
|
+
result.extend(sub_dirs)
|
|
94
|
+
return result
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_snapshot_file(task_dir: str, snapshot_name: str, filepath: str) -> Optional[str]:
|
|
98
|
+
snap_dir = _snapshot_base(task_dir)
|
|
99
|
+
# 扁平结构(snapshot_name == "files"):文件直接在 snapshots/ 下
|
|
100
|
+
if snapshot_name == "files":
|
|
101
|
+
path = os.path.join(snap_dir, filepath)
|
|
102
|
+
else:
|
|
103
|
+
path = os.path.join(snap_dir, snapshot_name, filepath)
|
|
104
|
+
if not os.path.exists(path):
|
|
105
|
+
return None
|
|
106
|
+
try:
|
|
107
|
+
with open(path, "r", encoding="utf-8") as f:
|
|
108
|
+
return f.read()
|
|
109
|
+
except UnicodeDecodeError:
|
|
110
|
+
return None
|