@bli-cockpit/cli 0.2.48 → 0.2.50
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-attribution-gaps.js +133 -0
- 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 +94 -1203
- package/dist/autostart-contract.js +79 -0
- package/dist/autostart-darwin-plist.js +265 -0
- package/dist/autostart-darwin.js +171 -0
- package/dist/autostart-windows-scripts.js +310 -0
- package/dist/autostart-windows-task-xml.js +260 -0
- package/dist/autostart-windows.js +237 -0
- package/dist/autostart-xml.js +23 -0
- package/dist/autostart.js +35 -1148
- package/dist/commands/agent-rules-command.js +55 -0
- package/dist/commands/agent-session-report.js +290 -0
- package/dist/commands/analyze.js +131 -0
- package/dist/commands/autostart-command.js +105 -0
- 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 +58 -1705
- package/dist/commands/cli-io.js +13 -0
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +323 -7
- package/dist/commands/local-arg-values.js +169 -0
- package/dist/commands/local-args-collector.js +604 -0
- package/dist/commands/local-args-tower.js +891 -0
- package/dist/commands/local-args.js +10 -1549
- package/dist/commands/local-help.js +30 -5
- package/dist/commands/local.js +21 -1786
- package/dist/commands/login.js +53 -0
- package/dist/commands/logout.js +66 -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 +231 -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 +378 -0
- package/dist/commands/onboard-receipts.js +66 -0
- package/dist/commands/onboard-report.js +274 -0
- package/dist/commands/onboard.js +449 -0
- package/dist/commands/ops-render.js +36 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/serve.js +13 -0
- package/dist/commands/session-sync.js +513 -534
- package/dist/commands/settings-render.js +28 -0
- package/dist/commands/settings.js +66 -2
- package/dist/commands/start.js +47 -0
- package/dist/commands/sync-followups.js +308 -0
- package/dist/commands/sync.js +387 -0
- 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 +76 -6
- package/package.json +2 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit agent-rules install|uninstall|status` — the ticket-binding guidance
|
|
3
|
+
* Tower writes into a machine's `AGENTS.md` and `CLAUDE.md`.
|
|
4
|
+
*
|
|
5
|
+
* Split out of commands/local.ts (BLI-3578); the files themselves are written
|
|
6
|
+
* by `../agent-rules.ts`. This module is the command: which hosts a verb
|
|
7
|
+
* touches, and the lines it prints. Moved verbatim. `agentRuleHostLabel` is
|
|
8
|
+
* shared with onboarding, which installs the same rules on its way through.
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { writeLine } from "./cli-io.js";
|
|
12
|
+
import { inspectAgentRules, installAgentRules, uninstallAgentRules, } from "../agent-rules.js";
|
|
13
|
+
export async function runAgentRules(command, io) {
|
|
14
|
+
const hosts = agentRuleHosts(command.host);
|
|
15
|
+
const scopePath = path.resolve(command.repoRoot ?? process.cwd());
|
|
16
|
+
const result = command.action === "install"
|
|
17
|
+
? await installAgentRules({ homeDir: command.homeDir, hosts, scopePath })
|
|
18
|
+
: command.action === "uninstall"
|
|
19
|
+
? await uninstallAgentRules({ homeDir: command.homeDir, hosts })
|
|
20
|
+
: await inspectAgentRules({ homeDir: command.homeDir, hosts, scopePath });
|
|
21
|
+
if (command.json) {
|
|
22
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if ("installed" in result) {
|
|
26
|
+
writeLine(io.stdout, result.installed
|
|
27
|
+
? "Tower agent rules are installed."
|
|
28
|
+
: "Tower agent rules are not installed.");
|
|
29
|
+
}
|
|
30
|
+
else {
|
|
31
|
+
writeLine(io.stdout, agentRulesStatusLine(result));
|
|
32
|
+
}
|
|
33
|
+
for (const target of result.targets) {
|
|
34
|
+
writeLine(io.stdout, `${agentRuleHostLabel(target.host)}: ${target.rules_file}`);
|
|
35
|
+
}
|
|
36
|
+
return 0;
|
|
37
|
+
}
|
|
38
|
+
function agentRuleHosts(host) {
|
|
39
|
+
return host === "all" ? ["codex", "claude"] : [host];
|
|
40
|
+
}
|
|
41
|
+
function agentRulesStatusLine(result) {
|
|
42
|
+
switch (result.status) {
|
|
43
|
+
case "created":
|
|
44
|
+
return "Tower agent rules installed.";
|
|
45
|
+
case "updated":
|
|
46
|
+
return "Tower agent rules updated.";
|
|
47
|
+
case "unchanged":
|
|
48
|
+
return "Tower agent rules already current.";
|
|
49
|
+
case "missing":
|
|
50
|
+
return "Tower agent rules were not installed.";
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
export function agentRuleHostLabel(host) {
|
|
54
|
+
return host === "claude" ? "CLAUDE.md" : "AGENTS.md";
|
|
55
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What one sync run REPORTS: the per-session attribution rows posted to the
|
|
3
|
+
* door, and the funnel counts the CLI prints.
|
|
4
|
+
*
|
|
5
|
+
* Split out of commands/session-sync.ts (BLI-3572) because these are pure
|
|
6
|
+
* projections — attribution results plus upload outcomes in, rows and counts
|
|
7
|
+
* out, no I/O and no ordering — while the engine next door is nothing but I/O
|
|
8
|
+
* and ordering. Behavior-preserving move: no logic changed, and every name is
|
|
9
|
+
* still re-exported from `./session-sync.js` for callers that had it there.
|
|
10
|
+
*/
|
|
11
|
+
import { CODEX_SESSION_ATTRIBUTION_STATE_RANK, NO_UPLOAD_ATTEMPT_RECORDED, notUploadableAttributionStateReason, } from "@bli-cockpit/telemetry-core";
|
|
12
|
+
import { isLiveRawEvidenceSyncAttribution, isRawEvidenceUploadableAttributionState, } from "../raw-evidence-attribution-policy.js";
|
|
13
|
+
/** How many Codex sessions the scan could not read at all. */
|
|
14
|
+
export function codexAttributionReadFailureCount(scan) {
|
|
15
|
+
return scan.directory_read_failed_count + scan.stat_failed_count;
|
|
16
|
+
}
|
|
17
|
+
/** How many Claude sessions or sidecars the scan could not read at all. */
|
|
18
|
+
export function claudeAttributionReadFailureCount(scan) {
|
|
19
|
+
return (scan.project_dir_read_failed_count +
|
|
20
|
+
scan.session_stat_failed_count +
|
|
21
|
+
scan.sidecar_dir_read_failed_count +
|
|
22
|
+
scan.sidecar_stat_failed_count);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* How good each attribution state is, for picking one row per session when the
|
|
26
|
+
* same session was attributed more than once. Re-exported under the collector's
|
|
27
|
+
* own name so callers do not have to reach into telemetry-core for it.
|
|
28
|
+
*/
|
|
29
|
+
export const ATTRIBUTION_STATE_RANK = CODEX_SESSION_ATTRIBUTION_STATE_RANK;
|
|
30
|
+
function normalizeCodexResult(result) {
|
|
31
|
+
return {
|
|
32
|
+
source: "codex",
|
|
33
|
+
session_id: result.codex_session_id,
|
|
34
|
+
state: result.state,
|
|
35
|
+
reason: result.reason,
|
|
36
|
+
signals: result.signals,
|
|
37
|
+
attribution_score: result.attribution_score,
|
|
38
|
+
path_score: result.path_score,
|
|
39
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
40
|
+
byte_size: result.byte_size,
|
|
41
|
+
session_file_mtime: result.session_file_mtime,
|
|
42
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
43
|
+
worktree: result.worktree,
|
|
44
|
+
cwd_basename: result.cwd_basename,
|
|
45
|
+
cwd_hash: result.cwd_hash,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
function normalizeClaudeResult(result) {
|
|
49
|
+
return {
|
|
50
|
+
source: "claude_code",
|
|
51
|
+
session_id: result.claude_session_id,
|
|
52
|
+
state: result.state,
|
|
53
|
+
reason: result.reason,
|
|
54
|
+
signals: result.signals,
|
|
55
|
+
attribution_score: result.attribution_score,
|
|
56
|
+
path_score: result.path_score,
|
|
57
|
+
content_hash_sha256: result.content_hash_sha256,
|
|
58
|
+
byte_size: result.byte_size,
|
|
59
|
+
session_file_mtime: result.session_file_mtime,
|
|
60
|
+
session_file_mtime_ms: result.session_file_mtime_ms,
|
|
61
|
+
worktree: result.worktree,
|
|
62
|
+
cwd_basename: result.cwd_basename,
|
|
63
|
+
cwd_hash: result.cwd_hash,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Dedupe every source's attributed results down to the single best result per
|
|
68
|
+
* `(source, session_id)`. Best means highest attribution-state rank, ties
|
|
69
|
+
* broken by the newer file mtime. Codex and Claude sessions that happen to
|
|
70
|
+
* share an id are never collapsed into each other because the key carries the
|
|
71
|
+
* source.
|
|
72
|
+
*/
|
|
73
|
+
function bestAttributedSessionsByKey(codexResults, claudeResults) {
|
|
74
|
+
const normalized = [
|
|
75
|
+
...codexResults.map(normalizeCodexResult),
|
|
76
|
+
...claudeResults.map(normalizeClaudeResult),
|
|
77
|
+
];
|
|
78
|
+
const bestByKey = new Map();
|
|
79
|
+
for (const result of normalized) {
|
|
80
|
+
const key = `${result.source}:${result.session_id}`;
|
|
81
|
+
const existing = bestByKey.get(key);
|
|
82
|
+
if (!existing ||
|
|
83
|
+
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
84
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
85
|
+
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
86
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
87
|
+
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
88
|
+
bestByKey.set(key, result);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return bestByKey;
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Which sessions this sync actually uploaded, and why the rest did not — read
|
|
95
|
+
* ONLY from main-file outcomes (kind `codex_jsonl` / `claude_jsonl`); a
|
|
96
|
+
* sidecar making it must never mark a session uploaded when the main did not
|
|
97
|
+
* (D3).
|
|
98
|
+
*/
|
|
99
|
+
function sessionUploadOutcomesByKey(outcomes) {
|
|
100
|
+
const uploadByKey = new Map();
|
|
101
|
+
const noUploadReasonByKey = new Map();
|
|
102
|
+
let anySyncIncomplete = false;
|
|
103
|
+
for (const outcome of outcomes) {
|
|
104
|
+
if (outcome.sync.status !== "uploaded") {
|
|
105
|
+
anySyncIncomplete = true;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
109
|
+
if (!upload.codex_session_id)
|
|
110
|
+
continue;
|
|
111
|
+
const source = upload.kind === "claude_jsonl"
|
|
112
|
+
? "claude_code"
|
|
113
|
+
: upload.kind === "codex_jsonl"
|
|
114
|
+
? "codex"
|
|
115
|
+
: null;
|
|
116
|
+
if (!source)
|
|
117
|
+
continue; // sidecars and other kinds do not set session state
|
|
118
|
+
const key = `${source}:${upload.codex_session_id}`;
|
|
119
|
+
if (!upload.raw_evidence_pointer_id) {
|
|
120
|
+
// An outcome with no pointer is a failure that named itself. Keep the
|
|
121
|
+
// reason even though there is nothing to point at.
|
|
122
|
+
if (upload.reason)
|
|
123
|
+
noUploadReasonByKey.set(key, upload.reason);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
uploadByKey.set(key, upload);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
return { uploadByKey, noUploadReasonByKey, anySyncIncomplete };
|
|
130
|
+
}
|
|
131
|
+
/** Turn one deduped, best-ranked session result into its report row. */
|
|
132
|
+
function sessionReportEntry(result, context) {
|
|
133
|
+
const key = `${result.source}:${result.session_id}`;
|
|
134
|
+
const upload = context.uploadByKey.get(key);
|
|
135
|
+
// A previously-durable Claude session with no fresh main upload this sync
|
|
136
|
+
// (damped / spooled / budget-deferred) reports reused_existing + its prior
|
|
137
|
+
// pointer rather than not_uploaded, so the store row never flips.
|
|
138
|
+
const priorDurablePointer = result.source === "claude_code"
|
|
139
|
+
? (context.claudePriorDurablePointers.get(result.session_id) ?? null)
|
|
140
|
+
: null;
|
|
141
|
+
return {
|
|
142
|
+
codex_session_id: result.session_id,
|
|
143
|
+
source: result.source,
|
|
144
|
+
observed_at: context.now.toISOString(),
|
|
145
|
+
attribution_state: result.state,
|
|
146
|
+
attribution_reason: result.reason,
|
|
147
|
+
attribution_score: result.attribution_score,
|
|
148
|
+
path_score: result.path_score,
|
|
149
|
+
signals: result.signals,
|
|
150
|
+
...(result.content_hash_sha256
|
|
151
|
+
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
152
|
+
: {}),
|
|
153
|
+
session_file_byte_size: result.byte_size,
|
|
154
|
+
session_file_mtime: result.session_file_mtime,
|
|
155
|
+
...(result.worktree
|
|
156
|
+
? {
|
|
157
|
+
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
158
|
+
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
159
|
+
repo_label: result.worktree.repo_label,
|
|
160
|
+
branch: result.worktree.branch,
|
|
161
|
+
}
|
|
162
|
+
: {}),
|
|
163
|
+
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
164
|
+
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
165
|
+
...(upload
|
|
166
|
+
? {
|
|
167
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
168
|
+
upload_state: upload.upload_state,
|
|
169
|
+
// A failed upload names itself; a successful one has nothing to
|
|
170
|
+
// explain.
|
|
171
|
+
...(upload.upload_state === "upload_failed"
|
|
172
|
+
? {
|
|
173
|
+
upload_reason: upload.reason ?? NO_UPLOAD_ATTEMPT_RECORDED,
|
|
174
|
+
}
|
|
175
|
+
: {}),
|
|
176
|
+
}
|
|
177
|
+
: priorDurablePointer
|
|
178
|
+
? {
|
|
179
|
+
raw_evidence_pointer_id: priorDurablePointer,
|
|
180
|
+
upload_state: "reused_existing",
|
|
181
|
+
}
|
|
182
|
+
: isRawEvidenceUploadableAttributionState(result.state, result.worktree !== null)
|
|
183
|
+
? {
|
|
184
|
+
upload_state: "not_uploaded",
|
|
185
|
+
// BLI-2107: `not_uploaded` used to be the branch of last
|
|
186
|
+
// resort, recording that nothing happened and never why. It
|
|
187
|
+
// now always carries a cause, even when the cause is that we
|
|
188
|
+
// have none — a session labelled NO_UPLOAD_ATTEMPT_RECORDED
|
|
189
|
+
// is a path that still needs instrumenting, and saying so is
|
|
190
|
+
// the point.
|
|
191
|
+
upload_reason: context.noUploadReasonByKey.get(key) ??
|
|
192
|
+
(context.anySyncIncomplete
|
|
193
|
+
? "sync_incomplete_this_pass"
|
|
194
|
+
: NO_UPLOAD_ATTEMPT_RECORDED),
|
|
195
|
+
}
|
|
196
|
+
: {
|
|
197
|
+
// BLI-3272: the upload policy refused this session's
|
|
198
|
+
// attribution state. That refusal used to spread `{}` here, so
|
|
199
|
+
// the row landed with upload_state NULL and upload_reason NULL
|
|
200
|
+
// — a withhold that named nothing, on 1,256 production
|
|
201
|
+
// sessions. It is a decision like any other and it says so.
|
|
202
|
+
// Any reason the pipeline did record still wins: it is the more
|
|
203
|
+
// specific answer.
|
|
204
|
+
upload_state: "not_uploaded",
|
|
205
|
+
upload_reason: context.noUploadReasonByKey.get(key) ??
|
|
206
|
+
notUploadableAttributionStateReason(result.reason),
|
|
207
|
+
}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Generalizes the per-session report across sources. Dedupe is per
|
|
212
|
+
* `(source, session_id)` so a Codex session and a Claude session that happen to
|
|
213
|
+
* share an id are never collapsed. Upload state maps ONLY from the main-file
|
|
214
|
+
* outcome (kind `codex_jsonl` / `claude_jsonl`); sidecar outcomes never set a
|
|
215
|
+
* session's upload state (D3). Damped Claude sessions report `reused_existing`
|
|
216
|
+
* carrying their prior durable pointer.
|
|
217
|
+
*
|
|
218
|
+
* The pass, named: pick the best-ranked result per session
|
|
219
|
+
* (`bestAttributedSessionsByKey`), read back what this sync actually uploaded
|
|
220
|
+
* (`sessionUploadOutcomesByKey`), then render one report row per session
|
|
221
|
+
* (`sessionReportEntry`).
|
|
222
|
+
*/
|
|
223
|
+
export function buildAgentSessionReport(options) {
|
|
224
|
+
const bestByKey = bestAttributedSessionsByKey(options.codexResults, options.claudeResults);
|
|
225
|
+
const uploadOutcomes = sessionUploadOutcomesByKey(options.outcomes);
|
|
226
|
+
return [...bestByKey.values()].map((result) => sessionReportEntry(result, {
|
|
227
|
+
...uploadOutcomes,
|
|
228
|
+
claudePriorDurablePointers: options.claudePriorDurablePointers,
|
|
229
|
+
now: options.now,
|
|
230
|
+
}));
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* The funnel one sync run prints: per-source scan counts, sidecar and
|
|
234
|
+
* damping counts, and the two budget totals.
|
|
235
|
+
*/
|
|
236
|
+
export function buildAgentSessionSummary(options) {
|
|
237
|
+
const sidecarOutcomes = options.outcomes.flatMap((outcome) => outcome.sync.raw_evidence_outcomes.filter((upload) => upload.kind === "claude_jsonl_sidecar"));
|
|
238
|
+
const attributedClaude = options.claudeAttribution.results.filter((result) => isLiveRawEvidenceSyncAttribution(result.state, result.reason));
|
|
239
|
+
const sidecarsCollected = attributedClaude.reduce((total, result) => total +
|
|
240
|
+
result.sidecar_files.filter((sidecar) => !sidecar.skipped_reason).length, 0);
|
|
241
|
+
const sidecarsSkipped = options.claudeAttribution.results.reduce((total, result) => total +
|
|
242
|
+
result.sidecar_files.filter((sidecar) => sidecar.skipped_reason).length, 0);
|
|
243
|
+
const codex = {
|
|
244
|
+
scanned: options.codexAttribution.scanned_file_count,
|
|
245
|
+
attributed: options.codexAttribution.counts.attributed,
|
|
246
|
+
attributed_fallback: options.codexAttribution.counts.attributed_fallback,
|
|
247
|
+
ambiguous: options.codexAttribution.counts.ambiguous,
|
|
248
|
+
unattributed: options.codexAttribution.counts.unattributed,
|
|
249
|
+
skipped: options.codexAttribution.counts.skipped,
|
|
250
|
+
stale: options.codexStaleCount,
|
|
251
|
+
read_failures: codexAttributionReadFailureCount(options.codexAttribution),
|
|
252
|
+
};
|
|
253
|
+
const claude = {
|
|
254
|
+
scanned: options.claudeAttribution.scanned_session_count,
|
|
255
|
+
attributed: options.claudeAttribution.counts.attributed,
|
|
256
|
+
attributed_fallback: options.claudeAttribution.counts.attributed_fallback,
|
|
257
|
+
ambiguous: options.claudeAttribution.counts.ambiguous,
|
|
258
|
+
unattributed: options.claudeAttribution.counts.unattributed,
|
|
259
|
+
skipped: options.claudeAttribution.counts.skipped,
|
|
260
|
+
stale: options.claudeStaleCount,
|
|
261
|
+
read_failures: claudeAttributionReadFailureCount(options.claudeAttribution),
|
|
262
|
+
sidecars_collected: sidecarsCollected,
|
|
263
|
+
sidecars_uploaded: sidecarOutcomes.filter((upload) => upload.upload_state === "uploaded" ||
|
|
264
|
+
upload.upload_state === "reused_existing").length,
|
|
265
|
+
sidecars_skipped: sidecarsSkipped,
|
|
266
|
+
sidecars_capped: options.claudeAttribution.counts.sidecars_capped,
|
|
267
|
+
sidecars_failed: sidecarOutcomes.filter((upload) => upload.upload_state === "upload_failed").length,
|
|
268
|
+
mains_oversized: options.claudeAttribution.counts.mains_oversized,
|
|
269
|
+
oversized_lines_skipped: options.claudeAttribution.counts.oversized_lines_skipped,
|
|
270
|
+
project_dirs_skipped: options.claudeAttribution.project_dirs_skipped,
|
|
271
|
+
sessions_schema_drift: options.claudeAttribution.counts.sessions_schema_drift,
|
|
272
|
+
growth_damped: options.growthDamped,
|
|
273
|
+
first_run_backfill: options.firstRunBackfill,
|
|
274
|
+
};
|
|
275
|
+
return {
|
|
276
|
+
scanned: codex.scanned,
|
|
277
|
+
attributed: codex.attributed,
|
|
278
|
+
attributed_fallback: codex.attributed_fallback,
|
|
279
|
+
ambiguous: codex.ambiguous,
|
|
280
|
+
unattributed: codex.unattributed,
|
|
281
|
+
skipped: codex.skipped,
|
|
282
|
+
stale: codex.stale,
|
|
283
|
+
report_posted: options.report.posted,
|
|
284
|
+
report_reason: options.report.reason,
|
|
285
|
+
codex,
|
|
286
|
+
claude,
|
|
287
|
+
files_deferred_byte_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_byte_budget, 0),
|
|
288
|
+
files_deferred_object_budget: options.outcomes.reduce((total, outcome) => total + outcome.sync.raw_evidence_deferred_object_budget, 0),
|
|
289
|
+
};
|
|
290
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { bufferedWritable, parseCapturedJson, replayCaptured, writeLine, } from "./cli-io.js";
|
|
2
|
+
import { normalizeUrl } from "./local-args.js";
|
|
3
|
+
import { runSync } from "./sync.js";
|
|
4
|
+
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
5
|
+
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
6
|
+
export async function runAnalyze(command, io) {
|
|
7
|
+
const syncStdout = [];
|
|
8
|
+
const syncStderr = [];
|
|
9
|
+
const syncIo = {
|
|
10
|
+
...io,
|
|
11
|
+
stdout: bufferedWritable(syncStdout),
|
|
12
|
+
stderr: bufferedWritable(syncStderr),
|
|
13
|
+
};
|
|
14
|
+
const syncCommand = {
|
|
15
|
+
...command,
|
|
16
|
+
kind: "sync",
|
|
17
|
+
json: true,
|
|
18
|
+
};
|
|
19
|
+
const syncExitCode = await runSync(syncCommand, syncIo);
|
|
20
|
+
const syncOutput = parseCapturedJson(syncStdout);
|
|
21
|
+
if (syncExitCode !== 0 || !isUploadedSyncResult(syncOutput)) {
|
|
22
|
+
if (command.json) {
|
|
23
|
+
writeLine(io.stdout, JSON.stringify({
|
|
24
|
+
status: syncExitCode === 0 ? "sync_not_completed" : "sync_failed",
|
|
25
|
+
sync: syncOutput,
|
|
26
|
+
error: syncStderr.join("").trim()
|
|
27
|
+
|| (syncExitCode === 0
|
|
28
|
+
? "Tower sync did not upload fresh evidence."
|
|
29
|
+
: "Tower sync failed."),
|
|
30
|
+
}, null, 2));
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
replayCaptured(io.stderr, syncStderr);
|
|
34
|
+
writeLine(io.stderr, syncExitCode === 0
|
|
35
|
+
? "Analysis was not queued because a fresh upload did not complete."
|
|
36
|
+
: "Analysis was not queued because the latest upload failed.");
|
|
37
|
+
}
|
|
38
|
+
return syncExitCode === 0 ? 1 : syncExitCode;
|
|
39
|
+
}
|
|
40
|
+
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
41
|
+
const session = await readLocalCollectorSessionFile(paths).catch((error) => {
|
|
42
|
+
// "Not signed in" is the right sentence for an absent session file and the
|
|
43
|
+
// wrong one for a corrupt one, which `cockpit login` will not repair
|
|
44
|
+
// (BLI-3238).
|
|
45
|
+
if (!isMissingFileFailure(error)) {
|
|
46
|
+
console.error("[cockpit-analyze] session file present but unreadable, reporting as not signed in", JSON.stringify({
|
|
47
|
+
reason: "session_file_unusable",
|
|
48
|
+
...describeError(error),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
throw new Error("Tower is not signed in. Run `cockpit onboard` or `cockpit login` first.");
|
|
52
|
+
});
|
|
53
|
+
const dashboardUrl = normalizeUrl(command.dashboardUrl ?? session.dashboard_url ?? DEFAULT_DASHBOARD_URL);
|
|
54
|
+
const response = await io.fetch(`${dashboardUrl}/api/ambient/analyze`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: {
|
|
57
|
+
authorization: `Bearer ${session.device_token}`,
|
|
58
|
+
"content-type": "application/json",
|
|
59
|
+
},
|
|
60
|
+
body: "{}",
|
|
61
|
+
});
|
|
62
|
+
const body = await readAnalyzeApiResponse(response);
|
|
63
|
+
if (!response.ok) {
|
|
64
|
+
const message = typeof body.message === "string"
|
|
65
|
+
? body.message
|
|
66
|
+
: `Analysis request failed with HTTP ${response.status}.`;
|
|
67
|
+
if (command.json) {
|
|
68
|
+
writeLine(io.stdout, JSON.stringify({
|
|
69
|
+
status: "analyze_failed",
|
|
70
|
+
sync: syncOutput,
|
|
71
|
+
http_status: response.status,
|
|
72
|
+
code: typeof body.code === "string" ? body.code : null,
|
|
73
|
+
message,
|
|
74
|
+
retry_after_seconds: typeof body.retry_after_seconds === "number"
|
|
75
|
+
? body.retry_after_seconds
|
|
76
|
+
: null,
|
|
77
|
+
}, null, 2));
|
|
78
|
+
}
|
|
79
|
+
else {
|
|
80
|
+
replayCaptured(io.stderr, syncStderr);
|
|
81
|
+
writeLine(io.stderr, `Analysis was not queued: ${message}`);
|
|
82
|
+
}
|
|
83
|
+
return 1;
|
|
84
|
+
}
|
|
85
|
+
const jobId = typeof body.job?.id === "string" ? body.job.id : null;
|
|
86
|
+
const jobStatus = typeof body.job?.status === "string" ? body.job.status : "pending";
|
|
87
|
+
if (command.json) {
|
|
88
|
+
writeLine(io.stdout, JSON.stringify({
|
|
89
|
+
status: "queued",
|
|
90
|
+
sync: syncOutput,
|
|
91
|
+
job: body.job ?? null,
|
|
92
|
+
dashboard_url: `${dashboardUrl}/my-work`,
|
|
93
|
+
}, null, 2));
|
|
94
|
+
return 0;
|
|
95
|
+
}
|
|
96
|
+
replayCaptured(io.stderr, syncStderr);
|
|
97
|
+
writeLine(io.stdout, "Tower uploaded your latest work.");
|
|
98
|
+
writeLine(io.stdout, "Tower analysis queued.");
|
|
99
|
+
if (jobId)
|
|
100
|
+
writeLine(io.stdout, `Job: ${jobId}`);
|
|
101
|
+
writeLine(io.stdout, `Status: ${jobStatus === "pending" ? "queued" : jobStatus}`);
|
|
102
|
+
writeLine(io.stdout, `Results: ${dashboardUrl}/my-work`);
|
|
103
|
+
return 0;
|
|
104
|
+
}
|
|
105
|
+
async function readAnalyzeApiResponse(response) {
|
|
106
|
+
const text = await response.text();
|
|
107
|
+
if (!text)
|
|
108
|
+
return {};
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(text);
|
|
111
|
+
return value && typeof value === "object" ? value : {};
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// `{}` erases whatever the analyze endpoint said, including its error, and
|
|
115
|
+
// the operator is left with a job id of `undefined` and no reason. The
|
|
116
|
+
// body is never logged; its shape is what identifies a proxy reply.
|
|
117
|
+
console.error("[cockpit-analyze] reply was not JSON", JSON.stringify({
|
|
118
|
+
reason: "response_body_not_json",
|
|
119
|
+
http_status: response.status,
|
|
120
|
+
byte_size: text.length,
|
|
121
|
+
content_type: response.headers.get("content-type") ?? "none",
|
|
122
|
+
}));
|
|
123
|
+
return {};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
function isUploadedSyncResult(value) {
|
|
127
|
+
return Boolean(value
|
|
128
|
+
&& typeof value === "object"
|
|
129
|
+
&& !Array.isArray(value)
|
|
130
|
+
&& value["status"] === "uploaded");
|
|
131
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { defaultExec, writeLine } from "./cli-io.js";
|
|
2
|
+
import { runAutostartHealDetached } from "./autostart-heal.js";
|
|
3
|
+
import { autostartStatus, installAutostartAgent, uninstallAutostartAgent, } from "../autostart.js";
|
|
4
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig, } from "../local-state.js";
|
|
5
|
+
export async function runAutostart(command, io) {
|
|
6
|
+
if (command.action === "heal-detached") {
|
|
7
|
+
// BLI-3553: the internal one-shot the scheduled macOS tick spawns. It has
|
|
8
|
+
// its own receipt and its own exit code; nothing else in this function
|
|
9
|
+
// applies to it.
|
|
10
|
+
return runAutostartHealDetached({
|
|
11
|
+
homeDir: command.homeDir,
|
|
12
|
+
dashboardUrl: command.dashboardUrl,
|
|
13
|
+
parentPid: command.parentPid ?? null,
|
|
14
|
+
json: command.json,
|
|
15
|
+
}, io);
|
|
16
|
+
}
|
|
17
|
+
const exec = io.exec ?? defaultExec();
|
|
18
|
+
const repoRoots = command.action === "install" || command.action === "status"
|
|
19
|
+
? await resolveAutostartRoots(command.homeDir, command.repoRoot)
|
|
20
|
+
: [];
|
|
21
|
+
const result = command.action === "install"
|
|
22
|
+
? await installAutostartAgent({
|
|
23
|
+
homeDir: command.homeDir,
|
|
24
|
+
repoRoot: repoRoots[0],
|
|
25
|
+
repoRoots,
|
|
26
|
+
dashboardUrl: command.dashboardUrl,
|
|
27
|
+
intervalSeconds: command.intervalSeconds,
|
|
28
|
+
exec,
|
|
29
|
+
})
|
|
30
|
+
: command.action === "uninstall"
|
|
31
|
+
? await uninstallAutostartAgent({ homeDir: command.homeDir, exec })
|
|
32
|
+
: await autostartStatus({
|
|
33
|
+
homeDir: command.homeDir,
|
|
34
|
+
repoRoot: repoRoots[0],
|
|
35
|
+
repoRoots,
|
|
36
|
+
dashboardUrl: command.dashboardUrl,
|
|
37
|
+
intervalSeconds: command.intervalSeconds,
|
|
38
|
+
exec,
|
|
39
|
+
});
|
|
40
|
+
if (command.json) {
|
|
41
|
+
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
42
|
+
return autostartExitCode(command.action, result);
|
|
43
|
+
}
|
|
44
|
+
writeAutostartResult(io, result);
|
|
45
|
+
return autostartExitCode(command.action, result);
|
|
46
|
+
}
|
|
47
|
+
function autostartExitCode(action, result) {
|
|
48
|
+
if (result.status === "unsupported")
|
|
49
|
+
return 1;
|
|
50
|
+
if (action === "status")
|
|
51
|
+
return result.status === "loaded" ? 0 : 1;
|
|
52
|
+
if (action === "install" && result.loaded === false)
|
|
53
|
+
return 1;
|
|
54
|
+
if (action === "uninstall" && result.status === "not_loaded")
|
|
55
|
+
return 1;
|
|
56
|
+
return 0;
|
|
57
|
+
}
|
|
58
|
+
export async function resolveAutostartRoots(homeDir, explicitRoot) {
|
|
59
|
+
if (explicitRoot)
|
|
60
|
+
return [explicitRoot];
|
|
61
|
+
const config = await readLocalCollectorConfig(getCollectorRuntimePaths(homeDir)).catch(() => null);
|
|
62
|
+
return config?.default_repo_paths ?? [];
|
|
63
|
+
}
|
|
64
|
+
function writeAutostartResult(io, result) {
|
|
65
|
+
switch (result.status) {
|
|
66
|
+
case "installed":
|
|
67
|
+
writeLine(io.stdout, result.loaded
|
|
68
|
+
? "Tower autostart installed and loaded."
|
|
69
|
+
: "Tower autostart installed (the operating-system scheduler reported a problem).");
|
|
70
|
+
writeLine(io.stdout, `Label: ${result.label}`);
|
|
71
|
+
writeLine(io.stdout, autostartLocationLine(result));
|
|
72
|
+
writeLine(io.stdout, `Interval: every ${result.interval_seconds}s`);
|
|
73
|
+
writeLine(io.stdout, `Repo: ${result.work_dir}`);
|
|
74
|
+
writeLine(io.stdout, `Dashboard: ${result.dashboard_url}`);
|
|
75
|
+
if (!result.loaded && result.message)
|
|
76
|
+
writeLine(io.stderr, result.message);
|
|
77
|
+
return;
|
|
78
|
+
case "uninstalled":
|
|
79
|
+
writeLine(io.stdout, "Tower autostart removed.");
|
|
80
|
+
writeLine(io.stdout, autostartLocationLine(result));
|
|
81
|
+
return;
|
|
82
|
+
case "absent":
|
|
83
|
+
writeLine(io.stdout, "Tower autostart is not installed.");
|
|
84
|
+
writeLine(io.stdout, autostartLocationLine(result));
|
|
85
|
+
return;
|
|
86
|
+
case "loaded":
|
|
87
|
+
writeLine(io.stdout, "Tower autostart is installed and loaded.");
|
|
88
|
+
writeLine(io.stdout, autostartLocationLine(result));
|
|
89
|
+
return;
|
|
90
|
+
case "not_loaded":
|
|
91
|
+
writeLine(io.stdout, "Tower autostart exists but is disabled or not loaded; run `cockpit autostart install` to repair it.");
|
|
92
|
+
writeLine(io.stdout, autostartLocationLine(result));
|
|
93
|
+
if (result.message)
|
|
94
|
+
writeLine(io.stderr, result.message);
|
|
95
|
+
return;
|
|
96
|
+
case "unsupported":
|
|
97
|
+
writeLine(io.stderr, `Autostart unsupported: ${result.message}`);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
export function autostartLocationLine(result) {
|
|
102
|
+
return result.task_name
|
|
103
|
+
? `Task: ${result.task_name}`
|
|
104
|
+
: `Plist: ${result.plist_path}`;
|
|
105
|
+
}
|
|
@@ -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
|
+
}
|