@bli-cockpit/cli 0.2.243 → 0.2.244
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-budget-admission.js +63 -0
- package/dist/adapters/raw-evidence-collection-state.js +6 -61
- package/dist/commands/local-args-collector-sources.js +2 -1
- package/dist/commands/local-help-commands-tower-ops.js +4 -4
- package/dist/commands/local-help-commands.js +3 -1
- package/dist/commands/local-help.js +1 -1
- package/dist/commands/manager-hints.js +4 -2
- package/dist/commands/manager-limits.json +3 -1
- package/dist/commands/manager-read-reasons.js +1 -0
- package/dist/commands/manager-render-home.js +13 -5
- package/dist/commands/manager-render.js +3 -0
- package/dist/commands/manager.js +3 -1
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/session-sync-outside-roots.js +72 -14
- package/dist/commands/sources-check.js +63 -2
- package/dist/commands/sources-discovery.js +1 -1
- package/dist/commands/sources-reconciliation.js +87 -0
- package/dist/evidence-transcripts-client.js +1 -0
- package/package.json +4 -4
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { terminalDeliveryReason } from "../raw-evidence-staging.js";
|
|
2
|
+
import { admitToStaging } from "../staging-gate.js";
|
|
3
|
+
/**
|
|
4
|
+
* The one door a file passes before its bytes may be copied: the per-sync
|
|
5
|
+
* budget below, then the disk (BLI-12832). A refusal from the disk hands the
|
|
6
|
+
* budget back, because nothing was staged and the next tick re-reads the source.
|
|
7
|
+
* A copy already staged costs the disk nothing and is never refused by it.
|
|
8
|
+
*/
|
|
9
|
+
export function admitEvidence(collection, byteLength, file) {
|
|
10
|
+
// Ticket 21952: a copy staged on an earlier tick is queued, not new bytes; re-charging it let a backlog use up the
|
|
11
|
+
// byte budget every tick. It still costs one object, and its upload resumes from the chunks the server holds.
|
|
12
|
+
const alreadyStaged = collection.staging.staged[file.contentHash] !== undefined;
|
|
13
|
+
const charged = alreadyStaged ? 0 : byteLength;
|
|
14
|
+
const budgetReason = admitToBudget(collection.budget, charged);
|
|
15
|
+
if (budgetReason)
|
|
16
|
+
return budgetReason;
|
|
17
|
+
const allowance = collection.budget.staging;
|
|
18
|
+
if (!allowance)
|
|
19
|
+
return null;
|
|
20
|
+
// BLI-12832: bytes the server refused for good are reclaimed by the prune
|
|
21
|
+
// (the refusal stays as a receipt), so copying them again would be churn.
|
|
22
|
+
if (!alreadyStaged &&
|
|
23
|
+
terminalDeliveryReason(collection.staging.delivery_attempts[file.contentHash]?.last_reason)) {
|
|
24
|
+
refundBudget(collection.budget, charged);
|
|
25
|
+
console.error("[staging-gate] not copying bytes the server refused for good", JSON.stringify({ reason: "delivery_terminal_refused", kind: file.kind, file_bytes: byteLength }));
|
|
26
|
+
return "delivery_terminal_refused";
|
|
27
|
+
}
|
|
28
|
+
const diskReason = admitToStaging(allowance, byteLength, {
|
|
29
|
+
alreadyStaged,
|
|
30
|
+
kind: file.kind,
|
|
31
|
+
});
|
|
32
|
+
if (!diskReason)
|
|
33
|
+
return null;
|
|
34
|
+
refundBudget(collection.budget, charged);
|
|
35
|
+
return `staging_deferred:${diskReason}`;
|
|
36
|
+
}
|
|
37
|
+
/** Hands back one admission that a later gate refused, so nothing was staged and nothing counts as sent. */
|
|
38
|
+
function refundBudget(budget, byteLength) {
|
|
39
|
+
budget.remainingObjects += 1;
|
|
40
|
+
budget.remainingBytes += byteLength;
|
|
41
|
+
budget.admittedBytes = Math.max(0, (budget.admittedBytes ?? 0) - byteLength);
|
|
42
|
+
budget.admittedObjects = Math.max(0, (budget.admittedObjects ?? 0) - 1);
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Decrements the per-sync budget when a file fits, or returns a deferred-skip
|
|
46
|
+
* reason when it does not. The object budget bounds request count; the byte
|
|
47
|
+
* budget bounds buffered bytes.
|
|
48
|
+
*/
|
|
49
|
+
export function admitToBudget(budget, byteLength) {
|
|
50
|
+
const single = budget.singleFileByteAllowance;
|
|
51
|
+
const firstLargeFile = single && budget.remainingBytes === single.normalBytes &&
|
|
52
|
+
byteLength <= single.maxFileBytes;
|
|
53
|
+
const reason = budget.remainingObjects <= 0 ? "deferred_object_budget" :
|
|
54
|
+
byteLength > 0 && byteLength > budget.remainingBytes && !firstLargeFile ? "deferred_byte_budget" : null;
|
|
55
|
+
if (reason) {
|
|
56
|
+
[budget.deferredObjects, budget.deferredBytes] = [(budget.deferredObjects ?? 0) + 1, (budget.deferredBytes ?? 0) + byteLength];
|
|
57
|
+
return reason;
|
|
58
|
+
}
|
|
59
|
+
budget.remainingObjects -= 1;
|
|
60
|
+
budget.remainingBytes -= byteLength;
|
|
61
|
+
[budget.admittedBytes, budget.admittedObjects] = [(budget.admittedBytes ?? 0) + byteLength, (budget.admittedObjects ?? 0) + 1];
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
@@ -7,13 +7,14 @@
|
|
|
7
7
|
* contract, so `raw-evidence-completeness.ts` can read and write them without
|
|
8
8
|
* knowing about packs, budgets or disks.
|
|
9
9
|
*
|
|
10
|
-
* Two mechanics
|
|
10
|
+
* Two mechanics are exposed here rather than in any one reader, because the Codex
|
|
11
11
|
* reader, the Claude reader, the image reader and the git-diff reader all spend
|
|
12
12
|
* them:
|
|
13
13
|
*
|
|
14
14
|
* - **`admitToBudget`** — the per-sync byte and object budget (D7b),
|
|
15
15
|
* decremented in place so one `cockpit sync` across many worktrees honours
|
|
16
|
-
* one cap.
|
|
16
|
+
* one cap. Admission and disk-refusal refunds live together in
|
|
17
|
+
* `raw-evidence-budget-admission.ts`; the exports here keep reader APIs stable.
|
|
17
18
|
* - **`stageEvidenceBytes`** — bytes go on disk exactly once; content an
|
|
18
19
|
* earlier sync already staged is adopted rather than copied again.
|
|
19
20
|
*
|
|
@@ -26,8 +27,8 @@ import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core"
|
|
|
26
27
|
import crypto from "node:crypto";
|
|
27
28
|
import fs from "node:fs/promises";
|
|
28
29
|
import path from "node:path";
|
|
29
|
-
import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject,
|
|
30
|
-
import {
|
|
30
|
+
import { DELIVERY_BACKOFF_BYPASS_REASON, deliveryBackoffApplies, heldSourceKeys, readRawEvidenceStagingState, resolveStagedObject, } from "../raw-evidence-staging.js";
|
|
31
|
+
import { openStagingAllowance, } from "../staging-gate.js";
|
|
31
32
|
import { chmodPrivate } from "./raw-evidence-pack-store.js";
|
|
32
33
|
import { GIT_DIFF_TIMEOUT_MS, MAX_GIT_DIFF_BYTES, } from "./raw-evidence-git-diff.js";
|
|
33
34
|
export const DEFAULT_SINCE_MINUTES = 24 * 60;
|
|
@@ -201,60 +202,4 @@ export async function stageEvidenceBytes(collection, options) {
|
|
|
201
202
|
collection.stagedNewCount += 1;
|
|
202
203
|
return { local_path: destination, staged_in_pack: true };
|
|
203
204
|
}
|
|
204
|
-
|
|
205
|
-
* The one door a file passes before its bytes may be copied: the per-sync
|
|
206
|
-
* budget below, then the disk (BLI-12832). A refusal from the disk hands the
|
|
207
|
-
* budget back, because nothing was staged and the next tick re-reads the source.
|
|
208
|
-
* A copy already staged costs the disk nothing and is never refused by it.
|
|
209
|
-
*/
|
|
210
|
-
export function admitEvidence(collection, byteLength, file) {
|
|
211
|
-
// Ticket 21952: a copy staged on an earlier tick is queued, not new bytes; re-charging it let a backlog use up the
|
|
212
|
-
// byte budget every tick. It still costs one object, and its upload resumes from the chunks the server holds.
|
|
213
|
-
const alreadyStaged = collection.staging.staged[file.contentHash] !== undefined;
|
|
214
|
-
const charged = alreadyStaged ? 0 : byteLength;
|
|
215
|
-
const budgetReason = admitToBudget(collection.budget, charged);
|
|
216
|
-
if (budgetReason)
|
|
217
|
-
return budgetReason;
|
|
218
|
-
const allowance = collection.budget.staging;
|
|
219
|
-
if (!allowance)
|
|
220
|
-
return null;
|
|
221
|
-
// BLI-12832: bytes the server refused for good are reclaimed by the prune
|
|
222
|
-
// (the refusal stays as a receipt), so copying them again would be churn.
|
|
223
|
-
if (!alreadyStaged &&
|
|
224
|
-
terminalDeliveryReason(collection.staging.delivery_attempts[file.contentHash]?.last_reason)) {
|
|
225
|
-
refundBudget(collection.budget, charged);
|
|
226
|
-
console.error("[staging-gate] not copying bytes the server refused for good", JSON.stringify({ reason: "delivery_terminal_refused", kind: file.kind, file_bytes: byteLength }));
|
|
227
|
-
return "delivery_terminal_refused";
|
|
228
|
-
}
|
|
229
|
-
const diskReason = admitToStaging(allowance, byteLength, {
|
|
230
|
-
alreadyStaged,
|
|
231
|
-
kind: file.kind,
|
|
232
|
-
});
|
|
233
|
-
if (!diskReason)
|
|
234
|
-
return null;
|
|
235
|
-
refundBudget(collection.budget, charged);
|
|
236
|
-
return `staging_deferred:${diskReason}`;
|
|
237
|
-
}
|
|
238
|
-
/** Hands back one admission that a later gate refused, so nothing was staged and nothing counts as sent. */
|
|
239
|
-
function refundBudget(budget, byteLength) {
|
|
240
|
-
budget.remainingObjects += 1;
|
|
241
|
-
budget.remainingBytes += byteLength;
|
|
242
|
-
budget.admittedBytes = Math.max(0, (budget.admittedBytes ?? 0) - byteLength);
|
|
243
|
-
budget.admittedObjects = Math.max(0, (budget.admittedObjects ?? 0) - 1);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Decrements the per-sync budget when a file fits, or returns a deferred-skip
|
|
247
|
-
* reason when it does not. The object budget bounds request count; the byte
|
|
248
|
-
* budget bounds buffered bytes.
|
|
249
|
-
*/
|
|
250
|
-
export function admitToBudget(budget, byteLength) {
|
|
251
|
-
const reason = budget.remainingObjects <= 0 ? "deferred_object_budget" : byteLength > budget.remainingBytes ? "deferred_byte_budget" : null;
|
|
252
|
-
if (reason) {
|
|
253
|
-
[budget.deferredObjects, budget.deferredBytes] = [(budget.deferredObjects ?? 0) + 1, (budget.deferredBytes ?? 0) + byteLength];
|
|
254
|
-
return reason;
|
|
255
|
-
}
|
|
256
|
-
budget.remainingObjects -= 1;
|
|
257
|
-
budget.remainingBytes -= byteLength;
|
|
258
|
-
[budget.admittedBytes, budget.admittedObjects] = [(budget.admittedBytes ?? 0) + byteLength, (budget.admittedObjects ?? 0) + 1];
|
|
259
|
-
return null;
|
|
260
|
-
}
|
|
205
|
+
export { admitEvidence, admitToBudget } from "./raw-evidence-budget-admission.js";
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs, } from "./local-arg-values.js";
|
|
10
10
|
export function parseSourcesArgs(args) {
|
|
11
11
|
const values = parseNamedArgs(args, {
|
|
12
|
-
allowedFlags: ["--older-than", "--home", "--dashboard-url", "--json"],
|
|
12
|
+
allowedFlags: ["--older-than", "--home", "--dashboard-url", "--json", "--reconcile"],
|
|
13
13
|
valueFlags: ["--older-than", "--home", "--dashboard-url"],
|
|
14
14
|
});
|
|
15
15
|
const action = values.positionals[0] ?? "check";
|
|
@@ -17,6 +17,7 @@ export function parseSourcesArgs(args) {
|
|
|
17
17
|
throw new Error("sources has one verb: `cockpit sources check [--older-than <days>] [--json]`.");
|
|
18
18
|
}
|
|
19
19
|
return {
|
|
20
|
+
reconcile: values.booleans.has("--reconcile"),
|
|
20
21
|
kind: "sources",
|
|
21
22
|
action: "check",
|
|
22
23
|
olderThanDays: optionalPositiveInteger(values.flags.get("--older-than"), "--older-than") ?? null,
|
|
@@ -226,10 +226,10 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
226
226
|
" \"YYYY-MM-DD: \" dates a bullet, without it the bullet is undated. none clears --next or --blocker. blocked needs a blocker (given, or already on the card),",
|
|
227
227
|
" and a blocker goes only on a Blocked card: sent with any other health it is refused (summary_blocker_needs_blocked); sending only a new health drops the stored one.",
|
|
228
228
|
" --version is summary.updatedAt from `home <client> --json`, or none when the client has no card yet (then --health, --headline and at least one --bullet are all needed).",
|
|
229
|
-
" Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is
|
|
230
|
-
"
|
|
229
|
+
" Limits: headline 140 characters, each bullet 160, next and blocker 200; a longer text is refused here with its length. A day spelled with a month name at the start of a bullet (\"Oct 3: x\") is refused, never saved undated. --version none is for a client with no card: on one that has a card the write is refused with \"This client already has a card: pass its version\". The edit is stored as written by your agent",
|
|
230
|
+
" (source agent, \"Written by <you>'s agent\"): Tower's check never rewrites it, whatever changes; your agent's next edit, or a person's on the page, replaces it.",
|
|
231
231
|
" A saved card that says On track while an open action is more than 14 days overdue is saved anyway and prints a \"Warning: ...\" line naming the action overdue longest.",
|
|
232
|
-
" Write the card after your item writes:
|
|
232
|
+
" Write the card after your item writes: the check will not rewrite it to take them in.",
|
|
233
233
|
" Health rule: an open action more than 14 days overdue, as `cockpit manager home` shows it (Tower's today, America/Vancouver), means the card is at_risk (or blocked, with a named blocker).",
|
|
234
234
|
" Do not set on_track over one; tell the person instead. The headline or a bullet must name the action overdue longest, with the day it was due.",
|
|
235
235
|
" cockpit manager list <client> [--timeline] [--needs-review] [--status open|done|dropped|closed|all] [--owner <name>|--no-owner] [--overdue] [--kind <kind>] [--search <text>] [--archived] [--review dismissed|all]",
|
|
@@ -261,7 +261,7 @@ export const TOWER_OPS_COMMAND_HELP = [
|
|
|
261
261
|
" --version and --timeline stay flags. A field given as a flag and in the file is refused, so is an unknown key. The file gets the flags' own checks. Example: cat fields.json | cockpit manager summary Acme --version none --fields-file -",
|
|
262
262
|
"Free text (the flags above and every value in a fields file) that holds a provider key (an sk-, rk_, sb_secret_, ghp_, github_pat_, AKIA/ASIA, xoxb- key, any JWT, a PEM private key) or a password given a value (\"password: x\") is refused, naming the flag or key; ordinary words, a commit sha, a URL and the words \"service-role key\" are plain text.",
|
|
263
263
|
"On a flag, free text (--headline, --bullet, --title, --detail, --next, --blocker, --owners, --source-note) goes in SINGLE quotes so the shell leaves $ alone: \"$12k\" in double quotes loses the $12k (zsh and bash read it as a variable). In bash and zsh write an apostrophe inside single quotes as '\\'' (end the quote, a backslash-escaped apostrophe, reopen); in PowerShell double the apostrophe ('Rowan''s deck'). The command prints what it saved: check it is your words.",
|
|
264
|
-
"<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add are
|
|
264
|
+
"<client> is the client's id, or its exact name (any case; `clients` lists them); a name that matches no client, or several, is refused with the names. Ids and versions are printed by list, queue and show. Items you add here are stored as added by your agent, not yet checked (they count in needs review until a person approves or dismisses them), never as you having checked them; text tagged [HR] is refused (item_hr_tagged, summary_hr_tagged).",
|
|
265
265
|
"--version is the item's version exactly as list or show prints it, copied as it is. Every change to an existing item names it: if someone or a sync changed the item",
|
|
266
266
|
"since, the write is refused with a sentence saying so and nothing is written; run show again for the new version and retry.",
|
|
267
267
|
"--owners on an action takes, as the web form says, \"One or more people, separated by commas\" (\"Rowan Ash, Juniper Twig\"), and it REPLACES the whole list. Leave it out and the owners stay;",
|
|
@@ -386,7 +386,9 @@ const COMMAND_HELP = new Map([
|
|
|
386
386
|
[
|
|
387
387
|
"sources",
|
|
388
388
|
[
|
|
389
|
-
"Usage: cockpit sources check [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
389
|
+
"Usage: cockpit sources check [--reconcile] [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
390
|
+
"",
|
|
391
|
+
"--reconcile adds read-only source-row, pointer and revision metadata checks; never collects or repairs.",
|
|
390
392
|
"",
|
|
391
393
|
"Answers, for every Codex and Claude transcript on this machine, whether Tower",
|
|
392
394
|
"holds it: stored in full, stored in part, or not stored, with the reason.",
|
|
@@ -140,7 +140,7 @@ const COMMAND_SUMMARY_LINES = [
|
|
|
140
140
|
" cockpit agent-rules [install|uninstall|status] [--host codex|claude|all] [--workspace <path>] [--json]",
|
|
141
141
|
" cockpit memory [search \"<question>\"|save|update <id>|forget <id>|profile|install|status|log <win|loss|noise> --store <bli|supermemory|both> \"<reason>\"] [--container <tag>] [--limit <n>] [--no-recent] [--include-originals|--no-originals] [--include-source|--no-source] [--custom-id <id>] [--extract] [--content-stdin] [--reason <label>] [--file <path>] [--dashboard-url <url>] [--dry-run] [--json]",
|
|
142
142
|
" cockpit clean [--dry-run] [--all-committed] [--reconcile] [--dashboard-url <url>] [--json]",
|
|
143
|
-
" cockpit sources check [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
143
|
+
" cockpit sources check [--reconcile] [--older-than <days>] [--dashboard-url <url>] [--json]",
|
|
144
144
|
" cockpit docs [list|tree|read <id|slug>|create --title <t>|update <id>|delete <id|slug>|trash|versions <id|slug>|restore <id>] [--parent <id>|root|--clear-parent] [--query <text> [--word]] [--limit <n>] [--visibility org|private] [--file <path>|--body-stdin] [--allow-empty] [--dashboard-url <url>] [--json]",
|
|
145
145
|
" cockpit msg [channels|create <name>|dm <email>|read <channel>|send <channel>|thread <id> --channel <channel>|show <message-id>] [--private] [--members a@x,b@y] [--description <text>] [--thread <id>] [--limit <n>] [--before <cursor>] [--dashboard-url <url>] [--json]",
|
|
146
146
|
" cockpit issue [list|show <BLI-id>|create --title <t>|update <BLI-id>|move <BLI-id> <state>|comment <BLI-id>|history <BLI-id>] [--state <s>] [--assignee me|unassigned|<uuid>] [--project <name|id>] [--limit <n>] [--priority 0-4] [--size S|M|L] [--parent <BLI-id>] [--file <path>|--body-stdin] [--dashboard-url <url>] [--json]",
|
|
@@ -20,7 +20,7 @@ const L = MANAGER_LIMITS_COPY;
|
|
|
20
20
|
export const SUMMARY_REFUSAL_NAMES = [
|
|
21
21
|
"summary_changed", "summary_empty", "summary_incomplete", "summary_health_invalid", "summary_headline_empty", "summary_headline_too_long",
|
|
22
22
|
"summary_bullets_count", "summary_bullet_empty", "summary_bullet_too_long", "summary_bullet_date_invalid", "summary_next_too_long",
|
|
23
|
-
"summary_blocker_too_long", "summary_blocked_needs_blocker", "summary_blocker_needs_blocked",
|
|
23
|
+
"summary_blocker_too_long", "summary_blocked_needs_blocker", "summary_blocker_needs_blocked", "summary_hr_tagged",
|
|
24
24
|
];
|
|
25
25
|
/** The way forward after each named refusal of a "Where we are" edit; `home` is the ready-to-run `cockpit manager home <client> --json`. */
|
|
26
26
|
export function summaryHints(home) {
|
|
@@ -40,11 +40,12 @@ export function summaryHints(home) {
|
|
|
40
40
|
summary_blocker_too_long: `Shorten --blocker (the limit is ${L.blockerMax}).`,
|
|
41
41
|
summary_blocked_needs_blocker: "Add --blocker <what blocks it>, or pass another --health.",
|
|
42
42
|
summary_blocker_needs_blocked: "Add --health blocked, or leave --blocker out (`--blocker none` clears the stored one).",
|
|
43
|
+
summary_hr_tagged: "Run the summary again without the tagged text; that part stays out of Tower.",
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
export const ITEM_REFUSAL_NAMES = [
|
|
46
47
|
"item_changed", "item_busy", "item_archived", "item_not_archived", "item_dismissed", "person_item_not_dismissable",
|
|
47
|
-
"no_proposal", "proposal_replaced", "timeline_not_live", "request_reused", "proposal_decided", "proposal_not_addable",
|
|
48
|
+
"no_proposal", "proposal_replaced", "timeline_not_live", "request_reused", "proposal_decided", "proposal_not_addable", "item_hr_tagged",
|
|
48
49
|
];
|
|
49
50
|
/** Refusals that mean "the item is not as you last saw it": reading it again is the way forward. */
|
|
50
51
|
export const RESEEN_REFUSALS = new Set(["item_changed", "item_busy", "proposal_decided", "no_proposal"]);
|
|
@@ -65,6 +66,7 @@ export function itemHints(id, flag) {
|
|
|
65
66
|
person_item_not_dismissable: `Run \`cockpit manager archive ${id}${flag} --version <v>\`.`,
|
|
66
67
|
timeline_not_live: "`cockpit manager list <client> --timeline` lists the client's live timeline items.",
|
|
67
68
|
item_not_archived: "Nothing to bring back: it is in the lists already.",
|
|
69
|
+
item_hr_tagged: "Run the add or edit again without the tagged text; that part stays out of Tower.",
|
|
68
70
|
};
|
|
69
71
|
}
|
|
70
72
|
/**
|
|
@@ -146,13 +146,15 @@
|
|
|
146
146
|
"summary_next_too_long",
|
|
147
147
|
"summary_blocker_too_long",
|
|
148
148
|
"summary_blocked_needs_blocker",
|
|
149
|
-
"summary_blocker_needs_blocked"
|
|
149
|
+
"summary_blocker_needs_blocked",
|
|
150
|
+
"summary_hr_tagged"
|
|
150
151
|
],
|
|
151
152
|
"item": [
|
|
152
153
|
"item_archived",
|
|
153
154
|
"item_busy",
|
|
154
155
|
"item_changed",
|
|
155
156
|
"item_dismissed",
|
|
157
|
+
"item_hr_tagged",
|
|
156
158
|
"item_not_archived",
|
|
157
159
|
"no_proposal",
|
|
158
160
|
"person_item_not_dismissable",
|
|
@@ -51,6 +51,7 @@ export const SUMMARY_REFUSAL_WORDS = {
|
|
|
51
51
|
summary_blocker_too_long: "the blocker was over 200 characters",
|
|
52
52
|
summary_blocked_needs_blocker: "blocked with no blocker",
|
|
53
53
|
summary_blocker_needs_blocked: "a blocker on a card that is not blocked",
|
|
54
|
+
summary_hr_tagged: "text tagged [HR]",
|
|
54
55
|
};
|
|
55
56
|
/** Why a summary check kept the card that was there instead of writing one (the receipt's `kept_reason`), in words. */
|
|
56
57
|
export const SUMMARY_KEPT_WORDS = {
|
|
@@ -83,13 +83,14 @@ export function withTodayLine(rows, dates, today) {
|
|
|
83
83
|
const line = [todayLine(today)];
|
|
84
84
|
return at === -1 ? [...rows, line] : [...rows.slice(0, at), line, ...rows.slice(at)];
|
|
85
85
|
}
|
|
86
|
-
/** Who wrote the card, by its `source`: the credit's opening words (the day follows; a person's name comes after "Edited by"). */
|
|
86
|
+
/** Who wrote the card, by its `source`: the credit's opening words (the day follows; a person's name comes after "Edited by" and "Written by", then "'s agent" for the agent's card). */
|
|
87
87
|
export const SUMMARY_SOURCE_WORDS = {
|
|
88
88
|
model: "Written by AI",
|
|
89
89
|
computed: "AI unavailable · computed from counts",
|
|
90
90
|
person: "Edited by",
|
|
91
|
+
agent: "Written by",
|
|
91
92
|
};
|
|
92
|
-
/** Who wrote the card and when: "written by AI", "computed from counts", or "
|
|
93
|
+
/** Who wrote the card and when: "written by AI", "computed from counts", "edited by" a person, or "written by" a person's agent. */
|
|
93
94
|
function authorLine(summary, now) {
|
|
94
95
|
// The server's own Vancouver day when it sends one; never worked out here when it did.
|
|
95
96
|
const at = summary.writtenOn ? `${dayWords(summary.writtenOn)} (${ageText(summary.writtenAt, now)})` : when(summary.writtenAt, now);
|
|
@@ -98,6 +99,8 @@ function authorLine(summary, now) {
|
|
|
98
99
|
if (summary.source === "computed")
|
|
99
100
|
return `${SUMMARY_SOURCE_WORDS.computed}, ${at}.`;
|
|
100
101
|
// Never a user id in a person's terminal: the door fills the name, or says "a teammate".
|
|
102
|
+
if (summary.source === "agent")
|
|
103
|
+
return `${SUMMARY_SOURCE_WORDS.agent} ${summary.editedByName ?? "a teammate"}'s agent, ${at}.`;
|
|
101
104
|
return `${SUMMARY_SOURCE_WORDS.person} ${summary.editedByName ?? "a teammate"}, ${at}.`;
|
|
102
105
|
}
|
|
103
106
|
const CAP_WORDS = {
|
|
@@ -169,8 +172,11 @@ function summaryLines(home, now, color) {
|
|
|
169
172
|
if (checked !== null && Number.isFinite(new Date(checked).getTime()))
|
|
170
173
|
lines.push(` Last checked ${ageText(checked, now)}.`);
|
|
171
174
|
}
|
|
172
|
-
if (summary.inputsChanged && !summary.lastCheckFailed)
|
|
173
|
-
lines.push(
|
|
175
|
+
if (summary.inputsChanged && !summary.lastCheckFailed) {
|
|
176
|
+
lines.push(summary.source === "agent"
|
|
177
|
+
? " Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue after this was written; Tower's check leaves the agent's card, so the agent or a person writes it again."
|
|
178
|
+
: " Inputs changed since: the client's data changed, an action went overdue or an open action passed 14 days overdue (for an AI-written card, also a meeting or milestone came within a week or passed) after this was written; the next check writes it again.");
|
|
179
|
+
}
|
|
174
180
|
lines.push(counts, dim(` version ${summary.updatedAt} (what \`summary --version\` takes)`, color));
|
|
175
181
|
return lines;
|
|
176
182
|
}
|
|
@@ -378,7 +384,9 @@ export function summaryWrittenLines(summary, color, now = new Date()) {
|
|
|
378
384
|
lines.push(` Next: ${summary.next}`);
|
|
379
385
|
if (summary.blocker)
|
|
380
386
|
lines.push(` Blocker: ${summary.blocker}`);
|
|
381
|
-
lines.push(
|
|
387
|
+
lines.push(summary.source === "agent"
|
|
388
|
+
? ` ${authorLine(summary, now)} Tower's check never rewrites it; your agent's next edit, or a person's on the page, replaces it.`
|
|
389
|
+
: ` ${authorLine(summary, now)} It stands until the client's data changes, an action goes overdue or an open action passes 14 days overdue; the next check after that writes it again.`);
|
|
382
390
|
lines.push(dim(` version ${summary.updatedAt}`, color));
|
|
383
391
|
return lines;
|
|
384
392
|
}
|
|
@@ -149,6 +149,9 @@ export function handledByLines(item, timeline) {
|
|
|
149
149
|
const human = item.origin === "human";
|
|
150
150
|
if (human && item.createdBy)
|
|
151
151
|
lines.push(`Added by ${person(item.createdByName)}${onDay(item.createdAt)}.`);
|
|
152
|
+
// An AI item with a creator is a person's agent's add through the door (BLI-21947); the model's own have none.
|
|
153
|
+
if (!human && item.createdBy)
|
|
154
|
+
lines.push(`Added by ${person(item.createdByName)}'s agent${onDay(item.createdAt)}${item.reviewStatus === "unreviewed" ? "; not checked yet" : ""}.`);
|
|
152
155
|
if (item.reviewStatus === "rejected" && item.reviewedBy)
|
|
153
156
|
lines.push(`Dismissed by ${person(item.reviewedByName)}${onDay(item.reviewedAt)}.`);
|
|
154
157
|
else if (item.reviewedBy && item.reviewStatus === "reviewed") {
|
package/dist/commands/manager.js
CHANGED
|
@@ -144,6 +144,8 @@ function renderAnswer(command, body, color) {
|
|
|
144
144
|
}
|
|
145
145
|
/** An add answered `created: false`: the same request id came back, so the item that already existed is returned. */
|
|
146
146
|
const ALREADY_ADDED = "Already added earlier under this request id; nothing new was written.";
|
|
147
|
+
/** An add the server stored as the caller's agent's, unchecked (BLI-21947); an older server's reviewed add says nothing more. */
|
|
148
|
+
const addedUnchecked = (body) => body.item?.reviewStatus === "unreviewed" ? " Stored as added by your agent, not yet checked: a person approves or dismisses it." : "";
|
|
147
149
|
/** What a write did, in one sentence a person reads; the item's new version follows on its row. */
|
|
148
150
|
function writeSentence(command, body) {
|
|
149
151
|
const as = "as you";
|
|
@@ -152,7 +154,7 @@ function writeSentence(command, body) {
|
|
|
152
154
|
const queue = waiting === 0 ? "It leaves the review queue." : `${waiting === 1 ? "Its other suggested change still waits" : `Its ${waiting} other suggested changes still wait`} in the review queue.`;
|
|
153
155
|
switch (command.action) {
|
|
154
156
|
// Only an explicit `created: false` is "not created": an older server sends no field, and that add did write.
|
|
155
|
-
case "add": return body.created === false ? [ALREADY_ADDED] : [command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."];
|
|
157
|
+
case "add": return body.created === false ? [ALREADY_ADDED] : [`${command.timeline && !command.occurredAt ? "Added. It has no date, so it sits in \"Needs a date\"." : "Added."}${addedUnchecked(body)}`];
|
|
156
158
|
// Railway writes nothing when every field already equals the stored one: no new version, no reviewed mark.
|
|
157
159
|
case "edit": return [body.unchanged === true ? "No change; nothing was written." : "Saved your changes."];
|
|
158
160
|
// Railway writes nothing when the action already has that status, so its version comes back unchanged.
|
|
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
if (command === "--version" || command === "-V" || command === "version") {
|
|
24
|
-
writeLine(io?.stdout ?? process.stdout, "0.2.
|
|
24
|
+
writeLine(io?.stdout ?? process.stdout, "0.2.244");
|
|
25
25
|
return 0;
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
*
|
|
23
23
|
* - A session already durable with unchanged content rides for free. It
|
|
24
24
|
* uploads nothing (the object cursor reuses it) and keeps its row stable.
|
|
25
|
-
* - A session that is new, changed or not yet uploaded is "owed". The
|
|
25
|
+
* - A session that is new, changed or not yet uploaded is "owed". The longest-waiting
|
|
26
26
|
* ones ride this tick up to a per-tick session cap. The rest wait, and their
|
|
27
27
|
* row says `deferred_object_budget`, the existing "next tick has room"
|
|
28
28
|
* label, instead of the old "not uploadable" one.
|
|
@@ -34,9 +34,9 @@
|
|
|
34
34
|
*
|
|
35
35
|
* The cap, the byte allowance and the object allowance are per tick, so a
|
|
36
36
|
* 1,800-session backlog drains across many ticks and never blows one tick's
|
|
37
|
-
* time or the machine's memory.
|
|
38
|
-
*
|
|
39
|
-
*
|
|
37
|
+
* time or the machine's memory. A persisted retry order rotates offered
|
|
38
|
+
* sessions behind waiting ones,
|
|
39
|
+
* including when their source keeps growing.
|
|
40
40
|
*
|
|
41
41
|
* A new sibling must also join `scripts/build-public-cli.mjs` `runtimeFiles`.
|
|
42
42
|
*/
|
|
@@ -45,12 +45,13 @@ import path from "node:path";
|
|
|
45
45
|
import { RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES } from "@bli-cockpit/telemetry-core";
|
|
46
46
|
import { isRedactionStubObjectKey } from "../cursors/raw-evidence-stub-entry.js";
|
|
47
47
|
import { describeError } from "../health-detail.js";
|
|
48
|
-
import {
|
|
48
|
+
import { getCollectorRuntimePaths } from "../local-state.js";
|
|
49
|
+
import { isMissingFileError, writeJsonFileAtomic, writeJsonFile } from "../local-state-files.js";
|
|
49
50
|
import { rootScopeForReason } from "./agent-session-root-scope.js";
|
|
50
51
|
import { ensureOutsideRootsWorkspace, isOutsideRootsWorkspace, outsideRootsWorkspace, } from "./backfill-outside-roots.js";
|
|
51
52
|
/** Most outside-root sessions that upload bytes in one tick. 4 ticks an hour, so up to 3,840 a day. */
|
|
52
53
|
export const OUTSIDE_ROOT_SESSIONS_PER_TICK = 40;
|
|
53
|
-
/**
|
|
54
|
+
/** Normal outside-root byte allowance; a fresh allowance can admit one wire-bounded file. */
|
|
54
55
|
export const OUTSIDE_ROOT_BYTES_PER_TICK = 256 * 1024 * 1024;
|
|
55
56
|
/** Files (a Claude main plus its sidecars count one each) an outside-root pass may collect in one tick. */
|
|
56
57
|
export const OUTSIDE_ROOT_OBJECTS_PER_TICK = 120;
|
|
@@ -165,7 +166,27 @@ export async function admitOutsideRootSessions(options) {
|
|
|
165
166
|
if (sightings.length === 0) {
|
|
166
167
|
return { ...scan, outsideRoots: null };
|
|
167
168
|
}
|
|
168
|
-
const
|
|
169
|
+
const queuePath = path.join(getCollectorRuntimePaths(options.homeDir).cursors_dir, "outside-root-admission.json");
|
|
170
|
+
const turns = await readAdmissionTurns(queuePath);
|
|
171
|
+
const { admitted, holds, counts, budget } = selectOutsideRootSessions(sightings, plan, turns);
|
|
172
|
+
// Offered work rotates even on a later disk/delivery refusal. New arrivals
|
|
173
|
+
// join behind existing waiters; growth never resets a session's place.
|
|
174
|
+
const offered = new Set(admitted.map((sighting) => outsideRootKey(sighting.source, sighting.sessionId)));
|
|
175
|
+
const next = Object.values(turns).reduce((max, turn) => Math.max(max, turn), 0) + 1;
|
|
176
|
+
const pending = {};
|
|
177
|
+
for (const sighting of sightings) {
|
|
178
|
+
const key = outsideRootKey(sighting.source, sighting.sessionId);
|
|
179
|
+
if (isDurableAndUnchanged(sighting.result, (sighting.source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore).sessions[sighting.sessionId]) ||
|
|
180
|
+
sighting.result.byte_size > RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES)
|
|
181
|
+
continue;
|
|
182
|
+
pending[key] = offered.has(key) ? next + 1 : turns[key] ?? next;
|
|
183
|
+
}
|
|
184
|
+
try {
|
|
185
|
+
await writeJsonFileAtomic(queuePath, pending);
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
console.error("[session-sync] outside-root admission order not saved", JSON.stringify({ reason: "admission_order_write_failed", ...describeError(error) }));
|
|
189
|
+
}
|
|
169
190
|
if (admitted.length === 0) {
|
|
170
191
|
console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "nothing_to_upload_outside_roots", ...counts, ...budget }));
|
|
171
192
|
return { ...scan, outsideRoots: { workspace: null, holds, counts } };
|
|
@@ -203,7 +224,24 @@ export async function admitOutsideRootSessions(options) {
|
|
|
203
224
|
console.error("[session-sync] outside-root raw upload", JSON.stringify({ reason: "outside_root_sessions_admitted", ...counts, ...budget }));
|
|
204
225
|
return labelled;
|
|
205
226
|
}
|
|
206
|
-
|
|
227
|
+
/** Session ids and queue turns only; no source paths or transcript content. */
|
|
228
|
+
async function readAdmissionTurns(filePath) {
|
|
229
|
+
try {
|
|
230
|
+
const value = JSON.parse(await fs.readFile(filePath, "utf8"));
|
|
231
|
+
if (!value || typeof value !== "object" || Array.isArray(value) ||
|
|
232
|
+
Object.entries(value).some(([key, turn]) => !/^(codex|claude_code):/.test(key) || !Number.isSafeInteger(turn) || Number(turn) < 0)) {
|
|
233
|
+
throw new Error("invalid admission order");
|
|
234
|
+
}
|
|
235
|
+
return value;
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
if (!isMissingFileError(error)) {
|
|
239
|
+
console.error("[session-sync] outside-root admission order unavailable", JSON.stringify({ reason: "admission_order_read_failed", ...describeError(error) }));
|
|
240
|
+
}
|
|
241
|
+
return {};
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
function selectOutsideRootSessions(sightings, plan, turns) {
|
|
207
245
|
const cursorFor = (source) => source === "codex" ? plan.codexCursorBefore : plan.claudeCursorBefore;
|
|
208
246
|
const free = [];
|
|
209
247
|
const owed = [];
|
|
@@ -222,12 +260,28 @@ function selectOutsideRootSessions(sightings, plan) {
|
|
|
222
260
|
owed.push(sighting);
|
|
223
261
|
}
|
|
224
262
|
}
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
left.
|
|
229
|
-
|
|
230
|
-
|
|
263
|
+
const newTurn = Object.values(turns).reduce((max, turn) => Math.max(max, turn), 0) + 1;
|
|
264
|
+
owed.sort((left, right) => (turns[outsideRootKey(left.source, left.sessionId)] ?? newTurn) -
|
|
265
|
+
(turns[outsideRootKey(right.source, right.sessionId)] ?? newTurn) ||
|
|
266
|
+
left.result.session_file_mtime_ms - right.result.session_file_mtime_ms ||
|
|
267
|
+
outsideRootKey(left.source, left.sessionId).localeCompare(outsideRootKey(right.source, right.sessionId)));
|
|
268
|
+
// A main larger than the normal allowance gets a turn without other owed sessions. Otherwise a
|
|
269
|
+
// smaller main (or the other provider's reader, which runs first) could fill
|
|
270
|
+
// staging before the existing empty-staging exception ever sees it.
|
|
271
|
+
const riding = [];
|
|
272
|
+
let bytes = 0;
|
|
273
|
+
for (const sighting of owed) {
|
|
274
|
+
if (riding.length >= OUTSIDE_ROOT_SESSIONS_PER_TICK)
|
|
275
|
+
break;
|
|
276
|
+
if (bytes + sighting.result.byte_size > OUTSIDE_ROOT_BYTES_PER_TICK) {
|
|
277
|
+
if (riding.length === 0)
|
|
278
|
+
riding.push(sighting);
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
riding.push(sighting);
|
|
282
|
+
bytes += sighting.result.byte_size;
|
|
283
|
+
}
|
|
284
|
+
const waiting = owed.slice(riding.length);
|
|
231
285
|
for (const sighting of waiting) {
|
|
232
286
|
holds.set(outsideRootKey(sighting.source, sighting.sessionId), OUTSIDE_ROOT_WAITING_REASON);
|
|
233
287
|
}
|
|
@@ -273,6 +327,10 @@ export function applyOutsideRootHolds(rows, holds) {
|
|
|
273
327
|
* description session stay shared (they live on the same budget object).
|
|
274
328
|
*/
|
|
275
329
|
export function giveOutsideRootsTheirOwnAllowance(budget) {
|
|
330
|
+
budget.singleFileByteAllowance = {
|
|
331
|
+
normalBytes: OUTSIDE_ROOT_BYTES_PER_TICK,
|
|
332
|
+
maxFileBytes: RAW_EVIDENCE_UPLOAD_MAX_FILE_BYTES,
|
|
333
|
+
};
|
|
276
334
|
budget.remainingBytes = OUTSIDE_ROOT_BYTES_PER_TICK;
|
|
277
335
|
budget.remainingObjects = OUTSIDE_ROOT_OBJECTS_PER_TICK;
|
|
278
336
|
}
|
|
@@ -1,3 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit sources check [--older-than <days>] [--json]` — does Tower hold
|
|
3
|
+
* each agent transcript on this machine? (BLI-11122)
|
|
4
|
+
*
|
|
5
|
+
* On 2026-09-30 an agent freeing disk ran `find ~/.codex/... -mtime +60
|
|
6
|
+
* -delete` on the premise that the files were already in Tower; 349 of 1,491
|
|
7
|
+
* had no copy. This door is the machine answer that was missing: for every
|
|
8
|
+
* local Codex and Claude transcript it asks Tower which current objects it
|
|
9
|
+
* holds and answers `stored_full`, `stored_slim` (BLI-17273: Tower holds only the
|
|
10
|
+
* slim form, keep the file), `stored_partial` or `not_stored`, with the reason
|
|
11
|
+
* (`sources-verdict.ts`), judged against THIS machine's image mode.
|
|
12
|
+
*
|
|
13
|
+
* READ-ONLY, and nothing else: Tower never deletes a person's raw transcripts.
|
|
14
|
+
* Deleting one is the person's own act, outside Tower. This command reads the
|
|
15
|
+
* files and the server's list; it never writes, moves or deletes a file under
|
|
16
|
+
* `~/.codex` or `~/.claude`. `docs/runbooks/freeing-disk-on-a-collector-machine.md`.
|
|
17
|
+
*
|
|
18
|
+
* A server that cannot be asked is a failure with its reason (exit 1), never
|
|
19
|
+
* "not stored". A list cut short (`capped`, `failed_partial`) still answers,
|
|
20
|
+
* marked incomplete: a missing page can only understate what Tower holds.
|
|
21
|
+
*/
|
|
22
|
+
import { fetchSourceInventory, reconcileSource } from "./sources-reconciliation.js";
|
|
1
23
|
import { writeLine } from "./cli-io.js";
|
|
2
24
|
import { readDescribedImageKeys } from "../image-description-state.js";
|
|
3
25
|
import { resolveImageHandlingMode } from "../image-mode-state.js";
|
|
@@ -12,6 +34,10 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
12
34
|
const startedAt = Date.now();
|
|
13
35
|
const paths = getCollectorRuntimePaths(command.homeDir);
|
|
14
36
|
const config = await readLocalCollectorConfig(paths).catch(() => null);
|
|
37
|
+
if (command.reconcile && !config) {
|
|
38
|
+
console.error("[collector sources] reconciliation not run", JSON.stringify({ reason: "local_config_unavailable_opt_out_unknown" }));
|
|
39
|
+
return fail(io, command, "local_config_unavailable_opt_out_unknown", "Local collection preferences could not be read; no transcript store was scanned.");
|
|
40
|
+
}
|
|
15
41
|
const dashboardUrl = command.dashboardUrl ?? config?.dashboard_url ?? DEFAULT_DASHBOARD_URL;
|
|
16
42
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
17
43
|
if (!session || session.session_state !== "valid" || typeof session.device_token !== "string" || !session.device_token) {
|
|
@@ -25,7 +51,9 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
25
51
|
}
|
|
26
52
|
const homeDir = command.homeDir ?? paths.home_dir;
|
|
27
53
|
const olderThanMs = command.olderThanDays === null ? null : now.getTime() - command.olderThanDays * DAY_MS;
|
|
28
|
-
const
|
|
54
|
+
const optOutAll = command.reconcile === true && config?.raw_evidence_upload === "disabled";
|
|
55
|
+
const optOutClaude = command.reconcile === true && config?.collect_claude_jsonl === false;
|
|
56
|
+
const local = await discoverLocalTranscripts({ homeDir, olderThanMs, skipAll: optOutAll, skipClaude: optOutClaude });
|
|
29
57
|
const cursors = {
|
|
30
58
|
codex: await readRawEvidenceCursor(paths),
|
|
31
59
|
claude_code: await readRawEvidenceCursor(paths, { filename: CLAUDE_CURSOR_FILENAME, sessionsOnly: true }),
|
|
@@ -38,6 +66,36 @@ export async function runSourcesCheck(command, io, now = new Date()) {
|
|
|
38
66
|
console.error("[collector sources] judging against this machine's image mode", JSON.stringify({ reason: `image_mode_from_${imageMode.source}`, mode: imageMode.mode, slim_aware: imageMode.slimAware }));
|
|
39
67
|
const report = emptyReport(command, remote, local.newer_count, local.file_read_failed + local.directory_read_failed);
|
|
40
68
|
await judgeLocalTranscripts(local.files, remote, cursors, imageMode, describedKeys, report);
|
|
69
|
+
if (command.reconcile) {
|
|
70
|
+
const inventory = await fetchSourceInventory({ dashboardUrl, deviceToken: session.device_token, fetchImpl: io.fetch });
|
|
71
|
+
const { rows: _rows, ...inventoryCounts } = inventory;
|
|
72
|
+
const counts = { missing_row: 0, missing_pointer: 0, stale_revision: 0, unavailable_evidence: 0, explicit_opt_out: 0, unverifiable: 0, revision_verified: 0 };
|
|
73
|
+
const optOutSources = optOutAll ? ["codex", "claude_code"] : optOutClaude ? ["claude_code"] : [];
|
|
74
|
+
counts.explicit_opt_out = optOutSources.length;
|
|
75
|
+
for (const file of local.files) {
|
|
76
|
+
const finding = await reconcileSource({ file, inventory, remote,
|
|
77
|
+
cursor: file.kind === "claude_subagent" ? null : cursors[file.source].sessions[file.session_id] ?? null,
|
|
78
|
+
facts: fileFacts(file, undefined, { mode: imageMode.mode, describedKeys, slimAware: imageMode.slimAware }),
|
|
79
|
+
}).catch(() => ({ state: "unavailable_evidence", reason: "local_revision_unreadable" }));
|
|
80
|
+
counts[finding.state]++;
|
|
81
|
+
const entry = report.files.find(entry => entry.path === file.file_path);
|
|
82
|
+
if (entry)
|
|
83
|
+
entry.reconciliation = finding;
|
|
84
|
+
}
|
|
85
|
+
report.reconciliation = { counts, inventory: inventoryCounts,
|
|
86
|
+
scan: { started_at: new Date(startedAt).toISOString(), finished_at: new Date().toISOString(),
|
|
87
|
+
roots: ["codex/sessions", "codex/archived_sessions", "claude/projects"], older_than_days: command.olderThanDays, opt_out_sources: optOutSources },
|
|
88
|
+
limitations: ["Snapshot scan, not a live completeness guarantee; absent local sources cannot be discovered.", "Missing historical source identifiers remain unverifiable.", "Pointer metadata does not prove bucket bytes are readable; no storage bodies fetched.", "explicit_opt_out counts sources, not undiscovered files; opted-out stores are not scanned."],
|
|
89
|
+
};
|
|
90
|
+
if (inventory.status !== "exhausted" || report.local.unreadable > 0 || counts.unavailable_evidence > 0) {
|
|
91
|
+
report.status = "incomplete";
|
|
92
|
+
report.reason = "reconciliation_partial";
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
if (report.local.unreadable > 0) {
|
|
96
|
+
report.status = "incomplete";
|
|
97
|
+
report.reason = "local_scan_incomplete";
|
|
98
|
+
}
|
|
41
99
|
reportSourcesCheck(report, remote, startedAt);
|
|
42
100
|
if (command.json)
|
|
43
101
|
writeLine(io.stdout, JSON.stringify(report, null, 2));
|
|
@@ -93,6 +151,7 @@ function reportSourcesCheck(report, remote, startedAt) {
|
|
|
93
151
|
stored_partial: report.counts.stored_partial,
|
|
94
152
|
not_stored: report.counts.not_stored,
|
|
95
153
|
reasons: report.reasons,
|
|
154
|
+
...(report.reconciliation ? { reconciliation: report.reconciliation.counts } : {}),
|
|
96
155
|
elapsed_ms: Date.now() - startedAt,
|
|
97
156
|
}));
|
|
98
157
|
}
|
|
@@ -135,6 +194,8 @@ export function sourcesCheckLines(report) {
|
|
|
135
194
|
` stored in part ${report.counts.stored_partial.files} file(s), ${mb(report.counts.stored_partial.bytes)} MB`,
|
|
136
195
|
` not stored ${report.counts.not_stored.files} file(s), ${mb(report.counts.not_stored.bytes)} MB`,
|
|
137
196
|
];
|
|
197
|
+
if (report.reconciliation)
|
|
198
|
+
lines.push(` reconciliation ${Object.entries(report.reconciliation.counts).map(([state, count]) => `${state} ${count}`).join(", ")}`, ` row inventory ${report.reconciliation.inventory.status}; ${report.reconciliation.inventory.unique} unique of ${report.reconciliation.inventory.returned} returned (${report.reconciliation.inventory.requested} requested)`, ` scan ${report.reconciliation.scan.started_at} to ${report.reconciliation.scan.finished_at}; roots ${report.reconciliation.scan.roots.join(", ")}; opted out ${report.reconciliation.scan.opt_out_sources.join(", ") || "none"}`, ...report.reconciliation.limitations.map(reason => ` limitation ${reason}`));
|
|
138
199
|
const reasons = Object.entries(report.reasons).sort((a, b) => b[1] - a[1]);
|
|
139
200
|
if (reasons.length > 0)
|
|
140
201
|
lines.push(` why ${reasons.map(([reason, count]) => `${reason} ${count}`).join(", ")}`);
|
|
@@ -144,7 +205,7 @@ export function sourcesCheckLines(report) {
|
|
|
144
205
|
lines.push(` unreadable ${report.local.unreadable} file(s) could not be read and are not answered`);
|
|
145
206
|
lines.push(` Tower's list ${report.remote.status}: ${report.remote.unique} current transcript object(s) over ${report.remote.pages} page(s)`);
|
|
146
207
|
if (report.status === "incomplete") {
|
|
147
|
-
lines.push(` INCOMPLETE
|
|
208
|
+
lines.push(` INCOMPLETE The scan or evidence list is incomplete (${report.reason}); missing evidence is inconclusive. Run it again.`);
|
|
148
209
|
}
|
|
149
210
|
lines.push("Tower never deletes these files. `--json` lists every file with its path and verdict. Only a \"stored in full\" file is safe to delete by your own hand (never \"stored slim\"); see docs/runbooks/freeing-disk-on-a-collector-machine.md.");
|
|
150
211
|
return lines;
|
|
@@ -153,7 +153,7 @@ function shapeOrder(shape) {
|
|
|
153
153
|
}
|
|
154
154
|
async function findTranscriptFiles(options, roots, result, recordFailure) {
|
|
155
155
|
const found = [];
|
|
156
|
-
const stack = [...roots.codex, roots.claudeProjects];
|
|
156
|
+
const stack = options.skipAll ? [] : [...roots.codex, ...(options.skipClaude ? [] : [roots.claudeProjects])];
|
|
157
157
|
const seen = new Set();
|
|
158
158
|
while (stack.length > 0) {
|
|
159
159
|
const dir = stack.pop();
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** Metadata reconciliation only. Never writes cursors, starts collection or fetches bodies. */
|
|
2
|
+
import fs from "node:fs/promises";
|
|
3
|
+
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
4
|
+
export async function fetchSourceInventory(options) {
|
|
5
|
+
const rows = new Map();
|
|
6
|
+
const result = { status: "capped", reason: "page_limit", requested: 0, returned: 0, unique: 0, pages: 0, rows: [] };
|
|
7
|
+
let after = null;
|
|
8
|
+
try {
|
|
9
|
+
for (let page = 0; page < (options.maxPages ?? 400); page++) {
|
|
10
|
+
result.requested += 500;
|
|
11
|
+
const response = await options.fetchImpl(`${options.dashboardUrl}/api/ambient/evidence/transcripts?inventory=1`, {
|
|
12
|
+
method: "POST", headers: { "Content-Type": "application/json", Authorization: `Bearer ${options.deviceToken}` },
|
|
13
|
+
body: JSON.stringify({ schema_version: "evidence-transcripts.v1", after, limit: 500 }),
|
|
14
|
+
signal: AbortSignal.timeout(30_000),
|
|
15
|
+
});
|
|
16
|
+
if (!response.ok)
|
|
17
|
+
throw new Error(`http_${response.status}`);
|
|
18
|
+
const body = await response.json();
|
|
19
|
+
if (body.schema_version !== "source-reconciliation.v1" || !Array.isArray(body.rows) || body.rows.length > 500 ||
|
|
20
|
+
!(body.next_after === null || (typeof body.next_after === "string" && uuid.test(body.next_after))))
|
|
21
|
+
throw new Error("invalid_response");
|
|
22
|
+
const valid = body.rows.every((row) => row && typeof row.id === "string" && uuid.test(row.id) &&
|
|
23
|
+
typeof row.source === "string" && (row.codex_session_id === null || typeof row.codex_session_id === "string") &&
|
|
24
|
+
(row.raw_evidence_pointer_id === null || (typeof row.raw_evidence_pointer_id === "string" && uuid.test(row.raw_evidence_pointer_id))));
|
|
25
|
+
if (!valid)
|
|
26
|
+
throw new Error("invalid_response");
|
|
27
|
+
const next = body.next_after;
|
|
28
|
+
if (next && (next <= (after ?? "") || body.rows.length === 0 || next !== body.rows.at(-1).id))
|
|
29
|
+
throw new Error("cursor_not_advancing");
|
|
30
|
+
result.pages++;
|
|
31
|
+
result.returned += body.rows.length;
|
|
32
|
+
for (const row of body.rows)
|
|
33
|
+
rows.set(row.id, row);
|
|
34
|
+
if (!next) {
|
|
35
|
+
result.status = "exhausted";
|
|
36
|
+
result.reason = "ok";
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
after = next;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
catch (error) {
|
|
43
|
+
result.status = result.pages ? "failed_partial" : "unavailable";
|
|
44
|
+
result.reason = error instanceof Error && /^(http_\d+|invalid_response|cursor_not_advancing)$/.test(error.message) ? error.message : "request_failed";
|
|
45
|
+
}
|
|
46
|
+
result.rows = [...rows.values()];
|
|
47
|
+
result.unique = rows.size;
|
|
48
|
+
console.error("[collector sources] row inventory", JSON.stringify({ reason: result.reason, status: result.status, requested: result.requested, returned: result.returned, unique: result.unique, pages: result.pages }));
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
export async function reconcileSource(input) {
|
|
52
|
+
const answer = (state, reason) => ({ state, reason });
|
|
53
|
+
const { file, inventory, remote, cursor, facts } = input;
|
|
54
|
+
if (file.session_id_source === "path_hash")
|
|
55
|
+
return answer("unverifiable", "source_identifier_missing");
|
|
56
|
+
const rows = inventory.rows.filter(row => row.source === file.source && row.codex_session_id === file.session_id);
|
|
57
|
+
if (!rows.length && inventory.rows.some(row => (!row.source || row.source === file.source) && !row.codex_session_id?.trim())) {
|
|
58
|
+
return answer("unverifiable", "historical_source_identifier_missing");
|
|
59
|
+
}
|
|
60
|
+
if (!rows.length)
|
|
61
|
+
return inventory.status === "exhausted"
|
|
62
|
+
? answer("missing_row", "source_row_absent") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
|
|
63
|
+
// Duplicate device rows are expected; any usable pointer can answer this file.
|
|
64
|
+
const pointers = new Set(rows.map(row => row.raw_evidence_pointer_id).filter(Boolean));
|
|
65
|
+
if (!pointers.size)
|
|
66
|
+
return inventory.status === "exhausted"
|
|
67
|
+
? answer("missing_pointer", "source_rows_without_pointer") : answer("unavailable_evidence", `row_inventory_${inventory.status}`);
|
|
68
|
+
const objects = (remote.files.get(file.key) ?? []).filter(object => !object.stub &&
|
|
69
|
+
(file.kind === "claude_subagent" || (object.ref_id && pointers.has(object.ref_id))));
|
|
70
|
+
if (!objects.length)
|
|
71
|
+
return answer("unavailable_evidence", remote.status === "exhausted" ? "pointer_not_in_current_transcripts_or_stub" : `transcripts_${remote.status}`);
|
|
72
|
+
const split = facts.slimSplit ? await facts.slimSplit() : null;
|
|
73
|
+
const form = split?.slim ?? (facts.slimAware && facts.wholeForm ? await facts.wholeForm() : await facts.sanitized());
|
|
74
|
+
const stat = await fs.stat(file.file_path);
|
|
75
|
+
if (stat.size !== file.byte_size || stat.mtimeMs !== file.mtime_ms)
|
|
76
|
+
return answer("unavailable_evidence", "local_file_changed_during_check");
|
|
77
|
+
if (form && objects.some(object => object.content_hash_sha256 === form.sha256 || object.content_hash_sha256 === split?.full.sha256))
|
|
78
|
+
return answer("revision_verified", "current_redacted_hash_matches_pointer");
|
|
79
|
+
// Sync refreshes the observed file hash even when growth damping retains an older
|
|
80
|
+
// durable upload. uploaded_byte_size is the raw source size at that upload.
|
|
81
|
+
if (form && objects.every(object => object.content_hash_sha256 !== null) && cursor?.uploaded_object_key &&
|
|
82
|
+
objects.some(object => object.object_key === cursor.uploaded_object_key || object.ref_id === cursor.uploaded_object_key) &&
|
|
83
|
+
cursor.uploaded_byte_size !== null && cursor.uploaded_byte_size < file.byte_size) {
|
|
84
|
+
return answer("stale_revision", "local_tail_newer_than_cursor_upload");
|
|
85
|
+
}
|
|
86
|
+
return answer("unverifiable", form ? "revision_hash_mismatch_or_legacy_hash_missing" : "redacted_revision_unavailable");
|
|
87
|
+
}
|
|
@@ -78,6 +78,7 @@ function addTranscriptObject(object, files, state) {
|
|
|
78
78
|
const key = transcriptFileKey(identity);
|
|
79
79
|
const list = files.get(key) ?? [];
|
|
80
80
|
list.push({
|
|
81
|
+
ref_id: object.ref_id,
|
|
81
82
|
object_key: object.object_key,
|
|
82
83
|
byte_size: object.byte_size,
|
|
83
84
|
content_hash_sha256: object.content_hash_sha256,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bli-cockpit/cli",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.244",
|
|
4
4
|
"private": false,
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,9 +27,9 @@
|
|
|
27
27
|
"test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"@bli-cockpit/memory-mcp": "0.1.
|
|
31
|
-
"@bli-cockpit/mcp": "0.1.
|
|
32
|
-
"@bli-cockpit/telemetry-core": "0.1.
|
|
30
|
+
"@bli-cockpit/memory-mcp": "0.1.72",
|
|
31
|
+
"@bli-cockpit/mcp": "0.1.149",
|
|
32
|
+
"@bli-cockpit/telemetry-core": "0.1.79"
|
|
33
33
|
},
|
|
34
34
|
"repository": {
|
|
35
35
|
"type": "git",
|