@bli-cockpit/cli 0.1.3 → 0.1.6
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 +38 -7
- package/dist/adapters/codex-attribution.js +344 -0
- package/dist/adapters/local-sources.js +3 -0
- package/dist/adapters/raw-evidence.js +97 -17
- package/dist/commands/local.js +445 -22
- package/dist/cursors/raw-evidence-cursor.js +129 -0
- package/dist/evidence-upload-client.js +362 -0
- package/dist/local-state.js +93 -9
- package/dist/repo-identity.js +203 -0
- package/dist/server.js +7 -3
- package/dist/upload.js +266 -89
- package/package.json +2 -2
package/dist/commands/local.js
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
1
3
|
import { createCollectorServer } from "../server.js";
|
|
2
4
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, inspectLocalCollectorStatus, installLocalCollector, logoutLocalCollector, pairLocalCollector, readLocalCollectorSessionFile, readLocalSessionReference, startLocalWorkContext, } from "../local-state.js";
|
|
3
|
-
import { syncLocalAmbientEnvelope } from "../upload.js";
|
|
5
|
+
import { LocalUploadBlockedError, postCodexSessionReport, syncLocalAmbientEnvelope, } from "../upload.js";
|
|
6
|
+
import { scanAndAttributeCodexSessions, } from "../adapters/codex-attribution.js";
|
|
7
|
+
import { countStaleSessions, readRawEvidenceCursor, recordSessionObservation, writeRawEvidenceCursor, } from "../cursors/raw-evidence-cursor.js";
|
|
8
|
+
import { discoverGitWorktrees, } from "../repo-identity.js";
|
|
4
9
|
export const rootCommandNames = new Set([
|
|
5
10
|
"onboard",
|
|
6
11
|
"install",
|
|
@@ -56,14 +61,14 @@ export function localCommandHelp(command) {
|
|
|
56
61
|
if (command)
|
|
57
62
|
return localSubcommandHelp(command);
|
|
58
63
|
return [
|
|
59
|
-
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
64
|
+
" cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
60
65
|
" cockpit install [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
61
66
|
" cockpit login [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
62
67
|
" cockpit pair [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--json]",
|
|
63
68
|
" cockpit logout",
|
|
64
|
-
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
|
|
65
|
-
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
66
|
-
" cockpit status [--repo <path>] [--json]",
|
|
69
|
+
" cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
70
|
+
" cockpit sync [--repo <path>] [--dashboard-url <url>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
71
|
+
" cockpit status [--repo <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
67
72
|
" cockpit serve [--port <port>] [--repo <path>]",
|
|
68
73
|
].join("\n");
|
|
69
74
|
}
|
|
@@ -72,9 +77,10 @@ function localSubcommandHelp(command) {
|
|
|
72
77
|
[
|
|
73
78
|
"onboard",
|
|
74
79
|
[
|
|
75
|
-
"Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--json]",
|
|
80
|
+
"Usage: cockpit onboard [--ticket <id>] [--email <owner@email>] [--device-name <name>] [--dashboard-url <url>] [--repo <path>] [--branch <name>] [--json]",
|
|
76
81
|
"",
|
|
77
|
-
"Installs, pairs, starts
|
|
82
|
+
"Installs, pairs, starts work context(s), syncs once, and prints readiness proof.",
|
|
83
|
+
"If --repo is a parent folder, scans child git repos/worktrees and rolls them up by repo.",
|
|
78
84
|
"Use --email on shared or reused machines; mismatched existing sessions are re-paired.",
|
|
79
85
|
],
|
|
80
86
|
],
|
|
@@ -108,7 +114,8 @@ function localSubcommandHelp(command) {
|
|
|
108
114
|
[
|
|
109
115
|
"Usage: cockpit start [--ticket <id>] [--repo <path>] [--branch <name>] [--json]",
|
|
110
116
|
"",
|
|
111
|
-
"Starts local ambient capture.
|
|
117
|
+
"Starts local ambient capture. Parent folders start each child git worktree.",
|
|
118
|
+
"Add --ticket only when the work already has a visible ticket.",
|
|
112
119
|
],
|
|
113
120
|
],
|
|
114
121
|
[
|
|
@@ -116,7 +123,13 @@ function localSubcommandHelp(command) {
|
|
|
116
123
|
[
|
|
117
124
|
"Usage: cockpit sync [--repo <path>] [--dashboard-url <url>] [--json]",
|
|
118
125
|
"",
|
|
119
|
-
"Uploads
|
|
126
|
+
"Uploads latest local ambient envelope(s), or spools safe retries if blocked.",
|
|
127
|
+
"Parent folders sync each child git worktree; Codex JSONL transcripts are attributed",
|
|
128
|
+
"to repos deterministically and ambiguous transcripts are retained as unattributed",
|
|
129
|
+
"instead of being duplicated across repos.",
|
|
130
|
+
"Newly discovered repos get a general ambient work context automatically.",
|
|
131
|
+
"Discovery scans 3 folder levels and up to 50 repos by default; tune with",
|
|
132
|
+
"--max-depth and --max-repos.",
|
|
120
133
|
],
|
|
121
134
|
],
|
|
122
135
|
[
|
|
@@ -181,6 +194,8 @@ function parseOnboardArgs(args) {
|
|
|
181
194
|
"--json",
|
|
182
195
|
"--poll-interval-ms",
|
|
183
196
|
"--timeout-ms",
|
|
197
|
+
"--max-depth",
|
|
198
|
+
"--max-repos",
|
|
184
199
|
],
|
|
185
200
|
valueFlags: [
|
|
186
201
|
"--home",
|
|
@@ -192,6 +207,8 @@ function parseOnboardArgs(args) {
|
|
|
192
207
|
"--branch",
|
|
193
208
|
"--poll-interval-ms",
|
|
194
209
|
"--timeout-ms",
|
|
210
|
+
"--max-depth",
|
|
211
|
+
"--max-repos",
|
|
195
212
|
],
|
|
196
213
|
});
|
|
197
214
|
assertNoPositionals(values.positionals, "onboard");
|
|
@@ -207,6 +224,8 @@ function parseOnboardArgs(args) {
|
|
|
207
224
|
json: values.booleans.has("--json"),
|
|
208
225
|
pollIntervalMs: optionalPositiveInteger(values.flags.get("--poll-interval-ms"), "--poll-interval-ms"),
|
|
209
226
|
timeoutMs: optionalPositiveInteger(values.flags.get("--timeout-ms"), "--timeout-ms"),
|
|
227
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
228
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
210
229
|
};
|
|
211
230
|
}
|
|
212
231
|
function parseInstallArgs(args) {
|
|
@@ -284,6 +303,8 @@ function parseStartArgs(args) {
|
|
|
284
303
|
"--operator-id",
|
|
285
304
|
"--session-id",
|
|
286
305
|
"--json",
|
|
306
|
+
"--max-depth",
|
|
307
|
+
"--max-repos",
|
|
287
308
|
],
|
|
288
309
|
valueFlags: [
|
|
289
310
|
"--home",
|
|
@@ -292,6 +313,8 @@ function parseStartArgs(args) {
|
|
|
292
313
|
"--ticket",
|
|
293
314
|
"--operator-id",
|
|
294
315
|
"--session-id",
|
|
316
|
+
"--max-depth",
|
|
317
|
+
"--max-repos",
|
|
295
318
|
],
|
|
296
319
|
});
|
|
297
320
|
assertNoPositionals(values.positionals, "start");
|
|
@@ -304,12 +327,14 @@ function parseStartArgs(args) {
|
|
|
304
327
|
operatorId: optionalNonEmpty(values.flags.get("--operator-id")),
|
|
305
328
|
sessionId: optionalNonEmpty(values.flags.get("--session-id")),
|
|
306
329
|
json: values.booleans.has("--json"),
|
|
330
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
331
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
307
332
|
};
|
|
308
333
|
}
|
|
309
334
|
function parseSyncArgs(args) {
|
|
310
335
|
const values = parseNamedArgs(args, {
|
|
311
|
-
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json"],
|
|
312
|
-
valueFlags: ["--home", "--repo", "--dashboard-url"],
|
|
336
|
+
allowedFlags: ["--home", "--repo", "--dashboard-url", "--json", "--max-depth", "--max-repos"],
|
|
337
|
+
valueFlags: ["--home", "--repo", "--dashboard-url", "--max-depth", "--max-repos"],
|
|
313
338
|
});
|
|
314
339
|
assertNoPositionals(values.positionals, "sync");
|
|
315
340
|
return {
|
|
@@ -318,12 +343,14 @@ function parseSyncArgs(args) {
|
|
|
318
343
|
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
319
344
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
320
345
|
json: values.booleans.has("--json"),
|
|
346
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
347
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
321
348
|
};
|
|
322
349
|
}
|
|
323
350
|
function parseStatusArgs(args) {
|
|
324
351
|
const values = parseNamedArgs(args, {
|
|
325
|
-
allowedFlags: ["--home", "--repo", "--json"],
|
|
326
|
-
valueFlags: ["--home", "--repo"],
|
|
352
|
+
allowedFlags: ["--home", "--repo", "--json", "--max-depth", "--max-repos"],
|
|
353
|
+
valueFlags: ["--home", "--repo", "--max-depth", "--max-repos"],
|
|
327
354
|
});
|
|
328
355
|
assertNoPositionals(values.positionals, "status");
|
|
329
356
|
return {
|
|
@@ -331,6 +358,8 @@ function parseStatusArgs(args) {
|
|
|
331
358
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
332
359
|
repoRoot: optionalNonEmpty(values.flags.get("--repo")),
|
|
333
360
|
json: values.booleans.has("--json"),
|
|
361
|
+
maxDepth: optionalPositiveInteger(values.flags.get("--max-depth"), "--max-depth"),
|
|
362
|
+
maxRepos: optionalPositiveInteger(values.flags.get("--max-repos"), "--max-repos"),
|
|
334
363
|
};
|
|
335
364
|
}
|
|
336
365
|
function parseServeArgs(args) {
|
|
@@ -455,6 +484,19 @@ async function runOnboard(command, io) {
|
|
|
455
484
|
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
456
485
|
}
|
|
457
486
|
}
|
|
487
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
488
|
+
if (worktrees.length > 1) {
|
|
489
|
+
const multi = await runMultiRepoOnboard(command, io, worktrees);
|
|
490
|
+
if (command.json) {
|
|
491
|
+
writeLine(io.stdout, JSON.stringify({
|
|
492
|
+
...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
|
|
493
|
+
mode: "multi_repo",
|
|
494
|
+
repos: multi.results,
|
|
495
|
+
codex_sessions: multi.codex_sessions,
|
|
496
|
+
}, null, 2));
|
|
497
|
+
}
|
|
498
|
+
return multi.ok ? 0 : 1;
|
|
499
|
+
}
|
|
458
500
|
const context = await startLocalWorkContext({
|
|
459
501
|
homeDir: command.homeDir,
|
|
460
502
|
repoRoot: command.repoRoot,
|
|
@@ -468,12 +510,17 @@ async function runOnboard(command, io) {
|
|
|
468
510
|
writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
|
|
469
511
|
writeLine(io.stdout, `Context: ${context.work_context_id}`);
|
|
470
512
|
}
|
|
471
|
-
|
|
513
|
+
const run = await runAttributedWorktreeSync({
|
|
472
514
|
homeDir: command.homeDir,
|
|
473
|
-
repoRoot: command.repoRoot,
|
|
474
515
|
dashboardUrl: command.dashboardUrl,
|
|
475
|
-
|
|
516
|
+
startContexts: false,
|
|
517
|
+
worktrees,
|
|
518
|
+
fetchImpl: io.fetch,
|
|
476
519
|
});
|
|
520
|
+
sync = run.outcomes[0]?.sync ?? null;
|
|
521
|
+
if (!sync) {
|
|
522
|
+
throw new Error("Onboard sync produced no result for the repo worktree.");
|
|
523
|
+
}
|
|
477
524
|
status = await inspectLocalCollectorStatus({
|
|
478
525
|
homeDir: command.homeDir,
|
|
479
526
|
repoRoot: command.repoRoot,
|
|
@@ -481,7 +528,10 @@ async function runOnboard(command, io) {
|
|
|
481
528
|
});
|
|
482
529
|
if (sync.status !== "uploaded") {
|
|
483
530
|
if (command.json) {
|
|
484
|
-
writeLine(io.stdout, JSON.stringify(
|
|
531
|
+
writeLine(io.stdout, JSON.stringify({
|
|
532
|
+
...onboardResult("blocked", command, install, pair, sync, status),
|
|
533
|
+
codex_sessions: run.summary,
|
|
534
|
+
}, null, 2));
|
|
485
535
|
}
|
|
486
536
|
else {
|
|
487
537
|
writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
|
|
@@ -491,7 +541,10 @@ async function runOnboard(command, io) {
|
|
|
491
541
|
return 1;
|
|
492
542
|
}
|
|
493
543
|
if (command.json) {
|
|
494
|
-
writeLine(io.stdout, JSON.stringify(
|
|
544
|
+
writeLine(io.stdout, JSON.stringify({
|
|
545
|
+
...onboardResult("pass", command, install, pair, sync, status),
|
|
546
|
+
codex_sessions: run.summary,
|
|
547
|
+
}, null, 2));
|
|
495
548
|
return 0;
|
|
496
549
|
}
|
|
497
550
|
writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
|
|
@@ -500,6 +553,9 @@ async function runOnboard(command, io) {
|
|
|
500
553
|
writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
|
|
501
554
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
502
555
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
556
|
+
writeLine(io.stdout, rawEvidenceSyncLine(sync));
|
|
557
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
558
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
503
559
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
504
560
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
505
561
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -560,6 +616,300 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
560
616
|
function normalizeUrlForComparison(value) {
|
|
561
617
|
return value ? normalizeUrl(value) : null;
|
|
562
618
|
}
|
|
619
|
+
/**
|
|
620
|
+
* Shared sync orchestration for single-repo and parent-folder modes: Codex
|
|
621
|
+
* sessions are scanned and attributed once across every discovered worktree,
|
|
622
|
+
* each worktree syncs with only its own attributed transcripts, and the
|
|
623
|
+
* ambiguous/unattributed/skipped remainder is reported with reason labels
|
|
624
|
+
* instead of being duplicated into every repo or silently dropped.
|
|
625
|
+
*/
|
|
626
|
+
async function runAttributedWorktreeSync(options) {
|
|
627
|
+
const now = new Date();
|
|
628
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
629
|
+
const attribution = await scanAndAttributeCodexSessions({
|
|
630
|
+
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
631
|
+
worktrees: options.worktrees,
|
|
632
|
+
now,
|
|
633
|
+
});
|
|
634
|
+
const outcomes = [];
|
|
635
|
+
let ok = true;
|
|
636
|
+
for (const worktree of options.worktrees) {
|
|
637
|
+
let context = null;
|
|
638
|
+
if (options.startContexts) {
|
|
639
|
+
context = await startLocalWorkContext({
|
|
640
|
+
homeDir: options.homeDir,
|
|
641
|
+
repoRoot: worktree.repo_root,
|
|
642
|
+
branch: options.branch,
|
|
643
|
+
activeTicketId: options.activeTicketId,
|
|
644
|
+
operatorId: options.operatorId,
|
|
645
|
+
sessionId: options.sessionId,
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
const syncOptions = {
|
|
649
|
+
homeDir: options.homeDir,
|
|
650
|
+
repoRoot: worktree.repo_root,
|
|
651
|
+
dashboardUrl: options.dashboardUrl,
|
|
652
|
+
codexSessionFiles: attribution.results
|
|
653
|
+
.filter((result) => result.state === "attributed" &&
|
|
654
|
+
result.worktree?.worktree_fingerprint ===
|
|
655
|
+
worktree.worktree_fingerprint)
|
|
656
|
+
.map((result) => ({
|
|
657
|
+
local_path: result.file_path,
|
|
658
|
+
codex_session_id: result.codex_session_id,
|
|
659
|
+
})),
|
|
660
|
+
fetch: options.fetchImpl,
|
|
661
|
+
};
|
|
662
|
+
let sync;
|
|
663
|
+
try {
|
|
664
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
665
|
+
}
|
|
666
|
+
catch (error) {
|
|
667
|
+
// A newly cloned repo has no work context yet. Capture is permissive
|
|
668
|
+
// and ticket binding comes later, so start general ambient capture for
|
|
669
|
+
// it instead of blocking every other repo's sync until someone runs
|
|
670
|
+
// `cockpit start` by hand.
|
|
671
|
+
if (error instanceof LocalUploadBlockedError &&
|
|
672
|
+
error.blocker === "missing_context") {
|
|
673
|
+
context = await startLocalWorkContext({
|
|
674
|
+
homeDir: options.homeDir,
|
|
675
|
+
repoRoot: worktree.repo_root,
|
|
676
|
+
branch: options.branch,
|
|
677
|
+
});
|
|
678
|
+
sync = await syncLocalAmbientEnvelope(syncOptions);
|
|
679
|
+
}
|
|
680
|
+
else {
|
|
681
|
+
throw error;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
ok = ok && sync.status === "uploaded";
|
|
685
|
+
outcomes.push({ worktree, context, sync });
|
|
686
|
+
}
|
|
687
|
+
const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
|
|
688
|
+
// The sessions cursor is an optimization; a broken local state dir must not
|
|
689
|
+
// turn already-completed syncs into a CLI crash.
|
|
690
|
+
let staleSessionCount = 0;
|
|
691
|
+
try {
|
|
692
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
693
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
694
|
+
const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
|
|
695
|
+
staleSessionCount = countStaleSessions(cursor, seenSessionIds);
|
|
696
|
+
for (const result of attribution.results) {
|
|
697
|
+
const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
|
|
698
|
+
const sessionDurable = reported?.upload_state === "uploaded" ||
|
|
699
|
+
reported?.upload_state === "reused_existing";
|
|
700
|
+
recordSessionObservation(cursor, result.codex_session_id, {
|
|
701
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
702
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
703
|
+
byte_size: result.byte_size,
|
|
704
|
+
byte_offset: sessionDurable ? result.byte_size : 0,
|
|
705
|
+
state: result.state,
|
|
706
|
+
reason: result.reason,
|
|
707
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
708
|
+
uploaded_object_key: sessionDurable
|
|
709
|
+
? (reported?.raw_evidence_pointer_id ?? null)
|
|
710
|
+
: null,
|
|
711
|
+
last_seen_at: now.toISOString(),
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
cursor.updated_at = now.toISOString();
|
|
715
|
+
await writeRawEvidenceCursor(paths, cursor);
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
719
|
+
}
|
|
720
|
+
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
721
|
+
const report = firstUploaded
|
|
722
|
+
? await postCodexSessionReport({
|
|
723
|
+
homeDir: options.homeDir,
|
|
724
|
+
repoRoot: firstUploaded.worktree.repo_root,
|
|
725
|
+
dashboardUrl: options.dashboardUrl,
|
|
726
|
+
sessions,
|
|
727
|
+
fetch: options.fetchImpl,
|
|
728
|
+
now,
|
|
729
|
+
})
|
|
730
|
+
: {
|
|
731
|
+
posted: false,
|
|
732
|
+
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
733
|
+
};
|
|
734
|
+
return {
|
|
735
|
+
ok,
|
|
736
|
+
outcomes,
|
|
737
|
+
attribution,
|
|
738
|
+
summary: {
|
|
739
|
+
scanned: attribution.scanned_file_count,
|
|
740
|
+
attributed: attribution.counts.attributed,
|
|
741
|
+
ambiguous: attribution.counts.ambiguous,
|
|
742
|
+
unattributed: attribution.counts.unattributed,
|
|
743
|
+
skipped: attribution.counts.skipped,
|
|
744
|
+
stale: staleSessionCount,
|
|
745
|
+
report_posted: report.posted,
|
|
746
|
+
report_reason: report.reason,
|
|
747
|
+
},
|
|
748
|
+
};
|
|
749
|
+
}
|
|
750
|
+
const ATTRIBUTION_STATE_RANK = {
|
|
751
|
+
attributed: 3,
|
|
752
|
+
ambiguous: 2,
|
|
753
|
+
unattributed: 1,
|
|
754
|
+
skipped: 0,
|
|
755
|
+
};
|
|
756
|
+
function buildCodexSessionReport(results, outcomes, now) {
|
|
757
|
+
// Resumed/forked sessions can span multiple files with the same session id;
|
|
758
|
+
// report each session once, preferring the strongest attribution and the
|
|
759
|
+
// freshest file.
|
|
760
|
+
const bestBySessionId = new Map();
|
|
761
|
+
for (const result of results) {
|
|
762
|
+
const existing = bestBySessionId.get(result.codex_session_id);
|
|
763
|
+
if (!existing ||
|
|
764
|
+
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
765
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
766
|
+
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
767
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
768
|
+
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
769
|
+
bestBySessionId.set(result.codex_session_id, result);
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
const dedupedResults = [...bestBySessionId.values()];
|
|
773
|
+
const uploadBySessionId = new Map();
|
|
774
|
+
for (const outcome of outcomes) {
|
|
775
|
+
if (outcome.sync.status !== "uploaded")
|
|
776
|
+
continue;
|
|
777
|
+
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
778
|
+
if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
|
|
779
|
+
uploadBySessionId.set(upload.codex_session_id, upload);
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
return dedupedResults.map((result) => {
|
|
784
|
+
const upload = uploadBySessionId.get(result.codex_session_id);
|
|
785
|
+
return {
|
|
786
|
+
codex_session_id: result.codex_session_id,
|
|
787
|
+
observed_at: now.toISOString(),
|
|
788
|
+
attribution_state: result.state,
|
|
789
|
+
attribution_reason: result.reason,
|
|
790
|
+
attribution_score: result.attribution_score,
|
|
791
|
+
path_score: result.path_score,
|
|
792
|
+
signals: result.signals,
|
|
793
|
+
...(result.content_hash_sha256
|
|
794
|
+
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
795
|
+
: {}),
|
|
796
|
+
session_file_byte_size: result.byte_size,
|
|
797
|
+
session_file_mtime: result.session_file_mtime,
|
|
798
|
+
...(result.worktree
|
|
799
|
+
? {
|
|
800
|
+
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
801
|
+
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
802
|
+
repo_label: result.worktree.repo_label,
|
|
803
|
+
branch: result.worktree.branch,
|
|
804
|
+
}
|
|
805
|
+
: {}),
|
|
806
|
+
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
807
|
+
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
808
|
+
...(upload
|
|
809
|
+
? {
|
|
810
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
811
|
+
upload_state: upload.upload_state,
|
|
812
|
+
}
|
|
813
|
+
: result.state === "attributed"
|
|
814
|
+
? { upload_state: "not_uploaded" }
|
|
815
|
+
: {}),
|
|
816
|
+
};
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
function shortSha(value) {
|
|
820
|
+
return value ? value.slice(0, 12) : "unknown";
|
|
821
|
+
}
|
|
822
|
+
function codexSummaryLine(summary) {
|
|
823
|
+
return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
|
|
824
|
+
}
|
|
825
|
+
function attributionReportLine(summary) {
|
|
826
|
+
return summary.report_posted
|
|
827
|
+
? "Attribution report: recorded"
|
|
828
|
+
: `Attribution report: skipped (${summary.report_reason})`;
|
|
829
|
+
}
|
|
830
|
+
function rawEvidenceSyncLine(sync) {
|
|
831
|
+
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
832
|
+
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
833
|
+
: "";
|
|
834
|
+
return `Raw evidence: uploaded ${sync.raw_evidence_uploaded_object_count} object(s) in ${sync.raw_evidence_uploaded_chunk_count} chunk(s), reused ${sync.raw_evidence_reused_count}, failed ${sync.raw_evidence_failed_count}${failures}`;
|
|
835
|
+
}
|
|
836
|
+
function cursorStatusLine(sync) {
|
|
837
|
+
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
838
|
+
}
|
|
839
|
+
const DEFAULT_DISCOVERY_MAX_DEPTH = 3;
|
|
840
|
+
const DEFAULT_DISCOVERY_MAX_REPOS = 50;
|
|
841
|
+
async function discoverCommandWorktrees(repoRoot, discovery = {}, io) {
|
|
842
|
+
const maxWorktrees = discovery.maxRepos ?? DEFAULT_DISCOVERY_MAX_REPOS;
|
|
843
|
+
const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd(), {
|
|
844
|
+
maxDepth: discovery.maxDepth ?? DEFAULT_DISCOVERY_MAX_DEPTH,
|
|
845
|
+
maxWorktrees,
|
|
846
|
+
});
|
|
847
|
+
if (worktrees.length === 0) {
|
|
848
|
+
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
849
|
+
}
|
|
850
|
+
if (io && worktrees.length >= maxWorktrees) {
|
|
851
|
+
writeLine(io.stderr, `Warning: repo discovery hit the cap of ${maxWorktrees}; additional repos may be excluded. Raise --max-repos if this folder really holds more.`);
|
|
852
|
+
}
|
|
853
|
+
return worktrees;
|
|
854
|
+
}
|
|
855
|
+
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
856
|
+
if (!command.json) {
|
|
857
|
+
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
858
|
+
}
|
|
859
|
+
const run = await runAttributedWorktreeSync({
|
|
860
|
+
homeDir: command.homeDir,
|
|
861
|
+
dashboardUrl: command.dashboardUrl,
|
|
862
|
+
branch: command.branch,
|
|
863
|
+
activeTicketId: command.activeTicketId,
|
|
864
|
+
startContexts: true,
|
|
865
|
+
worktrees,
|
|
866
|
+
fetchImpl: io.fetch,
|
|
867
|
+
});
|
|
868
|
+
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
|
|
869
|
+
if (!command.json) {
|
|
870
|
+
for (const [index, outcome] of run.outcomes.entries()) {
|
|
871
|
+
const row = results[index];
|
|
872
|
+
if (!row)
|
|
873
|
+
continue;
|
|
874
|
+
const uploaded = outcome.sync.status === "uploaded";
|
|
875
|
+
writeLine(uploaded ? io.stdout : io.stderr, `${uploaded ? "PASS" : "BLOCKED"} ${row["repo_label"]}/${row["worktree_label"]} (${row["branch"]}) head:${shortSha(outcome.sync.head_sha ?? outcome.worktree.head_sha)} ${outcome.sync.status} objects:${outcome.sync.raw_evidence_uploaded_object_count} chunks:${outcome.sync.raw_evidence_uploaded_chunk_count} reused:${outcome.sync.raw_evidence_reused_count} failed:${outcome.sync.raw_evidence_failed_count} sessions:${row["attributed_session_count"]}${row["failure_reason"] ? ` reason:${row["failure_reason"]}` : ""}`);
|
|
876
|
+
}
|
|
877
|
+
writeLine(io.stdout, "4/5 Parent worktree sync complete.");
|
|
878
|
+
writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
|
|
879
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
880
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
881
|
+
writeLine(io.stdout, "5/5 Status ready.");
|
|
882
|
+
writeLine(run.ok ? io.stdout : io.stderr, run.ok
|
|
883
|
+
? "PASS: Cockpit collector is ready for harvest."
|
|
884
|
+
: "BLOCKED: One or more worktree uploads were spooled.");
|
|
885
|
+
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
886
|
+
}
|
|
887
|
+
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
888
|
+
}
|
|
889
|
+
function worktreeSyncRow(outcome, attribution) {
|
|
890
|
+
const { worktree, context, sync } = outcome;
|
|
891
|
+
const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
|
|
892
|
+
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
|
|
893
|
+
return {
|
|
894
|
+
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
895
|
+
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
896
|
+
worktree_label: context?.worktree_label ?? worktree.worktree_label,
|
|
897
|
+
worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
|
|
898
|
+
branch: context?.branch ?? worktree.branch,
|
|
899
|
+
head_sha: sync.head_sha ?? worktree.head_sha,
|
|
900
|
+
work_context_id: context?.work_context_id ?? sync.work_context_id,
|
|
901
|
+
upload_status: sync.status,
|
|
902
|
+
raw_evidence_file_count: sync.raw_evidence_file_count,
|
|
903
|
+
raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
|
|
904
|
+
raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
|
|
905
|
+
raw_evidence_reused_count: sync.raw_evidence_reused_count,
|
|
906
|
+
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
907
|
+
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
908
|
+
attributed_session_count: attributedSessionCount,
|
|
909
|
+
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
910
|
+
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
563
913
|
async function runLogin(command, io) {
|
|
564
914
|
const result = await pairLocalCollector({
|
|
565
915
|
homeDir: command.homeDir,
|
|
@@ -659,6 +1009,26 @@ async function runLogout(command, io) {
|
|
|
659
1009
|
return 0;
|
|
660
1010
|
}
|
|
661
1011
|
async function runStart(command, io) {
|
|
1012
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1013
|
+
if (worktrees.length > 1) {
|
|
1014
|
+
const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
|
|
1015
|
+
homeDir: command.homeDir,
|
|
1016
|
+
repoRoot: worktree.repo_root,
|
|
1017
|
+
branch: command.branch,
|
|
1018
|
+
activeTicketId: command.activeTicketId,
|
|
1019
|
+
operatorId: command.operatorId,
|
|
1020
|
+
sessionId: command.sessionId,
|
|
1021
|
+
})));
|
|
1022
|
+
if (command.json) {
|
|
1023
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
|
|
1024
|
+
return 0;
|
|
1025
|
+
}
|
|
1026
|
+
writeLine(io.stdout, `Cockpit parent work context active for ${contexts.length} worktree(s).`);
|
|
1027
|
+
for (const context of contexts) {
|
|
1028
|
+
writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
|
|
1029
|
+
}
|
|
1030
|
+
return 0;
|
|
1031
|
+
}
|
|
662
1032
|
const context = await startLocalWorkContext(command);
|
|
663
1033
|
if (command.json) {
|
|
664
1034
|
writeLine(io.stdout, JSON.stringify(context, null, 2));
|
|
@@ -672,23 +1042,57 @@ async function runStart(command, io) {
|
|
|
672
1042
|
return 0;
|
|
673
1043
|
}
|
|
674
1044
|
async function runSync(command, io) {
|
|
675
|
-
const
|
|
1045
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1046
|
+
const run = await runAttributedWorktreeSync({
|
|
676
1047
|
homeDir: command.homeDir,
|
|
677
|
-
repoRoot: command.repoRoot,
|
|
678
1048
|
dashboardUrl: command.dashboardUrl,
|
|
679
|
-
|
|
1049
|
+
startContexts: false,
|
|
1050
|
+
worktrees,
|
|
1051
|
+
fetchImpl: io.fetch,
|
|
680
1052
|
});
|
|
1053
|
+
if (worktrees.length > 1) {
|
|
1054
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
|
|
1055
|
+
if (command.json) {
|
|
1056
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1057
|
+
mode: "multi_repo",
|
|
1058
|
+
status: run.ok ? "uploaded" : "spooled",
|
|
1059
|
+
results: run.outcomes.map((outcome) => outcome.sync),
|
|
1060
|
+
repos: rows,
|
|
1061
|
+
codex_sessions: run.summary,
|
|
1062
|
+
}, null, 2));
|
|
1063
|
+
return run.ok ? 0 : 1;
|
|
1064
|
+
}
|
|
1065
|
+
writeLine(run.ok ? io.stdout : io.stderr, `Cockpit parent sync ${run.ok ? "uploaded" : "spooled"} ${run.outcomes.filter((outcome) => outcome.sync.status === "uploaded").length}/${run.outcomes.length} worktree(s).`);
|
|
1066
|
+
for (const outcome of run.outcomes) {
|
|
1067
|
+
const { worktree, sync } = outcome;
|
|
1068
|
+
const uploaded = sync.status === "uploaded";
|
|
1069
|
+
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
1070
|
+
writeLine(uploaded ? io.stdout : io.stderr, `- ${worktree.repo_label}/${worktree.worktree_label} (${worktree.branch}) head:${shortSha(sync.head_sha ?? worktree.head_sha)} ${sync.status} objects:${sync.raw_evidence_uploaded_object_count} chunks:${sync.raw_evidence_uploaded_chunk_count} reused:${sync.raw_evidence_reused_count} failed:${sync.raw_evidence_failed_count} cursor:${sync.cursor_tracked_object_count}${failureSuffix}`);
|
|
1071
|
+
}
|
|
1072
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
1073
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1074
|
+
return run.ok ? 0 : 1;
|
|
1075
|
+
}
|
|
1076
|
+
const result = run.outcomes[0]?.sync;
|
|
1077
|
+
if (!result) {
|
|
1078
|
+
throw new Error("Sync produced no result for the repo worktree.");
|
|
1079
|
+
}
|
|
681
1080
|
if (command.json) {
|
|
682
|
-
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
1081
|
+
writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
|
|
683
1082
|
return result.status === "uploaded" ? 0 : 1;
|
|
684
1083
|
}
|
|
685
1084
|
if (result.status === "uploaded") {
|
|
686
1085
|
writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
|
|
687
1086
|
writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
688
1087
|
writeLine(io.stdout, `Context: ${result.work_context_id}`);
|
|
1088
|
+
writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
|
|
689
1089
|
writeLine(io.stdout, `Facts: ${result.event_count}`);
|
|
690
1090
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
691
1091
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1092
|
+
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
1093
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
1094
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1095
|
+
writeLine(io.stdout, cursorStatusLine(result));
|
|
692
1096
|
return 0;
|
|
693
1097
|
}
|
|
694
1098
|
writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
|
|
@@ -698,6 +1102,25 @@ async function runSync(command, io) {
|
|
|
698
1102
|
return 1;
|
|
699
1103
|
}
|
|
700
1104
|
async function runStatus(command, io) {
|
|
1105
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot, { maxDepth: command.maxDepth, maxRepos: command.maxRepos }, io);
|
|
1106
|
+
if (worktrees.length > 1) {
|
|
1107
|
+
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
1108
|
+
...(await inspectLocalCollectorStatus({
|
|
1109
|
+
homeDir: command.homeDir,
|
|
1110
|
+
repoRoot: worktree.repo_root,
|
|
1111
|
+
})),
|
|
1112
|
+
head_sha: worktree.head_sha,
|
|
1113
|
+
})));
|
|
1114
|
+
if (command.json) {
|
|
1115
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
|
|
1116
|
+
return 0;
|
|
1117
|
+
}
|
|
1118
|
+
writeLine(io.stdout, "Cockpit parent status");
|
|
1119
|
+
for (const status of statuses) {
|
|
1120
|
+
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
1121
|
+
}
|
|
1122
|
+
return 0;
|
|
1123
|
+
}
|
|
701
1124
|
const status = await inspectLocalCollectorStatus(command);
|
|
702
1125
|
if (command.json) {
|
|
703
1126
|
writeLine(io.stdout, JSON.stringify(status, null, 2));
|