@bli-cockpit/cli 0.1.29 → 0.1.30
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/README.md +7 -1
- package/dist/adapters/attribution-core.js +70 -1
- package/dist/adapters/claude-attribution.js +4 -3
- package/dist/adapters/codex-attribution.js +6 -5
- package/dist/adapters/common.js +2 -5
- package/dist/backfill-lock.js +108 -0
- package/dist/commands/backfill.js +964 -0
- package/dist/commands/local-args.js +59 -1
- package/dist/commands/local.js +305 -8
- package/dist/commands/session-sync.js +22 -14
- package/dist/cursors/backfill-cursor.js +130 -0
- package/dist/upload.js +114 -25
- package/package.json +2 -2
|
@@ -26,6 +26,8 @@ export function parseLocalArgs(argv) {
|
|
|
26
26
|
return parseStartArgs(argv.slice(1));
|
|
27
27
|
case "sync":
|
|
28
28
|
return parseSyncArgs(argv.slice(1));
|
|
29
|
+
case "backfill":
|
|
30
|
+
return parseBackfillArgs(argv.slice(1));
|
|
29
31
|
case "status":
|
|
30
32
|
return parseStatusArgs(argv.slice(1));
|
|
31
33
|
case "sessions":
|
|
@@ -304,6 +306,52 @@ function parseSyncArgs(args) {
|
|
|
304
306
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
305
307
|
};
|
|
306
308
|
}
|
|
309
|
+
function parseBackfillArgs(args) {
|
|
310
|
+
const values = parseNamedArgs(args, {
|
|
311
|
+
allowedFlags: [
|
|
312
|
+
"--home",
|
|
313
|
+
"--repo",
|
|
314
|
+
"--workspace",
|
|
315
|
+
"--since-days",
|
|
316
|
+
"--all",
|
|
317
|
+
"--source",
|
|
318
|
+
"--dry-run",
|
|
319
|
+
"--max-files",
|
|
320
|
+
"--yes",
|
|
321
|
+
"--json",
|
|
322
|
+
],
|
|
323
|
+
valueFlags: [
|
|
324
|
+
"--home",
|
|
325
|
+
"--repo",
|
|
326
|
+
"--workspace",
|
|
327
|
+
"--since-days",
|
|
328
|
+
"--source",
|
|
329
|
+
"--max-files",
|
|
330
|
+
],
|
|
331
|
+
});
|
|
332
|
+
assertNoPositionals(values.positionals, "backfill");
|
|
333
|
+
const source = values.flags.get("--source");
|
|
334
|
+
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
335
|
+
throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
|
|
336
|
+
}
|
|
337
|
+
const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
|
|
338
|
+
const all = values.booleans.has("--all");
|
|
339
|
+
if (all && sinceDays !== undefined) {
|
|
340
|
+
throw new Error("--since-days and --all cannot be combined.");
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
kind: "backfill",
|
|
344
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
345
|
+
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
346
|
+
source,
|
|
347
|
+
sinceDays,
|
|
348
|
+
all,
|
|
349
|
+
dryRun: values.booleans.has("--dry-run"),
|
|
350
|
+
maxFiles: optionalPositiveInteger(values.flags.get("--max-files"), "--max-files"),
|
|
351
|
+
yes: values.booleans.has("--yes"),
|
|
352
|
+
json: values.booleans.has("--json"),
|
|
353
|
+
};
|
|
354
|
+
}
|
|
307
355
|
function parseStatusArgs(args) {
|
|
308
356
|
const values = parseNamedArgs(args, {
|
|
309
357
|
allowedFlags: [
|
|
@@ -339,6 +387,8 @@ function parseSessionsArgs(args) {
|
|
|
339
387
|
"--repo",
|
|
340
388
|
"--workspace",
|
|
341
389
|
"--source",
|
|
390
|
+
"--since-days",
|
|
391
|
+
"--all",
|
|
342
392
|
"--json",
|
|
343
393
|
"--max-depth",
|
|
344
394
|
"--max-repos",
|
|
@@ -348,6 +398,7 @@ function parseSessionsArgs(args) {
|
|
|
348
398
|
"--repo",
|
|
349
399
|
"--workspace",
|
|
350
400
|
"--source",
|
|
401
|
+
"--since-days",
|
|
351
402
|
"--max-depth",
|
|
352
403
|
"--max-repos",
|
|
353
404
|
],
|
|
@@ -355,13 +406,20 @@ function parseSessionsArgs(args) {
|
|
|
355
406
|
assertNoPositionals(values.positionals, "sessions");
|
|
356
407
|
const source = values.flags.get("--source");
|
|
357
408
|
if (source !== undefined && source !== "codex" && source !== "claude") {
|
|
358
|
-
throw new Error("--source must be 'codex' or 'claude'.");
|
|
409
|
+
throw new Error("--source must be 'codex' or 'claude'. Omit it to scan both.");
|
|
410
|
+
}
|
|
411
|
+
const sinceDays = optionalPositiveInteger(values.flags.get("--since-days"), "--since-days");
|
|
412
|
+
const all = values.booleans.has("--all");
|
|
413
|
+
if (all && sinceDays !== undefined) {
|
|
414
|
+
throw new Error("--since-days and --all cannot be combined.");
|
|
359
415
|
}
|
|
360
416
|
return {
|
|
361
417
|
kind: "sessions",
|
|
362
418
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
363
419
|
repoRoot: optionalNonEmpty(workRootFlagValue(values)),
|
|
364
420
|
source,
|
|
421
|
+
sinceDays,
|
|
422
|
+
all,
|
|
365
423
|
json: values.booleans.has("--json"),
|
|
366
424
|
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
367
425
|
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
package/dist/commands/local.js
CHANGED
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
import { execFile, spawn } from "node:child_process";
|
|
2
|
-
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { access, readdir, readFile, stat } from "node:fs/promises";
|
|
3
3
|
import os from "node:os";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { createCollectorServer } from "../server.js";
|
|
6
6
|
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
7
|
+
import { runBackfillCommand } from "./backfill.js";
|
|
8
|
+
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
7
9
|
import { parseLocalArgs, normalizeUrl } from "./local-args.js";
|
|
8
10
|
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
9
11
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
10
|
-
import {
|
|
12
|
+
import { CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT, CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES, defaultCodexSessionDirs, scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
11
13
|
import { scanAndAttributeClaudeSessions } from "../adapters/claude-attribution.js";
|
|
14
|
+
import { backfillCompletionMarkerPath, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
12
15
|
import { acquireSyncLock } from "../sync-lock.js";
|
|
13
16
|
import { discoverGitWorktrees } from "../repo-identity.js";
|
|
14
17
|
import { runAttributedWorktreeSync, } from "./session-sync.js";
|
|
@@ -23,6 +26,7 @@ export const rootCommandNames = new Set([
|
|
|
23
26
|
"logout",
|
|
24
27
|
"start",
|
|
25
28
|
"sync",
|
|
29
|
+
"backfill",
|
|
26
30
|
"status",
|
|
27
31
|
"sessions",
|
|
28
32
|
"serve",
|
|
@@ -61,6 +65,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
61
65
|
return await runStart(command, io);
|
|
62
66
|
case "sync":
|
|
63
67
|
return await runSync(command, io);
|
|
68
|
+
case "backfill":
|
|
69
|
+
return await runBackfillCommand(command, io);
|
|
64
70
|
case "status":
|
|
65
71
|
return await runStatus(command, io);
|
|
66
72
|
case "sessions":
|
|
@@ -93,8 +99,9 @@ export function localCommandHelp(command) {
|
|
|
93
99
|
" cockpit logout",
|
|
94
100
|
" cockpit start [--ticket <id>|--clear-ticket] [--topic <label>] [--intent <intent>] [--phase <phase>] [--workspace <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
95
101
|
" cockpit sync [--workspace <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
102
|
+
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
|
|
96
103
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
97
|
-
" cockpit sessions [--source codex|claude] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
104
|
+
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
98
105
|
" cockpit serve [--port <port>] [--workspace <path>]",
|
|
99
106
|
" cockpit autostart [install|uninstall|status] [--workspace <path>] [--dashboard-url <url>] [--interval-seconds <n>] [--json]",
|
|
100
107
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
@@ -198,6 +205,18 @@ function localSubcommandHelp(command) {
|
|
|
198
205
|
"--max-depth and --max-repos.",
|
|
199
206
|
],
|
|
200
207
|
],
|
|
208
|
+
[
|
|
209
|
+
"backfill",
|
|
210
|
+
[
|
|
211
|
+
"Usage: cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--yes] [--workspace <path>] [--json]",
|
|
212
|
+
"",
|
|
213
|
+
"Backfills historical Codex and Claude Code session evidence using the saved collection roots from local config when --workspace is omitted.",
|
|
214
|
+
"Omit --source to scan both sources; there is no --source all literal.",
|
|
215
|
+
"The non-all window is capped at the collector session paired_at timestamp.",
|
|
216
|
+
"--all requires a dry-run review and TTY confirmation; pass --yes for headless agent runs.",
|
|
217
|
+
"--dry-run validates the paired collector and dashboard reachability, prints the same summary tables, and writes nothing.",
|
|
218
|
+
],
|
|
219
|
+
],
|
|
201
220
|
[
|
|
202
221
|
"status",
|
|
203
222
|
[
|
|
@@ -210,12 +229,13 @@ function localSubcommandHelp(command) {
|
|
|
210
229
|
[
|
|
211
230
|
"sessions",
|
|
212
231
|
[
|
|
213
|
-
"Usage: cockpit sessions [--source codex|claude] [--workspace <path>] [--json]",
|
|
232
|
+
"Usage: cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--json]",
|
|
214
233
|
"",
|
|
215
234
|
"Read-only: re-runs Codex + Claude session attribution and prints each",
|
|
216
235
|
"session's id, source, state, reason, scores, signals, and per-sidecar",
|
|
217
236
|
"skip reasons. No upload, no cursor writes. Answers \"why is session X",
|
|
218
237
|
"missing?\" locally — counts and labels only, never paths or content.",
|
|
238
|
+
"--since-days uses the same paired_at cap as `cockpit backfill`; --all scans the full local history.",
|
|
219
239
|
"`--repo <path>` remains supported as a backward-compatible alias.",
|
|
220
240
|
],
|
|
221
241
|
],
|
|
@@ -638,6 +658,8 @@ function writeOnboardLiveStatus(io, command, roots, options) {
|
|
|
638
658
|
writeLine(io.stdout, `Initial sync: ${options.initialSyncOk ? options.sync?.status ?? "uploaded" : "blocked"}`);
|
|
639
659
|
writeLine(io.stdout, `Agent rules: ${options.agentRules ? onboardAgentRulesInstallLine(options.agentRules) : "not refreshed"}`);
|
|
640
660
|
writeLine(io.stdout, `Dashboard: ${command.dashboardUrl}/my-work`);
|
|
661
|
+
if (options.backfillHint)
|
|
662
|
+
writeLine(io.stdout, options.backfillHint);
|
|
641
663
|
writeLine(io.stdout, "Next: cockpit status");
|
|
642
664
|
}
|
|
643
665
|
function backgroundSyncLine(result) {
|
|
@@ -660,6 +682,7 @@ async function runOnboard(command, io) {
|
|
|
660
682
|
let rootsResult = null;
|
|
661
683
|
let agentRules = null;
|
|
662
684
|
let autostart = null;
|
|
685
|
+
let backfillHint = null;
|
|
663
686
|
try {
|
|
664
687
|
if (!command.json) {
|
|
665
688
|
writeLine(io.stdout, "Cockpit harvest onboarding");
|
|
@@ -667,6 +690,7 @@ async function runOnboard(command, io) {
|
|
|
667
690
|
writeLine(io.stdout, `Ticket: ${command.activeTicketId ?? "general ambient"}`);
|
|
668
691
|
}
|
|
669
692
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
693
|
+
backfillHint = await onboardBackfillHint(command.homeDir);
|
|
670
694
|
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
671
695
|
const interactive = !command.json && isInteractiveStdin(io);
|
|
672
696
|
rootsResult = await resolveOnboardingRoots({
|
|
@@ -756,6 +780,7 @@ async function runOnboard(command, io) {
|
|
|
756
780
|
root_resolution: rootsResult,
|
|
757
781
|
agent_rules: agentRules,
|
|
758
782
|
autostart,
|
|
783
|
+
backfill_hint: backfillHint,
|
|
759
784
|
mode: "multi_repo",
|
|
760
785
|
repos: multi.results,
|
|
761
786
|
codex_sessions: multi.codex_sessions,
|
|
@@ -769,6 +794,7 @@ async function runOnboard(command, io) {
|
|
|
769
794
|
agentRules,
|
|
770
795
|
autostart,
|
|
771
796
|
initialSyncOk: true,
|
|
797
|
+
backfillHint,
|
|
772
798
|
});
|
|
773
799
|
}
|
|
774
800
|
return multi.ok ? 0 : 1;
|
|
@@ -828,6 +854,7 @@ async function runOnboard(command, io) {
|
|
|
828
854
|
root_resolution: rootsResult,
|
|
829
855
|
agent_rules: agentRules,
|
|
830
856
|
autostart,
|
|
857
|
+
backfill_hint: backfillHint,
|
|
831
858
|
codex_sessions: run.summary,
|
|
832
859
|
}, null, 2));
|
|
833
860
|
return 0;
|
|
@@ -853,6 +880,7 @@ async function runOnboard(command, io) {
|
|
|
853
880
|
agentRules,
|
|
854
881
|
autostart,
|
|
855
882
|
initialSyncOk: true,
|
|
883
|
+
backfillHint,
|
|
856
884
|
});
|
|
857
885
|
return 0;
|
|
858
886
|
}
|
|
@@ -981,6 +1009,8 @@ function cursorStatusLine(sync) {
|
|
|
981
1009
|
}
|
|
982
1010
|
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
983
1011
|
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
1012
|
+
const ALL_SESSION_SCAN_WINDOW_MINUTES = 20 * 365 * 24 * 60;
|
|
1013
|
+
const SESSION_SCAN_OVERRIDE_LIMIT = 10_000;
|
|
984
1014
|
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
985
1015
|
if (Array.isArray(repoRoot)) {
|
|
986
1016
|
const discovered = [];
|
|
@@ -1223,6 +1253,20 @@ async function runStart(command, io) {
|
|
|
1223
1253
|
return 0;
|
|
1224
1254
|
}
|
|
1225
1255
|
async function runSync(command, io) {
|
|
1256
|
+
const backfillLock = await inspectBackfillLock(getCollectorRuntimePaths(command.homeDir));
|
|
1257
|
+
if (backfillLock.held) {
|
|
1258
|
+
if (command.json) {
|
|
1259
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1260
|
+
status: "live_sync_paused_during_backfill",
|
|
1261
|
+
reason: "live sync paused during backfill",
|
|
1262
|
+
held_since: backfillLock.held_since,
|
|
1263
|
+
}, null, 2));
|
|
1264
|
+
}
|
|
1265
|
+
else {
|
|
1266
|
+
writeLine(io.stdout, "live sync paused during backfill");
|
|
1267
|
+
}
|
|
1268
|
+
return 0;
|
|
1269
|
+
}
|
|
1226
1270
|
// Single-flight: a launchd timer and a manual sync must not interleave the
|
|
1227
1271
|
// cursor read-modify-write. A blocked invocation exits cleanly (B.4 §7).
|
|
1228
1272
|
const lock = await acquireSyncLock(getCollectorRuntimePaths(command.homeDir));
|
|
@@ -1301,6 +1345,7 @@ async function runSyncLocked(command, io) {
|
|
|
1301
1345
|
return 1;
|
|
1302
1346
|
}
|
|
1303
1347
|
async function runStatus(command, io) {
|
|
1348
|
+
const backfillCursor = await inspectBackfillCursor(command.homeDir);
|
|
1304
1349
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1305
1350
|
if (worktrees.length > 1) {
|
|
1306
1351
|
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
@@ -1311,10 +1356,11 @@ async function runStatus(command, io) {
|
|
|
1311
1356
|
head_sha: worktree.head_sha,
|
|
1312
1357
|
})));
|
|
1313
1358
|
if (command.json) {
|
|
1314
|
-
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
|
|
1359
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses, backfill_cursor: backfillCursor }, null, 2));
|
|
1315
1360
|
return 0;
|
|
1316
1361
|
}
|
|
1317
1362
|
writeLine(io.stdout, "Cockpit parent status");
|
|
1363
|
+
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
1318
1364
|
for (const status of statuses) {
|
|
1319
1365
|
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
1320
1366
|
}
|
|
@@ -1322,7 +1368,7 @@ async function runStatus(command, io) {
|
|
|
1322
1368
|
}
|
|
1323
1369
|
const status = await inspectLocalCollectorStatus(command);
|
|
1324
1370
|
if (command.json) {
|
|
1325
|
-
writeLine(io.stdout, JSON.stringify(status, null, 2));
|
|
1371
|
+
writeLine(io.stdout, JSON.stringify({ ...status, backfill_cursor: backfillCursor }, null, 2));
|
|
1326
1372
|
return 0;
|
|
1327
1373
|
}
|
|
1328
1374
|
writeLine(io.stdout, "Cockpit local status");
|
|
@@ -1339,6 +1385,7 @@ async function runStatus(command, io) {
|
|
|
1339
1385
|
writeLine(io.stdout, `last_upload_success: ${status.last_upload_success_at ?? "never"}`);
|
|
1340
1386
|
writeLine(io.stdout, `last_upload_failure: ${status.last_upload_failure_reason ?? "none"}`);
|
|
1341
1387
|
writeLine(io.stdout, `pending_uploads: ${status.pending_upload_count}`);
|
|
1388
|
+
writeLine(io.stdout, `backfill: ${backfillCursorLine(backfillCursor)}`);
|
|
1342
1389
|
for (const detail of status.details)
|
|
1343
1390
|
writeLine(io.stdout, `- ${detail}`);
|
|
1344
1391
|
return 0;
|
|
@@ -1351,6 +1398,251 @@ function displayWorkLabel(status) {
|
|
|
1351
1398
|
return `${status.work_label} (${status.work_id})`;
|
|
1352
1399
|
return status.work_label ?? status.work_id ?? "no active work context";
|
|
1353
1400
|
}
|
|
1401
|
+
async function fileExists(filePath) {
|
|
1402
|
+
try {
|
|
1403
|
+
await access(filePath);
|
|
1404
|
+
return true;
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
async function onboardBackfillHint(homeDir) {
|
|
1411
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
1412
|
+
if (await fileExists(backfillCompletionMarkerPath(paths)))
|
|
1413
|
+
return null;
|
|
1414
|
+
const hasArchivedSessions = await hasAnyJsonlFile([
|
|
1415
|
+
path.join(homeDir ?? os.homedir(), ".codex", "archived_sessions"),
|
|
1416
|
+
]);
|
|
1417
|
+
return hasArchivedSessions
|
|
1418
|
+
? "Backfill: archived sessions found. Run `cockpit backfill --all` to include older local history."
|
|
1419
|
+
: null;
|
|
1420
|
+
}
|
|
1421
|
+
async function inspectBackfillCursor(homeDir) {
|
|
1422
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
1423
|
+
const marker = await readBackfillCompletionMarker(paths);
|
|
1424
|
+
if (marker) {
|
|
1425
|
+
return {
|
|
1426
|
+
state: "done",
|
|
1427
|
+
remaining_count: 0,
|
|
1428
|
+
updated_at: marker.cursor.updated_at,
|
|
1429
|
+
completed_at: marker.completed_at,
|
|
1430
|
+
sources: summarizeBackfillCursorSources(marker.cursor),
|
|
1431
|
+
};
|
|
1432
|
+
}
|
|
1433
|
+
const cursor = await readBackfillCursor(paths);
|
|
1434
|
+
if (!cursor.updated_at) {
|
|
1435
|
+
return {
|
|
1436
|
+
state: "never_run",
|
|
1437
|
+
remaining_count: 0,
|
|
1438
|
+
updated_at: null,
|
|
1439
|
+
completed_at: null,
|
|
1440
|
+
sources: summarizeBackfillCursorSources(cursor),
|
|
1441
|
+
};
|
|
1442
|
+
}
|
|
1443
|
+
return {
|
|
1444
|
+
state: "remaining",
|
|
1445
|
+
remaining_count: await countRemainingBackfillSessionFiles(homeDir ?? os.homedir(), cursor),
|
|
1446
|
+
updated_at: cursor.updated_at,
|
|
1447
|
+
completed_at: null,
|
|
1448
|
+
sources: summarizeBackfillCursorSources(cursor),
|
|
1449
|
+
};
|
|
1450
|
+
}
|
|
1451
|
+
async function readBackfillCompletionMarker(paths) {
|
|
1452
|
+
try {
|
|
1453
|
+
const raw = JSON.parse(await readFile(backfillCompletionMarkerPath(paths), "utf8"));
|
|
1454
|
+
if (typeof raw.completed_at !== "string")
|
|
1455
|
+
return null;
|
|
1456
|
+
const cursor = raw.cursor && typeof raw.cursor === "object"
|
|
1457
|
+
? normalizeBackfillCursor(raw.cursor)
|
|
1458
|
+
: await readBackfillCursor(paths);
|
|
1459
|
+
return { completed_at: raw.completed_at, cursor };
|
|
1460
|
+
}
|
|
1461
|
+
catch {
|
|
1462
|
+
return null;
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
function normalizeBackfillCursor(value) {
|
|
1466
|
+
const record = value;
|
|
1467
|
+
return {
|
|
1468
|
+
schema_version: "cockpit-backfill-cursor.v1",
|
|
1469
|
+
updated_at: typeof record.updated_at === "string" && record.updated_at
|
|
1470
|
+
? record.updated_at
|
|
1471
|
+
: null,
|
|
1472
|
+
sources: {
|
|
1473
|
+
codex: {
|
|
1474
|
+
oldest_mtime_ms_processed: typeof record.sources?.codex?.oldest_mtime_ms_processed === "number"
|
|
1475
|
+
? record.sources.codex.oldest_mtime_ms_processed
|
|
1476
|
+
: null,
|
|
1477
|
+
oldest_mtime_processed: typeof record.sources?.codex?.oldest_mtime_processed === "string"
|
|
1478
|
+
? record.sources.codex.oldest_mtime_processed
|
|
1479
|
+
: null,
|
|
1480
|
+
state_counts: numberRecord(record.sources?.codex?.state_counts),
|
|
1481
|
+
reason_counts: numberRecord(record.sources?.codex?.reason_counts),
|
|
1482
|
+
},
|
|
1483
|
+
claude_code: {
|
|
1484
|
+
oldest_mtime_ms_processed: typeof record.sources?.claude_code?.oldest_mtime_ms_processed === "number"
|
|
1485
|
+
? record.sources.claude_code.oldest_mtime_ms_processed
|
|
1486
|
+
: null,
|
|
1487
|
+
oldest_mtime_processed: typeof record.sources?.claude_code?.oldest_mtime_processed === "string"
|
|
1488
|
+
? record.sources.claude_code.oldest_mtime_processed
|
|
1489
|
+
: null,
|
|
1490
|
+
state_counts: numberRecord(record.sources?.claude_code?.state_counts),
|
|
1491
|
+
reason_counts: numberRecord(record.sources?.claude_code?.reason_counts),
|
|
1492
|
+
},
|
|
1493
|
+
},
|
|
1494
|
+
};
|
|
1495
|
+
}
|
|
1496
|
+
function numberRecord(value) {
|
|
1497
|
+
if (!value || typeof value !== "object")
|
|
1498
|
+
return {};
|
|
1499
|
+
const out = {};
|
|
1500
|
+
for (const [key, raw] of Object.entries(value)) {
|
|
1501
|
+
if (typeof raw === "number" && Number.isFinite(raw))
|
|
1502
|
+
out[key] = raw;
|
|
1503
|
+
}
|
|
1504
|
+
return out;
|
|
1505
|
+
}
|
|
1506
|
+
function summarizeBackfillCursorSources(cursor) {
|
|
1507
|
+
return {
|
|
1508
|
+
codex: summarizeBackfillSource(cursor.sources.codex),
|
|
1509
|
+
claude_code: summarizeBackfillSource(cursor.sources.claude_code),
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
function summarizeBackfillSource(source) {
|
|
1513
|
+
return {
|
|
1514
|
+
oldest_mtime_processed: source.oldest_mtime_processed,
|
|
1515
|
+
observed_count: Object.values(source.state_counts).reduce((total, count) => total + count, 0),
|
|
1516
|
+
state_counts: source.state_counts,
|
|
1517
|
+
reason_counts: source.reason_counts,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
function backfillCursorLine(status) {
|
|
1521
|
+
switch (status.state) {
|
|
1522
|
+
case "done":
|
|
1523
|
+
return `done (${status.completed_at ?? "completion marker present"})`;
|
|
1524
|
+
case "never_run":
|
|
1525
|
+
return "never run";
|
|
1526
|
+
case "remaining":
|
|
1527
|
+
return `${status.remaining_count} remaining`;
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
async function countRemainingBackfillSessionFiles(homeDir, cursor) {
|
|
1531
|
+
const codex = await countJsonlFilesBeforeCursor(defaultCodexSessionDirs(homeDir), cursor.sources.codex.oldest_mtime_ms_processed);
|
|
1532
|
+
const claude = await countClaudeMainFilesBeforeCursor(path.join(homeDir, ".claude", "projects"), cursor.sources.claude_code.oldest_mtime_ms_processed);
|
|
1533
|
+
return codex + claude;
|
|
1534
|
+
}
|
|
1535
|
+
async function countJsonlFilesBeforeCursor(roots, oldestProcessedMs) {
|
|
1536
|
+
let count = 0;
|
|
1537
|
+
await walkFiles(roots, async (filePath, entryName) => {
|
|
1538
|
+
if (!entryName.endsWith(".jsonl"))
|
|
1539
|
+
return;
|
|
1540
|
+
const info = await stat(filePath).catch(() => null);
|
|
1541
|
+
if (!info?.isFile())
|
|
1542
|
+
return;
|
|
1543
|
+
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
1544
|
+
count += 1;
|
|
1545
|
+
});
|
|
1546
|
+
return count;
|
|
1547
|
+
}
|
|
1548
|
+
async function countClaudeMainFilesBeforeCursor(projectsDir, oldestProcessedMs) {
|
|
1549
|
+
let count = 0;
|
|
1550
|
+
await walkFiles([projectsDir], async (filePath, entryName) => {
|
|
1551
|
+
if (!entryName.endsWith(".jsonl"))
|
|
1552
|
+
return;
|
|
1553
|
+
if (filePath.includes(`${path.sep}subagents${path.sep}`))
|
|
1554
|
+
return;
|
|
1555
|
+
const info = await stat(filePath).catch(() => null);
|
|
1556
|
+
if (!info?.isFile())
|
|
1557
|
+
return;
|
|
1558
|
+
if (oldestProcessedMs === null || info.mtimeMs < oldestProcessedMs)
|
|
1559
|
+
count += 1;
|
|
1560
|
+
});
|
|
1561
|
+
return count;
|
|
1562
|
+
}
|
|
1563
|
+
async function hasAnyJsonlFile(roots) {
|
|
1564
|
+
let found = false;
|
|
1565
|
+
await walkFiles(roots, async (_filePath, entryName) => {
|
|
1566
|
+
if (entryName.endsWith(".jsonl"))
|
|
1567
|
+
found = true;
|
|
1568
|
+
}, () => found);
|
|
1569
|
+
return found;
|
|
1570
|
+
}
|
|
1571
|
+
async function walkFiles(roots, onFile, shouldStop = () => false) {
|
|
1572
|
+
const stack = [...roots];
|
|
1573
|
+
while (stack.length > 0 && !shouldStop()) {
|
|
1574
|
+
const current = stack.pop();
|
|
1575
|
+
if (!current)
|
|
1576
|
+
continue;
|
|
1577
|
+
let entries;
|
|
1578
|
+
try {
|
|
1579
|
+
entries = await readdir(current, { withFileTypes: true });
|
|
1580
|
+
}
|
|
1581
|
+
catch {
|
|
1582
|
+
continue;
|
|
1583
|
+
}
|
|
1584
|
+
for (const entry of entries) {
|
|
1585
|
+
if (shouldStop())
|
|
1586
|
+
return;
|
|
1587
|
+
const full = path.join(current, entry.name);
|
|
1588
|
+
if (entry.isDirectory()) {
|
|
1589
|
+
stack.push(full);
|
|
1590
|
+
}
|
|
1591
|
+
else if (entry.isFile()) {
|
|
1592
|
+
await onFile(full, entry.name);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
async function sessionsScanWindow(command, now) {
|
|
1598
|
+
if (command.all) {
|
|
1599
|
+
return {
|
|
1600
|
+
mode: "all",
|
|
1601
|
+
since_days: null,
|
|
1602
|
+
started_at: new Date(now.getTime() - ALL_SESSION_SCAN_WINDOW_MINUTES * 60_000)
|
|
1603
|
+
.toISOString(),
|
|
1604
|
+
paired_at: await readPairedAt(command.homeDir),
|
|
1605
|
+
since_minutes: ALL_SESSION_SCAN_WINDOW_MINUTES,
|
|
1606
|
+
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
1607
|
+
};
|
|
1608
|
+
}
|
|
1609
|
+
if (command.sinceDays !== undefined) {
|
|
1610
|
+
const requestedMs = now.getTime() - command.sinceDays * 24 * 60 * 60_000;
|
|
1611
|
+
const pairedAt = await readPairedAt(command.homeDir);
|
|
1612
|
+
const pairedMs = pairedAt ? Date.parse(pairedAt) : Number.NaN;
|
|
1613
|
+
const startedAtMs = Number.isFinite(pairedMs)
|
|
1614
|
+
? Math.max(requestedMs, pairedMs)
|
|
1615
|
+
: requestedMs;
|
|
1616
|
+
return {
|
|
1617
|
+
mode: "since_days",
|
|
1618
|
+
since_days: command.sinceDays,
|
|
1619
|
+
started_at: new Date(startedAtMs).toISOString(),
|
|
1620
|
+
paired_at: pairedAt,
|
|
1621
|
+
since_minutes: Math.max(1, Math.ceil((now.getTime() - startedAtMs) / 60_000)),
|
|
1622
|
+
limit: SESSION_SCAN_OVERRIDE_LIMIT,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
return {
|
|
1626
|
+
mode: "default",
|
|
1627
|
+
since_days: null,
|
|
1628
|
+
started_at: null,
|
|
1629
|
+
paired_at: await readPairedAt(command.homeDir),
|
|
1630
|
+
since_minutes: null,
|
|
1631
|
+
limit: CODEX_ATTRIBUTION_SCAN_WINDOW_SESSION_LIMIT,
|
|
1632
|
+
};
|
|
1633
|
+
}
|
|
1634
|
+
async function readPairedAt(homeDir) {
|
|
1635
|
+
const session = await readLocalCollectorSessionFile(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
1636
|
+
return typeof session?.paired_at === "string" ? session.paired_at : null;
|
|
1637
|
+
}
|
|
1638
|
+
function sessionsWindowLine(window) {
|
|
1639
|
+
if (window.mode === "all")
|
|
1640
|
+
return "all local history";
|
|
1641
|
+
if (window.mode === "since_days") {
|
|
1642
|
+
return `since ${window.started_at} (${window.since_days} day request, paired_at cap ${window.paired_at ?? "unavailable"})`;
|
|
1643
|
+
}
|
|
1644
|
+
return "default scan window";
|
|
1645
|
+
}
|
|
1354
1646
|
/**
|
|
1355
1647
|
* Read-only diagnostic: re-runs attribution (no upload, no cursor writes) and
|
|
1356
1648
|
* prints why each session is or is not collected. Per-session reasons otherwise
|
|
@@ -1362,6 +1654,7 @@ async function runSessions(command, io) {
|
|
|
1362
1654
|
const now = new Date();
|
|
1363
1655
|
const homeDir = command.homeDir ?? os.homedir();
|
|
1364
1656
|
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1657
|
+
const window = await sessionsScanWindow(command, now);
|
|
1365
1658
|
const wantCodex = command.source !== "claude";
|
|
1366
1659
|
const wantClaude = command.source !== "codex";
|
|
1367
1660
|
const codex = wantCodex
|
|
@@ -1369,8 +1662,8 @@ async function runSessions(command, io) {
|
|
|
1369
1662
|
sessionsDirs: defaultCodexSessionDirs(homeDir),
|
|
1370
1663
|
worktrees,
|
|
1371
1664
|
now,
|
|
1372
|
-
sinceMinutes:
|
|
1373
|
-
limit:
|
|
1665
|
+
sinceMinutes: window.since_minutes ?? CODEX_ATTRIBUTION_SCAN_WINDOW_MINUTES,
|
|
1666
|
+
limit: window.limit,
|
|
1374
1667
|
})
|
|
1375
1668
|
: null;
|
|
1376
1669
|
const claude = wantClaude
|
|
@@ -1378,6 +1671,8 @@ async function runSessions(command, io) {
|
|
|
1378
1671
|
projectsDir: path.join(homeDir, ".claude", "projects"),
|
|
1379
1672
|
worktrees,
|
|
1380
1673
|
now,
|
|
1674
|
+
sinceMinutes: window.since_minutes ?? undefined,
|
|
1675
|
+
limit: window.mode === "default" ? undefined : window.limit,
|
|
1381
1676
|
})
|
|
1382
1677
|
: null;
|
|
1383
1678
|
// Safe output contract (B.4 §6): id, state, reason, scores, signals, sidecar
|
|
@@ -1412,6 +1707,7 @@ async function runSessions(command, io) {
|
|
|
1412
1707
|
}));
|
|
1413
1708
|
if (command.json) {
|
|
1414
1709
|
writeLine(io.stdout, JSON.stringify({
|
|
1710
|
+
window,
|
|
1415
1711
|
...(codex
|
|
1416
1712
|
? { codex: { counts: codex.counts, sessions: codexRows } }
|
|
1417
1713
|
: {}),
|
|
@@ -1428,6 +1724,7 @@ async function runSessions(command, io) {
|
|
|
1428
1724
|
return 0;
|
|
1429
1725
|
}
|
|
1430
1726
|
writeLine(io.stdout, "Cockpit sessions (read-only attribution)");
|
|
1727
|
+
writeLine(io.stdout, `window: ${sessionsWindowLine(window)}`);
|
|
1431
1728
|
for (const row of codexRows) {
|
|
1432
1729
|
writeLine(io.stdout, sessionRowLine(row));
|
|
1433
1730
|
}
|