@harness-mix/cli 0.1.5 → 0.1.7
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/README.md +86 -56
- package/docs/harness-management.md +4 -4
- package/docs/multi-agent-collaboration.md +9 -9
- package/docs/storage-verification-design.md +49 -0
- package/output/native-build/renderer-extension.js +4210 -2273
- package/package.json +11 -9
- package/scripts/codebuddy-migration-test.cjs +1 -1
- package/scripts/collaboration-test.cjs +8 -3
- package/scripts/collaboration-ui-smoke.cjs +40 -26
- package/scripts/core-runtime-test.cjs +9 -1
- package/scripts/integrations-test.cjs +63 -4
- package/scripts/integrations-ui-smoke.cjs +77 -16
- package/scripts/native-protocol-test.cjs +113 -2
- package/scripts/native-sidebar-test.cjs +7 -0
- package/scripts/native-skill-roots-test.cjs +90 -0
- package/scripts/native-ui-smoke.cjs +23 -4
- package/scripts/projector-test.cjs +13 -0
- package/scripts/storage-verification-test.cjs +78 -0
- package/scripts/switch-harness-test.cjs +1 -1
- package/scripts/test-live-mentions.cjs +152 -0
- package/src/main/adapters/antigravity.js +20 -17
- package/src/main/adapters/claude.js +41 -8
- package/src/main/adapters/codebuddy.js +1 -0
- package/src/main/adapters/codex.js +20 -2
- package/src/main/adapters/cursor.js +5 -1
- package/src/main/adapters/dsh.js +8 -1
- package/src/main/adapters/grok.js +4 -1
- package/src/main/adapters/hermes.js +8 -0
- package/src/main/adapters/kiro.js +2 -1
- package/src/main/adapters/managed-mcp.js +15 -2
- package/src/main/adapters/omp.js +11 -0
- package/src/main/adapters/openclaw.js +6 -0
- package/src/main/adapters/opencode.js +5 -1
- package/src/main/adapters/pi.js +5 -1
- package/src/main/adapters/qoder.js +1 -0
- package/src/main/adapters/trae.js +4 -0
- package/src/main/adapters/zcode.js +3 -0
- package/src/main/host/collaboration.js +59 -11
- package/src/main/host/core-session.js +7 -1
- package/src/main/host/handoff-checkpoints.js +8 -1
- package/src/main/host/integrations.js +164 -26
- package/src/main/host/runtime.js +270 -116
- package/src/main/host/store.js +32 -11
- package/src/main/host/thread-storage.js +73 -0
- package/src/main/host/thread-store.js +123 -0
- package/src/main/host/verification-gates.js +118 -0
- package/src/main/native/protocol.js +209 -83
- package/src/main/native/thread-list.js +3 -0
- package/src/main/protocol-core/projector.js +20 -3
- package/src/main/shared-contracts/event.js +2 -1
- package/src/main/shared-contracts/item.js +1 -0
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts +2 -2
- package/src/native-ui/renderer-extension/dist/types/harness-mix-settings.d.ts.map +1 -1
- 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-chatgpt-context.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts +11 -0
- package/src/native-ui/renderer-extension/dist/types/renderer-harness-mentions.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts +29 -4
- package/src/native-ui/renderer-extension/dist/types/renderer-integrations-client.d.ts.map +1 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-model-client.d.ts +5 -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-settings-lifecycle.d.ts +2 -1
- package/src/native-ui/renderer-extension/dist/types/renderer-settings-lifecycle.d.ts.map +1 -1
- 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/integrations-page.d.ts +2 -1
- package/src/native-ui/renderer-extension/dist/types/settings/integrations-page.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/storage-page.d.ts +20 -0
- package/src/native-ui/renderer-extension/dist/types/settings/storage-page.d.ts.map +1 -0
- package/src/native-ui/renderer-extension/dist/types/tsconfig.tsbuildinfo +1 -1
- package/src/native-ui/renderer-extension/src/harness-mix-settings.ts +8 -88
- package/src/native-ui/renderer-extension/src/renderer-binding-probe.ts +150 -168
- package/src/native-ui/renderer-extension/src/renderer-chatgpt-context.ts +5 -1
- package/src/native-ui/renderer-extension/src/renderer-harness-mentions.ts +556 -123
- package/src/native-ui/renderer-extension/src/renderer-integrations-client.ts +36 -3
- package/src/native-ui/renderer-extension/src/renderer-model-client.ts +4 -0
- package/src/native-ui/renderer-extension/src/renderer-settings-lifecycle.ts +12 -0
- package/src/native-ui/renderer-extension/src/settings/icons.ts +6 -3
- package/src/native-ui/renderer-extension/src/settings/integrations-page.ts +1434 -62
- package/src/native-ui/renderer-extension/src/settings/localization.ts +8 -4
- package/src/native-ui/renderer-extension/src/settings/pages.ts +17 -8
- package/src/native-ui/renderer-extension/src/settings/shell.css +103 -8
- package/src/native-ui/renderer-extension/src/settings/storage-page.ts +74 -0
- package/src/native-ui/renderer-extension/test/renderer-chatgpt-context.test.ts +4 -1
- package/src/native-ui/renderer-extension/test/renderer-model-client.test.ts +2 -0
- package/src/native-ui/renderer-extension/test/settings/harness-settings.test.ts +5 -0
- package/src/native-ui/renderer-extension/test/settings/localization.test.ts +1 -1
- package/src/native-ui/renderer-extension/test/settings/pages.test.ts +50 -4
- package/src/native-ui/renderer-extension/test/settings/shell.test.ts +8 -2
package/src/main/host/store.js
CHANGED
|
@@ -3,9 +3,14 @@ const path = require("node:path");
|
|
|
3
3
|
|
|
4
4
|
/** 线程记录的 JSON 持久化:折叠排队保存 + tmp 文件原子替换,避免中间状态内存堆积 */
|
|
5
5
|
class Store {
|
|
6
|
-
constructor(directory, filename = "threads.json") {
|
|
6
|
+
constructor(directory, filename = "threads.json", options = {}) {
|
|
7
7
|
this.directory = directory;
|
|
8
8
|
this.file = path.join(directory, filename);
|
|
9
|
+
this.schemaVersion = options.schemaVersion ?? null;
|
|
10
|
+
this.serialize = options.serialize ?? ((value) => value);
|
|
11
|
+
this.deserialize = options.deserialize ?? ((value) => value);
|
|
12
|
+
this.backup = options.backup === true;
|
|
13
|
+
this.backupPending = false;
|
|
9
14
|
this.writing = false;
|
|
10
15
|
this.pendingData = null;
|
|
11
16
|
this.pendingResolvers = [];
|
|
@@ -21,17 +26,24 @@ class Store {
|
|
|
21
26
|
}
|
|
22
27
|
await fs.mkdir(this.directory, { recursive: true });
|
|
23
28
|
try {
|
|
24
|
-
const
|
|
25
|
-
if (!Array.isArray(
|
|
26
|
-
|
|
29
|
+
const parsed = JSON.parse(await fs.readFile(this.file, "utf8"));
|
|
30
|
+
if (!Array.isArray(parsed) && this.schemaVersion != null && parsed?.schemaVersion !== this.schemaVersion) {
|
|
31
|
+
throw new Error(`Unsupported thread store schema: ${parsed?.schemaVersion ?? 'missing'}; original file preserved`);
|
|
32
|
+
}
|
|
33
|
+
const threads = Array.isArray(parsed) ? parsed : parsed?.threads;
|
|
34
|
+
if (!Array.isArray(threads)) throw new Error('Invalid thread store; original file preserved');
|
|
35
|
+
if (this.backup && Array.isArray(parsed)) this.backupPending = true;
|
|
36
|
+
return threads.map((thread) => this.deserialize(thread));
|
|
27
37
|
} catch (error) {
|
|
28
38
|
if (error.code === 'ENOENT') return [];
|
|
29
39
|
throw error;
|
|
30
40
|
}
|
|
31
41
|
}
|
|
32
42
|
|
|
33
|
-
save(threads) {
|
|
34
|
-
|
|
43
|
+
save(threads) {
|
|
44
|
+
// Capture the serializable projection now. Callers may release heavyweight
|
|
45
|
+
// in-memory checkpoint fields immediately after save() returns.
|
|
46
|
+
this.pendingData = threads.map((value) => this.serialize(value));
|
|
35
47
|
const promise = new Promise((resolve, reject) => {
|
|
36
48
|
this.pendingResolvers.push(resolve);
|
|
37
49
|
this.pendingRejecters.push(reject);
|
|
@@ -52,16 +64,25 @@ class Store {
|
|
|
52
64
|
this.pendingRejecters = [];
|
|
53
65
|
const tmpFile = `${this.file}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
54
66
|
try {
|
|
55
|
-
const
|
|
56
|
-
|
|
57
|
-
|
|
67
|
+
const payload = this.schemaVersion == null
|
|
68
|
+
? data
|
|
69
|
+
: { schemaVersion: this.schemaVersion, savedAt: Date.now(), threads: data };
|
|
70
|
+
const contents = JSON.stringify(payload, null, 2);
|
|
71
|
+
await fs.mkdir(this.directory, { recursive: true });
|
|
72
|
+
await fs.writeFile(tmpFile, contents);
|
|
73
|
+
if (this.backupPending) {
|
|
74
|
+
await fs.copyFile(this.file, `${this.file}.bak`).catch((error) => {
|
|
75
|
+
if (error.code !== 'ENOENT') throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
58
78
|
for (let attempt = 0; ; attempt++) {
|
|
59
|
-
try { await fs.rename(tmpFile, this.file); break; }
|
|
79
|
+
try { await fs.rename(tmpFile, this.file); break; }
|
|
60
80
|
catch (error) {
|
|
61
81
|
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 9) throw error;
|
|
62
82
|
await new Promise(resolve => setTimeout(resolve, 50));
|
|
63
83
|
}
|
|
64
|
-
}
|
|
84
|
+
}
|
|
85
|
+
this.backupPending = false;
|
|
65
86
|
for (const r of resolvers) r();
|
|
66
87
|
} catch (error) {
|
|
67
88
|
await fs.unlink(tmpFile).catch(() => {});
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const { Buffer } = require('node:buffer');
|
|
2
|
+
|
|
3
|
+
const DERIVED_THREAD_FIELDS = [
|
|
4
|
+
'tools', 'pendingApprovals', 'interactions', 'currentTurn', 'coreThread',
|
|
5
|
+
'coreUsage', 'usage', 'capabilities', 'coreEnabled',
|
|
6
|
+
];
|
|
7
|
+
const DERIVED_MESSAGE_FIELDS = [
|
|
8
|
+
'coreTurn', 'coreItems', 'text', 'thinking', 'artifacts', 'at', 'endedAt',
|
|
9
|
+
'streaming', 'stopReason', 'items',
|
|
10
|
+
];
|
|
11
|
+
|
|
12
|
+
function compactThread(thread) {
|
|
13
|
+
// Use structural sharing while serializing. A deep clone here would recreate the
|
|
14
|
+
// same large transient allocation this format is intended to avoid.
|
|
15
|
+
const value = { ...thread };
|
|
16
|
+
if (value.coreState?.version !== 1) return value;
|
|
17
|
+
for (const key of DERIVED_THREAD_FIELDS) delete value[key];
|
|
18
|
+
value.messages = (value.messages ?? []).map((message) => {
|
|
19
|
+
if (message.role !== 'assistant' || !message.coreTurnId) return message;
|
|
20
|
+
const compact = { ...message };
|
|
21
|
+
for (const key of DERIVED_MESSAGE_FIELDS) delete compact[key];
|
|
22
|
+
return compact;
|
|
23
|
+
});
|
|
24
|
+
value.storage = { schemaVersion: 2 };
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function hydrateThread(thread) {
|
|
29
|
+
const value = { ...thread };
|
|
30
|
+
delete value.storage;
|
|
31
|
+
value.messages ??= [];
|
|
32
|
+
value.tools ??= [];
|
|
33
|
+
value.pendingApprovals ??= [];
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function byteLength(value) {
|
|
38
|
+
return Buffer.byteLength(JSON.stringify(value));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function storageProjection(threads) {
|
|
42
|
+
const rows = threads.map((thread) => {
|
|
43
|
+
if (thread._storageStub) return {
|
|
44
|
+
threadId: thread.id, title: thread.title, updatedAt: thread.updatedAt ?? thread.createdAt ?? 0,
|
|
45
|
+
archived: thread.archived === true, messageCount: thread.messageCount ?? null,
|
|
46
|
+
logicalBytes: null, persistedBytes: thread.recordBytes ?? null, savedBytes: null, loaded: false,
|
|
47
|
+
};
|
|
48
|
+
const logicalBytes = byteLength(thread);
|
|
49
|
+
const persistedBytes = byteLength(compactThread(thread));
|
|
50
|
+
return {
|
|
51
|
+
threadId: thread.id,
|
|
52
|
+
title: thread.title,
|
|
53
|
+
updatedAt: thread.updatedAt ?? thread.createdAt ?? 0,
|
|
54
|
+
archived: thread.archived === true,
|
|
55
|
+
messageCount: thread.messages?.length ?? 0,
|
|
56
|
+
logicalBytes,
|
|
57
|
+
persistedBytes,
|
|
58
|
+
savedBytes: Math.max(0, logicalBytes - persistedBytes),
|
|
59
|
+
loaded: true,
|
|
60
|
+
};
|
|
61
|
+
}).sort((a, b) => b.persistedBytes - a.persistedBytes);
|
|
62
|
+
return {
|
|
63
|
+
schemaVersion: 2,
|
|
64
|
+
threadCount: rows.length,
|
|
65
|
+
loadedThreadCount: rows.filter(row => row.loaded).length,
|
|
66
|
+
logicalBytes: rows.reduce((sum, row) => sum + (row.logicalBytes ?? 0), 0),
|
|
67
|
+
persistedBytes: rows.reduce((sum, row) => sum + (row.persistedBytes ?? 0), 0),
|
|
68
|
+
savedBytes: rows.reduce((sum, row) => sum + (row.savedBytes ?? 0), 0),
|
|
69
|
+
largestThreads: rows.filter(row => row.persistedBytes != null).sort((a, b) => b.persistedBytes - a.persistedBytes).slice(0, 20),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
module.exports = { compactThread, hydrateThread, storageProjection };
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
const fsSync = require('node:fs');
|
|
2
|
+
const { promises: fs } = fsSync;
|
|
3
|
+
const path = require('node:path');
|
|
4
|
+
const { compactThread, hydrateThread } = require('./thread-storage');
|
|
5
|
+
|
|
6
|
+
const SCHEMA_VERSION = 3;
|
|
7
|
+
|
|
8
|
+
async function writeAtomic(file, value) {
|
|
9
|
+
const tmp = `${file}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
10
|
+
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
11
|
+
const contents = JSON.stringify(value, null, 2);
|
|
12
|
+
await fs.writeFile(tmp, contents);
|
|
13
|
+
for (let attempt = 0; ; attempt++) {
|
|
14
|
+
try { await fs.rename(tmp, file); return Buffer.byteLength(contents); }
|
|
15
|
+
catch (error) {
|
|
16
|
+
if (!['EPERM', 'EACCES', 'EBUSY'].includes(error.code) || attempt === 9) { await fs.unlink(tmp).catch(() => {}); throw error; }
|
|
17
|
+
await new Promise(resolve => setTimeout(resolve, 50));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function recordName(id) { return `${encodeURIComponent(id)}.json`; }
|
|
23
|
+
|
|
24
|
+
function summarize(thread) {
|
|
25
|
+
const firstUser = (thread.messages ?? []).find(message => message.role === 'user');
|
|
26
|
+
const fields = ['id', 'harnessId', 'title', 'cwd', 'originalCwd', 'nativeSessionId', 'status', 'connectionStatus', 'createdAt', 'updatedAt', 'archived', 'section', 'sectionEnteredAt', 'projectId', 'parentThreadId', 'ephemeral', 'gitInfo', 'workspace', 'isolation', 'harnessChain', 'pendingHandoff', 'options', 'model'];
|
|
27
|
+
const summary = Object.fromEntries(fields.filter(key => thread[key] !== undefined).map(key => [key, thread[key]]));
|
|
28
|
+
summary.preview = firstUser?.text || thread.preview || thread.title || '';
|
|
29
|
+
summary.messageCount = thread.messages?.length ?? thread.messageCount ?? 0;
|
|
30
|
+
if (thread.recordBytes != null) summary.recordBytes = thread.recordBytes;
|
|
31
|
+
return summary;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
class ThreadStore {
|
|
35
|
+
constructor(directory) {
|
|
36
|
+
this.directory = directory;
|
|
37
|
+
this.legacyFile = path.join(directory, 'threads.json');
|
|
38
|
+
this.shardDirectory = path.join(directory, 'threads');
|
|
39
|
+
this.recordsDirectory = path.join(this.shardDirectory, 'records');
|
|
40
|
+
this.indexFile = path.join(this.shardDirectory, 'index.json');
|
|
41
|
+
this.file = this.indexFile;
|
|
42
|
+
this.writing = false;
|
|
43
|
+
this.pending = null;
|
|
44
|
+
this.waiters = [];
|
|
45
|
+
}
|
|
46
|
+
async #readIndex() {
|
|
47
|
+
try {
|
|
48
|
+
const value = JSON.parse(await fs.readFile(this.indexFile, 'utf8'));
|
|
49
|
+
if (value?.schemaVersion !== SCHEMA_VERSION || !Array.isArray(value.threads)) throw new Error('Unsupported sharded thread store; original files preserved');
|
|
50
|
+
return value;
|
|
51
|
+
} catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
52
|
+
}
|
|
53
|
+
async #readLegacy() {
|
|
54
|
+
try {
|
|
55
|
+
const value = JSON.parse(await fs.readFile(this.legacyFile, 'utf8'));
|
|
56
|
+
const threads = Array.isArray(value) ? value : value?.threads;
|
|
57
|
+
if (!Array.isArray(threads)) throw new Error('Invalid legacy thread store; original file preserved');
|
|
58
|
+
return threads.map(hydrateThread);
|
|
59
|
+
} catch (error) { if (error.code === 'ENOENT') return []; throw error; }
|
|
60
|
+
}
|
|
61
|
+
async loadIndex() {
|
|
62
|
+
const index = await this.#readIndex();
|
|
63
|
+
if (!index) return this.#readLegacy();
|
|
64
|
+
return index.threads.map(summary => ({
|
|
65
|
+
...summary,
|
|
66
|
+
status: summary.status === 'working' ? 'interrupted' : summary.status === 'opening' ? 'ready' : summary.status,
|
|
67
|
+
connectionStatus: 'ready',
|
|
68
|
+
messages: [], tools: [], pendingApprovals: [], _storageStub: true,
|
|
69
|
+
}));
|
|
70
|
+
}
|
|
71
|
+
async load() {
|
|
72
|
+
const index = await this.#readIndex();
|
|
73
|
+
if (!index) return this.#readLegacy();
|
|
74
|
+
return Promise.all(index.threads.map(async summary => hydrateThread(JSON.parse(await fs.readFile(path.join(this.recordsDirectory, recordName(summary.id)), 'utf8')))));
|
|
75
|
+
}
|
|
76
|
+
hydrateInto(thread) {
|
|
77
|
+
if (!thread?._storageStub) return thread;
|
|
78
|
+
const record = hydrateThread(JSON.parse(fsSync.readFileSync(path.join(this.recordsDirectory, recordName(thread.id)), 'utf8')));
|
|
79
|
+
for (const key of Object.keys(thread)) delete thread[key];
|
|
80
|
+
Object.assign(thread, record);
|
|
81
|
+
return thread;
|
|
82
|
+
}
|
|
83
|
+
save(threads) {
|
|
84
|
+
this.pending = {
|
|
85
|
+
index: { schemaVersion: SCHEMA_VERSION, savedAt: Date.now(), threads: threads.map(summarize) },
|
|
86
|
+
records: threads.filter(thread => !thread._storageStub).map(thread => ({ id: thread.id, value: compactThread(thread) })),
|
|
87
|
+
};
|
|
88
|
+
const promise = new Promise((resolve, reject) => this.waiters.push({ resolve, reject }));
|
|
89
|
+
void this.#drain();
|
|
90
|
+
return promise;
|
|
91
|
+
}
|
|
92
|
+
async #drain() {
|
|
93
|
+
if (this.writing) return;
|
|
94
|
+
this.writing = true;
|
|
95
|
+
while (this.pending) {
|
|
96
|
+
const snapshot = this.pending; this.pending = null;
|
|
97
|
+
const waiters = this.waiters; this.waiters = [];
|
|
98
|
+
try {
|
|
99
|
+
await fs.mkdir(this.recordsDirectory, { recursive: true });
|
|
100
|
+
if (snapshot.records.length && !fsSync.existsSync(`${this.legacyFile}.bak`)) await fs.copyFile(this.legacyFile, `${this.legacyFile}.bak`).catch(error => { if (error.code !== 'ENOENT') throw error; });
|
|
101
|
+
for (const record of snapshot.records) {
|
|
102
|
+
const bytes = await writeAtomic(path.join(this.recordsDirectory, recordName(record.id)), record.value);
|
|
103
|
+
const summary = snapshot.index.threads.find(thread => thread.id === record.id);
|
|
104
|
+
if (summary) summary.recordBytes = bytes;
|
|
105
|
+
}
|
|
106
|
+
await writeAtomic(this.indexFile, snapshot.index);
|
|
107
|
+
for (const waiter of waiters) waiter.resolve();
|
|
108
|
+
} catch (error) { for (const waiter of waiters) waiter.reject(error); }
|
|
109
|
+
}
|
|
110
|
+
this.writing = false;
|
|
111
|
+
}
|
|
112
|
+
remove(threadId) { return fs.unlink(path.join(this.recordsDirectory, recordName(threadId))).catch(error => { if (error.code !== 'ENOENT') throw error; }); }
|
|
113
|
+
async inspectFiles() {
|
|
114
|
+
const index = await fs.stat(this.indexFile).catch(() => null);
|
|
115
|
+
const legacy = await fs.stat(this.legacyFile).catch(() => null);
|
|
116
|
+
const records = await fs.readdir(this.recordsDirectory, { withFileTypes: true }).catch(() => []);
|
|
117
|
+
let recordBytes = 0;
|
|
118
|
+
for (const entry of records) if (entry.isFile() && entry.name.endsWith('.json')) recordBytes += (await fs.stat(path.join(this.recordsDirectory, entry.name))).size;
|
|
119
|
+
return { storageSchemaVersion: SCHEMA_VERSION, indexFile: this.indexFile, indexBytes: index?.size ?? 0, recordsDirectory: this.recordsDirectory, recordCount: records.filter(entry => entry.isFile() && entry.name.endsWith('.json')).length, recordBytes, legacyFile: this.legacyFile, legacyBytes: legacy?.size ?? 0 };
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
module.exports = { ThreadStore, summarize, SCHEMA_VERSION };
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
const { spawn } = require('node:child_process');
|
|
2
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
3
|
+
const { redactText } = require('../native/redact');
|
|
4
|
+
const { terminateTree } = require('../native/process-utils');
|
|
5
|
+
|
|
6
|
+
const MODES = new Set(['off', 'advisory', 'required']);
|
|
7
|
+
const DEFAULT_CHECKS = Object.freeze({
|
|
8
|
+
turnCompleted: true,
|
|
9
|
+
noPendingInteractions: true,
|
|
10
|
+
noRunningTools: true,
|
|
11
|
+
noReviewErrors: true,
|
|
12
|
+
cleanWorkingTree: false,
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
function normalizePolicy(input = {}) {
|
|
16
|
+
const mode = MODES.has(input.mode) ? input.mode : 'off';
|
|
17
|
+
const checks = { ...DEFAULT_CHECKS };
|
|
18
|
+
for (const key of Object.keys(checks)) if (typeof input.checks?.[key] === 'boolean') checks[key] = input.checks[key];
|
|
19
|
+
const commands = (Array.isArray(input.commands) ? input.commands : []).slice(0, 8).map((entry, index) => {
|
|
20
|
+
const command = typeof entry === 'string' ? entry : entry?.command;
|
|
21
|
+
if (typeof command !== 'string' || !command.trim() || command.length > 512) throw new Error(`Invalid verification command at index ${index}`);
|
|
22
|
+
if (redactText(command) !== command) throw new Error(`Verification command at index ${index} contains credential-like data; use the native process environment instead`);
|
|
23
|
+
const timeoutMs = typeof entry === 'object' && Number.isSafeInteger(entry.timeoutMs)
|
|
24
|
+
? Math.min(600_000, Math.max(1_000, entry.timeoutMs)) : 120_000;
|
|
25
|
+
return { id: typeof entry === 'object' && entry.id ? String(entry.id).slice(0, 64) : `command-${index + 1}`, command: command.trim(), timeoutMs };
|
|
26
|
+
});
|
|
27
|
+
return { schemaVersion: 1, mode, autoRun: input.autoRun === true, checks, commands };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function runCommand(command, cwd, timeoutMs) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const startedAt = Date.now();
|
|
33
|
+
const child = spawn(command, { cwd, shell: true, windowsHide: true, env: process.env });
|
|
34
|
+
let stdout = '', stderr = '', timedOut = false;
|
|
35
|
+
const append = (current, chunk) => (current + chunk.toString()).slice(-16_000);
|
|
36
|
+
child.stdout?.on('data', chunk => { stdout = append(stdout, chunk); });
|
|
37
|
+
child.stderr?.on('data', chunk => { stderr = append(stderr, chunk); });
|
|
38
|
+
const timer = setTimeout(() => { timedOut = true; void terminateTree(child.pid); }, timeoutMs);
|
|
39
|
+
child.on('error', error => { stderr = append(stderr, error.message); });
|
|
40
|
+
child.on('close', (code, signal) => {
|
|
41
|
+
clearTimeout(timer);
|
|
42
|
+
resolve({ command, status: !timedOut && code === 0 ? 'passed' : 'failed', exitCode: code, signal, timedOut, stdout: redactText(stdout), stderr: redactText(stderr), durationMs: Date.now() - startedAt });
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
class VerificationGates {
|
|
48
|
+
constructor(runtime) { this.runtime = runtime; this.running = new Map(); }
|
|
49
|
+
policy(thread) { return normalizePolicy(thread.verificationGate?.policy); }
|
|
50
|
+
configure(thread, policy) {
|
|
51
|
+
const normalized = normalizePolicy(policy);
|
|
52
|
+
thread.verificationGate = { policy: normalized, latestReport: null };
|
|
53
|
+
return normalized;
|
|
54
|
+
}
|
|
55
|
+
inspect(thread) {
|
|
56
|
+
const policy = this.policy(thread);
|
|
57
|
+
const report = thread.verificationGate?.latestReport ?? null;
|
|
58
|
+
const latestTurnId = this.runtime.execution.lastTurn(thread.id)?.id ?? null;
|
|
59
|
+
const current = report !== null && report.turnId === latestTurnId;
|
|
60
|
+
return { policy, report, current, satisfied: policy.mode !== 'required' || (current && report?.status === 'passed') };
|
|
61
|
+
}
|
|
62
|
+
assertSatisfied(thread, operation) {
|
|
63
|
+
const state = this.inspect(thread);
|
|
64
|
+
if (!state.satisfied) throw new Error(`验证门禁未通过,不能${operation};请先运行验证`);
|
|
65
|
+
}
|
|
66
|
+
invalidate(thread, reason = 'A newer turn changed the task state') {
|
|
67
|
+
const gate = thread.verificationGate;
|
|
68
|
+
const report = gate?.latestReport;
|
|
69
|
+
if (!report || report.status === 'stale') return null;
|
|
70
|
+
const invalidatedAt = Date.now();
|
|
71
|
+
const stale = { ...report, status: 'stale', invalidatedAt, invalidationReason: reason };
|
|
72
|
+
thread.verificationGate = { policy: this.policy(thread), latestReport: stale };
|
|
73
|
+
if (stale.turnId && this.runtime.core.getTurn(stale.turnId)) {
|
|
74
|
+
this.runtime.core.dispatch({ threadId: thread.id, turnId: stale.turnId, type: 'verification.updated', payload: { report: stale }, timestamp: invalidatedAt });
|
|
75
|
+
this.runtime.execution.sync(thread);
|
|
76
|
+
}
|
|
77
|
+
return stale;
|
|
78
|
+
}
|
|
79
|
+
run(thread, { turnId } = {}) {
|
|
80
|
+
if (this.running.has(thread.id)) return this.running.get(thread.id);
|
|
81
|
+
const task = this.#run(thread, turnId).finally(() => this.running.delete(thread.id));
|
|
82
|
+
this.running.set(thread.id, task);
|
|
83
|
+
return task;
|
|
84
|
+
}
|
|
85
|
+
async #run(thread, turnId) {
|
|
86
|
+
const policy = this.policy(thread);
|
|
87
|
+
const startedAt = Date.now();
|
|
88
|
+
const turn = turnId ? this.runtime.core.getTurn(turnId) : this.runtime.execution.lastTurn(thread.id);
|
|
89
|
+
const items = turn ? this.runtime.core.getItemsForTurn(turn.id) : [];
|
|
90
|
+
const checks = [];
|
|
91
|
+
const add = (id, enabled, passed, detail) => { if (enabled) checks.push({ id, status: passed ? 'passed' : 'failed', detail }); };
|
|
92
|
+
add('turnCompleted', policy.checks.turnCompleted, turn?.status === 'completed', turn ? `Turn status: ${turn.status}` : 'No completed Turn');
|
|
93
|
+
add('noPendingInteractions', policy.checks.noPendingInteractions, !(thread.interactions?.length), `${thread.interactions?.length ?? 0} pending interaction(s)`);
|
|
94
|
+
add('noRunningTools', policy.checks.noRunningTools, !items.some(item => item.type === 'tool_call' && item.state === 'running'), 'No running tools');
|
|
95
|
+
add('noReviewErrors', policy.checks.noReviewErrors, !(thread.messages ?? []).some(message => message.coreTurnId === turn?.id && message.reviewError), 'Workspace review settled without error');
|
|
96
|
+
if (policy.checks.cleanWorkingTree) {
|
|
97
|
+
const git = await runCommand('git status --porcelain', thread.cwd, 30_000);
|
|
98
|
+
checks.push({ id: 'cleanWorkingTree', status: git.status === 'passed' && !git.stdout.trim() ? 'passed' : 'failed', detail: git.status === 'passed' ? (git.stdout.trim() || 'Working tree clean') : (git.stderr || 'git status failed') });
|
|
99
|
+
}
|
|
100
|
+
const commands = [];
|
|
101
|
+
for (const entry of policy.commands) commands.push({ id: entry.id, ...await runCommand(entry.command, thread.cwd, entry.timeoutMs) });
|
|
102
|
+
const failed = [...checks, ...commands].some(result => result.status !== 'passed');
|
|
103
|
+
const report = {
|
|
104
|
+
schemaVersion: 1, reportId: `verification_${randomUUID()}`, threadId: thread.id,
|
|
105
|
+
turnId: turn?.id ?? null, mode: policy.mode, status: failed ? 'failed' : 'passed',
|
|
106
|
+
startedAt, completedAt: Date.now(), checks, commands,
|
|
107
|
+
};
|
|
108
|
+
report.contentDigest = createHash('sha256').update(JSON.stringify(report)).digest('hex');
|
|
109
|
+
thread.verificationGate = { policy, latestReport: report };
|
|
110
|
+
if (turn) {
|
|
111
|
+
this.runtime.core.dispatch({ threadId: thread.id, turnId: turn.id, type: 'verification.updated', payload: { report }, timestamp: report.completedAt });
|
|
112
|
+
this.runtime.execution.sync(thread);
|
|
113
|
+
}
|
|
114
|
+
return report;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
module.exports = { VerificationGates, normalizePolicy, DEFAULT_CHECKS };
|