@harness-mix/cli 0.2.0 → 0.2.1
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/CHANGELOG.md +13 -0
- package/NOTICE +14 -0
- package/README.md +4 -24
- package/docs/multi-agent-collaboration.md +1 -1
- package/licenses/anthropic-codex-theme-MIT.txt +21 -0
- package/licenses/codex-dream-skin-MIT.txt +21 -0
- package/output/native-build/renderer-extension.js +703 -58
- package/package.json +192 -189
- package/scripts/collaboration-test.cjs +262 -200
- package/scripts/collaboration-ui-smoke.cjs +85 -77
- package/scripts/core-concurrency-test.cjs +23 -1
- package/scripts/e2e-pi-cancel.cjs +99 -0
- package/scripts/native-file-change-test.cjs +42 -2
- package/scripts/native-sidebar-test.cjs +8 -0
- package/scripts/native-vendor-adapters-test.cjs +67 -1
- package/scripts/pets-test.cjs +63 -0
- package/scripts/pi-cancel-race-test.cjs +102 -0
- package/scripts/stuck-turn-test.cjs +73 -0
- package/scripts/workspace-test.cjs +9 -0
- package/src/assets/skins/dream-skin/NOTICE.md +13 -0
- package/src/assets/skins/dream-skin/themes/gothic-void-crusade/hero.webp +0 -0
- package/src/assets/skins/dream-skin/themes/gothic-void-crusade/theme.json +34 -0
- package/src/main/adapters/claude.js +9 -6
- package/src/main/adapters/pi-family.js +41 -8
- package/src/main/harness-adapter/event-normalizer.js +270 -258
- package/src/main/host/collaboration-mcp.cjs +8 -1
- package/src/main/host/collaboration-tools.js +1 -1
- package/src/main/host/collaboration.js +81 -11
- package/src/main/host/runtime.js +1420 -1341
- package/src/main/native/pets.js +79 -2
- package/src/main/native/protocol.js +1160 -1145
- package/src/main/native/thread-list.js +6 -1
- package/src/main/workspace/core-review.js +75 -0
- package/src/main/workspace/diff.js +5 -1
- package/src/main/workspace/file-changes.js +9 -4
- package/src/main/workspace/review-controller.js +115 -102
- package/src/main/workspace/review.js +90 -88
- package/src/native-ui/renderer-extension/dist/contract-audit.js +16637 -0
- package/src/native-ui/renderer-extension/dist/index.js +34853 -0
- package/src/native-ui/renderer-extension/dist/production.js +34263 -0
- package/src/native-ui/renderer-extension/dist/renderer-binding-probe.js +34254 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-binding-probe.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +11 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts +73 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-pet-switcher.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts +1 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-team-cards.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts +12 -0
- package/src/native-ui/renderer-extension/dist/types/settings/collaboration-page.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/icons.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/localization.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts +5 -3
- package/src/native-ui/renderer-extension/dist/types/settings/pages.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pet-market.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts +9 -0
- package/src/native-ui/renderer-extension/dist/types/settings/pets-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts +1 -1
- package/src/native-ui/renderer-extension/dist/types/settings/skin-runtime.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +3181 -3171
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +14 -0
- package/src/native-ui/renderer-extension/src/renderer-pet-switcher.ts +308 -0
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +2 -0
- package/src/native-ui/renderer-extension/src/renderer-team-cards.ts +76 -21
- package/src/native-ui/renderer-extension/src/settings/collaboration-page.ts +139 -0
- package/src/native-ui/renderer-extension/src/settings/icons.ts +155 -153
- package/src/native-ui/renderer-extension/src/settings/localization.ts +785 -783
- package/src/native-ui/renderer-extension/src/settings/pages.ts +694 -681
- package/src/native-ui/renderer-extension/src/settings/pet-market.css +110 -1
- package/src/native-ui/renderer-extension/src/settings/pet-market.ts +276 -22
- package/src/native-ui/renderer-extension/src/settings/pets-client.ts +32 -0
- package/src/native-ui/renderer-extension/src/settings/shell.css +2294 -2255
- package/src/native-ui/renderer-extension/src/settings/skin-runtime.ts +51 -1
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
- package/src/native-ui/renderer-extension/test/renderer-pet-switcher.test.ts +400 -0
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +80 -79
- package/src/native-ui/renderer-extension/test/settings/pet-market.test.ts +378 -0
- package/src/native-ui/renderer-extension/test/settings/pets-client.test.ts +83 -0
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +3 -0
- package/src/native-ui/renderer-extension/test/settings/skin-runtime.test.ts +15 -1
|
@@ -16,7 +16,12 @@ function includesThread(thread, query = {}, threads = []) {
|
|
|
16
16
|
if (query.isPinned === false && thread.isPinned) return false;
|
|
17
17
|
if (query.cwd != null && !(Array.isArray(query.cwd) ? query.cwd : [query.cwd]).includes(thread.cwd)) return false;
|
|
18
18
|
if (query.modelProviders?.length && !query.modelProviders.includes('harnessmix')) return false;
|
|
19
|
-
if (query.sourceKinds?.length
|
|
19
|
+
if (query.sourceKinds?.length) {
|
|
20
|
+
// 协作子任务线程投影为 subAgentThreadSpawn(Desktop 子代理入口按此 sourceKind 查询);
|
|
21
|
+
// 其余外部线程保持 vscode 来源。
|
|
22
|
+
const sourceKind = thread.parentThreadId ? 'subAgentThreadSpawn' : 'vscode';
|
|
23
|
+
if (!query.sourceKinds.includes(sourceKind)) return false;
|
|
24
|
+
}
|
|
20
25
|
if (query.searchTerm && !(thread.title || '').toLowerCase().includes(query.searchTerm.toLowerCase())) return false;
|
|
21
26
|
return true;
|
|
22
27
|
}
|
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
const { diff } = require('./diff');
|
|
2
2
|
|
|
3
|
+
// 把工具/文件事件里的路径归一化成「相对 cwd、小写、正斜杠」的形式,
|
|
4
|
+
// 绝对路径只接受 cwd 之下的;归一化失败返回 null(忽略该条目)。
|
|
5
|
+
function normalizePath(cwd) {
|
|
6
|
+
return value => {
|
|
7
|
+
if (typeof value !== 'string' || !value) return null;
|
|
8
|
+
let p = value.replace(/\\/g, '/');
|
|
9
|
+
if (/^[a-z]:\//i.test(p)) {
|
|
10
|
+
const lower = p.toLowerCase();
|
|
11
|
+
if (!cwd || !lower.startsWith(cwd + '/')) return null;
|
|
12
|
+
p = p.slice(cwd.length + 1);
|
|
13
|
+
}
|
|
14
|
+
p = p.replace(/^\.\//, '');
|
|
15
|
+
return p ? p.toLowerCase() : null;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// 收集一组 Core Turn 中可归因的触碰路径(file_change 条目与工具结构化 path)。
|
|
20
|
+
function collectTurnPaths(runtime, turnIds, cwd) {
|
|
21
|
+
const add = normalizePath(cwd);
|
|
22
|
+
const touched = new Set();
|
|
23
|
+
for (const turnId of turnIds) {
|
|
24
|
+
for (const item of runtime.core.getItemsForTurn(turnId)) {
|
|
25
|
+
if (item.type !== 'file_change' && item.type !== 'tool_call') continue;
|
|
26
|
+
const p = add(item.path);
|
|
27
|
+
if (p) touched.add(p);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return touched;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// 本轮真实触碰的文件路径集合(相对 cwd、小写、正斜杠):来自文件编辑类工具的
|
|
34
|
+
// 结构化 path 与原生 file_change 条目。协作 Lead 回合合并其子线程的触碰路径,
|
|
35
|
+
// 让团队卡片展示的是整个团队而非其他会话的改动。
|
|
36
|
+
function touchedPaths(runtime, thread, message) {
|
|
37
|
+
const cwd = String(thread.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
38
|
+
const turns = [message.coreTurnId];
|
|
39
|
+
for (const child of runtime.threads ?? []) {
|
|
40
|
+
if (child.parentThreadId !== thread.id) continue;
|
|
41
|
+
for (const m of child.messages ?? []) if (m.coreTurnId) turns.push(m.coreTurnId);
|
|
42
|
+
}
|
|
43
|
+
return collectTurnPaths(runtime, turns, cwd);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// 明确归属于「其他会话」的文件路径集合:同目录并发时,对方 Harness 上报的
|
|
47
|
+
// file_change / 工具 path 条目是正向归属证据。本回合快照(目录级 diff)会把
|
|
48
|
+
// 这些外来改动一起捕进来,必须按归属剔除,否则 Agent Team / 并发会话的编辑
|
|
49
|
+
// 会污染本回合的变更卡片与撤回列表。只处理同 cwd 的线程:不同 cwd(worktree
|
|
50
|
+
// 等)的相对路径不可比较,也进不了本目录快照。无归属证据的改动保持原行为。
|
|
51
|
+
function foreignPaths(runtime, thread, message) {
|
|
52
|
+
const cwd = String(thread.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase();
|
|
53
|
+
const mine = new Set([thread.id]);
|
|
54
|
+
for (const child of runtime.threads ?? []) {
|
|
55
|
+
if (child.parentThreadId === thread.id) mine.add(child.id);
|
|
56
|
+
}
|
|
57
|
+
const foreign = new Set();
|
|
58
|
+
for (const other of runtime.threads ?? []) {
|
|
59
|
+
if (mine.has(other.id)) continue;
|
|
60
|
+
if (String(other.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase() !== cwd) continue;
|
|
61
|
+
const turns = (other.messages ?? []).map(m => m.coreTurnId).filter(Boolean);
|
|
62
|
+
for (const p of collectTurnPaths(runtime, turns, cwd)) foreign.add(p);
|
|
63
|
+
}
|
|
64
|
+
return foreign;
|
|
65
|
+
}
|
|
66
|
+
|
|
3
67
|
// Snapshot acquisition/undo stay in Workspace. Core stores their presentation.
|
|
4
68
|
async function projectReview(runtime, thread, message, record) {
|
|
5
69
|
const core = runtime.core;
|
|
@@ -17,8 +81,19 @@ async function projectReview(runtime, thread, message, record) {
|
|
|
17
81
|
const nativeItems = core.getItemsForTurn(message.coreTurnId).filter(item => item.type === 'file_change' && item.source === 'native');
|
|
18
82
|
const nativePaths = new Set(nativeItems.map(item => item.path));
|
|
19
83
|
changes = changes.filter(change => nativePaths.has(change.path));
|
|
84
|
+
} else if (!hasNativePatch && record.concurrent) {
|
|
85
|
+
// 无原生 patch 的 Harness(如 Pi):并发同目录时用本轮工具触碰路径收窄全量快照,
|
|
86
|
+
// 避免同项目其他会话的改动出现在本回合卡片里。本轮没有任何可归因路径时
|
|
87
|
+
// (纯 shell 会话等)保留全量快照,不虚报归属。
|
|
88
|
+
const touched = touchedPaths(runtime, thread, message);
|
|
89
|
+
if (touched.size) changes = changes.filter(change => touched.has(change.path.toLowerCase()));
|
|
20
90
|
}
|
|
21
91
|
|
|
92
|
+
// 正向归属于其他会话的改动无条件剔除(不依赖 concurrent 标记:对方可能
|
|
93
|
+
// 在本回合开始后才启动)。无归属证据的改动保持原样,不虚报归属。
|
|
94
|
+
const foreign = foreignPaths(runtime, thread, message);
|
|
95
|
+
if (foreign.size) changes = changes.filter(change => !foreign.has(change.path.toLowerCase()));
|
|
96
|
+
|
|
22
97
|
core.dispatch({ threadId: thread.id, turnId: message.coreTurnId, type: 'files.updated', payload: {
|
|
23
98
|
source: 'snapshot', replace: true,
|
|
24
99
|
changes: changes.map(change => ({
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Bounded line LCS; large inputs use a content-exact coarse replacement.
|
|
2
|
+
// Line comparison ignores CR/LF differences: a whole-file line-ending flip
|
|
3
|
+
// (checkout/stash with autocrlf, editor re-save) is not a content edit and
|
|
4
|
+
// must not surface as a full-file +N/-N phantom change.
|
|
5
|
+
const eolSafe = s => s.replace(/\r\n$/, '\n');
|
|
2
6
|
function diff(before = '', after = '') {
|
|
3
|
-
const lines = s => s ? s.match(/[^\n]*\n|[^\n]+$/g) ?? [] : [];
|
|
7
|
+
const lines = s => s ? (s.match(/[^\n]*\n|[^\n]+$/g) ?? []).map(eolSafe) : [];
|
|
4
8
|
const a = lines(before), b = lines(after), rows = [];
|
|
5
9
|
let old = 1, next = 1;
|
|
6
10
|
const add = (kind, text) => rows.push({ kind, text: text.replace(/\r?\n$/, ''), old: kind === 'add' ? null : old++, next: kind === 'remove' ? null : next++ });
|
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
const path = require('node:path');
|
|
2
2
|
const { diff } = require('./diff');
|
|
3
3
|
|
|
4
|
+
// 工作区外的改动(Claude 计划文件、全局配置、其他盘符……)是合法事件:保留展示
|
|
5
|
+
// (规范绝对路径 + outsideWorkspace 标记),撤回/预览等磁盘操作仍由既有守卫优雅拒绝。
|
|
6
|
+
// 注意:这里绝不能抛错——异常会沿 emit 同步传回 Adapter 事件泵,把原生会话误判为
|
|
7
|
+
// 崩溃(crashed),之后所有 send 都报“原生会话不可用”。无路径的改动同样静默丢弃。
|
|
4
8
|
function canonicalChanges(cwd, changes = [], previous = []) {
|
|
5
|
-
return changes.map(change => {
|
|
9
|
+
return changes.filter(change => typeof change?.path === 'string' && change.path.trim()).map(change => {
|
|
6
10
|
const absolute = path.resolve(cwd, change.path);
|
|
7
11
|
const relative = path.relative(cwd, absolute).replace(/\\/g, '/');
|
|
8
|
-
|
|
9
|
-
const
|
|
12
|
+
const outside = !relative || relative === '..' || relative.startsWith('../') || path.isAbsolute(relative);
|
|
13
|
+
const canonical = outside ? absolute.replace(/\\/g, '/') : relative;
|
|
14
|
+
const old = previous.find(item => item.path === canonical);
|
|
10
15
|
if (old && typeof old.before === 'string' && typeof old.after === 'string') {
|
|
11
16
|
if (change.before === old.after) {
|
|
12
17
|
change = { ...change, before: old.before, changeType: old.changeType === 'added' && change.changeType !== 'deleted' ? 'added' : change.changeType };
|
|
@@ -15,7 +20,7 @@ function canonicalChanges(cwd, changes = [], previous = []) {
|
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
const patch = typeof change.before === 'string' && typeof change.after === 'string' ? diff(change.before, change.after) : change.patch;
|
|
18
|
-
return { ...change, path:
|
|
23
|
+
return { ...change, path: canonical, ...(outside ? { outsideWorkspace: true } : {}), ...(patch ? { patch, added: patch.added, removed: patch.removed } : {}) };
|
|
19
24
|
});
|
|
20
25
|
}
|
|
21
26
|
module.exports = { canonicalChanges };
|
|
@@ -1,102 +1,115 @@
|
|
|
1
|
-
const fs = require('node:fs/promises');
|
|
2
|
-
const { projectReview, readReview } = require('./core-review');
|
|
3
|
-
|
|
4
|
-
// Owns workspace review settlement, live refresh and undo. It never decides Turn state.
|
|
5
|
-
class ReviewController {
|
|
6
|
-
constructor(runtime, { save, broadcast }) {
|
|
7
|
-
this.runtime = runtime; this.save = save; this.broadcast = broadcast;
|
|
8
|
-
}
|
|
9
|
-
get threads() { return this.runtime.threads; }
|
|
10
|
-
get reviews() { return this.runtime.reviews; }
|
|
11
|
-
get listeners() { return this.runtime.listeners; }
|
|
12
|
-
get settlements() { return this.runtime.settlements; }
|
|
13
|
-
get reviewMonitors() { return this.runtime.reviewMonitors; }
|
|
14
|
-
async settle(thread, message) {
|
|
15
|
-
if (this.settlements.has(thread.id)) return;
|
|
16
|
-
this.settlements.add(thread.id);
|
|
17
|
-
const monitor = this.reviewMonitors.get(thread.id);
|
|
18
|
-
if (monitor) { monitor.closed = true; clearInterval(monitor.timer); this.reviewMonitors.delete(thread.id); }
|
|
19
|
-
thread.reviewPending = true;
|
|
20
|
-
try {
|
|
21
|
-
if (message?.reviewId) {
|
|
22
|
-
if (message.concurrent) {
|
|
23
|
-
const loaded = await this.reviews.load(message.reviewId);
|
|
24
|
-
loaded.concurrent = true;
|
|
25
|
-
loaded.note = '此轮执行期间存在同项目并发任务,文件变更可能包含其他任务的修改。';
|
|
26
|
-
await this.reviews.save(loaded);
|
|
27
|
-
}
|
|
28
|
-
message.review = await this.reviews.finish(message.reviewId);
|
|
29
|
-
if (message.concurrent && message.review) {
|
|
30
|
-
message.review.concurrent = true;
|
|
31
|
-
message.review.note = '此轮执行期间存在同项目并发任务,文件变更可能包含其他任务的修改。';
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
if (message?.review) {
|
|
35
|
-
const record = await this.reviews.load(message.reviewId);
|
|
36
|
-
if (message.concurrent) record.concurrent = true;
|
|
37
|
-
const summary = await projectReview(this.runtime, thread, message, record);
|
|
38
|
-
if (message.concurrent) summary.concurrent = true;
|
|
39
|
-
message.review = summary;
|
|
40
|
-
this.emitReviewUpdate(thread, message, summary);
|
|
41
|
-
}
|
|
42
|
-
} catch (e) { if (message) message.reviewError = '文件审查暂不可用:' + e.message; }
|
|
43
|
-
finally {
|
|
44
|
-
thread.reviewPending = false;
|
|
45
|
-
this.settlements.delete(thread.id);
|
|
46
|
-
await this.save(); this.broadcast();
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
readReview(thread, message, file) { return readReview(this.runtime, thread, message, file); }
|
|
51
|
-
|
|
52
|
-
reviewMessage(threadId, messageId) {
|
|
53
|
-
const thread = this.threads.find(t => t.id === threadId);
|
|
54
|
-
const message = thread.messages.find(m => m.id === messageId);
|
|
55
|
-
if (!message?.review) throw Error('该轮没有可审查的文件快照(旧历史无法补建)');
|
|
56
|
-
return { thread, message };
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
// The UI subscribes to a turn-scoped event; workspace snapshots stay in Main.
|
|
60
|
-
startReviewUpdates(thread, message) {
|
|
61
|
-
if (!message.reviewId || this.reviewMonitors.has(thread.id)) return;
|
|
62
|
-
const monitor = { busy: false, closed: false };
|
|
63
|
-
const tick = async () => {
|
|
64
|
-
if (monitor.closed || monitor.busy || !message.streaming) return;
|
|
65
|
-
monitor.busy = true;
|
|
66
|
-
try {
|
|
67
|
-
const fileRevision = thread.fileRevision;
|
|
68
|
-
const record = await this.reviews.preview(message.reviewId);
|
|
69
|
-
if (!monitor.closed && message.streaming && fileRevision === thread.fileRevision) {
|
|
70
|
-
if (message.concurrent) record.concurrent = true;
|
|
71
|
-
const review = await projectReview(this.runtime, thread, message, record);
|
|
72
|
-
if (message.concurrent) review.concurrent = true;
|
|
73
|
-
message.liveReview = review;
|
|
74
|
-
this.emitReviewUpdate(thread, message, review);
|
|
75
|
-
}
|
|
76
|
-
} catch (e) {
|
|
77
|
-
if (!monitor.closed) for (const listener of this.listeners) listener({ type: 'turn/diff/updated', threadId: thread.id, turnId: message.id, error: e.message });
|
|
78
|
-
} finally { monitor.busy = false; }
|
|
79
|
-
};
|
|
80
|
-
monitor.
|
|
81
|
-
monitor.timer
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const { projectReview, readReview } = require('./core-review');
|
|
3
|
+
|
|
4
|
+
// Owns workspace review settlement, live refresh and undo. It never decides Turn state.
|
|
5
|
+
class ReviewController {
|
|
6
|
+
constructor(runtime, { save, broadcast }) {
|
|
7
|
+
this.runtime = runtime; this.save = save; this.broadcast = broadcast;
|
|
8
|
+
}
|
|
9
|
+
get threads() { return this.runtime.threads; }
|
|
10
|
+
get reviews() { return this.runtime.reviews; }
|
|
11
|
+
get listeners() { return this.runtime.listeners; }
|
|
12
|
+
get settlements() { return this.runtime.settlements; }
|
|
13
|
+
get reviewMonitors() { return this.runtime.reviewMonitors; }
|
|
14
|
+
async settle(thread, message) {
|
|
15
|
+
if (this.settlements.has(thread.id)) return;
|
|
16
|
+
this.settlements.add(thread.id);
|
|
17
|
+
const monitor = this.reviewMonitors.get(thread.id);
|
|
18
|
+
if (monitor) { monitor.closed = true; clearInterval(monitor.timer); clearTimeout(monitor.nudgeTimer); this.reviewMonitors.delete(thread.id); }
|
|
19
|
+
thread.reviewPending = true;
|
|
20
|
+
try {
|
|
21
|
+
if (message?.reviewId) {
|
|
22
|
+
if (message.concurrent) {
|
|
23
|
+
const loaded = await this.reviews.load(message.reviewId);
|
|
24
|
+
loaded.concurrent = true;
|
|
25
|
+
loaded.note = '此轮执行期间存在同项目并发任务,文件变更可能包含其他任务的修改。';
|
|
26
|
+
await this.reviews.save(loaded);
|
|
27
|
+
}
|
|
28
|
+
message.review = await this.reviews.finish(message.reviewId);
|
|
29
|
+
if (message.concurrent && message.review) {
|
|
30
|
+
message.review.concurrent = true;
|
|
31
|
+
message.review.note = '此轮执行期间存在同项目并发任务,文件变更可能包含其他任务的修改。';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
if (message?.review) {
|
|
35
|
+
const record = await this.reviews.load(message.reviewId);
|
|
36
|
+
if (message.concurrent) record.concurrent = true;
|
|
37
|
+
const summary = await projectReview(this.runtime, thread, message, record);
|
|
38
|
+
if (message.concurrent) summary.concurrent = true;
|
|
39
|
+
message.review = summary;
|
|
40
|
+
this.emitReviewUpdate(thread, message, summary);
|
|
41
|
+
}
|
|
42
|
+
} catch (e) { if (message) message.reviewError = '文件审查暂不可用:' + e.message; }
|
|
43
|
+
finally {
|
|
44
|
+
thread.reviewPending = false;
|
|
45
|
+
this.settlements.delete(thread.id);
|
|
46
|
+
await this.save(); this.broadcast();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
readReview(thread, message, file) { return readReview(this.runtime, thread, message, file); }
|
|
51
|
+
|
|
52
|
+
reviewMessage(threadId, messageId) {
|
|
53
|
+
const thread = this.threads.find(t => t.id === threadId);
|
|
54
|
+
const message = thread.messages.find(m => m.id === messageId);
|
|
55
|
+
if (!message?.review) throw Error('该轮没有可审查的文件快照(旧历史无法补建)');
|
|
56
|
+
return { thread, message };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The UI subscribes to a turn-scoped event; workspace snapshots stay in Main.
|
|
60
|
+
startReviewUpdates(thread, message) {
|
|
61
|
+
if (!message.reviewId || this.reviewMonitors.has(thread.id)) return;
|
|
62
|
+
const monitor = { busy: false, closed: false, nudgeTimer: null };
|
|
63
|
+
const tick = async () => {
|
|
64
|
+
if (monitor.closed || monitor.busy || !message.streaming) return;
|
|
65
|
+
monitor.busy = true;
|
|
66
|
+
try {
|
|
67
|
+
const fileRevision = thread.fileRevision;
|
|
68
|
+
const record = await this.reviews.preview(message.reviewId);
|
|
69
|
+
if (!monitor.closed && message.streaming && fileRevision === thread.fileRevision) {
|
|
70
|
+
if (message.concurrent) record.concurrent = true;
|
|
71
|
+
const review = await projectReview(this.runtime, thread, message, record);
|
|
72
|
+
if (message.concurrent) review.concurrent = true;
|
|
73
|
+
message.liveReview = review;
|
|
74
|
+
this.emitReviewUpdate(thread, message, review);
|
|
75
|
+
}
|
|
76
|
+
} catch (e) {
|
|
77
|
+
if (!monitor.closed) for (const listener of this.listeners) listener({ type: 'turn/diff/updated', threadId: thread.id, turnId: message.id, error: e.message });
|
|
78
|
+
} finally { monitor.busy = false; }
|
|
79
|
+
};
|
|
80
|
+
monitor.tick = tick;
|
|
81
|
+
monitor.timer = setInterval(() => void tick(), 3000);
|
|
82
|
+
monitor.timer.unref?.();
|
|
83
|
+
this.reviewMonitors.set(thread.id, monitor);
|
|
84
|
+
void tick();
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// 文件编辑工具落盘后立刻刷新一次(去抖 250ms),不再等 3s 轮询;
|
|
88
|
+
// 快照仍是唯一事实来源,这里只是提前触发。
|
|
89
|
+
nudge(threadId) {
|
|
90
|
+
const monitor = this.reviewMonitors.get(threadId);
|
|
91
|
+
if (!monitor || monitor.closed || monitor.nudgeTimer) return;
|
|
92
|
+
monitor.nudgeTimer = setTimeout(() => {
|
|
93
|
+
monitor.nudgeTimer = null;
|
|
94
|
+
if (!monitor.closed) void monitor.tick?.();
|
|
95
|
+
}, 250);
|
|
96
|
+
monitor.nudgeTimer.unref?.();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
emitReviewUpdate(thread, message, review) {
|
|
100
|
+
for (const listener of this.listeners) listener({ type: 'turn/diff/updated', threadId: thread.id, turnId: message.id, review });
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async undoFile(threadId, messageId, file) {
|
|
104
|
+
const { thread, message } = this.reviewMessage(threadId, messageId);
|
|
105
|
+
if (this.threads.some(t => t.cwd.toLowerCase() === thread.cwd.toLowerCase() && (this.runtime.execution.isRunning(t.id) || t.status === 'working' || t.reviewPending))) throw Error('项目任务执行或结算中,暂不能撤回');
|
|
106
|
+
const record = await this.reviews.load(message.review.id);
|
|
107
|
+
if ((await fs.realpath(thread.cwd)).toLowerCase() !== record.root.toLowerCase()) throw Error('任务目录已移动,禁止从新目录撤回旧项目文件');
|
|
108
|
+
message.review = await this.reviews.undo(message.review.id, file);
|
|
109
|
+
await projectReview(this.runtime, thread, message, await this.reviews.load(message.review.id));
|
|
110
|
+
await this.save(); this.broadcast();
|
|
111
|
+
return message.review;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
}
|
|
115
|
+
module.exports = { ReviewController };
|
|
@@ -1,88 +1,90 @@
|
|
|
1
|
-
const fs = require('node:fs/promises');
|
|
2
|
-
const path = require('node:path');
|
|
3
|
-
const { randomUUID } = require('node:crypto');
|
|
4
|
-
const { snapshot, resolveFile, readText } = require('./files');
|
|
5
|
-
const { diff } = require('./diff');
|
|
6
|
-
|
|
7
|
-
class ReviewStore {
|
|
8
|
-
constructor(directory) { this.directory = path.join(directory, 'reviews'); this.locks = new Set(); this.cache = new Map(); }
|
|
9
|
-
file(id) { if (!/^[\da-f-]{36}$/.test(id)) throw Error('无效审查 ID'); return path.join(this.directory, id + '.json'); }
|
|
10
|
-
async save(record) {
|
|
11
|
-
this.cache.set(record.id, record);
|
|
12
|
-
await fs.mkdir(this.directory, { recursive: true });
|
|
13
|
-
const file = this.file(record.id);
|
|
14
|
-
await fs.writeFile(file + '.tmp', JSON.stringify(record));
|
|
15
|
-
await fs.rename(file + '.tmp', file);
|
|
16
|
-
}
|
|
17
|
-
async load(id) {
|
|
18
|
-
if (this.cache.has(id)) return this.cache.get(id);
|
|
19
|
-
const record = JSON.parse(await fs.readFile(this.file(id), 'utf8'));
|
|
20
|
-
for (const change of record.changes) change.path = change.path.replace(/\\/g, '/');
|
|
21
|
-
this.cache.set(id, record);
|
|
22
|
-
return record;
|
|
23
|
-
}
|
|
24
|
-
async begin(root) {
|
|
25
|
-
const baseline = await snapshot(root);
|
|
26
|
-
const record = { id: randomUUID(), root: await fs.realpath(root), baseline, at: Date.now(), changes: [] };
|
|
27
|
-
await this.save(record);
|
|
28
|
-
return record.id;
|
|
29
|
-
}
|
|
30
|
-
async preview(id) {
|
|
31
|
-
const record = await this.load(id);
|
|
32
|
-
if (!record.baseline) return record;
|
|
33
|
-
const after = await snapshot(record.root, record.lastSnapshot?.files || record.baseline.files);
|
|
34
|
-
record.lastSnapshot = after;
|
|
35
|
-
record.changes = [];
|
|
36
|
-
const previousNames = new Set(record.baseline.names), currentNames = new Set(after.names);
|
|
37
|
-
for (const file of new Set([...record.baseline.names, ...after.names])) {
|
|
38
|
-
const before = record.baseline.files[file], next = after.files[file];
|
|
39
|
-
// A skipped/binary/oversized file is not a deletion or an addition.
|
|
40
|
-
if ((!before && previousNames.has(file)) || (!next && currentNames.has(file))) continue;
|
|
41
|
-
if (before?.hash === next?.hash) continue;
|
|
42
|
-
const delta = diff(before?.text, next?.text);
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
record.
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (change.
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
await fs.
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
await
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
|
|
1
|
+
const fs = require('node:fs/promises');
|
|
2
|
+
const path = require('node:path');
|
|
3
|
+
const { randomUUID } = require('node:crypto');
|
|
4
|
+
const { snapshot, resolveFile, readText } = require('./files');
|
|
5
|
+
const { diff } = require('./diff');
|
|
6
|
+
|
|
7
|
+
class ReviewStore {
|
|
8
|
+
constructor(directory) { this.directory = path.join(directory, 'reviews'); this.locks = new Set(); this.cache = new Map(); }
|
|
9
|
+
file(id) { if (!/^[\da-f-]{36}$/.test(id)) throw Error('无效审查 ID'); return path.join(this.directory, id + '.json'); }
|
|
10
|
+
async save(record) {
|
|
11
|
+
this.cache.set(record.id, record);
|
|
12
|
+
await fs.mkdir(this.directory, { recursive: true });
|
|
13
|
+
const file = this.file(record.id);
|
|
14
|
+
await fs.writeFile(file + '.tmp', JSON.stringify(record));
|
|
15
|
+
await fs.rename(file + '.tmp', file);
|
|
16
|
+
}
|
|
17
|
+
async load(id) {
|
|
18
|
+
if (this.cache.has(id)) return this.cache.get(id);
|
|
19
|
+
const record = JSON.parse(await fs.readFile(this.file(id), 'utf8'));
|
|
20
|
+
for (const change of record.changes) change.path = change.path.replace(/\\/g, '/');
|
|
21
|
+
this.cache.set(id, record);
|
|
22
|
+
return record;
|
|
23
|
+
}
|
|
24
|
+
async begin(root) {
|
|
25
|
+
const baseline = await snapshot(root);
|
|
26
|
+
const record = { id: randomUUID(), root: await fs.realpath(root), baseline, at: Date.now(), changes: [] };
|
|
27
|
+
await this.save(record);
|
|
28
|
+
return record.id;
|
|
29
|
+
}
|
|
30
|
+
async preview(id) {
|
|
31
|
+
const record = await this.load(id);
|
|
32
|
+
if (!record.baseline) return record;
|
|
33
|
+
const after = await snapshot(record.root, record.lastSnapshot?.files || record.baseline.files);
|
|
34
|
+
record.lastSnapshot = after;
|
|
35
|
+
record.changes = [];
|
|
36
|
+
const previousNames = new Set(record.baseline.names), currentNames = new Set(after.names);
|
|
37
|
+
for (const file of new Set([...record.baseline.names, ...after.names])) {
|
|
38
|
+
const before = record.baseline.files[file], next = after.files[file];
|
|
39
|
+
// A skipped/binary/oversized file is not a deletion or an addition.
|
|
40
|
+
if ((!before && previousNames.has(file)) || (!next && currentNames.has(file))) continue;
|
|
41
|
+
if (before?.hash === next?.hash) continue;
|
|
42
|
+
const delta = diff(before?.text, next?.text);
|
|
43
|
+
// 仅行尾符/编码差异不产生真实增删行,不列为变更(防止全文件幻影 diff)
|
|
44
|
+
if (!delta.added && !delta.removed) continue;
|
|
45
|
+
record.changes.push({ path: file.replace(/\\/g, '/'), before: before ?? null, after: next ?? null, added: delta.added, removed: delta.removed, coarse: delta.coarse });
|
|
46
|
+
}
|
|
47
|
+
record.skipped = record.baseline.skipped.length + after.skipped.length;
|
|
48
|
+
return record;
|
|
49
|
+
}
|
|
50
|
+
async finish(id) {
|
|
51
|
+
const record = await this.preview(id);
|
|
52
|
+
delete record.baseline;
|
|
53
|
+
delete record.lastSnapshot;
|
|
54
|
+
record.endedAt = Date.now();
|
|
55
|
+
await this.save(record);
|
|
56
|
+
return this.summary(record);
|
|
57
|
+
}
|
|
58
|
+
summary(record) {
|
|
59
|
+
return { id: record.id, at: record.at, endedAt: record.endedAt, live: !record.endedAt, skipped: record.skipped,
|
|
60
|
+
...(record.concurrent ? { concurrent: true, ...(record.note ? { note: record.note } : {}) } : {}),
|
|
61
|
+
files: record.changes.map(c => ({ path: c.path, added: c.added, removed: c.removed, undone: c.undone, kind: !c.before ? 'added' : !c.after ? 'deleted' : 'modified' })) };
|
|
62
|
+
}
|
|
63
|
+
async detail(id, file) {
|
|
64
|
+
const record = await this.preview(id), change = record.changes.find(c => c.path === file);
|
|
65
|
+
if (!change) throw Error('文件不在本轮审查记录中');
|
|
66
|
+
return { path: file, undone: change.undone, ...diff(change.before?.text, change.after?.text) };
|
|
67
|
+
}
|
|
68
|
+
async undo(id, file) {
|
|
69
|
+
if (this.locks.has(id)) throw Error('撤回正在进行');
|
|
70
|
+
this.locks.add(id);
|
|
71
|
+
try {
|
|
72
|
+
const record = await this.load(id), change = record.changes.find(c => c.path === file);
|
|
73
|
+
if (!record.endedAt || !change || change.undone) throw Error('该文件不可撤回');
|
|
74
|
+
const target = await resolveFile(record.root, file);
|
|
75
|
+
let current = null;
|
|
76
|
+
try { current = await readText(record.root, file); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
|
77
|
+
if (current?.hash !== change.after?.hash) throw Error('文件在任务结束后已发生变化,拒绝覆盖。请先手动处理冲突。');
|
|
78
|
+
// The review journal retains both revisions even for a removed new file.
|
|
79
|
+
if (change.before) {
|
|
80
|
+
await fs.mkdir(path.dirname(target), { recursive: true });
|
|
81
|
+
await resolveFile(record.root, file);
|
|
82
|
+
await fs.writeFile(target, change.before.text, { flag: current ? 'w' : 'wx', mode: change.before.mode });
|
|
83
|
+
} else await fs.unlink(target);
|
|
84
|
+
change.undone = true;
|
|
85
|
+
await this.save(record);
|
|
86
|
+
return this.summary(record);
|
|
87
|
+
} finally { this.locks.delete(id); }
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
module.exports = { ReviewStore };
|