@bli-cockpit/cli 0.2.248 → 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.
- package/dist/adapters/raw-evidence-collection-state.js +2 -0
- package/dist/adapters/raw-evidence-prepare-memo.js +191 -0
- package/dist/adapters/raw-evidence-transcript-file.js +101 -9
- package/dist/adapters/raw-evidence.js +18 -0
- package/dist/autostart-darwin-plist.js +21 -5
- package/dist/autostart.js +1 -1
- package/dist/commands/docs-sync.js +2 -2
- package/dist/commands/doctor-disk-words.js +1 -1
- package/dist/commands/local-args-tower-manager.js +28 -17
- package/dist/commands/local-args-tower-notes.js +1 -1
- package/dist/commands/local-args-tower-settings.js +24 -3
- package/dist/commands/local-help-commands-tower-ops.js +20 -2
- package/dist/commands/local-help-commands-tower.js +2 -2
- package/dist/commands/local-help-commands.js +1 -1
- package/dist/commands/local-help-docs-sync.js +1 -1
- package/dist/commands/local-help-project.js +1 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/manager-decisions.js +189 -0
- package/dist/commands/manager-hints.js +2 -0
- package/dist/commands/manager-limits.json +42 -1
- package/dist/commands/manager-meetings.js +184 -0
- package/dist/commands/manager-requests.js +15 -0
- package/dist/commands/manager.js +16 -2
- package/dist/commands/notes-accounts.js +1 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/settings.js +1 -1
- package/dist/evidence-upload-terminal.js +47 -5
- package/dist/raw-evidence-staging.js +2 -1
- package/dist/upload-evidence-delivery-offer.js +3 -0
- package/package.json +2 -2
|
@@ -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
|
|
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:
|
|
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`
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
//
|
|
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
|
|
@@ -69,7 +69,7 @@ export function printSyncDetail(door, ref, body) {
|
|
|
69
69
|
}
|
|
70
70
|
return 0;
|
|
71
71
|
}
|
|
72
|
-
/** ticket 22178: who owns a stuck page, what was decided, which ticket carries it.
|
|
72
|
+
/** ticket 22178: who owns a stuck page, what was decided, which ticket carries it. `unlink` unlinks in the call; `keep_tower`/`take_notion` apply on the next pass (the door's `note` says so). */
|
|
73
73
|
export async function syncTriage(command, door) {
|
|
74
74
|
const ref = command.docRef ?? "";
|
|
75
75
|
const answer = await askAgentDoor(door, {
|
|
@@ -88,7 +88,7 @@ export async function syncTriage(command, door) {
|
|
|
88
88
|
const changed = Array.isArray(body["changed"]) ? body["changed"].map(str) : [];
|
|
89
89
|
writeLine(door.io.stdout, str(body["outcome"]) === "unchanged"
|
|
90
90
|
? "Nothing changed: those labels were already set."
|
|
91
|
-
: `Saved ${changed.join(", ")}. Owner ${str(body["owner"]) || "unowned"}, decision ${str(body["decision"]) || "none"}, ticket ${str(body["ticket"]) || "none"}.
|
|
91
|
+
: `Saved ${changed.join(", ")}. Owner ${str(body["owner"]) || "unowned"}, decision ${str(body["decision"]) || "none"}, ticket ${str(body["ticket"]) || "none"}. ${str(body["note"]) || "Nothing else changed."}`);
|
|
92
92
|
return 0;
|
|
93
93
|
}
|
|
94
94
|
async function syncResolve(command, door) {
|
|
@@ -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
|
-
|
|
95
|
-
|
|
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
|
|
155
|
-
shape.id =
|
|
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
|
-
|
|
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
|