@bli-cockpit/cli 0.2.49 → 0.2.51
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/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +63 -1183
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +41 -1961
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +144 -4
- package/dist/commands/local-args-collector.js +26 -0
- package/dist/commands/local-args-tower.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +19 -2
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +286 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +465 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +105 -0
- package/dist/commands/sync.js +7 -1
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +57 -3
- package/package.json +2 -1
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which scanned sessions are actually uploadable, and how they are cut into
|
|
3
|
+
* the units one upload call handles. Batches are per worktree because the sync
|
|
4
|
+
* path uploads one repo's envelope at a time, and capped in size so a single
|
|
5
|
+
* failure costs one batch rather than the whole history.
|
|
6
|
+
*/
|
|
7
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
8
|
+
export const BACKFILL_UPLOAD_BATCH_SESSIONS = 25;
|
|
9
|
+
export function uploadableCandidates(candidates) {
|
|
10
|
+
return candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
11
|
+
candidate.worktree);
|
|
12
|
+
}
|
|
13
|
+
export function buildBackfillBatches(candidates) {
|
|
14
|
+
const byWorktree = new Map();
|
|
15
|
+
for (const candidate of candidates) {
|
|
16
|
+
if (!candidate.worktree)
|
|
17
|
+
continue;
|
|
18
|
+
const key = candidate.worktree.worktree_fingerprint;
|
|
19
|
+
byWorktree.set(key, [...(byWorktree.get(key) ?? []), candidate]);
|
|
20
|
+
}
|
|
21
|
+
const batches = [];
|
|
22
|
+
for (const group of byWorktree.values()) {
|
|
23
|
+
const worktree = group[0]?.worktree;
|
|
24
|
+
if (!worktree)
|
|
25
|
+
continue;
|
|
26
|
+
for (let offset = 0; offset < group.length; offset += BACKFILL_UPLOAD_BATCH_SESSIONS) {
|
|
27
|
+
batches.push({
|
|
28
|
+
worktree,
|
|
29
|
+
candidates: group.slice(offset, offset + BACKFILL_UPLOAD_BATCH_SESSIONS),
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return batches;
|
|
34
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How backfill identifies and orders one archived session. The cursor key is
|
|
3
|
+
* the durable identity every later stage keys on — batches, durability, the
|
|
4
|
+
* resume cursor — so it is computed in exactly one place, from the source, the
|
|
5
|
+
* session id and a path normalized the same way on every host.
|
|
6
|
+
*/
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
export function isAfterCursor(candidate, cursor) {
|
|
10
|
+
const source = cursor.sources[candidate.source];
|
|
11
|
+
const oldest = source?.oldest_mtime_ms_processed;
|
|
12
|
+
if (oldest === null || oldest === undefined)
|
|
13
|
+
return true;
|
|
14
|
+
if (candidate.session_file_mtime_ms < oldest)
|
|
15
|
+
return true;
|
|
16
|
+
const key = candidateCursorKey(candidate);
|
|
17
|
+
if (candidate.session_file_mtime_ms === oldest) {
|
|
18
|
+
return !source.processed_keys_at_oldest_mtime.includes(key);
|
|
19
|
+
}
|
|
20
|
+
const newest = source.newest_mtime_ms_covered;
|
|
21
|
+
// A cursor written before upper-edge coverage existed gets one safe
|
|
22
|
+
// migration pass across the previously processed interval. Once that pass is
|
|
23
|
+
// acknowledged, subsequent --all runs only inspect genuinely newer files.
|
|
24
|
+
if (newest === null)
|
|
25
|
+
return true;
|
|
26
|
+
if (candidate.session_file_mtime_ms > newest)
|
|
27
|
+
return true;
|
|
28
|
+
if (candidate.session_file_mtime_ms < newest)
|
|
29
|
+
return false;
|
|
30
|
+
// A legacy cursor has no boundary identities. Rechecking the equal-time
|
|
31
|
+
// boundary is safe because durable evidence is content-addressed.
|
|
32
|
+
return !source.processed_keys_at_newest_mtime.includes(key);
|
|
33
|
+
}
|
|
34
|
+
export function compareBackfillCandidates(a, b) {
|
|
35
|
+
return (b.session_file_mtime_ms - a.session_file_mtime_ms ||
|
|
36
|
+
candidateCursorKey(a).localeCompare(candidateCursorKey(b)));
|
|
37
|
+
}
|
|
38
|
+
export function candidateCursorKey(candidate) {
|
|
39
|
+
return crypto
|
|
40
|
+
.createHash("sha256")
|
|
41
|
+
.update(JSON.stringify([
|
|
42
|
+
candidate.source,
|
|
43
|
+
candidate.session_id,
|
|
44
|
+
normalizedCursorPath(candidate.file_path),
|
|
45
|
+
]))
|
|
46
|
+
.digest("hex");
|
|
47
|
+
}
|
|
48
|
+
function normalizedCursorPath(filePath) {
|
|
49
|
+
const windowsStyle = path.win32.isAbsolute(filePath) && !path.posix.isAbsolute(filePath);
|
|
50
|
+
if (windowsStyle)
|
|
51
|
+
return path.win32.normalize(filePath).toLowerCase();
|
|
52
|
+
const normalized = path.resolve(filePath);
|
|
53
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
54
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The only irreversible writes backfill makes: the pointer a delivered session
|
|
3
|
+
* earns in the live raw-evidence cursor, and the backfill cursor's advance.
|
|
4
|
+
*
|
|
5
|
+
* The cursor advances solely through a contiguous prefix of sessions that both
|
|
6
|
+
* earned a durable pointer and were acknowledged by the server, and not at all
|
|
7
|
+
* while discovery is incomplete — a misjudged "resolved" here loses that
|
|
8
|
+
* history for good. Backfill and scheduled sync share these cursor files; the
|
|
9
|
+
* caller holds the collection lock for exactly that reason.
|
|
10
|
+
*/
|
|
11
|
+
import { recordBackfillCursorObservations, } from "../cursors/backfill-cursor.js";
|
|
12
|
+
import { CLAUDE_CURSOR_FILENAME, readRawEvidenceCursor, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
13
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
14
|
+
import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
|
|
15
|
+
import { indexDurableMainObjectKeys } from "./backfill-evidence-outcomes.js";
|
|
16
|
+
/**
|
|
17
|
+
* Writes the pointer a session earned back into the live raw-evidence cursor,
|
|
18
|
+
* so the next scheduled sync knows it is already delivered. Backfill and sync
|
|
19
|
+
* share these cursors; the caller holds the collection lock for exactly this.
|
|
20
|
+
*/
|
|
21
|
+
export async function recordBackfillDurableSessionPointers(options) {
|
|
22
|
+
const durableObjectBySession = indexDurableMainObjectKeys(options.syncResults);
|
|
23
|
+
let recordedCount = 0;
|
|
24
|
+
for (const source of ["codex", "claude_code"]) {
|
|
25
|
+
const filename = source === "claude_code" ? CLAUDE_CURSOR_FILENAME : undefined;
|
|
26
|
+
const cursor = await readRawEvidenceCursor(options.paths, { filename });
|
|
27
|
+
let changed = false;
|
|
28
|
+
for (const candidate of options.candidates) {
|
|
29
|
+
if (candidate.source !== source)
|
|
30
|
+
continue;
|
|
31
|
+
const objectKey = durableObjectBySession.get(`${source}:${candidate.session_id}`);
|
|
32
|
+
const prior = cursor.sessions[candidate.session_id];
|
|
33
|
+
// No pointer, no prior entry, or already pointed: nothing this pass owes.
|
|
34
|
+
if (!objectKey || !prior || prior.uploaded_object_key)
|
|
35
|
+
continue;
|
|
36
|
+
cursor.sessions[candidate.session_id] = deliveredCursorEntry(prior, candidate, objectKey, options.now);
|
|
37
|
+
changed = true;
|
|
38
|
+
recordedCount += 1;
|
|
39
|
+
}
|
|
40
|
+
if (changed) {
|
|
41
|
+
await writeRawEvidenceCursor(options.paths, cursor, {
|
|
42
|
+
filename,
|
|
43
|
+
sessionsOnly: source === "claude_code",
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (recordedCount > 0) {
|
|
48
|
+
console.error("[backfill] terminal session pointers recorded", JSON.stringify({ count: recordedCount }));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** The cursor entry for a session backfill has just delivered in full. */
|
|
52
|
+
function deliveredCursorEntry(prior, candidate, objectKey, now) {
|
|
53
|
+
return {
|
|
54
|
+
...prior,
|
|
55
|
+
file_hash_sha256: candidate.content_hash_sha256,
|
|
56
|
+
file_mtime_ms: candidate.session_file_mtime_ms,
|
|
57
|
+
byte_size: candidate.byte_size,
|
|
58
|
+
// The whole file was delivered, so the incremental reader starts at its end.
|
|
59
|
+
byte_offset: candidate.byte_size,
|
|
60
|
+
state: candidate.state,
|
|
61
|
+
reason: candidate.reason,
|
|
62
|
+
worktree_fingerprint: candidate.worktree?.worktree_fingerprint ?? null,
|
|
63
|
+
uploaded_object_key: objectKey,
|
|
64
|
+
uploaded_at: now.toISOString(),
|
|
65
|
+
uploaded_byte_size: candidate.byte_size,
|
|
66
|
+
last_seen_at: now.toISOString(),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
export function advanceBackfillCursorThroughResolvedPrefix(options) {
|
|
70
|
+
if (!options.discoveryComplete)
|
|
71
|
+
return false;
|
|
72
|
+
const observations = [];
|
|
73
|
+
for (const source of ["codex", "claude_code"]) {
|
|
74
|
+
const remaining = options.candidates
|
|
75
|
+
.filter((candidate) => candidate.source === source &&
|
|
76
|
+
isAfterCursor(candidate, options.cursor))
|
|
77
|
+
.sort(compareBackfillCandidates);
|
|
78
|
+
for (const candidate of remaining) {
|
|
79
|
+
const key = candidateCursorKey(candidate);
|
|
80
|
+
if (options.retryableCandidateKeys.has(key))
|
|
81
|
+
break;
|
|
82
|
+
const resolved = isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null)
|
|
83
|
+
? options.durableCandidateKeys.has(key)
|
|
84
|
+
: true;
|
|
85
|
+
if (!resolved)
|
|
86
|
+
break;
|
|
87
|
+
observations.push({
|
|
88
|
+
source: candidate.source,
|
|
89
|
+
cursor_key: key,
|
|
90
|
+
state: candidate.state,
|
|
91
|
+
reason: candidate.reason,
|
|
92
|
+
session_file_mtime_ms: candidate.session_file_mtime_ms,
|
|
93
|
+
session_file_mtime: candidate.session_file_mtime,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (observations.length === 0)
|
|
98
|
+
return false;
|
|
99
|
+
recordBackfillCursorObservations(options.cursor, observations, options.now);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The flag surface of `cockpit backfill`, read back out: the window a bare
|
|
3
|
+
* invocation means, and the copyable command that reproduces or widens a run.
|
|
4
|
+
* Every retry an operator is handed is built here, so the flags stay spelled
|
|
5
|
+
* one way and quoting stays correct on both supported shells.
|
|
6
|
+
*/
|
|
7
|
+
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, } from "../repo-identity.js";
|
|
8
|
+
// Window a bare `cockpit backfill` uses. Wide enough to cover a new machine's
|
|
9
|
+
// recent history and an intern who went quiet for a few weeks, narrow enough
|
|
10
|
+
// that it is not the whole-history scan `--all` deliberately gates.
|
|
11
|
+
export const DEFAULT_BACKFILL_SINCE_DAYS = 30;
|
|
12
|
+
export function defaultBackfillWindowNotice() {
|
|
13
|
+
return [
|
|
14
|
+
`No window given — backfilling the last ${DEFAULT_BACKFILL_SINCE_DAYS} days.`,
|
|
15
|
+
"The effective start is capped at the collector paired_at timestamp.",
|
|
16
|
+
"Use `cockpit backfill --since-days N` for a different window, or `cockpit backfill --all` for the full local history (review a dry-run first; add `--yes` on headless agent runs).",
|
|
17
|
+
].join("\n");
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Produces a copyable retry that preserves the requested history window and
|
|
21
|
+
* every discovery/selection override. Values that need quoting use syntax
|
|
22
|
+
* accepted by the supported native shells: POSIX shells on macOS and
|
|
23
|
+
* PowerShell on Windows.
|
|
24
|
+
*/
|
|
25
|
+
export function backfillRetryCommand(command) {
|
|
26
|
+
const parts = ["cockpit", "backfill"];
|
|
27
|
+
if (command.all) {
|
|
28
|
+
parts.push("--all", "--yes");
|
|
29
|
+
}
|
|
30
|
+
else if (command.sinceDays !== undefined) {
|
|
31
|
+
parts.push("--since-days", String(command.sinceDays));
|
|
32
|
+
}
|
|
33
|
+
if (command.source)
|
|
34
|
+
parts.push("--source", command.source);
|
|
35
|
+
if (command.maxFiles !== undefined) {
|
|
36
|
+
parts.push("--max-files", String(command.maxFiles));
|
|
37
|
+
}
|
|
38
|
+
if (command.maxDepth !== undefined) {
|
|
39
|
+
parts.push("--max-depth", String(command.maxDepth));
|
|
40
|
+
}
|
|
41
|
+
if (command.maxRepos !== undefined) {
|
|
42
|
+
parts.push("--max-repos", String(command.maxRepos));
|
|
43
|
+
}
|
|
44
|
+
if (command.repoRoot) {
|
|
45
|
+
parts.push("--workspace", quoteCliArgument(command.repoRoot));
|
|
46
|
+
}
|
|
47
|
+
if (command.dryRun)
|
|
48
|
+
parts.push("--dry-run");
|
|
49
|
+
return parts.join(" ");
|
|
50
|
+
}
|
|
51
|
+
export function backfillDiscoveryRetryCommand(command, discovery) {
|
|
52
|
+
const retry = { ...command };
|
|
53
|
+
if (discovery.incomplete_reasons.includes("max_depth_reached")) {
|
|
54
|
+
retry.maxDepth =
|
|
55
|
+
(command.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH) + 1;
|
|
56
|
+
}
|
|
57
|
+
if (discovery.incomplete_reasons.includes("max_worktrees_reached")) {
|
|
58
|
+
retry.maxRepos =
|
|
59
|
+
(command.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS) * 2;
|
|
60
|
+
}
|
|
61
|
+
return backfillRetryCommand(retry);
|
|
62
|
+
}
|
|
63
|
+
function quoteCliArgument(value) {
|
|
64
|
+
if (/^[a-z0-9_./:\\-]+$/iu.test(value))
|
|
65
|
+
return value;
|
|
66
|
+
const escaped = process.platform === "win32"
|
|
67
|
+
? value.replaceAll("'", "''")
|
|
68
|
+
: value.replaceAll("'", `'\"'\"'`);
|
|
69
|
+
return `'${escaped}'`;
|
|
70
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { candidateCursorKey } from "./backfill-candidates.js";
|
|
2
|
+
/** Evidence the server accepted the batch for but had no budget left to store. */
|
|
3
|
+
export function deferredEvidenceCount(sync) {
|
|
4
|
+
return (sync.raw_evidence_deferred_byte_budget +
|
|
5
|
+
sync.raw_evidence_deferred_object_budget);
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Which candidates in this batch are now safe to leave behind forever: a
|
|
9
|
+
* session counts as durable only when its main transcript AND every sidecar
|
|
10
|
+
* we expected earned a pointer, and nothing in it failed. This set is what the
|
|
11
|
+
* cursor advances through, so an over-generous answer here loses history
|
|
12
|
+
* permanently.
|
|
13
|
+
*/
|
|
14
|
+
export function durableBackfillCandidateKeys(batch, sync) {
|
|
15
|
+
const durable = new Set();
|
|
16
|
+
if (sync.status !== "uploaded")
|
|
17
|
+
return durable;
|
|
18
|
+
if (deferredEvidenceCount(sync) > 0) {
|
|
19
|
+
// Deferred outcomes have no per-file identity. Advancing any candidate in
|
|
20
|
+
// this batch could therefore strand the deferred main transcript.
|
|
21
|
+
return durable;
|
|
22
|
+
}
|
|
23
|
+
const outcomesBySession = summarizeEvidenceOutcomesBySession(sync);
|
|
24
|
+
for (const candidate of batch.candidates) {
|
|
25
|
+
const summary = outcomesBySession.get(`${candidate.source}:${candidate.session_id}`);
|
|
26
|
+
if (summary &&
|
|
27
|
+
!summary.failed &&
|
|
28
|
+
summary.durableMainCount > 0 &&
|
|
29
|
+
summary.durableSidecarCount >= expectedSidecarCount(candidate)) {
|
|
30
|
+
durable.add(candidateCursorKey(candidate));
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return durable;
|
|
34
|
+
}
|
|
35
|
+
function summarizeEvidenceOutcomesBySession(sync) {
|
|
36
|
+
const outcomesBySession = new Map();
|
|
37
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
38
|
+
const source = backfillSourceForEvidenceKind(outcome.kind);
|
|
39
|
+
if (!source || !outcome.codex_session_id)
|
|
40
|
+
continue;
|
|
41
|
+
const key = `${source}:${outcome.codex_session_id}`;
|
|
42
|
+
const summary = outcomesBySession.get(key) ?? {
|
|
43
|
+
durableMainCount: 0,
|
|
44
|
+
durableSidecarCount: 0,
|
|
45
|
+
failed: false,
|
|
46
|
+
};
|
|
47
|
+
if (outcome.upload_state === "upload_failed") {
|
|
48
|
+
summary.failed = true;
|
|
49
|
+
}
|
|
50
|
+
else if (outcome.raw_evidence_pointer_id &&
|
|
51
|
+
(outcome.upload_state === "uploaded" ||
|
|
52
|
+
outcome.upload_state === "reused_existing")) {
|
|
53
|
+
if (outcome.kind === "codex_jsonl" || outcome.kind === "claude_jsonl") {
|
|
54
|
+
summary.durableMainCount += 1;
|
|
55
|
+
}
|
|
56
|
+
else if (outcome.kind === "claude_jsonl_sidecar") {
|
|
57
|
+
summary.durableSidecarCount += 1;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
outcomesBySession.set(key, summary);
|
|
61
|
+
}
|
|
62
|
+
return outcomesBySession;
|
|
63
|
+
}
|
|
64
|
+
/** Sidecars this session owes a pointer for; one already skipped at scan time is not owed. */
|
|
65
|
+
function expectedSidecarCount(candidate) {
|
|
66
|
+
if (candidate.source !== "claude_code")
|
|
67
|
+
return 0;
|
|
68
|
+
return (candidate.claude?.sidecar_files.filter((sidecar) => !sidecar.skipped_reason)
|
|
69
|
+
.length ?? 0);
|
|
70
|
+
}
|
|
71
|
+
/** Storage keys for main transcripts that actually landed, keyed by source and session. */
|
|
72
|
+
export function indexDurableMainObjectKeys(syncResults) {
|
|
73
|
+
const durableObjectBySession = new Map();
|
|
74
|
+
for (const sync of syncResults) {
|
|
75
|
+
for (const outcome of sync.raw_evidence_outcomes) {
|
|
76
|
+
if (!outcome.codex_session_id ||
|
|
77
|
+
(outcome.kind !== "codex_jsonl" && outcome.kind !== "claude_jsonl") ||
|
|
78
|
+
(outcome.upload_state !== "uploaded" &&
|
|
79
|
+
outcome.upload_state !== "reused_existing") ||
|
|
80
|
+
!outcome.object_key) {
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const source = outcome.kind === "codex_jsonl" ? "codex" : "claude_code";
|
|
84
|
+
durableObjectBySession.set(`${source}:${outcome.codex_session_id}`, outcome.object_key);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return durableObjectBySession;
|
|
88
|
+
}
|
|
89
|
+
export function countSessionUploadFailures(sync) {
|
|
90
|
+
return sync.raw_evidence_outcomes.filter((outcome) => Boolean(outcome.codex_session_id) &&
|
|
91
|
+
outcome.upload_state === "upload_failed" &&
|
|
92
|
+
Boolean(backfillSourceForEvidenceKind(outcome.kind))).length;
|
|
93
|
+
}
|
|
94
|
+
function backfillSourceForEvidenceKind(kind) {
|
|
95
|
+
if (kind === "codex_jsonl" || kind === "codex_image_attachment") {
|
|
96
|
+
return "codex";
|
|
97
|
+
}
|
|
98
|
+
if (kind === "claude_jsonl" ||
|
|
99
|
+
kind === "claude_jsonl_sidecar" ||
|
|
100
|
+
kind === "claude_image_attachment") {
|
|
101
|
+
return "claude_code";
|
|
102
|
+
}
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Everything the scan could not account for cleanly, in one ledger — the
|
|
3
|
+
* module that exists so a session is never silently dropped.
|
|
4
|
+
*
|
|
5
|
+
* Scope is the load-bearing distinction. A global issue means discovery may
|
|
6
|
+
* have hidden a session at any mtime, so no source watermark is safe to
|
|
7
|
+
* advance; a candidate issue only stops the contiguous cursor prefix at the
|
|
8
|
+
* affected session; a selection issue is the operator's own `--max-files` cap.
|
|
9
|
+
* Every write goes through a named operation here rather than a bare `.push`,
|
|
10
|
+
* so the ledger's shape and priority order have one owner.
|
|
11
|
+
*/
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
15
|
+
import { describeError } from "../health-detail.js";
|
|
16
|
+
import { isRawEvidenceUploadableAttributionState } from "../raw-evidence-attribution-policy.js";
|
|
17
|
+
import { candidateCursorKey } from "./backfill-candidates.js";
|
|
18
|
+
/**
|
|
19
|
+
* Everything the scan could not account for cleanly, in one ledger. Read in
|
|
20
|
+
* three passes — what the Codex store hid, what the Claude store hid, then
|
|
21
|
+
* what is wrong with the candidates that survived — because the first two are
|
|
22
|
+
* global (discovery may have missed a session at any mtime, so no watermark is
|
|
23
|
+
* safe to advance) and the third only stops the contiguous cursor prefix.
|
|
24
|
+
*/
|
|
25
|
+
export async function backfillScanIssues(options) {
|
|
26
|
+
const issues = [];
|
|
27
|
+
if (options.codexAttribution) {
|
|
28
|
+
await addCodexStoreScanIssues(issues, options.codexAttribution, options.codexSessionDirs);
|
|
29
|
+
}
|
|
30
|
+
if (options.claudeAttribution) {
|
|
31
|
+
await addClaudeStoreScanIssues(issues, options.claudeAttribution, options.claudeProjectsDir, options.candidates);
|
|
32
|
+
}
|
|
33
|
+
addCandidateScanIssues(issues, options.candidates, options.omittedCandidateCount);
|
|
34
|
+
sortScanIssuesByPriority(issues);
|
|
35
|
+
return issues;
|
|
36
|
+
}
|
|
37
|
+
/** What the archived Codex store could not tell us — every one of these hides history. */
|
|
38
|
+
async function addCodexStoreScanIssues(issues, codexAttribution, codexSessionDirs) {
|
|
39
|
+
countScanIssue(issues, "codex_session_limit_applied", codexAttribution.session_limit_applied
|
|
40
|
+
? Math.max(1, codexAttribution.discovered_file_count -
|
|
41
|
+
codexAttribution.scanned_file_count)
|
|
42
|
+
: 0, "global");
|
|
43
|
+
// A session directory that simply does not exist on this machine is not a
|
|
44
|
+
// read failure; subtract those before reporting one.
|
|
45
|
+
const missingTopLevelDirs = (await Promise.all(codexSessionDirs.map(isMissingPath))).filter(Boolean).length;
|
|
46
|
+
countScanIssue(issues, "codex_directory_read_failed", Math.max(0, codexAttribution.directory_read_failed_count - missingTopLevelDirs), "global");
|
|
47
|
+
countScanIssue(issues, "codex_session_stat_failed", codexAttribution.stat_failed_count, "global");
|
|
48
|
+
}
|
|
49
|
+
/** The same census for the Claude store, which also has sidecars to account for. */
|
|
50
|
+
async function addClaudeStoreScanIssues(issues, claudeAttribution, claudeProjectsDir, candidates) {
|
|
51
|
+
countScanIssue(issues, "claude_session_limit_applied", claudeAttribution.session_limit_applied
|
|
52
|
+
? Math.max(1, claudeAttribution.discovered_session_count -
|
|
53
|
+
claudeAttribution.scanned_session_count)
|
|
54
|
+
: 0, "global");
|
|
55
|
+
const projectsDirMissing = await isMissingPath(claudeProjectsDir);
|
|
56
|
+
countScanIssue(issues, "claude_project_dir_read_failed", Math.max(0, claudeAttribution.project_dir_read_failed_count -
|
|
57
|
+
(projectsDirMissing ? 1 : 0)), "global");
|
|
58
|
+
countScanIssue(issues, "claude_session_stat_failed", claudeAttribution.session_stat_failed_count, "global");
|
|
59
|
+
countScanIssue(issues, "claude_sidecar_stat_failed", claudeAttribution.sidecar_stat_failed_count, "global");
|
|
60
|
+
countScanIssue(issues, "claude_sidecar_dir_read_failed", await countUnreadableClaudeSidecarDirs(candidates), "global");
|
|
61
|
+
}
|
|
62
|
+
/** What is wrong with the sessions that did survive discovery, plus the one we chose to drop. */
|
|
63
|
+
function addCandidateScanIssues(issues, candidates, omittedCandidateCount) {
|
|
64
|
+
countScanIssue(issues, "backfill_max_files_applied", omittedCandidateCount, "selection");
|
|
65
|
+
countScanIssue(issues, "candidate_file_read_failed", candidates.filter((candidate) => candidate.reason === "file_read_failed")
|
|
66
|
+
.length, "candidate");
|
|
67
|
+
countScanIssue(issues, "candidate_worktree_unavailable", candidates.filter((candidate) => isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) && candidate.worktree === null).length, "candidate");
|
|
68
|
+
countScanIssue(issues, "claude_main_file_too_large", candidates.filter((candidate) => candidate.source === "claude_code" &&
|
|
69
|
+
candidate.claude?.main_file_oversized).length, "candidate");
|
|
70
|
+
countScanIssue(issues, "claude_sidecar_limit_applied", candidates.reduce((total, candidate) => total + (candidate.claude?.sidecars_capped ?? 0), 0), "candidate");
|
|
71
|
+
countScanIssue(issues, "claude_sidecar_file_unreadable", candidates.reduce((total, candidate) => total +
|
|
72
|
+
(candidate.claude?.sidecar_files.filter((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
73
|
+
sidecar.skipped_reason === "file_too_large").length ?? 0), 0), "candidate");
|
|
74
|
+
}
|
|
75
|
+
function scanIssuePriority(scope) {
|
|
76
|
+
if (scope === "global")
|
|
77
|
+
return 0;
|
|
78
|
+
if (scope === "candidate")
|
|
79
|
+
return 1;
|
|
80
|
+
return 2;
|
|
81
|
+
}
|
|
82
|
+
export function retryableCandidateKeys(candidates) {
|
|
83
|
+
return new Set(candidates
|
|
84
|
+
.filter((candidate) => candidate.reason === "file_read_failed" ||
|
|
85
|
+
candidate.reason === "repo_not_on_disk" ||
|
|
86
|
+
(isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null) &&
|
|
87
|
+
!candidate.worktree) ||
|
|
88
|
+
Boolean(candidate.claude?.main_file_oversized) ||
|
|
89
|
+
(candidate.claude?.sidecars_capped ?? 0) > 0 ||
|
|
90
|
+
Boolean(candidate.claude?.sidecar_files.some((sidecar) => sidecar.skipped_reason === "file_read_failed" ||
|
|
91
|
+
sidecar.skipped_reason === "file_too_large")))
|
|
92
|
+
.map(candidateCursorKey));
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* A main session file whose only story is "too large to upload under the
|
|
96
|
+
* current cap" (BLI-2727). This mirrors exactly the two branches in
|
|
97
|
+
* `countReadOnlyGuards` that emit the `file_too_large` reason, so a candidate
|
|
98
|
+
* is in this set if and only if it contributed to that scan issue's count —
|
|
99
|
+
* one predicate, no drift between "why the issue fired" and "which candidate
|
|
100
|
+
* caused it". Deterministic and non-retryable: rerunning backfill cannot
|
|
101
|
+
* resolve it (only a larger cap or a smaller file can), so unlike a transient
|
|
102
|
+
* read failure it must never poison completion or a batch's success.
|
|
103
|
+
*/
|
|
104
|
+
export function oversizedBackfillCandidateKeys(candidates) {
|
|
105
|
+
const keys = new Set();
|
|
106
|
+
for (const candidate of candidates) {
|
|
107
|
+
if ((candidate.source === "claude_code" &&
|
|
108
|
+
candidate.claude?.main_file_oversized) ||
|
|
109
|
+
candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
110
|
+
keys.add(candidateCursorKey(candidate));
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return keys;
|
|
114
|
+
}
|
|
115
|
+
// --- Scan issue ledger --------------------------------------------------
|
|
116
|
+
//
|
|
117
|
+
// `scan.issues` is the running ledger of everything the scan could not
|
|
118
|
+
// account for cleanly. Every write to it goes through one of the named
|
|
119
|
+
// operations below instead of a bare `.push`/`.sort`, so the ledger's shape
|
|
120
|
+
// (global vs candidate vs selection scope, priority order) has one owner.
|
|
121
|
+
/** Record one issue on the ledger. */
|
|
122
|
+
function addScanIssue(issues, issue) {
|
|
123
|
+
issues.push(issue);
|
|
124
|
+
}
|
|
125
|
+
/** Record one issue only if it actually happened; a zero count is not a finding. */
|
|
126
|
+
function countScanIssue(issues, reason, count, scope) {
|
|
127
|
+
if (count > 0)
|
|
128
|
+
addScanIssue(issues, { reason, count, scope });
|
|
129
|
+
}
|
|
130
|
+
/** Repo discovery could not fully enumerate a root: one global issue per reason. */
|
|
131
|
+
export function addRepoDiscoveryIssues(issues, incompleteReasons) {
|
|
132
|
+
for (const reason of incompleteReasons) {
|
|
133
|
+
addScanIssue(issues, {
|
|
134
|
+
reason: `repo_discovery_${reason}`,
|
|
135
|
+
count: 1,
|
|
136
|
+
scope: "global",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
/** Stable read order: global issues first, then candidate, then selection; alphabetical within a scope. */
|
|
141
|
+
export function sortScanIssuesByPriority(issues) {
|
|
142
|
+
issues.sort((a, b) => scanIssuePriority(a.scope) - scanIssuePriority(b.scope) ||
|
|
143
|
+
a.reason.localeCompare(b.reason));
|
|
144
|
+
}
|
|
145
|
+
/** The read-only guard pass's two aggregate outcomes, each recorded once if it fired at all. */
|
|
146
|
+
export function addReadOnlyGuardIssues(issues, guardCounts) {
|
|
147
|
+
const readFailed = guardCounts.get("file_read_failed");
|
|
148
|
+
if (readFailed) {
|
|
149
|
+
addScanIssue(issues, {
|
|
150
|
+
reason: "candidate_file_read_failed",
|
|
151
|
+
count: readFailed,
|
|
152
|
+
scope: "candidate",
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
const tooLarge = guardCounts.get("file_too_large");
|
|
156
|
+
if (tooLarge) {
|
|
157
|
+
addScanIssue(issues, {
|
|
158
|
+
reason: "file_too_large",
|
|
159
|
+
count: tooLarge,
|
|
160
|
+
scope: "candidate",
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// BLI-2727: a deterministic, labeled oversized skip must never poison
|
|
165
|
+
// completion — it is a permanent, non-retryable fact about the file, not an
|
|
166
|
+
// in-flight problem a rerun can fix. `scan.issues`/`retryable_candidate_keys`
|
|
167
|
+
// still carry it (so it's never silently dropped from reporting); every
|
|
168
|
+
// completion-gating computation excludes it explicitly instead, by filtering
|
|
169
|
+
// through `blockingScanIssues` below.
|
|
170
|
+
//
|
|
171
|
+
// Two scan issues describe the exact same oversized-main candidates:
|
|
172
|
+
// `backfillScanIssues` pushes the Claude-specific `claude_main_file_too_large`
|
|
173
|
+
// (from `claude?.main_file_oversized`) and `countReadOnlyGuards` (via
|
|
174
|
+
// `addReadOnlyGuardIssues` above) separately pushes the source-agnostic
|
|
175
|
+
// `file_too_large` (same predicate as `oversizedBackfillCandidateKeys`, so
|
|
176
|
+
// this list can never drift from it). Both must be excluded from
|
|
177
|
+
// completion-gating together.
|
|
178
|
+
const OVERSIZED_SCAN_ISSUE_REASONS = new Set([
|
|
179
|
+
"file_too_large",
|
|
180
|
+
"claude_main_file_too_large",
|
|
181
|
+
]);
|
|
182
|
+
export function blockingScanIssues(issues) {
|
|
183
|
+
return issues.filter((issue) => !OVERSIZED_SCAN_ISSUE_REASONS.has(issue.reason));
|
|
184
|
+
}
|
|
185
|
+
async function countUnreadableClaudeSidecarDirs(candidates) {
|
|
186
|
+
let unreadable = 0;
|
|
187
|
+
for (const candidate of candidates) {
|
|
188
|
+
if (candidate.source !== "claude_code")
|
|
189
|
+
continue;
|
|
190
|
+
const subagentsDir = path.join(path.dirname(candidate.file_path), path.basename(candidate.file_path).replace(/\.jsonl$/i, ""), "subagents");
|
|
191
|
+
try {
|
|
192
|
+
await fs.readdir(subagentsDir);
|
|
193
|
+
}
|
|
194
|
+
catch (error) {
|
|
195
|
+
if (!isMissingFsError(error))
|
|
196
|
+
unreadable += 1;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
return unreadable;
|
|
200
|
+
}
|
|
201
|
+
async function isMissingPath(filePath) {
|
|
202
|
+
try {
|
|
203
|
+
await fs.stat(filePath);
|
|
204
|
+
return false;
|
|
205
|
+
}
|
|
206
|
+
catch (error) {
|
|
207
|
+
return isMissingFsError(error);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
function isMissingFsError(error) {
|
|
211
|
+
if (!error || typeof error !== "object")
|
|
212
|
+
return false;
|
|
213
|
+
const code = error.code;
|
|
214
|
+
return code === "ENOENT" || code === "ENOTDIR";
|
|
215
|
+
}
|
|
216
|
+
export async function countReadOnlyGuards(candidates) {
|
|
217
|
+
const counts = new Map();
|
|
218
|
+
const retryableCandidateKeys = new Set();
|
|
219
|
+
// Aggregated: this runs over the whole archived history, so a per-candidate
|
|
220
|
+
// line could be thousands. The count already travels; the reason did not
|
|
221
|
+
// (BLI-3238).
|
|
222
|
+
let firstReadFailure = null;
|
|
223
|
+
for (const candidate of candidates) {
|
|
224
|
+
if (candidate.reason === "repo_not_on_disk") {
|
|
225
|
+
increment(counts, "repo_not_on_disk");
|
|
226
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
227
|
+
}
|
|
228
|
+
if (candidate.source === "claude_code" && candidate.claude?.main_file_oversized) {
|
|
229
|
+
increment(counts, "file_too_large");
|
|
230
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
if (candidate.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES) {
|
|
234
|
+
increment(counts, "file_too_large");
|
|
235
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
if (!isRawEvidenceUploadableAttributionState(candidate.state, candidate.worktree !== null))
|
|
239
|
+
continue;
|
|
240
|
+
try {
|
|
241
|
+
await fs.readFile(candidate.file_path);
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
increment(counts, "file_read_failed");
|
|
245
|
+
firstReadFailure ??= describeError(error);
|
|
246
|
+
retryableCandidateKeys.add(candidateCursorKey(candidate));
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
const readFailedCount = counts.get("file_read_failed") ?? 0;
|
|
250
|
+
if (readFailedCount > 0) {
|
|
251
|
+
console.error("[cockpit-backfill] archived sessions could not be read", JSON.stringify({
|
|
252
|
+
reason: "file_read_failed",
|
|
253
|
+
read_failed_count: readFailedCount,
|
|
254
|
+
candidate_count: candidates.length,
|
|
255
|
+
...firstReadFailure,
|
|
256
|
+
}));
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
counts,
|
|
260
|
+
retryable_candidate_keys: retryableCandidateKeys,
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
export function increment(counts, key) {
|
|
264
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
265
|
+
}
|