dsh-kingdom 2.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ASSETS_NOTICE.md +22 -22
- package/README.md +246 -243
- package/THIRD_PARTY_NOTICES.md +92 -92
- package/docs/DSH-KINGDOM-3.0-ANNOUNCEMENT.md +13 -0
- package/docs/V2.0-RELEASE-NOTES.md +30 -30
- package/docs/V2.0-USER-GUIDE.md +74 -74
- package/docs/V3.0-RELEASE-NOTES.md +21 -0
- package/docs/V3.0-USER-GUIDE.md +33 -0
- package/docs/V3.1-RELEASE-NOTES.md +76 -0
- package/docs/V3.2-RELEASE-NOTES.md +103 -0
- package/lib/core/binding.js +167 -60
- package/lib/core/binding.js.map +1 -1
- package/lib/core/db.js +582 -546
- package/lib/core/db.js.map +1 -1
- package/lib/core/delivery-ack.js +1356 -0
- package/lib/core/delivery-ack.js.map +1 -0
- package/lib/core/delivery-change.js +1237 -0
- package/lib/core/delivery-change.js.map +1 -0
- package/lib/core/owner-binding-intent.js +14 -25
- package/lib/core/owner-binding-intent.js.map +1 -1
- package/lib/core/owner-window.js +698 -23
- package/lib/core/owner-window.js.map +1 -1
- package/lib/core/task-service.js +144 -5
- package/lib/core/task-service.js.map +1 -1
- package/lib/core/territory.js +43 -9
- package/lib/core/territory.js.map +1 -1
- package/lib/gui/assets/characters/chancellor-idle.svg +67 -67
- package/lib/gui/assets/characters/chancellor-sleeping.svg +67 -67
- package/lib/gui/assets/characters/chancellor-thinking.svg +67 -67
- package/lib/gui/assets/characters/chancellor-working.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-idle.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-sleeping.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-thinking.svg +67 -67
- package/lib/gui/assets/characters/knight-redraw-r1-working.svg +67 -67
- package/lib/gui/assets/characters/supervisor-idle.svg +67 -67
- package/lib/gui/assets/characters/supervisor-sleeping.svg +67 -67
- package/lib/gui/assets/characters/supervisor-thinking.svg +67 -67
- package/lib/gui/assets/characters/supervisor-working.svg +67 -67
- package/lib/gui/console-app.js +7 -1
- package/lib/gui/console-app.js.map +1 -1
- package/lib/gui/contract.js +6 -0
- package/lib/gui/contract.js.map +1 -1
- package/lib/gui/control-contract.js +3 -2
- package/lib/gui/control-contract.js.map +1 -1
- package/lib/gui/owner-control.js +114 -4
- package/lib/gui/owner-control.js.map +1 -1
- package/lib/gui/owner-ui.js +645 -183
- package/lib/gui/owner-ui.js.map +1 -1
- package/lib/gui/server.js +109 -109
- package/lib/gui/snapshot.js +127 -4
- package/lib/gui/snapshot.js.map +1 -1
- package/lib/gui/visual-assets.js +37 -21
- package/lib/gui/visual-assets.js.map +1 -1
- package/lib/gui/visual-system.js +269 -269
- package/lib/gui/workbench-ui.js +549 -136
- package/lib/gui/workbench-ui.js.map +1 -1
- package/lib/gui/workbench.js +133 -0
- package/lib/gui/workbench.js.map +1 -1
- package/lib/index.js +392 -40
- package/lib/index.js.map +1 -1
- package/lib/types/core/binding.d.ts +29 -0
- package/lib/types/core/db.d.ts +14 -0
- package/lib/types/core/delivery-ack.d.ts +688 -0
- package/lib/types/core/delivery-change.d.ts +381 -0
- package/lib/types/core/owner-binding-intent.d.ts +9 -1
- package/lib/types/core/owner-window.d.ts +237 -3
- package/lib/types/core/task-service.d.ts +22 -0
- package/lib/types/core/territory.d.ts +18 -3
- package/lib/types/gui/contract.d.ts +162 -1
- package/lib/types/gui/owner-control.d.ts +61 -1
- package/lib/types/gui/snapshot.d.ts +8 -0
- package/lib/types/gui/visual-assets.d.ts +16 -0
- package/lib/types/gui/workbench.d.ts +47 -1
- package/lib/types/index.d.ts +60 -1
- package/lib/types/worker/governed-executor.d.ts +15 -0
- package/lib/worker/governed-executor.js +21 -0
- package/lib/worker/governed-executor.js.map +1 -1
- package/package.json +80 -75
|
@@ -0,0 +1,1237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Owner 交付改动证据 —— 有界本地前后快照与内容寻址差异。
|
|
3
|
+
*
|
|
4
|
+
* ## 这份证据是什么,不是什么
|
|
5
|
+
*
|
|
6
|
+
* - 在执行**实际副作用前**冻结一份有界、脱敏的工作区基线(PRE),在同一
|
|
7
|
+
* Task/attempt 的 terminal / WorkerResult 落账**前**再冻结一份 POST;两者相减
|
|
8
|
+
* 得到每文件差异,写入专用本机持久证据目录,并只把 manifest/hash 与有界元数据
|
|
9
|
+
* 记入既有事件账本。
|
|
10
|
+
* - 它证明的是「某 Task/attempt 窗口内观察到这些仓库相对路径发生了变化」。它
|
|
11
|
+
* **不证明 Git 作者身份**,也不证明是哪个 Worker 或进程写的,更不能替代主管
|
|
12
|
+
* 审查或 Owner 验收。面板必须把它标注为「主管确认的改动证据」而不是作者证据。
|
|
13
|
+
* - 未跟踪文件、任务前已脏文件、并行变化、超限/二进制/符号链接都按各自真实标签
|
|
14
|
+
* 呈现;超限或无法判定时 `coverage.complete = false`,绝不静默说成完整覆盖。
|
|
15
|
+
* - 候选路径**只**由 Git 自己枚举(`ls-files --cached --others --exclude-standard`
|
|
16
|
+
* 加 `diff --name-only HEAD`),因此 `.gitignore` 排除的路径根本不会成为候选,
|
|
17
|
+
* 正文也不会被打开或持久复制;不新增任何通用目录扫描器或秘密检测框架。
|
|
18
|
+
* VCS 忽略内容、证据目录自身、以及任何无法枚举的情形都如实记为部分覆盖。
|
|
19
|
+
*
|
|
20
|
+
* ## 边界
|
|
21
|
+
*
|
|
22
|
+
* - 不引入任何 schema 变更:新事实写进既有 events 账本,正文写文件系统。
|
|
23
|
+
* - 不读任意请求路径:读回只接受内容寻址的 manifest id / entry id,并重验 hash。
|
|
24
|
+
* - 采集失败不阻断执行:返回 `ok:false`,调用方据此不产生任何改动链接。
|
|
25
|
+
* - 两侧正文都按同一个有界预算保留(每个快照各自计算),因此增删片段可见;
|
|
26
|
+
* 预算耗尽时只保留摘要并如实标为部分覆盖。
|
|
27
|
+
*/
|
|
28
|
+
import { createHash } from 'node:crypto';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { closeSync, existsSync, lstatSync, mkdirSync, mkdtempSync, openSync, readFileSync, readSync, realpathSync, renameSync, rmSync, statSync, writeFileSync, } from 'node:fs';
|
|
31
|
+
import { tmpdir } from 'node:os';
|
|
32
|
+
import { isAbsolute, join, relative, sep } from 'node:path';
|
|
33
|
+
import { CHANGE_EVIDENCE_LABEL, CHANGE_EVIDENCE_NOTE, redactDeliveryText, validateRepoRelativePath, } from './delivery-ack.js';
|
|
34
|
+
import { kingdomRoot } from '../paths.js';
|
|
35
|
+
export { CHANGE_EVIDENCE_LABEL, CHANGE_EVIDENCE_NOTE };
|
|
36
|
+
export const DELIVERY_CHANGE_SNAPSHOT_EVENT_TYPE = 'DELIVERY_CHANGE_SNAPSHOT';
|
|
37
|
+
export const DELIVERY_CHANGE_MANIFEST_VERSION = 'KingdomDeliveryChangeManifest/v1';
|
|
38
|
+
export const DELIVERY_CHANGE_SNAPSHOT_VERSION = 'KingdomDeliveryWorkspaceSnapshot/v1';
|
|
39
|
+
export const CHANGE_EVIDENCE_DRIFT = '证据正文缺失或哈希漂移,不能作为可信改动定位。';
|
|
40
|
+
export const DEFAULT_DELIVERY_CHANGE_BOUNDS = Object.freeze({
|
|
41
|
+
maxFiles: 2000, maxFileBytes: 262_144, maxHashBytes: 33_554_432, maxReadBytes: 33_554_432, maxStoredBytes: 4_194_304,
|
|
42
|
+
maxDiffLines: 160, maxEntries: 200,
|
|
43
|
+
maxCandidateBytes: 4_194_304, maxIgnoredBytes: 262_144,
|
|
44
|
+
});
|
|
45
|
+
/** VCS 忽略样本要保留多少条路径名(只保留路径名,绝不保留正文)。 */
|
|
46
|
+
const IGNORED_SAMPLE_LIMIT = 32;
|
|
47
|
+
const HASH = /^[0-9a-f]{64}$/u;
|
|
48
|
+
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
|
|
49
|
+
/**
|
|
50
|
+
* 交付改动证据的专用本机持久目录:<dshHome>/kingdom/delivery-evidence。
|
|
51
|
+
*
|
|
52
|
+
* 只保存有界、脱敏、内容寻址的快照与差异;不接触 kingdom.db 本身。
|
|
53
|
+
* `DSH_KINGDOM_EVIDENCE_ROOT` 只用于把测试指向一次性临时目录。
|
|
54
|
+
*/
|
|
55
|
+
export function deliveryEvidenceRoot() {
|
|
56
|
+
const env = process.env.DSH_KINGDOM_EVIDENCE_ROOT;
|
|
57
|
+
if (env && env.trim().length > 0)
|
|
58
|
+
return env.trim();
|
|
59
|
+
return join(kingdomRoot(), 'delivery-evidence');
|
|
60
|
+
}
|
|
61
|
+
export function deliveryEvidencePaths(evidenceRoot) {
|
|
62
|
+
const root = evidenceRoot && evidenceRoot.trim() ? evidenceRoot.trim() : deliveryEvidenceRoot();
|
|
63
|
+
return { root, snapshots: join(root, 'snapshots'), blobs: join(root, 'blobs'), manifests: join(root, 'manifests') };
|
|
64
|
+
}
|
|
65
|
+
let atomicCounter = 0;
|
|
66
|
+
function writeAtomic(file, body) {
|
|
67
|
+
const tmp = `${file}.tmp-${process.pid}-${atomicCounter++}`;
|
|
68
|
+
writeFileSync(tmp, body, 'utf8');
|
|
69
|
+
renameSync(tmp, file);
|
|
70
|
+
}
|
|
71
|
+
function writeContentAddressed(directory, body) {
|
|
72
|
+
const digest = sha256(body);
|
|
73
|
+
mkdirSync(directory, { recursive: true });
|
|
74
|
+
const file = join(directory, digest);
|
|
75
|
+
if (!existsSync(file))
|
|
76
|
+
writeAtomic(file, body);
|
|
77
|
+
return digest;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* 只在目录内按内容摘要读取。任何路径拼接或非摘要输入都直接拒绝,
|
|
81
|
+
* 保证读取端不能借证据 id 读取任意本机文件。
|
|
82
|
+
*/
|
|
83
|
+
function readContentAddressed(directory, digest) {
|
|
84
|
+
if (!HASH.test(digest))
|
|
85
|
+
return null;
|
|
86
|
+
const file = join(directory, digest);
|
|
87
|
+
try {
|
|
88
|
+
const body = readFileSync(file, 'utf8');
|
|
89
|
+
return sha256(body) === digest ? body : null;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 按「规范化正文摘要」命名的文件读取。
|
|
97
|
+
*
|
|
98
|
+
* manifest 与 snapshot 的文件名是**规范化正文**(去掉自身 id 字段)的摘要,
|
|
99
|
+
* 因此不能用完整文件内容的摘要去比对文件名;调用方必须用自己的规范化函数
|
|
100
|
+
* 重新计算摘要并核对,才是真正的 hash 重验。
|
|
101
|
+
*/
|
|
102
|
+
function readNamedRecord(directory, digest) {
|
|
103
|
+
if (!HASH.test(digest))
|
|
104
|
+
return null;
|
|
105
|
+
try {
|
|
106
|
+
return readFileSync(join(directory, `${digest}.json`), 'utf8');
|
|
107
|
+
}
|
|
108
|
+
catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
export function readChangeBlob(evidenceRoot, storedHash) {
|
|
113
|
+
return readContentAddressed(deliveryEvidencePaths(evidenceRoot).blobs, storedHash);
|
|
114
|
+
}
|
|
115
|
+
const GIT_PROBE_CEILING = 33_554_432;
|
|
116
|
+
/**
|
|
117
|
+
* 运行一次有界的 git 探测。
|
|
118
|
+
*
|
|
119
|
+
* 本机沙箱会拒绝父/子进程之间的匿名管道,因此 stdout 重定向到真实文件再读回
|
|
120
|
+
* (而不是 `encoding: 'utf8'` 的管道)。stderr **必须与 stdout 分开**:Git 会把
|
|
121
|
+
* `LF will be replaced by CRLF` 之类的 warning 写到 stderr,两者混在同一文件里
|
|
122
|
+
* 会污染路径清单,让真实改动被判成「无差异」。超时或非零退出都只表示
|
|
123
|
+
* `UNAVAILABLE`,不会伪造 VCS 事实。输出超过上限时**带 `truncated` 返回**,
|
|
124
|
+
* 调用方必须据此报失败或部分覆盖,不能把截断当成「没有改动」。
|
|
125
|
+
*/
|
|
126
|
+
function runGit(cwd, args, options) {
|
|
127
|
+
let directory = null;
|
|
128
|
+
let outFd = null;
|
|
129
|
+
let errFd = null;
|
|
130
|
+
try {
|
|
131
|
+
directory = mkdtempSync(join(tmpdir(), 'kingdom-git-'));
|
|
132
|
+
const out = join(directory, 'out.txt');
|
|
133
|
+
const err = join(directory, 'err.txt');
|
|
134
|
+
outFd = openSync(out, 'w');
|
|
135
|
+
errFd = openSync(err, 'w');
|
|
136
|
+
const result = spawnSync('git', ['-C', cwd, ...args], { stdio: ['ignore', outFd, errFd], timeout: options.timeoutMs ?? 5000, windowsHide: true });
|
|
137
|
+
closeSync(outFd);
|
|
138
|
+
outFd = null;
|
|
139
|
+
closeSync(errFd);
|
|
140
|
+
errFd = null;
|
|
141
|
+
if (result.error || result.status !== 0)
|
|
142
|
+
return { ok: false, stdout: '', truncated: false };
|
|
143
|
+
const size = statSync(out).size;
|
|
144
|
+
if (size > options.maxBytes)
|
|
145
|
+
return { ok: true, stdout: '', truncated: true };
|
|
146
|
+
return { ok: true, stdout: readFileSync(out, 'utf8'), truncated: false };
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
return { ok: false, stdout: '', truncated: false };
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
if (outFd !== null) {
|
|
153
|
+
try {
|
|
154
|
+
closeSync(outFd);
|
|
155
|
+
}
|
|
156
|
+
catch { /* already closed */ }
|
|
157
|
+
}
|
|
158
|
+
if (errFd !== null) {
|
|
159
|
+
try {
|
|
160
|
+
closeSync(errFd);
|
|
161
|
+
}
|
|
162
|
+
catch { /* already closed */ }
|
|
163
|
+
}
|
|
164
|
+
if (directory)
|
|
165
|
+
rmSync(directory, { recursive: true, force: true });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
/** 把 Git 输出的仓库相对路径拆成规范化的非空路径列表(忽略空项与非法路径)。 */
|
|
169
|
+
function parsePathList(value) {
|
|
170
|
+
const paths = [];
|
|
171
|
+
for (const raw of value.split('\u0000')) {
|
|
172
|
+
const safe = validateRepoRelativePath(raw);
|
|
173
|
+
if (safe)
|
|
174
|
+
paths.push(safe);
|
|
175
|
+
}
|
|
176
|
+
return paths;
|
|
177
|
+
}
|
|
178
|
+
function observeVcs(workspacePath) {
|
|
179
|
+
const unavailable = (reason, scope = 'UNKNOWN') => ({
|
|
180
|
+
repo: { vcs: 'UNAVAILABLE', identity: null, head: null, scope, reason },
|
|
181
|
+
repoRoot: null, workspacePrefix: '',
|
|
182
|
+
});
|
|
183
|
+
const top = runGit(workspacePath, ['rev-parse', '--show-toplevel'], { maxBytes: 65_536 });
|
|
184
|
+
if (!top.ok)
|
|
185
|
+
return unavailable('工作区不是可读的 Git 仓库或 git 不可用。');
|
|
186
|
+
const repoRoot = (() => {
|
|
187
|
+
try {
|
|
188
|
+
return realpathSync.native(top.stdout.trim());
|
|
189
|
+
}
|
|
190
|
+
catch {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
})();
|
|
194
|
+
if (!repoRoot)
|
|
195
|
+
return unavailable('Git 仓库根目录无法解析。');
|
|
196
|
+
const workspace = (() => {
|
|
197
|
+
try {
|
|
198
|
+
return realpathSync.native(workspacePath);
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
})();
|
|
204
|
+
const scope = workspace === repoRoot
|
|
205
|
+
? 'REPO_ROOT'
|
|
206
|
+
: workspace && within(repoRoot, workspace) ? 'WORKSPACE_SUBDIR' : 'OUTSIDE_REPO';
|
|
207
|
+
if (scope === 'OUTSIDE_REPO')
|
|
208
|
+
return unavailable('领地工作区不在该 Git 仓库内,VCS 基准不可用。', scope);
|
|
209
|
+
const workspacePrefix = scope === 'WORKSPACE_SUBDIR' && workspace
|
|
210
|
+
? relative(repoRoot, workspace).split(sep).join('/')
|
|
211
|
+
: '';
|
|
212
|
+
const head = runGit(workspacePath, ['rev-parse', 'HEAD'], { maxBytes: 65_536 });
|
|
213
|
+
// 脏/未跟踪分类由候选枚举的同一批 `ls-files` 输出给出,此处不再单独跑 `git status`。
|
|
214
|
+
return {
|
|
215
|
+
repo: { vcs: 'GIT', identity: sha256(repoRoot).slice(0, 32), head: head.ok ? head.stdout.trim() || null : null, scope, reason: null },
|
|
216
|
+
repoRoot, workspacePrefix,
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function within(root, candidate) {
|
|
220
|
+
const rel = relative(root, candidate);
|
|
221
|
+
return rel === '' || (!rel.startsWith(`..${sep}`) && rel !== '..' && !isAbsolute(rel));
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* 由 Git 自己枚举候选路径:已跟踪文件 ∪ 未跟踪但**未被 ignore** 的文件。
|
|
225
|
+
*
|
|
226
|
+
* `--exclude-standard` 让 Git 自己结算**全部** ignore 规则(工作区**内部**任意深度的
|
|
227
|
+
* `.gitignore`、工作区到仓库根之间每一层 `.gitignore`、`.git/info/exclude` 与
|
|
228
|
+
* `core.excludesFile`)并直接决定候选集合:被忽略的私有文件既不会出现在这里,也不会
|
|
229
|
+
* 在后续被打开正文或持久复制。被排除路径身份的枚举同样由 Git 自己给出并有字节上限,
|
|
230
|
+
* 不靠「看起来有没有规则」的预判。整个候选集合的读取量受 `maxReadBytes` 限制,超出即
|
|
231
|
+
* 如实记为部分覆盖,因此不会把整仓正文读进内存或写进证据目录。任一命令失败或输出超限
|
|
232
|
+
* 都返回失败——宁可没有改动证据,也不截断成「没有改动」;有意排除(证据目录自身)单独
|
|
233
|
+
* 计数并保持部分覆盖。
|
|
234
|
+
*/
|
|
235
|
+
function enumerateCandidates(workspacePath, vcs, bounds, evidenceRoot) {
|
|
236
|
+
if (vcs.repo.vcs !== 'GIT' || !vcs.repoRoot) {
|
|
237
|
+
return { ok: false, code: 'VCS_ENUMERATION_UNAVAILABLE',
|
|
238
|
+
reason: '没有可用的 Git 基准,无法界定本次窗口的候选路径;不采集工作区正文。' };
|
|
239
|
+
}
|
|
240
|
+
// 所有 Git 调用都从**仓库根**执行,pathspec 与输出因此都是**根相对**的:
|
|
241
|
+
// 子目录 Territory(workspace 是仓库子目录)下若从 workspacePath 执行,Git 会把
|
|
242
|
+
// 根相对 pathspec 当成工作区相对路径,即使真实改动存在也会枚举为空。
|
|
243
|
+
const gitCwd = vcs.repoRoot;
|
|
244
|
+
const pathspec = vcs.workspacePrefix ? [vcs.workspacePrefix] : ['.'];
|
|
245
|
+
const tracked = runGit(gitCwd, ['ls-files', '-z', '--cached', '--', ...pathspec], { maxBytes: bounds.maxCandidateBytes });
|
|
246
|
+
if (!tracked.ok || tracked.truncated) {
|
|
247
|
+
return { ok: false, code: 'VCS_ENUMERATION_FAILED',
|
|
248
|
+
reason: 'Git 已跟踪清单不可读或输出超限;不为该 attempt 生成任何改动链接。' };
|
|
249
|
+
}
|
|
250
|
+
const others = runGit(gitCwd, ['ls-files', '-z', '--others', '--exclude-standard', '--', ...pathspec], { maxBytes: bounds.maxCandidateBytes });
|
|
251
|
+
if (!others.ok || others.truncated) {
|
|
252
|
+
return { ok: false, code: 'VCS_ENUMERATION_FAILED',
|
|
253
|
+
reason: 'Git 未跟踪候选枚举失败或输出超限;不为该 attempt 生成任何改动链接。' };
|
|
254
|
+
}
|
|
255
|
+
const diff = runGit(gitCwd, ['diff', '--name-only', '-z', 'HEAD', '--', ...pathspec], { maxBytes: bounds.maxCandidateBytes });
|
|
256
|
+
if (!diff.ok || diff.truncated) {
|
|
257
|
+
return { ok: false, code: 'VCS_ENUMERATION_FAILED',
|
|
258
|
+
reason: 'Git 与 HEAD 的差异清单不可读或超限;不为该 attempt 生成任何改动链接。' };
|
|
259
|
+
}
|
|
260
|
+
// 被 ignore 排除的路径身份**一律**交给 Git 自己枚举(`--exclude-standard`),不做
|
|
261
|
+
// 「本仓库/本工作区看起来有没有规则」的预判:规则可能来自工作区**内部**任意深度的
|
|
262
|
+
// `.gitignore`,也可能来自 linked worktree 的 `.git/worktrees/<id>/info/exclude`;
|
|
263
|
+
// 任何一层漏检都会让 `ignoredComplete` 假报为 true,从而把只改忽略规则、内容一字未改
|
|
264
|
+
// 的旧文件写成可确认的假新增/假删除。枚举本身有字节上限:一旦失败或超限,
|
|
265
|
+
// `ignoredComplete` 即为 false——不得默认「未忽略」。
|
|
266
|
+
const ignored = runGit(gitCwd, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', ...pathspec], { maxBytes: bounds.maxIgnoredBytes });
|
|
267
|
+
const ignoredComplete = ignored.ok && !ignored.truncated;
|
|
268
|
+
const evidencePrefix = evidencePrefixFor(vcs.repoRoot, workspacePath, evidenceRoot);
|
|
269
|
+
const trackedPaths = new Set(tracked.stdout.split('\u0000'));
|
|
270
|
+
const untrackedPaths = new Set(others.stdout.split('\u0000'));
|
|
271
|
+
const changedPaths = new Set(diff.stdout.split('\u0000'));
|
|
272
|
+
const paths = new Set();
|
|
273
|
+
const dirty = new Map();
|
|
274
|
+
let evidenceRootExcluded = 0;
|
|
275
|
+
for (const entry of [...new Set([...trackedPaths, ...untrackedPaths, ...changedPaths])]) {
|
|
276
|
+
const mapped = mapRepoRelativeToWorkspace(entry, vcs.workspacePrefix);
|
|
277
|
+
if (mapped === null)
|
|
278
|
+
continue;
|
|
279
|
+
if (evidencePrefix !== null && (mapped === evidencePrefix || mapped.startsWith(`${evidencePrefix}/`))) {
|
|
280
|
+
evidenceRootExcluded += 1;
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
paths.add(mapped);
|
|
284
|
+
if (dirty.has(mapped))
|
|
285
|
+
continue;
|
|
286
|
+
// 未跟踪(且未被 ignore)优先于「相对 HEAD 有差异」:它的基线本来就不在版本库内。
|
|
287
|
+
if (untrackedPaths.has(entry) && !trackedPaths.has(entry))
|
|
288
|
+
dirty.set(mapped, '??');
|
|
289
|
+
else if (changedPaths.has(entry))
|
|
290
|
+
dirty.set(mapped, ' M');
|
|
291
|
+
}
|
|
292
|
+
const ignoredPaths = parsePathList(ignored.stdout);
|
|
293
|
+
const ignoredIdentities = new Set();
|
|
294
|
+
for (const entry of ignoredPaths) {
|
|
295
|
+
const mapped = mapRepoRelativeToWorkspace(entry, vcs.workspacePrefix);
|
|
296
|
+
if (mapped !== null)
|
|
297
|
+
ignoredIdentities.add(mapped);
|
|
298
|
+
}
|
|
299
|
+
return {
|
|
300
|
+
ok: true,
|
|
301
|
+
value: {
|
|
302
|
+
paths: [...paths].sort((a, b) => a.localeCompare(b)),
|
|
303
|
+
evidencePrefix,
|
|
304
|
+
ignoredPaths: ignoredPaths.length,
|
|
305
|
+
ignoredSamples: ignoredPaths.slice(0, IGNORED_SAMPLE_LIMIT),
|
|
306
|
+
ignoredIdentities: [...ignoredIdentities].sort((a, b) => a.localeCompare(b)),
|
|
307
|
+
ignoredComplete,
|
|
308
|
+
evidenceRootExcluded,
|
|
309
|
+
dirty,
|
|
310
|
+
},
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
/** 证据目录相对工作区的路径前缀;不在工作区内(或就是工作区本身)时为 null。 */
|
|
314
|
+
function evidencePrefixFor(repoRoot, workspacePath, evidenceRoot) {
|
|
315
|
+
let resolvedRoot;
|
|
316
|
+
let resolvedEvidence;
|
|
317
|
+
try {
|
|
318
|
+
resolvedRoot = realpathSync.native(repoRoot);
|
|
319
|
+
resolvedEvidence = realpathSync.native(evidenceRoot);
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
if (!within(resolvedRoot, resolvedEvidence))
|
|
325
|
+
return null;
|
|
326
|
+
const relativeToWorkspace = relative(workspacePath, resolvedEvidence).split(sep).join('/');
|
|
327
|
+
if (!relativeToWorkspace || relativeToWorkspace === '.')
|
|
328
|
+
return null;
|
|
329
|
+
if (relativeToWorkspace.startsWith('..') || isAbsolute(relativeToWorkspace))
|
|
330
|
+
return null;
|
|
331
|
+
return relativeToWorkspace;
|
|
332
|
+
}
|
|
333
|
+
/** 把仓库相对路径映射成工作区相对路径;不在工作区内或非法时返回 null。 */
|
|
334
|
+
function mapRepoRelativeToWorkspace(path, workspacePrefix) {
|
|
335
|
+
if (!workspacePrefix)
|
|
336
|
+
return validateRepoRelativePath(path);
|
|
337
|
+
if (path === workspacePrefix)
|
|
338
|
+
return null;
|
|
339
|
+
if (!path.startsWith(`${workspacePrefix}/`))
|
|
340
|
+
return null;
|
|
341
|
+
return validateRepoRelativePath(path.slice(workspacePrefix.length + 1));
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* 把工作区相对路径还原成**仓库相对路径**(合同要求公开展示路径)。
|
|
345
|
+
*
|
|
346
|
+
* 工作区就是仓库根(`workspacePrefix` 为空)时两者相同;工作区是仓库子目录时
|
|
347
|
+
* 补回该前缀,例如子目录领地里的 `app.ts` → `src/app.ts`。函数只做前缀拼接并
|
|
348
|
+
* 复用既有仓库相对路径校验,不引入本机绝对路径,也不新增路径解析层。
|
|
349
|
+
*/
|
|
350
|
+
function repoRelativePath(path, workspacePrefix) {
|
|
351
|
+
return validateRepoRelativePath(workspacePrefix ? `${workspacePrefix}/${path}` : path)
|
|
352
|
+
?? validateRepoRelativePath(path)
|
|
353
|
+
?? path;
|
|
354
|
+
}
|
|
355
|
+
function isBinary(buffer) {
|
|
356
|
+
const limit = Math.min(buffer.length, 8192);
|
|
357
|
+
for (let index = 0; index < limit; index += 1)
|
|
358
|
+
if (buffer[index] === 0)
|
|
359
|
+
return true;
|
|
360
|
+
return false;
|
|
361
|
+
}
|
|
362
|
+
/** PEM 私钥/证书正文标记:命中即不保留正文(现有交付文本脱敏遮不住 base64 主体)。 */
|
|
363
|
+
const PEM_MARKER = /-----BEGIN [A-Z0-9 ]*(?:PRIVATE KEY|CERTIFICATE|PUBLIC KEY)[A-Z0-9 ]*-----/u;
|
|
364
|
+
/**
|
|
365
|
+
* 冻结一份有界工作区快照。**永不抛出**:采集失败只返回 `ok:false`,
|
|
366
|
+
* 调用方据此不产生任何改动链接,而不是把失败伪装成空差异。
|
|
367
|
+
*
|
|
368
|
+
* 候选路径全部来自 Git(见 {@link enumerateCandidates}):没有可用的 Git 基准、
|
|
369
|
+
* 枚举失败或输出超限时直接返回失败,绝不退化成「扫描任意工作区正文」。
|
|
370
|
+
*/
|
|
371
|
+
export function captureWorkspaceSnapshot(input) {
|
|
372
|
+
try {
|
|
373
|
+
const bounds = { ...DEFAULT_DELIVERY_CHANGE_BOUNDS, ...(input.bounds ?? {}) };
|
|
374
|
+
const paths = deliveryEvidencePaths(input.evidenceRoot);
|
|
375
|
+
let workspace;
|
|
376
|
+
try {
|
|
377
|
+
workspace = realpathSync.native(input.workspacePath);
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
return { ok: false, code: 'WORKSPACE_UNREADABLE', reason: '领地工作区不存在或不可解析,未采集快照。' };
|
|
381
|
+
}
|
|
382
|
+
let stat;
|
|
383
|
+
try {
|
|
384
|
+
stat = statSync(workspace);
|
|
385
|
+
}
|
|
386
|
+
catch {
|
|
387
|
+
return { ok: false, code: 'WORKSPACE_UNREADABLE', reason: '领地工作区不可读取,未采集快照。' };
|
|
388
|
+
}
|
|
389
|
+
if (!stat.isDirectory())
|
|
390
|
+
return { ok: false, code: 'WORKSPACE_NOT_DIRECTORY', reason: '领地工作区不是目录,未采集快照。' };
|
|
391
|
+
const vcs = observeVcs(workspace);
|
|
392
|
+
const candidates = enumerateCandidates(workspace, vcs, bounds, paths.root);
|
|
393
|
+
if (!candidates.ok)
|
|
394
|
+
return { ok: false, code: candidates.code, reason: candidates.reason };
|
|
395
|
+
const scope = candidates.value;
|
|
396
|
+
const coverage = { complete: true, observedFiles: 0, retainedFiles: 0, omittedByLimit: 0,
|
|
397
|
+
omittedUnreadable: 0, skippedSymlinks: 0, skippedOversize: 0, binaryFiles: 0, sensitiveBodiesSkipped: 0,
|
|
398
|
+
ignoredPaths: scope.ignoredPaths, evidenceRootExcluded: scope.evidenceRootExcluded, reasons: [], ignoredSamples: scope.ignoredSamples };
|
|
399
|
+
if (scope.ignoredPaths > 0) {
|
|
400
|
+
// 被 ignore 规则排除的私有内容从未成为候选、正文也从未被打开:这是有意的
|
|
401
|
+
// 范围限定,但必须如实报成部分覆盖,不能悄悄说成「已完整检查」。
|
|
402
|
+
coverage.complete = false;
|
|
403
|
+
coverage.reasons.push('IGNORED_CONTENT_EXCLUDED');
|
|
404
|
+
}
|
|
405
|
+
if (!scope.ignoredComplete) {
|
|
406
|
+
// 被 ignore 排除的路径身份没能完整确认:不得默认「未忽略」,否则增删判定会把
|
|
407
|
+
// 一个其实只是被 ignore 的旧文件写成窗口内新增/删除。如实记部分覆盖。
|
|
408
|
+
coverage.complete = false;
|
|
409
|
+
coverage.reasons.push('IGNORED_SCOPE_UNCONFIRMED');
|
|
410
|
+
}
|
|
411
|
+
if (scope.evidenceRootExcluded > 0) {
|
|
412
|
+
coverage.complete = false;
|
|
413
|
+
coverage.reasons.push('EVIDENCE_ROOT_EXCLUDED');
|
|
414
|
+
}
|
|
415
|
+
const files = [];
|
|
416
|
+
let storedBytes = 0;
|
|
417
|
+
let readBytes = 0;
|
|
418
|
+
/**
|
|
419
|
+
* 预算或读取上限耗尽时只能记「本快照没有读取/枚举到该候选」,**不能**记成
|
|
420
|
+
* `ABSENT`:文件未进入快照不等于它当时不存在。因此这里给出的是
|
|
421
|
+
* `readability='UNREADABLE'` 的有界占位观察,差异端据此只能得到 `UNKNOWN`。
|
|
422
|
+
*/
|
|
423
|
+
const omittedObservation = (relativePath) => ({
|
|
424
|
+
path: relativePath, repoPath: repoRelativePath(relativePath, vcs.workspacePrefix),
|
|
425
|
+
size: 0, binary: false, symlink: false, oversize: false, sensitive: false,
|
|
426
|
+
retained: false, rawHash: null, storedHash: null, vcs: scope.dirty.get(relativePath) ?? null,
|
|
427
|
+
readability: 'UNREADABLE',
|
|
428
|
+
});
|
|
429
|
+
for (const relativePath of scope.paths) {
|
|
430
|
+
if (files.length >= bounds.maxFiles) {
|
|
431
|
+
coverage.complete = false;
|
|
432
|
+
coverage.omittedByLimit += 1;
|
|
433
|
+
if (!coverage.reasons.includes('FILE_LIMIT_REACHED'))
|
|
434
|
+
coverage.reasons.push('FILE_LIMIT_REACHED');
|
|
435
|
+
files.push(omittedObservation(relativePath));
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
438
|
+
if (readBytes >= bounds.maxReadBytes) {
|
|
439
|
+
coverage.complete = false;
|
|
440
|
+
coverage.omittedByLimit += 1;
|
|
441
|
+
if (!coverage.reasons.includes('READ_BUDGET_REACHED'))
|
|
442
|
+
coverage.reasons.push('READ_BUDGET_REACHED');
|
|
443
|
+
files.push(omittedObservation(relativePath));
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const observation = observeFile(join(workspace, relativePath), relativePath, vcs.workspacePrefix, paths.blobs, bounds, coverage, () => storedBytes, (bytes) => { storedBytes += bytes; }, (bytes) => { readBytes += bytes; }, scope.dirty.get(relativePath) ?? null);
|
|
447
|
+
files.push(observation);
|
|
448
|
+
}
|
|
449
|
+
const snapshot = {
|
|
450
|
+
version: DELIVERY_CHANGE_SNAPSHOT_VERSION,
|
|
451
|
+
snapshotId: '',
|
|
452
|
+
phase: input.phase,
|
|
453
|
+
taskId: input.taskId,
|
|
454
|
+
attemptNo: input.attemptNo,
|
|
455
|
+
territoryId: input.territoryId,
|
|
456
|
+
workspaceKey: sha256(workspace),
|
|
457
|
+
executionId: input.executionId ?? null,
|
|
458
|
+
leaseId: input.leaseId ?? null,
|
|
459
|
+
dispatchId: input.dispatchId ?? null,
|
|
460
|
+
repo: vcs.repo,
|
|
461
|
+
capturedAt: input.now ?? new Date().toISOString(),
|
|
462
|
+
files,
|
|
463
|
+
coverage,
|
|
464
|
+
candidateScope: {
|
|
465
|
+
kind: 'VCS_CANDIDATES',
|
|
466
|
+
candidateCount: scope.paths.length,
|
|
467
|
+
pathspec: vcs.workspacePrefix || '.',
|
|
468
|
+
evidencePrefix: scope.evidencePrefix,
|
|
469
|
+
evidenceRootExcluded: scope.evidenceRootExcluded,
|
|
470
|
+
},
|
|
471
|
+
// 「本快照没有该路径」只有在候选集合完整可信时才能当成「它当时不存在」。
|
|
472
|
+
// 读不出来的候选仍然出现在快照里(`UNREADABLE` + 有界摘要),属于「有观察、
|
|
473
|
+
// 无结论」,由差异端单独转 `UNKNOWN`,因此不计入这里的枚举完整性。
|
|
474
|
+
//
|
|
475
|
+
// `.gitignore` 排除内容与证据目录自身**不**在此列:它们是候选集合的**有意**范围
|
|
476
|
+
// 限定,候选枚举本身没有被截断。把有意排除当截断会让未忽略路径的真实新增/删除
|
|
477
|
+
// 全部降级为 `UNKNOWN`,与「候选集合已被完整枚举」的事实矛盾。
|
|
478
|
+
enumerationComplete: coverage.omittedByLimit === 0,
|
|
479
|
+
// 被 ignore 排除的路径身份(只含路径名)与其完整性:增删判定据此区分「当时被
|
|
480
|
+
// ignore」与「当时确实不存在」。这份集合无法完整确认时,`ignoredPathsComplete`
|
|
481
|
+
// 为 false,差异端必须 fail-closed,不得默认「未忽略」。
|
|
482
|
+
ignoredPathIdentities: scope.ignoredIdentities,
|
|
483
|
+
ignoredPathsComplete: scope.ignoredComplete,
|
|
484
|
+
};
|
|
485
|
+
const body = JSON.stringify({ ...snapshot, snapshotId: '' });
|
|
486
|
+
snapshot.snapshotId = sha256(body);
|
|
487
|
+
const snapshotFile = join(paths.snapshots, `${snapshot.snapshotId}.json`);
|
|
488
|
+
mkdirSync(paths.snapshots, { recursive: true });
|
|
489
|
+
if (!existsSync(snapshotFile))
|
|
490
|
+
writeAtomic(snapshotFile, JSON.stringify(snapshot));
|
|
491
|
+
return { ok: true, snapshot, snapshotFile };
|
|
492
|
+
}
|
|
493
|
+
catch (error) {
|
|
494
|
+
return { ok: false, code: 'SNAPSHOT_CAPTURE_FAILED',
|
|
495
|
+
reason: `快照采集失败,未产生改动证据:${error instanceof Error ? error.message.slice(0, 200) : '未知错误'}` };
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
/** 有界读取一个文件;调用方已按 `maxHashBytes` 限定大小。 */
|
|
499
|
+
function readBounded(absolute, limit) {
|
|
500
|
+
const fd = openSync(absolute, 'r');
|
|
501
|
+
try {
|
|
502
|
+
const buffer = Buffer.alloc(limit);
|
|
503
|
+
const bytes = readSync(fd, buffer, 0, limit, 0);
|
|
504
|
+
return buffer.subarray(0, bytes);
|
|
505
|
+
}
|
|
506
|
+
finally {
|
|
507
|
+
closeSync(fd);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
function observeFile(absolute, relativePath, workspacePrefix, blobDirectory, bounds, coverage, storedBytes, addStoredBytes, addReadBytes, vcsCode) {
|
|
511
|
+
const base = { path: relativePath, repoPath: repoRelativePath(relativePath, workspacePrefix),
|
|
512
|
+
size: 0, binary: false, symlink: false, oversize: false,
|
|
513
|
+
sensitive: false, retained: false, rawHash: null, storedHash: null, vcs: vcsCode, readability: 'UNREADABLE' };
|
|
514
|
+
let stat;
|
|
515
|
+
try {
|
|
516
|
+
stat = lstatSync(absolute);
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
// 采集瞬间文件已不在:这只表示「该时刻不存在」,不是读取失败。
|
|
520
|
+
return { ...base, readability: 'ABSENT' };
|
|
521
|
+
}
|
|
522
|
+
if (stat.isSymbolicLink()) {
|
|
523
|
+
coverage.skippedSymlinks += 1;
|
|
524
|
+
coverage.reasons.push('SYMLINK_SKIPPED');
|
|
525
|
+
return { ...base, symlink: true, readability: 'SYMLINK' };
|
|
526
|
+
}
|
|
527
|
+
if (!stat.isFile())
|
|
528
|
+
return { ...base, readability: 'ABSENT' };
|
|
529
|
+
coverage.observedFiles += 1;
|
|
530
|
+
const size = stat.size;
|
|
531
|
+
const oversize = size > bounds.maxFileBytes;
|
|
532
|
+
if (oversize) {
|
|
533
|
+
coverage.skippedOversize += 1;
|
|
534
|
+
coverage.reasons.push('FILE_SIZE_LIMIT_REACHED');
|
|
535
|
+
}
|
|
536
|
+
// 完整内容摘要必须覆盖整个文件:带大小前缀的「首段摘要」会把只在尾部变化的
|
|
537
|
+
// 超限文件错判成「未变化」。超过可完整比较上限时不再猜测,直接 `UNREADABLE`。
|
|
538
|
+
if (size > bounds.maxHashBytes) {
|
|
539
|
+
coverage.omittedUnreadable += 1;
|
|
540
|
+
coverage.complete = false;
|
|
541
|
+
if (!coverage.reasons.includes('CONTENT_HASH_LIMIT_REACHED'))
|
|
542
|
+
coverage.reasons.push('CONTENT_HASH_LIMIT_REACHED');
|
|
543
|
+
return { ...base, size, oversize, readability: 'UNREADABLE' };
|
|
544
|
+
}
|
|
545
|
+
let buffer;
|
|
546
|
+
try {
|
|
547
|
+
buffer = readBounded(absolute, size);
|
|
548
|
+
}
|
|
549
|
+
catch {
|
|
550
|
+
coverage.omittedUnreadable += 1;
|
|
551
|
+
coverage.complete = false;
|
|
552
|
+
coverage.reasons.push('FILE_UNREADABLE');
|
|
553
|
+
return { ...base, size, oversize, readability: 'UNREADABLE' };
|
|
554
|
+
}
|
|
555
|
+
addReadBytes(buffer.length);
|
|
556
|
+
// 读回字节数不足(采集瞬间被截断/并发改写)时完整摘要不可得:同样不猜。
|
|
557
|
+
const rawHash = buffer.length === size ? sha256(buffer) : null;
|
|
558
|
+
const binary = isBinary(buffer);
|
|
559
|
+
if (binary)
|
|
560
|
+
coverage.binaryFiles += 1;
|
|
561
|
+
if (rawHash === null) {
|
|
562
|
+
coverage.omittedUnreadable += 1;
|
|
563
|
+
coverage.complete = false;
|
|
564
|
+
if (!coverage.reasons.includes('CONTENT_HASH_INCOMPLETE'))
|
|
565
|
+
coverage.reasons.push('CONTENT_HASH_INCOMPLETE');
|
|
566
|
+
return { ...base, size, oversize, binary, readability: 'UNREADABLE' };
|
|
567
|
+
}
|
|
568
|
+
const observation = { ...base, size, binary, oversize, rawHash, readability: 'READABLE' };
|
|
569
|
+
if (binary || blobDirectory === null)
|
|
570
|
+
return observation;
|
|
571
|
+
// PEM 主体无法被文本脱敏遮住:必须在**原始缓冲**上、写 blob 之前判定。
|
|
572
|
+
// 若先脱敏再测标记,`-----BEGIN PRIVATE KEY-----` 已被替换,主体会被持久写入。
|
|
573
|
+
if (PEM_MARKER.test(buffer.toString('utf8'))) {
|
|
574
|
+
coverage.sensitiveBodiesSkipped += 1;
|
|
575
|
+
coverage.complete = false;
|
|
576
|
+
coverage.reasons.push('SENSITIVE_BODY_NOT_RETAINED');
|
|
577
|
+
return { ...observation, sensitive: true };
|
|
578
|
+
}
|
|
579
|
+
if (oversize)
|
|
580
|
+
return observation;
|
|
581
|
+
const text = redactDeliveryText(buffer.toString('utf8'));
|
|
582
|
+
if (storedBytes() + buffer.length > bounds.maxStoredBytes) {
|
|
583
|
+
coverage.reasons.push('STORED_BODY_BUDGET_REACHED');
|
|
584
|
+
coverage.complete = false;
|
|
585
|
+
return observation;
|
|
586
|
+
}
|
|
587
|
+
const storedHash = writeContentAddressed(blobDirectory, text);
|
|
588
|
+
addStoredBytes(Buffer.byteLength(text));
|
|
589
|
+
coverage.retainedFiles += 1;
|
|
590
|
+
return { ...observation, retained: true, storedHash };
|
|
591
|
+
}
|
|
592
|
+
// ── 差异 manifest ────────────────────────────────────────────────────
|
|
593
|
+
export function readWorkspaceSnapshot(evidenceRoot, snapshotId) {
|
|
594
|
+
const body = readNamedRecord(deliveryEvidencePaths(evidenceRoot).snapshots, snapshotId);
|
|
595
|
+
if (body === null)
|
|
596
|
+
return null;
|
|
597
|
+
try {
|
|
598
|
+
const parsed = JSON.parse(body);
|
|
599
|
+
if (parsed.version !== DELIVERY_CHANGE_SNAPSHOT_VERSION || parsed.snapshotId !== snapshotId)
|
|
600
|
+
return null;
|
|
601
|
+
if (!Array.isArray(parsed.files))
|
|
602
|
+
return null;
|
|
603
|
+
// 重新计算规范化正文摘要,文件被替换或字段被篡改时不再可信。
|
|
604
|
+
if (sha256(JSON.stringify({ ...parsed, snapshotId: '' })) !== snapshotId)
|
|
605
|
+
return null;
|
|
606
|
+
// 该快照形状尚未发布:每个观察必须自带经过校验的 canonical `repoPath`,且 ignore
|
|
607
|
+
// 路径身份必须完整声明(含「无法完整确认」这一事实)。缺少任一字段都按不可用处理,
|
|
608
|
+
// 绝不按工作区相对 `path` 回填——那会把未发布的开发期形状固化成 alias/回退合同。
|
|
609
|
+
for (const file of parsed.files) {
|
|
610
|
+
if (typeof file.repoPath !== 'string' || validateRepoRelativePath(file.repoPath) === null)
|
|
611
|
+
return null;
|
|
612
|
+
}
|
|
613
|
+
if (typeof parsed.ignoredPathsComplete !== 'boolean' || !Array.isArray(parsed.ignoredPathIdentities))
|
|
614
|
+
return null;
|
|
615
|
+
return parsed;
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
/**
|
|
622
|
+
* manifest 的规范化正文:`evidenceId` 本身不参与摘要,其余字段顺序固定。
|
|
623
|
+
* 采集、落盘与读回重验必须使用同一个函数,否则「hash 重验」没有意义。
|
|
624
|
+
*/
|
|
625
|
+
function manifestBody(manifest) {
|
|
626
|
+
return {
|
|
627
|
+
version: manifest.version, taskId: manifest.taskId, attemptNo: manifest.attemptNo, territoryId: manifest.territoryId,
|
|
628
|
+
workspaceKey: manifest.workspaceKey, repo: manifest.repo, executionId: manifest.executionId, leaseId: manifest.leaseId,
|
|
629
|
+
dispatchId: manifest.dispatchId, pre: manifest.pre, post: manifest.post, entries: manifest.entries,
|
|
630
|
+
coverage: manifest.coverage, note: manifest.note, createdAt: manifest.createdAt,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
export function deliveryChangeEvidenceId(manifest) {
|
|
634
|
+
return sha256(JSON.stringify(manifestBody(manifest)));
|
|
635
|
+
}
|
|
636
|
+
/** 采集执行前的有界基线;失败返回 null(调用方不得伪造空差异)。 */
|
|
637
|
+
export function capturePreChangeSnapshot(input) {
|
|
638
|
+
const captured = captureWorkspaceSnapshot({ ...input, phase: 'PRE' });
|
|
639
|
+
return captured.ok ? captured.snapshot : null;
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* terminal / WorkerResult 之前采集 POST 并生成差异 manifest。
|
|
643
|
+
*
|
|
644
|
+
* 采集或写入任一步失败都返回 null:宁可没有改动定位,也不产生不可信的链接。
|
|
645
|
+
*/
|
|
646
|
+
export function capturePostAndBuildChangeManifest(input) {
|
|
647
|
+
const captured = captureWorkspaceSnapshot({
|
|
648
|
+
workspacePath: input.workspacePath,
|
|
649
|
+
taskId: input.taskId,
|
|
650
|
+
attemptNo: input.attemptNo,
|
|
651
|
+
territoryId: input.territoryId,
|
|
652
|
+
phase: 'POST',
|
|
653
|
+
executionId: input.executionId ?? null,
|
|
654
|
+
leaseId: input.leaseId ?? null,
|
|
655
|
+
dispatchId: input.dispatchId ?? null,
|
|
656
|
+
evidenceRoot: input.evidenceRoot,
|
|
657
|
+
bounds: input.bounds,
|
|
658
|
+
});
|
|
659
|
+
if (!captured.ok)
|
|
660
|
+
return null;
|
|
661
|
+
if (input.pre.taskId !== input.taskId || input.pre.attemptNo !== input.attemptNo)
|
|
662
|
+
return null;
|
|
663
|
+
const built = buildDeliveryChangeManifest({
|
|
664
|
+
evidenceRoot: input.evidenceRoot,
|
|
665
|
+
pre: input.pre,
|
|
666
|
+
post: captured.snapshot,
|
|
667
|
+
bounds: input.bounds,
|
|
668
|
+
});
|
|
669
|
+
return built.ok ? built.manifest : null;
|
|
670
|
+
}
|
|
671
|
+
/** 由 PRE/POST 两份快照生成有界、脱敏的差异 manifest,并原子写入内容寻址文件。 */
|
|
672
|
+
export function buildDeliveryChangeManifest(input) {
|
|
673
|
+
try {
|
|
674
|
+
const bounds = { ...DEFAULT_DELIVERY_CHANGE_BOUNDS, ...(input.bounds ?? {}) };
|
|
675
|
+
const paths = deliveryEvidencePaths(input.evidenceRoot);
|
|
676
|
+
const pre = input.pre;
|
|
677
|
+
const post = input.post;
|
|
678
|
+
const before = new Map(pre.files.map(file => [file.path, file]));
|
|
679
|
+
const after = new Map(post.files.map(file => [file.path, file]));
|
|
680
|
+
const allPaths = [...new Set([...before.keys(), ...after.keys()])].sort((a, b) => a.localeCompare(b));
|
|
681
|
+
const entries = [];
|
|
682
|
+
let omitted = 0;
|
|
683
|
+
const bothEnumComplete = pre.enumerationComplete && post.enumerationComplete;
|
|
684
|
+
// 增删判定还要看「另一时点缺少该路径」是否可能只是被 ignore:只有两时点都被
|
|
685
|
+
// ignore 排除的路径身份完整可信,才能把缺项读成「当时确实不存在」。任一时刻的
|
|
686
|
+
// ignore 身份无法完整确认时,整条增删判定必须 fail-closed。
|
|
687
|
+
const ignoreKnowledgeComplete = pre.ignoredPathsComplete === true && post.ignoredPathsComplete === true;
|
|
688
|
+
const preIgnored = new Set(pre.ignoredPathIdentities);
|
|
689
|
+
const postIgnored = new Set(post.ignoredPathIdentities);
|
|
690
|
+
// 工作区相对仓库根的前缀只解析一次;循环内每次都扫快照既慢又无意义。
|
|
691
|
+
const workspacePrefix = workspacePrefixOf(pre) || workspacePrefixOf(post);
|
|
692
|
+
for (const path of allPaths) {
|
|
693
|
+
if (entries.length >= bounds.maxEntries) {
|
|
694
|
+
omitted += 1;
|
|
695
|
+
continue;
|
|
696
|
+
}
|
|
697
|
+
const entry = buildEntry(path, before.get(path) ?? null, after.get(path) ?? null, bothEnumComplete, ignoreKnowledgeComplete, preIgnored.has(path), postIgnored.has(path), paths.root, bounds, workspacePrefix);
|
|
698
|
+
if (entry)
|
|
699
|
+
entries.push(entry);
|
|
700
|
+
}
|
|
701
|
+
const reasons = [...new Set([...pre.coverage.reasons, ...post.coverage.reasons])];
|
|
702
|
+
if (omitted > 0 && !reasons.includes('ENTRY_LIMIT_REACHED'))
|
|
703
|
+
reasons.push('ENTRY_LIMIT_REACHED');
|
|
704
|
+
// 任一侧枚举不完整时,「某路径不在该侧快照里」不能读成「它当时不存在」:
|
|
705
|
+
// 增删判定整体降级为不可确认,并如实记入顶层原因。
|
|
706
|
+
if (!pre.enumerationComplete || !post.enumerationComplete) {
|
|
707
|
+
if (!reasons.includes('ENUMERATION_INCOMPLETE'))
|
|
708
|
+
reasons.push('ENUMERATION_INCOMPLETE');
|
|
709
|
+
}
|
|
710
|
+
if (!ignoreKnowledgeComplete && !reasons.includes('IGNORED_SCOPE_UNCONFIRMED')) {
|
|
711
|
+
reasons.push('IGNORED_SCOPE_UNCONFIRMED');
|
|
712
|
+
}
|
|
713
|
+
// 二进制、超限、符号链接或基线正文未保留时,条目只能给状态与摘要,
|
|
714
|
+
// 行级覆盖并不完整:必须如实标成部分覆盖,不能静默说成完整。
|
|
715
|
+
let entriesComplete = true;
|
|
716
|
+
const markIncomplete = (reason) => {
|
|
717
|
+
entriesComplete = false;
|
|
718
|
+
if (!reasons.includes(reason))
|
|
719
|
+
reasons.push(reason);
|
|
720
|
+
};
|
|
721
|
+
for (const entry of entries) {
|
|
722
|
+
if (entry.binary)
|
|
723
|
+
markIncomplete('BINARY_BODY_NOT_COMPARED_BY_LINES');
|
|
724
|
+
if (entry.baselineBodyUnavailable)
|
|
725
|
+
markIncomplete('BASELINE_BODY_NOT_RETAINED');
|
|
726
|
+
if (entry.labels.includes('SYMLINK_NOT_FOLLOWED'))
|
|
727
|
+
markIncomplete('SYMLINK_NOT_FOLLOWED');
|
|
728
|
+
if (entry.reasonCode === 'SYMLINK_CHANGE_UNPROVEN')
|
|
729
|
+
markIncomplete('SYMLINK_CHANGE_UNPROVEN');
|
|
730
|
+
if (entry.labels.includes('CHANGE_NOT_PROVEN'))
|
|
731
|
+
markIncomplete('CHANGE_NOT_PROVEN');
|
|
732
|
+
if (entry.reasonCode === 'FILE_OVER_SIZE_LIMIT')
|
|
733
|
+
markIncomplete('FILE_OVER_SIZE_LIMIT');
|
|
734
|
+
// 另一时点没有观察结果、或命中 PEM 标记而未保留正文:都不是完整比较。
|
|
735
|
+
if (entry.reasonCode === 'EXISTENCE_NOT_OBSERVED')
|
|
736
|
+
markIncomplete('EXISTENCE_NOT_OBSERVED');
|
|
737
|
+
if (entry.reasonCode === 'IGNORED_TRANSITION_UNPROVEN')
|
|
738
|
+
markIncomplete('IGNORED_TRANSITION_UNPROVEN');
|
|
739
|
+
if (entry.labels.includes('SENSITIVE_BODY_NOT_RETAINED'))
|
|
740
|
+
markIncomplete('SENSITIVE_BODY_NOT_RETAINED');
|
|
741
|
+
}
|
|
742
|
+
// 没有 VCS 基准时无法区分「任务前已脏」与「本次窗口内的改动」,
|
|
743
|
+
// 因此不能声称完整覆盖,也不能把窗口观测说成任务改动。
|
|
744
|
+
const manifestRepo = pre.repo.vcs === 'GIT' ? pre.repo : post.repo;
|
|
745
|
+
if (manifestRepo.vcs !== 'GIT')
|
|
746
|
+
markIncomplete('NO_VCS_BASELINE');
|
|
747
|
+
const coverage = {
|
|
748
|
+
complete: pre.coverage.complete && post.coverage.complete && omitted === 0 && entriesComplete,
|
|
749
|
+
reasons,
|
|
750
|
+
entryCount: entries.length,
|
|
751
|
+
omitted,
|
|
752
|
+
};
|
|
753
|
+
const manifest = {
|
|
754
|
+
version: DELIVERY_CHANGE_MANIFEST_VERSION,
|
|
755
|
+
evidenceId: '',
|
|
756
|
+
taskId: pre.taskId,
|
|
757
|
+
attemptNo: pre.attemptNo,
|
|
758
|
+
territoryId: pre.territoryId,
|
|
759
|
+
workspaceKey: pre.workspaceKey,
|
|
760
|
+
repo: pre.repo.vcs === 'GIT' ? pre.repo : post.repo,
|
|
761
|
+
executionId: post.executionId ?? pre.executionId ?? null,
|
|
762
|
+
leaseId: post.leaseId ?? pre.leaseId ?? null,
|
|
763
|
+
dispatchId: post.dispatchId ?? pre.dispatchId ?? null,
|
|
764
|
+
pre: { snapshotId: pre.snapshotId, capturedAt: pre.capturedAt, coverage: pre.coverage },
|
|
765
|
+
post: { snapshotId: post.snapshotId, capturedAt: post.capturedAt, coverage: post.coverage },
|
|
766
|
+
entries,
|
|
767
|
+
coverage,
|
|
768
|
+
note: CHANGE_EVIDENCE_NOTE,
|
|
769
|
+
createdAt: input.now ?? new Date().toISOString(),
|
|
770
|
+
};
|
|
771
|
+
manifest.evidenceId = deliveryChangeEvidenceId(manifest);
|
|
772
|
+
mkdirSync(paths.manifests, { recursive: true });
|
|
773
|
+
const file = join(paths.manifests, `${manifest.evidenceId}.json`);
|
|
774
|
+
if (!existsSync(file))
|
|
775
|
+
writeAtomic(file, JSON.stringify(manifest));
|
|
776
|
+
return { ok: true, manifest, file };
|
|
777
|
+
}
|
|
778
|
+
catch (error) {
|
|
779
|
+
return { ok: false, code: 'MANIFEST_BUILD_FAILED',
|
|
780
|
+
reason: `差异 manifest 写入失败:${error instanceof Error ? error.message.slice(0, 200) : '未知错误'}` };
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
/** 该时刻是否**真正读到过**这个路径的内容:只有读到过才能证明它当时存在。 */
|
|
784
|
+
function existedAt(file) {
|
|
785
|
+
return Boolean(file && file.readability === 'READABLE' && file.rawHash !== null);
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* 该时刻是否**亲眼确认**这个路径不存在(与「本快照没有读取到」严格区分)。
|
|
789
|
+
*
|
|
790
|
+
* 符号链接在这里算「不存在」只用于删除判定,且 `buildEntry` 先处理符号链接
|
|
791
|
+
* (一律 `UNKNOWN`),因此这个分支不会被用来把未变化的链接报成删除。
|
|
792
|
+
*/
|
|
793
|
+
function absentAt(file) {
|
|
794
|
+
return Boolean(file && (file.readability === 'ABSENT' || file.symlink === true));
|
|
795
|
+
}
|
|
796
|
+
/** 删除侧判定:另一时点要么根本没有该路径,要么亲眼确认它不存在。 */
|
|
797
|
+
function missingAt(file) {
|
|
798
|
+
return file === null || file === undefined || absentAt(file);
|
|
799
|
+
}
|
|
800
|
+
/**
|
|
801
|
+
* 从一份快照里还原工作区相对仓库根的路径前缀。
|
|
802
|
+
*
|
|
803
|
+
* 前缀不是新字段:每个文件观察同时带有工作区相对 `path` 与仓库相对 `repoPath`,
|
|
804
|
+
* 两者相减即得;工作区就是仓库根(或快照里没有任何文件)时为空串。这样
|
|
805
|
+
* `buildDeliveryChangeManifest` 不需要额外的隐藏参数,也不会引入第二套路径状态。
|
|
806
|
+
*/
|
|
807
|
+
function workspacePrefixOf(snapshot) {
|
|
808
|
+
const sample = snapshot.files.find(file => typeof file.repoPath === 'string' && file.repoPath.endsWith(`/${file.path}`));
|
|
809
|
+
if (!sample || typeof sample.repoPath !== 'string')
|
|
810
|
+
return '';
|
|
811
|
+
const prefix = sample.repoPath.slice(0, sample.repoPath.length - sample.path.length - 1);
|
|
812
|
+
return validateRepoRelativePath(prefix) ?? '';
|
|
813
|
+
}
|
|
814
|
+
function buildEntry(path, beforeFile, afterFile,
|
|
815
|
+
/** 两侧候选枚举是否都完整可信;为 false 时「不在快照里」不能当成「不存在」。 */
|
|
816
|
+
enumerationComplete,
|
|
817
|
+
/** 两侧被 ignore 排除的路径身份是否都完整可信;为 false 时缺项不能当成「当时未被忽略」。 */
|
|
818
|
+
ignoreKnowledgeComplete,
|
|
819
|
+
/** 该路径是否出现在基线时点的 ignore 排除身份集合里。 */
|
|
820
|
+
beforeIgnored,
|
|
821
|
+
/** 该路径是否出现在结果时点的 ignore 排除身份集合里。 */
|
|
822
|
+
afterIgnored, evidenceRoot, bounds,
|
|
823
|
+
/** 工作区相对仓库根的路径前缀;用于把展示路径还原成仓库相对路径。 */
|
|
824
|
+
workspacePrefix) {
|
|
825
|
+
const entryId = sha256(`delivery-change\u0000${path}`);
|
|
826
|
+
const repoPath = repoRelativePath(path, workspacePrefix);
|
|
827
|
+
const beforeHash = beforeFile?.rawHash ?? null;
|
|
828
|
+
const afterHash = afterFile?.rawHash ?? null;
|
|
829
|
+
const binary = Boolean(beforeFile?.binary || afterFile?.binary);
|
|
830
|
+
const oversize = Boolean(beforeFile?.oversize || afterFile?.oversize);
|
|
831
|
+
const symlink = Boolean(beforeFile?.symlink || afterFile?.symlink);
|
|
832
|
+
/** 任一侧命中 PEM 标记:两侧正文都不展示,避免把主体带进公开视图。 */
|
|
833
|
+
const sensitive = Boolean(beforeFile?.sensitive || afterFile?.sensitive);
|
|
834
|
+
// 两侧都真实读到完整内容时,摘要相同**就是**「本窗口没有变化」的证明。
|
|
835
|
+
// 无法完整比较(超限/预算省略/读取失败)的路径摘要为 null,绝不当作「未变化」。
|
|
836
|
+
const bothReadable = existedAt(beforeFile) && existedAt(afterFile);
|
|
837
|
+
if (bothReadable && !symlink && beforeHash !== null && afterHash !== null && beforeHash === afterHash)
|
|
838
|
+
return null;
|
|
839
|
+
const beforeObservable = beforeFile !== null;
|
|
840
|
+
const afterObservable = afterFile !== null;
|
|
841
|
+
// 增删只在「另一时点有确定的不存在结论」时成立。该结论要求:候选枚举未被文件/读取
|
|
842
|
+
// 预算截断,且该时点被 ignore 排除的路径身份完整可信,并且这条路径确实不在其中——
|
|
843
|
+
// 只改 `.gitignore`(清空规则→旧文件看似新增;新增规则→旧文件看似删除)时,缺项其实
|
|
844
|
+
// 是「当时被 ignore」而内容一字未改,绝不能写成 ADDED/DELETED。
|
|
845
|
+
// `UNREADABLE` 是「有观察、没结论」,同样绝不能当成不存在。
|
|
846
|
+
const afterMissing = missingAt(afterFile);
|
|
847
|
+
const missingProven = enumerationComplete && ignoreKnowledgeComplete;
|
|
848
|
+
const createdInWindow = missingProven && !beforeObservable && existedAt(afterFile) && !beforeIgnored;
|
|
849
|
+
const removedInWindow = missingProven && existedAt(beforeFile) && afterMissing && !afterIgnored;
|
|
850
|
+
// 读不出来就不能说它变了、也不能说它没变。
|
|
851
|
+
const readabilityUnproven = beforeFile?.readability === 'UNREADABLE' || afterFile?.readability === 'UNREADABLE';
|
|
852
|
+
/**
|
|
853
|
+
* 一侧在快照里有观察结果、另一侧**根本没有该路径**。
|
|
854
|
+
*
|
|
855
|
+
* 只有缺项被证明是「当时确实不存在」时增删才成立;预算截断,或另一时点可能被 ignore
|
|
856
|
+
* 排除(含 ignore 身份无法完整确认),缺项都只能记不可确认,否则会把仍然存在的文件
|
|
857
|
+
* 报成删除、把任务前就存在的文件报成新增。
|
|
858
|
+
*/
|
|
859
|
+
const existenceUnproven = !createdInWindow && !removedInWindow && beforeObservable !== afterObservable
|
|
860
|
+
&& (!missingProven || beforeIgnored || afterIgnored);
|
|
861
|
+
const labels = [];
|
|
862
|
+
let status;
|
|
863
|
+
let reasonCode;
|
|
864
|
+
if (symlink) {
|
|
865
|
+
// 符号链接只观测不跟随,因此没有「链接自身」的可比较摘要:两个时点都观察到链接
|
|
866
|
+
// 也不能证明它没变,观察到一次更不能说明它变了。无法证明就不给可确认状态。
|
|
867
|
+
status = 'UNKNOWN';
|
|
868
|
+
reasonCode = 'SYMLINK_CHANGE_UNPROVEN';
|
|
869
|
+
labels.push('SYMLINK_NOT_FOLLOWED');
|
|
870
|
+
labels.push('CHANGE_NOT_PROVEN');
|
|
871
|
+
}
|
|
872
|
+
else if (createdInWindow) {
|
|
873
|
+
status = 'ADDED';
|
|
874
|
+
reasonCode = 'FILE_APPEARED_IN_WINDOW';
|
|
875
|
+
labels.push(afterFile?.vcs === '??' ? 'UNTRACKED_FILE_CREATED_IN_WINDOW' : 'FILE_CREATED_IN_WINDOW');
|
|
876
|
+
}
|
|
877
|
+
else if (removedInWindow) {
|
|
878
|
+
status = 'DELETED';
|
|
879
|
+
reasonCode = 'FILE_REMOVED_IN_WINDOW';
|
|
880
|
+
labels.push('FILE_REMOVED_IN_WINDOW');
|
|
881
|
+
}
|
|
882
|
+
else if (existenceUnproven) {
|
|
883
|
+
// 文件没进入快照不等于它当时不存在:预算/枚举缺口,或另一时点可能被 ignore 排除,
|
|
884
|
+
// 都只能记不可确认,不可被确认为新增或删除。
|
|
885
|
+
status = 'UNKNOWN';
|
|
886
|
+
reasonCode = !ignoreKnowledgeComplete || beforeIgnored || afterIgnored
|
|
887
|
+
? 'IGNORED_TRANSITION_UNPROVEN'
|
|
888
|
+
: 'EXISTENCE_NOT_OBSERVED';
|
|
889
|
+
labels.push('CHANGE_NOT_PROVEN');
|
|
890
|
+
}
|
|
891
|
+
else if (readabilityUnproven) {
|
|
892
|
+
status = 'UNKNOWN';
|
|
893
|
+
reasonCode = 'FILE_CHANGE_UNPROVEN';
|
|
894
|
+
labels.push('CHANGE_NOT_PROVEN');
|
|
895
|
+
}
|
|
896
|
+
else if (oversize) {
|
|
897
|
+
status = 'MODIFIED';
|
|
898
|
+
reasonCode = 'FILE_OVER_SIZE_LIMIT';
|
|
899
|
+
labels.push('BODY_NOT_COMPARED_BY_LINES');
|
|
900
|
+
}
|
|
901
|
+
else if (binary) {
|
|
902
|
+
status = 'MODIFIED';
|
|
903
|
+
reasonCode = 'BINARY_CONTENT_CHANGED';
|
|
904
|
+
labels.push('BINARY_CONTENT_CHANGED');
|
|
905
|
+
}
|
|
906
|
+
else if (beforeFile && beforeFile.vcs && beforeFile.vcs !== '??') {
|
|
907
|
+
status = 'MODIFIED';
|
|
908
|
+
reasonCode = 'PRE_EXISTING_DIRTY_FILE_CHANGED';
|
|
909
|
+
labels.push('PRE_EXISTING_DIRTY_FILE');
|
|
910
|
+
}
|
|
911
|
+
else if (beforeFile?.vcs === '??') {
|
|
912
|
+
status = 'MODIFIED';
|
|
913
|
+
reasonCode = 'UNTRACKED_FILE_CHANGED';
|
|
914
|
+
labels.push('FILE_WAS_UNTRACKED_BEFORE_WINDOW');
|
|
915
|
+
}
|
|
916
|
+
else {
|
|
917
|
+
status = 'MODIFIED';
|
|
918
|
+
reasonCode = 'CONTENT_CHANGED_IN_WINDOW';
|
|
919
|
+
labels.push('CONTENT_CHANGED_IN_WINDOW');
|
|
920
|
+
}
|
|
921
|
+
labels.push('AUTHORSHIP_NOT_PROVEN');
|
|
922
|
+
if (sensitive)
|
|
923
|
+
labels.push('SENSITIVE_BODY_NOT_RETAINED');
|
|
924
|
+
const changed = status !== 'UNKNOWN';
|
|
925
|
+
const notes = [changed
|
|
926
|
+
? '该路径内容在本 Task/attempt 窗口内发生变化;不证明作者身份,也不证明是哪个执行者或进程写入。'
|
|
927
|
+
: '该路径在本次窗口内无法比较(另一时点没有该路径的观察结果,或采集时无法读取完整内容):既不声称变化,也不声称未变化;该条目不可被确认为改动。'];
|
|
928
|
+
if (beforeFile?.vcs && beforeFile.vcs !== '??')
|
|
929
|
+
notes.push('基线时该文件已相对 VCS 基准处于改动状态,本次差异可能混入任务前改动。');
|
|
930
|
+
if (beforeFile?.vcs === '??')
|
|
931
|
+
notes.push('基线时该文件已是未跟踪文件。');
|
|
932
|
+
if (binary)
|
|
933
|
+
notes.push('二进制内容不展示正文。');
|
|
934
|
+
if (oversize)
|
|
935
|
+
notes.push('文件超过单文件大小上限,只比较**完整内容摘要**(流式读取整个文件),不展示行级差异。');
|
|
936
|
+
if (symlink)
|
|
937
|
+
notes.push('符号链接只观测不跟随,链接自身没有可比较摘要:本窗口内无法证明它是否变化,该条目不可被确认为改动。');
|
|
938
|
+
if (sensitive)
|
|
939
|
+
notes.push('该路径命中 PEM 私钥/证书标记,两侧正文都不保留、也不展示。');
|
|
940
|
+
if (status === 'UNKNOWN' && reasonCode === 'FILE_CHANGE_UNPROVEN')
|
|
941
|
+
notes.push('该路径在采集时不可读取或无法完整比较,未做任何内容比较。');
|
|
942
|
+
if (status === 'UNKNOWN' && reasonCode === 'SYMLINK_CHANGE_UNPROVEN')
|
|
943
|
+
notes.push('不跟随符号链接,也不读取其目标正文;因此既不能确认变化,也不能确认未变化。');
|
|
944
|
+
if (status === 'UNKNOWN' && reasonCode === 'EXISTENCE_NOT_OBSERVED')
|
|
945
|
+
notes.push('该路径只在其中一个时点有存在性结论(另一时点受文件/读取预算或枚举上限影响没有观察结果),无法证明新增或删除。');
|
|
946
|
+
if (status === 'UNKNOWN' && reasonCode === 'IGNORED_TRANSITION_UNPROVEN')
|
|
947
|
+
notes.push('该路径只在其中一个时点进入候选集合:另一时点它被 `.gitignore` 一类规则排除(或该时点被排除的路径身份无法完整确认),本窗口既不声明新增也不声明删除。');
|
|
948
|
+
let hunks = [];
|
|
949
|
+
let baselineBodyUnavailable = false;
|
|
950
|
+
if (changed && !binary && !oversize && !symlink && !sensitive) {
|
|
951
|
+
const beforeText = beforeFile?.retained && beforeFile.storedHash ? readChangeBlob(evidenceRoot, beforeFile.storedHash) : null;
|
|
952
|
+
const afterText = afterFile?.retained && afterFile.storedHash ? readChangeBlob(evidenceRoot, afterFile.storedHash) : null;
|
|
953
|
+
// 只有基线里确实存在该文件、却没有保留正文时才算「基线正文不可用」;
|
|
954
|
+
// 新增文件本来就没有基线,不能因此把覆盖说成不完整。
|
|
955
|
+
if (beforeFile !== null && beforeFile.readability === 'READABLE' && beforeText === null)
|
|
956
|
+
baselineBodyUnavailable = true;
|
|
957
|
+
if (beforeText !== null && afterText !== null) {
|
|
958
|
+
hunks = diffLines(beforeText, afterText, bounds.maxDiffLines);
|
|
959
|
+
}
|
|
960
|
+
else if (beforeText !== null || afterText !== null) {
|
|
961
|
+
// 只保留一侧正文时无法给出「前后对照」的行级差异;仍按真实基线展示片段。
|
|
962
|
+
hunks = diffLines(beforeText ?? '', afterText ?? '', bounds.maxDiffLines);
|
|
963
|
+
if (baselineBodyUnavailable)
|
|
964
|
+
notes.push('基线正文未保留(超出有界预算或未捕获),只展示结果侧片段。');
|
|
965
|
+
}
|
|
966
|
+
else {
|
|
967
|
+
notes.push('两侧正文都未保留,只保留内容摘要与状态。');
|
|
968
|
+
}
|
|
969
|
+
}
|
|
970
|
+
// 展示路径一律是仓库相对路径:工作区相对 `path` 只用于本地读取与条目身份。
|
|
971
|
+
return { entryId, path, repoPath, status, reasonCode, labels, note: notes.join(' '), binary, bodyRetained: Boolean(afterFile?.retained || beforeFile?.retained),
|
|
972
|
+
beforeHash, afterHash, baselineBodyUnavailable, hunks };
|
|
973
|
+
}
|
|
974
|
+
/** 有界行级差异:裁掉公共前后缀后,把中段如实标成 REMOVED/ADDED 片段。 */
|
|
975
|
+
export function diffLines(before, after, maxLines) {
|
|
976
|
+
const a = before.split(/\r?\n/u);
|
|
977
|
+
const b = after.split(/\r?\n/u);
|
|
978
|
+
let start = 0;
|
|
979
|
+
while (start < a.length && start < b.length && a[start] === b[start])
|
|
980
|
+
start += 1;
|
|
981
|
+
let endA = a.length - 1;
|
|
982
|
+
let endB = b.length - 1;
|
|
983
|
+
while (endA >= start && endB >= start && a[endA] === b[endB]) {
|
|
984
|
+
endA -= 1;
|
|
985
|
+
endB -= 1;
|
|
986
|
+
}
|
|
987
|
+
if (start > endA && start > endB)
|
|
988
|
+
return [];
|
|
989
|
+
// 窗口内没有可比较的变化(例如完全不同的内容却长度为零)时保持空片段。
|
|
990
|
+
if (endA < start && endB < start)
|
|
991
|
+
return [];
|
|
992
|
+
const hunks = [];
|
|
993
|
+
const removed = a.slice(start, endA + 1).slice(0, maxLines);
|
|
994
|
+
const added = b.slice(start, endB + 1).slice(0, maxLines);
|
|
995
|
+
for (let index = 0; index < removed.length; index += 1) {
|
|
996
|
+
hunks.push({ kind: 'REMOVED', beforeLine: start + index + 1, afterLine: null, text: boundedLine(removed[index]) });
|
|
997
|
+
}
|
|
998
|
+
for (let index = 0; index < added.length; index += 1) {
|
|
999
|
+
hunks.push({ kind: 'ADDED', beforeLine: null, afterLine: start + index + 1, text: boundedLine(added[index]) });
|
|
1000
|
+
}
|
|
1001
|
+
return hunks;
|
|
1002
|
+
}
|
|
1003
|
+
function boundedLine(value) {
|
|
1004
|
+
const text = redactDeliveryText(value).slice(0, 400);
|
|
1005
|
+
return text;
|
|
1006
|
+
}
|
|
1007
|
+
/** 读回并按内容寻址重验 manifest 与保留正文的 hash;漂移即不可用。 */
|
|
1008
|
+
export function verifyChangeManifest(evidenceRoot, evidenceId) {
|
|
1009
|
+
const body = readNamedRecord(deliveryEvidencePaths(evidenceRoot).manifests, evidenceId);
|
|
1010
|
+
if (body === null)
|
|
1011
|
+
return { ok: false, code: 'EVIDENCE_MISSING', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1012
|
+
let manifest;
|
|
1013
|
+
try {
|
|
1014
|
+
manifest = JSON.parse(body);
|
|
1015
|
+
}
|
|
1016
|
+
catch {
|
|
1017
|
+
return { ok: false, code: 'EVIDENCE_UNREADABLE', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1018
|
+
}
|
|
1019
|
+
if (manifest.version !== DELIVERY_CHANGE_MANIFEST_VERSION || manifest.evidenceId !== evidenceId) {
|
|
1020
|
+
return { ok: false, code: 'EVIDENCE_DRIFT', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1021
|
+
}
|
|
1022
|
+
if (deliveryChangeEvidenceId(manifest) !== evidenceId)
|
|
1023
|
+
return { ok: false, code: 'EVIDENCE_DRIFT', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1024
|
+
const pre = readWorkspaceSnapshot(evidenceRoot, manifest.pre.snapshotId);
|
|
1025
|
+
const post = readWorkspaceSnapshot(evidenceRoot, manifest.post.snapshotId);
|
|
1026
|
+
if (!pre || !post)
|
|
1027
|
+
return { ok: false, code: 'EVIDENCE_SNAPSHOT_MISSING', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1028
|
+
for (const file of [...pre.files, ...post.files]) {
|
|
1029
|
+
if (!file.retained || !file.storedHash)
|
|
1030
|
+
continue;
|
|
1031
|
+
if (readChangeBlob(evidenceRoot, file.storedHash) === null) {
|
|
1032
|
+
return { ok: false, code: 'EVIDENCE_BLOB_DRIFT', reason: CHANGE_EVIDENCE_DRIFT };
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return { ok: true, manifest };
|
|
1036
|
+
}
|
|
1037
|
+
/** 读取由主管 ACCEPT 绑定在该 Task 上的改动证据引用。 */
|
|
1038
|
+
export function readAcceptedChangeEvidence(review) {
|
|
1039
|
+
if (!review || review.event_type !== 'TASK_ACCEPTED')
|
|
1040
|
+
return null;
|
|
1041
|
+
let payload;
|
|
1042
|
+
try {
|
|
1043
|
+
payload = JSON.parse(review.payload_json);
|
|
1044
|
+
}
|
|
1045
|
+
catch {
|
|
1046
|
+
return null;
|
|
1047
|
+
}
|
|
1048
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1049
|
+
return null;
|
|
1050
|
+
const raw = payload.delivery_change_evidence;
|
|
1051
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
1052
|
+
return null;
|
|
1053
|
+
const value = raw;
|
|
1054
|
+
if (value.kind !== 'SUPERVISOR_CONFIRMED_BOUNDED_WINDOW')
|
|
1055
|
+
return null;
|
|
1056
|
+
if (typeof value.evidenceId !== 'string' || !HASH.test(value.evidenceId))
|
|
1057
|
+
return null;
|
|
1058
|
+
if (!Number.isSafeInteger(value.attemptNo) || value.attemptNo < 1)
|
|
1059
|
+
return null;
|
|
1060
|
+
if (!Array.isArray(value.entryIds) || value.entryIds.length === 0 || value.entryIds.length > 200)
|
|
1061
|
+
return null;
|
|
1062
|
+
const entryIds = value.entryIds.filter((id) => typeof id === 'string' && HASH.test(id));
|
|
1063
|
+
if (entryIds.length !== value.entryIds.length)
|
|
1064
|
+
return null;
|
|
1065
|
+
if (typeof value.selectionDigest !== 'string' || !HASH.test(value.selectionDigest))
|
|
1066
|
+
return null;
|
|
1067
|
+
if (selectionDigest(entryIds) !== value.selectionDigest)
|
|
1068
|
+
return null;
|
|
1069
|
+
return {
|
|
1070
|
+
kind: 'SUPERVISOR_CONFIRMED_BOUNDED_WINDOW',
|
|
1071
|
+
evidenceId: value.evidenceId,
|
|
1072
|
+
attemptNo: value.attemptNo,
|
|
1073
|
+
entryIds: [...entryIds].sort(),
|
|
1074
|
+
selectionDigest: value.selectionDigest,
|
|
1075
|
+
entryCount: Number.isSafeInteger(value.entryCount) ? value.entryCount : entryIds.length,
|
|
1076
|
+
selectedCount: entryIds.length,
|
|
1077
|
+
coverageComplete: value.coverageComplete === true,
|
|
1078
|
+
coverageReasons: Array.isArray(value.coverageReasons)
|
|
1079
|
+
? value.coverageReasons.filter((item) => typeof item === 'string').slice(0, 16) : [],
|
|
1080
|
+
repoHead: typeof value.repoHead === 'string' && value.repoHead.length <= 64 ? value.repoHead : null,
|
|
1081
|
+
note: CHANGE_EVIDENCE_NOTE,
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
export function selectionDigest(entryIds) {
|
|
1085
|
+
return sha256([...entryIds].sort().join('\u0000'));
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* 校验主管显式选择:evidence 必须存在且 hash 一致,选中的 entry 必须真实存在。
|
|
1089
|
+
* 任一失败都返回错误,调用方必须拒绝并零写入。
|
|
1090
|
+
*/
|
|
1091
|
+
export function validateChangeSelection(evidenceRoot, manifest, entryIds, expected) {
|
|
1092
|
+
if (manifest.taskId !== expected.taskId || manifest.attemptNo !== expected.attemptNo) {
|
|
1093
|
+
return { ok: false, code: 'CHANGE_EVIDENCE_STALE', reason: '改动证据不属于本次 Task/attempt,拒绝确认。' };
|
|
1094
|
+
}
|
|
1095
|
+
const selected = [...new Set(entryIds)];
|
|
1096
|
+
if (!selected.length)
|
|
1097
|
+
return { ok: false, code: 'CHANGE_SELECTION_EMPTY', reason: '必须显式选择至少一条属于本次交付的改动引用。' };
|
|
1098
|
+
if (selected.length > 200)
|
|
1099
|
+
return { ok: false, code: 'CHANGE_SELECTION_TOO_LARGE', reason: '一次确认的改动引用过多。' };
|
|
1100
|
+
const known = new Map(manifest.entries.map(entry => [entry.entryId, entry]));
|
|
1101
|
+
for (const id of selected) {
|
|
1102
|
+
const entry = HASH.test(id) ? known.get(id) : undefined;
|
|
1103
|
+
if (!entry) {
|
|
1104
|
+
return { ok: false, code: 'CHANGE_ENTRY_UNKNOWN', reason: '选中的改动引用不在该快照内,拒绝确认。' };
|
|
1105
|
+
}
|
|
1106
|
+
// 无法证明是否变化的条目(超限摘要相同、采集时不可读)不是改动,不能被确认。
|
|
1107
|
+
if (entry.status === 'UNKNOWN') {
|
|
1108
|
+
return { ok: false, code: 'CHANGE_ENTRY_UNPROVEN',
|
|
1109
|
+
reason: `改动条目 ${entry.repoPath} 无法证明在本窗口发生变化(${entry.reasonCode}),拒绝确认。` };
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const ref = {
|
|
1113
|
+
kind: 'SUPERVISOR_CONFIRMED_BOUNDED_WINDOW',
|
|
1114
|
+
evidenceId: manifest.evidenceId,
|
|
1115
|
+
attemptNo: manifest.attemptNo,
|
|
1116
|
+
entryIds: selected.sort(),
|
|
1117
|
+
selectionDigest: selectionDigest(selected),
|
|
1118
|
+
entryCount: manifest.entries.length,
|
|
1119
|
+
selectedCount: selected.length,
|
|
1120
|
+
coverageComplete: manifest.coverage.complete,
|
|
1121
|
+
coverageReasons: manifest.coverage.reasons.slice(0, 16),
|
|
1122
|
+
repoHead: manifest.repo.head,
|
|
1123
|
+
note: CHANGE_EVIDENCE_NOTE,
|
|
1124
|
+
};
|
|
1125
|
+
return { ok: true, ref };
|
|
1126
|
+
}
|
|
1127
|
+
/** 事件载荷:只含 task/attempt/result、manifest/hash 与有界元数据,不含仓库正文。 */
|
|
1128
|
+
export function changeSnapshotEventPayload(manifest, resultRef) {
|
|
1129
|
+
return {
|
|
1130
|
+
version: DELIVERY_CHANGE_MANIFEST_VERSION,
|
|
1131
|
+
taskId: manifest.taskId,
|
|
1132
|
+
attemptNo: manifest.attemptNo,
|
|
1133
|
+
resultId: resultRef,
|
|
1134
|
+
executionId: manifest.executionId,
|
|
1135
|
+
leaseId: manifest.leaseId,
|
|
1136
|
+
dispatchId: manifest.dispatchId,
|
|
1137
|
+
evidenceId: manifest.evidenceId,
|
|
1138
|
+
selectionDigest: null,
|
|
1139
|
+
entryCount: manifest.entries.length,
|
|
1140
|
+
coverageComplete: manifest.coverage.complete,
|
|
1141
|
+
coverageReasons: manifest.coverage.reasons.slice(0, 16),
|
|
1142
|
+
repoVcs: manifest.repo.vcs,
|
|
1143
|
+
repoHead: manifest.repo.head,
|
|
1144
|
+
preSnapshotId: manifest.pre.snapshotId,
|
|
1145
|
+
postSnapshotId: manifest.post.snapshotId,
|
|
1146
|
+
note: CHANGE_EVIDENCE_NOTE,
|
|
1147
|
+
};
|
|
1148
|
+
}
|
|
1149
|
+
export function changeSnapshotEventId(kingdomId, taskId, attemptNo, executionRef) {
|
|
1150
|
+
return `delivery-change:${sha256(`${kingdomId}\u0000${taskId}\u0000${attemptNo}\u0000${executionRef ?? 'none'}`)}`;
|
|
1151
|
+
}
|
|
1152
|
+
/** Territory canonical workspace 的不可逆摘要,用于把快照绑定到具体领地工作区。 */
|
|
1153
|
+
export function workspaceKeyOf(workspacePath) {
|
|
1154
|
+
if (!workspacePath || !workspacePath.trim())
|
|
1155
|
+
return null;
|
|
1156
|
+
try {
|
|
1157
|
+
return sha256(realpathSync.native(workspacePath));
|
|
1158
|
+
}
|
|
1159
|
+
catch {
|
|
1160
|
+
return null;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
/** 精确读取某 Task/attempt 的改动快照事件(不走有界 GUI 投影)。 */
|
|
1164
|
+
export function readChangeSnapshotEvent(store, kingdomId, taskId, attemptNo) {
|
|
1165
|
+
const rows = store.db.prepare("SELECT * FROM events WHERE kingdom_id = ? AND event_type = ? AND target_type = 'task' AND target_id = ? ORDER BY seq DESC LIMIT 50").all(kingdomId, DELIVERY_CHANGE_SNAPSHOT_EVENT_TYPE, taskId);
|
|
1166
|
+
for (const event of rows) {
|
|
1167
|
+
let payload;
|
|
1168
|
+
try {
|
|
1169
|
+
const parsed = JSON.parse(event.payload_json);
|
|
1170
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
1171
|
+
continue;
|
|
1172
|
+
payload = parsed;
|
|
1173
|
+
}
|
|
1174
|
+
catch {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
if (payload.attemptNo !== attemptNo)
|
|
1178
|
+
continue;
|
|
1179
|
+
if (typeof payload.evidenceId !== 'string' || !HASH.test(payload.evidenceId))
|
|
1180
|
+
continue;
|
|
1181
|
+
return {
|
|
1182
|
+
event,
|
|
1183
|
+
evidenceId: payload.evidenceId,
|
|
1184
|
+
attemptNo,
|
|
1185
|
+
resultId: typeof payload.resultId === 'string' ? payload.resultId : null,
|
|
1186
|
+
executionId: typeof payload.executionId === 'string' ? payload.executionId : null,
|
|
1187
|
+
coverageComplete: payload.coverageComplete === true,
|
|
1188
|
+
coverageReasons: Array.isArray(payload.coverageReasons)
|
|
1189
|
+
? payload.coverageReasons.filter((item) => typeof item === 'string').slice(0, 16) : [],
|
|
1190
|
+
};
|
|
1191
|
+
}
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
/** 主管候选清单用的有界条目视图(只读,不写任何事实);展示路径是仓库相对路径。 */
|
|
1195
|
+
export function listChangeEntries(manifest, limit = 200) {
|
|
1196
|
+
return manifest.entries.slice(0, limit).map(entry => ({ entryId: entry.entryId, repoPath: entry.repoPath, status: entry.status, note: entry.note }));
|
|
1197
|
+
}
|
|
1198
|
+
/** 读回一条已确认改动条目的有界视图:只按 entry id 取,不接受任意路径。 */
|
|
1199
|
+
export function readChangeEntry(evidenceRoot, ref, entryId) {
|
|
1200
|
+
if (!ref.entryIds.includes(entryId))
|
|
1201
|
+
return { ok: false, code: 'CHANGE_ENTRY_UNKNOWN', reason: '该条目不在主管确认的改动引用内。' };
|
|
1202
|
+
const verified = verifyChangeManifest(evidenceRoot, ref.evidenceId);
|
|
1203
|
+
if (!verified.ok)
|
|
1204
|
+
return verified;
|
|
1205
|
+
const entry = verified.manifest.entries.find(candidate => candidate.entryId === entryId);
|
|
1206
|
+
if (!entry)
|
|
1207
|
+
return { ok: false, code: 'CHANGE_ENTRY_UNKNOWN', reason: '该条目不在快照内。' };
|
|
1208
|
+
if (entry.status === 'UNKNOWN') {
|
|
1209
|
+
return { ok: false, code: 'CHANGE_ENTRY_UNPROVEN', reason: '该条目无法证明在本窗口发生变化,不能作为已确认改动读取。' };
|
|
1210
|
+
}
|
|
1211
|
+
return { ok: true, entry, manifest: verified.manifest };
|
|
1212
|
+
}
|
|
1213
|
+
/**
|
|
1214
|
+
* 把已确认的改动引用解析成可投影条目。任何 hash 漂移或正文缺失都返回 null,
|
|
1215
|
+
* 调用方据此显示「不可定位」,而不是继续用可能被替换的正文。
|
|
1216
|
+
*/
|
|
1217
|
+
export function resolveChangeEvidenceForDelivery(evidenceRoot, ref) {
|
|
1218
|
+
const verified = verifyChangeManifest(evidenceRoot, ref.evidenceId);
|
|
1219
|
+
if (!verified.ok || verified.manifest.attemptNo !== ref.attemptNo)
|
|
1220
|
+
return null;
|
|
1221
|
+
const selected = new Set(ref.entryIds);
|
|
1222
|
+
const entries = verified.manifest.entries
|
|
1223
|
+
.filter(entry => selected.has(entry.entryId) && entry.status !== 'UNKNOWN')
|
|
1224
|
+
.map(entry => ({ entryId: entry.entryId, repoPath: entry.repoPath, status: entry.status, note: entry.note }));
|
|
1225
|
+
if (entries.length !== selected.size)
|
|
1226
|
+
return null;
|
|
1227
|
+
return {
|
|
1228
|
+
evidenceId: ref.evidenceId,
|
|
1229
|
+
attemptNo: ref.attemptNo,
|
|
1230
|
+
repoHead: verified.manifest.repo.head,
|
|
1231
|
+
coverageComplete: verified.manifest.coverage.complete,
|
|
1232
|
+
coverageReasons: verified.manifest.coverage.reasons.slice(0, 16),
|
|
1233
|
+
note: CHANGE_EVIDENCE_NOTE,
|
|
1234
|
+
entries,
|
|
1235
|
+
};
|
|
1236
|
+
}
|
|
1237
|
+
//# sourceMappingURL=delivery-change.js.map
|