@bli-cockpit/cli 0.2.243 → 0.2.245
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/issue-contracts.js +65 -4
- package/dist/commands/issue-list-page.js +34 -6
- package/dist/commands/issue-page-args.js +64 -0
- package/dist/commands/issue-relations.js +3 -2
- package/dist/commands/issue-show.js +13 -2
- package/dist/commands/issue-versions.js +5 -8
- package/dist/commands/issue-write.js +71 -16
- package/dist/commands/issue.js +22 -3
- package/dist/commands/local-args-collector-sources.js +2 -1
- package/dist/commands/local-args-tower-milestone.js +120 -0
- package/dist/commands/local-args-tower-work.js +44 -51
- package/dist/commands/local-args-tower.js +1 -0
- package/dist/commands/local-args.js +2 -0
- package/dist/commands/local-command-shapes-milestone.js +1 -0
- package/dist/commands/local-help-commands-tower-ops.js +4 -4
- package/dist/commands/local-help-commands-tower.js +6 -3
- package/dist/commands/local-help-commands.js +3 -1
- package/dist/commands/local-help-milestone.js +23 -0
- package/dist/commands/local-help-project.js +1 -0
- package/dist/commands/local-help.js +3 -1
- package/dist/commands/local.js +2 -0
- 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/milestone.js +157 -0
- package/dist/commands/project-args.js +78 -2
- package/dist/commands/project.js +52 -0
- 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";
|
|
@@ -119,11 +119,18 @@ export async function readBody(command, io) {
|
|
|
119
119
|
return { ok: false, reason: "description_too_long", detail: error instanceof Error ? error.message : String(error) };
|
|
120
120
|
}
|
|
121
121
|
}
|
|
122
|
-
/**
|
|
122
|
+
/**
|
|
123
|
+
* The project/parent halves both `create` and `update` need, resolved once. Three answers per field: `undefined`
|
|
124
|
+
* (not asked to change), `null` (`none`: clear it, update only; the parser refuses it elsewhere, BLI-22367), or a
|
|
125
|
+
* uuid. A project or ticket literally named "none" is reached by its uuid.
|
|
126
|
+
*/
|
|
123
127
|
export async function resolveRefs(command, door) {
|
|
124
128
|
let projectId;
|
|
125
129
|
let parentId;
|
|
126
|
-
if (command.project) {
|
|
130
|
+
if (command.project && command.project.toLowerCase() === "none") {
|
|
131
|
+
projectId = null;
|
|
132
|
+
}
|
|
133
|
+
else if (command.project) {
|
|
127
134
|
const resolved = await resolveProjectId(door, command.project);
|
|
128
135
|
if (resolved.status === "list_failed")
|
|
129
136
|
return { ok: false, reason: resolved.reason, detail: resolved.detail };
|
|
@@ -136,11 +143,65 @@ export async function resolveRefs(command, door) {
|
|
|
136
143
|
}
|
|
137
144
|
projectId = resolved.id;
|
|
138
145
|
}
|
|
139
|
-
if (command.parentRef) {
|
|
146
|
+
if (command.parentRef && command.parentRef.toLowerCase() === "none") {
|
|
147
|
+
parentId = null;
|
|
148
|
+
}
|
|
149
|
+
else if (command.parentRef) {
|
|
140
150
|
const resolved = await resolveIssueUuid(door, command.parentRef);
|
|
141
151
|
if (resolved.status === "failed")
|
|
142
152
|
return { ok: false, reason: resolved.reason, detail: resolved.detail };
|
|
143
153
|
parentId = resolved.id;
|
|
144
154
|
}
|
|
145
|
-
return { ok: true, ...(projectId ? { projectId } : {}), ...(parentId ? { parentId } : {}) };
|
|
155
|
+
return { ok: true, ...(projectId !== undefined ? { projectId } : {}), ...(parentId !== undefined ? { parentId } : {}) };
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* The receipt for a write the door REFUSED. The door's own receipt wins. Without one, a 4xx answer that names its
|
|
159
|
+
* reason is a known refusal before anything was written: `not_committed`, never "check again" (BLI-22367; the
|
|
160
|
+
* self-parent and unknown-label refusals used to say `unknown_check_again` forever). Only a lost answer, a
|
|
161
|
+
* `mutation_unknown`, a 5xx, or a status with no reason label stays `unknown_check_again`.
|
|
162
|
+
*/
|
|
163
|
+
export function receiptAfterRefusal(answer, requestId) {
|
|
164
|
+
const sent = answer.body?.mutation;
|
|
165
|
+
if (sent && typeof sent === "object" && sent.outcome)
|
|
166
|
+
return sent;
|
|
167
|
+
const status = answer.httpStatus;
|
|
168
|
+
const known = typeof status === "number" && status >= 400 && status < 500
|
|
169
|
+
&& answer.reason !== "mutation_unknown" && !/^http_\d+$/.test(answer.reason);
|
|
170
|
+
return known
|
|
171
|
+
? { request_id: requestId, outcome: "not_committed", reason: answer.reason }
|
|
172
|
+
: { request_id: requestId, outcome: "unknown_check_again" };
|
|
173
|
+
}
|
|
174
|
+
const DELIVERY_WORDS = {
|
|
175
|
+
pending: "queued, not delivered yet",
|
|
176
|
+
delivered: "delivered",
|
|
177
|
+
failed: "failed; the Tower save stands",
|
|
178
|
+
not_mirrored: "not mirrored",
|
|
179
|
+
tower_only: "not mirrored (Tower-only)",
|
|
180
|
+
unknown_check_again: "status unknown; check cockpit ticket mutation-status",
|
|
181
|
+
};
|
|
182
|
+
/**
|
|
183
|
+
* One sentence a person reads after a committed write: the Tower save first, the Linear copy second, so a pending
|
|
184
|
+
* or failed delivery never reads as a failed save (contract C1).
|
|
185
|
+
*/
|
|
186
|
+
export function savedLine(receipt) {
|
|
187
|
+
if (!receipt || receipt.outcome !== "committed")
|
|
188
|
+
return null;
|
|
189
|
+
const revision = receipt.revision === undefined ? "" : ` (revision ${receipt.revision})`;
|
|
190
|
+
if (receipt.reason === "unchanged")
|
|
191
|
+
return `Saved in Tower${revision}: nothing needed to change.`;
|
|
192
|
+
const status = receipt.delivery?.status;
|
|
193
|
+
if (!status)
|
|
194
|
+
return `Saved in Tower${revision}.`;
|
|
195
|
+
const words = DELIVERY_WORDS[status] ?? status;
|
|
196
|
+
const why = receipt.delivery?.reason ? ` (${receipt.delivery.reason})` : "";
|
|
197
|
+
return `Saved in Tower${revision}. Linear copy: ${words}${why}.`;
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The canonical ticket link (contract C3): the door's own `canonical_url` when it sent one, else
|
|
201
|
+
* `<configured dashboard url>/tickets/<IDENTIFIER>`. Never a Linear link.
|
|
202
|
+
*/
|
|
203
|
+
export function canonicalTicketUrl(dashboardUrl, identifier, sent) {
|
|
204
|
+
if (typeof sent === "string" && /^https?:\/\//.test(sent))
|
|
205
|
+
return sent;
|
|
206
|
+
return `${dashboardUrl.replace(/\/+$/, "")}/tickets/${encodeURIComponent(identifier)}`;
|
|
146
207
|
}
|
|
@@ -10,8 +10,23 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { askAgentDoor } from "./agent-door.js";
|
|
12
12
|
import { writeLine } from "./cli-io.js";
|
|
13
|
-
import {
|
|
13
|
+
import { TAG, withSizeLabel } from "./issue-contracts.js";
|
|
14
14
|
const ALL_PAGE_SIZE = 500;
|
|
15
|
+
/**
|
|
16
|
+
* `--all` page deadline and bounded retry (BLI-22367). A live export timed out on page 5 of 16 (`turn_timed_out`
|
|
17
|
+
* at the 30 s read deadline) while the database answered every `work_issue_page` call in at most 288 ms
|
|
18
|
+
* (pg_stat_statements, 2026-10-19): the stall was transport or the serverless function, not the query. A 500-row
|
|
19
|
+
* page gets a longer deadline than a 100-row read, and a page that times out, cannot reach Tower or gets a 5xx is
|
|
20
|
+
* asked again FROM THE SAME CURSOR, at most twice, before the walk stops honestly with `failed_partial` and the
|
|
21
|
+
* resume cursor. A 4xx (an invalid cursor or filter) is never retried. Every retry is counted on the coverage line.
|
|
22
|
+
*/
|
|
23
|
+
export const ALL_PAGE_DEADLINE_MS = 60_000;
|
|
24
|
+
export const allWalkRetry = { delaysMs: [1_000, 3_000] };
|
|
25
|
+
function retryablePageFailure(answer) {
|
|
26
|
+
if (answer.httpStatus !== undefined)
|
|
27
|
+
return answer.httpStatus >= 500;
|
|
28
|
+
return answer.reason === "turn_timed_out" || answer.reason === "gateway_unreachable";
|
|
29
|
+
}
|
|
15
30
|
/** The additive page fields as `--json` output, only the ones the door sent. */
|
|
16
31
|
export function pageFieldsOf(body) {
|
|
17
32
|
const fields = {};
|
|
@@ -61,20 +76,33 @@ export async function streamAllIssues(command, door, base) {
|
|
|
61
76
|
let pages = 0;
|
|
62
77
|
let expected = null;
|
|
63
78
|
let cursor = null;
|
|
79
|
+
let retries = 0;
|
|
64
80
|
for (;;) {
|
|
65
81
|
if (cursor)
|
|
66
82
|
query.set("cursor", cursor);
|
|
67
|
-
|
|
83
|
+
let answer = await askAgentDoor(door, {
|
|
68
84
|
path: `/api/work/issues?${query.toString()}`,
|
|
69
85
|
method: "GET",
|
|
70
86
|
label: "issue list",
|
|
71
|
-
timeoutMs:
|
|
87
|
+
timeoutMs: ALL_PAGE_DEADLINE_MS,
|
|
72
88
|
});
|
|
89
|
+
for (let attempt = 0; !answer.ok && retryablePageFailure(answer) && attempt < allWalkRetry.delaysMs.length; attempt += 1) {
|
|
90
|
+
retries += 1;
|
|
91
|
+
console.error(`${TAG} page retry`, JSON.stringify({ page: pages + 1, attempt: attempt + 2, reason: answer.reason, http_status: answer.httpStatus ?? null }));
|
|
92
|
+
await new Promise((resolve) => setTimeout(resolve, allWalkRetry.delaysMs[attempt]));
|
|
93
|
+
answer = await askAgentDoor(door, {
|
|
94
|
+
path: `/api/work/issues?${query.toString()}`,
|
|
95
|
+
method: "GET",
|
|
96
|
+
label: "issue list",
|
|
97
|
+
timeoutMs: ALL_PAGE_DEADLINE_MS,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
73
100
|
if (!answer.ok) {
|
|
74
|
-
console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned }));
|
|
101
|
+
console.error(`${TAG} walk interrupted`, JSON.stringify({ reason: answer.reason, pages, returned, retries }));
|
|
75
102
|
writeLine(door.io.stdout, JSON.stringify({
|
|
76
103
|
type: "coverage", coverage: "failed_partial", reason: answer.reason, detail: answer.detail,
|
|
77
|
-
pages, returned, unique: seen.size, expected, next_cursor: cursor,
|
|
104
|
+
pages, requested: expected, returned, unique: seen.size, expected, retries, next_cursor: cursor,
|
|
105
|
+
resume: cursor ? `cockpit ticket list --cursor ${cursor} --limit ${query.get("limit")} --json (same filters), then continue page by page` : "start again; the first page failed",
|
|
78
106
|
}));
|
|
79
107
|
return 1;
|
|
80
108
|
}
|
|
@@ -94,7 +122,7 @@ export async function streamAllIssues(command, door, base) {
|
|
|
94
122
|
}
|
|
95
123
|
const exact = expected === null || expected === returned;
|
|
96
124
|
writeLine(door.io.stdout, JSON.stringify({
|
|
97
|
-
type: "coverage", coverage: "complete", pages, returned, unique: seen.size, expected,
|
|
125
|
+
type: "coverage", coverage: "complete", pages, requested: expected, returned, unique: seen.size, expected, retries,
|
|
98
126
|
...(returned === seen.size && exact ? {} : { note: "returned, unique and expected do not all agree; rows changed during the walk" }),
|
|
99
127
|
}));
|
|
100
128
|
return 0;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit ticket list` page and filter flags (BLI-21315) and `--archived` (BLI-22367), split out of
|
|
3
|
+
* `local-args-tower-work.ts` so that parser stays within its size band. Pure argument checks: no I/O.
|
|
4
|
+
*/
|
|
5
|
+
import { optionalNonEmpty } from "./local-arg-values.js";
|
|
6
|
+
/**
|
|
7
|
+
* `list --archived` (BLI-22367): archived tickets only, most recently archived first. The archived page takes
|
|
8
|
+
* --q, --project, --limit, --cursor and --all; any other filter is refused here rather than silently ignored.
|
|
9
|
+
*/
|
|
10
|
+
export function readArchivedFlag(action, values) {
|
|
11
|
+
if (!values.booleans.has("--archived"))
|
|
12
|
+
return false;
|
|
13
|
+
if (action !== "list")
|
|
14
|
+
throw new Error("--archived belongs to `cockpit ticket list` (archived tickets only).");
|
|
15
|
+
const unsupported = ["--state", "--assignee", "--label", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"]
|
|
16
|
+
.filter((flag) => values.flags.has(flag));
|
|
17
|
+
if (unsupported.length > 0)
|
|
18
|
+
throw new Error(`list --archived takes --q, --project, --limit, --cursor and --all; not ${unsupported.join(", ")}.`);
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
21
|
+
const INSTANT = /^\d{4}-\d\d-\d\d(?:T\d\d:\d\d:\d\d(?:\.\d{1,6})?(?:Z|[+-]\d\d:\d\d))?$/;
|
|
22
|
+
/** BLI-21315: the cursor contract's flags. Filters and sort belong to `list`; `--cursor` also to `history`. */
|
|
23
|
+
export function readIssuePage(action, values) {
|
|
24
|
+
const named = ["--q", "--created-after", "--created-before", "--updated-after", "--updated-before", "--sort", "--dir"];
|
|
25
|
+
for (const flag of named) {
|
|
26
|
+
if (values.flags.has(flag) && action !== "list")
|
|
27
|
+
throw new Error(`${flag} belongs to \`cockpit ticket list\`.`);
|
|
28
|
+
}
|
|
29
|
+
if (values.booleans.has("--all") && action !== "list")
|
|
30
|
+
throw new Error("--all belongs to `cockpit ticket list`.");
|
|
31
|
+
if (values.flags.has("--cursor") && action !== "list" && action !== "history") {
|
|
32
|
+
throw new Error("--cursor belongs to `cockpit ticket list` and `cockpit ticket history`.");
|
|
33
|
+
}
|
|
34
|
+
const instants = {};
|
|
35
|
+
for (const flag of ["--created-after", "--created-before", "--updated-after", "--updated-before"]) {
|
|
36
|
+
const value = optionalNonEmpty(values.flags.get(flag));
|
|
37
|
+
if (value !== undefined && (!INSTANT.test(value) || !Number.isFinite(Date.parse(value)))) {
|
|
38
|
+
throw new Error(`${flag} must be a UTC instant like 2026-10-01T00:00:00Z or a date like 2026-10-01.`);
|
|
39
|
+
}
|
|
40
|
+
instants[flag] = value;
|
|
41
|
+
}
|
|
42
|
+
const sort = optionalNonEmpty(values.flags.get("--sort"));
|
|
43
|
+
if (sort !== undefined && sort !== "updated_at" && sort !== "created_at")
|
|
44
|
+
throw new Error("--sort must be updated_at or created_at.");
|
|
45
|
+
const dir = optionalNonEmpty(values.flags.get("--dir"));
|
|
46
|
+
if (dir !== undefined && dir !== "asc" && dir !== "desc")
|
|
47
|
+
throw new Error("--dir must be asc or desc.");
|
|
48
|
+
const all = values.booleans.has("--all");
|
|
49
|
+
const cursor = optionalNonEmpty(values.flags.get("--cursor"));
|
|
50
|
+
if (all && cursor)
|
|
51
|
+
throw new Error("--all walks from the start; drop --cursor, or pass --cursor without --all to read one page.");
|
|
52
|
+
const q = optionalNonEmpty(values.flags.get("--q"));
|
|
53
|
+
return {
|
|
54
|
+
...(cursor ? { cursor } : {}),
|
|
55
|
+
...(q ? { q } : {}),
|
|
56
|
+
...(instants["--created-after"] ? { createdAfter: instants["--created-after"] } : {}),
|
|
57
|
+
...(instants["--created-before"] ? { createdBefore: instants["--created-before"] } : {}),
|
|
58
|
+
...(instants["--updated-after"] ? { updatedAfter: instants["--updated-after"] } : {}),
|
|
59
|
+
...(instants["--updated-before"] ? { updatedBefore: instants["--updated-before"] } : {}),
|
|
60
|
+
...(sort ? { sort } : {}),
|
|
61
|
+
...(dir ? { dir } : {}),
|
|
62
|
+
...(all ? { all } : {}),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
@@ -9,7 +9,7 @@ import { randomUUID } from "node:crypto";
|
|
|
9
9
|
*/
|
|
10
10
|
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
11
11
|
import { writeLine } from "./cli-io.js";
|
|
12
|
-
import { askTicketWrite, refuseWrite } from "./issue-write.js";
|
|
12
|
+
import { askTicketWrite, printSaved, refuseWrite, reportCommit } from "./issue-write.js";
|
|
13
13
|
import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
|
|
14
14
|
export const RELATION_VALUE_FLAGS = ["--blocks", "--blocked-by", "--related", "--duplicate-of", "--relation"];
|
|
15
15
|
const KIND_FLAGS = [
|
|
@@ -120,10 +120,11 @@ export async function unrelateIssue(command, door) {
|
|
|
120
120
|
}
|
|
121
121
|
function finishWrite(door, body, requestId, sentence, tag) {
|
|
122
122
|
writeLine(door.io.stderr, `${TAG} ${tag} ${JSON.stringify({ relation_id: body.relation_id ?? null })}`);
|
|
123
|
-
|
|
123
|
+
reportCommit(door, body.mutation, requestId);
|
|
124
124
|
if (door.json)
|
|
125
125
|
return emitAgentDoor(door, { ok: true, issue: body.issue, relation_id: body.relation_id, mutation: body.mutation });
|
|
126
126
|
writeLine(door.io.stdout, sentence);
|
|
127
|
+
printSaved(door, body.mutation);
|
|
127
128
|
return 0;
|
|
128
129
|
}
|
|
129
130
|
/** `unrelate <id> --blocks B`: the edge id, found through the same read `relations` prints. */
|
|
@@ -15,7 +15,7 @@ import { mirroredTicketIdInTitle } from "@bli-cockpit/telemetry-core";
|
|
|
15
15
|
import { askAgentDoor, emitAgentDoor, failAgentDoor, } from "./agent-door.js";
|
|
16
16
|
import { writeLine } from "./cli-io.js";
|
|
17
17
|
import { assigneeLabel, resolveAssigneeDirectory, withAssigneeIdentity } from "./issue-directory.js";
|
|
18
|
-
import { READ_DEADLINE_MS, TAG, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
|
|
18
|
+
import { READ_DEADLINE_MS, TAG, canonicalTicketUrl, sizeForEstimate, withSizeLabel, } from "./issue-contracts.js";
|
|
19
19
|
async function fetchOneIssue(door, ref) {
|
|
20
20
|
const answer = await askAgentDoor(door, {
|
|
21
21
|
path: `/api/work/issues/${encodeURIComponent(ref)}`,
|
|
@@ -25,7 +25,8 @@ async function fetchOneIssue(door, ref) {
|
|
|
25
25
|
});
|
|
26
26
|
if (!answer.ok)
|
|
27
27
|
return { ref, ok: false, reason: answer.reason, detail: answer.detail };
|
|
28
|
-
const
|
|
28
|
+
const detailBody = answer.body;
|
|
29
|
+
const issue = detailBody.issue;
|
|
29
30
|
if (!issue) {
|
|
30
31
|
return {
|
|
31
32
|
ref,
|
|
@@ -54,6 +55,8 @@ async function fetchOneIssue(door, ref) {
|
|
|
54
55
|
ok: true,
|
|
55
56
|
issue,
|
|
56
57
|
comments,
|
|
58
|
+
canonicalUrl: canonicalTicketUrl(door.dashboardUrl, issue.identifier, detailBody.canonical_url),
|
|
59
|
+
...(detailBody.resolved_from ? { resolvedFrom: detailBody.resolved_from } : {}),
|
|
57
60
|
...(linearTicketId ? { linearTicketId } : {}),
|
|
58
61
|
...(commentsAnswer.ok ? {} : { commentsUnreadReason: commentsAnswer.reason }),
|
|
59
62
|
};
|
|
@@ -64,6 +67,12 @@ function renderOneIssue(door, result, directory) {
|
|
|
64
67
|
const size = sizeForEstimate(issue.estimate ?? null);
|
|
65
68
|
writeLine(door.io.stdout, `${issue.state} · priority ${issue.priority ?? 0}${size ? ` · size ${size}` : ""} · assignee ${assigneeLabel(issue.assignee_id, directory)} · updated ${issue.updated_at}`);
|
|
66
69
|
writeLine(door.io.stdout, `id ${issue.id}${issue.parent_id ? ` · parent ${issue.parent_id}` : ""}`);
|
|
70
|
+
const archivedAt = issue.archived_at;
|
|
71
|
+
if (archivedAt)
|
|
72
|
+
writeLine(door.io.stdout, `ARCHIVED ${archivedAt} — hidden from lists and the board; \`cockpit ticket unarchive ${issue.identifier}\` brings it back.`);
|
|
73
|
+
writeLine(door.io.stdout, `Link: ${result.canonicalUrl}`);
|
|
74
|
+
if (result.resolvedFrom)
|
|
75
|
+
writeLine(door.io.stdout, `(found through the old Linear id ${result.resolvedFrom.ref})`);
|
|
67
76
|
if (linearTicketId)
|
|
68
77
|
writeLine(door.io.stdout, `Linear: ${linearTicketId}`);
|
|
69
78
|
writeLine(door.io.stdout, "");
|
|
@@ -100,6 +109,8 @@ function readIssueJson(result, directory) {
|
|
|
100
109
|
return {
|
|
101
110
|
issue: withSizeLabel(withAssigneeIdentity(result.issue, directory)),
|
|
102
111
|
comments: result.comments,
|
|
112
|
+
canonical_url: result.canonicalUrl,
|
|
113
|
+
...(result.resolvedFrom ? { resolved_from: result.resolvedFrom } : {}),
|
|
103
114
|
...(result.linearTicketId ? { linear_ticket_id: result.linearTicketId } : {}),
|
|
104
115
|
...(result.commentsUnreadReason ? { comments_unread_reason: result.commentsUnreadReason } : {}),
|
|
105
116
|
};
|
|
@@ -12,6 +12,7 @@ import { randomUUID } from "node:crypto";
|
|
|
12
12
|
*/
|
|
13
13
|
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
14
14
|
import { writeLine } from "./cli-io.js";
|
|
15
|
+
import { printSaved, refuseWrite, reportCommit } from "./issue-write.js";
|
|
15
16
|
import { READ_DEADLINE_MS, TAG, WRITE_DEADLINE_MS } from "./issue-contracts.js";
|
|
16
17
|
export async function listIssueVersions(command, door) {
|
|
17
18
|
const ref = command.issueRef ?? "";
|
|
@@ -75,19 +76,15 @@ export async function restoreIssueVersion(command, door) {
|
|
|
75
76
|
writeLine(door.io.stderr, `${TAG} check local commit with cockpit ticket mutation-status ${requestId}; retry unchanged input with --request-id ${requestId}.`);
|
|
76
77
|
return failAgentDoor(door, TAG, "mutation_unknown", "Result unknown. Check this request ID before retrying with identical input.");
|
|
77
78
|
}
|
|
78
|
-
if (!answer.ok)
|
|
79
|
-
|
|
80
|
-
const mutation = answer.body?.mutation ?? { request_id: requestId, outcome: "unknown_check_again" };
|
|
81
|
-
if (door.json)
|
|
82
|
-
return emitAgentDoor(door, { ok: false, error: answer.reason, detail: answer.detail, mutation }, 1);
|
|
83
|
-
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
84
|
-
}
|
|
79
|
+
if (!answer.ok)
|
|
80
|
+
return refuseWrite(door, answer, requestId);
|
|
85
81
|
const body = answer.body;
|
|
86
82
|
const newVersion = body.mutation?.description_version;
|
|
87
83
|
writeLine(door.io.stderr, `${TAG} restored ${JSON.stringify({ restored_version: chosen.version, new_version: newVersion ?? null })}`);
|
|
88
|
-
|
|
84
|
+
reportCommit(door, body.mutation, requestId);
|
|
89
85
|
if (door.json)
|
|
90
86
|
return emitAgentDoor(door, { ok: true, issue: body.issue, mutation: body.mutation });
|
|
91
87
|
writeLine(door.io.stdout, `Restored version ${chosen.version} of ${ref} as new version ${newVersion ?? "?"}.`);
|
|
88
|
+
printSaved(door, body.mutation);
|
|
92
89
|
return 0;
|
|
93
90
|
}
|