@basou/core 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +425 -5
- package/dist/index.js +706 -229
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/schemas/event.schema.json +57 -0
- package/schemas/session-import.schema.json +75 -0
- package/schemas/session.schema.json +18 -0
package/dist/index.js
CHANGED
|
@@ -767,7 +767,7 @@ function isLazyExpired(approval, now) {
|
|
|
767
767
|
|
|
768
768
|
// src/decisions/decisions-renderer.ts
|
|
769
769
|
import { lstat } from "fs/promises";
|
|
770
|
-
import { dirname, join as
|
|
770
|
+
import { dirname as dirname2, join as join6, resolve } from "path";
|
|
771
771
|
|
|
772
772
|
// src/events/event-replay.ts
|
|
773
773
|
import { createReadStream } from "fs";
|
|
@@ -781,7 +781,14 @@ var BaseEventSchema = z3.object({
|
|
|
781
781
|
id: EventIdSchema,
|
|
782
782
|
session_id: SessionIdSchema,
|
|
783
783
|
occurred_at: IsoTimestampSchema,
|
|
784
|
-
source: EventSourceSchema
|
|
784
|
+
source: EventSourceSchema,
|
|
785
|
+
// Tamper-evidence back-pointer (hex sha-256 of the PREVIOUS event line's
|
|
786
|
+
// written bytes; the first line carries the session-bound genesis hash).
|
|
787
|
+
// Present only on sessions written with chaining enabled (import paths);
|
|
788
|
+
// live/ad-hoc sessions omit it. Declared on the base so the `.strict()`
|
|
789
|
+
// variants below treat it as a known key. Additive optional => no
|
|
790
|
+
// schema_version bump.
|
|
791
|
+
prev_hash: z3.string().optional()
|
|
785
792
|
});
|
|
786
793
|
var SessionStartedEventSchema = BaseEventSchema.extend({
|
|
787
794
|
type: z3.literal("session_started")
|
|
@@ -1006,7 +1013,208 @@ async function readAllEvents(sessionDir, options = {}) {
|
|
|
1006
1013
|
|
|
1007
1014
|
// src/storage/sessions.ts
|
|
1008
1015
|
import { readdir as readdir2 } from "fs/promises";
|
|
1009
|
-
import { join as
|
|
1016
|
+
import { join as join5 } from "path";
|
|
1017
|
+
|
|
1018
|
+
// src/events/chained-append.ts
|
|
1019
|
+
import { appendFile, readFile as readFile3 } from "fs/promises";
|
|
1020
|
+
import { join as join4 } from "path";
|
|
1021
|
+
|
|
1022
|
+
// src/storage/lockfile.ts
|
|
1023
|
+
import { mkdir, readFile as readFile2, unlink as unlink2 } from "fs/promises";
|
|
1024
|
+
import { dirname, join as join3 } from "path";
|
|
1025
|
+
var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
1026
|
+
async function acquireLock(paths, scope, resourceId) {
|
|
1027
|
+
const lockPath = lockfilePath(paths, scope, resourceId);
|
|
1028
|
+
const body = {
|
|
1029
|
+
pid: process.pid,
|
|
1030
|
+
acquired_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1031
|
+
};
|
|
1032
|
+
const serialised = JSON.stringify(body);
|
|
1033
|
+
try {
|
|
1034
|
+
await atomicCreate(lockPath, serialised);
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
if (findErrorCode(error, "ENOENT")) {
|
|
1037
|
+
try {
|
|
1038
|
+
await mkdir(dirname(lockPath), { recursive: true });
|
|
1039
|
+
await atomicCreate(lockPath, serialised);
|
|
1040
|
+
return {
|
|
1041
|
+
release: async () => {
|
|
1042
|
+
await unlink2(lockPath).catch(() => void 0);
|
|
1043
|
+
}
|
|
1044
|
+
};
|
|
1045
|
+
} catch (retryError) {
|
|
1046
|
+
throw new Error("Failed to acquire lock", { cause: retryError });
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
if (!findErrorCode(error, "EEXIST")) {
|
|
1050
|
+
throw error;
|
|
1051
|
+
}
|
|
1052
|
+
const stale = await isStaleLock(lockPath);
|
|
1053
|
+
if (!stale) {
|
|
1054
|
+
throw new Error("Lock is held by another process", { cause: error });
|
|
1055
|
+
}
|
|
1056
|
+
await unlink2(lockPath).catch(() => void 0);
|
|
1057
|
+
try {
|
|
1058
|
+
await atomicCreate(lockPath, serialised);
|
|
1059
|
+
} catch (retryError) {
|
|
1060
|
+
throw new Error("Lock is held by another process", { cause: retryError });
|
|
1061
|
+
}
|
|
1062
|
+
}
|
|
1063
|
+
return {
|
|
1064
|
+
release: async () => {
|
|
1065
|
+
await unlink2(lockPath).catch(() => void 0);
|
|
1066
|
+
}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
async function isStaleLock(lockPath) {
|
|
1070
|
+
let body;
|
|
1071
|
+
try {
|
|
1072
|
+
const raw = await readFile2(lockPath, "utf8");
|
|
1073
|
+
const parsed = JSON.parse(raw);
|
|
1074
|
+
if (typeof parsed !== "object" || parsed === null) return true;
|
|
1075
|
+
const candidate = parsed;
|
|
1076
|
+
if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
|
|
1077
|
+
return true;
|
|
1078
|
+
}
|
|
1079
|
+
body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
|
|
1080
|
+
} catch {
|
|
1081
|
+
return true;
|
|
1082
|
+
}
|
|
1083
|
+
const ageMs = Date.now() - Date.parse(body.acquired_at);
|
|
1084
|
+
if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
|
|
1085
|
+
return true;
|
|
1086
|
+
}
|
|
1087
|
+
try {
|
|
1088
|
+
process.kill(body.pid, 0);
|
|
1089
|
+
return false;
|
|
1090
|
+
} catch (error) {
|
|
1091
|
+
if (findErrorCode(error, "ESRCH")) return true;
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
function lockfilePath(paths, scope, resourceId) {
|
|
1096
|
+
const sep = resourceId.indexOf("_");
|
|
1097
|
+
const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
|
|
1098
|
+
return join3(paths.locks, `${scope}_${ulid2}.lock`);
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// src/events/chain.ts
|
|
1102
|
+
import { createHash } from "crypto";
|
|
1103
|
+
var GENESIS_PREFIX = "basou:event-chain:v1:";
|
|
1104
|
+
function genesisHash(sessionId) {
|
|
1105
|
+
return createHash("sha256").update(`${GENESIS_PREFIX}${sessionId}`, "utf8").digest("hex");
|
|
1106
|
+
}
|
|
1107
|
+
function lineHash(rawLine) {
|
|
1108
|
+
const hash = createHash("sha256");
|
|
1109
|
+
if (typeof rawLine === "string") {
|
|
1110
|
+
hash.update(rawLine, "utf8");
|
|
1111
|
+
} else {
|
|
1112
|
+
hash.update(rawLine);
|
|
1113
|
+
}
|
|
1114
|
+
return hash.digest("hex");
|
|
1115
|
+
}
|
|
1116
|
+
function serializeEventLine(event) {
|
|
1117
|
+
return JSON.stringify(event);
|
|
1118
|
+
}
|
|
1119
|
+
function chainEvents(events, sessionId) {
|
|
1120
|
+
let prev = genesisHash(sessionId);
|
|
1121
|
+
const lines = [];
|
|
1122
|
+
for (const event of events) {
|
|
1123
|
+
const chained = { ...event, prev_hash: prev };
|
|
1124
|
+
const line = serializeEventLine(chained);
|
|
1125
|
+
lines.push(line);
|
|
1126
|
+
prev = lineHash(line);
|
|
1127
|
+
}
|
|
1128
|
+
return { lines, headHash: prev, count: lines.length };
|
|
1129
|
+
}
|
|
1130
|
+
function chainRawJsonLines(rawLines, sessionId) {
|
|
1131
|
+
let prev = genesisHash(sessionId);
|
|
1132
|
+
const lines = [];
|
|
1133
|
+
for (const rawLine of rawLines) {
|
|
1134
|
+
const parsed = JSON.parse(rawLine);
|
|
1135
|
+
const line = JSON.stringify({ ...parsed, prev_hash: prev });
|
|
1136
|
+
lines.push(line);
|
|
1137
|
+
prev = lineHash(line);
|
|
1138
|
+
}
|
|
1139
|
+
return { lines, headHash: prev, count: lines.length };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// src/events/chained-append.ts
|
|
1143
|
+
function splitLinesBytes(buf) {
|
|
1144
|
+
const out = [];
|
|
1145
|
+
let start = 0;
|
|
1146
|
+
for (let i = 0; i < buf.length; i++) {
|
|
1147
|
+
if (buf[i] === 10) {
|
|
1148
|
+
out.push(buf.subarray(start, i));
|
|
1149
|
+
start = i + 1;
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
if (start < buf.length) out.push(buf.subarray(start));
|
|
1153
|
+
return out;
|
|
1154
|
+
}
|
|
1155
|
+
function carriesPrevHash(line) {
|
|
1156
|
+
try {
|
|
1157
|
+
const obj = JSON.parse(line.toString("utf8"));
|
|
1158
|
+
return typeof obj === "object" && obj !== null && "prev_hash" in obj;
|
|
1159
|
+
} catch {
|
|
1160
|
+
return false;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
async function inspectChainTail(paths, sessionId) {
|
|
1164
|
+
const filePath = join4(paths.sessions, sessionId, "events.jsonl");
|
|
1165
|
+
let raw;
|
|
1166
|
+
try {
|
|
1167
|
+
raw = await readFile3(filePath);
|
|
1168
|
+
} catch (error) {
|
|
1169
|
+
if (findErrorCode(error, "ENOENT")) {
|
|
1170
|
+
return { chained: true, head: genesisHash(sessionId), count: 0 };
|
|
1171
|
+
}
|
|
1172
|
+
throw new Error("Failed to read events.jsonl", { cause: error });
|
|
1173
|
+
}
|
|
1174
|
+
if (raw.length === 0) {
|
|
1175
|
+
return { chained: true, head: genesisHash(sessionId), count: 0 };
|
|
1176
|
+
}
|
|
1177
|
+
if (raw[raw.length - 1] !== 10) {
|
|
1178
|
+
throw new Error("Unterminated final line in events.jsonl");
|
|
1179
|
+
}
|
|
1180
|
+
const lines = splitLinesBytes(raw);
|
|
1181
|
+
const first = lines[0];
|
|
1182
|
+
const last = lines[lines.length - 1];
|
|
1183
|
+
const firstChained = carriesPrevHash(first);
|
|
1184
|
+
if (firstChained !== carriesPrevHash(last)) {
|
|
1185
|
+
throw new Error("events.jsonl is partially chained");
|
|
1186
|
+
}
|
|
1187
|
+
return {
|
|
1188
|
+
chained: firstChained,
|
|
1189
|
+
head: firstChained ? lineHash(last) : genesisHash(sessionId),
|
|
1190
|
+
count: lines.length
|
|
1191
|
+
};
|
|
1192
|
+
}
|
|
1193
|
+
async function appendChainedEventLocked(paths, sessionId, event) {
|
|
1194
|
+
let validated;
|
|
1195
|
+
try {
|
|
1196
|
+
validated = EventSchema.parse(event);
|
|
1197
|
+
} catch (error) {
|
|
1198
|
+
throw new Error("Invalid Basou event payload", { cause: error });
|
|
1199
|
+
}
|
|
1200
|
+
const tail = await inspectChainTail(paths, sessionId);
|
|
1201
|
+
const line = tail.chained ? serializeEventLine({ ...validated, prev_hash: tail.head }) : serializeEventLine(validated);
|
|
1202
|
+
try {
|
|
1203
|
+
await appendFile(join4(paths.sessions, sessionId, "events.jsonl"), `${line}
|
|
1204
|
+
`, "utf8");
|
|
1205
|
+
} catch (error) {
|
|
1206
|
+
throw new Error("Failed to append event to events.jsonl", { cause: error });
|
|
1207
|
+
}
|
|
1208
|
+
return { chained: tail.chained };
|
|
1209
|
+
}
|
|
1210
|
+
async function appendChainedEvent(paths, sessionId, event) {
|
|
1211
|
+
const lock = await acquireLock(paths, "session", sessionId);
|
|
1212
|
+
try {
|
|
1213
|
+
return await appendChainedEventLocked(paths, sessionId, event);
|
|
1214
|
+
} finally {
|
|
1215
|
+
await lock.release();
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1010
1218
|
|
|
1011
1219
|
// src/schemas/session.schema.ts
|
|
1012
1220
|
import { z as z4 } from "zod";
|
|
@@ -1062,6 +1270,10 @@ var SessionMetricsSchema = z4.object({
|
|
|
1062
1270
|
active_time_method: z4.string().optional(),
|
|
1063
1271
|
machine_active_time_ms: z4.number().int().nonnegative().optional()
|
|
1064
1272
|
});
|
|
1273
|
+
var SessionIntegritySchema = z4.object({
|
|
1274
|
+
head_hash: z4.string(),
|
|
1275
|
+
event_count: z4.number().int().nonnegative()
|
|
1276
|
+
}).strict();
|
|
1065
1277
|
var SessionInnerSchema = z4.object({
|
|
1066
1278
|
id: SessionIdSchema,
|
|
1067
1279
|
label: z4.string().optional(),
|
|
@@ -1077,7 +1289,8 @@ var SessionInnerSchema = z4.object({
|
|
|
1077
1289
|
related_files: z4.array(z4.string()).default([]),
|
|
1078
1290
|
events_log: z4.string().default("events.jsonl"),
|
|
1079
1291
|
summary: z4.string().nullable().optional(),
|
|
1080
|
-
metrics: SessionMetricsSchema.optional()
|
|
1292
|
+
metrics: SessionMetricsSchema.optional(),
|
|
1293
|
+
integrity: SessionIntegritySchema.optional()
|
|
1081
1294
|
});
|
|
1082
1295
|
var SessionSchema = z4.object({
|
|
1083
1296
|
schema_version: SchemaVersionSchema,
|
|
@@ -1096,7 +1309,7 @@ async function enumerateSessionDirs(paths) {
|
|
|
1096
1309
|
}
|
|
1097
1310
|
}
|
|
1098
1311
|
async function readSessionYaml(paths, sessionId) {
|
|
1099
|
-
const filePath =
|
|
1312
|
+
const filePath = join5(paths.sessions, sessionId, "session.yaml");
|
|
1100
1313
|
let raw;
|
|
1101
1314
|
try {
|
|
1102
1315
|
raw = await readYamlFile(filePath);
|
|
@@ -1110,11 +1323,26 @@ async function readSessionYaml(paths, sessionId) {
|
|
|
1110
1323
|
}
|
|
1111
1324
|
return result.data;
|
|
1112
1325
|
}
|
|
1326
|
+
async function finalizeSessionYaml(paths, sessionId, mutate) {
|
|
1327
|
+
const lock = await acquireLock(paths, "session", sessionId);
|
|
1328
|
+
try {
|
|
1329
|
+
const session = await readSessionYaml(paths, sessionId);
|
|
1330
|
+
mutate(session);
|
|
1331
|
+
const tail = await inspectChainTail(paths, sessionId);
|
|
1332
|
+
if (tail.chained && tail.count > 0) {
|
|
1333
|
+
session.session.integrity = { head_hash: tail.head, event_count: tail.count };
|
|
1334
|
+
}
|
|
1335
|
+
const validated = SessionSchema.parse(session);
|
|
1336
|
+
await overwriteYamlFile(join5(paths.sessions, sessionId, "session.yaml"), validated);
|
|
1337
|
+
} finally {
|
|
1338
|
+
await lock.release();
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1113
1341
|
async function classifySuspect(paths, sessionId, session, now, onWarning) {
|
|
1114
1342
|
if (session.session.status !== "running") {
|
|
1115
1343
|
return { suspect: false, suspectReason: null };
|
|
1116
1344
|
}
|
|
1117
|
-
const sessionDir =
|
|
1345
|
+
const sessionDir = join5(paths.sessions, sessionId);
|
|
1118
1346
|
let endedFound = false;
|
|
1119
1347
|
let lastEventOccurredAt = null;
|
|
1120
1348
|
const replayOpts = onWarning !== void 0 ? { onWarning } : {};
|
|
@@ -1182,7 +1410,7 @@ async function renderDecisions(input) {
|
|
|
1182
1410
|
const decisions = [];
|
|
1183
1411
|
const knownEventIds = /* @__PURE__ */ new Set();
|
|
1184
1412
|
for (const entry of entries) {
|
|
1185
|
-
const sessionDir =
|
|
1413
|
+
const sessionDir = join6(input.paths.sessions, entry.sessionId);
|
|
1186
1414
|
try {
|
|
1187
1415
|
for await (const ev of replayEvents(sessionDir, {
|
|
1188
1416
|
onWarning: (w) => input.onWarning?.(w, entry.sessionId)
|
|
@@ -1212,7 +1440,7 @@ async function renderDecisions(input) {
|
|
|
1212
1440
|
const c = Date.parse(a.occurredAt) - Date.parse(b.occurredAt);
|
|
1213
1441
|
return c !== 0 ? c : a.decisionId.localeCompare(b.decisionId);
|
|
1214
1442
|
});
|
|
1215
|
-
const repoRoot =
|
|
1443
|
+
const repoRoot = dirname2(input.paths.root);
|
|
1216
1444
|
const fileExistenceCache = /* @__PURE__ */ new Map();
|
|
1217
1445
|
async function fileExists(relPath) {
|
|
1218
1446
|
const cached = fileExistenceCache.get(relPath);
|
|
@@ -1287,8 +1515,8 @@ function shortDecisionSessionId(sessionId) {
|
|
|
1287
1515
|
}
|
|
1288
1516
|
|
|
1289
1517
|
// src/events/event-writer.ts
|
|
1290
|
-
import { appendFile } from "fs/promises";
|
|
1291
|
-
import { join as
|
|
1518
|
+
import { appendFile as appendFile2 } from "fs/promises";
|
|
1519
|
+
import { basename, join as join7 } from "path";
|
|
1292
1520
|
async function appendEvent(sessionDir, event) {
|
|
1293
1521
|
let validated;
|
|
1294
1522
|
try {
|
|
@@ -1296,15 +1524,15 @@ async function appendEvent(sessionDir, event) {
|
|
|
1296
1524
|
} catch (error) {
|
|
1297
1525
|
throw new Error("Invalid Basou event payload", { cause: error });
|
|
1298
1526
|
}
|
|
1299
|
-
const line = `${
|
|
1527
|
+
const line = `${serializeEventLine(validated)}
|
|
1300
1528
|
`;
|
|
1301
1529
|
try {
|
|
1302
|
-
await
|
|
1530
|
+
await appendFile2(join7(sessionDir, "events.jsonl"), line, "utf8");
|
|
1303
1531
|
} catch (error) {
|
|
1304
1532
|
throw new Error("Failed to append event to events.jsonl", { cause: error });
|
|
1305
1533
|
}
|
|
1306
1534
|
}
|
|
1307
|
-
async function writeEventsBulk(sessionDir, events) {
|
|
1535
|
+
async function writeEventsBulk(sessionDir, events, options = {}) {
|
|
1308
1536
|
const validated = [];
|
|
1309
1537
|
try {
|
|
1310
1538
|
for (const event of events) {
|
|
@@ -1313,14 +1541,182 @@ async function writeEventsBulk(sessionDir, events) {
|
|
|
1313
1541
|
} catch (error) {
|
|
1314
1542
|
throw new Error("Invalid Basou event payload", { cause: error });
|
|
1315
1543
|
}
|
|
1316
|
-
const filePath =
|
|
1317
|
-
|
|
1544
|
+
const filePath = join7(sessionDir, "events.jsonl");
|
|
1545
|
+
let body;
|
|
1546
|
+
let result = null;
|
|
1547
|
+
if (options.chain === true) {
|
|
1548
|
+
const { lines, headHash, count } = chainEvents(validated, basename(sessionDir));
|
|
1549
|
+
body = lines.length > 0 ? `${lines.join("\n")}
|
|
1550
|
+
` : "";
|
|
1551
|
+
result = count > 0 ? { headHash, count } : null;
|
|
1552
|
+
} else {
|
|
1553
|
+
body = validated.length > 0 ? `${validated.map(serializeEventLine).join("\n")}
|
|
1318
1554
|
` : "";
|
|
1555
|
+
}
|
|
1319
1556
|
try {
|
|
1320
1557
|
await atomicReplace(filePath, body);
|
|
1321
1558
|
} catch (error) {
|
|
1322
1559
|
throw new Error("Failed to write events.jsonl", { cause: error });
|
|
1323
1560
|
}
|
|
1561
|
+
return result;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// src/events/verify.ts
|
|
1565
|
+
import { readFile as readFile4 } from "fs/promises";
|
|
1566
|
+
import { join as join8 } from "path";
|
|
1567
|
+
var STRICT_STATUSES = /* @__PURE__ */ new Set([
|
|
1568
|
+
"completed",
|
|
1569
|
+
"failed",
|
|
1570
|
+
"interrupted",
|
|
1571
|
+
"imported",
|
|
1572
|
+
"archived"
|
|
1573
|
+
]);
|
|
1574
|
+
function isLiveStatus(status) {
|
|
1575
|
+
return !STRICT_STATUSES.has(status);
|
|
1576
|
+
}
|
|
1577
|
+
async function verifyEventsChain(paths, sessionId) {
|
|
1578
|
+
const first = await verifyOnce(paths, sessionId);
|
|
1579
|
+
if (first.status === "tampered" && first.reason === "anchor_mismatch") {
|
|
1580
|
+
return await verifyOnce(paths, sessionId);
|
|
1581
|
+
}
|
|
1582
|
+
return first;
|
|
1583
|
+
}
|
|
1584
|
+
async function verifyOnce(paths, sessionId) {
|
|
1585
|
+
const sessionDir = join8(paths.sessions, sessionId);
|
|
1586
|
+
let raw = null;
|
|
1587
|
+
try {
|
|
1588
|
+
raw = await readFile4(join8(sessionDir, "events.jsonl"));
|
|
1589
|
+
} catch (error) {
|
|
1590
|
+
if (!findErrorCode(error, "ENOENT")) {
|
|
1591
|
+
throw new Error("Failed to read events.jsonl", { cause: error });
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
let anchor;
|
|
1595
|
+
try {
|
|
1596
|
+
const session = await readSessionYaml(paths, sessionId);
|
|
1597
|
+
anchor = {
|
|
1598
|
+
kind: "present",
|
|
1599
|
+
integrity: session.session.integrity,
|
|
1600
|
+
status: session.session.status
|
|
1601
|
+
};
|
|
1602
|
+
} catch (error) {
|
|
1603
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
1604
|
+
anchor = { kind: "absent" };
|
|
1605
|
+
} else {
|
|
1606
|
+
anchor = { kind: "unreadable" };
|
|
1607
|
+
}
|
|
1608
|
+
}
|
|
1609
|
+
const terminated = raw === null || raw.length === 0 || raw[raw.length - 1] === 10;
|
|
1610
|
+
const segments = raw === null ? [] : splitLinesBytes2(raw);
|
|
1611
|
+
const tailFragment = !terminated && segments.length > 0 ? segments.pop() : null;
|
|
1612
|
+
const lines = segments;
|
|
1613
|
+
const carriesPrevHash2 = (s) => {
|
|
1614
|
+
try {
|
|
1615
|
+
const obj = JSON.parse(s.toString("utf8"));
|
|
1616
|
+
return typeof obj === "object" && obj !== null && "prev_hash" in obj;
|
|
1617
|
+
} catch {
|
|
1618
|
+
return false;
|
|
1619
|
+
}
|
|
1620
|
+
};
|
|
1621
|
+
const chained = lines.some((l) => l.length > 0 && carriesPrevHash2(l)) || tailFragment !== null && carriesPrevHash2(tailFragment);
|
|
1622
|
+
if (!chained) {
|
|
1623
|
+
if (anchor.kind === "present" && anchor.integrity !== void 0) {
|
|
1624
|
+
return {
|
|
1625
|
+
status: "tampered",
|
|
1626
|
+
eventCount: lines.length,
|
|
1627
|
+
reason: "anchor_without_chain"
|
|
1628
|
+
};
|
|
1629
|
+
}
|
|
1630
|
+
if (raw === null || raw.length === 0) {
|
|
1631
|
+
return { status: "empty", eventCount: 0 };
|
|
1632
|
+
}
|
|
1633
|
+
return { status: "unchained", eventCount: lines.length };
|
|
1634
|
+
}
|
|
1635
|
+
let expected = genesisHash(sessionId);
|
|
1636
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1637
|
+
const line = lines[i];
|
|
1638
|
+
const lineNo = i + 1;
|
|
1639
|
+
if (line.length === 0) {
|
|
1640
|
+
return { status: "tampered", eventCount: lines.length, reason: "blank_line", line: lineNo };
|
|
1641
|
+
}
|
|
1642
|
+
let parsed;
|
|
1643
|
+
try {
|
|
1644
|
+
parsed = JSON.parse(line.toString("utf8"));
|
|
1645
|
+
} catch {
|
|
1646
|
+
return {
|
|
1647
|
+
status: "tampered",
|
|
1648
|
+
eventCount: lines.length,
|
|
1649
|
+
reason: "malformed_line",
|
|
1650
|
+
line: lineNo
|
|
1651
|
+
};
|
|
1652
|
+
}
|
|
1653
|
+
const record = parsed;
|
|
1654
|
+
if (typeof record.prev_hash !== "string") {
|
|
1655
|
+
return {
|
|
1656
|
+
status: "tampered",
|
|
1657
|
+
eventCount: lines.length,
|
|
1658
|
+
reason: "missing_prev_hash",
|
|
1659
|
+
line: lineNo
|
|
1660
|
+
};
|
|
1661
|
+
}
|
|
1662
|
+
if (record.prev_hash !== expected) {
|
|
1663
|
+
return {
|
|
1664
|
+
status: "tampered",
|
|
1665
|
+
eventCount: lines.length,
|
|
1666
|
+
reason: i === 0 ? "genesis_mismatch" : "broken_link",
|
|
1667
|
+
line: lineNo
|
|
1668
|
+
};
|
|
1669
|
+
}
|
|
1670
|
+
if (record.session_id !== sessionId) {
|
|
1671
|
+
return {
|
|
1672
|
+
status: "tampered",
|
|
1673
|
+
eventCount: lines.length,
|
|
1674
|
+
reason: "session_id_mismatch",
|
|
1675
|
+
line: lineNo
|
|
1676
|
+
};
|
|
1677
|
+
}
|
|
1678
|
+
expected = lineHash(line);
|
|
1679
|
+
}
|
|
1680
|
+
const live = anchor.kind === "present" && isLiveStatus(anchor.status);
|
|
1681
|
+
if (tailFragment !== null || !terminated) {
|
|
1682
|
+
if (live) {
|
|
1683
|
+
return { status: "in_progress", eventCount: lines.length };
|
|
1684
|
+
}
|
|
1685
|
+
return {
|
|
1686
|
+
status: "tampered",
|
|
1687
|
+
eventCount: lines.length,
|
|
1688
|
+
reason: "torn_tail",
|
|
1689
|
+
line: lines.length + 1
|
|
1690
|
+
};
|
|
1691
|
+
}
|
|
1692
|
+
if (anchor.kind === "absent") {
|
|
1693
|
+
return { status: "incomplete", eventCount: lines.length, reason: "yaml_missing" };
|
|
1694
|
+
}
|
|
1695
|
+
if (anchor.kind === "unreadable") {
|
|
1696
|
+
return { status: "tampered", eventCount: lines.length, reason: "yaml_unreadable" };
|
|
1697
|
+
}
|
|
1698
|
+
if (live) {
|
|
1699
|
+
return { status: "in_progress", eventCount: lines.length };
|
|
1700
|
+
}
|
|
1701
|
+
if (anchor.integrity === void 0) {
|
|
1702
|
+
return { status: "tampered", eventCount: lines.length, reason: "anchor_missing" };
|
|
1703
|
+
}
|
|
1704
|
+
if (anchor.integrity.event_count !== lines.length || anchor.integrity.head_hash !== expected) {
|
|
1705
|
+
return { status: "tampered", eventCount: lines.length, reason: "anchor_mismatch" };
|
|
1706
|
+
}
|
|
1707
|
+
return { status: "verified", eventCount: lines.length };
|
|
1708
|
+
}
|
|
1709
|
+
function splitLinesBytes2(buf) {
|
|
1710
|
+
const out = [];
|
|
1711
|
+
let start = 0;
|
|
1712
|
+
for (let i = 0; i < buf.length; i++) {
|
|
1713
|
+
if (buf[i] === 10) {
|
|
1714
|
+
out.push(buf.subarray(start, i));
|
|
1715
|
+
start = i + 1;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (start < buf.length) out.push(buf.subarray(start));
|
|
1719
|
+
return out;
|
|
1324
1720
|
}
|
|
1325
1721
|
|
|
1326
1722
|
// src/git/snapshot.ts
|
|
@@ -1617,12 +2013,12 @@ function parseDiffNameStatus(raw) {
|
|
|
1617
2013
|
}
|
|
1618
2014
|
|
|
1619
2015
|
// src/handoff/handoff-renderer.ts
|
|
1620
|
-
import { join as
|
|
2016
|
+
import { join as join12 } from "path";
|
|
1621
2017
|
|
|
1622
2018
|
// src/storage/tasks.ts
|
|
1623
|
-
import { createHash } from "crypto";
|
|
1624
|
-
import { mkdir as
|
|
1625
|
-
import { join as
|
|
2019
|
+
import { createHash as createHash2 } from "crypto";
|
|
2020
|
+
import { mkdir as mkdir3, readdir as readdir3, readFile as readFile7, rename as rename2, stat as stat2, unlink as unlink3 } from "fs/promises";
|
|
2021
|
+
import { join as join11 } from "path";
|
|
1626
2022
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
1627
2023
|
import { z as z8 } from "zod";
|
|
1628
2024
|
|
|
@@ -1664,9 +2060,9 @@ var TaskSchema = z6.object({
|
|
|
1664
2060
|
});
|
|
1665
2061
|
|
|
1666
2062
|
// src/storage/ad-hoc-session.ts
|
|
1667
|
-
import { mkdir, rm } from "fs/promises";
|
|
2063
|
+
import { mkdir as mkdir2, rm } from "fs/promises";
|
|
1668
2064
|
import { homedir } from "os";
|
|
1669
|
-
import { join as
|
|
2065
|
+
import { join as join9 } from "path";
|
|
1670
2066
|
|
|
1671
2067
|
// src/lib/path-sanitizer.ts
|
|
1672
2068
|
import { posix as path } from "path";
|
|
@@ -1746,87 +2142,94 @@ async function createAdHocSessionWithEvent(input) {
|
|
|
1746
2142
|
taskId: input.taskId ?? null
|
|
1747
2143
|
})
|
|
1748
2144
|
);
|
|
1749
|
-
const sessionDir =
|
|
2145
|
+
const sessionDir = join9(input.paths.sessions, sessionId);
|
|
2146
|
+
const sessionYamlPath = join9(sessionDir, "session.yaml");
|
|
2147
|
+
const lock = await acquireLock(input.paths, "session", sessionId);
|
|
2148
|
+
let bulkResult = null;
|
|
1750
2149
|
try {
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
const sessionYamlPath = join6(sessionDir, "session.yaml");
|
|
1756
|
-
try {
|
|
1757
|
-
await linkYamlFile(sessionYamlPath, initialSession);
|
|
1758
|
-
} catch (error) {
|
|
1759
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1760
|
-
if (findErrorCode(error, "EEXIST")) {
|
|
1761
|
-
throw new Error("Session directory collision (retry the command)", {
|
|
1762
|
-
cause: error
|
|
1763
|
-
});
|
|
2150
|
+
try {
|
|
2151
|
+
await mkdir2(sessionDir, { recursive: true });
|
|
2152
|
+
} catch (error) {
|
|
2153
|
+
throw new Error("Failed to create session directory", { cause: error });
|
|
1764
2154
|
}
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
{
|
|
1774
|
-
schema_version: "0.1.0",
|
|
1775
|
-
id: startedEventId,
|
|
1776
|
-
session_id: sessionId,
|
|
1777
|
-
occurred_at: input.occurredAt,
|
|
1778
|
-
source: "local-cli",
|
|
1779
|
-
type: "session_started"
|
|
1780
|
-
},
|
|
1781
|
-
{
|
|
1782
|
-
schema_version: "0.1.0",
|
|
1783
|
-
id: statusToRunningEventId,
|
|
1784
|
-
session_id: sessionId,
|
|
1785
|
-
occurred_at: input.occurredAt,
|
|
1786
|
-
source: "local-cli",
|
|
1787
|
-
type: "session_status_changed",
|
|
1788
|
-
from: "initialized",
|
|
1789
|
-
to: "running"
|
|
1790
|
-
},
|
|
1791
|
-
...targetEvents,
|
|
1792
|
-
{
|
|
1793
|
-
schema_version: "0.1.0",
|
|
1794
|
-
id: statusToCompletedEventId,
|
|
1795
|
-
session_id: sessionId,
|
|
1796
|
-
occurred_at: input.occurredAt,
|
|
1797
|
-
source: "local-cli",
|
|
1798
|
-
type: "session_status_changed",
|
|
1799
|
-
from: "running",
|
|
1800
|
-
to: "completed"
|
|
1801
|
-
},
|
|
1802
|
-
{
|
|
1803
|
-
schema_version: "0.1.0",
|
|
1804
|
-
id: endedEventId,
|
|
1805
|
-
session_id: sessionId,
|
|
1806
|
-
occurred_at: input.occurredAt,
|
|
1807
|
-
source: "local-cli",
|
|
1808
|
-
type: "session_ended",
|
|
1809
|
-
exit_code: 0
|
|
1810
|
-
}
|
|
1811
|
-
];
|
|
1812
|
-
await writeEventsBulk(sessionDir, events);
|
|
1813
|
-
} catch (error) {
|
|
1814
|
-
await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
1815
|
-
throw error;
|
|
1816
|
-
}
|
|
1817
|
-
try {
|
|
1818
|
-
const finalSession = SessionSchema.parse({
|
|
1819
|
-
...initialSession,
|
|
1820
|
-
session: {
|
|
1821
|
-
...initialSession.session,
|
|
1822
|
-
status: "completed",
|
|
1823
|
-
ended_at: input.occurredAt,
|
|
1824
|
-
invocation: { ...initialSession.session.invocation, exit_code: 0 }
|
|
2155
|
+
try {
|
|
2156
|
+
await linkYamlFile(sessionYamlPath, initialSession);
|
|
2157
|
+
} catch (error) {
|
|
2158
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2159
|
+
if (findErrorCode(error, "EEXIST")) {
|
|
2160
|
+
throw new Error("Session directory collision (retry the command)", {
|
|
2161
|
+
cause: error
|
|
2162
|
+
});
|
|
1825
2163
|
}
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
2164
|
+
throw error;
|
|
2165
|
+
}
|
|
2166
|
+
try {
|
|
2167
|
+
const targetEvents = input.targetEventBuilders.map((build, index) => {
|
|
2168
|
+
const targetEventId = targetEventIds[index];
|
|
2169
|
+
return assertTargetEventIdentity(build(sessionId, targetEventId), sessionId, targetEventId);
|
|
2170
|
+
});
|
|
2171
|
+
const events = [
|
|
2172
|
+
{
|
|
2173
|
+
schema_version: "0.1.0",
|
|
2174
|
+
id: startedEventId,
|
|
2175
|
+
session_id: sessionId,
|
|
2176
|
+
occurred_at: input.occurredAt,
|
|
2177
|
+
source: "local-cli",
|
|
2178
|
+
type: "session_started"
|
|
2179
|
+
},
|
|
2180
|
+
{
|
|
2181
|
+
schema_version: "0.1.0",
|
|
2182
|
+
id: statusToRunningEventId,
|
|
2183
|
+
session_id: sessionId,
|
|
2184
|
+
occurred_at: input.occurredAt,
|
|
2185
|
+
source: "local-cli",
|
|
2186
|
+
type: "session_status_changed",
|
|
2187
|
+
from: "initialized",
|
|
2188
|
+
to: "running"
|
|
2189
|
+
},
|
|
2190
|
+
...targetEvents,
|
|
2191
|
+
{
|
|
2192
|
+
schema_version: "0.1.0",
|
|
2193
|
+
id: statusToCompletedEventId,
|
|
2194
|
+
session_id: sessionId,
|
|
2195
|
+
occurred_at: input.occurredAt,
|
|
2196
|
+
source: "local-cli",
|
|
2197
|
+
type: "session_status_changed",
|
|
2198
|
+
from: "running",
|
|
2199
|
+
to: "completed"
|
|
2200
|
+
},
|
|
2201
|
+
{
|
|
2202
|
+
schema_version: "0.1.0",
|
|
2203
|
+
id: endedEventId,
|
|
2204
|
+
session_id: sessionId,
|
|
2205
|
+
occurred_at: input.occurredAt,
|
|
2206
|
+
source: "local-cli",
|
|
2207
|
+
type: "session_ended",
|
|
2208
|
+
exit_code: 0
|
|
2209
|
+
}
|
|
2210
|
+
];
|
|
2211
|
+
bulkResult = await writeEventsBulk(sessionDir, events, { chain: true });
|
|
2212
|
+
} catch (error) {
|
|
2213
|
+
await rm(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
2214
|
+
throw error;
|
|
2215
|
+
}
|
|
2216
|
+
try {
|
|
2217
|
+
const finalSession = SessionSchema.parse({
|
|
2218
|
+
...initialSession,
|
|
2219
|
+
session: {
|
|
2220
|
+
...initialSession.session,
|
|
2221
|
+
status: "completed",
|
|
2222
|
+
ended_at: input.occurredAt,
|
|
2223
|
+
invocation: { ...initialSession.session.invocation, exit_code: 0 },
|
|
2224
|
+
...bulkResult !== null ? { integrity: { head_hash: bulkResult.headHash, event_count: bulkResult.count } } : {}
|
|
2225
|
+
}
|
|
2226
|
+
});
|
|
2227
|
+
await overwriteYamlFile(sessionYamlPath, finalSession);
|
|
2228
|
+
} catch (error) {
|
|
2229
|
+
throw new FailedToFinalizeError(sessionId, targetEventIds, error);
|
|
2230
|
+
}
|
|
2231
|
+
} finally {
|
|
2232
|
+
await lock.release();
|
|
1830
2233
|
}
|
|
1831
2234
|
return {
|
|
1832
2235
|
sessionId,
|
|
@@ -1875,8 +2278,7 @@ async function appendEventToExistingSession(input) {
|
|
|
1875
2278
|
}
|
|
1876
2279
|
const eventId = prefixedUlid("evt");
|
|
1877
2280
|
const event = assertTargetEventIdentity(input.eventBuilder(eventId), input.sessionId, eventId);
|
|
1878
|
-
|
|
1879
|
-
await appendEvent(sessionDir, event);
|
|
2281
|
+
await appendChainedEventLocked(input.paths, input.sessionId, event);
|
|
1880
2282
|
return { eventId, sessionStatus: status };
|
|
1881
2283
|
}
|
|
1882
2284
|
function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
|
|
@@ -1889,75 +2291,9 @@ function assertTargetEventIdentity(event, expectedSessionId, expectedEventId) {
|
|
|
1889
2291
|
return event;
|
|
1890
2292
|
}
|
|
1891
2293
|
|
|
1892
|
-
// src/storage/lockfile.ts
|
|
1893
|
-
import { readFile as readFile3, unlink as unlink2 } from "fs/promises";
|
|
1894
|
-
import { join as join7 } from "path";
|
|
1895
|
-
var STALE_LOCK_MAX_AGE_MS = 60 * 60 * 1e3;
|
|
1896
|
-
async function acquireLock(paths, scope, resourceId) {
|
|
1897
|
-
const lockPath = lockfilePath(paths, scope, resourceId);
|
|
1898
|
-
const body = {
|
|
1899
|
-
pid: process.pid,
|
|
1900
|
-
acquired_at: (/* @__PURE__ */ new Date()).toISOString()
|
|
1901
|
-
};
|
|
1902
|
-
const serialised = JSON.stringify(body);
|
|
1903
|
-
try {
|
|
1904
|
-
await atomicCreate(lockPath, serialised);
|
|
1905
|
-
} catch (error) {
|
|
1906
|
-
if (!findErrorCode(error, "EEXIST")) {
|
|
1907
|
-
throw error;
|
|
1908
|
-
}
|
|
1909
|
-
const stale = await isStaleLock(lockPath);
|
|
1910
|
-
if (!stale) {
|
|
1911
|
-
throw new Error("Lock is held by another process", { cause: error });
|
|
1912
|
-
}
|
|
1913
|
-
await unlink2(lockPath).catch(() => void 0);
|
|
1914
|
-
try {
|
|
1915
|
-
await atomicCreate(lockPath, serialised);
|
|
1916
|
-
} catch (retryError) {
|
|
1917
|
-
throw new Error("Lock is held by another process", { cause: retryError });
|
|
1918
|
-
}
|
|
1919
|
-
}
|
|
1920
|
-
return {
|
|
1921
|
-
release: async () => {
|
|
1922
|
-
await unlink2(lockPath).catch(() => void 0);
|
|
1923
|
-
}
|
|
1924
|
-
};
|
|
1925
|
-
}
|
|
1926
|
-
async function isStaleLock(lockPath) {
|
|
1927
|
-
let body;
|
|
1928
|
-
try {
|
|
1929
|
-
const raw = await readFile3(lockPath, "utf8");
|
|
1930
|
-
const parsed = JSON.parse(raw);
|
|
1931
|
-
if (typeof parsed !== "object" || parsed === null) return true;
|
|
1932
|
-
const candidate = parsed;
|
|
1933
|
-
if (typeof candidate.pid !== "number" || typeof candidate.acquired_at !== "string") {
|
|
1934
|
-
return true;
|
|
1935
|
-
}
|
|
1936
|
-
body = { pid: candidate.pid, acquired_at: candidate.acquired_at };
|
|
1937
|
-
} catch {
|
|
1938
|
-
return true;
|
|
1939
|
-
}
|
|
1940
|
-
const ageMs = Date.now() - Date.parse(body.acquired_at);
|
|
1941
|
-
if (!Number.isFinite(ageMs) || ageMs > STALE_LOCK_MAX_AGE_MS) {
|
|
1942
|
-
return true;
|
|
1943
|
-
}
|
|
1944
|
-
try {
|
|
1945
|
-
process.kill(body.pid, 0);
|
|
1946
|
-
return false;
|
|
1947
|
-
} catch (error) {
|
|
1948
|
-
if (findErrorCode(error, "ESRCH")) return true;
|
|
1949
|
-
return false;
|
|
1950
|
-
}
|
|
1951
|
-
}
|
|
1952
|
-
function lockfilePath(paths, scope, resourceId) {
|
|
1953
|
-
const sep = resourceId.indexOf("_");
|
|
1954
|
-
const ulid2 = sep >= 0 ? resourceId.slice(sep + 1) : resourceId;
|
|
1955
|
-
return join7(paths.locks, `${scope}_${ulid2}.lock`);
|
|
1956
|
-
}
|
|
1957
|
-
|
|
1958
2294
|
// src/storage/task-index.ts
|
|
1959
|
-
import { readFile as
|
|
1960
|
-
import { join as
|
|
2295
|
+
import { readFile as readFile6 } from "fs/promises";
|
|
2296
|
+
import { join as join10 } from "path";
|
|
1961
2297
|
|
|
1962
2298
|
// src/schemas/task-index.schema.ts
|
|
1963
2299
|
import { z as z7 } from "zod";
|
|
@@ -1976,13 +2312,13 @@ var TASK_INDEX_SCHEMA_VERSION = "0.1.0";
|
|
|
1976
2312
|
|
|
1977
2313
|
// src/storage/task-index.ts
|
|
1978
2314
|
function taskIndexPath(paths) {
|
|
1979
|
-
return
|
|
2315
|
+
return join10(paths.tasks, "index.json");
|
|
1980
2316
|
}
|
|
1981
2317
|
async function readTaskIndex(paths) {
|
|
1982
2318
|
const filePath = taskIndexPath(paths);
|
|
1983
2319
|
let raw;
|
|
1984
2320
|
try {
|
|
1985
|
-
raw = await
|
|
2321
|
+
raw = await readFile6(filePath, "utf8");
|
|
1986
2322
|
} catch (error) {
|
|
1987
2323
|
if (findErrorCode(error, "ENOENT")) {
|
|
1988
2324
|
throw new Error("Task index not found", { cause: error });
|
|
@@ -2090,10 +2426,10 @@ function splitFrontMatter(raw) {
|
|
|
2090
2426
|
return { yamlText, body };
|
|
2091
2427
|
}
|
|
2092
2428
|
async function readTaskFile(paths, taskId) {
|
|
2093
|
-
const filePath =
|
|
2429
|
+
const filePath = join11(paths.tasks, `${taskId}.md`);
|
|
2094
2430
|
let raw;
|
|
2095
2431
|
try {
|
|
2096
|
-
raw = await
|
|
2432
|
+
raw = await readFile7(filePath, "utf8");
|
|
2097
2433
|
} catch (error) {
|
|
2098
2434
|
if (findErrorCode(error, "ENOENT")) {
|
|
2099
2435
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2123,7 +2459,7 @@ async function readTaskFile(paths, taskId) {
|
|
|
2123
2459
|
}
|
|
2124
2460
|
async function writeTaskFile(paths, taskId, doc, options) {
|
|
2125
2461
|
const validated = TaskSchema.parse(doc.task);
|
|
2126
|
-
const filePath =
|
|
2462
|
+
const filePath = join11(paths.tasks, `${taskId}.md`);
|
|
2127
2463
|
const yamlText = stringifyYaml(validated);
|
|
2128
2464
|
const trimmedBody = doc.body.length === 0 ? "" : `
|
|
2129
2465
|
${doc.body.endsWith("\n") ? doc.body : `${doc.body}
|
|
@@ -2208,7 +2544,7 @@ async function safeUpdateTaskIndex(paths, op) {
|
|
|
2208
2544
|
}
|
|
2209
2545
|
var ARCHIVE_DIR_NAME = "archive";
|
|
2210
2546
|
function archiveTasksDir(paths) {
|
|
2211
|
-
return
|
|
2547
|
+
return join11(paths.tasks, ARCHIVE_DIR_NAME);
|
|
2212
2548
|
}
|
|
2213
2549
|
async function enumerateArchivedTaskIds(paths) {
|
|
2214
2550
|
let entries;
|
|
@@ -2238,10 +2574,10 @@ async function readTaskFileWithArchiveFallback(paths, taskId) {
|
|
|
2238
2574
|
throw error;
|
|
2239
2575
|
}
|
|
2240
2576
|
}
|
|
2241
|
-
const archiveFilePath =
|
|
2577
|
+
const archiveFilePath = join11(archiveTasksDir(paths), `${taskId}.md`);
|
|
2242
2578
|
let raw;
|
|
2243
2579
|
try {
|
|
2244
|
-
raw = await
|
|
2580
|
+
raw = await readFile7(archiveFilePath, "utf8");
|
|
2245
2581
|
} catch (error) {
|
|
2246
2582
|
if (findErrorCode(error, "ENOENT")) {
|
|
2247
2583
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2532,7 +2868,7 @@ async function createTaskAttachLocked(input) {
|
|
|
2532
2868
|
...sessionDoc,
|
|
2533
2869
|
session: { ...sessionDoc.session, task_id: input.taskId }
|
|
2534
2870
|
};
|
|
2535
|
-
await overwriteYamlFile(
|
|
2871
|
+
await overwriteYamlFile(join11(input.paths.sessions, input.sessionId, "session.yaml"), updated);
|
|
2536
2872
|
} catch (error) {
|
|
2537
2873
|
throw new TaskWriteAfterEventError({
|
|
2538
2874
|
taskId: input.taskId,
|
|
@@ -2791,17 +3127,17 @@ function buildUpdatedDoc(input) {
|
|
|
2791
3127
|
return { task: next, body: input.currentDoc.body };
|
|
2792
3128
|
}
|
|
2793
3129
|
async function computeTaskMdSnapshot(paths, taskId) {
|
|
2794
|
-
const filePath =
|
|
2795
|
-
const [stats, raw] = await Promise.all([stat2(filePath),
|
|
2796
|
-
const hash =
|
|
3130
|
+
const filePath = join11(paths.tasks, `${taskId}.md`);
|
|
3131
|
+
const [stats, raw] = await Promise.all([stat2(filePath), readFile7(filePath)]);
|
|
3132
|
+
const hash = createHash2("sha256").update(raw).digest("hex");
|
|
2797
3133
|
return { mtimeMs: stats.mtimeMs, hash };
|
|
2798
3134
|
}
|
|
2799
3135
|
async function readTaskFileWithSnapshot(paths, taskId) {
|
|
2800
|
-
const filePath =
|
|
3136
|
+
const filePath = join11(paths.tasks, `${taskId}.md`);
|
|
2801
3137
|
let rawBuffer;
|
|
2802
3138
|
let stats;
|
|
2803
3139
|
try {
|
|
2804
|
-
[rawBuffer, stats] = await Promise.all([
|
|
3140
|
+
[rawBuffer, stats] = await Promise.all([readFile7(filePath), stat2(filePath)]);
|
|
2805
3141
|
} catch (error) {
|
|
2806
3142
|
if (findErrorCode(error, "ENOENT")) {
|
|
2807
3143
|
throw new Error("Task file not found", { cause: error });
|
|
@@ -2809,7 +3145,7 @@ async function readTaskFileWithSnapshot(paths, taskId) {
|
|
|
2809
3145
|
throw new Error("Failed to read task file", { cause: error });
|
|
2810
3146
|
}
|
|
2811
3147
|
const raw = rawBuffer.toString("utf8");
|
|
2812
|
-
const hash =
|
|
3148
|
+
const hash = createHash2("sha256").update(rawBuffer).digest("hex");
|
|
2813
3149
|
let split;
|
|
2814
3150
|
try {
|
|
2815
3151
|
split = splitFrontMatter(raw);
|
|
@@ -3289,7 +3625,7 @@ async function deleteTaskLocked(input) {
|
|
|
3289
3625
|
});
|
|
3290
3626
|
const eventId = adHoc.targetEventIds[0];
|
|
3291
3627
|
try {
|
|
3292
|
-
await unlink3(
|
|
3628
|
+
await unlink3(join11(input.paths.tasks, `${input.taskId}.md`));
|
|
3293
3629
|
} catch (error) {
|
|
3294
3630
|
throw new TaskWriteAfterEventError({
|
|
3295
3631
|
taskId: input.taskId,
|
|
@@ -3359,10 +3695,10 @@ async function archiveTaskLocked(input) {
|
|
|
3359
3695
|
{ task: next, body: doc.body },
|
|
3360
3696
|
{ mode: "overwrite" }
|
|
3361
3697
|
);
|
|
3362
|
-
await
|
|
3698
|
+
await mkdir3(archiveTasksDir(input.paths), { recursive: true });
|
|
3363
3699
|
await rename2(
|
|
3364
|
-
|
|
3365
|
-
|
|
3700
|
+
join11(input.paths.tasks, `${input.taskId}.md`),
|
|
3701
|
+
join11(archiveTasksDir(input.paths), `${input.taskId}.md`)
|
|
3366
3702
|
);
|
|
3367
3703
|
} catch (error) {
|
|
3368
3704
|
throw new TaskWriteAfterEventError({
|
|
@@ -3398,7 +3734,7 @@ async function renderHandoff(input) {
|
|
|
3398
3734
|
const tasksCreated = [];
|
|
3399
3735
|
const tasksStatusChanged = [];
|
|
3400
3736
|
for (const entry of entries) {
|
|
3401
|
-
const sessionDir =
|
|
3737
|
+
const sessionDir = join12(input.paths.sessions, entry.sessionId);
|
|
3402
3738
|
try {
|
|
3403
3739
|
for await (const ev of replayEvents(sessionDir, {
|
|
3404
3740
|
onWarning: (w) => input.onWarning?.(w, entry.sessionId)
|
|
@@ -3952,7 +4288,12 @@ var SessionInnerImportSchema = z10.object({
|
|
|
3952
4288
|
related_files: z10.array(z10.string()).default([]),
|
|
3953
4289
|
events_log: z10.string().optional(),
|
|
3954
4290
|
summary: z10.string().nullable().optional(),
|
|
3955
|
-
metrics: SessionMetricsSchema.optional()
|
|
4291
|
+
metrics: SessionMetricsSchema.optional(),
|
|
4292
|
+
// Accepted so a payload assembled from an on-disk chained session.yaml
|
|
4293
|
+
// round-trips, and DISCARDED by the importer (buildSessionRecord never
|
|
4294
|
+
// copies it): the integrity anchor is computed at write time, never
|
|
4295
|
+
// imported. Mirrors the accept-and-discard of `prev_hash` on events.
|
|
4296
|
+
integrity: SessionIntegritySchema.optional()
|
|
3956
4297
|
}).strict();
|
|
3957
4298
|
var SessionImportPayloadSchema = z10.object({
|
|
3958
4299
|
schema_version: z10.string(),
|
|
@@ -4034,7 +4375,7 @@ function serializeJsonSchema(schema) {
|
|
|
4034
4375
|
}
|
|
4035
4376
|
|
|
4036
4377
|
// src/stats/work-stats.ts
|
|
4037
|
-
import { join as
|
|
4378
|
+
import { join as join13 } from "path";
|
|
4038
4379
|
function resolveTimeZone(timeZone) {
|
|
4039
4380
|
if (timeZone !== void 0 && timeZone.length > 0) return timeZone;
|
|
4040
4381
|
return Intl.DateTimeFormat().resolvedOptions().timeZone;
|
|
@@ -4065,7 +4406,7 @@ async function computeWorkStats(input) {
|
|
|
4065
4406
|
const events = [];
|
|
4066
4407
|
let eventsUnreadable = false;
|
|
4067
4408
|
try {
|
|
4068
|
-
for await (const ev of replayEvents(
|
|
4409
|
+
for await (const ev of replayEvents(join13(input.paths.sessions, entry.sessionId), {
|
|
4069
4410
|
onWarning: (w) => input.onWarning?.(w, entry.sessionId)
|
|
4070
4411
|
})) {
|
|
4071
4412
|
events.push(ev);
|
|
@@ -4320,28 +4661,28 @@ function tzDate(ms, timeZone) {
|
|
|
4320
4661
|
}
|
|
4321
4662
|
|
|
4322
4663
|
// src/storage/basou-dir.ts
|
|
4323
|
-
import { lstat as lstat3, mkdir as
|
|
4324
|
-
import { join as
|
|
4664
|
+
import { lstat as lstat3, mkdir as mkdir4 } from "fs/promises";
|
|
4665
|
+
import { join as join14 } from "path";
|
|
4325
4666
|
function basouPaths(repositoryRoot) {
|
|
4326
|
-
const root =
|
|
4327
|
-
const approvalsBase =
|
|
4667
|
+
const root = join14(repositoryRoot, ".basou");
|
|
4668
|
+
const approvalsBase = join14(root, "approvals");
|
|
4328
4669
|
return {
|
|
4329
4670
|
root,
|
|
4330
|
-
sessions:
|
|
4331
|
-
tasks:
|
|
4671
|
+
sessions: join14(root, "sessions"),
|
|
4672
|
+
tasks: join14(root, "tasks"),
|
|
4332
4673
|
approvals: {
|
|
4333
|
-
pending:
|
|
4334
|
-
resolved:
|
|
4674
|
+
pending: join14(approvalsBase, "pending"),
|
|
4675
|
+
resolved: join14(approvalsBase, "resolved")
|
|
4335
4676
|
},
|
|
4336
|
-
locks:
|
|
4337
|
-
logs:
|
|
4338
|
-
raw:
|
|
4339
|
-
tmp:
|
|
4677
|
+
locks: join14(root, "locks"),
|
|
4678
|
+
logs: join14(root, "logs"),
|
|
4679
|
+
raw: join14(root, "raw"),
|
|
4680
|
+
tmp: join14(root, "tmp"),
|
|
4340
4681
|
files: {
|
|
4341
|
-
manifest:
|
|
4342
|
-
status:
|
|
4343
|
-
handoff:
|
|
4344
|
-
decisions:
|
|
4682
|
+
manifest: join14(root, "manifest.yaml"),
|
|
4683
|
+
status: join14(root, "status.json"),
|
|
4684
|
+
handoff: join14(root, "handoff.md"),
|
|
4685
|
+
decisions: join14(root, "decisions.md")
|
|
4345
4686
|
}
|
|
4346
4687
|
};
|
|
4347
4688
|
}
|
|
@@ -4382,7 +4723,7 @@ async function ensureBasouDirectory(repositoryRoot) {
|
|
|
4382
4723
|
}
|
|
4383
4724
|
async function mkdirLabeled(target, label) {
|
|
4384
4725
|
try {
|
|
4385
|
-
await
|
|
4726
|
+
await mkdir4(target, { recursive: true });
|
|
4386
4727
|
} catch (error) {
|
|
4387
4728
|
if (hasErrorCode3(error) && (error.code === "ENOTDIR" || error.code === "EEXIST")) {
|
|
4388
4729
|
throw new Error(`${label} exists but is not a directory`, { cause: error });
|
|
@@ -4397,16 +4738,16 @@ function hasErrorCode3(error) {
|
|
|
4397
4738
|
}
|
|
4398
4739
|
|
|
4399
4740
|
// src/storage/gitignore.ts
|
|
4400
|
-
import { readFile as
|
|
4401
|
-
import { join as
|
|
4741
|
+
import { readFile as readFile8, writeFile as writeFile2 } from "fs/promises";
|
|
4742
|
+
import { join as join15 } from "path";
|
|
4402
4743
|
var MARKER = "# Basou - default ignore";
|
|
4403
4744
|
var BASOU_GITIGNORE_BLOCK = "# Basou - default ignore\n.basou/logs/\n.basou/raw/\n.basou/tmp/\n.basou/locks/\n.basou/status.json\n.basou/sessions/*/events.jsonl\n.basou/sessions/*/artifacts/\n.basou/approvals/pending/\n.basou/approvals/resolved/\n\n# Basou - default commit\n# .basou/manifest.yaml\n# .basou/handoff.md\n# .basou/decisions.md\n# .basou/tasks/\n# .basou/sessions/*/session.yaml\n# .basou/sessions/*/transcript.md\n# .basou/sessions/*/changed-files.json\n";
|
|
4404
4745
|
async function appendBasouGitignore(repositoryRoot) {
|
|
4405
|
-
const gitignorePath =
|
|
4746
|
+
const gitignorePath = join15(repositoryRoot, ".gitignore");
|
|
4406
4747
|
let body;
|
|
4407
4748
|
let existed;
|
|
4408
4749
|
try {
|
|
4409
|
-
body = await
|
|
4750
|
+
body = await readFile8(gitignorePath, "utf8");
|
|
4410
4751
|
existed = true;
|
|
4411
4752
|
} catch (error) {
|
|
4412
4753
|
if (hasErrorCode4(error) && error.code === "ENOENT") {
|
|
@@ -4512,12 +4853,12 @@ function hasErrorCode5(error) {
|
|
|
4512
4853
|
}
|
|
4513
4854
|
|
|
4514
4855
|
// src/storage/markdown-store.ts
|
|
4515
|
-
import { readFile as
|
|
4856
|
+
import { readFile as readFile9 } from "fs/promises";
|
|
4516
4857
|
var GENERATED_START = "<!-- BASOU:GENERATED:START -->";
|
|
4517
4858
|
var GENERATED_END = "<!-- BASOU:GENERATED:END -->";
|
|
4518
4859
|
async function readMarkdownFile(filePath) {
|
|
4519
4860
|
try {
|
|
4520
|
-
return await
|
|
4861
|
+
return await readFile9(filePath, "utf8");
|
|
4521
4862
|
} catch (error) {
|
|
4522
4863
|
if (hasErrorCode6(error) && error.code === "ENOENT") return null;
|
|
4523
4864
|
throw new Error("Failed to read markdown file", { cause: error });
|
|
@@ -4615,9 +4956,9 @@ function hasErrorCode6(error) {
|
|
|
4615
4956
|
}
|
|
4616
4957
|
|
|
4617
4958
|
// src/storage/session-import.ts
|
|
4618
|
-
import { mkdir as
|
|
4959
|
+
import { mkdir as mkdir5, readFile as readFile10, rm as rm2 } from "fs/promises";
|
|
4619
4960
|
import { homedir as homedir2 } from "os";
|
|
4620
|
-
import { join as
|
|
4961
|
+
import { join as join16 } from "path";
|
|
4621
4962
|
async function importSessionFromJson(paths, manifest, payload, options) {
|
|
4622
4963
|
if (options.taskIdOverride !== void 0 && !TaskIdSchema.safeParse(options.taskIdOverride).success) {
|
|
4623
4964
|
throw new Error(`Invalid task_id: ${options.taskIdOverride}`);
|
|
@@ -4642,21 +4983,22 @@ async function importSessionFromJson(paths, manifest, payload, options) {
|
|
|
4642
4983
|
pathSanitizeReport
|
|
4643
4984
|
};
|
|
4644
4985
|
}
|
|
4645
|
-
const sessionDir =
|
|
4986
|
+
const sessionDir = join16(paths.sessions, newSessionId);
|
|
4646
4987
|
try {
|
|
4647
|
-
await
|
|
4988
|
+
await mkdir5(sessionDir, { recursive: true });
|
|
4648
4989
|
} catch (error) {
|
|
4649
4990
|
throw new Error("Failed to create session directory", { cause: error });
|
|
4650
4991
|
}
|
|
4992
|
+
let chainResult;
|
|
4651
4993
|
try {
|
|
4652
|
-
await writeEventsBulk(sessionDir, rewrittenEvents);
|
|
4994
|
+
chainResult = await writeEventsBulk(sessionDir, rewrittenEvents, { chain: true });
|
|
4653
4995
|
} catch (error) {
|
|
4654
4996
|
await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
4655
4997
|
throw error;
|
|
4656
4998
|
}
|
|
4657
4999
|
try {
|
|
4658
|
-
const sessionYamlPath =
|
|
4659
|
-
await linkYamlFile(sessionYamlPath, sessionRecord);
|
|
5000
|
+
const sessionYamlPath = join16(sessionDir, "session.yaml");
|
|
5001
|
+
await linkYamlFile(sessionYamlPath, withIntegrity(sessionRecord, chainResult));
|
|
4660
5002
|
} catch (error) {
|
|
4661
5003
|
await rm2(sessionDir, { recursive: true, force: true }).catch(() => void 0);
|
|
4662
5004
|
if (findErrorCode(error, "EEXIST")) {
|
|
@@ -4713,6 +5055,16 @@ function assertChronologicalOrder(events) {
|
|
|
4713
5055
|
}
|
|
4714
5056
|
}
|
|
4715
5057
|
}
|
|
5058
|
+
function withIntegrity(record, chainResult) {
|
|
5059
|
+
if (chainResult === null) return record;
|
|
5060
|
+
return {
|
|
5061
|
+
...record,
|
|
5062
|
+
session: {
|
|
5063
|
+
...record.session,
|
|
5064
|
+
integrity: { head_hash: chainResult.headHash, event_count: chainResult.count }
|
|
5065
|
+
}
|
|
5066
|
+
};
|
|
5067
|
+
}
|
|
4716
5068
|
function buildSessionRecord(input, manifest, newSessionId, options) {
|
|
4717
5069
|
const home = homedir2();
|
|
4718
5070
|
const workingDirectoryRaw = input.working_directory;
|
|
@@ -4813,9 +5165,13 @@ function reuseDerivedIds(priorDerived, freshDerived, sessionId) {
|
|
|
4813
5165
|
async function reimportPreservingId(paths, manifest, priorSessionId, freshPayload, options = {}) {
|
|
4814
5166
|
const sessionId = priorSessionId;
|
|
4815
5167
|
const importSource = freshPayload.session.source.kind;
|
|
4816
|
-
const sessionDir =
|
|
5168
|
+
const sessionDir = join16(paths.sessions, priorSessionId);
|
|
4817
5169
|
const lock = options.dryRun === true ? null : await acquireLock(paths, "session", priorSessionId);
|
|
4818
5170
|
try {
|
|
5171
|
+
const priorVerdict = await verifyEventsChain(paths, priorSessionId);
|
|
5172
|
+
if (priorVerdict.status === "tampered") {
|
|
5173
|
+
return { status: "skipped", reason: "prior_chain_broken" };
|
|
5174
|
+
}
|
|
4819
5175
|
let priorUnreadable = false;
|
|
4820
5176
|
const priorEvents = await readAllEvents(sessionDir, {
|
|
4821
5177
|
onWarning: () => {
|
|
@@ -4843,20 +5199,35 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
|
|
|
4843
5199
|
const { record } = buildSessionRecord(freshPayload.session, manifest, sessionId, {});
|
|
4844
5200
|
const preservedInner = {
|
|
4845
5201
|
...record.session,
|
|
4846
|
-
//
|
|
4847
|
-
//
|
|
4848
|
-
// sessions); never drop that link on a re-derive.
|
|
5202
|
+
// Defensive: keep any task_id already present on the prior yaml so a
|
|
5203
|
+
// re-derive never drops a link, whatever wrote it.
|
|
4849
5204
|
task_id: prior.session.task_id ?? null,
|
|
4850
5205
|
// Re-derivation always yields a null summary; keep a prior non-null one.
|
|
4851
5206
|
summary: prior.session.summary ?? record.session.summary ?? null
|
|
4852
5207
|
};
|
|
4853
5208
|
const updatedRecord = { schema_version: "0.1.0", session: preservedInner };
|
|
4854
5209
|
if (options.dryRun !== true) {
|
|
4855
|
-
|
|
5210
|
+
const eventsPath = join16(sessionDir, "events.jsonl");
|
|
5211
|
+
let priorEventsRaw = null;
|
|
4856
5212
|
try {
|
|
4857
|
-
await
|
|
5213
|
+
priorEventsRaw = await readFile10(eventsPath);
|
|
4858
5214
|
} catch (error) {
|
|
4859
|
-
|
|
5215
|
+
if (!findErrorCode(error, "ENOENT")) {
|
|
5216
|
+
throw new Error("Failed to read events.jsonl", { cause: error });
|
|
5217
|
+
}
|
|
5218
|
+
}
|
|
5219
|
+
const chainResult = await writeEventsBulk(sessionDir, mergedEvents, { chain: true });
|
|
5220
|
+
try {
|
|
5221
|
+
await overwriteYamlFile(
|
|
5222
|
+
join16(sessionDir, "session.yaml"),
|
|
5223
|
+
withIntegrity(updatedRecord, chainResult)
|
|
5224
|
+
);
|
|
5225
|
+
} catch (error) {
|
|
5226
|
+
if (priorEventsRaw !== null) {
|
|
5227
|
+
await atomicReplace(eventsPath, priorEventsRaw).catch(() => void 0);
|
|
5228
|
+
} else {
|
|
5229
|
+
await rm2(eventsPath, { force: true }).catch(() => void 0);
|
|
5230
|
+
}
|
|
4860
5231
|
throw error;
|
|
4861
5232
|
}
|
|
4862
5233
|
}
|
|
@@ -4871,6 +5242,100 @@ async function reimportPreservingId(paths, manifest, priorSessionId, freshPayloa
|
|
|
4871
5242
|
await lock?.release();
|
|
4872
5243
|
}
|
|
4873
5244
|
}
|
|
5245
|
+
async function rechainSessionInPlace(paths, sessionId, options = {}) {
|
|
5246
|
+
const sessionDir = join16(paths.sessions, sessionId);
|
|
5247
|
+
let lock;
|
|
5248
|
+
try {
|
|
5249
|
+
lock = await acquireLock(paths, "session", sessionId);
|
|
5250
|
+
} catch (error) {
|
|
5251
|
+
if (error instanceof Error && error.message === "Lock is held by another process") {
|
|
5252
|
+
throw error;
|
|
5253
|
+
}
|
|
5254
|
+
throw new Error("Failed to acquire lock", { cause: error });
|
|
5255
|
+
}
|
|
5256
|
+
try {
|
|
5257
|
+
let record;
|
|
5258
|
+
try {
|
|
5259
|
+
record = await readSessionYaml(paths, sessionId);
|
|
5260
|
+
} catch (error) {
|
|
5261
|
+
if (error instanceof Error && error.message === "YAML file not found") {
|
|
5262
|
+
return { status: "skipped", reason: "yaml_missing" };
|
|
5263
|
+
}
|
|
5264
|
+
return { status: "skipped", reason: "yaml_unreadable" };
|
|
5265
|
+
}
|
|
5266
|
+
if (record.session.status !== "imported") {
|
|
5267
|
+
return { status: "skipped", reason: "not_imported" };
|
|
5268
|
+
}
|
|
5269
|
+
const verdict = await verifyEventsChain(paths, sessionId);
|
|
5270
|
+
if (verdict.status === "verified") {
|
|
5271
|
+
return { status: "skipped", reason: "already_chained" };
|
|
5272
|
+
}
|
|
5273
|
+
if (verdict.status === "empty") {
|
|
5274
|
+
return { status: "skipped", reason: "empty" };
|
|
5275
|
+
}
|
|
5276
|
+
if (verdict.status !== "unchained") {
|
|
5277
|
+
return { status: "skipped", reason: "tampered" };
|
|
5278
|
+
}
|
|
5279
|
+
const eventsPath = join16(sessionDir, "events.jsonl");
|
|
5280
|
+
let priorRaw;
|
|
5281
|
+
try {
|
|
5282
|
+
priorRaw = await readFile10(eventsPath);
|
|
5283
|
+
} catch (error) {
|
|
5284
|
+
throw new Error("Failed to read events.jsonl", { cause: error });
|
|
5285
|
+
}
|
|
5286
|
+
if (priorRaw.length === 0 || priorRaw[priorRaw.length - 1] !== 10) {
|
|
5287
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5288
|
+
}
|
|
5289
|
+
const text = priorRaw.toString("utf8");
|
|
5290
|
+
if (!priorRaw.equals(Buffer.from(text, "utf8"))) {
|
|
5291
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5292
|
+
}
|
|
5293
|
+
const rawLines = text.slice(0, -1).split("\n");
|
|
5294
|
+
for (const line of rawLines) {
|
|
5295
|
+
if (line.trim().length === 0) {
|
|
5296
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5297
|
+
}
|
|
5298
|
+
let parsed;
|
|
5299
|
+
try {
|
|
5300
|
+
parsed = JSON.parse(line);
|
|
5301
|
+
} catch {
|
|
5302
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5303
|
+
}
|
|
5304
|
+
if (JSON.stringify(parsed) !== line) {
|
|
5305
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5306
|
+
}
|
|
5307
|
+
if (!EventSchema.safeParse(parsed).success) {
|
|
5308
|
+
return { status: "skipped", reason: "events_unreadable" };
|
|
5309
|
+
}
|
|
5310
|
+
if (parsed.session_id !== sessionId) {
|
|
5311
|
+
return { status: "skipped", reason: "session_id_mismatch" };
|
|
5312
|
+
}
|
|
5313
|
+
}
|
|
5314
|
+
if (options.dryRun === true) {
|
|
5315
|
+
return { status: "rechained", eventCount: rawLines.length };
|
|
5316
|
+
}
|
|
5317
|
+
const chainResult = chainRawJsonLines(rawLines, sessionId);
|
|
5318
|
+
const body = `${chainResult.lines.join("\n")}
|
|
5319
|
+
`;
|
|
5320
|
+
try {
|
|
5321
|
+
await atomicReplace(eventsPath, body);
|
|
5322
|
+
} catch (error) {
|
|
5323
|
+
throw new Error("Failed to write events.jsonl", { cause: error });
|
|
5324
|
+
}
|
|
5325
|
+
try {
|
|
5326
|
+
await overwriteYamlFile(
|
|
5327
|
+
join16(sessionDir, "session.yaml"),
|
|
5328
|
+
withIntegrity(record, { headHash: chainResult.headHash, count: chainResult.count })
|
|
5329
|
+
);
|
|
5330
|
+
} catch (error) {
|
|
5331
|
+
await atomicReplace(eventsPath, priorRaw).catch(() => void 0);
|
|
5332
|
+
throw error;
|
|
5333
|
+
}
|
|
5334
|
+
return { status: "rechained", eventCount: chainResult.count };
|
|
5335
|
+
} finally {
|
|
5336
|
+
await lock.release();
|
|
5337
|
+
}
|
|
5338
|
+
}
|
|
4874
5339
|
|
|
4875
5340
|
// src/index.ts
|
|
4876
5341
|
var BASOU_CORE_VERSION = "0.1.0";
|
|
@@ -4900,6 +5365,7 @@ export {
|
|
|
4900
5365
|
SessionIdSchema,
|
|
4901
5366
|
SessionImportPayloadSchema,
|
|
4902
5367
|
SessionInnerImportSchema,
|
|
5368
|
+
SessionIntegritySchema,
|
|
4903
5369
|
SessionMetricsSchema,
|
|
4904
5370
|
SessionSchema,
|
|
4905
5371
|
SessionSourceKindSchema,
|
|
@@ -4912,6 +5378,8 @@ export {
|
|
|
4912
5378
|
WorkspaceIdSchema,
|
|
4913
5379
|
acquireLock,
|
|
4914
5380
|
appendBasouGitignore,
|
|
5381
|
+
appendChainedEvent,
|
|
5382
|
+
appendChainedEventLocked,
|
|
4915
5383
|
appendEvent,
|
|
4916
5384
|
appendEventToExistingSession,
|
|
4917
5385
|
archiveTask,
|
|
@@ -4919,6 +5387,8 @@ export {
|
|
|
4919
5387
|
basouPaths,
|
|
4920
5388
|
buildJsonSchemas,
|
|
4921
5389
|
buildStatusSnapshot,
|
|
5390
|
+
chainEvents,
|
|
5391
|
+
chainRawJsonLines,
|
|
4922
5392
|
classifySuspect,
|
|
4923
5393
|
claudeCodeAdapterMetadata,
|
|
4924
5394
|
claudeTranscriptToImportPayload,
|
|
@@ -4934,13 +5404,17 @@ export {
|
|
|
4934
5404
|
enumerateArchivedTaskIds,
|
|
4935
5405
|
enumerateSessionDirs,
|
|
4936
5406
|
enumerateTaskIds,
|
|
5407
|
+
finalizeSessionYaml,
|
|
4937
5408
|
findErrorCode,
|
|
5409
|
+
genesisHash,
|
|
4938
5410
|
getDiff,
|
|
4939
5411
|
getSnapshot,
|
|
4940
5412
|
importSessionFromJson,
|
|
5413
|
+
inspectChainTail,
|
|
4941
5414
|
isImportDerivedSource,
|
|
4942
5415
|
isLazyExpired,
|
|
4943
5416
|
isValidPrefixedId,
|
|
5417
|
+
lineHash,
|
|
4944
5418
|
linkYamlFile,
|
|
4945
5419
|
loadApproval,
|
|
4946
5420
|
loadSessionEntries,
|
|
@@ -4957,6 +5431,7 @@ export {
|
|
|
4957
5431
|
readTaskFile,
|
|
4958
5432
|
readTaskFileWithArchiveFallback,
|
|
4959
5433
|
readYamlFile,
|
|
5434
|
+
rechainSessionInPlace,
|
|
4960
5435
|
reconcileAllTasks,
|
|
4961
5436
|
reconcileTask,
|
|
4962
5437
|
refreshTaskLinkedSessions,
|
|
@@ -4972,12 +5447,14 @@ export {
|
|
|
4972
5447
|
sanitizePath,
|
|
4973
5448
|
sanitizeRelatedFiles,
|
|
4974
5449
|
sanitizeWorkingDirectory,
|
|
5450
|
+
serializeEventLine,
|
|
4975
5451
|
serializeJsonSchema,
|
|
4976
5452
|
sessionWorkStatsFromEvents,
|
|
4977
5453
|
summarizeAdapterOutput,
|
|
4978
5454
|
tryRemoteUrl,
|
|
4979
5455
|
ulid,
|
|
4980
5456
|
updateTaskStatusWithEvent,
|
|
5457
|
+
verifyEventsChain,
|
|
4981
5458
|
writeEventsBulk,
|
|
4982
5459
|
writeManifest,
|
|
4983
5460
|
writeMarkdownFile,
|