metergraph-cli 0.0.0-stage → 0.2.0-preview.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,216 @@
1
+ import { authMessage } from "./output.js";
2
+
3
+ // Fixed text for read command reasons and notices. Reasons shared with sign
4
+ // in and doctor fall back to their wording. Nothing here contains a value from
5
+ // a server response other than validated metadata printed by readText.
6
+
7
+ const READ_MESSAGES = {
8
+ not_signed_in: 'This project is not signed in. Run "metergraph login --runtime local" first.',
9
+ capability_unavailable:
10
+ "The service does not offer this read to the project's Metadata grant. No data was read and no broader access was requested.",
11
+ exceeds_service_bounds:
12
+ "The requested --days or --limit is larger than the service allows. It was not reduced automatically. No data was read.",
13
+ insufficient_scope: "The service refused this read because the grant lacks a required scope. No broader scope was requested.",
14
+ forbidden: "The service refused this read for the signed in grant.",
15
+ rate_limited: "The service is rate limiting requests. Nothing was retried.",
16
+ endpoint_unavailable: "This service does not offer this read endpoint.",
17
+ request_rejected:
18
+ "The service rejected the request parameters, for example an expired or foreign cursor. No data was read.",
19
+ contract_version_unsupported: "The service answered with an agent access contract version this CLI does not support.",
20
+ content_in_metadata_response:
21
+ "The service's Metadata response contained a field that could carry captured content or a credential. Nothing from it was printed.",
22
+ filter_mismatch: "The service reported different trace filters than the ones requested. Nothing from it was printed.",
23
+ filter_not_applied:
24
+ "The service returned traces that do not match the requested filter. Nothing from it was printed.",
25
+ read_response_invalid: "The service's response is not usable JSON. Nothing from it was printed.",
26
+ usage_response_invalid: "The service's usage response does not match the contract. Nothing from it was printed.",
27
+ routes_response_invalid: "The service's routes response does not match the contract. Nothing from it was printed.",
28
+ traces_response_invalid: "The service's traces response does not match the contract. Nothing from it was printed.",
29
+ workspace_context_mismatch:
30
+ "The service answered for a different workspace than this project is bound to. Nothing from it was printed.",
31
+ profile_mismatch:
32
+ "The service reported a different deployment profile than this project is bound to. Nothing from it was printed.",
33
+ timeout: "The command did not finish within --timeout-ms.",
34
+ cancelled:
35
+ "The command was interrupted. Read commands never change workspace configuration or telemetry. " +
36
+ "An interrupted grant refresh may require running login again.",
37
+ credential_in_metadata_response:
38
+ "The service's Metadata response contained a credential this CLI holds for the project. Nothing from it was printed.",
39
+ };
40
+
41
+ export function readMessage(outcome, reason) {
42
+ return READ_MESSAGES[reason] ?? authMessage(outcome, reason);
43
+ }
44
+
45
+ const NOTICES = {
46
+ application_traffic_not_verified:
47
+ "Application traffic is not verified. A signed in project, existing data or a configured SDK alone does not prove it.",
48
+ evidence_incomplete: "The service marked this evidence as incomplete.",
49
+ rows_truncated: "The service returned fewer rows than exist. Totals cover the returned rows only.",
50
+ more_pages: "More traces exist. Pass --cursor with next_cursor to read the next page.",
51
+ routes_truncated_locally:
52
+ "The service returned more routes than --limit. Only the first rows are shown; the routes endpoint has no server limit or window.",
53
+ route_details_omitted: "Route descriptions, constraints and evaluation contract bodies are not printed.",
54
+ warning_messages_omitted: "Service warning messages are not printed; only their codes are.",
55
+ unsafe_text_omitted: "Some names contained control or formatting characters and are shown as null.",
56
+ unrecognized_capabilities_ignored: "Capabilities this CLI does not recognize are not shown.",
57
+ privacy_class_details_omitted: "Privacy class descriptions are not printed.",
58
+ trace_links_unavailable:
59
+ "The service does not provide workspace-bound trace links yet, so no link is shown.",
60
+ };
61
+
62
+ export function noticeText(token) {
63
+ return NOTICES[token] ?? null;
64
+ }
65
+
66
+ const yesNo = (value) => (value === true ? "yes" : value === false ? "no" : "not checked");
67
+ const show = (value) => (value === null || value === undefined ? "-" : String(value));
68
+ const usd = (value) => (value === null ? "-" : `$${value.toFixed(4)}`);
69
+
70
+ function table(header, rows) {
71
+ const widths = header.map((title, column) =>
72
+ Math.min(40, Math.max(title.length, ...rows.map((row) => row[column].length))),
73
+ );
74
+ const line = (cells) => cells.map((cell, column) => cell.padEnd(widths[column])).join(" ").trimEnd();
75
+ return [line(header), ...rows.map(line)];
76
+ }
77
+
78
+ export function readText(result, message) {
79
+ const data = result.data;
80
+ const lines = [`Metergraph ${result.command} (Metadata only)`];
81
+ if (data.origin !== null) lines.push(`Origin: ${data.origin}`);
82
+ if (result.command === "status") {
83
+ statusLines(data, lines);
84
+ } else {
85
+ if (data.workspace !== null) lines.push(`Workspace: ${data.workspace.id}`);
86
+ if (result.ok) resultLines(result.command, data.result, lines);
87
+ }
88
+ const notices = data.notices.map(noticeText).filter(Boolean);
89
+ if (notices.length > 0) lines.push("", ...notices.map((text) => `Note: ${text}`));
90
+ if (!result.ok) {
91
+ lines.push("", `Result: ${result.outcome} (exit ${result.exit_code})`, message);
92
+ if (data.retry_after_seconds !== null && data.retry_after_seconds !== undefined) {
93
+ lines.push(`Retry after: ${data.retry_after_seconds} seconds`);
94
+ }
95
+ }
96
+ if (data.next_action?.message) lines.push(`Next: ${data.next_action.message}`);
97
+ return `${lines.join("\n")}\n`;
98
+ }
99
+
100
+ function statusLines(data, lines) {
101
+ lines.push(
102
+ `Configured: ${data.configured ? "yes (.metergraph/project.json)" : "no"}`,
103
+ `Reachable: ${yesNo(data.reachable)}`,
104
+ `Authenticated: ${data.authenticated ? "yes, verified by the service" : "no"}`,
105
+ `Workspace: bound ${show(data.workspace.intended)}, verified ${show(data.workspace.actual)}`,
106
+ `Deployment profile: bound ${show(data.deployment_profile)}, reported by the service: ${
107
+ data.deployment_profile_verified === true
108
+ ? "same"
109
+ : data.deployment_profile_verified === false
110
+ ? "different"
111
+ : "not checked"
112
+ }`,
113
+ `Scopes: ${data.scopes.length > 0 ? data.scopes.join(" ") : "none"}`,
114
+ );
115
+ if (data.capabilities !== null) {
116
+ const flag = (name) => yesNo(data.capabilities[name]);
117
+ lines.push(
118
+ `Metadata reads: usage ${flag("usage")}, routes ${flag("routes")}, traces ${flag("trace_metadata")}`,
119
+ "Content, evidence and replay: not available to this grant",
120
+ );
121
+ }
122
+ lines.push(`Application traffic verified: no`);
123
+ }
124
+
125
+ function resultLines(command, report, lines) {
126
+ if (command === "context") {
127
+ const { workspace } = report;
128
+ lines.push(
129
+ `Slug: ${show(workspace.slug)}`,
130
+ `Name: ${show(workspace.name)}`,
131
+ `Created: ${workspace.created_at}`,
132
+ `Metadata retention: ${report.retention.metadata_days} days`,
133
+ `Content captured by the workspace: ${report.content.captured ? "yes" : "no"}; included in agent reads: no`,
134
+ `Access: ${report.access.scopes.join(" ")}`,
135
+ `Generated: ${report.provenance.generated_at}`,
136
+ );
137
+ return;
138
+ }
139
+ if (command === "capabilities") {
140
+ lines.push(`Deployment profile: ${report.deployment_profile}`, "");
141
+ const rows = Object.entries(report.agent)
142
+ .filter(([, entry]) => entry !== null)
143
+ .map(([name, entry]) => [name, entry.available ? "yes" : "no", entry.privacy_class, entry.required_scope]);
144
+ lines.push(...table(["CAPABILITY", "AVAILABLE", "CLASS", "SCOPE"], rows));
145
+ const { bounds } = report;
146
+ lines.push(
147
+ "",
148
+ `Bounds: up to ${bounds.max_days} days, ${bounds.max_rows} rows, ${bounds.max_response_bytes} bytes; content included by default: no`,
149
+ );
150
+ return;
151
+ }
152
+ if (command === "usage") {
153
+ lines.push(`Window: last ${report.window.days} days (${report.window.since} to ${report.window.until})`, "");
154
+ if (report.empty) {
155
+ lines.push("No usage rows in this window.");
156
+ } else {
157
+ const rows = report.items.map((item) => [
158
+ item.date,
159
+ show(item.route),
160
+ String(item.calls),
161
+ String(item.error_calls),
162
+ usd(item.cost_usd),
163
+ String(item.input_tokens),
164
+ String(item.output_tokens),
165
+ show(item.p95_latency_ms),
166
+ ]);
167
+ lines.push(...table(["DATE", "ROUTE", "CALLS", "ERRORS", "COST", "IN_TOKENS", "OUT_TOKENS", "P95_MS"], rows));
168
+ const totals = report.totals;
169
+ lines.push(
170
+ "",
171
+ `Returned rows: ${report.rows}. Totals of these rows${totals.complete ? "" : " (incomplete, not workspace totals)"}: ` +
172
+ `${totals.calls} calls, ${totals.error_calls} errors, ${usd(totals.cost_usd)}, ` +
173
+ `${totals.input_tokens} input and ${totals.output_tokens} output tokens.`,
174
+ );
175
+ }
176
+ lines.push(`Evidence: ${report.complete ? "complete" : "incomplete"}`);
177
+ return;
178
+ }
179
+ if (command === "routes") {
180
+ lines.push("");
181
+ if (report.empty) {
182
+ lines.push("No routes.");
183
+ } else {
184
+ const rows = report.routes.map((row) => [
185
+ show(row.route),
186
+ String(row.calls),
187
+ String(row.replay_eligible_calls),
188
+ show(row.evaluation_contract_version),
189
+ show(row.updated_at),
190
+ ]);
191
+ lines.push(...table(["ROUTE", "CALLS", "REPLAY_ELIGIBLE", "CONTRACT_VERSION", "UPDATED"], rows));
192
+ lines.push("", `Shown: ${report.rows} of ${report.server_rows} routes returned by the service.`);
193
+ }
194
+ return;
195
+ }
196
+ if (command === "traces") {
197
+ lines.push(`Window: last ${report.window.days} days`, "");
198
+ if (report.empty) {
199
+ lines.push("No traces on this page.");
200
+ } else {
201
+ const rows = report.traces.map((trace) => [
202
+ trace.last_span_at,
203
+ trace.status,
204
+ trace.trace_id,
205
+ show(trace.trace_name),
206
+ String(trace.span_count),
207
+ usd(trace.cost_usd),
208
+ trace.routes.join(",") || "-",
209
+ ]);
210
+ lines.push(...table(["LAST_SPAN", "STATUS", "TRACE_ID", "NAME", "SPANS", "COST", "ROUTES"], rows));
211
+ }
212
+ lines.push("", `Page: ${report.page.rows} traces, limit ${report.page.limit}`);
213
+ if (report.next_cursor !== null) lines.push(`Next page: --cursor ${report.next_cursor}`);
214
+ lines.push("Trace links: not available from the service yet");
215
+ }
216
+ }
package/src/read.js ADDED
@@ -0,0 +1,325 @@
1
+ import { readBinding, resolveProject } from "./auth-binding.js";
2
+ import { checkDeployment } from "./doctor.js";
3
+ import { sessionFor } from "./auth-session.js";
4
+ import { Stop, openStore, resolveConfigDir } from "./auth-store.js";
5
+ import { MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_PATHS } from "./constants.js";
6
+ import { parseJsonObject } from "./http.js";
7
+ import {
8
+ CAPABILITY_NAMES,
9
+ capabilitySummary,
10
+ routesReport,
11
+ tracesReport,
12
+ usageReport,
13
+ workspaceContext,
14
+ } from "./read-contract.js";
15
+ import { deadline, failureOf, send, trapSignals } from "./transport.js";
16
+
17
+ // The read commands: status, context, capabilities, usage, routes and traces.
18
+ // Each one uses the project's existing verified session (never a browser,
19
+ // never a new grant), shares one total deadline across every request,
20
+ // including a token refresh, and sends GET requests only, to fixed paths on
21
+ // the bound origin. Nothing is written except what the session helper writes
22
+ // when it refreshes the grant. Each returns { outcome, reason, data } where
23
+ // data holds only validated fields and fixed tokens, and never a credential
24
+ // value this run holds, even inside an otherwise valid name.
25
+
26
+ // Which capability each endpoint read needs.
27
+ const READS = Object.freeze({
28
+ usage: { capability: "usage", path: READ_PATHS.usage, validate: usageReport },
29
+ routes: { capability: "routes", path: READ_PATHS.routes, validate: routesReport },
30
+ traces: { capability: "trace_metadata", path: READ_PATHS.traces, validate: tracesReport },
31
+ });
32
+
33
+ export async function runRead(options) {
34
+ const ctx = {
35
+ command: options.command,
36
+ origin: null,
37
+ workspaceId: null,
38
+ profile: null,
39
+ configured: false,
40
+ reachable: null,
41
+ healthy: null,
42
+ profileVerified: null,
43
+ authenticated: false,
44
+ verifiedWorkspace: null,
45
+ capabilities: null,
46
+ };
47
+ const notices = new Set();
48
+ const trap = trapSignals();
49
+ const limit = deadline(options.timeoutMs, trap.signal);
50
+ // Token values this run holds. Filled after the session is verified.
51
+ let known = [];
52
+ const build = (outcome, report) =>
53
+ options.command === "status" ? statusData(ctx, notices, outcome) : readData(ctx, report, notices, outcome);
54
+ const end = (outcome, reason, report = null) => {
55
+ const data = build(outcome, report);
56
+ // Validated metadata can still be any string the service chose. If any
57
+ // printed string holds a credential this run knows, nothing is printed.
58
+ if (holdsKnown(data, known)) {
59
+ notices.clear();
60
+ return {
61
+ outcome: "verification_failed",
62
+ reason: "credential_in_metadata_response",
63
+ data: build("verification_failed", null),
64
+ };
65
+ }
66
+ return { outcome, reason, data };
67
+ };
68
+ // A deadline or Ctrl+C reported by a lower layer as a timeout or
69
+ // cancellation is reported here by what actually happened.
70
+ const settle = (result) => {
71
+ const interrupted =
72
+ (result.outcome === "authorization_failed" && result.reason === "cancelled") ||
73
+ (result.outcome === "connection_failed" && result.reason === "timeout");
74
+ if (interrupted && limit.timedOut()) return { outcome: "connection_failed", reason: "timeout" };
75
+ if (interrupted && trap.signal.aborted) return { outcome: "cancelled", reason: "cancelled" };
76
+ return result;
77
+ };
78
+
79
+ try {
80
+ const root = resolveProject(options.project);
81
+ const configDir = resolveConfigDir(options.configDir);
82
+ const found = readBinding(root);
83
+ if (found === null) return end("login_required", "not_signed_in");
84
+ const binding = found.binding;
85
+ Object.assign(ctx, {
86
+ origin: binding.origin,
87
+ workspaceId: binding.workspace_id,
88
+ profile: binding.deployment_profile,
89
+ configured: true,
90
+ });
91
+ const store = openStore(configDir, { create: false });
92
+
93
+ if (options.command === "status") {
94
+ const health = await checkHealth(binding.origin, limit.signal);
95
+ ctx.reachable = health.reachable;
96
+ ctx.healthy = health.healthy;
97
+ if (!health.ok) {
98
+ const settled = settle(health);
99
+ return end(settled.outcome, settled.reason);
100
+ }
101
+ // The service's own unauthenticated profile report must be the bound
102
+ // profile before the grant is even tried.
103
+ const deployment = checkDeployment(
104
+ await send(binding.origin, "/v1/deployment", { signal: limit.signal, maxBytes: MAX_BODY_BYTES }),
105
+ );
106
+ if (deployment.outcome !== null) {
107
+ if (deployment.outcome === "unhealthy") ctx.healthy = false;
108
+ const settled = settle(deployment);
109
+ return end(settled.outcome, settled.reason);
110
+ }
111
+ ctx.profileVerified = deployment.profile === binding.deployment_profile;
112
+ if (!ctx.profileVerified) return end("verification_failed", "profile_mismatch");
113
+ }
114
+
115
+ const session = await sessionFor(binding, store, limit.signal);
116
+ if (!session.ok) {
117
+ const settled = settle(session);
118
+ return end(settled.outcome, settled.reason);
119
+ }
120
+ known = session.knownCredentials;
121
+ ctx.authenticated = true;
122
+ const { documents } = session;
123
+
124
+ // Notices describe what a command prints, so documents a command only
125
+ // checks report theirs into a set that is dropped.
126
+ const printed = (command) => (options.command === command ? notices : new Set());
127
+ const capabilities = capabilitySummary(documents.capabilities, ctx, printed("capabilities"));
128
+ if (!capabilities.ok) return end(capabilities.outcome, capabilities.reason);
129
+ ctx.capabilities = capabilities.value;
130
+
131
+ if (options.command === "status" || options.command === "context") {
132
+ const context = workspaceContext(documents.workspace, ctx, printed("context"));
133
+ if (!context.ok) return end(context.outcome, context.reason);
134
+ ctx.verifiedWorkspace = context.value.workspace.id;
135
+ if (options.command === "status") {
136
+ notices.add("application_traffic_not_verified");
137
+ return end("ok", null);
138
+ }
139
+ return end("ok", null, context.value);
140
+ }
141
+ if (options.command === "capabilities") return end("ok", null, capabilities.value);
142
+
143
+ const read = READS[options.command];
144
+ const entry = capabilities.value.agent[read.capability];
145
+ if (entry === null || entry.available !== true) return end("capability_unavailable", "capability_unavailable");
146
+ // The service's own bounds; a request beyond them is refused, never
147
+ // clamped into a different question.
148
+ const { bounds } = capabilities.value;
149
+ if ((options.days !== null && options.days > bounds.max_days) || options.limit > bounds.max_rows) {
150
+ return end("unsupported", "exceeds_service_bounds");
151
+ }
152
+
153
+ const response = await fetchRead(session.session, read.path, queryFor(options), limit.signal);
154
+ if (!response.ok) {
155
+ const settled = settle(response);
156
+ return end(settled.outcome, settled.reason, response.retryAfter === undefined ? null : { retry_after_seconds: response.retryAfter });
157
+ }
158
+ const report = read.validate(response.body, ctx, requestOf(options), notices);
159
+ if (!report.ok) return end(report.outcome, report.reason);
160
+ return end("ok", null, report.value);
161
+ } catch (error) {
162
+ if (error instanceof Stop) {
163
+ const settled = settle(error);
164
+ return end(settled.outcome, settled.reason);
165
+ }
166
+ throw error;
167
+ } finally {
168
+ trap.release();
169
+ }
170
+ }
171
+
172
+ function requestOf(options) {
173
+ return {
174
+ days: options.days,
175
+ limit: options.limit,
176
+ route: options.route ?? null,
177
+ status: options.status ?? null,
178
+ cursor: options.cursor ?? null,
179
+ };
180
+ }
181
+
182
+ // The query string is built here from validated options only. The routes
183
+ // endpoint takes none.
184
+ function queryFor(options) {
185
+ if (options.command === "routes") return null;
186
+ const query = new URLSearchParams();
187
+ query.set("days", String(options.days));
188
+ query.set("limit", String(options.limit));
189
+ if (options.command === "traces") {
190
+ if (options.route !== null) query.set("route", options.route);
191
+ if (options.status !== null) query.set("status", options.status);
192
+ if (options.cursor !== null) query.set("cursor", options.cursor);
193
+ }
194
+ return query;
195
+ }
196
+
197
+ // Unauthenticated GET /healthz on the bound origin, so status can tell an
198
+ // unreachable service from a refused grant. status then checks GET
199
+ // /v1/deployment with doctor's decoder, under the same deadline.
200
+ async function checkHealth(origin, signal) {
201
+ const response = await send(origin, "/healthz", { signal, maxBytes: MAX_BODY_BYTES });
202
+ const reachable = response.kind === "response" || response.status !== null;
203
+ const failure = failureOf(response);
204
+ if (failure !== null) {
205
+ return { ok: false, ...failure, reachable, healthy: failure.outcome === "unhealthy" ? false : null };
206
+ }
207
+ if (response.status !== 200) return { ok: false, outcome: "unsupported", reason: "unexpected_status", reachable, healthy: null };
208
+ const body = parseJsonObject(response);
209
+ if (body === null || typeof body.ok !== "boolean") {
210
+ return { ok: false, outcome: "unsupported", reason: "invalid_response", reachable, healthy: null };
211
+ }
212
+ if (!body.ok) return { ok: false, outcome: "unhealthy", reason: "reported_unhealthy", reachable, healthy: false };
213
+ return { ok: true, reachable, healthy: true };
214
+ }
215
+
216
+ const RETRY_AFTER = /^[0-9]{1,5}$/;
217
+ const INSUFFICIENT_SCOPE = /(?:^|[\s,])error\s*=\s*"?insufficient_scope"?(?:$|[\s,])/i;
218
+
219
+ // True when any string in value (keys included) contains one of the known
220
+ // credential values. An exact containment check against values this run
221
+ // holds, not a guess at what looks secret.
222
+ function holdsKnown(value, known) {
223
+ if (known.length === 0) return false;
224
+ if (typeof value === "string") return known.some((secret) => value.includes(secret));
225
+ if (value === null || typeof value !== "object") return false;
226
+ for (const [key, entry] of Object.entries(value)) {
227
+ if (holdsKnown(key, known) || holdsKnown(entry, known)) return true;
228
+ }
229
+ return false;
230
+ }
231
+
232
+ // One GET with the session's access token. A refused token is never
233
+ // refreshed and retried here: the grant was valid moments ago, so a refusal
234
+ // means it was revoked or access was lost.
235
+ async function fetchRead(session, path, query, signal) {
236
+ const response = await send(session.origin, path, {
237
+ signal,
238
+ maxBytes: READ_MAX_BYTES,
239
+ bearer: session.accessToken,
240
+ query,
241
+ });
242
+ if (response.kind === "response") {
243
+ const { status } = response;
244
+ if (status === 401) return { ok: false, outcome: "login_required", reason: "access_revoked" };
245
+ if (status === 403) {
246
+ const body = parseJsonObject(response);
247
+ const header = response.headers["www-authenticate"];
248
+ const scope =
249
+ (typeof header === "string" && INSUFFICIENT_SCOPE.test(header)) ||
250
+ (body !== null && body.error === "insufficient_scope");
251
+ return { ok: false, outcome: "permission_denied", reason: scope ? "insufficient_scope" : "forbidden" };
252
+ }
253
+ if (status === 429) {
254
+ const header = response.headers["retry-after"];
255
+ const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
256
+ return {
257
+ ok: false,
258
+ outcome: "rate_limited",
259
+ reason: "rate_limited",
260
+ retryAfter: seconds !== null && seconds <= 86400 ? seconds : null,
261
+ };
262
+ }
263
+ if (status === 404) return { ok: false, outcome: "unsupported", reason: "endpoint_unavailable" };
264
+ if (status === 400 || status === 422) return { ok: false, outcome: "unsupported", reason: "request_rejected" };
265
+ }
266
+ const failure = failureOf(response);
267
+ if (failure !== null) return { ok: false, ...failure };
268
+ if (response.status !== 200) return { ok: false, outcome: "unsupported", reason: "unexpected_status" };
269
+ const body = parseJsonObject(response);
270
+ if (body === null) return { ok: false, outcome: "verification_failed", reason: "read_response_invalid" };
271
+ return { ok: true, body };
272
+ }
273
+
274
+ // The shape shared by context, capabilities, usage, routes and traces.
275
+ function readData(ctx, report, notices, outcome) {
276
+ return {
277
+ origin: ctx.origin,
278
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
279
+ deployment_profile: ctx.profile,
280
+ authenticated: ctx.authenticated,
281
+ scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
282
+ result: outcome === "ok" ? report : null,
283
+ retry_after_seconds: outcome === "rate_limited" ? report?.retry_after_seconds ?? null : null,
284
+ notices: [...notices].sort(),
285
+ next_action: nextAction(outcome),
286
+ };
287
+ }
288
+
289
+ // status keeps configured, reachable, authenticated, the bound and verified
290
+ // workspace and the capability flags apart. Application traffic is never
291
+ // claimed: existing data or a configured SDK does not prove it.
292
+ function statusData(ctx, notices, outcome) {
293
+ const agent = ctx.capabilities?.agent ?? null;
294
+ return {
295
+ origin: ctx.origin,
296
+ configured: ctx.configured,
297
+ reachable: ctx.reachable,
298
+ healthy: ctx.healthy,
299
+ authenticated: ctx.authenticated,
300
+ deployment_profile: ctx.profile,
301
+ // true when GET /v1/deployment reported exactly the bound profile, false
302
+ // when it reported another one, null when it was not checked.
303
+ deployment_profile_verified: ctx.profileVerified,
304
+ workspace: {
305
+ intended: ctx.workspaceId,
306
+ actual: ctx.verifiedWorkspace,
307
+ match: ctx.verifiedWorkspace === null ? null : ctx.verifiedWorkspace === ctx.workspaceId,
308
+ },
309
+ scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
310
+ capabilities:
311
+ agent === null ? null : Object.fromEntries(CAPABILITY_NAMES.map((name) => [name, agent[name]?.available ?? null])),
312
+ content_access: false,
313
+ application_traffic_verified: false,
314
+ notices: [...notices].sort(),
315
+ next_action: nextAction(outcome),
316
+ };
317
+ }
318
+
319
+ function nextAction(outcome) {
320
+ if (outcome === "login_required") {
321
+ return { kind: "login", message: 'Run "metergraph login --runtime local" in this project to sign in again.' };
322
+ }
323
+ if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
324
+ return null;
325
+ }