@bli-cockpit/cli 0.2.119 → 0.2.122
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/commands/analyze.js +74 -54
- package/dist/commands/brief-rewrite.js +164 -101
- package/dist/commands/brief.js +38 -13
- package/dist/commands/careers.js +81 -9
- package/dist/commands/correct.js +38 -21
- package/dist/commands/docs.js +13 -10
- package/dist/commands/editor.js +59 -30
- package/dist/commands/install-receipts.js +106 -91
- package/dist/commands/local-args-tower-admin.js +4 -0
- package/dist/commands/local-args-tower-cal.js +28 -3
- package/dist/commands/local-args-tower-careers.js +56 -9
- package/dist/commands/local-args-tower-chat.js +55 -28
- package/dist/commands/local-args-tower-docs-msg.js +39 -8
- package/dist/commands/local-args-tower-mail.js +27 -1
- package/dist/commands/local-args-tower-models.js +14 -17
- package/dist/commands/local-args-tower-work.js +37 -6
- package/dist/commands/local-help-commands-tower.js +15 -1
- package/dist/commands/local-help-commands.js +2 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/mcp-stdio-probe.js +92 -73
- package/dist/commands/memory-hook-performance.js +135 -101
- package/dist/commands/memory-install-claude.js +15 -14
- package/dist/commands/memory-install-codex.js +10 -6
- package/dist/commands/memory-install-config.js +5 -4
- package/dist/commands/memory-install-contract.js +56 -10
- package/dist/commands/memory-install-report.js +16 -11
- package/dist/commands/memory-install-skills.js +11 -11
- package/dist/commands/memory-log.js +22 -5
- package/dist/commands/msg.js +11 -5
- package/dist/commands/onboard-setup.js +16 -1
- package/dist/commands/ops-sections.js +89 -0
- package/dist/commands/ops.js +117 -120
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/scout.js +90 -68
- package/dist/commands/session-sync-failures.js +19 -13
- package/dist/commands/session-sync-record.js +53 -52
- package/dist/commands/session-sync-upload.js +15 -11
- package/dist/commands/sessions.js +61 -51
- package/dist/commands/slack.js +90 -61
- package/dist/commands/status.js +53 -41
- package/dist/commands/workbook.js +23 -20
- package/package.json +2 -2
package/dist/commands/careers.js
CHANGED
|
@@ -1,16 +1,88 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises';
|
|
1
2
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, openAgentDoor } from './agent-door.js';
|
|
3
|
+
/**
|
|
4
|
+
* `cockpit careers` — application review (BLI-3706) and, since BLI-4460, the
|
|
5
|
+
* pipeline: invite, decide, the role's take-home and the four settings.
|
|
6
|
+
*
|
|
7
|
+
* Every verb goes through the SAME Tower door its MCP twin calls; nothing here
|
|
8
|
+
* decides policy. The take-home body arrives from a FILE rather than a flag,
|
|
9
|
+
* because an email body does not belong in an argument vector, a shell history
|
|
10
|
+
* or a process list.
|
|
11
|
+
*/
|
|
2
12
|
export async function runCareers(command, io) {
|
|
3
13
|
const door = await openAgentDoor('careers', command, io);
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
const
|
|
12
|
-
const answer = await askAgentDoor(door, { path, method: command.action === 'rescreen' ? 'POST' : 'GET', label: `careers ${command.action}`, timeoutMs: 60_000 });
|
|
14
|
+
let plan;
|
|
15
|
+
try {
|
|
16
|
+
plan = await planCareersRequest(command);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
return failAgentDoor(door, '[careers]', 'invalid_request', error instanceof Error ? error.message : 'Could not read that request.');
|
|
20
|
+
}
|
|
21
|
+
const answer = await askAgentDoor(door, { ...plan, label: `careers ${command.action}`, timeoutMs: 60_000 });
|
|
13
22
|
if (!answer.ok)
|
|
14
23
|
return failAgentDoor(door, '[careers]', answer.reason, answer.detail);
|
|
15
24
|
return emitAgentDoor(door, answer.body);
|
|
25
|
+
}
|
|
26
|
+
async function planCareersRequest(command) {
|
|
27
|
+
const application = `/api/careers/applications/${encodeURIComponent(command.id ?? '')}`;
|
|
28
|
+
switch (command.action) {
|
|
29
|
+
case 'list': {
|
|
30
|
+
const query = new URLSearchParams();
|
|
31
|
+
if (command.role)
|
|
32
|
+
query.set('role', command.role);
|
|
33
|
+
if (command.minScore !== undefined)
|
|
34
|
+
query.set('min_score', String(command.minScore));
|
|
35
|
+
if (command.since)
|
|
36
|
+
query.set('since', command.since);
|
|
37
|
+
if (command.stage)
|
|
38
|
+
query.set('stage', command.stage);
|
|
39
|
+
return { path: `/api/careers/applications?${query}`, method: 'GET' };
|
|
40
|
+
}
|
|
41
|
+
case 'show': return { path: application, method: 'GET' };
|
|
42
|
+
case 'rescreen': return { path: `${application}/rescreen`, method: 'POST' };
|
|
43
|
+
case 'invite': return { path: `${application}/invite`, method: 'POST' };
|
|
44
|
+
case 'decide': return { path: `${application}/decide`, method: 'POST', body: { decision: command.decision } };
|
|
45
|
+
case 'takehome-show': return { path: takehomePath(command), method: 'GET' };
|
|
46
|
+
case 'takehome-set': return { path: takehomePath(command), method: 'PUT', body: await takehomeBody(command) };
|
|
47
|
+
case 'settings-show': return { path: '/api/careers/settings', method: 'GET' };
|
|
48
|
+
case 'settings-set': return { path: '/api/careers/settings', method: 'PUT', body: settingsBody(command) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function takehomePath(command) {
|
|
52
|
+
return `/api/careers/takehomes/${encodeURIComponent(command.takehomeRole ?? '')}`;
|
|
53
|
+
}
|
|
54
|
+
async function takehomeBody(command) {
|
|
55
|
+
const body = {};
|
|
56
|
+
if (command.subject)
|
|
57
|
+
body.subject = command.subject;
|
|
58
|
+
if (command.link)
|
|
59
|
+
body.artifact_link = command.link;
|
|
60
|
+
if (command.passScore !== undefined)
|
|
61
|
+
body.pass_score = command.passScore;
|
|
62
|
+
if (command.bodyFile) {
|
|
63
|
+
const text = await readFile(command.bodyFile, 'utf8');
|
|
64
|
+
if (!text.includes('{first_name}') || !text.includes('{link}')) {
|
|
65
|
+
throw new Error('That body file is missing {first_name} or {link}, so nothing was sent to Tower.');
|
|
66
|
+
}
|
|
67
|
+
body.body_text = text;
|
|
68
|
+
}
|
|
69
|
+
if (Object.keys(body).length === 0) {
|
|
70
|
+
throw new Error('careers takehome set needs at least one of --subject, --body-file, --link or --pass-score.');
|
|
71
|
+
}
|
|
72
|
+
return body;
|
|
73
|
+
}
|
|
74
|
+
function settingsBody(command) {
|
|
75
|
+
const body = {};
|
|
76
|
+
if (command.autoInviteEnabled !== undefined)
|
|
77
|
+
body.auto_invite_enabled = command.autoInviteEnabled;
|
|
78
|
+
if (command.autoInviteMinScore !== undefined)
|
|
79
|
+
body.auto_invite_min_score = command.autoInviteMinScore;
|
|
80
|
+
if (command.attentionMinScore !== undefined)
|
|
81
|
+
body.attention_min_score = command.attentionMinScore;
|
|
82
|
+
if (command.inviteAccountId !== undefined)
|
|
83
|
+
body.invite_account_id = command.inviteAccountId;
|
|
84
|
+
if (Object.keys(body).length === 0) {
|
|
85
|
+
throw new Error('careers settings set needs at least one of --enabled, --auto-invite-min-score, --attention-min-score or --account.');
|
|
86
|
+
}
|
|
87
|
+
return body;
|
|
16
88
|
}
|
package/dist/commands/correct.js
CHANGED
|
@@ -37,11 +37,22 @@ export async function runCorrect(command, io) {
|
|
|
37
37
|
writeFailure(command, io, "no_correction_text", 'Say what is wrong: `cockpit correct --claim <id> --text "..."`, or pipe it in on stdin.');
|
|
38
38
|
return 1;
|
|
39
39
|
}
|
|
40
|
+
const selection = await readBriefClaim(command, io, dashboardUrl, session.device_token, log);
|
|
41
|
+
if (!selection)
|
|
42
|
+
return 1;
|
|
43
|
+
const filed = await fileCorrection(command, io, dashboardUrl, session.device_token, log, selection, text);
|
|
44
|
+
if (!filed)
|
|
45
|
+
return 1;
|
|
46
|
+
printFiledCorrection(command, io, filed);
|
|
47
|
+
logFiledCorrection(command, io, filed, selection.claim, text, startedAt);
|
|
48
|
+
return 0;
|
|
49
|
+
}
|
|
50
|
+
async function readBriefClaim(command, io, dashboardUrl, deviceToken, log) {
|
|
40
51
|
// Step one: which page, whose, and does that line exist.
|
|
41
52
|
const read = await towerJsonRequest({
|
|
42
53
|
dashboardUrl,
|
|
43
54
|
path: `/api/jarvis/brief${briefQuery(command)}`,
|
|
44
|
-
deviceToken
|
|
55
|
+
deviceToken,
|
|
45
56
|
fetch: io.fetch,
|
|
46
57
|
method: "GET",
|
|
47
58
|
label: "correct:brief",
|
|
@@ -50,62 +61,69 @@ export async function runCorrect(command, io) {
|
|
|
50
61
|
});
|
|
51
62
|
if (!read.ok) {
|
|
52
63
|
writeFailure(command, io, read.reason, read.detail);
|
|
53
|
-
return
|
|
64
|
+
return null;
|
|
54
65
|
}
|
|
55
66
|
const brief = read.body;
|
|
56
67
|
const personId = brief.page?.personId;
|
|
57
68
|
if (!brief.ok || !personId) {
|
|
58
69
|
writeFailure(command, io, brief.error ?? "no_page", brief.reply ?? "There is no page to correct.");
|
|
59
|
-
return
|
|
70
|
+
return null;
|
|
60
71
|
}
|
|
61
72
|
const claim = (brief.claims ?? []).find((candidate) => candidate.claimId === command.claimId);
|
|
62
73
|
if (!claim) {
|
|
63
74
|
writeFailure(command, io, "unknown_claim", `That page has no line called “${command.claimId}”. Run \`cockpit brief --claims\` to see the ids.`);
|
|
64
|
-
return
|
|
75
|
+
return null;
|
|
65
76
|
}
|
|
77
|
+
return { brief, personId, claim };
|
|
78
|
+
}
|
|
79
|
+
async function fileCorrection(command, io, dashboardUrl, deviceToken, log, selection, text) {
|
|
66
80
|
// Step two: the same door the panel's form posts to.
|
|
67
81
|
const written = await towerJsonRequest({
|
|
68
82
|
dashboardUrl,
|
|
69
83
|
path: "/api/jarvis/corrections",
|
|
70
|
-
deviceToken
|
|
84
|
+
deviceToken,
|
|
71
85
|
fetch: io.fetch,
|
|
72
86
|
label: "correct",
|
|
73
87
|
timeoutMs: REQUEST_DEADLINE_MS,
|
|
74
88
|
log,
|
|
75
89
|
body: {
|
|
76
|
-
personId,
|
|
77
|
-
pageId: brief.page?.pageId ?? null,
|
|
90
|
+
personId: selection.personId,
|
|
91
|
+
pageId: selection.brief.page?.pageId ?? null,
|
|
78
92
|
claimId: command.claimId,
|
|
79
93
|
// The line as it reads now, so the ledger records what was disputed and
|
|
80
94
|
// the live check has the sentence to work from.
|
|
81
|
-
quotedText: claim.text ?? null,
|
|
95
|
+
quotedText: selection.claim.text ?? null,
|
|
82
96
|
correctionText: text,
|
|
83
|
-
contextLinks: claim.links ?? [],
|
|
97
|
+
contextLinks: selection.claim.links ?? [],
|
|
84
98
|
clauseOnPage: true,
|
|
85
|
-
clauseIsObserved: Boolean(claim.observed),
|
|
99
|
+
clauseIsObserved: Boolean(selection.claim.observed),
|
|
86
100
|
...(command.supersedes ? { supersedes: command.supersedes } : {}),
|
|
87
101
|
},
|
|
88
102
|
});
|
|
89
103
|
if (!written.ok) {
|
|
90
104
|
writeFailure(command, io, written.reason, written.detail);
|
|
91
|
-
return
|
|
105
|
+
return null;
|
|
92
106
|
}
|
|
93
107
|
const filed = written.body;
|
|
94
108
|
if (!filed.correctionId) {
|
|
95
109
|
writeFailure(command, io, "not_filed", filed.error ?? "Tower did not record that correction.");
|
|
96
|
-
return
|
|
110
|
+
return null;
|
|
97
111
|
}
|
|
112
|
+
return filed;
|
|
113
|
+
}
|
|
114
|
+
function printFiledCorrection(command, io, filed) {
|
|
98
115
|
if (command.json) {
|
|
99
116
|
writeLine(io.stdout, JSON.stringify({ ok: true, ...filed }));
|
|
117
|
+
return;
|
|
100
118
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
119
|
+
const styled = colorEnabled(io);
|
|
120
|
+
writeLine(io.stdout, filed.reply ?? "Filed.");
|
|
121
|
+
if (filed.finding)
|
|
122
|
+
writeLine(io.stdout, dim(`The record says: ${filed.finding}`, styled));
|
|
123
|
+
if (filed.link)
|
|
124
|
+
writeLine(io.stdout, dim(filed.link, styled));
|
|
125
|
+
}
|
|
126
|
+
function logFiledCorrection(command, io, filed, claim, text, startedAt) {
|
|
109
127
|
writeLine(io.stderr, `[correct cli] filed ${JSON.stringify({
|
|
110
128
|
tier: filed.tier ?? null,
|
|
111
129
|
outcome: filed.outcome ?? null,
|
|
@@ -117,7 +135,6 @@ export async function runCorrect(command, io) {
|
|
|
117
135
|
text_length: text.length,
|
|
118
136
|
elapsed_ms: Date.now() - startedAt,
|
|
119
137
|
})}`);
|
|
120
|
-
return 0;
|
|
121
138
|
}
|
|
122
139
|
function briefQuery(command) {
|
|
123
140
|
const params = new URLSearchParams({ claims: "1" });
|
package/dist/commands/docs.js
CHANGED
|
@@ -225,8 +225,19 @@ async function updateDoc(command, door) {
|
|
|
225
225
|
&& !command.clearParent) {
|
|
226
226
|
return failAgentDoor(door, TAG, "invalid_body", "docs update needs at least one of --title, --visibility, --parent/--clear-parent, or a body on --body-stdin/--file.");
|
|
227
227
|
}
|
|
228
|
-
const answer = await
|
|
229
|
-
|
|
228
|
+
const answer = await updateDocument(command, door, resolved.id, bodyMarkdown);
|
|
229
|
+
if (!answer.ok)
|
|
230
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
231
|
+
const document = answer.body.document ?? {};
|
|
232
|
+
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
|
|
233
|
+
if (door.json)
|
|
234
|
+
return emitAgentDoor(door, { ok: true, document });
|
|
235
|
+
writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
|
|
236
|
+
return 0;
|
|
237
|
+
}
|
|
238
|
+
async function updateDocument(command, door, documentId, bodyMarkdown) {
|
|
239
|
+
return askAgentDoor(door, {
|
|
240
|
+
path: `/api/docs/documents/${encodeURIComponent(documentId)}`,
|
|
230
241
|
method: "PATCH",
|
|
231
242
|
label: "docs update",
|
|
232
243
|
timeoutMs: WRITE_DEADLINE_MS,
|
|
@@ -242,12 +253,4 @@ async function updateDoc(command, door) {
|
|
|
242
253
|
...(command.allowEmpty ? { allow_empty: true } : {}),
|
|
243
254
|
},
|
|
244
255
|
});
|
|
245
|
-
if (!answer.ok)
|
|
246
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
247
|
-
const document = answer.body.document ?? {};
|
|
248
|
-
writeLine(door.io.stderr, `${TAG} updated ${JSON.stringify({ document_id: document["id"] ?? resolved.id })}`);
|
|
249
|
-
if (door.json)
|
|
250
|
-
return emitAgentDoor(door, { ok: true, document });
|
|
251
|
-
writeLine(door.io.stdout, `Updated ${String(document["title"] ?? "")} (${String(document["id"] ?? resolved.id)}).`);
|
|
252
|
-
return 0;
|
|
253
256
|
}
|
package/dist/commands/editor.js
CHANGED
|
@@ -105,14 +105,32 @@ export async function editInEditor(options) {
|
|
|
105
105
|
const platform = options.platform ?? process.platform;
|
|
106
106
|
const log = options.log ?? ((line) => console.error(line));
|
|
107
107
|
const chosen = resolveEditorCommand(options.env, platform);
|
|
108
|
-
if (!chosen)
|
|
109
|
-
return
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
108
|
+
if (!chosen)
|
|
109
|
+
return noEditorConfigured();
|
|
110
|
+
const unavailable = invalidWindowsEditorReason(chosen, platform);
|
|
111
|
+
if (unavailable)
|
|
112
|
+
return unavailable;
|
|
113
|
+
const scratch = await writeEditorScratchFile(options);
|
|
114
|
+
if (!scratch.ok)
|
|
115
|
+
return scratch;
|
|
116
|
+
try {
|
|
117
|
+
return await runEditorSession(options, chosen, scratch.file, log);
|
|
115
118
|
}
|
|
119
|
+
finally {
|
|
120
|
+
// Whatever happened. The page is not a secret, but it is somebody's writing
|
|
121
|
+
// and it does not belong in /tmp after the command returns.
|
|
122
|
+
await removeEditorScratchDirectory(scratch.directory);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
function noEditorConfigured() {
|
|
126
|
+
return {
|
|
127
|
+
ok: false,
|
|
128
|
+
reason: "no_editor_configured",
|
|
129
|
+
detail: "No editor is configured. Set EDITOR (or VISUAL) — for example `export EDITOR=nano` — " +
|
|
130
|
+
"or pipe the edited page in on stdin instead.",
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
function invalidWindowsEditorReason(chosen, platform) {
|
|
116
134
|
// A `.cmd` or `.bat` is a batch script; Node cannot execute one without
|
|
117
135
|
// handing the whole line to cmd.exe, and that is the one thing this module
|
|
118
136
|
// will not do with a person's own path in it.
|
|
@@ -125,12 +143,14 @@ export async function editInEditor(options) {
|
|
|
125
143
|
"notepad, or the full path to Code.exe.",
|
|
126
144
|
};
|
|
127
145
|
}
|
|
128
|
-
|
|
129
|
-
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
async function writeEditorScratchFile(options) {
|
|
130
149
|
try {
|
|
131
|
-
directory = await mkdtemp(join(tmpdir(), "cockpit-brief-"));
|
|
132
|
-
file = join(directory, `brief-${randomBytes(4).toString("hex")}${options.suffix ?? ".md"}`);
|
|
150
|
+
const directory = await mkdtemp(join(tmpdir(), "cockpit-brief-"));
|
|
151
|
+
const file = join(directory, `brief-${randomBytes(4).toString("hex")}${options.suffix ?? ".md"}`);
|
|
133
152
|
await writeFile(file, options.contents, "utf8");
|
|
153
|
+
return { ok: true, directory, file };
|
|
134
154
|
}
|
|
135
155
|
catch (error) {
|
|
136
156
|
return {
|
|
@@ -139,13 +159,11 @@ export async function editInEditor(options) {
|
|
|
139
159
|
detail: `The page could not be written to a scratch file (${messageOf(error)}).`,
|
|
140
160
|
};
|
|
141
161
|
}
|
|
162
|
+
}
|
|
163
|
+
async function runEditorSession(options, chosen, file, log) {
|
|
142
164
|
try {
|
|
143
165
|
const spawn = options.spawn ?? spawnSync;
|
|
144
|
-
log
|
|
145
|
-
program: basenameOf(chosen.program),
|
|
146
|
-
extra_args: chosen.args.length,
|
|
147
|
-
bytes: Buffer.byteLength(options.contents, "utf8"),
|
|
148
|
-
})}`);
|
|
166
|
+
reportEditorOpening(log, chosen, options.contents);
|
|
149
167
|
// `stdio: "inherit"` hands the terminal over: a full-screen editor needs the
|
|
150
168
|
// real tty. `shell: false` is the default and is stated to make the rule
|
|
151
169
|
// above impossible to lose in a refactor.
|
|
@@ -170,12 +188,8 @@ export async function editInEditor(options) {
|
|
|
170
188
|
"Nothing has been sent to Tower.",
|
|
171
189
|
};
|
|
172
190
|
}
|
|
173
|
-
const text =
|
|
174
|
-
log
|
|
175
|
-
program: basenameOf(chosen.program),
|
|
176
|
-
status: result.status ?? null,
|
|
177
|
-
bytes: Buffer.byteLength(text, "utf8"),
|
|
178
|
-
})}`);
|
|
191
|
+
const text = await readEditedText(file);
|
|
192
|
+
reportEditorClosed(log, chosen, result.status, text);
|
|
179
193
|
return { ok: true, text, program: basenameOf(chosen.program) };
|
|
180
194
|
}
|
|
181
195
|
catch (error) {
|
|
@@ -185,14 +199,29 @@ export async function editInEditor(options) {
|
|
|
185
199
|
detail: `The edited page could not be read back (${messageOf(error)}).`,
|
|
186
200
|
};
|
|
187
201
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
202
|
+
}
|
|
203
|
+
function reportEditorOpening(log, chosen, contents) {
|
|
204
|
+
log(`[brief edit] editor opening ${JSON.stringify({
|
|
205
|
+
program: basenameOf(chosen.program),
|
|
206
|
+
extra_args: chosen.args.length,
|
|
207
|
+
bytes: Buffer.byteLength(contents, "utf8"),
|
|
208
|
+
})}`);
|
|
209
|
+
}
|
|
210
|
+
async function readEditedText(file) {
|
|
211
|
+
return stripLeadingBom(await readFile(file, "utf8"));
|
|
212
|
+
}
|
|
213
|
+
function reportEditorClosed(log, chosen, status, text) {
|
|
214
|
+
log(`[brief edit] editor closed ${JSON.stringify({
|
|
215
|
+
program: basenameOf(chosen.program),
|
|
216
|
+
status,
|
|
217
|
+
bytes: Buffer.byteLength(text, "utf8"),
|
|
218
|
+
})}`);
|
|
219
|
+
}
|
|
220
|
+
async function removeEditorScratchDirectory(directory) {
|
|
221
|
+
await rm(directory, { recursive: true, force: true }).catch(() => {
|
|
222
|
+
// Deliberately silent: the temp directory is the OS's to reap, and a
|
|
223
|
+
// failure to remove it must not turn a saved edit into a failed command.
|
|
224
|
+
});
|
|
196
225
|
}
|
|
197
226
|
/** The program's own name, never the directories around it. */
|
|
198
227
|
function basenameOf(program) {
|
|
@@ -48,6 +48,17 @@ export function sanitizeInstallErrorCode(value) {
|
|
|
48
48
|
export async function reportInstallEventsBestEffort(options) {
|
|
49
49
|
if (options.events.length === 0)
|
|
50
50
|
return null;
|
|
51
|
+
if (shouldWithholdDevBuildReceipts(options))
|
|
52
|
+
return null;
|
|
53
|
+
const paths = getCollectorRuntimePaths(options.homeDir);
|
|
54
|
+
if (!(await queueInstallEvents(options, paths)))
|
|
55
|
+
return null;
|
|
56
|
+
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
57
|
+
if (!hasValidInstallEventSession(session))
|
|
58
|
+
return null;
|
|
59
|
+
return deliverPendingInstallEvents(options, paths, session.device_token);
|
|
60
|
+
}
|
|
61
|
+
function shouldWithholdDevBuildReceipts(options) {
|
|
51
62
|
// BLI-3554. Withheld BEFORE the outbox, not before the POST: an entry queued
|
|
52
63
|
// by a checkout survives in `~/.cockpit` and the next real scheduled tick
|
|
53
64
|
// would deliver it under the workspace version, which is exactly how 47
|
|
@@ -65,32 +76,18 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
65
76
|
cli_version: LOCAL_COLLECTOR_VERSION,
|
|
66
77
|
fix: "set COCKPIT_DEV=0 to post receipts from a checkout deliberately",
|
|
67
78
|
}));
|
|
68
|
-
return
|
|
79
|
+
return true;
|
|
69
80
|
}
|
|
70
|
-
|
|
81
|
+
return suppression.suppressed;
|
|
82
|
+
}
|
|
83
|
+
async function queueInstallEvents(options, paths) {
|
|
71
84
|
try {
|
|
72
85
|
await enqueueInstallEventEntry(paths, {
|
|
73
86
|
dashboardUrl: options.dashboardUrl,
|
|
74
87
|
cliVersion: LOCAL_COLLECTOR_VERSION,
|
|
75
88
|
command: options.command,
|
|
76
89
|
osPlatform: os.platform(),
|
|
77
|
-
events: options.events.map(
|
|
78
|
-
step: event.step.trim().slice(0, 120),
|
|
79
|
-
status: event.status,
|
|
80
|
-
...(event.error_code
|
|
81
|
-
? { error_code: sanitizeInstallErrorCode(event.error_code) }
|
|
82
|
-
: {}),
|
|
83
|
-
// Already redacted and capped at the point it was produced; bounded
|
|
84
|
-
// again here because this mapping is what the server contract sees.
|
|
85
|
-
...(event.error_detail
|
|
86
|
-
? {
|
|
87
|
-
error_detail: event.error_detail
|
|
88
|
-
.trim()
|
|
89
|
-
.slice(0, SYNC_ERROR_DETAIL_MAX_CHARS),
|
|
90
|
-
}
|
|
91
|
-
: {}),
|
|
92
|
-
...(event.at ? { at: event.at } : {}),
|
|
93
|
-
})),
|
|
90
|
+
events: options.events.map(normalizeInstallEvent),
|
|
94
91
|
});
|
|
95
92
|
}
|
|
96
93
|
catch (error) {
|
|
@@ -107,90 +104,44 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
107
104
|
if (options.json) {
|
|
108
105
|
writeLine(options.io.stderr, "Install event outbox unavailable: local_write_failed");
|
|
109
106
|
}
|
|
110
|
-
return
|
|
107
|
+
return false;
|
|
111
108
|
}
|
|
112
|
-
|
|
109
|
+
return true;
|
|
110
|
+
}
|
|
111
|
+
function normalizeInstallEvent(event) {
|
|
112
|
+
// Already redacted and capped at the point it was produced; bounded
|
|
113
|
+
// again here because this mapping is what the server contract sees.
|
|
114
|
+
const detail = event.error_detail?.trim().slice(0, SYNC_ERROR_DETAIL_MAX_CHARS);
|
|
115
|
+
const code = event.error_code ? sanitizeInstallErrorCode(event.error_code) : undefined;
|
|
116
|
+
return {
|
|
117
|
+
step: event.step.trim().slice(0, 120),
|
|
118
|
+
status: event.status,
|
|
119
|
+
...(code ? { error_code: code } : {}),
|
|
120
|
+
...(detail ? { error_detail: detail } : {}),
|
|
121
|
+
...(event.at ? { at: event.at } : {}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
function hasValidInstallEventSession(session) {
|
|
113
125
|
if (!session ||
|
|
114
126
|
session.session_state !== "valid" ||
|
|
115
127
|
typeof session.device_token !== "string" ||
|
|
116
128
|
!session.device_token) {
|
|
117
|
-
return
|
|
129
|
+
return false;
|
|
118
130
|
}
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
async function deliverPendingInstallEvents(options, paths, deviceToken) {
|
|
119
134
|
const pending = (await readPendingInstallEventEntries(paths)).slice(0, 20);
|
|
120
135
|
const failures = [];
|
|
121
136
|
let delivered = 0;
|
|
122
137
|
let observedMinCliVersion = null;
|
|
123
138
|
for (let offset = 0; offset < pending.length; offset += 5) {
|
|
124
|
-
await Promise.all(pending.slice(offset, offset + 5).map(
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const response = await options.io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
|
|
129
|
-
method: "POST",
|
|
130
|
-
headers: {
|
|
131
|
-
"Content-Type": "application/json",
|
|
132
|
-
Authorization: `Bearer ${session.device_token}`,
|
|
133
|
-
},
|
|
134
|
-
body: JSON.stringify({
|
|
135
|
-
cli_version: entry.cli_version,
|
|
136
|
-
command: entry.command,
|
|
137
|
-
os_platform: entry.os_platform,
|
|
138
|
-
events: entry.events,
|
|
139
|
-
}),
|
|
140
|
-
signal: controller.signal,
|
|
141
|
-
});
|
|
142
|
-
if (!response.ok) {
|
|
143
|
-
throw new Error(`http_${response.status}`);
|
|
144
|
-
}
|
|
145
|
-
const receipt = (await response.json().catch((error) => {
|
|
146
|
-
// This reply carries the server-published `min_cli_version` floor
|
|
147
|
-
// (BLI-2678). A body that will not parse means the floor is not
|
|
148
|
-
// observed on this tick and the forced-update path silently does
|
|
149
|
-
// nothing — while the 2xx above says the receipt landed fine.
|
|
150
|
-
console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({
|
|
151
|
-
reason: "receipt_body_unreadable",
|
|
152
|
-
http_status: response.status,
|
|
153
|
-
...describeError(error),
|
|
154
|
-
}));
|
|
155
|
-
return null;
|
|
156
|
-
}));
|
|
157
|
-
if (typeof receipt?.min_cli_version === "string" &&
|
|
158
|
-
receipt.min_cli_version.trim()) {
|
|
159
|
-
observedMinCliVersion = receipt.min_cli_version.trim();
|
|
160
|
-
}
|
|
161
|
-
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
139
|
+
await Promise.all(pending.slice(offset, offset + 5).map((entry) => deliverInstallEventEntry(options.io, paths, entry, deviceToken, failures).then((floor) => {
|
|
140
|
+
if (floor != null)
|
|
141
|
+
observedMinCliVersion = floor;
|
|
142
|
+
if (floor !== undefined)
|
|
162
143
|
delivered += 1;
|
|
163
|
-
|
|
164
|
-
catch (error) {
|
|
165
|
-
const failureReason = classifyInstallTelemetryError(error);
|
|
166
|
-
failures.push(failureReason);
|
|
167
|
-
// The classified reason is the coarse bucket the outbox row keeps;
|
|
168
|
-
// beside it, what actually happened. `network_error` covers DNS,
|
|
169
|
-
// TLS, timeout and abort, and only one of those is worth waking up
|
|
170
|
-
// for (BLI-3238).
|
|
171
|
-
console.error("[install-receipts] install event delivery failed, entry kept for retry", JSON.stringify({
|
|
172
|
-
reason: failureReason,
|
|
173
|
-
outbox_id: entry.outbox_id,
|
|
174
|
-
...describeError(error),
|
|
175
|
-
}));
|
|
176
|
-
await recordInstallEventAttemptFailure(paths, entry, {
|
|
177
|
-
attemptedAt: new Date().toISOString(),
|
|
178
|
-
failureReason,
|
|
179
|
-
}).catch((writeError) => {
|
|
180
|
-
// Double failure: delivery failed AND the retry bookkeeping did.
|
|
181
|
-
// The entry stays queued, so nothing is lost, but the attempt
|
|
182
|
-
// count stops advancing and the outbox looks stuck for no reason.
|
|
183
|
-
console.error("[install-receipts] could not record the delivery failure against the entry", JSON.stringify({
|
|
184
|
-
reason: "attempt_bookkeeping_failed",
|
|
185
|
-
outbox_id: entry.outbox_id,
|
|
186
|
-
...describeError(writeError),
|
|
187
|
-
}));
|
|
188
|
-
});
|
|
189
|
-
}
|
|
190
|
-
finally {
|
|
191
|
-
clearTimeout(timeout);
|
|
192
|
-
}
|
|
193
|
-
}));
|
|
144
|
+
})));
|
|
194
145
|
}
|
|
195
146
|
if (delivered > 0) {
|
|
196
147
|
// The success branch says so too (BLI-3554 / the logging contract): a log
|
|
@@ -209,6 +160,70 @@ export async function reportInstallEventsBestEffort(options) {
|
|
|
209
160
|
}
|
|
210
161
|
return observedMinCliVersion;
|
|
211
162
|
}
|
|
163
|
+
async function deliverInstallEventEntry(io, paths, entry, deviceToken, failures) {
|
|
164
|
+
const controller = new AbortController();
|
|
165
|
+
const timeout = setTimeout(() => controller.abort(), 5_000);
|
|
166
|
+
try {
|
|
167
|
+
const response = await postInstallEventEntry(io, entry, deviceToken, controller.signal);
|
|
168
|
+
const floor = await readObservedMinCliVersion(response);
|
|
169
|
+
await removeInstallEventEntry(paths, entry.outbox_id);
|
|
170
|
+
return floor;
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
await keepFailedInstallEventEntry(paths, entry, error, failures);
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
clearTimeout(timeout);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function postInstallEventEntry(io, entry, deviceToken, signal) {
|
|
181
|
+
const response = await io.fetch(`${entry.dashboard_url}/api/ambient/install-events`, {
|
|
182
|
+
method: "POST",
|
|
183
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${deviceToken}` },
|
|
184
|
+
body: JSON.stringify({
|
|
185
|
+
cli_version: entry.cli_version,
|
|
186
|
+
command: entry.command,
|
|
187
|
+
os_platform: entry.os_platform,
|
|
188
|
+
events: entry.events,
|
|
189
|
+
}),
|
|
190
|
+
signal,
|
|
191
|
+
});
|
|
192
|
+
if (!response.ok)
|
|
193
|
+
throw new Error(`http_${response.status}`);
|
|
194
|
+
return response;
|
|
195
|
+
}
|
|
196
|
+
async function readObservedMinCliVersion(response) {
|
|
197
|
+
const receipt = (await response.json().catch((error) => {
|
|
198
|
+
// This reply carries the server-published `min_cli_version` floor
|
|
199
|
+
// (BLI-2678). A body that will not parse means the floor is not
|
|
200
|
+
// observed on this tick and the forced-update path silently does
|
|
201
|
+
// nothing — while the 2xx above says the receipt landed fine.
|
|
202
|
+
console.error("[install-receipts] receipt body unreadable; no min_cli_version observed", JSON.stringify({ reason: "receipt_body_unreadable", http_status: response.status, ...describeError(error) }));
|
|
203
|
+
return null;
|
|
204
|
+
}));
|
|
205
|
+
return typeof receipt?.min_cli_version === "string" && receipt.min_cli_version.trim()
|
|
206
|
+
? receipt.min_cli_version.trim()
|
|
207
|
+
: null;
|
|
208
|
+
}
|
|
209
|
+
async function keepFailedInstallEventEntry(paths, entry, error, failures) {
|
|
210
|
+
const failureReason = classifyInstallTelemetryError(error);
|
|
211
|
+
failures.push(failureReason);
|
|
212
|
+
// The classified reason is the coarse bucket the outbox row keeps;
|
|
213
|
+
// beside it, what actually happened. `network_error` covers DNS,
|
|
214
|
+
// TLS, timeout and abort, and only one of those is worth waking up
|
|
215
|
+
// for (BLI-3238).
|
|
216
|
+
console.error("[install-receipts] install event delivery failed, entry kept for retry", JSON.stringify({ reason: failureReason, outbox_id: entry.outbox_id, ...describeError(error) }));
|
|
217
|
+
await recordInstallEventAttemptFailure(paths, entry, {
|
|
218
|
+
attemptedAt: new Date().toISOString(),
|
|
219
|
+
failureReason,
|
|
220
|
+
}).catch((writeError) => {
|
|
221
|
+
// Double failure: delivery failed AND the retry bookkeeping did.
|
|
222
|
+
// The entry stays queued, so nothing is lost, but the attempt
|
|
223
|
+
// count stops advancing and the outbox looks stuck for no reason.
|
|
224
|
+
console.error("[install-receipts] could not record the delivery failure against the entry", JSON.stringify({ reason: "attempt_bookkeeping_failed", outbox_id: entry.outbox_id, ...describeError(writeError) }));
|
|
225
|
+
});
|
|
226
|
+
}
|
|
212
227
|
function classifyInstallTelemetryError(error) {
|
|
213
228
|
if (error instanceof Error && error.name === "AbortError") {
|
|
214
229
|
return "timeout";
|
|
@@ -89,6 +89,8 @@ export function parseOpsArgs(args) {
|
|
|
89
89
|
// BLI-3912: one line per model — latency, tokens/s, today's spend, last
|
|
90
90
|
// probe verdict. A SECOND door like --memory, asked for only when named.
|
|
91
91
|
"--models",
|
|
92
|
+
"--turns",
|
|
93
|
+
"--tool-router",
|
|
92
94
|
"--person",
|
|
93
95
|
"--dry-run",
|
|
94
96
|
"--json",
|
|
@@ -130,6 +132,8 @@ export function parseOpsArgs(args) {
|
|
|
130
132
|
skips: values.booleans.has("--skips"),
|
|
131
133
|
memory: values.booleans.has("--memory") || memoryDays !== undefined,
|
|
132
134
|
models: values.booleans.has("--models"),
|
|
135
|
+
turns: values.booleans.has("--turns"),
|
|
136
|
+
toolRouter: values.booleans.has("--tool-router"),
|
|
133
137
|
...(memoryDays === undefined ? {} : { memoryDays: Number(memoryDays) }),
|
|
134
138
|
...base,
|
|
135
139
|
};
|