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.
Files changed (43) hide show
  1. package/README.md +60 -11
  2. package/assets/skill/SKILL.md +52 -3
  3. package/assets/skill/manifest.json +3 -3
  4. package/assets/skills/manifest.json +13 -13
  5. package/assets/skills/metergraph-analyze/SKILL.md +62 -4
  6. package/assets/skills/metergraph-investigate/SKILL.md +222 -35
  7. package/assets/skills/metergraph-onboarding/SKILL.md +7 -0
  8. package/assets/skills/metergraph-report/SKILL.md +19 -2
  9. package/bin/metergraph.js +1 -1
  10. package/package.json +1 -1
  11. package/src/agent-key.js +160 -0
  12. package/src/approval-handoff.js +5 -0
  13. package/src/args.js +572 -17
  14. package/src/auth-browser.js +5 -19
  15. package/src/auth-callback.js +24 -15
  16. package/src/auth-login.js +55 -12
  17. package/src/auth-oauth.js +77 -7
  18. package/src/callback-page.js +58 -0
  19. package/src/cli.js +190 -45
  20. package/src/constants.js +73 -3
  21. package/src/environment.js +169 -0
  22. package/src/experiments-contract.js +462 -0
  23. package/src/experiments-output.js +189 -0
  24. package/src/experiments.js +397 -0
  25. package/src/origin.js +6 -0
  26. package/src/output.js +437 -131
  27. package/src/pricing-args.js +163 -0
  28. package/src/pricing-output.js +206 -0
  29. package/src/pricing-reference.js +195 -0
  30. package/src/pricing.js +307 -0
  31. package/src/read-contract.js +976 -16
  32. package/src/read-output.js +449 -30
  33. package/src/read.js +312 -60
  34. package/src/setup-deployment.js +11 -3
  35. package/src/setup-interactive.js +341 -0
  36. package/src/setup-refusal.js +172 -0
  37. package/src/setup-state.js +4 -2
  38. package/src/setup.js +227 -53
  39. package/src/skill-bundle.js +2 -2
  40. package/src/style.js +101 -0
  41. package/src/transport.js +13 -7
  42. package/src/usage-aggregates.js +197 -0
  43. package/src/verify-output.js +21 -11
@@ -0,0 +1,397 @@
1
+ import { agentKeySource, keySession, keyTarget, readAgentKey } from "./agent-key.js";
2
+ import { readBinding, resolveProject } from "./auth-binding.js";
3
+ import { Stop, openStore, readCredential, resolveConfigDir } from "./auth-store.js";
4
+ import { DEFAULT_ORIGIN, EXPERIMENT_PATHS, EXPERIMENT_START_SCOPE, METADATA_SCOPE, READ_MAX_BYTES } from "./constants.js";
5
+ import { AGENT_URL_ENV, classifyEnvironment, handoffPages } from "./environment.js";
6
+ import {
7
+ experimentEstimate,
8
+ experimentRefusal,
9
+ experimentStarted,
10
+ experimentSummary,
11
+ experimentsList,
12
+ } from "./experiments-contract.js";
13
+ import { parseJsonObject } from "./http.js";
14
+ import { parseOrigin } from "./origin.js";
15
+ import { holdsKnown } from "./read.js";
16
+ import { deadline, failureOf, send, trapSignals } from "./transport.js";
17
+
18
+ // Experiments: replay a workload's recent production calls on 1 to 3
19
+ // treatments and read the built-in statistics per arm against production.
20
+ //
21
+ // list, estimate and get are Metadata reads. runRead (read.js) establishes
22
+ // their session exactly as for every other read command, a saved grant or a
23
+ // Metadata key, and calls experimentRead with it. They send only these
24
+ // requests and print only validated fields; no input, output or tracked
25
+ // field value is ever requested.
26
+ //
27
+ // start is the only command that changes a workspace. It needs a separate
28
+ // coding-agent key with Debug access and the agent:analysis-run grant, from
29
+ // --agent-token-file or METERGRAPH_AGENT_TOKEN, verified for exactly that
30
+ // (auth-oauth.js checkStartWorkspace and checkStartCapabilities) before it is
31
+ // used for this one POST. A saved sign in is Metadata only and never starts
32
+ // one. Each start creates a new experiment, so a start that succeeded is not
33
+ // retried.
34
+
35
+ const CAPABILITIES = Object.freeze({ list: "experiments", estimate: "experiment_estimate", get: "experiment" });
36
+ const RETRY_AFTER = /^[0-9]{1,5}$/;
37
+ // The service's refusal reasons with their own fixed message here; any other
38
+ // is reported as experiment_refused with its code kept in data.refusal.
39
+ export const REFUSAL_REASONS = Object.freeze([
40
+ "over_budget",
41
+ "no_replayable_calls",
42
+ "experiments_in_progress",
43
+ "daily_budget_exhausted",
44
+ "route_unpriced",
45
+ "invalid_reasoning_effort",
46
+ "invalid_settings",
47
+ "invalid_sample",
48
+ "invalid_treatment",
49
+ "invalid_treatments",
50
+ "invalid_tracked_fields",
51
+ "invalid_request",
52
+ ]);
53
+ export const POLL_AFTER_SECONDS = 15;
54
+
55
+ const isObject = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
56
+
57
+ // A Metadata capability this grant can read: listed, available and plainly
58
+ // metadata only.
59
+ function readable(entry) {
60
+ return isObject(entry) && entry.available === true && entry.content === false && entry.mutates === false &&
61
+ entry.external_calls === false && entry.privacy_class === "metadata" && entry.required_scope === METADATA_SCOPE;
62
+ }
63
+
64
+ function retryAfterOf(response) {
65
+ const header = response.headers["retry-after"];
66
+ const seconds = typeof header === "string" && RETRY_AFTER.test(header.trim()) ? Number(header.trim()) : null;
67
+ return seconds !== null && seconds <= 86400 ? seconds : null;
68
+ }
69
+
70
+ // One answer from an experiments endpoint as { ok: true, body } or a failure
71
+ // with fixed tokens. A refusal keeps the service's code, reason and numbers.
72
+ function answer(response, expected) {
73
+ if (response.kind === "response") {
74
+ const { status } = response;
75
+ if (status === 401) return { ok: false, outcome: "login_required", reason: "access_revoked" };
76
+ if (status === 403) return { ok: false, outcome: "permission_denied", reason: "forbidden" };
77
+ if (status === 429) return { ok: false, outcome: "rate_limited", reason: "rate_limited", retryAfter: retryAfterOf(response) };
78
+ if (status === 501) return { ok: false, outcome: "capability_unavailable", reason: "experiments_unavailable" };
79
+ if (status === 404 || status === 409 || status === 422) {
80
+ const refusal = experimentRefusal(parseJsonObject(response));
81
+ if (refusal === null) return { ok: false, outcome: "unsupported", reason: "request_rejected" };
82
+ const named = refusal.code === "not_found" ? "experiment_not_found" : refusal.reason;
83
+ const reason = named === "experiment_not_found" || REFUSAL_REASONS.includes(named) ? named : "experiment_refused";
84
+ return { ok: false, outcome: "refused", reason, refusal };
85
+ }
86
+ }
87
+ const failure = failureOf(response);
88
+ if (failure !== null) return { ok: false, ...failure };
89
+ if (response.status !== expected) return { ok: false, outcome: "unsupported", reason: "unexpected_status" };
90
+ const body = parseJsonObject(response);
91
+ if (body === null) return { ok: false, outcome: "verification_failed", reason: "experiment_response_invalid" };
92
+ return { ok: true, body };
93
+ }
94
+
95
+ // The list, estimate or summary read for runRead, with its verified session.
96
+ export async function experimentRead(session, options, ctx, documents, signal, notices) {
97
+ if (!readable(documents.capabilities?.agent?.[CAPABILITIES[options.action]])) {
98
+ return { ok: false, outcome: "capability_unavailable", reason: "experiments_unavailable" };
99
+ }
100
+ const request = options.experiment;
101
+ const common = { signal, maxBytes: READ_MAX_BYTES, bearer: session.accessToken };
102
+ let response;
103
+ let validate;
104
+ if (options.action === "list") {
105
+ const query = new URLSearchParams();
106
+ if (request.workloadId !== null) query.set("workload_id", request.workloadId);
107
+ query.set("limit", String(request.limit));
108
+ response = await send(session.origin, EXPERIMENT_PATHS.list, { ...common, query });
109
+ validate = (body) => experimentsList(body, ctx, request, notices);
110
+ } else if (options.action === "estimate") {
111
+ response = await send(session.origin, EXPERIMENT_PATHS.estimate, { ...common, method: "POST", json: request.request });
112
+ validate = (body) => experimentEstimate(body, ctx, request.request, notices);
113
+ } else {
114
+ // The ID is a validated UUID, so the path is still a fixed shape.
115
+ response = await send(session.origin, `${EXPERIMENT_PATHS.list}/${request.id}`, common);
116
+ validate = (body) => experimentSummary(body, ctx, request, notices);
117
+ }
118
+ const read = answer(response, 200);
119
+ if (!read.ok) {
120
+ if (read.reason === "access_revoked" && ctx.credential === "agent_key") read.reason = "agent_key_rejected";
121
+ return read;
122
+ }
123
+ return validate(read.body);
124
+ }
125
+
126
+ // What an agent does next with an experiments result, or undefined for the
127
+ // read commands' shared handling (keys, sign in, workspace, rate limits).
128
+ export function experimentNextAction(outcome, ctx, options, report) {
129
+ if (options.command !== "experiments") return undefined;
130
+ const origin = ctx.origin;
131
+ if (outcome === "ok" && options.action === "estimate") {
132
+ return {
133
+ kind: report.blocking_reasons.length > 0 ? "change_experiment" : "start_experiment",
134
+ message: report.blocking_reasons.length > 0
135
+ ? "A start now would be refused: this estimate is above the experiment budget or the workspace's daily " +
136
+ "cap. Sample fewer calls, drop a treatment or lower max_tokens, then estimate again."
137
+ : 'Run the same command as "metergraph experiments start" to start it. A start needs a coding-agent key ' +
138
+ "with Debug access and Allow analysis starts, in --agent-token-file or METERGRAPH_AGENT_TOKEN; " +
139
+ "or start it in the dashboard.",
140
+ url: report.new_experiment_url,
141
+ start_granted: report.start_granted,
142
+ };
143
+ }
144
+ if (outcome === "ok" && options.action === "get") return pollOrOpen(report.experiment);
145
+ if (outcome === "refused" && ctx.reason === "experiment_not_found") {
146
+ return {
147
+ kind: "list_experiments",
148
+ message: "No experiment with this ID is visible to this credential's workspace. List them to find it.",
149
+ command: "metergraph experiments list --json",
150
+ };
151
+ }
152
+ if (outcome === "refused") return refusedAction(ctx.reason);
153
+ if (outcome === "capability_unavailable") {
154
+ return {
155
+ kind: "use_dashboard",
156
+ message:
157
+ "This deployment does not offer experiments to this credential: the feature may be off for it. " +
158
+ "Nothing was read or started.",
159
+ url: origin === null ? null : `${origin}/#experiments`,
160
+ };
161
+ }
162
+ return undefined;
163
+ }
164
+
165
+ function pollOrOpen(experiment, { started = false } = {}) {
166
+ if (!experiment.terminal) {
167
+ return {
168
+ kind: "poll_experiment",
169
+ message: started
170
+ ? `The experiment is ${experiment.status}. Poll it in about ${POLL_AFTER_SECONDS} seconds with a Metadata key ` +
171
+ "or this project's sign in; the start key is not used for reads."
172
+ : `The experiment is ${experiment.status}. Run the same command again in about ${POLL_AFTER_SECONDS} seconds.`,
173
+ command: `metergraph experiments get --id ${experiment.id} --json`,
174
+ poll_after_seconds: POLL_AFTER_SECONDS,
175
+ };
176
+ }
177
+ if (experiment.status === "failed") {
178
+ return {
179
+ kind: "open_experiment",
180
+ message:
181
+ "The experiment failed; its error_code says why. Statistics cover only the cases that finished. " +
182
+ "Inputs, outputs and tracked field values are in the dashboard.",
183
+ url: experiment.app_url,
184
+ };
185
+ }
186
+ return {
187
+ kind: "open_experiment",
188
+ message:
189
+ "The statistics show how each treatment differs from production on the same calls; they do not judge it. " +
190
+ "Inputs, outputs and tracked field values are in the dashboard.",
191
+ url: experiment.app_url,
192
+ };
193
+ }
194
+
195
+ function refusedAction(reason) {
196
+ if (reason === "experiments_in_progress" || reason === "daily_budget_exhausted") {
197
+ return {
198
+ kind: "retry_later",
199
+ message: reason === "experiments_in_progress"
200
+ ? "Two experiments are already running in this workspace. Start this one when one finishes."
201
+ : "This workspace has used its daily experiment budget. Try again later.",
202
+ };
203
+ }
204
+ return {
205
+ kind: "change_experiment",
206
+ message:
207
+ "Change the request and estimate it again: sample fewer calls, drop or change a treatment, lower max_tokens " +
208
+ "or pick a workload with captured calls. Nothing was started.",
209
+ };
210
+ }
211
+
212
+ // experiments start: one POST with a start key, then the experiment to poll.
213
+ export async function runExperimentStart(options) {
214
+ const env = options.env ?? process.env;
215
+ const ctx = {
216
+ environment: options.environment ?? classifyEnvironment(env),
217
+ origin: null,
218
+ workspaceId: null,
219
+ intendedWorkspace: null,
220
+ profile: null,
221
+ authenticated: false,
222
+ scopes: [],
223
+ reason: null,
224
+ // Where the Keys page is before any origin is resolved.
225
+ keysOrigin: options.origin ?? (typeof env[AGENT_URL_ENV] === "string" ? parseOrigin(env[AGENT_URL_ENV]) : null) ??
226
+ DEFAULT_ORIGIN,
227
+ };
228
+ const notices = new Set();
229
+ const trap = trapSignals();
230
+ const limit = deadline(options.timeoutMs, trap.signal);
231
+ let known = [];
232
+ const end = (outcome, reason, report = null) => {
233
+ ctx.reason = reason;
234
+ const data = startData(ctx, notices, outcome, report);
235
+ if (holdsKnown(data, known)) {
236
+ return { outcome: "verification_failed", reason: "credential_in_metadata_response",
237
+ data: startData({ ...ctx, reason: "credential_in_metadata_response" }, new Set(), "verification_failed", null) };
238
+ }
239
+ return { outcome, reason, data };
240
+ };
241
+ const settle = (result) => {
242
+ const interrupted =
243
+ (result.outcome === "authorization_failed" && result.reason === "cancelled") ||
244
+ (result.outcome === "connection_failed" && result.reason === "timeout");
245
+ if (interrupted && limit.timedOut()) return { outcome: "connection_failed", reason: "timeout" };
246
+ if (interrupted && trap.signal.aborted) return { outcome: "cancelled", reason: "cancelled" };
247
+ return result;
248
+ };
249
+ try {
250
+ const root = resolveProject(options.project);
251
+ const binding = readBinding(root)?.binding ?? null;
252
+ const source = agentKeySource(options, env);
253
+ if (source === null) return end("permission_denied", "experiment_start_key_required");
254
+ // As for reads: a binding's non-hosted origin is confirmed by a saved
255
+ // sign in for it on this machine, or by --url or METERGRAPH_URL.
256
+ let originConfirmed = false;
257
+ if (binding !== null) {
258
+ try {
259
+ const store = openStore(resolveConfigDir(options.configDir), { create: false });
260
+ const record = store === null ? null : readCredential(store, binding.credential_slot);
261
+ originConfirmed = record !== null && record.origin === binding.origin;
262
+ } catch (error) {
263
+ if (!(error instanceof Stop)) throw error;
264
+ }
265
+ }
266
+ if (source.kind === "env") {
267
+ const shape = readAgentKey(source, env);
268
+ if (!shape.ok) return end(shape.outcome, shape.reason);
269
+ }
270
+ const target = keyTarget({ binding, options, env, originConfirmed });
271
+ if (!target.ok) return end(target.outcome, target.reason);
272
+ Object.assign(ctx, {
273
+ origin: target.origin, workspaceId: target.workspaceId, intendedWorkspace: target.workspaceId, profile: target.profile,
274
+ });
275
+ const session = await keySession({ target, source, env, signal: limit.signal, access: "experiment_start" });
276
+ if (!session.ok) {
277
+ const settled = settle(session);
278
+ const reason = settled.reason === "agent_key_insufficient_scope" ? "experiment_start_not_granted" : settled.reason;
279
+ return end(settled.outcome, reason);
280
+ }
281
+ ctx.profile = session.session.profile;
282
+ if (ctx.workspaceId === null) {
283
+ ctx.workspaceId = session.session.workspaceId;
284
+ notices.add("workspace_not_pinned");
285
+ }
286
+ known = session.knownCredentials;
287
+ ctx.authenticated = true;
288
+ ctx.scopes = session.session.scopes;
289
+ const request = options.experiment.request;
290
+ // The start has its own deadline: the service samples, prices and
291
+ // launches within the request, and an answer cut short is ambiguous.
292
+ const post = deadline(options.timeoutMs, trap.signal);
293
+ const response = await send(ctx.origin, EXPERIMENT_PATHS.start, {
294
+ method: "POST", json: request, signal: post.signal, maxBytes: READ_MAX_BYTES, bearer: session.session.accessToken,
295
+ });
296
+ const started = answer(response, 202);
297
+ if (!started.ok) {
298
+ if (unconfirmed(response, started)) return end("unconfirmed", "experiment_start_unconfirmed");
299
+ if (started.reason === "access_revoked") started.reason = "agent_key_rejected";
300
+ if (started.outcome === "permission_denied") started.reason = "experiment_start_not_granted";
301
+ if (started.reason === "timeout" && post.timedOut()) return end("connection_failed", "timeout");
302
+ if (trap.signal.aborted) return end("cancelled", "cancelled");
303
+ return end(started.outcome, started.reason, started.refusal ? { refusal: started.refusal } : null);
304
+ }
305
+ // Accepted: whatever its answer says from here, an experiment exists.
306
+ const value = experimentStarted(started.body, ctx, request, notices);
307
+ if (!value.ok) return end("unconfirmed", "experiment_start_unconfirmed");
308
+ if (value.value.experiment.status === "failed") return end("unhealthy", "experiment_launch_failed", value.value);
309
+ return end("ok", null, value.value);
310
+ } catch (error) {
311
+ if (error instanceof Stop) {
312
+ const settled = settle(error);
313
+ return end(settled.outcome, settled.reason);
314
+ }
315
+ throw error;
316
+ } finally {
317
+ trap.release();
318
+ }
319
+ }
320
+
321
+ // A start whose request was handed to the network and then failed, or that
322
+ // the service answered with a server error, may have been recorded.
323
+ function unconfirmed(response, failure) {
324
+ if (response.kind === "error") return response.sent === true;
325
+ if (response.status >= 200 && response.status < 300) return true;
326
+ if (failure.outcome === "unhealthy" && failure.reason === "server_error") return true;
327
+ if (response.status === 503) {
328
+ const body = parseJsonObject(response);
329
+ // A managed-workflow refusal is decided before anything is recorded.
330
+ return !(isObject(body?.error) && body.error.code === "managed_features_disabled");
331
+ }
332
+ return failure.outcome === "unsupported" && failure.reason === "response_too_large";
333
+ }
334
+
335
+ function startData(ctx, notices, outcome, report) {
336
+ return {
337
+ origin: ctx.origin,
338
+ workspace: ctx.workspaceId === null ? null : { id: ctx.workspaceId },
339
+ deployment_profile: ctx.profile,
340
+ environment: ctx.environment.kind,
341
+ credential: "agent_key",
342
+ authenticated: ctx.authenticated,
343
+ scopes: ctx.scopes,
344
+ result: outcome === "ok" || ctx.reason === "experiment_launch_failed" ? report : null,
345
+ refusal: outcome === "refused" ? report?.refusal ?? null : null,
346
+ notices: [...notices].sort(),
347
+ next_action: startNextAction(outcome, ctx, report),
348
+ };
349
+ }
350
+
351
+ function startNextAction(outcome, ctx, report) {
352
+ const keys = handoffPages(ctx.origin ?? ctx.keysOrigin).keys_url;
353
+ if (outcome === "ok") return pollOrOpen({ ...report.experiment, app_url: report.app_url }, { started: true });
354
+ if (outcome === "unconfirmed") {
355
+ return {
356
+ kind: "check_before_retry",
357
+ message:
358
+ "The start request reached the service, but its answer was not confirmed: an experiment may exist and " +
359
+ "may be running. List the newest experiments with a Metadata key or this project's sign in before " +
360
+ "starting again; each start creates a new experiment.",
361
+ command: "metergraph experiments list --json",
362
+ };
363
+ }
364
+ if (ctx.reason === "experiment_launch_failed") {
365
+ return {
366
+ kind: "start_again_later",
367
+ message:
368
+ "The service recorded the experiment but could not launch its runner, so it failed and nothing ran. " +
369
+ "Start it again later.",
370
+ url: report.app_url,
371
+ };
372
+ }
373
+ if (outcome === "refused") return refusedAction(ctx.reason);
374
+ if (["experiment_start_key_required", "experiment_start_not_granted", "experiment_start_unavailable"].includes(ctx.reason)) {
375
+ return {
376
+ kind: "use_experiment_start_key",
377
+ url: keys,
378
+ message:
379
+ "Starting an experiment needs a coding-agent key with Debug access and Allow analysis starts, which only " +
380
+ "a workspace owner can grant on the Keys page. Pass it with --agent-token-file or METERGRAPH_AGENT_TOKEN; " +
381
+ "do not ask for it in chat. A start replays the sampled calls' captured prompts on each treatment's " +
382
+ "provider within the server's budget. Or start the experiment in the dashboard.",
383
+ required_scope: EXPERIMENT_START_SCOPE,
384
+ };
385
+ }
386
+ if (ctx.reason === "agent_key_rejected") {
387
+ return { kind: "replace_agent_key", url: keys, message: "The service refused this key: it is unknown, expired or revoked." };
388
+ }
389
+ if (ctx.reason === "workspace_context_mismatch") {
390
+ return { kind: "use_workspace_key", url: keys, message: "This key belongs to a different workspace than this project or --workspace names." };
391
+ }
392
+ if (outcome === "rate_limited") return { kind: "retry_later", message: "Wait, then run the same command again." };
393
+ if (outcome === "capability_unavailable") {
394
+ return { kind: "use_dashboard", message: "This deployment does not offer experiments to this key. Nothing was started.", url: null };
395
+ }
396
+ return null;
397
+ }
package/src/origin.js CHANGED
@@ -9,6 +9,7 @@ const HTTPS_ORIGIN = /^https:\/\/[^/?#@\\]+\/?$/i;
9
9
  // such as 127.1 or 0x7f000001 are rejected even though they resolve locally.
10
10
  const HTTP_LOOPBACK_ORIGIN = /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]{1,5})?\/?$/i;
11
11
  const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "[::1]"]);
12
+ const SAFE_HOSTNAME = /^(?:[a-z0-9-]+(?:\.[a-z0-9-]+)*\.?|\[[0-9a-f:.]+\])$/;
12
13
 
13
14
  // Returns the normalized origin (for example "https://example.com") or null.
14
15
  // The caller must not echo the raw input when this returns null.
@@ -32,6 +33,11 @@ export function parseOrigin(raw) {
32
33
  if (url.pathname !== "/" || url.search !== "" || url.hash !== "") return null;
33
34
  if (url.hostname === "" || url.port === "0") return null;
34
35
  if (url.protocol === "http:" && !LOOPBACK_HOSTNAMES.has(url.hostname)) return null;
36
+ // A DNS name (letters, digits, hyphens and dots) or a bracketed IPv6
37
+ // literal. The URL parser accepts characters such as ; $ ( ) ` & ' in a
38
+ // host, which no real deployment uses and which would be unsafe wherever
39
+ // the origin is shown inside a command.
40
+ if (!SAFE_HOSTNAME.test(url.hostname)) return null;
35
41
 
36
42
  return url.origin;
37
43
  }