@akagilnc/pi-workflow-roles 0.1.5207 → 0.1.5216
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/acp-host/production-host.js +163 -709
- package/dist/headless-host/production-host.js +139 -685
- package/dist/migrate-book-topology.js +0 -1
- package/dist/public-cli/main.js +0 -2
- package/dist/sitian-appender.js +0 -1
- package/dist/submission-ledger.js +8 -29
- package/dist/ticket-provenance-contracts.js +4 -42
- package/dist/ticket-provenance.js +27 -456
- package/package.json +2 -3
- package/scripts/build-package.mjs +1 -3
- package/src/diarist.ts +5 -5
- package/src/host-contracts.ts +0 -100
- package/src/role-runtime.ts +4 -57
- package/src/sitian-appender.ts +0 -1
- package/src/sitian-contracts.ts +0 -3
- package/src/submission-ledger.ts +10 -31
- package/src/ticket-provenance-contracts.ts +10 -48
- package/src/ticket-provenance.ts +33 -499
- package/dist/pi/in-process-session.js +0 -660
- package/dist/stream-idle-guard.js +0 -67
- package/schemas/tool-execution-observation.schema.json +0 -107
- package/scripts/generate-tool-execution-observation-schema.ts +0 -7
- package/src/in-process-session.ts +0 -1
- package/src/pi/in-process-session.ts +0 -861
- package/src/stream-idle-guard.ts +0 -98
- package/src/tool-execution-observation.ts +0 -168
package/src/ticket-provenance.ts
CHANGED
|
@@ -1,20 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 起居录 volume helpers — ADR 0075
|
|
2
|
+
* 起居录 volume helpers — ADR 0075 / ADR 0081 / ADR 0086 / #1090.
|
|
3
3
|
* 一册=一个追加式 records.jsonl。每轮新投影经司天台 appender 追加为不可变提交;
|
|
4
|
-
*
|
|
5
|
-
* snapshot 继续可读,但后续不为升级回写旧卷。
|
|
4
|
+
* 纯追加、不回读历史去重、不折叠。
|
|
6
5
|
*/
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
8
|
-
import { appendFileSync } from "node:fs";
|
|
9
6
|
import { readFile, unlink } from "node:fs/promises";
|
|
10
7
|
import { basename, dirname, join, resolve } from "node:path";
|
|
11
8
|
|
|
12
|
-
import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
|
|
13
9
|
import {
|
|
14
|
-
ensureRealDirectoryTree,
|
|
15
10
|
errnoCode,
|
|
16
11
|
packageMachineHome,
|
|
17
|
-
physicalPathIdentity,
|
|
18
12
|
physicallyContainedIn,
|
|
19
13
|
resolveActivationLedgerHome,
|
|
20
14
|
} from "./activation-ledger-topology.ts";
|
|
@@ -32,11 +26,7 @@ import {
|
|
|
32
26
|
} from "./sitian-facade.ts";
|
|
33
27
|
import {
|
|
34
28
|
TICKET_PROVENANCE_KIND,
|
|
35
|
-
projectTicketProvenanceHeader,
|
|
36
|
-
projectTicketProvenanceLine,
|
|
37
|
-
projectTicketProvenanceSessions,
|
|
38
29
|
type TicketProvenanceBound,
|
|
39
|
-
type TicketProvenanceHeader,
|
|
40
30
|
type TicketProvenanceLine,
|
|
41
31
|
type TicketProvenanceRange,
|
|
42
32
|
type TicketProvenanceSession,
|
|
@@ -61,7 +51,7 @@ export class TicketProvenanceInputError extends Error {
|
|
|
61
51
|
* Host-owned session source roots (ADR 0038 / ADR 0081 cross-host).
|
|
62
52
|
* Narrow directory identities — not whole `.pi` / whole `.ak-roles`.
|
|
63
53
|
*/
|
|
64
|
-
|
|
54
|
+
function dialogueSessionSourceRoots(home?: string): readonly string[] {
|
|
65
55
|
const machineHome =
|
|
66
56
|
typeof home === "string" && home.trim() !== ""
|
|
67
57
|
? home
|
|
@@ -103,7 +93,7 @@ function assertDialogueSessionSourcePath(path: string, home?: string): void {
|
|
|
103
93
|
}
|
|
104
94
|
|
|
105
95
|
/** Subject string for ticket-keyed volumes — history follows the ticket. */
|
|
106
|
-
|
|
96
|
+
function ticketProvenanceSubject(ticketNumber: number): string {
|
|
107
97
|
if (!isSafePositiveTicketNumber(ticketNumber)) {
|
|
108
98
|
throw new Error(
|
|
109
99
|
`ticket-provenance subject requires a positive ticket number, got ${String(ticketNumber)}`,
|
|
@@ -153,223 +143,6 @@ type TicketProvenanceRaw = {
|
|
|
153
143
|
readonly sourceIdentity?: string;
|
|
154
144
|
};
|
|
155
145
|
|
|
156
|
-
type TicketProvenanceCommit = {
|
|
157
|
-
readonly timestamp: string;
|
|
158
|
-
readonly sessions: readonly TicketProvenanceSession[];
|
|
159
|
-
readonly lines: readonly (TicketProvenanceLine | TicketProvenanceRaw | string)[];
|
|
160
|
-
};
|
|
161
|
-
|
|
162
|
-
function projectTicketProvenanceRaw(value: unknown): TicketProvenanceRaw | undefined {
|
|
163
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
164
|
-
const raw = value as Record<string, unknown>;
|
|
165
|
-
if (typeof raw.raw !== "string" || !Number.isInteger(raw.s) || (raw.s as number) < 0 ||
|
|
166
|
-
!Number.isInteger(raw.sourcePosition) || (raw.sourcePosition as number) < 0) {
|
|
167
|
-
return undefined;
|
|
168
|
-
}
|
|
169
|
-
const sourceIdentity = typeof raw.sourceIdentity === "string" && raw.sourceIdentity !== ""
|
|
170
|
-
? raw.sourceIdentity
|
|
171
|
-
: undefined;
|
|
172
|
-
return {
|
|
173
|
-
raw: raw.raw,
|
|
174
|
-
s: raw.s as number,
|
|
175
|
-
sourcePosition: raw.sourcePosition as number,
|
|
176
|
-
...(sourceIdentity === undefined ? {} : { sourceIdentity }),
|
|
177
|
-
};
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function projectTicketProvenanceCommit(value: unknown): TicketProvenanceCommit | undefined {
|
|
181
|
-
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
182
|
-
const record = value as Record<string, unknown>;
|
|
183
|
-
if (record.kind !== TICKET_PROVENANCE_KIND || typeof record.timestamp !== "string") {
|
|
184
|
-
return undefined;
|
|
185
|
-
}
|
|
186
|
-
const payload = record.payload;
|
|
187
|
-
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
|
|
188
|
-
const body = payload as Record<string, unknown>;
|
|
189
|
-
if (body.type !== "ticket-provenance-append") return undefined;
|
|
190
|
-
const sessions = projectTicketProvenanceSessions(body.sessions);
|
|
191
|
-
if (sessions === undefined || !Array.isArray(body.lines)) return undefined;
|
|
192
|
-
const lines: (TicketProvenanceLine | TicketProvenanceRaw | string)[] = [];
|
|
193
|
-
for (const raw of body.lines) {
|
|
194
|
-
if (typeof raw === "string") { lines.push(raw); continue; }
|
|
195
|
-
const line = projectTicketProvenanceLine(raw);
|
|
196
|
-
if (line !== undefined) { lines.push(line); continue; }
|
|
197
|
-
const preserved = projectTicketProvenanceRaw(raw);
|
|
198
|
-
if (preserved === undefined) return undefined;
|
|
199
|
-
lines.push(preserved);
|
|
200
|
-
}
|
|
201
|
-
return { timestamp: record.timestamp, sessions, lines };
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
export type ReadTicketProvenanceResult = {
|
|
205
|
-
readonly header: TicketProvenanceHeader | undefined;
|
|
206
|
-
readonly lines: readonly TicketProvenanceLine[];
|
|
207
|
-
/**
|
|
208
|
-
* Body rows that could not be projected (bad JSON or unusable shape).
|
|
209
|
-
* Kept byte-stable across lawful rewrites — 证不出的原样留存 (#918 / G8).
|
|
210
|
-
*/
|
|
211
|
-
readonly unprojectedRaw: readonly string[];
|
|
212
|
-
readonly recordFile: string;
|
|
213
|
-
/** Internal cumulative coverage truth, keyed by normalized session index. */
|
|
214
|
-
readonly sourceIdentities: ReadonlyMap<number, ReadonlySet<string>>;
|
|
215
|
-
/** Upgrade fallback for records written before sourceIdentity existed. */
|
|
216
|
-
readonly legacySourcePositions: ReadonlyMap<number, ReadonlySet<number>>;
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
/** Read the unique diary file (empty/absent → no header, no lines). */
|
|
220
|
-
export async function readTicketProvenance(
|
|
221
|
-
ticketNumber: number | null,
|
|
222
|
-
cwd: string,
|
|
223
|
-
home?: string,
|
|
224
|
-
runDirectory?: string,
|
|
225
|
-
): Promise<ReadTicketProvenanceResult> {
|
|
226
|
-
const { recordFile } = ticketNumber === null
|
|
227
|
-
? resolveSitianRecordPath(ticketProvenanceRecordInput(null, cwd, home, runDirectory))
|
|
228
|
-
: resolveTicketProvenanceVolume(ticketNumber, cwd, home);
|
|
229
|
-
let text: string;
|
|
230
|
-
try {
|
|
231
|
-
text = await readFile(recordFile, "utf8");
|
|
232
|
-
} catch (error) {
|
|
233
|
-
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
234
|
-
return {
|
|
235
|
-
header: undefined,
|
|
236
|
-
lines: [],
|
|
237
|
-
unprojectedRaw: [],
|
|
238
|
-
recordFile,
|
|
239
|
-
sourceIdentities: new Map(),
|
|
240
|
-
legacySourcePositions: new Map(),
|
|
241
|
-
};
|
|
242
|
-
}
|
|
243
|
-
throw error;
|
|
244
|
-
}
|
|
245
|
-
const physical = text.split("\n");
|
|
246
|
-
let header: TicketProvenanceHeader | undefined;
|
|
247
|
-
const lines: TicketProvenanceLine[] = [];
|
|
248
|
-
const unprojectedRaw: string[] = [];
|
|
249
|
-
const sourceIdentities = new Map<number, Set<string>>();
|
|
250
|
-
const legacySourcePositions = new Map<number, Set<number>>();
|
|
251
|
-
const rememberIdentity = (s: number, identity: string): boolean => {
|
|
252
|
-
const seen = sourceIdentities.get(s) ?? new Set<string>();
|
|
253
|
-
const fresh = !seen.has(identity);
|
|
254
|
-
seen.add(identity);
|
|
255
|
-
sourceIdentities.set(s, seen);
|
|
256
|
-
return fresh;
|
|
257
|
-
};
|
|
258
|
-
const rememberLegacyPosition = (s: number, position: number): void => {
|
|
259
|
-
const seen = legacySourcePositions.get(s) ?? new Set<number>();
|
|
260
|
-
seen.add(position);
|
|
261
|
-
legacySourcePositions.set(s, seen);
|
|
262
|
-
};
|
|
263
|
-
const remapCoverage = <T>(
|
|
264
|
-
coverage: Map<number, Set<T>>,
|
|
265
|
-
priorIndexes: readonly number[],
|
|
266
|
-
): void => {
|
|
267
|
-
const normalized = new Map<number, Set<T>>();
|
|
268
|
-
for (const [s, values] of coverage) {
|
|
269
|
-
const target = priorIndexes[s] ?? s;
|
|
270
|
-
const union = normalized.get(target) ?? new Set<T>();
|
|
271
|
-
for (const value of values) union.add(value);
|
|
272
|
-
normalized.set(target, union);
|
|
273
|
-
}
|
|
274
|
-
coverage.clear();
|
|
275
|
-
for (const [s, values] of normalized) coverage.set(s, values);
|
|
276
|
-
};
|
|
277
|
-
let sawFirst = false;
|
|
278
|
-
for (let index = 0; index < physical.length; index += 1) {
|
|
279
|
-
const raw = physical[index]!;
|
|
280
|
-
if (!raw.trim()) continue;
|
|
281
|
-
let parsed: unknown;
|
|
282
|
-
try {
|
|
283
|
-
parsed = JSON.parse(raw);
|
|
284
|
-
} catch {
|
|
285
|
-
// Stock / damaged rows stay unprojected — keep raw bytes on rewrite.
|
|
286
|
-
unprojectedRaw.push(raw);
|
|
287
|
-
continue;
|
|
288
|
-
}
|
|
289
|
-
if (!sawFirst) {
|
|
290
|
-
sawFirst = true;
|
|
291
|
-
header = projectTicketProvenanceHeader(parsed);
|
|
292
|
-
// Legacy snapshot: first line header followed by bare dialogue rows.
|
|
293
|
-
if (header !== undefined) continue;
|
|
294
|
-
}
|
|
295
|
-
const line = projectTicketProvenanceLine(parsed);
|
|
296
|
-
if (line !== undefined) {
|
|
297
|
-
lines.push(line);
|
|
298
|
-
continue;
|
|
299
|
-
}
|
|
300
|
-
const commit = projectTicketProvenanceCommit(parsed);
|
|
301
|
-
if (commit !== undefined) {
|
|
302
|
-
const merged = mergeSessionBounds(header?.sessions, commit.sessions);
|
|
303
|
-
remapCoverage(sourceIdentities, merged.priorIndexes);
|
|
304
|
-
remapCoverage(legacySourcePositions, merged.priorIndexes);
|
|
305
|
-
const remapped: TicketProvenanceLine[] = [];
|
|
306
|
-
for (const entry of commit.lines) {
|
|
307
|
-
if (typeof entry === "string") { unprojectedRaw.push(entry); continue; }
|
|
308
|
-
const s = merged.incomingIndexes[entry.s] ?? merged.priorIndexes[entry.s] ?? entry.s;
|
|
309
|
-
if ("raw" in entry) {
|
|
310
|
-
if (entry.sourceIdentity !== undefined) {
|
|
311
|
-
if (rememberIdentity(s, entry.sourceIdentity)) unprojectedRaw.push(entry.raw);
|
|
312
|
-
} else {
|
|
313
|
-
rememberLegacyPosition(s, entry.sourcePosition);
|
|
314
|
-
unprojectedRaw.push(entry.raw);
|
|
315
|
-
}
|
|
316
|
-
continue;
|
|
317
|
-
}
|
|
318
|
-
remapped.push({ ...entry, s });
|
|
319
|
-
if (entry.id === undefined) {
|
|
320
|
-
if (entry.sourceIdentity !== undefined) rememberIdentity(s, entry.sourceIdentity);
|
|
321
|
-
else if (entry.sourcePosition !== undefined) rememberLegacyPosition(s, entry.sourcePosition);
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
const now = commit.timestamp;
|
|
325
|
-
header = {
|
|
326
|
-
repo: header?.repo ?? resolveBookKeyFromGit(cwd),
|
|
327
|
-
ticket: ticketNumber,
|
|
328
|
-
createdAt: header?.createdAt ?? now,
|
|
329
|
-
updatedAt: header?.updatedAt !== undefined && header.updatedAt > now ? header.updatedAt : now,
|
|
330
|
-
sessions: merged.sessions,
|
|
331
|
-
};
|
|
332
|
-
const carried = lines.map((entry) => ({
|
|
333
|
-
...entry,
|
|
334
|
-
s: merged.priorIndexes[entry.s] ?? entry.s,
|
|
335
|
-
}));
|
|
336
|
-
lines.splice(0, lines.length, ...mergeFreshIntoCarried(carried, remapped));
|
|
337
|
-
continue;
|
|
338
|
-
}
|
|
339
|
-
// Unknown stock / damaged rows remain readable and are never upgraded in place.
|
|
340
|
-
unprojectedRaw.push(raw);
|
|
341
|
-
}
|
|
342
|
-
for (const line of lines) {
|
|
343
|
-
if (line.id !== undefined) continue;
|
|
344
|
-
if (line.sourceIdentity !== undefined) rememberIdentity(line.s, line.sourceIdentity);
|
|
345
|
-
else if (line.sourcePosition !== undefined) rememberLegacyPosition(line.s, line.sourcePosition);
|
|
346
|
-
}
|
|
347
|
-
return {
|
|
348
|
-
header,
|
|
349
|
-
lines,
|
|
350
|
-
unprojectedRaw,
|
|
351
|
-
recordFile,
|
|
352
|
-
sourceIdentities,
|
|
353
|
-
legacySourcePositions,
|
|
354
|
-
};
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
/**
|
|
358
|
-
* Ensure the per-ticket directory + volume file exist (ADR 0075 ticket-provenance-file).
|
|
359
|
-
* Empty file is lawful (bound court with no dialogue yet). Does not forge entries.
|
|
360
|
-
*/
|
|
361
|
-
export function ensureTicketProvenanceVolume(
|
|
362
|
-
ticketNumber: number,
|
|
363
|
-
cwd: string,
|
|
364
|
-
home?: string,
|
|
365
|
-
): TicketProvenanceVolumePath {
|
|
366
|
-
const input = ticketProvenanceRecordInput(ticketNumber, cwd, home);
|
|
367
|
-
const path = resolveSitianRecordPath(input);
|
|
368
|
-
ensureRealDirectoryTree(path.ledgerHome, path.sessionDir);
|
|
369
|
-
appendFileSync(path.recordFile, "", "utf8");
|
|
370
|
-
return { recordFile: path.recordFile, volumeDir: path.sessionDir };
|
|
371
|
-
}
|
|
372
|
-
|
|
373
146
|
/** Assign a run-owned unbound diary as its original block through Sitian. */
|
|
374
147
|
export async function rehomeUnboundTicketProvenance(
|
|
375
148
|
runDirectory: string,
|
|
@@ -391,7 +164,6 @@ export async function rehomeUnboundTicketProvenance(
|
|
|
391
164
|
|
|
392
165
|
export type ReprojectTicketProvenanceResult = {
|
|
393
166
|
readonly recordFile: string;
|
|
394
|
-
readonly header: TicketProvenanceHeader;
|
|
395
167
|
readonly lines: readonly TicketProvenanceLine[];
|
|
396
168
|
};
|
|
397
169
|
|
|
@@ -421,161 +193,6 @@ function resolveBoundIndex(
|
|
|
421
193
|
return undefined;
|
|
422
194
|
}
|
|
423
195
|
|
|
424
|
-
/** Exact range identity for cumulative header dedupe (idempotent resubmit). */
|
|
425
|
-
function dialogueIdentity(s: number, id: string): string {
|
|
426
|
-
return `${s}\u0000${id}`;
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
function rangeDeclarationKey(range: TicketProvenanceRange): string {
|
|
430
|
-
return JSON.stringify({
|
|
431
|
-
from: range.from,
|
|
432
|
-
to: range.to,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
/**
|
|
437
|
-
* #918 甲案:prior ranges ∪ 本轮 ranges。按 path 合并;先保 prior 序,再追加新 path。
|
|
438
|
-
* path identity 与 I/O 接缝同用 `physicalPathIdentity`(symlink-stable),故
|
|
439
|
-
* `p` / `p/./` / 经 symlink 祖先的别名合为一条;保留首见 path 字面与累计 ranges。
|
|
440
|
-
* 即使 prior 缺失 / sessions=[],incoming 自身也必须按 identity 归并——首轮同一
|
|
441
|
-
* 物理卷不得铸出多个 s(#918 C5)。遗漏不代表删除。
|
|
442
|
-
*/
|
|
443
|
-
function mergeSessionBounds(
|
|
444
|
-
prior: readonly TicketProvenanceSession[] | undefined,
|
|
445
|
-
incoming: readonly TicketProvenanceSession[],
|
|
446
|
-
): {
|
|
447
|
-
readonly sessions: readonly TicketProvenanceSession[];
|
|
448
|
-
readonly priorIndexes: readonly number[];
|
|
449
|
-
readonly incomingIndexes: readonly number[];
|
|
450
|
-
} {
|
|
451
|
-
const merged: { path: string; ranges: TicketProvenanceRange[] }[] = [];
|
|
452
|
-
const indexByIdentity = new Map<string, number>();
|
|
453
|
-
|
|
454
|
-
const absorb = (sessions: readonly TicketProvenanceSession[]): number[] => {
|
|
455
|
-
const indexes: number[] = [];
|
|
456
|
-
for (const session of sessions) {
|
|
457
|
-
const identity = physicalPathIdentity(session.path);
|
|
458
|
-
let targetIndex = indexByIdentity.get(identity);
|
|
459
|
-
if (targetIndex === undefined) {
|
|
460
|
-
targetIndex = merged.length;
|
|
461
|
-
indexByIdentity.set(identity, targetIndex);
|
|
462
|
-
merged.push({ path: session.path, ranges: [] });
|
|
463
|
-
}
|
|
464
|
-
indexes.push(targetIndex);
|
|
465
|
-
const target = merged[targetIndex]!;
|
|
466
|
-
const seen = new Set(target.ranges.map(rangeDeclarationKey));
|
|
467
|
-
for (const range of session.ranges) {
|
|
468
|
-
const key = rangeDeclarationKey(range);
|
|
469
|
-
if (seen.has(key)) continue;
|
|
470
|
-
seen.add(key);
|
|
471
|
-
target.ranges.push({ from: { ...range.from }, to: { ...range.to } });
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
return indexes;
|
|
475
|
-
};
|
|
476
|
-
|
|
477
|
-
const priorIndexes = absorb(prior ?? []);
|
|
478
|
-
const incomingIndexes = absorb(incoming);
|
|
479
|
-
return { sessions: merged, priorIndexes, incomingIndexes };
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
/**
|
|
483
|
-
* #918 第一节:已投影行即卷宗。只对 prior 未声明的 range(按 path identity + 精确
|
|
484
|
-
* from/to)读源;已声明的投影原样结转,不重读历史源。
|
|
485
|
-
*/
|
|
486
|
-
function undeclaredSessionRanges(
|
|
487
|
-
prior: readonly TicketProvenanceSession[] | undefined,
|
|
488
|
-
merged: readonly TicketProvenanceSession[],
|
|
489
|
-
): readonly {
|
|
490
|
-
readonly s: number;
|
|
491
|
-
readonly session: TicketProvenanceSession;
|
|
492
|
-
}[] {
|
|
493
|
-
const priorKeys = new Map<string, Set<string>>();
|
|
494
|
-
for (const session of prior ?? []) {
|
|
495
|
-
const identity = physicalPathIdentity(session.path);
|
|
496
|
-
let keys = priorKeys.get(identity);
|
|
497
|
-
if (keys === undefined) {
|
|
498
|
-
keys = new Set();
|
|
499
|
-
priorKeys.set(identity, keys);
|
|
500
|
-
}
|
|
501
|
-
for (const range of session.ranges) keys.add(rangeDeclarationKey(range));
|
|
502
|
-
}
|
|
503
|
-
const out: {
|
|
504
|
-
s: number;
|
|
505
|
-
session: TicketProvenanceSession;
|
|
506
|
-
}[] = [];
|
|
507
|
-
for (let s = 0; s < merged.length; s += 1) {
|
|
508
|
-
const session = merged[s]!;
|
|
509
|
-
const identity = physicalPathIdentity(session.path);
|
|
510
|
-
const declared = priorKeys.get(identity) ?? new Set();
|
|
511
|
-
const fresh = session.ranges.filter(
|
|
512
|
-
(range) => !declared.has(rangeDeclarationKey(range)),
|
|
513
|
-
);
|
|
514
|
-
if (fresh.length === 0) continue;
|
|
515
|
-
out.push({
|
|
516
|
-
s,
|
|
517
|
-
session: {
|
|
518
|
-
path: session.path,
|
|
519
|
-
ranges: fresh.map((range) => ({
|
|
520
|
-
from: { ...range.from },
|
|
521
|
-
to: { ...range.to },
|
|
522
|
-
})),
|
|
523
|
-
},
|
|
524
|
-
});
|
|
525
|
-
}
|
|
526
|
-
return out;
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
/**
|
|
530
|
-
* Merge newly projected rows into the carried archive.
|
|
531
|
-
* Native id remains the typed first-seen identity; exact range resubmission is a no-op.
|
|
532
|
-
*/
|
|
533
|
-
function mergeFreshIntoCarried(
|
|
534
|
-
carried: readonly TicketProvenanceLine[],
|
|
535
|
-
fresh: readonly TicketProvenanceLine[],
|
|
536
|
-
): TicketProvenanceLine[] {
|
|
537
|
-
const seenSources = new Map<string, TicketProvenanceLine>();
|
|
538
|
-
const bySession = new Map<number, TicketProvenanceLine[]>();
|
|
539
|
-
const absorb = (lines: readonly TicketProvenanceLine[]): void => {
|
|
540
|
-
for (const line of lines) {
|
|
541
|
-
const sourceIdentity = line.id === undefined
|
|
542
|
-
? line.sourceIdentity
|
|
543
|
-
: `id\u0000${line.id}`;
|
|
544
|
-
const identity = sourceIdentity === undefined
|
|
545
|
-
? undefined
|
|
546
|
-
: `${line.s}\u0000${sourceIdentity}`;
|
|
547
|
-
const seen = identity === undefined ? undefined : seenSources.get(identity);
|
|
548
|
-
if (seen !== undefined) {
|
|
549
|
-
if (line.sourcePosition !== undefined) {
|
|
550
|
-
Object.assign(seen, { sourcePosition: line.sourcePosition });
|
|
551
|
-
}
|
|
552
|
-
continue;
|
|
553
|
-
}
|
|
554
|
-
const bucket = bySession.get(line.s) ?? [];
|
|
555
|
-
bucket.push({ ...line });
|
|
556
|
-
if (identity !== undefined) seenSources.set(identity, bucket.at(-1)!);
|
|
557
|
-
bySession.set(line.s, bucket);
|
|
558
|
-
}
|
|
559
|
-
};
|
|
560
|
-
absorb(carried);
|
|
561
|
-
absorb(fresh);
|
|
562
|
-
return [...bySession.entries()]
|
|
563
|
-
.sort(([left], [right]) => left - right)
|
|
564
|
-
.flatMap(([, lines]) => lines
|
|
565
|
-
.map((line, archiveIndex) => ({ line, archiveIndex }))
|
|
566
|
-
.sort((left, right) => {
|
|
567
|
-
const leftPosition = left.line.sourcePosition;
|
|
568
|
-
const rightPosition = right.line.sourcePosition;
|
|
569
|
-
if (leftPosition === undefined && rightPosition === undefined) {
|
|
570
|
-
return left.archiveIndex - right.archiveIndex;
|
|
571
|
-
}
|
|
572
|
-
if (leftPosition === undefined) return 1;
|
|
573
|
-
if (rightPosition === undefined) return -1;
|
|
574
|
-
return leftPosition - rightPosition || left.archiveIndex - right.archiveIndex;
|
|
575
|
-
})
|
|
576
|
-
.map(({ line }) => line));
|
|
577
|
-
}
|
|
578
|
-
|
|
579
196
|
/**
|
|
580
197
|
* Give every logical source row a replay-invariant ordinal. Native ids identify
|
|
581
198
|
* replayed rows directly; idless rows use their ordinal after the preceding
|
|
@@ -657,14 +274,9 @@ function normalizeResolvedRanges(
|
|
|
657
274
|
async function projectSessionRanges(input: {
|
|
658
275
|
readonly s: number;
|
|
659
276
|
readonly session: TicketProvenanceSession;
|
|
660
|
-
readonly seenIds: Set<string>;
|
|
661
|
-
readonly coveredSourceIdentities: ReadonlySet<string>;
|
|
662
|
-
readonly legacyCoveredSourcePositions: ReadonlySet<number>;
|
|
663
277
|
readonly home?: string;
|
|
664
278
|
}): Promise<{
|
|
665
|
-
readonly
|
|
666
|
-
readonly raw: TicketProvenanceRaw[];
|
|
667
|
-
readonly sourcePositionByIdentity: ReadonlyMap<string, number>;
|
|
279
|
+
readonly entries: (TicketProvenanceLine | TicketProvenanceRaw)[];
|
|
668
280
|
}> {
|
|
669
281
|
assertDialogueSessionSourcePath(input.session.path, input.home);
|
|
670
282
|
let sessionLines: LedgerSessionLine[];
|
|
@@ -672,9 +284,6 @@ async function projectSessionRanges(input: {
|
|
|
672
284
|
sessionLines = await readLedgerSessionJsonlLines(input.session.path);
|
|
673
285
|
} catch (error) {
|
|
674
286
|
if (error instanceof TicketProvenanceInputError) throw error;
|
|
675
|
-
// Only absence/path-shape misses are model input errors → reask.
|
|
676
|
-
// EISDIR = authorized-root path that is a directory, not a session file.
|
|
677
|
-
// EIO / EMFILE / EACCES / other runtime faults keep native identity + cause.
|
|
678
287
|
const code = errnoCode(error);
|
|
679
288
|
if (code === "ENOENT" || code === "ENOTDIR" || code === "EISDIR") {
|
|
680
289
|
const detail = error instanceof Error ? error.message : String(error);
|
|
@@ -689,13 +298,7 @@ async function projectSessionRanges(input: {
|
|
|
689
298
|
const rows = sessionLines.map((entry) => entry.row);
|
|
690
299
|
const dialogue = adaptSessionDialogue(rows);
|
|
691
300
|
const sourceFacts = stableSourceFacts(sessionLines);
|
|
692
|
-
const
|
|
693
|
-
for (const fact of sourceFacts) {
|
|
694
|
-
sourcePositionByIdentity.set(fact.identity, fact.position);
|
|
695
|
-
}
|
|
696
|
-
const seenIds = input.seenIds;
|
|
697
|
-
const lines: TicketProvenanceLine[] = [];
|
|
698
|
-
const raw: TicketProvenanceRaw[] = [];
|
|
301
|
+
const entries: (TicketProvenanceLine | TicketProvenanceRaw)[] = [];
|
|
699
302
|
const ranges = normalizeResolvedRanges(
|
|
700
303
|
input.session.ranges,
|
|
701
304
|
sessionLines,
|
|
@@ -704,20 +307,13 @@ async function projectSessionRanges(input: {
|
|
|
704
307
|
for (const range of ranges) {
|
|
705
308
|
for (let index = range.fromIndex; index <= range.toIndex; index += 1) {
|
|
706
309
|
const { position: sourcePosition, identity: sourceIdentity } = sourceFacts[index]!;
|
|
707
|
-
if (input.coveredSourceIdentities.has(sourceIdentity) ||
|
|
708
|
-
input.legacyCoveredSourcePositions.has(sourcePosition)) continue;
|
|
709
310
|
const entry = sessionLines[index]!;
|
|
710
311
|
if (entry.row === undefined) {
|
|
711
|
-
|
|
312
|
+
entries.push({ raw: entry.raw, s: input.s, sourcePosition, sourceIdentity });
|
|
712
313
|
continue;
|
|
713
314
|
}
|
|
714
315
|
for (const event of dialogue[index] ?? []) {
|
|
715
|
-
|
|
716
|
-
const identity = dialogueIdentity(input.s, event.id);
|
|
717
|
-
if (seenIds.has(identity)) continue;
|
|
718
|
-
seenIds.add(identity);
|
|
719
|
-
}
|
|
720
|
-
lines.push({
|
|
316
|
+
entries.push({
|
|
721
317
|
speaker: event.speaker,
|
|
722
318
|
s: input.s,
|
|
723
319
|
sourcePosition,
|
|
@@ -727,15 +323,13 @@ async function projectSessionRanges(input: {
|
|
|
727
323
|
}
|
|
728
324
|
}
|
|
729
325
|
}
|
|
730
|
-
return {
|
|
326
|
+
return { entries };
|
|
731
327
|
}
|
|
732
328
|
|
|
733
329
|
/**
|
|
734
|
-
*
|
|
735
|
-
* #
|
|
736
|
-
*
|
|
737
|
-
* 证不出的 body 原字节经 unprojectedRaw 原样留存。
|
|
738
|
-
* Persistence appends one immutable commit through the Sitian appender seam.
|
|
330
|
+
* Append dialogue projection from LLM-submitted bounds into records.jsonl.
|
|
331
|
+
* #1090: 纯追加——不回读已成录历史查重、不折叠、不刷新;同一区间再次提交也留下新的追加记录。
|
|
332
|
+
* 证不出的 body 原字节原样留卷。
|
|
739
333
|
*/
|
|
740
334
|
export async function reprojectTicketProvenance(input: {
|
|
741
335
|
readonly ticketNumber: number | null;
|
|
@@ -744,102 +338,42 @@ export async function reprojectTicketProvenance(input: {
|
|
|
744
338
|
readonly runDirectory?: string;
|
|
745
339
|
readonly sessions: readonly TicketProvenanceSession[];
|
|
746
340
|
}): Promise<ReprojectTicketProvenanceResult> {
|
|
747
|
-
const
|
|
341
|
+
const recordInput = ticketProvenanceRecordInput(
|
|
342
|
+
input.ticketNumber,
|
|
343
|
+
input.cwd,
|
|
344
|
+
input.home,
|
|
345
|
+
input.runDirectory,
|
|
346
|
+
);
|
|
347
|
+
const resolved = resolveSitianRecordPath(recordInput);
|
|
748
348
|
if (input.sessions.length === 0) {
|
|
749
|
-
const now = new Date().toISOString();
|
|
750
|
-
return {
|
|
751
|
-
recordFile: prior.recordFile,
|
|
752
|
-
header: prior.header ?? {
|
|
753
|
-
repo: resolveBookKeyFromGit(input.cwd),
|
|
754
|
-
ticket: input.ticketNumber,
|
|
755
|
-
createdAt: now,
|
|
756
|
-
updatedAt: now,
|
|
757
|
-
sessions: [],
|
|
758
|
-
},
|
|
759
|
-
lines: prior.lines,
|
|
760
|
-
};
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
const merged = mergeSessionBounds(prior.header?.sessions, input.sessions);
|
|
764
|
-
const deltas = undeclaredSessionRanges(prior.header?.sessions, merged.sessions);
|
|
765
|
-
if (deltas.length === 0) {
|
|
766
|
-
const now = new Date().toISOString();
|
|
767
349
|
return {
|
|
768
|
-
recordFile:
|
|
769
|
-
|
|
770
|
-
repo: resolveBookKeyFromGit(input.cwd),
|
|
771
|
-
ticket: input.ticketNumber,
|
|
772
|
-
createdAt: now,
|
|
773
|
-
updatedAt: now,
|
|
774
|
-
sessions: merged.sessions,
|
|
775
|
-
},
|
|
776
|
-
lines: prior.lines,
|
|
350
|
+
recordFile: resolved.recordFile,
|
|
351
|
+
lines: [],
|
|
777
352
|
};
|
|
778
353
|
}
|
|
779
354
|
|
|
780
|
-
const
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
),
|
|
784
|
-
);
|
|
785
|
-
const fresh: TicketProvenanceLine[] = [];
|
|
786
|
-
const raw: TicketProvenanceRaw[] = [];
|
|
787
|
-
for (const delta of deltas) {
|
|
355
|
+
const entries: (TicketProvenanceLine | TicketProvenanceRaw)[] = [];
|
|
356
|
+
for (let s = 0; s < input.sessions.length; s += 1) {
|
|
357
|
+
const session = input.sessions[s]!;
|
|
788
358
|
const projected = await projectSessionRanges({
|
|
789
|
-
s
|
|
790
|
-
session
|
|
791
|
-
seenIds,
|
|
792
|
-
coveredSourceIdentities: prior.sourceIdentities.get(delta.s) ?? new Set(),
|
|
793
|
-
legacyCoveredSourcePositions: prior.legacySourcePositions.get(delta.s) ?? new Set(),
|
|
359
|
+
s,
|
|
360
|
+
session,
|
|
794
361
|
...(input.home === undefined ? {} : { home: input.home }),
|
|
795
362
|
});
|
|
796
|
-
|
|
797
|
-
if (line.s !== delta.s) return [];
|
|
798
|
-
const sourceIdentity = line.id === undefined
|
|
799
|
-
? line.sourceIdentity
|
|
800
|
-
: `id\u0000${line.id}`;
|
|
801
|
-
if (sourceIdentity === undefined) return [];
|
|
802
|
-
const sourcePosition = projected.sourcePositionByIdentity.get(sourceIdentity);
|
|
803
|
-
return sourcePosition === undefined ? [] : [{ ...line, sourcePosition }];
|
|
804
|
-
}));
|
|
805
|
-
fresh.push(...projected.lines);
|
|
806
|
-
raw.push(...projected.raw);
|
|
363
|
+
entries.push(...projected.entries);
|
|
807
364
|
}
|
|
808
|
-
|
|
809
|
-
const now = new Date().toISOString();
|
|
810
|
-
const header: TicketProvenanceHeader = {
|
|
811
|
-
repo: prior.header?.repo ?? resolveBookKeyFromGit(input.cwd),
|
|
812
|
-
ticket: input.ticketNumber,
|
|
813
|
-
createdAt: prior.header?.createdAt ?? now,
|
|
814
|
-
updatedAt: now,
|
|
815
|
-
sessions: merged.sessions,
|
|
816
|
-
};
|
|
817
|
-
// One immutable projection commit. Its deterministic identity makes retries of
|
|
818
|
-
// the same logical increment converge, while unrelated concurrent increments
|
|
819
|
-
// append independently and are folded by readTicketProvenance.
|
|
820
|
-
const identityMaterial = JSON.stringify({
|
|
821
|
-
ticket: input.ticketNumber,
|
|
822
|
-
deltas: deltas.map(({ s, session }) => ({
|
|
823
|
-
s,
|
|
824
|
-
path: physicalPathIdentity(session.path),
|
|
825
|
-
ranges: session.ranges,
|
|
826
|
-
})),
|
|
827
|
-
lines: [...fresh, ...raw],
|
|
828
|
-
});
|
|
829
|
-
const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
|
|
365
|
+
|
|
830
366
|
const pointer = appendSitianRecord({
|
|
831
|
-
...
|
|
832
|
-
identity,
|
|
367
|
+
...recordInput,
|
|
833
368
|
payload: {
|
|
834
369
|
type: "ticket-provenance-append",
|
|
835
|
-
sessions:
|
|
836
|
-
lines:
|
|
370
|
+
sessions: input.sessions,
|
|
371
|
+
lines: entries,
|
|
837
372
|
},
|
|
838
373
|
});
|
|
839
|
-
|
|
374
|
+
|
|
840
375
|
return {
|
|
841
376
|
recordFile: pointer.recordFile,
|
|
842
|
-
|
|
843
|
-
lines: folded.lines,
|
|
377
|
+
lines: entries.filter((entry): entry is TicketProvenanceLine => "speaker" in entry),
|
|
844
378
|
};
|
|
845
379
|
}
|