@meffecta/agent 0.0.1 → 1.0.1

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/lib/doctor.js ADDED
@@ -0,0 +1,452 @@
1
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
2
+ import { requireDeployment, UserError } from "./config.js";
3
+ import { api, capture, gcloudArgs, requireCommand } from "./gcloud.js";
4
+
5
+ /**
6
+ * Judge a deployment, rather than describe it.
7
+ *
8
+ * `status` tells you what the service looks like; this tells you what is wrong with it.
9
+ * Every check here corresponds to a way a deployment has actually gone quiet or gone
10
+ * double — a scaled-to-zero service with no triggers looks perfectly healthy and runs
11
+ * nothing; an always-on one with triggers runs every cron twice; a sweep whose cadence no
12
+ * longer matches the inbox poll interval checks mail on the old schedule for ever. None of
13
+ * those show up as an error anywhere until someone notices the work stopped.
14
+ *
15
+ * Read-only, and every failure carries the command that fixes it.
16
+ */
17
+
18
+ const OK = "ok";
19
+ const WARN = "warn";
20
+ const FAIL = "fail";
21
+
22
+ class Report {
23
+ constructor() {
24
+ this.rows = [];
25
+ }
26
+ add(level, title, detail, fix) {
27
+ this.rows.push({ level, title, detail, fix });
28
+ }
29
+ ok(title, detail) {
30
+ this.add(OK, title, detail);
31
+ }
32
+ warn(title, detail, fix) {
33
+ this.add(WARN, title, detail, fix);
34
+ }
35
+ fail(title, detail, fix) {
36
+ this.add(FAIL, title, detail, fix);
37
+ }
38
+ }
39
+
40
+ const MARK = { [OK]: "✓", [WARN]: "!", [FAIL]: "✗" };
41
+
42
+ /** Cron minutes the sweep should run at, given the poll interval. Mirrors setup-scheduler.sh. */
43
+
44
+ /**
45
+ * The jobs the content repo declares on disk, parsed the way the engine parses them.
46
+ *
47
+ * This is a different question from what `GET /jobs` answers. That reports what the running
48
+ * service registered from a clone taken at *its* boot, so a job pushed since — or a cron
49
+ * changed since — is invisible to it until the service restarts. Comparing the two is the
50
+ * only way to see that gap, and it is the ordinary way a new job never runs.
51
+ */
52
+ export function readLocalJobs(dir) {
53
+ if (!existsSync(dir)) {
54
+ return undefined;
55
+ }
56
+ const jobs = [];
57
+ for (const file of readdirSync(dir).filter((f) => f.endsWith(".md"))) {
58
+ const text = readFileSync(`${dir}/${file}`, "utf8");
59
+ const match = text.match(/^---\n([\s\S]*?)\n---\n/);
60
+ if (!match) {
61
+ continue;
62
+ }
63
+ const meta = {};
64
+ for (const line of match[1].split("\n")) {
65
+ const at = line.indexOf(": ");
66
+ if (at > 0) {
67
+ meta[line.slice(0, at).trim()] = line
68
+ .slice(at + 2)
69
+ .trim()
70
+ .replace(/^["']|["']$/g, "");
71
+ }
72
+ }
73
+ jobs.push({
74
+ name: meta.name || file.slice(0, -3),
75
+ cron: meta.cron,
76
+ webhook: meta.webhook,
77
+ inbox: meta.inbox,
78
+ disabled: meta.disabled === "true",
79
+ });
80
+ }
81
+ return jobs;
82
+ }
83
+
84
+ export function expectedSweepSchedule(pollSeconds) {
85
+ // Unset means the engine's own default; 0 must clamp to one minute rather than fall
86
+ // back to it, which is what setup-scheduler.sh does and what doctor has to agree with.
87
+ const raw = pollSeconds === undefined || pollSeconds === null || pollSeconds === "" ? 300 : Number(pollSeconds);
88
+ const seconds = Number.isFinite(raw) ? raw : 300;
89
+ const minutes = Math.max(1, Math.ceil(seconds / 60));
90
+ return minutes >= 60 ? "17 * * * *" : `*/${minutes} * * * *`;
91
+ }
92
+
93
+ /**
94
+ * Scaling and triggering are one decision expressed in two places, and both wrong pairings
95
+ * fail quietly: scaled to zero with nothing to drive it runs no jobs while looking
96
+ * perfectly healthy, and always-on with external triggers runs every cron twice — once
97
+ * from Cloud Scheduler and once from the in-process timer. Pure, so it can be tested
98
+ * without a deployment to point at.
99
+ */
100
+ export function judgeTriggering({ minScale, external, throttled }) {
101
+ const scaledToZero = String(minScale ?? "0") === "0";
102
+ const findings = [];
103
+ if (scaledToZero && !external) {
104
+ findings.push({
105
+ level: FAIL,
106
+ title: "Scaled to zero with no triggers",
107
+ detail: "Nothing in the service fires on its own, so no job will ever run. It will look healthy.",
108
+ fix: "meffecta-agent setup-scheduler",
109
+ });
110
+ } else if (!scaledToZero && external) {
111
+ findings.push({
112
+ level: FAIL,
113
+ title: "Always-on and externally triggered",
114
+ detail: `min-instances=${minScale} keeps the in-process timers running while Cloud Scheduler also fires — every cron runs twice.`,
115
+ fix: "Set SCALING=scale-to-zero in deployment.env, then: meffecta-agent deploy",
116
+ });
117
+ } else if (external) {
118
+ findings.push({
119
+ level: OK,
120
+ title: "Triggering",
121
+ detail: "scaled to zero, driven by Cloud Scheduler and Cloud Tasks",
122
+ });
123
+ } else {
124
+ findings.push({
125
+ level: OK,
126
+ title: "Triggering",
127
+ detail: `always-on (min-instances=${minScale}), driven by in-process timers`,
128
+ });
129
+ }
130
+ if (scaledToZero && !throttled) {
131
+ findings.push({
132
+ level: WARN,
133
+ title: "Paying for idle CPU",
134
+ detail: "min-instances=0 with CPU always allocated — the saving of scaling to zero is not being taken.",
135
+ fix: "meffecta-agent deploy",
136
+ });
137
+ }
138
+ return findings;
139
+ }
140
+
141
+ export async function doctor() {
142
+ requireCommand("gcloud", "the CLI reads your deployment through it");
143
+ const d = requireDeployment();
144
+ const r = new Report();
145
+
146
+ const svc = JSON.parse(
147
+ capture(
148
+ "gcloud",
149
+ gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
150
+ ),
151
+ );
152
+ const tpl = svc.spec.template;
153
+ const ann = tpl.metadata.annotations ?? {};
154
+ const container = tpl.spec.containers[0];
155
+ const env = Object.fromEntries((container.env ?? []).map((e) => [e.name, e]));
156
+ const value = (name) => env[name]?.value;
157
+ const minScale = ann["autoscaling.knative.dev/minScale"] ?? "0";
158
+ const throttled = ann["run.googleapis.com/cpu-throttling"] !== "false";
159
+ const external = Boolean(value("AGENT_TASKS_QUEUE") && value("AGENT_PUBLIC_URL"));
160
+
161
+ // --- the service is actually up -------------------------------------------------
162
+ const ready = (svc.status?.conditions ?? []).find((c) => c.type === "Ready");
163
+ if (ready?.status === "True") {
164
+ r.ok("Service ready", `${svc.status.latestReadyRevisionName} serving`);
165
+ } else {
166
+ r.fail("Service not ready", ready?.message ?? "no Ready condition", "meffecta-agent logs");
167
+ }
168
+
169
+ // --- the pairing that decides whether anything runs at all ----------------------
170
+ for (const finding of judgeTriggering({ minScale, external, throttled })) {
171
+ r.add(finding.level, finding.title, finding.detail, finding.fix);
172
+ }
173
+
174
+ // --- configuration the engine cannot boot without -------------------------------
175
+ if (value("GIT_REPO_URL")) {
176
+ r.ok("Content repo", value("GIT_REPO_URL"));
177
+ } else {
178
+ r.fail(
179
+ "GIT_REPO_URL is unset",
180
+ "The engine clones the content repo at boot for its job registrations; without it the service will not start.",
181
+ "meffecta-agent set-env GIT_REPO_URL=https://github.com/<owner>/<repo>.git",
182
+ );
183
+ }
184
+
185
+ const secrets = new Set(
186
+ capture("gcloud", gcloudArgs(d, ["secrets", "list", "--format=value(name)"]))
187
+ .split("\n")
188
+ .filter(Boolean)
189
+ .map((n) => n.split("/").pop()),
190
+ );
191
+ for (const name of ["AGENT_WEBHOOK_SECRET", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN"]) {
192
+ const ref = env[name]?.valueFrom?.secretKeyRef?.name;
193
+ if (!env[name]) {
194
+ r.fail(`${name} not set`, "Required by the engine.", `meffecta-agent set-secret ${name}`);
195
+ } else if (ref && !secrets.has(ref)) {
196
+ r.fail(
197
+ `${name} points at a missing secret`,
198
+ `The service reads secret "${ref}", which is not in Secret Manager.`,
199
+ `meffecta-agent set-secret ${name}`,
200
+ );
201
+ }
202
+ }
203
+
204
+ const mounted = (container.volumeMounts ?? []).some((m) => m.mountPath === "/memory");
205
+ if (mounted || value("MEMORY_DIR")) {
206
+ r.ok("Memory", mounted ? "/memory volume mounted" : `MEMORY_DIR=${value("MEMORY_DIR")}`);
207
+ } else {
208
+ r.warn(
209
+ "No memory volume",
210
+ "Job memory, the queue journal and the spawn spool are in-process only — a restart loses pending work.",
211
+ "meffecta-agent setup-infra",
212
+ );
213
+ }
214
+
215
+ // --- the triggers, against what the content repo actually declares ---------------
216
+ if (external) {
217
+ let jobs = [];
218
+ try {
219
+ jobs = JSON.parse(await api(d, "/jobs"));
220
+ r.ok("Jobs registered", `${jobs.length} loaded from the content repo`);
221
+ } catch (err) {
222
+ r.fail("Cannot read the job list", err.message.split("\n")[0], "meffecta-agent logs");
223
+ }
224
+
225
+ // What the service registered came from a clone taken at its boot; the files here are
226
+ // current. A job pushed since, or a cron edited since, is invisible until it restarts.
227
+ const local = readLocalJobs(`${process.cwd()}/jobs`);
228
+ if (local) {
229
+ const registered = new Map(jobs.map((j) => [j.name, j]));
230
+ const unregistered = local.filter((j) => !registered.has(j.name));
231
+ const stale = local.filter((j) => registered.has(j.name) && registered.get(j.name).cron !== j.cron);
232
+ const gone = jobs.filter((j) => !local.some((l) => l.name === j.name));
233
+ if (unregistered.length || stale.length || gone.length) {
234
+ r.fail(
235
+ "The service has not picked up the content repo",
236
+ [
237
+ unregistered.length && `not registered: ${unregistered.map((j) => j.name).join(", ")}`,
238
+ stale.length && `cron changed on disk: ${stale.map((j) => j.name).join(", ")}`,
239
+ gone.length && `registered but no longer in jobs/: ${gone.map((j) => j.name).join(", ")}`,
240
+ ]
241
+ .filter(Boolean)
242
+ .join("; ") + ". Registrations are read at boot, so this needs a restart.",
243
+ "meffecta-agent deploy (a rollout restarts it and re-syncs the triggers)",
244
+ );
245
+ } else {
246
+ r.ok("Content repo", `${local.length} job(s) on disk, all registered`);
247
+ }
248
+ }
249
+
250
+ const scheduler = capture(
251
+ "gcloud",
252
+ gcloudArgs(d, [
253
+ "scheduler",
254
+ "jobs",
255
+ "list",
256
+ `--location=${d.REGION}`,
257
+ "--format=value[separator='\t'](ID,schedule,state)",
258
+ ]),
259
+ )
260
+ .split("\n")
261
+ .filter(Boolean)
262
+ .map((line) => line.split("\t"));
263
+ const byId = new Map(scheduler.map(([id, schedule, state]) => [id, { schedule, state }]));
264
+
265
+ const wanted = jobs.filter((j) => j.cron);
266
+ const missing = wanted.filter((j) => !byId.has(`${d.SERVICE}-job-${j.name}`));
267
+ const drifted = wanted.filter((j) => {
268
+ const found = byId.get(`${d.SERVICE}-job-${j.name}`);
269
+ return found && found.schedule !== j.cron;
270
+ });
271
+ const stale = [...byId.keys()].filter(
272
+ (id) => id.startsWith(`${d.SERVICE}-job-`) && !wanted.some((j) => `${d.SERVICE}-job-${j.name}` === id),
273
+ );
274
+ if (missing.length || drifted.length || stale.length) {
275
+ const parts = [
276
+ missing.length && `${missing.length} job(s) with no trigger: ${missing.map((j) => j.name).join(", ")}`,
277
+ drifted.length && `${drifted.length} schedule(s) out of date: ${drifted.map((j) => j.name).join(", ")}`,
278
+ stale.length && `${stale.length} trigger(s) for jobs that no longer exist: ${stale.join(", ")}`,
279
+ ].filter(Boolean);
280
+ r.fail("Triggers do not match the content repo", parts.join("; "), "meffecta-agent setup-scheduler");
281
+ } else if (wanted.length) {
282
+ r.ok("Cron triggers", `${wanted.length} in place, all matching the content repo`);
283
+ }
284
+
285
+ // An inbox job with no allowFrom refuses every message, so it looks registered and
286
+ // healthy while doing nothing at all. That is a failure, not a preference.
287
+ const unset = jobs.filter((job) => job.inbox && !job.allowFrom);
288
+ if (unset.length > 0) {
289
+ r.fail(
290
+ "Inbox jobs that refuse all mail",
291
+ `${unset.map((job) => `${job.name} (${job.inbox})`).join(", ")} — no allowFrom:, so every message is dropped and the job never runs.`,
292
+ "Add `allowFrom: you@example.com, @yourdomain.com` (or `*` for anyone) to the job, then: meffecta-agent deploy",
293
+ );
294
+ }
295
+ const anyone = jobs.filter((job) => job.allowFrom?.split(",").some((rule) => rule.trim() === "*"));
296
+ if (anyone.length > 0) {
297
+ r.warn(
298
+ "Inbox jobs anyone can trigger",
299
+ `${anyone.map((job) => job.name).join(", ")} — allowFrom: * accepts mail from any sender.`,
300
+ "Deliberate is fine; narrow it to addresses or domains if it was not.",
301
+ );
302
+ }
303
+
304
+ const sweep = byId.get(`${d.SERVICE}-sweep`);
305
+ if (!sweep) {
306
+ r.fail(
307
+ "No sweep",
308
+ "Due follow-ups, runs left behind by a dead process, and watched inboxes are all handled by the sweep. Without it they never happen.",
309
+ "meffecta-agent setup-scheduler",
310
+ );
311
+ } else {
312
+ const watchesInbox = jobs.some((j) => j.inbox);
313
+ const expected = watchesInbox ? expectedSweepSchedule(value("AGENT_INBOX_POLL_SECONDS")) : "17 * * * *";
314
+ if (sweep.schedule !== expected) {
315
+ r.warn(
316
+ "Sweep cadence is stale",
317
+ `Runs "${sweep.schedule}", but ${
318
+ watchesInbox
319
+ ? `AGENT_INBOX_POLL_SECONDS=${value("AGENT_INBOX_POLL_SECONDS") ?? 300} wants "${expected}"`
320
+ : `no job watches an inbox, so "${expected}" is enough`
321
+ }.`,
322
+ "meffecta-agent setup-scheduler",
323
+ );
324
+ } else {
325
+ r.ok("Sweep", `${sweep.schedule}${watchesInbox ? " — matching the inbox poll interval" : ""}`);
326
+ }
327
+ }
328
+
329
+ try {
330
+ const [state] = capture(
331
+ "gcloud",
332
+ gcloudArgs(d, [
333
+ "tasks",
334
+ "queues",
335
+ "describe",
336
+ `${d.SERVICE}-runs`,
337
+ `--location=${d.REGION}`,
338
+ "--format=value(state)",
339
+ ]),
340
+ ).split("\t");
341
+ if (state === "RUNNING") {
342
+ r.ok("Task queue", `${d.SERVICE}-runs running`);
343
+ } else {
344
+ r.fail(
345
+ "Task queue is not running",
346
+ `State ${state}. Webhooks, manual runs and follow-ups are delivered through it.`,
347
+ `gcloud tasks queues resume ${d.SERVICE}-runs --location ${d.REGION} --project ${d.PROJECT}`,
348
+ );
349
+ }
350
+ } catch {
351
+ r.fail(
352
+ "No task queue",
353
+ "External triggers are configured but the queue is missing.",
354
+ "meffecta-agent setup-scheduler",
355
+ );
356
+ }
357
+ }
358
+
359
+ // --- the image the next cold start will pull ------------------------------------
360
+ const repo = d.ARTIFACT_REPO ?? `${d.SERVICE}-images`;
361
+ try {
362
+ const digest = capture(
363
+ "gcloud",
364
+ gcloudArgs(d, [
365
+ "run",
366
+ "revisions",
367
+ "describe",
368
+ svc.status.latestReadyRevisionName,
369
+ `--region=${d.REGION}`,
370
+ "--format=value(status.imageDigest)",
371
+ ]),
372
+ )
373
+ .split("@")
374
+ .pop();
375
+ const present = capture(
376
+ "gcloud",
377
+ gcloudArgs(d, [
378
+ "artifacts",
379
+ "docker",
380
+ "images",
381
+ "list",
382
+ `${d.REGION}-docker.pkg.dev/${d.PROJECT}/${repo}`,
383
+ "--include-tags",
384
+ "--format=value(version)",
385
+ ]),
386
+ )
387
+ .split("\n")
388
+ .includes(digest);
389
+ if (present) {
390
+ r.ok("Running image", "still in Artifact Registry");
391
+ } else {
392
+ r.fail(
393
+ "The running image is gone from Artifact Registry",
394
+ "A revision pins a digest and a scaled-to-zero service pulls it on every cold start — the service will fail to wake.",
395
+ "meffecta-agent deploy",
396
+ );
397
+ }
398
+ const policies = capture(
399
+ "gcloud",
400
+ gcloudArgs(d, [
401
+ "artifacts",
402
+ "repositories",
403
+ "list-cleanup-policies",
404
+ repo,
405
+ `--location=${d.REGION}`,
406
+ "--format=value(name)",
407
+ ]),
408
+ );
409
+ if (policies.trim()) {
410
+ r.ok("Registry cleanup", "policy in place");
411
+ } else {
412
+ r.warn(
413
+ "No registry cleanup policy",
414
+ "Mirrored engine images accumulate; they are a cache of GHCR and can be expired freely.",
415
+ "meffecta-agent artifact-cleanup",
416
+ );
417
+ }
418
+ } catch {
419
+ // A deployment pulling through a remote repository has no registry of its own.
420
+ }
421
+
422
+ return r;
423
+ }
424
+
425
+ export function printReport(r) {
426
+ const width = Math.max(...r.rows.map((row) => row.title.length));
427
+ for (const { level, title, detail, fix } of r.rows) {
428
+ console.log(`${MARK[level]} ${title.padEnd(width)} ${detail ?? ""}`);
429
+ if (fix) {
430
+ console.log(`${" ".repeat(width + 3)}→ ${fix}`);
431
+ }
432
+ }
433
+ const failed = r.rows.filter((row) => row.level === FAIL).length;
434
+ const warned = r.rows.filter((row) => row.level === WARN).length;
435
+ console.log("");
436
+ if (failed) {
437
+ console.log(`${failed} problem(s) that stop this deployment working${warned ? `, ${warned} worth a look` : ""}.`);
438
+ } else if (warned) {
439
+ console.log(`Nothing broken; ${warned} thing(s) worth a look.`);
440
+ } else {
441
+ console.log("Everything checks out.");
442
+ }
443
+ return failed > 0 ? 1 : 0;
444
+ }
445
+
446
+ export async function runDoctor() {
447
+ const report = await doctor();
448
+ if (report.rows.length === 0) {
449
+ throw new UserError("Nothing could be checked.");
450
+ }
451
+ return printReport(report);
452
+ }
package/lib/flags.js ADDED
@@ -0,0 +1,82 @@
1
+ import { UserError } from "./config.js";
2
+
3
+ /**
4
+ * Argument parsing for the handful of commands this CLI implements itself.
5
+ *
6
+ * Most commands are passthrough — their arguments belong to a shell script and must arrive
7
+ * untouched — so what is needed here is small and strict rather than general: know the
8
+ * flags a command has, refuse anything else, and never guess. The rule throughout is that
9
+ * a malformed invocation is an error, not a default. `run <job> --in` with no value used
10
+ * to become an immediate run, which is the opposite of what was asked for.
11
+ *
12
+ * Supports `--name value`, `--name=value`, boolean flags, and `--` to end flag parsing.
13
+ */
14
+ export function parseFlags(argv, spec = {}) {
15
+ const flags = {};
16
+ const positional = [];
17
+ let onlyPositional = false;
18
+
19
+ for (let i = 0; i < argv.length; i += 1) {
20
+ const token = argv[i];
21
+
22
+ if (onlyPositional || !token.startsWith("--")) {
23
+ positional.push(token);
24
+ continue;
25
+ }
26
+ if (token === "--") {
27
+ onlyPositional = true;
28
+ continue;
29
+ }
30
+
31
+ const eq = token.indexOf("=");
32
+ const name = (eq === -1 ? token : token.slice(0, eq)).slice(2);
33
+ const definition = spec[name];
34
+ if (!definition) {
35
+ const known = Object.keys(spec);
36
+ throw new UserError(
37
+ `Unknown option --${name}.` +
38
+ (known.length ? `\nThis command takes: ${known.map((k) => `--${k}`).join(", ")}` : "") +
39
+ '\nIf it is part of your text, quote the whole thing: meffecta-agent ask "…"',
40
+ );
41
+ }
42
+
43
+ if (definition.type === "boolean") {
44
+ flags[name] = eq === -1 ? true : argv[i].slice(eq + 1) !== "false";
45
+ continue;
46
+ }
47
+
48
+ let raw;
49
+ if (eq !== -1) {
50
+ raw = token.slice(eq + 1);
51
+ } else {
52
+ raw = argv[i + 1];
53
+ i += 1;
54
+ }
55
+ // A flag whose value is missing, or is the next flag, is a typo — not a default.
56
+ if (raw === undefined || (raw.startsWith("--") && eq === -1)) {
57
+ throw new UserError(`--${name} needs a value.`);
58
+ }
59
+
60
+ if (definition.type === "int") {
61
+ const value = Number(raw);
62
+ if (!Number.isInteger(value)) {
63
+ throw new UserError(`--${name} takes a whole number, not "${raw}".`);
64
+ }
65
+ if (definition.min !== undefined && value < definition.min) {
66
+ throw new UserError(`--${name} must be at least ${definition.min} (got ${value}).`);
67
+ }
68
+ if (definition.max !== undefined && value > definition.max) {
69
+ throw new UserError(`--${name} must be at most ${definition.max} (got ${value}).`);
70
+ }
71
+ flags[name] = value;
72
+ continue;
73
+ }
74
+
75
+ if (definition.choices && !definition.choices.includes(raw)) {
76
+ throw new UserError(`--${name} must be one of: ${definition.choices.join(", ")} (got "${raw}").`);
77
+ }
78
+ flags[name] = raw;
79
+ }
80
+
81
+ return { flags, positional };
82
+ }