cc-viewer 1.6.302 → 1.6.304
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/dist/assets/{App-COezHOeh.js → App-CE9-RsMp.js} +1 -1
- package/dist/assets/{App-eFrjLzF_.css → App-DfsHLalW.css} +1 -1
- package/dist/assets/{MdxEditorPanel-BkJKua3-.js → MdxEditorPanel-CDLhUUIj.js} +1 -1
- package/dist/assets/{Mobile-BtNceCJC.js → Mobile-BkZFYw5X.js} +1 -1
- package/dist/assets/index-BDK4eIE5.js +2 -0
- package/dist/assets/{index-Be9T-kDq.css → index-Cy7Xfu81.css} +1 -1
- package/dist/assets/{seqResourceLoaders-De_-fYhE.css → seqResourceLoaders-D-2EA12R.css} +2 -2
- package/dist/assets/seqResourceLoaders-aRa4CORe.js +2 -0
- package/dist/index.html +2 -2
- package/findcc.js +21 -1
- package/package.json +1 -1
- package/server/lib/enrich-plan-input.js +9 -3
- package/server/lib/enrich-workflow.js +95 -0
- package/server/lib/log-watcher.js +2 -0
- package/server/lib/pty-flood-coalescer.js +76 -12
- package/server/lib/resync-nudge-gate.js +33 -0
- package/server/lib/session-transcript-reader.js +66 -7
- package/server/lib/workflow-journal.js +155 -0
- package/server/lib/workflow-live.js +255 -0
- package/server/lib/workflow-watcher.js +280 -0
- package/server/routes/files-content.js +16 -6
- package/server/routes/workflow-journal.js +78 -0
- package/server/routes/workspaces.js +2 -0
- package/server/server.js +43 -3
- package/dist/assets/index-DSz9bNGm.js +0 -2
- package/dist/assets/seqResourceLoaders-C8gBbhlC.js +0 -2
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Journal
|
|
3
|
+
*
|
|
4
|
+
* 定位 + 读取 + 归一化 Claude Code 的 workflow run journal:
|
|
5
|
+
* <projectsDir>/<encoded-cwd>/<sessionId>/workflows/<runId>.json
|
|
6
|
+
*
|
|
7
|
+
* journal 运行中被整体覆写,含 workflowName / summary / status / phases[] /
|
|
8
|
+
* workflowProgress[](workflow_phase | workflow_agent) / totalTokens / totalToolCalls。
|
|
9
|
+
* 这里把它压成前端工作流面板要用的精简模型(normalizeWorkflowJournal)。
|
|
10
|
+
*
|
|
11
|
+
* 安全:runId 受 RUN_ID_RE 限制(无路径分隔符);session 子目录由 findTranscriptPath
|
|
12
|
+
* 解析(恒在 projectsDir 内);读盘前 realpath 复核仍落在 projectsDir 内,防穿越。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { existsSync, readFileSync, statSync, realpathSync, readdirSync } from 'node:fs';
|
|
16
|
+
import { join, dirname, resolve, sep } from 'node:path';
|
|
17
|
+
import { getClaudeConfigDir } from '../../findcc.js';
|
|
18
|
+
import { findTranscriptPath } from './session-transcript-reader.js';
|
|
19
|
+
|
|
20
|
+
const RUN_ID_RE = /^wf_[A-Za-z0-9_-]+$/;
|
|
21
|
+
const TASK_ID_RE = /^[A-Za-z0-9_-]+$/;
|
|
22
|
+
const MAX_JOURNAL_BYTES = 16 * 1024 * 1024; // journal 通常 ~100KB,16MB 兜底防异常大文件
|
|
23
|
+
|
|
24
|
+
function projectsDir() {
|
|
25
|
+
return process.env.CCV_PROJECTS_DIR || join(getClaudeConfigDir(), 'projects');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* sessionId(+projectHint) → 该 session 的 workflows 目录绝对路径(可能不存在)。
|
|
30
|
+
* @returns {string | null}
|
|
31
|
+
*/
|
|
32
|
+
export function resolveWorkflowsDir(sessionId, projectHint) {
|
|
33
|
+
if (!sessionId) return null;
|
|
34
|
+
const transcript = findTranscriptPath(sessionId, projectHint);
|
|
35
|
+
if (!transcript) return null;
|
|
36
|
+
return join(dirname(transcript), sessionId, 'workflows');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function isInsideProjectsDir(realPath) {
|
|
40
|
+
let root;
|
|
41
|
+
try { root = realpathSync(projectsDir()); } catch { return false; }
|
|
42
|
+
return realPath === root || realPath.startsWith(root + sep);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 解析 journal 文件绝对路径。
|
|
47
|
+
* - runId:直接 <wfDir>/<runId>.json。
|
|
48
|
+
* - 否则 taskId:扫 wfDir 找 .taskId 匹配的 wf_*.json。
|
|
49
|
+
* @returns {string | null}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveJournalPath({ sessionId, projectHint, runId, taskId }) {
|
|
52
|
+
const wfDir = resolveWorkflowsDir(sessionId, projectHint);
|
|
53
|
+
if (!wfDir) return null;
|
|
54
|
+
|
|
55
|
+
if (runId && RUN_ID_RE.test(runId)) {
|
|
56
|
+
const p = join(wfDir, `${runId}.json`);
|
|
57
|
+
if (!existsSync(p)) return null;
|
|
58
|
+
let real;
|
|
59
|
+
try { real = realpathSync(p); } catch { return null; }
|
|
60
|
+
return isInsideProjectsDir(real) ? real : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (taskId && TASK_ID_RE.test(taskId)) {
|
|
64
|
+
let files;
|
|
65
|
+
try { files = readdirSync(wfDir); } catch { return null; }
|
|
66
|
+
for (const f of files) {
|
|
67
|
+
if (!f.startsWith('wf_') || !f.endsWith('.json')) continue;
|
|
68
|
+
const p = join(wfDir, f);
|
|
69
|
+
try {
|
|
70
|
+
if (statSync(p).size > MAX_JOURNAL_BYTES) continue;
|
|
71
|
+
const j = JSON.parse(readFileSync(p, 'utf-8'));
|
|
72
|
+
if (j && j.taskId === taskId) {
|
|
73
|
+
const real = realpathSync(p);
|
|
74
|
+
return isInsideProjectsDir(real) ? real : null;
|
|
75
|
+
}
|
|
76
|
+
} catch { /* 跳过坏文件 */ }
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 把 journal 原始对象压成前端面板模型。坏输入返回 null。
|
|
84
|
+
*/
|
|
85
|
+
export function normalizeWorkflowJournal(j) {
|
|
86
|
+
if (!j || typeof j !== 'object') return null;
|
|
87
|
+
|
|
88
|
+
const phases = Array.isArray(j.phases)
|
|
89
|
+
? j.phases.map((p, i) => ({
|
|
90
|
+
index: i + 1,
|
|
91
|
+
title: typeof p?.title === 'string' ? p.title : '',
|
|
92
|
+
detail: typeof p?.detail === 'string' ? p.detail : '',
|
|
93
|
+
}))
|
|
94
|
+
: [];
|
|
95
|
+
|
|
96
|
+
const agents = Array.isArray(j.workflowProgress)
|
|
97
|
+
? j.workflowProgress
|
|
98
|
+
.filter(p => p && p.type === 'workflow_agent')
|
|
99
|
+
.map(a => ({
|
|
100
|
+
index: typeof a.index === 'number' ? a.index : null,
|
|
101
|
+
label: typeof a.label === 'string' ? a.label : '',
|
|
102
|
+
phaseIndex: typeof a.phaseIndex === 'number' ? a.phaseIndex : null,
|
|
103
|
+
phaseTitle: typeof a.phaseTitle === 'string' ? a.phaseTitle : '',
|
|
104
|
+
agentId: typeof a.agentId === 'string' ? a.agentId : '',
|
|
105
|
+
agentType: typeof a.agentType === 'string' ? a.agentType : '',
|
|
106
|
+
model: typeof a.model === 'string' ? a.model : '',
|
|
107
|
+
state: typeof a.state === 'string' ? a.state : '',
|
|
108
|
+
tokens: typeof a.tokens === 'number' ? a.tokens : 0,
|
|
109
|
+
toolCalls: typeof a.toolCalls === 'number' ? a.toolCalls : 0,
|
|
110
|
+
durationMs: typeof a.durationMs === 'number' ? a.durationMs : null,
|
|
111
|
+
lastToolName: typeof a.lastToolName === 'string' ? a.lastToolName : '',
|
|
112
|
+
lastToolSummary: typeof a.lastToolSummary === 'string' ? a.lastToolSummary : '',
|
|
113
|
+
promptPreview: typeof a.promptPreview === 'string' ? a.promptPreview : '',
|
|
114
|
+
resultPreview: typeof a.resultPreview === 'string' ? a.resultPreview : '',
|
|
115
|
+
startedAt: typeof a.startedAt === 'number' ? a.startedAt : null,
|
|
116
|
+
lastProgressAt: typeof a.lastProgressAt === 'number' ? a.lastProgressAt : null,
|
|
117
|
+
}))
|
|
118
|
+
: [];
|
|
119
|
+
|
|
120
|
+
return {
|
|
121
|
+
runId: typeof j.runId === 'string' ? j.runId : '',
|
|
122
|
+
taskId: typeof j.taskId === 'string' ? j.taskId : '',
|
|
123
|
+
workflowName: typeof j.workflowName === 'string' ? j.workflowName : '',
|
|
124
|
+
summary: typeof j.summary === 'string' ? j.summary : '',
|
|
125
|
+
status: typeof j.status === 'string' ? j.status : '',
|
|
126
|
+
durationMs: typeof j.durationMs === 'number' ? j.durationMs : null,
|
|
127
|
+
agentCount: typeof j.agentCount === 'number' ? j.agentCount : agents.length,
|
|
128
|
+
totalTokens: typeof j.totalTokens === 'number' ? j.totalTokens : 0,
|
|
129
|
+
totalToolCalls: typeof j.totalToolCalls === 'number' ? j.totalToolCalls : 0,
|
|
130
|
+
defaultModel: typeof j.defaultModel === 'string' ? j.defaultModel : '',
|
|
131
|
+
startTime: typeof j.startTime === 'number' ? j.startTime : null,
|
|
132
|
+
phases,
|
|
133
|
+
agents,
|
|
134
|
+
// 权威完成快照显式标记 live:false(与 deriveLiveJournal 的 live:true 对称):
|
|
135
|
+
// 让前端「乱序到达的 live REST 不得覆盖已完成快照」的判断可靠,且 workflowStore
|
|
136
|
+
// 据此把该 run 移出活跃集合。
|
|
137
|
+
live: false,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* 读 + 归一化一个 journal 文件路径。坏/超限返回 null。
|
|
143
|
+
*/
|
|
144
|
+
export function readNormalizedJournal(journalPath) {
|
|
145
|
+
try {
|
|
146
|
+
if (!journalPath || !existsSync(journalPath)) return null;
|
|
147
|
+
if (statSync(journalPath).size > MAX_JOURNAL_BYTES) return null;
|
|
148
|
+
const j = JSON.parse(readFileSync(journalPath, 'utf-8'));
|
|
149
|
+
return normalizeWorkflowJournal(j);
|
|
150
|
+
} catch {
|
|
151
|
+
return null;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export const _RUN_ID_RE = RUN_ID_RE;
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Live (运行中逐帧)
|
|
3
|
+
*
|
|
4
|
+
* workflow run journal(<sessionDir>/workflows/<runId>.json)只在「完成时」一次性落盘,
|
|
5
|
+
* 运行中拿不到。但运行中以下文件在持续增长,可据此实时推导面板:
|
|
6
|
+
* <sessionDir>/subagents/workflows/<runId>/
|
|
7
|
+
* ├─ agent-<id>.jsonl 子代理转写(持续增长)→ token / 工具数 / model / prompt
|
|
8
|
+
* ├─ agent-<id>.meta.json {"agentType":...}
|
|
9
|
+
* └─ journal.jsonl started / result 事件(带 agentId)→ running / done 判定
|
|
10
|
+
*
|
|
11
|
+
* 推导出的模型与 normalizeWorkflowJournal 同形(phases 为空 → 前端走扁平 agent 列表),
|
|
12
|
+
* 完成后由权威的 <runId>.json 快照接管(phase 分组)。
|
|
13
|
+
*
|
|
14
|
+
* token 口径:input + output + cache_creation(运行中单调增;与完成快照略有出入,完成时被
|
|
15
|
+
* 权威值替换)。工具数与快照一致(统计 tool_use 块)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, readFileSync, statSync, readdirSync, realpathSync, openSync, readSync, closeSync } from 'node:fs';
|
|
19
|
+
import { join, dirname, sep } from 'node:path';
|
|
20
|
+
import { getClaudeConfigDir } from '../../findcc.js';
|
|
21
|
+
import { findTranscriptPath } from './session-transcript-reader.js';
|
|
22
|
+
import { _RUN_ID_RE as RUN_ID_RE } from './workflow-journal.js';
|
|
23
|
+
|
|
24
|
+
const MAX_AGENT_BYTES = 64 * 1024 * 1024;
|
|
25
|
+
const LABEL_MAX = 80;
|
|
26
|
+
const PROMPT_PREVIEW_MAX = 600; // 头部菱形 hover 预览的 prompt 截断长度(与 journal promptPreview 量级一致)
|
|
27
|
+
|
|
28
|
+
function projectsDir() {
|
|
29
|
+
return process.env.CCV_PROJECTS_DIR || join(getClaudeConfigDir(), 'projects');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** sessionId(+hint) → <sessionDir>/subagents/workflows/<runId>(可能不存在)。 */
|
|
33
|
+
export function resolveRunDir(sessionId, projectHint, runId) {
|
|
34
|
+
if (!sessionId || !runId) return null;
|
|
35
|
+
// 与完成态 journal 同一 runId 校验:拒绝含路径分隔符/`..` 的 runId,防穿越到其他 run/session 目录。
|
|
36
|
+
if (!RUN_ID_RE.test(runId)) return null;
|
|
37
|
+
const transcript = findTranscriptPath(sessionId, projectHint);
|
|
38
|
+
if (!transcript) return null;
|
|
39
|
+
return join(dirname(transcript), sessionId, 'subagents', 'workflows', runId);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isInsideProjectsDir(realPath) {
|
|
43
|
+
let root;
|
|
44
|
+
try { root = realpathSync(projectsDir()); } catch { return false; }
|
|
45
|
+
return realPath === root || realPath.startsWith(root + sep);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 从 workflows/scripts/<name>-<runId>.js 反推 workflowName(运行中即可拿到)。 */
|
|
49
|
+
function deriveWorkflowName(sessionDir, runId) {
|
|
50
|
+
try {
|
|
51
|
+
const scriptsDir = join(sessionDir, 'workflows', 'scripts');
|
|
52
|
+
for (const f of readdirSync(scriptsDir)) {
|
|
53
|
+
if (f.endsWith(`-${runId}.js`)) return f.slice(0, -(`-${runId}.js`.length));
|
|
54
|
+
}
|
|
55
|
+
} catch {}
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// 每文件增量解析缓存:filePath → { mtimeMs, size, offset, partial(Buffer), acc }。
|
|
60
|
+
// agent-*.jsonl 是 append-only:仅从上次 offset 续读新增字节、按行喂入累加器 acc,
|
|
61
|
+
// 避免每帧对正在增长的活跃 agent 文件做 O(size) 全量重读(partial 以字节保留,
|
|
62
|
+
// 跨读边界的不完整行/多字节 UTF-8 字符不被切坏,只在换行符处解码)。
|
|
63
|
+
const _agentParseCache = new Map();
|
|
64
|
+
|
|
65
|
+
function _newAcc() {
|
|
66
|
+
return { tokens: 0, toolCalls: 0, lastToolName: '', model: '', prompt: '', startedAt: null, lastProgressAt: null };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function _accSnapshot(acc) {
|
|
70
|
+
return {
|
|
71
|
+
tokens: acc.tokens, toolCalls: acc.toolCalls, lastToolName: acc.lastToolName,
|
|
72
|
+
model: acc.model, prompt: acc.prompt, startedAt: acc.startedAt, lastProgressAt: acc.lastProgressAt,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function _applyLine(acc, line) {
|
|
77
|
+
if (!line) return;
|
|
78
|
+
let o;
|
|
79
|
+
try { o = JSON.parse(line); } catch { return; }
|
|
80
|
+
const ts = Date.parse(o.timestamp || '');
|
|
81
|
+
if (!Number.isNaN(ts)) {
|
|
82
|
+
if (acc.startedAt === null) acc.startedAt = ts;
|
|
83
|
+
acc.lastProgressAt = ts;
|
|
84
|
+
}
|
|
85
|
+
const msg = o.message;
|
|
86
|
+
if (!msg) return;
|
|
87
|
+
if (o.type === 'user' && !acc.prompt && typeof msg.content === 'string') acc.prompt = msg.content;
|
|
88
|
+
if (typeof msg.model === 'string' && msg.model) acc.model = msg.model;
|
|
89
|
+
const u = msg.usage;
|
|
90
|
+
if (u) acc.tokens += (u.input_tokens || 0) + (u.output_tokens || 0) + (u.cache_creation_input_tokens || 0);
|
|
91
|
+
if (Array.isArray(msg.content)) {
|
|
92
|
+
for (const b of msg.content) {
|
|
93
|
+
if (b && b.type === 'tool_use') { acc.toolCalls++; if (b.name) acc.lastToolName = b.name; }
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function parseAgentFile(filePath) {
|
|
99
|
+
let st;
|
|
100
|
+
try { st = statSync(filePath); } catch { return null; }
|
|
101
|
+
const { mtimeMs, size } = st;
|
|
102
|
+
|
|
103
|
+
const cached = _agentParseCache.get(filePath);
|
|
104
|
+
if (cached && cached.mtimeMs === mtimeMs && cached.size === size) {
|
|
105
|
+
return _accSnapshot(cached.acc); // 未变 → 直接返回快照,不读盘
|
|
106
|
+
}
|
|
107
|
+
if (size > MAX_AGENT_BYTES) return _newAcc();
|
|
108
|
+
|
|
109
|
+
// 可续读:缓存存在且文件未被截断/轮转(size 未回退到 offset 之前)→ 从 offset 增量读;否则从头读。
|
|
110
|
+
let acc, offset, partial;
|
|
111
|
+
if (cached && cached.acc && size >= cached.offset) {
|
|
112
|
+
acc = cached.acc; offset = cached.offset; partial = cached.partial || Buffer.alloc(0);
|
|
113
|
+
} else {
|
|
114
|
+
acc = _newAcc(); offset = 0; partial = Buffer.alloc(0);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let chunk = Buffer.alloc(0);
|
|
118
|
+
try {
|
|
119
|
+
if (size > offset) {
|
|
120
|
+
const fd = openSync(filePath, 'r');
|
|
121
|
+
try {
|
|
122
|
+
const len = size - offset;
|
|
123
|
+
const buf = Buffer.allocUnsafe(len);
|
|
124
|
+
let read = 0;
|
|
125
|
+
while (read < len) {
|
|
126
|
+
const n = readSync(fd, buf, read, len - read, offset + read);
|
|
127
|
+
if (n <= 0) break;
|
|
128
|
+
read += n;
|
|
129
|
+
}
|
|
130
|
+
chunk = buf.subarray(0, read);
|
|
131
|
+
offset += read;
|
|
132
|
+
} finally { closeSync(fd); }
|
|
133
|
+
}
|
|
134
|
+
} catch {
|
|
135
|
+
return _accSnapshot(acc); // 读失败 → 返回已累计,不更新缓存(下次重试)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 只解码到「最后一个换行符」为止;其后不完整行以字节留到下次(避免切坏多字节字符)。
|
|
139
|
+
const combined = chunk.length ? Buffer.concat([partial, chunk]) : partial;
|
|
140
|
+
const lastNL = combined.lastIndexOf(0x0A);
|
|
141
|
+
let newPartial;
|
|
142
|
+
if (lastNL === -1) {
|
|
143
|
+
newPartial = combined;
|
|
144
|
+
} else {
|
|
145
|
+
const complete = combined.subarray(0, lastNL).toString('utf-8');
|
|
146
|
+
for (const line of complete.split('\n')) _applyLine(acc, line);
|
|
147
|
+
newPartial = combined.subarray(lastNL + 1);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
_agentParseCache.set(filePath, { mtimeMs, size, offset, partial: newPartial, acc });
|
|
151
|
+
return _accSnapshot(acc);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function readResumeJournal(runDir) {
|
|
155
|
+
const started = new Set();
|
|
156
|
+
const done = new Set();
|
|
157
|
+
try {
|
|
158
|
+
const lines = readFileSync(join(runDir, 'journal.jsonl'), 'utf-8').split('\n');
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
if (!line) continue;
|
|
161
|
+
let o;
|
|
162
|
+
try { o = JSON.parse(line); } catch { continue; }
|
|
163
|
+
if (!o.agentId) continue;
|
|
164
|
+
if (o.type === 'started') started.add(o.agentId);
|
|
165
|
+
else if (o.type === 'result') done.add(o.agentId);
|
|
166
|
+
}
|
|
167
|
+
} catch {}
|
|
168
|
+
return { started, done };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function labelFromPrompt(prompt, agentType) {
|
|
172
|
+
if (prompt) {
|
|
173
|
+
const firstLine = prompt.split('\n').map(s => s.trim()).find(Boolean) || '';
|
|
174
|
+
if (firstLine) return firstLine.length > LABEL_MAX ? firstLine.slice(0, LABEL_MAX) + '…' : firstLine;
|
|
175
|
+
}
|
|
176
|
+
return agentType || '';
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* 从 runDir 实时推导面板模型(与 normalizeWorkflowJournal 同形,phases 为空)。
|
|
181
|
+
* 无 agent 文件 → null。
|
|
182
|
+
*/
|
|
183
|
+
export function deriveLiveJournal(runDir, runId) {
|
|
184
|
+
if (!runDir || !existsSync(runDir)) return null;
|
|
185
|
+
let real;
|
|
186
|
+
try { real = realpathSync(runDir); } catch { return null; }
|
|
187
|
+
if (!isInsideProjectsDir(real)) return null;
|
|
188
|
+
|
|
189
|
+
let files;
|
|
190
|
+
try { files = readdirSync(runDir); } catch { return null; }
|
|
191
|
+
const agentFiles = files.filter(f => f.startsWith('agent-') && f.endsWith('.jsonl'));
|
|
192
|
+
if (agentFiles.length === 0) return null;
|
|
193
|
+
|
|
194
|
+
const { done } = readResumeJournal(runDir);
|
|
195
|
+
// sessionDir = runDir 上溯三级(runId → workflows → subagents → sessionDir)
|
|
196
|
+
const sessionDir = dirname(dirname(dirname(runDir)));
|
|
197
|
+
const workflowName = deriveWorkflowName(sessionDir, runId);
|
|
198
|
+
|
|
199
|
+
const agents = [];
|
|
200
|
+
let totalTokens = 0, totalToolCalls = 0;
|
|
201
|
+
for (const f of agentFiles) {
|
|
202
|
+
const agentId = f.slice('agent-'.length, -'.jsonl'.length);
|
|
203
|
+
const parsed = parseAgentFile(join(runDir, f));
|
|
204
|
+
if (!parsed) continue;
|
|
205
|
+
let agentType = '';
|
|
206
|
+
try { agentType = JSON.parse(readFileSync(join(runDir, `agent-${agentId}.meta.json`), 'utf-8')).agentType || ''; } catch {}
|
|
207
|
+
// agent 文件存在即已启动(排队中尚无文件),故非 done 一律 running
|
|
208
|
+
const state = done.has(agentId) ? 'done' : 'running';
|
|
209
|
+
totalTokens += parsed.tokens;
|
|
210
|
+
totalToolCalls += parsed.toolCalls;
|
|
211
|
+
agents.push({
|
|
212
|
+
index: null,
|
|
213
|
+
label: labelFromPrompt(parsed.prompt, agentType),
|
|
214
|
+
phaseIndex: null,
|
|
215
|
+
phaseTitle: '',
|
|
216
|
+
agentId,
|
|
217
|
+
agentType,
|
|
218
|
+
model: parsed.model,
|
|
219
|
+
state,
|
|
220
|
+
tokens: parsed.tokens,
|
|
221
|
+
toolCalls: parsed.toolCalls,
|
|
222
|
+
durationMs: (parsed.startedAt && parsed.lastProgressAt) ? (parsed.lastProgressAt - parsed.startedAt) : null,
|
|
223
|
+
lastToolName: parsed.lastToolName,
|
|
224
|
+
lastToolSummary: '',
|
|
225
|
+
// 头部菱形 hover 用:实时态取首条 user prompt 截断;尾部 resultPreview 待完成快照(journal)补。
|
|
226
|
+
promptPreview: parsed.prompt
|
|
227
|
+
? (parsed.prompt.length > PROMPT_PREVIEW_MAX ? parsed.prompt.slice(0, PROMPT_PREVIEW_MAX) + '…' : parsed.prompt)
|
|
228
|
+
: '',
|
|
229
|
+
resultPreview: '',
|
|
230
|
+
startedAt: parsed.startedAt,
|
|
231
|
+
lastProgressAt: parsed.lastProgressAt,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
agents.sort((a, b) => (a.startedAt || 0) - (b.startedAt || 0));
|
|
236
|
+
const allDone = agents.length > 0 && agents.every(a => a.state === 'done');
|
|
237
|
+
const startTime = agents.reduce((min, a) => (a.startedAt && (min === null || a.startedAt < min)) ? a.startedAt : min, null);
|
|
238
|
+
|
|
239
|
+
return {
|
|
240
|
+
runId: runId || '',
|
|
241
|
+
taskId: '',
|
|
242
|
+
workflowName,
|
|
243
|
+
summary: '',
|
|
244
|
+
status: allDone ? 'finishing' : 'running',
|
|
245
|
+
durationMs: null,
|
|
246
|
+
agentCount: agents.length,
|
|
247
|
+
totalTokens,
|
|
248
|
+
totalToolCalls,
|
|
249
|
+
defaultModel: '',
|
|
250
|
+
startTime,
|
|
251
|
+
phases: [],
|
|
252
|
+
agents,
|
|
253
|
+
live: true,
|
|
254
|
+
};
|
|
255
|
+
}
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workflow Watcher
|
|
3
|
+
*
|
|
4
|
+
* 监视某 session 的 workflows 目录(<sessionDir>/workflows/),journal 文件被整体覆写
|
|
5
|
+
* 时读全文件 → normalizeWorkflowJournal → 经 SSE 广播 `workflow_update` 事件,让前端
|
|
6
|
+
* 工作流面板实时跟随。
|
|
7
|
+
*
|
|
8
|
+
* 设计:
|
|
9
|
+
* - 复用 log-watcher 的 dir fs.watch + 防抖 + 安全网慢轮询模式;fs.watch 不可用/漏事件时
|
|
10
|
+
* 由 SAFETY_POLL_MS 兜底。
|
|
11
|
+
* - 惰性 arm:前端首次拉 journal(REST)时按 session 武装;同目录只 arm 一次(刷新 clients
|
|
12
|
+
* 引用)。arm 时把现存 journal 记入 seen 基线(不广播,初值由 REST 返回),之后仅广播签名
|
|
13
|
+
* 变化的文件,避免历史 journal 在 arm 瞬间洪泛。
|
|
14
|
+
* - 测试缝:__setWatchImplForTests 注入假 fs.watch;__triggerScanForTests 手动触发扫描,
|
|
15
|
+
* 规避真实 fs.watch/轮询时序在 CI 上的不确定性(仿 log-watcher.__setWatchFileImplForTests)。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, watch, readdirSync, statSync } from 'node:fs';
|
|
19
|
+
import { join, dirname } from 'node:path';
|
|
20
|
+
import { sendEventToClients } from './log-watcher.js';
|
|
21
|
+
import { readNormalizedJournal } from './workflow-journal.js';
|
|
22
|
+
import { deriveLiveJournal } from './workflow-live.js';
|
|
23
|
+
|
|
24
|
+
const DEBOUNCE_MS = 120;
|
|
25
|
+
const SAFETY_POLL_MS = 5000;
|
|
26
|
+
const PRESENCE_POLL_MS = 1000;
|
|
27
|
+
|
|
28
|
+
const _armed = new Map(); // workflowsDir → state(完成快照)
|
|
29
|
+
const _armedLive = new Map(); // runDir → state(运行中逐帧)
|
|
30
|
+
|
|
31
|
+
let _watchImpl = watch;
|
|
32
|
+
/** 测试用:替换 fs.watch 实现。生产恒为 node:fs watch。 */
|
|
33
|
+
export function __setWatchImplForTests(fn) { _watchImpl = fn || watch; }
|
|
34
|
+
|
|
35
|
+
function _signature(p) {
|
|
36
|
+
try { const st = statSync(p); return `${st.mtimeMs}:${st.size}`; } catch { return null; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _isJournalFile(f) { return f.startsWith('wf_') && f.endsWith('.json'); }
|
|
40
|
+
|
|
41
|
+
function _scan(state) {
|
|
42
|
+
const { workflowsDir, sessionId, project, clients, seen } = state;
|
|
43
|
+
let files;
|
|
44
|
+
try { files = readdirSync(workflowsDir); } catch { return; }
|
|
45
|
+
for (const f of files) {
|
|
46
|
+
if (!_isJournalFile(f)) continue;
|
|
47
|
+
const p = join(workflowsDir, f);
|
|
48
|
+
const sig = _signature(p);
|
|
49
|
+
if (!sig) continue;
|
|
50
|
+
if (seen.get(f) === sig) continue; // 未变
|
|
51
|
+
seen.set(f, sig);
|
|
52
|
+
const data = readNormalizedJournal(p);
|
|
53
|
+
if (!data) continue;
|
|
54
|
+
sendEventToClients(clients, 'workflow_update', {
|
|
55
|
+
sessionId,
|
|
56
|
+
project: project || null,
|
|
57
|
+
runId: data.runId,
|
|
58
|
+
taskId: data.taskId,
|
|
59
|
+
data,
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function _scheduleScan(state) {
|
|
65
|
+
if (state.debounceTimer) return;
|
|
66
|
+
state.debounceTimer = setTimeout(() => {
|
|
67
|
+
state.debounceTimer = null;
|
|
68
|
+
_scan(state);
|
|
69
|
+
}, DEBOUNCE_MS);
|
|
70
|
+
state.debounceTimer.unref?.();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function _startWatch(state) {
|
|
74
|
+
if (state.watcher || !existsSync(state.workflowsDir)) return false;
|
|
75
|
+
try {
|
|
76
|
+
state.watcher = _watchImpl(state.workflowsDir, () => _scheduleScan(state));
|
|
77
|
+
state.watcher?.on?.('error', () => {
|
|
78
|
+
try { state.watcher?.close?.(); } catch {}
|
|
79
|
+
state.watcher = null;
|
|
80
|
+
});
|
|
81
|
+
return true;
|
|
82
|
+
} catch {
|
|
83
|
+
state.watcher = null;
|
|
84
|
+
return false;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function _baseline(state) {
|
|
89
|
+
try {
|
|
90
|
+
for (const f of readdirSync(state.workflowsDir)) {
|
|
91
|
+
if (!_isJournalFile(f)) continue;
|
|
92
|
+
const sig = _signature(join(state.workflowsDir, f));
|
|
93
|
+
if (sig) state.seen.set(f, sig);
|
|
94
|
+
}
|
|
95
|
+
} catch {}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* 武装对某 workflows 目录的监视。同目录重复调用只刷新 clients 引用。
|
|
100
|
+
* @param {{ workflowsDir: string, sessionId?: string, project?: string, clients: Array }} opts
|
|
101
|
+
*/
|
|
102
|
+
export function armWorkflowWatch({ workflowsDir, sessionId, project, clients } = {}) {
|
|
103
|
+
if (!workflowsDir || !Array.isArray(clients)) return null;
|
|
104
|
+
|
|
105
|
+
const existing = _armed.get(workflowsDir);
|
|
106
|
+
if (existing) { existing.clients = clients; return existing; }
|
|
107
|
+
|
|
108
|
+
const state = {
|
|
109
|
+
workflowsDir, sessionId, project, clients,
|
|
110
|
+
seen: new Map(), watcher: null,
|
|
111
|
+
debounceTimer: null, safetyTimer: null, presenceTimer: null,
|
|
112
|
+
};
|
|
113
|
+
_armed.set(workflowsDir, state);
|
|
114
|
+
|
|
115
|
+
_baseline(state); // 现存 journal 记入基线,不广播(初值走 REST)
|
|
116
|
+
|
|
117
|
+
if (!_startWatch(state)) {
|
|
118
|
+
// 目录尚不存在:轮询等它出现再 watch
|
|
119
|
+
state.presenceTimer = setInterval(() => {
|
|
120
|
+
if (_startWatch(state)) {
|
|
121
|
+
clearInterval(state.presenceTimer);
|
|
122
|
+
state.presenceTimer = null;
|
|
123
|
+
_scheduleScan(state);
|
|
124
|
+
}
|
|
125
|
+
}, PRESENCE_POLL_MS);
|
|
126
|
+
state.presenceTimer.unref?.();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// 安全网慢轮询,兜 fs.watch 漏事件
|
|
130
|
+
state.safetyTimer = setInterval(() => _scan(state), SAFETY_POLL_MS);
|
|
131
|
+
state.safetyTimer.unref?.();
|
|
132
|
+
|
|
133
|
+
return state;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** 测试用:手动触发一次扫描(绕过防抖/真实 fs 事件时序)。 */
|
|
137
|
+
export function __triggerScanForTests(workflowsDir) {
|
|
138
|
+
const state = _armed.get(workflowsDir);
|
|
139
|
+
if (state) _scan(state);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function _disposeState(state) {
|
|
143
|
+
if (!state) return;
|
|
144
|
+
if (state.debounceTimer) clearTimeout(state.debounceTimer);
|
|
145
|
+
if (state.safetyTimer) clearInterval(state.safetyTimer);
|
|
146
|
+
if (state.presenceTimer) clearInterval(state.presenceTimer);
|
|
147
|
+
try { state.watcher?.close?.(); } catch {}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** 解除单个目录监视。 */
|
|
151
|
+
export function unwatchWorkflowDir(workflowsDir) {
|
|
152
|
+
const state = _armed.get(workflowsDir);
|
|
153
|
+
if (!state) return;
|
|
154
|
+
_disposeState(state);
|
|
155
|
+
_armed.delete(workflowsDir);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** 解除单个完成快照目录的监视别名 + 运行中目录监视,详见各自实现。 */
|
|
159
|
+
|
|
160
|
+
// --- 运行中逐帧(watch <sessionDir>/subagents/workflows/<runId>/)---
|
|
161
|
+
|
|
162
|
+
function _liveSignature(data) {
|
|
163
|
+
if (!data) return 'none';
|
|
164
|
+
const states = data.agents.map(a => `${a.agentId}:${a.state}:${a.tokens}:${a.toolCalls}`).join('|');
|
|
165
|
+
return `${data.status}#${data.agentCount}#${data.totalTokens}#${data.totalToolCalls}#${states}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// runDir = <sessionDir>/subagents/workflows/<runId> → 上溯三级到 sessionDir,查权威快照是否已落盘
|
|
169
|
+
function _authoritativeJournalExists(runDir, runId) {
|
|
170
|
+
try {
|
|
171
|
+
const sessionDir = dirname(dirname(dirname(runDir)));
|
|
172
|
+
return existsSync(join(sessionDir, 'workflows', `${runId}.json`));
|
|
173
|
+
} catch { return false; }
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _scanLive(state) {
|
|
177
|
+
const { runDir, runId, sessionId, project, clients } = state;
|
|
178
|
+
const data = deriveLiveJournal(runDir, runId);
|
|
179
|
+
if (data) {
|
|
180
|
+
const sig = _liveSignature(data);
|
|
181
|
+
if (sig !== state.lastSig) { // 有实质变化才广播
|
|
182
|
+
state.lastSig = sig;
|
|
183
|
+
sendEventToClients(clients, 'workflow_update', {
|
|
184
|
+
sessionId,
|
|
185
|
+
project: project || null,
|
|
186
|
+
runId: data.runId,
|
|
187
|
+
taskId: data.taskId || null,
|
|
188
|
+
data,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// 权威完成快照已落盘 → 逐帧使命结束,自我拆除(最终态由 workflows 目录 watch 接管广播,
|
|
193
|
+
// 且 workflowStore 权威锁会忽略其后乱序的 live 帧)。避免完成后 safetyTimer 永久空转重读。
|
|
194
|
+
if (_authoritativeJournalExists(runDir, runId)) {
|
|
195
|
+
unwatchWorkflowLive(runDir);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _scheduleLiveScan(state) {
|
|
200
|
+
if (state.debounceTimer) return;
|
|
201
|
+
state.debounceTimer = setTimeout(() => {
|
|
202
|
+
state.debounceTimer = null;
|
|
203
|
+
_scanLive(state);
|
|
204
|
+
}, DEBOUNCE_MS);
|
|
205
|
+
state.debounceTimer.unref?.();
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function _startLiveWatch(state) {
|
|
209
|
+
if (state.watcher || !existsSync(state.runDir)) return false;
|
|
210
|
+
try {
|
|
211
|
+
state.watcher = _watchImpl(state.runDir, () => _scheduleLiveScan(state));
|
|
212
|
+
state.watcher?.on?.('error', () => {
|
|
213
|
+
try { state.watcher?.close?.(); } catch {}
|
|
214
|
+
state.watcher = null;
|
|
215
|
+
});
|
|
216
|
+
return true;
|
|
217
|
+
} catch {
|
|
218
|
+
state.watcher = null;
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 武装对某运行中 workflow 的逐帧监视(subagents/workflows/<runId> 目录)。
|
|
225
|
+
* 同 runDir 重复调用只刷新 clients。arm 时立即广播一次当前推导态(闭合 REST/arm 竞态)。
|
|
226
|
+
* @param {{ runDir: string, runId: string, sessionId?: string, project?: string, clients: Array }} opts
|
|
227
|
+
*/
|
|
228
|
+
export function armWorkflowLiveWatch({ runDir, runId, sessionId, project, clients } = {}) {
|
|
229
|
+
if (!runDir || !runId || !Array.isArray(clients)) return null;
|
|
230
|
+
|
|
231
|
+
const existing = _armedLive.get(runDir);
|
|
232
|
+
if (existing) { existing.clients = clients; return existing; }
|
|
233
|
+
|
|
234
|
+
const state = {
|
|
235
|
+
runDir, runId, sessionId, project, clients,
|
|
236
|
+
lastSig: null, watcher: null,
|
|
237
|
+
debounceTimer: null, safetyTimer: null, presenceTimer: null,
|
|
238
|
+
};
|
|
239
|
+
_armedLive.set(runDir, state);
|
|
240
|
+
|
|
241
|
+
if (!_startLiveWatch(state)) {
|
|
242
|
+
state.presenceTimer = setInterval(() => {
|
|
243
|
+
if (_startLiveWatch(state)) {
|
|
244
|
+
clearInterval(state.presenceTimer);
|
|
245
|
+
state.presenceTimer = null;
|
|
246
|
+
_scheduleLiveScan(state);
|
|
247
|
+
}
|
|
248
|
+
}, PRESENCE_POLL_MS);
|
|
249
|
+
state.presenceTimer.unref?.();
|
|
250
|
+
} else {
|
|
251
|
+
_scheduleLiveScan(state);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
state.safetyTimer = setInterval(() => _scanLive(state), SAFETY_POLL_MS);
|
|
255
|
+
state.safetyTimer.unref?.();
|
|
256
|
+
|
|
257
|
+
return state;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 测试用:手动触发一次逐帧扫描。 */
|
|
261
|
+
export function __triggerLiveScanForTests(runDir) {
|
|
262
|
+
const state = _armedLive.get(runDir);
|
|
263
|
+
if (state) _scanLive(state);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** 解除单个运行中目录监视。 */
|
|
267
|
+
export function unwatchWorkflowLive(runDir) {
|
|
268
|
+
const state = _armedLive.get(runDir);
|
|
269
|
+
if (!state) return;
|
|
270
|
+
_disposeState(state);
|
|
271
|
+
_armedLive.delete(runDir);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** 解除所有 workflow 监视(workspace 切换/进程退出)。 */
|
|
275
|
+
export function unwatchAllWorkflows() {
|
|
276
|
+
for (const state of _armed.values()) _disposeState(state);
|
|
277
|
+
_armed.clear();
|
|
278
|
+
for (const state of _armedLive.values()) _disposeState(state);
|
|
279
|
+
_armedLive.clear();
|
|
280
|
+
}
|