@bli-cockpit/cli 0.2.242 → 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/git-state.js +1 -0
- package/dist/adapters/raw-evidence-budget-admission.js +63 -0
- package/dist/adapters/raw-evidence-collection-state.js +6 -50
- package/dist/adapters/raw-evidence-git-diff.js +1 -0
- package/dist/agent-rules.js +8 -0
- package/dist/autostart-contract.js +12 -0
- package/dist/autostart-darwin-plist.js +2 -2
- package/dist/autostart-darwin.js +1 -1
- package/dist/autostart-remind.js +245 -0
- package/dist/autostart-self-heal.js +17 -1
- package/dist/autostart-windows-scripts.js +7 -7
- package/dist/autostart.js +16 -15
- package/dist/commands/approvals-check-where.js +14 -0
- package/dist/commands/approvals-render.js +8 -1
- package/dist/commands/approvals.js +3 -2
- package/dist/commands/autostart-command.js +10 -0
- package/dist/commands/autostart-heal.js +36 -1
- package/dist/commands/brain.js +1 -1
- package/dist/commands/cal-agenda.js +44 -0
- package/dist/commands/cal-availability-share.js +97 -0
- package/dist/commands/cal-booking.js +147 -0
- package/dist/commands/cal-join.js +62 -0
- package/dist/commands/cal-links.js +68 -0
- package/dist/commands/cal-people.js +136 -0
- package/dist/commands/cal-remind.js +176 -0
- package/dist/commands/cal-reminders.js +33 -0
- package/dist/commands/cal-suggest.js +89 -0
- package/dist/commands/cal.js +41 -1
- package/dist/commands/cycle-args.js +127 -0
- package/dist/commands/cycles.js +117 -0
- package/dist/commands/docs-comments-args.js +85 -0
- package/dist/commands/docs-comments.js +128 -0
- package/dist/commands/docs-files-args.js +49 -0
- package/dist/commands/docs-files.js +179 -0
- package/dist/commands/docs-formatting.js +45 -0
- package/dist/commands/docs-inbox-args.js +93 -0
- package/dist/commands/docs-inbox.js +126 -0
- package/dist/commands/docs-notion.js +5 -0
- package/dist/commands/docs-read.js +2 -1
- package/dist/commands/docs-recovery.js +173 -0
- package/dist/commands/docs-refs-args.js +104 -0
- package/dist/commands/docs-refs.js +153 -0
- package/dist/commands/docs-search-args.js +79 -0
- package/dist/commands/docs-search.js +64 -0
- package/dist/commands/docs-sync-args.js +82 -0
- package/dist/commands/docs-sync.js +197 -0
- package/dist/commands/docs-tree.js +136 -0
- package/dist/commands/docs.js +85 -51
- package/dist/commands/doctor-update.js +4 -1
- package/dist/commands/inbox-args.js +81 -0
- package/dist/commands/inbox.js +88 -0
- package/dist/commands/issue-batch.js +61 -0
- package/dist/commands/issue-relations.js +151 -0
- package/dist/commands/issue-write.js +38 -5
- package/dist/commands/issue.js +32 -1
- package/dist/commands/local-args-collector-sources.js +2 -1
- package/dist/commands/local-args-tower-approvals.js +16 -4
- package/dist/commands/local-args-tower-cal-booking.js +81 -0
- package/dist/commands/local-args-tower-cal-links.js +42 -0
- package/dist/commands/local-args-tower-cal-remind.js +74 -0
- package/dist/commands/local-args-tower-cal-share.js +41 -0
- package/dist/commands/local-args-tower-cal.js +81 -8
- package/dist/commands/local-args-tower-docs-msg.js +98 -9
- package/dist/commands/local-args-tower-msg.js +222 -10
- package/dist/commands/local-args-tower-search.js +32 -5
- package/dist/commands/local-args-tower-work.js +110 -34
- package/dist/commands/local-auth.js +1 -1
- package/dist/commands/local-help-commands-tower-ops.js +6 -4
- package/dist/commands/local-help-commands-tower.js +47 -31
- package/dist/commands/local-help-commands.js +3 -1
- package/dist/commands/local-help-cycles.js +9 -0
- package/dist/commands/local-help-docs-files.js +10 -0
- package/dist/commands/local-help-docs-formatting.js +7 -0
- package/dist/commands/local-help-docs-inbox.js +10 -0
- package/dist/commands/local-help-docs-recovery.js +10 -0
- package/dist/commands/local-help-docs-refs.js +11 -0
- package/dist/commands/local-help-docs-sync.js +9 -0
- package/dist/commands/local-help-inbox.js +9 -0
- package/dist/commands/local-help-project.js +21 -0
- package/dist/commands/local-help-templates.js +9 -0
- package/dist/commands/local-help-views.js +10 -0
- package/dist/commands/local-help-work-notify.js +6 -0
- package/dist/commands/local-help.js +3 -3
- 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/mcp-stdio-probe.js +1 -0
- package/dist/commands/msg-activity.js +134 -0
- package/dist/commands/msg-channel-args.js +92 -0
- package/dist/commands/msg-channel.js +125 -0
- package/dist/commands/msg-context-args.js +67 -0
- package/dist/commands/msg-context.js +65 -0
- package/dist/commands/msg-dm.js +28 -0
- package/dist/commands/msg-file.js +185 -0
- package/dist/commands/msg-mark-args.js +78 -0
- package/dist/commands/msg-mark.js +148 -0
- package/dist/commands/msg-notify-args.js +100 -0
- package/dist/commands/msg-notify.js +122 -0
- package/dist/commands/msg-schedule-args.js +114 -0
- package/dist/commands/msg-schedule.js +226 -0
- package/dist/commands/msg-search-args.js +71 -0
- package/dist/commands/msg-search.js +48 -0
- package/dist/commands/msg-write.js +15 -1
- package/dist/commands/msg.js +62 -39
- package/dist/commands/project-args.js +82 -0
- package/dist/commands/project.js +71 -7
- package/dist/commands/public-root.js +1 -1
- package/dist/commands/search.js +23 -0
- package/dist/commands/session-sync-failures.js +17 -4
- package/dist/commands/session-sync-health.js +7 -2
- package/dist/commands/session-sync-hold.js +42 -1
- package/dist/commands/session-sync-outside-roots.js +72 -14
- package/dist/commands/session-sync-upload.js +2 -0
- package/dist/commands/session-sync.js +1 -0
- 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/commands/sync-doctor.js +14 -1
- package/dist/commands/sync-report.js +1 -1
- package/dist/commands/template-args.js +122 -0
- package/dist/commands/templates.js +162 -0
- package/dist/commands/view-args.js +131 -0
- package/dist/commands/views.js +138 -0
- package/dist/commands/work-notify-args.js +67 -0
- package/dist/commands/work-notify.js +63 -0
- package/dist/delivery-hold-notice.js +34 -0
- package/dist/evidence-transcripts-client.js +1 -0
- package/dist/log-rotation.js +2 -2
- package/dist/notifier.js +83 -0
- package/dist/process-runner.js +4 -0
- package/dist/repo-identity-git.js +1 -0
- package/package.json +4 -4
|
@@ -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
|
+
}
|
|
@@ -14,6 +14,7 @@ import { doctorNeedsPerson, sanitizeEventCode } from "./doctor-report.js";
|
|
|
14
14
|
import { runSelfUpdate } from "./install-update.js";
|
|
15
15
|
import { reportInstallEventsBestEffort } from "./install-receipts.js";
|
|
16
16
|
import { clearDoctorRequest, requestedDoctorId } from "./sync-doctor-request.js";
|
|
17
|
+
import { envWithNodeRuntimeOnPath } from "../scheduled-self-update.js";
|
|
17
18
|
export async function runDoctorAfterSync(command, io, dashboardUrl, overrides = {}, facts) {
|
|
18
19
|
if (shouldSuppressFleetReceipts({ dashboardUrl }).suppressed) {
|
|
19
20
|
console.error("[sync-doctor] checkout does not repair the fleet machine", JSON.stringify({ reason: "receipt_suppressed:dev_build" }));
|
|
@@ -69,11 +70,23 @@ export async function runDoctorAfterSync(command, io, dashboardUrl, overrides =
|
|
|
69
70
|
await lock.handle.release();
|
|
70
71
|
}
|
|
71
72
|
}
|
|
73
|
+
/**
|
|
74
|
+
* Ticket 21952. launchd hands the tick `PATH=/usr/bin:/bin:/usr/sbin:/sbin`, where neither `node` nor `npm` lives, so
|
|
75
|
+
* the tick doctor's `npm view` and `npm install` could not start on any Mac: every macOS receipt read
|
|
76
|
+
* `latest_version_unknown -> fail:npm_install_failed` while already on the newest CLI, and Windows (whose scheduler
|
|
77
|
+
* keeps the user's PATH) read `already_latest`. The scheduled self-update already runs npm with the running node's bin
|
|
78
|
+
* dir on PATH (`envWithNodeRuntimeOnPath`); the tick doctor now does the same. A caller's explicit env still wins.
|
|
79
|
+
*/
|
|
80
|
+
export function withNodeRuntimeOnPath(io) {
|
|
81
|
+
const env = envWithNodeRuntimeOnPath(io.env ?? process.env);
|
|
82
|
+
const wrap = (runner) => runner ? (cmd, args, options) => runner(cmd, args, { ...options, env: options?.env ?? env }) : undefined;
|
|
83
|
+
return { exec: wrap(io.exec), interactiveExec: wrap(io.interactiveExec) };
|
|
84
|
+
}
|
|
72
85
|
async function collectDoctorReceipt(command, io, dashboardUrl, overrides, facts, paths, requestId) {
|
|
73
86
|
const session = await readLocalCollectorSessionFile(paths).catch(() => null);
|
|
74
87
|
let receipt = null;
|
|
75
88
|
const quiet = new Writable({ write(_chunk, _encoding, done) { done(); } });
|
|
76
|
-
const doctorIo = { ...io, stdout: quiet };
|
|
89
|
+
const doctorIo = { ...io, stdout: quiet, ...withNodeRuntimeOnPath(io) };
|
|
77
90
|
await runDoctor({ kind: "doctor", alias: "doctor", automaticTick: true, homeDir: command.homeDir, dashboardUrl, json: true, dryRun: false }, doctorIo, {
|
|
78
91
|
selfUpdate: (updateIo, options) => runSelfUpdate({ ...updateIo, stdout: quiet }, options),
|
|
79
92
|
runLogin: async () => { throw new Error("automatic_doctor_cannot_prompt"); },
|
|
@@ -152,7 +152,7 @@ function syncResult(run) {
|
|
|
152
152
|
notice: run.notice,
|
|
153
153
|
// BLI-4022: unlike the reasons, the hold survives a successful run — it is
|
|
154
154
|
// what the tick withheld, not why it failed.
|
|
155
|
-
holdNotice: run.hold?.notice ?? null,
|
|
155
|
+
holdNotice: run.hold?.notice ?? run.budgetHold?.notice ?? null,
|
|
156
156
|
sessionsObserved: run.sessions_observed,
|
|
157
157
|
sessionsOutsideRoot: run.sessions_outside_root,
|
|
158
158
|
outsideRootDirectories: run.outside_root_directories,
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cockpit ticket templates [list|show|versions|save|update|apply]` argument parsing (BLI-21779, P11).
|
|
3
|
+
* `templates` is an `issue` action; its flags are read here so `local-args-tower-work.ts` only dispatches,
|
|
4
|
+
* the way `view-args.ts` does for views.
|
|
5
|
+
*
|
|
6
|
+
* Property flags mean the same on save, update and apply: `--description <text|->` (`-` reads stdin),
|
|
7
|
+
* `--state`, `--priority 0-4`, `--size S|M|L`, `--project <name|id>`, `--label <name>` (repeatable).
|
|
8
|
+
* On save and update, the value `none` clears a preset; on apply, `none` clears what the template would
|
|
9
|
+
* have copied. Nothing here carries a title except `apply`, and nothing stores one.
|
|
10
|
+
*/
|
|
11
|
+
import { estimateForSize } from "./issue-contracts.js";
|
|
12
|
+
import { optionalNonEmpty, optionalUrl } from "./local-arg-values.js";
|
|
13
|
+
export const TEMPLATE_VERBS = ["list", "show", "versions", "save", "update", "apply"];
|
|
14
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
const STATES = ["backlog", "todo", "in_progress", "in_review", "done", "canceled"];
|
|
16
|
+
const NONE = (value) => value.trim().toLowerCase() === "none";
|
|
17
|
+
export function parseTemplateArgs(values, rest) {
|
|
18
|
+
const first = rest[0];
|
|
19
|
+
const verb = (first === undefined ? "list" : first);
|
|
20
|
+
if (!TEMPLATE_VERBS.includes(verb)) {
|
|
21
|
+
throw new Error(`Unknown templates command: ${first}. Try list, show, versions, save, update or apply.`);
|
|
22
|
+
}
|
|
23
|
+
const positional = rest.slice(first === undefined ? 0 : 1);
|
|
24
|
+
const needsRef = verb !== "list" && verb !== "save";
|
|
25
|
+
if (needsRef && positional.length !== 1)
|
|
26
|
+
throw new Error(`ticket templates ${verb} takes exactly one template id or name.`);
|
|
27
|
+
if (!needsRef && positional.length > 0)
|
|
28
|
+
throw new Error(`ticket templates ${verb} does not take "${positional[0]}".`);
|
|
29
|
+
const given = (flag) => values.flags.has(flag) || values.booleans.has(flag);
|
|
30
|
+
const writes = verb === "save" || verb === "update" || verb === "apply";
|
|
31
|
+
for (const flag of ["--description", "--state", "--priority", "--size", "--project", "--label"]) {
|
|
32
|
+
if (given(flag) && !writes)
|
|
33
|
+
throw new Error(`${flag} belongs to \`cockpit ticket templates save\`, \`update\` and \`apply\`.`);
|
|
34
|
+
}
|
|
35
|
+
if (given("--name") && verb !== "save" && verb !== "update")
|
|
36
|
+
throw new Error("--name belongs to `cockpit ticket templates save` and `update`.");
|
|
37
|
+
if (verb !== "update" && (given("--archive") || given("--restore") || given("--expected-updated-at"))) {
|
|
38
|
+
throw new Error("--archive, --restore and --expected-updated-at belong to `cockpit ticket templates update`.");
|
|
39
|
+
}
|
|
40
|
+
if (verb !== "apply" && (given("--title") || given("--assignee") || given("--parent") || given("--due"))) {
|
|
41
|
+
throw new Error("--title, --assignee, --parent and --due belong to `cockpit ticket templates apply`; a template never stores them.");
|
|
42
|
+
}
|
|
43
|
+
if (verb !== "list" && given("--include-archived"))
|
|
44
|
+
throw new Error("--include-archived belongs to `cockpit ticket templates list`.");
|
|
45
|
+
if (given("--archive") && given("--restore"))
|
|
46
|
+
throw new Error("Give --archive or --restore, not both.");
|
|
47
|
+
if ((verb === "save" || verb === "apply") === false && given("--request-id")) {
|
|
48
|
+
throw new Error("--request-id belongs to `cockpit ticket templates save` and `apply`.");
|
|
49
|
+
}
|
|
50
|
+
if (given("--expected-revision") || given("--limit") || given("--shared") || given("--private")) {
|
|
51
|
+
throw new Error("That flag does not belong to ticket templates.");
|
|
52
|
+
}
|
|
53
|
+
const name = optionalNonEmpty(values.flags.get("--name"));
|
|
54
|
+
if (verb === "save" && !name)
|
|
55
|
+
throw new Error('ticket templates save needs --name "<name>".');
|
|
56
|
+
const rawDescription = values.flags.get("--description");
|
|
57
|
+
const rawState = optionalNonEmpty(values.flags.get("--state"));
|
|
58
|
+
if (rawState && !NONE(rawState) && !STATES.includes(rawState))
|
|
59
|
+
throw new Error(`--state must be one of: ${STATES.join(", ")} (or none).`);
|
|
60
|
+
const rawPriority = optionalNonEmpty(values.flags.get("--priority"));
|
|
61
|
+
let priority;
|
|
62
|
+
if (rawPriority !== undefined) {
|
|
63
|
+
if (NONE(rawPriority))
|
|
64
|
+
priority = null;
|
|
65
|
+
else {
|
|
66
|
+
priority = Number(rawPriority);
|
|
67
|
+
if (!Number.isInteger(priority) || priority < 0 || priority > 4) {
|
|
68
|
+
throw new Error("--priority must be 0-4 (0 none, 1 urgent, 2 high, 3 medium, 4 low), or the word none to clear it.");
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const rawSize = optionalNonEmpty(values.flags.get("--size"));
|
|
73
|
+
const estimate = rawSize === undefined ? undefined : NONE(rawSize) ? null : estimateForSize(rawSize);
|
|
74
|
+
const rawProject = optionalNonEmpty(values.flags.get("--project"));
|
|
75
|
+
const labelValues = (values.flagValues.get("--label") ?? []).map((v) => v.trim()).filter(Boolean);
|
|
76
|
+
const labels = labelValues.length === 1 && NONE(labelValues[0]) ? [] : labelValues;
|
|
77
|
+
const assignee = optionalNonEmpty(values.flags.get("--assignee"));
|
|
78
|
+
if (assignee !== undefined && assignee !== "me" && !UUID.test(assignee))
|
|
79
|
+
throw new Error('--assignee must be "me" or a person\'s uuid.');
|
|
80
|
+
const due = optionalNonEmpty(values.flags.get("--due"));
|
|
81
|
+
if (due && !NONE(due) && !/^\d{4}-\d{2}-\d{2}$/.test(due))
|
|
82
|
+
throw new Error("--due must be YYYY-MM-DD (or none).");
|
|
83
|
+
const expectedUpdatedAt = optionalNonEmpty(values.flags.get("--expected-updated-at"));
|
|
84
|
+
if (expectedUpdatedAt && Number.isNaN(Date.parse(expectedUpdatedAt))) {
|
|
85
|
+
throw new Error("--expected-updated-at must be the updated_at timestamp you last read.");
|
|
86
|
+
}
|
|
87
|
+
const requestId = optionalNonEmpty(values.flags.get("--request-id"));
|
|
88
|
+
if (requestId && !UUID.test(requestId))
|
|
89
|
+
throw new Error("--request-id must be a UUID.");
|
|
90
|
+
const changes = rawDescription !== undefined || rawState !== undefined || priority !== undefined || estimate !== undefined
|
|
91
|
+
|| rawProject !== undefined || labelValues.length > 0 || name !== undefined;
|
|
92
|
+
if (verb === "update" && !changes && !given("--archive") && !given("--restore")) {
|
|
93
|
+
throw new Error("ticket templates update needs a field to change: --name, --description, --state, --priority, --size, --project, --label, --archive or --restore.");
|
|
94
|
+
}
|
|
95
|
+
const templates = {
|
|
96
|
+
verb,
|
|
97
|
+
...(needsRef ? { ref: positional[0] } : {}),
|
|
98
|
+
...(name ? { name } : {}),
|
|
99
|
+
...(rawDescription === undefined ? {} : { description: NONE(rawDescription) ? null : rawDescription }),
|
|
100
|
+
...(rawState === undefined ? {} : { state: NONE(rawState) ? null : rawState }),
|
|
101
|
+
...(priority === undefined ? {} : { priority }),
|
|
102
|
+
...(estimate === undefined ? {} : { estimate }),
|
|
103
|
+
...(rawProject === undefined ? {} : { project: NONE(rawProject) ? null : rawProject }),
|
|
104
|
+
...(labelValues.length > 0 ? { labels } : {}),
|
|
105
|
+
...(optionalNonEmpty(values.flags.get("--title")) ? { title: optionalNonEmpty(values.flags.get("--title")) } : {}),
|
|
106
|
+
...(assignee ? { assignee } : {}),
|
|
107
|
+
...(optionalNonEmpty(values.flags.get("--parent")) ? { parentRef: optionalNonEmpty(values.flags.get("--parent")) } : {}),
|
|
108
|
+
...(due === undefined ? {} : { due: NONE(due) ? null : due }),
|
|
109
|
+
...(given("--archive") ? { archived: true } : given("--restore") ? { archived: false } : {}),
|
|
110
|
+
...(given("--include-archived") ? { includeArchived: true } : {}),
|
|
111
|
+
...(expectedUpdatedAt ? { expectedUpdatedAt } : {}),
|
|
112
|
+
};
|
|
113
|
+
return {
|
|
114
|
+
kind: "issue",
|
|
115
|
+
action: "templates",
|
|
116
|
+
homeDir: optionalNonEmpty(values.flags.get("--home")),
|
|
117
|
+
dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
|
|
118
|
+
...(requestId ? { requestId } : {}),
|
|
119
|
+
json: values.booleans.has("--json"),
|
|
120
|
+
templates,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { askAgentDoor, emitAgentDoor, failAgentDoor } from "./agent-door.js";
|
|
2
|
+
import { isInteractiveStdin, readPipedText, writeLine } from "./cli-io.js";
|
|
3
|
+
import { BODY_MAX_CHARS, READ_DEADLINE_MS, WRITE_DEADLINE_MS, resolveRefs, sizeForEstimate } from "./issue-contracts.js";
|
|
4
|
+
const TAG = "[ticket templates cli]";
|
|
5
|
+
const presetText = (t) => {
|
|
6
|
+
const parts = [];
|
|
7
|
+
if (t.state)
|
|
8
|
+
parts.push(`state ${t.state}`);
|
|
9
|
+
if (t.priority !== null)
|
|
10
|
+
parts.push(`priority ${t.priority}`);
|
|
11
|
+
if (t.estimate !== null)
|
|
12
|
+
parts.push(`size ${sizeForEstimate(t.estimate) ?? t.estimate}`);
|
|
13
|
+
if (t.project_id)
|
|
14
|
+
parts.push(`project ${t.project_id}`);
|
|
15
|
+
if (t.label_names.length > 0)
|
|
16
|
+
parts.push(`labels ${t.label_names.join(", ")}`);
|
|
17
|
+
return parts.length === 0 ? "no presets" : parts.join(", ");
|
|
18
|
+
};
|
|
19
|
+
const templateLine = (t) => `${t.id} ${t.name} [v${t.version}${t.archived_at ? ", archived" : ""}${t.mine ? ", yours" : ""}] ${presetText(t)}`;
|
|
20
|
+
/** `--json` rows get a `size` letter beside the raw estimate, like every ticket read. */
|
|
21
|
+
const withSize = (row) => ({ ...row, size: sizeForEstimate(row.estimate ?? null) });
|
|
22
|
+
export async function runTemplates(command, io, door) {
|
|
23
|
+
const t = command.templates;
|
|
24
|
+
const ref = t.ref ? encodeURIComponent(t.ref) : "";
|
|
25
|
+
switch (t.verb) {
|
|
26
|
+
case "list": {
|
|
27
|
+
const answer = await askAgentDoor(door, {
|
|
28
|
+
path: `/api/work/templates${t.includeArchived ? "?include_archived=true" : ""}`,
|
|
29
|
+
method: "GET", label: "ticket templates list", timeoutMs: READ_DEADLINE_MS,
|
|
30
|
+
});
|
|
31
|
+
if (!answer.ok)
|
|
32
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
33
|
+
const body = answer.body;
|
|
34
|
+
const templates = body.templates ?? [];
|
|
35
|
+
if (door.json)
|
|
36
|
+
return emitAgentDoor(door, { ok: true, ...body, templates: templates.map(withSize) });
|
|
37
|
+
if (templates.length === 0)
|
|
38
|
+
writeLine(door.io.stdout, "No templates yet. Save one with `cockpit ticket templates save --name <name>`.");
|
|
39
|
+
for (const row of templates)
|
|
40
|
+
writeLine(door.io.stdout, templateLine(row));
|
|
41
|
+
if (body.archived_hidden)
|
|
42
|
+
writeLine(door.io.stdout, `\n${body.archived_hidden} archived template(s) not shown (--include-archived).`);
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
case "show": {
|
|
46
|
+
const answer = await askAgentDoor(door, { path: `/api/work/templates/${ref}`, method: "GET", label: "ticket templates show", timeoutMs: READ_DEADLINE_MS });
|
|
47
|
+
if (!answer.ok)
|
|
48
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
49
|
+
const body = answer.body;
|
|
50
|
+
if (door.json)
|
|
51
|
+
return emitAgentDoor(door, { ok: true, template: withSize(body.template) });
|
|
52
|
+
const row = body.template;
|
|
53
|
+
writeLine(door.io.stdout, templateLine(row));
|
|
54
|
+
writeLine(door.io.stdout, row.description ? `\n${row.description}` : "\n(no description)");
|
|
55
|
+
return 0;
|
|
56
|
+
}
|
|
57
|
+
case "versions": {
|
|
58
|
+
const answer = await askAgentDoor(door, { path: `/api/work/templates/${ref}/versions`, method: "GET", label: "ticket templates versions", timeoutMs: READ_DEADLINE_MS });
|
|
59
|
+
if (!answer.ok)
|
|
60
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail);
|
|
61
|
+
const body = answer.body;
|
|
62
|
+
if (door.json)
|
|
63
|
+
return emitAgentDoor(door, { ok: true, ...body, template: withSize(body.template), versions: (body.versions ?? []).map((v) => withSize(v)) });
|
|
64
|
+
writeLine(door.io.stdout, `${body.template.name}: ${body.versions?.length ?? 0} version(s), current v${body.template.version}.`);
|
|
65
|
+
for (const v of body.versions ?? [])
|
|
66
|
+
writeLine(door.io.stdout, `v${v.version} ${v.created_at} ${v.name}${v.archived ? " (archived)" : ""}`);
|
|
67
|
+
return 0;
|
|
68
|
+
}
|
|
69
|
+
case "save":
|
|
70
|
+
case "update":
|
|
71
|
+
return writeTemplate(command, io, door, ref);
|
|
72
|
+
case "apply":
|
|
73
|
+
return applyTemplate(command, io, door, ref);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
/** `--description -` reads the text from stdin; anything else is the text itself. */
|
|
77
|
+
async function readDescription(value, io) {
|
|
78
|
+
if (value !== "-")
|
|
79
|
+
return { ok: true, text: value };
|
|
80
|
+
if (isInteractiveStdin(io))
|
|
81
|
+
return { ok: false, detail: "--description - reads the text from stdin, and nothing is piped in." };
|
|
82
|
+
try {
|
|
83
|
+
return { ok: true, text: await readPipedText(io.stdin, { maxChars: BODY_MAX_CHARS, overflowMessage: `A template description is limited to ${BODY_MAX_CHARS} characters.` }) };
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
return { ok: false, detail: error instanceof Error ? error.message : String(error) };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
async function writeTemplate(command, io, door, ref) {
|
|
90
|
+
const t = command.templates;
|
|
91
|
+
const description = await readDescription(t.description, io);
|
|
92
|
+
if (!description.ok)
|
|
93
|
+
return failAgentDoor(door, TAG, "invalid_body", description.detail);
|
|
94
|
+
const payload = {
|
|
95
|
+
...(t.verb === "save" && command.requestId ? { request_id: command.requestId } : {}),
|
|
96
|
+
...(t.verb === "update" && t.expectedUpdatedAt ? { expected_updated_at: t.expectedUpdatedAt } : {}),
|
|
97
|
+
...(t.name === undefined ? {} : { name: t.name }),
|
|
98
|
+
...(description.text === undefined ? {} : { description: description.text }),
|
|
99
|
+
...(t.state === undefined ? {} : { state: t.state }),
|
|
100
|
+
...(t.priority === undefined ? {} : { priority: t.priority }),
|
|
101
|
+
...(t.estimate === undefined ? {} : { estimate: t.estimate }),
|
|
102
|
+
...(t.project === undefined ? {} : { project: t.project }),
|
|
103
|
+
...(t.labels ? { label_names: t.labels } : {}),
|
|
104
|
+
...(t.archived === undefined ? {} : { archived: t.archived }),
|
|
105
|
+
};
|
|
106
|
+
const answer = await askAgentDoor(door, {
|
|
107
|
+
path: t.verb === "save" ? "/api/work/templates" : `/api/work/templates/${ref}`,
|
|
108
|
+
method: t.verb === "save" ? "POST" : "PATCH",
|
|
109
|
+
label: `ticket templates ${t.verb}`,
|
|
110
|
+
timeoutMs: WRITE_DEADLINE_MS,
|
|
111
|
+
body: payload,
|
|
112
|
+
});
|
|
113
|
+
if (!answer.ok) {
|
|
114
|
+
const mutation = answer.body?.mutation;
|
|
115
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
|
|
116
|
+
}
|
|
117
|
+
const body = answer.body;
|
|
118
|
+
console.error(`${TAG} ${t.verb} done`, JSON.stringify({ template_id: body.template.id, version: body.template.version, replayed: Boolean(body.replayed) }));
|
|
119
|
+
if (door.json)
|
|
120
|
+
return emitAgentDoor(door, { ok: true, ...body, template: withSize(body.template) });
|
|
121
|
+
writeLine(door.io.stdout, `${t.verb === "save" ? (body.replayed ? "Already saved" : "Saved") : "Updated"}: ${body.template.name} (${body.template.id}), version ${body.template.version}${body.template.archived_at ? ", archived" : ""}.`);
|
|
122
|
+
return 0;
|
|
123
|
+
}
|
|
124
|
+
async function applyTemplate(command, io, door, ref) {
|
|
125
|
+
const t = command.templates;
|
|
126
|
+
const description = await readDescription(t.description, io);
|
|
127
|
+
if (!description.ok)
|
|
128
|
+
return failAgentDoor(door, TAG, "invalid_body", description.detail);
|
|
129
|
+
// A project name or a parent identifier is resolved to ids here, the way `ticket create` does.
|
|
130
|
+
const refs = await resolveRefs({ project: t.project ?? undefined, parentRef: t.parentRef }, door);
|
|
131
|
+
if (!refs.ok)
|
|
132
|
+
return failAgentDoor(door, TAG, refs.reason, refs.detail);
|
|
133
|
+
const payload = {
|
|
134
|
+
...(command.requestId ? { request_id: command.requestId } : {}),
|
|
135
|
+
...(t.title ? { title: t.title } : {}),
|
|
136
|
+
...(description.text === undefined ? {} : { description: description.text }),
|
|
137
|
+
...(t.state === undefined ? {} : { state: t.state }),
|
|
138
|
+
...(t.priority === undefined ? {} : { priority: t.priority }),
|
|
139
|
+
...(t.estimate === undefined ? {} : { estimate: t.estimate }),
|
|
140
|
+
...(t.project === null ? { project_id: null } : refs.projectId ? { project_id: refs.projectId } : {}),
|
|
141
|
+
...(t.labels ? { labels: t.labels } : {}),
|
|
142
|
+
...(t.assignee ? { assignee_id: t.assignee } : {}),
|
|
143
|
+
...(refs.parentId ? { parent_id: refs.parentId } : {}),
|
|
144
|
+
...(t.due === undefined ? {} : { due_date: t.due }),
|
|
145
|
+
};
|
|
146
|
+
const answer = await askAgentDoor(door, {
|
|
147
|
+
path: `/api/work/templates/${ref}/apply`, method: "POST", label: "ticket templates apply", timeoutMs: WRITE_DEADLINE_MS, body: payload,
|
|
148
|
+
});
|
|
149
|
+
if (!answer.ok) {
|
|
150
|
+
const mutation = answer.body?.mutation;
|
|
151
|
+
return failAgentDoor(door, TAG, answer.reason, answer.detail, mutation ? { mutation } : {});
|
|
152
|
+
}
|
|
153
|
+
const body = answer.body;
|
|
154
|
+
console.error(`${TAG} apply done`, JSON.stringify({ template_id: body.template?.id, version: body.template?.version, labels: body.labels?.status }));
|
|
155
|
+
if (door.json)
|
|
156
|
+
return emitAgentDoor(door, { ok: true, ...body, issue: withSize(body.issue) });
|
|
157
|
+
writeLine(door.io.stdout, `Created ${body.issue.identifier ?? ""} from template v${body.template?.version ?? "?"}: ${body.issue.title ?? ""}`);
|
|
158
|
+
if (body.labels?.status === "failed") {
|
|
159
|
+
writeLine(door.io.stderr, `The ticket exists but its labels (${(body.labels.names ?? []).join(", ")}) were not applied: ${body.labels.reason}. Add them with \`cockpit ticket label\`.`);
|
|
160
|
+
}
|
|
161
|
+
return 0;
|
|
162
|
+
}
|