@bli-cockpit/cli 0.2.99 → 0.2.100
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/backfill-checkpoint.js +3 -1
- package/dist/commands/backfill-issues.js +8 -55
- package/dist/commands/backfill-report.js +22 -6
- package/dist/commands/backfill-scan.js +2 -1
- package/dist/commands/backfill-skip-policy.js +134 -0
- package/dist/commands/careers.js +16 -0
- package/dist/commands/doctor-pipeline-verdicts.js +238 -0
- package/dist/commands/doctor-pipeline.js +37 -107
- package/dist/commands/doctor.js +2 -2
- package/dist/commands/local-args-tower-careers.js +20 -0
- package/dist/commands/local-args-tower-pages.js +17 -2
- package/dist/commands/local-args-tower.js +2 -1
- package/dist/commands/local-args.js +3 -1
- package/dist/commands/local-help-commands-tower.js +1 -0
- package/dist/commands/local-help-commands.js +8 -2
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +3 -0
- package/dist/commands/notes-file.js +8 -1
- package/dist/commands/notes-folders.js +35 -0
- package/dist/commands/notes-writes.js +37 -4
- package/dist/commands/notes.js +6 -0
- package/dist/commands/public-root.js +1 -1
- package/dist/cursors/backfill-completion-marker.js +135 -0
- package/dist/cursors/backfill-cursor.js +18 -99
- package/dist/sync-lock.js +10 -1
- package/package.json +2 -2
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { inspectBackfillLock } from "../backfill-lock.js";
|
|
4
|
-
import {
|
|
4
|
+
import { readBackfillCompletionMarker, readBackfillCursor, } from "../cursors/backfill-cursor.js";
|
|
5
5
|
import { savedDiscoveryLimitArgs } from "../discovery-limits.js";
|
|
6
|
-
import { describeError } from "../health-detail.js";
|
|
7
6
|
import { runStagingPrune } from "../disk-prune.js";
|
|
8
7
|
import { retentionOptionsFromEnv } from "../disk-retention.js";
|
|
9
8
|
import { readDiskFootprint } from "../disk-usage.js";
|
|
@@ -13,7 +12,8 @@ import { runRawEvidenceLocalGc, rawEvidenceGcSummary } from "../raw-evidence-gc.
|
|
|
13
12
|
import { runBackfillCommand } from "./backfill.js";
|
|
14
13
|
import { diskRowMessage, mib, redeliveryLine } from "./doctor-disk-words.js";
|
|
15
14
|
import { doctorRoots } from "./doctor-access.js";
|
|
16
|
-
import {
|
|
15
|
+
import { backfillCompletionStepState, backfillFixVerdict, jsonField, parseDoctorBackfillJson, parseDoctorSyncJson, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
|
|
16
|
+
import { fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
17
17
|
/**
|
|
18
18
|
* The `backfill-complete`, `gc-checked`, `disk-bounded`, and `sync-fresh`
|
|
19
19
|
* check family: does the collection pipeline itself have everything it
|
|
@@ -22,31 +22,15 @@ import { asRecord, fail, needsFix, ok, skipped } from "./doctor-report.js";
|
|
|
22
22
|
* is committed, disk-bounded reads per-file against the upload ledger
|
|
23
23
|
* (BLI-3619), and sync-fresh is the one check whose "fix" is a live proof
|
|
24
24
|
* (`cockpit sync`) rather than a local computation.
|
|
25
|
-
*/
|
|
26
|
-
const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
27
|
-
/**
|
|
28
|
-
* Pure so it can be unit-tested without touching the real machine's home
|
|
29
|
-
* directory (`getCollectorRuntimePaths()` defaults to `os.homedir()` and
|
|
30
|
-
* doctor never threads `--home` through the backfill steps). Returns `null`
|
|
31
|
-
* when the marker does not cover the roots/sources — the caller falls
|
|
32
|
-
* through to the lock/never-run diagnosis in that case.
|
|
33
25
|
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
26
|
+
* This module is the half that TOUCHES the machine: it runs the catch-up,
|
|
27
|
+
* prunes the disk, execs a sync. The verdicts those steps reach — what a
|
|
28
|
+
* completion marker proves, what a stand-aside receipt means, when an
|
|
29
|
+
* unfinished backfill is busy rather than broken — are pure functions in
|
|
30
|
+
* `doctor-pipeline-verdicts.ts`, and every one of them is re-exported from
|
|
31
|
+
* here so callers keep one address.
|
|
38
32
|
*/
|
|
39
|
-
|
|
40
|
-
if (!backfillCompletionCovers(marker, roots, ["codex", "claude_code"])) {
|
|
41
|
-
return null;
|
|
42
|
-
}
|
|
43
|
-
const oversized = marker?.oversized_skips;
|
|
44
|
-
if (oversized && oversized.count > 0) {
|
|
45
|
-
return ok("backfill-complete", "complete_with_oversized_skips", `caught up on old Codex and Claude sessions in every saved folder ` +
|
|
46
|
-
`(complete_with_oversized_skips · ${oversized.count} file${oversized.count === 1 ? "" : "s"} too big to upload)`);
|
|
47
|
-
}
|
|
48
|
-
return ok("backfill-complete", "complete", "caught up on old Codex and Claude sessions in every saved folder");
|
|
49
|
-
}
|
|
33
|
+
const GC_MIN_INTERVAL_MS = 24 * 60 * 60 * 1000;
|
|
50
34
|
export async function checkBackfillState(context) {
|
|
51
35
|
const paths = getCollectorRuntimePaths();
|
|
52
36
|
const roots = await doctorRoots(context);
|
|
@@ -70,7 +54,8 @@ export async function fixBackfillState(context) {
|
|
|
70
54
|
yes: true,
|
|
71
55
|
json: true,
|
|
72
56
|
}, capture.io);
|
|
73
|
-
const
|
|
57
|
+
const stdout = capture.stdout();
|
|
58
|
+
const output = `${stdout}\n${capture.stderr()}`;
|
|
74
59
|
if (code === 0) {
|
|
75
60
|
// Re-read the marker this run just wrote instead of hand-rolling a second
|
|
76
61
|
// message: `checkBackfillState`'s pure core already knows how to say
|
|
@@ -81,11 +66,9 @@ export async function fixBackfillState(context) {
|
|
|
81
66
|
return recheck;
|
|
82
67
|
return ok("backfill-complete", "completed", "ran `cockpit backfill --all --yes`");
|
|
83
68
|
}
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}
|
|
88
|
-
return fail("backfill-complete", reason ?? "backfill_failed", "backfill did not complete");
|
|
69
|
+
const verdict = backfillFixVerdict(parseDoctorBackfillJson(stdout), jsonField(output, "failure_reason"));
|
|
70
|
+
console.error("[cockpit-doctor] catch-up run did not finish", JSON.stringify({ reason: verdict.code, row_status: verdict.status, exit_code: code }));
|
|
71
|
+
return verdict;
|
|
89
72
|
}
|
|
90
73
|
export async function checkGcState(context) {
|
|
91
74
|
if (context.io.env["COCKPIT_DISABLE_GC"] === "1") {
|
|
@@ -196,11 +179,6 @@ function captureStream(target, chunks, forward) {
|
|
|
196
179
|
},
|
|
197
180
|
};
|
|
198
181
|
}
|
|
199
|
-
function jsonField(output, field) {
|
|
200
|
-
const escaped = field.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&");
|
|
201
|
-
const match = output.match(new RegExp(`"${escaped}"\\s*:\\s*"([^"]+)"`, "u"));
|
|
202
|
-
return match?.[1] ?? null;
|
|
203
|
-
}
|
|
204
182
|
export async function checkSyncState(context) {
|
|
205
183
|
const roots = await doctorRoots(context);
|
|
206
184
|
if (roots.length === 0) {
|
|
@@ -211,75 +189,6 @@ export async function checkSyncState(context) {
|
|
|
211
189
|
// and only turns green from those command receipts.
|
|
212
190
|
return needsFix("sync-fresh", "per_root_verification_required", `fresh upload proof is required for ${roots.length} saved root${roots.length === 1 ? "" : "s"}`);
|
|
213
191
|
}
|
|
214
|
-
/**
|
|
215
|
-
* `cockpit sync --json` prints exactly one JSON document to stdout (stderr is
|
|
216
|
-
* for human text; see AGENTS.md logging conventions), so this is a real parse
|
|
217
|
-
* rather than the doctor module's usual regex field-scrape — which cannot
|
|
218
|
-
* disambiguate same-named fields nested under `codex_sessions.codex` vs
|
|
219
|
-
* `codex_sessions.claude` (BLI-2728).
|
|
220
|
-
*/
|
|
221
|
-
function parseDoctorSyncJson(stdout) {
|
|
222
|
-
try {
|
|
223
|
-
const parsed = JSON.parse(stdout.trim());
|
|
224
|
-
return parsed && typeof parsed === "object" ? parsed : null;
|
|
225
|
-
}
|
|
226
|
-
catch (error) {
|
|
227
|
-
// `null` sends doctor back to its regex field-scrape, quietly losing the
|
|
228
|
-
// BLI-2728 disambiguation. Something wrote to stdout that was not the one
|
|
229
|
-
// JSON document the contract promises — a stray console.log in the
|
|
230
|
-
// collector would do exactly this and look like nothing at all.
|
|
231
|
-
console.error("[cockpit-doctor] sync --json stdout was not one JSON document", JSON.stringify({
|
|
232
|
-
reason: "sync_json_unparseable",
|
|
233
|
-
byte_size: stdout.length,
|
|
234
|
-
...describeError(error),
|
|
235
|
-
}));
|
|
236
|
-
return null;
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
/**
|
|
240
|
-
* BLI-2728: a tick that only deferred objects past the per-tick raw-evidence
|
|
241
|
-
* object budget (`RAW_EVIDENCE_DEFAULT_OBJECT_BUDGET`, adapters/raw-evidence.ts)
|
|
242
|
-
* is a backlog that is draining, not a failure — `attributedSyncRunStatus`
|
|
243
|
-
* marks the run not-fully-`ok` (so `cockpit sync` exits non-zero and doctor's
|
|
244
|
-
* exec sees `code !== 0`) purely because objects remain queued, with the
|
|
245
|
-
* per-repo upload itself still having succeeded. A genuine failure (auth,
|
|
246
|
-
* server rejection, network, a real upload_failed outcome, an unposted
|
|
247
|
-
* session report) must still read red — this only fires when NOTHING else in
|
|
248
|
-
* the tick's own summary looks wrong. Pure so it is unit-testable without a
|
|
249
|
-
* live exec/fs harness; the remaining-object count is read straight from the
|
|
250
|
-
* tick's own summary, never recomputed.
|
|
251
|
-
*/
|
|
252
|
-
export function syncBacklogDrainingVerdict(parsed) {
|
|
253
|
-
if (!parsed)
|
|
254
|
-
return null;
|
|
255
|
-
const deferredObjects = positiveNumberOrZero(parsed.raw_evidence_deferred_object_budget);
|
|
256
|
-
if (deferredObjects <= 0)
|
|
257
|
-
return null;
|
|
258
|
-
const deferredBytes = positiveNumberOrZero(parsed.raw_evidence_deferred_byte_budget);
|
|
259
|
-
const failedCount = positiveNumberOrZero(parsed.raw_evidence_failed_count);
|
|
260
|
-
const retryReasons = Array.isArray(parsed.raw_evidence_retry_reasons)
|
|
261
|
-
? parsed.raw_evidence_retry_reasons.length
|
|
262
|
-
: 0;
|
|
263
|
-
const sessions = asRecord(parsed.codex_sessions);
|
|
264
|
-
const reportPosted = sessions?.["report_posted"];
|
|
265
|
-
const codexReadFailures = positiveNumberOrZero(asRecord(sessions?.["codex"])?.["read_failures"]);
|
|
266
|
-
const claudeSessions = asRecord(sessions?.["claude"]);
|
|
267
|
-
const claudeReadFailures = positiveNumberOrZero(claudeSessions?.["read_failures"]);
|
|
268
|
-
const claudeSidecarsFailed = positiveNumberOrZero(claudeSessions?.["sidecars_failed"]);
|
|
269
|
-
const onlyDeferredObjectBudget = deferredBytes === 0 &&
|
|
270
|
-
failedCount === 0 &&
|
|
271
|
-
retryReasons === 0 &&
|
|
272
|
-
reportPosted === true &&
|
|
273
|
-
codexReadFailures === 0 &&
|
|
274
|
-
claudeReadFailures === 0 &&
|
|
275
|
-
claudeSidecarsFailed === 0;
|
|
276
|
-
return onlyDeferredObjectBudget ? { remainingObjects: deferredObjects } : null;
|
|
277
|
-
}
|
|
278
|
-
function positiveNumberOrZero(value) {
|
|
279
|
-
return typeof value === "number" && Number.isFinite(value) && value > 0
|
|
280
|
-
? value
|
|
281
|
-
: 0;
|
|
282
|
-
}
|
|
283
192
|
export async function fixSyncState(context) {
|
|
284
193
|
const exec = context.io.exec;
|
|
285
194
|
if (!exec)
|
|
@@ -301,6 +210,23 @@ export async function fixSyncState(context) {
|
|
|
301
210
|
const parsed = parseDoctorSyncJson(result.stdout);
|
|
302
211
|
const output = `${result.stdout}\n${result.stderr}`;
|
|
303
212
|
const status = jsonField(output, "status");
|
|
213
|
+
// BLI-4303, before any verdict: a sync that stood aside for the collection
|
|
214
|
+
// lock never ran, so neither its exit code nor its missing `uploaded`
|
|
215
|
+
// receipt says anything about this machine's health.
|
|
216
|
+
const standAside = syncStandAsideVerdict(parsed, status);
|
|
217
|
+
if (standAside) {
|
|
218
|
+
console.error("[cockpit-doctor] sync stood aside for a running collection run", JSON.stringify({
|
|
219
|
+
reason: standAside.code,
|
|
220
|
+
owner_alive: standAside.ownerAlive,
|
|
221
|
+
held_since: standAside.heldSince,
|
|
222
|
+
}));
|
|
223
|
+
return standAside.ownerAlive
|
|
224
|
+
? ok("sync-fresh", standAside.code, "a background sync is running right now and owns the collection lock " +
|
|
225
|
+
`(last heartbeat ${standAside.heldSince}); this machine is collecting`)
|
|
226
|
+
: needsFix("sync-fresh", standAside.code, "sync stood aside for a collection lock whose owner has stopped " +
|
|
227
|
+
`reporting (last heartbeat ${standAside.heldSince ?? "unknown"}); ` +
|
|
228
|
+
"rerun `cockpit doctor` — the next sync takes the lock over");
|
|
229
|
+
}
|
|
304
230
|
if (result.code !== 0) {
|
|
305
231
|
const draining = syncBacklogDrainingVerdict(parsed);
|
|
306
232
|
if (draining) {
|
|
@@ -317,4 +243,8 @@ export async function fixSyncState(context) {
|
|
|
317
243
|
return ok("sync-fresh", "synced", roots.length === 1
|
|
318
244
|
? "ran `cockpit sync` and received an uploaded receipt"
|
|
319
245
|
: `received uploaded receipts for ${roots.length} saved roots`);
|
|
320
|
-
}
|
|
246
|
+
}
|
|
247
|
+
// Re-exported so `doctor.ts`, the doctor tests and every other caller keep
|
|
248
|
+
// importing these from `./doctor-pipeline.js` regardless of which sibling
|
|
249
|
+
// computes them.
|
|
250
|
+
export { backfillCompletionStepState, backfillFixVerdict, isCollectionBusyReason, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline-verdicts.js";
|
package/dist/commands/doctor.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { withStage } from "../crash-guard.js";
|
|
2
2
|
import { checkSingleInstallState, fixAuthState, fixRootState, readAuthState, readRootState, } from "./doctor-access.js";
|
|
3
|
-
import { backfillCompletionStepState, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, } from "./doctor-pipeline.js";
|
|
3
|
+
import { backfillCompletionStepState, backfillFixVerdict, checkBackfillState, checkDiskState, checkGcState, checkSyncState, fixBackfillState, fixDiskState, fixGcState, fixSyncState, syncBacklogDrainingVerdict, syncStandAsideVerdict, } from "./doctor-pipeline.js";
|
|
4
4
|
import { checkMcpAnswersState } from "./doctor-mcp.js";
|
|
5
5
|
import { checkMemoryDaemonState } from "./doctor-memory-daemon.js";
|
|
6
6
|
import { checkAutostartState, checkMemoryState, fixAutostartState, fixMemoryState, } from "./doctor-registration.js";
|
|
@@ -178,4 +178,4 @@ function defaultDoctorDeps(hooks) {
|
|
|
178
178
|
}
|
|
179
179
|
// Re-exported so every consumer keeps importing from `./doctor.js` regardless
|
|
180
180
|
// of which sibling a symbol now lives in.
|
|
181
|
-
export { backfillCompletionStepState, syncBacklogDrainingVerdict };
|
|
181
|
+
export { backfillCompletionStepState, backfillFixVerdict, syncBacklogDrainingVerdict, syncStandAsideVerdict, };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { optionalNonEmpty, optionalUrl, parseNamedArgs } from './local-arg-values.js';
|
|
2
|
+
export function parseCareersArgs(args) {
|
|
3
|
+
const values = parseNamedArgs(args, { allowedFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url', '--json'], valueFlags: ['--role', '--min-score', '--since', '--home', '--dashboard-url'] });
|
|
4
|
+
const action = values.positionals[0] ?? 'list';
|
|
5
|
+
if (action !== 'list' && action !== 'show' && action !== 'rescreen')
|
|
6
|
+
throw new Error('careers takes list, show, or rescreen.');
|
|
7
|
+
const id = values.positionals[1];
|
|
8
|
+
if (action !== 'list' && !id)
|
|
9
|
+
throw new Error(`careers ${action} needs an application id.`);
|
|
10
|
+
if (values.positionals.length > (action === 'list' ? 1 : 2))
|
|
11
|
+
throw new Error('Unexpected careers argument.');
|
|
12
|
+
const rawScore = values.flags.get('--min-score');
|
|
13
|
+
const minScore = rawScore === undefined ? undefined : Number(rawScore);
|
|
14
|
+
if (minScore !== undefined && (!Number.isFinite(minScore) || minScore < 0 || minScore > 100))
|
|
15
|
+
throw new Error('--min-score must be 0 to 100.');
|
|
16
|
+
const since = optionalNonEmpty(values.flags.get('--since'));
|
|
17
|
+
if (since && !Number.isFinite(Date.parse(since)))
|
|
18
|
+
throw new Error('--since must be a date.');
|
|
19
|
+
return { kind: 'careers', action, id, role: optionalNonEmpty(values.flags.get('--role')), minScore, since: since ? new Date(since).toISOString() : undefined, homeDir: optionalNonEmpty(values.flags.get('--home')), dashboardUrl: optionalUrl(values.flags.get('--dashboard-url')), json: values.booleans.has('--json') };
|
|
20
|
+
}
|
|
@@ -153,6 +153,7 @@ export function parseBriefArgs(args) {
|
|
|
153
153
|
};
|
|
154
154
|
}
|
|
155
155
|
const NOTES_ACTIONS = new Set([
|
|
156
|
+
"folders", "mkdir", "rmdir", "rename",
|
|
156
157
|
"list",
|
|
157
158
|
"show",
|
|
158
159
|
"shelf",
|
|
@@ -175,12 +176,14 @@ const NOTES_ACTIONS_NEEDING_A_NOTE = new Set([
|
|
|
175
176
|
export function parseNotesArgs(args) {
|
|
176
177
|
const values = parseNamedArgs(args, {
|
|
177
178
|
allowedFlags: [
|
|
179
|
+
"--wait",
|
|
178
180
|
"--home",
|
|
179
181
|
"--dashboard-url",
|
|
180
182
|
"--file",
|
|
181
183
|
"--name",
|
|
182
184
|
"--exclude",
|
|
183
185
|
"--to",
|
|
186
|
+
"--folder",
|
|
184
187
|
"--clear-shelf",
|
|
185
188
|
"--apply",
|
|
186
189
|
"--series",
|
|
@@ -190,6 +193,7 @@ export function parseNotesArgs(args) {
|
|
|
190
193
|
"--limit",
|
|
191
194
|
"--yes",
|
|
192
195
|
"--json",
|
|
196
|
+
"--tree",
|
|
193
197
|
],
|
|
194
198
|
valueFlags: [
|
|
195
199
|
"--home",
|
|
@@ -198,6 +202,7 @@ export function parseNotesArgs(args) {
|
|
|
198
202
|
"--name",
|
|
199
203
|
"--exclude",
|
|
200
204
|
"--to",
|
|
205
|
+
"--folder",
|
|
201
206
|
"--series",
|
|
202
207
|
"--kind",
|
|
203
208
|
"--since",
|
|
@@ -236,23 +241,33 @@ export function parseNotesArgs(args) {
|
|
|
236
241
|
if (paths.length === 0)
|
|
237
242
|
throw new Error("notes upload needs at least one file path.");
|
|
238
243
|
}
|
|
244
|
+
else if (action === "mkdir" || action === "rmdir" || action === "rename") {
|
|
245
|
+
if (rest.length !== 1 || !rest[0]?.trim())
|
|
246
|
+
throw new Error(`notes ${action} needs one folder path.`);
|
|
247
|
+
}
|
|
239
248
|
else if (action !== "paste" && !NOTES_ACTIONS_NEEDING_A_NOTE.has(action) && rest.length > 0) {
|
|
240
249
|
throw new Error(`notes ${action} does not take "${rest[0]}".`);
|
|
241
250
|
}
|
|
251
|
+
if (action === "rename" && !optionalNonEmpty(values.flags.get("--name")))
|
|
252
|
+
throw new Error("notes rename needs --name <name>.");
|
|
242
253
|
const to = optionalNonEmpty(values.flags.get("--to"));
|
|
243
254
|
const clearShelf = values.booleans.has("--clear-shelf");
|
|
244
255
|
if (action === "move") {
|
|
245
256
|
if (to && clearShelf) {
|
|
246
257
|
throw new Error("notes move accepts either --to or --clear-shelf, not both.");
|
|
247
258
|
}
|
|
248
|
-
if (!to && !clearShelf) {
|
|
249
|
-
throw new Error('notes move needs --to "<shelf>"
|
|
259
|
+
if (!to && !clearShelf && !values.flags.get("--folder")) {
|
|
260
|
+
throw new Error('notes move needs --to "<shelf>", --clear-shelf, or --folder "<path>".');
|
|
250
261
|
}
|
|
251
262
|
}
|
|
252
263
|
const limit = optionalPositiveInteger(values.flags.get("--limit"), "--limit");
|
|
253
264
|
return {
|
|
254
265
|
kind: "notes",
|
|
266
|
+
...(values.booleans.has("--wait") ? { wait: true } : {}),
|
|
255
267
|
action,
|
|
268
|
+
...(values.flags.has("--folder") ? { folder: values.flags.get("--folder") } : {}),
|
|
269
|
+
...(action === "mkdir" || action === "rmdir" || action === "rename" ? { folder: rest[0] } : {}),
|
|
270
|
+
...(values.booleans.has("--tree") ? { tree: true } : {}),
|
|
256
271
|
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
257
272
|
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
258
273
|
...(noteId ? { noteId } : {}),
|
|
@@ -36,4 +36,5 @@ export { SEARCH_KINDS, parseSearchArgs } from "./local-args-tower-search.js";
|
|
|
36
36
|
export { parseModelsArgs } from "./local-args-tower-models.js";
|
|
37
37
|
export { parseMailArgs } from "./local-args-tower-mail.js";
|
|
38
38
|
export { parseCalArgs } from "./local-args-tower-cal.js";
|
|
39
|
-
export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
39
|
+
export { parseUsageArgs } from "./local-args-tower-usage.js";
|
|
40
|
+
export { parseCareersArgs } from './local-args-tower-careers.js';
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* verbatim, no logic change.
|
|
17
17
|
*/
|
|
18
18
|
import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
|
|
19
|
-
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, } from "./local-args-tower.js";
|
|
19
|
+
import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseCareersArgs, } from "./local-args-tower.js";
|
|
20
20
|
// `normalizeUrl` has always been part of this module's surface — `local.ts` and
|
|
21
21
|
// `local-auth.ts` import it from here — so it stays exported from this address
|
|
22
22
|
// even though it now lives next door. The same goes for the four names the
|
|
@@ -115,6 +115,8 @@ export function parseLocalArgs(argv) {
|
|
|
115
115
|
return parseSearchArgs(argv.slice(1));
|
|
116
116
|
case "release":
|
|
117
117
|
return parseReleaseArgs(argv.slice(1));
|
|
118
|
+
case "careers":
|
|
119
|
+
return parseCareersArgs(argv.slice(1));
|
|
118
120
|
case "usage":
|
|
119
121
|
return parseUsageArgs(argv.slice(1));
|
|
120
122
|
default:
|
|
@@ -22,6 +22,7 @@ const SEARCH_KIND_LIST = SEARCH_KINDS.join(",");
|
|
|
22
22
|
const SEARCH_CORPORA_COUNT = SEARCH_KINDS.length;
|
|
23
23
|
/** One entry per Tower noun, in the order `cockpit --help` lists them. */
|
|
24
24
|
export const TOWER_COMMAND_HELP = [
|
|
25
|
+
["careers", ["Usage: cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]", "Super-admin application review. Lists at most 100 matches with total and has_more; use filters to narrow."]],
|
|
25
26
|
[
|
|
26
27
|
"docs",
|
|
27
28
|
[
|
|
@@ -450,8 +450,14 @@ export function localSubcommandHelp(command) {
|
|
|
450
450
|
[
|
|
451
451
|
"notes",
|
|
452
452
|
[
|
|
453
|
-
"Usage: cockpit notes [list|show <id>|shelf|shelves|upload <paths
|
|
454
|
-
"",
|
|
453
|
+
"Usage: cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>] [flags]",
|
|
454
|
+
"Audio uploads: MP3, M4A, WAV, WebM and OGG, up to 4 MB. --wait polls transcription for at most 330 seconds.",
|
|
455
|
+
"",
|
|
456
|
+
"folders [--tree] [--json]: list folders and visible note counts.",
|
|
457
|
+
"mkdir <path> [--json]: create a folder and any missing ancestors.",
|
|
458
|
+
"rename <path> --name <name> [--json]: rename a folder and its descendant paths.",
|
|
459
|
+
"rmdir <path> [--json]: delete an empty folder; refuses notes or subfolders.",
|
|
460
|
+
"upload, paste and move accept --folder <path>; use / for Root. Shelf placement stays independent.",
|
|
455
461
|
"The /meeting-notes surface, typed. Bare `cockpit notes` lists the library.",
|
|
456
462
|
"list [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] — every note you may open, grouped by shelf, newest meeting first. Never a word of a note.",
|
|
457
463
|
"show <id> — one note with its text, its shelf, and why you are allowed to see it.",
|
|
@@ -52,6 +52,7 @@ export const rootCommandNames = new Set([
|
|
|
52
52
|
"project",
|
|
53
53
|
"search",
|
|
54
54
|
"release",
|
|
55
|
+
"careers",
|
|
55
56
|
"usage",
|
|
56
57
|
]);
|
|
57
58
|
export function localCommandHelp(command) {
|
|
@@ -83,7 +84,7 @@ export function localCommandHelp(command) {
|
|
|
83
84
|
" cockpit brief [edit|rewrite|history] [--for <person>] [--date <YYYY-MM-DD>] [--delta [--against <YYYY-MM-DD>]] [--version <pageId>] [--tldr|--full] [--versions] [--claims] [--days <n>] [--reason \"<why>\"] [--wait|--no-wait] [--dashboard-url <url>] [--json]",
|
|
84
85
|
" cockpit brief status [--who <person>] [--render] [--dashboard-url <url>] [--json]",
|
|
85
86
|
" cockpit correct --claim <claimId> --text \"<what is wrong>\" [--for <person>] [--version <pageId>] [--supersedes <id>] [--dashboard-url <url>] [--json]",
|
|
86
|
-
" cockpit notes [list|show <id>|shelf|shelves|upload <paths
|
|
87
|
+
" cockpit notes [list|show <id>|shelf|shelves|folders|mkdir <path>|rmdir <path>|rename <path>|upload <paths...> [--wait]|paste|share <id>|unshare <id>|move <id>|place <id>] [--folder <path>] [--series <shelf>] [--kind <kind>] [--since <YYYY-MM-DD>] [--until <YYYY-MM-DD>] [--limit <n>] [--file <path>] [--name <n>] [--exclude \"<sentence>\"] [--to \"<shelf>\"|--clear-shelf] [--apply] [--yes] [--dashboard-url <url>] [--json]",
|
|
87
88
|
" cockpit backfill (--since-days <n>|--all) [--source codex|claude] [--dry-run] [--max-files <n>] [--max-depth <n>] [--max-repos <n>] [--yes] [--workspace <path>] [--json]",
|
|
88
89
|
" cockpit status [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
89
90
|
" cockpit sessions [--source codex|claude] [--since-days <n>|--all] [--workspace <path>] [--max-depth <n>] [--max-repos <n>] [--json]",
|
|
@@ -100,6 +101,7 @@ export function localCommandHelp(command) {
|
|
|
100
101
|
" cockpit project [list] [--archived] [--dashboard-url <url>] [--json]",
|
|
101
102
|
` cockpit search "<words>" [--kind ${SEARCH_KINDS.join(",")}] [--limit <n>] [--dashboard-url <url>] [--json]`,
|
|
102
103
|
" cockpit release [--dry-run] [--skip-checks] [--no-floor] [--tag <tag>] [--access <public|restricted>] [--otp <code>]",
|
|
104
|
+
" cockpit careers [list|show <id>|rescreen <id>] [--role <slug>] [--min-score <n>] [--since <date>] [--json]",
|
|
103
105
|
" cockpit usage people [--since 30d|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
|
|
104
106
|
"",
|
|
105
107
|
`Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
|
package/dist/commands/local.js
CHANGED
|
@@ -43,6 +43,7 @@ import { runCal } from "./cal.js";
|
|
|
43
43
|
import { runMail } from "./mail.js";
|
|
44
44
|
import { runProject } from "./project.js";
|
|
45
45
|
import { runSearch } from "./search.js";
|
|
46
|
+
import { runCareers } from "./careers.js";
|
|
46
47
|
import { runUsage } from "./usage.js";
|
|
47
48
|
import { parseLocalArgs } from "./local-args.js";
|
|
48
49
|
// `./local.js` is the published entry point for this command surface: the
|
|
@@ -180,6 +181,8 @@ export async function runLocalCockpitCli(argv, io = defaultIo()) {
|
|
|
180
181
|
return await runSearch(command, io);
|
|
181
182
|
case "release":
|
|
182
183
|
return await runRelease(command, io);
|
|
184
|
+
case "careers":
|
|
185
|
+
return await runCareers(command, io);
|
|
183
186
|
case "usage":
|
|
184
187
|
return await runUsage(command, io);
|
|
185
188
|
}
|
|
@@ -34,6 +34,10 @@ import { errorMessage } from "./cli-io.js";
|
|
|
34
34
|
* courtesy check to avoid uploading something the server is certain to refuse,
|
|
35
35
|
* not a second source of truth.
|
|
36
36
|
*/
|
|
37
|
+
export function noteAudioMime(name) {
|
|
38
|
+
const mime = { mp3: "audio/mpeg", m4a: "audio/mp4", wav: "audio/wav", webm: "audio/webm", ogg: "audio/ogg" };
|
|
39
|
+
return mime[name.split(".").at(-1)?.toLowerCase() ?? ""];
|
|
40
|
+
}
|
|
37
41
|
export const NOTE_FILE_MAX_BYTES = 20 * 1024 * 1024;
|
|
38
42
|
/**
|
|
39
43
|
* Above this, the terminal says it is working before it starts. The route's own
|
|
@@ -52,7 +56,7 @@ export function noteFileRefusalSentence(refusal, filePath) {
|
|
|
52
56
|
case "file_empty":
|
|
53
57
|
return `There is nothing in that file: ${filePath}`;
|
|
54
58
|
case "file_too_big":
|
|
55
|
-
return "That file is too big
|
|
59
|
+
return "That file is too big. Audio is limited to 4 MB; other notes to 20 MB.";
|
|
56
60
|
case "looks_like_a_key_file":
|
|
57
61
|
// The same rule the server's gate applies, said the same way: the name is
|
|
58
62
|
// all it takes to decide, and looking inside to be sure would already be
|
|
@@ -88,6 +92,9 @@ export async function readNoteFile(filePath) {
|
|
|
88
92
|
}
|
|
89
93
|
if (size === 0)
|
|
90
94
|
return { ok: false, refusal: "file_empty", detail: "byte_size_0" };
|
|
95
|
+
if (noteAudioMime(fileName) && size > 4_000_000) {
|
|
96
|
+
return { ok: false, refusal: "file_too_big", detail: "file_too_large: audio limit is 4 MB (4,000,000 bytes)" };
|
|
97
|
+
}
|
|
91
98
|
if (size > NOTE_FILE_MAX_BYTES) {
|
|
92
99
|
return { ok: false, refusal: "file_too_big", detail: `byte_size_${size}` };
|
|
93
100
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { writeLine } from "./cli-io.js";
|
|
2
|
+
import { ask, emit, fail, READ_DEADLINE_MS } from "./notes-door.js";
|
|
3
|
+
export async function runFolderCommand(command, door) {
|
|
4
|
+
const path = command.folder?.split("/").map(part => part.trim()).join("/");
|
|
5
|
+
const answer = await ask(door, {
|
|
6
|
+
path: "/api/notes/folders", method: command.action === "mkdir" ? "POST" : "GET",
|
|
7
|
+
label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS,
|
|
8
|
+
...(command.action === "mkdir" ? { body: { path } } : {}),
|
|
9
|
+
});
|
|
10
|
+
if (!answer.ok)
|
|
11
|
+
return fail(door, answer.reason, answer.detail);
|
|
12
|
+
const body = answer.body;
|
|
13
|
+
if (command.action === "rmdir" || command.action === "rename") {
|
|
14
|
+
const folder = body.folders?.find(folder => folder.path === path);
|
|
15
|
+
if (!folder)
|
|
16
|
+
return fail(door, "folder_not_found", "That folder could not be found.");
|
|
17
|
+
const removed = await ask(door, { path: `/api/notes/folders/${encodeURIComponent(folder.id)}`, method: command.action === "rename" ? "PATCH" : "DELETE", label: `notes ${command.action}`, timeoutMs: READ_DEADLINE_MS, ...(command.action === "rename" ? { body: { name: command.name } } : {}) });
|
|
18
|
+
if (!removed.ok)
|
|
19
|
+
return fail(door, removed.reason, removed.detail);
|
|
20
|
+
if (door.json)
|
|
21
|
+
return emit(door, removed.body);
|
|
22
|
+
writeLine(door.io.stdout, removed.body.headline);
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
if (door.json)
|
|
26
|
+
return emit(door, body);
|
|
27
|
+
if (command.action === "mkdir")
|
|
28
|
+
writeLine(door.io.stdout, body.headline ?? "Folder ready.");
|
|
29
|
+
else
|
|
30
|
+
for (const folder of body.folders ?? []) {
|
|
31
|
+
const label = command.tree ? `${" ".repeat(folder.path.split("/").length - 1)}${folder.name}` : folder.path;
|
|
32
|
+
writeLine(door.io.stdout, `${label} (${folder.note_count})`);
|
|
33
|
+
}
|
|
34
|
+
return 0;
|
|
35
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* `notes.ts`, named in its header.
|
|
5
5
|
*/
|
|
6
6
|
import { isInteractiveStdin, readLine, readPipedText, writeLine, yesByDefault } from "./cli-io.js";
|
|
7
|
-
import { NOTE_SLOW_UPLOAD_BYTES, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
|
|
7
|
+
import { NOTE_SLOW_UPLOAD_BYTES, noteAudioMime, decodeTextBytes, noteFileRefusalSentence, readNoteFile, } from "./notes-file.js";
|
|
8
8
|
import { ask, emit, fail, sayUpload, errorText, TAG, READ_DEADLINE_MS } from "./notes-door.js";
|
|
9
9
|
/**
|
|
10
10
|
* The upload route's own ceiling is `maxDuration = 300` — reading a note is one
|
|
@@ -36,9 +36,11 @@ export async function uploadNotes(command, door) {
|
|
|
36
36
|
writeLine(door.io.stderr, `Reading ${read.fileName} (${Math.round(read.bytes.byteLength / 1024)} KB). This can take a couple of minutes.`);
|
|
37
37
|
}
|
|
38
38
|
const form = new FormData();
|
|
39
|
-
form.set("file", new File([new Uint8Array(read.bytes)], read.fileName));
|
|
39
|
+
form.set("file", new File([new Uint8Array(read.bytes)], read.fileName, { type: noteAudioMime(read.fileName) ?? "" }));
|
|
40
40
|
if (command.exclude)
|
|
41
41
|
form.set("exclusions", command.exclude);
|
|
42
|
+
if (command.folder)
|
|
43
|
+
form.set("folder_path", command.folder);
|
|
42
44
|
const answer = await ask(door, {
|
|
43
45
|
path: "/api/notes/upload",
|
|
44
46
|
method: "POST",
|
|
@@ -55,7 +57,14 @@ export async function uploadNotes(command, door) {
|
|
|
55
57
|
continue;
|
|
56
58
|
}
|
|
57
59
|
const body = answer.body;
|
|
58
|
-
|
|
60
|
+
if (command.wait && body.noteId && body.transcription?.status === "pending") {
|
|
61
|
+
body.transcription = await waitForTranscription(door, body.noteId);
|
|
62
|
+
}
|
|
63
|
+
if (body.transcription?.status === "failed")
|
|
64
|
+
worstExit = 1;
|
|
65
|
+
if (!door.json && body.transcription)
|
|
66
|
+
writeLine(door.io.stdout, `${body.noteId}: transcription ${body.transcription.status}${body.transcription.reason ? ": " + body.transcription.reason : ""}`);
|
|
67
|
+
results.push({ path: filePath, ok: body.stored === true && body.transcription?.status !== "failed", body });
|
|
59
68
|
if (body.stored !== true)
|
|
60
69
|
worstExit = 1;
|
|
61
70
|
if (!door.json)
|
|
@@ -114,6 +123,8 @@ export async function pasteNote(command, door) {
|
|
|
114
123
|
form.set("name", command.name);
|
|
115
124
|
if (command.exclude)
|
|
116
125
|
form.set("exclusions", command.exclude);
|
|
126
|
+
if (command.folder)
|
|
127
|
+
form.set("folder_path", command.folder);
|
|
117
128
|
// Bytes, not `.length` (BLI-3482). `NOTE_SLOW_UPLOAD_BYTES` is a BYTE
|
|
118
129
|
// threshold, and the file path above already compares `bytes.byteLength`
|
|
119
130
|
// against it; `text.length` counts UTF-16 units, so a note in any non-Latin
|
|
@@ -225,7 +236,10 @@ export async function moveNote(command, door) {
|
|
|
225
236
|
timeoutMs: READ_DEADLINE_MS,
|
|
226
237
|
// `--clear-shelf` sends the empty string, which is what the browser's own
|
|
227
238
|
// move box sends when a person empties it.
|
|
228
|
-
body: { note_id: command.noteId,
|
|
239
|
+
body: { note_id: command.noteId,
|
|
240
|
+
...(command.folder ? { folder_path: command.folder } : {}),
|
|
241
|
+
...(command.to || command.clearShelf ? { category: command.clearShelf ? "" : command.to } : {}),
|
|
242
|
+
},
|
|
229
243
|
});
|
|
230
244
|
if (!answer.ok)
|
|
231
245
|
return fail(door, answer.reason, answer.detail);
|
|
@@ -242,4 +256,23 @@ export async function moveNote(command, door) {
|
|
|
242
256
|
for (const line of body.lines ?? [])
|
|
243
257
|
writeLine(door.io.stdout, line);
|
|
244
258
|
return body.ok === true ? 0 : 1;
|
|
259
|
+
}
|
|
260
|
+
/** Bounded polling of the same note read used by the browser and MCP. */
|
|
261
|
+
async function waitForTranscription(door, noteId) {
|
|
262
|
+
const deadline = Date.now() + 330_000;
|
|
263
|
+
for (let attempt = 0; attempt < 110 && Date.now() < deadline; attempt++) {
|
|
264
|
+
const answer = await ask(door, { path: `/api/notes/library/${encodeURIComponent(noteId)}`,
|
|
265
|
+
method: "GET", label: "notes transcription", timeoutMs: Math.min(10_000, deadline - Date.now()) });
|
|
266
|
+
if (!answer.ok)
|
|
267
|
+
return { status: "failed", reason: answer.reason };
|
|
268
|
+
const status = answer.body.audio?.transcription_status;
|
|
269
|
+
if (status === "done")
|
|
270
|
+
return { status: "done" };
|
|
271
|
+
if (status?.startsWith("failed:"))
|
|
272
|
+
return { status: "failed", reason: status.slice(7) };
|
|
273
|
+
if (status !== "pending")
|
|
274
|
+
return { status: "failed", reason: "transcription_status_unavailable" };
|
|
275
|
+
await new Promise((resolve) => setTimeout(resolve, Math.min(3000, Math.max(0, deadline - Date.now()))));
|
|
276
|
+
}
|
|
277
|
+
return { status: "failed", reason: "transcription_wait_timeout" };
|
|
245
278
|
}
|
package/dist/commands/notes.js
CHANGED
|
@@ -36,6 +36,7 @@
|
|
|
36
36
|
import { loadPairedSession } from "../tower-client.js";
|
|
37
37
|
import { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
38
38
|
import { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
39
|
+
import { runFolderCommand } from "./notes-folders.js";
|
|
39
40
|
export { listNotes, listShelves, showNote, showShelf } from "./notes-reads.js";
|
|
40
41
|
export { uploadNotes, pasteNote, shareNote, moveNote, placeNote } from "./notes-writes.js";
|
|
41
42
|
export { ask, emit, fail, sayUpload, sayScope, errorText, TAG, } from "./notes-door.js";
|
|
@@ -48,6 +49,11 @@ export async function runNotes(command, io) {
|
|
|
48
49
|
json: command.json,
|
|
49
50
|
};
|
|
50
51
|
switch (command.action) {
|
|
52
|
+
case "folders":
|
|
53
|
+
case "mkdir":
|
|
54
|
+
case "rmdir":
|
|
55
|
+
case "rename":
|
|
56
|
+
return runFolderCommand(command, door);
|
|
51
57
|
case "list":
|
|
52
58
|
return listNotes(command, door);
|
|
53
59
|
case "shelves":
|
|
@@ -15,7 +15,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
18
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
18
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.100");
|
|
19
19
|
return 0;
|
|
20
20
|
}
|
|
21
21
|
|