@bli-cockpit/cli 0.2.49 → 0.2.51
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/raw-evidence-claude-reader.js +108 -0
- package/dist/adapters/raw-evidence-codex-reader.js +147 -0
- package/dist/adapters/raw-evidence-collection-state.js +199 -0
- package/dist/adapters/raw-evidence-facts.js +338 -0
- package/dist/adapters/raw-evidence-git-diff-reader.js +187 -0
- package/dist/adapters/raw-evidence-image-reader.js +107 -0
- package/dist/adapters/raw-evidence-sanitize.js +56 -0
- package/dist/adapters/raw-evidence-transcript-file.js +182 -0
- package/dist/adapters/raw-evidence.js +63 -1183
- package/dist/commands/backfill-batches.js +34 -0
- package/dist/commands/backfill-candidates.js +54 -0
- package/dist/commands/backfill-checkpoint.js +101 -0
- package/dist/commands/backfill-command-line.js +70 -0
- package/dist/commands/backfill-evidence-outcomes.js +104 -0
- package/dist/commands/backfill-issues.js +265 -0
- package/dist/commands/backfill-output.js +75 -0
- package/dist/commands/backfill-plan.js +71 -0
- package/dist/commands/backfill-reasons.js +107 -0
- package/dist/commands/backfill-report.js +298 -0
- package/dist/commands/backfill-result.js +150 -0
- package/dist/commands/backfill-scan.js +274 -0
- package/dist/commands/backfill-scope.js +114 -0
- package/dist/commands/backfill-session-report.js +145 -0
- package/dist/commands/backfill-types.js +1 -0
- package/dist/commands/backfill-upload.js +212 -0
- package/dist/commands/backfill.js +41 -1961
- package/dist/commands/doctor.js +57 -0
- package/dist/commands/jarvis-trace.js +184 -0
- package/dist/commands/jarvis.js +144 -4
- package/dist/commands/local-args-collector.js +26 -0
- package/dist/commands/local-args-tower.js +21 -0
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help.js +19 -2
- package/dist/commands/local.js +3 -0
- package/dist/commands/memory-install-claude.js +294 -0
- package/dist/commands/memory-install-codex.js +205 -0
- package/dist/commands/memory-install-contract.js +286 -0
- package/dist/commands/memory-install-files.js +63 -0
- package/dist/commands/memory-install-skills.js +121 -0
- package/dist/commands/memory-install-toml.js +265 -0
- package/dist/commands/memory-install.js +465 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/sync-followups.js +105 -0
- package/dist/commands/sync.js +7 -1
- package/dist/local-state-attributed-target.js +75 -0
- package/dist/local-state-config.js +147 -0
- package/dist/local-state-files.js +59 -0
- package/dist/local-state-identity.js +73 -0
- package/dist/local-state-pairing.js +263 -0
- package/dist/local-state-paths.js +61 -0
- package/dist/local-state-session.js +68 -0
- package/dist/local-state-status.js +163 -0
- package/dist/local-state-work-context.js +190 -0
- package/dist/local-state.js +34 -848
- package/dist/tower-client.js +3 -2
- package/dist/tower-stream.js +57 -3
- package/package.json +2 -1
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { LocalCollectorSessionFileSchema, LocalUserSessionReferenceSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
3
|
+
import { readJsonFile } from "./local-state-files.js";
|
|
4
|
+
/**
|
|
5
|
+
* Who this machine is signed in as, in the shape every caller wants: a
|
|
6
|
+
* reference that always exists, so nothing downstream has to handle "there is
|
|
7
|
+
* no session file yet" as an error.
|
|
8
|
+
*/
|
|
9
|
+
export async function readLocalSessionReference(paths, fallback = {}) {
|
|
10
|
+
try {
|
|
11
|
+
const rawSession = await readJsonFile(paths.session_file);
|
|
12
|
+
const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
|
|
13
|
+
if (collectorSession.success) {
|
|
14
|
+
return toSessionReference(collectorSession.data);
|
|
15
|
+
}
|
|
16
|
+
return LocalUserSessionReferenceSchema.parse(rawSession);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
// `session_state: "missing"` is correct before login and says so loudly
|
|
20
|
+
// enough on its own. It is also what a session file that EXISTS but is
|
|
21
|
+
// corrupt, truncated or unreadable collapses to — a paired machine that
|
|
22
|
+
// silently reads as never-signed-in, which is indistinguishable in every
|
|
23
|
+
// downstream receipt (BLI-3238).
|
|
24
|
+
if (!isMissingFileFailure(error)) {
|
|
25
|
+
console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
|
|
26
|
+
reason: "session_file_unusable",
|
|
27
|
+
...describeError(error),
|
|
28
|
+
}));
|
|
29
|
+
}
|
|
30
|
+
return LocalUserSessionReferenceSchema.parse({
|
|
31
|
+
operator_id: fallback.operatorId ?? "unknown",
|
|
32
|
+
auth_subject_id: "unknown",
|
|
33
|
+
session_id: fallback.sessionId ?? "missing",
|
|
34
|
+
session_file_path: paths.session_file,
|
|
35
|
+
session_state: "missing",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The strict read, for the callers that need the device token itself and
|
|
41
|
+
* cannot proceed on a "missing" reference.
|
|
42
|
+
*/
|
|
43
|
+
export async function readLocalCollectorSessionFile(paths) {
|
|
44
|
+
return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Drops the device token and re-states expiry as a session state, so an
|
|
48
|
+
* expired session cannot travel as `valid` just because the file says so.
|
|
49
|
+
*/
|
|
50
|
+
export function toSessionReference(session) {
|
|
51
|
+
const now = Date.now();
|
|
52
|
+
const expiresAt = Date.parse(session.expires_at ?? "");
|
|
53
|
+
const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
|
|
54
|
+
return LocalUserSessionReferenceSchema.parse({
|
|
55
|
+
operator_id: session.operator_id,
|
|
56
|
+
auth_subject_id: session.auth_subject_id,
|
|
57
|
+
email: session.email,
|
|
58
|
+
team_id: session.team_id,
|
|
59
|
+
device_id: session.device_id,
|
|
60
|
+
device_name: session.device_name,
|
|
61
|
+
session_id: session.session_id,
|
|
62
|
+
session_file_path: session.session_file_path,
|
|
63
|
+
session_state: sessionState,
|
|
64
|
+
auth_method: session.auth_method,
|
|
65
|
+
issued_at: session.issued_at,
|
|
66
|
+
expires_at: session.expires_at,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import os from "node:os";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
4
|
+
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
5
|
+
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
6
|
+
import { getCollectorRuntimePaths, } from "./local-state-paths.js";
|
|
7
|
+
import { LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
|
|
8
|
+
import { readLocalSessionReference } from "./local-state-session.js";
|
|
9
|
+
import { resolveIdentityOrFallback } from "./local-state-identity.js";
|
|
10
|
+
import { readLocalWorkContextForRepo, workDisplayLabel, } from "./local-state-work-context.js";
|
|
11
|
+
/**
|
|
12
|
+
* `cockpit status` — the one answer to "is this machine collecting, and is
|
|
13
|
+
* anything landing?". A green status must never be able to hide a session that
|
|
14
|
+
* never arrived, so every queue and every stuck object is reported here.
|
|
15
|
+
*/
|
|
16
|
+
export async function inspectLocalCollectorStatus(options = {}) {
|
|
17
|
+
const now = options.now ?? new Date();
|
|
18
|
+
const reading = await readCollectorStatus(options, now);
|
|
19
|
+
const { paths, config, session, identity, context, branch } = reading;
|
|
20
|
+
const { uploadSpool, healthOutbox, stuckEvidence } = reading;
|
|
21
|
+
const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
|
|
22
|
+
const uploadState = classifyUploadState(config, uploadSpool, session);
|
|
23
|
+
return {
|
|
24
|
+
installed: Boolean(config),
|
|
25
|
+
config_file: paths.config_file,
|
|
26
|
+
session_file: paths.session_file,
|
|
27
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
28
|
+
config_collector_version: config?.collector_version ?? null,
|
|
29
|
+
session_state: session.session_state,
|
|
30
|
+
repo: context?.repo ?? identity.repo_root,
|
|
31
|
+
branch,
|
|
32
|
+
active_ticket_id: context?.active_ticket_id ?? null,
|
|
33
|
+
work_label: context ? workDisplayLabel(context) : null,
|
|
34
|
+
work_id: context?.work_context_id ?? null,
|
|
35
|
+
work_context_id: context?.work_context_id ?? null,
|
|
36
|
+
repo_label: context?.repo_label ?? identity.repo_label,
|
|
37
|
+
repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
|
|
38
|
+
worktree_label: context?.worktree_label ?? identity.worktree_label,
|
|
39
|
+
worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
|
|
40
|
+
worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
|
|
41
|
+
collector_freshness: freshness,
|
|
42
|
+
upload_state: uploadState,
|
|
43
|
+
last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
|
|
44
|
+
last_upload_success_at: uploadSpool.last_upload_success_at,
|
|
45
|
+
last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
|
|
46
|
+
pending_upload_count: uploadSpool.pending_upload_count,
|
|
47
|
+
upload_retry_command: uploadSpool.retry_command,
|
|
48
|
+
pending_health_receipt_count: healthOutbox.pending_count,
|
|
49
|
+
oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
|
|
50
|
+
last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
|
|
51
|
+
stuck_evidence_object_count: stuckEvidence.stuck_object_count,
|
|
52
|
+
stuck_evidence_held_count: stuckEvidence.held_object_count,
|
|
53
|
+
stuck_evidence_max_attempts: stuckEvidence.max_attempts,
|
|
54
|
+
stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
|
|
55
|
+
stuck_evidence_reasons: stuckEvidence.reasons,
|
|
56
|
+
details: describeCollectorStatus(reading),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
async function readCollectorStatus(options, now) {
|
|
60
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
61
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
62
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
63
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
64
|
+
const session = await readLocalSessionReference(paths, {
|
|
65
|
+
operatorId: options.operatorId,
|
|
66
|
+
sessionId: options.sessionId,
|
|
67
|
+
});
|
|
68
|
+
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
69
|
+
const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
|
|
70
|
+
const branch = options.branch ?? identity.branch;
|
|
71
|
+
const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
|
|
72
|
+
summarizeLocalUploadSpool(paths),
|
|
73
|
+
summarizeInstallEventOutbox(paths),
|
|
74
|
+
readRawEvidenceStagingState(paths.state_dir),
|
|
75
|
+
]);
|
|
76
|
+
const stuckEvidence = summarizeStuckEvidence(stagingState, now);
|
|
77
|
+
return {
|
|
78
|
+
paths,
|
|
79
|
+
config,
|
|
80
|
+
session,
|
|
81
|
+
identity,
|
|
82
|
+
context,
|
|
83
|
+
branch,
|
|
84
|
+
uploadSpool,
|
|
85
|
+
healthOutbox,
|
|
86
|
+
stuckEvidence,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Not installed outranks everything, and a pending retry outranks a valid
|
|
90
|
+
// session. The return type is inferred: the four labels are already written
|
|
91
|
+
// once in `LocalCollectorStatus` and once here, and a third copy would be a
|
|
92
|
+
// third place to forget one.
|
|
93
|
+
function classifyUploadState(config, uploadSpool, session) {
|
|
94
|
+
return !config
|
|
95
|
+
? "not_installed"
|
|
96
|
+
: uploadSpool.pending_upload_count > 0
|
|
97
|
+
? "retry_pending"
|
|
98
|
+
: session.session_state === "valid"
|
|
99
|
+
? "ready"
|
|
100
|
+
: "local_only_missing_auth";
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The sentences an operator actually reads. Each of the first five is stated
|
|
104
|
+
* either way round, because a line that only appears on failure cannot answer
|
|
105
|
+
* "did anything land at all today?"; the last four appear only when there is a
|
|
106
|
+
* queue, and each names its own reason and count.
|
|
107
|
+
*/
|
|
108
|
+
function describeCollectorStatus(reading) {
|
|
109
|
+
const { paths, config, session, context, uploadSpool, healthOutbox, stuckEvidence } = reading;
|
|
110
|
+
const details = [];
|
|
111
|
+
details.push(config
|
|
112
|
+
? `Config exists at ${paths.config_file}.`
|
|
113
|
+
: "Local config missing. Run `cockpit install`.");
|
|
114
|
+
details.push(session.session_state === "valid"
|
|
115
|
+
? "Local user session is valid."
|
|
116
|
+
: "Local user session missing or not paired; upload remains local-only.");
|
|
117
|
+
details.push(context
|
|
118
|
+
? `Active work ${workDisplayLabel(context)} (${context.work_context_id}) last updated ${context.updated_at ?? context.started_at}.`
|
|
119
|
+
: "Active work context missing. Run `cockpit start`.");
|
|
120
|
+
details.push(uploadSpool.last_upload_attempt_at
|
|
121
|
+
? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
|
|
122
|
+
: "No upload has been attempted yet.");
|
|
123
|
+
details.push(uploadSpool.last_upload_success_at
|
|
124
|
+
? `Last upload success: ${uploadSpool.last_upload_success_at}.`
|
|
125
|
+
: "No successful upload recorded yet.");
|
|
126
|
+
if (uploadSpool.last_upload_failure_reason) {
|
|
127
|
+
details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
|
|
128
|
+
}
|
|
129
|
+
if (uploadSpool.pending_upload_count > 0) {
|
|
130
|
+
details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
|
|
131
|
+
}
|
|
132
|
+
if (healthOutbox.pending_count > 0) {
|
|
133
|
+
details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
|
|
134
|
+
}
|
|
135
|
+
if (stuckEvidence.stuck_object_count > 0) {
|
|
136
|
+
details.push(`Raw evidence stuck: ${stuckEvidence.stuck_object_count} object(s) have never been accepted (${stuckEvidence.held_object_count} waiting on backoff, worst ${stuckEvidence.max_attempts} attempt(s) since ${stuckEvidence.oldest_first_failed_at ?? "unknown"}) — reasons: ${stuckEvidence.reasons.join(", ") || "unknown"}.`);
|
|
137
|
+
}
|
|
138
|
+
return details;
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Whether this machine looks alive right now. A successful upload counts as
|
|
142
|
+
* activity as much as a context update does, so a long session that is still
|
|
143
|
+
* shipping data does not read as stale.
|
|
144
|
+
*/
|
|
145
|
+
export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
|
|
146
|
+
if (!context)
|
|
147
|
+
return "missing";
|
|
148
|
+
const latestActivityAt = latestTimestamp([
|
|
149
|
+
context.updated_at ?? context.started_at,
|
|
150
|
+
lastUploadSuccessAt,
|
|
151
|
+
]);
|
|
152
|
+
if (latestActivityAt === null)
|
|
153
|
+
return "stale";
|
|
154
|
+
return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
|
|
155
|
+
}
|
|
156
|
+
function latestTimestamp(values) {
|
|
157
|
+
const timestamps = values
|
|
158
|
+
.map((value) => Date.parse(value ?? ""))
|
|
159
|
+
.filter((value) => Number.isFinite(value));
|
|
160
|
+
if (timestamps.length === 0)
|
|
161
|
+
return null;
|
|
162
|
+
return Math.max(...timestamps);
|
|
163
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { LocalWorkContextSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { ensureRuntimeDirectories, getCollectorRuntimePaths, workContextFile, } from "./local-state-paths.js";
|
|
6
|
+
import { readJsonFile, writeJsonFile } from "./local-state-files.js";
|
|
7
|
+
import { LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
|
|
8
|
+
import { readLocalSessionReference } from "./local-state-session.js";
|
|
9
|
+
import { resolveIdentityOrFallback } from "./local-state-identity.js";
|
|
10
|
+
import { validateAttributedTargetIdentity } from "./local-state-attributed-target.js";
|
|
11
|
+
/** What `cockpit start` binds: the repo, branch and ticket this session's work belongs to. */
|
|
12
|
+
export async function startLocalWorkContext(options = {}) {
|
|
13
|
+
return writeLocalWorkContext(options);
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Starts a context for a transcript-attributed sync target that does not exist
|
|
17
|
+
* in the current git inventory, such as an approved wrapper folder or a
|
|
18
|
+
* deleted repo reconstructed from transcript provenance. This internal path
|
|
19
|
+
* validates the exact target identity before writing any context state.
|
|
20
|
+
*/
|
|
21
|
+
export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
|
|
22
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
23
|
+
const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
|
|
24
|
+
return writeLocalWorkContext(options, identity);
|
|
25
|
+
}
|
|
26
|
+
async function writeLocalWorkContext(options, attributedIdentity) {
|
|
27
|
+
if (options.activeTicketId && options.clearTicket) {
|
|
28
|
+
throw new Error("--ticket and --clear-ticket cannot be combined.");
|
|
29
|
+
}
|
|
30
|
+
const now = options.now ?? new Date();
|
|
31
|
+
const homeDir = options.homeDir ?? os.homedir();
|
|
32
|
+
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
33
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
34
|
+
await ensureRuntimeDirectories(paths);
|
|
35
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
36
|
+
const session = await readLocalSessionReference(paths, {
|
|
37
|
+
operatorId: options.operatorId,
|
|
38
|
+
sessionId: options.sessionId,
|
|
39
|
+
});
|
|
40
|
+
const identity = attributedIdentity ??
|
|
41
|
+
(await resolveIdentityOrFallback(repoRoot, options.branch));
|
|
42
|
+
const branch = attributedIdentity
|
|
43
|
+
? attributedIdentity.branch
|
|
44
|
+
: (options.branch ?? identity.branch);
|
|
45
|
+
const sessionId = options.sessionId ??
|
|
46
|
+
(session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
|
|
47
|
+
const operatorId = options.operatorId ?? session.operator_id;
|
|
48
|
+
const deviceId = session.device_id ?? config?.device_id ?? "device-unknown";
|
|
49
|
+
const workContextId = stableWorkContextId({
|
|
50
|
+
operatorId,
|
|
51
|
+
deviceId,
|
|
52
|
+
repoFingerprint: identity.repo_fingerprint,
|
|
53
|
+
worktreeFingerprint: identity.worktree_fingerprint,
|
|
54
|
+
});
|
|
55
|
+
const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
56
|
+
const context = buildWorkContext({
|
|
57
|
+
options,
|
|
58
|
+
now,
|
|
59
|
+
identity,
|
|
60
|
+
branch,
|
|
61
|
+
operatorId,
|
|
62
|
+
sessionId,
|
|
63
|
+
workContextId,
|
|
64
|
+
existingContext,
|
|
65
|
+
});
|
|
66
|
+
await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
|
|
67
|
+
await writeJsonFile(paths.active_work_context_file, context);
|
|
68
|
+
return context;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* The context record itself, kept in one piece because it is a schema: every
|
|
72
|
+
* field an ambient envelope will later carry as provenance is decided here.
|
|
73
|
+
*/
|
|
74
|
+
function buildWorkContext(input) {
|
|
75
|
+
const { options, now, identity, branch, operatorId, sessionId, workContextId, existingContext } = input;
|
|
76
|
+
const { activeTicketId, ticketBindingCandidates } = resolveTicketBinding(options, existingContext);
|
|
77
|
+
return LocalWorkContextSchema.parse({
|
|
78
|
+
work_context_id: workContextId,
|
|
79
|
+
repo: identity.repo_root,
|
|
80
|
+
branch,
|
|
81
|
+
repo_label: identity.repo_label,
|
|
82
|
+
repo_fingerprint: identity.repo_fingerprint,
|
|
83
|
+
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
84
|
+
head_sha: identity.head_sha ?? undefined,
|
|
85
|
+
worktree_label: identity.worktree_label,
|
|
86
|
+
worktree_fingerprint: identity.worktree_fingerprint,
|
|
87
|
+
worktree_is_primary: identity.worktree_is_primary,
|
|
88
|
+
operator_id: operatorId,
|
|
89
|
+
session_id: sessionId,
|
|
90
|
+
started_at: existingContext?.started_at ?? now.toISOString(),
|
|
91
|
+
updated_at: now.toISOString(),
|
|
92
|
+
active_ticket_id: activeTicketId,
|
|
93
|
+
ticket_binding_candidates: ticketBindingCandidates,
|
|
94
|
+
topic_label: options.topicLabel,
|
|
95
|
+
topic_summary_redacted: options.topicSummaryRedacted,
|
|
96
|
+
work_intent: options.workIntent,
|
|
97
|
+
work_phase: options.workPhase,
|
|
98
|
+
intent_source: options.intentSource,
|
|
99
|
+
intent_confidence: options.intentConfidence,
|
|
100
|
+
pull_request_url: existingContext?.pull_request_url,
|
|
101
|
+
provenance: {
|
|
102
|
+
capture_source: "collector_runtime",
|
|
103
|
+
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
104
|
+
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
105
|
+
repo: identity.repo_root,
|
|
106
|
+
branch,
|
|
107
|
+
repo_label: identity.repo_label,
|
|
108
|
+
repo_fingerprint: identity.repo_fingerprint,
|
|
109
|
+
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
110
|
+
worktree_label: identity.worktree_label,
|
|
111
|
+
worktree_fingerprint: identity.worktree_fingerprint,
|
|
112
|
+
worktree_is_primary: identity.worktree_is_primary,
|
|
113
|
+
operator_id: operatorId,
|
|
114
|
+
session_id: sessionId,
|
|
115
|
+
work_context_id: workContextId,
|
|
116
|
+
},
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Three answers, not two: a named ticket binds, `--clear-ticket` unbinds, and
|
|
121
|
+
* saying neither leaves whatever the previous `cockpit start` bound in place.
|
|
122
|
+
*/
|
|
123
|
+
// The return type is inferred deliberately: naming it would mean indexing the
|
|
124
|
+
// context schema by field name, and this module keeps its literals to the ones
|
|
125
|
+
// that reach disk.
|
|
126
|
+
function resolveTicketBinding(options, existingContext) {
|
|
127
|
+
const activeTicketId = options.clearTicket
|
|
128
|
+
? undefined
|
|
129
|
+
: (options.activeTicketId ?? existingContext?.active_ticket_id);
|
|
130
|
+
const ticketBindingCandidates = options.activeTicketId
|
|
131
|
+
? [
|
|
132
|
+
{
|
|
133
|
+
ticket_id: options.activeTicketId,
|
|
134
|
+
binding_source: "active_work_context",
|
|
135
|
+
confidence: 1,
|
|
136
|
+
evidence_labels: ["cockpit_start_ticket"],
|
|
137
|
+
},
|
|
138
|
+
]
|
|
139
|
+
: options.clearTicket
|
|
140
|
+
? []
|
|
141
|
+
: (existingContext?.ticket_binding_candidates ?? []);
|
|
142
|
+
return { activeTicketId, ticketBindingCandidates };
|
|
143
|
+
}
|
|
144
|
+
/** The active context: what the last `cockpit start` on this machine bound, whatever the folder. */
|
|
145
|
+
export async function readLocalWorkContext(paths) {
|
|
146
|
+
return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* The context for ONE repo, because a machine running two checkouts at once
|
|
150
|
+
* would otherwise attribute both to whichever was started last.
|
|
151
|
+
*/
|
|
152
|
+
export async function readLocalWorkContextForRepo(paths, repoRoot) {
|
|
153
|
+
const identity = await resolveIdentityOrFallback(repoRoot);
|
|
154
|
+
const context = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
155
|
+
if (context)
|
|
156
|
+
return context;
|
|
157
|
+
const active = await readLocalWorkContext(paths);
|
|
158
|
+
if (active.worktree_fingerprint === identity.worktree_fingerprint ||
|
|
159
|
+
path.resolve(active.repo) === identity.repo_root) {
|
|
160
|
+
return active;
|
|
161
|
+
}
|
|
162
|
+
throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --workspace "${identity.repo_root}"\`.`);
|
|
163
|
+
}
|
|
164
|
+
async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
|
|
165
|
+
return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* The same operator, device, repo and worktree must produce the same id on
|
|
169
|
+
* every run, because the ambient envelope joins sessions on it.
|
|
170
|
+
*/
|
|
171
|
+
function stableWorkContextId(input) {
|
|
172
|
+
return `work-${sha256([
|
|
173
|
+
input.operatorId,
|
|
174
|
+
input.deviceId,
|
|
175
|
+
input.repoFingerprint,
|
|
176
|
+
input.worktreeFingerprint,
|
|
177
|
+
].join(":")).slice(0, 32)}`;
|
|
178
|
+
}
|
|
179
|
+
function sha256(value) {
|
|
180
|
+
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* The label a person reads for a work context — repo over worktree, so two
|
|
184
|
+
* checkouts of one repo are told apart at a glance.
|
|
185
|
+
*/
|
|
186
|
+
export function workDisplayLabel(context) {
|
|
187
|
+
const repoLabel = (context.repo_label ?? path.basename(context.repo)) || "workspace";
|
|
188
|
+
const worktreeLabel = context.worktree_label ?? repoLabel;
|
|
189
|
+
return `${repoLabel}/${worktreeLabel}`;
|
|
190
|
+
}
|