@bli-cockpit/cli 0.2.208 → 0.2.211

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.
@@ -0,0 +1,154 @@
1
+ /**
2
+ * This machine's image-handling mode, as Tower last told it (BLI-17273).
3
+ *
4
+ * The fleet default is `IMAGE_HANDLING_MODE` (keep). Tower can set a different mode for ONE machine
5
+ * (`collector_image_mode:<device_id>` in `system_settings`); the answer rides the reply to the
6
+ * sync-start receipt (`image_handling_mode`). The tick records what it heard here, in one small file in
7
+ * the collector state directory, and every later reader (the uploader, `cockpit sources check`)
8
+ * reads the file. The mode for a tick is therefore:
9
+ *
10
+ * the reply's value, when this tick got one (it is written to the file first, so the file IS it)
11
+ * else the last value on file (an offline tick must not flip a slim machine back
12
+ * to keep: each flip re-uploads every live-window
13
+ * transcript as a new object)
14
+ * else `IMAGE_HANDLING_MODE`
15
+ *
16
+ * The file also remembers, sticky, that this machine EVER ran slim (`drop_seen_at`). A machine put
17
+ * back on keep still holds slim server copies of the files it uploaded while slim, and
18
+ * `cockpit sources check` must go on treating those as "keep your local file".
19
+ *
20
+ * Holds the mode and two timestamps. Never an id, a path or a byte.
21
+ */
22
+ import path from "node:path";
23
+ import { IMAGE_HANDLING_MODE } from "@bli-cockpit/telemetry-core";
24
+ import { describeError, isMissingFileFailure } from "./health-detail.js";
25
+ import { readJsonFile, writeJsonFileAtomic } from "./local-state-files.js";
26
+ export const IMAGE_MODE_STATE_FILENAME = "image-handling-mode.json";
27
+ const TAG = "[image-mode]";
28
+ export function isImageHandlingMode(value) {
29
+ return value === "describe_and_keep" || value === "describe_and_drop";
30
+ }
31
+ export function imageModeStatePath(stateDir) {
32
+ return path.join(stateDir, IMAGE_MODE_STATE_FILENAME);
33
+ }
34
+ /**
35
+ * Stands in for `drop_seen_at` when the file was there but could not be read, so whether this machine
36
+ * ever ran slim is unknown. It counts as "ran slim": the wrong guess costs a kept local file, the
37
+ * other wrong guess lets `cockpit sources check` call a slim server copy safe to delete.
38
+ */
39
+ export const DROP_SEEN_UNKNOWN = "unknown_state_unreadable";
40
+ async function readImageModeFile(stateDir) {
41
+ let raw;
42
+ try {
43
+ raw = await readJsonFile(imageModeStatePath(stateDir));
44
+ }
45
+ catch (error) {
46
+ if (isMissingFileFailure(error))
47
+ return { state: null, damaged: false, recoveredDropSeenAt: null };
48
+ console.error(TAG + " cache unreadable, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_unreadable", ...describeError(error) }));
49
+ return { state: null, damaged: true, recoveredDropSeenAt: null };
50
+ }
51
+ const record = raw && typeof raw === "object" && !Array.isArray(raw) ? raw : null;
52
+ if (!record) {
53
+ console.error(TAG + " cache is not an object, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_shape_invalid" }));
54
+ return { state: null, damaged: true, recoveredDropSeenAt: null };
55
+ }
56
+ const dropSeenAt = typeof record.drop_seen_at === "string" ? record.drop_seen_at : null;
57
+ if (!isImageHandlingMode(record.mode)) {
58
+ console.error(TAG + " cached mode not recognised, using the fleet default and treating this machine as ever-slim", JSON.stringify({ reason: "image_mode_cache_value_invalid" }));
59
+ return { state: null, damaged: true, recoveredDropSeenAt: dropSeenAt };
60
+ }
61
+ return {
62
+ state: {
63
+ mode: record.mode,
64
+ observed_at: typeof record.observed_at === "string" ? record.observed_at : "",
65
+ drop_seen_at: dropSeenAt,
66
+ },
67
+ damaged: false,
68
+ recoveredDropSeenAt: null,
69
+ };
70
+ }
71
+ /** The file's content, or null when there is none or it cannot be trusted (said out loud when unreadable). */
72
+ export async function readImageModeState(stateDir) {
73
+ return (await readImageModeFile(stateDir)).state;
74
+ }
75
+ /**
76
+ * What a reader of the file should use right now: the cached mode, else (damaged file) slim when a
77
+ * `drop_seen_at` could be recovered from it, else the fleet default. `slimAware` is true when this
78
+ * machine runs slim OR has ever run slim. `read` is a read the caller already did this tick, so a
79
+ * damaged file is read and logged once.
80
+ */
81
+ export async function resolveImageHandlingMode(stateDir, read) {
82
+ const { state, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
83
+ // A damaged file that still named when this machine first ran slim: it ran slim, keep it slim
84
+ // (flipping to keep would re-upload every live-window transcript as a new whole object).
85
+ const mode = state?.mode ?? (damaged && recoveredDropSeenAt !== null ? "describe_and_drop" : IMAGE_HANDLING_MODE);
86
+ return {
87
+ mode,
88
+ source: state ? "cache" : damaged ? "damaged_cache" : "default",
89
+ // A file that is there but unreadable is "ever slim, unknown": fail closed (BLI-17273).
90
+ slimAware: mode === "describe_and_drop" || state?.drop_seen_at != null || damaged,
91
+ state,
92
+ };
93
+ }
94
+ /**
95
+ * Put what the reply said on file. An unknown value is ignored with a log (the old value stays);
96
+ * a write failure is logged and the previous file stays in force. Returns true only when the file
97
+ * now holds the observed mode.
98
+ */
99
+ export async function recordObservedImageMode(stateDir, observed, now = () => new Date(), read) {
100
+ if (observed === undefined || observed === null)
101
+ return false;
102
+ if (!isImageHandlingMode(observed)) {
103
+ console.error(TAG + " reply carried a mode this collector does not know, ignored", JSON.stringify({ reason: "image_mode_reply_value_unknown", value_type: typeof observed }));
104
+ return false;
105
+ }
106
+ const { state: previous, damaged, recoveredDropSeenAt } = read ?? (await readImageModeFile(stateDir));
107
+ const stamp = now().toISOString();
108
+ const next = {
109
+ mode: observed,
110
+ observed_at: stamp,
111
+ // Never rebuild "never ran slim" over a file we could not read: keep what parsed, else the unknown marker.
112
+ drop_seen_at: previous?.drop_seen_at ?? (damaged ? (recoveredDropSeenAt ?? DROP_SEEN_UNKNOWN) : observed === "describe_and_drop" ? stamp : null),
113
+ };
114
+ if (damaged) {
115
+ console.error(TAG + " rewriting a damaged mode file; ever-slim history kept", JSON.stringify({ reason: "image_mode_cache_damaged_rewritten", drop_seen_recovered: recoveredDropSeenAt !== null }));
116
+ }
117
+ try {
118
+ await writeJsonFileAtomic(imageModeStatePath(stateDir), { schema_version: "cockpit-image-handling-mode.v1", ...next });
119
+ }
120
+ catch (error) {
121
+ console.error(TAG + " could not cache the mode Tower sent; the previous value stays in force", JSON.stringify({ reason: "image_mode_cache_write_failed", mode: observed, ...describeError(error) }));
122
+ return false;
123
+ }
124
+ if (previous?.mode !== observed) {
125
+ console.error(TAG + " mode for this machine changed", JSON.stringify({ reason: "image_mode_changed", from: previous?.mode ?? (damaged ? "damaged" : "none"), to: observed }));
126
+ }
127
+ return true;
128
+ }
129
+ /**
130
+ * The tick's decision, logged once: where the mode came from and what it is. Called right after the
131
+ * sync-start receipt. `observed` is the reply's value (undefined when no reply arrived).
132
+ */
133
+ export async function settleTickImageMode(stateDir, observed, now) {
134
+ // One read per tick: a damaged file is logged once, and the 'changed' line can say from:'damaged'.
135
+ const read = await readImageModeFile(stateDir);
136
+ const wrote = await recordObservedImageMode(stateDir, observed, now, read);
137
+ // After a write the file is whole again, so read it fresh; otherwise the tick's own read stands.
138
+ const resolved = wrote ? await resolveImageHandlingMode(stateDir) : await resolveImageHandlingMode(stateDir, read);
139
+ const source = wrote ? "reply" : resolved.source;
140
+ console.error(TAG + " mode for this tick", JSON.stringify({ reason: `image_mode_from_${source}`, source, mode: resolved.mode }));
141
+ return { mode: resolved.mode, source };
142
+ }
143
+ /**
144
+ * `settleTickImageMode` for the sync tick, which it must never fail: a throw is logged and the
145
+ * uploader falls back to the file or the fleet default.
146
+ */
147
+ export async function settleTickImageModeBestEffort(stateDir, observed) {
148
+ try {
149
+ await settleTickImageMode(stateDir, observed);
150
+ }
151
+ catch (error) {
152
+ console.error(TAG + " could not settle the mode for this tick; the uploader reads the file or the fleet default", JSON.stringify({ reason: "image_mode_settle_failed", error_name: error instanceof Error ? error.name : typeof error }));
153
+ }
154
+ }
@@ -47,6 +47,26 @@ export async function writeJsonFile(filePath, value) {
47
47
  await fs.chmod(filePath, 0o600).catch(() => undefined);
48
48
  }
49
49
  }
50
+ /**
51
+ * Same file, same owner-only mode, but written to a sibling temp file and renamed over the target, so
52
+ * a reader (or a crash) never sees a half-written file (BLI-17273: a torn image-mode file made a slim
53
+ * machine forget it had ever run slim). The temp file is removed when the write or the rename fails.
54
+ */
55
+ export async function writeJsonFileAtomic(filePath, value) {
56
+ await fs.mkdir(path.dirname(filePath), { recursive: true });
57
+ const temp = `${filePath}.${process.pid}.${Math.random().toString(36).slice(2, 10)}.tmp`;
58
+ try {
59
+ await fs.writeFile(temp, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
60
+ if (process.platform !== "win32") {
61
+ await fs.chmod(temp, 0o600).catch(() => undefined);
62
+ }
63
+ await fs.rename(temp, filePath);
64
+ }
65
+ catch (error) {
66
+ await fs.rm(temp, { force: true }).catch(() => undefined);
67
+ throw error;
68
+ }
69
+ }
50
70
  /**
51
71
  * Separate from `isMissingFileFailure`: the two callers here hold a raw `fs`
52
72
  * rejection rather than a wrapped failure, and "the file was never there" is
@@ -64,7 +64,9 @@ async function writeLocalWorkContext(options, attributedIdentity) {
64
64
  existingContext,
65
65
  });
66
66
  await writeJsonFile(workContextFile(paths, identity.worktree_fingerprint), context);
67
- await writeJsonFile(paths.active_work_context_file, context);
67
+ if (!options.leaveActiveContext) {
68
+ await writeJsonFile(paths.active_work_context_file, context);
69
+ }
68
70
  return context;
69
71
  }
70
72
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.208",
3
+ "version": "0.2.211",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.63",
31
- "@bli-cockpit/mcp": "0.1.118",
32
- "@bli-cockpit/telemetry-core": "0.1.72"
30
+ "@bli-cockpit/memory-mcp": "0.1.65",
31
+ "@bli-cockpit/mcp": "0.1.120",
32
+ "@bli-cockpit/telemetry-core": "0.1.74"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",