@meffecta/agent 1.1.0 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/engine.json +2 -2
- package/lib/commands.js +40 -7
- package/lib/create-job.js +9 -1
- package/lib/create-system.js +398 -0
- package/lib/doctor.js +10 -1
- package/lib/flags.js +41 -2
- package/lib/history.js +360 -0
- package/lib/integrations.js +3 -2
- package/lib/verify-credentials.js +14 -6
- package/package.json +1 -1
package/engine.json
CHANGED
package/lib/commands.js
CHANGED
|
@@ -4,9 +4,11 @@ import { fileURLToPath } from "node:url";
|
|
|
4
4
|
import { configPath, decideAnalytics, readConfig, resolvePosthog, writeConfig } from "./analytics.js";
|
|
5
5
|
import { requireDeployment, UserError } from "./config.js";
|
|
6
6
|
import { checkJobs, createJob } from "./create-job.js";
|
|
7
|
+
import { checkSystems, createSystem } from "./create-system.js";
|
|
7
8
|
import { runDoctor } from "./doctor.js";
|
|
8
9
|
import { parseFlags } from "./flags.js";
|
|
9
10
|
import { api, requireCommand, stream } from "./gcloud.js";
|
|
11
|
+
import { history } from "./history.js";
|
|
10
12
|
import { connect } from "./integrations.js";
|
|
11
13
|
import { resources } from "./resources.js";
|
|
12
14
|
import { verifyCredentials } from "./verify-credentials.js";
|
|
@@ -310,7 +312,15 @@ async function jobs() {
|
|
|
310
312
|
}
|
|
311
313
|
|
|
312
314
|
async function runJob(args) {
|
|
313
|
-
const { flags, positional } = parseFlags(
|
|
315
|
+
const { flags, positional, help } = parseFlags(
|
|
316
|
+
args,
|
|
317
|
+
{ in: { type: "int", min: 1, max: 3600 } },
|
|
318
|
+
{ usage: "meffecta-agent run <job> [--in <seconds>]" },
|
|
319
|
+
);
|
|
320
|
+
if (help) {
|
|
321
|
+
console.log(help);
|
|
322
|
+
return 0;
|
|
323
|
+
}
|
|
314
324
|
const name = positional[0];
|
|
315
325
|
if (!name) {
|
|
316
326
|
throw new UserError("Which job? Try: meffecta-agent jobs");
|
|
@@ -331,11 +341,19 @@ async function ask(args) {
|
|
|
331
341
|
// model and effort are deliberately not validated here. The engine owns that list, this
|
|
332
342
|
// CLI may be deploying an engine older or newer than itself, and /test already answers
|
|
333
343
|
// with the valid values — so the authority stays in one place and the error stays good.
|
|
334
|
-
const { flags, positional } = parseFlags(
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
344
|
+
const { flags, positional, help } = parseFlags(
|
|
345
|
+
args,
|
|
346
|
+
{
|
|
347
|
+
model: {},
|
|
348
|
+
effort: {},
|
|
349
|
+
timeoutSeconds: { type: "int", min: 1, max: 3600 },
|
|
350
|
+
},
|
|
351
|
+
{ usage: 'meffecta-agent ask "how did search do last week" [--model M] [--effort E]' },
|
|
352
|
+
);
|
|
353
|
+
if (help) {
|
|
354
|
+
console.log(help);
|
|
355
|
+
return 0;
|
|
356
|
+
}
|
|
339
357
|
const prompt = positional.join(" ");
|
|
340
358
|
if (!prompt) {
|
|
341
359
|
throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
|
|
@@ -538,7 +556,15 @@ async function triggers() {
|
|
|
538
556
|
/** Recent service logs, without making anyone remember the filter syntax. */
|
|
539
557
|
async function logs(args) {
|
|
540
558
|
requireCommand("gcloud", "logs come from Cloud Logging");
|
|
541
|
-
const { flags } = parseFlags(
|
|
559
|
+
const { flags, help } = parseFlags(
|
|
560
|
+
args,
|
|
561
|
+
{ limit: { type: "int", min: 1, max: 1000 } },
|
|
562
|
+
{ usage: "meffecta-agent logs [--limit N]" },
|
|
563
|
+
);
|
|
564
|
+
if (help) {
|
|
565
|
+
console.log(help);
|
|
566
|
+
return 0;
|
|
567
|
+
}
|
|
542
568
|
const d = requireDeployment();
|
|
543
569
|
const limit = String(flags.limit ?? 40);
|
|
544
570
|
const { code } = await stream("gcloud", [
|
|
@@ -771,7 +797,9 @@ export const GROUPS = [
|
|
|
771
797
|
["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
|
|
772
798
|
["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
|
|
773
799
|
["logs", "Recent service logs (--limit N)", logs],
|
|
800
|
+
["history", "What it has actually run: outcome, duration and cost per run (--job, --since, --failed)", history],
|
|
774
801
|
["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
|
|
802
|
+
["check-systems", "Validate the register here, and every job's systems: against it", checkSystems],
|
|
775
803
|
["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
|
|
776
804
|
["resources", "Everything the set-up built in Google Cloud, and what each part is for", resources],
|
|
777
805
|
],
|
|
@@ -780,6 +808,11 @@ export const GROUPS = [
|
|
|
780
808
|
title: "Operate it",
|
|
781
809
|
commands: [
|
|
782
810
|
["create-job", 'Write a new job from a description: create-job "a report every Wednesday 2pm"', createJob],
|
|
811
|
+
[
|
|
812
|
+
"create-system",
|
|
813
|
+
'Write a systems/ register entry: create-system "our HubSpot CRM" — it finds the variables',
|
|
814
|
+
createSystem,
|
|
815
|
+
],
|
|
783
816
|
["run", "Trigger one job now, or --in <seconds>", runJob],
|
|
784
817
|
["ask", "Ask it something as a one-off run", ask],
|
|
785
818
|
["sweep", "Run the housekeeping sweep now", sweep],
|
package/lib/create-job.js
CHANGED
|
@@ -215,7 +215,15 @@ function buildPrompt(description, { timezone, existing }) {
|
|
|
215
215
|
}
|
|
216
216
|
|
|
217
217
|
export async function createJob(args) {
|
|
218
|
-
const { flags, positional } = parseFlags(
|
|
218
|
+
const { flags, positional, help } = parseFlags(
|
|
219
|
+
args,
|
|
220
|
+
{ name: {}, model: {}, "dry-run": { type: "boolean" } },
|
|
221
|
+
{ usage: 'meffecta-agent create-job "a report every Wednesday at 2pm" [--name <file>] [--dry-run]' },
|
|
222
|
+
);
|
|
223
|
+
if (help) {
|
|
224
|
+
console.log(help);
|
|
225
|
+
return 0;
|
|
226
|
+
}
|
|
219
227
|
const description = positional.join(" ").trim();
|
|
220
228
|
if (!description) {
|
|
221
229
|
throw new UserError(
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
// `create-system "<the system>"` — a register entry written by the agent itself.
|
|
2
|
+
//
|
|
3
|
+
// A register entry used to be a document: prose the model read to learn which of this
|
|
4
|
+
// deployment's variables reach which system. It is now load-bearing. A job's `systems:`
|
|
5
|
+
// names entries, and only those entries' `requires` and `selectors` are put in that run's
|
|
6
|
+
// environment — so an entry that lists too little leaves a job unable to work, one that
|
|
7
|
+
// lists too much hands out more than it should, and a `group:` sharing a name with a file
|
|
8
|
+
// in systems/ fails the engine's boot outright.
|
|
9
|
+
//
|
|
10
|
+
// WHERE THE MODEL RUNS: on the deployment, like create-job, and here the reason is sharper
|
|
11
|
+
// than context. Only a run inside the deployment can see which variables are actually SET,
|
|
12
|
+
// and the whole value of an entry is naming the real ones. A model on a laptop would guess
|
|
13
|
+
// them from the skill's documentation and be plausibly, silently wrong.
|
|
14
|
+
//
|
|
15
|
+
// WHAT COMES BACK IS NOT TRUSTED. The entry is parsed the way the engine parses it and
|
|
16
|
+
// checked before it is written — the name, the required fields, variables shaped like
|
|
17
|
+
// variables, and both directions of the group/filename collision that would stop the
|
|
18
|
+
// service booting. As with create-job, the value is the checking.
|
|
19
|
+
//
|
|
20
|
+
// It writes a file and stops. No commit, no push: the operator reads what their agent will
|
|
21
|
+
// be told it may reach.
|
|
22
|
+
|
|
23
|
+
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
24
|
+
import { resolve } from "node:path";
|
|
25
|
+
import { requireDeployment, UserError } from "./config.js";
|
|
26
|
+
import { readLocalJobs, readLocalSystems } from "./doctor.js";
|
|
27
|
+
import { parseFlags } from "./flags.js";
|
|
28
|
+
import { api, capture, gcloudArgs } from "./gcloud.js";
|
|
29
|
+
|
|
30
|
+
/** Sentinels rather than code fences: an entry's prose may itself contain fenced blocks. */
|
|
31
|
+
const BEGIN = "SYSTEM_FILE_BEGIN";
|
|
32
|
+
const END = "SYSTEM_FILE_END";
|
|
33
|
+
|
|
34
|
+
/** Every field the engine and the baseline prompt define for an entry. */
|
|
35
|
+
const REQUIRED_FIELDS = ["system", "skill", "requires", "access", "probe"];
|
|
36
|
+
|
|
37
|
+
/** The same shape src/systems.ts reads a variable name in. */
|
|
38
|
+
const VARIABLE = /^[A-Z][A-Z0-9_]*$/;
|
|
39
|
+
|
|
40
|
+
/** A system name is a filename, and the key a job's `systems:` names it by. */
|
|
41
|
+
export function validName(name) {
|
|
42
|
+
return /^[a-z0-9](?:[a-z0-9-]{0,46}[a-z0-9])?$/.test(name ?? "");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The engine's frontmatter rules (src/jobs.ts), which the register is parsed with too. */
|
|
46
|
+
export function parseFrontmatter(content) {
|
|
47
|
+
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
48
|
+
if (!match) {
|
|
49
|
+
return { meta: {}, body: content.trim() };
|
|
50
|
+
}
|
|
51
|
+
const meta = {};
|
|
52
|
+
for (const line of match[1].split("\n")) {
|
|
53
|
+
const [key, ...rest] = line.split(": ");
|
|
54
|
+
if (key && rest.length) {
|
|
55
|
+
meta[key.trim()] = rest.join(": ").replace(/^["']|["']$/g, "");
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return { meta, body: match[2].trim() };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function extractSystemFile(answer) {
|
|
62
|
+
const start = answer.indexOf(BEGIN);
|
|
63
|
+
const stop = answer.indexOf(END);
|
|
64
|
+
if (start < 0 || stop < 0 || stop < start) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const named = answer.match(/SYSTEM_NAME:\s*([^\n]+)/);
|
|
68
|
+
const notes = answer.match(/SYSTEM_NOTES:\s*([^\n]+)/);
|
|
69
|
+
return {
|
|
70
|
+
name: named?.[1].trim().replace(/\.md$/, ""),
|
|
71
|
+
file: `${answer.slice(start + BEGIN.length, stop).trim()}\n`,
|
|
72
|
+
notes: notes?.[1].trim(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const list = (value) =>
|
|
77
|
+
(value ?? "")
|
|
78
|
+
.split(",")
|
|
79
|
+
.map((part) => part.trim())
|
|
80
|
+
.filter(Boolean);
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Everything that would make this entry grant the wrong things, or stop the service
|
|
84
|
+
* booting. `register` is the entries already on disk; `configured` is the variable names
|
|
85
|
+
* actually set on the deployment, when they could be read.
|
|
86
|
+
*
|
|
87
|
+
* Returns human problems. A `__note__` prefix marks one that is worth saying but not worth
|
|
88
|
+
* refusing over — writing the entry before minting the credential is a normal order to
|
|
89
|
+
* work in.
|
|
90
|
+
*/
|
|
91
|
+
export function validateSystem({ name, file, register = [], configured }) {
|
|
92
|
+
const problems = [];
|
|
93
|
+
const names = new Set(register.map((entry) => entry.name));
|
|
94
|
+
const groups = new Set(register.flatMap((entry) => entry.groups));
|
|
95
|
+
|
|
96
|
+
if (!validName(name)) {
|
|
97
|
+
problems.push(
|
|
98
|
+
`"${name}" is not usable as a system name. It becomes systems/<name>.md and the key a job's systems: names it by: lower-case letters, digits and hyphens.`,
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
if (names.has(name)) {
|
|
102
|
+
problems.push(`systems/${name}.md already exists. Pass --name to call this one something else.`);
|
|
103
|
+
}
|
|
104
|
+
// Both directions of the collision the engine refuses to boot on.
|
|
105
|
+
if (groups.has(name)) {
|
|
106
|
+
problems.push(
|
|
107
|
+
`"${name}" is already a group: on another entry. A job naming it could mean either, so the engine refuses to boot — rename this entry, or the group.`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { meta, body } = parseFrontmatter(file);
|
|
112
|
+
if (!file.startsWith("---\n")) {
|
|
113
|
+
problems.push("No frontmatter block — the engine would find no variables here at all.");
|
|
114
|
+
}
|
|
115
|
+
for (const field of REQUIRED_FIELDS) {
|
|
116
|
+
if (!meta[field]) {
|
|
117
|
+
problems.push(`No ${field}: line. Every entry carries ${REQUIRED_FIELDS.join(", ")}.`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
for (const group of list(meta.group)) {
|
|
121
|
+
if (names.has(group)) {
|
|
122
|
+
problems.push(
|
|
123
|
+
`group: ${group} is also the name of systems/${group}.md. A job naming it could mean either, so the engine refuses to boot.`,
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// `requires: none — <how it is reached instead>` is a real and common entry: a system the
|
|
129
|
+
// runtime service account is invited on carries no variable at all.
|
|
130
|
+
const declared = [...list(meta.requires), ...list(meta.selectors)];
|
|
131
|
+
const claimsNone = /^none\b/i.test(meta.requires ?? "");
|
|
132
|
+
const variables = declared.map((segment) => segment.match(/^([A-Z][A-Z0-9_]*)(?=$|[\s(])/)?.[1]).filter(Boolean);
|
|
133
|
+
const prose = (claimsNone ? list(meta.selectors) : declared).filter(
|
|
134
|
+
(segment) => !VARIABLE.test(segment.split(/[\s(]/)[0]),
|
|
135
|
+
);
|
|
136
|
+
if (!claimsNone && variables.length === 0) {
|
|
137
|
+
problems.push(
|
|
138
|
+
`requires: names no variable the engine can read. Use exact names (HUBSPOT_TOKEN), or "none — <how it is reached instead>" for a system that needs no credential.`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (claimsNone && variables.length === 0 && !/—|--|:/.test(meta.requires ?? "")) {
|
|
142
|
+
problems.push("__note__requires: none — say how it IS reached, or a run has nowhere to go when it needs to.");
|
|
143
|
+
}
|
|
144
|
+
// `requires: none — domain-wide delegation, keyless on Cloud Run` is one sentence that
|
|
145
|
+
// happens to contain a comma. Once the field opens with `none` the whole line is prose,
|
|
146
|
+
// and picking through its clauses for variable names finds only phrases.
|
|
147
|
+
if (!claimsNone) {
|
|
148
|
+
for (const segment of prose) {
|
|
149
|
+
problems.push(
|
|
150
|
+
`__note__"${segment}" in requires:/selectors: is not shaped like a variable name, so the engine will not pass it to a scoped run.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
if (configured) {
|
|
155
|
+
const missing = variables.filter((variable) => !configured.has(variable));
|
|
156
|
+
if (missing.length) {
|
|
157
|
+
problems.push(
|
|
158
|
+
`__note__Not set on this deployment yet: ${missing.join(", ")}. A job scoped to this system gets nothing for them — meffecta-agent set-secret <NAME>.`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (body.length < 20) {
|
|
163
|
+
problems.push(
|
|
164
|
+
"__note__No prose under the frontmatter. That half is what tells a run which portal, which database, and what not to touch.",
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
return problems;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function buildPrompt(description, { existing, groups }) {
|
|
171
|
+
return [
|
|
172
|
+
"You are writing ONE systems/ register entry for the Meffecta Agent deployment you are running in.",
|
|
173
|
+
"",
|
|
174
|
+
"The register is not documentation. A job's `systems:` frontmatter names entries here, and",
|
|
175
|
+
"ONLY those entries' `requires` and `selectors` variables are put in that run's environment.",
|
|
176
|
+
"An entry naming too few leaves a job unable to work; one naming too many hands out more",
|
|
177
|
+
"than it should. Write the exact set.",
|
|
178
|
+
"",
|
|
179
|
+
"Read first:",
|
|
180
|
+
" - systems/*.md in your working directory — the house style, and what already exists",
|
|
181
|
+
" - the skill that reaches this system, for what KIND of credential it needs (the skills",
|
|
182
|
+
" are shared across deployments and deliberately name no deployment's variables)",
|
|
183
|
+
" - worlds/ if present, for the per-project selectors this system might need",
|
|
184
|
+
"",
|
|
185
|
+
"The operator asked for an entry for:",
|
|
186
|
+
` ${JSON.stringify(description)}`,
|
|
187
|
+
"",
|
|
188
|
+
"Rules:",
|
|
189
|
+
" - Find the variable NAMES that are actually set in your environment for this system",
|
|
190
|
+
" (`printenv | cut -d= -f1 | grep -i <something>` lists names only). Never print, quote",
|
|
191
|
+
" or write a variable's VALUE anywhere — not in the file, not in your answer.",
|
|
192
|
+
" - requires: the exact names that must all be set. If the system authenticates as the",
|
|
193
|
+
' runtime service account and needs no variable, write "none — <how it is reached>".',
|
|
194
|
+
" - selectors: optional per-project names (one per world), where an unset one means that",
|
|
195
|
+
" project is not wired up rather than that the system is broken.",
|
|
196
|
+
" - skill: the skill that reaches it, or `none` and say how it is reached instead.",
|
|
197
|
+
" - access: what a JOB may do with it, written honestly — read-only, may send mail, may",
|
|
198
|
+
" change DNS.",
|
|
199
|
+
" - probe: the cheapest read that proves the credential works, with NO side effect —",
|
|
200
|
+
" list domains rather than send, read quota rather than generate.",
|
|
201
|
+
" - group: optional labels a job can name instead of listing entries. Do not reuse a name",
|
|
202
|
+
" that is already an entry filename; the engine refuses to boot on that ambiguity.",
|
|
203
|
+
" - Do not invent variables. If this deployment has no credential for the system, say so",
|
|
204
|
+
" in SYSTEM_NOTES and write the entry with the names it WOULD need.",
|
|
205
|
+
" - The prose under the frontmatter is what only this deployment knows: which portal,",
|
|
206
|
+
" which account, what the naming means, what not to touch.",
|
|
207
|
+
existing.length ? ` - Entry names already taken: ${existing.join(", ")}` : "",
|
|
208
|
+
groups.length ? ` - Group names already in use: ${groups.join(", ")}` : "",
|
|
209
|
+
"",
|
|
210
|
+
"Answer in EXACTLY this shape and nothing else — no preamble, no code fences:",
|
|
211
|
+
"",
|
|
212
|
+
"SYSTEM_NAME: <short-kebab-case-name>",
|
|
213
|
+
`${BEGIN}`,
|
|
214
|
+
"---",
|
|
215
|
+
"<frontmatter lines>",
|
|
216
|
+
"---",
|
|
217
|
+
"<the prose>",
|
|
218
|
+
`${END}`,
|
|
219
|
+
"SYSTEM_NOTES: <one line: anything the operator must set up or decide, or 'none'>",
|
|
220
|
+
]
|
|
221
|
+
.filter(Boolean)
|
|
222
|
+
.join("\n");
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Variable NAMES set on the deployment — never a value, which this has no reason to read
|
|
227
|
+
* and no way to print. Undefined when gcloud cannot answer: that costs one unsaid note, not
|
|
228
|
+
* the check, so it is never worth failing the command over.
|
|
229
|
+
*/
|
|
230
|
+
function configuredVariables(d) {
|
|
231
|
+
try {
|
|
232
|
+
const svc = JSON.parse(
|
|
233
|
+
capture(
|
|
234
|
+
"gcloud",
|
|
235
|
+
gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
|
|
236
|
+
),
|
|
237
|
+
);
|
|
238
|
+
return new Set((svc.spec.template.spec.containers[0].env ?? []).map((e) => e.name));
|
|
239
|
+
} catch {
|
|
240
|
+
return undefined;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function createSystem(args) {
|
|
245
|
+
const { flags, positional, help } = parseFlags(
|
|
246
|
+
args,
|
|
247
|
+
{ name: {}, model: {}, "dry-run": { type: "boolean" } },
|
|
248
|
+
{ usage: 'meffecta-agent create-system "our HubSpot CRM" [--name <file>] [--dry-run]' },
|
|
249
|
+
);
|
|
250
|
+
if (help) {
|
|
251
|
+
console.log(help);
|
|
252
|
+
return 0;
|
|
253
|
+
}
|
|
254
|
+
const description = positional.join(" ").trim();
|
|
255
|
+
if (!description) {
|
|
256
|
+
throw new UserError(
|
|
257
|
+
'Which system? e.g.\n\n meffecta-agent create-system "our HubSpot CRM"\n meffecta-agent create-system "the Postgres read replica for the product database"',
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
const d = requireDeployment();
|
|
261
|
+
const systemsDir = resolve(process.cwd(), "systems");
|
|
262
|
+
const register = readLocalSystems(systemsDir) ?? [];
|
|
263
|
+
const existing = register.map((entry) => entry.name);
|
|
264
|
+
const groups = [...new Set(register.flatMap((entry) => entry.groups))];
|
|
265
|
+
|
|
266
|
+
console.error(
|
|
267
|
+
`Asking ${d.SERVICE} to write it — only it can see which variables are actually set. This takes a minute.`,
|
|
268
|
+
);
|
|
269
|
+
const params = new URLSearchParams({ prompt: buildPrompt(description, { existing, groups }) });
|
|
270
|
+
if (flags.model) {
|
|
271
|
+
params.set("model", String(flags.model));
|
|
272
|
+
}
|
|
273
|
+
const answer = await api(d, `/test?${params}`, { accept: "text/markdown" });
|
|
274
|
+
|
|
275
|
+
const extracted = extractSystemFile(answer);
|
|
276
|
+
if (!extracted?.file) {
|
|
277
|
+
throw new UserError(
|
|
278
|
+
`The deployment did not answer in the expected shape, so nothing was written.\n\n${answer.slice(0, 600)}`,
|
|
279
|
+
);
|
|
280
|
+
}
|
|
281
|
+
const name = String(flags.name ?? extracted.name ?? "").trim();
|
|
282
|
+
const problems = validateSystem({ name, file: extracted.file, register, configured: configuredVariables(d) });
|
|
283
|
+
const notes = problems.filter((p) => p.startsWith("__note__")).map((p) => p.slice("__note__".length));
|
|
284
|
+
const errors = problems.filter((p) => !p.startsWith("__note__"));
|
|
285
|
+
|
|
286
|
+
console.log("");
|
|
287
|
+
console.log(`── systems/${name || "?"}.md ${"─".repeat(Math.max(0, 57 - name.length))}`);
|
|
288
|
+
console.log(extracted.file.trimEnd());
|
|
289
|
+
console.log("─".repeat(72));
|
|
290
|
+
|
|
291
|
+
if (extracted.notes && extracted.notes.toLowerCase() !== "none") {
|
|
292
|
+
console.log(`\nIt says: ${extracted.notes}`);
|
|
293
|
+
}
|
|
294
|
+
for (const note of notes) {
|
|
295
|
+
console.log(`\nℹ ${note}`);
|
|
296
|
+
}
|
|
297
|
+
if (errors.length) {
|
|
298
|
+
console.log("");
|
|
299
|
+
for (const problem of errors) {
|
|
300
|
+
console.log(`✖ ${problem}`);
|
|
301
|
+
}
|
|
302
|
+
throw new UserError("\nNot written. Re-run to try again, or fix it by hand from the text above.");
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
if (flags["dry-run"]) {
|
|
306
|
+
console.log("\n(dry run — nothing written)");
|
|
307
|
+
return 0;
|
|
308
|
+
}
|
|
309
|
+
mkdirSync(systemsDir, { recursive: true });
|
|
310
|
+
writeFileSync(resolve(systemsDir, `${name}.md`), extracted.file);
|
|
311
|
+
console.log(`\n✔ Written to systems/${name}.md — read it before you commit it. It decides what a job may reach.`);
|
|
312
|
+
console.log("");
|
|
313
|
+
console.log(" git add systems/ && git commit && git push live on the next run");
|
|
314
|
+
console.log(` meffecta-agent doctor checks the register against what is set`);
|
|
315
|
+
console.log(` systems: ${name}${" ".repeat(Math.max(1, 36 - name.length))}name it in a job to scope that job to it`);
|
|
316
|
+
return 0;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* `check-systems` — the same validation, on the entries that already exist.
|
|
321
|
+
*
|
|
322
|
+
* `create-system` writes a file and stops so the operator reads it first, which means the
|
|
323
|
+
* next thing that happens is a hand-edit. And the register only became load-bearing
|
|
324
|
+
* recently: every entry written while it was documentation has never been checked at all,
|
|
325
|
+
* on a deployment where a job's `systems:` now decides what reaches the run.
|
|
326
|
+
*
|
|
327
|
+
* It also checks the other direction — every job's `systems:` against the register — because
|
|
328
|
+
* this is the command that has both loaded. Those are the two failures that do not announce
|
|
329
|
+
* themselves: a group named like a file stops the service booting, and a name matching
|
|
330
|
+
* nothing leaves a job running with no credentials and reporting every system unavailable.
|
|
331
|
+
*
|
|
332
|
+
* No gcloud, no deployment: this runs on the files in front of you.
|
|
333
|
+
*/
|
|
334
|
+
export async function checkSystems() {
|
|
335
|
+
const systemsDir = resolve(process.cwd(), "systems");
|
|
336
|
+
const register = readLocalSystems(systemsDir);
|
|
337
|
+
if (!register) {
|
|
338
|
+
throw new UserError(`No systems/ directory in ${process.cwd()}. Run this from your content repo.`);
|
|
339
|
+
}
|
|
340
|
+
if (register.length === 0) {
|
|
341
|
+
console.log('No register entries yet. Write one: meffecta-agent create-system "..."');
|
|
342
|
+
return 0;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
let failed = 0;
|
|
346
|
+
for (const entry of [...register].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
347
|
+
const problems = validateSystem({
|
|
348
|
+
name: entry.name,
|
|
349
|
+
file: readFileSync(resolve(systemsDir, `${entry.name}.md`), "utf8"),
|
|
350
|
+
// Excluding itself, or every entry collides with its own filename.
|
|
351
|
+
register: register.filter((other) => other.name !== entry.name),
|
|
352
|
+
});
|
|
353
|
+
const errors = problems.filter((p) => !p.startsWith("__note__"));
|
|
354
|
+
const notes = problems.filter((p) => p.startsWith("__note__"));
|
|
355
|
+
if (errors.length === 0 && notes.length === 0) {
|
|
356
|
+
console.log(`✔ ${entry.name}`);
|
|
357
|
+
continue;
|
|
358
|
+
}
|
|
359
|
+
console.log(`${errors.length ? "✖" : "·"} ${entry.name}`);
|
|
360
|
+
for (const problem of [...errors, ...notes.map((n) => n.slice("__note__".length))]) {
|
|
361
|
+
console.log(` ${problem}`);
|
|
362
|
+
}
|
|
363
|
+
failed += errors.length ? 1 : 0;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// The other direction: what the jobs ask for.
|
|
367
|
+
const names = new Set(register.map((entry) => entry.name));
|
|
368
|
+
const groups = new Set(register.flatMap((entry) => entry.groups));
|
|
369
|
+
const jobs = readLocalJobs(resolve(process.cwd(), "jobs")) ?? [];
|
|
370
|
+
const scoped = jobs.filter((job) => job.systems && job.systems !== "*");
|
|
371
|
+
const unresolved = scoped
|
|
372
|
+
.map((job) => ({
|
|
373
|
+
job: job.name,
|
|
374
|
+
missing: list(job.systems).filter((n) => !names.has(n) && !groups.has(n)),
|
|
375
|
+
}))
|
|
376
|
+
.filter((entry) => entry.missing.length);
|
|
377
|
+
console.log("");
|
|
378
|
+
if (unresolved.length) {
|
|
379
|
+
for (const { job, missing } of unresolved) {
|
|
380
|
+
console.log(`✖ jobs/${job}.md is scoped to ${missing.join(", ")} — no entry and no group of that name`);
|
|
381
|
+
}
|
|
382
|
+
console.log(" Such a job runs with the credentials of the systems it DID name, and reports the rest");
|
|
383
|
+
console.log(" unavailable. It looks healthy while doing nothing.");
|
|
384
|
+
console.log("");
|
|
385
|
+
failed += unresolved.length;
|
|
386
|
+
} else if (scoped.length) {
|
|
387
|
+
console.log(`${scoped.length} of ${jobs.length} job(s) scoped, all naming systems that exist.`);
|
|
388
|
+
} else if (jobs.length) {
|
|
389
|
+
console.log(`No job declares systems:, so every run is given every credential this deployment holds.`);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
console.log(
|
|
393
|
+
failed
|
|
394
|
+
? `${failed} problem(s). A scoped run gets exactly what its entries declare, so these decide what works.`
|
|
395
|
+
: `${register.length} register ${register.length === 1 ? "entry" : "entries"} valid. \`meffecta-agent doctor\` also checks them against what is actually set.`,
|
|
396
|
+
);
|
|
397
|
+
return failed ? 1 : 0;
|
|
398
|
+
}
|
package/lib/doctor.js
CHANGED
|
@@ -347,7 +347,7 @@ export async function doctor() {
|
|
|
347
347
|
if (badAuth.length) {
|
|
348
348
|
r.fail(
|
|
349
349
|
"An inbox job has a requireAuth: it cannot honor",
|
|
350
|
-
`${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — AgentMail
|
|
350
|
+
`${badAuth.map((j) => `${j.name}: ${j.requireAuth}`).join("; ")}. Known values are dmarc and dkim, and only on a gmail: inbox — neither AgentMail nor Graph reports a per-message verdict this engine can check. Such a job refuses every message.`,
|
|
351
351
|
"Fix the value, or remove the line",
|
|
352
352
|
);
|
|
353
353
|
} else if (unauthenticated.length) {
|
|
@@ -357,6 +357,15 @@ export async function doctor() {
|
|
|
357
357
|
"Add requireAuth: dmarc to the job",
|
|
358
358
|
);
|
|
359
359
|
}
|
|
360
|
+
// Not a fix the operator can apply in the job file, so it is stated once rather than
|
|
361
|
+
// nagged about: on these, allowFrom: is the whole of the protection.
|
|
362
|
+
const unverifiable = inboxJobs.filter((j) => !j.inbox.startsWith("gmail:"));
|
|
363
|
+
if (unverifiable.length) {
|
|
364
|
+
r.ok(
|
|
365
|
+
`${unverifiable.length} inbox job(s) rely on allowFrom: alone`,
|
|
366
|
+
`${unverifiable.map((j) => `${j.name} (${j.inbox.split(":")[0]})`).join(", ")} — no per-message verdict to check on these systems, so the sender list is the whole of the protection`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
360
369
|
|
|
361
370
|
// --- what each job may reach ------------------------------------------------
|
|
362
371
|
const register = readLocalSystems(`${process.cwd()}/systems`);
|
package/lib/flags.js
CHANGED
|
@@ -11,11 +11,19 @@ import { UserError } from "./config.js";
|
|
|
11
11
|
*
|
|
12
12
|
* Supports `--name value`, `--name=value`, boolean flags, and `--` to end flag parsing.
|
|
13
13
|
*/
|
|
14
|
-
export function parseFlags(argv, spec = {}) {
|
|
14
|
+
export function parseFlags(argv, spec = {}, { usage } = {}) {
|
|
15
15
|
const flags = {};
|
|
16
16
|
const positional = [];
|
|
17
17
|
let onlyPositional = false;
|
|
18
18
|
|
|
19
|
+
// `help` is answered here rather than by each command, because the spec is the only
|
|
20
|
+
// description of a command's options that exists — and the top-level help tells people
|
|
21
|
+
// this works. It comes back as a flag rather than printing and exiting, so the caller
|
|
22
|
+
// still owns its own exit code and this stays testable.
|
|
23
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
24
|
+
return { flags, positional, help: describeFlags(spec, usage) };
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
for (let i = 0; i < argv.length; i += 1) {
|
|
20
28
|
const token = argv[i];
|
|
21
29
|
|
|
@@ -78,5 +86,36 @@ export function parseFlags(argv, spec = {}) {
|
|
|
78
86
|
flags[name] = raw;
|
|
79
87
|
}
|
|
80
88
|
|
|
81
|
-
return { flags, positional };
|
|
89
|
+
return { flags, positional, help: undefined };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What a command takes, from the only place that knows: its own spec. */
|
|
93
|
+
export function describeFlags(spec, usage) {
|
|
94
|
+
const lines = usage ? [usage, ""] : [];
|
|
95
|
+
const names = Object.keys(spec);
|
|
96
|
+
if (names.length === 0) {
|
|
97
|
+
return [...lines, "This command takes no options."].join("\n");
|
|
98
|
+
}
|
|
99
|
+
const shown = names.map((name) => {
|
|
100
|
+
const definition = spec[name];
|
|
101
|
+
if (definition.type === "boolean") {
|
|
102
|
+
return [`--${name}`, ""];
|
|
103
|
+
}
|
|
104
|
+
if (definition.type === "int") {
|
|
105
|
+
const bounds = [
|
|
106
|
+
definition.min !== undefined && `min ${definition.min}`,
|
|
107
|
+
definition.max !== undefined && `max ${definition.max}`,
|
|
108
|
+
]
|
|
109
|
+
.filter(Boolean)
|
|
110
|
+
.join(", ");
|
|
111
|
+
return [`--${name} <n>`, bounds];
|
|
112
|
+
}
|
|
113
|
+
return [`--${name} <value>`, definition.choices ? `one of: ${definition.choices.join(", ")}` : ""];
|
|
114
|
+
});
|
|
115
|
+
const width = Math.max(...shown.map(([flag]) => flag.length));
|
|
116
|
+
return [
|
|
117
|
+
...lines,
|
|
118
|
+
"Options:",
|
|
119
|
+
...shown.map(([flag, note]) => ` ${flag.padEnd(width)}${note ? ` ${note}` : ""}`),
|
|
120
|
+
].join("\n");
|
|
82
121
|
}
|
package/lib/history.js
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
import { requireDeployment, UserError } from "./config.js";
|
|
2
|
+
import { parseFlags } from "./flags.js";
|
|
3
|
+
import { capture, gcloudArgs, probe, requireCommand } from "./gcloud.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* What the deployment has actually been doing.
|
|
7
|
+
*
|
|
8
|
+
* Every other read this CLI offers is present-tense — `jobs` is the boot-time registration
|
|
9
|
+
* list, `queue` is what is pending, `status` is right now. The engine records the past
|
|
10
|
+
* faithfully, one `job-end` object per run in the audit bucket with the outcome, the
|
|
11
|
+
* duration and what the model cost, and until this there was no way to read it back short
|
|
12
|
+
* of composing a storage query by hand.
|
|
13
|
+
*
|
|
14
|
+
* **It reads the bucket, not the service.** That is deliberate on a scale-to-zero
|
|
15
|
+
* deployment: asking the engine would wake an instance and bill for it, and the records are
|
|
16
|
+
* not in the engine anyway. Looking at a year of history should not cost a cold start.
|
|
17
|
+
*
|
|
18
|
+
* The object names are what make this cheap without an index:
|
|
19
|
+
* `YYYY/MM/DD/HH-MM-SS.mmm_job-end_<job>.json` sorts lexicographically into chronological
|
|
20
|
+
* order and carries the job in the name, so the listing narrows by day and by job, and only
|
|
21
|
+
* the handful of records actually being shown are fetched.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** One run, as `job-end` records it. */
|
|
25
|
+
const RECORD_KIND = "job-end";
|
|
26
|
+
const DAY_MS = 86_400_000;
|
|
27
|
+
|
|
28
|
+
/** A year, so a mistyped date cannot turn into a listing of the whole bucket. */
|
|
29
|
+
const MAX_DAYS = 366;
|
|
30
|
+
|
|
31
|
+
function utcMidnight(date) {
|
|
32
|
+
return Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate());
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseDay(value, flag) {
|
|
36
|
+
const at = Date.parse(`${value.trim()}T00:00:00Z`);
|
|
37
|
+
if (Number.isNaN(at)) {
|
|
38
|
+
throw new UserError(`${flag} takes a date like 2026-08-01 (got "${value}").`);
|
|
39
|
+
}
|
|
40
|
+
return at;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The window to look in, as UTC midnights. `--since` is where to start — a date, or `7d` /
|
|
45
|
+
* `36h` counted back from the end — and `--until` is where to stop, so a closed range like
|
|
46
|
+
* `--since 2026-08-01 --until 2026-08-07` asks about that week and nothing outside it.
|
|
47
|
+
*
|
|
48
|
+
* Both ends are inclusive whole days, because the audit trail is partitioned by day and a
|
|
49
|
+
* half-day window would be a promise the listing cannot keep.
|
|
50
|
+
*/
|
|
51
|
+
export function resolveWindow({ since, until } = {}, now = new Date()) {
|
|
52
|
+
const end = until ? parseDay(until, "--until") : utcMidnight(now);
|
|
53
|
+
let start;
|
|
54
|
+
if (!since) {
|
|
55
|
+
start = end - 6 * DAY_MS;
|
|
56
|
+
} else {
|
|
57
|
+
const relative = /^(\d+)([dh])$/.exec(since.trim());
|
|
58
|
+
if (relative) {
|
|
59
|
+
const count = Number(relative[1]);
|
|
60
|
+
const days = relative[2] === "d" ? Math.max(count, 1) : Math.max(Math.ceil(count / 24), 1);
|
|
61
|
+
start = end - (days - 1) * DAY_MS;
|
|
62
|
+
} else {
|
|
63
|
+
start = parseDay(since, "--since");
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (start > end) {
|
|
67
|
+
throw new UserError(
|
|
68
|
+
until
|
|
69
|
+
? `--since ${since} is after --until ${until}.`
|
|
70
|
+
: `--since ${since} is in the future — there is no history there yet.`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
const days = (end - start) / DAY_MS + 1;
|
|
74
|
+
if (days > MAX_DAYS) {
|
|
75
|
+
throw new UserError(`That is ${days} days. Ask for at most ${MAX_DAYS} at a time.`);
|
|
76
|
+
}
|
|
77
|
+
return { start, end, days };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The UTC day prefixes a window covers, oldest first — the audit trail's own partitioning. */
|
|
81
|
+
export function dayPrefixes(window) {
|
|
82
|
+
const prefixes = [];
|
|
83
|
+
for (let at = window.start; at <= window.end; at += DAY_MS) {
|
|
84
|
+
const day = new Date(at);
|
|
85
|
+
prefixes.push(
|
|
86
|
+
`${day.getUTCFullYear()}/${String(day.getUTCMonth() + 1).padStart(2, "0")}/${String(day.getUTCDate()).padStart(2, "0")}`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
return prefixes;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A job name as the audit trail spells it: `recordAudit` replaces everything outside
|
|
94
|
+
* `[A-Za-z0-9._-]` with `-`, so a filter has to ask the same question the writer answered.
|
|
95
|
+
*/
|
|
96
|
+
export function qualifier(job) {
|
|
97
|
+
return job.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Where this deployment writes its audit trail, read off the service. */
|
|
101
|
+
function auditBucket(d) {
|
|
102
|
+
const svc = JSON.parse(
|
|
103
|
+
capture(
|
|
104
|
+
"gcloud",
|
|
105
|
+
gcloudArgs(d, ["run", "services", "describe", d.SERVICE, `--region=${d.REGION}`, "--format=json"]),
|
|
106
|
+
),
|
|
107
|
+
);
|
|
108
|
+
const bucket = (svc.spec?.template?.spec?.containers?.[0]?.env ?? []).find((e) => e.name === "AUDIT_BUCKET")?.value;
|
|
109
|
+
if (!bucket) {
|
|
110
|
+
throw new UserError(
|
|
111
|
+
"This deployment has no AUDIT_BUCKET set, so nothing records what its runs did.\n\n" +
|
|
112
|
+
" meffecta-agent setup-infra\n\nprovisions the bucket and sets it.",
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
return bucket;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** When the run ended, in the reader's own timezone. The record carries its own UTC stamp. */
|
|
119
|
+
export function localTime(record) {
|
|
120
|
+
const at = record.at ? new Date(record.at) : undefined;
|
|
121
|
+
return at && !Number.isNaN(at.getTime())
|
|
122
|
+
? at.toLocaleString(undefined, { dateStyle: "short", timeStyle: "short" })
|
|
123
|
+
: "—";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function formatDuration(ms) {
|
|
127
|
+
if (!Number.isFinite(ms)) {
|
|
128
|
+
return "—";
|
|
129
|
+
}
|
|
130
|
+
const seconds = Math.round(ms / 1000);
|
|
131
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${String(seconds % 60).padStart(2, "0")}s`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function formatCost(record) {
|
|
135
|
+
const usd = record.cli?.total_cost_usd;
|
|
136
|
+
return typeof usd === "number" ? `$${usd.toFixed(2)}` : "—";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function describeTrigger(trigger) {
|
|
140
|
+
if (!trigger) {
|
|
141
|
+
return "—";
|
|
142
|
+
}
|
|
143
|
+
if (trigger.kind === "spawn") {
|
|
144
|
+
return `spawn:${trigger.label ?? "?"}`;
|
|
145
|
+
}
|
|
146
|
+
return String(trigger.kind ?? "—");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The first eight characters of a run id — what the table shows, and what `<id>` matches. */
|
|
150
|
+
export function shortId(runId) {
|
|
151
|
+
return typeof runId === "string" ? runId.slice(0, 8) : "—";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Newest-first object names, already narrowed to what will be fetched. */
|
|
155
|
+
export function selectRecords(names, { limit, job }) {
|
|
156
|
+
const wanted = job ? `_${RECORD_KIND}_${qualifier(job)}.json` : `_${RECORD_KIND}_`;
|
|
157
|
+
return names
|
|
158
|
+
.filter((name) => name.includes(wanted))
|
|
159
|
+
.sort()
|
|
160
|
+
.slice(-limit)
|
|
161
|
+
.reverse();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Indent a block so a multi-line result reads as one field rather than as more output. */
|
|
165
|
+
function indent(text, prefix = " ") {
|
|
166
|
+
return String(text)
|
|
167
|
+
.split("\n")
|
|
168
|
+
.map((line) => prefix + line)
|
|
169
|
+
.join("\n");
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* One run in full. The table answers "what has it been doing"; this answers "what happened
|
|
174
|
+
* in that one" — what it was told, what it could reach, what it produced, how it ended.
|
|
175
|
+
*/
|
|
176
|
+
export function renderRun(record) {
|
|
177
|
+
const lines = [];
|
|
178
|
+
const status = record.status === "error" ? "FAILED" : (record.status ?? "—");
|
|
179
|
+
lines.push(`${record.jobName ?? "—"} · ${status} · ${localTime(record)}`, "");
|
|
180
|
+
|
|
181
|
+
const field = (label, value) => {
|
|
182
|
+
if (value !== undefined && value !== null && value !== "") {
|
|
183
|
+
lines.push(`${label.padEnd(11)}${value}`);
|
|
184
|
+
}
|
|
185
|
+
};
|
|
186
|
+
field("Run id", record.runId);
|
|
187
|
+
field("Trigger", describeTrigger(record.trigger));
|
|
188
|
+
field("Duration", formatDuration(record.durationMs));
|
|
189
|
+
field("Cost", formatCost(record));
|
|
190
|
+
const settings = record.jobSettings;
|
|
191
|
+
if (settings) {
|
|
192
|
+
field(
|
|
193
|
+
"Settings",
|
|
194
|
+
[
|
|
195
|
+
settings.model && `model ${settings.model}`,
|
|
196
|
+
settings.effort && `effort ${settings.effort}`,
|
|
197
|
+
settings.timeoutMs && `timeout ${Math.round(settings.timeoutMs / 1000)}s`,
|
|
198
|
+
settings.allowedTools && `tools ${settings.allowedTools}`,
|
|
199
|
+
]
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.join(", "),
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
if (record.scope) {
|
|
205
|
+
const { systems, variables, unknown } = record.scope;
|
|
206
|
+
field(
|
|
207
|
+
"Systems",
|
|
208
|
+
Array.isArray(systems)
|
|
209
|
+
? `${systems.join(", ") || "none"}${typeof variables === "number" ? ` (${variables} variables)` : ""}${unknown?.length ? ` — unmatched: ${unknown.join(", ")}` : ""}`
|
|
210
|
+
: String(systems),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
if (record.modelFallback) {
|
|
214
|
+
field("Fell back", `${record.modelFallback.from} → ${record.modelFallback.to} (${record.modelFallback.reason})`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
if (record.prompt) {
|
|
218
|
+
lines.push("", "Prompt", indent(record.prompt));
|
|
219
|
+
}
|
|
220
|
+
if (record.result) {
|
|
221
|
+
const truncated =
|
|
222
|
+
record.resultChars > record.result.length ? ` (first part of ${record.resultChars} characters)` : "";
|
|
223
|
+
lines.push("", `Result${truncated}`, indent(record.result));
|
|
224
|
+
}
|
|
225
|
+
if (record.error) {
|
|
226
|
+
lines.push("", "Error", indent(record.error));
|
|
227
|
+
}
|
|
228
|
+
if (record.stderr) {
|
|
229
|
+
lines.push("", "stderr", indent(record.stderr));
|
|
230
|
+
}
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function history(args) {
|
|
235
|
+
requireCommand("gcloud", "run records are read from the audit bucket");
|
|
236
|
+
const { flags, positional, help } = parseFlags(
|
|
237
|
+
args,
|
|
238
|
+
{
|
|
239
|
+
job: { type: "string" },
|
|
240
|
+
since: { type: "string" },
|
|
241
|
+
until: { type: "string" },
|
|
242
|
+
limit: { type: "int", min: 1, max: 200 },
|
|
243
|
+
failed: { type: "boolean" },
|
|
244
|
+
json: { type: "boolean" },
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
usage:
|
|
248
|
+
"meffecta-agent history [<run id>] [options]\n\n" +
|
|
249
|
+
" history the last week of runs\n" +
|
|
250
|
+
" history --job morning-brief --since 30d one job, further back\n" +
|
|
251
|
+
" history --since 2026-08-01 --until 2026-08-07 a closed range\n" +
|
|
252
|
+
" history --failed only the ones that broke\n" +
|
|
253
|
+
" history 8b2811f7 one run in full, by the id in column one",
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
if (help) {
|
|
257
|
+
console.log(help);
|
|
258
|
+
return 0;
|
|
259
|
+
}
|
|
260
|
+
const wantedId = positional[0];
|
|
261
|
+
const d = requireDeployment();
|
|
262
|
+
const bucket = auditBucket(d);
|
|
263
|
+
const window = resolveWindow(flags);
|
|
264
|
+
const prefixes = dayPrefixes(window);
|
|
265
|
+
const span = prefixes.length === 1 ? prefixes[0] : `${prefixes[0]} to ${prefixes.at(-1)}`;
|
|
266
|
+
// Looking for one run means reading until it is found, so the cap is a search depth rather
|
|
267
|
+
// than a page size — and a run named by id is worth looking further back for.
|
|
268
|
+
const limit = flags.limit ?? (wantedId ? 200 : 20);
|
|
269
|
+
|
|
270
|
+
// One listing per day rather than one recursive listing of the bucket: a year of audit
|
|
271
|
+
// records is a large object count, and the window is almost always a few days of it. A
|
|
272
|
+
// day with no runs simply is not there, which `probe` reports as nothing, not a failure.
|
|
273
|
+
const listings = await Promise.all(
|
|
274
|
+
prefixes.map((prefix) => probe("gcloud", gcloudArgs(d, ["storage", "ls", `gs://${bucket}/${prefix}/**`]))),
|
|
275
|
+
);
|
|
276
|
+
const names = listings
|
|
277
|
+
.flatMap((out) => (out ?? "").split("\n"))
|
|
278
|
+
.map((line) => line.trim())
|
|
279
|
+
.filter((line) => line.startsWith("gs://"));
|
|
280
|
+
|
|
281
|
+
const chosen = selectRecords(names, { limit, job: flags.job });
|
|
282
|
+
if (chosen.length === 0) {
|
|
283
|
+
console.log(
|
|
284
|
+
`No runs recorded ${span}${flags.job ? ` for ${flags.job}` : ""}.\n\n` +
|
|
285
|
+
"The audit trail starts when a run ends, so a deployment that has not run yet is empty.\n" +
|
|
286
|
+
"Widen the window with --since 30d, or ask for a range: --since 2026-08-01 --until 2026-08-07.",
|
|
287
|
+
);
|
|
288
|
+
return 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Fetched only for the records being shown — the listing above is names, the cheap half,
|
|
292
|
+
// and this is the one that costs a read per run.
|
|
293
|
+
const records = [];
|
|
294
|
+
for (const name of chosen) {
|
|
295
|
+
const body = await probe("gcloud", gcloudArgs(d, ["storage", "cat", name]));
|
|
296
|
+
if (!body) {
|
|
297
|
+
continue;
|
|
298
|
+
}
|
|
299
|
+
try {
|
|
300
|
+
const record = JSON.parse(body);
|
|
301
|
+
records.push(record);
|
|
302
|
+
// Newest first, so the wanted run is usually in the first few objects. Stopping there
|
|
303
|
+
// beats fetching the rest of the window to throw it away.
|
|
304
|
+
if (wantedId && String(record.runId ?? "").startsWith(wantedId)) {
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
} catch {
|
|
308
|
+
// A truncated or half-written object is one run missing from a listing, not a reason
|
|
309
|
+
// to fail the whole read.
|
|
310
|
+
console.error(`(skipped an unreadable record: ${name.split("/").pop()})`);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
if (wantedId) {
|
|
315
|
+
const match = records.find((r) => String(r.runId ?? "").startsWith(wantedId));
|
|
316
|
+
if (!match) {
|
|
317
|
+
throw new UserError(
|
|
318
|
+
`No run whose id starts with "${wantedId}" ${span}.\n\n` +
|
|
319
|
+
"Ids are the first column of `meffecta-agent history`. Look further back with --since.",
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
console.log(flags.json ? JSON.stringify(match, null, 2) : renderRun(match));
|
|
323
|
+
return 0;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const rows = flags.failed ? records.filter((r) => r.status === "error") : records;
|
|
327
|
+
if (flags.json) {
|
|
328
|
+
console.log(JSON.stringify(rows, null, 2));
|
|
329
|
+
return 0;
|
|
330
|
+
}
|
|
331
|
+
if (rows.length === 0) {
|
|
332
|
+
console.log(`No failed runs ${span}.`);
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const cells = rows.map((r) => [
|
|
337
|
+
shortId(r.runId),
|
|
338
|
+
localTime(r),
|
|
339
|
+
r.jobName ?? "—",
|
|
340
|
+
describeTrigger(r.trigger),
|
|
341
|
+
r.status ?? "—",
|
|
342
|
+
formatDuration(r.durationMs),
|
|
343
|
+
formatCost(r),
|
|
344
|
+
]);
|
|
345
|
+
const widths = cells[0].map((_, i) => Math.max(...cells.map((row) => row[i].length)));
|
|
346
|
+
for (const row of cells) {
|
|
347
|
+
console.log(row.map((cell, i) => (i >= 5 ? cell.padStart(widths[i]) : cell.padEnd(widths[i]))).join(" "));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const failed = records.filter((r) => r.status === "error").length;
|
|
351
|
+
const spend = records.reduce((sum, r) => sum + (r.cli?.total_cost_usd ?? 0), 0);
|
|
352
|
+
console.log(
|
|
353
|
+
`\n${rows.length} run(s), ${span}` +
|
|
354
|
+
`${failed ? `, ${failed} failed` : ""}` +
|
|
355
|
+
`${spend > 0 ? `, $${spend.toFixed(2)} of model usage` : ""}` +
|
|
356
|
+
`${records.length === limit ? ` — newest ${limit}, raise with --limit` : ""}`,
|
|
357
|
+
);
|
|
358
|
+
console.log("One run in full: meffecta-agent history <id from the first column>");
|
|
359
|
+
return 0;
|
|
360
|
+
}
|
package/lib/integrations.js
CHANGED
|
@@ -219,8 +219,9 @@ const INTEGRATIONS = {
|
|
|
219
219
|
"",
|
|
220
220
|
"and `deploy` to register it — both fields are read when the service boots.",
|
|
221
221
|
"",
|
|
222
|
-
"The system is required: `gmail:<address>`,
|
|
223
|
-
"
|
|
222
|
+
"The system is required: `gmail:<address>`, `outlook:<address>` for a Microsoft 365",
|
|
223
|
+
"mailbox, or `agentmail:<address>` for an inbox the agent owns. A bare address is",
|
|
224
|
+
"refused rather than guessed at, because an address",
|
|
224
225
|
"polled against the wrong system finds nothing for ever and looks healthy doing it.",
|
|
225
226
|
"",
|
|
226
227
|
"allowFrom is required and enforced in code: mail from anyone not listed is dropped",
|
|
@@ -105,12 +105,20 @@ export function buildVerifyPrompt() {
|
|
|
105
105
|
*/
|
|
106
106
|
export function verifyCredentials(ask) {
|
|
107
107
|
return async (args) => {
|
|
108
|
-
const { flags, positional } = parseFlags(
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
108
|
+
const { flags, positional, help } = parseFlags(
|
|
109
|
+
args,
|
|
110
|
+
{
|
|
111
|
+
quick: { type: "boolean" },
|
|
112
|
+
model: {},
|
|
113
|
+
effort: {},
|
|
114
|
+
timeoutSeconds: { type: "int", min: 1, max: 3600 },
|
|
115
|
+
},
|
|
116
|
+
{ usage: "meffecta-agent verify-credentials [--quick]" },
|
|
117
|
+
);
|
|
118
|
+
if (help) {
|
|
119
|
+
console.log(help);
|
|
120
|
+
return 0;
|
|
121
|
+
}
|
|
114
122
|
if (positional.length) {
|
|
115
123
|
throw new UserError(`verify-credentials takes no arguments (got "${positional.join(" ")}").`);
|
|
116
124
|
}
|