@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
package/dist/local-state.js
CHANGED
|
@@ -1,853 +1,39 @@
|
|
|
1
|
-
import { getUserLocalCockpitPaths, LocalCollectorSessionFileSchema, LocalCollectorConfigSchema, LocalUserSessionReferenceSchema, LocalWorkContextSchema, } from "@bli-cockpit/telemetry-core";
|
|
2
|
-
import crypto from "node:crypto";
|
|
3
|
-
import { readFileSync } from "node:fs";
|
|
4
|
-
import fs from "node:fs/promises";
|
|
5
|
-
import os from "node:os";
|
|
6
|
-
import path from "node:path";
|
|
7
|
-
import { normalizeGitOrigin, repoFingerprintFromLocalRoot, repoFingerprintFromOrigin, repoLabelFromOrigin, resolveRepoWorktreeIdentity, stableWorktreeFingerprint, stableWorktreeRoot, } from "./repo-identity.js";
|
|
8
|
-
import { isSamePath, normalizeCollectionRoots, } from "./root-normalization.js";
|
|
9
|
-
import { summarizeLocalUploadSpool } from "./spool/local-spool.js";
|
|
10
|
-
import { summarizeInstallEventOutbox } from "./spool/install-event-outbox.js";
|
|
11
|
-
import { readRawEvidenceStagingState, summarizeStuckEvidence, } from "./raw-evidence-staging.js";
|
|
12
|
-
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
13
|
-
import { serverFailureDetail } from "./upload-http.js";
|
|
14
|
-
const localCollectorPackage = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
15
|
-
export const LOCAL_COLLECTOR_VERSION = typeof localCollectorPackage.version === "string"
|
|
16
|
-
? localCollectorPackage.version
|
|
17
|
-
: "0.0.0";
|
|
18
|
-
export const DEFAULT_DASHBOARD_URL = "https://bli-cockpit-dashboard.vercel.app";
|
|
19
|
-
export function getCollectorRuntimePaths(homeDir = os.homedir()) {
|
|
20
|
-
const paths = getUserLocalCockpitPaths(homeDir);
|
|
21
|
-
return {
|
|
22
|
-
...paths,
|
|
23
|
-
active_work_context_file: path.join(paths.state_dir, "active-work-context.json"),
|
|
24
|
-
work_contexts_dir: path.join(paths.state_dir, "work-contexts"),
|
|
25
|
-
};
|
|
26
|
-
}
|
|
27
|
-
export async function installLocalCollector(options = {}) {
|
|
28
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
29
|
-
const repoRoots = normalizeRepoRoots(options.repoRoots);
|
|
30
|
-
const repoRoot = path.resolve(options.repoRoot ?? repoRoots[0] ?? process.cwd());
|
|
31
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
32
|
-
await ensureRuntimeDirectories(paths);
|
|
33
|
-
const existingConfig = await readLocalCollectorConfig(paths).catch(() => null);
|
|
34
|
-
const defaultRepoPaths = normalizeRepoRoots([
|
|
35
|
-
...(options.replaceRepoRoots ? [] : (existingConfig?.default_repo_paths ?? [])),
|
|
36
|
-
...(repoRoots.length > 0 ? repoRoots : [repoRoot]),
|
|
37
|
-
]);
|
|
38
|
-
const rawEvidenceUpload = existingConfig?.raw_evidence_upload === "disabled" ||
|
|
39
|
-
existingConfig?.raw_evidence_upload === "remote_short_retention_opt_in"
|
|
40
|
-
? "remote_durable_opt_in"
|
|
41
|
-
: (existingConfig?.raw_evidence_upload ?? "remote_durable_opt_in");
|
|
42
|
-
const config = LocalCollectorConfigSchema.parse({
|
|
43
|
-
schema_version: "telemetry-core.v1",
|
|
44
|
-
dashboard_url: options.dashboardUrl ?? existingConfig?.dashboard_url ?? DEFAULT_DASHBOARD_URL,
|
|
45
|
-
supabase_url: options.supabaseUrl ?? existingConfig?.supabase_url,
|
|
46
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
47
|
-
device_id: existingConfig?.device_id ?? `device-${crypto.randomUUID()}`,
|
|
48
|
-
device_name: normalizeDeviceName(options.deviceName) ??
|
|
49
|
-
existingConfig?.device_name ??
|
|
50
|
-
defaultDeviceName(),
|
|
51
|
-
claimed_owner_email: existingConfig?.claimed_owner_email,
|
|
52
|
-
operator_id: existingConfig?.operator_id,
|
|
53
|
-
default_repo_paths: defaultRepoPaths,
|
|
54
|
-
raw_evidence_upload: rawEvidenceUpload,
|
|
55
|
-
session_file_path: paths.session_file,
|
|
56
|
-
state_dir_path: paths.state_dir,
|
|
57
|
-
});
|
|
58
|
-
await writeJsonFile(paths.config_file, config);
|
|
59
|
-
return {
|
|
60
|
-
config,
|
|
61
|
-
paths,
|
|
62
|
-
auth_pairing_state: "missing",
|
|
63
|
-
message: "Local collector installed. Pair/login is still required before remote upload.",
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
function normalizeRepoRoots(repoRoots) {
|
|
67
|
-
if (!repoRoots)
|
|
68
|
-
return [];
|
|
69
|
-
const seen = new Set();
|
|
70
|
-
const candidates = [];
|
|
71
|
-
for (const root of repoRoots) {
|
|
72
|
-
const resolved = path.resolve(root);
|
|
73
|
-
if (seen.has(resolved))
|
|
74
|
-
continue;
|
|
75
|
-
seen.add(resolved);
|
|
76
|
-
candidates.push(resolved);
|
|
77
|
-
}
|
|
78
|
-
return normalizeCollectionRoots(candidates);
|
|
79
|
-
}
|
|
80
1
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
* run). Never registers collection roots: root consent stays with
|
|
84
|
-
* onboard/install. A corrupt existing config is never overwritten.
|
|
85
|
-
*/
|
|
86
|
-
export async function ensureLocalCollectorConfig(options = {}) {
|
|
87
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
88
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
89
|
-
await ensureRuntimeDirectories(paths);
|
|
90
|
-
let existing = null;
|
|
91
|
-
try {
|
|
92
|
-
existing = await readLocalCollectorConfig(paths);
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
if (!isMissingFileError(error)) {
|
|
96
|
-
const reason = error instanceof Error ? error.message : String(error);
|
|
97
|
-
throw new Error(`Local collector config at ${paths.config_file} is unreadable (${reason}). ` +
|
|
98
|
-
"Run `cockpit onboard` to repair it.");
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
if (existing)
|
|
102
|
-
return { config: existing, paths, created: false };
|
|
103
|
-
const config = LocalCollectorConfigSchema.parse({
|
|
104
|
-
schema_version: "telemetry-core.v1",
|
|
105
|
-
dashboard_url: normalizeDashboardUrl(options.dashboardUrl ?? DEFAULT_DASHBOARD_URL),
|
|
106
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
107
|
-
device_id: `device-${crypto.randomUUID()}`,
|
|
108
|
-
device_name: defaultDeviceName(),
|
|
109
|
-
default_repo_paths: [],
|
|
110
|
-
session_file_path: paths.session_file,
|
|
111
|
-
state_dir_path: paths.state_dir,
|
|
112
|
-
});
|
|
113
|
-
await writeJsonFile(paths.config_file, config);
|
|
114
|
-
return { config, paths, created: true };
|
|
115
|
-
}
|
|
116
|
-
export async function pairLocalCollector(options = {}) {
|
|
117
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
118
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
119
|
-
await ensureRuntimeDirectories(paths);
|
|
120
|
-
const config = await readLocalCollectorConfig(paths).catch((error) => {
|
|
121
|
-
// "Local config missing" is correct when it is absent. A config that
|
|
122
|
-
// exists and will not parse gets the same sentence and the same useless
|
|
123
|
-
// advice — run onboard again, which will not fix it (BLI-3238).
|
|
124
|
-
if (!isMissingFileFailure(error)) {
|
|
125
|
-
console.error("[local-state] collector config present but unreadable, reporting it as missing", JSON.stringify({
|
|
126
|
-
reason: "config_unreadable",
|
|
127
|
-
...describeError(error),
|
|
128
|
-
}));
|
|
129
|
-
}
|
|
130
|
-
throw new Error("Local config missing. Run `cockpit onboard` (or `cockpit install`) first, then retry `cockpit login`.");
|
|
131
|
-
});
|
|
132
|
-
const dashboardUrl = normalizeDashboardUrl(options.dashboardUrl ?? config.dashboard_url);
|
|
133
|
-
const deviceId = config.device_id ?? `device-${crypto.randomUUID()}`;
|
|
134
|
-
const deviceName = normalizeDeviceName(options.deviceName) ??
|
|
135
|
-
config.device_name ??
|
|
136
|
-
defaultDeviceName();
|
|
137
|
-
const claimedOwnerEmail = normalizeOptionalEmail(options.claimedOwnerEmail) ??
|
|
138
|
-
config.claimed_owner_email;
|
|
139
|
-
if (!config.device_id ||
|
|
140
|
-
config.device_name !== deviceName ||
|
|
141
|
-
(claimedOwnerEmail && config.claimed_owner_email !== claimedOwnerEmail)) {
|
|
142
|
-
await writeJsonFile(paths.config_file, {
|
|
143
|
-
...config,
|
|
144
|
-
device_id: deviceId,
|
|
145
|
-
device_name: deviceName,
|
|
146
|
-
claimed_owner_email: claimedOwnerEmail,
|
|
147
|
-
});
|
|
148
|
-
}
|
|
149
|
-
const fetchImpl = options.fetch ?? globalThis.fetch;
|
|
150
|
-
if (!fetchImpl) {
|
|
151
|
-
throw new Error("global fetch is unavailable; use Node.js 20 or newer.");
|
|
152
|
-
}
|
|
153
|
-
const startResponse = await postPairStart(fetchImpl, dashboardUrl, {
|
|
154
|
-
device_id: deviceId,
|
|
155
|
-
device_name: deviceName,
|
|
156
|
-
claimed_owner_email: claimedOwnerEmail,
|
|
157
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
158
|
-
}, options.pairingAccessToken);
|
|
159
|
-
options.onPairStarted?.(startResponse);
|
|
160
|
-
const sessionFile = await pollPairRequest(fetchImpl, dashboardUrl, {
|
|
161
|
-
paths,
|
|
162
|
-
startResponse,
|
|
163
|
-
pollIntervalMs: options.pollIntervalMs,
|
|
164
|
-
timeoutMs: options.timeoutMs,
|
|
165
|
-
sleep: options.sleep,
|
|
166
|
-
});
|
|
167
|
-
return {
|
|
168
|
-
status: "paired",
|
|
169
|
-
session: toSessionReference(sessionFile),
|
|
170
|
-
session_file: paths.session_file,
|
|
171
|
-
dashboard_url: dashboardUrl,
|
|
172
|
-
approve_url: startResponse.approve_url,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
export async function logoutLocalCollector(options = {}) {
|
|
176
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
177
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
178
|
-
let removed = false;
|
|
179
|
-
try {
|
|
180
|
-
await fs.unlink(paths.session_file);
|
|
181
|
-
removed = true;
|
|
182
|
-
}
|
|
183
|
-
catch (error) {
|
|
184
|
-
if (!isMissingFileError(error))
|
|
185
|
-
throw error;
|
|
186
|
-
}
|
|
187
|
-
return { removed, session_file: paths.session_file };
|
|
188
|
-
}
|
|
189
|
-
export async function startLocalWorkContext(options = {}) {
|
|
190
|
-
return writeLocalWorkContext(options);
|
|
191
|
-
}
|
|
192
|
-
/**
|
|
193
|
-
* Starts a context for a transcript-attributed sync target that does not exist
|
|
194
|
-
* in the current git inventory, such as an approved wrapper folder or a
|
|
195
|
-
* deleted repo reconstructed from transcript provenance. This internal path
|
|
196
|
-
* validates the exact target identity before writing any context state.
|
|
197
|
-
*/
|
|
198
|
-
export async function startLocalWorkContextForAttributedTarget(options, attributedIdentity) {
|
|
199
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
200
|
-
const identity = await validateAttributedTargetIdentity(repoRoot, attributedIdentity);
|
|
201
|
-
return writeLocalWorkContext(options, identity);
|
|
202
|
-
}
|
|
203
|
-
async function writeLocalWorkContext(options, attributedIdentity) {
|
|
204
|
-
if (options.activeTicketId && options.clearTicket) {
|
|
205
|
-
throw new Error("--ticket and --clear-ticket cannot be combined.");
|
|
206
|
-
}
|
|
207
|
-
const now = options.now ?? new Date();
|
|
208
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
209
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
210
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
211
|
-
await ensureRuntimeDirectories(paths);
|
|
212
|
-
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
213
|
-
const session = await readLocalSessionReference(paths, {
|
|
214
|
-
operatorId: options.operatorId,
|
|
215
|
-
sessionId: options.sessionId,
|
|
216
|
-
});
|
|
217
|
-
const identity = attributedIdentity ??
|
|
218
|
-
(await resolveIdentityOrFallback(repoRoot, options.branch));
|
|
219
|
-
const branch = attributedIdentity
|
|
220
|
-
? attributedIdentity.branch
|
|
221
|
-
: (options.branch ?? identity.branch);
|
|
222
|
-
const sessionId = options.sessionId ??
|
|
223
|
-
(session.session_state === "missing" ? `local-${crypto.randomUUID()}` : session.session_id);
|
|
224
|
-
const operatorId = options.operatorId ?? session.operator_id;
|
|
225
|
-
const deviceId = session.device_id ?? config?.device_id ?? "device-unknown";
|
|
226
|
-
const workContextId = stableWorkContextId({
|
|
227
|
-
operatorId,
|
|
228
|
-
deviceId,
|
|
229
|
-
repoFingerprint: identity.repo_fingerprint,
|
|
230
|
-
worktreeFingerprint: identity.worktree_fingerprint,
|
|
231
|
-
});
|
|
232
|
-
const existingContext = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
233
|
-
const activeTicketId = options.clearTicket
|
|
234
|
-
? undefined
|
|
235
|
-
: (options.activeTicketId ?? existingContext?.active_ticket_id);
|
|
236
|
-
const ticketBindingCandidates = options.activeTicketId
|
|
237
|
-
? [
|
|
238
|
-
{
|
|
239
|
-
ticket_id: options.activeTicketId,
|
|
240
|
-
binding_source: "active_work_context",
|
|
241
|
-
confidence: 1,
|
|
242
|
-
evidence_labels: ["cockpit_start_ticket"],
|
|
243
|
-
},
|
|
244
|
-
]
|
|
245
|
-
: options.clearTicket
|
|
246
|
-
? []
|
|
247
|
-
: (existingContext?.ticket_binding_candidates ?? []);
|
|
248
|
-
const context = LocalWorkContextSchema.parse({
|
|
249
|
-
work_context_id: workContextId,
|
|
250
|
-
repo: identity.repo_root,
|
|
251
|
-
branch,
|
|
252
|
-
repo_label: identity.repo_label,
|
|
253
|
-
repo_fingerprint: identity.repo_fingerprint,
|
|
254
|
-
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
255
|
-
head_sha: identity.head_sha ?? undefined,
|
|
256
|
-
worktree_label: identity.worktree_label,
|
|
257
|
-
worktree_fingerprint: identity.worktree_fingerprint,
|
|
258
|
-
worktree_is_primary: identity.worktree_is_primary,
|
|
259
|
-
operator_id: operatorId,
|
|
260
|
-
session_id: sessionId,
|
|
261
|
-
started_at: existingContext?.started_at ?? now.toISOString(),
|
|
262
|
-
updated_at: now.toISOString(),
|
|
263
|
-
active_ticket_id: activeTicketId,
|
|
264
|
-
ticket_binding_candidates: ticketBindingCandidates,
|
|
265
|
-
topic_label: options.topicLabel,
|
|
266
|
-
topic_summary_redacted: options.topicSummaryRedacted,
|
|
267
|
-
work_intent: options.workIntent,
|
|
268
|
-
work_phase: options.workPhase,
|
|
269
|
-
intent_source: options.intentSource,
|
|
270
|
-
intent_confidence: options.intentConfidence,
|
|
271
|
-
pull_request_url: existingContext?.pull_request_url,
|
|
272
|
-
provenance: {
|
|
273
|
-
capture_source: "collector_runtime",
|
|
274
|
-
capture_adapter_version: LOCAL_COLLECTOR_VERSION,
|
|
275
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
276
|
-
repo: identity.repo_root,
|
|
277
|
-
branch,
|
|
278
|
-
repo_label: identity.repo_label,
|
|
279
|
-
repo_fingerprint: identity.repo_fingerprint,
|
|
280
|
-
repo_origin_url: identity.repo_origin_url ?? undefined,
|
|
281
|
-
worktree_label: identity.worktree_label,
|
|
282
|
-
worktree_fingerprint: identity.worktree_fingerprint,
|
|
283
|
-
worktree_is_primary: identity.worktree_is_primary,
|
|
284
|
-
operator_id: operatorId,
|
|
285
|
-
session_id: sessionId,
|
|
286
|
-
work_context_id: workContextId,
|
|
287
|
-
},
|
|
288
|
-
});
|
|
289
|
-
await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
|
|
290
|
-
await writeJsonFile(paths.active_work_context_file, context);
|
|
291
|
-
return context;
|
|
292
|
-
}
|
|
293
|
-
async function validateAttributedTargetIdentity(requestedRepoRoot, identity) {
|
|
294
|
-
const canonicalRoot = await stableWorktreeRoot(requestedRepoRoot);
|
|
295
|
-
const identityRoot = await stableWorktreeRoot(identity.repo_root);
|
|
296
|
-
const identityRequestedPath = await stableWorktreeRoot(identity.requested_path);
|
|
297
|
-
if (!isSamePath(canonicalRoot, identityRoot) ||
|
|
298
|
-
!isSamePath(canonicalRoot, identityRequestedPath)) {
|
|
299
|
-
throw new Error("Attributed target identity paths do not match the requested repo root.");
|
|
300
|
-
}
|
|
301
|
-
const expectedWorktreeFingerprint = stableWorktreeFingerprint(canonicalRoot);
|
|
302
|
-
if (identity.worktree_fingerprint !== expectedWorktreeFingerprint) {
|
|
303
|
-
throw new Error("Attributed target worktree fingerprint does not match its repo root.");
|
|
304
|
-
}
|
|
305
|
-
const expectedWorktreeLabel = path.basename(canonicalRoot) || "workspace";
|
|
306
|
-
if (identity.worktree_label !== expectedWorktreeLabel) {
|
|
307
|
-
throw new Error("Attributed target worktree label does not match its repo root.");
|
|
308
|
-
}
|
|
309
|
-
if (identity.repo_origin_url) {
|
|
310
|
-
const normalizedOrigin = normalizeGitOrigin(identity.repo_origin_url);
|
|
311
|
-
if (normalizedOrigin !== identity.repo_origin_url) {
|
|
312
|
-
throw new Error("Attributed target repo origin is not normalized.");
|
|
313
|
-
}
|
|
314
|
-
if (identity.repo_fingerprint !== repoFingerprintFromOrigin(normalizedOrigin)) {
|
|
315
|
-
throw new Error("Attributed target repo fingerprint does not match its origin.");
|
|
316
|
-
}
|
|
317
|
-
if (identity.repo_label !== repoLabelFromOrigin(normalizedOrigin)) {
|
|
318
|
-
throw new Error("Attributed target repo label does not match its origin.");
|
|
319
|
-
}
|
|
320
|
-
if (identity.worktree_is_primary) {
|
|
321
|
-
throw new Error("Origin-derived attributed targets cannot claim a primary live worktree.");
|
|
322
|
-
}
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
325
|
-
if (identity.repo_fingerprint !==
|
|
326
|
-
repoFingerprintFromLocalRoot(canonicalRoot)) {
|
|
327
|
-
throw new Error("Attributed target repo fingerprint does not match its local root.");
|
|
328
|
-
}
|
|
329
|
-
if (identity.repo_label !== expectedWorktreeLabel) {
|
|
330
|
-
throw new Error("Attributed target repo label does not match its local root.");
|
|
331
|
-
}
|
|
332
|
-
if (!identity.worktree_is_primary) {
|
|
333
|
-
throw new Error("Local attributed targets must use their primary local identity.");
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
return {
|
|
337
|
-
...identity,
|
|
338
|
-
requested_path: canonicalRoot,
|
|
339
|
-
repo_root: canonicalRoot,
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
export async function inspectLocalCollectorStatus(options = {}) {
|
|
343
|
-
const now = options.now ?? new Date();
|
|
344
|
-
const homeDir = options.homeDir ?? os.homedir();
|
|
345
|
-
const repoRoot = path.resolve(options.repoRoot ?? process.cwd());
|
|
346
|
-
const paths = getCollectorRuntimePaths(homeDir);
|
|
347
|
-
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
348
|
-
const session = await readLocalSessionReference(paths, {
|
|
349
|
-
operatorId: options.operatorId,
|
|
350
|
-
sessionId: options.sessionId,
|
|
351
|
-
});
|
|
352
|
-
const identity = await resolveIdentityOrFallback(repoRoot, options.branch);
|
|
353
|
-
const context = await readLocalWorkContextForRepo(paths, repoRoot).catch(() => null);
|
|
354
|
-
const branch = options.branch ?? identity.branch;
|
|
355
|
-
const [uploadSpool, healthOutbox, stagingState] = await Promise.all([
|
|
356
|
-
summarizeLocalUploadSpool(paths),
|
|
357
|
-
summarizeInstallEventOutbox(paths),
|
|
358
|
-
readRawEvidenceStagingState(paths.state_dir),
|
|
359
|
-
]);
|
|
360
|
-
const stuckEvidence = summarizeStuckEvidence(stagingState, now);
|
|
361
|
-
const freshness = classifyCollectorFreshness(context, uploadSpool.last_upload_success_at, now);
|
|
362
|
-
const uploadState = !config
|
|
363
|
-
? "not_installed"
|
|
364
|
-
: uploadSpool.pending_upload_count > 0
|
|
365
|
-
? "retry_pending"
|
|
366
|
-
: session.session_state === "valid"
|
|
367
|
-
? "ready"
|
|
368
|
-
: "local_only_missing_auth";
|
|
369
|
-
const details = [];
|
|
370
|
-
details.push(config
|
|
371
|
-
? `Config exists at ${paths.config_file}.`
|
|
372
|
-
: "Local config missing. Run `cockpit install`.");
|
|
373
|
-
details.push(session.session_state === "valid"
|
|
374
|
-
? "Local user session is valid."
|
|
375
|
-
: "Local user session missing or not paired; upload remains local-only.");
|
|
376
|
-
details.push(context
|
|
377
|
-
? `Active work ${workDisplayLabel(context)} (${context.work_context_id}) last updated ${context.updated_at ?? context.started_at}.`
|
|
378
|
-
: "Active work context missing. Run `cockpit start`.");
|
|
379
|
-
details.push(uploadSpool.last_upload_attempt_at
|
|
380
|
-
? `Last upload attempt: ${uploadSpool.last_upload_attempt_at}.`
|
|
381
|
-
: "No upload has been attempted yet.");
|
|
382
|
-
details.push(uploadSpool.last_upload_success_at
|
|
383
|
-
? `Last upload success: ${uploadSpool.last_upload_success_at}.`
|
|
384
|
-
: "No successful upload recorded yet.");
|
|
385
|
-
if (uploadSpool.last_upload_failure_reason) {
|
|
386
|
-
details.push(`Last upload failure: ${uploadSpool.last_upload_failure_reason}.`);
|
|
387
|
-
}
|
|
388
|
-
if (uploadSpool.pending_upload_count > 0) {
|
|
389
|
-
details.push(`Upload retry pending: ${uploadSpool.pending_upload_count} safe metadata record(s) spooled. Run \`${uploadSpool.retry_command ?? "cockpit sync"}\` to retry.`);
|
|
390
|
-
}
|
|
391
|
-
if (healthOutbox.pending_count > 0) {
|
|
392
|
-
details.push(`Collector health retry pending: ${healthOutbox.pending_count} sanitized receipt(s) queued since ${healthOutbox.oldest_created_at ?? "unknown"}.`);
|
|
393
|
-
}
|
|
394
|
-
if (stuckEvidence.stuck_object_count > 0) {
|
|
395
|
-
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"}.`);
|
|
396
|
-
}
|
|
397
|
-
return {
|
|
398
|
-
installed: Boolean(config),
|
|
399
|
-
config_file: paths.config_file,
|
|
400
|
-
session_file: paths.session_file,
|
|
401
|
-
collector_version: LOCAL_COLLECTOR_VERSION,
|
|
402
|
-
config_collector_version: config?.collector_version ?? null,
|
|
403
|
-
session_state: session.session_state,
|
|
404
|
-
repo: context?.repo ?? identity.repo_root,
|
|
405
|
-
branch,
|
|
406
|
-
active_ticket_id: context?.active_ticket_id ?? null,
|
|
407
|
-
work_label: context ? workDisplayLabel(context) : null,
|
|
408
|
-
work_id: context?.work_context_id ?? null,
|
|
409
|
-
work_context_id: context?.work_context_id ?? null,
|
|
410
|
-
repo_label: context?.repo_label ?? identity.repo_label,
|
|
411
|
-
repo_fingerprint: context?.repo_fingerprint ?? identity.repo_fingerprint,
|
|
412
|
-
worktree_label: context?.worktree_label ?? identity.worktree_label,
|
|
413
|
-
worktree_fingerprint: context?.worktree_fingerprint ?? identity.worktree_fingerprint,
|
|
414
|
-
worktree_is_primary: context?.worktree_is_primary ?? identity.worktree_is_primary,
|
|
415
|
-
collector_freshness: freshness,
|
|
416
|
-
upload_state: uploadState,
|
|
417
|
-
last_upload_attempt_at: uploadSpool.last_upload_attempt_at,
|
|
418
|
-
last_upload_success_at: uploadSpool.last_upload_success_at,
|
|
419
|
-
last_upload_failure_reason: uploadSpool.last_upload_failure_reason,
|
|
420
|
-
pending_upload_count: uploadSpool.pending_upload_count,
|
|
421
|
-
upload_retry_command: uploadSpool.retry_command,
|
|
422
|
-
pending_health_receipt_count: healthOutbox.pending_count,
|
|
423
|
-
oldest_pending_health_receipt_at: healthOutbox.oldest_created_at,
|
|
424
|
-
last_health_receipt_failure_reason: healthOutbox.last_failure_reason,
|
|
425
|
-
stuck_evidence_object_count: stuckEvidence.stuck_object_count,
|
|
426
|
-
stuck_evidence_held_count: stuckEvidence.held_object_count,
|
|
427
|
-
stuck_evidence_max_attempts: stuckEvidence.max_attempts,
|
|
428
|
-
stuck_evidence_oldest_failure_at: stuckEvidence.oldest_first_failed_at,
|
|
429
|
-
stuck_evidence_reasons: stuckEvidence.reasons,
|
|
430
|
-
details,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
function workDisplayLabel(context) {
|
|
434
|
-
const repoLabel = (context.repo_label ?? path.basename(context.repo)) || "workspace";
|
|
435
|
-
const worktreeLabel = context.worktree_label ?? repoLabel;
|
|
436
|
-
return `${repoLabel}/${worktreeLabel}`;
|
|
437
|
-
}
|
|
438
|
-
export async function readLocalCollectorConfig(paths) {
|
|
439
|
-
return LocalCollectorConfigSchema.parse(await readJsonFile(paths.config_file));
|
|
440
|
-
}
|
|
441
|
-
export async function readLocalWorkContext(paths) {
|
|
442
|
-
return LocalWorkContextSchema.parse(await readJsonFile(paths.active_work_context_file));
|
|
443
|
-
}
|
|
444
|
-
export async function readLocalWorkContextForRepo(paths, repoRoot) {
|
|
445
|
-
const identity = await resolveIdentityOrFallback(repoRoot);
|
|
446
|
-
const context = await readLocalWorkContextByFingerprint(paths, identity.worktree_fingerprint).catch(() => null);
|
|
447
|
-
if (context)
|
|
448
|
-
return context;
|
|
449
|
-
const active = await readLocalWorkContext(paths);
|
|
450
|
-
if (active.worktree_fingerprint === identity.worktree_fingerprint ||
|
|
451
|
-
path.resolve(active.repo) === identity.repo_root) {
|
|
452
|
-
return active;
|
|
453
|
-
}
|
|
454
|
-
throw new Error(`Active work context missing for ${identity.worktree_label}. Run \`cockpit start --workspace "${identity.repo_root}"\`.`);
|
|
455
|
-
}
|
|
456
|
-
async function readLocalWorkContextByFingerprint(paths, worktreeFingerprint) {
|
|
457
|
-
return LocalWorkContextSchema.parse(await readJsonFile(workContextFile(paths, worktreeFingerprint)));
|
|
458
|
-
}
|
|
459
|
-
export async function readLocalSessionReference(paths, fallback = {}) {
|
|
460
|
-
try {
|
|
461
|
-
const rawSession = await readJsonFile(paths.session_file);
|
|
462
|
-
const collectorSession = LocalCollectorSessionFileSchema.safeParse(rawSession);
|
|
463
|
-
if (collectorSession.success) {
|
|
464
|
-
return toSessionReference(collectorSession.data);
|
|
465
|
-
}
|
|
466
|
-
return LocalUserSessionReferenceSchema.parse(rawSession);
|
|
467
|
-
}
|
|
468
|
-
catch (error) {
|
|
469
|
-
// `session_state: "missing"` is correct before login and says so loudly
|
|
470
|
-
// enough on its own. It is also what a session file that EXISTS but is
|
|
471
|
-
// corrupt, truncated or unreadable collapses to — a paired machine that
|
|
472
|
-
// silently reads as never-signed-in, which is indistinguishable in every
|
|
473
|
-
// downstream receipt (BLI-3238).
|
|
474
|
-
if (!isMissingFileFailure(error)) {
|
|
475
|
-
console.error("[local-state] session file present but unusable, reading as missing", JSON.stringify({
|
|
476
|
-
reason: "session_file_unusable",
|
|
477
|
-
...describeError(error),
|
|
478
|
-
}));
|
|
479
|
-
}
|
|
480
|
-
return LocalUserSessionReferenceSchema.parse({
|
|
481
|
-
operator_id: fallback.operatorId ?? "unknown",
|
|
482
|
-
auth_subject_id: "unknown",
|
|
483
|
-
session_id: fallback.sessionId ?? "missing",
|
|
484
|
-
session_file_path: paths.session_file,
|
|
485
|
-
session_state: "missing",
|
|
486
|
-
});
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
export async function readLocalCollectorSessionFile(paths) {
|
|
490
|
-
return LocalCollectorSessionFileSchema.parse(await readJsonFile(paths.session_file));
|
|
491
|
-
}
|
|
492
|
-
export async function resolveGitBranch(repoRoot) {
|
|
493
|
-
try {
|
|
494
|
-
const gitPath = path.join(repoRoot, ".git");
|
|
495
|
-
const stat = await fs.stat(gitPath);
|
|
496
|
-
const headPath = stat.isFile()
|
|
497
|
-
? path.join(await resolveWorktreeGitDir(gitPath), "HEAD")
|
|
498
|
-
: path.join(gitPath, "HEAD");
|
|
499
|
-
const head = (await fs.readFile(headPath, "utf8")).trim();
|
|
500
|
-
if (head.startsWith("ref: refs/heads/")) {
|
|
501
|
-
return head.slice("ref: refs/heads/".length);
|
|
502
|
-
}
|
|
503
|
-
return head ? `detached:${head.slice(0, 12)}` : "unknown";
|
|
504
|
-
}
|
|
505
|
-
catch (error) {
|
|
506
|
-
// A folder that is not a git repo is a legitimate workspace under the
|
|
507
|
-
// session-first commandment, so a missing `.git` stays quiet. A `.git`
|
|
508
|
-
// that exists and cannot be read is a different thing: every session
|
|
509
|
-
// collected from this repo gets branch `unknown` and nothing says why.
|
|
510
|
-
if (!isMissingFileFailure(error)) {
|
|
511
|
-
console.error("[local-state] could not read HEAD, branch recorded as unknown", JSON.stringify({
|
|
512
|
-
reason: "git_head_unreadable",
|
|
513
|
-
...describeError(error),
|
|
514
|
-
}));
|
|
515
|
-
}
|
|
516
|
-
return "unknown";
|
|
517
|
-
}
|
|
518
|
-
}
|
|
519
|
-
async function resolveWorktreeGitDir(gitFile) {
|
|
520
|
-
const raw = await fs.readFile(gitFile, "utf8");
|
|
521
|
-
const match = raw.match(/^gitdir:\s*(.+)$/m);
|
|
522
|
-
if (!match)
|
|
523
|
-
return path.dirname(gitFile);
|
|
524
|
-
const gitDir = match[1].trim();
|
|
525
|
-
return path.isAbsolute(gitDir) ? gitDir : path.resolve(path.dirname(gitFile), gitDir);
|
|
526
|
-
}
|
|
527
|
-
async function ensureRuntimeDirectories(paths) {
|
|
528
|
-
await fs.mkdir(paths.config_dir, { recursive: true, mode: 0o700 });
|
|
529
|
-
await fs.mkdir(paths.state_dir, { recursive: true, mode: 0o700 });
|
|
530
|
-
await fs.mkdir(paths.spool_dir, { recursive: true, mode: 0o700 });
|
|
531
|
-
await fs.mkdir(paths.cursors_dir, { recursive: true, mode: 0o700 });
|
|
532
|
-
await fs.mkdir(paths.work_contexts_dir, { recursive: true, mode: 0o700 });
|
|
533
|
-
if (process.platform !== "win32") {
|
|
534
|
-
// The `mode` above only applies to directories this call CREATES, so these
|
|
535
|
-
// chmods are what actually tightens a directory that already existed with
|
|
536
|
-
// looser bits. Failing means the operator's device token and cursors stay
|
|
537
|
-
// world-readable — non-fatal, deliberately, but not something to find out
|
|
538
|
-
// about never (BLI-3238). Reported by directory name only, never a path.
|
|
539
|
-
const tightened = [
|
|
540
|
-
["config_dir", fs.chmod(paths.config_dir, 0o700)],
|
|
541
|
-
["state_dir", fs.chmod(paths.state_dir, 0o700)],
|
|
542
|
-
["spool_dir", fs.chmod(paths.spool_dir, 0o700)],
|
|
543
|
-
["cursors_dir", fs.chmod(paths.cursors_dir, 0o700)],
|
|
544
|
-
["work_contexts_dir", fs.chmod(paths.work_contexts_dir, 0o700)],
|
|
545
|
-
];
|
|
546
|
-
await Promise.all(tightened.map(async ([name, work]) => {
|
|
547
|
-
try {
|
|
548
|
-
await work;
|
|
549
|
-
}
|
|
550
|
-
catch (error) {
|
|
551
|
-
console.error("[local-state] could not restrict a runtime directory to owner-only", JSON.stringify({
|
|
552
|
-
reason: "runtime_dir_chmod_failed",
|
|
553
|
-
directory: name,
|
|
554
|
-
...describeError(error),
|
|
555
|
-
}));
|
|
556
|
-
}
|
|
557
|
-
}));
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
function workContextFile(paths, worktreeFingerprint) {
|
|
561
|
-
return path.join(paths.work_contexts_dir, `${worktreeFingerprint}.json`);
|
|
562
|
-
}
|
|
563
|
-
async function resolveIdentityOrFallback(repoRoot, branchOverride) {
|
|
564
|
-
const resolvedRoot = await stableWorktreeRoot(repoRoot);
|
|
565
|
-
const identity = await resolveRepoWorktreeIdentity(resolvedRoot).catch(() => null);
|
|
566
|
-
if (identity) {
|
|
567
|
-
return branchOverride ? { ...identity, branch: branchOverride } : identity;
|
|
568
|
-
}
|
|
569
|
-
const repoLabel = path.basename(resolvedRoot) || "workspace";
|
|
570
|
-
const repoFingerprint = repoFingerprintFromLocalRoot(resolvedRoot);
|
|
571
|
-
const worktreeFingerprint = stableWorktreeFingerprint(resolvedRoot);
|
|
572
|
-
const branch = branchOverride ?? (await resolveGitBranch(resolvedRoot));
|
|
573
|
-
return {
|
|
574
|
-
requested_path: resolvedRoot,
|
|
575
|
-
repo_root: resolvedRoot,
|
|
576
|
-
repo_label: repoLabel,
|
|
577
|
-
repo_fingerprint: repoFingerprint,
|
|
578
|
-
repo_origin_url: null,
|
|
579
|
-
branch,
|
|
580
|
-
head_sha: null,
|
|
581
|
-
worktree_label: repoLabel,
|
|
582
|
-
worktree_fingerprint: worktreeFingerprint,
|
|
583
|
-
worktree_is_primary: true,
|
|
584
|
-
};
|
|
585
|
-
}
|
|
586
|
-
function stableWorkContextId(input) {
|
|
587
|
-
return `work-${sha256([
|
|
588
|
-
input.operatorId,
|
|
589
|
-
input.deviceId,
|
|
590
|
-
input.repoFingerprint,
|
|
591
|
-
input.worktreeFingerprint,
|
|
592
|
-
].join(":")).slice(0, 32)}`;
|
|
593
|
-
}
|
|
594
|
-
function sha256(value) {
|
|
595
|
-
return crypto.createHash("sha256").update(value, "utf8").digest("hex");
|
|
596
|
-
}
|
|
597
|
-
/**
|
|
598
|
-
* The one reader behind the config file, the session file and every work
|
|
599
|
-
* context — and therefore the one place worth reporting from.
|
|
2
|
+
* The collector's own state on a machine — the durable on-disk contract behind
|
|
3
|
+
* `cockpit install`, `login`, `logout`, `start` and `status`.
|
|
600
4
|
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
* could distinguish is "no, nothing is there" from "yes, and it is corrupt or
|
|
605
|
-
* unreadable" — so the distinction is drawn HERE, once, rather than in twenty
|
|
606
|
-
* places where it would be twenty chances to forget (BLI-3238).
|
|
607
|
-
*
|
|
608
|
-
* The error still propagates unchanged; callers keep whatever they decided.
|
|
609
|
-
*/
|
|
610
|
-
async function readJsonFile(filePath) {
|
|
611
|
-
try {
|
|
612
|
-
return JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
613
|
-
}
|
|
614
|
-
catch (error) {
|
|
615
|
-
// Absent is the ordinary pre-onboarding state on every one of these files
|
|
616
|
-
// and stays quiet; anything else means state exists and cannot be used.
|
|
617
|
-
if (!isMissingFileFailure(error)) {
|
|
618
|
-
console.error("[local-state] a collector state file exists but could not be read", JSON.stringify({
|
|
619
|
-
reason: "state_file_unreadable",
|
|
620
|
-
// Which file, without the path: the basename of these is a fixed
|
|
621
|
-
// vocabulary (`config.json`, `session.json`, a work-context
|
|
622
|
-
// fingerprint) and carries no repo or operator name.
|
|
623
|
-
state_file: path.basename(filePath),
|
|
624
|
-
...describeError(error),
|
|
625
|
-
}));
|
|
626
|
-
}
|
|
627
|
-
throw error;
|
|
628
|
-
}
|
|
629
|
-
}
|
|
630
|
-
async function writeJsonFile(filePath, value) {
|
|
631
|
-
await fs.mkdir(path.dirname(filePath), { recursive: true });
|
|
632
|
-
await fs.writeFile(filePath, `${JSON.stringify(value, null, 2)}\n`, {
|
|
633
|
-
mode: 0o600,
|
|
634
|
-
});
|
|
635
|
-
if (process.platform !== "win32") {
|
|
636
|
-
await fs.chmod(filePath, 0o600).catch(() => undefined);
|
|
637
|
-
}
|
|
638
|
-
}
|
|
639
|
-
export function classifyCollectorFreshness(context, lastUploadSuccessAt, now) {
|
|
640
|
-
if (!context)
|
|
641
|
-
return "missing";
|
|
642
|
-
const latestActivityAt = latestTimestamp([
|
|
643
|
-
context.updated_at ?? context.started_at,
|
|
644
|
-
lastUploadSuccessAt,
|
|
645
|
-
]);
|
|
646
|
-
if (latestActivityAt === null)
|
|
647
|
-
return "stale";
|
|
648
|
-
return now.getTime() - latestActivityAt <= 5 * 60 * 1000 ? "fresh" : "stale";
|
|
649
|
-
}
|
|
650
|
-
function latestTimestamp(values) {
|
|
651
|
-
const timestamps = values
|
|
652
|
-
.map((value) => Date.parse(value ?? ""))
|
|
653
|
-
.filter((value) => Number.isFinite(value));
|
|
654
|
-
if (timestamps.length === 0)
|
|
655
|
-
return null;
|
|
656
|
-
return Math.max(...timestamps);
|
|
657
|
-
}
|
|
658
|
-
function normalizeDashboardUrl(value) {
|
|
659
|
-
const normalized = value.trim().replace(/\/+$/, "");
|
|
660
|
-
if (!normalized)
|
|
661
|
-
throw new Error("Dashboard URL cannot be empty.");
|
|
662
|
-
return normalized;
|
|
663
|
-
}
|
|
664
|
-
async function postPairStart(fetchImpl, dashboardUrl, body, accessToken) {
|
|
665
|
-
const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/start`, {
|
|
666
|
-
method: "POST",
|
|
667
|
-
headers: {
|
|
668
|
-
"Content-Type": "application/json",
|
|
669
|
-
...(accessToken ? { "Authorization": `Bearer ${accessToken}` } : {}),
|
|
670
|
-
},
|
|
671
|
-
body: JSON.stringify(body),
|
|
672
|
-
});
|
|
673
|
-
const parsed = await readResponseJson(response);
|
|
674
|
-
if (!response.ok) {
|
|
675
|
-
throw new Error(pairFailureMessage("Pair request failed", response, parsed));
|
|
676
|
-
}
|
|
677
|
-
return parsePairStartResponse(parsed);
|
|
678
|
-
}
|
|
679
|
-
async function pollPairRequest(fetchImpl, dashboardUrl, options) {
|
|
680
|
-
const pollIntervalMs = options.pollIntervalMs ?? options.startResponse.poll_after_ms;
|
|
681
|
-
const timeoutMs = options.timeoutMs ?? 10 * 60 * 1000;
|
|
682
|
-
const sleepImpl = options.sleep ??
|
|
683
|
-
((milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds)));
|
|
684
|
-
const deadline = Date.now() + timeoutMs;
|
|
685
|
-
while (Date.now() <= deadline) {
|
|
686
|
-
const response = await fetchImpl(`${dashboardUrl}/api/ambient/pair/poll`, {
|
|
687
|
-
method: "POST",
|
|
688
|
-
headers: { "Content-Type": "application/json" },
|
|
689
|
-
body: JSON.stringify({
|
|
690
|
-
request_id: options.startResponse.request_id,
|
|
691
|
-
client_secret: options.startResponse.client_secret,
|
|
692
|
-
}),
|
|
693
|
-
});
|
|
694
|
-
const parsed = await readResponseJson(response);
|
|
695
|
-
if (!response.ok) {
|
|
696
|
-
throw new Error(pairFailureMessage("Pair polling failed", response, parsed));
|
|
697
|
-
}
|
|
698
|
-
const status = readStringField(parsed, "status");
|
|
699
|
-
if (status === "approved") {
|
|
700
|
-
const session = parsePairApprovedSession(parsed, options.paths.session_file);
|
|
701
|
-
await writeJsonFile(options.paths.session_file, session);
|
|
702
|
-
return session;
|
|
703
|
-
}
|
|
704
|
-
if (status === "expired") {
|
|
705
|
-
throw new Error("Pair request expired. Run `cockpit login` again.");
|
|
706
|
-
}
|
|
707
|
-
if (status === "revoked") {
|
|
708
|
-
throw new Error("Pair request was revoked. Run `cockpit login` again.");
|
|
709
|
-
}
|
|
710
|
-
if (status !== "pending") {
|
|
711
|
-
throw new Error(`Unexpected pair request status: ${status}`);
|
|
712
|
-
}
|
|
713
|
-
await sleepImpl(Math.max(250, pollIntervalMs));
|
|
714
|
-
}
|
|
715
|
-
throw new Error("Timed out waiting for dashboard approval. Run `cockpit login` again.");
|
|
716
|
-
}
|
|
717
|
-
/**
|
|
718
|
-
* What `cockpit login` tells the operator when pairing is refused.
|
|
5
|
+
* This file is the table of contents and holds no logic. Each state family
|
|
6
|
+
* lives in a named sibling, and every public name below is still importable
|
|
7
|
+
* from `./local-state.js`, which is what ~90 modules and suites depend on:
|
|
719
8
|
*
|
|
720
|
-
*
|
|
721
|
-
*
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
727
|
-
*
|
|
9
|
+
* - `local-state-paths.ts` — where every collector file lives, the owner-only
|
|
10
|
+
* directory setup, and the per-worktree context filename.
|
|
11
|
+
* - `local-state-files.ts` — the ONE reader and ONE writer behind every state
|
|
12
|
+
* file, and therefore the one place a corrupt file is reported from.
|
|
13
|
+
* - `local-state-config.ts` — `config.json`: install, ensure, read, the values
|
|
14
|
+
* they default (collector version, dashboard URL, device name), and the one
|
|
15
|
+
* on-disk migration of a retired raw-evidence choice.
|
|
16
|
+
* - `local-state-session.ts` — `session.json` read as a reference, with expiry
|
|
17
|
+
* collapsed into the session state.
|
|
18
|
+
* - `local-state-pairing.ts` — `cockpit login` and `cockpit logout`: the two
|
|
19
|
+
* operations that make and unmake that session file, plus the pair
|
|
20
|
+
* start/poll HTTP client and its refusal message.
|
|
21
|
+
* - `local-state-identity.ts` — which repo and worktree a folder is, with the
|
|
22
|
+
* path-derived fallback that keeps a non-git folder collectable.
|
|
23
|
+
* - `local-state-attributed-target.ts` — re-derives a caller-supplied identity
|
|
24
|
+
* for a wrapper folder or a deleted repo before any context state is written.
|
|
25
|
+
* - `local-state-work-context.ts` — the active work context and the
|
|
26
|
+
* per-worktree ones: what `cockpit start` bound, and the stable work id.
|
|
27
|
+
* - `local-state-status.ts` — `cockpit status`: the reading, the sentences an
|
|
28
|
+
* operator reads, and the freshness classifier.
|
|
728
29
|
*
|
|
729
|
-
*
|
|
730
|
-
*
|
|
30
|
+
* A new sibling here must also join `scripts/build-public-cli.mjs`
|
|
31
|
+
* `runtimeFiles`, or the repo tests stay green while the packed CLI breaks.
|
|
731
32
|
*/
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
return `${serverWords} (HTTP ${response.status})`;
|
|
740
|
-
}
|
|
741
|
-
async function readResponseJson(response) {
|
|
742
|
-
const text = await response.text();
|
|
743
|
-
if (!text)
|
|
744
|
-
return {};
|
|
745
|
-
try {
|
|
746
|
-
return JSON.parse(text);
|
|
747
|
-
}
|
|
748
|
-
catch {
|
|
749
|
-
// Pairing path. A captive portal or a proxy answering with HTML is the
|
|
750
|
-
// classic reason `cockpit login` fails on a new machine and the operator
|
|
751
|
-
// sees only "pair request failed". The body is never logged; its shape is.
|
|
752
|
-
console.error("[local-state] pairing reply was not JSON", JSON.stringify({
|
|
753
|
-
reason: "response_body_not_json",
|
|
754
|
-
http_status: response.status,
|
|
755
|
-
byte_size: text.length,
|
|
756
|
-
content_type: response.headers.get("content-type") ?? "none",
|
|
757
|
-
}));
|
|
758
|
-
return { message: text };
|
|
759
|
-
}
|
|
760
|
-
}
|
|
761
|
-
function parsePairStartResponse(value) {
|
|
762
|
-
if (!value || typeof value !== "object") {
|
|
763
|
-
throw new Error("Pair request response was not an object.");
|
|
764
|
-
}
|
|
765
|
-
const record = value;
|
|
766
|
-
return {
|
|
767
|
-
request_id: requiredString(record, "request_id"),
|
|
768
|
-
user_code: requiredString(record, "user_code"),
|
|
769
|
-
approve_url: requiredString(record, "approve_url"),
|
|
770
|
-
expires_at: requiredString(record, "expires_at"),
|
|
771
|
-
poll_after_ms: requiredNumber(record, "poll_after_ms"),
|
|
772
|
-
client_secret: requiredString(record, "client_secret"),
|
|
773
|
-
};
|
|
774
|
-
}
|
|
775
|
-
function parsePairApprovedSession(value, sessionFilePath) {
|
|
776
|
-
if (!value || typeof value !== "object") {
|
|
777
|
-
throw new Error("Pair approval response was not an object.");
|
|
778
|
-
}
|
|
779
|
-
const session = value["session"];
|
|
780
|
-
if (!session || typeof session !== "object") {
|
|
781
|
-
throw new Error("Pair approval response did not include a session.");
|
|
782
|
-
}
|
|
783
|
-
return LocalCollectorSessionFileSchema.parse({
|
|
784
|
-
...session,
|
|
785
|
-
session_file_path: sessionFilePath,
|
|
786
|
-
});
|
|
787
|
-
}
|
|
788
|
-
function toSessionReference(session) {
|
|
789
|
-
const now = Date.now();
|
|
790
|
-
const expiresAt = Date.parse(session.expires_at ?? "");
|
|
791
|
-
const sessionState = Number.isFinite(expiresAt) && expiresAt <= now ? "expired" : session.session_state;
|
|
792
|
-
return LocalUserSessionReferenceSchema.parse({
|
|
793
|
-
operator_id: session.operator_id,
|
|
794
|
-
auth_subject_id: session.auth_subject_id,
|
|
795
|
-
email: session.email,
|
|
796
|
-
team_id: session.team_id,
|
|
797
|
-
device_id: session.device_id,
|
|
798
|
-
device_name: session.device_name,
|
|
799
|
-
session_id: session.session_id,
|
|
800
|
-
session_file_path: session.session_file_path,
|
|
801
|
-
session_state: sessionState,
|
|
802
|
-
auth_method: session.auth_method,
|
|
803
|
-
issued_at: session.issued_at,
|
|
804
|
-
expires_at: session.expires_at,
|
|
805
|
-
});
|
|
806
|
-
}
|
|
807
|
-
function defaultDeviceName() {
|
|
808
|
-
return normalizeDeviceName(os.hostname()) ?? "Local machine";
|
|
809
|
-
}
|
|
810
|
-
function normalizeDeviceName(value) {
|
|
811
|
-
const trimmed = value?.trim().replace(/\s+/g, " ");
|
|
812
|
-
return trimmed ? trimmed.slice(0, 120) : undefined;
|
|
813
|
-
}
|
|
814
|
-
function normalizeOptionalEmail(value) {
|
|
815
|
-
const trimmed = value?.trim().toLowerCase();
|
|
816
|
-
return trimmed && trimmed.includes("@") ? trimmed : undefined;
|
|
817
|
-
}
|
|
818
|
-
function responseErrorMessage(value, fallback) {
|
|
819
|
-
if (value && typeof value === "object") {
|
|
820
|
-
const record = value;
|
|
821
|
-
const message = record["message"] ?? record["error"];
|
|
822
|
-
if (typeof message === "string" && message.trim())
|
|
823
|
-
return message;
|
|
824
|
-
}
|
|
825
|
-
return fallback;
|
|
826
|
-
}
|
|
827
|
-
function readStringField(value, field) {
|
|
828
|
-
if (value && typeof value === "object") {
|
|
829
|
-
const entry = value[field];
|
|
830
|
-
if (typeof entry === "string")
|
|
831
|
-
return entry;
|
|
832
|
-
}
|
|
833
|
-
throw new Error(`Pair response missing ${field}.`);
|
|
834
|
-
}
|
|
835
|
-
function requiredString(record, field) {
|
|
836
|
-
const value = record[field];
|
|
837
|
-
if (typeof value !== "string" || !value.trim()) {
|
|
838
|
-
throw new Error(`Pair response missing ${field}.`);
|
|
839
|
-
}
|
|
840
|
-
return value;
|
|
841
|
-
}
|
|
842
|
-
function requiredNumber(record, field) {
|
|
843
|
-
const value = record[field];
|
|
844
|
-
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
845
|
-
throw new Error(`Pair response missing ${field}.`);
|
|
846
|
-
}
|
|
847
|
-
return value;
|
|
848
|
-
}
|
|
849
|
-
function isMissingFileError(error) {
|
|
850
|
-
return (error instanceof Error &&
|
|
851
|
-
"code" in error &&
|
|
852
|
-
error.code === "ENOENT");
|
|
853
|
-
}
|
|
33
|
+
export { getCollectorRuntimePaths } from "./local-state-paths.js";
|
|
34
|
+
export { DEFAULT_DASHBOARD_URL, ensureLocalCollectorConfig, installLocalCollector, LOCAL_COLLECTOR_VERSION, readLocalCollectorConfig, } from "./local-state-config.js";
|
|
35
|
+
export { readLocalCollectorSessionFile, readLocalSessionReference, } from "./local-state-session.js";
|
|
36
|
+
export { logoutLocalCollector, pairLocalCollector, } from "./local-state-pairing.js";
|
|
37
|
+
export { resolveGitBranch } from "./local-state-identity.js";
|
|
38
|
+
export { readLocalWorkContext, readLocalWorkContextForRepo, startLocalWorkContext, startLocalWorkContextForAttributedTarget, } from "./local-state-work-context.js";
|
|
39
|
+
export { classifyCollectorFreshness, inspectLocalCollectorStatus, } from "./local-state-status.js";
|