create-principles-disciple 1.147.8 → 1.147.10
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/_release/product-identity.json +2 -2
- package/core/dist/runtime-v2/activation/adaptive-delivery-record.d.ts +37 -0
- package/core/dist/runtime-v2/activation/adaptive-delivery-record.d.ts.map +1 -0
- package/core/dist/runtime-v2/activation/adaptive-delivery-record.js +126 -0
- package/core/dist/runtime-v2/activation/adaptive-delivery-record.js.map +1 -0
- package/core/dist/runtime-v2/activation/adaptive-principle-intervention.d.ts +46 -0
- package/core/dist/runtime-v2/activation/adaptive-principle-intervention.d.ts.map +1 -0
- package/core/dist/runtime-v2/activation/adaptive-principle-intervention.js +91 -0
- package/core/dist/runtime-v2/activation/adaptive-principle-intervention.js.map +1 -0
- package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts +2 -0
- package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.d.ts.map +1 -1
- package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.js +2 -0
- package/core/dist/runtime-v2/activation/prompt-activation-reader-contract.js.map +1 -1
- package/core/dist/runtime-v2/index.d.ts +4 -0
- package/core/dist/runtime-v2/index.d.ts.map +1 -1
- package/core/dist/runtime-v2/index.js +3 -0
- package/core/dist/runtime-v2/index.js.map +1 -1
- package/core/dist/runtime-v2/store/sqlite-connection.js +1 -1
- package/core/dist/runtime-v2/store/sqlite-connection.js.map +1 -1
- package/core/dist/runtime-v2/store/workspace-leak-guard.d.ts.map +1 -1
- package/core/dist/runtime-v2/store/workspace-leak-guard.js +8 -2
- package/core/dist/runtime-v2/store/workspace-leak-guard.js.map +1 -1
- package/core/dist/runtime-v2/types/event-types.d.ts +6 -3
- package/core/dist/runtime-v2/types/event-types.d.ts.map +1 -1
- package/core/dist/runtime-v2/types/event-types.js +2 -0
- package/core/dist/runtime-v2/types/event-types.js.map +1 -1
- package/core/package.json +5 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -1
- package/dist/install-summary.d.ts +3 -0
- package/dist/install-summary.d.ts.map +1 -1
- package/dist/install-summary.js +17 -9
- package/dist/install-summary.js.map +1 -1
- package/dist/installers/workbuddy-host-installer.d.ts +20 -1
- package/dist/installers/workbuddy-host-installer.d.ts.map +1 -1
- package/dist/installers/workbuddy-host-installer.js +75 -14
- package/dist/installers/workbuddy-host-installer.js.map +1 -1
- package/host-runtime/dist/active-principle-prompt.d.ts +28 -0
- package/host-runtime/dist/active-principle-prompt.js +163 -50
- package/host-runtime/dist/adaptive-journal-reconciliation.d.ts +205 -0
- package/host-runtime/dist/adaptive-journal-reconciliation.js +1050 -0
- package/host-runtime/dist/adaptive-principle-revalidation.d.ts +8 -0
- package/host-runtime/dist/adaptive-principle-revalidation.js +52 -0
- package/host-runtime/dist/index.d.ts +17 -0
- package/host-runtime/dist/index.js +35 -0
- package/host-runtime/dist/prompt-activation-authority.d.ts +7 -0
- package/host-runtime/dist/prompt-activation-authority.js +16 -0
- package/host-runtime/package.json +1 -1
- package/package.json +3 -3
- package/pd-cli/dist/commands/runtime-activation.d.ts +18 -0
- package/pd-cli/dist/commands/runtime-activation.d.ts.map +1 -1
- package/pd-cli/dist/commands/runtime-activation.js +73 -1
- package/pd-cli/dist/commands/runtime-activation.js.map +1 -1
- package/pd-cli/dist/index.js +3 -1
- package/pd-cli/dist/index.js.map +1 -1
- package/pd-cli/package.json +1 -1
- package/plugin/dist/bundle.js +457 -455
- package/plugin/dist/core/adaptive-config-identity.d.ts +106 -0
- package/plugin/dist/core/event-log.d.ts +3 -0
- package/plugin/dist/governance-audit.js +3 -3
- package/plugin/dist/hooks/adaptive-activation-config.d.ts +8 -0
- package/plugin/dist/hooks/prompt-helpers.d.ts +5 -0
- package/plugin/dist/hooks/prompt-types.d.ts +3 -0
- package/plugin/dist/hooks/prompt.d.ts +8 -1
- package/plugin/dist/host-runtime/openclaw-host-runtime.d.ts +13 -0
- package/plugin/dist/openclaw-sdk.d.ts +4 -0
- package/plugin/dist/openclaw.plugin.json +33 -1
- package/plugin/dist/rulehost-evidence.js +1 -1
- package/plugin/openclaw.plugin.json +33 -1
- package/plugin/package.json +1 -1
- package/release-manager/dist/installers/workbuddy-host-installer.js +75 -14
- package/release-manager/package.json +1 -1
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
import * as fs from 'node:fs';
|
|
2
|
+
import * as path from 'node:path';
|
|
3
|
+
import { INTERVENTION_SHORT_SOURCE_RETENTION_DAYS, canonicalStringify, validateAdaptiveDeliveryRecord, } from '@principles/core/runtime-v2';
|
|
4
|
+
/**
|
|
5
|
+
* PRI-1109 — conservative, read-only reconciliation of adaptive-delivery journal
|
|
6
|
+
* records (approved design: docs/specs/PD-AA-PRI-1109-JOURNAL-RECONCILE-DESIGN-v0.2.md).
|
|
7
|
+
*
|
|
8
|
+
* Semantic red lines enforced here:
|
|
9
|
+
* - closed/reconciled is a recorded phase, never success;
|
|
10
|
+
* - payload=confirmed and submission are only transcribed as recorded evidence
|
|
11
|
+
* declarations (never re-verified by this reader);
|
|
12
|
+
* - an absent terminal, absent start, or absent log is "not visible in the
|
|
13
|
+
* scanned range", never a claim about what happened outside it;
|
|
14
|
+
* - non-adaptive events are irrelevant, not invalid;
|
|
15
|
+
* - every truncation / coverage limit is explicit, never silent.
|
|
16
|
+
*/
|
|
17
|
+
export const ADAPTIVE_JOURNAL_SCHEMA_VERSION = 'pd-aa-journal-reconciliation/v1';
|
|
18
|
+
export const ADAPTIVE_JOURNAL_CANDIDATE_DIRS = ['.pd/logs', '.state/logs'];
|
|
19
|
+
export const ADAPTIVE_JOURNAL_MAX_DATE_FILES_PER_DIR = 9;
|
|
20
|
+
export const ADAPTIVE_JOURNAL_MAX_FILE_BYTES = 4 * 1024 * 1024;
|
|
21
|
+
/**
|
|
22
|
+
* Character budget for the full-content duplicate identity comparison. A record
|
|
23
|
+
* comes from a file capped at ADAPTIVE_JOURNAL_MAX_FILE_BYTES, so on the default
|
|
24
|
+
* read path its canonical form fits this budget. A caller that raises
|
|
25
|
+
* maxFileBytes far enough can exceed it: such records are then treated as
|
|
26
|
+
* DISTINCT (never as exact duplicates) and the limitation is reported via
|
|
27
|
+
* coverage.identityComparisonLimited.
|
|
28
|
+
*/
|
|
29
|
+
const IDENTITY_COMPARISON_MAX_CHARS = ADAPTIVE_JOURNAL_MAX_FILE_BYTES;
|
|
30
|
+
export const ADAPTIVE_JOURNAL_MAX_INVOCATIONS = 1000;
|
|
31
|
+
export const ADAPTIVE_JOURNAL_MAX_TERMINAL_VARIANTS = 5;
|
|
32
|
+
export const ADAPTIVE_JOURNAL_SUPPORTED_HOST = 'openclaw';
|
|
33
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
34
|
+
const DATE_FILE_PATTERN = /^events_(\d{4}-\d{2}-\d{2})\.jsonl$/;
|
|
35
|
+
const MAX_REJECTED_SAMPLES = 3;
|
|
36
|
+
function isRecord(value) {
|
|
37
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
38
|
+
}
|
|
39
|
+
function optionalString(value) {
|
|
40
|
+
return typeof value === 'string' && value.trim().length > 0 ? value : null;
|
|
41
|
+
}
|
|
42
|
+
function optionalBoolean(value) {
|
|
43
|
+
return typeof value === 'boolean' ? value : null;
|
|
44
|
+
}
|
|
45
|
+
function parseIso(value) {
|
|
46
|
+
const parsed = Date.parse(value);
|
|
47
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
48
|
+
}
|
|
49
|
+
function utcDateKey(ms) {
|
|
50
|
+
return new Date(ms).toISOString().slice(0, 10);
|
|
51
|
+
}
|
|
52
|
+
function normalizeComparablePath(value) {
|
|
53
|
+
const resolved = path.resolve(value);
|
|
54
|
+
return path.sep === '\\' ? resolved.toLowerCase() : resolved;
|
|
55
|
+
}
|
|
56
|
+
function normalizeWorkspace(value) {
|
|
57
|
+
let resolved = normalizeComparablePath(value);
|
|
58
|
+
if (resolved.endsWith(path.sep))
|
|
59
|
+
resolved = resolved.slice(0, -path.sep.length);
|
|
60
|
+
return resolved;
|
|
61
|
+
}
|
|
62
|
+
function isInside(parent, child) {
|
|
63
|
+
const parentComparable = normalizeComparablePath(parent);
|
|
64
|
+
const childComparable = normalizeComparablePath(child);
|
|
65
|
+
return childComparable === parentComparable || childComparable.startsWith(`${parentComparable}${path.sep}`);
|
|
66
|
+
}
|
|
67
|
+
function nodeErrorCode(error) {
|
|
68
|
+
if (error === null || typeof error !== 'object' || !Object.hasOwn(error, 'code'))
|
|
69
|
+
return null;
|
|
70
|
+
const value = Reflect.get(error, 'code');
|
|
71
|
+
return typeof value === 'string' ? value : null;
|
|
72
|
+
}
|
|
73
|
+
function realpathOrNull(target) {
|
|
74
|
+
try {
|
|
75
|
+
return fs.realpathSync(target);
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
function emptyCoverage() {
|
|
82
|
+
return {
|
|
83
|
+
status: 'complete',
|
|
84
|
+
sourceDirsFound: 0,
|
|
85
|
+
filesRead: 0,
|
|
86
|
+
filesSkipped: 0,
|
|
87
|
+
bytesRead: 0,
|
|
88
|
+
dateFilesIntersecting: 0,
|
|
89
|
+
limitCode: null,
|
|
90
|
+
filesOmitted: [],
|
|
91
|
+
truncatedFiles: [],
|
|
92
|
+
errors: [],
|
|
93
|
+
identityComparisonLimited: false,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function emptyTotals() {
|
|
97
|
+
return {
|
|
98
|
+
linesRead: 0,
|
|
99
|
+
adaptiveRecords: 0,
|
|
100
|
+
nonAdaptiveLines: 0,
|
|
101
|
+
malformedJsonLines: 0,
|
|
102
|
+
invalidEnvelopeRecords: 0,
|
|
103
|
+
invalidRecords: 0,
|
|
104
|
+
duplicatesExact: 0,
|
|
105
|
+
uniqueInvocations: 0,
|
|
106
|
+
rejectedRecords: 0,
|
|
107
|
+
countsAreLowerBounds: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
function unavailableTotals() {
|
|
111
|
+
return {
|
|
112
|
+
linesRead: null,
|
|
113
|
+
adaptiveRecords: null,
|
|
114
|
+
nonAdaptiveLines: null,
|
|
115
|
+
malformedJsonLines: null,
|
|
116
|
+
invalidEnvelopeRecords: null,
|
|
117
|
+
invalidRecords: null,
|
|
118
|
+
duplicatesExact: null,
|
|
119
|
+
uniqueInvocations: null,
|
|
120
|
+
rejectedRecords: null,
|
|
121
|
+
countsAreLowerBounds: false,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function unavailableAnswer(reasonCodes) {
|
|
125
|
+
const answer = { count: null, countBasis: null, invocationIds: [] };
|
|
126
|
+
if (reasonCodes !== undefined)
|
|
127
|
+
answer.reasonCodes = reasonCodes;
|
|
128
|
+
return answer;
|
|
129
|
+
}
|
|
130
|
+
function validateLimits(input, defaults) {
|
|
131
|
+
const resolved = { ...defaults };
|
|
132
|
+
const provided = input.limits ?? {};
|
|
133
|
+
const keys = [
|
|
134
|
+
'maxDateFilesPerDir',
|
|
135
|
+
'maxFileBytes',
|
|
136
|
+
'maxInvocations',
|
|
137
|
+
'maxTerminalVariantsPerInvocation',
|
|
138
|
+
];
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const value = provided[key];
|
|
141
|
+
if (value === undefined)
|
|
142
|
+
continue;
|
|
143
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
144
|
+
return {
|
|
145
|
+
ok: false,
|
|
146
|
+
error: {
|
|
147
|
+
code: 'invalid_limit',
|
|
148
|
+
message: `limit ${key} must be a positive integer`,
|
|
149
|
+
nextAction: `为 ${key} 传入正整数(默认 ${defaults[key]})。`,
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
resolved[key] = value;
|
|
154
|
+
}
|
|
155
|
+
return { ok: true, limits: resolved };
|
|
156
|
+
}
|
|
157
|
+
function resolveWindow(input, now) {
|
|
158
|
+
const nowMs = now.getTime();
|
|
159
|
+
let endMs = nowMs;
|
|
160
|
+
let endIsNow = true;
|
|
161
|
+
if (input.until !== undefined) {
|
|
162
|
+
const parsed = parseIso(input.until);
|
|
163
|
+
if (parsed === null) {
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
error: {
|
|
167
|
+
code: 'invalid_until',
|
|
168
|
+
message: `--until is not a valid ISO timestamp: ${input.until}`,
|
|
169
|
+
nextAction: '使用 ISO 时间(例如 2026-11-05T10:00:00Z)。',
|
|
170
|
+
},
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (parsed > nowMs) {
|
|
174
|
+
return {
|
|
175
|
+
ok: false,
|
|
176
|
+
error: {
|
|
177
|
+
code: 'until_after_now',
|
|
178
|
+
message: `--until (${input.until}) is after now (${now.toISOString()})`,
|
|
179
|
+
nextAction: '把 --until 调整到当前时间或更早。',
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
endMs = parsed;
|
|
184
|
+
endIsNow = false;
|
|
185
|
+
}
|
|
186
|
+
const defaultSinceMs = endMs - INTERVENTION_SHORT_SOURCE_RETENTION_DAYS * DAY_MS;
|
|
187
|
+
const requestedStart = input.since ?? new Date(defaultSinceMs).toISOString();
|
|
188
|
+
const parsedSince = parseIso(requestedStart);
|
|
189
|
+
if (parsedSince === null) {
|
|
190
|
+
return {
|
|
191
|
+
ok: false,
|
|
192
|
+
error: {
|
|
193
|
+
code: 'invalid_since',
|
|
194
|
+
message: `--since is not a valid ISO timestamp: ${requestedStart}`,
|
|
195
|
+
nextAction: '使用 ISO 时间(例如 2026-10-29T10:00:00Z)。',
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
if (parsedSince > endMs) {
|
|
200
|
+
return {
|
|
201
|
+
ok: false,
|
|
202
|
+
error: {
|
|
203
|
+
code: 'since_after_until',
|
|
204
|
+
message: `--since (${requestedStart}) is after --until (${new Date(endMs).toISOString()})`,
|
|
205
|
+
nextAction: '保证 --since 早于或等于 --until。',
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const retentionBoundaryMs = nowMs - INTERVENTION_SHORT_SOURCE_RETENTION_DAYS * DAY_MS;
|
|
210
|
+
const clampedByRetention = parsedSince < retentionBoundaryMs;
|
|
211
|
+
const startMs = clampedByRetention ? retentionBoundaryMs : parsedSince;
|
|
212
|
+
const dates = [];
|
|
213
|
+
const firstDayMs = Date.UTC(new Date(startMs).getUTCFullYear(), new Date(startMs).getUTCMonth(), new Date(startMs).getUTCDate());
|
|
214
|
+
for (let cursor = firstDayMs; cursor <= endMs; cursor += DAY_MS) {
|
|
215
|
+
dates.push(utcDateKey(cursor));
|
|
216
|
+
}
|
|
217
|
+
// EventLog flushes the in-memory buffer into a file named by the flush time,
|
|
218
|
+
// while each record's ts/date is fixed at creation. A record created just
|
|
219
|
+
// before midnight can therefore be appended to the next UTC day's file, so
|
|
220
|
+
// the day after the window end is a candidate even though its own date does
|
|
221
|
+
// not intersect the window. Envelope ts still decides inclusion later.
|
|
222
|
+
dates.push(utcDateKey(firstDayMs + dates.length * DAY_MS));
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
resolved: {
|
|
226
|
+
window: {
|
|
227
|
+
start: new Date(startMs).toISOString(),
|
|
228
|
+
end: new Date(endMs).toISOString(),
|
|
229
|
+
inclusiveBounds: '[start,end]',
|
|
230
|
+
endIsNow,
|
|
231
|
+
requestedStart,
|
|
232
|
+
clampedByRetention,
|
|
233
|
+
retentionDays: INTERVENTION_SHORT_SOURCE_RETENTION_DAYS,
|
|
234
|
+
},
|
|
235
|
+
startMs,
|
|
236
|
+
endMs,
|
|
237
|
+
dates,
|
|
238
|
+
},
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
function selectDateFiles(options) {
|
|
242
|
+
const { workspaceDir, dates, maxDateFilesPerDir, coverage } = options;
|
|
243
|
+
const dateSet = new Set(dates);
|
|
244
|
+
const selected = [];
|
|
245
|
+
const canonicalWorkspace = realpathOrNull(workspaceDir);
|
|
246
|
+
ADAPTIVE_JOURNAL_CANDIDATE_DIRS.forEach((logDir, dirPriority) => {
|
|
247
|
+
const absoluteDir = path.join(workspaceDir, logDir);
|
|
248
|
+
let entries;
|
|
249
|
+
try {
|
|
250
|
+
entries = fs.readdirSync(absoluteDir);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
const errnoCode = nodeErrorCode(error);
|
|
254
|
+
if (errnoCode === 'ENOENT' || errnoCode === 'ENOTDIR')
|
|
255
|
+
return;
|
|
256
|
+
coverage.errors.push({
|
|
257
|
+
logDir,
|
|
258
|
+
file: null,
|
|
259
|
+
code: 'read_failed',
|
|
260
|
+
message: `log directory is not readable${errnoCode === null ? '' : ` (${errnoCode})`}`,
|
|
261
|
+
});
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
coverage.sourceDirsFound += 1;
|
|
265
|
+
if (canonicalWorkspace === null) {
|
|
266
|
+
coverage.errors.push({
|
|
267
|
+
logDir,
|
|
268
|
+
file: null,
|
|
269
|
+
code: 'stat_failed',
|
|
270
|
+
message: 'workspace directory is not readable, cannot verify log containment',
|
|
271
|
+
});
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const canonicalDir = realpathOrNull(absoluteDir);
|
|
275
|
+
if (canonicalDir === null) {
|
|
276
|
+
coverage.errors.push({ logDir, file: null, code: 'stat_failed', message: 'log directory is not readable' });
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
if (!isInside(canonicalWorkspace, canonicalDir)) {
|
|
280
|
+
coverage.errors.push({
|
|
281
|
+
logDir,
|
|
282
|
+
file: null,
|
|
283
|
+
code: 'outside_workspace',
|
|
284
|
+
message: 'log directory resolves outside the workspace; not read',
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const matched = [];
|
|
289
|
+
for (const entry of entries) {
|
|
290
|
+
const match = DATE_FILE_PATTERN.exec(entry);
|
|
291
|
+
if (match === null)
|
|
292
|
+
continue;
|
|
293
|
+
const [, dateKey] = match;
|
|
294
|
+
if (dateKey === undefined || !dateSet.has(dateKey))
|
|
295
|
+
continue;
|
|
296
|
+
matched.push({ absolutePath: path.join(absoluteDir, entry), dateKey, dirPriority, logDir });
|
|
297
|
+
}
|
|
298
|
+
matched.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
|
|
299
|
+
coverage.dateFilesIntersecting += matched.length;
|
|
300
|
+
if (matched.length > maxDateFilesPerDir) {
|
|
301
|
+
const keep = matched.slice(matched.length - maxDateFilesPerDir);
|
|
302
|
+
const omit = matched.slice(0, matched.length - maxDateFilesPerDir);
|
|
303
|
+
coverage.limitCode = 'coverage_file_limit_reached';
|
|
304
|
+
coverage.filesOmitted.push(...omit.map((file) => file.absolutePath));
|
|
305
|
+
selected.push(...keep);
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
selected.push(...matched);
|
|
309
|
+
});
|
|
310
|
+
selected.sort((left, right) => {
|
|
311
|
+
if (left.dateKey === right.dateKey)
|
|
312
|
+
return left.dirPriority - right.dirPriority;
|
|
313
|
+
return left.dateKey.localeCompare(right.dateKey);
|
|
314
|
+
});
|
|
315
|
+
return selected;
|
|
316
|
+
}
|
|
317
|
+
function readBoundedLines(filePath, maxBytes) {
|
|
318
|
+
const handle = fs.openSync(filePath, 'r');
|
|
319
|
+
try {
|
|
320
|
+
const stats = fs.fstatSync(handle);
|
|
321
|
+
if (!stats.isFile())
|
|
322
|
+
throw new Error('not a regular file');
|
|
323
|
+
const { size } = stats;
|
|
324
|
+
const truncated = size > maxBytes;
|
|
325
|
+
const bytesToRead = truncated ? maxBytes : size;
|
|
326
|
+
const buffer = Buffer.allocUnsafe(bytesToRead);
|
|
327
|
+
if (bytesToRead > 0) {
|
|
328
|
+
fs.readSync(handle, buffer, 0, bytesToRead, truncated ? size - bytesToRead : 0);
|
|
329
|
+
}
|
|
330
|
+
let text = buffer.toString('utf8');
|
|
331
|
+
if (truncated) {
|
|
332
|
+
const firstNewline = text.indexOf('\n');
|
|
333
|
+
text = firstNewline === -1 ? '' : text.slice(firstNewline + 1);
|
|
334
|
+
}
|
|
335
|
+
const lines = text
|
|
336
|
+
.split(/\r?\n/)
|
|
337
|
+
.filter((line) => line.trim().length > 0);
|
|
338
|
+
return { lines, bytesRead: bytesToRead, truncated };
|
|
339
|
+
}
|
|
340
|
+
finally {
|
|
341
|
+
fs.closeSync(handle);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* Full canonical serialization of journal record data for duplicate identity.
|
|
346
|
+
* Never truncated: journal data comes from `JSON.parse`, so only JSON values
|
|
347
|
+
* occur, and object keys are sorted exactly like canonicalStringify while array
|
|
348
|
+
* order is preserved.
|
|
349
|
+
*/
|
|
350
|
+
function canonicalJournalDataInner(value) {
|
|
351
|
+
if (value === null)
|
|
352
|
+
return 'null';
|
|
353
|
+
if (typeof value === 'string')
|
|
354
|
+
return JSON.stringify(value);
|
|
355
|
+
if (typeof value === 'number')
|
|
356
|
+
return Number.isFinite(value) ? String(value) : 'null';
|
|
357
|
+
if (typeof value === 'boolean')
|
|
358
|
+
return String(value);
|
|
359
|
+
if (Array.isArray(value))
|
|
360
|
+
return `[${value.map(canonicalJournalDataInner).join(',')}]`;
|
|
361
|
+
if (isRecord(value)) {
|
|
362
|
+
const keys = Object.keys(value).sort((left, right) => (left < right ? -1 : left > right ? 1 : 0));
|
|
363
|
+
const parts = [];
|
|
364
|
+
for (const key of keys) {
|
|
365
|
+
const entry = value[key];
|
|
366
|
+
if (entry === undefined)
|
|
367
|
+
continue;
|
|
368
|
+
parts.push(`${JSON.stringify(key)}:${canonicalJournalDataInner(entry)}`);
|
|
369
|
+
}
|
|
370
|
+
return `{${parts.join(',')}}`;
|
|
371
|
+
}
|
|
372
|
+
return 'null';
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* Returns the full canonical text, or null when it exceeds
|
|
376
|
+
* IDENTITY_COMPARISON_MAX_CHARS — the caller must then treat the record as
|
|
377
|
+
* DISTINCT and report the limitation instead of asserting equality.
|
|
378
|
+
*/
|
|
379
|
+
function canonicalJournalData(value) {
|
|
380
|
+
const text = canonicalJournalDataInner(value);
|
|
381
|
+
return text.length > IDENTITY_COMPARISON_MAX_CHARS ? null : text;
|
|
382
|
+
}
|
|
383
|
+
function identityKey(record) {
|
|
384
|
+
// Exact duplication is decided on the whole normalized record data, not on
|
|
385
|
+
// the display projection: two terminals that differ only in evidence reference
|
|
386
|
+
// or target digest are different observations and must stay conflicted.
|
|
387
|
+
const canonical = canonicalStringify(record.data);
|
|
388
|
+
// canonicalStringify documents its output as a marked PREFIX once the data
|
|
389
|
+
// exceeds its own limit, so truncated text can never certify equality. Fall
|
|
390
|
+
// back to the full local canonicalization and report a conservative distinct.
|
|
391
|
+
if (!canonical.truncated)
|
|
392
|
+
return canonical.text;
|
|
393
|
+
return canonicalJournalData(record.data);
|
|
394
|
+
}
|
|
395
|
+
function countDistinctIdentities(records) {
|
|
396
|
+
const keys = new Set();
|
|
397
|
+
let limited = 0;
|
|
398
|
+
for (const record of records) {
|
|
399
|
+
if (record.identityKey === null)
|
|
400
|
+
limited += 1;
|
|
401
|
+
else
|
|
402
|
+
keys.add(record.identityKey);
|
|
403
|
+
}
|
|
404
|
+
// Budget-limited records can never be proven equal to anything.
|
|
405
|
+
return keys.size + limited;
|
|
406
|
+
}
|
|
407
|
+
function buildProjection(data, fallbackTs) {
|
|
408
|
+
const receiptState = data.receiptState === 'closed' || data.receiptState === 'reconciled' ? data.receiptState : 'open';
|
|
409
|
+
const evidence = isRecord(data.evidence) ? data.evidence : null;
|
|
410
|
+
return {
|
|
411
|
+
ts: fallbackTs,
|
|
412
|
+
receiptState,
|
|
413
|
+
execution: String(data.execution ?? 'unknown'),
|
|
414
|
+
payload: String(data.payload ?? 'unknown'),
|
|
415
|
+
selection: String(data.selection ?? 'unavailable'),
|
|
416
|
+
submission: String(data.submission ?? 'unknown'),
|
|
417
|
+
selectionPending: optionalBoolean(data.selectionPending),
|
|
418
|
+
reason: optionalString(data.reason),
|
|
419
|
+
artifactId: optionalString(data.artifactId) ?? optionalString(data.principleArtifactId),
|
|
420
|
+
activationId: optionalString(data.activationId) ?? optionalString(data.activationRunId),
|
|
421
|
+
requestId: optionalString(data.requestId),
|
|
422
|
+
evidenceReferencePresent: evidence !== null && optionalString(evidence.reference) !== null,
|
|
423
|
+
evidenceIndependentlyVerifiedAsRecorded: evidence === null ? null : optionalBoolean(evidence.independentlyVerified),
|
|
424
|
+
hostKillConfirmedAsRecorded: evidence === null ? null : optionalBoolean(evidence.hostKillConfirmed),
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function sortedByTs(items) {
|
|
428
|
+
return [...items].sort((left, right) => left.tsMs - right.tsMs);
|
|
429
|
+
}
|
|
430
|
+
function compareTargets(opens, terminals) {
|
|
431
|
+
let compared = false;
|
|
432
|
+
let mismatched = false;
|
|
433
|
+
for (const open of opens) {
|
|
434
|
+
for (const terminal of terminals) {
|
|
435
|
+
for (const field of ['artifactId', 'activationId']) {
|
|
436
|
+
const left = open.projection[field];
|
|
437
|
+
const right = terminal.projection[field];
|
|
438
|
+
if (left !== null && right !== null) {
|
|
439
|
+
compared = true;
|
|
440
|
+
if (left !== right)
|
|
441
|
+
mismatched = true;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
if (mismatched)
|
|
447
|
+
return { consistent: false, mismatched: true };
|
|
448
|
+
return { consistent: compared ? true : null, mismatched: false };
|
|
449
|
+
}
|
|
450
|
+
function classifyGroup(options) {
|
|
451
|
+
const { group, window, coverageStatus, maxTerminalVariants } = options;
|
|
452
|
+
const deduped = [];
|
|
453
|
+
const seen = new Set();
|
|
454
|
+
let duplicateOpen = 0;
|
|
455
|
+
let duplicateTerminal = 0;
|
|
456
|
+
for (const record of group.records) {
|
|
457
|
+
if (record.identityKey !== null && seen.has(record.identityKey)) {
|
|
458
|
+
if (record.state === 'open')
|
|
459
|
+
duplicateOpen += 1;
|
|
460
|
+
else
|
|
461
|
+
duplicateTerminal += 1;
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
if (record.identityKey !== null)
|
|
465
|
+
seen.add(record.identityKey);
|
|
466
|
+
deduped.push(record);
|
|
467
|
+
}
|
|
468
|
+
const opens = sortedByTs(deduped.filter((record) => record.state === 'open'));
|
|
469
|
+
const terminals = sortedByTs(deduped.filter((record) => record.state !== 'open'));
|
|
470
|
+
const reasonCodes = [];
|
|
471
|
+
const unknowns = [];
|
|
472
|
+
let classification = 'paired_open_and_terminal';
|
|
473
|
+
let targetConsistent = null;
|
|
474
|
+
if (group.fromTruncatedFile) {
|
|
475
|
+
reasonCodes.push('record_before_coverage_start');
|
|
476
|
+
unknowns.push('该调用至少一条记录来自被截断的文件尾部,更早的记录可能不可见');
|
|
477
|
+
}
|
|
478
|
+
if (group.workspaceDir === null) {
|
|
479
|
+
reasonCodes.push('workspace_identity_unknown');
|
|
480
|
+
unknowns.push('工作区身份缺失,未与已验证工作区的记录合并');
|
|
481
|
+
}
|
|
482
|
+
if (group.sessionId === null) {
|
|
483
|
+
unknowns.push('session 身份缺失,调用归属未确认');
|
|
484
|
+
}
|
|
485
|
+
if (duplicateOpen + duplicateTerminal > 0) {
|
|
486
|
+
reasonCodes.push('exact_duplicate_record');
|
|
487
|
+
}
|
|
488
|
+
if (group.invalidReason !== null) {
|
|
489
|
+
classification = 'invalid';
|
|
490
|
+
reasonCodes.push(group.invalidReason);
|
|
491
|
+
unknowns.push('存在无法归属或未通过校验的记录,该调用不能作为配对证据');
|
|
492
|
+
}
|
|
493
|
+
else if (opens.length === 0 && terminals.length > 0) {
|
|
494
|
+
if (countDistinctIdentities(terminals) > 1) {
|
|
495
|
+
classification = 'conflicted';
|
|
496
|
+
reasonCodes.push('conflicting_terminal_states');
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
classification = 'terminal_only';
|
|
500
|
+
}
|
|
501
|
+
reasonCodes.push('start_not_visible_in_range');
|
|
502
|
+
unknowns.push('开始记录在当前范围内不可见');
|
|
503
|
+
}
|
|
504
|
+
else if (opens.length > 0 && terminals.length === 0) {
|
|
505
|
+
if (countDistinctIdentities(opens) > 1) {
|
|
506
|
+
classification = 'conflicted';
|
|
507
|
+
reasonCodes.push('multiple_starts_conflict');
|
|
508
|
+
}
|
|
509
|
+
else {
|
|
510
|
+
classification = 'open_only';
|
|
511
|
+
}
|
|
512
|
+
reasonCodes.push('terminal_not_visible_in_range');
|
|
513
|
+
if (!window.endIsNow)
|
|
514
|
+
reasonCodes.push('window_end_explicit_not_now');
|
|
515
|
+
unknowns.push('终态记录在当前范围内不可见');
|
|
516
|
+
if (!window.endIsNow)
|
|
517
|
+
unknowns.push('窗口终点由 --until 显式指定,并非 now;终态是否在窗口之后仍未知');
|
|
518
|
+
}
|
|
519
|
+
else if (opens.length === 0 && terminals.length === 0) {
|
|
520
|
+
classification = 'invalid';
|
|
521
|
+
unknowns.push('该调用没有任何可归属的记录');
|
|
522
|
+
}
|
|
523
|
+
else {
|
|
524
|
+
if (countDistinctIdentities(opens) > 1) {
|
|
525
|
+
classification = 'conflicted';
|
|
526
|
+
reasonCodes.push('multiple_starts_conflict');
|
|
527
|
+
}
|
|
528
|
+
if (countDistinctIdentities(terminals) > 1) {
|
|
529
|
+
classification = 'conflicted';
|
|
530
|
+
reasonCodes.push('conflicting_terminal_states');
|
|
531
|
+
}
|
|
532
|
+
const comparison = compareTargets(opens, terminals);
|
|
533
|
+
targetConsistent = comparison.consistent;
|
|
534
|
+
if (comparison.mismatched) {
|
|
535
|
+
classification = 'conflicted';
|
|
536
|
+
reasonCodes.push('target_identity_mismatch');
|
|
537
|
+
unknowns.push('目标一致性校验失败:同一 hookInvocationId 的 artifactId/activationId 不一致');
|
|
538
|
+
}
|
|
539
|
+
else if (comparison.consistent === null) {
|
|
540
|
+
unknowns.push('目标身份不可比对(artifactId/activationId 缺失)');
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
let terminalVariantsOmitted = 0;
|
|
544
|
+
let terminalVariants = [];
|
|
545
|
+
if (terminals.length > 1) {
|
|
546
|
+
let extraTerminalVariants = terminals.slice(1);
|
|
547
|
+
const allowedExtras = Math.max(0, maxTerminalVariants - 1);
|
|
548
|
+
if (extraTerminalVariants.length > allowedExtras) {
|
|
549
|
+
terminalVariantsOmitted = extraTerminalVariants.length - allowedExtras;
|
|
550
|
+
extraTerminalVariants = extraTerminalVariants.slice(0, allowedExtras);
|
|
551
|
+
reasonCodes.push('terminal_variants_truncated');
|
|
552
|
+
reasonCodes.push('output_truncated');
|
|
553
|
+
unknowns.push(`终态变体超过每调用上限,已省略 ${terminalVariantsOmitted} 个;冲突结论仍保留`);
|
|
554
|
+
}
|
|
555
|
+
terminalVariants = extraTerminalVariants.map((record) => record.projection);
|
|
556
|
+
}
|
|
557
|
+
const firstRecordMs = deduped.reduce((min, record) => (record.tsMs < min ? record.tsMs : min), deduped[0]?.tsMs ?? 0);
|
|
558
|
+
const lastRecordMs = deduped.reduce((max, record) => (record.tsMs > max ? record.tsMs : max), deduped[0]?.tsMs ?? 0);
|
|
559
|
+
const invocation = {
|
|
560
|
+
invocationId: group.hookInvocationId,
|
|
561
|
+
identity: {
|
|
562
|
+
host: ADAPTIVE_JOURNAL_SUPPORTED_HOST,
|
|
563
|
+
workspaceDir: group.workspaceDir,
|
|
564
|
+
workspaceVerified: group.workspaceDir !== null,
|
|
565
|
+
sessionId: group.sessionId,
|
|
566
|
+
sessionIdSource: group.sessionIdSource,
|
|
567
|
+
runId: group.runId,
|
|
568
|
+
},
|
|
569
|
+
targetConsistent,
|
|
570
|
+
classification,
|
|
571
|
+
reasonCodes,
|
|
572
|
+
open: opens[0]?.projection ?? null,
|
|
573
|
+
terminal: terminals[0]?.projection ?? null,
|
|
574
|
+
terminalVariants,
|
|
575
|
+
terminalVariantsOmitted,
|
|
576
|
+
duplicates: { open: duplicateOpen, terminal: duplicateTerminal },
|
|
577
|
+
window: {
|
|
578
|
+
openInWindow: opens.length > 0,
|
|
579
|
+
terminalInWindow: terminals.length > 0,
|
|
580
|
+
terminalSearchComplete: coverageStatus === 'complete' && !group.fromTruncatedFile,
|
|
581
|
+
},
|
|
582
|
+
unknowns,
|
|
583
|
+
};
|
|
584
|
+
if (deduped.length === 0) {
|
|
585
|
+
invocation.unknowns.push('该调用只有无效记录,无法投影 open/terminal');
|
|
586
|
+
}
|
|
587
|
+
else if (firstRecordMs === 0 || lastRecordMs === 0) {
|
|
588
|
+
invocation.unknowns.push('记录时间不可比较');
|
|
589
|
+
}
|
|
590
|
+
return { invocation, duplicateOpen, duplicateTerminal };
|
|
591
|
+
}
|
|
592
|
+
const CLASSIFICATION_ORDER = {
|
|
593
|
+
conflicted: 0,
|
|
594
|
+
invalid: 1,
|
|
595
|
+
terminal_only: 2,
|
|
596
|
+
open_only: 3,
|
|
597
|
+
paired_open_and_terminal: 4,
|
|
598
|
+
};
|
|
599
|
+
function emptyOmittedByClassification() {
|
|
600
|
+
return {
|
|
601
|
+
paired_open_and_terminal: 0,
|
|
602
|
+
open_only: 0,
|
|
603
|
+
terminal_only: 0,
|
|
604
|
+
conflicted: 0,
|
|
605
|
+
invalid: 0,
|
|
606
|
+
};
|
|
607
|
+
}
|
|
608
|
+
export function reconcileAdaptiveJournal(input) {
|
|
609
|
+
const now = input.now instanceof Date ? input.now : new Date();
|
|
610
|
+
const generatedAt = new Date().toISOString();
|
|
611
|
+
const host = input.host ?? ADAPTIVE_JOURNAL_SUPPORTED_HOST;
|
|
612
|
+
const fail = (error) => ({
|
|
613
|
+
ok: false,
|
|
614
|
+
schemaVersion: ADAPTIVE_JOURNAL_SCHEMA_VERSION,
|
|
615
|
+
generatedAt,
|
|
616
|
+
readOnly: true,
|
|
617
|
+
error,
|
|
618
|
+
});
|
|
619
|
+
if (host !== ADAPTIVE_JOURNAL_SUPPORTED_HOST) {
|
|
620
|
+
return fail({
|
|
621
|
+
code: 'unsupported_host',
|
|
622
|
+
message: `host "${host}" is not supported by this read-only reconciler`,
|
|
623
|
+
nextAction: `本轮只支持 --host ${ADAPTIVE_JOURNAL_SUPPORTED_HOST};其它宿主需单独设计。`,
|
|
624
|
+
});
|
|
625
|
+
}
|
|
626
|
+
const limitsResult = validateLimits(input, {
|
|
627
|
+
maxDateFilesPerDir: ADAPTIVE_JOURNAL_MAX_DATE_FILES_PER_DIR,
|
|
628
|
+
maxFileBytes: ADAPTIVE_JOURNAL_MAX_FILE_BYTES,
|
|
629
|
+
maxInvocations: ADAPTIVE_JOURNAL_MAX_INVOCATIONS,
|
|
630
|
+
maxTerminalVariantsPerInvocation: ADAPTIVE_JOURNAL_MAX_TERMINAL_VARIANTS,
|
|
631
|
+
});
|
|
632
|
+
if (!limitsResult.ok)
|
|
633
|
+
return fail(limitsResult.error);
|
|
634
|
+
const windowResult = resolveWindow(input, now);
|
|
635
|
+
if (!windowResult.ok)
|
|
636
|
+
return fail(windowResult.error);
|
|
637
|
+
const workspaceDir = path.resolve(input.workspaceDir);
|
|
638
|
+
const workspaceKey = normalizeWorkspace(workspaceDir);
|
|
639
|
+
const coverage = emptyCoverage();
|
|
640
|
+
const files = selectDateFiles({
|
|
641
|
+
workspaceDir,
|
|
642
|
+
dates: windowResult.resolved.dates,
|
|
643
|
+
maxDateFilesPerDir: limitsResult.limits.maxDateFilesPerDir,
|
|
644
|
+
coverage,
|
|
645
|
+
});
|
|
646
|
+
const request = {
|
|
647
|
+
workspaceDir,
|
|
648
|
+
host: ADAPTIVE_JOURNAL_SUPPORTED_HOST,
|
|
649
|
+
window: windowResult.resolved.window,
|
|
650
|
+
logDirs: [...ADAPTIVE_JOURNAL_CANDIDATE_DIRS],
|
|
651
|
+
pairingKey: ['host', 'workspaceDir', 'sessionId', 'hookInvocationId'],
|
|
652
|
+
};
|
|
653
|
+
if (coverage.sourceDirsFound === 0) {
|
|
654
|
+
coverage.status = 'unavailable';
|
|
655
|
+
return {
|
|
656
|
+
ok: true,
|
|
657
|
+
report: {
|
|
658
|
+
schemaVersion: ADAPTIVE_JOURNAL_SCHEMA_VERSION,
|
|
659
|
+
generatedAt,
|
|
660
|
+
readOnly: true,
|
|
661
|
+
request,
|
|
662
|
+
coverage,
|
|
663
|
+
totals: unavailableTotals(),
|
|
664
|
+
invocations: [],
|
|
665
|
+
rejected: [],
|
|
666
|
+
outputTruncated: false,
|
|
667
|
+
truncation: null,
|
|
668
|
+
answers: {
|
|
669
|
+
q1_open_and_terminal: unavailableAnswer(),
|
|
670
|
+
q2_open_without_visible_terminal: unavailableAnswer(['terminal_not_visible_in_range']),
|
|
671
|
+
q3_missing_start_or_identity_or_invalid: unavailableAnswer([
|
|
672
|
+
'invalid_envelope',
|
|
673
|
+
'missing_hook_invocation_id',
|
|
674
|
+
'invalid_four_dimension_state',
|
|
675
|
+
'start_not_visible_in_range',
|
|
676
|
+
'workspace_identity_unknown',
|
|
677
|
+
]),
|
|
678
|
+
q4_duplicate_or_conflicted: unavailableAnswer([
|
|
679
|
+
'exact_duplicate_record',
|
|
680
|
+
'multiple_starts_conflict',
|
|
681
|
+
'conflicting_terminal_states',
|
|
682
|
+
'target_identity_mismatch',
|
|
683
|
+
]),
|
|
684
|
+
q5: {
|
|
685
|
+
confirmedFacts: [],
|
|
686
|
+
unknowns: ['无可读日志目录 = 未知,不等于零调用。'],
|
|
687
|
+
notes: ['只读视图:未修改任何日志。'],
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
nextAction: [
|
|
691
|
+
'未找到 .pd/logs 或 .state/logs:确认工作区路径,或先让宿主产生事件日志。',
|
|
692
|
+
],
|
|
693
|
+
},
|
|
694
|
+
};
|
|
695
|
+
}
|
|
696
|
+
const totals = emptyTotals();
|
|
697
|
+
const rejected = new Map();
|
|
698
|
+
const reject = (reasonCode, sample) => {
|
|
699
|
+
const entry = rejected.get(reasonCode) ?? { count: 0, samples: [] };
|
|
700
|
+
entry.count += 1;
|
|
701
|
+
if (entry.samples.length < MAX_REJECTED_SAMPLES)
|
|
702
|
+
entry.samples.push(sample);
|
|
703
|
+
rejected.set(reasonCode, entry);
|
|
704
|
+
totals.rejectedRecords = (totals.rejectedRecords ?? 0) + 1;
|
|
705
|
+
};
|
|
706
|
+
const groups = new Map();
|
|
707
|
+
const seenRawLines = new Set();
|
|
708
|
+
let duplicatesExact = 0;
|
|
709
|
+
const getGroup = (seed) => {
|
|
710
|
+
const { hookInvocationId, workspaceKey: recordWorkspaceKey, workspaceDir: recordWorkspaceDir, sessionId, sessionIdSource, runId, invalidReason, } = seed;
|
|
711
|
+
const key = [
|
|
712
|
+
ADAPTIVE_JOURNAL_SUPPORTED_HOST,
|
|
713
|
+
recordWorkspaceKey,
|
|
714
|
+
sessionId ?? '__unknown_session__',
|
|
715
|
+
hookInvocationId,
|
|
716
|
+
].join('|');
|
|
717
|
+
const existing = groups.get(key);
|
|
718
|
+
if (existing !== undefined) {
|
|
719
|
+
if (invalidReason !== null)
|
|
720
|
+
existing.invalidReason = invalidReason;
|
|
721
|
+
return existing;
|
|
722
|
+
}
|
|
723
|
+
const created = {
|
|
724
|
+
hookInvocationId,
|
|
725
|
+
workspaceKey: recordWorkspaceKey,
|
|
726
|
+
workspaceDir: recordWorkspaceDir,
|
|
727
|
+
sessionId,
|
|
728
|
+
sessionIdSource,
|
|
729
|
+
runId,
|
|
730
|
+
invalidReason,
|
|
731
|
+
fromTruncatedFile: false,
|
|
732
|
+
records: [],
|
|
733
|
+
};
|
|
734
|
+
groups.set(key, created);
|
|
735
|
+
return created;
|
|
736
|
+
};
|
|
737
|
+
for (const file of files) {
|
|
738
|
+
let read;
|
|
739
|
+
try {
|
|
740
|
+
read = readBoundedLines(file.absolutePath, limitsResult.limits.maxFileBytes);
|
|
741
|
+
}
|
|
742
|
+
catch (error) {
|
|
743
|
+
coverage.filesSkipped += 1;
|
|
744
|
+
coverage.errors.push({
|
|
745
|
+
logDir: file.logDir,
|
|
746
|
+
file: path.basename(file.absolutePath),
|
|
747
|
+
code: 'read_failed',
|
|
748
|
+
message: error instanceof Error ? error.message : 'unknown read failure',
|
|
749
|
+
});
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
coverage.filesRead += 1;
|
|
753
|
+
coverage.bytesRead += read.bytesRead;
|
|
754
|
+
if (read.truncated)
|
|
755
|
+
coverage.truncatedFiles.push(file.absolutePath);
|
|
756
|
+
totals.linesRead = (totals.linesRead ?? 0) + read.lines.length;
|
|
757
|
+
for (let index = 0; index < read.lines.length; index += 1) {
|
|
758
|
+
const line = read.lines[index];
|
|
759
|
+
if (line === undefined)
|
|
760
|
+
continue;
|
|
761
|
+
if (seenRawLines.has(line)) {
|
|
762
|
+
duplicatesExact += 1;
|
|
763
|
+
continue;
|
|
764
|
+
}
|
|
765
|
+
seenRawLines.add(line);
|
|
766
|
+
const lineOrdinal = index + 1;
|
|
767
|
+
let parsed;
|
|
768
|
+
try {
|
|
769
|
+
parsed = JSON.parse(line);
|
|
770
|
+
}
|
|
771
|
+
catch {
|
|
772
|
+
totals.malformedJsonLines = (totals.malformedJsonLines ?? 0) + 1;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
if (!isRecord(parsed)) {
|
|
776
|
+
totals.invalidEnvelopeRecords = (totals.invalidEnvelopeRecords ?? 0) + 1;
|
|
777
|
+
reject('invalid_envelope', { logDir: file.logDir, file: path.basename(file.absolutePath), line: lineOrdinal, hookInvocationId: null });
|
|
778
|
+
continue;
|
|
779
|
+
}
|
|
780
|
+
if (parsed.type !== 'adaptive_delivery') {
|
|
781
|
+
totals.nonAdaptiveLines = (totals.nonAdaptiveLines ?? 0) + 1;
|
|
782
|
+
continue;
|
|
783
|
+
}
|
|
784
|
+
totals.adaptiveRecords = (totals.adaptiveRecords ?? 0) + 1;
|
|
785
|
+
const ts = optionalString(parsed.ts);
|
|
786
|
+
const tsMs = ts === null ? null : parseIso(ts);
|
|
787
|
+
const data = isRecord(parsed.data) ? parsed.data : null;
|
|
788
|
+
if (ts === null || tsMs === null || data === null) {
|
|
789
|
+
totals.invalidEnvelopeRecords = (totals.invalidEnvelopeRecords ?? 0) + 1;
|
|
790
|
+
reject('invalid_envelope', { logDir: file.logDir, file: path.basename(file.absolutePath), line: lineOrdinal, hookInvocationId: null });
|
|
791
|
+
continue;
|
|
792
|
+
}
|
|
793
|
+
if (tsMs < windowResult.resolved.startMs || tsMs > windowResult.resolved.endMs)
|
|
794
|
+
continue;
|
|
795
|
+
const recordHost = optionalString(data.host);
|
|
796
|
+
if (recordHost !== ADAPTIVE_JOURNAL_SUPPORTED_HOST) {
|
|
797
|
+
reject('host_identity_unknown', { logDir: file.logDir, file: path.basename(file.absolutePath), line: lineOrdinal, hookInvocationId: optionalString(data.hookInvocationId) });
|
|
798
|
+
continue;
|
|
799
|
+
}
|
|
800
|
+
const recordedWorkspace = optionalString(data.workspaceDir);
|
|
801
|
+
if (recordedWorkspace !== null && normalizeWorkspace(recordedWorkspace) !== workspaceKey) {
|
|
802
|
+
reject('foreign_workspace', { logDir: file.logDir, file: path.basename(file.absolutePath), line: lineOrdinal, hookInvocationId: optionalString(data.hookInvocationId) });
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const recordWorkspaceDir = recordedWorkspace === null ? null : workspaceDir;
|
|
806
|
+
const recordWorkspaceKey = recordedWorkspace === null ? '__unknown_workspace__' : workspaceKey;
|
|
807
|
+
const envelopeSession = optionalString(parsed.sessionId);
|
|
808
|
+
const dataSession = optionalString(data.sessionId);
|
|
809
|
+
let sessionId = envelopeSession ?? dataSession;
|
|
810
|
+
let sessionIdSource = 'none';
|
|
811
|
+
if (envelopeSession !== null && dataSession !== null)
|
|
812
|
+
sessionIdSource = 'both';
|
|
813
|
+
else if (envelopeSession !== null)
|
|
814
|
+
sessionIdSource = 'envelope';
|
|
815
|
+
else if (dataSession !== null)
|
|
816
|
+
sessionIdSource = 'data';
|
|
817
|
+
let invalidReason = null;
|
|
818
|
+
if (envelopeSession !== null && dataSession !== null && envelopeSession !== dataSession) {
|
|
819
|
+
sessionId = envelopeSession;
|
|
820
|
+
invalidReason = 'session_identity_mismatch';
|
|
821
|
+
}
|
|
822
|
+
const hookInvocationId = optionalString(data.hookInvocationId);
|
|
823
|
+
if (hookInvocationId === null) {
|
|
824
|
+
totals.invalidRecords = (totals.invalidRecords ?? 0) + 1;
|
|
825
|
+
reject('missing_hook_invocation_id', { logDir: file.logDir, file: path.basename(file.absolutePath), line: lineOrdinal, hookInvocationId: null });
|
|
826
|
+
continue;
|
|
827
|
+
}
|
|
828
|
+
const validation = validateAdaptiveDeliveryRecord(data);
|
|
829
|
+
const runId = optionalString(data.runId);
|
|
830
|
+
if (!validation.ok) {
|
|
831
|
+
totals.invalidRecords = (totals.invalidRecords ?? 0) + 1;
|
|
832
|
+
getGroup({
|
|
833
|
+
hookInvocationId,
|
|
834
|
+
workspaceKey: recordWorkspaceKey,
|
|
835
|
+
workspaceDir: recordWorkspaceDir,
|
|
836
|
+
sessionId,
|
|
837
|
+
sessionIdSource,
|
|
838
|
+
runId,
|
|
839
|
+
invalidReason: 'invalid_four_dimension_state',
|
|
840
|
+
});
|
|
841
|
+
continue;
|
|
842
|
+
}
|
|
843
|
+
const projection = buildProjection(data, ts);
|
|
844
|
+
const record = {
|
|
845
|
+
identityKey: '',
|
|
846
|
+
ts,
|
|
847
|
+
tsMs,
|
|
848
|
+
state: projection.receiptState,
|
|
849
|
+
data,
|
|
850
|
+
projection,
|
|
851
|
+
fromTruncatedFile: read.truncated,
|
|
852
|
+
};
|
|
853
|
+
record.identityKey = identityKey(record);
|
|
854
|
+
if (record.identityKey === null)
|
|
855
|
+
coverage.identityComparisonLimited = true;
|
|
856
|
+
const group = getGroup({
|
|
857
|
+
hookInvocationId,
|
|
858
|
+
workspaceKey: recordWorkspaceKey,
|
|
859
|
+
workspaceDir: recordWorkspaceDir,
|
|
860
|
+
sessionId,
|
|
861
|
+
sessionIdSource,
|
|
862
|
+
runId,
|
|
863
|
+
invalidReason,
|
|
864
|
+
});
|
|
865
|
+
group.records.push(record);
|
|
866
|
+
if (read.truncated)
|
|
867
|
+
group.fromTruncatedFile = true;
|
|
868
|
+
if (projection.receiptState !== 'open')
|
|
869
|
+
group.runId = group.runId ?? runId;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
coverage.status = coverage.limitCode !== null || coverage.errors.length > 0 || coverage.truncatedFiles.length > 0
|
|
873
|
+
? 'partial'
|
|
874
|
+
: 'complete';
|
|
875
|
+
const invocations = [];
|
|
876
|
+
for (const group of groups.values()) {
|
|
877
|
+
invocations.push(classifyGroup({
|
|
878
|
+
group,
|
|
879
|
+
window: windowResult.resolved.window,
|
|
880
|
+
coverageStatus: coverage.status,
|
|
881
|
+
maxTerminalVariants: limitsResult.limits.maxTerminalVariantsPerInvocation,
|
|
882
|
+
}).invocation);
|
|
883
|
+
}
|
|
884
|
+
invocations.sort((left, right) => {
|
|
885
|
+
const byClass = CLASSIFICATION_ORDER[left.classification] - CLASSIFICATION_ORDER[right.classification];
|
|
886
|
+
if (byClass !== 0)
|
|
887
|
+
return byClass;
|
|
888
|
+
const leftStart = left.open?.ts ?? left.terminal?.ts ?? '';
|
|
889
|
+
const rightStart = right.open?.ts ?? right.terminal?.ts ?? '';
|
|
890
|
+
return leftStart.localeCompare(rightStart);
|
|
891
|
+
});
|
|
892
|
+
totals.duplicatesExact = duplicatesExact + invocations.reduce((sum, item) => sum + item.duplicates.open + item.duplicates.terminal, 0);
|
|
893
|
+
const outputLimit = limitsResult.limits.maxInvocations;
|
|
894
|
+
const omittedByClassification = emptyOmittedByClassification();
|
|
895
|
+
let kept = invocations;
|
|
896
|
+
let truncatedInvocations = 0;
|
|
897
|
+
if (invocations.length > outputLimit) {
|
|
898
|
+
const omitted = invocations.slice(outputLimit);
|
|
899
|
+
for (const invocation of omitted)
|
|
900
|
+
omittedByClassification[invocation.classification] += 1;
|
|
901
|
+
kept = invocations.slice(0, outputLimit);
|
|
902
|
+
truncatedInvocations = omitted.length;
|
|
903
|
+
}
|
|
904
|
+
const terminalVariantsOmitted = invocations.reduce((sum, item) => sum + item.terminalVariantsOmitted, 0);
|
|
905
|
+
const variantsTruncated = terminalVariantsOmitted > 0;
|
|
906
|
+
const outputTruncated = truncatedInvocations > 0 || variantsTruncated;
|
|
907
|
+
const countsAreLowerBounds = truncatedInvocations > 0
|
|
908
|
+
|| variantsTruncated
|
|
909
|
+
|| coverage.limitCode !== null
|
|
910
|
+
|| coverage.truncatedFiles.length > 0
|
|
911
|
+
|| coverage.errors.length > 0;
|
|
912
|
+
totals.uniqueInvocations = invocations.length;
|
|
913
|
+
totals.countsAreLowerBounds = countsAreLowerBounds;
|
|
914
|
+
const countBasis = coverage.status === 'complete' && !countsAreLowerBounds ? 'exact' : 'lower_bound';
|
|
915
|
+
const q1Items = kept.filter((item) => item.classification === 'paired_open_and_terminal');
|
|
916
|
+
const q2Items = kept.filter((item) => item.classification === 'open_only');
|
|
917
|
+
const q3Invocations = kept.filter((item) => item.classification === 'terminal_only'
|
|
918
|
+
|| item.classification === 'invalid'
|
|
919
|
+
|| item.reasonCodes.includes('workspace_identity_unknown'));
|
|
920
|
+
const q4Items = kept.filter((item) => item.classification === 'conflicted'
|
|
921
|
+
|| item.duplicates.open > 0
|
|
922
|
+
|| item.duplicates.terminal > 0);
|
|
923
|
+
const rejectedCount = totals.rejectedRecords ?? 0;
|
|
924
|
+
const q3ReasonCodes = new Set(q3Invocations.flatMap((item) => item.reasonCodes));
|
|
925
|
+
for (const reasonCode of rejected.keys())
|
|
926
|
+
q3ReasonCodes.add(reasonCode);
|
|
927
|
+
const q4ReasonCodes = new Set(q4Items.flatMap((item) => item.reasonCodes));
|
|
928
|
+
const nextAction = [];
|
|
929
|
+
const suggestions = [];
|
|
930
|
+
if (truncatedInvocations > 0) {
|
|
931
|
+
suggestions.push('缩小窗口:--since/--until');
|
|
932
|
+
suggestions.push('按 host 或 session 分片');
|
|
933
|
+
nextAction.push(`输出截断:${truncatedInvocations} 个调用未输出;缩小窗口或按 host/session 分片后重试。`);
|
|
934
|
+
}
|
|
935
|
+
if (terminalVariantsOmitted > 0) {
|
|
936
|
+
suggestions.push('提高每调用终态变体上限后重试');
|
|
937
|
+
nextAction.push(`终态变体截断:${terminalVariantsOmitted} 个终态未输出;冲突结论仍保留,必要时提高上限。`);
|
|
938
|
+
}
|
|
939
|
+
if (coverage.limitCode !== null) {
|
|
940
|
+
suggestions.push('缩小窗口或提高日期文件上限后重试');
|
|
941
|
+
nextAction.push(`覆盖不完整:${coverage.filesOmitted.length} 个相交日期文件未读取(coverage_file_limit_reached)。`);
|
|
942
|
+
}
|
|
943
|
+
if (coverage.errors.length > 0) {
|
|
944
|
+
nextAction.push('覆盖不完整:存在无法读取或越界的日志;检查文件权限与工作区路径。');
|
|
945
|
+
}
|
|
946
|
+
if (coverage.truncatedFiles.length > 0) {
|
|
947
|
+
suggestions.push('缩小窗口后重试(单文件超过 4 MiB 只读取了尾部)');
|
|
948
|
+
nextAction.push(`覆盖不完整:${coverage.truncatedFiles.length} 个文件只读取了尾部,统计为下界。`);
|
|
949
|
+
}
|
|
950
|
+
if (coverage.identityComparisonLimited) {
|
|
951
|
+
suggestions.push('缩小单条记录内容或降低 maxFileBytes 后重试');
|
|
952
|
+
nextAction.push('重复判定受限:部分记录的规范化内容超出比较预算,已保守地按不同记录处理,不能据此确认完全重复。');
|
|
953
|
+
}
|
|
954
|
+
const confirmedFacts = [];
|
|
955
|
+
if (coverage.filesRead === 0) {
|
|
956
|
+
confirmedFacts.push('窗口内没有读到任何日志文件。');
|
|
957
|
+
}
|
|
958
|
+
else {
|
|
959
|
+
confirmedFacts.push(`读取 ${coverage.filesRead} 个日期文件,共扫描 ${totals.linesRead ?? 0} 行;识别 ${totals.adaptiveRecords ?? 0} 条 adaptive_delivery 记录。`);
|
|
960
|
+
}
|
|
961
|
+
confirmedFacts.push(`配对 ${q1Items.length} 个调用;配对成立不等于执行成功(closed/reconciled 只是记录声明的阶段)。`);
|
|
962
|
+
if ((totals.duplicatesExact ?? 0) > 0)
|
|
963
|
+
confirmedFacts.push(`去重 ${totals.duplicatesExact} 条完全重复记录(保留首条)。`);
|
|
964
|
+
if (q4Items.length > 0)
|
|
965
|
+
confirmedFacts.push(`发现 ${q4Items.length} 个冲突或重复相关的调用。`);
|
|
966
|
+
const unknowns = [
|
|
967
|
+
'无日志 = 未知,不等于零调用。',
|
|
968
|
+
'未找到终态只表示当前范围内不可见,不表示终态发生在窗口之后。',
|
|
969
|
+
];
|
|
970
|
+
if (!windowResult.resolved.window.endIsNow) {
|
|
971
|
+
unknowns.push('窗口终点由 --until 显式指定,并非 now;窗口外的记录不参与本报告。');
|
|
972
|
+
}
|
|
973
|
+
if (coverage.limitCode !== null)
|
|
974
|
+
unknowns.push('日期文件读取达到上限,部分相交日期文件未纳入。');
|
|
975
|
+
if (coverage.truncatedFiles.length > 0)
|
|
976
|
+
unknowns.push('存在被截断的文件,更早的记录可能不可见。');
|
|
977
|
+
if (coverage.errors.length > 0)
|
|
978
|
+
unknowns.push('存在无法读取或越界的日志目录,覆盖不完整。');
|
|
979
|
+
if (coverage.identityComparisonLimited)
|
|
980
|
+
unknowns.push('存在超出比较预算的记录:完全相同性未被证明,已保守地按不同记录处理。');
|
|
981
|
+
if (q2Items.length > 0)
|
|
982
|
+
unknowns.push(`${q2Items.length} 个调用在当前范围内未找到终态。`);
|
|
983
|
+
unknowns.push('本工具不重新认证记录中的独立验证标志,只按记录原样转录。');
|
|
984
|
+
const notes = [
|
|
985
|
+
'只读视图:未修改任何日志;只读取两个候选目录中与窗口相交的日期文件。',
|
|
986
|
+
`生效窗口:${windowResult.resolved.window.start} ~ ${windowResult.resolved.window.end}(窗口终点${windowResult.resolved.window.endIsNow ? '为 now' : '由 --until 指定'})。`,
|
|
987
|
+
];
|
|
988
|
+
if (windowResult.resolved.window.clampedByRetention)
|
|
989
|
+
notes.push('请求的 --since 早于日志保留边界,已截断到保留边界。');
|
|
990
|
+
if (outputTruncated)
|
|
991
|
+
notes.push('输出被截断,结论仅为已输出子集;计数为下界。');
|
|
992
|
+
if (coverage.status === 'partial')
|
|
993
|
+
notes.push('覆盖不完整:结论与计数仅为当前读取范围的下界。');
|
|
994
|
+
if (coverage.identityComparisonLimited)
|
|
995
|
+
notes.push('重复判定受限:部分记录规范化内容超出比较预算,完全相同性未被证明,已保守地按不同记录处理(coverage.identityComparisonLimited=true)。');
|
|
996
|
+
const report = {
|
|
997
|
+
schemaVersion: ADAPTIVE_JOURNAL_SCHEMA_VERSION,
|
|
998
|
+
generatedAt,
|
|
999
|
+
readOnly: true,
|
|
1000
|
+
request,
|
|
1001
|
+
coverage,
|
|
1002
|
+
totals,
|
|
1003
|
+
invocations: kept,
|
|
1004
|
+
rejected: [...rejected.entries()]
|
|
1005
|
+
.map(([reasonCode, entry]) => ({ reasonCode, count: entry.count, samples: entry.samples }))
|
|
1006
|
+
.sort((left, right) => right.count - left.count || left.reasonCode.localeCompare(right.reasonCode)),
|
|
1007
|
+
outputTruncated,
|
|
1008
|
+
truncation: outputTruncated
|
|
1009
|
+
? {
|
|
1010
|
+
reason: 'output_truncated',
|
|
1011
|
+
limit: outputLimit,
|
|
1012
|
+
included: kept.length,
|
|
1013
|
+
omitted: truncatedInvocations,
|
|
1014
|
+
omittedByClassification,
|
|
1015
|
+
variantLimitPerInvocation: limitsResult.limits.maxTerminalVariantsPerInvocation,
|
|
1016
|
+
terminalVariantsOmitted,
|
|
1017
|
+
coverageLimitCode: coverage.limitCode,
|
|
1018
|
+
suggestions,
|
|
1019
|
+
}
|
|
1020
|
+
: null,
|
|
1021
|
+
answers: {
|
|
1022
|
+
q1_open_and_terminal: {
|
|
1023
|
+
count: q1Items.length,
|
|
1024
|
+
countBasis,
|
|
1025
|
+
invocationIds: q1Items.map((item) => item.invocationId),
|
|
1026
|
+
},
|
|
1027
|
+
q2_open_without_visible_terminal: {
|
|
1028
|
+
count: q2Items.length,
|
|
1029
|
+
countBasis,
|
|
1030
|
+
invocationIds: q2Items.map((item) => item.invocationId),
|
|
1031
|
+
reasonCodes: [...new Set(q2Items.flatMap((item) => item.reasonCodes))].sort(),
|
|
1032
|
+
},
|
|
1033
|
+
q3_missing_start_or_identity_or_invalid: {
|
|
1034
|
+
count: q3Invocations.length + rejectedCount,
|
|
1035
|
+
countBasis,
|
|
1036
|
+
invocationIds: q3Invocations.map((item) => item.invocationId),
|
|
1037
|
+
reasonCodes: [...q3ReasonCodes].sort(),
|
|
1038
|
+
},
|
|
1039
|
+
q4_duplicate_or_conflicted: {
|
|
1040
|
+
count: q4Items.length + (totals.duplicatesExact ?? 0),
|
|
1041
|
+
countBasis,
|
|
1042
|
+
invocationIds: q4Items.map((item) => item.invocationId),
|
|
1043
|
+
reasonCodes: [...q4ReasonCodes].sort(),
|
|
1044
|
+
},
|
|
1045
|
+
q5: { confirmedFacts, unknowns, notes },
|
|
1046
|
+
},
|
|
1047
|
+
nextAction,
|
|
1048
|
+
};
|
|
1049
|
+
return { ok: true, report };
|
|
1050
|
+
}
|