@awebai/oats 0.34.3 → 0.35.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/bin/oats.mjs +8 -2
- package/docs/desktop-cli-api.md +32 -16
- package/docs/integrations.md +1 -1
- package/docs/knowledge.md +1 -1
- package/docs/official-catalog.md +2 -2
- package/docs/packages.md +8 -8
- package/docs/release-notes/v0.34.4.md +51 -0
- package/docs/release-notes/v0.35.0.md +53 -0
- package/docs/souls-and-instances.md +27 -2
- package/docs/workspaces.md +1 -1
- package/lib/core.mjs +147 -51
- package/lib/instance-resolution.mjs +23 -9
- package/lib/remote.mjs +3 -1
- package/package-catalog.json +1 -1
- package/package.json +1 -1
- package/packages/record/README.md +55 -0
- package/packages/record/bin/capture.mjs +204 -27
- package/packages/record/lib/capture-cc.mjs +10 -4
- package/packages/record/lib/sessions-for-home.mjs +3 -1
- package/skills/oats-getting-started/SKILL.md +1 -1
|
@@ -12,15 +12,17 @@
|
|
|
12
12
|
// accounts) whose sources are never captured — see lib/ignore.mjs.
|
|
13
13
|
// Reconciliation is the capture; hooks and watch only decide when to run it.
|
|
14
14
|
|
|
15
|
-
import { watch } from "node:fs";
|
|
15
|
+
import { closeSync, constants, fstatSync, openSync, readFileSync, watch } from "node:fs";
|
|
16
16
|
import { homedir, hostname } from "node:os";
|
|
17
|
-
import { join } from "node:path";
|
|
17
|
+
import { basename, join, resolve } from "node:path";
|
|
18
18
|
import process from "node:process";
|
|
19
19
|
|
|
20
20
|
import { RecordStore } from "../lib/store.mjs";
|
|
21
21
|
import { captureAllSessions, captureSessions } from "../lib/capture-cc.mjs";
|
|
22
22
|
import { sessionsForHome } from "../lib/sessions-for-home.mjs";
|
|
23
|
-
import { SESSION_FORMATS } from "../lib/formats.mjs";
|
|
23
|
+
import { jsonlLines, SESSION_FORMATS } from "../lib/formats.mjs";
|
|
24
|
+
import { cwdOfLine } from "../lib/sessions-for-home.mjs";
|
|
25
|
+
import { digest, readRange } from "../lib/session-snapshot.mjs";
|
|
24
26
|
import { captureAwLogs, defaultCommLogDir } from "../lib/capture-aw.mjs";
|
|
25
27
|
import { RecordIndex } from "../lib/index-db.mjs";
|
|
26
28
|
import { IgnoreError, ignoreFilePath, loadIgnore } from "../lib/ignore.mjs";
|
|
@@ -43,7 +45,7 @@ function loadIgnoreOrExit(recordRoot) {
|
|
|
43
45
|
// hostname-derived owner, which forked the whole record into a second owner
|
|
44
46
|
// namespace: 571 duplicate journals from one typo. Parsing must refuse what
|
|
45
47
|
// it does not understand before anything can be written.
|
|
46
|
-
const VALUE_FLAGS = new Set(["root", "owner", "home"]);
|
|
48
|
+
const VALUE_FLAGS = new Set(["root", "owner", "home", "file", "format"]);
|
|
47
49
|
const BOOL_FLAGS = new Set([
|
|
48
50
|
"watch",
|
|
49
51
|
"status",
|
|
@@ -54,6 +56,7 @@ const BOOL_FLAGS = new Set([
|
|
|
54
56
|
"aw-only",
|
|
55
57
|
"no-index",
|
|
56
58
|
"current-roots",
|
|
59
|
+
"json",
|
|
57
60
|
]);
|
|
58
61
|
|
|
59
62
|
const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
@@ -81,6 +84,29 @@ const USAGE = `capture — land sessions and aw client logs in the turn record.
|
|
|
81
84
|
explicit observer-time inventory for standalone
|
|
82
85
|
or legacy sources lacking launch history. Not a
|
|
83
86
|
certificate of all historical source locations.
|
|
87
|
+
capture --file <path> --format cc|pi|codex --home <dir> [--json]
|
|
88
|
+
capture ONE session file (an archived session,
|
|
89
|
+
say) as the --home capture of <dir> would: under
|
|
90
|
+
the capture lock, as a final pass, ignore rules
|
|
91
|
+
applied. <dir> must be an OATS instance home and
|
|
92
|
+
the owner explicit (--owner or TURN_RECORD_OWNER;
|
|
93
|
+
never the hostname). The format is never sniffed.
|
|
94
|
+
The file must be a regular file (no symlink, FIFO
|
|
95
|
+
or directory); it is read once. The session id
|
|
96
|
+
comes from the file NAME, as for live capture: a
|
|
97
|
+
renamed file lands in another stream. Again with
|
|
98
|
+
the same file and owner appends nothing.
|
|
99
|
+
--json prints {thread, stream, firstTurnId,
|
|
100
|
+
lastTurnId, turns, appended, ignored, sha256 (of
|
|
101
|
+
the bytes captured), status, complete, ...};
|
|
102
|
+
without it, one line. Lock skips, holds and
|
|
103
|
+
incomplete tails exit 0 with complete:false.
|
|
104
|
+
Errors print {status:"failed", complete:false,
|
|
105
|
+
code, error}: E_USAGE (exit 2); E_FILE_UNREADABLE,
|
|
106
|
+
E_NOT_REGULAR_FILE, E_IGNORED (an ignore rule
|
|
107
|
+
excludes it; nothing read), E_NO_TURNS (no
|
|
108
|
+
records), E_FORMAT (no session header of that
|
|
109
|
+
format) and E_CAPTURE_FAILED (exit 1).
|
|
84
110
|
capture --install-hint print the Claude Code hook snippet
|
|
85
111
|
capture --help this text
|
|
86
112
|
capture --quiet suppress per-pass progress
|
|
@@ -106,6 +132,7 @@ function parseArgs(argv) {
|
|
|
106
132
|
if (VALUE_FLAGS.has(name)) {
|
|
107
133
|
const value = argv[++i];
|
|
108
134
|
if (value === undefined) throw new UsageError(`${a} needs a value`);
|
|
135
|
+
if (name === "file" && args.file !== undefined) throw new UsageError("one --file per call");
|
|
109
136
|
args[name] = value;
|
|
110
137
|
} else if (BOOL_FLAGS.has(name)) {
|
|
111
138
|
args[name] = true;
|
|
@@ -121,6 +148,11 @@ try {
|
|
|
121
148
|
args = parseArgs(process.argv.slice(2));
|
|
122
149
|
} catch (err) {
|
|
123
150
|
if (!(err instanceof UsageError)) throw err;
|
|
151
|
+
// --file --json answers JSON even for a usage error: it is for programs.
|
|
152
|
+
if (process.argv.includes("--json")) {
|
|
153
|
+
console.log(JSON.stringify({ status: "failed", complete: false, code: "E_USAGE", error: err.message }, null, 2));
|
|
154
|
+
process.exit(2);
|
|
155
|
+
}
|
|
124
156
|
console.error(`capture: ${err.message}\n\n${USAGE}`);
|
|
125
157
|
process.exit(2);
|
|
126
158
|
}
|
|
@@ -279,6 +311,171 @@ function pass() {
|
|
|
279
311
|
});
|
|
280
312
|
}
|
|
281
313
|
|
|
314
|
+
/** A final capture of exact files, one batch per format, under the lock: counts into `outcome`, issues into
|
|
315
|
+
* `issues`, then the index unless --no-index. → withCaptureLock's answer (`outcome`, or a skip). */
|
|
316
|
+
function captureUnderLock(recordStore, recordOwner, batches, ignore, outcome, issues) {
|
|
317
|
+
return withCaptureLock(() => {
|
|
318
|
+
for (const [format, files] of batches) {
|
|
319
|
+
const r = captureSessions(recordStore, { owner: recordOwner, files, format, ignore, final: true });
|
|
320
|
+
outcome.appended += r.appended;
|
|
321
|
+
outcome.held += r.held;
|
|
322
|
+
outcome.incomplete += r.incomplete;
|
|
323
|
+
outcome.ignored += r.ignored;
|
|
324
|
+
issues.push(...r.issues);
|
|
325
|
+
}
|
|
326
|
+
if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
|
|
327
|
+
const index = new RecordIndex(recordStore);
|
|
328
|
+
try {
|
|
329
|
+
index.update();
|
|
330
|
+
} finally {
|
|
331
|
+
index.close();
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return outcome;
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/** A stream's turns that no tombstone hides. A tombstoned turn is hidden everywhere; a boundary naming one
|
|
339
|
+
* would be refused by recall, so boundaries come from the visible turns only. */
|
|
340
|
+
function visibleTurns(recordStore, stream, claims = recordStore.tombstoneClaims()) {
|
|
341
|
+
return recordStore.readStream(stream).filter((t) => !recordStore.claimHides(claims, t));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** The outcome vocabulary: complete only with no failure, skip, hold or incomplete tail (and, for --home, no
|
|
345
|
+
* unattributed source). */
|
|
346
|
+
function statusOf(outcome, unattributed = 0) {
|
|
347
|
+
return outcome.failed ? "failed" : outcome.skipped ? "skipped" : outcome.held ? "held" : outcome.incomplete || unattributed ? "incomplete" : "complete";
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// capture --file: ONE session file the operator attributes to an instance home (an archived session the
|
|
351
|
+
// --home sweep can no longer find), captured exactly as --home capture would capture it: same stream
|
|
352
|
+
// identity, same lock, final, ignore rules applied. The file is opened once and read from that descriptor;
|
|
353
|
+
// the receipt's sha256 is of the bytes captured. Every outcome that binds nothing is an error code.
|
|
354
|
+
const SESSION_HEADER = { cc: "a record with a cwd", pi: "a session record with a cwd", codex: "a session_meta record with a payload cwd" };
|
|
355
|
+
const HEADER_HINT = { cc: "a cc record with a cwd", pi: "a pi session header", codex: "a codex session_meta header" };
|
|
356
|
+
// pi and codex headers are specific; any record with a cwd reads as cc, so cc is named last.
|
|
357
|
+
const HEADER_ORDER = ["pi", "codex", "cc"];
|
|
358
|
+
|
|
359
|
+
/** Whether the headers `found` in a file make it a `stated` session. pi and codex need their header. cc needs a
|
|
360
|
+
* record with a cwd and NO other format's session header anywhere in the file: a cc transcript never holds
|
|
361
|
+
* one, so a single one is decisive (pi's session record carries a top-level cwd too). */
|
|
362
|
+
function isFormat(found, stated) {
|
|
363
|
+
return stated === "cc" ? found.has("cc") && !found.has("pi") && !found.has("codex") : found.has(stated);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Records (non-blank lines, an undecodable one included) and the formats whose session header a COMPLETE
|
|
367
|
+
* line carries. A pi or codex file is settled at its header; a cc file is judged whole. A line its writer
|
|
368
|
+
* never terminated is no header. */
|
|
369
|
+
function sessionHeaders(bytes, stated) {
|
|
370
|
+
let records = 0;
|
|
371
|
+
const found = new Set();
|
|
372
|
+
const terminated = bytes.length > 0 && bytes[bytes.length - 1] === 10;
|
|
373
|
+
const take = (text, complete) => {
|
|
374
|
+
if (text === null || text.trim() !== "") records++;
|
|
375
|
+
if (complete && text !== null) for (const source of HEADER_ORDER) if (cwdOfLine(source, text) !== undefined) found.add(source);
|
|
376
|
+
};
|
|
377
|
+
let pending;
|
|
378
|
+
for (const { text } of jsonlLines(bytes)) {
|
|
379
|
+
if (pending !== undefined) take(pending, true);
|
|
380
|
+
if (stated !== "cc" && found.has(stated)) return { records, found };
|
|
381
|
+
pending = text;
|
|
382
|
+
}
|
|
383
|
+
if (pending !== undefined) take(pending, terminated);
|
|
384
|
+
return { records, found };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function fileKind(stat) {
|
|
388
|
+
return stat.isDirectory() ? "a directory" : stat.isFIFO() ? "a FIFO" : stat.isSocket() ? "a socket" : stat.isCharacterDevice() || stat.isBlockDevice() ? "a device" : "not a regular file";
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** → the exit status. */
|
|
392
|
+
function fileCapture() {
|
|
393
|
+
const json = Boolean(args.json);
|
|
394
|
+
const fail = (code, error, extra = {}) => {
|
|
395
|
+
if (json) console.log(JSON.stringify({ ...extra, status: "failed", complete: false, code, error }, null, 2));
|
|
396
|
+
else console.error(`capture --file: ${code}: ${error}`);
|
|
397
|
+
return code === "E_USAGE" ? 2 : 1;
|
|
398
|
+
};
|
|
399
|
+
if (args.file === undefined) return fail("E_USAGE", args.format !== undefined ? "--format needs --file" : "--json needs --file");
|
|
400
|
+
for (const mode of ["watch", "status", "install-hint", "sessions-only", "aw-only", "current-roots"]) {
|
|
401
|
+
if (args[mode]) return fail("E_USAGE", `--file does not combine with --${mode}`);
|
|
402
|
+
}
|
|
403
|
+
if (args.home === undefined) return fail("E_USAGE", "--file needs --home <instance home>: the instance the session belongs to");
|
|
404
|
+
let instance;
|
|
405
|
+
try {
|
|
406
|
+
const meta = JSON.parse(readFileSync(join(args.home, "instance.json"), "utf8"));
|
|
407
|
+
if (typeof meta?.instance === "string" && meta.instance) instance = meta.instance;
|
|
408
|
+
} catch { /* not an instance home: said below */ }
|
|
409
|
+
if (!instance) return fail("E_USAGE", `--home ${args.home} is not an instance home (no instance.json naming an instance)`);
|
|
410
|
+
const fileOwner = args.owner ?? process.env.TURN_RECORD_OWNER;
|
|
411
|
+
if (!fileOwner) return fail("E_USAGE", "--file needs an explicit owner: --owner <name> or TURN_RECORD_OWNER (the hostname is never assumed)");
|
|
412
|
+
if (args.format === undefined) return fail("E_USAGE", "--file needs --format cc|pi|codex (the format is never guessed)");
|
|
413
|
+
if (!Object.hasOwn(SESSION_FORMATS, args.format)) return fail("E_USAGE", `--format must be cc, pi or codex, not ${JSON.stringify(args.format)}`);
|
|
414
|
+
const fmt = SESSION_FORMATS[args.format];
|
|
415
|
+
// Absolute, as every other capture caller passes: path ignore rules, the offsets key and the receipt all
|
|
416
|
+
// read the path, and a relative spelling would slip past a path rule.
|
|
417
|
+
const path = resolve(args.file);
|
|
418
|
+
const where = { home: args.home, owner: fileOwner, file: path, format: args.format };
|
|
419
|
+
|
|
420
|
+
let ignore;
|
|
421
|
+
try { ignore = loadIgnore(root); } catch (err) { return fail("E_CAPTURE_FAILED", err.message, where); }
|
|
422
|
+
const sessionId = fmt.sessionId(path);
|
|
423
|
+
// Before the open, on the path and its keys, as every capture checks: an ignored file is never read.
|
|
424
|
+
if (ignore.ignores(path, [basename(path), sessionId, ...(fmt.ignoreKeys?.(path) ?? [])])) {
|
|
425
|
+
return fail("E_IGNORED", `a capture ignore rule (${ignoreFilePath(root)}) excludes ${path}; nothing was read`, where);
|
|
426
|
+
}
|
|
427
|
+
let fd;
|
|
428
|
+
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
|
|
429
|
+
catch (err) {
|
|
430
|
+
if (err.code === "ELOOP" || err.code === "EMLINK") return fail("E_NOT_REGULAR_FILE", `${path} is a symbolic link; pass the file it names`, where);
|
|
431
|
+
return fail("E_FILE_UNREADABLE", `cannot open ${path}: ${err.message}`, where);
|
|
432
|
+
}
|
|
433
|
+
let stat, bytes;
|
|
434
|
+
try {
|
|
435
|
+
stat = fstatSync(fd);
|
|
436
|
+
if (!stat.isFile()) return fail("E_NOT_REGULAR_FILE", `${path} is ${fileKind(stat)}, not a regular file`, where);
|
|
437
|
+
bytes = readRange(fd, 0, stat.size, path);
|
|
438
|
+
} catch (err) {
|
|
439
|
+
return fail("E_FILE_UNREADABLE", `cannot read ${path}: ${err.message}`, where);
|
|
440
|
+
} finally { closeSync(fd); }
|
|
441
|
+
|
|
442
|
+
const { records, found } = sessionHeaders(bytes, args.format);
|
|
443
|
+
if (!records) return fail("E_NO_TURNS", `${path} has no records${bytes.length ? " (only blank lines)" : " (it is empty)"}`, where);
|
|
444
|
+
if (!isFormat(found, args.format)) {
|
|
445
|
+
const other = HEADER_ORDER.find((f) => f !== args.format && isFormat(found, f));
|
|
446
|
+
return fail("E_FORMAT", other
|
|
447
|
+
? `${path} is not a ${args.format} session: it has ${HEADER_HINT[other]}; pass --format ${other}`
|
|
448
|
+
: `${path} has no ${args.format} session header (${SESSION_HEADER[args.format]})`, where);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const sha256 = digest(bytes);
|
|
452
|
+
const fileStore = new RecordStore(root, { owner: fileOwner });
|
|
453
|
+
const outcome = { appended: 0, skipped: false, held: 0, incomplete: 0, failed: 0, ignored: 0 };
|
|
454
|
+
const issues = [];
|
|
455
|
+
try {
|
|
456
|
+
Object.assign(outcome, captureUnderLock(fileStore, fileOwner, [[args.format, [{ path, pinned: { bytes, stat } }]]], ignore, outcome, issues));
|
|
457
|
+
} catch (err) {
|
|
458
|
+
// A failed pass may have appended part of the file: never claim a count for it.
|
|
459
|
+
return fail("E_CAPTURE_FAILED", err.message || String(err), { ...where, sha256, appended: null });
|
|
460
|
+
}
|
|
461
|
+
if (process.exitCode) return fail("E_CAPTURE_FAILED", "capture lock release failed; see stderr for recovery", { ...where, sha256 });
|
|
462
|
+
|
|
463
|
+
const stream = `${fileOwner}~${fmt.source}.${sessionId}`;
|
|
464
|
+
const turns = visibleTurns(fileStore, stream);
|
|
465
|
+
const status = statusOf(outcome);
|
|
466
|
+
const { lock: _lock, ...counts } = outcome;
|
|
467
|
+
const receipt = {
|
|
468
|
+
...where, instance, thread: `${fmt.source}:session:${sessionId}`, stream, sessionId,
|
|
469
|
+
turns: turns.length, firstTurnId: turns[0]?.id ?? null, lastTurnId: turns.at(-1)?.id ?? null,
|
|
470
|
+
...counts, status, complete: status === "complete", sha256, ...(issues.length ? { issues } : {}),
|
|
471
|
+
};
|
|
472
|
+
if (json) console.log(JSON.stringify(receipt, null, 2));
|
|
473
|
+
else console.log(`capture --file: ${status}, ${receipt.turns} turns (${outcome.appended} new) in ${stream}, sha256 ${sha256}`);
|
|
474
|
+
return 0;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
if (args.file !== undefined || args.format !== undefined || args.json) process.exit(fileCapture());
|
|
478
|
+
|
|
282
479
|
if (args["install-hint"]) {
|
|
283
480
|
const self = new URL(import.meta.url).pathname;
|
|
284
481
|
console.log(`Add to Claude Code settings.json to capture on session stop/end:
|
|
@@ -337,31 +534,11 @@ if (args.home) {
|
|
|
337
534
|
if (!formats.has(s.source)) formats.set(s.source, []);
|
|
338
535
|
formats.get(s.source).push(s);
|
|
339
536
|
}
|
|
340
|
-
Object.assign(outcome,
|
|
341
|
-
for (const [format, files] of formats) {
|
|
342
|
-
const r = captureSessions(store, { owner, files, format, ignore, final: true });
|
|
343
|
-
outcome.appended += r.appended;
|
|
344
|
-
outcome.held += r.held;
|
|
345
|
-
outcome.incomplete += r.incomplete;
|
|
346
|
-
outcome.ignored += r.ignored;
|
|
347
|
-
issues.push(...r.issues);
|
|
348
|
-
}
|
|
349
|
-
if (!args["no-index"]) { // an earlier append-only pass may have left unindexed turns
|
|
350
|
-
const index = new RecordIndex(store);
|
|
351
|
-
try {
|
|
352
|
-
index.update();
|
|
353
|
-
} finally {
|
|
354
|
-
index.close();
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
return outcome;
|
|
358
|
-
}));
|
|
359
|
-
// A tombstoned turn is hidden everywhere; a boundary naming one would be
|
|
360
|
-
// refused by recall, so boundaries come from the visible turns only.
|
|
537
|
+
Object.assign(outcome, captureUnderLock(store, owner, formats, ignore, outcome, issues));
|
|
361
538
|
const claims = store.tombstoneClaims();
|
|
362
539
|
for (const s of found) {
|
|
363
540
|
const stream = `${owner}~${s.source}.${s.sessionId}`;
|
|
364
|
-
const turns = store
|
|
541
|
+
const turns = visibleTurns(store, stream, claims);
|
|
365
542
|
if (!turns.length) continue; // ignored by rule, nothing capturable yet, or all hidden
|
|
366
543
|
sessions.push({
|
|
367
544
|
thread: s.thread,
|
|
@@ -389,7 +566,7 @@ if (args.home) {
|
|
|
389
566
|
outcome.failed++;
|
|
390
567
|
error = "capture lock release failed; see stderr for recovery";
|
|
391
568
|
}
|
|
392
|
-
const status = outcome
|
|
569
|
+
const status = statusOf(outcome, unattributed.length);
|
|
393
570
|
console.log(JSON.stringify({ home: args.home, owner, ...outcome, status, complete: status === "complete", sessions, sourceRoots: args["current-roots"] ? "current-env" : "launch-history",
|
|
394
571
|
...(error ? { error } : {}), ...(issues.length ? { issues } : {}), ...(unattributed.length ? { unattributed } : {}),
|
|
395
572
|
}, null, 2));
|
|
@@ -189,6 +189,10 @@ function offsetFromJournal(store, streamId, sourcePath, final, sourceBytes) {
|
|
|
189
189
|
// `final` also verifies unchanged offsets against journals and checks source
|
|
190
190
|
// stability through the pass. The caller must quiesce writers for retirement;
|
|
191
191
|
// a performed pass is a snapshot, not a promise about future writes.
|
|
192
|
+
//
|
|
193
|
+
// A file given as `{ path, pinned: { bytes, stat } }` is PINNED: its caller opened it once and read these bytes from
|
|
194
|
+
// that descriptor (`capture --file`). It is captured from them as a final pass, never opened again, so the
|
|
195
|
+
// turns are exactly those bytes; `path` keys its offsets as for any other file.
|
|
192
196
|
export function captureSessions(store, { owner, roots, files, format = "cc", ignore = null, final = false }) {
|
|
193
197
|
const fmt = SESSION_FORMATS[format];
|
|
194
198
|
if (!fmt) throw new Error(`unknown session format ${format}`);
|
|
@@ -205,6 +209,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
205
209
|
|
|
206
210
|
for (const file of files ?? fmt.listFiles(roots)) {
|
|
207
211
|
const path = typeof file === "string" ? file : file.path;
|
|
212
|
+
const pinned = typeof file === "string" ? null : file.pinned ?? null;
|
|
208
213
|
const expected = typeof file === "string" ? null : file.snapshot;
|
|
209
214
|
const capturedPi = typeof file === "string" ? undefined : file.capturedPi ?? expected?.capturedPi;
|
|
210
215
|
if (capturedPi && (fmt.source !== "pi" || (expected?.capturedPi && !isDeepStrictEqual(capturedPi, expected.capturedPi)))) throw new Error("protected capture proof/format differs from discovery");
|
|
@@ -215,20 +220,21 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
215
220
|
ignored++;
|
|
216
221
|
continue; // never opened: nothing stored, nothing remembered
|
|
217
222
|
}
|
|
218
|
-
const fd = openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
|
|
223
|
+
const fd = pinned ? null : openSync(path, capturedPi ? constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK : "r");
|
|
219
224
|
try {
|
|
220
225
|
if (capturedPi) assertProtectedDescriptor(fd, path, capturedPi);
|
|
221
|
-
const stat = fstatSync(fd);
|
|
226
|
+
const stat = pinned ? pinned.stat : fstatSync(fd);
|
|
222
227
|
const snapshot = { ...identity(stat), ...(capturedPi ? { capturedPi } : {}) };
|
|
223
228
|
if (expected) assertIdentity(stat, expected, path); // BEFORE reading bytes
|
|
224
229
|
// Final home capture stages a descriptor-pinned snapshot. All attribution
|
|
225
230
|
// and stability checks precede the first append, never a post-write alarm.
|
|
226
|
-
const sourceBytes = final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
|
|
231
|
+
const sourceBytes = pinned ? pinned.bytes : final || expected || capturedPi ? readRange(fd, 0, stat.size, path, capturedPi) : undefined;
|
|
227
232
|
if (expected && digest(sourceBytes.subarray(0, expected.size)) !== expected.hash) {
|
|
228
233
|
throw new Error(`session source content changed since attribution: ${path}`);
|
|
229
234
|
}
|
|
230
235
|
if (sourceBytes) snapshot.hash = digest(sourceBytes);
|
|
231
236
|
const verifySource = () => {
|
|
237
|
+
if (pinned) return; // the bytes are the source: nothing is read from the path again
|
|
232
238
|
guardCapturedPath(path, capturedPi);
|
|
233
239
|
if (sourceBytes) verifySnapshot(fd, path, snapshot);
|
|
234
240
|
};
|
|
@@ -354,7 +360,7 @@ export function captureSessions(store, { owner, roots, files, format = "cc", ign
|
|
|
354
360
|
// wasted append-only bytes).
|
|
355
361
|
if (grew) saveOffsets(store, offsets);
|
|
356
362
|
verifySource();
|
|
357
|
-
} finally { closeSync(fd); }
|
|
363
|
+
} finally { if (fd !== null) closeSync(fd); }
|
|
358
364
|
}
|
|
359
365
|
saveOffsets(store, offsets);
|
|
360
366
|
return {
|
|
@@ -58,7 +58,9 @@ function* wholeLines(fd, bound, path, capturedPi) {
|
|
|
58
58
|
// Never attribute a source using a line its writer has not terminated.
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
-
|
|
61
|
+
/** The cwd a native line carries as its format's session header (cc: any record's `cwd`; pi: the `session`
|
|
62
|
+
* record; codex: `session_meta`), else undefined. */
|
|
63
|
+
export function cwdOfLine(source, line) {
|
|
62
64
|
if (line === null || !line.trim()) return undefined;
|
|
63
65
|
let d;
|
|
64
66
|
try {
|
|
@@ -61,7 +61,7 @@ members:
|
|
|
61
61
|
- git:github.com/acme/platform
|
|
62
62
|
packages:
|
|
63
63
|
oats.framework: v1.4.1 # bare versions resolve through the official catalog
|
|
64
|
-
oats.okf: v4.0
|
|
64
|
+
oats.okf: v4.1.0
|
|
65
65
|
defaults:
|
|
66
66
|
capabilities: { oats.core: { from: package } }
|
|
67
67
|
knowledge: { oats.okf: { from: package } }
|