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