@promptctl/cc-candybar 1.17.3 → 1.17.5
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.mjs +79 -79
- package/package.json +9 -5
- package/src/daemon/cache/session-usage-store.ts +195 -38
- package/src/daemon/process-fingerprint.ts +135 -0
- package/src/daemon/server.ts +42 -21
- package/src/daemon/socket-lease.ts +64 -36
- package/src/daemon/socket-ownership.ts +69 -25
- package/src/proc/launch.ts +4 -0
- package/src/segments/metrics.ts +122 -36
- package/src/segments/session.ts +4 -169
- package/src/utils/claude.ts +79 -12
- package/src/utils/transcript-fs.ts +118 -1
package/src/segments/session.ts
CHANGED
|
@@ -1,30 +1,7 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
findAgentTranscripts,
|
|
6
|
-
parseJsonlFile,
|
|
7
|
-
type ParsedEntry,
|
|
8
|
-
} from "../utils/claude";
|
|
9
|
-
import { dirname } from "node:path";
|
|
10
|
-
|
|
11
|
-
export interface SessionUsageEntry {
|
|
12
|
-
timestamp: string;
|
|
13
|
-
message: {
|
|
14
|
-
usage: {
|
|
15
|
-
input_tokens: number;
|
|
16
|
-
output_tokens: number;
|
|
17
|
-
cache_creation_input_tokens?: number;
|
|
18
|
-
cache_read_input_tokens?: number;
|
|
19
|
-
};
|
|
20
|
-
};
|
|
21
|
-
costUSD?: number;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface SessionUsage {
|
|
25
|
-
totalCost: number;
|
|
26
|
-
entries: SessionUsageEntry[];
|
|
27
|
-
}
|
|
1
|
+
// [LAW:decomposition] The session usage CONTRACT — the shapes the daemon's
|
|
2
|
+
// SessionUsageStore produces and the render payload reads. The transcript fold
|
|
3
|
+
// that produces them is incremental and lives in the store (its single owner);
|
|
4
|
+
// this module is the type seam both sides agree on.
|
|
28
5
|
|
|
29
6
|
export interface TokenBreakdown {
|
|
30
7
|
input: number;
|
|
@@ -44,145 +21,3 @@ export interface SessionInfo {
|
|
|
44
21
|
export interface UsageInfo {
|
|
45
22
|
session: SessionInfo;
|
|
46
23
|
}
|
|
47
|
-
|
|
48
|
-
function convertToSessionEntry(entry: ParsedEntry): SessionUsageEntry {
|
|
49
|
-
return {
|
|
50
|
-
timestamp: entry.timestamp.toISOString(),
|
|
51
|
-
message: {
|
|
52
|
-
usage: {
|
|
53
|
-
input_tokens: entry.message?.usage?.input_tokens || 0,
|
|
54
|
-
output_tokens: entry.message?.usage?.output_tokens || 0,
|
|
55
|
-
cache_creation_input_tokens:
|
|
56
|
-
entry.message?.usage?.cache_creation_input_tokens,
|
|
57
|
-
cache_read_input_tokens: entry.message?.usage?.cache_read_input_tokens,
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
costUSD: entry.costUSD,
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export class SessionProvider {
|
|
65
|
-
// [LAW:one-source-of-truth] The transcript path is always in hand — the
|
|
66
|
-
// daemon's SessionUsageStore holds it (hookData.transcript_path or its seed
|
|
67
|
-
// scan) and passes it straight here. There is no lookup-by-id path: recovering
|
|
68
|
-
// a path we already hold by scanning every project dir is the data-duplication
|
|
69
|
-
// this method exists to avoid.
|
|
70
|
-
// [LAW:no-silent-failure] A transcript that exists but can't be read or
|
|
71
|
-
// parsed is `failed`, not an empty session — the old catch-to-null dressed
|
|
72
|
-
// a read failure as "no usage", and the lie survived all the way to the
|
|
73
|
-
// rendered bar. An empty transcript is a real (zero-entry) usage, not
|
|
74
|
-
// absence.
|
|
75
|
-
async getSessionUsageFromPath(
|
|
76
|
-
sessionId: string,
|
|
77
|
-
transcriptPath: string,
|
|
78
|
-
): Promise<Outcome<SessionUsage>> {
|
|
79
|
-
try {
|
|
80
|
-
debug(`Found transcript at: ${transcriptPath}`);
|
|
81
|
-
|
|
82
|
-
const mainEntries = await parseJsonlFile(transcriptPath);
|
|
83
|
-
const projectPath = dirname(transcriptPath);
|
|
84
|
-
const agentTranscripts = await findAgentTranscripts(
|
|
85
|
-
sessionId,
|
|
86
|
-
projectPath,
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
debug(`Found ${agentTranscripts.length} agent transcripts for session`);
|
|
90
|
-
|
|
91
|
-
// [LAW:one-source-of-truth] parseJsonlFile returns its cached entries
|
|
92
|
-
// array BY REFERENCE; the parse cache is the canonical store of a file's
|
|
93
|
-
// parsed entries and is shared across providers. Mutating that array
|
|
94
|
-
// (the old `push`) corrupted the cache — agent entries leaked into the
|
|
95
|
-
// main transcript's cached value and re-appended on every warm hit.
|
|
96
|
-
// Build a fresh combined list instead so no shared array is touched.
|
|
97
|
-
const agentEntries = (
|
|
98
|
-
await Promise.all(agentTranscripts.map((p) => parseJsonlFile(p)))
|
|
99
|
-
).flat();
|
|
100
|
-
const parsedEntries = [...mainEntries, ...agentEntries];
|
|
101
|
-
|
|
102
|
-
if (parsedEntries.length === 0) {
|
|
103
|
-
return ok({ totalCost: 0, entries: [] });
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const entries: SessionUsageEntry[] = [];
|
|
107
|
-
let totalCost = 0;
|
|
108
|
-
|
|
109
|
-
for (const entry of parsedEntries) {
|
|
110
|
-
if (entry.message?.usage) {
|
|
111
|
-
const sessionEntry = convertToSessionEntry(entry);
|
|
112
|
-
|
|
113
|
-
if (sessionEntry.costUSD !== undefined) {
|
|
114
|
-
totalCost += sessionEntry.costUSD;
|
|
115
|
-
} else {
|
|
116
|
-
const cost = await PricingService.calculateCostForEntry(entry.raw);
|
|
117
|
-
sessionEntry.costUSD = cost;
|
|
118
|
-
totalCost += cost;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
entries.push(sessionEntry);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
debug(
|
|
126
|
-
`Parsed ${entries.length} usage entries, total cost: $${totalCost.toFixed(4)}`,
|
|
127
|
-
);
|
|
128
|
-
return ok({ totalCost, entries });
|
|
129
|
-
} catch (error) {
|
|
130
|
-
return failed(
|
|
131
|
-
`session transcript (${sessionId}): ${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
-
);
|
|
133
|
-
}
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
calculateTokenBreakdown(entries: SessionUsageEntry[]): TokenBreakdown {
|
|
137
|
-
return entries.reduce(
|
|
138
|
-
(breakdown, entry) => ({
|
|
139
|
-
input: breakdown.input + (entry.message.usage.input_tokens || 0),
|
|
140
|
-
output: breakdown.output + (entry.message.usage.output_tokens || 0),
|
|
141
|
-
cacheCreation:
|
|
142
|
-
breakdown.cacheCreation +
|
|
143
|
-
(entry.message.usage.cache_creation_input_tokens || 0),
|
|
144
|
-
cacheRead:
|
|
145
|
-
breakdown.cacheRead +
|
|
146
|
-
(entry.message.usage.cache_read_input_tokens || 0),
|
|
147
|
-
}),
|
|
148
|
-
{ input: 0, output: 0, cacheCreation: 0, cacheRead: 0 },
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// [LAW:types-are-the-program] Pure projection SessionUsage → SessionInfo, no
|
|
153
|
-
// I/O. The store computes it once per ingest from already-parsed usage; the
|
|
154
|
-
// empty-usage arm yields the all-null SessionInfo (which the payload drops).
|
|
155
|
-
toSessionInfo(sessionUsage: SessionUsage): SessionInfo {
|
|
156
|
-
if (sessionUsage.entries.length === 0) {
|
|
157
|
-
return {
|
|
158
|
-
cost: null,
|
|
159
|
-
calculatedCost: null,
|
|
160
|
-
officialCost: null,
|
|
161
|
-
tokens: null,
|
|
162
|
-
tokenBreakdown: null,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const tokenBreakdown = this.calculateTokenBreakdown(sessionUsage.entries);
|
|
167
|
-
const totalTokens =
|
|
168
|
-
tokenBreakdown.input +
|
|
169
|
-
tokenBreakdown.output +
|
|
170
|
-
tokenBreakdown.cacheCreation +
|
|
171
|
-
tokenBreakdown.cacheRead;
|
|
172
|
-
|
|
173
|
-
// [LAW:single-enforcer] Transcript-derived projection only. `cost` is the
|
|
174
|
-
// priced-transcript total; the authoritative native cost (officialCost =
|
|
175
|
-
// hook total_cost_usd) is overlaid by the store at READ time — it is
|
|
176
|
-
// per-render and must not be frozen into this mtime-keyed record, so it is
|
|
177
|
-
// null here by construction.
|
|
178
|
-
const calculatedCost = sessionUsage.totalCost;
|
|
179
|
-
|
|
180
|
-
return {
|
|
181
|
-
cost: calculatedCost,
|
|
182
|
-
calculatedCost,
|
|
183
|
-
officialCost: null,
|
|
184
|
-
tokens: totalTokens,
|
|
185
|
-
tokenBreakdown,
|
|
186
|
-
};
|
|
187
|
-
}
|
|
188
|
-
}
|
package/src/utils/claude.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import { existsSync, createReadStream } from "node:fs";
|
|
2
2
|
// [LAW:single-enforcer] readdir/readFile/stat come from the gated transcript-fs
|
|
3
3
|
// owner, not node:fs/promises — the in-flight-I/O bound lives at one seam.
|
|
4
|
-
import { readdir, readFile, stat } from "./transcript-fs";
|
|
4
|
+
import { readdir, readFile, readAppended, stat } from "./transcript-fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { createInterface } from "node:readline";
|
|
8
8
|
import { debug } from "./logger";
|
|
9
|
+
import { ok, type Outcome } from "./outcome";
|
|
9
10
|
|
|
10
11
|
export interface ClaudeHookData {
|
|
11
12
|
// cc-candybar internal — not part of Anthropic's schema
|
|
@@ -147,6 +148,12 @@ export async function findProjectPaths(
|
|
|
147
148
|
export async function findAgentTranscripts(
|
|
148
149
|
sessionId: string,
|
|
149
150
|
projectPath: string,
|
|
151
|
+
// [LAW:one-source-of-truth] Agent-file session identity is IMMUTABLE — a file
|
|
152
|
+
// that belonged to this session never changes owner. Paths in `verified` skip
|
|
153
|
+
// the first-line readFile, so an incremental refold (which passes its prior
|
|
154
|
+
// file set) pays the O(file) verification only for NEWLY-appeared sidechains,
|
|
155
|
+
// not every sidechain every render. The readdir still runs (cheap).
|
|
156
|
+
verified?: ReadonlySet<string>,
|
|
150
157
|
): Promise<string[]> {
|
|
151
158
|
const agentFiles: string[] = [];
|
|
152
159
|
|
|
@@ -160,6 +167,10 @@ export async function findAgentTranscripts(
|
|
|
160
167
|
|
|
161
168
|
for (const fileName of agentFileNames) {
|
|
162
169
|
const filePath = join(subagentsDir, fileName);
|
|
170
|
+
if (verified?.has(filePath)) {
|
|
171
|
+
agentFiles.push(filePath);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
163
174
|
try {
|
|
164
175
|
const content = await readFile(filePath, "utf-8");
|
|
165
176
|
const firstLine = content.split("\n")[0];
|
|
@@ -436,17 +447,13 @@ function makeEntry(parsed: Record<string, unknown>): ParsedEntry | null {
|
|
|
436
447
|
};
|
|
437
448
|
}
|
|
438
449
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
const lines = content
|
|
444
|
-
.trim()
|
|
445
|
-
.split("\n")
|
|
446
|
-
.filter((line) => line.trim());
|
|
450
|
+
// [LAW:one-source-of-truth] The one line→entry loop. Both the whole-file parse
|
|
451
|
+
// and the incremental append reader frame their text into lines through here, so
|
|
452
|
+
// a malformed-line policy or a projected field can never diverge between the two.
|
|
453
|
+
function parseJsonlLines(text: string): ParsedEntry[] {
|
|
447
454
|
const entries: ParsedEntry[] = [];
|
|
448
|
-
|
|
449
|
-
|
|
455
|
+
for (const line of text.split("\n")) {
|
|
456
|
+
if (!line.trim()) continue;
|
|
450
457
|
try {
|
|
451
458
|
const entry = makeEntry(JSON.parse(line));
|
|
452
459
|
if (entry) entries.push(entry);
|
|
@@ -455,10 +462,70 @@ async function parseJsonlFileInMemory(
|
|
|
455
462
|
continue;
|
|
456
463
|
}
|
|
457
464
|
}
|
|
458
|
-
|
|
459
465
|
return entries;
|
|
460
466
|
}
|
|
461
467
|
|
|
468
|
+
async function parseJsonlFileInMemory(
|
|
469
|
+
filePath: string,
|
|
470
|
+
): Promise<ParsedEntry[]> {
|
|
471
|
+
const content = await readFile(filePath, "utf-8");
|
|
472
|
+
return parseJsonlLines(content);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// A byte cursor into an append-only transcript: the offset consumed through the
|
|
476
|
+
// last complete line, the mtime observed at that read, and the inode. The store
|
|
477
|
+
// keys its per-file fold by this — `offset` bounds the next read to only what's
|
|
478
|
+
// new; `mtimeMs` lets an unchanged file skip the read entirely; `ino` detects a
|
|
479
|
+
// rewrite (a rename-based /compact swaps the inode) so the fold resets instead
|
|
480
|
+
// of splicing new bytes onto a stale prefix.
|
|
481
|
+
export interface TranscriptCursor {
|
|
482
|
+
readonly offset: number;
|
|
483
|
+
readonly mtimeMs: number;
|
|
484
|
+
readonly ino: number;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// [LAW:dataflow-not-control-flow][LAW:one-source-of-truth] Read only the entries
|
|
488
|
+
// appended to an append-only transcript since `prior`. The returned `entries`
|
|
489
|
+
// are TRANSIENT — the caller folds them into its own compact aggregate and drops
|
|
490
|
+
// them, so this reader (unlike the retained parseCache) pins no O(file) memory:
|
|
491
|
+
// its cost is O(bytes appended since last render), which is the whole point.
|
|
492
|
+
//
|
|
493
|
+
// A partial trailing line (a render observing the file mid-write, before its
|
|
494
|
+
// newline) is NOT consumed: the cursor advances only through the last complete
|
|
495
|
+
// line, so the partial line is re-read intact once its newline lands. Cutting at
|
|
496
|
+
// a newline also guarantees clean UTF-8 boundaries (0x0A is never a continuation
|
|
497
|
+
// byte), so no multibyte codepoint is split across reads.
|
|
498
|
+
//
|
|
499
|
+
// `reset` (from readAppended) means the file shrank/was rewritten (a /compact):
|
|
500
|
+
// `entries` then cover the whole new file from offset 0 and the caller discards
|
|
501
|
+
// its prior fold for this file. Absent/failed pass straight through.
|
|
502
|
+
export async function readAppendedEntries(
|
|
503
|
+
filePath: string,
|
|
504
|
+
prior: TranscriptCursor | undefined,
|
|
505
|
+
): Promise<
|
|
506
|
+
Outcome<{ entries: ParsedEntry[]; cursor: TranscriptCursor; reset: boolean }>
|
|
507
|
+
> {
|
|
508
|
+
const r = await readAppended(
|
|
509
|
+
filePath,
|
|
510
|
+
prior === undefined ? undefined : { offset: prior.offset, ino: prior.ino },
|
|
511
|
+
);
|
|
512
|
+
if (r.kind !== "ok") return r;
|
|
513
|
+
const { buf, start, mtimeMs, ino, reset } = r.value;
|
|
514
|
+
// Consume only through the last newline; a trailing partial line waits.
|
|
515
|
+
const lastNl = buf.lastIndexOf(0x0a);
|
|
516
|
+
if (lastNl < 0) {
|
|
517
|
+
return ok({ entries: [], cursor: { offset: start, mtimeMs, ino }, reset });
|
|
518
|
+
}
|
|
519
|
+
const entries = parseJsonlLines(
|
|
520
|
+
buf.subarray(0, lastNl + 1).toString("utf-8"),
|
|
521
|
+
);
|
|
522
|
+
return ok({
|
|
523
|
+
entries,
|
|
524
|
+
cursor: { offset: start + lastNl + 1, mtimeMs, ino },
|
|
525
|
+
reset,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
462
529
|
async function parseJsonlFileStreaming(
|
|
463
530
|
filePath: string,
|
|
464
531
|
): Promise<ParsedEntry[]> {
|
|
@@ -5,8 +5,25 @@ import {
|
|
|
5
5
|
stat as fsStat,
|
|
6
6
|
} from "node:fs/promises";
|
|
7
7
|
import type { FileHandle } from "node:fs/promises";
|
|
8
|
+
import { statSync } from "node:fs";
|
|
8
9
|
|
|
9
10
|
import { ABSENT, failed, ok, type Outcome } from "./outcome";
|
|
11
|
+
import { debug } from "./logger";
|
|
12
|
+
|
|
13
|
+
// [LAW:single-enforcer] The one per-render freshness probe: a SYNC single-file
|
|
14
|
+
// stat's mtimeMs (0 when the file is absent or unreadable). Sync by design — the
|
|
15
|
+
// mtime gate must resolve before deciding whether to read, and a sync stat
|
|
16
|
+
// consumes no gate slot (the async `readAppended`/`readFile` bulk reads are what
|
|
17
|
+
// the limiter bounds). Both the usage store and the metrics provider key their
|
|
18
|
+
// incremental fold off this one helper so the ENOENT→0 policy lives in one place.
|
|
19
|
+
export function statMtimeMs(filePath: string | undefined): number {
|
|
20
|
+
if (!filePath) return 0;
|
|
21
|
+
try {
|
|
22
|
+
return statSync(filePath).mtimeMs;
|
|
23
|
+
} catch {
|
|
24
|
+
return 0;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
10
27
|
|
|
11
28
|
// [LAW:single-enforcer] One owner of the transcript-scanning in-flight-I/O
|
|
12
29
|
// budget. Every readdir/stat/readFile over the ~/.claude/projects tree passes
|
|
@@ -120,6 +137,97 @@ export const stat = gated(fsStat);
|
|
|
120
137
|
// (permissions, I/O) is a real read failure — `failed`, carrying its reason
|
|
121
138
|
// to whichever boundary owns the log effect. Folding both into one null made
|
|
122
139
|
// a broken transcript indistinguishable from a missing one.
|
|
140
|
+
// [LAW:single-enforcer] The forward complement of readTail, through the SAME
|
|
141
|
+
// gate: read the bytes of an append-only file from `priorOffset` to EOF (the
|
|
142
|
+
// whole file when `priorOffset` is undefined). One open→stat→read→close per
|
|
143
|
+
// gate slot, exactly like readTail. A transcript scanner that maintains a byte
|
|
144
|
+
// cursor to re-read only what was appended MUST use this, not raw node:fs, or it
|
|
145
|
+
// reintroduces the unbounded-fs state the gate forbids.
|
|
146
|
+
//
|
|
147
|
+
// [LAW:one-source-of-truth] `reset` is the single signal that the file shrank
|
|
148
|
+
// below the caller's cursor — a truncation or /compact rewrite — so the caller
|
|
149
|
+
// discards its prior fold for this file and re-folds from the returned bytes
|
|
150
|
+
// (which then start at offset 0). Append-only monotonicity makes every other
|
|
151
|
+
// case a pure suffix read: `start..size` is exactly what's new.
|
|
152
|
+
//
|
|
153
|
+
// [LAW:no-silent-failure] ENOENT (fresh session, no transcript yet) is `absent`;
|
|
154
|
+
// any other error is `failed`, carrying its reason to the boundary that logs.
|
|
155
|
+
export async function readAppended(
|
|
156
|
+
path: string,
|
|
157
|
+
prior: { offset: number; ino: number } | undefined,
|
|
158
|
+
): Promise<
|
|
159
|
+
Outcome<{
|
|
160
|
+
buf: Buffer;
|
|
161
|
+
start: number;
|
|
162
|
+
size: number;
|
|
163
|
+
mtimeMs: number;
|
|
164
|
+
ino: number;
|
|
165
|
+
reset: boolean;
|
|
166
|
+
}>
|
|
167
|
+
> {
|
|
168
|
+
return gate.run(async () => {
|
|
169
|
+
let fh: FileHandle | null = null;
|
|
170
|
+
try {
|
|
171
|
+
fh = await fsOpen(path, "r");
|
|
172
|
+
const { size, mtimeMs, ino } = await fh.stat();
|
|
173
|
+
// [LAW:one-source-of-truth] The prior fold is a valid PREFIX of this file
|
|
174
|
+
// only while the file stays append-only. Two independent signals break
|
|
175
|
+
// that: the inode changed (a rename-based /compact or log rotation swapped
|
|
176
|
+
// the file under the path — the realistic rewrite mechanism), or the file
|
|
177
|
+
// shrank below our cursor (an in-place truncate). Either ⇒ re-read from 0;
|
|
178
|
+
// the suffix-only read would otherwise splice new bytes onto a fold of a
|
|
179
|
+
// file that no longer exists. (A cursor-only size check missed a rewrite
|
|
180
|
+
// that grew back to ≥ the cursor.)
|
|
181
|
+
const reset =
|
|
182
|
+
prior !== undefined && (ino !== prior.ino || size < prior.offset);
|
|
183
|
+
const start = reset || prior === undefined ? 0 : prior.offset;
|
|
184
|
+
if (start >= size) {
|
|
185
|
+
return ok({ buf: Buffer.alloc(0), start, size, mtimeMs, ino, reset });
|
|
186
|
+
}
|
|
187
|
+
const buf = Buffer.alloc(size - start);
|
|
188
|
+
// [LAW:no-silent-fallbacks] A single read may return short — parsing a
|
|
189
|
+
// zero-padded tail would fabricate entries. Loop until the window fills or
|
|
190
|
+
// EOF; on a short final read (the file shrank under us) return only the
|
|
191
|
+
// bytes actually read, never the zero padding.
|
|
192
|
+
let off = 0;
|
|
193
|
+
while (off < buf.length) {
|
|
194
|
+
const { bytesRead } = await fh.read(
|
|
195
|
+
buf,
|
|
196
|
+
off,
|
|
197
|
+
buf.length - off,
|
|
198
|
+
start + off,
|
|
199
|
+
);
|
|
200
|
+
if (bytesRead === 0) break;
|
|
201
|
+
off += bytesRead;
|
|
202
|
+
}
|
|
203
|
+
return ok({
|
|
204
|
+
buf: off === buf.length ? buf : buf.subarray(0, off),
|
|
205
|
+
start,
|
|
206
|
+
size,
|
|
207
|
+
mtimeMs,
|
|
208
|
+
ino,
|
|
209
|
+
reset,
|
|
210
|
+
});
|
|
211
|
+
} catch (e) {
|
|
212
|
+
if ((e as NodeJS.ErrnoException).code === "ENOENT") return ABSENT;
|
|
213
|
+
return failed(
|
|
214
|
+
`readAppended ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
215
|
+
);
|
|
216
|
+
} finally {
|
|
217
|
+
// [LAW:no-silent-failure] A close() rejection must NOT override the typed
|
|
218
|
+
// Outcome the try returned — callers await this and read `.kind`, so a raw
|
|
219
|
+
// rejection would escape every failed-guard. A failed close after a good
|
|
220
|
+
// read doesn't invalidate the read, but it can signal a real fs problem
|
|
221
|
+
// (ENOSPC on fsync), so log it at debug rather than swallowing it blind.
|
|
222
|
+
await fh?.close().catch((e: unknown) => {
|
|
223
|
+
debug(
|
|
224
|
+
`transcript-fs: close failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
225
|
+
);
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
|
|
123
231
|
export async function readTail(
|
|
124
232
|
path: string,
|
|
125
233
|
maxBytes: number,
|
|
@@ -156,7 +264,16 @@ export async function readTail(
|
|
|
156
264
|
`readTail ${path}: ${e instanceof Error ? e.message : String(e)}`,
|
|
157
265
|
);
|
|
158
266
|
} finally {
|
|
159
|
-
|
|
267
|
+
// [LAW:no-silent-failure] A close() rejection must NOT override the typed
|
|
268
|
+
// Outcome the try returned — callers await this and read `.kind`, so a raw
|
|
269
|
+
// rejection would escape every failed-guard. A failed close after a good
|
|
270
|
+
// read doesn't invalidate the read, but it can signal a real fs problem
|
|
271
|
+
// (ENOSPC on fsync), so log it at debug rather than swallowing it blind.
|
|
272
|
+
await fh?.close().catch((e: unknown) => {
|
|
273
|
+
debug(
|
|
274
|
+
`transcript-fs: close failed: ${e instanceof Error ? e.message : String(e)}`,
|
|
275
|
+
);
|
|
276
|
+
});
|
|
160
277
|
}
|
|
161
278
|
});
|
|
162
279
|
}
|