@bli-cockpit/cli 0.2.249 → 0.2.250

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.
@@ -23,6 +23,7 @@
23
23
  * `.staging-<pid>-<rand>` directory that `raw-evidence-facts.ts` promotes once
24
24
  * collection is done.
25
25
  */
26
+ import { openPrepareMemo } from "./raw-evidence-prepare-memo.js";
26
27
  import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
27
28
  import crypto from "node:crypto";
28
29
  import fs from "node:fs/promises";
@@ -63,6 +64,7 @@ export async function openCollection(context, options, places) {
63
64
  if (options.images)
64
65
  budget.images ??= options.images;
65
66
  const stagingAllowance = (budget.staging ??= await openStagingAllowance(options.stateDir, options.env ?? process.env, { statfs: options.statfs }));
67
+ budget.prepareMemo ??= await openPrepareMemo(options.stateDir);
66
68
  return {
67
69
  context,
68
70
  filesDir: path.join(places.stagingDir, "files"),
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Remember what a transcript became, so a tick does not rebuild it (BLI-3936).
3
+ *
4
+ * Turning a transcript into the bytes that go up costs a read, the image step, the slim rules, the
5
+ * secret mask and a sha256 of the result. The result only matters for ONE decision: is this
6
+ * content already durable remotely? Before this file, every 15-minute tick (and every worktree
7
+ * pass inside it) paid that whole cost for every recent transcript to learn the answer it had
8
+ * already learned the tick before: 280 slim passes and 0.38 GB of JSON per tick on the reference
9
+ * Mac, plus 82 mask passes, for transcripts that had not changed.
10
+ *
11
+ * The memo answers the question without the work. It is keyed by the same source key the delivery
12
+ * ledger uses, and an entry is trusted only when ALL of these still hold:
13
+ *
14
+ * - the file's size, inode and mtime/ctime (nanoseconds) are exactly what they were when the
15
+ * entry was written, so the file has not been appended to, rewritten or replaced;
16
+ * - the recipe is the same: collector version (slim rules and secret rules ship with it), the
17
+ * machine's image-handling mode, and whether an image step ran at all;
18
+ * - the entry is not `volatile`: a transcript that still held an image with no stored
19
+ * description (or whose slim step failed) can change shape on the next tick without the file
20
+ * changing, so it is always rebuilt.
21
+ *
22
+ * The caller then reuses the entry ONLY when the remembered content hash is already durable
23
+ * remotely (`skipContentHashes`). Anything else, including every file that is not yet delivered,
24
+ * takes the normal full path, so the memo can only ever remove work whose outcome is already
25
+ * known. A memo that is lost, corrupt, or wrong in the safe direction costs one slow tick, never
26
+ * a missed upload. It holds ids, sizes, hashes and redaction metadata, never a byte of content.
27
+ */
28
+ import path from "node:path";
29
+ import fs from "node:fs/promises";
30
+ import { isMissingFileFailure } from "../health-detail.js";
31
+ import { readJsonFile, writeJsonFileAtomic } from "../local-state-files.js";
32
+ import { LOCAL_COLLECTOR_VERSION } from "../local-state-config.js";
33
+ export const PREPARE_MEMO_FILENAME = "transcript-prepare-memo.json";
34
+ const MEMO_FORMAT = "prepare-memo.v1";
35
+ /** About 700 bytes an entry, so this is at most a few MB; the oldest leave first. */
36
+ export const PREPARE_MEMO_MAX_ENTRIES = 20_000;
37
+ /** A source not seen for this long is a deleted or rotated session. */
38
+ const FORGET_AFTER_MS = 14 * 24 * 3_600_000;
39
+ /** Refresh `seen_ms` at most this often, so an unchanged tick does not rewrite the file. */
40
+ const SEEN_REFRESH_MS = 24 * 3_600_000;
41
+ const TAG = "[raw-evidence-memo]";
42
+ export function sourceStampOf(stat) {
43
+ return {
44
+ size: Number(stat.size),
45
+ identity: [stat.ino, stat.mtimeNs, stat.ctimeNs].map(String).join(":"),
46
+ };
47
+ }
48
+ /** Stat a file the way the memo wants it. Null when it cannot be stat'd: the caller takes the normal path, which says why. */
49
+ export async function statSourceForMemo(filePath) {
50
+ try {
51
+ return sourceStampOf(await fs.stat(filePath, { bigint: true }));
52
+ }
53
+ catch {
54
+ return null;
55
+ }
56
+ }
57
+ /** Everything besides the file that decides what its prepared bytes are. */
58
+ export function prepareRecipe(options) {
59
+ return [MEMO_FORMAT, LOCAL_COLLECTOR_VERSION, options.kind, options.imageMode ?? "no_image_step"].join("|");
60
+ }
61
+ export function prepareMemoPath(stateDir) {
62
+ return path.join(stateDir, PREPARE_MEMO_FILENAME);
63
+ }
64
+ const EMPTY_COUNTS = { skipped_count: 0, skipped_bytes: 0, rebuilt_count: 0, rebuilt_bytes: 0, volatile_count: 0 };
65
+ export async function openPrepareMemo(stateDir) {
66
+ const entries = await readMemoEntries(stateDir);
67
+ let dirty = false;
68
+ let counts = { ...EMPTY_COUNTS };
69
+ /** Sources recalled since the last `settle`: only these have their `seen_ms` refreshed. */
70
+ const recalled = new Set();
71
+ return {
72
+ recall(sourceKey, stamp, recipe) {
73
+ const entry = entries.get(sourceKey);
74
+ if (!entry)
75
+ return null;
76
+ if (entry.volatile || entry.recipe !== recipe)
77
+ return null;
78
+ if (entry.size !== stamp.size || entry.identity !== stamp.identity)
79
+ return null;
80
+ recalled.add(sourceKey);
81
+ return entry;
82
+ },
83
+ remember(sourceKey, entry, now = Date.now()) {
84
+ entries.set(sourceKey, { ...entry, seen_ms: now });
85
+ dirty = true;
86
+ },
87
+ count(outcome, bytes) {
88
+ if (outcome === "skipped") {
89
+ counts.skipped_count += 1;
90
+ counts.skipped_bytes += bytes;
91
+ return;
92
+ }
93
+ counts.rebuilt_count += 1;
94
+ counts.rebuilt_bytes += bytes;
95
+ if (outcome === "volatile")
96
+ counts.volatile_count += 1;
97
+ },
98
+ async settle(now = Date.now()) {
99
+ const done = counts;
100
+ counts = { ...EMPTY_COUNTS };
101
+ dirty = refreshSeen(entries, recalled, now) || dirty;
102
+ recalled.clear();
103
+ if (!dirty)
104
+ return done;
105
+ const kept = trimEntries(entries, now);
106
+ try {
107
+ await writeJsonFileAtomic(prepareMemoPath(stateDir), { schema_version: MEMO_FORMAT, entries: Object.fromEntries(kept) });
108
+ dirty = false;
109
+ }
110
+ catch (error) {
111
+ console.error(TAG + " memo not written; the next tick rebuilds what it would have skipped", JSON.stringify({ reason: "prepare_memo_write_failed", entries: kept.length, error_name: error instanceof Error ? error.name : typeof error }));
112
+ }
113
+ return done;
114
+ },
115
+ };
116
+ }
117
+ /**
118
+ * Entries recalled this pass keep their `seen_ms` fresh, lazily, so they are not forgotten while in
119
+ * use. Only those: an entry nobody recalls (a deleted or rotated session) ages out after
120
+ * FORGET_AFTER_MS.
121
+ */
122
+ function refreshSeen(entries, recalled, now) {
123
+ let touched = false;
124
+ for (const key of recalled) {
125
+ const entry = entries.get(key);
126
+ if (entry && now - entry.seen_ms > SEEN_REFRESH_MS) {
127
+ entry.seen_ms = now;
128
+ touched = true;
129
+ }
130
+ }
131
+ return touched;
132
+ }
133
+ function trimEntries(entries, now) {
134
+ for (const [key, entry] of entries) {
135
+ if (now - entry.seen_ms > FORGET_AFTER_MS)
136
+ entries.delete(key);
137
+ }
138
+ const newestFirst = [...entries.entries()].sort((a, b) => b[1].seen_ms - a[1].seen_ms);
139
+ const kept = newestFirst.slice(0, PREPARE_MEMO_MAX_ENTRIES);
140
+ if (kept.length < entries.size) {
141
+ entries.clear();
142
+ for (const [key, entry] of kept)
143
+ entries.set(key, entry);
144
+ }
145
+ return kept;
146
+ }
147
+ async function readMemoEntries(stateDir) {
148
+ const entries = new Map();
149
+ let raw;
150
+ try {
151
+ raw = await readJsonFile(prepareMemoPath(stateDir));
152
+ }
153
+ catch (error) {
154
+ if (!isMissingFileFailure(error)) {
155
+ console.error(TAG + " memo unreadable; every transcript is rebuilt once", JSON.stringify({ reason: "prepare_memo_unreadable" }));
156
+ }
157
+ return entries;
158
+ }
159
+ const stored = raw;
160
+ if (stored?.schema_version !== MEMO_FORMAT || !stored.entries || typeof stored.entries !== "object")
161
+ return entries;
162
+ let dropped = 0;
163
+ for (const [key, value] of Object.entries(stored.entries)) {
164
+ const entry = parseEntry(value);
165
+ if (entry)
166
+ entries.set(key, entry);
167
+ else
168
+ dropped += 1;
169
+ }
170
+ if (dropped > 0) {
171
+ console.error(TAG + " memo entries dropped as malformed", JSON.stringify({ reason: "prepare_memo_entries_malformed", dropped, kept: entries.size }));
172
+ }
173
+ return entries;
174
+ }
175
+ function parseEntry(value) {
176
+ if (!value || typeof value !== "object")
177
+ return null;
178
+ const entry = value;
179
+ if (typeof entry["size"] !== "number" || typeof entry["identity"] !== "string")
180
+ return null;
181
+ if (typeof entry["recipe"] !== "string" || typeof entry["byte_size"] !== "number")
182
+ return null;
183
+ if (typeof entry["content_hash"] !== "string" || !/^[0-9a-f]{64}$/u.test(entry["content_hash"]))
184
+ return null;
185
+ if (typeof entry["volatile"] !== "boolean" || typeof entry["seen_ms"] !== "number")
186
+ return null;
187
+ const redacted = entry["redacted"];
188
+ if (redacted !== null && (typeof redacted !== "object" || typeof redacted.completenessLabel !== "string"))
189
+ return null;
190
+ return entry;
191
+ }
@@ -10,6 +10,11 @@
10
10
  *
11
11
  * Every refusal pushes a named skip into the gap ledger first, so a file this
12
12
  * module declines is never silently absent.
13
+ *
14
+ * BLI-3936: the read, the image step, the slim rules, the mask and the hash exist to answer one
15
+ * question, "is this content already durable remotely?". A transcript whose file has not changed
16
+ * since the tick that answered it is not rebuilt: `reuseUnchangedTranscript` replays the answer
17
+ * from the prepare memo (`raw-evidence-prepare-memo.ts`) and says so in the pass's summary line.
13
18
  */
14
19
  import fs from "node:fs/promises";
15
20
  import path from "node:path";
@@ -20,6 +25,7 @@ import { maskSecretsInTranscript, } from "./raw-evidence-sanitize.js";
20
25
  import { mightHoldEmbeddedImages } from "@bli-cockpit/telemetry-core";
21
26
  import { markBudgetCapApplied, markCapApplied, } from "./raw-evidence-completeness.js";
22
27
  import { evidenceEntry } from "./raw-evidence-manifest.js";
28
+ import { prepareRecipe, statSourceForMemo, } from "./raw-evidence-prepare-memo.js";
23
29
  import { describeSnapshot, stubReplacementClaim, supersedeClaimFor, } from "./raw-evidence-snapshot-supersede.js";
24
30
  import { admitEvidence, stageEvidenceBytes, } from "./raw-evidence-collection-state.js";
25
31
  /**
@@ -44,6 +50,12 @@ export async function collectOneEvidenceFile(collection, options) {
44
50
  logDeliveryBackoffHold(options.kind, names.sourceKey);
45
51
  return false;
46
52
  }
53
+ // BLI-3936: stat BEFORE the read. The same stamp, taken again after the work, proves the file
54
+ // did not move underneath it; only then is the result remembered.
55
+ const stamp = await statSourceForMemo(options.filePath);
56
+ const recipe = prepareRecipe({ imageMode: collection.budget.images?.mode, kind: options.kind });
57
+ if (await reuseUnchangedTranscript(collection, options, names, stamp, recipe))
58
+ return true;
47
59
  const read = await readEvidenceFileWithinCap(options.filePath, options.maxFileBytes);
48
60
  if (read.status === "read_failed")
49
61
  return skip("file_read_failed");
@@ -51,7 +63,10 @@ export async function collectOneEvidenceFile(collection, options) {
51
63
  markCapApplied(collection, options.kind, "max_file_bytes");
52
64
  return skip("file_too_large");
53
65
  }
54
- const { sanitized, contentHash, stubClaim } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
66
+ const { sanitized, contentHash, stubClaim, volatile, redactedEntry } = await prepareTranscriptForUpload(collection, options, names, read.bytes);
67
+ await rememberPreparedTranscript(collection, options, names, {
68
+ stamp, recipe, readBytes: read.bytes.byteLength, contentHash, byteSize: sanitized.bytes.byteLength, volatile, redactedEntry,
69
+ });
55
70
  if (collection.skipContentHashes.has(contentHash) && !stubClaim) {
56
71
  collection.reused.push({
57
72
  kind: options.kind,
@@ -82,17 +97,20 @@ function recordEvidenceSkip(collection, kind, label, reason) {
82
97
  async function prepareTranscriptForUpload(collection, options, names, bytes) {
83
98
  // BLI-16977: describe each distinct embedded image once and, when the one switch says so, swap the
84
99
  // base64 for a short marker BEFORE masking, so the masker and the content hash see the final bytes.
85
- const imagesPrepared = await prepareEmbeddedImages(collection, {
100
+ const images = await prepareEmbeddedImages(collection, {
86
101
  bytes,
87
102
  kind: options.kind,
88
103
  sessionId: options.sessionId,
89
104
  });
105
+ const redactedBefore = collection.redacted.length;
90
106
  const sanitized = maskSecretsInTranscript(collection, {
91
- raw: imagesPrepared,
107
+ raw: images.bytes,
92
108
  kind: options.kind,
93
109
  evidenceLabel: names.evidenceLabel,
94
110
  secretLikeFileName: names.secretLikeFileName,
95
111
  });
112
+ // The ledger entry this mask pushed (if any), kept so a later skip can replay it.
113
+ const redactedEntry = collection.redacted.length > redactedBefore ? collection.redacted[collection.redacted.length - 1] : null;
96
114
  const contentHash = sha256(sanitized.bytes);
97
115
  // BLI-11102: a session the server named a redaction stub is re-offered even
98
116
  // when these bytes are already durable, because the stub is not a delivery.
@@ -103,7 +121,7 @@ async function prepareTranscriptForUpload(collection, options, names, bytes) {
103
121
  byteSize: sanitized.bytes.byteLength,
104
122
  alreadyDurable: collection.skipContentHashes.has(contentHash),
105
123
  });
106
- return { sanitized, contentHash, stubClaim };
124
+ return { sanitized, contentHash, stubClaim, volatile: images.volatile, redactedEntry };
107
125
  }
108
126
  /**
109
127
  * The transcript bytes after the image step. Unchanged (the very same buffer) unless this sync has
@@ -115,25 +133,99 @@ async function prepareEmbeddedImages(collection, file) {
115
133
  const session = collection.budget.images;
116
134
  const source = file.kind.startsWith("codex") ? "codex" : file.kind.startsWith("claude") ? "claude_code" : null;
117
135
  if (!session || !source)
118
- return file.bytes;
136
+ return { bytes: file.bytes, volatile: false };
119
137
  // Drop mode slims every transcript, image-bearing or not; keep mode only describes the ones that can hold an image.
120
138
  if (session.mode !== "describe_and_drop" && !mightHoldEmbeddedImages(file.bytes))
121
- return file.bytes;
139
+ return { bytes: file.bytes, volatile: false };
122
140
  try {
123
141
  const prepared = await session.prepareTranscript({
124
142
  text: file.bytes.toString("utf8"),
125
143
  source,
126
144
  sessionId: file.sessionId,
127
145
  });
146
+ // BLI-3936: "volatile" = the next tick could build different bytes, or do image work, from
147
+ // this very file: an image still without a stored description (keep mode never replaces, so
148
+ // any image counts) or a slim step that failed and should be retried.
149
+ const imagesPending = session.mode === "describe_and_drop"
150
+ ? prepared.imageCount > prepared.replacedCount
151
+ : prepared.imageCount > 0;
152
+ const slimFailed = session.mode === "describe_and_drop" && prepared.slim === null;
153
+ const volatile = imagesPending || slimFailed;
128
154
  if (!prepared.changed)
129
- return file.bytes;
130
- return Buffer.from(prepared.text, "utf8");
155
+ return { bytes: file.bytes, volatile };
156
+ return { bytes: Buffer.from(prepared.text, "utf8"), volatile };
131
157
  }
132
158
  catch (error) {
133
159
  console.error("[raw-evidence] image or slim step failed, uploading the transcript as it was", JSON.stringify({ reason: "image_step_failed", kind: file.kind, ...describeError(error) }));
134
- return file.bytes;
160
+ return { bytes: file.bytes, volatile: true };
135
161
  }
136
162
  }
163
+ /**
164
+ * BLI-3936: the answer for a transcript whose file has not changed since a tick that found its
165
+ * content already durable remotely. Replays exactly what the full path would have recorded (the
166
+ * reuse entry, and the redaction ledger entry if the mask had fired) without reading, slimming,
167
+ * masking or hashing a byte. Anything else, including every file not yet delivered, returns false
168
+ * and takes the full path, so this can only remove work whose outcome is already known.
169
+ */
170
+ async function reuseUnchangedTranscript(collection, options, names, stamp, recipe) {
171
+ const memo = collection.budget.prepareMemo;
172
+ if (!memo || !stamp)
173
+ return false;
174
+ // A session the server named a redaction stub is re-offered whatever is durable (BLI-11102).
175
+ if (collection.stubReoffers.has(options.sessionId))
176
+ return false;
177
+ // A file over this pass's cap takes the full path, which names the cap it hit.
178
+ if (options.maxFileBytes && stamp.size > options.maxFileBytes)
179
+ return false;
180
+ const known = memo.recall(names.sourceKey, stamp, recipe);
181
+ if (!known || !collection.skipContentHashes.has(known.content_hash))
182
+ return false;
183
+ if (known.redacted) {
184
+ collection.redacted.push({
185
+ kind: options.kind,
186
+ label: names.evidenceLabel,
187
+ redaction: known.redacted.redaction,
188
+ completenessLabel: known.redacted.completenessLabel,
189
+ });
190
+ }
191
+ collection.reused.push({
192
+ kind: options.kind,
193
+ label: names.evidenceLabel,
194
+ content_hash_sha256: known.content_hash,
195
+ codex_session_id: options.sessionId,
196
+ });
197
+ memo.count("skipped", stamp.size);
198
+ return true;
199
+ }
200
+ /** Remember what this transcript became, but only if the file stayed put while it was built. */
201
+ async function rememberPreparedTranscript(collection, options, names, built) {
202
+ const memo = collection.budget.prepareMemo;
203
+ if (!memo)
204
+ return;
205
+ memo.count(built.volatile ? "volatile" : "rebuilt", built.readBytes);
206
+ if (built.volatile || !built.stamp)
207
+ return;
208
+ const after = await statSourceForMemo(options.filePath);
209
+ const stable = after !== null
210
+ && after.size === built.stamp.size
211
+ && after.identity === built.stamp.identity
212
+ && built.readBytes === built.stamp.size;
213
+ if (!stable)
214
+ return;
215
+ const known = memo.recall(names.sourceKey, built.stamp, built.recipe);
216
+ if (known?.content_hash === built.contentHash)
217
+ return;
218
+ memo.remember(names.sourceKey, {
219
+ ...built.stamp,
220
+ recipe: built.recipe,
221
+ content_hash: built.contentHash,
222
+ byte_size: built.byteSize,
223
+ redacted: built.redactedEntry
224
+ ? { redaction: built.redactedEntry.redaction, completenessLabel: built.redactedEntry.completenessLabel }
225
+ : null,
226
+ volatile: false,
227
+ });
228
+ }
137
229
  function nameEvidenceFile(options) {
138
230
  const fileName = path.basename(options.filePath);
139
231
  const secretLikeFileName = isSecretLikePath(fileName);
@@ -92,6 +92,7 @@ export async function collectRawEvidencePack(context, options) {
92
92
  await ensurePrivateDir(collection.filesDir);
93
93
  recordAttributionCompleteness(collection, options);
94
94
  await collectEverySource(collection, options, sinceMinutes);
95
+ await reportPrepareMemo(collection);
95
96
  return collection.entries.length === 0
96
97
  ? await finishWithEmptyPack(collection, options, run)
97
98
  : await finishWithPromotedPack(collection, options, run);
@@ -100,6 +101,23 @@ export async function collectRawEvidencePack(context, options) {
100
101
  return await finishWithFailedPack(collection, { ...run, error });
101
102
  }
102
103
  }
104
+ /**
105
+ * BLI-3936: say how many transcripts this pass did not have to rebuild, and keep the memo that
106
+ * made it possible. Counts and bytes only. Never throws: the memo is a cache.
107
+ */
108
+ async function reportPrepareMemo(collection) {
109
+ const counts = await collection.budget.prepareMemo?.settle();
110
+ if (!counts || counts.skipped_count + counts.rebuilt_count === 0)
111
+ return;
112
+ console.error(counts.skipped_count > 0 ? "[raw-evidence] slim skipped unchanged" : "[raw-evidence] no unchanged transcripts to skip", JSON.stringify({
113
+ reason: "source_unchanged_since_last_prepare",
114
+ count: counts.skipped_count,
115
+ bytes: counts.skipped_bytes,
116
+ rebuilt_count: counts.rebuilt_count,
117
+ rebuilt_bytes: counts.rebuilt_bytes,
118
+ rebuilt_volatile_count: counts.volatile_count,
119
+ }));
120
+ }
103
121
  /**
104
122
  * Every kind of evidence this repo can offer, in the order it is read. Codex and
105
123
  * Claude transcripts are each switchable off by the caller; the git diffs are
@@ -25,6 +25,17 @@ import { decodeXmlEntities, xmlEscape } from "./autostart-xml.js";
25
25
  * cheap — the collection lock turns overlap into a named no-op.
26
26
  */
27
27
  export const DARWIN_NETWORK_CHANGE_SIGNAL = "/private/var/run/resolv.conf";
28
+ /**
29
+ * The least time launchd leaves between two starts of the tick (BLI-3936). Its default is 10
30
+ * seconds, and WatchPaths on the session directories fires on every transcript write, so on a
31
+ * busy Mac the tick restarted the moment it exited. launchd delays a throttled start rather than
32
+ * dropping it, so a transcript write or a network change inside the window still produces exactly
33
+ * one run, at the end of it. Five minutes keeps capture near-real-time (a session is collected
34
+ * within five minutes of its last write) and caps the tick at 12 runs an hour however many
35
+ * agents are writing. Applies to every trigger: launchd cannot say which one fired, which is
36
+ * why this is the whole debounce and no code-side "which trigger was it" check exists.
37
+ */
38
+ export const DARWIN_MIN_SECONDS_BETWEEN_TICKS = 300;
28
39
  /**
29
40
  * What the tick says to sync.err.log when neither the registered node binary
30
41
  * nor a login-shell node exists. Named, actionable, path-free (BLI-3553).
@@ -165,12 +176,13 @@ function renderPlist(options) {
165
176
  ` <integer>${options.intervalSeconds}</integer>`,
166
177
  " <key>RunAtLoad</key>",
167
178
  " <true/>",
168
- // WatchPaths makes a transcript write fire `cockpit sync` within seconds, so
169
- // captures land near-real-time; StartInterval above is the safety-net floor.
170
- // It fires often, but launchd single-flights the job, sync holds its own
171
- // lock, and growing-transcript re-upload is damped — so cost stays bounded
172
- // and no extra debounce is needed.
179
+ // WatchPaths makes a transcript write fire `cockpit sync` soon after, so captures land
180
+ // near-real-time; StartInterval above is the safety-net floor. launchd single-flights the job
181
+ // and sync holds its own lock, but neither stops a busy Mac restarting the tick the moment it
182
+ // exits; ThrottleInterval is the minimum gap between starts (BLI-3936).
173
183
  ...watchPathsBlock(options.watchPaths),
184
+ " <key>ThrottleInterval</key>",
185
+ ` <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`,
174
186
  " <key>StandardOutPath</key>",
175
187
  ` <string>${xmlEscape(options.stdoutPath)}</string>`,
176
188
  " <key>StandardErrorPath</key>",
@@ -262,6 +274,10 @@ function darwinAgentRegistrationProblems(plist, expected) {
262
274
  if (!plist.includes(` <integer>${expected.intervalSeconds}</integer>`)) {
263
275
  problems.push(`cadence is not ${expected.intervalSeconds} seconds`);
264
276
  }
277
+ // BLI-3936: a plist written before the throttle existed has no minimum gap between ticks.
278
+ if (!plist.includes(` <key>ThrottleInterval</key>\n <integer>${DARWIN_MIN_SECONDS_BETWEEN_TICKS}</integer>`)) {
279
+ problems.push(`minimum gap between ticks is not ${DARWIN_MIN_SECONDS_BETWEEN_TICKS} seconds`);
280
+ }
265
281
  const expectedCommand = renderDarwinSyncCommand({
266
282
  discoveryArgs: expected.discoveryArgs,
267
283
  workDirs: expected.workDirs,
package/dist/autostart.js CHANGED
@@ -22,7 +22,7 @@ import { installRemindAgent, remindAgentStatus, uninstallRemindAgent } from "./a
22
22
  * import site the rest of the collector already knows (BLI-3571).
23
23
  */
24
24
  export { AUTOSTART_LABEL, DEFAULT_AUTOSTART_INTERVAL_SECONDS, WINDOWS_AUTOSTART_TASK_NAME, REMIND_AUTOSTART_LABEL, WINDOWS_REMIND_TASK_NAME, } from "./autostart-contract.js";
25
- export { DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
25
+ export { DARWIN_MIN_SECONDS_BETWEEN_TICKS, DARWIN_NETWORK_CHANGE_SIGNAL, DARWIN_NODE_MISSING_MESSAGE, writeDarwinAutostartPlist, } from "./autostart-darwin-plist.js";
26
26
  export { launchdUserDomain, reregisterDarwinAgent, } from "./autostart-darwin.js";
27
27
  /**
28
28
  * Installs (or refreshes) the scheduled `cockpit sync` this machine's OS
@@ -19,7 +19,7 @@ export function uncommittedReasonWords(reason) {
19
19
  }
20
20
  /** The same for objects the server refused outright (terminal). */
21
21
  export function terminalReasonWords(reason) {
22
- if (reason === "storage_rejected_object_too_large" || /_failed_http_413/u.test(reason))
22
+ if (reason === "storage_rejected_object_too_large" || reason === "evidence_too_large_http_413" || /_failed_http_413/u.test(reason))
23
23
  return "too large for storage";
24
24
  if (reason === "storage_rejected_media_type")
25
25
  return "a file type storage refuses";
@@ -18,6 +18,10 @@
18
18
  * cockpit manager approve <id> --version <v> [--timeline] [--restore] (--restore brings a dismissed item back)
19
19
  * cockpit manager dismiss|archive|unarchive <id> --version <v> [--timeline]
20
20
  * cockpit manager proposal <id> <proposal-id> accept|dismiss|add-new --version <v> [--timeline]
21
+ * cockpit manager meetings | tie | decide-tie ... (meetings tied to clients: usage in `manager-meetings.ts`)
22
+ * cockpit manager decisions <client> [--status open|approved|dismissed|all] (BLI-22187, `manager-decisions.ts`)
23
+ * cockpit manager add-decision <client> --title <t> [--why <t>] [--owners 'A, B'] [--due YYYY-MM-DD] [--request-id <uuid>]
24
+ * cockpit manager decide <decision-id> approve|dismiss|reopen --version <v> [--note <t>]
21
25
  *
22
26
  * Free text goes in a JSON file or on stdin with `--fields-file <path>|-` on add, edit and summary (no shell quoting at all,
23
27
  * `manager-fields-file.ts`); on a flag it goes in SINGLE quotes (the shell leaves `$` alone) and the help says how to write
@@ -27,33 +31,35 @@
27
31
  * `help` and `--help` are the help, never a list and never another command.
28
32
  */
29
33
  import { optionalNonEmpty, optionalUrl, parseNamedArgs, rejectSecretLikeValue } from "./local-arg-values.js";
34
+ import { readDecisionFields } from "./manager-decisions.js";
30
35
  import { mergeFieldsFile } from "./manager-fields-file.js";
31
36
  import { runIdRefusal } from "./manager-hints.js";
37
+ import { readMeetingTieArgs, readTieId } from "./manager-meetings.js";
32
38
  export const MANAGER_VERBS = [
33
39
  "clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "edit", "done", "drop", "reopen", "link",
34
- "approve", "dismiss", "proposal", "archive", "unarchive",
40
+ "approve", "dismiss", "proposal", "archive", "unarchive", "meetings", "tie", "decide-tie", "decisions", "add-decision", "decide",
35
41
  ];
36
42
  export const MANAGER_ACTION_STATUSES = ["open", "done", "dropped", "closed", "all"];
37
43
  export const MANAGER_TIMELINE_KINDS = ["meeting", "document", "milestone", "internal"];
38
44
  export const MANAGER_SUMMARY_HEALTHS = ["on_track", "at_risk", "blocked"];
39
45
  /** The verbs whose one positional word is a client (its id or its exact name). */
40
- const CLIENT_VERBS = new Set(["client", "home", "summary", "list", "queue", "add"]);
46
+ const CLIENT_VERBS = new Set(["client", "home", "summary", "list", "queue", "add", "tie", "decisions", "add-decision"]);
41
47
  const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
42
48
  const VERSION = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}/;
43
49
  const ONE_VERB = `manager takes one verb: ${MANAGER_VERBS.join(", ")}. Example: \`cockpit manager clients\` or \`cockpit manager list <client>\`.`;
44
50
  const OWN_FLAGS = [
45
51
  "--timeline", "--needs-review", "--status", "--owner", "--owners", "--no-owner", "--kind", "--search", "--archived", "--review",
46
52
  "--title", "--detail", "--due", "--date", "--meeting", "--none", "--version", "--request-id", "--restore", "--overdue", "--source-url", "--source-note",
47
- "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
53
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file", "--client", "--event", "--note", "--occurrence", "--cursor", "--page-size", "--why",
48
54
  ];
49
55
  const COMMON_FLAGS = ["--home", "--dashboard-url", "--json", "--help"];
50
56
  const VALUE_FLAGS = [
51
57
  "--status", "--owner", "--owners", "--kind", "--search", "--review", "--title", "--detail", "--due", "--date", "--meeting",
52
58
  "--version", "--request-id", "--source-url", "--source-note", "--home", "--dashboard-url",
53
- "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file",
59
+ "--health", "--headline", "--bullet", "--next", "--blocker", "--fields-file", "--client", "--event", "--note", "--occurrence", "--cursor", "--page-size", "--why",
54
60
  ];
55
61
  /** The flags whose value is a person's own words (not an id, a version or a choice). */
56
- const TEXT_FLAGS = ["--title", "--detail", "--headline", "--bullet", "--next", "--blocker", "--owners", "--owner", "--source-note", "--source-url"];
62
+ const TEXT_FLAGS = ["--title", "--detail", "--headline", "--bullet", "--next", "--blocker", "--owners", "--owner", "--source-note", "--source-url", "--why", "--note"];
57
63
  /** `summary --bullet` is given once per bullet: the one value flag that may repeat. */
58
64
  const REPEATABLE_FLAGS = new Set(["--bullet"]);
59
65
  /** Which flags each verb owns, so a flag on the wrong verb is refused by name rather than ignored. */
@@ -77,6 +83,8 @@ export const FLAGS_BY_VERB = {
77
83
  proposal: ["--timeline", "--version"],
78
84
  archive: ["--timeline", "--version"],
79
85
  unarchive: ["--timeline", "--version"],
86
+ meetings: ["--client", "--event", "--note", "--page-size", "--cursor"], tie: ["--event", "--note", "--occurrence", "--request-id"], "decide-tie": ["--version"],
87
+ decisions: ["--status"], "add-decision": ["--title", "--why", "--owners", "--due", "--request-id"], decide: ["--version", "--note"],
80
88
  };
81
89
  /** Positional words each verb takes, named for the refusal. */
82
90
  const POSITIONALS = {
@@ -90,15 +98,9 @@ const POSITIONALS = {
90
98
  show: ["id"],
91
99
  add: ["client"],
92
100
  edit: ["id"],
93
- done: ["action id"],
94
- drop: ["action id"],
95
- reopen: ["action id"],
96
- link: ["action id"],
97
- approve: ["id"],
98
- dismiss: ["id"],
99
- proposal: ["id", "proposal id", "accept, dismiss or add-new"],
100
- archive: ["id"],
101
- unarchive: ["id"],
101
+ done: ["action id"], drop: ["action id"], reopen: ["action id"], link: ["action id"],
102
+ approve: ["id"], dismiss: ["id"], proposal: ["id", "proposal id", "accept, dismiss or add-new"], archive: ["id"], unarchive: ["id"],
103
+ meetings: [], tie: ["client"], "decide-tie": ["tie id", "accept, dismiss or remove"], decisions: ["client"], "add-decision": ["client"], decide: ["decision id", "approve, dismiss or reopen"],
102
104
  };
103
105
  /** A value flag given twice would silently keep only the last; say so instead. */
104
106
  function refuseRepeatedFlags(args) {
@@ -151,13 +153,20 @@ export function parseManagerArgs(args, options = {}) {
151
153
  shape.client = rest[0];
152
154
  else if (verb === "run")
153
155
  shape.runId = readRunId(rest[0]);
154
- else if (verb !== "clients")
155
- shape.id = readId(rest[0], verb === "done" || verb === "drop" || verb === "reopen" || verb === "link" ? "An action" : "An item");
156
+ else if (verb === "decide-tie")
157
+ shape.id = readTieId(rest[0]);
158
+ else if (verb !== "clients" && verb !== "meetings")
159
+ shape.id = readId(rest[0], verb === "done" || verb === "drop" || verb === "reopen" || verb === "link" ? "An action" : verb === "decide" ? "A decision" : "An item");
156
160
  if (verb === "proposal") {
157
161
  shape.proposalId = rest[1];
158
162
  shape.decision = readDecision(rest[2]);
159
163
  }
160
- const needsVersion = !["clients", "client", "home", "summary", "run", "list", "queue", "show", "add"].includes(verb);
164
+ // BLI-22187: the decision verbs read their own flags (`manager-decisions.ts`); `decide`'s version comes from `decisions <client>`.
165
+ if (verb === "decisions" || verb === "add-decision" || verb === "decide") {
166
+ readDecisionFields(verb, values, shape, rest[1], { readId, readDate, readOwnersList, isBlank });
167
+ return shape;
168
+ }
169
+ const needsVersion = !["clients", "client", "home", "summary", "run", "list", "queue", "show", "add", "meetings", "tie"].includes(verb);
161
170
  if (needsVersion)
162
171
  shape.version = readVersion(flag("--version"), verb);
163
172
  let fileBullets;
@@ -172,6 +181,8 @@ export function parseManagerArgs(args, options = {}) {
172
181
  readSummaryFields(values, shape, fileBullets);
173
182
  if (verb === "list")
174
183
  readListFilters(values, shape);
184
+ if (verb === "meetings" || verb === "tie" || verb === "decide-tie")
185
+ readMeetingTieArgs(verb, values, rest, shape);
175
186
  if (verb === "add")
176
187
  readAddFields(values, shape);
177
188
  if (verb === "edit")
@@ -16,7 +16,7 @@ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs,
16
16
  * `otter` and `granola` are deliberately absent: Otter's public API is
17
17
  * Enterprise-only and Granola still runs on its own server-side cron.
18
18
  */
19
- const CONNECTABLE_PROVIDERS = ["fellow", "circleback", "wisprflow"];
19
+ const CONNECTABLE_PROVIDERS = ["fellow", "circleback", "wisprflow", "fireflies"];
20
20
  /**
21
21
  * BLI-12926. Providers with no API key at all: the person signs in through
22
22
  * their browser and `connect` runs that sign-in itself. `--key-stdin` is