@bli-cockpit/cli 0.1.2 → 0.1.5
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 -9
- 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 +389 -16
- 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 +271 -95
- 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 { 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,7 +61,7 @@ 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>] [--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]",
|
|
@@ -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,10 @@ 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.",
|
|
120
130
|
],
|
|
121
131
|
],
|
|
122
132
|
[
|
|
@@ -455,6 +465,19 @@ async function runOnboard(command, io) {
|
|
|
455
465
|
writeLine(io.stdout, `Device: ${pair.session.device_name ?? pair.session.device_id ?? "unknown"}`);
|
|
456
466
|
}
|
|
457
467
|
}
|
|
468
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
469
|
+
if (worktrees.length > 1) {
|
|
470
|
+
const multi = await runMultiRepoOnboard(command, io, worktrees);
|
|
471
|
+
if (command.json) {
|
|
472
|
+
writeLine(io.stdout, JSON.stringify({
|
|
473
|
+
...onboardResult(multi.ok ? "pass" : "blocked", command, install, pair, null, null),
|
|
474
|
+
mode: "multi_repo",
|
|
475
|
+
repos: multi.results,
|
|
476
|
+
codex_sessions: multi.codex_sessions,
|
|
477
|
+
}, null, 2));
|
|
478
|
+
}
|
|
479
|
+
return multi.ok ? 0 : 1;
|
|
480
|
+
}
|
|
458
481
|
const context = await startLocalWorkContext({
|
|
459
482
|
homeDir: command.homeDir,
|
|
460
483
|
repoRoot: command.repoRoot,
|
|
@@ -468,12 +491,17 @@ async function runOnboard(command, io) {
|
|
|
468
491
|
writeLine(io.stdout, `Ticket: ${context.active_ticket_id ?? "general ambient"}`);
|
|
469
492
|
writeLine(io.stdout, `Context: ${context.work_context_id}`);
|
|
470
493
|
}
|
|
471
|
-
|
|
494
|
+
const run = await runAttributedWorktreeSync({
|
|
472
495
|
homeDir: command.homeDir,
|
|
473
|
-
repoRoot: command.repoRoot,
|
|
474
496
|
dashboardUrl: command.dashboardUrl,
|
|
475
|
-
|
|
497
|
+
startContexts: false,
|
|
498
|
+
worktrees,
|
|
499
|
+
fetchImpl: io.fetch,
|
|
476
500
|
});
|
|
501
|
+
sync = run.outcomes[0]?.sync ?? null;
|
|
502
|
+
if (!sync) {
|
|
503
|
+
throw new Error("Onboard sync produced no result for the repo worktree.");
|
|
504
|
+
}
|
|
477
505
|
status = await inspectLocalCollectorStatus({
|
|
478
506
|
homeDir: command.homeDir,
|
|
479
507
|
repoRoot: command.repoRoot,
|
|
@@ -481,7 +509,10 @@ async function runOnboard(command, io) {
|
|
|
481
509
|
});
|
|
482
510
|
if (sync.status !== "uploaded") {
|
|
483
511
|
if (command.json) {
|
|
484
|
-
writeLine(io.stdout, JSON.stringify(
|
|
512
|
+
writeLine(io.stdout, JSON.stringify({
|
|
513
|
+
...onboardResult("blocked", command, install, pair, sync, status),
|
|
514
|
+
codex_sessions: run.summary,
|
|
515
|
+
}, null, 2));
|
|
485
516
|
}
|
|
486
517
|
else {
|
|
487
518
|
writeLine(io.stderr, "BLOCKED: ambient upload failed; safe retry metadata was spooled.");
|
|
@@ -491,7 +522,10 @@ async function runOnboard(command, io) {
|
|
|
491
522
|
return 1;
|
|
492
523
|
}
|
|
493
524
|
if (command.json) {
|
|
494
|
-
writeLine(io.stdout, JSON.stringify(
|
|
525
|
+
writeLine(io.stdout, JSON.stringify({
|
|
526
|
+
...onboardResult("pass", command, install, pair, sync, status),
|
|
527
|
+
codex_sessions: run.summary,
|
|
528
|
+
}, null, 2));
|
|
495
529
|
return 0;
|
|
496
530
|
}
|
|
497
531
|
writeLine(io.stdout, "4/5 Ambient metadata uploaded.");
|
|
@@ -500,6 +534,9 @@ async function runOnboard(command, io) {
|
|
|
500
534
|
writeLine(io.stdout, `Sources: ${sync.source_scan_count}`);
|
|
501
535
|
writeLine(io.stdout, `Risk flags: ${sync.risk_flag_count}`);
|
|
502
536
|
writeLine(io.stdout, `Raw evidence files: ${sync.raw_evidence_file_count}`);
|
|
537
|
+
writeLine(io.stdout, rawEvidenceSyncLine(sync));
|
|
538
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
539
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
503
540
|
writeLine(io.stdout, "5/5 Status ready.");
|
|
504
541
|
writeLine(io.stdout, `Upload state: ${status.upload_state}`);
|
|
505
542
|
writeLine(io.stdout, "PASS: Cockpit collector is ready for harvest.");
|
|
@@ -560,6 +597,269 @@ async function readOnboardSessionReuseCandidate(homeDir) {
|
|
|
560
597
|
function normalizeUrlForComparison(value) {
|
|
561
598
|
return value ? normalizeUrl(value) : null;
|
|
562
599
|
}
|
|
600
|
+
/**
|
|
601
|
+
* Shared sync orchestration for single-repo and parent-folder modes: Codex
|
|
602
|
+
* sessions are scanned and attributed once across every discovered worktree,
|
|
603
|
+
* each worktree syncs with only its own attributed transcripts, and the
|
|
604
|
+
* ambiguous/unattributed/skipped remainder is reported with reason labels
|
|
605
|
+
* instead of being duplicated into every repo or silently dropped.
|
|
606
|
+
*/
|
|
607
|
+
async function runAttributedWorktreeSync(options) {
|
|
608
|
+
const now = new Date();
|
|
609
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
610
|
+
const attribution = await scanAndAttributeCodexSessions({
|
|
611
|
+
sessionsDir: path.join(homeDir, ".codex", "sessions"),
|
|
612
|
+
worktrees: options.worktrees,
|
|
613
|
+
now,
|
|
614
|
+
});
|
|
615
|
+
const outcomes = [];
|
|
616
|
+
let ok = true;
|
|
617
|
+
for (const worktree of options.worktrees) {
|
|
618
|
+
let context = null;
|
|
619
|
+
if (options.startContexts) {
|
|
620
|
+
context = await startLocalWorkContext({
|
|
621
|
+
homeDir: options.homeDir,
|
|
622
|
+
repoRoot: worktree.repo_root,
|
|
623
|
+
branch: options.branch,
|
|
624
|
+
activeTicketId: options.activeTicketId,
|
|
625
|
+
operatorId: options.operatorId,
|
|
626
|
+
sessionId: options.sessionId,
|
|
627
|
+
});
|
|
628
|
+
}
|
|
629
|
+
const sync = await syncLocalAmbientEnvelope({
|
|
630
|
+
homeDir: options.homeDir,
|
|
631
|
+
repoRoot: worktree.repo_root,
|
|
632
|
+
dashboardUrl: options.dashboardUrl,
|
|
633
|
+
codexSessionFiles: attribution.results
|
|
634
|
+
.filter((result) => result.state === "attributed" &&
|
|
635
|
+
result.worktree?.worktree_fingerprint ===
|
|
636
|
+
worktree.worktree_fingerprint)
|
|
637
|
+
.map((result) => ({
|
|
638
|
+
local_path: result.file_path,
|
|
639
|
+
codex_session_id: result.codex_session_id,
|
|
640
|
+
})),
|
|
641
|
+
fetch: options.fetchImpl,
|
|
642
|
+
});
|
|
643
|
+
ok = ok && sync.status === "uploaded";
|
|
644
|
+
outcomes.push({ worktree, context, sync });
|
|
645
|
+
}
|
|
646
|
+
const sessions = buildCodexSessionReport(attribution.results, outcomes, now);
|
|
647
|
+
// The sessions cursor is an optimization; a broken local state dir must not
|
|
648
|
+
// turn already-completed syncs into a CLI crash.
|
|
649
|
+
let staleSessionCount = 0;
|
|
650
|
+
try {
|
|
651
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
652
|
+
const cursor = await readRawEvidenceCursor(paths);
|
|
653
|
+
const seenSessionIds = new Set(attribution.results.map((result) => result.codex_session_id));
|
|
654
|
+
staleSessionCount = countStaleSessions(cursor, seenSessionIds);
|
|
655
|
+
for (const result of attribution.results) {
|
|
656
|
+
const reported = sessions.find((session) => session.codex_session_id === result.codex_session_id);
|
|
657
|
+
const sessionDurable = reported?.upload_state === "uploaded" ||
|
|
658
|
+
reported?.upload_state === "reused_existing";
|
|
659
|
+
recordSessionObservation(cursor, result.codex_session_id, {
|
|
660
|
+
file_hash_sha256: result.content_hash_sha256,
|
|
661
|
+
file_mtime_ms: result.session_file_mtime_ms,
|
|
662
|
+
byte_size: result.byte_size,
|
|
663
|
+
byte_offset: sessionDurable ? result.byte_size : 0,
|
|
664
|
+
state: result.state,
|
|
665
|
+
reason: result.reason,
|
|
666
|
+
worktree_fingerprint: result.worktree?.worktree_fingerprint ?? null,
|
|
667
|
+
uploaded_object_key: sessionDurable
|
|
668
|
+
? (reported?.raw_evidence_pointer_id ?? null)
|
|
669
|
+
: null,
|
|
670
|
+
last_seen_at: now.toISOString(),
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
cursor.updated_at = now.toISOString();
|
|
674
|
+
await writeRawEvidenceCursor(paths, cursor);
|
|
675
|
+
}
|
|
676
|
+
catch {
|
|
677
|
+
// Best-effort: stale counts read 0 and observations re-record next sync.
|
|
678
|
+
}
|
|
679
|
+
const firstUploaded = outcomes.find((outcome) => outcome.sync.status === "uploaded");
|
|
680
|
+
const report = firstUploaded
|
|
681
|
+
? await postCodexSessionReport({
|
|
682
|
+
homeDir: options.homeDir,
|
|
683
|
+
repoRoot: firstUploaded.worktree.repo_root,
|
|
684
|
+
dashboardUrl: options.dashboardUrl,
|
|
685
|
+
sessions,
|
|
686
|
+
fetch: options.fetchImpl,
|
|
687
|
+
now,
|
|
688
|
+
})
|
|
689
|
+
: {
|
|
690
|
+
posted: false,
|
|
691
|
+
reason: sessions.length === 0 ? "no_sessions_observed" : "no_successful_sync",
|
|
692
|
+
};
|
|
693
|
+
return {
|
|
694
|
+
ok,
|
|
695
|
+
outcomes,
|
|
696
|
+
attribution,
|
|
697
|
+
summary: {
|
|
698
|
+
scanned: attribution.scanned_file_count,
|
|
699
|
+
attributed: attribution.counts.attributed,
|
|
700
|
+
ambiguous: attribution.counts.ambiguous,
|
|
701
|
+
unattributed: attribution.counts.unattributed,
|
|
702
|
+
skipped: attribution.counts.skipped,
|
|
703
|
+
stale: staleSessionCount,
|
|
704
|
+
report_posted: report.posted,
|
|
705
|
+
report_reason: report.reason,
|
|
706
|
+
},
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
const ATTRIBUTION_STATE_RANK = {
|
|
710
|
+
attributed: 3,
|
|
711
|
+
ambiguous: 2,
|
|
712
|
+
unattributed: 1,
|
|
713
|
+
skipped: 0,
|
|
714
|
+
};
|
|
715
|
+
function buildCodexSessionReport(results, outcomes, now) {
|
|
716
|
+
// Resumed/forked sessions can span multiple files with the same session id;
|
|
717
|
+
// report each session once, preferring the strongest attribution and the
|
|
718
|
+
// freshest file.
|
|
719
|
+
const bestBySessionId = new Map();
|
|
720
|
+
for (const result of results) {
|
|
721
|
+
const existing = bestBySessionId.get(result.codex_session_id);
|
|
722
|
+
if (!existing ||
|
|
723
|
+
(ATTRIBUTION_STATE_RANK[result.state] ?? 0) >
|
|
724
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) ||
|
|
725
|
+
((ATTRIBUTION_STATE_RANK[result.state] ?? 0) ===
|
|
726
|
+
(ATTRIBUTION_STATE_RANK[existing.state] ?? 0) &&
|
|
727
|
+
result.session_file_mtime_ms > existing.session_file_mtime_ms)) {
|
|
728
|
+
bestBySessionId.set(result.codex_session_id, result);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const dedupedResults = [...bestBySessionId.values()];
|
|
732
|
+
const uploadBySessionId = new Map();
|
|
733
|
+
for (const outcome of outcomes) {
|
|
734
|
+
if (outcome.sync.status !== "uploaded")
|
|
735
|
+
continue;
|
|
736
|
+
for (const upload of outcome.sync.raw_evidence_outcomes) {
|
|
737
|
+
if (upload.codex_session_id && upload.raw_evidence_pointer_id) {
|
|
738
|
+
uploadBySessionId.set(upload.codex_session_id, upload);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return dedupedResults.map((result) => {
|
|
743
|
+
const upload = uploadBySessionId.get(result.codex_session_id);
|
|
744
|
+
return {
|
|
745
|
+
codex_session_id: result.codex_session_id,
|
|
746
|
+
observed_at: now.toISOString(),
|
|
747
|
+
attribution_state: result.state,
|
|
748
|
+
attribution_reason: result.reason,
|
|
749
|
+
attribution_score: result.attribution_score,
|
|
750
|
+
path_score: result.path_score,
|
|
751
|
+
signals: result.signals,
|
|
752
|
+
...(result.content_hash_sha256
|
|
753
|
+
? { session_file_hash_sha256: result.content_hash_sha256 }
|
|
754
|
+
: {}),
|
|
755
|
+
session_file_byte_size: result.byte_size,
|
|
756
|
+
session_file_mtime: result.session_file_mtime,
|
|
757
|
+
...(result.worktree
|
|
758
|
+
? {
|
|
759
|
+
repo_fingerprint: result.worktree.repo_fingerprint,
|
|
760
|
+
worktree_fingerprint: result.worktree.worktree_fingerprint,
|
|
761
|
+
repo_label: result.worktree.repo_label,
|
|
762
|
+
branch: result.worktree.branch,
|
|
763
|
+
}
|
|
764
|
+
: {}),
|
|
765
|
+
...(result.cwd_basename ? { cwd_basename: result.cwd_basename } : {}),
|
|
766
|
+
...(result.cwd_hash ? { cwd_hash: result.cwd_hash } : {}),
|
|
767
|
+
...(upload
|
|
768
|
+
? {
|
|
769
|
+
raw_evidence_pointer_id: upload.raw_evidence_pointer_id,
|
|
770
|
+
upload_state: upload.upload_state,
|
|
771
|
+
}
|
|
772
|
+
: result.state === "attributed"
|
|
773
|
+
? { upload_state: "not_uploaded" }
|
|
774
|
+
: {}),
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
function shortSha(value) {
|
|
779
|
+
return value ? value.slice(0, 12) : "unknown";
|
|
780
|
+
}
|
|
781
|
+
function codexSummaryLine(summary) {
|
|
782
|
+
return `Codex sessions: attributed ${summary.attributed}, ambiguous ${summary.ambiguous}, unattributed ${summary.unattributed}, skipped ${summary.skipped}, stale ${summary.stale}`;
|
|
783
|
+
}
|
|
784
|
+
function attributionReportLine(summary) {
|
|
785
|
+
return summary.report_posted
|
|
786
|
+
? "Attribution report: recorded"
|
|
787
|
+
: `Attribution report: skipped (${summary.report_reason})`;
|
|
788
|
+
}
|
|
789
|
+
function rawEvidenceSyncLine(sync) {
|
|
790
|
+
const failures = sync.raw_evidence_failure_reasons.length > 0
|
|
791
|
+
? ` failures: ${sync.raw_evidence_failure_reasons.join(",")}`
|
|
792
|
+
: "";
|
|
793
|
+
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}`;
|
|
794
|
+
}
|
|
795
|
+
function cursorStatusLine(sync) {
|
|
796
|
+
return `Cursor: ${sync.cursor_tracked_object_count} durable object(s) tracked`;
|
|
797
|
+
}
|
|
798
|
+
async function discoverCommandWorktrees(repoRoot) {
|
|
799
|
+
const worktrees = await discoverGitWorktrees(repoRoot ?? process.cwd());
|
|
800
|
+
if (worktrees.length === 0) {
|
|
801
|
+
throw new Error("No git repos found. Run from a git repo, or from a parent folder containing git repos.");
|
|
802
|
+
}
|
|
803
|
+
return worktrees;
|
|
804
|
+
}
|
|
805
|
+
async function runMultiRepoOnboard(command, io, worktrees) {
|
|
806
|
+
if (!command.json) {
|
|
807
|
+
writeLine(io.stdout, `3/5 Parent folder mode: discovered ${worktrees.length} git worktree(s).`);
|
|
808
|
+
}
|
|
809
|
+
const run = await runAttributedWorktreeSync({
|
|
810
|
+
homeDir: command.homeDir,
|
|
811
|
+
dashboardUrl: command.dashboardUrl,
|
|
812
|
+
branch: command.branch,
|
|
813
|
+
activeTicketId: command.activeTicketId,
|
|
814
|
+
startContexts: true,
|
|
815
|
+
worktrees,
|
|
816
|
+
fetchImpl: io.fetch,
|
|
817
|
+
});
|
|
818
|
+
const results = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
|
|
819
|
+
if (!command.json) {
|
|
820
|
+
for (const [index, outcome] of run.outcomes.entries()) {
|
|
821
|
+
const row = results[index];
|
|
822
|
+
if (!row)
|
|
823
|
+
continue;
|
|
824
|
+
const uploaded = outcome.sync.status === "uploaded";
|
|
825
|
+
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"]}` : ""}`);
|
|
826
|
+
}
|
|
827
|
+
writeLine(io.stdout, "4/5 Parent worktree sync complete.");
|
|
828
|
+
writeLine(io.stdout, `Uploaded: ${results.filter((row) => row["upload_status"] === "uploaded").length}/${results.length}`);
|
|
829
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
830
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
831
|
+
writeLine(io.stdout, "5/5 Status ready.");
|
|
832
|
+
writeLine(run.ok ? io.stdout : io.stderr, run.ok
|
|
833
|
+
? "PASS: Cockpit collector is ready for harvest."
|
|
834
|
+
: "BLOCKED: One or more worktree uploads were spooled.");
|
|
835
|
+
writeLine(io.stdout, `Open: ${command.dashboardUrl}/my-work`);
|
|
836
|
+
}
|
|
837
|
+
return { ok: run.ok, results, codex_sessions: run.summary };
|
|
838
|
+
}
|
|
839
|
+
function worktreeSyncRow(outcome, attribution) {
|
|
840
|
+
const { worktree, context, sync } = outcome;
|
|
841
|
+
const attributedSessionCount = attribution.results.filter((result) => result.state === "attributed" &&
|
|
842
|
+
result.worktree?.worktree_fingerprint === worktree.worktree_fingerprint).length;
|
|
843
|
+
return {
|
|
844
|
+
repo_label: context?.repo_label ?? worktree.repo_label,
|
|
845
|
+
repo_fingerprint: context?.repo_fingerprint ?? worktree.repo_fingerprint,
|
|
846
|
+
worktree_label: context?.worktree_label ?? worktree.worktree_label,
|
|
847
|
+
worktree_fingerprint: context?.worktree_fingerprint ?? worktree.worktree_fingerprint,
|
|
848
|
+
branch: context?.branch ?? worktree.branch,
|
|
849
|
+
head_sha: sync.head_sha ?? worktree.head_sha,
|
|
850
|
+
work_context_id: context?.work_context_id ?? sync.work_context_id,
|
|
851
|
+
upload_status: sync.status,
|
|
852
|
+
raw_evidence_file_count: sync.raw_evidence_file_count,
|
|
853
|
+
raw_evidence_uploaded_object_count: sync.raw_evidence_uploaded_object_count,
|
|
854
|
+
raw_evidence_uploaded_chunk_count: sync.raw_evidence_uploaded_chunk_count,
|
|
855
|
+
raw_evidence_reused_count: sync.raw_evidence_reused_count,
|
|
856
|
+
raw_evidence_failed_count: sync.raw_evidence_failed_count,
|
|
857
|
+
raw_evidence_failure_reasons: sync.raw_evidence_failure_reasons,
|
|
858
|
+
attributed_session_count: attributedSessionCount,
|
|
859
|
+
cursor_tracked_object_count: sync.cursor_tracked_object_count,
|
|
860
|
+
failure_reason: sync.status === "spooled" ? sync.failure_reason : null,
|
|
861
|
+
};
|
|
862
|
+
}
|
|
563
863
|
async function runLogin(command, io) {
|
|
564
864
|
const result = await pairLocalCollector({
|
|
565
865
|
homeDir: command.homeDir,
|
|
@@ -636,7 +936,7 @@ function nextStepForOnboardBlocker(blocker) {
|
|
|
636
936
|
case "ticket_binding":
|
|
637
937
|
return "Run `cockpit start --ticket <id>` when actual ticket work begins, then run `cockpit sync`.";
|
|
638
938
|
case "device_pairing":
|
|
639
|
-
return "
|
|
939
|
+
return "Approve from Ambient -> Collector approvals, or paste the pairing code there, then rerun `cockpit onboard`.";
|
|
640
940
|
case "network_or_ingest":
|
|
641
941
|
return "Check dashboard URL/network, then run `cockpit sync --json` or rerun `cockpit onboard`.";
|
|
642
942
|
case "install":
|
|
@@ -659,6 +959,26 @@ async function runLogout(command, io) {
|
|
|
659
959
|
return 0;
|
|
660
960
|
}
|
|
661
961
|
async function runStart(command, io) {
|
|
962
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
963
|
+
if (worktrees.length > 1) {
|
|
964
|
+
const contexts = await Promise.all(worktrees.map((worktree) => startLocalWorkContext({
|
|
965
|
+
homeDir: command.homeDir,
|
|
966
|
+
repoRoot: worktree.repo_root,
|
|
967
|
+
branch: command.branch,
|
|
968
|
+
activeTicketId: command.activeTicketId,
|
|
969
|
+
operatorId: command.operatorId,
|
|
970
|
+
sessionId: command.sessionId,
|
|
971
|
+
})));
|
|
972
|
+
if (command.json) {
|
|
973
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", contexts }, null, 2));
|
|
974
|
+
return 0;
|
|
975
|
+
}
|
|
976
|
+
writeLine(io.stdout, `Cockpit parent work context active for ${contexts.length} worktree(s).`);
|
|
977
|
+
for (const context of contexts) {
|
|
978
|
+
writeLine(io.stdout, `- ${context.repo_label ?? context.repo}/${context.worktree_label ?? "worktree"} · ${context.branch} · ${context.work_context_id}`);
|
|
979
|
+
}
|
|
980
|
+
return 0;
|
|
981
|
+
}
|
|
662
982
|
const context = await startLocalWorkContext(command);
|
|
663
983
|
if (command.json) {
|
|
664
984
|
writeLine(io.stdout, JSON.stringify(context, null, 2));
|
|
@@ -672,23 +992,57 @@ async function runStart(command, io) {
|
|
|
672
992
|
return 0;
|
|
673
993
|
}
|
|
674
994
|
async function runSync(command, io) {
|
|
675
|
-
const
|
|
995
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
996
|
+
const run = await runAttributedWorktreeSync({
|
|
676
997
|
homeDir: command.homeDir,
|
|
677
|
-
repoRoot: command.repoRoot,
|
|
678
998
|
dashboardUrl: command.dashboardUrl,
|
|
679
|
-
|
|
999
|
+
startContexts: false,
|
|
1000
|
+
worktrees,
|
|
1001
|
+
fetchImpl: io.fetch,
|
|
680
1002
|
});
|
|
1003
|
+
if (worktrees.length > 1) {
|
|
1004
|
+
const rows = run.outcomes.map((outcome) => worktreeSyncRow(outcome, run.attribution));
|
|
1005
|
+
if (command.json) {
|
|
1006
|
+
writeLine(io.stdout, JSON.stringify({
|
|
1007
|
+
mode: "multi_repo",
|
|
1008
|
+
status: run.ok ? "uploaded" : "spooled",
|
|
1009
|
+
results: run.outcomes.map((outcome) => outcome.sync),
|
|
1010
|
+
repos: rows,
|
|
1011
|
+
codex_sessions: run.summary,
|
|
1012
|
+
}, null, 2));
|
|
1013
|
+
return run.ok ? 0 : 1;
|
|
1014
|
+
}
|
|
1015
|
+
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).`);
|
|
1016
|
+
for (const outcome of run.outcomes) {
|
|
1017
|
+
const { worktree, sync } = outcome;
|
|
1018
|
+
const uploaded = sync.status === "uploaded";
|
|
1019
|
+
const failureSuffix = sync.status === "spooled" ? ` reason:${sync.failure_reason}` : "";
|
|
1020
|
+
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}`);
|
|
1021
|
+
}
|
|
1022
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
1023
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1024
|
+
return run.ok ? 0 : 1;
|
|
1025
|
+
}
|
|
1026
|
+
const result = run.outcomes[0]?.sync;
|
|
1027
|
+
if (!result) {
|
|
1028
|
+
throw new Error("Sync produced no result for the repo worktree.");
|
|
1029
|
+
}
|
|
681
1030
|
if (command.json) {
|
|
682
|
-
writeLine(io.stdout, JSON.stringify(result, null, 2));
|
|
1031
|
+
writeLine(io.stdout, JSON.stringify({ ...result, codex_sessions: run.summary }, null, 2));
|
|
683
1032
|
return result.status === "uploaded" ? 0 : 1;
|
|
684
1033
|
}
|
|
685
1034
|
if (result.status === "uploaded") {
|
|
686
1035
|
writeLine(io.stdout, "Cockpit ambient envelope uploaded.");
|
|
687
1036
|
writeLine(io.stdout, `Ticket: ${displayTicketId(result.ticket_id)}`);
|
|
688
1037
|
writeLine(io.stdout, `Context: ${result.work_context_id}`);
|
|
1038
|
+
writeLine(io.stdout, `Head: ${shortSha(result.head_sha)}`);
|
|
689
1039
|
writeLine(io.stdout, `Facts: ${result.event_count}`);
|
|
690
1040
|
writeLine(io.stdout, `Risk flags: ${result.risk_flag_count}`);
|
|
691
1041
|
writeLine(io.stdout, `Raw evidence files: ${result.raw_evidence_file_count}`);
|
|
1042
|
+
writeLine(io.stdout, rawEvidenceSyncLine(result));
|
|
1043
|
+
writeLine(io.stdout, codexSummaryLine(run.summary));
|
|
1044
|
+
writeLine(io.stdout, attributionReportLine(run.summary));
|
|
1045
|
+
writeLine(io.stdout, cursorStatusLine(result));
|
|
692
1046
|
return 0;
|
|
693
1047
|
}
|
|
694
1048
|
writeLine(io.stderr, "Cockpit ambient upload failed; safe retry metadata was spooled.");
|
|
@@ -698,6 +1052,25 @@ async function runSync(command, io) {
|
|
|
698
1052
|
return 1;
|
|
699
1053
|
}
|
|
700
1054
|
async function runStatus(command, io) {
|
|
1055
|
+
const worktrees = await discoverCommandWorktrees(command.repoRoot);
|
|
1056
|
+
if (worktrees.length > 1) {
|
|
1057
|
+
const statuses = await Promise.all(worktrees.map(async (worktree) => ({
|
|
1058
|
+
...(await inspectLocalCollectorStatus({
|
|
1059
|
+
homeDir: command.homeDir,
|
|
1060
|
+
repoRoot: worktree.repo_root,
|
|
1061
|
+
})),
|
|
1062
|
+
head_sha: worktree.head_sha,
|
|
1063
|
+
})));
|
|
1064
|
+
if (command.json) {
|
|
1065
|
+
writeLine(io.stdout, JSON.stringify({ mode: "multi_repo", statuses }, null, 2));
|
|
1066
|
+
return 0;
|
|
1067
|
+
}
|
|
1068
|
+
writeLine(io.stdout, "Cockpit parent status");
|
|
1069
|
+
for (const status of statuses) {
|
|
1070
|
+
writeLine(io.stdout, `- ${status.repo_label ?? status.repo}/${status.worktree_label ?? "worktree"} · ${status.branch} · head:${shortSha(status.head_sha)} · ${status.upload_state}`);
|
|
1071
|
+
}
|
|
1072
|
+
return 0;
|
|
1073
|
+
}
|
|
701
1074
|
const status = await inspectLocalCollectorStatus(command);
|
|
702
1075
|
if (command.json) {
|
|
703
1076
|
writeLine(io.stdout, JSON.stringify(status, null, 2));
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
const CURSOR_FILENAME = "raw-evidence.json";
|
|
4
|
+
const MAX_TRACKED_OBJECTS = 500;
|
|
5
|
+
const MAX_TRACKED_SESSIONS = 500;
|
|
6
|
+
export function emptyRawEvidenceCursorState() {
|
|
7
|
+
return {
|
|
8
|
+
schema_version: "cockpit-raw-evidence-cursor.v1",
|
|
9
|
+
updated_at: null,
|
|
10
|
+
objects: {},
|
|
11
|
+
sessions: {},
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
export async function readRawEvidenceCursor(paths) {
|
|
15
|
+
try {
|
|
16
|
+
const raw = JSON.parse(await fs.readFile(rawEvidenceCursorPath(paths), "utf8"));
|
|
17
|
+
return parseCursorState(raw);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return emptyRawEvidenceCursorState();
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export async function writeRawEvidenceCursor(paths, state) {
|
|
24
|
+
const filePath = rawEvidenceCursorPath(paths);
|
|
25
|
+
await fs.mkdir(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
26
|
+
const pruned = pruneCursorState(state);
|
|
27
|
+
await fs.writeFile(filePath, `${JSON.stringify(pruned, null, 2)}\n`, {
|
|
28
|
+
mode: 0o600,
|
|
29
|
+
});
|
|
30
|
+
if (process.platform !== "win32") {
|
|
31
|
+
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
export function markObjectCommitted(state, contentHash, entry) {
|
|
35
|
+
state.objects[contentHash] = entry;
|
|
36
|
+
}
|
|
37
|
+
export function hasCommittedObject(state, contentHash) {
|
|
38
|
+
return Boolean(contentHash && state.objects[contentHash]);
|
|
39
|
+
}
|
|
40
|
+
export function recordSessionObservation(state, codexSessionId, entry) {
|
|
41
|
+
state.sessions[codexSessionId] = entry;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Sessions tracked by the cursor that no longer appear in the current scan
|
|
45
|
+
* window are stale: previously observed work that stopped producing evidence.
|
|
46
|
+
*/
|
|
47
|
+
export function countStaleSessions(state, seenSessionIds) {
|
|
48
|
+
return Object.keys(state.sessions).filter((id) => !seenSessionIds.has(id))
|
|
49
|
+
.length;
|
|
50
|
+
}
|
|
51
|
+
function pruneCursorState(state) {
|
|
52
|
+
return {
|
|
53
|
+
...state,
|
|
54
|
+
objects: pruneNewest(state.objects, MAX_TRACKED_OBJECTS, (entry) => entry.committed_at),
|
|
55
|
+
sessions: pruneNewest(state.sessions, MAX_TRACKED_SESSIONS, (entry) => entry.last_seen_at),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
function pruneNewest(record, max, sortKey) {
|
|
59
|
+
const entries = Object.entries(record);
|
|
60
|
+
if (entries.length <= max)
|
|
61
|
+
return record;
|
|
62
|
+
entries.sort((a, b) => sortKey(b[1]).localeCompare(sortKey(a[1])));
|
|
63
|
+
return Object.fromEntries(entries.slice(0, max));
|
|
64
|
+
}
|
|
65
|
+
function parseCursorState(value) {
|
|
66
|
+
if (!value || typeof value !== "object")
|
|
67
|
+
return emptyRawEvidenceCursorState();
|
|
68
|
+
const record = value;
|
|
69
|
+
return {
|
|
70
|
+
schema_version: "cockpit-raw-evidence-cursor.v1",
|
|
71
|
+
updated_at: optionalString(record["updated_at"]),
|
|
72
|
+
objects: parseRecord(record["objects"], parseObjectEntry),
|
|
73
|
+
sessions: parseRecord(record["sessions"], parseSessionEntry),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
function parseRecord(value, parseEntry) {
|
|
77
|
+
if (!value || typeof value !== "object")
|
|
78
|
+
return {};
|
|
79
|
+
const out = {};
|
|
80
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
81
|
+
const parsed = parseEntry(entry);
|
|
82
|
+
if (parsed)
|
|
83
|
+
out[key] = parsed;
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
function parseObjectEntry(value) {
|
|
88
|
+
if (!value || typeof value !== "object")
|
|
89
|
+
return null;
|
|
90
|
+
const record = value;
|
|
91
|
+
const objectKey = optionalString(record["object_key"]);
|
|
92
|
+
const committedAt = optionalString(record["committed_at"]);
|
|
93
|
+
if (!objectKey || !committedAt)
|
|
94
|
+
return null;
|
|
95
|
+
return {
|
|
96
|
+
object_key: objectKey,
|
|
97
|
+
byte_size: optionalNumber(record["byte_size"]),
|
|
98
|
+
committed_at: committedAt,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function parseSessionEntry(value) {
|
|
102
|
+
if (!value || typeof value !== "object")
|
|
103
|
+
return null;
|
|
104
|
+
const record = value;
|
|
105
|
+
const lastSeenAt = optionalString(record["last_seen_at"]);
|
|
106
|
+
const state = optionalString(record["state"]);
|
|
107
|
+
if (!lastSeenAt || !state)
|
|
108
|
+
return null;
|
|
109
|
+
return {
|
|
110
|
+
file_hash_sha256: optionalString(record["file_hash_sha256"]),
|
|
111
|
+
file_mtime_ms: optionalNumber(record["file_mtime_ms"]),
|
|
112
|
+
byte_size: optionalNumber(record["byte_size"]),
|
|
113
|
+
byte_offset: optionalNumber(record["byte_offset"]),
|
|
114
|
+
state,
|
|
115
|
+
reason: optionalString(record["reason"]) ?? "unknown",
|
|
116
|
+
worktree_fingerprint: optionalString(record["worktree_fingerprint"]),
|
|
117
|
+
uploaded_object_key: optionalString(record["uploaded_object_key"]),
|
|
118
|
+
last_seen_at: lastSeenAt,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
function rawEvidenceCursorPath(paths) {
|
|
122
|
+
return path.join(paths.cursors_dir, CURSOR_FILENAME);
|
|
123
|
+
}
|
|
124
|
+
function optionalString(value) {
|
|
125
|
+
return typeof value === "string" && value.trim() ? value : null;
|
|
126
|
+
}
|
|
127
|
+
function optionalNumber(value) {
|
|
128
|
+
return typeof value === "number" && Number.isFinite(value) ? value : 0;
|
|
129
|
+
}
|