taskchef 9.0.3 → 9.0.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.0.3",
3
+ "version": "9.0.4",
4
4
  "description": "Native Codex dashboard with lightweight project delegation.",
5
5
  "author": {
6
6
  "name": "Favo Yang",
@@ -88,6 +88,16 @@ not read again. Appended files are read from the last complete line; replaced
88
88
  or truncated files are read again. Changed compressed files are decoded again;
89
89
  unchanged compressed files use the same cache. A runtime without zstd support
90
90
  marks compressed histories partial. Concurrent views share this reader.
91
+ Plain and compressed siblings share one cache identity. Reads prefer the plain
92
+ file when both exist and retry briefly during format transitions. A stale
93
+ selected path can resolve its sibling, so compression or restoration alone does
94
+ not mark accounting partial. Existing compressed cache identities are normalized
95
+ when loaded.
96
+
97
+ Optional Details message counts use the same path resolution. Compressed logs
98
+ are decoded as a stream, retaining bounded head and tail samples. Their
99
+ `fileBytes` and `sampledBytes` describe decoded JSONL bytes, as for plain files.
100
+ Compression does not change database-derived board status or replies.
91
101
 
92
102
  Computed accounting results are saved under TaskChef's state directory in
93
103
  `cache/usage/`, with one hashed file per chat and duration-view variant. Files
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "taskchef",
3
- "version": "9.0.3",
3
+ "version": "9.0.4",
4
4
  "description": "A native Codex dashboard for chats, usage, pull requests, and schedules.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Favo Yang",
@@ -0,0 +1,36 @@
1
+ import { open, stat } from "node:fs/promises";
2
+ import * as zlib from "node:zlib";
3
+
4
+ export function plainRolloutPath(path) { return path.replace(/\.jsonl\.zst$/, ".jsonl"); }
5
+
6
+ // Codex can replace either representation while readers are resolving a path.
7
+ async function resolve(path, inspect) {
8
+ const plain = plainRolloutPath(path);
9
+ for (let attempt = 0; ; attempt++) {
10
+ for (const candidate of [plain, `${plain}.zst`]) {
11
+ try { return { path: candidate, ...await inspect(candidate) }; }
12
+ catch (error) { if (error.code !== "ENOENT") throw error; }
13
+ }
14
+ if (attempt === 3) { const error = new Error("Rollout is missing."); error.code = "ENOENT"; throw error; }
15
+ await new Promise(resolve => setTimeout(resolve, 50));
16
+ }
17
+ }
18
+
19
+ export function rolloutMetadata(path) { return resolve(path, async path => ({ info: await stat(path) })); }
20
+ export function openRollout(path) {
21
+ return resolve(path, async path => {
22
+ const handle = await open(path, "r");
23
+ try { return { handle, info: await handle.stat() }; }
24
+ catch (error) { await handle.close(); throw error; }
25
+ });
26
+ }
27
+
28
+ export async function* rolloutChunks({ path, handle }, options = {}) {
29
+ const compressed = path.endsWith(".jsonl.zst");
30
+ if (compressed && typeof zlib.createZstdDecompress !== "function") throw new Error("This runtime cannot decode compressed rollouts.");
31
+ const input = handle.createReadStream({ ...options, autoClose: false });
32
+ const stream = compressed ? zlib.createZstdDecompress() : input;
33
+ if (compressed) { input.on("error", error => stream.destroy(error)); input.pipe(stream); }
34
+ try { for await (const chunk of stream) yield chunk; }
35
+ finally { input.destroy(); if (compressed) stream.destroy(); }
36
+ }
@@ -1,3 +1,4 @@
1
+ import { openRollout, rolloutChunks } from "./codex-rollout.js";
1
2
  import { createHash } from "node:crypto";
2
3
  import { normalizeUri } from "micromark-util-sanitize-uri";
3
4
  import { displayUserPrompt, responseAnnotations } from "./user-prompt.js";
@@ -7,7 +8,7 @@ import { CodexPricing } from "./codex-pricing.js";
7
8
  import { replyImage, replyImages, localReplyImage } from "./reply-image.js";
8
9
  import { homedir } from "node:os";
9
10
  import { basename, isAbsolute, join } from "node:path";
10
- import { open, stat, readFile, readdir, mkdir } from "node:fs/promises";
11
+ import { stat, readFile, readdir, mkdir } from "node:fs/promises";
11
12
  import { acquireFileLock } from "./file-lock.js";
12
13
  import { writeDurableAtomic } from "./state-store.js";
13
14
  import { dirname } from "node:path";
@@ -392,18 +393,37 @@ async function readSlice(handle, start, bytes) {
392
393
  }
393
394
 
394
395
  async function readSession(file, now) {
395
- const handle = await open(file.path, "r");
396
+ const source = await openRollout(file.path);
397
+ const { handle } = source;
396
398
  try {
397
- const size = (await handle.stat()).size;
398
- let headEnd = Math.min(size, HEAD_BYTES);
399
- let head = await readSlice(handle, 0, headEnd);
400
- if (!head.includes("\n") && headEnd < size) {
401
- headEnd = Math.min(size, META_LINE_BYTES);
399
+ let size, headEnd, head, tail, contiguous;
400
+ if (source.path.endsWith(".jsonl.zst")) {
401
+ let prefix = Buffer.alloc(0), suffix = Buffer.alloc(0);
402
+ size = 0;
403
+ for await (const chunk of rolloutChunks(source, { highWaterMark: TAIL_BYTES })) {
404
+ size += chunk.length;
405
+ if (prefix.length < META_LINE_BYTES) prefix = Buffer.concat([prefix, chunk.subarray(0, META_LINE_BYTES - prefix.length)]);
406
+ suffix = Buffer.concat([suffix, chunk]);
407
+ if (suffix.length > TAIL_BYTES) suffix = Buffer.from(suffix.subarray(suffix.length - TAIL_BYTES));
408
+ }
409
+ headEnd = Math.min(size, HEAD_BYTES);
410
+ if (!prefix.subarray(0, headEnd).includes(10) && headEnd < size) headEnd = Math.min(size, META_LINE_BYTES);
411
+ head = prefix.subarray(0, headEnd).toString("utf8");
412
+ const tailStart = Math.max(0, size - TAIL_BYTES);
413
+ contiguous = tailStart <= headEnd;
414
+ tail = suffix.subarray(contiguous ? headEnd - tailStart : 0).toString("utf8");
415
+ } else {
416
+ size = source.info.size;
417
+ headEnd = Math.min(size, HEAD_BYTES);
402
418
  head = await readSlice(handle, 0, headEnd);
419
+ if (!head.includes("\n") && headEnd < size) {
420
+ headEnd = Math.min(size, META_LINE_BYTES);
421
+ head = await readSlice(handle, 0, headEnd);
422
+ }
423
+ const tailStart = Math.max(0, size - TAIL_BYTES);
424
+ contiguous = tailStart <= headEnd;
425
+ tail = contiguous ? await readSlice(handle, headEnd, size - headEnd) : await readSlice(handle, tailStart, size - tailStart);
403
426
  }
404
- const tailStart = Math.max(0, size - TAIL_BYTES);
405
- const contiguous = tailStart <= headEnd;
406
- const tail = contiguous ? await readSlice(handle, headEnd, size - headEnd) : await readSlice(handle, tailStart, size - tailStart);
407
427
  let cwd = "";
408
428
  let createdAt = "";
409
429
  let lastEvent = "";
@@ -438,9 +458,9 @@ async function readSession(file, now) {
438
458
  parseLines(tail.slice(tail.indexOf("\n") + 1), accept);
439
459
  }
440
460
  if (!hasSessionMeta) throw new Error("Session metadata is missing or invalid.");
441
- const updatedAt = new Date(file.mtimeMs).toISOString();
461
+ const updatedAt = new Date(source.info.mtimeMs).toISOString();
442
462
  const projectName = cwd ? basename(cwd) || cwd : "Unknown project";
443
- const active = !file.archive && lastEvent === "task_started" && now - file.mtimeMs < ACTIVE_WINDOW_MS;
463
+ const active = !file.archive && lastEvent === "task_started" && now - source.info.mtimeMs < ACTIVE_WINDOW_MS;
444
464
  const activity = active ? "A turn started recently; live activity is unverified."
445
465
  : lastEvent === "task_complete" ? "Latest recorded turn ended; task outcome is unknown."
446
466
  : lastEvent === "turn_aborted" ? "Latest recorded turn stopped; task outcome is unknown."
@@ -450,7 +470,7 @@ async function readSession(file, now) {
450
470
  summary: activity, status: active ? "working" : null, createdAt: createdAt || updatedAt, updatedAt,
451
471
  updatedBy: "Local Codex log", project: { name: projectName, path: cwd, githubRepos: [] },
452
472
  threadId: file.id, turnRef: null, turnId: null, lastResult: null, latestTurn: null,
453
- observed: { archive: file.archive, lastTurnEvent: lastEvent || null, lastTurnEventAt: lastEventAt || null, recentFileActivity: !file.archive && now - file.mtimeMs < ACTIVE_WINDOW_MS, userMessages, assistantMessages, sampledBytes: Math.min(size, headEnd + TAIL_BYTES), fileBytes: size },
473
+ observed: { archive: file.archive, lastTurnEvent: lastEvent || null, lastTurnEventAt: lastEventAt || null, recentFileActivity: !file.archive && now - source.info.mtimeMs < ACTIVE_WINDOW_MS, userMessages, assistantMessages, sampledBytes: Math.min(size, headEnd + TAIL_BYTES), fileBytes: size },
454
474
  };
455
475
  } finally { await handle.close(); }
456
476
  }
@@ -698,9 +718,7 @@ export class CodexSessionScanner {
698
718
  try {
699
719
  const path = this.rolloutPaths.get(id);
700
720
  if (typeof path !== "string" || !isAbsolute(path)) return detail;
701
- const info = await stat(path);
702
- if (!info.isFile()) return detail;
703
- const log = await readSession({ path, id, archive: task.observed.archive, mtimeMs: info.mtimeMs }, this.now());
721
+ const log = await readSession({ path, id, archive: task.observed.archive }, this.now());
704
722
  return { ...detail, observed: { ...detail.observed,
705
723
  userMessages: log.observed.userMessages,
706
724
  assistantMessages: log.observed.assistantMessages,
@@ -1,10 +1,9 @@
1
1
  import { migrateCache } from "./next-cache.js";
2
- import { createReadStream } from "node:fs";
3
- import { readdir, stat, readFile } from "node:fs/promises";
2
+ import { readdir, readFile } from "node:fs/promises";
4
3
  import { join, basename } from "node:path";
5
4
  import { createHash } from "node:crypto";
6
5
  import { writeDurableAtomic } from "./state-store.js";
7
- import * as zlib from "node:zlib";
6
+ import { plainRolloutPath, rolloutMetadata, openRollout, rolloutChunks } from "./codex-rollout.js";
8
7
  import { estimateCallCost } from "./codex-pricing.js";
9
8
 
10
9
  const FIELDS = ["input_tokens", "cached_input_tokens", "cache_write_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"];
@@ -77,7 +76,7 @@ async function inventory(root, paths = []) {
77
76
  catch (error) { if (error.code === "ENOENT") return paths; throw error; }
78
77
  for (const entry of entries) {
79
78
  if (entry.isDirectory()) await inventory(join(root, entry.name), paths);
80
- else if (entry.isFile() && /\.jsonl(?:\.zst)?$/.test(entry.name)) paths.push(join(root, entry.name));
79
+ else if (entry.isFile() && /\.jsonl(?:\.zst)?$/.test(entry.name)) paths.push(plainRolloutPath(join(root, entry.name)));
81
80
  }
82
81
  return paths;
83
82
  }
@@ -113,10 +112,10 @@ export class CodexSessionUsage {
113
112
  const sources = [];
114
113
  for (const session of sessions) {
115
114
  const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
116
- if (session.rollout_path) paths.add(session.rollout_path);
115
+ if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
117
116
  const versions = [];
118
117
  for (const path of paths) {
119
- try { const info = await stat(path); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
118
+ try { const { info } = await rolloutMetadata(path); versions.push([path, info.dev, info.ino, info.size, info.mtimeMs]); }
120
119
  catch { versions.push([path, "missing"]); }
121
120
  }
122
121
  sources.push([session.id, versions]);
@@ -133,12 +132,20 @@ export class CodexSessionUsage {
133
132
  }
134
133
  const sourceKeys = (items) => items.flatMap(([sessionId, versions]) => versions
135
134
  .filter(version => version.length > 2)
136
- .map(version => JSON.stringify([sessionId, basename(version[0])])));
135
+ .map(version => JSON.stringify([sessionId, basename(plainRolloutPath(version[0]))])));
137
136
  const currentSources = new Set(sourceKeys(sources));
138
137
  let knownSources = [];
139
138
  try {
140
139
  // Older cache entries already contain source metadata in their signature.
141
140
  knownSources = saved?.knownSources ?? sourceKeys(JSON.parse(saved.sourceSignature)[2]);
141
+ knownSources = [...new Set(knownSources.map(key => {
142
+ const [sessionId, name] = JSON.parse(key);
143
+ return JSON.stringify([sessionId, plainRolloutPath(name)]);
144
+ }))];
145
+ if (saved?.recordSources) saved = { ...saved, recordSources: Object.fromEntries(Object.entries(saved.recordSources).map(([key, rows]) => {
146
+ const [sessionId, name] = JSON.parse(key);
147
+ return [JSON.stringify([sessionId, plainRolloutPath(name)]), rows];
148
+ })) };
142
149
  if (!Array.isArray(knownSources) || knownSources.some(key => {
143
150
  if (typeof key !== "string") return true;
144
151
  const parts = JSON.parse(key);
@@ -153,7 +160,7 @@ export class CodexSessionUsage {
153
160
  const recordSources = {};
154
161
  for (const session of sessions) {
155
162
  const paths = new Set(this.paths.filter(path => basename(path).includes(`-${session.id}`)));
156
- if (session.rollout_path) paths.add(session.rollout_path);
163
+ if (session.rollout_path) paths.add(plainRolloutPath(session.rollout_path));
157
164
  const records = [], versions = [];
158
165
  let failed = inventoryFailed, latestFailed = false, timePartial = inventoryFailed;
159
166
  if (!paths.size) failed = latestFailed = timePartial = true;
@@ -169,7 +176,7 @@ export class CodexSessionUsage {
169
176
  const sourceKey = JSON.stringify([session.id, basename(path)]);
170
177
  if (Array.isArray(saved?.recordSources?.[sourceKey]) && !missingSources.includes(sourceKey)) missingSources.push(sourceKey);
171
178
  retryRead ||= availablePaths.has(path);
172
- latestFailed ||= session.id !== id || path === session.rollout_path;
179
+ latestFailed ||= session.id !== id || path === plainRolloutPath(session.rollout_path ?? "");
173
180
  versions.push([path, "missing"]);
174
181
  }
175
182
  }
@@ -244,38 +251,42 @@ export class CodexSessionUsage {
244
251
  return result;
245
252
  }
246
253
  async readFile(path, expectedId) {
247
- const info = await stat(path);
248
- if (!info.isFile() || info.size === 0) throw new Error("Rollout is empty or not a file.");
249
- let cached = this.files.get(path);
250
- if (cached && cached.identity === `${info.dev}:${info.ino}` && cached.size === info.size && cached.mtime === info.mtimeMs) return this.sourceView(cached, expectedId);
251
- const compressed = path.endsWith(".zst");
252
- if (!cached || compressed || cached.identity !== `${info.dev}:${info.ino}` || info.size < cached.size || info.size === cached.size) {
253
- cached = { identity: `${info.dev}:${info.ino}`, offset: 0, records: [], partial: false, turn: null, root: null, model: null, valid: false };
254
- }
255
- let pending = Buffer.alloc(0), consumed = cached.offset, skipping = false;
256
- for await (const chunk of readChunks(path, { start: cached.offset, end: info.size - 1, highWaterMark: 256 * 1024 }, compressed)) {
257
- pending = Buffer.concat([pending, chunk]);
258
- let end;
259
- while ((end = pending.indexOf(10)) !== -1) {
260
- const line = pending.subarray(0, end);
261
- consumed += end + 1;
262
- if (!skipping) this.line(cached, line, expectedId);
263
- skipping = false;
264
- pending = pending.subarray(end + 1);
265
- cached.offset = consumed;
254
+ const source = await openRollout(path);
255
+ try {
256
+ path = plainRolloutPath(path);
257
+ const { info } = source;
258
+ if (!info.isFile() || info.size === 0) throw new Error("Rollout is empty or not a file.");
259
+ let cached = this.files.get(path);
260
+ if (cached && cached.identity === `${info.dev}:${info.ino}` && cached.size === info.size && cached.mtime === info.mtimeMs) return this.sourceView(cached, expectedId);
261
+ const compressed = source.path.endsWith(".zst");
262
+ if (!cached || compressed || cached.identity !== `${info.dev}:${info.ino}` || info.size < cached.size || info.size === cached.size) {
263
+ cached = { identity: `${info.dev}:${info.ino}`, offset: 0, records: [], partial: false, turn: null, root: null, model: null, valid: false };
266
264
  }
267
- if (pending.length > 2 * 1024 * 1024) {
268
- // Image/message bodies can be huge; accounting records are small.
269
- if (/"type"\s*:\s*"(?:turn_context|session_meta|token_count)"/.test(pending.subarray(0, 256).toString())) this.issue(cached);
270
- consumed += pending.length; pending = Buffer.alloc(0); skipping = true;
265
+ let pending = Buffer.alloc(0), consumed = cached.offset, skipping = false;
266
+ for await (const chunk of rolloutChunks(source, { start: cached.offset, end: info.size - 1, highWaterMark: 256 * 1024 })) {
267
+ pending = Buffer.concat([pending, chunk]);
268
+ let end;
269
+ while ((end = pending.indexOf(10)) !== -1) {
270
+ const line = pending.subarray(0, end);
271
+ consumed += end + 1;
272
+ if (!skipping) this.line(cached, line, expectedId);
273
+ skipping = false;
274
+ pending = pending.subarray(end + 1);
275
+ cached.offset = consumed;
276
+ }
277
+ if (pending.length > 2 * 1024 * 1024) {
278
+ // Image/message bodies can be huge; accounting records are small.
279
+ if (/"type"\s*:\s*"(?:turn_context|session_meta|token_count)"/.test(pending.subarray(0, 256).toString())) this.issue(cached);
280
+ consumed += pending.length; pending = Buffer.alloc(0); skipping = true;
281
+ }
271
282
  }
272
- }
273
- cached.size = info.size; cached.mtime = info.mtimeMs;
274
- cached.tail = pending; cached.skipping = skipping;
275
- const view = this.sourceView(cached, expectedId);
276
- if (!view.valid) throw new Error("Rollout belongs to a different chat.");
277
- this.files.set(path, cached);
278
- return view;
283
+ cached.size = info.size; cached.mtime = info.mtimeMs;
284
+ cached.tail = pending; cached.skipping = skipping;
285
+ const view = this.sourceView(cached, expectedId);
286
+ if (!view.valid) throw new Error("Rollout belongs to a different chat.");
287
+ this.files.set(path, cached);
288
+ return view;
289
+ } finally { await source.handle.close(); }
279
290
  }
280
291
  sourceView(cached, expectedId) {
281
292
  // EOF may contain a complete JSON record or a partially written line.
@@ -310,15 +321,6 @@ export class CodexSessionUsage {
310
321
  }
311
322
  }
312
323
 
313
- async function* readChunks(path, options, compressed) {
314
- if (compressed && typeof zlib.createZstdDecompress !== "function") throw new Error("This runtime cannot decode compressed rollouts.");
315
- const input = createReadStream(path, options);
316
- const stream = compressed ? zlib.createZstdDecompress() : input;
317
- if (compressed) { input.on("error", error => stream.destroy(error)); input.pipe(stream); }
318
- try { for await (const chunk of stream) yield chunk; }
319
- finally { input.destroy(); if (compressed) stream.destroy(); }
320
- }
321
-
322
324
  function validResult(value) {
323
325
  return value && ["latest", "total"].every(key => {
324
326
  const part = value[key];