@ornncompute/cli 0.2.8 → 0.2.10

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.
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { defineStaffMcpCapability, preview } from "../capability.js";
3
- import { mapBody, seg } from "./staff-shared.js";
3
+ import { errorTextOf, mapBody, requireUuid, resolveVersion, seg, statusOf } from "./staff-shared.js";
4
4
  // Staff commerce verbs: reservations, bids, forward inventory, and the tenant
5
5
  // credit profile. MCP-only — see `defineStaffMcpCapability`.
6
6
  export const reservationCancelInput = z.object({
@@ -28,8 +28,6 @@ export const reservationCancelCapability = defineStaffMcpCapability({
28
28
  export const reservationTransferInput = z.object({
29
29
  reservationId: z.string(),
30
30
  targetTenantId: z.string(),
31
- targetAuthUserId: z.string().optional(),
32
- nodeId: z.string().optional(),
33
31
  reservedStrategy: z.enum(["reject", "park"]),
34
32
  confirmReservedTransfer: z.boolean().optional(),
35
33
  notes: z.string().max(2000).optional(),
@@ -39,35 +37,50 @@ export const reservationTransferCapability = defineStaffMcpCapability({
39
37
  id: "reservation.transfer",
40
38
  domain: "reservations",
41
39
  roles: ["admin"],
42
- description: "Internal staff only: transfer a reservation to a different tenant/node. Mirrors the internal node-overlay transfer action. " +
40
+ description: "Internal staff only: transfer a reservation to a different tenant. Mirrors the internal node-overlay transfer action. " +
43
41
  "reservedStrategy has no default and must be chosen explicitly: \"reject\" (transfer only if the reservation isn't " +
44
42
  "currently provisioned/assigned) or \"park\" (clean up old access and park it for the target tenant, for a live handoff). " +
45
43
  "The dashboard always computes this from the reservation's live state before sending it rather than relying on the " +
46
44
  "backend's own default of \"reject\", because a silent \"reject\" default on a reservation the backend doesn't detect as " +
47
- "actively provisioned/assigned would transfer it without ever asking for confirm_reserved_transfer.",
45
+ "actively provisioned/assigned would transfer it without ever asking for confirmReservedTransfer.",
48
46
  mutation: "preview-confirm",
49
47
  http: { method: "POST", path: "/internal/reservations/{reservationId}/transfer" },
50
48
  input: reservationTransferInput,
51
49
  execute: async (ctx, input) => {
52
50
  const parsed = reservationTransferInput.parse(input);
51
+ // camelCase: commerce's transferReservationRequest has no snake aliases,
52
+ // so the previous all-snake body bound nothing but `notes` and every
53
+ // transfer failed with "targetOrganizationId is required".
53
54
  const body = {
54
- target_tenant_id: parsed.targetTenantId,
55
- reserved_strategy: parsed.reservedStrategy,
56
- confirm_reserved_transfer: parsed.confirmReservedTransfer ?? false,
57
- ...mapBody(parsed, {
58
- targetAuthUserId: "target_auth_user_id",
59
- nodeId: "node_id",
60
- notes: "notes",
61
- }),
55
+ targetOrganizationId: requireUuid(parsed.targetTenantId, "targetTenantId"),
56
+ reservedStrategy: parsed.reservedStrategy,
57
+ confirmReservedTransfer: parsed.confirmReservedTransfer ?? false,
58
+ ...mapBody(parsed, { notes: "notes" }),
62
59
  };
63
60
  if (!ctx.confirmed)
64
61
  return preview("reservation.transfer", body);
65
- return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}/transfer`, {
66
- method: "POST",
67
- body,
68
- });
62
+ try {
63
+ return await ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}/transfer`, {
64
+ method: "POST",
65
+ body,
66
+ });
67
+ }
68
+ catch (error) {
69
+ throw inTenantVocabulary(error);
70
+ }
69
71
  },
70
72
  });
73
+ /**
74
+ * Commerce names the transfer target `targetOrganizationId`; this tool's
75
+ * parameter is `targetTenantId`. Passing the wire name through tells the
76
+ * caller to go fix a parameter that does not exist, so translate it back.
77
+ */
78
+ function inTenantVocabulary(error) {
79
+ const text = errorTextOf(error);
80
+ if (!text.includes("targetOrganizationId"))
81
+ return error;
82
+ return new Error(text.replaceAll("targetOrganizationId", "targetTenantId"));
83
+ }
71
84
  export const reservationMachinesListInput = z.object({ reservationId: z.string() });
72
85
  export const reservationMachinesListCapability = defineStaffMcpCapability({
73
86
  id: "reservation.machines.list",
@@ -86,32 +99,55 @@ export const reservationMachinesListCapability = defineStaffMcpCapability({
86
99
  });
87
100
  export const reservationSshKeyAddInput = z.object({
88
101
  reservationId: z.string(),
102
+ tenantId: z.string(),
89
103
  userId: z.string(),
90
- publicKey: z.string().min(1),
104
+ sshKeyId: z.string().optional(),
105
+ keyId: z.string().optional(),
106
+ publicKey: z.string().min(1).optional(),
91
107
  label: z.string().optional(),
92
- nodeId: z.string().optional(),
93
108
  confirm: z.boolean().optional(),
94
109
  });
95
110
  export const reservationSshKeyAddCapability = defineStaffMcpCapability({
96
111
  id: "reservation.ssh-key.add",
97
112
  domain: "reservations",
98
113
  roles: ["admin"],
99
- description: "Internal staff only: add an SSH key to a reservation and queue sync to its machines. Requires confirm: true.",
114
+ description: "Internal staff only: attach an SSH key to a reservation for a tenant user. " +
115
+ "Pass sshKeyId to attach a key the organization already has, or publicKey (with label) to register a new " +
116
+ "organization key and attach it. Attaching does not itself push the key to live machines; use ssh_key_push for that. " +
117
+ "Requires confirm: true.",
100
118
  mutation: "preview-confirm",
101
119
  http: { method: "POST", path: "/internal/v1/reservations/{reservationId}/ssh-keys" },
102
120
  input: reservationSshKeyAddInput,
103
121
  execute: async (ctx, input) => {
104
122
  const parsed = reservationSshKeyAddInput.parse(input);
105
- const body = {
106
- user_id: parsed.userId,
107
- public_key: parsed.publicKey,
108
- ...mapBody(parsed, { label: "label", nodeId: "node_id" }),
109
- };
110
- if (!ctx.confirmed)
111
- return preview("reservation.ssh-key.add", body);
123
+ const keyId = parsed.sshKeyId ?? parsed.keyId;
124
+ if (!keyId && !parsed.publicKey) {
125
+ throw new Error("Pass sshKeyId to attach an existing organization key, or publicKey to register a new one.");
126
+ }
127
+ if (!ctx.confirmed) {
128
+ return preview("reservation.ssh-key.add", {
129
+ reservationId: parsed.reservationId,
130
+ userId: parsed.userId,
131
+ ...(keyId ? { sshKeyId: keyId } : { publicKey: parsed.publicKey, label: parsed.label }),
132
+ });
133
+ }
134
+ // Commerce attaches an existing authorization key by id; it reads only
135
+ // sshKeyId/userId. The old body also sent public_key, label and node_id,
136
+ // which bound nothing, so a publicKey-only call always failed.
137
+ let sshKeyId = keyId;
138
+ if (!sshKeyId) {
139
+ const registered = (await ctx.fetch(`/v1/organizations/${seg(parsed.tenantId)}/ssh-keys`, {
140
+ method: "POST",
141
+ body: { public_key: parsed.publicKey, ...mapBody(parsed, { label: "label" }) },
142
+ }));
143
+ if (!registered.id) {
144
+ throw new Error("Authorization did not return an SSH key id.");
145
+ }
146
+ sshKeyId = registered.id;
147
+ }
112
148
  return ctx.fetch(`/internal/v1/reservations/${seg(parsed.reservationId)}/ssh-keys`, {
113
149
  method: "POST",
114
- body,
150
+ body: { sshKeyId, userId: parsed.userId },
115
151
  });
116
152
  },
117
153
  });
@@ -119,69 +155,181 @@ export const reservationSshKeyRemoveInput = z.object({
119
155
  reservationId: z.string(),
120
156
  keyId: z.string(),
121
157
  userId: z.string(),
122
- nodeId: z.string().optional(),
123
158
  confirm: z.boolean().optional(),
124
159
  });
125
160
  export const reservationSshKeyRemoveCapability = defineStaffMcpCapability({
126
161
  id: "reservation.ssh-key.remove",
127
162
  domain: "reservations",
128
163
  roles: ["admin"],
129
- description: "Internal staff only: remove an SSH key from a reservation and queue cleanup on its machines. Requires confirm: true.",
164
+ description: "Internal staff only: detach an SSH key from a reservation for a tenant user. " +
165
+ "Detaching does not itself remove the key from live machines; use ssh_key_push to resync them. Requires confirm: true.",
130
166
  mutation: "preview-confirm",
131
167
  http: { method: "DELETE", path: "/internal/v1/reservations/{reservationId}/ssh-keys/{keyId}" },
132
168
  input: reservationSshKeyRemoveInput,
133
169
  execute: async (ctx, input) => {
134
170
  const parsed = reservationSshKeyRemoveInput.parse(input);
135
- const body = {
136
- user_id: parsed.userId,
137
- ...mapBody(parsed, { nodeId: "node_id" }),
138
- };
139
- if (!ctx.confirmed)
140
- return preview("reservation.ssh-key.remove", body);
141
- return ctx.fetch(`/internal/v1/reservations/${seg(parsed.reservationId)}/ssh-keys/${seg(parsed.keyId)}`, { method: "DELETE", body });
171
+ if (!ctx.confirmed) {
172
+ return preview("reservation.ssh-key.remove", {
173
+ reservationId: parsed.reservationId,
174
+ keyId: parsed.keyId,
175
+ userId: parsed.userId,
176
+ });
177
+ }
178
+ // The handler reads userId from the query string, not the body. Sent as a
179
+ // body it was dropped and the detach ran unscoped across every user.
180
+ const result = await ctx.fetch(`/internal/v1/reservations/${seg(parsed.reservationId)}/ssh-keys/${seg(parsed.keyId)}?userId=${seg(parsed.userId)}`, { method: "DELETE" });
181
+ return (result ?? {
182
+ removed: true,
183
+ reservationId: parsed.reservationId,
184
+ keyId: parsed.keyId,
185
+ userId: parsed.userId,
186
+ });
142
187
  },
143
188
  });
144
189
  export const reservationPublishInput = z.object({
145
- reservationId: z.string(),
190
+ reservationId: z.string().optional(),
191
+ reservation_id: z.string().optional(),
192
+ version: z.number().int().positive().optional(),
193
+ awaitDownPayment: z.boolean().optional(),
194
+ await_down_payment: z.boolean().optional(),
146
195
  confirm: z.boolean().optional(),
147
196
  });
148
197
  export const reservationPublishCapability = defineStaffMcpCapability({
149
198
  id: "reservation.publish",
150
199
  domain: "reservations",
151
200
  roles: ["admin"],
152
- description: "Internal staff only: publish a commerce draft into a live Commerce reservation. Requires confirm: true.",
201
+ description: "Internal staff only: publish a commerce draft or uncommitted bid reservation into a live Commerce reservation. Requires confirm: true.",
153
202
  mutation: "preview-confirm",
154
203
  http: { method: "POST", path: "/internal/reservations/{reservationId}/publish" },
155
204
  input: reservationPublishInput,
156
205
  execute: async (ctx, input) => {
157
206
  const parsed = reservationPublishInput.parse(input);
207
+ const reservationId = parsed.reservationId || parsed.reservation_id;
208
+ if (!reservationId) {
209
+ throw new Error("reservationId is required");
210
+ }
211
+ const version = await resolveVersion(ctx, `/internal/reservations/${seg(reservationId)}`, parsed.version);
212
+ const awaitDownPayment = parsed.awaitDownPayment ?? parsed.await_down_payment;
213
+ const body = {
214
+ ...(version !== undefined ? { version } : {}),
215
+ ...(awaitDownPayment !== undefined ? { awaitDownPayment, await_down_payment: awaitDownPayment } : {}),
216
+ };
158
217
  if (!ctx.confirmed) {
159
- return preview("reservation.publish", { reservationId: parsed.reservationId });
218
+ return preview("reservation.publish", { reservationId, ...body });
160
219
  }
161
- return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}/publish`, {
220
+ return ctx.fetch(`/internal/reservations/${seg(reservationId)}/publish`, {
162
221
  method: "POST",
222
+ ...(Object.keys(body).length > 0 ? { body } : {}),
163
223
  });
164
224
  },
165
225
  });
226
+ export const reservationShowInput = z.object({ reservationId: z.string() });
227
+ // Accepting a bid inserts its fill reservation as 'uncommitted' (commerce's
228
+ // InsertBidFillReservation): it holds no capacity and carries no invoice until
229
+ // it is published, and buyer-facing reads deliberately filter it out. Staff had
230
+ // no read of their own, so the reservationId that bid.accept returns could not
231
+ // be looked up anywhere at all. These two verbs are the admin-side read, which
232
+ // does surface uncommitted rows.
233
+ export const reservationShowCapability = defineStaffMcpCapability({
234
+ id: "reservation.show",
235
+ domain: "reservations",
236
+ roles: ["reviewer", "admin"],
237
+ description: "Internal staff only: show one commerce reservation, including draft and uncommitted rows that " +
238
+ "customer-facing reads hide (an accepted bid's fill reservation is uncommitted until it is published).",
239
+ mutation: "read",
240
+ http: { method: "GET", path: "/internal/reservations/{reservationId}" },
241
+ input: reservationShowInput,
242
+ execute: async (ctx, input) => {
243
+ const parsed = reservationShowInput.parse(input);
244
+ return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}`, { method: "GET" });
245
+ },
246
+ });
247
+ export const reservationListInput = z.object({
248
+ organizationId: z.string().optional(),
249
+ status: z.string().optional(),
250
+ cursor: z.string().optional(),
251
+ limit: z.number().int().min(1).max(500).optional(),
252
+ });
253
+ export const reservationListCapability = defineStaffMcpCapability({
254
+ id: "reservation.list",
255
+ domain: "reservations",
256
+ roles: ["reviewer", "admin"],
257
+ description: "Internal staff only: list commerce reservations, optionally filtered by tenant and status. " +
258
+ "Includes draft and uncommitted rows that customer-facing reads hide.",
259
+ mutation: "read",
260
+ http: { method: "GET", path: "/internal/reservations" },
261
+ input: reservationListInput,
262
+ execute: async (ctx, input) => {
263
+ const parsed = reservationListInput.parse(input ?? {});
264
+ const parts = [];
265
+ if (parsed.organizationId) {
266
+ parts.push(`organization_id=${encodeURIComponent(parsed.organizationId)}`);
267
+ }
268
+ if (parsed.status)
269
+ parts.push(`status=${encodeURIComponent(parsed.status)}`);
270
+ if (parsed.cursor)
271
+ parts.push(`cursor=${encodeURIComponent(parsed.cursor)}`);
272
+ if (parsed.limit)
273
+ parts.push(`limit=${encodeURIComponent(String(parsed.limit))}`);
274
+ const qs = parts.length > 0 ? `?${parts.join("&")}` : "";
275
+ return ctx.fetch(`/internal/reservations${qs}`, { method: "GET" });
276
+ },
277
+ });
278
+ // Kept a plain object because tool registration reads `schema.shape`; the
279
+ // required-ness is enforced in execute below.
166
280
  export const bidAcceptInput = z.object({
167
- bidId: z.string(),
281
+ bidId: z.string().optional(),
282
+ bid_id: z.string().optional(),
168
283
  acceptedGpuCount: z.number().int().min(1).optional(),
284
+ accepted_gpu_count: z.number().int().min(1).optional(),
285
+ fillNodeCount: z.number().int().min(1).optional(),
286
+ fill_node_count: z.number().int().min(1).optional(),
169
287
  confirm: z.boolean().optional(),
170
288
  });
171
289
  export const bidAcceptCapability = defineStaffMcpCapability({
172
290
  id: "bid.accept",
173
291
  domain: "bids",
174
292
  roles: ["admin"],
175
- description: "Internal staff only: accept an active bid. Optionally set acceptedGpuCount (defaults to the bid's full gpu_count). Mirrors the internal dashboard's Accept action.",
293
+ description: "Internal staff only: accept an active bid. acceptedGpuCount (alias fillNodeCount) is required - commerce " +
294
+ "has no default; pass the bid's full nodeCount to fill it completely, or a smaller number for a partial " +
295
+ "fill. Mirrors the internal dashboard's Accept action. Automatically publishes the resulting reservation " +
296
+ "so it holds capacity and is immediately visible to customer reads.",
176
297
  mutation: "preview-confirm",
177
298
  http: { method: "POST", path: "/internal/bids/{bidId}/accept" },
178
299
  input: bidAcceptInput,
179
300
  execute: async (ctx, input) => {
180
301
  const parsed = bidAcceptInput.parse(input);
181
- const body = mapBody(parsed, { acceptedGpuCount: "accepted_gpu_count" });
302
+ const bidId = parsed.bidId || parsed.bid_id;
303
+ if (!bidId) {
304
+ throw new Error("bidId is required");
305
+ }
306
+ const count = parsed.acceptedGpuCount ?? parsed.accepted_gpu_count ?? parsed.fillNodeCount ?? parsed.fill_node_count;
307
+ // commerce's acceptBidRequest.Validate rejects a missing count outright
308
+ // (services/commerce/internal/api/admin/bid_types.go), so refuse here
309
+ // rather than sending a call that always 400s.
310
+ if (count === undefined) {
311
+ throw new Error("acceptedGpuCount (or fillNodeCount) is required; commerce has no default fill count. Pass the bid's nodeCount to fill it completely.");
312
+ }
313
+ const body = { accepted_gpu_count: count };
182
314
  if (!ctx.confirmed)
183
- return preview("bid.accept", { bidId: parsed.bidId, ...body });
184
- return ctx.fetch(`/internal/bids/${seg(parsed.bidId)}/accept`, { method: "POST", body });
315
+ return preview("bid.accept", { bidId, ...body });
316
+ const accepted = (await ctx.fetch(`/internal/bids/${seg(bidId)}/accept`, { method: "POST", body }));
317
+ const reservationId = (accepted?.reservationId ?? accepted?.reservation_id);
318
+ if (reservationId) {
319
+ try {
320
+ await ctx.fetch(`/internal/reservations/${seg(reservationId)}/publish`, {
321
+ method: "POST",
322
+ body: {},
323
+ });
324
+ }
325
+ catch (publishErr) {
326
+ return {
327
+ ...accepted,
328
+ publishWarning: `Bid accepted successfully, but automatic reservation publish failed: ${publishErr instanceof Error ? publishErr.message : String(publishErr)}. Use admin_reservation_publish to retry.`,
329
+ };
330
+ }
331
+ }
332
+ return accepted;
185
333
  },
186
334
  });
187
335
  export const bidRejectInput = z.object({
@@ -203,10 +351,44 @@ export const bidRejectCapability = defineStaffMcpCapability({
203
351
  return ctx.fetch(`/internal/bids/${seg(parsed.bidId)}/reject`, { method: "POST" });
204
352
  },
205
353
  });
354
+ export const bidShowInput = z.object({
355
+ bidId: z.string().optional(),
356
+ bid_id: z.string().optional(),
357
+ });
358
+ export const bidShowCapability = defineStaffMcpCapability({
359
+ id: "bid.show",
360
+ domain: "bids",
361
+ roles: ["reviewer", "admin"],
362
+ description: "Internal staff only: fetch a single buyer bid by ID.",
363
+ mutation: "read",
364
+ http: { method: "GET", path: "/internal/bids/{bidId}" },
365
+ input: bidShowInput,
366
+ execute: async (ctx, input) => {
367
+ const parsed = bidShowInput.parse(input);
368
+ const bidId = parsed.bidId || parsed.bid_id;
369
+ if (!bidId) {
370
+ throw new Error("bidId is required");
371
+ }
372
+ return ctx.fetch(`/internal/bids/${seg(bidId)}`, { method: "GET" });
373
+ },
374
+ });
375
+ /** Commerce parses listing windows as RFC3339 timestamps, not bare dates. */
376
+ function rfc3339(value) {
377
+ const trimmed = value.trim();
378
+ if (!trimmed.includes("T"))
379
+ return `${trimmed}T00:00:00Z`;
380
+ return trimmed.endsWith("Z") || /[+-]\d{2}:\d{2}$/.test(trimmed) ? trimmed : `${trimmed}Z`;
381
+ }
206
382
  export const inventoryUpdateInput = z.object({
207
383
  inventoryId: z.string(),
208
- siteOperator: z.string().min(1).optional(),
209
- siteNickname: z.string().min(1).optional(),
384
+ version: z
385
+ .number()
386
+ .int()
387
+ .positive()
388
+ .optional()
389
+ .describe("Optimistic-concurrency version. Read from the listing when omitted."),
390
+ operatorId: z.string().min(1).optional(),
391
+ facilityId: z.string().min(1).optional(),
210
392
  gpuType: z.string().min(1).optional(),
211
393
  nodeCount: z
212
394
  .number()
@@ -215,16 +397,15 @@ export const inventoryUpdateInput = z.object({
215
397
  .optional()
216
398
  .describe("Total nodes (listing capacity), not total GPUs."),
217
399
  gpusPerNode: z.number().int().min(1).max(32).optional(),
218
- availableFrom: z.string().nullable().optional(),
219
- availableTo: z.string().nullable().optional(),
220
- availableStartAt: z.string().nullable().optional(),
221
- availableEndAt: z.string().nullable().optional(),
400
+ availableFrom: z.string().optional(),
401
+ availableTo: z.string().optional(),
402
+ availableStartAt: z.string().optional(),
403
+ availableEndAt: z.string().optional(),
222
404
  cpu: z.string().nullable().optional(),
223
405
  ram: z.string().nullable().optional(),
224
406
  fabricType: z.string().nullable().optional(),
225
407
  storage: z.string().nullable().optional(),
226
408
  internet: z.string().nullable().optional(),
227
- networkHardware: z.string().nullable().optional(),
228
409
  buyNowPricePerGpuHour: z.number().positive().nullable().optional(),
229
410
  confirm: z.boolean().optional(),
230
411
  });
@@ -233,30 +414,40 @@ export const inventoryUpdateCapability = defineStaffMcpCapability({
233
414
  domain: "listings",
234
415
  roles: ["admin"],
235
416
  description: "Internal staff only: patch editable fields on a forward inventory listing. Requires confirm: true. " +
236
- "At least one editable field is required. buyNowPricePerGpuHour may be null to clear the price.",
417
+ "At least one editable field is required. buyNowPricePerGpuHour may be null to clear the price. " +
418
+ "operatorId/facilityId take UUIDs. Site nickname and network hardware are not patchable on a listing.",
237
419
  mutation: "preview-confirm",
238
420
  http: { method: "PATCH", path: "/inventory/{inventoryId}" },
239
421
  input: inventoryUpdateInput,
240
422
  execute: async (ctx, input) => {
241
423
  const parsed = inventoryUpdateInput.parse(input);
242
424
  const body = mapBody(parsed, {
243
- siteOperator: "site_operator",
244
- siteNickname: "site_nickname",
245
- gpuType: "gpu_type",
246
- nodeCount: "node_count",
247
- gpusPerNode: "gpus_per_node",
248
- availableFrom: "available_from",
249
- availableTo: "available_to",
250
- availableStartAt: "available_start_at",
251
- availableEndAt: "available_end_at",
425
+ operatorId: "operatorId",
426
+ facilityId: "facilityId",
427
+ gpuType: "gpuType",
428
+ nodeCount: "capacityNodes",
429
+ gpusPerNode: "gpusPerNode",
252
430
  cpu: "cpu",
253
431
  ram: "ram",
254
- fabricType: "fabric_type",
432
+ fabricType: "fabricType",
255
433
  storage: "storage",
256
434
  internet: "internet",
257
- networkHardware: "network_hardware",
258
- buyNowPricePerGpuHour: "buy_now_price_per_gpu_hour",
259
435
  });
436
+ const startAt = parsed.availableStartAt ?? parsed.availableFrom;
437
+ if (startAt !== undefined)
438
+ body.startAt = rfc3339(startAt);
439
+ const endAt = parsed.availableEndAt ?? parsed.availableTo;
440
+ if (endAt !== undefined)
441
+ body.endAt = rfc3339(endAt);
442
+ // The rate is a decimal string on the wire; null clears the buy-now price.
443
+ if (parsed.buyNowPricePerGpuHour !== undefined) {
444
+ body.buyNowRatePerGpuHour =
445
+ parsed.buyNowPricePerGpuHour === null ? null : String(parsed.buyNowPricePerGpuHour);
446
+ }
447
+ // commerce's patchListingRequest.Validate refuses version <= 0, and the
448
+ // schema never carried one, so every update 400'd with "missing version".
449
+ // Resolve it the way inventory.delist does.
450
+ body.version = await resolveVersion(ctx, `/inventory/${seg(parsed.inventoryId)}`, parsed.version);
260
451
  if (!ctx.confirmed)
261
452
  return preview("inventory.update", body);
262
453
  return ctx.fetch(`/inventory/${seg(parsed.inventoryId)}`, { method: "PATCH", body });
@@ -273,18 +464,226 @@ export const tenantCreditProfileShowCapability = defineStaffMcpCapability({
273
464
  input: tenantCreditProfileShowInput,
274
465
  execute: async (ctx, input) => {
275
466
  const parsed = tenantCreditProfileShowInput.parse(input);
276
- return ctx.fetch(`/internal/organizations/${seg(parsed.tenantId)}`, { method: "GET" });
467
+ try {
468
+ return await ctx.fetch(`/internal/organizations/${seg(parsed.tenantId)}`, { method: "GET" });
469
+ }
470
+ catch (error) {
471
+ // Commerce holds a projection, not the roster: an organization only
472
+ // appears there once authorization has pushed it across, so this read
473
+ // returned ORGANIZATION_NOT_FOUND for live tenants that had never
474
+ // transacted. Authorization owns the record, so fall back to it and say
475
+ // plainly that no commerce credit profile exists yet.
476
+ if (statusOf(error) !== 404)
477
+ throw error;
478
+ const organization = await ctx.fetch(`/v1/organizations/${seg(parsed.tenantId)}`, {
479
+ method: "GET",
480
+ });
481
+ return { organization, creditProfile: null, creditProfileSource: "authorization" };
482
+ }
483
+ },
484
+ });
485
+ export const reservationDraftCreateInput = z.object({
486
+ organizationId: z.string(),
487
+ nodeCount: z.number().int().positive(),
488
+ startAt: z.string(),
489
+ endAt: z.string(),
490
+ billingMode: z.string().default("reserved"),
491
+ pricePerGpuHr: z.string(),
492
+ listingId: z.string().optional(),
493
+ accessMode: z.string().optional(),
494
+ notes: z.string().optional(),
495
+ confirm: z.boolean().optional(),
496
+ });
497
+ export const reservationDraftCreateCapability = defineStaffMcpCapability({
498
+ id: "reservation.draft-create",
499
+ domain: "reservations",
500
+ roles: ["admin"],
501
+ description: "Internal staff only: create a draft reservation in Commerce. Requires confirm: true.",
502
+ mutation: "preview-confirm",
503
+ http: { method: "POST", path: "/internal/reservations" },
504
+ input: reservationDraftCreateInput,
505
+ execute: async (ctx, input) => {
506
+ const parsed = reservationDraftCreateInput.parse(input);
507
+ const body = {
508
+ organizationId: parsed.organizationId,
509
+ nodeCount: parsed.nodeCount,
510
+ startAt: parsed.startAt,
511
+ endAt: parsed.endAt,
512
+ billingMode: parsed.billingMode,
513
+ pricePerGpuHr: parsed.pricePerGpuHr,
514
+ draft: true,
515
+ ...mapBody(parsed, {
516
+ listingId: "listingId",
517
+ accessMode: "accessMode",
518
+ notes: "notes",
519
+ }),
520
+ };
521
+ if (!ctx.confirmed)
522
+ return preview("reservation.draft-create", body);
523
+ return ctx.fetch("/internal/reservations", { method: "POST", body });
524
+ },
525
+ });
526
+ export const reservationUpdateInput = z.object({
527
+ reservationId: z.string(),
528
+ startAt: z.string().optional(),
529
+ endAt: z.string().optional(),
530
+ nodeCount: z.number().int().positive().optional(),
531
+ notes: z.string().nullable().optional(),
532
+ expectedStatus: z.string().optional(),
533
+ confirm: z.boolean().optional(),
534
+ });
535
+ export const reservationUpdateCapability = defineStaffMcpCapability({
536
+ id: "reservation.update",
537
+ domain: "reservations",
538
+ roles: ["admin"],
539
+ description: "Internal staff only: update an existing reservation's dates, node count, notes, or draft status. Requires confirm: true.",
540
+ mutation: "preview-confirm",
541
+ http: { method: "PATCH", path: "/internal/reservations/{reservationId}" },
542
+ input: reservationUpdateInput,
543
+ execute: async (ctx, input) => {
544
+ const parsed = reservationUpdateInput.parse(input);
545
+ const body = mapBody(parsed, {
546
+ startAt: "startAt",
547
+ endAt: "endAt",
548
+ nodeCount: "nodeCount",
549
+ notes: "notes",
550
+ expectedStatus: "expectedStatus",
551
+ });
552
+ if (!ctx.confirmed) {
553
+ return preview("reservation.update", { reservationId: parsed.reservationId, ...body });
554
+ }
555
+ return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}`, {
556
+ method: "PATCH",
557
+ body,
558
+ });
559
+ },
560
+ });
561
+ export const reservationActivateInput = z.object({
562
+ reservationId: z.string(),
563
+ version: z.number().int().optional(),
564
+ confirm: z.boolean().optional(),
565
+ });
566
+ export const reservationActivateCapability = defineStaffMcpCapability({
567
+ id: "reservation.activate",
568
+ domain: "reservations",
569
+ roles: ["admin"],
570
+ description: "Internal staff only: activate a confirmed reservation. Requires confirm: true.",
571
+ mutation: "preview-confirm",
572
+ http: { method: "POST", path: "/internal/reservations/{reservationId}/activate" },
573
+ input: reservationActivateInput,
574
+ execute: async (ctx, input) => {
575
+ const parsed = reservationActivateInput.parse(input);
576
+ const body = {
577
+ version: await resolveVersion(ctx, `/internal/reservations/${seg(parsed.reservationId)}`, parsed.version),
578
+ };
579
+ if (!ctx.confirmed)
580
+ return preview("reservation.activate", { reservationId: parsed.reservationId, ...body });
581
+ return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}/activate`, {
582
+ method: "POST",
583
+ body,
584
+ });
585
+ },
586
+ });
587
+ export const reservationCompleteInput = z.object({
588
+ reservationId: z.string(),
589
+ version: z.number().int().optional(),
590
+ confirm: z.boolean().optional(),
591
+ });
592
+ export const reservationCompleteCapability = defineStaffMcpCapability({
593
+ id: "reservation.complete",
594
+ domain: "reservations",
595
+ roles: ["admin"],
596
+ description: "Internal staff only: complete an active reservation. Requires confirm: true.",
597
+ mutation: "preview-confirm",
598
+ http: { method: "POST", path: "/internal/reservations/{reservationId}/complete" },
599
+ input: reservationCompleteInput,
600
+ execute: async (ctx, input) => {
601
+ const parsed = reservationCompleteInput.parse(input);
602
+ const body = {
603
+ version: await resolveVersion(ctx, `/internal/reservations/${seg(parsed.reservationId)}`, parsed.version),
604
+ };
605
+ if (!ctx.confirmed)
606
+ return preview("reservation.complete", { reservationId: parsed.reservationId, ...body });
607
+ return ctx.fetch(`/internal/reservations/${seg(parsed.reservationId)}/complete`, {
608
+ method: "POST",
609
+ body,
610
+ });
611
+ },
612
+ });
613
+ export const inventoryDelistInput = z.object({
614
+ inventoryId: z.string(),
615
+ version: z.number().int().positive().optional(),
616
+ confirm: z.boolean().optional(),
617
+ });
618
+ export const inventoryDelistCapability = defineStaffMcpCapability({
619
+ id: "inventory.delist",
620
+ domain: "listings",
621
+ roles: ["admin"],
622
+ description: "Internal staff only: delist an inventory listing. Sets its state to delisted. Requires confirm: true.",
623
+ mutation: "preview-confirm",
624
+ http: { method: "PATCH", path: "/inventory/{inventoryId}" },
625
+ input: inventoryDelistInput,
626
+ execute: async (ctx, input) => {
627
+ const parsed = inventoryDelistInput.parse(input);
628
+ const body = {
629
+ state: "delisted",
630
+ version: await resolveVersion(ctx, `/inventory/${seg(parsed.inventoryId)}`, parsed.version),
631
+ };
632
+ if (!ctx.confirmed)
633
+ return preview("inventory.delist", { inventoryId: parsed.inventoryId, ...body });
634
+ return ctx.fetch(`/inventory/${seg(parsed.inventoryId)}`, {
635
+ method: "PATCH",
636
+ body,
637
+ });
638
+ },
639
+ });
640
+ export const billingInvoicesListInput = z.object({
641
+ status: z
642
+ .enum(["due", "open", "paid", "draft", "void", "uncollectible"])
643
+ .default("due")
644
+ .optional(),
645
+ cursor: z.string().optional(),
646
+ limit: z.number().int().positive().max(100).optional(),
647
+ });
648
+ export const billingInvoicesListCapability = defineStaffMcpCapability({
649
+ id: "billing.invoices-list",
650
+ domain: "billing",
651
+ roles: ["reviewer", "admin"],
652
+ description: "Internal staff only: list commerce invoices with an optional status filter (defaults to due).",
653
+ mutation: "read",
654
+ http: { method: "GET", path: "/internal/invoices" },
655
+ input: billingInvoicesListInput,
656
+ execute: async (ctx, input) => {
657
+ const parsed = billingInvoicesListInput.parse(input ?? {});
658
+ const parts = [];
659
+ if (parsed.status)
660
+ parts.push(`status=${encodeURIComponent(parsed.status)}`);
661
+ if (parsed.cursor)
662
+ parts.push(`cursor=${encodeURIComponent(parsed.cursor)}`);
663
+ if (parsed.limit)
664
+ parts.push(`limit=${encodeURIComponent(String(parsed.limit))}`);
665
+ const qs = parts.length > 0 ? `?${parts.join("&")}` : "";
666
+ return ctx.fetch(`/internal/invoices${qs}`, { method: "GET" });
277
667
  },
278
668
  });
279
669
  export const staffCommerceCapabilities = [
670
+ reservationShowCapability,
671
+ reservationListCapability,
280
672
  reservationCancelCapability,
281
673
  reservationTransferCapability,
282
674
  reservationMachinesListCapability,
283
675
  reservationSshKeyAddCapability,
284
676
  reservationSshKeyRemoveCapability,
285
677
  reservationPublishCapability,
678
+ reservationDraftCreateCapability,
679
+ reservationUpdateCapability,
680
+ reservationActivateCapability,
681
+ reservationCompleteCapability,
682
+ bidShowCapability,
286
683
  bidAcceptCapability,
287
684
  bidRejectCapability,
288
685
  inventoryUpdateCapability,
686
+ inventoryDelistCapability,
289
687
  tenantCreditProfileShowCapability,
688
+ billingInvoicesListCapability,
290
689
  ];