@akagilnc/pi-workflow-roles 0.1.4291 → 0.1.4321
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/CLAUDE.md +2 -2
- package/dist/acp-host/production-host.js +542 -451
- package/dist/headless-host/production-host.js +554 -463
- package/dist/public-cli/main.js +229 -242
- package/dist/session-dialogue.js +61 -63
- package/dist/sitian-facade.js +0 -1
- package/dist/ticket-provenance-contracts.js +5 -4
- package/dist/ticket-provenance.js +295 -79
- package/package.json +1 -1
- package/souls/notary.md +1 -1
- package/src/session-dialogue.ts +68 -65
- package/src/sitian-facade.ts +1 -3
- package/src/ticket-provenance-contracts.ts +11 -7
- package/src/ticket-provenance.ts +332 -93
- package/dist/atomic-write.js +0 -23
- package/dist/sitian-volume.js +0 -67
- package/src/sitian-volume.ts +0 -91
package/src/ticket-provenance.ts
CHANGED
|
@@ -1,15 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901。
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* 起居录 volume helpers — ADR 0075「2026-09-14 修订」/ #901 / #918。
|
|
3
|
+
* 一册=一个追加式 records.jsonl。每轮新投影经司天台 appender 追加为不可变提交;
|
|
4
|
+
* 读取时折叠全部提交得到累计 sessions 与对话视图。既有首行册子头+裸对话行的
|
|
5
|
+
* snapshot 继续可读,但后续不为升级回写旧卷。
|
|
6
6
|
*/
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { appendFileSync } from "node:fs";
|
|
9
|
+
import { readFile } from "node:fs/promises";
|
|
7
10
|
import { basename, dirname, join, resolve } from "node:path";
|
|
8
11
|
|
|
9
12
|
import { resolveBookKeyFromGit } from "./activation-ledger-git.ts";
|
|
10
13
|
import {
|
|
14
|
+
ensureRealDirectoryTree,
|
|
11
15
|
errnoCode,
|
|
12
16
|
packageMachineHome,
|
|
17
|
+
physicalPathIdentity,
|
|
13
18
|
physicallyContainedIn,
|
|
14
19
|
resolveActivationLedgerHome,
|
|
15
20
|
} from "./activation-ledger-topology.ts";
|
|
@@ -20,16 +25,15 @@ import {
|
|
|
20
25
|
import { isSafePositiveTicketNumber } from "./run-ticket-number.ts";
|
|
21
26
|
import { adaptSessionDialogue, nativeEventId } from "./session-dialogue.ts";
|
|
22
27
|
import {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
resolveSitianVolume,
|
|
26
|
-
rewriteSitianVolume,
|
|
28
|
+
appendSitianRecord,
|
|
29
|
+
resolveSitianRecordPath,
|
|
27
30
|
type SitianRecordInput,
|
|
28
31
|
} from "./sitian-facade.ts";
|
|
29
32
|
import {
|
|
30
33
|
TICKET_PROVENANCE_KIND,
|
|
31
34
|
projectTicketProvenanceHeader,
|
|
32
35
|
projectTicketProvenanceLine,
|
|
36
|
+
projectTicketProvenanceSessions,
|
|
33
37
|
type TicketProvenanceAmendment,
|
|
34
38
|
type TicketProvenanceBound,
|
|
35
39
|
type TicketProvenanceHeader,
|
|
@@ -59,7 +63,9 @@ export class TicketProvenanceInputError extends Error {
|
|
|
59
63
|
*/
|
|
60
64
|
export function dialogueSessionSourceRoots(home?: string): readonly string[] {
|
|
61
65
|
const machineHome =
|
|
62
|
-
typeof home === "string" && home.trim() !== ""
|
|
66
|
+
typeof home === "string" && home.trim() !== ""
|
|
67
|
+
? home
|
|
68
|
+
: packageMachineHome();
|
|
63
69
|
return [
|
|
64
70
|
join(machineHome, ".claude", "projects"),
|
|
65
71
|
join(machineHome, ".codex", "sessions"),
|
|
@@ -73,10 +79,15 @@ export function dialogueSessionSourceRoots(home?: string): readonly string[] {
|
|
|
73
79
|
*/
|
|
74
80
|
function isLedgerRoleSessionFile(absolute: string, home?: string): boolean {
|
|
75
81
|
const machineHome =
|
|
76
|
-
typeof home === "string" && home.trim() !== ""
|
|
82
|
+
typeof home === "string" && home.trim() !== ""
|
|
83
|
+
? home
|
|
84
|
+
: packageMachineHome();
|
|
77
85
|
const ledgerHome = resolveActivationLedgerHome(machineHome);
|
|
78
86
|
if (!physicallyContainedIn(ledgerHome, absolute)) return false;
|
|
79
|
-
return
|
|
87
|
+
return (
|
|
88
|
+
basename(absolute) === "session.jsonl" &&
|
|
89
|
+
basename(dirname(absolute)) === "session"
|
|
90
|
+
);
|
|
80
91
|
}
|
|
81
92
|
|
|
82
93
|
/** Real I/O seam gate: only host session stores or sitian role-run session.jsonl. */
|
|
@@ -127,12 +138,47 @@ export function resolveTicketProvenanceVolume(
|
|
|
127
138
|
cwd: string,
|
|
128
139
|
home?: string,
|
|
129
140
|
): TicketProvenanceVolumePath {
|
|
130
|
-
|
|
141
|
+
const path = resolveSitianRecordPath(
|
|
142
|
+
ticketProvenanceRecordInput(ticketNumber, cwd, home),
|
|
143
|
+
);
|
|
144
|
+
return { recordFile: path.recordFile, volumeDir: path.sessionDir };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type TicketProvenanceCommit = {
|
|
148
|
+
readonly timestamp: string;
|
|
149
|
+
readonly sessions: readonly TicketProvenanceSession[];
|
|
150
|
+
readonly lines: readonly TicketProvenanceLine[];
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
function projectTicketProvenanceCommit(value: unknown): TicketProvenanceCommit | undefined {
|
|
154
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
155
|
+
const record = value as Record<string, unknown>;
|
|
156
|
+
if (record.kind !== TICKET_PROVENANCE_KIND || typeof record.timestamp !== "string") {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
const payload = record.payload;
|
|
160
|
+
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) return undefined;
|
|
161
|
+
const body = payload as Record<string, unknown>;
|
|
162
|
+
if (body.type !== "ticket-provenance-append") return undefined;
|
|
163
|
+
const sessions = projectTicketProvenanceSessions(body.sessions);
|
|
164
|
+
if (sessions === undefined || !Array.isArray(body.lines)) return undefined;
|
|
165
|
+
const lines: TicketProvenanceLine[] = [];
|
|
166
|
+
for (const raw of body.lines) {
|
|
167
|
+
const line = projectTicketProvenanceLine(raw);
|
|
168
|
+
if (line === undefined) return undefined;
|
|
169
|
+
lines.push(line);
|
|
170
|
+
}
|
|
171
|
+
return { timestamp: record.timestamp, sessions, lines };
|
|
131
172
|
}
|
|
132
173
|
|
|
133
174
|
export type ReadTicketProvenanceResult = {
|
|
134
175
|
readonly header: TicketProvenanceHeader | undefined;
|
|
135
176
|
readonly lines: readonly TicketProvenanceLine[];
|
|
177
|
+
/**
|
|
178
|
+
* Body rows that could not be projected (bad JSON or unusable shape).
|
|
179
|
+
* Kept byte-stable across lawful rewrites — 证不出的原样留存 (#918 / G8).
|
|
180
|
+
*/
|
|
181
|
+
readonly unprojectedRaw: readonly string[];
|
|
136
182
|
readonly recordFile: string;
|
|
137
183
|
};
|
|
138
184
|
|
|
@@ -142,15 +188,20 @@ export async function readTicketProvenance(
|
|
|
142
188
|
cwd: string,
|
|
143
189
|
home?: string,
|
|
144
190
|
): Promise<ReadTicketProvenanceResult> {
|
|
145
|
-
const { recordFile
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
191
|
+
const { recordFile } = resolveTicketProvenanceVolume(ticketNumber, cwd, home);
|
|
192
|
+
let text: string;
|
|
193
|
+
try {
|
|
194
|
+
text = await readFile(recordFile, "utf8");
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
197
|
+
return { header: undefined, lines: [], unprojectedRaw: [], recordFile };
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
150
200
|
}
|
|
151
201
|
const physical = text.split("\n");
|
|
152
202
|
let header: TicketProvenanceHeader | undefined;
|
|
153
203
|
const lines: TicketProvenanceLine[] = [];
|
|
204
|
+
const unprojectedRaw: string[] = [];
|
|
154
205
|
let sawFirst = false;
|
|
155
206
|
for (let index = 0; index < physical.length; index += 1) {
|
|
156
207
|
const raw = physical[index]!;
|
|
@@ -159,19 +210,47 @@ export async function readTicketProvenance(
|
|
|
159
210
|
try {
|
|
160
211
|
parsed = JSON.parse(raw);
|
|
161
212
|
} catch {
|
|
162
|
-
// Stock / damaged rows stay unprojected —
|
|
213
|
+
// Stock / damaged rows stay unprojected — keep raw bytes on rewrite.
|
|
214
|
+
unprojectedRaw.push(raw);
|
|
163
215
|
continue;
|
|
164
216
|
}
|
|
165
217
|
if (!sawFirst) {
|
|
166
218
|
sawFirst = true;
|
|
167
219
|
header = projectTicketProvenanceHeader(parsed);
|
|
168
|
-
//
|
|
220
|
+
// Legacy snapshot: first line header followed by bare dialogue rows.
|
|
169
221
|
if (header !== undefined) continue;
|
|
170
222
|
}
|
|
171
223
|
const line = projectTicketProvenanceLine(parsed);
|
|
172
|
-
if (line !== undefined)
|
|
224
|
+
if (line !== undefined) {
|
|
225
|
+
lines.push(line);
|
|
226
|
+
continue;
|
|
227
|
+
}
|
|
228
|
+
const commit = projectTicketProvenanceCommit(parsed);
|
|
229
|
+
if (commit !== undefined) {
|
|
230
|
+
const merged = mergeSessionBounds(header?.sessions, commit.sessions);
|
|
231
|
+
const remapped = commit.lines.map((entry) => ({
|
|
232
|
+
...entry,
|
|
233
|
+
s: merged.incomingIndexes[entry.s] ?? entry.s,
|
|
234
|
+
}));
|
|
235
|
+
const now = commit.timestamp;
|
|
236
|
+
header = {
|
|
237
|
+
repo: header?.repo ?? resolveBookKeyFromGit(cwd),
|
|
238
|
+
ticket: ticketNumber,
|
|
239
|
+
createdAt: header?.createdAt ?? now,
|
|
240
|
+
updatedAt: now,
|
|
241
|
+
sessions: merged.sessions,
|
|
242
|
+
};
|
|
243
|
+
const carried = lines.map((entry) => ({
|
|
244
|
+
...entry,
|
|
245
|
+
s: merged.priorIndexes[entry.s] ?? entry.s,
|
|
246
|
+
}));
|
|
247
|
+
lines.splice(0, lines.length, ...mergeFreshIntoCarried(carried, remapped));
|
|
248
|
+
continue;
|
|
249
|
+
}
|
|
250
|
+
// Unknown stock / damaged rows remain readable and are never upgraded in place.
|
|
251
|
+
unprojectedRaw.push(raw);
|
|
173
252
|
}
|
|
174
|
-
return { header, lines, recordFile };
|
|
253
|
+
return { header, lines, unprojectedRaw, recordFile };
|
|
175
254
|
}
|
|
176
255
|
|
|
177
256
|
/**
|
|
@@ -183,7 +262,11 @@ export function ensureTicketProvenanceVolume(
|
|
|
183
262
|
cwd: string,
|
|
184
263
|
home?: string,
|
|
185
264
|
): TicketProvenanceVolumePath {
|
|
186
|
-
|
|
265
|
+
const input = ticketProvenanceRecordInput(ticketNumber, cwd, home);
|
|
266
|
+
const path = resolveSitianRecordPath(input);
|
|
267
|
+
ensureRealDirectoryTree(path.ledgerHome, path.sessionDir);
|
|
268
|
+
appendFileSync(path.recordFile, "", "utf8");
|
|
269
|
+
return { recordFile: path.recordFile, volumeDir: path.sessionDir };
|
|
187
270
|
}
|
|
188
271
|
|
|
189
272
|
/** One session line the mechanical projector could not enter. */
|
|
@@ -231,6 +314,149 @@ function amendmentKey(s: number, line: number): string {
|
|
|
231
314
|
return `${s}:${line}`;
|
|
232
315
|
}
|
|
233
316
|
|
|
317
|
+
/** Exact range identity for cumulative header dedupe (idempotent resubmit). */
|
|
318
|
+
function rangeDeclarationKey(range: TicketProvenanceRange): string {
|
|
319
|
+
return JSON.stringify({
|
|
320
|
+
from: range.from,
|
|
321
|
+
to: range.to,
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* #918 甲案:prior ranges ∪ 本轮 ranges。按 path 合并;先保 prior 序,再追加新 path。
|
|
327
|
+
* path identity 与 I/O 接缝同用 `physicalPathIdentity`(symlink-stable),故
|
|
328
|
+
* `p` / `p/./` / 经 symlink 祖先的别名合为一条;保留首见 path 字面与累计 ranges。
|
|
329
|
+
* 即使 prior 缺失 / sessions=[],incoming 自身也必须按 identity 归并——首轮同一
|
|
330
|
+
* 物理卷不得铸出多个 s(#918 C5)。遗漏不代表删除。
|
|
331
|
+
*/
|
|
332
|
+
function mergeSessionBounds(
|
|
333
|
+
prior: readonly TicketProvenanceSession[] | undefined,
|
|
334
|
+
incoming: readonly TicketProvenanceSession[],
|
|
335
|
+
): {
|
|
336
|
+
readonly sessions: readonly TicketProvenanceSession[];
|
|
337
|
+
readonly priorIndexes: readonly number[];
|
|
338
|
+
readonly incomingIndexes: readonly number[];
|
|
339
|
+
} {
|
|
340
|
+
const merged: { path: string; ranges: TicketProvenanceRange[] }[] = [];
|
|
341
|
+
const indexByIdentity = new Map<string, number>();
|
|
342
|
+
|
|
343
|
+
const absorb = (sessions: readonly TicketProvenanceSession[]): number[] => {
|
|
344
|
+
const indexes: number[] = [];
|
|
345
|
+
for (const session of sessions) {
|
|
346
|
+
const identity = physicalPathIdentity(session.path);
|
|
347
|
+
let targetIndex = indexByIdentity.get(identity);
|
|
348
|
+
if (targetIndex === undefined) {
|
|
349
|
+
targetIndex = merged.length;
|
|
350
|
+
indexByIdentity.set(identity, targetIndex);
|
|
351
|
+
merged.push({ path: session.path, ranges: [] });
|
|
352
|
+
}
|
|
353
|
+
indexes.push(targetIndex);
|
|
354
|
+
const target = merged[targetIndex]!;
|
|
355
|
+
const seen = new Set(target.ranges.map(rangeDeclarationKey));
|
|
356
|
+
for (const range of session.ranges) {
|
|
357
|
+
const key = rangeDeclarationKey(range);
|
|
358
|
+
if (seen.has(key)) continue;
|
|
359
|
+
seen.add(key);
|
|
360
|
+
target.ranges.push({ from: { ...range.from }, to: { ...range.to } });
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
return indexes;
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
const priorIndexes = absorb(prior ?? []);
|
|
367
|
+
const incomingIndexes = absorb(incoming);
|
|
368
|
+
return { sessions: merged, priorIndexes, incomingIndexes };
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* #918 第一节:已投影行即卷宗。只对 prior 未声明的 range(按 path identity + 精确
|
|
373
|
+
* from/to)读源;已声明的按 s,line 结转,不重读历史源。
|
|
374
|
+
*/
|
|
375
|
+
function undeclaredSessionRanges(
|
|
376
|
+
prior: readonly TicketProvenanceSession[] | undefined,
|
|
377
|
+
merged: readonly TicketProvenanceSession[],
|
|
378
|
+
): readonly {
|
|
379
|
+
readonly s: number;
|
|
380
|
+
readonly session: TicketProvenanceSession;
|
|
381
|
+
}[] {
|
|
382
|
+
const priorKeys = new Map<string, Set<string>>();
|
|
383
|
+
for (const session of prior ?? []) {
|
|
384
|
+
const identity = physicalPathIdentity(session.path);
|
|
385
|
+
let keys = priorKeys.get(identity);
|
|
386
|
+
if (keys === undefined) {
|
|
387
|
+
keys = new Set();
|
|
388
|
+
priorKeys.set(identity, keys);
|
|
389
|
+
}
|
|
390
|
+
for (const range of session.ranges) keys.add(rangeDeclarationKey(range));
|
|
391
|
+
}
|
|
392
|
+
const out: { s: number; session: TicketProvenanceSession }[] = [];
|
|
393
|
+
for (let s = 0; s < merged.length; s += 1) {
|
|
394
|
+
const session = merged[s]!;
|
|
395
|
+
const declared =
|
|
396
|
+
priorKeys.get(physicalPathIdentity(session.path)) ?? new Set();
|
|
397
|
+
const fresh = session.ranges.filter(
|
|
398
|
+
(range) => !declared.has(rangeDeclarationKey(range)),
|
|
399
|
+
);
|
|
400
|
+
if (fresh.length === 0) continue;
|
|
401
|
+
out.push({
|
|
402
|
+
s,
|
|
403
|
+
session: {
|
|
404
|
+
path: session.path,
|
|
405
|
+
ranges: fresh.map((range) => ({
|
|
406
|
+
from: { ...range.from },
|
|
407
|
+
to: { ...range.to },
|
|
408
|
+
})),
|
|
409
|
+
},
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Sort archive rows by session index then source line (stable within equal keys). */
|
|
416
|
+
function compareLinePosition(
|
|
417
|
+
left: TicketProvenanceLine,
|
|
418
|
+
right: TicketProvenanceLine,
|
|
419
|
+
): number {
|
|
420
|
+
if (left.s !== right.s) return left.s - right.s;
|
|
421
|
+
const leftLine = left.line ?? Number.MAX_SAFE_INTEGER;
|
|
422
|
+
const rightLine = right.line ?? Number.MAX_SAFE_INTEGER;
|
|
423
|
+
return leftLine - rightLine;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Merge newly projected rows into the carried archive.
|
|
428
|
+
* 既有卷宗按 (s,line) 优先;fresh 不得复制无 id 行(#918 C2/G1)。
|
|
429
|
+
* id 去重仍保留(跨 path 同 id 首见胜)。
|
|
430
|
+
*/
|
|
431
|
+
function mergeFreshIntoCarried(
|
|
432
|
+
carried: readonly TicketProvenanceLine[],
|
|
433
|
+
fresh: readonly TicketProvenanceLine[],
|
|
434
|
+
): TicketProvenanceLine[] {
|
|
435
|
+
const seenIds = new Set<string>();
|
|
436
|
+
const seenPositions = new Set<string>();
|
|
437
|
+
const out: TicketProvenanceLine[] = [];
|
|
438
|
+
for (const line of carried) {
|
|
439
|
+
if (line.id !== undefined) seenIds.add(line.id);
|
|
440
|
+
if (line.line !== undefined)
|
|
441
|
+
seenPositions.add(amendmentKey(line.s, line.line));
|
|
442
|
+
out.push(line);
|
|
443
|
+
}
|
|
444
|
+
for (const line of fresh) {
|
|
445
|
+
if (line.id !== undefined) {
|
|
446
|
+
if (seenIds.has(line.id)) continue;
|
|
447
|
+
seenIds.add(line.id);
|
|
448
|
+
}
|
|
449
|
+
if (line.line !== undefined) {
|
|
450
|
+
const position = amendmentKey(line.s, line.line);
|
|
451
|
+
if (seenPositions.has(position)) continue;
|
|
452
|
+
seenPositions.add(position);
|
|
453
|
+
}
|
|
454
|
+
out.push(line);
|
|
455
|
+
}
|
|
456
|
+
out.sort(compareLinePosition);
|
|
457
|
+
return out;
|
|
458
|
+
}
|
|
459
|
+
|
|
234
460
|
/**
|
|
235
461
|
* Resolve submitted ranges against the session, then sort by source position and
|
|
236
462
|
* merge overlaps so each physical row is visited once (#901 source order).
|
|
@@ -354,10 +580,12 @@ async function projectSessionRanges(input: {
|
|
|
354
580
|
}
|
|
355
581
|
|
|
356
582
|
/**
|
|
357
|
-
* Reproject the unique diary
|
|
358
|
-
*
|
|
359
|
-
*
|
|
360
|
-
*
|
|
583
|
+
* Reproject the unique diary from cumulative bounds + optional amendments.
|
|
584
|
+
* #918:已投影行即卷宗——按 s,line 结转;本轮只读 prior 未声明的 range(或新
|
|
585
|
+
* session 区间)。精确重复提交=幂等 no-op,不因历史源不可读而失败。
|
|
586
|
+
* amendments 只在本轮读取的新范围确有对应坏行时生效;空 sessions 保持 no-op。
|
|
587
|
+
* 证不出的 body 原字节经 unprojectedRaw 原样留存。
|
|
588
|
+
* Persistence appends one immutable commit through the Sitian appender seam.
|
|
361
589
|
*/
|
|
362
590
|
export async function reprojectTicketProvenance(input: {
|
|
363
591
|
readonly ticketNumber: number;
|
|
@@ -366,96 +594,107 @@ export async function reprojectTicketProvenance(input: {
|
|
|
366
594
|
readonly sessions: readonly TicketProvenanceSession[];
|
|
367
595
|
readonly amendments?: readonly TicketProvenanceAmendment[];
|
|
368
596
|
}): Promise<ReprojectTicketProvenanceResult> {
|
|
369
|
-
const recordInput = ticketProvenanceRecordInput(
|
|
370
|
-
input.ticketNumber,
|
|
371
|
-
input.cwd,
|
|
372
|
-
input.home,
|
|
373
|
-
);
|
|
374
597
|
const prior = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
unparsable: [],
|
|
408
|
-
};
|
|
409
|
-
}
|
|
598
|
+
if (input.sessions.length === 0) {
|
|
599
|
+
const now = new Date().toISOString();
|
|
600
|
+
return {
|
|
601
|
+
recordFile: prior.recordFile,
|
|
602
|
+
header: prior.header ?? {
|
|
603
|
+
repo: resolveBookKeyFromGit(input.cwd),
|
|
604
|
+
ticket: input.ticketNumber,
|
|
605
|
+
createdAt: now,
|
|
606
|
+
updatedAt: now,
|
|
607
|
+
sessions: [],
|
|
608
|
+
},
|
|
609
|
+
lines: prior.lines,
|
|
610
|
+
unparsable: [],
|
|
611
|
+
};
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
const merged = mergeSessionBounds(prior.header?.sessions, input.sessions);
|
|
615
|
+
const deltas = undeclaredSessionRanges(prior.header?.sessions, merged.sessions);
|
|
616
|
+
if (deltas.length === 0) {
|
|
617
|
+
const now = new Date().toISOString();
|
|
618
|
+
return {
|
|
619
|
+
recordFile: prior.recordFile,
|
|
620
|
+
header: prior.header ?? {
|
|
621
|
+
repo: resolveBookKeyFromGit(input.cwd),
|
|
622
|
+
ticket: input.ticketNumber,
|
|
623
|
+
createdAt: now,
|
|
624
|
+
updatedAt: now,
|
|
625
|
+
sessions: merged.sessions,
|
|
626
|
+
},
|
|
627
|
+
lines: prior.lines,
|
|
628
|
+
unparsable: [],
|
|
629
|
+
};
|
|
410
630
|
}
|
|
411
631
|
|
|
412
632
|
const amendmentsByKey = new Map<string, TicketProvenanceAmendment>();
|
|
413
|
-
for (const amendment of amendments) {
|
|
414
|
-
|
|
633
|
+
for (const amendment of input.amendments ?? []) {
|
|
634
|
+
const cumulativeIndex = merged.incomingIndexes[amendment.s];
|
|
635
|
+
if (cumulativeIndex === undefined) continue;
|
|
636
|
+
amendmentsByKey.set(amendmentKey(cumulativeIndex, amendment.line), {
|
|
637
|
+
...amendment,
|
|
638
|
+
s: cumulativeIndex,
|
|
639
|
+
});
|
|
415
640
|
}
|
|
416
641
|
|
|
417
|
-
const
|
|
642
|
+
const seenIds = new Set(
|
|
643
|
+
prior.lines.flatMap((line) => line.id === undefined ? [] : [line.id]),
|
|
644
|
+
);
|
|
645
|
+
const fresh: TicketProvenanceLine[] = [];
|
|
418
646
|
const unparsable: UnparsableSessionLine[] = [];
|
|
419
|
-
|
|
420
|
-
const seenIds = new Set<string>();
|
|
421
|
-
for (let s = 0; s < sessions.length; s += 1) {
|
|
647
|
+
for (const delta of deltas) {
|
|
422
648
|
const projected = await projectSessionRanges({
|
|
423
|
-
s,
|
|
424
|
-
session:
|
|
649
|
+
s: delta.s,
|
|
650
|
+
session: delta.session,
|
|
425
651
|
amendmentsByKey,
|
|
426
652
|
seenIds,
|
|
427
653
|
...(input.home === undefined ? {} : { home: input.home }),
|
|
428
654
|
});
|
|
429
|
-
|
|
655
|
+
fresh.push(...projected.lines);
|
|
430
656
|
unparsable.push(...projected.unparsable);
|
|
431
657
|
}
|
|
432
|
-
|
|
658
|
+
const lines = mergeFreshIntoCarried(prior.lines, fresh);
|
|
433
659
|
const now = new Date().toISOString();
|
|
434
660
|
const header: TicketProvenanceHeader = {
|
|
435
|
-
repo: resolveBookKeyFromGit(input.cwd),
|
|
661
|
+
repo: prior.header?.repo ?? resolveBookKeyFromGit(input.cwd),
|
|
436
662
|
ticket: input.ticketNumber,
|
|
437
663
|
createdAt: prior.header?.createdAt ?? now,
|
|
438
664
|
updatedAt: now,
|
|
439
|
-
sessions,
|
|
665
|
+
sessions: merged.sessions,
|
|
440
666
|
};
|
|
441
|
-
|
|
442
|
-
// Still-open gaps → reask without publishing a partial/rejected projection.
|
|
443
667
|
if (unparsable.length > 0) {
|
|
444
|
-
return {
|
|
445
|
-
recordFile: prior.recordFile,
|
|
446
|
-
header,
|
|
447
|
-
lines,
|
|
448
|
-
unparsable,
|
|
449
|
-
};
|
|
668
|
+
return { recordFile: prior.recordFile, header, lines, unparsable };
|
|
450
669
|
}
|
|
451
670
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
671
|
+
// One immutable projection commit. Its deterministic identity makes retries of
|
|
672
|
+
// the same logical increment converge, while unrelated concurrent increments
|
|
673
|
+
// append independently and are folded by readTicketProvenance.
|
|
674
|
+
const identityMaterial = JSON.stringify({
|
|
675
|
+
ticket: input.ticketNumber,
|
|
676
|
+
deltas: deltas.map(({ s, session }) => ({
|
|
677
|
+
s,
|
|
678
|
+
path: physicalPathIdentity(session.path),
|
|
679
|
+
ranges: session.ranges,
|
|
680
|
+
})),
|
|
681
|
+
lines: fresh,
|
|
682
|
+
});
|
|
683
|
+
const identity = `ticket-provenance:${createHash("sha256").update(identityMaterial).digest("hex")}`;
|
|
684
|
+
const pointer = appendSitianRecord({
|
|
685
|
+
...ticketProvenanceRecordInput(input.ticketNumber, input.cwd, input.home),
|
|
686
|
+
identity,
|
|
687
|
+
payload: {
|
|
688
|
+
type: "ticket-provenance-append",
|
|
689
|
+
sessions: merged.sessions,
|
|
690
|
+
lines: fresh,
|
|
691
|
+
},
|
|
692
|
+
});
|
|
693
|
+
const folded = await readTicketProvenance(input.ticketNumber, input.cwd, input.home);
|
|
455
694
|
return {
|
|
456
|
-
recordFile:
|
|
457
|
-
header,
|
|
458
|
-
lines,
|
|
459
|
-
unparsable,
|
|
695
|
+
recordFile: pointer.recordFile,
|
|
696
|
+
header: folded.header ?? header,
|
|
697
|
+
lines: folded.lines,
|
|
698
|
+
unparsable: [],
|
|
460
699
|
};
|
|
461
700
|
}
|
package/dist/atomic-write.js
DELETED
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Same-directory temp + rename atomic file replace.
|
|
3
|
-
* Shared primitive for ledger-adjacent typed pages (analyst metrics, etc.).
|
|
4
|
-
* Does not open/truncate an existing destination inode, so hard-linked twins
|
|
5
|
-
* keep prior bytes until the directory entry is swapped.
|
|
6
|
-
* Parent directory must already exist — callers that write under the package
|
|
7
|
-
* ledger home own confinement via ensureRealDirectoryTree (ADR 0038).
|
|
8
|
-
*/
|
|
9
|
-
import { randomUUID } from "node:crypto";
|
|
10
|
-
import { rename, rm, writeFile } from "node:fs/promises";
|
|
11
|
-
import { dirname, join } from "node:path";
|
|
12
|
-
export async function writeFileAtomically(destination, contents) {
|
|
13
|
-
const parent = dirname(destination);
|
|
14
|
-
const temporary = join(parent, `.atomic-write-${randomUUID()}.tmp`);
|
|
15
|
-
try {
|
|
16
|
-
await writeFile(temporary, contents);
|
|
17
|
-
await rename(temporary, destination);
|
|
18
|
-
}
|
|
19
|
-
catch (error) {
|
|
20
|
-
await rm(temporary, { force: true }).catch(() => undefined);
|
|
21
|
-
throw error;
|
|
22
|
-
}
|
|
23
|
-
}
|
package/dist/sitian-volume.js
DELETED
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Sitian whole-file volume I/O (ADR 0065 records-owner / record-entry).
|
|
3
|
-
*
|
|
4
|
-
* Owns destination resolution, create, raw read, and whole-file rewrite for
|
|
5
|
-
* kinds whose persistence shape is not the appender SitianRecord row
|
|
6
|
-
* (ticket-provenance header + bare dialogue lines, #901). Appender kernel
|
|
7
|
-
* stays append-only; this module does not restore append watermarks.
|
|
8
|
-
*/
|
|
9
|
-
import { appendFileSync } from "node:fs";
|
|
10
|
-
import { readFile } from "node:fs/promises";
|
|
11
|
-
import { ensureRealDirectoryTree, errorText, } from "./activation-ledger-topology.js";
|
|
12
|
-
import { writeFileAtomically } from "./atomic-write.js";
|
|
13
|
-
import { resolveSitianRecordPath } from "./sitian-appender.js";
|
|
14
|
-
import { SitianInfrastructureError, } from "./sitian-contracts.js";
|
|
15
|
-
/** Resolve a volume path from ambient topology — callers never pick the destination. */
|
|
16
|
-
export function resolveSitianVolume(input) {
|
|
17
|
-
const path = resolveSitianRecordPath(input);
|
|
18
|
-
return { recordFile: path.recordFile, volumeDir: path.sessionDir };
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Ensure the volume directory + file exist.
|
|
22
|
-
* Append-open creates an absent volume without truncating concurrent first writers.
|
|
23
|
-
*/
|
|
24
|
-
export function ensureSitianVolume(input) {
|
|
25
|
-
try {
|
|
26
|
-
const { sessionDir, recordFile, ledgerHome } = resolveSitianRecordPath(input);
|
|
27
|
-
ensureRealDirectoryTree(ledgerHome, sessionDir);
|
|
28
|
-
appendFileSync(recordFile, "", "utf8");
|
|
29
|
-
return { recordFile, volumeDir: sessionDir };
|
|
30
|
-
}
|
|
31
|
-
catch (error) {
|
|
32
|
-
if (error instanceof SitianInfrastructureError)
|
|
33
|
-
throw error;
|
|
34
|
-
throw new SitianInfrastructureError(`Sitian volume ensure failure: ${errorText(error)}`, { cause: error });
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
/** Raw volume text. Absent file → text undefined (not an error). */
|
|
38
|
-
export async function readSitianVolumeText(input) {
|
|
39
|
-
const { recordFile } = resolveSitianVolume(input);
|
|
40
|
-
try {
|
|
41
|
-
return { recordFile, text: await readFile(recordFile, "utf8") };
|
|
42
|
-
}
|
|
43
|
-
catch (error) {
|
|
44
|
-
if (error.code === "ENOENT") {
|
|
45
|
-
return { recordFile, text: undefined };
|
|
46
|
-
}
|
|
47
|
-
throw new SitianInfrastructureError(`Sitian volume read failure: ${errorText(error)}`, { cause: error });
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Replace the entire volume body (whole-file reproject).
|
|
52
|
-
* Same-directory temp + rename — never truncate the live inode in place
|
|
53
|
-
* (#901 sole diary; reuse atomic-write, no parallel persist mechanism).
|
|
54
|
-
* Not append; no identity claim / torn-tail watermark — those stay on appender.
|
|
55
|
-
*/
|
|
56
|
-
export async function rewriteSitianVolume(input) {
|
|
57
|
-
try {
|
|
58
|
-
const volume = ensureSitianVolume(input);
|
|
59
|
-
await writeFileAtomically(volume.recordFile, input.body);
|
|
60
|
-
return volume;
|
|
61
|
-
}
|
|
62
|
-
catch (error) {
|
|
63
|
-
if (error instanceof SitianInfrastructureError)
|
|
64
|
-
throw error;
|
|
65
|
-
throw new SitianInfrastructureError(`Sitian volume rewrite failure: ${errorText(error)}`, { cause: error });
|
|
66
|
-
}
|
|
67
|
-
}
|