metergraph-cli 0.2.0 → 0.2.2
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 +21 -16
- package/assets/skill/SKILL.md +53 -4
- package/assets/skill/manifest.json +3 -3
- package/assets/skills/manifest.json +16 -16
- package/assets/skills/metergraph-analyze/SKILL.md +62 -4
- package/assets/skills/metergraph-investigate/SKILL.md +222 -35
- package/assets/skills/metergraph-local-bundle/SKILL.md +4 -4
- package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
- package/assets/skills/metergraph-report/SKILL.md +19 -2
- package/package.json +1 -1
- package/src/agent-key.js +157 -0
- package/src/approval-handoff.js +5 -0
- package/src/args.js +373 -14
- package/src/auth-browser.js +5 -19
- package/src/auth-callback.js +24 -15
- package/src/auth-login.js +55 -12
- package/src/auth-oauth.js +20 -3
- package/src/callback-page.js +58 -0
- package/src/cli.js +94 -37
- package/src/constants.js +32 -3
- package/src/environment.js +169 -0
- package/src/origin.js +6 -0
- package/src/output.js +325 -130
- package/src/read-contract.js +953 -13
- package/src/read-output.js +449 -30
- package/src/read.js +297 -59
- package/src/setup-deployment.js +4 -3
- package/src/setup-refusal.js +172 -0
- package/src/setup-state.js +4 -2
- package/src/setup.js +227 -53
- package/src/skill-bundle.js +2 -2
- package/src/style.js +101 -0
- package/src/transport.js +8 -4
- package/src/usage-aggregates.js +197 -0
- package/src/verify-output.js +21 -11
package/src/read.js
CHANGED
|
@@ -1,23 +1,33 @@
|
|
|
1
|
+
import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key.js";
|
|
1
2
|
import { readBinding, resolveProject } from "./auth-binding.js";
|
|
2
3
|
import { checkDeployment } from "./doctor.js";
|
|
3
4
|
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";
|
|
5
|
+
import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
|
|
6
|
+
import { DEFAULT_ORIGIN, MAX_BODY_BYTES, METADATA_SCOPE, READ_MAX_BYTES, READ_MAX_LIMIT, READ_PATHS } from "./constants.js";
|
|
6
7
|
import { parseJsonObject } from "./http.js";
|
|
7
8
|
import {
|
|
9
|
+
readQuery,
|
|
10
|
+
groupedRequest,
|
|
11
|
+
READ_ENVIRONMENTS,
|
|
8
12
|
CAPABILITY_NAMES,
|
|
9
13
|
capabilitySummary,
|
|
10
14
|
routesReport,
|
|
11
15
|
tracesReport,
|
|
16
|
+
usageAggregatesReport,
|
|
12
17
|
usageReport,
|
|
13
18
|
workspaceContext,
|
|
14
19
|
} from "./read-contract.js";
|
|
20
|
+
import { compareReports, priorWindow, rankReport } from "./usage-aggregates.js";
|
|
15
21
|
import { deadline, failureOf, send, trapSignals } from "./transport.js";
|
|
22
|
+
import { agentKeyAction, classifyEnvironment, handoffPages } from "./environment.js";
|
|
16
23
|
|
|
17
24
|
// The read commands: status, context, capabilities, usage, routes and traces.
|
|
18
|
-
// Each one uses the project's existing verified session
|
|
19
|
-
//
|
|
20
|
-
//
|
|
25
|
+
// Each one uses either the project's existing verified session or a
|
|
26
|
+
// coding-agent key with Metadata access (agent-key.js); never a browser,
|
|
27
|
+
// never a new grant. A saved project grant is used when one exists, unless
|
|
28
|
+
// --agent-token-file names a key explicitly; otherwise a key in
|
|
29
|
+
// METERGRAPH_AGENT_TOKEN is used. Each one shares one total deadline across
|
|
30
|
+
// every request, including a token refresh, and sends GET requests only, to fixed paths on
|
|
21
31
|
// the bound origin. Nothing is written except what the session helper writes
|
|
22
32
|
// when it refreshes the grant. Each returns { outcome, reason, data } where
|
|
23
33
|
// data holds only validated fields and fixed tokens, and never a credential
|
|
@@ -29,12 +39,21 @@ const READS = Object.freeze({
|
|
|
29
39
|
routes: { capability: "routes", path: READ_PATHS.routes, validate: routesReport },
|
|
30
40
|
traces: { capability: "trace_metadata", path: READ_PATHS.traces, validate: tracesReport },
|
|
31
41
|
});
|
|
42
|
+
const GROUPED_USAGE = Object.freeze({
|
|
43
|
+
capability: "usage_aggregates",
|
|
44
|
+
path: READ_PATHS.usageAggregates,
|
|
45
|
+
validate: usageAggregatesReport,
|
|
46
|
+
});
|
|
32
47
|
|
|
33
48
|
export async function runRead(options) {
|
|
49
|
+
const env = options.env ?? process.env;
|
|
34
50
|
const ctx = {
|
|
35
51
|
command: options.command,
|
|
52
|
+
environment: options.environment ?? classifyEnvironment(env),
|
|
53
|
+
credential: null,
|
|
36
54
|
origin: null,
|
|
37
55
|
workspaceId: null,
|
|
56
|
+
intendedWorkspace: null,
|
|
38
57
|
profile: null,
|
|
39
58
|
configured: false,
|
|
40
59
|
reachable: null,
|
|
@@ -49,14 +68,19 @@ export async function runRead(options) {
|
|
|
49
68
|
const limit = deadline(options.timeoutMs, trap.signal);
|
|
50
69
|
// Token values this run holds. Filled after the session is verified.
|
|
51
70
|
let known = [];
|
|
71
|
+
const grouped = options.command === "usage" && options.grouped !== undefined && options.grouped !== null;
|
|
52
72
|
const build = (outcome, report) =>
|
|
53
|
-
options.command === "status"
|
|
73
|
+
options.command === "status"
|
|
74
|
+
? statusData(ctx, notices, outcome, options)
|
|
75
|
+
: readData(ctx, report, notices, outcome, options, { grouped, report });
|
|
54
76
|
const end = (outcome, reason, report = null) => {
|
|
77
|
+
ctx.reason = reason;
|
|
55
78
|
const data = build(outcome, report);
|
|
56
79
|
// Validated metadata can still be any string the service chose. If any
|
|
57
80
|
// printed string holds a credential this run knows, nothing is printed.
|
|
58
81
|
if (holdsKnown(data, known)) {
|
|
59
82
|
notices.clear();
|
|
83
|
+
ctx.reason = "credential_in_metadata_response";
|
|
60
84
|
return {
|
|
61
85
|
outcome: "verification_failed",
|
|
62
86
|
reason: "credential_in_metadata_response",
|
|
@@ -80,18 +104,57 @@ export async function runRead(options) {
|
|
|
80
104
|
const root = resolveProject(options.project);
|
|
81
105
|
const configDir = resolveConfigDir(options.configDir);
|
|
82
106
|
const found = readBinding(root);
|
|
83
|
-
|
|
84
|
-
const
|
|
107
|
+
const binding = found?.binding ?? null;
|
|
108
|
+
const source = agentKeySource(options, env);
|
|
109
|
+
// The private store matters only for a bound project. With a key at
|
|
110
|
+
// hand, a store that cannot be read means no grant here; without one,
|
|
111
|
+
// its error is the answer, as before.
|
|
112
|
+
let store = null;
|
|
113
|
+
let grantSaved = false;
|
|
114
|
+
let originConfirmed = false;
|
|
115
|
+
if (binding !== null) {
|
|
116
|
+
try {
|
|
117
|
+
store = openStore(configDir, { create: false });
|
|
118
|
+
const record = store === null ? null : readCredential(store, binding.credential_slot);
|
|
119
|
+
grantSaved = record !== null;
|
|
120
|
+
originConfirmed = record !== null && record.origin === binding.origin;
|
|
121
|
+
} catch (error) {
|
|
122
|
+
if (!(error instanceof Stop) || source === null) throw error;
|
|
123
|
+
store = null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// An explicit key file wins. Otherwise a saved grant wins over the
|
|
127
|
+
// environment variable, so a project signed in on this machine behaves as
|
|
128
|
+
// it always has; a binding without a grant here (for example one
|
|
129
|
+
// committed to the repository and cloned into a remote workspace) uses
|
|
130
|
+
// the key, pinned to the binding.
|
|
131
|
+
const useKey = source !== null && (source.kind === "file" || !grantSaved);
|
|
132
|
+
if (!useKey && binding === null) return end("login_required", "not_signed_in");
|
|
133
|
+
if (!useKey && source !== null) notices.add("agent_key_not_used");
|
|
134
|
+
|
|
135
|
+
ctx.credential = useKey ? "agent_key" : "oauth_grant";
|
|
136
|
+
// A key in the environment is checked for shape before any request,
|
|
137
|
+
// including status's unauthenticated probes.
|
|
138
|
+
if (useKey && source.kind === "env") {
|
|
139
|
+
const shape = readAgentKey(source, env);
|
|
140
|
+
if (!shape.ok) return end(shape.outcome, shape.reason);
|
|
141
|
+
}
|
|
142
|
+
// --url, METERGRAPH_URL (for a key only) and --workspace may repeat the
|
|
143
|
+
// binding but never contradict it.
|
|
144
|
+
const target = keyTarget({ binding, options, env: useKey ? env : {}, originConfirmed: originConfirmed || !useKey });
|
|
145
|
+
if (!target.ok) return end(target.outcome, target.reason);
|
|
85
146
|
Object.assign(ctx, {
|
|
86
|
-
origin:
|
|
87
|
-
workspaceId:
|
|
88
|
-
|
|
89
|
-
|
|
147
|
+
origin: target.origin,
|
|
148
|
+
workspaceId: target.workspaceId,
|
|
149
|
+
intendedWorkspace: target.workspaceId,
|
|
150
|
+
profile: target.profile,
|
|
151
|
+
// configured means this project is bound; a key alone binds nothing.
|
|
152
|
+
configured: binding !== null,
|
|
90
153
|
});
|
|
91
|
-
const store = openStore(configDir, { create: false });
|
|
92
154
|
|
|
155
|
+
let verifiedProfile = null;
|
|
93
156
|
if (options.command === "status") {
|
|
94
|
-
const health = await checkHealth(
|
|
157
|
+
const health = await checkHealth(ctx.origin, limit.signal);
|
|
95
158
|
ctx.reachable = health.reachable;
|
|
96
159
|
ctx.healthy = health.healthy;
|
|
97
160
|
if (!health.ok) {
|
|
@@ -101,22 +164,31 @@ export async function runRead(options) {
|
|
|
101
164
|
// The service's own unauthenticated profile report must be the bound
|
|
102
165
|
// profile before the grant is even tried.
|
|
103
166
|
const deployment = checkDeployment(
|
|
104
|
-
await send(
|
|
167
|
+
await send(ctx.origin, "/v1/deployment", { signal: limit.signal, maxBytes: MAX_BODY_BYTES }),
|
|
105
168
|
);
|
|
106
169
|
if (deployment.outcome !== null) {
|
|
107
170
|
if (deployment.outcome === "unhealthy") ctx.healthy = false;
|
|
108
171
|
const settled = settle(deployment);
|
|
109
172
|
return end(settled.outcome, settled.reason);
|
|
110
173
|
}
|
|
111
|
-
|
|
174
|
+
verifiedProfile = deployment.profile;
|
|
175
|
+
ctx.profileVerified = ctx.profile === null || deployment.profile === ctx.profile;
|
|
112
176
|
if (!ctx.profileVerified) return end("verification_failed", "profile_mismatch");
|
|
177
|
+
ctx.profile = deployment.profile;
|
|
113
178
|
}
|
|
114
179
|
|
|
115
|
-
const session =
|
|
180
|
+
const session = useKey
|
|
181
|
+
? await keySession({ target, source, env, signal: limit.signal, verifiedProfile })
|
|
182
|
+
: await sessionFor(binding, store, limit.signal);
|
|
116
183
|
if (!session.ok) {
|
|
117
184
|
const settled = settle(session);
|
|
118
185
|
return end(settled.outcome, settled.reason);
|
|
119
186
|
}
|
|
187
|
+
ctx.profile = session.session.profile;
|
|
188
|
+
if (ctx.workspaceId === null) {
|
|
189
|
+
ctx.workspaceId = session.session.workspaceId;
|
|
190
|
+
notices.add("workspace_not_pinned");
|
|
191
|
+
}
|
|
120
192
|
known = session.knownCredentials;
|
|
121
193
|
ctx.authenticated = true;
|
|
122
194
|
const { documents } = session;
|
|
@@ -140,24 +212,73 @@ export async function runRead(options) {
|
|
|
140
212
|
}
|
|
141
213
|
if (options.command === "capabilities") return end("ok", null, capabilities.value);
|
|
142
214
|
|
|
143
|
-
const read = READS[options.command];
|
|
215
|
+
const read = grouped ? GROUPED_USAGE : READS[options.command];
|
|
144
216
|
const entry = capabilities.value.agent[read.capability];
|
|
145
|
-
if (entry === null || entry.available !== true)
|
|
217
|
+
if (entry === null || entry.available !== true) {
|
|
218
|
+
return end("capability_unavailable", grouped ? "usage_aggregates_unavailable" : "capability_unavailable");
|
|
219
|
+
}
|
|
146
220
|
// The service's own bounds; a request beyond them is refused, never
|
|
147
221
|
// clamped into a different question.
|
|
148
222
|
const { bounds } = capabilities.value;
|
|
149
|
-
|
|
223
|
+
// An explicit grouped window is held to the same day bound as --days.
|
|
224
|
+
const explicitDays = grouped && options.grouped.since !== null
|
|
225
|
+
? ((options.grouped.until === null ? Date.now() : Date.parse(options.grouped.until)) -
|
|
226
|
+
Date.parse(options.grouped.since)) / (24 * 60 * 60 * 1000)
|
|
227
|
+
: null;
|
|
228
|
+
if (
|
|
229
|
+
(options.days !== null && options.days > bounds.max_days) ||
|
|
230
|
+
(explicitDays !== null && explicitDays > bounds.max_days) ||
|
|
231
|
+
options.limit > bounds.max_rows
|
|
232
|
+
) {
|
|
150
233
|
return end("unsupported", "exceeds_service_bounds");
|
|
151
234
|
}
|
|
152
235
|
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
236
|
+
const fetchOne = async (query, request, into) => {
|
|
237
|
+
// When this read was asked for, to check a window that ends "now".
|
|
238
|
+
request = { ...request, requestedAt: Date.now() };
|
|
239
|
+
const response = await fetchRead(session.session, read.path, query, limit.signal);
|
|
240
|
+
if (!response.ok) {
|
|
241
|
+
// A key refused moments after it was verified was revoked meanwhile.
|
|
242
|
+
if (ctx.credential === "agent_key" && response.reason === "access_revoked") response.reason = "agent_key_rejected";
|
|
243
|
+
const settled = settle(response);
|
|
244
|
+
return {
|
|
245
|
+
ok: false,
|
|
246
|
+
...settled,
|
|
247
|
+
detail: response.retryAfter === undefined ? null : { retry_after_seconds: response.retryAfter },
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
return read.validate(response.body, ctx, request, into);
|
|
251
|
+
};
|
|
252
|
+
if (!grouped) {
|
|
253
|
+
const report = await fetchOne(readQuery(options), requestOf(options), notices);
|
|
254
|
+
if (!report.ok) return end(report.outcome, report.reason, report.detail ?? null);
|
|
255
|
+
return end("ok", null, report.value);
|
|
157
256
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
257
|
+
|
|
258
|
+
// Grouped usage: the current window, then for --compare the prior
|
|
259
|
+
// window computed from the window the service actually read.
|
|
260
|
+
const request = groupedRequest(options);
|
|
261
|
+
const current = await fetchOne(readQuery(options, request), request, notices);
|
|
262
|
+
if (!current.ok) return end(current.outcome, current.reason, current.detail ?? null);
|
|
263
|
+
let report = rankReport(current.value, options.grouped.sort, notices);
|
|
264
|
+
if (options.grouped.compare !== null) {
|
|
265
|
+
const window = priorWindow(current.value.window, options.grouped.compare);
|
|
266
|
+
const priorRequest = { ...request, days: null, since: window.since, until: window.until, limit: options.limit };
|
|
267
|
+
// The prior read's own coverage notices describe the prior window; they
|
|
268
|
+
// are kept apart and summarised by the comparison.
|
|
269
|
+
const priorNotices = new Set();
|
|
270
|
+
const prior = await fetchOne(readQuery(options, priorRequest), priorRequest, priorNotices);
|
|
271
|
+
if (!prior.ok) return end(prior.outcome, prior.reason, prior.detail ?? null);
|
|
272
|
+
// Both windows must cover the same environments, or the change compares
|
|
273
|
+
// different populations.
|
|
274
|
+
if (JSON.stringify(prior.value[READ_ENVIRONMENTS]) !== JSON.stringify(current.value[READ_ENVIRONMENTS])) {
|
|
275
|
+
return end("verification_failed", "filter_mismatch");
|
|
276
|
+
}
|
|
277
|
+
for (const token of priorNotices) if (token !== "current_day_partial") notices.add(`prior_${token}`);
|
|
278
|
+
// The ranked report, so comparison rows follow the requested --sort.
|
|
279
|
+
report = { ...report, comparison: compareReports(report, prior.value, options.grouped.compare, notices) };
|
|
280
|
+
}
|
|
281
|
+
return end("ok", null, report);
|
|
161
282
|
} catch (error) {
|
|
162
283
|
if (error instanceof Stop) {
|
|
163
284
|
const settled = settle(error);
|
|
@@ -176,23 +297,11 @@ function requestOf(options) {
|
|
|
176
297
|
route: options.route ?? null,
|
|
177
298
|
status: options.status ?? null,
|
|
178
299
|
cursor: options.cursor ?? null,
|
|
300
|
+
metadata: options.metadata ?? null,
|
|
301
|
+
groupByMetadata: options.groupByMetadata ?? null,
|
|
179
302
|
};
|
|
180
303
|
}
|
|
181
304
|
|
|
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
305
|
|
|
197
306
|
// Unauthenticated GET /healthz on the bound origin, so status can tell an
|
|
198
307
|
// unreachable service from a refused grant. status then checks GET
|
|
@@ -229,6 +338,14 @@ function holdsKnown(value, known) {
|
|
|
229
338
|
return false;
|
|
230
339
|
}
|
|
231
340
|
|
|
341
|
+
const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
342
|
+
|
|
343
|
+
function retryAfterOf(response) {
|
|
344
|
+
const header = response.headers["retry-after"];
|
|
345
|
+
const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
|
|
346
|
+
return seconds !== null && seconds <= 86400 ? seconds : null;
|
|
347
|
+
}
|
|
348
|
+
|
|
232
349
|
// One GET with the session's access token. A refused token is never
|
|
233
350
|
// refreshed and retried here: the grant was valid moments ago, so a refusal
|
|
234
351
|
// means it was revoked or access was lost.
|
|
@@ -251,16 +368,18 @@ async function fetchRead(session, path, query, signal) {
|
|
|
251
368
|
return { ok: false, outcome: "permission_denied", reason: scope ? "insufficient_scope" : "forbidden" };
|
|
252
369
|
}
|
|
253
370
|
if (status === 429) {
|
|
254
|
-
|
|
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
|
-
};
|
|
371
|
+
return { ok: false, outcome: "rate_limited", reason: "rate_limited", retryAfter: retryAfterOf(response) };
|
|
262
372
|
}
|
|
263
373
|
if (status === 404) return { ok: false, outcome: "unsupported", reason: "endpoint_unavailable" };
|
|
374
|
+
if (status === 413) return { ok: false, outcome: "unsupported", reason: "response_too_large" };
|
|
375
|
+
if (status === 503) {
|
|
376
|
+
// The service's read budget (time or pool) was exceeded. Nothing is
|
|
377
|
+
// retried here; the result says when to retry and how to narrow it.
|
|
378
|
+
const body = parseJsonObject(response);
|
|
379
|
+
if (isObject(body?.error) && body.error.code === "temporarily_unavailable") {
|
|
380
|
+
return { ok: false, outcome: "unhealthy", reason: "temporarily_unavailable", retryAfter: retryAfterOf(response) };
|
|
381
|
+
}
|
|
382
|
+
}
|
|
264
383
|
if (status === 400 || status === 422) return { ok: false, outcome: "unsupported", reason: "request_rejected" };
|
|
265
384
|
}
|
|
266
385
|
const failure = failureOf(response);
|
|
@@ -272,27 +391,32 @@ async function fetchRead(session, path, query, signal) {
|
|
|
272
391
|
}
|
|
273
392
|
|
|
274
393
|
// The shape shared by context, capabilities, usage, routes and traces.
|
|
275
|
-
function readData(ctx, report, notices, outcome) {
|
|
394
|
+
function readData(ctx, report, notices, outcome, options, extra = {}) {
|
|
276
395
|
return {
|
|
277
396
|
origin: ctx.origin,
|
|
278
397
|
workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
|
|
279
398
|
deployment_profile: ctx.profile,
|
|
399
|
+
environment: ctx.environment.kind,
|
|
400
|
+
credential: ctx.credential,
|
|
280
401
|
authenticated: ctx.authenticated,
|
|
281
402
|
scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
|
|
282
403
|
result: outcome === "ok" ? report : null,
|
|
283
|
-
retry_after_seconds: outcome === "rate_limited"
|
|
404
|
+
retry_after_seconds: outcome === "rate_limited" || report?.retry_after_seconds !== undefined
|
|
405
|
+
? report?.retry_after_seconds ?? null : null,
|
|
284
406
|
notices: [...notices].sort(),
|
|
285
|
-
next_action: nextAction(outcome),
|
|
407
|
+
next_action: nextAction(outcome, ctx, options, extra),
|
|
286
408
|
};
|
|
287
409
|
}
|
|
288
410
|
|
|
289
411
|
// status keeps configured, reachable, authenticated, the bound and verified
|
|
290
412
|
// workspace and the capability flags apart. Application traffic is never
|
|
291
413
|
// claimed: existing data or a configured SDK does not prove it.
|
|
292
|
-
function statusData(ctx, notices, outcome) {
|
|
414
|
+
function statusData(ctx, notices, outcome, options) {
|
|
293
415
|
const agent = ctx.capabilities?.agent ?? null;
|
|
294
416
|
return {
|
|
295
417
|
origin: ctx.origin,
|
|
418
|
+
environment: ctx.environment.kind,
|
|
419
|
+
credential: ctx.credential,
|
|
296
420
|
configured: ctx.configured,
|
|
297
421
|
reachable: ctx.reachable,
|
|
298
422
|
healthy: ctx.healthy,
|
|
@@ -301,10 +425,15 @@ function statusData(ctx, notices, outcome) {
|
|
|
301
425
|
// true when GET /v1/deployment reported exactly the bound profile, false
|
|
302
426
|
// when it reported another one, null when it was not checked.
|
|
303
427
|
deployment_profile_verified: ctx.profileVerified,
|
|
428
|
+
// intended is null for an agent key with no binding and no --workspace;
|
|
429
|
+
// match is then null too, and the notices say workspace_not_pinned.
|
|
304
430
|
workspace: {
|
|
305
|
-
intended: ctx.
|
|
431
|
+
intended: ctx.intendedWorkspace,
|
|
306
432
|
actual: ctx.verifiedWorkspace,
|
|
307
|
-
match:
|
|
433
|
+
match:
|
|
434
|
+
ctx.verifiedWorkspace === null || ctx.intendedWorkspace === null
|
|
435
|
+
? null
|
|
436
|
+
: ctx.verifiedWorkspace === ctx.intendedWorkspace,
|
|
308
437
|
},
|
|
309
438
|
scopes: ctx.authenticated ? [METADATA_SCOPE] : [],
|
|
310
439
|
capabilities:
|
|
@@ -312,14 +441,123 @@ function statusData(ctx, notices, outcome) {
|
|
|
312
441
|
content_access: false,
|
|
313
442
|
application_traffic_verified: false,
|
|
314
443
|
notices: [...notices].sort(),
|
|
315
|
-
next_action: nextAction(outcome),
|
|
444
|
+
next_action: nextAction(outcome, ctx, options),
|
|
316
445
|
};
|
|
317
446
|
}
|
|
318
447
|
|
|
319
|
-
|
|
448
|
+
const NARROW =
|
|
449
|
+
"Narrow the read: a shorter --days or --since/--until window, exact --route, --provider or --model filters, " +
|
|
450
|
+
"fewer --group-by dimensions or a lower --limit.";
|
|
451
|
+
|
|
452
|
+
// The next action for a read, by credential and environment. Where browser
|
|
453
|
+
// sign in cannot finish (SSH, a cloud workspace, CI), the answer is always
|
|
454
|
+
// the agent key handoff, never "run login". Grouped usage adds its own steps
|
|
455
|
+
// (a supported read, retry, narrow, raise the limit, compare like for like);
|
|
456
|
+
// their text is fixed except a window and limit computed from validated values.
|
|
457
|
+
function nextAction(outcome, ctx, options, { grouped = false, report = null } = {}) {
|
|
458
|
+
const origin = ctx.origin ?? options.origin ?? DEFAULT_ORIGIN;
|
|
459
|
+
const keyed = ctx.credential === "agent_key";
|
|
460
|
+
const reason = ctx.reason;
|
|
461
|
+
const capabilities = ctx.capabilities;
|
|
462
|
+
if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
|
|
463
|
+
if (reason === "usage_aggregates_unavailable" || (grouped && reason === "endpoint_unavailable")) {
|
|
464
|
+
// Offer the route-by-day read only when this grant actually has it.
|
|
465
|
+
if (capabilities?.agent?.usage?.available !== true) return null;
|
|
466
|
+
return {
|
|
467
|
+
kind: "use_supported_read",
|
|
468
|
+
message:
|
|
469
|
+
"This deployment does not offer grouped usage to this grant. \"metergraph usage --days N\" still lists " +
|
|
470
|
+
"cost per route and day over the last N x 24 hours. Grouping by provider, model, UTC day or a metadata key " +
|
|
471
|
+
"needs a service that offers grouped usage; nothing was read.",
|
|
472
|
+
command: "metergraph usage --days 7 --json",
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (grouped && reason === "temporarily_unavailable") {
|
|
476
|
+
return { kind: "retry_later", message: `The service stopped the read at its time budget. Wait, then retry. ${NARROW}` };
|
|
477
|
+
}
|
|
478
|
+
if (grouped && reason === "response_too_large") return { kind: "narrow_read", message: NARROW };
|
|
479
|
+
if (outcome === "ok" && report?.sort && !report.sort.complete && report.matched_groups !== null) {
|
|
480
|
+
const needed = report.matched_groups;
|
|
481
|
+
// The largest --limit this deployment accepts, not only the CLI's own.
|
|
482
|
+
const maxLimit = Math.min(READ_MAX_LIMIT, capabilities?.bounds?.max_rows ?? READ_MAX_LIMIT);
|
|
483
|
+
return needed <= maxLimit
|
|
484
|
+
? {
|
|
485
|
+
kind: "raise_limit",
|
|
486
|
+
message: `The ranking covers the ${report.rows} highest-cost groups of ${needed}. ` +
|
|
487
|
+
`Run the same command with --limit ${needed} to rank every group.`,
|
|
488
|
+
}
|
|
489
|
+
: { kind: "narrow_read", message: `The ranking covers the ${report.rows} highest-cost groups of ${needed}. ${NARROW}` };
|
|
490
|
+
}
|
|
491
|
+
if (outcome === "ok" && report?.comparison?.partial_day_compared_with_full) {
|
|
492
|
+
const window = report.comparison.like_for_like_window;
|
|
493
|
+
return {
|
|
494
|
+
kind: "compare_like_for_like",
|
|
495
|
+
message:
|
|
496
|
+
"The current window ends part way through today (UTC) and the prior period is whole days. " +
|
|
497
|
+
"For equal lengths, run the same command with --compare previous-same-time " +
|
|
498
|
+
`(prior window ${window.since} to ${window.until}).`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
if (keyed && (reason === "agent_key_rejected" || reason === "agent_key_insufficient_scope")) {
|
|
502
|
+
return {
|
|
503
|
+
kind: "replace_agent_key",
|
|
504
|
+
url: handoffPages(origin).keys_url,
|
|
505
|
+
message:
|
|
506
|
+
"The service refused this key: it is unknown, expired or revoked, or it lacks Metadata access. Ask the " +
|
|
507
|
+
"person to create a new coding-agent key with Metadata access on the Keys page and replace the secret. " +
|
|
508
|
+
"Do not ask for the key in chat.",
|
|
509
|
+
};
|
|
510
|
+
}
|
|
511
|
+
if (keyed && (reason === "scope_mismatch" || reason === "content_access_granted")) {
|
|
512
|
+
return {
|
|
513
|
+
kind: "use_metadata_only_key",
|
|
514
|
+
url: handoffPages(origin).keys_url,
|
|
515
|
+
message:
|
|
516
|
+
"This key grants more than Metadata access, so the CLI does not use it. Ask the person for a " +
|
|
517
|
+
"coding-agent key limited to Metadata access.",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (keyed && reason === "workspace_context_mismatch") {
|
|
521
|
+
return {
|
|
522
|
+
kind: "use_workspace_key",
|
|
523
|
+
url: handoffPages(origin).keys_url,
|
|
524
|
+
workspace: ctx.intendedWorkspace === null ? null : { id: ctx.intendedWorkspace },
|
|
525
|
+
message:
|
|
526
|
+
"This key belongs to a different workspace than this project or --workspace names. Ask the person to " +
|
|
527
|
+
"create the key in the intended workspace, or pass the intended workspace with --workspace.",
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
if (keyed && reason === "agent_key_origin_unconfirmed") {
|
|
531
|
+
return {
|
|
532
|
+
kind: "confirm_origin",
|
|
533
|
+
message:
|
|
534
|
+
"This project's binding names a deployment other than the hosted service. A Metadata key is sent there " +
|
|
535
|
+
"only when you confirm the same address: set METERGRAPH_URL or pass --url with it.",
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
if (keyed && (reason === "bound_to_other_origin" || reason === "bound_to_other_workspace")) {
|
|
539
|
+
return {
|
|
540
|
+
kind: "match_project_binding",
|
|
541
|
+
message:
|
|
542
|
+
"This project's .metergraph/project.json names a different origin or workspace. Remove --url, " +
|
|
543
|
+
"--workspace or METERGRAPH_URL, or use a key for the bound workspace.",
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
if (keyed && reason?.startsWith("agent_token")) {
|
|
547
|
+
return {
|
|
548
|
+
kind: "fix_agent_key",
|
|
549
|
+
message:
|
|
550
|
+
"The key could not be used as given. METERGRAPH_AGENT_TOKEN must hold exactly one key with no label " +
|
|
551
|
+
"or spaces; --agent-token-file must name a private (0600) file owned by this user.",
|
|
552
|
+
};
|
|
553
|
+
}
|
|
320
554
|
if (outcome === "login_required") {
|
|
321
|
-
|
|
555
|
+
if (ctx.environment.remote) return agentKeyAction(ctx.environment, origin);
|
|
556
|
+
return {
|
|
557
|
+
kind: "login",
|
|
558
|
+
message: 'Run "metergraph login --runtime local" in this project to sign in again.',
|
|
559
|
+
alternative: agentKeyAction(ctx.environment, origin),
|
|
560
|
+
};
|
|
322
561
|
}
|
|
323
|
-
if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
|
|
324
562
|
return null;
|
|
325
563
|
}
|
package/src/setup-deployment.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { classifyEnvironment, remoteSetupAction } from "./environment.js";
|
|
2
2
|
import { runDoctor } from "./doctor.js";
|
|
3
3
|
import { action, planDeploymentRoute, verifyDeploymentRoute } from "./deployment-route.js";
|
|
4
4
|
import { AUTH_HTTP_TIMEOUT_MS } from "./constants.js";
|
|
@@ -164,8 +164,9 @@ export async function preflightNonHostedSetup(options, { env = process.env } = {
|
|
|
164
164
|
// Setup itself refuses a remote session, but only after this preflight.
|
|
165
165
|
// Refuse here first, so a remote shell sends no request and never reads the
|
|
166
166
|
// separate agent token file.
|
|
167
|
-
|
|
168
|
-
|
|
167
|
+
const environment = classifyEnvironment(env);
|
|
168
|
+
if (environment.remote) {
|
|
169
|
+
return handoff("unsupported", "run_on_local_machine", remoteSetupAction(environment, plan.origin, options), {
|
|
169
170
|
profile: plan.deployment_profile, origin: plan.origin, workspaceId: plan.workspace_id,
|
|
170
171
|
});
|
|
171
172
|
}
|