@ornncompute/cli 0.2.1 → 0.2.3

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
@@ -44,6 +44,7 @@ ornn whoami [--json]
44
44
  ornn status [--json]
45
45
  ornn listings list [--gpu-type <type>] [--facility <name>] [--operator <name>] [--json]
46
46
  ornn listings show <listing-id> [--open] [--json]
47
+ ornn listings create [--gpu-type <type>] [--node-count <n>] [--yes] [--json]
47
48
  ornn buy <listing-id> [--no-open] [--json]
48
49
  ornn exchange create <listing-id> --node-count <n> [--min-node-count <n>] --start-date <yyyy-mm-dd> --end-date <yyyy-mm-dd> --price <usd> [--no-open] [--json]
49
50
  ornn exchange list [--limit <1-500>] [--cursor <last-id>] [--json]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ornncompute/cli",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Command-line interface for Ornn compute access workflows.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,7 +1,55 @@
1
+ import { readFileSync } from "node:fs";
1
2
  import { createInterface } from "node:readline/promises";
2
3
  import { stdin as defaultStdin, stdout as defaultStdout } from "node:process";
3
4
 
4
- import { cliRequest, operatorRequest } from "./api-client.mjs";
5
+ import { cliRequest, loadRequiredSession, operatorRequest, resolveApiBaseUrl } from "./api-client.mjs";
6
+
7
+ async function collectSse(url, headers, durationMs, maxEvents, fetchImpl) {
8
+ const controller = new AbortController();
9
+ const timer = setTimeout(() => controller.abort(), durationMs);
10
+ const events = [];
11
+ try {
12
+ const res = await fetchImpl(url, { method: "GET", headers, signal: controller.signal });
13
+ if (!res.ok || !res.body) {
14
+ const detail = await res.text().catch(() => "");
15
+ throw new Error(detail.trim() || `http_${res.status}`);
16
+ }
17
+ const reader = res.body.getReader();
18
+ const decoder = new TextDecoder();
19
+ let buffer = "";
20
+ while (events.length < maxEvents) {
21
+ const { done, value } = await reader.read();
22
+ if (done) break;
23
+ buffer += decoder.decode(value, { stream: true });
24
+ const parts = buffer.split("\n\n");
25
+ buffer = parts.pop() ?? "";
26
+ for (const part of parts) {
27
+ const data = part
28
+ .split("\n")
29
+ .filter((line) => line.startsWith("data:"))
30
+ .map((line) => line.slice(5).trim())
31
+ .join("\n");
32
+ if (!data) continue;
33
+ try {
34
+ events.push(JSON.parse(data));
35
+ } catch {
36
+ events.push({ raw: data });
37
+ }
38
+ if (events.length >= maxEvents) break;
39
+ }
40
+ }
41
+ try {
42
+ await reader.cancel();
43
+ } catch {
44
+ // already closed
45
+ }
46
+ } catch (error) {
47
+ if (!(error instanceof Error) || error.name !== "AbortError") throw error;
48
+ } finally {
49
+ clearTimeout(timer);
50
+ }
51
+ return { events };
52
+ }
5
53
 
6
54
  async function loadCapabilities() {
7
55
  try {
@@ -34,18 +82,56 @@ export async function confirmCatalogMutation({ stdin, stdout, yes, label }) {
34
82
  function catalogFetch({ env, fetchImpl }) {
35
83
  return async (path, init) => {
36
84
  const method = init?.method ?? "GET";
85
+ const observabilityPrefix = "/v1/observability";
86
+ const staffObservability =
87
+ path.startsWith(`${observabilityPrefix}/nodes/`) ||
88
+ path.startsWith(`${observabilityPrefix}/nodes?`);
37
89
  const staffPath =
90
+ staffObservability ||
38
91
  path.startsWith("/internal") ||
39
92
  path.startsWith("/provisioning") ||
40
93
  path === "/inventory" ||
41
94
  path.startsWith("/inventory/");
42
95
  const request = staffPath ? operatorRequest : cliRequest;
96
+ const requestPath = staffObservability ? path.slice(observabilityPrefix.length) : path;
97
+ if (init?.headers?.Accept === "text/event-stream") {
98
+ const session = await loadRequiredSession({ env });
99
+ const base = resolveApiBaseUrl({ env, session });
100
+ const parsed = new URL(
101
+ staffObservability ? `/v1/cli/operator${requestPath}` : path,
102
+ `${base.replace(/\/+$/, "")}/`,
103
+ );
104
+ const duration = Number(parsed.searchParams.get("duration") ?? "20") * 1000;
105
+ const maxEvents = Number(parsed.searchParams.get("max_events") ?? "40");
106
+ return collectSse(
107
+ parsed.toString(),
108
+ {
109
+ Accept: "text/event-stream",
110
+ Authorization: `Bearer ${session.accessToken}`,
111
+ "X-Ornn-Credential-Type": "user",
112
+ },
113
+ duration,
114
+ maxEvents,
115
+ fetchImpl,
116
+ );
117
+ }
118
+ let body = init?.body;
119
+ if (
120
+ body &&
121
+ typeof body === "object" &&
122
+ typeof body.identity_file === "string" &&
123
+ body.identity_file.trim()
124
+ ) {
125
+ const privateKey = readFileSync(body.identity_file.trim(), "utf8");
126
+ body = { ...body, private_key: privateKey };
127
+ delete body.identity_file;
128
+ }
43
129
  return request({
44
- endpoint: path,
130
+ endpoint: requestPath,
45
131
  env,
46
132
  fetchImpl,
47
133
  method,
48
- ...(init?.body !== undefined ? { body: init.body } : {}),
134
+ ...(body !== undefined ? { body } : {}),
49
135
  });
50
136
  };
51
137
  }
@@ -174,11 +260,113 @@ export async function usersQueryCommand(args, context) {
174
260
  return 0;
175
261
  }
176
262
 
177
- export async function nodesConsoleCommand(nodeId, context) {
263
+ export async function nodesConsoleCommand(args, context) {
178
264
  const { nodesConsoleCapability } = await loadCapabilities();
265
+ const [nodeId, ...rest] = args;
266
+ if (!nodeId) {
267
+ throw new Error("Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]");
268
+ }
269
+ const options = context.parseCommandOptions(
270
+ rest,
271
+ { boolean: ["json"], value: ["identity-file"] },
272
+ "Usage: ornn nodes console <node-id> [--identity-file <path>] [--json]",
273
+ );
179
274
  const result = await runCatalogCapability(
180
275
  nodesConsoleCapability,
181
- { node_id: nodeId },
276
+ { node_id: nodeId, identity_file: options.identityFile },
277
+ {
278
+ ...context,
279
+ role: options.identityFile ? "user" : context.staff ? "reviewer" : "user",
280
+ },
281
+ );
282
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
283
+ return 0;
284
+ }
285
+
286
+ export async function telemetryLatestCommand(args, context) {
287
+ const { telemetryLatestCapability } = await loadCapabilities();
288
+ const [nodeId, ...rest] = args;
289
+ const options = context.parseCommandOptions(
290
+ rest,
291
+ { boolean: ["json"], value: ["start", "end", "span", "max-points"] },
292
+ "Usage: ornn telemetry latest <node-id> [--span 15m] [--start <iso>] [--end <iso>] [--max-points <n>]",
293
+ );
294
+ const result = await runCatalogCapability(
295
+ telemetryLatestCapability,
296
+ {
297
+ node_id: nodeId,
298
+ start: options.start,
299
+ end: options.end,
300
+ span: options.span,
301
+ max_points: options.maxPoints != null ? Number(options.maxPoints) : undefined,
302
+ },
303
+ { ...context, role: context.staff ? "reviewer" : "user" },
304
+ );
305
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
306
+ return 0;
307
+ }
308
+
309
+ export async function logsLatestCommand(args, context) {
310
+ const { logsLatestCapability } = await loadCapabilities();
311
+ const [nodeId, ...rest] = args;
312
+ const options = context.parseCommandOptions(
313
+ rest,
314
+ { boolean: ["json"], value: ["stream", "count", "before", "cursor"] },
315
+ "Usage: ornn logs latest <node-id> [--stream kernel] [--count 200] [--before <iso>] [--cursor <token>]",
316
+ );
317
+ const result = await runCatalogCapability(
318
+ logsLatestCapability,
319
+ {
320
+ node_id: nodeId,
321
+ stream: options.stream,
322
+ count: options.count != null ? Number(options.count) : undefined,
323
+ before: options.before,
324
+ cursor: options.cursor,
325
+ },
326
+ { ...context, role: context.staff ? "reviewer" : "user" },
327
+ );
328
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
329
+ return 0;
330
+ }
331
+
332
+ export async function telemetryTailCommand(args, context) {
333
+ const { telemetryTailCapability } = await loadCapabilities();
334
+ const [nodeId, ...rest] = args;
335
+ const options = context.parseCommandOptions(
336
+ rest,
337
+ { boolean: ["json"], value: ["duration", "max-events", "fields"] },
338
+ "Usage: ornn telemetry tail <node-id> [--duration 20] [--max-events 40]",
339
+ );
340
+ const result = await runCatalogCapability(
341
+ telemetryTailCapability,
342
+ {
343
+ node_id: nodeId,
344
+ duration: options.duration != null ? Number(options.duration) : undefined,
345
+ max_events: options.maxEvents != null ? Number(options.maxEvents) : undefined,
346
+ fields: options.fields,
347
+ },
348
+ { ...context, role: context.staff ? "reviewer" : "user" },
349
+ );
350
+ context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
351
+ return 0;
352
+ }
353
+
354
+ export async function logsTailCommand(args, context) {
355
+ const { logsTailCapability } = await loadCapabilities();
356
+ const [nodeId, ...rest] = args;
357
+ const options = context.parseCommandOptions(
358
+ rest,
359
+ { boolean: ["json"], value: ["stream", "duration", "max-events"] },
360
+ "Usage: ornn logs tail <node-id> [--stream kernel] [--duration 20]",
361
+ );
362
+ const result = await runCatalogCapability(
363
+ logsTailCapability,
364
+ {
365
+ node_id: nodeId,
366
+ stream: options.stream,
367
+ duration: options.duration != null ? Number(options.duration) : undefined,
368
+ max_events: options.maxEvents != null ? Number(options.maxEvents) : undefined,
369
+ },
182
370
  { ...context, role: context.staff ? "reviewer" : "user" },
183
371
  );
184
372
  context.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
package/src/cli.mjs CHANGED
@@ -1,11 +1,6 @@
1
1
  import { spawn } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
2
  import { createReadStream, createWriteStream, existsSync } from "node:fs";
4
3
  import {
5
- chmod,
6
- mkdir,
7
- mkdtemp,
8
- open,
9
4
  readFile,
10
5
  rename,
11
6
  rm,
@@ -13,9 +8,8 @@ import {
13
8
  writeFile,
14
9
  } from "node:fs/promises";
15
10
  import { createRequire } from "node:module";
16
- import { isIP } from "node:net";
17
- import { homedir, hostname, tmpdir } from "node:os";
18
- import { basename, dirname, join } from "node:path";
11
+ import { homedir } from "node:os";
12
+ import { basename, join } from "node:path";
19
13
  import { Readable } from "node:stream";
20
14
  import { pipeline } from "node:stream/promises";
21
15
 
@@ -44,8 +38,12 @@ import {
44
38
  } from "./device-auth.mjs";
45
39
  import {
46
40
  listingsCreateCommand,
41
+ logsLatestCommand,
42
+ logsTailCommand,
47
43
  nodesConsoleCommand,
48
44
  operatorListCommand,
45
+ telemetryLatestCommand,
46
+ telemetryTailCommand,
49
47
  usersQueryCommand,
50
48
  } from "./catalog-dispatch.mjs";
51
49
  import { maybeNotifyUpdate, updateCommand } from "./update.mjs";
@@ -134,7 +132,11 @@ Usage:
134
132
  ornn gpus checkout <gpu-id> [--no-open] [--json]
135
133
  ornn nodes list [--json]
136
134
  ornn nodes show <node-id> [--json]
137
- ornn nodes console <node-id> [--json]
135
+ ornn nodes console <node-id> [--identity-file <path>] [--json]
136
+ ornn telemetry latest <node-id> [--span 15m] [--start <iso>] [--end <iso>] [--max-points <n>] [--json]
137
+ ornn telemetry tail <node-id> [--duration <seconds>] [--json]
138
+ ornn logs latest <node-id> [--stream kernel] [--count <n>] [--before <iso>] [--cursor <token>] [--json]
139
+ ornn logs tail <node-id> [--stream kernel] [--duration <seconds>] [--json]
138
140
  ornn nodes launch <reservation-id> --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--network public|private] [--storage-load-drive-id <id>] [--storage-save-drive-id <id>] [--wait] [--json]
139
141
  ornn nodes switch <reservation-id> --network public|private --key <path|id|label> [--mode bare-metal|vm] [--username <name>] [--wait] [--json]
140
142
  ornn nodes wait <node-or-reservation-id> [--timeout <seconds>] [--json]
@@ -465,6 +467,43 @@ async function dispatch(argv = [], io = {}) {
465
467
  });
466
468
  }
467
469
 
470
+ if (command === "telemetry" && args[0] === "latest") {
471
+ return await telemetryLatestCommand(args.slice(1), {
472
+ env,
473
+ fetchImpl,
474
+ parseCommandOptions,
475
+ stdout,
476
+ staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
477
+ });
478
+ }
479
+ if (command === "telemetry" && args[0] === "tail") {
480
+ return await telemetryTailCommand(args.slice(1), {
481
+ env,
482
+ fetchImpl,
483
+ parseCommandOptions,
484
+ stdout,
485
+ staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
486
+ });
487
+ }
488
+ if (command === "logs" && args[0] === "latest") {
489
+ return await logsLatestCommand(args.slice(1), {
490
+ env,
491
+ fetchImpl,
492
+ parseCommandOptions,
493
+ stdout,
494
+ staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
495
+ });
496
+ }
497
+ if (command === "logs" && args[0] === "tail") {
498
+ return await logsTailCommand(args.slice(1), {
499
+ env,
500
+ fetchImpl,
501
+ parseCommandOptions,
502
+ stdout,
503
+ staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
504
+ });
505
+ }
506
+
468
507
  if (LISTINGS_COMMANDS.has(command)) {
469
508
  return await availability(args, {
470
509
  commandName: command,
@@ -503,7 +542,16 @@ async function dispatch(argv = [], io = {}) {
503
542
  }
504
543
 
505
544
  if (command === "nodes") {
506
- return await nodes(args, { env, fetchImpl, openBrowserImpl, spawnProcess, stderr, stdout });
545
+ return await nodes(args, {
546
+ env,
547
+ fetchImpl,
548
+ openBrowserImpl,
549
+ parseCommandOptions,
550
+ spawnProcess,
551
+ stderr,
552
+ stdout,
553
+ staff: Boolean(env.ORNN_OPERATOR_TOKEN || env.ORNN_STAFF),
554
+ });
507
555
  }
508
556
 
509
557
  if (command === "ssh") {
@@ -2715,8 +2763,7 @@ async function nodes(args, context) {
2715
2763
  }
2716
2764
 
2717
2765
  if (subcommand === "console" && id) {
2718
- parseCommandOptions([nested, ...rest].filter(Boolean), { boolean: ["json"] }, "Usage: ornn nodes console <node-id>");
2719
- return await nodesConsoleCommand(id, context);
2766
+ return await nodesConsoleCommand([id, nested, ...rest].filter(Boolean), context);
2720
2767
  }
2721
2768
 
2722
2769
  if (subcommand === "show" && id) {
@@ -8637,12 +8684,6 @@ function validateMinNodeCount(minNodeCount, nodeCount) {
8637
8684
  }
8638
8685
  }
8639
8686
 
8640
- function validateMinGpuCount(minGpuCount, gpuCount) {
8641
- if (minGpuCount > gpuCount) {
8642
- throw new Error("--min-gpu-count must be less than or equal to --gpu-count.");
8643
- }
8644
- }
8645
-
8646
8687
  function arrayOption(value) {
8647
8688
  if (value === undefined || value === null || value === "") {
8648
8689
  return [];
@@ -25,6 +25,8 @@ const nodesInput = z.object({
25
25
  const consoleInput = z.object({
26
26
  node_id: z.string().min(1),
27
27
  nodeId: z.string().optional(),
28
+ identity_file: z.string().optional(),
29
+ commands: z.array(z.string()).optional(),
28
30
  });
29
31
  export const listOperatorsCapability = defineCapability({
30
32
  id: "operator.list",
@@ -106,14 +108,19 @@ export const nodesConsoleCapability = defineCapability({
106
108
  id: "nodes.console",
107
109
  domain: "fleet",
108
110
  roles: ["user", "reviewer", "admin"],
109
- description: "Open an authenticated WebSocket into the node's real host shell (PTY), not ssh(1) and not the agent packet bus. Returns a short-lived wss URL plus ticket. CLI attaches an interactive TTY. MCP and Slack return a command loop over the same socket.",
111
+ description: "Run an allowlisted host snapshot (ps -ef, nvidia-smi) on one node. Pass the orchestrator node UUID, never a hostname. Users must target a reserved node and pass identity_file (local PEM, same as ornn ssh --identity-file). Reviewers and admins may target any enrolled node; the orchestrator loads the stored admin SSH key. Slack staff never pass a key.",
110
112
  mutation: "read",
111
- http: { method: "POST", path: "/v1/nodes/{id}/console" },
113
+ http: { method: "POST", path: "/internal/nodes/{id}/console" },
112
114
  slack: {
113
115
  parameters: {
114
116
  type: "object",
115
117
  properties: {
116
- node_id: { type: "string", description: "Node UUID from nodes.list." },
118
+ node_id: { type: "string", description: "Node UUID from nodes.list, not a hostname." },
119
+ commands: {
120
+ type: "array",
121
+ items: { type: "string", enum: ["ps", "ps -ef", "nvidia-smi", "nvidia-smi -L"] },
122
+ description: "Optional allowlisted commands. Default is ps and nvidia-smi.",
123
+ },
117
124
  },
118
125
  required: ["node_id"],
119
126
  },
@@ -122,9 +129,19 @@ export const nodesConsoleCapability = defineCapability({
122
129
  execute: async (ctx, input) => {
123
130
  const parsed = consoleInput.parse(input);
124
131
  const id = (parsed.node_id || parsed.nodeId || "").trim();
125
- return ctx.fetch(`/v1/nodes/${encodeURIComponent(id)}/console`, {
132
+ if (ctx.role === "user") {
133
+ const identity = parsed.identity_file?.trim();
134
+ if (!identity) {
135
+ return { error: "identity_file_required", detail: "Users must pass identity_file." };
136
+ }
137
+ return ctx.fetch(`/nodes/${encodeURIComponent(id)}/console`, {
138
+ method: "POST",
139
+ body: { identity_file: identity, commands: parsed.commands },
140
+ });
141
+ }
142
+ return ctx.fetch(`/internal/nodes/${encodeURIComponent(id)}/console`, {
126
143
  method: "POST",
127
- body: { node_id: id },
144
+ body: { commands: parsed.commands },
128
145
  });
129
146
  },
130
147
  });
@@ -0,0 +1,5 @@
1
+ export declare const telemetryLatestCapability: import("../capability.ts").Capability<unknown, unknown>;
2
+ export declare const logsLatestCapability: import("../capability.ts").Capability<unknown, unknown>;
3
+ export declare const telemetryTailCapability: import("../capability.ts").Capability<unknown, unknown>;
4
+ export declare const logsTailCapability: import("../capability.ts").Capability<unknown, unknown>;
5
+ export declare const observabilityCapabilities: import("../capability.ts").Capability<unknown, unknown>[];
@@ -0,0 +1,188 @@
1
+ import { z } from "zod";
2
+ import { defineCapability } from "../capability.js";
3
+ function queryPath(path, params) {
4
+ const parts = [];
5
+ for (const [key, value] of Object.entries(params)) {
6
+ if (value?.trim())
7
+ parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(value.trim())}`);
8
+ }
9
+ return parts.length ? `${path}?${parts.join("&")}` : path;
10
+ }
11
+ function nodePrefix(role, id) {
12
+ const encoded = encodeURIComponent(id);
13
+ return role === "user"
14
+ ? `/v1/observability/machines/${encoded}`
15
+ : `/v1/observability/nodes/${encoded}`;
16
+ }
17
+ const latestInput = z.object({
18
+ node_id: z.string().min(1),
19
+ nodeId: z.string().optional(),
20
+ start: z.string().optional(),
21
+ end: z.string().optional(),
22
+ span: z.string().optional(),
23
+ max_points: z.number().int().min(1).max(500).optional(),
24
+ });
25
+ const logsLatestInput = z.object({
26
+ node_id: z.string().min(1),
27
+ nodeId: z.string().optional(),
28
+ stream: z.enum(["serial", "kernel"]).default("kernel"),
29
+ count: z.number().int().min(1).max(1000).optional(),
30
+ before: z.string().optional(),
31
+ cursor: z.string().optional(),
32
+ });
33
+ const tailInput = z.object({
34
+ node_id: z.string().min(1),
35
+ nodeId: z.string().optional(),
36
+ duration: z.number().int().min(1).max(120).optional(),
37
+ max_events: z.number().int().min(1).max(200).optional(),
38
+ fields: z.string().optional(),
39
+ });
40
+ const logsTailInput = tailInput.extend({
41
+ stream: z.enum(["serial", "kernel"]).default("kernel"),
42
+ });
43
+ function nodeId(input) {
44
+ return (input.node_id || input.nodeId || "").trim();
45
+ }
46
+ function expandSpan(span, start, end) {
47
+ if (start?.trim() && end?.trim()) {
48
+ return { start: start.trim(), end: end.trim() };
49
+ }
50
+ const now = Date.now();
51
+ const match = /^(\d+)(m|h|d)$/.exec((span ?? "15m").trim());
52
+ const amount = match ? Number(match[1]) : 15;
53
+ const unit = match?.[2] ?? "m";
54
+ const ms = unit === "d" ? amount * 86_400_000 : unit === "h" ? amount * 3_600_000 : amount * 60_000;
55
+ return { start: new Date(now - ms).toISOString(), end: new Date(now).toISOString() };
56
+ }
57
+ export const telemetryLatestCapability = defineCapability({
58
+ id: "telemetry.latest",
59
+ domain: "observability",
60
+ roles: ["user", "reviewer", "admin"],
61
+ description: "One-shot telemetry window for one node (start/end or span plus max_points). Users may only query a reserved machine. Reviewers and admins may query any enrolled node. Pass a node UUID. Use this for heartbeat / last-seen / public_ip. Not a live stream.",
62
+ mutation: "read",
63
+ http: { method: "GET", path: "/v1/observability/nodes/{id}/telemetry/query" },
64
+ slack: {
65
+ parameters: {
66
+ type: "object",
67
+ properties: {
68
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
69
+ span: { type: "string", description: "Lookback like 15m, 1h, 1d. Default 15m." },
70
+ start: { type: "string" },
71
+ end: { type: "string" },
72
+ max_points: { type: "number", description: "Default 240, max 500." },
73
+ },
74
+ required: ["node_id"],
75
+ },
76
+ },
77
+ input: latestInput,
78
+ execute: async (ctx, input) => {
79
+ const parsed = latestInput.parse(input);
80
+ const id = nodeId(parsed);
81
+ const range = expandSpan(parsed.span, parsed.start, parsed.end);
82
+ return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/telemetry/query`, {
83
+ start: range.start,
84
+ end: range.end,
85
+ max_points: String(parsed.max_points ?? 240),
86
+ }), { method: "GET" });
87
+ },
88
+ });
89
+ export const logsLatestCapability = defineCapability({
90
+ id: "logs.latest",
91
+ domain: "observability",
92
+ roles: ["user", "reviewer", "admin"],
93
+ description: "One-shot newest kernel or serial log page (before + count + cursor). Users only on a reserved machine. Not a live tail. First call omits cursor; older pages pass next_cursor.",
94
+ mutation: "read",
95
+ http: { method: "GET", path: "/v1/observability/nodes/{id}/logs/{stream}/query" },
96
+ slack: {
97
+ parameters: {
98
+ type: "object",
99
+ properties: {
100
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
101
+ stream: { type: "string", enum: ["serial", "kernel"] },
102
+ count: { type: "number", description: "Page size. Default 200, max 1000." },
103
+ before: { type: "string", description: "RFC3339 upper bound. Default now." },
104
+ cursor: { type: "string", description: "next_cursor from the previous page." },
105
+ },
106
+ required: ["node_id"],
107
+ },
108
+ },
109
+ input: logsLatestInput,
110
+ execute: async (ctx, input) => {
111
+ const parsed = logsLatestInput.parse(input);
112
+ const id = nodeId(parsed);
113
+ const end = parsed.before?.trim() || new Date().toISOString();
114
+ const start = new Date(Date.parse(end) - 7 * 86_400_000).toISOString();
115
+ return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/logs/${parsed.stream}/query`, {
116
+ start,
117
+ end,
118
+ limit: String(parsed.count ?? 200),
119
+ order: "desc",
120
+ cursor: parsed.cursor,
121
+ }), { method: "GET" });
122
+ },
123
+ });
124
+ export const telemetryTailCapability = defineCapability({
125
+ id: "telemetry.tail",
126
+ domain: "observability",
127
+ roles: ["user", "reviewer", "admin"],
128
+ description: "Live telemetry follow on the same Observability subscribe feed the graphs use. Users only on a reserved machine. Slack and MCP attach for duration (default 20s) and return events. Not telemetry.latest.",
129
+ mutation: "read",
130
+ http: { method: "GET", path: "/v1/observability/nodes/{id}/telemetry/subscribe" },
131
+ slack: {
132
+ parameters: {
133
+ type: "object",
134
+ properties: {
135
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
136
+ duration: { type: "number", description: "Seconds to attach. Default 20, max 120." },
137
+ max_events: { type: "number" },
138
+ fields: { type: "string" },
139
+ },
140
+ required: ["node_id"],
141
+ },
142
+ },
143
+ input: tailInput,
144
+ execute: async (ctx, input) => {
145
+ const parsed = tailInput.parse(input);
146
+ const id = nodeId(parsed);
147
+ return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/telemetry/subscribe`, {
148
+ fields: parsed.fields,
149
+ duration: String(parsed.duration ?? 20),
150
+ max_events: String(parsed.max_events ?? 40),
151
+ }), { method: "GET", headers: { Accept: "text/event-stream" } });
152
+ },
153
+ });
154
+ export const logsTailCapability = defineCapability({
155
+ id: "logs.tail",
156
+ domain: "observability",
157
+ roles: ["user", "reviewer", "admin"],
158
+ description: "Live kernel or serial log follow on the same Observability subscribe feed LogViewer uses. Users only on a reserved machine. Slack and MCP attach for duration (default 20s). Not logs.latest.",
159
+ mutation: "read",
160
+ http: { method: "GET", path: "/v1/observability/nodes/{id}/logs/{stream}/subscribe" },
161
+ slack: {
162
+ parameters: {
163
+ type: "object",
164
+ properties: {
165
+ node_id: { type: "string", description: "Node UUID from nodes.list." },
166
+ stream: { type: "string", enum: ["serial", "kernel"] },
167
+ duration: { type: "number", description: "Seconds to attach. Default 20, max 120." },
168
+ max_events: { type: "number" },
169
+ },
170
+ required: ["node_id"],
171
+ },
172
+ },
173
+ input: logsTailInput,
174
+ execute: async (ctx, input) => {
175
+ const parsed = logsTailInput.parse(input);
176
+ const id = nodeId(parsed);
177
+ return ctx.fetch(queryPath(`${nodePrefix(ctx.role, id)}/logs/${parsed.stream}/subscribe`, {
178
+ duration: String(parsed.duration ?? 20),
179
+ max_events: String(parsed.max_events ?? 40),
180
+ }), { method: "GET", headers: { Accept: "text/event-stream" } });
181
+ },
182
+ });
183
+ export const observabilityCapabilities = [
184
+ telemetryLatestCapability,
185
+ telemetryTailCapability,
186
+ logsLatestCapability,
187
+ logsTailCapability,
188
+ ];
@@ -1,6 +1,6 @@
1
1
  import type { ZodType } from "zod";
2
2
  import type { ClientKind, Role } from "./role.ts";
3
- export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing"];
3
+ export declare const DOMAINS: readonly ["identity", "listings", "fleet", "users", "reservations", "bids", "access", "clusters", "slurm", "kubernetes", "networks", "storage", "vpn", "billing", "observability"];
4
4
  export type Domain = (typeof DOMAINS)[number];
5
5
  export type Mutation = "read" | "preview-confirm" | "local";
6
6
  export type HttpSpec = {
@@ -14,6 +14,7 @@ export const DOMAINS = [
14
14
  "storage",
15
15
  "vpn",
16
16
  "billing",
17
+ "observability",
17
18
  ];
18
19
  /** Fill CLI/MCP/Slack names from `id`. Slack parameters stay optional. */
19
20
  export function defineCapability(spec) {
@@ -2,6 +2,7 @@ import { kubernetesCapabilities, slurmCapabilities } from "./capabilities/cluste
2
2
  import { fleetCapabilities } from "./capabilities/fleet.js";
3
3
  import { identityCapabilities } from "./capabilities/identity.js";
4
4
  import { listingCapabilities } from "./capabilities/listings.js";
5
+ import { observabilityCapabilities } from "./capabilities/observability.js";
5
6
  import { userCapabilities } from "./capabilities/users.js";
6
7
  /** System of record for CLI, MCP, and Slack verbs. Add capabilities here. */
7
8
  export const catalog = [
@@ -9,6 +10,7 @@ export const catalog = [
9
10
  ...listingCapabilities,
10
11
  ...fleetCapabilities,
11
12
  ...userCapabilities,
13
+ ...observabilityCapabilities,
12
14
  ...slurmCapabilities,
13
15
  ...kubernetesCapabilities,
14
16
  ];
@@ -6,5 +6,6 @@ export { identityCapabilities, statusCapability, whoamiCapability } from "./capa
6
6
  export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.ts";
7
7
  export { userCapabilities, usersQueryCapability } from "./capabilities/users.ts";
8
8
  export { kubernetesCapabilities, slurmCapabilities } from "./capabilities/clusters.ts";
9
+ export { logsLatestCapability, logsTailCapability, observabilityCapabilities, telemetryLatestCapability, telemetryTailCapability, } from "./capabilities/observability.ts";
9
10
  export { CLIENTS, parseRole, roleAtLeast, ROLES, type ClientKind, type Role } from "./role.ts";
10
11
  export { bindHardwareFields, HARDWARE_CATALOG_PATH, HARDWARE_KINDS, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, looksLikeSpecDump, normalizeCatalogQuery, parseHardwareCatalog, projectCatalogTerms, proposeListingPayload, resolveCatalogTerm, UNRESOLVED_CATALOG_HINT, type CatalogBindResult, type CatalogTerm, type HardwareCatalog, type HardwareKind, type ResolvedCatalogTerm, } from "./spec-catalog.ts";
@@ -6,5 +6,6 @@ export { identityCapabilities, statusCapability, whoamiCapability } from "./capa
6
6
  export { createListingCapability, listingCreateInput, listCatalogTermsCapability, listingCapabilities, listListingsCapability, proposeListingCapability, } from "./capabilities/listings.js";
7
7
  export { userCapabilities, usersQueryCapability } from "./capabilities/users.js";
8
8
  export { kubernetesCapabilities, slurmCapabilities } from "./capabilities/clusters.js";
9
+ export { logsLatestCapability, logsTailCapability, observabilityCapabilities, telemetryLatestCapability, telemetryTailCapability, } from "./capabilities/observability.js";
9
10
  export { CLIENTS, parseRole, roleAtLeast, ROLES } from "./role.js";
10
11
  export { bindHardwareFields, HARDWARE_CATALOG_PATH, HARDWARE_KINDS, HARDWARE_LISTING_FIELDS, HARDWARE_LISTING_FILTERS, loadHardwareCatalog, looksLikeSpecDump, normalizeCatalogQuery, parseHardwareCatalog, projectCatalogTerms, proposeListingPayload, resolveCatalogTerm, UNRESOLVED_CATALOG_HINT, } from "./spec-catalog.js";