@ornncompute/cli 1.0.64 → 1.0.72

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/README.md CHANGED
@@ -56,6 +56,10 @@ ornn completion <bash|fish|zsh> --format human
56
56
  ornn resources
57
57
  ```
58
58
 
59
+ The CLI uses Authorization's newest membership on every request, regardless of
60
+ approval. `ornn context` displays that current organization; login and launch
61
+ use the same context. Reserve browsing and purchasing still require approval.
62
+
59
63
  Advanced customer resources are registered under one noun and one verb:
60
64
 
61
65
  ```text
@@ -96,7 +100,7 @@ relisted (secondary) listing term with Markets `POST /v1/markets/bookings`
96
100
  when the window starts. `market` and `offers` call Markets (`/v1/markets`):
97
101
  buy `--nodes` at market (Markets also takes GPU counts and limit bids; the
98
102
  CLI does not send them yet), end a fill with
99
- `market end` (Commerce `POST /commerce/reservations/{id}/stop`), post an offer
103
+ `market end` (Commerce `DELETE /commerce/reservations/{id}`), post an offer
100
104
  on a whole primary listing for one of its terms (`--listing-term-id`, optional
101
105
  `--duration-seconds`), list the offers your organization made or received, and
102
106
  accept or cancel offers by `--offer-id`.
@@ -119,11 +123,15 @@ customer and takes `--organization-id UUID`, `--listing-id UUID`,
119
123
  whole seconds, the term is non-renewable Reserved, and the price is the term's.
120
124
  Reuse the key and payload on retries. Creation requires an eligible human
121
125
  terminal. Native capacity creation takes
122
- `--gpu-slug`, `--capacity-node-count`, `--gpus-per-node`, `--facility-id`,
123
- `--provisioning-type`, `--state`, `--terms` (a JSON array) and `--idempotency-key`,
124
- plus optional `--organization-id`. Supply availability dates or `--days-to-rfs`.
125
- Reuse the key and the same body on retries. Prices are integer micro-USD inside
126
- terms, with explicit duration bounds, nullable deposit and renewable flag.
126
+ `--gpu-slug`, `--gpus-per-node`, `--facility-id`, `--provisioning-type`,
127
+ `--state`, `--terms` (a JSON array) and `--idempotency-key`, plus optional
128
+ `--organization-id`. Give `--capacity-node-count`, or `--node-ids` (a JSON array)
129
+ to attach nodes at creation; the count then defaults to the number of nodes.
130
+ Primary capacity takes `--days-to-rfs` (0 when ready now) or both
131
+ `--available-at` and `--available-until`, not both. Reuse the key and the same
132
+ body on retries. Prices are integer micro-USD inside terms, with explicit
133
+ duration bounds, nullable deposit, renewable flag and optional
134
+ `preemptNoticeSeconds`.
127
135
  `ornn admin capacity-offers update` edits a selected term using `--listing-id`,
128
136
  `--listing-terms-id`, the complete term fields and `--idempotency-key`. Financial
129
137
  editing remains staff-only. The old customer `capacity-offers update` is removed.
@@ -351,9 +359,9 @@ npm install -g @ornncompute/cli@0.2.10
351
359
  ```
352
360
 
353
361
  Both directions reuse valid `auth.json`, `config.json`, and
354
- `update-check.json`. `selectedOrganizationId` is an untrusted hint; the gateway
355
- revalidates membership. Neither direction rewrites credentials or configured
356
- endpoints. Rollback is unsupported from day 91. From day 91 through day 180,
362
+ `update-check.json`. Current CLI releases ignore saved organization hints and
363
+ use the gateway's newest membership. Neither direction rewrites credentials or
364
+ configured endpoints. Rollback is unsupported from day 91. From day 91 through day 180,
357
365
  legacy compatibility calls return a non-forwarding `426 Upgrade Required`
358
366
  tombstone with `cli_version_unsupported`, minimum version `1.0.0`, and the
359
367
  migration URL. The legacy route surface is absent from day 181
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "1.0.64",
3
+ "version": "1.0.72",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -80,17 +80,12 @@ export async function loadRequiredSession({ env = process.env } = {}) {
80
80
  }
81
81
  export function userCredentialHeaders(session) {
82
82
  if (!session?.accessToken) return {};
83
- const headers = {
83
+ return {
84
84
  Authorization: `Bearer ${session.accessToken}`,
85
- // This marker and the organization selector grant nothing by themselves.
86
- // The gateway re-resolves the bearer and replaces them with trusted scope.
85
+ // The gateway resolves the bearer to trusted user and organization scope.
87
86
  "X-Ornn-Credential-Type": "user",
88
87
  "X-Ornn-CLI-Version": CLI_VERSION,
89
88
  };
90
- if (typeof session.selectedOrganizationId === "string" && session.selectedOrganizationId.trim()) {
91
- headers["X-Organization-Id"] = session.selectedOrganizationId.trim();
92
- }
93
- return headers;
94
89
  }
95
90
 
96
91
  export async function cliRequest({
@@ -134,26 +134,6 @@ export async function bindAuthUser(user, { env = process.env, expectedAccessToke
134
134
  return true;
135
135
  }
136
136
 
137
- export async function saveOrganizationHint(
138
- selectedOrganizationId,
139
- { env = process.env, expectedAccessToken } = {}
140
- ) {
141
- const session = await loadAuthSession({ env });
142
- if (
143
- !session?.accessToken ||
144
- (expectedAccessToken && session.accessToken !== expectedAccessToken)
145
- ) {
146
- return null;
147
- }
148
- const payload = { ...session };
149
- if (typeof selectedOrganizationId === "string" && selectedOrganizationId.trim()) {
150
- payload.selectedOrganizationId = selectedOrganizationId.trim();
151
- } else {
152
- delete payload.selectedOrganizationId;
153
- }
154
- return writeAuthSession(payload, env);
155
- }
156
-
157
137
  function pendingString(value, field) {
158
138
  return typeof value?.[field] === "string" ? value[field].trim() : "";
159
139
  }
@@ -236,17 +236,11 @@ function retiredSyntaxError(argv, findLegacyCliMigration, migrationGuideUrl) {
236
236
  );
237
237
  }
238
238
 
239
- function resolveRequest(catalog, argv, findLegacyCliMigration, migrationGuideUrl) {
240
- if (argv.length === 0 || (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h"))) {
239
+ function resolveRequest(catalog, argvIn, findLegacyCliMigration, migrationGuideUrl) {
240
+ const argv = argvIn[0] === "--help" || argvIn[0] === "-h" ? ["help", ...argvIn.slice(1)] : argvIn;
241
+ if (argv.length === 0) {
241
242
  return { kind: "root", command: "help" };
242
243
  }
243
- if (argv[0] === "--help" || argv[0] === "-h") {
244
- return {
245
- kind: "error",
246
- command: "help",
247
- error: new VnextUsageError("Usage: ornn [--format human|json|ndjson]"),
248
- };
249
- }
250
244
 
251
245
  const retired = retiredSyntaxError(argv, findLegacyCliMigration, migrationGuideUrl);
252
246
  if (retired) {
@@ -311,15 +305,7 @@ function resolveRequest(catalog, argv, findLegacyCliMigration, migrationGuideUrl
311
305
  if (capability) {
312
306
  const command = vnextCommand(capability);
313
307
  const rest = argv.slice(command.length);
314
- const helpFlags = rest.filter((argument) => argument === "--help" || argument === "-h");
315
- if (helpFlags.length) {
316
- if (rest.length !== 1) {
317
- return {
318
- kind: "error",
319
- command: command.join(" "),
320
- error: new VnextUsageError(`Usage: ${capability.cli.vnext.human}`),
321
- };
322
- }
308
+ if (rest.some((argument) => argument === "--help" || argument === "-h")) {
323
309
  return { kind: "help", command: `help ${command.join(" ")}`, capability };
324
310
  }
325
311
  if (rest.length && capability.cli.vnext.arguments !== "semantic") {
@@ -985,6 +971,7 @@ async function executeCapability(
985
971
  `${resolveApiBaseUrl({ env: io.env })}/v1/orchestrator`)
986
972
  : undefined;
987
973
  const parsedInput = parseCapabilityInput(capability, rawInput);
974
+ let ownOrganizationId;
988
975
  let data;
989
976
  try {
990
977
  data = await capability.execute(
@@ -993,7 +980,7 @@ async function executeCapability(
993
980
  confirmed,
994
981
  installerBaseUrl,
995
982
  role,
996
- fetch: (path, init = {}) => {
983
+ fetch: async (path, init = {}) => {
997
984
  if (executionRuntime?.transport) return executionRuntime.transport(path, init);
998
985
  if (audience === "admin") {
999
986
  // Shared observability capabilities use their owner namespace;
@@ -1004,6 +991,11 @@ async function executeCapability(
1004
991
  : path;
1005
992
  return adminFetch(io, endpoint, init);
1006
993
  }
994
+ if (capability.domain === "listings" && ownOrganizationId === undefined) {
995
+ // Verify listing owner IDs before the request, never after a committed mutation.
996
+ const session = await customerCapabilityFetch(io, "/v1/cli/session", {}, authSnapshot);
997
+ ownOrganizationId = session.selectedOrganization?.id ?? null;
998
+ }
1007
999
  return customerCapabilityFetch(io, path, init, authSnapshot);
1008
1000
  },
1009
1001
  cli: {
@@ -1026,7 +1018,7 @@ async function executeCapability(
1026
1018
  interaction?.close();
1027
1019
  }
1028
1020
  if (audience === "customer") {
1029
- assertCustomerResultSafe(data, authSnapshot?.current?.selectedOrganizationId);
1021
+ assertCustomerResultSafe(data, ownOrganizationId);
1030
1022
  }
1031
1023
  if (audience === "admin" || sessionCommand || isInteractionExecution(data)) return data;
1032
1024
  return {
@@ -868,35 +868,15 @@ async function resolveLaunchReservation(context, runtime, input, accessMode) {
868
868
  return fetchReservation(context.fetch, runtime, selected, accessMode);
869
869
  }
870
870
 
871
- async function selectedOrganization(context, input, runtime) {
871
+ async function selectedOrganization(context, runtime) {
872
872
  const session = context.cli?.session;
873
873
  if (!session) throw new Error("The current organization session is unavailable.");
874
- let result = record(await session("context", { organization: input.organization }));
875
- let data = record(result.data);
876
- let selected = record(data.selected_organization);
877
- if (!text(selected.id) && runtime.eligibleHuman) {
878
- const choices = (Array.isArray(data.organizations) ? data.organizations : [])
879
- .map(record)
880
- .filter(
881
- (organization) =>
882
- organization.approved === true && text(organization.id) && text(organization.name)
883
- )
884
- .map((organization) => ({ label: text(organization.name), value: text(organization.id) }));
885
- if (choices.length > 1) {
886
- const answer = await runtime.ask({ label: "Organization", choices });
887
- if (!answer)
888
- runtime.usage("Choose an approved organization before launch.", {
889
- code: "organization_required",
890
- });
891
- result = record(await session("context", { organization: answer }));
892
- data = record(result.data);
893
- selected = record(data.selected_organization);
894
- }
895
- }
874
+ const result = record(await session("context", {}));
875
+ const selected = record(record(result.data).selected_organization);
896
876
  const id = text(selected.id);
897
877
  const name = text(selected.name);
898
- if (!id || !name || selected.approved !== true) {
899
- runtime.usage("Select one approved organization before launch.", {
878
+ if (!id || !name) {
879
+ runtime.usage("Join an organization before launching compute.", {
900
880
  code: "organization_required",
901
881
  field: "organization",
902
882
  });
@@ -934,12 +914,6 @@ function clusterMachineLaunchPreflight(runtime, input, membershipCount) {
934
914
  });
935
915
  }
936
916
  if (runtime.eligibleHuman) return;
937
- if (!UUID.test(text(input.organization) ?? "")) {
938
- runtime.usage("Automation requires a canonical organization ID.", {
939
- code: "canonical_organization_id_required",
940
- field: "organization",
941
- });
942
- }
943
917
  if (input.yes !== true) {
944
918
  runtime.usage("--yes is required for automation after reviewing the exact intent.", {
945
919
  code: "consent_required",
@@ -1153,7 +1127,7 @@ export async function launchClusterMode(
1153
1127
  clusterMachineLaunchPreflight(runtime, input, membershipCount);
1154
1128
  if (accessMode === "slurm") keyInputShapePreflight(input, runtime);
1155
1129
  const idempotencyKey = requireMutationKey(runtime, input);
1156
- const organization = await selectedOrganization(context, input, runtime);
1130
+ const organization = await selectedOrganization(context, runtime);
1157
1131
  const keyPlan = accessMode === "slurm" ? await keyPlanFromInput(context, input, runtime) : null;
1158
1132
  const reservation = await resolveLaunchReservation(context, runtime, input, accessMode);
1159
1133
  const reservationId = text(reservation.id);
@@ -1321,11 +1295,7 @@ export async function launchClusterMode(
1321
1295
  presentation: { lines: ["No cluster was created."] },
1322
1296
  });
1323
1297
  }
1324
- const currentOrganization = await selectedOrganization(
1325
- context,
1326
- { organization: organization.id },
1327
- runtime
1328
- );
1298
+ const currentOrganization = await selectedOrganization(context, runtime);
1329
1299
  if (currentOrganization.id !== organization.id) {
1330
1300
  runtime.error("The selected organization changed after review; no cluster was created.", {
1331
1301
  code: "organization_changed",
@@ -1,5 +1,3 @@
1
- import { createInterface } from "node:readline/promises";
2
-
3
1
  import { CliApiError, cliRequest, resolveApiBaseUrl } from "./api-client.mjs";
4
2
  import {
5
3
  bindAuthUser,
@@ -7,7 +5,6 @@ import {
7
5
  getAuthConfigPath,
8
6
  loadAuthSession,
9
7
  saveAuthSession,
10
- saveOrganizationHint,
11
8
  } from "./auth-store.mjs";
12
9
  import {
13
10
  DeviceAuthError,
@@ -17,8 +14,6 @@ import {
17
14
  waitForDeviceApproval,
18
15
  } from "./device-auth.mjs";
19
16
 
20
- const CANONICAL_ID = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
21
-
22
17
  export class SessionCommandError extends Error {
23
18
  constructor(
24
19
  message,
@@ -83,25 +78,22 @@ function positiveNumberOption(value, name, usageText) {
83
78
 
84
79
  export function parseSessionCommandInput(capabilityId, argv) {
85
80
  if (capabilityId === "identity.login") {
86
- const usageText =
87
- "Usage: ornn login [--organization <exact-name>] [--no-browser] [--timeout <seconds>]";
81
+ const usageText = "Usage: ornn login [--no-browser] [--timeout <seconds>]";
88
82
  const parsed = parseOptions(argv, {
89
83
  booleans: ["--no-browser"],
90
- values: ["--auth-base", "--organization", "--poll-interval", "--timeout"],
84
+ values: ["--auth-base", "--poll-interval", "--timeout"],
91
85
  usageText,
92
86
  });
93
87
  return {
94
88
  authBase: parsed["auth-base"],
95
89
  noBrowser: parsed["no-browser"],
96
- organization: parsed.organization,
97
90
  pollInterval: positiveNumberOption(parsed["poll-interval"], "--poll-interval", usageText),
98
91
  timeout: positiveNumberOption(parsed.timeout, "--timeout", usageText),
99
92
  };
100
93
  }
101
94
  if (capabilityId === "identity.context") {
102
95
  return parseOptions(argv, {
103
- values: ["--organization"],
104
- usageText: "Usage: ornn context [--organization <exact-name-or-id>]",
96
+ usageText: "Usage: ornn context",
105
97
  });
106
98
  }
107
99
  if (capabilityId === "identity.logout") {
@@ -142,154 +134,7 @@ function sessionContext(value) {
142
134
  };
143
135
  }
144
136
 
145
- async function readServerContext({ env, fetchImpl, session: suppliedSession }) {
146
- const session = suppliedSession === undefined ? await loadAuthSession({ env }) : suppliedSession;
147
- try {
148
- return sessionContext(
149
- await cliRequest({
150
- endpoint: "/v1/cli/session",
151
- env,
152
- fetchImpl,
153
- method: "GET",
154
- ...(session ? { session } : {}),
155
- })
156
- );
157
- } catch (error) {
158
- if (
159
- !(error instanceof CliApiError) ||
160
- error.status !== 403 ||
161
- !session?.accessToken ||
162
- !session.selectedOrganizationId
163
- ) {
164
- throw error;
165
- }
166
- const unscopedSession = { ...session };
167
- delete unscopedSession.selectedOrganizationId;
168
- return sessionContext(
169
- await cliRequest({
170
- endpoint: "/v1/cli/session",
171
- env,
172
- fetchImpl,
173
- method: "GET",
174
- session: unscopedSession,
175
- })
176
- );
177
- }
178
- }
179
-
180
- function selectionAction(machine) {
181
- return {
182
- type: "command",
183
- command: machine
184
- ? "ornn context --organization <canonical-id> --format json"
185
- : "ornn context --organization <exact-name>",
186
- description: machine
187
- ? "Select one approved organization by canonical identifier."
188
- : "Select one approved organization by its exact displayed name.",
189
- };
190
- }
191
-
192
- function findApprovedOrganization(organizations, selector, machine) {
193
- if (machine && !CANONICAL_ID.test(selector)) {
194
- throw new SessionCommandError(
195
- "Automation must select an organization by canonical identifier.",
196
- {
197
- action: selectionAction(true),
198
- code: "organization_id_required",
199
- exitCode: 2,
200
- }
201
- );
202
- }
203
- const matches = organizations.filter((candidate) =>
204
- machine ? candidate.id === selector : candidate.id === selector || candidate.name === selector
205
- );
206
- if (matches.length === 0) {
207
- throw new SessionCommandError("The requested organization is not a current membership.", {
208
- action: selectionAction(machine),
209
- code: "organization_not_available",
210
- exitCode: 5,
211
- });
212
- }
213
- if (matches.length > 1) {
214
- throw new SessionCommandError(
215
- "More than one organization has that name; select by canonical identifier.",
216
- {
217
- action: selectionAction(true),
218
- code: "organization_ambiguous",
219
- exitCode: 2,
220
- }
221
- );
222
- }
223
- if (!matches[0].approved) {
224
- throw new SessionCommandError(`Organization ${matches[0].name} is not approved.`, {
225
- code: "organization_not_approved",
226
- exitCode: 5,
227
- });
228
- }
229
- return matches[0];
230
- }
231
-
232
- async function promptForOrganization(organizations, { stdin, stdout }) {
233
- stdout.write("Choose an approved organization by typing its exact name:\n");
234
- for (const candidate of organizations) stdout.write(` ${candidate.name}\n`);
235
- const readline = createInterface({ input: stdin, output: stdout, terminal: false });
236
- try {
237
- return await readline.question("Organization: ");
238
- } finally {
239
- readline.close();
240
- }
241
- }
242
-
243
- function updateSnapshotOrganization(sessionSnapshot, selectedOrganizationId) {
244
- if (!sessionSnapshot?.current) return;
245
- const next = { ...sessionSnapshot.current };
246
- if (selectedOrganizationId) next.selectedOrganizationId = selectedOrganizationId;
247
- else delete next.selectedOrganizationId;
248
- sessionSnapshot.current = next;
249
- }
250
-
251
- async function selectAndRevalidate(
252
- selector,
253
- context,
254
- { env, fetchImpl, machine, sessionSnapshot }
255
- ) {
256
- const selected = findApprovedOrganization(context.organizations, selector, machine);
257
- const expectedAccessToken = sessionSnapshot?.current?.accessToken;
258
- await saveOrganizationHint(selected.id, { env, expectedAccessToken });
259
- updateSnapshotOrganization(sessionSnapshot, selected.id);
260
- const revalidated = await readServerContext({
261
- env,
262
- fetchImpl,
263
- session: sessionSnapshot?.current,
264
- });
265
- if (revalidated.selected_organization?.id !== selected.id) {
266
- await saveOrganizationHint(null, { env, expectedAccessToken });
267
- updateSnapshotOrganization(sessionSnapshot, null);
268
- throw new SessionCommandError(
269
- "Organization membership changed before selection could be verified.",
270
- {
271
- action: selectionAction(machine),
272
- code: "organization_membership_changed",
273
- exitCode: 5,
274
- }
275
- );
276
- }
277
- return revalidated;
278
- }
279
-
280
- async function resolveContext(
281
- input,
282
- {
283
- eligibleHuman,
284
- env,
285
- fetchImpl,
286
- machine,
287
- promptIfMultiple = false,
288
- sessionSnapshot,
289
- stdin,
290
- stdout,
291
- }
292
- ) {
137
+ async function context({ env, fetchImpl, sessionSnapshot }) {
293
138
  const localSession =
294
139
  sessionSnapshot?.current === undefined
295
140
  ? await loadAuthSession({ env })
@@ -297,83 +142,28 @@ async function resolveContext(
297
142
  if (sessionSnapshot && sessionSnapshot.current === undefined) {
298
143
  sessionSnapshot.current = localSession;
299
144
  }
300
- let context = await readServerContext({ env, fetchImpl, session: localSession });
301
- await bindAuthUser(context.user, {
145
+ const data = sessionContext(
146
+ await cliRequest({
147
+ endpoint: "/v1/cli/session",
148
+ env,
149
+ fetchImpl,
150
+ method: "GET",
151
+ session: localSession,
152
+ })
153
+ );
154
+ await bindAuthUser(data.user, {
302
155
  env,
303
156
  expectedAccessToken: localSession?.accessToken,
304
157
  });
305
- if (sessionSnapshot?.current && context.user?.id) {
158
+ if (sessionSnapshot?.current && data.user?.id) {
306
159
  sessionSnapshot.current = {
307
160
  ...sessionSnapshot.current,
308
- user: { ...(sessionSnapshot.current.user ?? {}), ...context.user },
309
- };
310
- }
311
- const localHint = localSession?.selectedOrganizationId;
312
- const hintIsCurrent =
313
- context.selected_organization && context.selected_organization.id === localHint;
314
- if (localHint && !hintIsCurrent) {
315
- await saveOrganizationHint(null, {
316
- env,
317
- expectedAccessToken: localSession?.accessToken,
318
- });
319
- updateSnapshotOrganization(sessionSnapshot, null);
320
- }
321
-
322
- const selectionRuntime = { env, fetchImpl, machine, sessionSnapshot };
323
- if (input.organization) {
324
- return {
325
- context: await selectAndRevalidate(input.organization, context, selectionRuntime),
326
- action: null,
327
- exitCode: 0,
328
- outcome: { class: "ready", phase: "complete" },
161
+ user: { ...(sessionSnapshot.current.user ?? {}), ...data.user },
329
162
  };
330
163
  }
331
164
 
332
- if (hintIsCurrent) {
333
- return {
334
- context,
335
- action: null,
336
- exitCode: 0,
337
- outcome: { class: "ready", phase: "complete" },
338
- };
339
- }
340
-
341
- const approved = context.organizations.filter((candidate) => candidate.approved);
342
- if (approved.length === 1) {
343
- context = await selectAndRevalidate(approved[0].id, context, {
344
- ...selectionRuntime,
345
- machine: true,
346
- });
347
- return {
348
- context,
349
- action: null,
350
- exitCode: 0,
351
- outcome: { class: "ready", phase: "complete" },
352
- };
353
- }
354
- if (approved.length > 1 && promptIfMultiple && eligibleHuman) {
355
- const answer = await promptForOrganization(approved, { stdin, stdout });
356
- context = await selectAndRevalidate(answer, context, {
357
- ...selectionRuntime,
358
- machine: false,
359
- });
360
- return {
361
- context,
362
- action: null,
363
- exitCode: 0,
364
- outcome: { class: "ready", phase: "complete" },
365
- };
366
- }
367
- if (approved.length > 1) {
368
- return {
369
- context,
370
- action: selectionAction(machine),
371
- exitCode: 3,
372
- outcome: { class: "action_required", phase: "organization_selection" },
373
- };
374
- }
375
165
  return {
376
- context,
166
+ data,
377
167
  action: null,
378
168
  exitCode: 0,
379
169
  outcome: { class: "ready", phase: "complete" },
@@ -485,26 +275,8 @@ async function login(input, runtime) {
485
275
 
486
276
  await saveAuthSession(session, { env: runtime.env });
487
277
  runtime.stderr.write(`\nSession saved to ${getAuthConfigPath(runtime.env)}\n`);
488
- const resolved = await resolveContext(
489
- { organization: input.organization },
490
- { ...runtime, machine: false, promptIfMultiple: true }
491
- );
492
- return {
493
- data: { ...resolved.context, session_saved: true },
494
- action: resolved.action,
495
- exitCode: resolved.exitCode,
496
- outcome: resolved.outcome,
497
- };
498
- }
499
-
500
- async function context(input, runtime) {
501
- const resolved = await resolveContext(input, { ...runtime, promptIfMultiple: false });
502
- return {
503
- data: resolved.context,
504
- action: resolved.action,
505
- exitCode: resolved.exitCode,
506
- outcome: resolved.outcome,
507
- };
278
+ const resolved = await context(runtime);
279
+ return { ...resolved, data: { ...resolved.data, session_saved: true } };
508
280
  }
509
281
 
510
282
  async function logout(runtime) {
@@ -559,7 +331,7 @@ async function logout(runtime) {
559
331
 
560
332
  export async function executeSessionCommand(command, input, runtime) {
561
333
  if (command === "login") return login(input, runtime);
562
- if (command === "context") return context(input, runtime);
334
+ if (command === "context") return context(runtime);
563
335
  if (command === "logout") return logout(runtime);
564
336
  throw new CliApiError(`Unsupported session capability: ${command}`);
565
337
  }
@@ -408,39 +408,25 @@ function machineMutationPreflight(input, interaction, { requireYes = true } = {}
408
408
  }
409
409
  }
410
410
 
411
- async function selectedOrganization(context, input, interaction) {
411
+ async function selectedOrganization(context, interaction, expectedId) {
412
412
  const session = context.cli?.session;
413
413
  if (!session) throw new Error("The current organization session is unavailable.");
414
- let result = record(await session("context", { organization: input.organization }));
415
- let data = record(result.data);
416
- let selected = record(data.selected_organization);
417
- if (!text(selected.id) && interaction.eligibleHuman) {
418
- const choices = (Array.isArray(data.organizations) ? data.organizations : [])
419
- .map(record)
420
- .filter(
421
- (organization) =>
422
- organization.approved === true && text(organization.id) && text(organization.name)
423
- )
424
- .map((organization) => ({ label: text(organization.name), value: text(organization.id) }));
425
- if (choices.length > 1) {
426
- const answer = await interaction.ask({ label: "Organization", choices });
427
- if (!answer)
428
- interaction.usage("Choose an approved organization before launch.", {
429
- code: "organization_required",
430
- });
431
- result = record(await session("context", { organization: answer }));
432
- data = record(result.data);
433
- selected = record(data.selected_organization);
434
- }
435
- }
414
+ const result = record(await session("context", {}));
415
+ const selected = record(record(result.data).selected_organization);
436
416
  const id = text(selected.id);
437
417
  const name = text(selected.name);
438
- if (!id || !name || selected.approved !== true) {
439
- interaction.usage("Select one approved organization before launch.", {
418
+ if (!id || !name) {
419
+ interaction.usage("Join an organization before launching compute.", {
440
420
  code: "organization_required",
441
421
  field: "organization",
442
422
  });
443
423
  }
424
+ if (expectedId && id !== expectedId) {
425
+ interaction.error("Your organization changed; review the launch again before continuing.", {
426
+ code: "organization_changed",
427
+ exitCode: 2,
428
+ });
429
+ }
444
430
  return { id, name };
445
431
  }
446
432
 
@@ -820,7 +806,6 @@ function launchInputPreflight(input, interaction) {
820
806
  const target = text(input.reservation) ?? text(positionals[0]);
821
807
  if (interaction.eligibleHuman && !interaction.machine) return;
822
808
  requireCanonical(interaction, target, "reservation", "Capacity reservation");
823
- requireCanonical(interaction, input.organization, "organization", "Organization");
824
809
  const keyRef = text(input.keyId) ?? text(input.key);
825
810
  const suppliedPublicKey = text(input.publicKey) ?? text(input.publicKeyFile);
826
811
  if (!keyRef && !suppliedPublicKey) {
@@ -912,13 +897,14 @@ function launchOwnerSnapshot(machines) {
912
897
  function withAuthoritativeUsername(input, _machines, interaction) {
913
898
  const requested = text(input.username);
914
899
  if (requested && requested !== "tenant") {
915
- interaction.usage("Linux login is always tenant; --username cannot request another name.", {
916
- code: "launch_username_unsupported",
917
- });
900
+ interaction.usage(UNSUPPORTED_USERNAME_MESSAGE, { code: "launch_username_unsupported" });
918
901
  }
919
- return { ...input, username: "tenant" };
902
+ return input;
920
903
  }
921
904
 
905
+ const UNSUPPORTED_USERNAME_MESSAGE =
906
+ "--username cannot choose the Linux login; the machine reports its own login.";
907
+
922
908
  function validatedMachine(machines, key, input, interaction, expectedMode = "vm") {
923
909
  if (!machines.length) return null;
924
910
  if (machines.length !== 1) {
@@ -940,9 +926,7 @@ function validatedMachine(machines, key, input, interaction, expectedMode = "vm"
940
926
  }
941
927
  const username = text(input.username);
942
928
  if (username && username !== "tenant") {
943
- interaction.usage("Linux login is always tenant; --username cannot request another name.", {
944
- code: "launch_username_unsupported",
945
- });
929
+ interaction.usage(UNSUPPORTED_USERNAME_MESSAGE, { code: "launch_username_unsupported" });
946
930
  }
947
931
  const fingerprintValue = text(key.fingerprint);
948
932
  const assigned = Array.isArray(machine.authorized_key_fingerprints)
@@ -1072,7 +1056,7 @@ export const vmLaunchModeHandler = {
1072
1056
  async launch({ context, input, interaction }) {
1073
1057
  launchInputPreflight(input, interaction);
1074
1058
  input = await normalizePublicKeyInput(input);
1075
- const organization = await selectedOrganization(context, input, interaction);
1059
+ const organization = await selectedOrganization(context, interaction);
1076
1060
  const reservation = await resolveReservation(context, input, interaction, "vm");
1077
1061
  const reservationId = resourceId(reservation);
1078
1062
  const keyPlan = await keyPlanFromInput(context, input, interaction);
@@ -1123,6 +1107,7 @@ export const vmLaunchModeHandler = {
1123
1107
  });
1124
1108
  }
1125
1109
 
1110
+ await selectedOrganization(context, interaction, organization.id);
1126
1111
  const currentReservation = assertActiveReservation(
1127
1112
  await context.fetch(
1128
1113
  `/v1/workflows/commerce/reservations/${encodeURIComponent(reservationId)}`
@@ -1214,9 +1199,7 @@ export const vmLaunchModeHandler = {
1214
1199
  resourceId(key),
1215
1200
  "--idempotency-key",
1216
1201
  idempotencyKey,
1217
- ...(interaction.machine
1218
- ? ["--organization", resourceId(organization), "--yes", "--no-wait", "--format", "json"]
1219
- : []),
1202
+ ...(interaction.machine ? ["--yes", "--no-wait", "--format", "json"] : []),
1220
1203
  ].join(" ");
1221
1204
  return uncertainMutationFailure(interaction, error, {
1222
1205
  data: {
@@ -1684,8 +1667,6 @@ function workspaceResumeCommand(input, goal, facts, id, interaction, localResume
1684
1667
  goal.reservationId,
1685
1668
  "--mode",
1686
1669
  "bare-metal",
1687
- "--organization",
1688
- goal.organizationId,
1689
1670
  "--key",
1690
1671
  goal.keyId,
1691
1672
  "--network",
@@ -1892,6 +1873,7 @@ async function launchDurableBareMetalWorkspace({
1892
1873
  });
1893
1874
  }
1894
1875
 
1876
+ await selectedOrganization(context, interaction, organization.id);
1895
1877
  const [currentReservationValue, currentKeyPlan, currentFacts] = await Promise.all([
1896
1878
  context.fetch(`/v1/workflows/commerce/reservations/${encodeURIComponent(reservationId)}`),
1897
1879
  keyPlanFromInput(context, input, interaction),
@@ -2246,6 +2228,7 @@ export const bareMetalLaunchModeHandler = {
2246
2228
  mode: "bare-metal",
2247
2229
  async launch({ context, input, interaction }) {
2248
2230
  const durableWorkspace = workspaceIntentRequested(input);
2231
+ let resumeOrganizationId;
2249
2232
  if (durableWorkspace && input.resume === true) {
2250
2233
  if (!interaction.eligibleHuman || interaction.machine) {
2251
2234
  interaction.usage("--resume without a value requires an eligible interactive terminal.", {
@@ -2256,7 +2239,6 @@ export const bareMetalLaunchModeHandler = {
2256
2239
  const hasConflictingIntent =
2257
2240
  (input.$positionals?.length ?? 0) > 0 ||
2258
2241
  [
2259
- input.organization,
2260
2242
  input.reservation,
2261
2243
  input.reservationId,
2262
2244
  input.key,
@@ -2295,6 +2277,7 @@ export const bareMetalLaunchModeHandler = {
2295
2277
  field: "resume",
2296
2278
  });
2297
2279
  }
2280
+ resumeOrganizationId = remembered.organizationId;
2298
2281
  input = {
2299
2282
  ...input,
2300
2283
  $positionals: [remembered.reservationId],
@@ -2302,7 +2285,6 @@ export const bareMetalLaunchModeHandler = {
2302
2285
  key: remembered.keyId,
2303
2286
  mountAccess: remembered.mountAccess,
2304
2287
  network: remembered.networkId,
2305
- organization: remembered.organizationId,
2306
2288
  resume: remembered.id,
2307
2289
  username: remembered.username ?? undefined,
2308
2290
  volume: remembered.volumeId,
@@ -2323,7 +2305,7 @@ export const bareMetalLaunchModeHandler = {
2323
2305
  }
2324
2306
  launchInputPreflight(input, interaction);
2325
2307
  input = await normalizePublicKeyInput(input);
2326
- const organization = await selectedOrganization(context, input, interaction);
2308
+ const organization = await selectedOrganization(context, interaction, resumeOrganizationId);
2327
2309
  const reservation = await resolveReservation(context, input, interaction, "bare-metal");
2328
2310
  const reservationId = resourceId(reservation);
2329
2311
  const keyPlan = await keyPlanFromInput(context, input, interaction);
@@ -2398,6 +2380,7 @@ export const bareMetalLaunchModeHandler = {
2398
2380
  fetchAssignedKeys(context, reservationId),
2399
2381
  fetchMachines(context, reservationId),
2400
2382
  ]);
2383
+ await selectedOrganization(context, interaction, organization.id);
2401
2384
  const currentReservation = assertActiveReservation(
2402
2385
  currentReservationValue,
2403
2386
  reservationId,
@@ -2481,9 +2464,7 @@ export const bareMetalLaunchModeHandler = {
2481
2464
  resourceId(key),
2482
2465
  "--idempotency-key",
2483
2466
  idempotencyKey,
2484
- ...(interaction.machine
2485
- ? ["--organization", resourceId(organization), "--yes", "--no-wait", "--format", "json"]
2486
- : []),
2467
+ ...(interaction.machine ? ["--yes", "--no-wait", "--format", "json"] : []),
2487
2468
  ].join(" ");
2488
2469
  return uncertainMutationFailure(interaction, error, {
2489
2470
  data: {
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { defineCapability, preview } from "../capability.js";
3
+ import { ensureFacility, locateRegion, unplacedLocation } from "./locality.js";
3
4
  import { nativeListingCreateInput } from "./native-listing.js";
4
5
  import { idempotencyHeaders, microUsd } from "./market-shared.js";
5
6
  function adminHelp(command, outcome, human, automation, review) {
@@ -18,22 +19,28 @@ export const adminCapacityOffersCreateCapability = defineCapability({
18
19
  id: "listing.create",
19
20
  domain: "listings",
20
21
  roles: ["admin"],
21
- description: "Create native capacity with explicit organization/facility IDs, hardware, availability and advertised terms. Returns {listing, terms}. Requires an idempotencyKey reused on retries and confirm: true.",
22
+ description: "Create native capacity with an organization, a facilityId or a location, hardware, availability and advertised terms. A location is any state, city, address, or catalog region (us-west-3); Ornn places it in a catalog region and uses the organization's facility there. Primary capacity needs daysToRfs (0 when ready now) or both availableAt and availableUntil. nodeIds attach nodes at creation. Returns {listing, terms}. Requires an idempotencyKey reused on retries and confirm: true.",
22
23
  mutation: "preview-confirm",
23
24
  http: { method: "POST", path: "/commerce/v2/listings" },
24
25
  input: nativeListingCreateInput,
25
26
  slack: false,
26
27
  cli: {
27
28
  command: ["admin", "capacity-offers", "create"],
28
- vnext: adminHelp(["admin", "capacity-offers", "create"], "Create native capacity and its advertised financial terms.", "ornn admin capacity-offers create --gpu-slug GPU --capacity-node-count COUNT --gpus-per-node COUNT --facility-id UUID --provisioning-type bare-metal|virtual-machine --state draft|available --terms JSON --idempotency-key KEY [--organization-id UUID] [--available-at TIME --available-until TIME] [--days-to-rfs DAYS]", "Requires an eligible human terminal; reuse --idempotency-key on retries.", "Review the native parent fields and each financial term before default-no consent."),
29
+ vnext: adminHelp(["admin", "capacity-offers", "create"], "Create native capacity and its advertised financial terms.", "ornn admin capacity-offers create --gpu-slug GPU --gpus-per-node COUNT (--facility-id UUID | --location PLACE) --provisioning-type bare-metal|virtual-machine --state draft|available --terms JSON --idempotency-key KEY (--days-to-rfs DAYS | --available-at TIME --available-until TIME) [--capacity-node-count COUNT] [--node-ids JSON] [--organization-id UUID]", "Requires an eligible human terminal; --location also needs --organization-id; reuse --idempotency-key on retries.", "Review the native parent fields and each financial term before default-no consent."),
29
30
  },
30
31
  execute: async (ctx, input) => {
31
- const { confirm: _confirm, idempotencyKey, ...body } = nativeListingCreateInput.parse(input);
32
+ const { confirm: _confirm, idempotencyKey, location, ...body } = nativeListingCreateInput.parse(input);
33
+ const located = location ? await locateRegion(ctx.fetch, location) : null;
34
+ if (location && !located)
35
+ throw new Error(unplacedLocation(location));
32
36
  if (!ctx.confirmed)
33
- return preview("listing.create", body);
37
+ return preview("listing.create", located ? { ...body, located } : body);
38
+ const facilityId = located
39
+ ? (await ensureFacility(ctx.fetch, body.organizationId, located.region)).facilityId
40
+ : body.facilityId;
34
41
  return ctx.fetch("/commerce/v2/listings", {
35
42
  method: "POST",
36
- body,
43
+ body: { ...body, facilityId },
37
44
  headers: idempotencyHeaders(ctx, idempotencyKey),
38
45
  });
39
46
  },
@@ -16,7 +16,6 @@ export const launchInput = z
16
16
  network: z.string().optional(),
17
17
  noWait: z.boolean().optional(),
18
18
  nodes: z.union([z.string(), z.number()]).optional(),
19
- organization: z.string().optional(),
20
19
  publicKey: z.string().optional(),
21
20
  publicKeyFile: z.string().optional(),
22
21
  reservation: z.string().optional(),
@@ -141,7 +140,7 @@ export const launchCapability = cliOnly({
141
140
  order: 2,
142
141
  outcome: "Launch one reviewed VM, Bare Metal machine, Kubernetes cluster, or Slurm cluster from active reserved capacity, including a declared durable Bare Metal workspace.",
143
142
  human: "ornn launch [<capacity-reservation>] --mode vm|bare-metal|kubernetes|slurm [--key <ssh-key>]; Bare Metal may add --network shared --volume <managed-volume> --mount-access read-only|read-write; Kubernetes/Slurm prompt for missing --network public|private and --nodes <membership-count>, and may add running VMs with --vms <vm-ids>",
144
- automation: "ornn launch <canonical-reservation-id> --mode vm|bare-metal|kubernetes|slurm --organization <id> --yes --idempotency-key <key> --format json; VM/Bare Metal/Slurm require --key <canonical-key-id>; Bare Metal may add --network shared --volume <canonical-volume-id> --mount-access read-only|read-write; Kubernetes/Slurm require --network public|private --nodes <membership-count> --machines <canonical-machine-ids>, and may add --vms <canonical-vm-ids> (with --nodes 0 for a cluster of VMs only)",
143
+ automation: "ornn launch <canonical-reservation-id> --mode vm|bare-metal|kubernetes|slurm --yes --idempotency-key <key> --format json; VM/Bare Metal/Slurm require --key <canonical-key-id>; Bare Metal may add --network shared --volume <canonical-volume-id> --mount-access read-only|read-write; Kubernetes/Slurm require --network public|private --nodes <membership-count> --machines <canonical-machine-ids>, and may add --vms <canonical-vm-ids> (with --nodes 0 for a cluster of VMs only)",
145
144
  acceptedReferences: [
146
145
  "canonical capacity-reservation ID",
147
146
  "canonical eligible machine IDs",
@@ -1,11 +1,9 @@
1
1
  import { z } from "zod";
2
2
  import { defineCapability } from "../capability.js";
3
3
  const emptyInput = z.object({});
4
- const contextInput = z.object({ organization: z.string().min(1).optional() });
5
4
  const loginInput = z.object({
6
5
  authBase: z.string().min(1).optional(),
7
6
  noBrowser: z.boolean().optional(),
8
- organization: z.string().min(1).optional(),
9
7
  pollInterval: z.number().positive().optional(),
10
8
  timeout: z.number().positive().optional(),
11
9
  });
@@ -30,7 +28,7 @@ export const loginCapability = defineCapability({
30
28
  category: "session",
31
29
  arguments: "semantic",
32
30
  outcome: "Authenticate in a browser and return to the terminal.",
33
- human: "ornn login [--organization <exact-name>] [--no-browser] [--timeout <seconds>]",
31
+ human: "ornn login [--no-browser] [--timeout <seconds>]",
34
32
  automation: "ornn login --format json",
35
33
  resumableStates: ["browser authorization"],
36
34
  related: ["context", "logout"],
@@ -45,10 +43,10 @@ export const contextCapability = defineCapability({
45
43
  id: "identity.context",
46
44
  domain: "identity",
47
45
  roles: ["user"],
48
- description: "Show and select the current server-verified organization context.",
46
+ description: "Show the current server-verified organization context.",
49
47
  mutation: "read",
50
48
  http: { method: "GET", path: "/v1/cli/session" },
51
- input: contextInput,
49
+ input: emptyInput,
52
50
  cli: {
53
51
  command: ["context"],
54
52
  vnext: {
@@ -56,12 +54,8 @@ export const contextCapability = defineCapability({
56
54
  category: "session",
57
55
  arguments: "semantic",
58
56
  outcome: "Show the current user, selected organization, approval, and available organizations.",
59
- human: "ornn context [--organization <exact-name-or-id>]",
60
- automation: "ornn context --organization <canonical-id> --format json",
61
- acceptedReferences: [
62
- "exact organization name (human)",
63
- "canonical organization ID (automation)",
64
- ],
57
+ human: "ornn context",
58
+ automation: "ornn context --format json",
65
59
  related: ["login", "logout"],
66
60
  order: 2,
67
61
  },
@@ -1,6 +1,7 @@
1
1
  import { listSupplierOrganizations } from "../supplier-directory.js";
2
2
  import { z } from "zod";
3
3
  import { defineCapability, preview } from "../capability.js";
4
+ import { ensureFacility, LOCATION_LOOKUP_UNAVAILABLE, locateRegion, unplacedLocation, } from "./locality.js";
4
5
  import { bindHardwareFields, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, projectCatalogTerms, resolveCatalogTerm, } from "../spec-catalog.js";
5
6
  function queryPath(path, params) {
6
7
  const parts = [];
@@ -30,7 +31,10 @@ export const listingCreateInput = z.object({
30
31
  fabric_type: z.string().optional(),
31
32
  node_count: z.number().int().optional(),
32
33
  gpus_per_node: z.number().int().optional(),
33
- site_nickname: z.string().optional(),
34
+ site_nickname: z
35
+ .string()
36
+ .optional()
37
+ .describe("A state, city, address, or catalog region such as us-west-3."),
34
38
  site_supplier: z.string().optional(),
35
39
  ram: z.string().optional(),
36
40
  storage: z.string().optional(),
@@ -67,7 +71,10 @@ const slackListingCreateParams = {
67
71
  fabric_type: { type: "string" },
68
72
  node_count: { type: "integer" },
69
73
  gpus_per_node: { type: "integer" },
70
- site_nickname: { type: "string" },
74
+ site_nickname: {
75
+ type: "string",
76
+ description: "Where the capacity is, exactly as the user said it: a state, city, address, or catalog region such as us-west-3. Ornn places it in a catalog region.",
77
+ },
71
78
  site_supplier: { type: "string" },
72
79
  ram: { type: "string" },
73
80
  storage: { type: "string" },
@@ -242,7 +249,7 @@ export const createListingCapability = defineCapability({
242
249
  let organizationId = parsed.organizationId ?? parsed.organization_id;
243
250
  let facilityId = parsed.facilityId ?? parsed.facility_id;
244
251
  const supplierQuery = (parsed.site_supplier ?? "").trim().toLowerCase();
245
- const facilityQuery = (parsed.site_nickname ?? parsed.site ?? "").trim().toLowerCase();
252
+ const place = (parsed.site_nickname ?? parsed.site ?? "").trim();
246
253
  if (!organizationId && supplierQuery) {
247
254
  const matches = (await listSupplierOrganizations(ctx.fetch)).filter((org) => org.id.toLowerCase() === supplierQuery || org.name.toLowerCase() === supplierQuery);
248
255
  if (matches.length !== 1) {
@@ -250,7 +257,19 @@ export const createListingCapability = defineCapability({
250
257
  }
251
258
  organizationId = matches[0].id;
252
259
  }
253
- if ((facilityId && !organizationId) || (!facilityId && (facilityQuery || organizationId))) {
260
+ let located = null;
261
+ let lookupUnavailable = false;
262
+ if (!facilityId && place) {
263
+ try {
264
+ located = await locateRegion(ctx.fetch, place);
265
+ }
266
+ catch (error) {
267
+ if (ctx.confirmed)
268
+ throw error;
269
+ lookupUnavailable = true;
270
+ }
271
+ }
272
+ if ((facilityId && !organizationId) || (!facilityId && !place && organizationId)) {
254
273
  const matches = [];
255
274
  const seenCursors = new Set();
256
275
  let cursor;
@@ -262,14 +281,7 @@ export const createListingCapability = defineCapability({
262
281
  throw new Error("invalid facility catalog");
263
282
  matches.push(...response.facilities.filter((facility) => (!organizationId ||
264
283
  (facility.organizationId ?? facility.organization_id) === organizationId) &&
265
- (facilityId
266
- ? (facility.facilityId ?? facility.id) === facilityId
267
- : !facilityQuery ||
268
- [
269
- facility.facilityId ?? facility.id,
270
- facility.region,
271
- facility.displayName ?? facility.display_name,
272
- ].some((value) => value?.toLowerCase() === facilityQuery))));
284
+ (!facilityId || (facility.facilityId ?? facility.id) === facilityId)));
273
285
  cursor = response.nextCursor ?? undefined;
274
286
  if (cursor) {
275
287
  if (seenCursors.has(cursor) || seenCursors.size >= 49) {
@@ -302,6 +314,10 @@ export const createListingCapability = defineCapability({
302
314
  const gpusPerNode = parsed.gpus_per_node ?? 8;
303
315
  const boundPayload = bound.payload;
304
316
  const draft = Object.fromEntries(Object.entries(boundPayload).filter(([key]) => !LISTING_CREATE_INPUT_ONLY_KEYS.includes(key)));
317
+ if (located)
318
+ draft.site_nickname = located.name;
319
+ else if (place && !facilityId && !lookupUnavailable)
320
+ delete draft.site_nickname;
305
321
  const gpuSlug = normalized.gpu_type
306
322
  ? resolveCatalogTerm(hardware.terms, "gpu", normalized.gpu_type).canonical
307
323
  : null;
@@ -318,11 +334,12 @@ export const createListingCapability = defineCapability({
318
334
  percent: parsed.deposit_percent,
319
335
  })
320
336
  : 0);
321
- const listing = organizationId && facilityId && gpuSlug && buyerMicro != null
337
+ const priced = Boolean(organizationId && gpuSlug && buyerMicro != null);
338
+ const listingAt = (facility) => priced && facility
322
339
  ? primaryListingBody({
323
- organizationId,
324
- facilityId,
325
- gpuSlug,
340
+ organizationId: organizationId,
341
+ facilityId: facility,
342
+ gpuSlug: gpuSlug,
326
343
  gpusPerNode,
327
344
  provisioningType: parsed.access_mode ?? "virtual-machine",
328
345
  availableAt: startsAt,
@@ -337,13 +354,24 @@ export const createListingCapability = defineCapability({
337
354
  }, buyerMicro, downPaymentCents)
338
355
  : null;
339
356
  if (!ctx.confirmed) {
357
+ const listing = listingAt(facilityId);
340
358
  return preview("create_listing", {
341
359
  ...draft,
342
360
  node_count: nodeCount,
343
361
  gpus_per_node: gpusPerNode,
362
+ ...(located ? { facility_region: located.region } : {}),
363
+ ...(lookupUnavailable
364
+ ? { location_unavailable: place, location_hint: LOCATION_LOOKUP_UNAVAILABLE }
365
+ : place && !facilityId && !located
366
+ ? { location_unresolved: place, location_hint: unplacedLocation(place) }
367
+ : {}),
344
368
  ...(listing ? { listing } : {}),
345
369
  });
346
370
  }
371
+ if (located && !facilityId && priced) {
372
+ facilityId = (await ensureFacility(ctx.fetch, organizationId, located.region)).facilityId;
373
+ }
374
+ const listing = listingAt(facilityId);
347
375
  if (!listing) {
348
376
  return {
349
377
  ok: false,
@@ -0,0 +1,42 @@
1
+ import { statusOf } from "./staff-shared.js";
2
+ export const LOCALITY_GEOLOCATE_PATH = "/v1/orchestrator/locality/geolocate";
3
+ export const FACILITY_ENSURE_PATH = "/provisioning/facilities/ensure";
4
+ /**
5
+ * `GET /v1/orchestrator/locality/geolocate?address=`: the catalog region a typed
6
+ * place stands in. Orchestrator answers a region code or name as itself and
7
+ * geocodes anything else (state, city, address) to its nearest region.
8
+ * Null means Orchestrator could not place the text (404); an unavailable
9
+ * geocoder or gateway throws, so callers ask for a retry rather than new text.
10
+ */
11
+ export async function locateRegion(fetch, place) {
12
+ const address = place.trim();
13
+ if (!address)
14
+ return null;
15
+ let located;
16
+ try {
17
+ located = (await fetch(`${LOCALITY_GEOLOCATE_PATH}?address=${encodeURIComponent(address)}`, {
18
+ method: "GET",
19
+ }));
20
+ }
21
+ catch (error) {
22
+ if (statusOf(error) === 404)
23
+ return null;
24
+ throw error;
25
+ }
26
+ const region = typeof located?.facility_slug === "string" ? located.facility_slug.trim() : "";
27
+ if (!region)
28
+ return null;
29
+ const name = typeof located?.region === "string" && located.region.trim() ? located.region.trim() : region;
30
+ return { region, name };
31
+ }
32
+ export function unplacedLocation(place) {
33
+ return `Ornn could not place "${place}". Give a state, city, address, or catalog region such as us-west-3.`;
34
+ }
35
+ export const LOCATION_LOOKUP_UNAVAILABLE = "Ornn's location lookup is unavailable right now. Keep this location and try again in a moment.";
36
+ /** `POST /internal/v1/facilities/ensure`: the organization's facility at a catalog region. */
37
+ export async function ensureFacility(fetch, organizationId, region) {
38
+ return (await fetch(FACILITY_ENSURE_PATH, {
39
+ method: "POST",
40
+ body: { organizationId, region },
41
+ }));
42
+ }
@@ -293,7 +293,7 @@ export const marketEndCapability = defineCapability({
293
293
  roles: ["user"],
294
294
  description: "End a running fill: its reservation stops, its VMs stop, and its GPUs go back on the book. Requires confirm: true.",
295
295
  mutation: "preview-confirm",
296
- http: { method: "POST", path: "/commerce/reservations/{fillId}/stop" },
296
+ http: { method: "DELETE", path: "/commerce/reservations/{fillId}" },
297
297
  input: marketEndInput,
298
298
  cli: {
299
299
  command: ["market", "end"],
@@ -308,7 +308,7 @@ export const marketEndCapability = defineCapability({
308
308
  ], { fillId: parsed.fillId });
309
309
  if (gate)
310
310
  return gate;
311
- return ctx.fetch(`/commerce/reservations/${seg(parsed.fillId)}/stop`, { method: "POST" });
311
+ return ctx.fetch(`/commerce/reservations/${seg(parsed.fillId)}`, { method: "DELETE" });
312
312
  },
313
313
  });
314
314
  export const offersListInput = z.object({
@@ -7,35 +7,62 @@ export const nativeTermFields = {
7
7
  pricePerGpuHourMicroUsd: z.coerce.number().int().positive(),
8
8
  downPaymentCents: z.coerce.number().int().nonnegative().nullable().optional(),
9
9
  renewable: flag,
10
+ preemptNoticeSeconds: z.coerce.number().int().nonnegative().optional(),
10
11
  };
11
12
  export const nativeTermInput = z.object(nativeTermFields).strict();
12
- export const nativeTermsInput = z.preprocess((value) => {
13
- if (typeof value !== "string")
14
- return value;
15
- try {
16
- return JSON.parse(value);
17
- }
18
- catch {
19
- return value;
20
- }
21
- }, z.array(nativeTermInput));
13
+ function jsonArray(item) {
14
+ return z.preprocess((value) => {
15
+ if (typeof value !== "string")
16
+ return value;
17
+ try {
18
+ return JSON.parse(value);
19
+ }
20
+ catch {
21
+ return value;
22
+ }
23
+ }, z.array(item));
24
+ }
25
+ export const nativeTermsInput = jsonArray(nativeTermInput);
22
26
  export const listingWriteControls = {
23
27
  confirm: z.boolean().optional(),
24
28
  idempotencyKey: z.string().min(1).max(200),
25
29
  };
30
+ const listingLocation = z
31
+ .string()
32
+ .trim()
33
+ .min(1)
34
+ .max(256)
35
+ .optional()
36
+ .describe("Instead of facilityId: a state, city, address, or catalog region such as us-west-3. Ornn places it in a catalog region and uses the supplier's facility there.");
26
37
  export const nativeListingCreateInput = z
27
38
  .object({
28
39
  organizationId: z.string().uuid().nullable().optional(),
29
40
  kind: z.enum(["primary", "secondary"]).default("primary"),
30
41
  gpuSlug: z.string().min(1),
31
42
  gpusPerNode: z.coerce.number().int().positive(),
32
- capacityNodeCount: z.coerce.number().int().positive(),
33
- facilityId: z.string().uuid(),
43
+ capacityNodeCount: z.coerce
44
+ .number()
45
+ .int()
46
+ .positive()
47
+ .optional()
48
+ .describe("Defaults to the number of nodeIds."),
49
+ nodeIds: jsonArray(z.string().uuid()).optional(),
50
+ facilityId: z.string().uuid().optional(),
51
+ location: listingLocation,
34
52
  provisioningType: z.enum(["bare-metal", "virtual-machine"]),
35
53
  state: z.enum(["draft", "available"]),
36
- availableAt: z.iso.datetime({ offset: true }).nullable().optional(),
54
+ availableAt: z.iso
55
+ .datetime({ offset: true })
56
+ .nullable()
57
+ .optional()
58
+ .describe("Primary capacity takes availableAt and availableUntil, or daysToRfs, not both."),
37
59
  availableUntil: z.iso.datetime({ offset: true }).nullable().optional(),
38
- daysToRfs: z.coerce.number().int().nonnegative().optional(),
60
+ daysToRfs: z.coerce
61
+ .number()
62
+ .int()
63
+ .nonnegative()
64
+ .optional()
65
+ .describe("Days until ready for service; 0 when ready now. Required unless both dates are set."),
39
66
  cpu: z.string().optional(),
40
67
  ram: z.string().optional(),
41
68
  storage: z.string().optional(),
@@ -44,13 +71,22 @@ export const nativeListingCreateInput = z
44
71
  terms: nativeTermsInput,
45
72
  ...listingWriteControls,
46
73
  })
47
- .strict();
74
+ .strict()
75
+ .refine((value) => Boolean(value.facilityId) !== Boolean(value.location), {
76
+ message: "Give exactly one of facilityId or location.",
77
+ path: ["facilityId"],
78
+ })
79
+ .refine((value) => !value.location || Boolean(value.organizationId), {
80
+ message: "location needs organizationId, the supplier whose facility it is.",
81
+ path: ["organizationId"],
82
+ });
48
83
  export const nativeListingUpdateFields = {
49
84
  state: z.enum(["draft", "available", "delisted"]).optional(),
50
85
  nodeCount: z.coerce.number().int().positive().optional(),
51
86
  gpuSlug: z.string().min(1).optional(),
52
87
  gpusPerNode: z.coerce.number().int().positive().optional(),
53
88
  facilityId: z.string().uuid().optional(),
89
+ location: listingLocation,
54
90
  provisioningType: z.enum(["bare-metal", "virtual-machine"]).optional(),
55
91
  availableAt: z.iso.datetime({ offset: true }).optional(),
56
92
  availableUntil: z.iso.datetime({ offset: true }).nullable().optional(),