@bli-cockpit/cli 0.2.285 → 0.2.287
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/capture-opt-outs.js +129 -0
- package/dist/collection-scope.js +201 -0
- package/dist/commands/agent-session-report.js +3 -0
- package/dist/commands/analyze.js +2 -0
- package/dist/commands/backfill-scan.js +16 -11
- package/dist/commands/backfill-scope.js +16 -4
- package/dist/commands/backfill-session-report.js +2 -0
- package/dist/commands/cli-io.js +1 -3
- package/dist/commands/doctor-access.js +7 -0
- package/dist/commands/doctor-registration.js +39 -0
- package/dist/commands/doctor-report.js +1 -0
- package/dist/commands/doctor-shelf.js +62 -0
- package/dist/commands/doctor-update.js +2 -0
- package/dist/commands/doctor-usage-telemetry.js +18 -2
- package/dist/commands/doctor.js +9 -0
- package/dist/commands/heartbeat-post.js +19 -0
- package/dist/commands/heartbeat.js +13 -2
- package/dist/commands/install-receipts.js +4 -0
- package/dist/commands/local-args-collector-setup.js +2 -0
- package/dist/commands/local-args-collector-status.js +7 -21
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-discovery.js +2 -2
- package/dist/commands/local-help-commands-tower.js +6 -1
- package/dist/commands/local-help-commands.js +15 -2
- package/dist/commands/local-help.js +4 -2
- package/dist/commands/local.js +7 -0
- package/dist/commands/memory-capture-prompt.js +130 -0
- package/dist/commands/memory-install-claude.js +51 -4
- package/dist/commands/memory-install-report.js +19 -3
- package/dist/commands/memory-install-toml.js +22 -0
- package/dist/commands/memory-install.js +110 -11
- package/dist/commands/memory-store.js +5 -0
- package/dist/commands/memory-switch.js +165 -0
- package/dist/commands/memory-uninstall.js +187 -0
- package/dist/commands/memory-verb-list.js +6 -2
- package/dist/commands/onboard.js +5 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/roots-label.js +85 -0
- package/dist/commands/roots.js +28 -10
- package/dist/commands/session-sync-plan.js +8 -4
- package/dist/commands/session-sync-scan.js +25 -3
- package/dist/commands/session-sync-shelf.js +143 -0
- package/dist/commands/session-sync-upload.js +6 -2
- package/dist/commands/session-sync.js +68 -8
- package/dist/commands/shelf.js +165 -0
- package/dist/commands/sources-check.js +2 -2
- package/dist/commands/sources-reconciliation.js +2 -1
- package/dist/commands/sync-doctor.js +2 -1
- package/dist/commands/sync-receipt.js +1 -0
- package/dist/commands/sync-report.js +1 -0
- package/dist/commands/sync-stub-heal.js +2 -2
- package/dist/commands/sync.js +12 -0
- package/dist/error-message.js +11 -0
- package/dist/evidence-reconcile-client.js +2 -0
- package/dist/evidence-redelivery-plan.js +6 -0
- package/dist/evidence-redelivery.js +6 -1
- package/dist/evidence-stubs-client.js +2 -0
- package/dist/evidence-transcripts-client.js +2 -0
- package/dist/evidence-upload-transport.js +2 -0
- package/dist/founder-standing-file.js +52 -0
- package/dist/founder-standing.js +117 -0
- package/dist/image-describe-client.js +2 -1
- package/dist/live-founder-memo.js +57 -0
- package/dist/local-state-config.js +13 -7
- package/dist/memory-capture-choice.js +73 -0
- package/dist/root-work.js +55 -7
- package/dist/shelf-decision.js +52 -0
- package/dist/shelf-headers.js +15 -0
- package/dist/shelf-reports.js +167 -0
- package/dist/shelf-state.js +112 -0
- package/dist/source-opt-out.js +83 -6
- package/dist/staging-gate.js +11 -3
- package/dist/state-dir-audit.js +2 -0
- package/dist/tower-client.js +4 -1
- package/dist/upload-agent-artifacts.js +2 -0
- package/dist/upload-envelope-build.js +2 -2
- package/dist/upload-ingest-receipt.js +2 -0
- package/dist/upload-session-reports-wire.js +2 -0
- package/package.json +4 -4
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { optOutVerdict, } from "@bli-cockpit/telemetry-core";
|
|
2
|
+
import { writeLine } from "./commands/cli-io.js";
|
|
3
|
+
import { askTowerFounderStanding } from "./founder-standing.js";
|
|
4
|
+
import { recallLiveFounderAnswer } from "./live-founder-memo.js";
|
|
5
|
+
import { getCollectorRuntimePaths, readLocalCollectorConfig } from "./local-state.js";
|
|
6
|
+
import { readMemoryCaptureChoice } from "./memory-capture-choice.js";
|
|
7
|
+
import { configOptOutLabels } from "./source-opt-out.js";
|
|
8
|
+
/**
|
|
9
|
+
* BLI-22640 (Jeetesh's review of 2026-10-25): this machine's opt-outs, which of
|
|
10
|
+
* them it obeys, and the founder answer it acted on, in one reading.
|
|
11
|
+
*
|
|
12
|
+
* - The sync tick calls `refreshFounderStandingForOptOuts` first: a machine
|
|
13
|
+
* that holds any opt-out asks Tower live before anything is collected, and
|
|
14
|
+
* every decider in that tick reuses the answer (memory only,
|
|
15
|
+
* `live-founder-memo.ts`). A machine with no opt-out asks nothing.
|
|
16
|
+
* - The heartbeat carries `readCaptureOptOuts` as `capture_opt_outs`, so
|
|
17
|
+
* Tower's fleet records see what each machine obeyed, ignored and held, and
|
|
18
|
+
* Tower can compare `obeyed` with its own owners list. It reports what THIS
|
|
19
|
+
* process heard from Tower; it never reads the saved `founder-standing.json`.
|
|
20
|
+
* - `cockpit doctor` prints `captureOptOutLine` when something is ignored and
|
|
21
|
+
* `heldOptOutLine` when Tower could not be asked.
|
|
22
|
+
*
|
|
23
|
+
* Never throws: a reading that fails is logged and reads as "no opt-out".
|
|
24
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
25
|
+
*/
|
|
26
|
+
export async function readCaptureOptOuts(homeDir) {
|
|
27
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
28
|
+
try {
|
|
29
|
+
const found = await foundOptOuts(homeDir);
|
|
30
|
+
// What this process heard from Tower this tick; no answer means unknown (held).
|
|
31
|
+
const standing = found.length > 0 ? (recallLiveFounderAnswer(paths.state_dir)?.standing ?? "unknown") : "unknown";
|
|
32
|
+
return splitByVerdict(found, standing);
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
console.error("[capture-opt-outs] opt-outs could not be read; reporting none", JSON.stringify({ reason: "opt_outs_unreadable", error_name: error instanceof Error ? error.name : typeof error }));
|
|
36
|
+
return { standing: "unknown", obeyed: [], ignored: [] };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Asks Tower live when this machine holds an opt-out, so this tick decides on
|
|
41
|
+
* Tower's current answer. When Tower cannot be asked the answer is `unknown`
|
|
42
|
+
* and the opt-outs are HELD (`owner_check_unavailable`), never decided on the
|
|
43
|
+
* saved file. Never throws; logs both branches.
|
|
44
|
+
*/
|
|
45
|
+
export async function refreshFounderStandingForOptOuts(options) {
|
|
46
|
+
let found;
|
|
47
|
+
try {
|
|
48
|
+
found = await foundOptOuts(options.homeDir);
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
found = [];
|
|
52
|
+
}
|
|
53
|
+
if (found.length === 0) {
|
|
54
|
+
console.error("[capture-opt-outs] no opt-out on this machine; Tower not asked", JSON.stringify({ reason: "no_opt_out" }));
|
|
55
|
+
return { standing: "unknown", obeyed: [], ignored: [] };
|
|
56
|
+
}
|
|
57
|
+
const answer = await askTowerFounderStanding({
|
|
58
|
+
...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
|
|
59
|
+
...(options.dashboardUrl === undefined ? {} : { dashboardUrl: options.dashboardUrl }),
|
|
60
|
+
...(options.fetch === undefined ? {} : { fetch: options.fetch }),
|
|
61
|
+
});
|
|
62
|
+
const report = splitByVerdict(found, answer.standing);
|
|
63
|
+
console.error("[capture-opt-outs] this tick's opt-outs", JSON.stringify({
|
|
64
|
+
reason: optOutVerdict(report.standing).reason,
|
|
65
|
+
...(answer.standing === "unknown" ? { not_live_reason: answer.reason } : {}),
|
|
66
|
+
obeyed: report.obeyed,
|
|
67
|
+
ignored: report.ignored,
|
|
68
|
+
}));
|
|
69
|
+
return report;
|
|
70
|
+
}
|
|
71
|
+
/** The one line doctor prints, or null when nothing is ignored. */
|
|
72
|
+
export function captureOptOutLine(report) {
|
|
73
|
+
if (report.ignored.length === 0)
|
|
74
|
+
return null;
|
|
75
|
+
return `Ignored on this machine: ${report.ignored.map(labelWords).join(", ")}. Only Tower's founders can keep work out of Tower, `
|
|
76
|
+
+ "so BLI Memory stays on and these sessions upload; your settings are left as you wrote them (opt_out_ignored_not_founder).";
|
|
77
|
+
}
|
|
78
|
+
/** The line doctor prints when Tower could not be asked, or null when nothing is held. */
|
|
79
|
+
export function heldOptOutLine(report) {
|
|
80
|
+
if (report.standing !== "unknown" || report.obeyed.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
return `Held on this machine: ${report.obeyed.map(labelWords).join(", ")}. Tower could not say whether you are a founder, `
|
|
83
|
+
+ "so nothing these cover is sent until it can; your settings are left as you wrote them (owner_check_unavailable).";
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* `cockpit doctor`'s step: asks Tower when this machine holds an opt-out, and
|
|
87
|
+
* prints one line when any is ignored because the person is not a founder, or
|
|
88
|
+
* held because Tower could not be asked.
|
|
89
|
+
* `--json` keeps stdout for its object; the line goes to stderr there. Never
|
|
90
|
+
* throws: doctor is never lost to this line.
|
|
91
|
+
*/
|
|
92
|
+
export async function reportIgnoredOptOuts(options, io) {
|
|
93
|
+
try {
|
|
94
|
+
const report = await refreshFounderStandingForOptOuts({
|
|
95
|
+
...(options.homeDir === undefined ? {} : { homeDir: options.homeDir }),
|
|
96
|
+
fetch: io.fetch,
|
|
97
|
+
});
|
|
98
|
+
const line = captureOptOutLine(report) ?? heldOptOutLine(report);
|
|
99
|
+
if (line)
|
|
100
|
+
writeLine(options.json ? io.stderr : io.stdout, line);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
console.error("[doctor] opt-out report failed; doctor carries on", JSON.stringify({ reason: "opt_out_report_threw", error_name: error instanceof Error ? error.name : typeof error }));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
async function foundOptOuts(homeDir) {
|
|
107
|
+
const paths = getCollectorRuntimePaths(homeDir);
|
|
108
|
+
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
109
|
+
const choice = await readMemoryCaptureChoice(paths);
|
|
110
|
+
return [
|
|
111
|
+
...(choice.state === "off" ? ["memory_capture_off"] : []),
|
|
112
|
+
...configOptOutLabels(config),
|
|
113
|
+
];
|
|
114
|
+
}
|
|
115
|
+
function splitByVerdict(found, standing) {
|
|
116
|
+
if (found.length === 0)
|
|
117
|
+
return { standing, obeyed: [], ignored: [] };
|
|
118
|
+
return optOutVerdict(standing).obeyed
|
|
119
|
+
? { standing, obeyed: found, ignored: [] }
|
|
120
|
+
: { standing, obeyed: [], ignored: found };
|
|
121
|
+
}
|
|
122
|
+
function labelWords(label) {
|
|
123
|
+
switch (label) {
|
|
124
|
+
case "memory_capture_off": return "BLI Memory off";
|
|
125
|
+
case "collect_claude_jsonl_false": return "collect_claude_jsonl: false";
|
|
126
|
+
case "collect_codex_jsonl_false": return "collect_codex_jsonl: false";
|
|
127
|
+
case "raw_evidence_upload_disabled": return "raw_evidence_upload: \"disabled\"";
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* BLI-23590: "only collect from my approved folders" (`collection_scope: "roots_only"`).
|
|
3
|
+
*
|
|
4
|
+
* Roots are labels by default (session-first collection, BLI-10734): every session the machine can see is uploaded
|
|
5
|
+
* and tagged inside or outside. A person Tower names eligible (the founders and Ed, decided on the SERVER) can choose
|
|
6
|
+
* `roots_only`: a session whose working directory is outside every approved root is dropped from the scan result
|
|
7
|
+
* before anything is staged, uploaded, reported, committed-to-git-scanned or saved to the local work scan. Its only
|
|
8
|
+
* trace is a count on the heartbeat with {@link COLLECTION_SCOPE_ROOTS_ONLY_REASON}.
|
|
9
|
+
*
|
|
10
|
+
* Where the answer comes from, in order:
|
|
11
|
+
*
|
|
12
|
+
* 1. Tower's last reply (`collection_scope: { scope, eligible }` on the install-events reply), kept in
|
|
13
|
+
* `collection-scope.json` in the state directory so an offline tick keeps the last answer.
|
|
14
|
+
* 2. The local config key `collection_scope: "roots_only"`, honoured ONLY when the cached reply says
|
|
15
|
+
* `eligible: true`. An ineligible person's key, or one nobody has confirmed yet, is ignored and the reason is
|
|
16
|
+
* logged. Editing a laptop's config therefore cannot switch this on for anyone Tower has not cleared.
|
|
17
|
+
* 3. Otherwise `all`, today's behaviour for everyone.
|
|
18
|
+
*
|
|
19
|
+
* Plain-words note on "not read": the scan has to open a session file's head to learn its working directory, so the
|
|
20
|
+
* file is touched; nothing from an outside-root session is kept past that decision (no staging, upload, session row,
|
|
21
|
+
* hash in a report, git scan or work-scan entry).
|
|
22
|
+
*
|
|
23
|
+
* Holds scope, eligibility and a timestamp. Never a path, an id or a byte of a session.
|
|
24
|
+
*
|
|
25
|
+
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
26
|
+
*/
|
|
27
|
+
import path from "node:path";
|
|
28
|
+
import { COLLECTION_SCOPE_ROOTS_ONLY_REASON, CollectionScopeReplySchema, } from "@bli-cockpit/telemetry-core";
|
|
29
|
+
import { isPathWithin } from "./adapters/attribution-core-paths.js";
|
|
30
|
+
import { describeError, isMissingFileFailure } from "./health-detail.js";
|
|
31
|
+
import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
|
|
32
|
+
export const COLLECTION_SCOPE_STATE_FILENAME = "collection-scope.json";
|
|
33
|
+
const TAG = "[collection-scope]";
|
|
34
|
+
export function collectionScopeStatePath(stateDir) {
|
|
35
|
+
return path.join(stateDir, COLLECTION_SCOPE_STATE_FILENAME);
|
|
36
|
+
}
|
|
37
|
+
/** The cached answer, or null when there is none or it cannot be trusted (said out loud when unreadable). */
|
|
38
|
+
export async function readCollectionScopeState(stateDir) {
|
|
39
|
+
let raw;
|
|
40
|
+
try {
|
|
41
|
+
raw = await readJsonFile(collectionScopeStatePath(stateDir));
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
if (isMissingFileFailure(error))
|
|
45
|
+
return null;
|
|
46
|
+
console.error(TAG + " cache unreadable, collecting as the default", JSON.stringify({ reason: "collection_scope_cache_unreadable", ...describeError(error) }));
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
const parsed = CollectionScopeReplySchema.safeParse(raw && typeof raw === "object" && !Array.isArray(raw)
|
|
50
|
+
? { scope: raw["scope"], eligible: raw["eligible"] }
|
|
51
|
+
: null);
|
|
52
|
+
if (!parsed.success) {
|
|
53
|
+
console.error(TAG + " cache shape invalid, collecting as the default", JSON.stringify({ reason: "collection_scope_cache_shape_invalid" }));
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
const observedAt = raw["observed_at"];
|
|
57
|
+
return { ...parsed.data, observed_at: typeof observedAt === "string" ? observedAt : "" };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Put Tower's reply on file. A reply that names no scope (older server, failed read) is ignored and the previous
|
|
61
|
+
* answer stays in force. An unknown shape is ignored with a log. Returns true only when the file now holds the reply.
|
|
62
|
+
*/
|
|
63
|
+
export async function recordObservedCollectionScope(stateDir, observed, now = () => new Date()) {
|
|
64
|
+
if (observed === undefined || observed === null)
|
|
65
|
+
return false;
|
|
66
|
+
const parsed = CollectionScopeReplySchema.safeParse(observed);
|
|
67
|
+
if (!parsed.success) {
|
|
68
|
+
console.error(TAG + " reply carried a scope this collector does not understand, ignored", JSON.stringify({ reason: "collection_scope_reply_invalid" }));
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
const previous = await readCollectionScopeState(stateDir);
|
|
72
|
+
try {
|
|
73
|
+
await writeJsonFileAtomic(collectionScopeStatePath(stateDir), {
|
|
74
|
+
schema_version: "cockpit-collection-scope.v1",
|
|
75
|
+
...parsed.data,
|
|
76
|
+
observed_at: now().toISOString(),
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
console.error(TAG + " could not cache the answer Tower sent; the previous answer stays in force", JSON.stringify({ reason: "collection_scope_cache_write_failed", ...describeError(error) }));
|
|
81
|
+
return false;
|
|
82
|
+
}
|
|
83
|
+
if (previous?.scope !== parsed.data.scope || previous?.eligible !== parsed.data.eligible) {
|
|
84
|
+
console.error(TAG + " answer for this person changed", JSON.stringify({ reason: "collection_scope_changed", from: previous?.scope ?? "none", to: parsed.data.scope, eligible: parsed.data.eligible }));
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/** Pure: the scope a tick runs under, from Tower's cached answer and the local config request. */
|
|
89
|
+
export function resolveEffectiveCollectionScope(input) {
|
|
90
|
+
const { cache, localRequest } = input;
|
|
91
|
+
if (cache?.scope === "roots_only") {
|
|
92
|
+
// Tower only ever sends roots_only for a person it cleared; an `eligible: false` beside it is a contradiction,
|
|
93
|
+
// and the safe reading of a contradiction is the default.
|
|
94
|
+
if (cache.eligible)
|
|
95
|
+
return { scope: "roots_only", source: "server", reason: "tower_set_roots_only" };
|
|
96
|
+
return { scope: "all", source: "default", reason: "tower_answer_contradicts_itself_ignored" };
|
|
97
|
+
}
|
|
98
|
+
if (localRequest === "roots_only") {
|
|
99
|
+
if (cache?.eligible)
|
|
100
|
+
return { scope: "roots_only", source: "local_config_eligible", reason: "local_roots_only_request_tower_confirms_eligible" };
|
|
101
|
+
return {
|
|
102
|
+
scope: "all",
|
|
103
|
+
source: "default",
|
|
104
|
+
reason: cache ? "local_roots_only_ignored_not_eligible" : "local_roots_only_ignored_eligibility_unconfirmed",
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
return { scope: "all", source: "default", reason: cache ? "tower_says_all" : "no_answer_from_tower_yet" };
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The tick's decision, logged once. `localRequest` is the config key. Never throws: a read failure is the default
|
|
111
|
+
* scope with its reason, and the failure was already logged by the reader.
|
|
112
|
+
*/
|
|
113
|
+
export async function settleCollectionScope(stateDir, localRequest) {
|
|
114
|
+
const cache = await readCollectionScopeState(stateDir);
|
|
115
|
+
const effective = resolveEffectiveCollectionScope({ cache, localRequest });
|
|
116
|
+
console.error(TAG + " scope for this tick", JSON.stringify({ reason: effective.reason, scope: effective.scope, source: effective.source, eligible: cache?.eligible ?? null }));
|
|
117
|
+
return effective;
|
|
118
|
+
}
|
|
119
|
+
/** `settleCollectionScope` for the sync tick, which it must never fail. */
|
|
120
|
+
export async function settleTickCollectionScopeBestEffort(stateDir, observed) {
|
|
121
|
+
try {
|
|
122
|
+
await recordObservedCollectionScope(stateDir, observed);
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
console.error(TAG + " could not settle the answer for this tick", JSON.stringify({ reason: "collection_scope_settle_failed", ...describeError(error) }));
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
// ---------------------------------------------------------------------------
|
|
129
|
+
// The root test and the filter
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
/**
|
|
132
|
+
* Is this working directory inside an approved root? Uses the collector's one containment rule
|
|
133
|
+
* (`isPathWithin`): the platform is read from each path itself (a drive letter or UNC share is Windows), Windows
|
|
134
|
+
* compares case-insensitively with either slash, `projects-old` is not inside `projects`, and a Windows path never
|
|
135
|
+
* matches a POSIX root or the reverse.
|
|
136
|
+
*/
|
|
137
|
+
export function cwdInsideRoots(cwd, roots) {
|
|
138
|
+
if (!cwd || roots.length === 0)
|
|
139
|
+
return false;
|
|
140
|
+
return roots.some((root) => isPathWithin(cwd, root));
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* A session stays when its working directory is inside an approved root, or when attribution put it in a worktree
|
|
144
|
+
* that discovery found under the roots and its working directory is inside that worktree (a Codex-managed worktree
|
|
145
|
+
* of an approved repo lives outside the roots but is the approved repo's work). No recorded working directory means
|
|
146
|
+
* it cannot be proven inside, so it is withheld.
|
|
147
|
+
*/
|
|
148
|
+
export function sessionInsideRoots(result, roots) {
|
|
149
|
+
if (cwdInsideRoots(result.local_cwd, roots))
|
|
150
|
+
return true;
|
|
151
|
+
return Boolean(result.worktree && cwdInsideRoots(result.local_cwd, [result.worktree.repo_root]));
|
|
152
|
+
}
|
|
153
|
+
export function withholdOutsideRoots(results, roots) {
|
|
154
|
+
const kept = [];
|
|
155
|
+
let withheld = 0;
|
|
156
|
+
let withoutCwd = 0;
|
|
157
|
+
for (const result of results) {
|
|
158
|
+
if (sessionInsideRoots(result, roots)) {
|
|
159
|
+
kept.push(result);
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
withheld += 1;
|
|
163
|
+
if (!result.local_cwd)
|
|
164
|
+
withoutCwd += 1;
|
|
165
|
+
}
|
|
166
|
+
return { kept, withheld, withheld_without_cwd: withoutCwd };
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Drop the outside-root sessions from one source's scan result and keep its per-state counts honest (a count of
|
|
170
|
+
* `unattributed` that still included a withheld session would say the machine saw something it was told to forget).
|
|
171
|
+
* The other counters (files discovered, scanned) describe the READ and stay as they were.
|
|
172
|
+
*/
|
|
173
|
+
export function scopeScanResult(scan, roots) {
|
|
174
|
+
const { kept, withheld, withheld_without_cwd } = withholdOutsideRoots(scan.results, roots);
|
|
175
|
+
if (withheld === 0)
|
|
176
|
+
return { scan, withheld: 0, withheld_without_cwd: 0 };
|
|
177
|
+
const counts = Object.fromEntries(Object.keys(scan.counts).map((state) => [state, kept.filter((result) => result.state === state).length]));
|
|
178
|
+
return { scan: { ...scan, results: kept, counts }, withheld, withheld_without_cwd };
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* `cockpit backfill`'s version of the same drop: under `roots_only` a session outside every approved root never becomes
|
|
182
|
+
* a candidate, so backfill can never upload what the scheduled sync withholds. Under `all` the scans pass through.
|
|
183
|
+
*/
|
|
184
|
+
export function scopeHistoryScans(options, codex, claude) {
|
|
185
|
+
if (options.collectionScope.scope !== "roots_only")
|
|
186
|
+
return { codex, claude };
|
|
187
|
+
const codexScoped = codex ? scopeScanResult(codex, options.collectionRoots) : null;
|
|
188
|
+
const claudeScoped = claude ? scopeScanResult(claude, options.collectionRoots) : null;
|
|
189
|
+
logScopeWithhold("backfill", {
|
|
190
|
+
source: "codex+claude_code",
|
|
191
|
+
kept: (codexScoped?.scan.results.length ?? 0) + (claudeScoped?.scan.results.length ?? 0),
|
|
192
|
+
withheld: (codexScoped?.withheld ?? 0) + (claudeScoped?.withheld ?? 0),
|
|
193
|
+
withheld_without_cwd: (codexScoped?.withheld_without_cwd ?? 0) + (claudeScoped?.withheld_without_cwd ?? 0),
|
|
194
|
+
roots: options.collectionRoots.length,
|
|
195
|
+
});
|
|
196
|
+
return { codex: codexScoped?.scan ?? null, claude: claudeScoped?.scan ?? null };
|
|
197
|
+
}
|
|
198
|
+
/** The one log line every acting site writes, so a withhold is never silent. Counts only. */
|
|
199
|
+
export function logScopeWithhold(site, facts) {
|
|
200
|
+
console.error(`[${site}] sessions outside the approved roots withheld by collection scope`, JSON.stringify({ reason: COLLECTION_SCOPE_ROOTS_ONLY_REASON, ...facts }));
|
|
201
|
+
}
|
|
@@ -152,6 +152,9 @@ function sessionReportEntry(result, context) {
|
|
|
152
152
|
attribution_score: result.attribution_score,
|
|
153
153
|
path_score: result.path_score,
|
|
154
154
|
root_scope: rootScopeForReason(result.reason),
|
|
155
|
+
// BLI-23478 (three shelves): every row this collector sends is a team-shelf row; a drawer session sends no row
|
|
156
|
+
// (it left the pass in `session-sync-shelf.ts`). Tower refuses a founder's row without it once routing is on.
|
|
157
|
+
shelf: "team",
|
|
155
158
|
signals: result.signals,
|
|
156
159
|
...(result.content_hash_sha256
|
|
157
160
|
? { session_file_hash_sha256: result.content_hash_sha256 }
|
package/dist/commands/analyze.js
CHANGED
|
@@ -3,6 +3,7 @@ import { normalizeUrl } from "./local-args.js";
|
|
|
3
3
|
import { runSync } from "./sync.js";
|
|
4
4
|
import { describeError, isMissingFileFailure } from "../health-detail.js";
|
|
5
5
|
import { DEFAULT_DASHBOARD_URL, getCollectorRuntimePaths, readLocalCollectorSessionFile, } from "../local-state.js";
|
|
6
|
+
import { collectorShelfHeaders } from "../shelf-headers.js";
|
|
6
7
|
export async function runAnalyze(command, io) {
|
|
7
8
|
const sync = await runAnalysisSync(command, io);
|
|
8
9
|
if (sync.exitCode !== 0 || !isUploadedSyncResult(sync.output)) {
|
|
@@ -56,6 +57,7 @@ async function queueAnalysis(command, io, sync) {
|
|
|
56
57
|
method: "POST",
|
|
57
58
|
headers: {
|
|
58
59
|
authorization: `Bearer ${session.device_token}`,
|
|
60
|
+
...collectorShelfHeaders(),
|
|
59
61
|
"content-type": "application/json",
|
|
60
62
|
},
|
|
61
63
|
body: "{}",
|
|
@@ -18,12 +18,14 @@ import { liveWindowMsFor, scopeBackfillCandidates } from "./backfill-live-window
|
|
|
18
18
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
19
19
|
import { candidateCursorKey, compareBackfillCandidates, isAfterCursor, } from "./backfill-candidates.js";
|
|
20
20
|
import { addReadOnlyGuardIssues, addRepoDiscoveryIssues, backfillScanIssues, countReadOnlyGuards, deterministicSkipCandidateKeys, oversizedBackfillCandidateKeys, retryableCandidateKeys, sortScanIssuesByPriority, } from "./backfill-issues.js";
|
|
21
|
-
import { labelOutsideRootCandidates, outsideRootsWorkspace
|
|
21
|
+
import { labelOutsideRootCandidates, outsideRootsWorkspace } from "./backfill-outside-roots.js";
|
|
22
22
|
import { reasonCountsFor } from "./backfill-reasons.js";
|
|
23
23
|
import { blockedBackfillResult } from "./backfill-result.js";
|
|
24
24
|
import { emptyStubReofferPlan, loadStubReofferPlan, reopenStubbedSessions, } from "./backfill-stub-reoffer.js";
|
|
25
25
|
import { loadStubCursorHashes, locateStubbedSessions } from "./backfill-stub-locate.js";
|
|
26
26
|
import { resolveBackfillScope } from "./backfill-scope.js";
|
|
27
|
+
import { scopeHistoryScans } from "../collection-scope.js";
|
|
28
|
+
import { decideShelfMode, OWNER_CHECK_UNAVAILABLE, shelveBackfillScan, teamWorktreesOnly } from "./session-sync-shelf.js";
|
|
27
29
|
/**
|
|
28
30
|
* SCAN: is this machine paired, what roots/sources/window apply, and what did
|
|
29
31
|
* the archived Codex + Claude session stores actually contain. Returns either
|
|
@@ -44,14 +46,18 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
44
46
|
};
|
|
45
47
|
}
|
|
46
48
|
const { collectionRoots, sources, worktreeDiscovery } = scope;
|
|
49
|
+
const shelfDecision = await decideShelfMode({ homeDir: command.homeDir, dashboardUrl: scope.dashboardUrl, fetchImpl: io.fetch }); // BLI-23478
|
|
50
|
+
if (shelfDecision.mode === "hold")
|
|
51
|
+
return { kind: "blocked", result: blockedBackfillResult(command, { now, dashboardUrl: scope.dashboardUrl, reason: OWNER_CHECK_UNAVAILABLE, cursor: await readBackfillCursor(paths) }) };
|
|
52
|
+
const worktrees = teamWorktreesOnly(worktreeDiscovery.worktrees, shelfDecision);
|
|
47
53
|
const { scopedCursor, cursor, pointerlessTerminalSessions, stubReoffer, stubCursorHashes } = await reopenUndeliveredHistory(command, io, paths, scope);
|
|
48
54
|
const scan = await scanBackfillSessions({
|
|
49
55
|
command,
|
|
50
|
-
homeDir: command.homeDir ?? os.homedir(),
|
|
51
|
-
worktrees
|
|
56
|
+
homeDir: command.homeDir ?? os.homedir(), shelfDecision,
|
|
57
|
+
worktrees,
|
|
52
58
|
collectionRoots,
|
|
53
59
|
sources,
|
|
54
|
-
window: scope.window,
|
|
60
|
+
window: scope.window, collectionScope: scope.collectionScope,
|
|
55
61
|
cursor,
|
|
56
62
|
pointerlessTerminalSessions,
|
|
57
63
|
stubReoffer,
|
|
@@ -67,7 +73,7 @@ export async function scanBackfillRun(command, io, now) {
|
|
|
67
73
|
sources,
|
|
68
74
|
window: scope.window,
|
|
69
75
|
collectionRoots,
|
|
70
|
-
worktrees
|
|
76
|
+
worktrees,
|
|
71
77
|
retryCommand: scope.retryCommand,
|
|
72
78
|
scopedCursor,
|
|
73
79
|
cursor,
|
|
@@ -132,12 +138,11 @@ async function scanBackfillSessions(options) {
|
|
|
132
138
|
const scanLimit = options.command.maxFiles ?? 10_000;
|
|
133
139
|
const codexSessionDirs = defaultCodexSessionDirs(options.homeDir);
|
|
134
140
|
const claudeProjectsDir = path.join(options.homeDir, ".claude", "projects");
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const
|
|
139
|
-
|
|
140
|
-
: null;
|
|
141
|
+
const codexScanned = options.sources.includes("codex") ? await scanCodexHistory(options, codexSessionDirs, scanLimit) : null;
|
|
142
|
+
const claudeScanned = options.sources.includes("claude_code") ? await scanClaudeHistory(options, claudeProjectsDir, scanLimit) : null;
|
|
143
|
+
// BLI-23590 withholds sessions outside the approved roots first; BLI-23478 then keeps drawer sessions home.
|
|
144
|
+
const scoped = scopeHistoryScans(options, codexScanned, claudeScanned);
|
|
145
|
+
const { codexAttribution, claudeAttribution } = shelveBackfillScan(scoped.codex, scoped.claude, options.shelfDecision);
|
|
141
146
|
// BLI-11119: a stubbed session is found by the id in its content or by the
|
|
142
147
|
// file hash its cursor entry recorded, across every root the scan walked.
|
|
143
148
|
const scanned = locateStubbedSessions({
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* which stores and how far back was it asked for, and can it reach the
|
|
5
5
|
* dashboard at all. Nothing here opens a transcript.
|
|
6
6
|
*/
|
|
7
|
-
import {
|
|
7
|
+
import { settleCollectionScope } from "../collection-scope.js";
|
|
8
|
+
import { configOptOutLabels, resolveSourceSwitches } from "../source-opt-out.js";
|
|
9
|
+
import { liveStandingForDecision } from "../live-founder-memo.js";
|
|
8
10
|
import path from "node:path";
|
|
9
11
|
import { readLocalCollectorConfig, readLocalCollectorSessionFile, readLocalSessionReference, } from "../local-state.js";
|
|
10
12
|
import { DEFAULT_DISCOVERY_MAX_DEPTH, DEFAULT_DISCOVERY_MAX_REPOS, collectionRootPathAliases, discoverGitWorktreesInRootsWithStatus, } from "../repo-identity.js";
|
|
@@ -45,8 +47,11 @@ export async function resolveBackfillScope(command, io, now, paths) {
|
|
|
45
47
|
collectionRoots,
|
|
46
48
|
worktreeDiscovery,
|
|
47
49
|
retryCommand: backfillDiscoveryRetryCommand(command, worktreeDiscovery),
|
|
48
|
-
sources: honourSourceOptOuts(selectedSources(command.source), config
|
|
50
|
+
sources: honourSourceOptOuts(selectedSources(command.source), config,
|
|
51
|
+
// Tower's live word, asked only when the config holds an opt-out (BLI-22640).
|
|
52
|
+
configOptOutLabels(config).length > 0 ? (await liveStandingForDecision(paths, { fetch: io.fetch, dashboardUrl })).standing : "unknown"),
|
|
49
53
|
window,
|
|
54
|
+
collectionScope: await settleCollectionScope(paths.state_dir, config.collection_scope),
|
|
50
55
|
};
|
|
51
56
|
}
|
|
52
57
|
/**
|
|
@@ -58,8 +63,15 @@ export async function resolveBackfillScope(command, io, now, paths) {
|
|
|
58
63
|
* Backfill never reads the store of a source the machine said no to, and logs
|
|
59
64
|
* the reason label (ticket 22557).
|
|
60
65
|
*/
|
|
61
|
-
function honourSourceOptOuts(sources, config) {
|
|
62
|
-
|
|
66
|
+
function honourSourceOptOuts(sources, config, standing) {
|
|
67
|
+
// Founders only (BLI-22640): a non-founder's opt-out is ignored and named here.
|
|
68
|
+
const switches = resolveSourceSwitches(config, standing);
|
|
69
|
+
if (switches.ignored.length > 0) {
|
|
70
|
+
console.error("[backfill] opt-out ignored; only a founder's opt-out is obeyed, so these sessions upload", JSON.stringify({ reason: switches.verdict, ignored: switches.ignored }));
|
|
71
|
+
}
|
|
72
|
+
if (switches.held) {
|
|
73
|
+
console.error("[backfill] opt-out held; Tower could not say whether this person is a founder, so the sources it names send nothing", JSON.stringify({ reason: switches.verdict, held: configOptOutLabels(config) }));
|
|
74
|
+
}
|
|
63
75
|
const kept = sources.filter((source) => (source === "codex" ? switches.codex : switches.claude));
|
|
64
76
|
if (kept.length === sources.length)
|
|
65
77
|
return sources;
|
|
@@ -83,6 +83,8 @@ function sessionAttributionRow(candidate, uploads, now) {
|
|
|
83
83
|
path_score: candidate.path_score,
|
|
84
84
|
// BLI-11091: the same inside/outside label the sync path writes (PR 1453).
|
|
85
85
|
root_scope: rootScopeForReason(candidate.reason),
|
|
86
|
+
// BLI-23478: a team-shelf row; a drawer session never becomes a backfill candidate (`backfill-scan.ts`).
|
|
87
|
+
shelf: "team",
|
|
86
88
|
signals: candidate.signals,
|
|
87
89
|
...(candidate.content_hash_sha256
|
|
88
90
|
? { session_file_hash_sha256: candidate.content_hash_sha256 }
|
package/dist/commands/cli-io.js
CHANGED
|
@@ -24,9 +24,7 @@ export function writeFragment(stream, text) {
|
|
|
24
24
|
return;
|
|
25
25
|
stream.write(text);
|
|
26
26
|
}
|
|
27
|
-
export
|
|
28
|
-
return error instanceof Error ? error.message : String(error);
|
|
29
|
-
}
|
|
27
|
+
export { errorMessage } from "../error-message.js";
|
|
30
28
|
// ------------------------------------------------------------------- styling
|
|
31
29
|
const SGR_DIM = "\x1b[2m";
|
|
32
30
|
const SGR_RESET = "\x1b[0m";
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os from "node:os";
|
|
2
2
|
import { readRootWorkScan, outsideWorkDirectories, rootsAddCommand, sessionCoverageLine } from "../root-work.js";
|
|
3
3
|
import { scanWorkDirectories } from "./local-discovery.js";
|
|
4
|
+
import { settleCollectionScope } from "../collection-scope.js";
|
|
4
5
|
import { sendCollectorHeartbeatBestEffort } from "./heartbeat.js";
|
|
5
6
|
import { isInteractiveDoctorFix } from "./doctor-report.js";
|
|
6
7
|
import { describeError } from "../health-detail.js";
|
|
@@ -175,6 +176,12 @@ function shellQuote(value) {
|
|
|
175
176
|
export async function readRootsCoverWork(context) {
|
|
176
177
|
const homeDir = context.command.homeDir ?? os.homedir();
|
|
177
178
|
const paths = getCollectorRuntimePaths(homeDir);
|
|
179
|
+
// BLI-23590: under `roots_only` (Tower cleared this person) a session outside the roots is deliberately not
|
|
180
|
+
// collected. That is a choice, so the row is ok and says so; it never counts the withheld sessions as a gap.
|
|
181
|
+
const scope = await settleCollectionScope(paths.state_dir, (await readLocalCollectorConfig(paths).catch(() => null))?.collection_scope);
|
|
182
|
+
if (scope.scope === "roots_only") {
|
|
183
|
+
return ok("roots-cover-work", "work_outside_roots_deliberately_withheld", `Collection scope is roots only (${scope.source === "server" ? "set in Tower" : "your local setting, confirmed by Tower"}): sessions whose working folder is outside your approved roots are deliberately not read or uploaded, and only a count reaches Tower. This is a choice, not a gap; nothing needs fixing. To add a folder to what is collected: cockpit roots add <dir>`);
|
|
184
|
+
}
|
|
178
185
|
const scan = await readRootWorkScan(paths) ?? await scanWorkDirectories(homeDir, false);
|
|
179
186
|
const outside = outsideWorkDirectories(scan, (await readLocalCollectorConfig(paths))?.default_repo_paths ?? []);
|
|
180
187
|
const outsideCount = outside.reduce((sum, entry) => sum + entry.count, 0);
|
|
@@ -2,6 +2,7 @@ import { inspectAgentRules, installAgentRules } from "../agent-rules.js";
|
|
|
2
2
|
import { doctorRoots } from "./doctor-access.js";
|
|
3
3
|
import { installAutostartAgent } from "../autostart.js";
|
|
4
4
|
import { clearAutostartOptOut } from "../autostart-opt-out.js";
|
|
5
|
+
import { isMemoryCaptureOff } from "../memory-capture-choice.js";
|
|
5
6
|
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
6
7
|
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
7
8
|
import { autostartDoctorStatus, autostartRegistrationRoots, readAutostartRegistration, } from "./autostart-reading.js";
|
|
@@ -73,10 +74,21 @@ export async function fixAutostartState(context) {
|
|
|
73
74
|
* a hook failure, which is worse than waiting a day.
|
|
74
75
|
*/
|
|
75
76
|
export async function checkMemoryState(context) {
|
|
77
|
+
if (await memoryOptedOut(context)) {
|
|
78
|
+
// BLI-22640: a dry run of the opted-out install writes nothing and says
|
|
79
|
+
// whether any of our entries (or `bli-tower`) still needs the fix.
|
|
80
|
+
const preview = await installMemoryIntegration({ ...memoryCommandFor(context), dryRun: true }, context.io);
|
|
81
|
+
return optedOutStepState(preview, "check");
|
|
82
|
+
}
|
|
76
83
|
const outcome = await inspectMemoryIntegration(memoryCommandFor(context), context.io);
|
|
77
84
|
return memoryStepState(outcome, "check");
|
|
78
85
|
}
|
|
79
86
|
export async function fixMemoryState(context) {
|
|
87
|
+
if (await memoryOptedOut(context)) {
|
|
88
|
+
// Never re-adds BLI Memory: the opted-out install only takes our entries
|
|
89
|
+
// back off and keeps `bli-tower`, which `mcp-answers`' repair relies on.
|
|
90
|
+
return optedOutStepState(await installMemoryIntegration(memoryCommandFor(context), context.io), "fix");
|
|
91
|
+
}
|
|
80
92
|
let outcome = await installMemoryIntegration(memoryCommandFor(context), context.io);
|
|
81
93
|
if (!outcome.bin_found) {
|
|
82
94
|
// The public CLI declares the memory and Tower MCP packages as dependencies.
|
|
@@ -86,6 +98,33 @@ export async function fixMemoryState(context) {
|
|
|
86
98
|
}
|
|
87
99
|
return memoryStepState(outcome, "fix");
|
|
88
100
|
}
|
|
101
|
+
async function memoryOptedOut(context) {
|
|
102
|
+
return isMemoryCaptureOff(getCollectorRuntimePaths(context.command.homeDir), { fetch: context.io.fetch });
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* BLI-22640. The row on a machine whose owner turned BLI Memory off: never
|
|
106
|
+
* red for the absent registration (that is the choice), `skipped
|
|
107
|
+
* explicit_opt_out` once nothing of ours is left, and a fix only when an
|
|
108
|
+
* entry of ours is still on disk or `bli-tower` is missing.
|
|
109
|
+
*/
|
|
110
|
+
function optedOutStepState(outcome, phase) {
|
|
111
|
+
const failed = outcome.targets.find((target) => target.status === "failed");
|
|
112
|
+
if (failed) {
|
|
113
|
+
const message = `BLI Memory is off on this machine, but ${failed.target} could not be cleaned: ${failed.reason}`;
|
|
114
|
+
return phase === "fix"
|
|
115
|
+
? fail("memory-registered", failed.reason, message)
|
|
116
|
+
: needsFix("memory-registered", failed.reason, message);
|
|
117
|
+
}
|
|
118
|
+
const leftover = outcome.removed_count ?? 0;
|
|
119
|
+
const towerPending = outcome.targets.some((target) => target.status === "would_install");
|
|
120
|
+
if (phase === "check" && (leftover > 0 || towerPending)) {
|
|
121
|
+
return needsFix("memory-registered", leftover > 0 ? "opted_out_entries_left" : "registration_incomplete", leftover > 0
|
|
122
|
+
? `BLI Memory is off on this machine, but ${leftover} of its entries are still registered; doctor removes them`
|
|
123
|
+
: "BLI Memory is off on this machine; bli-tower is not registered yet");
|
|
124
|
+
}
|
|
125
|
+
const removed = phase === "fix" && leftover > 0 ? ` Removed ${leftover} of its entries.` : "";
|
|
126
|
+
return skipped("memory-registered", outcome.reason === "choice_unreadable" ? "choice_unreadable" : "explicit_opt_out", `BLI Memory is off on this machine (your choice); nothing of it is registered.${removed} \`cockpit memory on\` turns it back on.`);
|
|
127
|
+
}
|
|
89
128
|
function memoryCommandFor(context) {
|
|
90
129
|
return {
|
|
91
130
|
kind: "memory",
|
|
@@ -169,6 +169,7 @@ export function doctorNeedsPerson(rows, receipt) {
|
|
|
169
169
|
"authed": "cockpit doctor",
|
|
170
170
|
"roots-ok": "cockpit doctor",
|
|
171
171
|
"roots-cover-work": "cockpit doctor",
|
|
172
|
+
"shelf-routing": "cockpit doctor",
|
|
172
173
|
"single-install": "Run npm uninstall -g @bli-cockpit/cli with the Node installation that owns the extra CLI listed above.",
|
|
173
174
|
"autostart-alive": "cockpit doctor",
|
|
174
175
|
"usage-telemetry": "cockpit doctor",
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `shelf-routing` doctor row (BLI-23478, three-shelves design 9.1 item 13).
|
|
3
|
+
*
|
|
4
|
+
* Reads what the last heartbeat stored about Tower's founder lock (`shelf-state.json`) and this laptop's default.
|
|
5
|
+
* On a founder's laptop, once Tower enforces the lock AND an owner has switched founder routing on, the repair sets
|
|
6
|
+
* `shelf_default: drawer`, so a founder's work outside every labelled folder stays on the laptop from then on. It
|
|
7
|
+
* never overrides a default the founder chose (`cockpit shelf default team`), and it never runs before Tower says
|
|
8
|
+
* the lock is on: labels and the default do nothing until both locks exist (design 4.1).
|
|
9
|
+
*
|
|
10
|
+
* Every other laptop: one `ok` line, nothing written.
|
|
11
|
+
*/
|
|
12
|
+
import os from "node:os";
|
|
13
|
+
import { safeLog } from "@bli-cockpit/telemetry-core";
|
|
14
|
+
import { ensureLocalCollectorConfig } from "../local-state.js";
|
|
15
|
+
import { writeJsonFile } from "../local-state-files.js";
|
|
16
|
+
import { readShelfState } from "../shelf-state.js";
|
|
17
|
+
import { askFounderLive } from "../shelf-decision.js";
|
|
18
|
+
import { configForDisk } from "../source-opt-out.js";
|
|
19
|
+
import { needsFix, ok, skipped } from "./doctor-report.js";
|
|
20
|
+
const ID = "shelf-routing";
|
|
21
|
+
/**
|
|
22
|
+
* Whether this laptop's founder should have the drawer default now. Shared with the usage-telemetry row. The
|
|
23
|
+
* switch's state comes from the saved heartbeat answer; WHO is a founder is Tower's live answer, never the saved
|
|
24
|
+
* copy (`shelf-decision.ts`): a forged `is_owner` in the file changes nothing here.
|
|
25
|
+
*/
|
|
26
|
+
export async function founderRoutingOn(homeDir, fetchImpl) {
|
|
27
|
+
const state = readShelfState(homeDir);
|
|
28
|
+
if (!state?.shelf_enforced || state.founder_routing_enabled !== true)
|
|
29
|
+
return false;
|
|
30
|
+
return (await askFounderLive({ homeDir, fetchImpl })).standing === "founder";
|
|
31
|
+
}
|
|
32
|
+
export async function checkShelfRouting(context) {
|
|
33
|
+
const homeDir = context.command.homeDir ?? os.homedir();
|
|
34
|
+
const state = readShelfState(homeDir);
|
|
35
|
+
if (!state)
|
|
36
|
+
return skipped(ID, "not_enforced", "shelf routing: Tower has not answered this laptop yet; nothing changes until it does");
|
|
37
|
+
if (state.is_owner !== true)
|
|
38
|
+
return ok(ID, "enforced", "shelf routing: not a founder's laptop; nothing changes here");
|
|
39
|
+
if (state.cli_too_old) {
|
|
40
|
+
return { ...needsFix(ID, "cli_too_old", "shelf routing: this CLI is older than Tower's founder lock accepts; run `cockpit update`"), nextAction: "cockpit update" };
|
|
41
|
+
}
|
|
42
|
+
if (state.founder_routing_enabled !== true || !state.shelf_enforced) {
|
|
43
|
+
return ok(ID, "routing_disabled_by_owner", "shelf routing: founder routing is off in Tower; labels wait until an owner switches it on");
|
|
44
|
+
}
|
|
45
|
+
const config = await ensureLocalCollectorConfig({ homeDir }).then((result) => result.config).catch(() => null);
|
|
46
|
+
if (config?.shelf_default === "drawer")
|
|
47
|
+
return ok(ID, "drawer_default_set", "shelf routing: on; work outside team-labelled folders stays in the drawer on this laptop");
|
|
48
|
+
if (config?.shelf_default === "team")
|
|
49
|
+
return ok(ID, "team_default_chosen", "shelf routing: on; you chose team as the default (`cockpit shelf default drawer` to change it)");
|
|
50
|
+
return needsFix(ID, "drawer_default_missing", "shelf routing: on, but this laptop has no default yet; doctor sets it to drawer");
|
|
51
|
+
}
|
|
52
|
+
export async function fixShelfRouting(context) {
|
|
53
|
+
const homeDir = context.command.homeDir ?? os.homedir();
|
|
54
|
+
if (!(await founderRoutingOn(homeDir, context.io.fetch)))
|
|
55
|
+
return checkShelfRouting(context);
|
|
56
|
+
const { config, paths } = await ensureLocalCollectorConfig({ homeDir });
|
|
57
|
+
if (config.shelf_default)
|
|
58
|
+
return checkShelfRouting(context);
|
|
59
|
+
await writeJsonFile(paths.config_file, configForDisk({ ...config, shelf_default: "drawer" }));
|
|
60
|
+
safeLog("shelf", "drawer_default_set", { by: "doctor" });
|
|
61
|
+
return { ...ok(ID, "drawer_default_set", "shelf routing: on; work outside team-labelled folders now stays in the drawer on this laptop"), fixed: true };
|
|
62
|
+
}
|