@opvs-ai/cli 0.27.10 → 0.27.11

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.
@@ -0,0 +1,227 @@
1
+ import { createClient } from "@opvs-ai/core";
2
+ import { printJson } from "../lib/format.js";
3
+ /**
4
+ * The allow-list — the ONLY fields this command may print.
5
+ *
6
+ * The first block is what the reply carries today. The second block is the
7
+ * "if present" block from the card: the project and capacity fields the reply
8
+ * does NOT carry today, listed so that the day the API grows them they print
9
+ * themselves, and so that a reader can see the command is ready for them
10
+ * rather than silently dropping them.
11
+ */
12
+ export const MACHINE_FIELDS = [
13
+ "name",
14
+ "short_id",
15
+ "status",
16
+ "sidecar_version",
17
+ "last_health_check",
18
+ // Worker loop — the row HAS `worker_loop_running` / `worker_loop_changed_at`
19
+ // (migration 139), but `EnvironmentResponse` does not declare them, so
20
+ // pydantic drops them and the reply does not carry them YET. They are in the
21
+ // allow-list so the day the model declares them they appear by themselves.
22
+ "worker_loop_running",
23
+ "worker_loop_changed_at",
24
+ // Project — not on the environment row today. The reply answers a project
25
+ // question only when it carries one.
26
+ "project_id",
27
+ "board_id",
28
+ // Capacity — the platform stores no per-machine desk capacity, so these are
29
+ // absent today; `desk_count` appears when the reply carries an occupancy.
30
+ "desk_count",
31
+ "max_desks",
32
+ "capacity",
33
+ ];
34
+ /** The PLACEHOLDER for a field the reply does not carry. */
35
+ const MISSING = "-";
36
+ function isRecord(value) {
37
+ return typeof value === "object" && value !== null && !Array.isArray(value);
38
+ }
39
+ /** Render one reply value for a cell: `-` when the reply does not carry it. */
40
+ function cell(value) {
41
+ if (value === undefined || value === null || value === "")
42
+ return MISSING;
43
+ if (typeof value === "string")
44
+ return value;
45
+ if (typeof value === "number" || typeof value === "boolean")
46
+ return String(value);
47
+ return JSON.stringify(value);
48
+ }
49
+ /**
50
+ * Project ONE reply row onto the allow-list. Nothing else survives: a field
51
+ * the reply adds later (a token, a key, an install one-liner) is not printed,
52
+ * because it was never in the list.
53
+ */
54
+ export function summarizeMachine(row) {
55
+ const source = isRecord(row) ? row : {};
56
+ const out = {};
57
+ for (const field of MACHINE_FIELDS) {
58
+ out[field] = cell(source[field]);
59
+ }
60
+ return out;
61
+ }
62
+ /** The rows of a fleet reply, tolerating a reply that is not the shape we expect. */
63
+ export function machineRows(reply) {
64
+ if (!isRecord(reply))
65
+ return [];
66
+ const list = reply.environments;
67
+ return Array.isArray(list) ? list : [];
68
+ }
69
+ /**
70
+ * The `--project` filter, applied CLIENT-SIDE on what the reply carries.
71
+ *
72
+ * The environments route takes no project parameter, so the filter can only
73
+ * match a field the row actually has. A row with no project field matches
74
+ * NOTHING when `--project` is given — a filter that silently kept
75
+ * project-less rows would answer "these are the tenant's project machines"
76
+ * with rows that belong to no project at all.
77
+ */
78
+ export function matchesProject(row, projectId) {
79
+ if (!isRecord(row))
80
+ return false;
81
+ for (const field of PROJECT_FIELDS) {
82
+ const value = row[field];
83
+ if (value === undefined || value === null)
84
+ continue;
85
+ if (String(value) === projectId)
86
+ return true;
87
+ }
88
+ return false;
89
+ }
90
+ /** The reply fields that can carry a project reference. */
91
+ const PROJECT_FIELDS = ["project_id", "board_id"];
92
+ /**
93
+ * Does this row carry a project reference AT ALL?
94
+ *
95
+ * A row whose project fields are all null says nothing about projects, which
96
+ * is a different fact from a row whose project is a DIFFERENT project.
97
+ */
98
+ function carriesProject(row) {
99
+ if (!isRecord(row))
100
+ return false;
101
+ for (const field of PROJECT_FIELDS) {
102
+ const value = row[field];
103
+ if (value === undefined || value === null)
104
+ continue;
105
+ if (String(value) === "")
106
+ continue;
107
+ return true;
108
+ }
109
+ return false;
110
+ }
111
+ /**
112
+ * Does ANY row of the reply carry a project reference?
113
+ *
114
+ * THIS IS THE DIFFERENCE BETWEEN "no machine is in that project" and "this
115
+ * reply does not say". The environments reply carries no project field on any
116
+ * row today, so a `--project` filter over it drops every row — and printing
117
+ * "(no machines)" would tell a planner checking a client's onboarding that
118
+ * the client has no machines, which is a FALSE ANSWER (CP-359 fix round 1).
119
+ *
120
+ * So when the reply says nothing about projects, the command says so and
121
+ * stops, rather than answering a question the reply cannot answer.
122
+ */
123
+ export function replyCarriesProject(rows) {
124
+ return rows.some(carriesProject);
125
+ }
126
+ /** The sentence the command prints when the reply cannot answer `--project`. */
127
+ export const PROJECT_NOT_IN_REPLY = "This reply does not say which project a machine belongs to, so --project cannot filter it. " +
128
+ "Run opvs machines list without --project.";
129
+ /** The text table: one row per machine, one column per allow-listed field. */
130
+ export function renderMachinesTable(rows) {
131
+ if (rows.length === 0) {
132
+ console.log("(no machines)");
133
+ return;
134
+ }
135
+ const columns = MACHINE_FIELDS.map((f) => f);
136
+ const widths = columns.map((col, index) => Math.max(col.length, ...rows.map((row) => row[col].length)));
137
+ const header = columns.map((col, i) => col.padEnd(widths[i])).join(" ").trimEnd();
138
+ const sep = widths.map((w) => "-".repeat(w)).join(" ");
139
+ console.log(header);
140
+ console.log(sep);
141
+ for (const row of rows) {
142
+ const record = row;
143
+ console.log(columns.map((col, i) => record[col].padEnd(widths[i])).join(" ").trimEnd());
144
+ }
145
+ }
146
+ /** The API's own refusal — the sentence the server sent, not a paraphrase. */
147
+ function apiMessage(err, fallback) {
148
+ const candidate = err;
149
+ if (typeof candidate?.detail === "string" && candidate.detail)
150
+ return candidate.detail;
151
+ if (candidate?.detail && typeof candidate.detail === "object") {
152
+ const nested = candidate.detail.message;
153
+ if (typeof nested === "string" && nested)
154
+ return nested;
155
+ }
156
+ if (typeof candidate?.message === "string" && candidate.message)
157
+ return candidate.message;
158
+ return fallback;
159
+ }
160
+ export function registerMachinesCommands(program) {
161
+ const machines = program
162
+ .command("machines")
163
+ .description("Read the machines (environments) of this workspace");
164
+ machines
165
+ .command("list")
166
+ .description("List this workspace's machines")
167
+ .option("--project <id>", "Keep only the machines the reply ties to this project (the reply must carry a project field)")
168
+ .option("--json", "Print the allow-listed fields as JSON instead of the table")
169
+ .action(async (opts) => {
170
+ const wsSlug = program.opts().workspace;
171
+ let raw;
172
+ try {
173
+ const client = createClient(wsSlug);
174
+ const res = await client.get("/api/v1/environments");
175
+ const reply = (await res.json());
176
+ raw = machineRows(reply);
177
+ }
178
+ catch (err) {
179
+ console.error(`Error: ${apiMessage(err, "Failed to list machines")}`);
180
+ process.exit(1);
181
+ return;
182
+ }
183
+ // OUTSIDE the try: the guard's exit must not be caught by the API-error
184
+ // handler above and re-reported as "Error: exit 1".
185
+ if (opts.project) {
186
+ // A reply that says nothing about projects cannot answer the
187
+ // question. Printing "(no machines)" for a workspace that HAS
188
+ // machines would be a false answer, so the command says what is
189
+ // wrong and stops — in text and in `--json` alike.
190
+ if (raw.length > 0 && !replyCarriesProject(raw)) {
191
+ console.error(PROJECT_NOT_IN_REPLY);
192
+ process.exit(1);
193
+ return;
194
+ }
195
+ raw = raw.filter((row) => matchesProject(row, opts.project));
196
+ }
197
+ const rows = raw.map(summarizeMachine);
198
+ if (opts.json) {
199
+ printJson({ machines: rows, total: rows.length });
200
+ return;
201
+ }
202
+ renderMachinesTable(rows);
203
+ });
204
+ machines
205
+ .command("get <ref>")
206
+ .description("Show one machine by id, short code or prefix")
207
+ .option("--json", "Print the allow-listed fields as JSON instead of the text")
208
+ .action(async (ref, opts) => {
209
+ const wsSlug = program.opts().workspace;
210
+ try {
211
+ const client = createClient(wsSlug);
212
+ const res = await client.get(`/api/v1/environments/${encodeURIComponent(ref)}`);
213
+ const row = (await res.json());
214
+ const summary = summarizeMachine(row);
215
+ if (opts.json) {
216
+ printJson(summary);
217
+ return;
218
+ }
219
+ renderMachinesTable([summary]);
220
+ }
221
+ catch (err) {
222
+ console.error(`Error: ${apiMessage(err, `Failed to get machine '${ref}'`)}`);
223
+ process.exit(1);
224
+ }
225
+ });
226
+ }
227
+ //# sourceMappingURL=machines.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"machines.js","sourceRoot":"","sources":["../../src/commands/machines.ts"],"names":[],"mappings":"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"}
@@ -8,7 +8,7 @@ import { clampLimit } from "../lib/pagination.js";
8
8
  import { CLEAR, csv, intFlag, oneOf } from "../lib/options.js";
9
9
  import { confirm, YES_HELP } from "../lib/confirm.js";
10
10
  import { isUuid, resolveBoardRefs } from "../lib/board-refs.js";
11
- import { CalendarSpecError, parseEnds, parseRemind, parseZonedTime, } from "../lib/calendar-spec.js";
11
+ import { CalendarSpecError, parseEndsWithTz, parseRemind, parseZonedTime, } from "../lib/calendar-spec.js";
12
12
  import { TASK_STATUSES } from "./columns.js";
13
13
  /** A board KEY's grammar (agentboard `ck_boards_key_format`). A UUID prefix
14
14
  * is 8+ characters, so it can never pass this — which is how `tasks search`
@@ -32,6 +32,36 @@ function warnIfNotReady(data) {
32
32
  : "unknown";
33
33
  console.error(`Warning: task is not ready for builders; missing: ${missing}`);
34
34
  }
35
+ /** `$12.50` — money as the budget routes report it, in dollars. */
36
+ function usd(value) {
37
+ return typeof value === "number" && Number.isFinite(value) ? `$${value.toFixed(2)}` : "$?";
38
+ }
39
+ /** A card's completion-budget envelope as one line (REAS-304):
40
+ * `budget: $15.00/$15.00 (drawn/total; slice $5.00, cap $50.00)`.
41
+ * `null` for a body that is not an envelope, so the caller prints nothing. */
42
+ function budgetLine(data) {
43
+ const b = (data ?? {});
44
+ if (typeof b.drawn_usd !== "number" || typeof b.total_usd !== "number")
45
+ return null;
46
+ const sizing = typeof b.slice_usd === "number" && typeof b.cap_usd === "number"
47
+ ? `; slice ${usd(b.slice_usd)}, cap ${usd(b.cap_usd)}`
48
+ : "";
49
+ return `budget: ${usd(b.drawn_usd)}/${usd(b.total_usd)} (drawn/total${sizing})`;
50
+ }
51
+ /** The card's completion-budget envelope for `tasks get`, or `null` when it has
52
+ * none. Best effort on purpose: a card never minted against has none (404), and
53
+ * an older server has no such route — neither makes `tasks get` fail. */
54
+ async function readBudget(client, id) {
55
+ try {
56
+ const res = await client.get(`/api/v1/board/tasks/${id}/budget`);
57
+ const data = await res.json();
58
+ return budgetLine(data) ? data : null;
59
+ }
60
+ catch {
61
+ // no envelope, no route or no scope: there is no budget to show
62
+ return null;
63
+ }
64
+ }
35
65
  /** Collect a repeatable `--field key=value` option into an array. */
36
66
  function collectField(val, prev) {
37
67
  return prev.concat([val]);
@@ -105,7 +135,7 @@ function buildTaskSchedule(opts) {
105
135
  ends: "never",
106
136
  };
107
137
  if (opts.ends !== undefined) {
108
- const end = parseEnds(opts.ends);
138
+ const end = parseEndsWithTz(opts.ends);
109
139
  if (end.repeat_ends === "on_date") {
110
140
  repeat.ends = "on";
111
141
  repeat.until = end.repeat_until;
@@ -435,9 +465,25 @@ export function registerTaskCommands(program) {
435
465
  const detail = oneOf("--detail", opts.detail, ["summary", "standard", "full"]);
436
466
  if (detail)
437
467
  params.detail = detail;
438
- const url = buildUrl(`/api/v1/board/tasks/${id}`, params, opts.json ? "json" : cfg.format);
468
+ // `--json` or a saved `format: json` — the same JSON output either way.
469
+ const asJson = Boolean(opts.json) || cfg.format === "json";
470
+ const url = buildUrl(`/api/v1/board/tasks/${id}`, params, asJson ? "json" : cfg.format);
439
471
  const res = await client.get(url);
472
+ if (asJson && (res.headers.get("content-type") || "").includes("application/json")) {
473
+ // ONE JSON document: the budget is a key in it, never a line after it,
474
+ // which would break every parser reading the output.
475
+ const task = await res.json();
476
+ const budget = await readBudget(client, id);
477
+ const isCard = typeof task === "object" && task !== null && !Array.isArray(task);
478
+ printJson(budget && isCard ? { ...task, budget } : task);
479
+ return;
480
+ }
440
481
  await printResponse(res);
482
+ if (!asJson) {
483
+ const line = budgetLine(await readBudget(client, id));
484
+ if (line)
485
+ console.log(line);
486
+ }
441
487
  }
442
488
  catch (err) {
443
489
  const e = err;
@@ -598,6 +644,7 @@ export function registerTaskCommands(program) {
598
644
  .description("Update a task (status, priority, result)")
599
645
  .option("--status <status>", "Status: pending, assigned, in_progress, blocked, review, done, cancelled, failed (pending can only go to assigned/in_progress/cancelled)")
600
646
  .option("--any-status", "Skip status compare-and-set and write regardless of the card's current status")
647
+ .option("--force-release", "Release a builder claim when this update also moves the card. Use only when you know the box has stopped.")
601
648
  .option("--priority <priority>", "Priority: low, medium, high, critical")
602
649
  .option("--title <title>", "New title")
603
650
  .option("-d, --description <desc>", "New description (markdown)")
@@ -660,6 +707,15 @@ export function registerTaskCommands(program) {
660
707
  try {
661
708
  const schedule = buildTaskSchedule(opts);
662
709
  const body = {};
710
+ // `force_release` releases a builder claim, and the server only acts on
711
+ // it while this same PATCH also moves the card. Without --status there
712
+ // is nothing to move, so the flag would be a no-op the server ignores —
713
+ // refuse it here rather than let a planner believe the card was freed.
714
+ if (opts.forceRelease && !opts.status) {
715
+ console.error("Error: --force-release needs --status — it only releases a claim when the update also moves the card.");
716
+ process.exit(1);
717
+ return;
718
+ }
663
719
  if (opts.status)
664
720
  body.status = opts.status;
665
721
  if (opts.priority)
@@ -807,6 +863,13 @@ export function registerTaskCommands(program) {
807
863
  // Keep the optimistic-concurrency precondition out of the printed
808
864
  // changed-field list: it controls this write but is not persisted.
809
865
  const changedFields = Object.keys(body);
866
+ // `force_release` is request control, not a persisted field (the server
867
+ // pops it out of the update data before it reaches the task row), so it
868
+ // is set here, after the changed-field snapshot, and never sent as
869
+ // false — an absent key means "not requested".
870
+ if (opts.forceRelease) {
871
+ body.force_release = true;
872
+ }
810
873
  if (opts.status && !opts.anyStatus) {
811
874
  const currentRes = await client.get(`/api/v1/board/tasks/${id}`);
812
875
  // createClient returns a Response or throws. The truthiness check
@@ -1020,6 +1083,48 @@ export function registerTaskCommands(program) {
1020
1083
  process.exit(1);
1021
1084
  }
1022
1085
  });
1086
+ tasks
1087
+ .command("budget-extend <id>")
1088
+ .description("Give a card ONE more completion-budget slice (one build's draw), e.g. after it died " +
1089
+ "card_budget_exhausted, and lift its card_budget_exhausted park; then move the card to " +
1090
+ "Ready. Written on the card as a comment and in its activity log; refused while the card " +
1091
+ "still has a slice left, past the card's cap, and for a desk's own token. <id> accepts a " +
1092
+ "ref (OPS-142), a UUID prefix, or a full UUID")
1093
+ .requiredOption("--reason <text>", "Why the card gets another slice: 10 to 1000 characters, written on the card verbatim")
1094
+ .option("--json", "Print the raw grant response")
1095
+ .action(async (id, opts) => {
1096
+ const wsSlug = program.opts().workspace;
1097
+ try {
1098
+ const client = createClient(wsSlug);
1099
+ const res = await client.post(`/api/v1/board/tasks/${id}/budget-extend`, {
1100
+ reason: opts.reason,
1101
+ });
1102
+ const data = await res.json();
1103
+ if (opts.json) {
1104
+ printJson(data);
1105
+ return;
1106
+ }
1107
+ console.log(`${data.ref || data.task_id || id} budget slice +${usd(data.slice_usd)} granted: ` +
1108
+ `total now ${usd(data.total_usd)} of cap ${usd(data.cap_usd)} (drawn ${usd(data.drawn_usd)})`);
1109
+ if (data.park_cleared === true) {
1110
+ console.log(" park lifted: waiting_reason card_budget_exhausted cleared");
1111
+ }
1112
+ if (Array.isArray(data.warnings)) {
1113
+ for (const w of data.warnings)
1114
+ console.log(` ⚠ ${String(w)}`);
1115
+ }
1116
+ // Last, because it is the planner's to do: the park sent the card to
1117
+ // Backlog, and the grant does not move it back to Ready.
1118
+ if (typeof data.next_step === "string" && data.next_step) {
1119
+ console.log(` next: ${data.next_step}`);
1120
+ }
1121
+ }
1122
+ catch (err) {
1123
+ const e = err;
1124
+ console.error(`Error: ${e.detail || "Failed to extend the card's budget"}`);
1125
+ process.exit(1);
1126
+ }
1127
+ });
1023
1128
  tasks
1024
1129
  .command("subtasks <id>")
1025
1130
  .description("List subtasks of a task")