@uptimizr/collector-server 2.1.0 → 2.2.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.
- package/AGENTS.md +179 -6
- package/README.md +69 -0
- package/dist/__tests__/support/registryRequests.d.ts +2 -1
- package/dist/__tests__/support/registryRequests.d.ts.map +1 -1
- package/dist/__tests__/support/registryRequests.js +17 -1
- package/dist/__tests__/support/registryRequests.js.map +1 -1
- package/dist/__tests__/support/subscriptionFixtures.d.ts +63 -0
- package/dist/__tests__/support/subscriptionFixtures.d.ts.map +1 -0
- package/dist/__tests__/support/subscriptionFixtures.js +17 -0
- package/dist/__tests__/support/subscriptionFixtures.js.map +1 -0
- package/dist/agentReport.d.ts +256 -0
- package/dist/agentReport.d.ts.map +1 -0
- package/dist/agentReport.js +861 -0
- package/dist/agentReport.js.map +1 -0
- package/dist/app.d.ts +13 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +112 -5
- package/dist/app.js.map +1 -1
- package/dist/audit.d.ts +5 -1
- package/dist/audit.d.ts.map +1 -1
- package/dist/audit.js +24 -4
- package/dist/audit.js.map +1 -1
- package/dist/auth.d.ts +41 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +47 -0
- package/dist/auth.js.map +1 -1
- package/dist/cli.js +99 -2
- package/dist/cli.js.map +1 -1
- package/dist/clickhouseStore.d.ts.map +1 -1
- package/dist/clickhouseStore.js +44 -2
- package/dist/clickhouseStore.js.map +1 -1
- package/dist/config.d.ts +49 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +21 -0
- package/dist/config.js.map +1 -1
- package/dist/connectionLimiter.d.ts +23 -0
- package/dist/connectionLimiter.d.ts.map +1 -0
- package/dist/connectionLimiter.js +24 -0
- package/dist/connectionLimiter.js.map +1 -0
- package/dist/duckdbStore.d.ts.map +1 -1
- package/dist/duckdbStore.js +44 -1
- package/dist/duckdbStore.js.map +1 -1
- package/dist/internalDispatch.d.ts +36 -0
- package/dist/internalDispatch.d.ts.map +1 -0
- package/dist/internalDispatch.js +50 -0
- package/dist/internalDispatch.js.map +1 -0
- package/dist/memoryStore.d.ts.map +1 -1
- package/dist/memoryStore.js +314 -1
- package/dist/memoryStore.js.map +1 -1
- package/dist/mssqlStore.d.ts.map +1 -1
- package/dist/mssqlStore.js +44 -2
- package/dist/mssqlStore.js.map +1 -1
- package/dist/postgresStore.d.ts.map +1 -1
- package/dist/postgresStore.js +44 -2
- package/dist/postgresStore.js.map +1 -1
- package/dist/projectMetadata.d.ts +82 -0
- package/dist/projectMetadata.d.ts.map +1 -0
- package/dist/projectMetadata.js +50 -0
- package/dist/projectMetadata.js.map +1 -0
- package/dist/routes/context.d.ts +135 -0
- package/dist/routes/context.d.ts.map +1 -0
- package/dist/routes/context.js +306 -0
- package/dist/routes/context.js.map +1 -0
- package/dist/routes/insights.d.ts +36 -0
- package/dist/routes/insights.d.ts.map +1 -0
- package/dist/routes/insights.js +810 -0
- package/dist/routes/insights.js.map +1 -0
- package/dist/routes/live.d.ts +8 -0
- package/dist/routes/live.d.ts.map +1 -1
- package/dist/routes/live.js +8 -7
- package/dist/routes/live.js.map +1 -1
- package/dist/routes/mcp.d.ts +36 -0
- package/dist/routes/mcp.d.ts.map +1 -0
- package/dist/routes/mcp.js +262 -0
- package/dist/routes/mcp.js.map +1 -0
- package/dist/routes/meta.d.ts +16 -2
- package/dist/routes/meta.d.ts.map +1 -1
- package/dist/routes/meta.js +699 -10
- package/dist/routes/meta.js.map +1 -1
- package/dist/routes/metadata.d.ts +31 -0
- package/dist/routes/metadata.d.ts.map +1 -0
- package/dist/routes/metadata.js +163 -0
- package/dist/routes/metadata.js.map +1 -0
- package/dist/routes/narrative.d.ts +36 -0
- package/dist/routes/narrative.d.ts.map +1 -0
- package/dist/routes/narrative.js +95 -0
- package/dist/routes/narrative.js.map +1 -0
- package/dist/routes/panels.d.ts +44 -0
- package/dist/routes/panels.d.ts.map +1 -0
- package/dist/routes/panels.js +108 -0
- package/dist/routes/panels.js.map +1 -0
- package/dist/routes/query-dsl.d.ts +50 -0
- package/dist/routes/query-dsl.d.ts.map +1 -0
- package/dist/routes/query-dsl.js +359 -0
- package/dist/routes/query-dsl.js.map +1 -0
- package/dist/routes/query.d.ts +36 -0
- package/dist/routes/query.d.ts.map +1 -1
- package/dist/routes/query.js +101 -13
- package/dist/routes/query.js.map +1 -1
- package/dist/routes/subscriptions.d.ts +52 -0
- package/dist/routes/subscriptions.d.ts.map +1 -0
- package/dist/routes/subscriptions.js +290 -0
- package/dist/routes/subscriptions.js.map +1 -0
- package/dist/store.d.ts +113 -2
- package/dist/store.d.ts.map +1 -1
- package/dist/subscriptions/cli.d.ts +36 -0
- package/dist/subscriptions/cli.d.ts.map +1 -0
- package/dist/subscriptions/cli.js +143 -0
- package/dist/subscriptions/cli.js.map +1 -0
- package/dist/subscriptions/delivery.d.ts +101 -0
- package/dist/subscriptions/delivery.d.ts.map +1 -0
- package/dist/subscriptions/delivery.js +165 -0
- package/dist/subscriptions/delivery.js.map +1 -0
- package/dist/subscriptions/evaluate.d.ts +119 -0
- package/dist/subscriptions/evaluate.d.ts.map +1 -0
- package/dist/subscriptions/evaluate.js +326 -0
- package/dist/subscriptions/evaluate.js.map +1 -0
- package/dist/subscriptions/scheduler.d.ts +97 -0
- package/dist/subscriptions/scheduler.d.ts.map +1 -0
- package/dist/subscriptions/scheduler.js +332 -0
- package/dist/subscriptions/scheduler.js.map +1 -0
- package/dist/subscriptions/stream.d.ts +40 -0
- package/dist/subscriptions/stream.d.ts.map +1 -0
- package/dist/subscriptions/stream.js +38 -0
- package/dist/subscriptions/stream.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +25 -0
- package/dist/version.js.map +1 -0
- package/dist/webhookSignature.d.ts +64 -0
- package/dist/webhookSignature.d.ts.map +1 -0
- package/dist/webhookSignature.js +91 -0
- package/dist/webhookSignature.js.map +1 -0
- package/llms.txt +38 -1
- package/package.json +17 -14
|
@@ -0,0 +1,861 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `uptimizr agent report` — headless, scheduled analytics reports (ADR 0051 §6,
|
|
3
|
+
* design sketch §F.4).
|
|
4
|
+
*
|
|
5
|
+
* A weekly scene-health digest should not need a human to open a chat. This
|
|
6
|
+
* subcommand runs the **same** headless loop the browser assistant and the MCP
|
|
7
|
+
* server run — `runAgent` from `@uptimizr/agent-core` over the generated
|
|
8
|
+
* read-only tool catalog — once, from a shell, and writes Markdown to a file,
|
|
9
|
+
* to stdout, or to a signed webhook.
|
|
10
|
+
*
|
|
11
|
+
* Three boundaries are deliberate and load-bearing:
|
|
12
|
+
*
|
|
13
|
+
* 1. **The collector gains no in-process LLM loop.** This is a separate process
|
|
14
|
+
* that talks to the collector over its ordinary HTTP query API with an
|
|
15
|
+
* ordinary project API key, exactly as any other agent client would. Nothing
|
|
16
|
+
* here is importable by the server (keep backends thin, ADR 0005).
|
|
17
|
+
* 2. **Scheduling is the operator's.** cron, a systemd timer, a GitHub Action —
|
|
18
|
+
* the CLI runs once and exits with a meaningful code. Uptimizr operates
|
|
19
|
+
* nothing (ADR 0017).
|
|
20
|
+
* 3. **Provider configuration comes from the environment only and is never
|
|
21
|
+
* persisted.** The provider key is read once into the adapter; it is never
|
|
22
|
+
* logged, echoed, written to a report or included in an error message. The
|
|
23
|
+
* collector key is likewise never printed.
|
|
24
|
+
*
|
|
25
|
+
* The report is **read-only**: the catalog it exposes performs `GET`s against
|
|
26
|
+
* the query API and nothing else, so a `query`-capability key is all it needs
|
|
27
|
+
* (and all it should be given — #309 / ADR 0051 §7).
|
|
28
|
+
*/
|
|
29
|
+
import { writeFileSync } from "node:fs";
|
|
30
|
+
import { resolve as resolvePath } from "node:path";
|
|
31
|
+
import { ANALYTICS_AGENT_GUIDELINES, AGENT_SKILLS, DEFAULT_MAX_STEPS, createCollectorClient, getAgentSkill, readTools, renderContextForPrompt, renderCurrentTimeLine, runAgent, } from "@uptimizr/agent-core";
|
|
32
|
+
import { createHostedProvider } from "@uptimizr/agent-core/providers/hosted";
|
|
33
|
+
import { WEBHOOK_DELIVERY_HEADER, WEBHOOK_SIGNATURE_HEADER, newDeliveryId, parseWebhookUrl, signWebhookBody, } from "./webhookSignature.js";
|
|
34
|
+
/**
|
|
35
|
+
* A problem the operator can fix, reported as one clear line rather than a
|
|
36
|
+
* stack trace. Everything user-facing this module rejects — a missing variable,
|
|
37
|
+
* an unknown skill, an unreachable provider — is one of these.
|
|
38
|
+
*/
|
|
39
|
+
export class AgentReportError extends Error {
|
|
40
|
+
exitCode;
|
|
41
|
+
constructor(message,
|
|
42
|
+
/** Process exit code to use (see {@link EXIT}). */
|
|
43
|
+
exitCode = EXIT.usage) {
|
|
44
|
+
super(message);
|
|
45
|
+
this.exitCode = exitCode;
|
|
46
|
+
this.name = "AgentReportError";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Exit codes, documented because a scheduled job branches on them.
|
|
51
|
+
*
|
|
52
|
+
* `incomplete` is deliberately non-zero: a digest whose tool calls partly failed
|
|
53
|
+
* is still written (and says so), but a cron wrapper must be able to notice.
|
|
54
|
+
*/
|
|
55
|
+
export const EXIT = {
|
|
56
|
+
/** The report was produced and every tool call succeeded. */
|
|
57
|
+
ok: 0,
|
|
58
|
+
/** Usage or configuration error — nothing ran. */
|
|
59
|
+
usage: 1,
|
|
60
|
+
/** The provider call, or the webhook delivery, failed. */
|
|
61
|
+
provider: 2,
|
|
62
|
+
/** A report was produced but is incomplete: a tool call failed, or no answer. */
|
|
63
|
+
incomplete: 3,
|
|
64
|
+
};
|
|
65
|
+
/** Default model per wire format when `UPTIMIZR_AGENT_MODEL` is unset. */
|
|
66
|
+
const DEFAULT_MODELS = {
|
|
67
|
+
anthropic: "claude-sonnet-5",
|
|
68
|
+
openai: "gpt-4o-mini",
|
|
69
|
+
};
|
|
70
|
+
/** Default endpoint per wire format when `UPTIMIZR_AGENT_ENDPOINT` is unset. */
|
|
71
|
+
const DEFAULT_ENDPOINTS = {
|
|
72
|
+
anthropic: "https://api.anthropic.com/v1",
|
|
73
|
+
openai: "https://api.openai.com/v1",
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* Output-token ceiling for one provider turn. A report is a few paragraphs plus
|
|
77
|
+
* figures; the loop's step cap bounds the number of turns.
|
|
78
|
+
*/
|
|
79
|
+
const PROVIDER_MAX_TOKENS = 2048;
|
|
80
|
+
/** Default analysis window when neither `--window` nor `--since/--until` is given. */
|
|
81
|
+
const DEFAULT_WINDOW = "7d";
|
|
82
|
+
/** Version tag on the JSON report, so a consumer can branch on the shape. */
|
|
83
|
+
export const REPORT_SCHEMA = "uptimizr.agent-report/1";
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Argument parsing
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
/** Flags that take a value; everything else is a boolean switch. */
|
|
88
|
+
const VALUE_FLAGS = new Set([
|
|
89
|
+
"skill",
|
|
90
|
+
"scene",
|
|
91
|
+
"since",
|
|
92
|
+
"until",
|
|
93
|
+
"window",
|
|
94
|
+
"out",
|
|
95
|
+
"json",
|
|
96
|
+
"webhook",
|
|
97
|
+
"max-steps",
|
|
98
|
+
]);
|
|
99
|
+
/** Boolean switches. */
|
|
100
|
+
const BOOL_FLAGS = new Set(["dry-run", "list-skills", "help"]);
|
|
101
|
+
/**
|
|
102
|
+
* Parse `--flag value` / `--flag=value` / `--switch`. Deliberately tiny and
|
|
103
|
+
* dependency-free, matching the rest of the CLI, and strict: an unknown flag is
|
|
104
|
+
* an error rather than being silently ignored, because a typo'd `--scene` in a
|
|
105
|
+
* cron line would otherwise produce a confidently project-wide report.
|
|
106
|
+
*/
|
|
107
|
+
export function parseAgentReportArgs(argv) {
|
|
108
|
+
const flags = {};
|
|
109
|
+
const switches = new Set();
|
|
110
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
111
|
+
const arg = argv[i];
|
|
112
|
+
if (arg === "--")
|
|
113
|
+
continue;
|
|
114
|
+
if (!arg.startsWith("--")) {
|
|
115
|
+
throw new AgentReportError(`Unexpected argument ${JSON.stringify(arg)}. ` +
|
|
116
|
+
"`uptimizr agent report` takes flags only — see `uptimizr agent report --help`.");
|
|
117
|
+
}
|
|
118
|
+
const eq = arg.indexOf("=");
|
|
119
|
+
const name = eq > 2 ? arg.slice(2, eq) : arg.slice(2);
|
|
120
|
+
if (BOOL_FLAGS.has(name)) {
|
|
121
|
+
if (eq > 2)
|
|
122
|
+
throw new AgentReportError(`--${name} takes no value.`);
|
|
123
|
+
switches.add(name);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (!VALUE_FLAGS.has(name)) {
|
|
127
|
+
throw new AgentReportError(`Unknown flag --${name}. See \`uptimizr agent report --help\` for the full list.`);
|
|
128
|
+
}
|
|
129
|
+
if (eq > 2) {
|
|
130
|
+
flags[name] = arg.slice(eq + 1);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const next = argv[i + 1];
|
|
134
|
+
if (next === undefined || next.startsWith("--")) {
|
|
135
|
+
throw new AgentReportError(`Missing value for --${name}.`);
|
|
136
|
+
}
|
|
137
|
+
flags[name] = next;
|
|
138
|
+
i += 1;
|
|
139
|
+
}
|
|
140
|
+
return { flags, switches };
|
|
141
|
+
}
|
|
142
|
+
/** Milliseconds per `--window` unit. */
|
|
143
|
+
const WINDOW_UNITS = {
|
|
144
|
+
h: 3_600_000,
|
|
145
|
+
d: 86_400_000,
|
|
146
|
+
w: 604_800_000,
|
|
147
|
+
};
|
|
148
|
+
/**
|
|
149
|
+
* Resolve the analysis window: explicit `--since`/`--until` epoch milliseconds
|
|
150
|
+
* win, otherwise `--window <N><h|d|w>` counts back from now.
|
|
151
|
+
*
|
|
152
|
+
* Parsed by hand (digits then a unit letter) rather than with a regular
|
|
153
|
+
* expression — the value is operator input and plain scanning has no ReDoS
|
|
154
|
+
* surface at all.
|
|
155
|
+
*/
|
|
156
|
+
export function resolveWindow(flags, nowMs, defaultWindow = DEFAULT_WINDOW) {
|
|
157
|
+
const sinceFlag = flags.since;
|
|
158
|
+
const untilFlag = flags.until;
|
|
159
|
+
if (sinceFlag !== undefined || untilFlag !== undefined) {
|
|
160
|
+
if (flags.window !== undefined) {
|
|
161
|
+
throw new AgentReportError("Pass either --window or --since/--until, not both.");
|
|
162
|
+
}
|
|
163
|
+
const since = parseEpoch(sinceFlag, "--since");
|
|
164
|
+
const until = parseEpoch(untilFlag, "--until") ?? nowMs;
|
|
165
|
+
if (since === undefined)
|
|
166
|
+
throw new AgentReportError("--until also needs --since.");
|
|
167
|
+
if (since >= until)
|
|
168
|
+
throw new AgentReportError("--since must be before --until.");
|
|
169
|
+
return { since, until, label: "explicit" };
|
|
170
|
+
}
|
|
171
|
+
const raw = flags.window ?? defaultWindow;
|
|
172
|
+
const unit = raw.slice(-1);
|
|
173
|
+
const count = Number(raw.slice(0, -1));
|
|
174
|
+
const perUnit = WINDOW_UNITS[unit];
|
|
175
|
+
if (perUnit === undefined || !Number.isInteger(count) || count <= 0) {
|
|
176
|
+
throw new AgentReportError(`--window must be a positive whole number of hours, days or weeks ` +
|
|
177
|
+
`(e.g. 24h, 7d, 2w) — got ${JSON.stringify(raw)}.`);
|
|
178
|
+
}
|
|
179
|
+
return { since: nowMs - count * perUnit, until: nowMs, label: raw };
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* The window in the words a skill's text expects (`{{range}}`): "the last 7d",
|
|
183
|
+
* or the two dates when the window was given explicitly.
|
|
184
|
+
*
|
|
185
|
+
* The system prompt already states the window in epoch milliseconds, but the
|
|
186
|
+
* user turn a skill renders opens with a range of its own, and a report run with
|
|
187
|
+
* `--window 24h` must not ask for "the last 7 days" (#316).
|
|
188
|
+
*/
|
|
189
|
+
export function describeWindow(window) {
|
|
190
|
+
return window.label === "explicit"
|
|
191
|
+
? `${iso(window.since)} → ${iso(window.until)}`
|
|
192
|
+
: `the last ${window.label}`;
|
|
193
|
+
}
|
|
194
|
+
function parseEpoch(value, flag) {
|
|
195
|
+
if (value === undefined)
|
|
196
|
+
return undefined;
|
|
197
|
+
const parsed = Number(value);
|
|
198
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
199
|
+
throw new AgentReportError(`${flag} must be an epoch-millisecond integer (got "${value}").`);
|
|
200
|
+
}
|
|
201
|
+
return parsed;
|
|
202
|
+
}
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Prompt
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
/**
|
|
207
|
+
* The role statement for a headless report run.
|
|
208
|
+
*
|
|
209
|
+
* The behavioural half — never invent numbers, the data is aggregate and
|
|
210
|
+
* privacy-preserving, timestamps are epoch ms, explain tool errors — is the very
|
|
211
|
+
* block the browser assistant uses ({@link ANALYTICS_AGENT_GUIDELINES}), so what
|
|
212
|
+
* the two clients promise about the data cannot drift. Only the role and the
|
|
213
|
+
* output format differ, and both legitimately do: nobody is watching this run,
|
|
214
|
+
* so it must produce a finished document rather than a chat reply.
|
|
215
|
+
*/
|
|
216
|
+
const REPORT_ROLE = [
|
|
217
|
+
"You are Uptimizr's reporting analyst. You are running without a human present:",
|
|
218
|
+
"gather what you need with the read-only tools against this project's own collector,",
|
|
219
|
+
"then write the finished report in one pass. Nobody will ask you a follow-up.",
|
|
220
|
+
].join("\n");
|
|
221
|
+
/**
|
|
222
|
+
* Every skill opens by telling the agent to read the `uptimizr://context`
|
|
223
|
+
* resource first — the right instruction in an MCP client, where it *is* a
|
|
224
|
+
* resource. Headlessly there is no resource to read: the CLI has already fetched
|
|
225
|
+
* the document and rendered it into this prompt. Saying so prevents the model
|
|
226
|
+
* from opening the report with an apology about a tool it does not have.
|
|
227
|
+
*/
|
|
228
|
+
const CONTEXT_ALREADY_READ = "The block above IS this project's context document (`GET /api/v1/context`, the same " +
|
|
229
|
+
"one the `uptimizr://context` resource serves). It has already been read for you — the " +
|
|
230
|
+
"task below will tell you to read it first; treat that as done and use the names above.";
|
|
231
|
+
/** How the answer must be shaped, since it is written straight into a document. */
|
|
232
|
+
const REPORT_OUTPUT_RULES = [
|
|
233
|
+
"Write the report as GitHub-flavoured Markdown:",
|
|
234
|
+
"- Start with a one-paragraph summary a busy reader can act on.",
|
|
235
|
+
"- Then the findings, with the actual figures the tools returned.",
|
|
236
|
+
"- Do not add a top-level `#` heading and do not restate the method — the tool",
|
|
237
|
+
" calls you made are recorded separately and appended to your report.",
|
|
238
|
+
"- If the data is thin or a capture channel is off, say so plainly instead of",
|
|
239
|
+
" reporting a zero as a finding.",
|
|
240
|
+
].join("\n");
|
|
241
|
+
/**
|
|
242
|
+
* Compose the system prompt: role, shared guidelines, the clock, the window the
|
|
243
|
+
* operator asked for, the project context document, and the output rules.
|
|
244
|
+
*
|
|
245
|
+
* `projectContext` is the compact rendering of `GET /api/v1/context` (ADR 0051
|
|
246
|
+
* §5) — the project's real scene ids, region ids and custom-event names. It is
|
|
247
|
+
* injected here exactly as `useAssistant` and the eval harness inject it, and is
|
|
248
|
+
* simply absent on a collector too old to serve the endpoint.
|
|
249
|
+
*/
|
|
250
|
+
export function buildReportSystemPrompt(options) {
|
|
251
|
+
const parts = [
|
|
252
|
+
REPORT_ROLE,
|
|
253
|
+
"",
|
|
254
|
+
ANALYTICS_AGENT_GUIDELINES,
|
|
255
|
+
"",
|
|
256
|
+
renderCurrentTimeLine(options.nowMs),
|
|
257
|
+
"",
|
|
258
|
+
`Report window: since=${options.window.since} until=${options.window.until} ` +
|
|
259
|
+
"(epoch milliseconds). Use exactly this range for every tool call unless the " +
|
|
260
|
+
"skill explicitly asks for another.",
|
|
261
|
+
];
|
|
262
|
+
if (options.scene) {
|
|
263
|
+
parts.push(`Scope: scene "${options.scene}". Pass scene="${options.scene}" to every tool whose ` +
|
|
264
|
+
"schema accepts it, and say so in the report.");
|
|
265
|
+
}
|
|
266
|
+
const context = (options.projectContext ?? "").trim();
|
|
267
|
+
if (context.length > 0) {
|
|
268
|
+
parts.push("", context, "", CONTEXT_ALREADY_READ);
|
|
269
|
+
}
|
|
270
|
+
parts.push("", REPORT_OUTPUT_RULES);
|
|
271
|
+
return parts.join("\n");
|
|
272
|
+
}
|
|
273
|
+
/**
|
|
274
|
+
* Wrap the collector client so every request is timed and announced.
|
|
275
|
+
*
|
|
276
|
+
* The loop issues exactly one `GET` per *executable* tool call (an unknown tool
|
|
277
|
+
* or invalid arguments are rejected before any request), and executes them
|
|
278
|
+
* sequentially, so the recorded reads line up with the transcript's tool results
|
|
279
|
+
* in order — which is how {@link collectToolCalls} attaches a duration to each
|
|
280
|
+
* call without agent-core having to report one.
|
|
281
|
+
*/
|
|
282
|
+
function instrumentClient(client, reads, stderr) {
|
|
283
|
+
return {
|
|
284
|
+
async get(path, params) {
|
|
285
|
+
const began = Date.now();
|
|
286
|
+
try {
|
|
287
|
+
const result = await client.get(path, params);
|
|
288
|
+
const durationMs = Date.now() - began;
|
|
289
|
+
reads.push({ path, durationMs, ok: true });
|
|
290
|
+
stderr(` ✓ ${path} (${durationMs} ms)`);
|
|
291
|
+
return result;
|
|
292
|
+
}
|
|
293
|
+
catch (err) {
|
|
294
|
+
const durationMs = Date.now() - began;
|
|
295
|
+
reads.push({ path, durationMs, ok: false });
|
|
296
|
+
stderr(` ✗ ${path} (${durationMs} ms): ${err.message}`);
|
|
297
|
+
throw err;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Wrap the provider so each turn's requested tool calls are announced to stderr
|
|
304
|
+
* as they happen, and any reported token usage is summed.
|
|
305
|
+
*
|
|
306
|
+
* Announcing from here (rather than from `runAgent`'s streaming channel) is what
|
|
307
|
+
* makes the progress output about *tool calls* — the thing an operator watching
|
|
308
|
+
* a cron job wants — without asking the provider for a streamed response nobody
|
|
309
|
+
* is rendering.
|
|
310
|
+
*/
|
|
311
|
+
function instrumentProvider(provider, tally, stderr) {
|
|
312
|
+
return {
|
|
313
|
+
async complete(request) {
|
|
314
|
+
tally.turns += 1;
|
|
315
|
+
const step = tally.turns;
|
|
316
|
+
const response = await provider.complete(request);
|
|
317
|
+
if (response.usage) {
|
|
318
|
+
tally.usage = {
|
|
319
|
+
inputTokens: (tally.usage?.inputTokens ?? 0) + (response.usage.inputTokens ?? 0),
|
|
320
|
+
outputTokens: (tally.usage?.outputTokens ?? 0) + (response.usage.outputTokens ?? 0),
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
if (response.kind === "tool_calls") {
|
|
324
|
+
for (const call of response.toolCalls) {
|
|
325
|
+
stderr(` → step ${step}: ${call.name} ${compactArgs(call.arguments)}`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
stderr(` → step ${step}: final answer (${response.content.length} chars)`);
|
|
330
|
+
}
|
|
331
|
+
return response;
|
|
332
|
+
},
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
/** One-line rendering of tool arguments, capped so a cron log stays readable. */
|
|
336
|
+
function compactArgs(args) {
|
|
337
|
+
const json = JSON.stringify(args ?? {});
|
|
338
|
+
return json.length <= 160 ? json : `${json.slice(0, 160)}…`;
|
|
339
|
+
}
|
|
340
|
+
/** Prefixes the loop uses for failures that never reach the collector. */
|
|
341
|
+
const NO_REQUEST_PREFIXES = ["Error: unknown tool", "Error: invalid arguments for"];
|
|
342
|
+
/**
|
|
343
|
+
* Pair the transcript's tool calls with the timed reads, in order.
|
|
344
|
+
*
|
|
345
|
+
* A tool result that starts with one of {@link NO_REQUEST_PREFIXES} was rejected
|
|
346
|
+
* by the loop before it built a request, so it consumes no recorded read; every
|
|
347
|
+
* other result — including a collector error, which *did* make a request —
|
|
348
|
+
* consumes the next one.
|
|
349
|
+
*/
|
|
350
|
+
export function collectToolCalls(messages, reads) {
|
|
351
|
+
const requested = new Map();
|
|
352
|
+
const ordered = [];
|
|
353
|
+
let readIndex = 0;
|
|
354
|
+
for (const message of messages) {
|
|
355
|
+
if (message.role === "assistant") {
|
|
356
|
+
for (const call of message.toolCalls ?? []) {
|
|
357
|
+
requested.set(call.id, { name: call.name, arguments: call.arguments ?? {} });
|
|
358
|
+
}
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
if (message.role !== "tool")
|
|
362
|
+
continue;
|
|
363
|
+
const asked = requested.get(message.toolCallId);
|
|
364
|
+
const failed = message.content.startsWith("Error: ");
|
|
365
|
+
const madeRequest = !NO_REQUEST_PREFIXES.some((prefix) => message.content.startsWith(prefix));
|
|
366
|
+
const read = madeRequest ? reads[readIndex++] : undefined;
|
|
367
|
+
ordered.push({
|
|
368
|
+
name: asked?.name ?? message.name,
|
|
369
|
+
arguments: asked?.arguments ?? {},
|
|
370
|
+
durationMs: read?.durationMs ?? null,
|
|
371
|
+
ok: !failed,
|
|
372
|
+
error: failed ? message.content : null,
|
|
373
|
+
resultChars: message.content.length,
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
return ordered;
|
|
377
|
+
}
|
|
378
|
+
// ---------------------------------------------------------------------------
|
|
379
|
+
// Rendering
|
|
380
|
+
// ---------------------------------------------------------------------------
|
|
381
|
+
/** Escape the pipe characters that would break a Markdown table cell. */
|
|
382
|
+
function cell(text) {
|
|
383
|
+
return text.split("|").join("\\|");
|
|
384
|
+
}
|
|
385
|
+
/** `2026-09-19T08:00:00.000Z` → a compact, unambiguous stamp for a heading. */
|
|
386
|
+
function iso(ms) {
|
|
387
|
+
return new Date(ms).toISOString();
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Render the Markdown report: the model's findings, then a **Method** section
|
|
391
|
+
* that lists every tool call with its arguments and outcome.
|
|
392
|
+
*
|
|
393
|
+
* The Method section is not decoration — it is what makes an unattended,
|
|
394
|
+
* model-written document auditable: a reader can see exactly which aggregates
|
|
395
|
+
* the figures came from, re-run them, and spot a call that silently failed.
|
|
396
|
+
*/
|
|
397
|
+
export function renderReportMarkdown(report) {
|
|
398
|
+
const scene = report.scene ? `scene \`${report.scene}\`` : "all scenes";
|
|
399
|
+
const lines = [
|
|
400
|
+
`# ${report.title}`,
|
|
401
|
+
"",
|
|
402
|
+
`_${scene} · ${iso(report.window.since)} → ${iso(report.window.until)} · ` +
|
|
403
|
+
`generated ${report.finishedAt} by \`uptimizr agent report --skill ${report.skill}\`_`,
|
|
404
|
+
"",
|
|
405
|
+
];
|
|
406
|
+
lines.push(report.answer.trim() === "" ? "_The model returned no answer._" : report.answer.trim());
|
|
407
|
+
lines.push("", "## Method", "", report.toolCalls.length === 0
|
|
408
|
+
? "The agent made no tool calls — treat the findings above with suspicion."
|
|
409
|
+
: `The agent made ${report.toolCalls.length} read-only tool call(s) against the collector:`);
|
|
410
|
+
if (report.toolCalls.length > 0) {
|
|
411
|
+
lines.push("", "| # | Tool | Arguments | Duration | Result |", "| --- | --- | --- | --- | --- |");
|
|
412
|
+
report.toolCalls.forEach((call, index) => {
|
|
413
|
+
const duration = call.durationMs == null ? "—" : `${call.durationMs} ms`;
|
|
414
|
+
const outcome = call.ok ? `ok (${call.resultChars} chars)` : `**failed** — ${call.error}`;
|
|
415
|
+
lines.push(`| ${index + 1} | \`${cell(call.name)}\` | \`${cell(compactArgs(call.arguments))}\` | ` +
|
|
416
|
+
`${duration} | ${cell(outcome)} |`);
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
const usage = report.usage;
|
|
420
|
+
lines.push("", "## Run", "", `- Skill: \`${report.skill}\``, `- Collector: ${report.collectorUrl}`, `- Provider: ${report.provider.kind} (${report.provider.model})`, `- Provider turns: ${report.steps} of ${report.maxSteps}` +
|
|
421
|
+
(report.stoppedOnMaxSteps ? " — **stopped at the step cap**" : ""), `- Project context: ${report.context.available
|
|
422
|
+
? `${report.context.chars} characters from \`GET /api/v1/context\``
|
|
423
|
+
: "unavailable (older collector, or the read failed)"}`, `- Duration: ${(report.durationMs / 1000).toFixed(1)} s`, `- Tokens: ${usage
|
|
424
|
+
? `${usage.inputTokens ?? "?"} in / ${usage.outputTokens ?? "?"} out (as reported by the provider)`
|
|
425
|
+
: "not reported by this provider"}`, "");
|
|
426
|
+
return lines.join("\n");
|
|
427
|
+
}
|
|
428
|
+
// ---------------------------------------------------------------------------
|
|
429
|
+
// Providers
|
|
430
|
+
// ---------------------------------------------------------------------------
|
|
431
|
+
/**
|
|
432
|
+
* The deterministic, key-free provider (`UPTIMIZR_AGENT_PROVIDER=scripted`).
|
|
433
|
+
*
|
|
434
|
+
* It is a real {@link LlmProvider} driven through the real loop against the real
|
|
435
|
+
* collector — only the "model" is replaced by a rule: on its first turn it calls
|
|
436
|
+
* exactly the tools the chosen skill names, scoped to the run's window and
|
|
437
|
+
* scene; on its second it renders what the collector returned.
|
|
438
|
+
*
|
|
439
|
+
* It performs **no analysis** and is documented as such. What it is good for is
|
|
440
|
+
* everything around the analysis: proving the collector URL, the API key, the
|
|
441
|
+
* skill name, the output paths and the webhook signature all work, in CI or on a
|
|
442
|
+
* new host, with no provider account and no egress.
|
|
443
|
+
*/
|
|
444
|
+
export function createScriptedReportProvider(context) {
|
|
445
|
+
const byName = new Map(context.tools.map((tool) => [tool.name, tool]));
|
|
446
|
+
return {
|
|
447
|
+
async complete(request) {
|
|
448
|
+
const results = request.messages.filter((message) => message.role === "tool");
|
|
449
|
+
if (results.length === 0 && request.tools.length > 0) {
|
|
450
|
+
const toolCalls = context.skill.tools
|
|
451
|
+
.filter((name) => byName.has(name))
|
|
452
|
+
.map((name, index) => ({
|
|
453
|
+
id: `scripted-${index}`,
|
|
454
|
+
name,
|
|
455
|
+
arguments: scriptedArgsFor(byName.get(name), context),
|
|
456
|
+
}));
|
|
457
|
+
if (toolCalls.length > 0)
|
|
458
|
+
return { kind: "tool_calls", toolCalls };
|
|
459
|
+
}
|
|
460
|
+
const body = results
|
|
461
|
+
.map((message) => `### \`${message.name}\`\n\n\`\`\`json\n${message.content}\n\`\`\``)
|
|
462
|
+
.join("\n\n");
|
|
463
|
+
return {
|
|
464
|
+
kind: "final",
|
|
465
|
+
content: `This report was produced by the **scripted** provider: it called the ` +
|
|
466
|
+
`\`${context.skill.name}\` skill's tools and printed what the collector returned. ` +
|
|
467
|
+
"No model was involved, so nothing below is analysis — it is raw aggregate data.\n\n" +
|
|
468
|
+
body,
|
|
469
|
+
};
|
|
470
|
+
},
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
/** Window/scene arguments for a bare scripted call, where the schema takes them. */
|
|
474
|
+
function scriptedArgsFor(tool, context) {
|
|
475
|
+
const args = {};
|
|
476
|
+
if ("since" in tool.inputSchema)
|
|
477
|
+
args.since = context.window.since;
|
|
478
|
+
if ("until" in tool.inputSchema)
|
|
479
|
+
args.until = context.window.until;
|
|
480
|
+
if (context.scene && "scene" in tool.inputSchema)
|
|
481
|
+
args.scene = context.scene;
|
|
482
|
+
return args;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Resolve the provider from the environment — and only from the environment
|
|
486
|
+
* (ADR 0051 §6): nothing about a provider is ever written to disk by this CLI.
|
|
487
|
+
*
|
|
488
|
+
* The key is read from `UPTIMIZR_AGENT_API_KEY`, falling back to the provider's
|
|
489
|
+
* own conventional variable so an operator who already exports `ANTHROPIC_API_KEY`
|
|
490
|
+
* needs no second export. It is returned for the adapter's constructor and is
|
|
491
|
+
* never placed in any other value this module produces.
|
|
492
|
+
*/
|
|
493
|
+
export function resolveProvider(env) {
|
|
494
|
+
const requested = (env.UPTIMIZR_AGENT_PROVIDER ?? "anthropic").toLowerCase();
|
|
495
|
+
if (requested === "scripted") {
|
|
496
|
+
return { kind: "scripted", model: "scripted", endpoint: "(none)" };
|
|
497
|
+
}
|
|
498
|
+
if (requested !== "anthropic" && requested !== "openai") {
|
|
499
|
+
throw new AgentReportError(`UPTIMIZR_AGENT_PROVIDER must be "anthropic", "openai" or "scripted" ` +
|
|
500
|
+
`(got ${JSON.stringify(env.UPTIMIZR_AGENT_PROVIDER ?? "")}).`);
|
|
501
|
+
}
|
|
502
|
+
const kind = requested;
|
|
503
|
+
const fallbackKey = kind === "anthropic" ? env.ANTHROPIC_API_KEY : env.OPENAI_API_KEY;
|
|
504
|
+
const apiKey = (env.UPTIMIZR_AGENT_API_KEY ?? fallbackKey ?? "").trim();
|
|
505
|
+
if (apiKey === "") {
|
|
506
|
+
throw new AgentReportError(`No provider key. Set UPTIMIZR_AGENT_API_KEY (or ` +
|
|
507
|
+
`${kind === "anthropic" ? "ANTHROPIC_API_KEY" : "OPENAI_API_KEY"}) for the ${kind} ` +
|
|
508
|
+
"provider, or run with UPTIMIZR_AGENT_PROVIDER=scripted for a model-free run.");
|
|
509
|
+
}
|
|
510
|
+
return {
|
|
511
|
+
kind,
|
|
512
|
+
model: env.UPTIMIZR_AGENT_MODEL?.trim() || DEFAULT_MODELS[kind],
|
|
513
|
+
endpoint: env.UPTIMIZR_AGENT_ENDPOINT?.trim() || DEFAULT_ENDPOINTS[kind],
|
|
514
|
+
apiKey,
|
|
515
|
+
};
|
|
516
|
+
}
|
|
517
|
+
/** Build the backend a resolved configuration describes. */
|
|
518
|
+
function defaultCreateProvider(config, context, fetchImpl) {
|
|
519
|
+
if (config.kind === "scripted")
|
|
520
|
+
return createScriptedReportProvider(context);
|
|
521
|
+
return createHostedProvider({
|
|
522
|
+
api: config.kind,
|
|
523
|
+
endpoint: config.endpoint,
|
|
524
|
+
apiKey: config.apiKey ?? "",
|
|
525
|
+
model: config.model,
|
|
526
|
+
maxTokens: PROVIDER_MAX_TOKENS,
|
|
527
|
+
fetchImpl,
|
|
528
|
+
});
|
|
529
|
+
}
|
|
530
|
+
// ---------------------------------------------------------------------------
|
|
531
|
+
// Help
|
|
532
|
+
// ---------------------------------------------------------------------------
|
|
533
|
+
/** `uptimizr agent report --help`. */
|
|
534
|
+
export function reportUsage() {
|
|
535
|
+
return [
|
|
536
|
+
"uptimizr agent report — run a read-only analytics agent once and write a Markdown report.",
|
|
537
|
+
"",
|
|
538
|
+
"Usage:",
|
|
539
|
+
" uptimizr agent report --skill <name> [options]",
|
|
540
|
+
" uptimizr agent report --list-skills",
|
|
541
|
+
"",
|
|
542
|
+
"Options:",
|
|
543
|
+
" --skill <name> the investigation to run (required; see --list-skills)",
|
|
544
|
+
" --scene <id> scope the report to one scene",
|
|
545
|
+
" --window <NdNhNw> analysis window counting back from now (default 7d; e.g. 24h, 2w)",
|
|
546
|
+
" --since <epochMs> explicit window start (with --until; mutually exclusive with --window)",
|
|
547
|
+
" --until <epochMs> explicit window end (defaults to now)",
|
|
548
|
+
" --out <file|-> write the Markdown here (default: -, stdout)",
|
|
549
|
+
" --json <file|-> also write the structured report (tool calls, args, durations,",
|
|
550
|
+
" token usage when the provider reports it)",
|
|
551
|
+
" --webhook <url> POST {markdown, report} to this http(s) URL, signed when",
|
|
552
|
+
" UPTIMIZR_WEBHOOK_SECRET is set",
|
|
553
|
+
` --max-steps <n> cap on provider turns (default ${DEFAULT_MAX_STEPS})`,
|
|
554
|
+
" --dry-run print the prompt and the tool list; call no provider",
|
|
555
|
+
" --help show this help",
|
|
556
|
+
"",
|
|
557
|
+
"Environment:",
|
|
558
|
+
" UPTIMIZR_COLLECTOR_URL base URL of the collector (required), e.g. http://localhost:4318",
|
|
559
|
+
" UPTIMIZR_API_KEY project API key (required). A `query`-only key is enough —",
|
|
560
|
+
" mint one with `uptimizr new-key <projectId> --capabilities query`.",
|
|
561
|
+
" UPTIMIZR_AGENT_PROVIDER anthropic (default) | openai | scripted",
|
|
562
|
+
" UPTIMIZR_AGENT_MODEL model id (default: claude-sonnet-5 / gpt-4o-mini)",
|
|
563
|
+
" UPTIMIZR_AGENT_API_KEY provider key (falls back to ANTHROPIC_API_KEY / OPENAI_API_KEY)",
|
|
564
|
+
" UPTIMIZR_AGENT_ENDPOINT provider base URL (default: the provider's own)",
|
|
565
|
+
" UPTIMIZR_WEBHOOK_SECRET HMAC-SHA-256 secret for the X-Uptimizr-Signature header",
|
|
566
|
+
"",
|
|
567
|
+
"Exit codes:",
|
|
568
|
+
` ${EXIT.ok} report produced, every tool call succeeded`,
|
|
569
|
+
` ${EXIT.usage} usage or configuration error (nothing ran)`,
|
|
570
|
+
` ${EXIT.provider} the provider call or the webhook delivery failed`,
|
|
571
|
+
` ${EXIT.incomplete} report produced but incomplete (a tool call failed, or no answer)`,
|
|
572
|
+
"",
|
|
573
|
+
"Scheduling is yours: run it from cron, a systemd timer or a GitHub Action. The",
|
|
574
|
+
"collector runs no LLM loop of its own, and no provider configuration is persisted.",
|
|
575
|
+
].join("\n");
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* `uptimizr agent report --list-skills`.
|
|
579
|
+
*
|
|
580
|
+
* A skill declares its own arguments, but this command does not expose one flag
|
|
581
|
+
* per argument: `scene` comes from `--scene` and `range` is filled in from the
|
|
582
|
+
* resolved window (`--window`, or `--since`/`--until`). Printing `[--range]`
|
|
583
|
+
* would advertise a flag that does not exist, so arguments are named the way an
|
|
584
|
+
* operator actually supplies them.
|
|
585
|
+
*/
|
|
586
|
+
export function listSkills() {
|
|
587
|
+
const lines = ["Available skills:", ""];
|
|
588
|
+
for (const skill of AGENT_SKILLS) {
|
|
589
|
+
const args = skill.args
|
|
590
|
+
.map((arg) => {
|
|
591
|
+
if (arg.name === "range")
|
|
592
|
+
return "(range from --window)";
|
|
593
|
+
return arg.required ? `--${arg.name} <required>` : `[--${arg.name}]`;
|
|
594
|
+
})
|
|
595
|
+
.join(" ");
|
|
596
|
+
lines.push(` ${skill.name}${args ? ` ${args}` : ""}`);
|
|
597
|
+
lines.push(` ${skill.title} — ${skill.description}`);
|
|
598
|
+
lines.push(` Tools: ${skill.tools.join(", ")}`);
|
|
599
|
+
lines.push("");
|
|
600
|
+
}
|
|
601
|
+
return lines.join("\n");
|
|
602
|
+
}
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// The command
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
/** Read a required environment variable, or explain exactly what to set. */
|
|
607
|
+
function requireEnv(env, name, hint) {
|
|
608
|
+
const value = env[name]?.trim();
|
|
609
|
+
if (!value)
|
|
610
|
+
throw new AgentReportError(`${name} is not set. ${hint}`);
|
|
611
|
+
return value;
|
|
612
|
+
}
|
|
613
|
+
/** Fetch and render the project context, degrading to `""` on any failure. */
|
|
614
|
+
async function readProjectContext(client, nowMs, stderr) {
|
|
615
|
+
try {
|
|
616
|
+
const document = await client.get("api/v1/context");
|
|
617
|
+
return renderContextForPrompt(document, nowMs);
|
|
618
|
+
}
|
|
619
|
+
catch (err) {
|
|
620
|
+
// A collector older than ADR 0051 §5 has no /context. That is not an error:
|
|
621
|
+
// the run proceeds with exactly the prompt it would have had before.
|
|
622
|
+
stderr(`! project context unavailable (${err.message}); continuing without it`);
|
|
623
|
+
return "";
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Write a Markdown/JSON output to a file, or to stdout for `-`. */
|
|
627
|
+
function emit(target, content, deps, label) {
|
|
628
|
+
if (target === "-") {
|
|
629
|
+
deps.stdout(content.endsWith("\n") ? content : `${content}\n`);
|
|
630
|
+
return;
|
|
631
|
+
}
|
|
632
|
+
const path = resolvePath(process.cwd(), target);
|
|
633
|
+
try {
|
|
634
|
+
deps.writeFile(path, content.endsWith("\n") ? content : `${content}\n`);
|
|
635
|
+
}
|
|
636
|
+
catch (err) {
|
|
637
|
+
throw new AgentReportError(`Could not write ${label} to ${path}: ${err.message}`);
|
|
638
|
+
}
|
|
639
|
+
deps.stderr(`✓ ${label} written to ${path}`);
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Deliver the report to a webhook: one POST of `{markdown, report}`, signed with
|
|
643
|
+
* `UPTIMIZR_WEBHOOK_SECRET` over the exact bytes sent.
|
|
644
|
+
*
|
|
645
|
+
* Unsigned delivery is allowed but warned about, because a receiver that cannot
|
|
646
|
+
* verify the body has no way to tell a real digest from a forged one.
|
|
647
|
+
*/
|
|
648
|
+
async function deliverWebhook(rawUrl, markdown, report, deps) {
|
|
649
|
+
// Already validated before the run; re-parsed here so this function is safe
|
|
650
|
+
// to call on its own.
|
|
651
|
+
const url = parseWebhookUrl(rawUrl);
|
|
652
|
+
const body = JSON.stringify({ markdown, report });
|
|
653
|
+
const secret = deps.env.UPTIMIZR_WEBHOOK_SECRET?.trim();
|
|
654
|
+
const headers = {
|
|
655
|
+
"content-type": "application/json",
|
|
656
|
+
[WEBHOOK_DELIVERY_HEADER]: newDeliveryId(),
|
|
657
|
+
};
|
|
658
|
+
if (secret) {
|
|
659
|
+
headers[WEBHOOK_SIGNATURE_HEADER] = signWebhookBody(secret, body);
|
|
660
|
+
}
|
|
661
|
+
else {
|
|
662
|
+
deps.stderr("! UPTIMIZR_WEBHOOK_SECRET is not set — the delivery will be unsigned and the " +
|
|
663
|
+
"receiver cannot verify it came from this collector.");
|
|
664
|
+
}
|
|
665
|
+
let response;
|
|
666
|
+
try {
|
|
667
|
+
response = await deps.fetchImpl(url, { method: "POST", headers, body });
|
|
668
|
+
}
|
|
669
|
+
catch (err) {
|
|
670
|
+
throw new AgentReportError(`Webhook delivery to ${url.origin}${url.pathname} failed: ${err.message}`, EXIT.provider);
|
|
671
|
+
}
|
|
672
|
+
if (!response.ok) {
|
|
673
|
+
throw new AgentReportError(`Webhook delivery to ${url.origin}${url.pathname} returned ${response.status}.`, EXIT.provider);
|
|
674
|
+
}
|
|
675
|
+
deps.stderr(`✓ report delivered to ${url.origin}${url.pathname} (${response.status})`);
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Run `uptimizr agent report`.
|
|
679
|
+
*
|
|
680
|
+
* Returns the process exit code rather than calling `process.exit`, so the whole
|
|
681
|
+
* command is exercisable in-process by the test suite. Every failure the
|
|
682
|
+
* operator can act on surfaces as one stderr line, never a stack trace, and
|
|
683
|
+
* never containing a key.
|
|
684
|
+
*/
|
|
685
|
+
export async function runAgentReport(argv, overrides = {}) {
|
|
686
|
+
const deps = {
|
|
687
|
+
env: process.env,
|
|
688
|
+
now: () => Date.now(),
|
|
689
|
+
fetchImpl: fetch,
|
|
690
|
+
stdout: (text) => process.stdout.write(text),
|
|
691
|
+
stderr: (line) => process.stderr.write(`${line}\n`),
|
|
692
|
+
writeFile: (path, content) => writeFileSync(path, content, "utf8"),
|
|
693
|
+
createProvider: (config, context) => defaultCreateProvider(config, context, overrides.fetchImpl ?? fetch),
|
|
694
|
+
...overrides,
|
|
695
|
+
};
|
|
696
|
+
try {
|
|
697
|
+
return await execute(argv, deps);
|
|
698
|
+
}
|
|
699
|
+
catch (err) {
|
|
700
|
+
if (err instanceof AgentReportError) {
|
|
701
|
+
deps.stderr(`✗ ${err.message}`);
|
|
702
|
+
return err.exitCode;
|
|
703
|
+
}
|
|
704
|
+
throw err;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
async function execute(argv, deps) {
|
|
708
|
+
const { flags, switches } = parseAgentReportArgs(argv);
|
|
709
|
+
if (switches.has("help")) {
|
|
710
|
+
deps.stdout(`${reportUsage()}\n`);
|
|
711
|
+
return EXIT.ok;
|
|
712
|
+
}
|
|
713
|
+
if (switches.has("list-skills")) {
|
|
714
|
+
deps.stdout(listSkills());
|
|
715
|
+
return EXIT.ok;
|
|
716
|
+
}
|
|
717
|
+
const skillName = flags.skill;
|
|
718
|
+
if (!skillName) {
|
|
719
|
+
throw new AgentReportError("--skill is required. Run `uptimizr agent report --list-skills` to see the available ones.");
|
|
720
|
+
}
|
|
721
|
+
const skill = getAgentSkill(skillName);
|
|
722
|
+
if (!skill) {
|
|
723
|
+
throw new AgentReportError(`Unknown skill ${JSON.stringify(skillName)}. Available: ` +
|
|
724
|
+
`${AGENT_SKILLS.map((s) => s.name).join(", ")}.`);
|
|
725
|
+
}
|
|
726
|
+
const scene = flags.scene?.trim() || undefined;
|
|
727
|
+
for (const arg of skill.args) {
|
|
728
|
+
if (arg.required && arg.name === "scene" && !scene) {
|
|
729
|
+
throw new AgentReportError(`Skill "${skill.name}" needs --scene <id>: ${arg.description}`);
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
const nowMs = deps.now();
|
|
733
|
+
const window = resolveWindow(flags, nowMs);
|
|
734
|
+
const maxSteps = parseMaxSteps(flags["max-steps"]);
|
|
735
|
+
// Validate the webhook URL before spending a provider call on a report that
|
|
736
|
+
// could not be delivered anyway. `parseWebhookUrl` is the shared helper (it
|
|
737
|
+
// throws a plain Error so #311's webhook path can reuse it), so its message is
|
|
738
|
+
// re-raised here as the operator-facing kind.
|
|
739
|
+
if (flags.webhook !== undefined) {
|
|
740
|
+
try {
|
|
741
|
+
parseWebhookUrl(flags.webhook);
|
|
742
|
+
}
|
|
743
|
+
catch (err) {
|
|
744
|
+
throw new AgentReportError(err.message);
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
const collectorUrl = requireEnv(deps.env, "UPTIMIZR_COLLECTOR_URL", "Point it at your collector, e.g. http://localhost:4318.");
|
|
748
|
+
const apiKey = requireEnv(deps.env, "UPTIMIZR_API_KEY", "A read-only key is enough: `uptimizr new-key <projectId> --capabilities query`.");
|
|
749
|
+
// Advisory, every run: this command only ever reads, so a key that can also
|
|
750
|
+
// annotate or read raw sessions is more authority than it needs (ADR 0051 §7).
|
|
751
|
+
deps.stderr("· uptimizr agent report is read-only; a `query`-only key is recommended " +
|
|
752
|
+
"(uptimizr new-key <projectId> --capabilities query).");
|
|
753
|
+
const tools = readTools;
|
|
754
|
+
const client = createCollectorClient({ collectorUrl, apiKey }, deps.fetchImpl);
|
|
755
|
+
const projectContext = await readProjectContext(client, nowMs, deps.stderr);
|
|
756
|
+
const systemPrompt = buildReportSystemPrompt({
|
|
757
|
+
nowMs,
|
|
758
|
+
window,
|
|
759
|
+
scene,
|
|
760
|
+
projectContext,
|
|
761
|
+
});
|
|
762
|
+
const userTurn = skill.render({ scene, range: describeWindow(window) });
|
|
763
|
+
if (switches.has("dry-run")) {
|
|
764
|
+
deps.stdout([
|
|
765
|
+
`# Dry run — skill \`${skill.name}\``,
|
|
766
|
+
"",
|
|
767
|
+
`Collector: ${collectorUrl}`,
|
|
768
|
+
`Window: ${iso(window.since)} → ${iso(window.until)} (${window.label})`,
|
|
769
|
+
`Scene: ${scene ?? "(all)"}`,
|
|
770
|
+
`Provider: ${deps.env.UPTIMIZR_AGENT_PROVIDER ?? "anthropic"} — not called`,
|
|
771
|
+
`Max steps: ${maxSteps}`,
|
|
772
|
+
`Tools available: ${tools.length} (the skill's method uses ${skill.tools.join(", ")})`,
|
|
773
|
+
"",
|
|
774
|
+
"## System prompt",
|
|
775
|
+
"",
|
|
776
|
+
systemPrompt,
|
|
777
|
+
"",
|
|
778
|
+
"## User turn",
|
|
779
|
+
"",
|
|
780
|
+
userTurn,
|
|
781
|
+
"",
|
|
782
|
+
].join("\n"));
|
|
783
|
+
return EXIT.ok;
|
|
784
|
+
}
|
|
785
|
+
const providerConfig = resolveProvider(deps.env);
|
|
786
|
+
const providerContext = { skill, tools, window, scene };
|
|
787
|
+
const reads = [];
|
|
788
|
+
const tally = { turns: 0, usage: null };
|
|
789
|
+
const messages = [
|
|
790
|
+
{ role: "system", content: systemPrompt },
|
|
791
|
+
{ role: "user", content: userTurn },
|
|
792
|
+
];
|
|
793
|
+
deps.stderr(`· running ${skill.name} against ${collectorUrl} ` +
|
|
794
|
+
`with ${providerConfig.kind} (${providerConfig.model})`);
|
|
795
|
+
const startedAt = Date.now();
|
|
796
|
+
let result;
|
|
797
|
+
try {
|
|
798
|
+
result = await runAgent({
|
|
799
|
+
provider: instrumentProvider(deps.createProvider(providerConfig, providerContext), tally, deps.stderr),
|
|
800
|
+
client: instrumentClient(client, reads, deps.stderr),
|
|
801
|
+
tools,
|
|
802
|
+
maxSteps,
|
|
803
|
+
messages,
|
|
804
|
+
});
|
|
805
|
+
}
|
|
806
|
+
catch (err) {
|
|
807
|
+
// Provider failures carry the endpoint's own error text, which never
|
|
808
|
+
// contains the key (the adapter sends it as a header).
|
|
809
|
+
throw new AgentReportError(`The ${providerConfig.kind} provider failed: ${err.message}`, EXIT.provider);
|
|
810
|
+
}
|
|
811
|
+
const finishedAt = Date.now();
|
|
812
|
+
const toolCalls = collectToolCalls(result.messages, reads);
|
|
813
|
+
const report = {
|
|
814
|
+
schema: REPORT_SCHEMA,
|
|
815
|
+
skill: skill.name,
|
|
816
|
+
title: skill.title,
|
|
817
|
+
scene: scene ?? null,
|
|
818
|
+
window,
|
|
819
|
+
collectorUrl,
|
|
820
|
+
provider: { kind: providerConfig.kind, model: providerConfig.model },
|
|
821
|
+
startedAt: iso(startedAt),
|
|
822
|
+
finishedAt: iso(finishedAt),
|
|
823
|
+
durationMs: finishedAt - startedAt,
|
|
824
|
+
steps: result.steps,
|
|
825
|
+
maxSteps,
|
|
826
|
+
stoppedOnMaxSteps: result.stoppedOnMaxSteps,
|
|
827
|
+
usage: tally.usage,
|
|
828
|
+
context: { available: projectContext !== "", chars: projectContext.length },
|
|
829
|
+
toolCalls,
|
|
830
|
+
answer: result.content,
|
|
831
|
+
};
|
|
832
|
+
const markdown = renderReportMarkdown(report);
|
|
833
|
+
emit(flags.out ?? "-", markdown, deps, "report");
|
|
834
|
+
if (flags.json !== undefined) {
|
|
835
|
+
emit(flags.json, `${JSON.stringify(report, null, 2)}`, deps, "JSON report");
|
|
836
|
+
}
|
|
837
|
+
if (flags.webhook !== undefined) {
|
|
838
|
+
await deliverWebhook(flags.webhook, markdown, report, deps);
|
|
839
|
+
}
|
|
840
|
+
const failed = toolCalls.filter((call) => !call.ok);
|
|
841
|
+
if (failed.length > 0) {
|
|
842
|
+
deps.stderr(`✗ ${failed.length} of ${toolCalls.length} tool call(s) failed — the report is incomplete.`);
|
|
843
|
+
return EXIT.incomplete;
|
|
844
|
+
}
|
|
845
|
+
if (report.answer.trim() === "") {
|
|
846
|
+
deps.stderr("✗ the model returned no answer — the report is incomplete.");
|
|
847
|
+
return EXIT.incomplete;
|
|
848
|
+
}
|
|
849
|
+
return EXIT.ok;
|
|
850
|
+
}
|
|
851
|
+
/** Parse `--max-steps`, or fall back to the loop's own default. */
|
|
852
|
+
function parseMaxSteps(raw) {
|
|
853
|
+
if (raw === undefined)
|
|
854
|
+
return DEFAULT_MAX_STEPS;
|
|
855
|
+
const value = Number(raw);
|
|
856
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
857
|
+
throw new AgentReportError(`--max-steps must be a positive integer (got "${raw}").`);
|
|
858
|
+
}
|
|
859
|
+
return value;
|
|
860
|
+
}
|
|
861
|
+
//# sourceMappingURL=agentReport.js.map
|