@meffecta/agent 1.1.1 → 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 +33 -7
- package/lib/create-job.js +9 -1
- package/lib/create-system.js +9 -1
- 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
|
@@ -8,6 +8,7 @@ import { checkSystems, createSystem } from "./create-system.js";
|
|
|
8
8
|
import { runDoctor } from "./doctor.js";
|
|
9
9
|
import { parseFlags } from "./flags.js";
|
|
10
10
|
import { api, requireCommand, stream } from "./gcloud.js";
|
|
11
|
+
import { history } from "./history.js";
|
|
11
12
|
import { connect } from "./integrations.js";
|
|
12
13
|
import { resources } from "./resources.js";
|
|
13
14
|
import { verifyCredentials } from "./verify-credentials.js";
|
|
@@ -311,7 +312,15 @@ async function jobs() {
|
|
|
311
312
|
}
|
|
312
313
|
|
|
313
314
|
async function runJob(args) {
|
|
314
|
-
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
|
+
}
|
|
315
324
|
const name = positional[0];
|
|
316
325
|
if (!name) {
|
|
317
326
|
throw new UserError("Which job? Try: meffecta-agent jobs");
|
|
@@ -332,11 +341,19 @@ async function ask(args) {
|
|
|
332
341
|
// model and effort are deliberately not validated here. The engine owns that list, this
|
|
333
342
|
// CLI may be deploying an engine older or newer than itself, and /test already answers
|
|
334
343
|
// with the valid values — so the authority stays in one place and the error stays good.
|
|
335
|
-
const { flags, positional } = parseFlags(
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
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
|
+
}
|
|
340
357
|
const prompt = positional.join(" ");
|
|
341
358
|
if (!prompt) {
|
|
342
359
|
throw new UserError('Ask it what? e.g. meffecta-agent ask "how did search do last week"');
|
|
@@ -539,7 +556,15 @@ async function triggers() {
|
|
|
539
556
|
/** Recent service logs, without making anyone remember the filter syntax. */
|
|
540
557
|
async function logs(args) {
|
|
541
558
|
requireCommand("gcloud", "logs come from Cloud Logging");
|
|
542
|
-
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
|
+
}
|
|
543
568
|
const d = requireDeployment();
|
|
544
569
|
const limit = String(flags.limit ?? 40);
|
|
545
570
|
const { code } = await stream("gcloud", [
|
|
@@ -772,6 +797,7 @@ export const GROUPS = [
|
|
|
772
797
|
["secrets", "What is in Secret Manager, and whether the service reads it", secretsList],
|
|
773
798
|
["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
|
|
774
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],
|
|
775
801
|
["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
|
|
776
802
|
["check-systems", "Validate the register here, and every job's systems: against it", checkSystems],
|
|
777
803
|
["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
|
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(
|
package/lib/create-system.js
CHANGED
|
@@ -242,7 +242,15 @@ function configuredVariables(d) {
|
|
|
242
242
|
}
|
|
243
243
|
|
|
244
244
|
export async function createSystem(args) {
|
|
245
|
-
const { flags, positional } = parseFlags(
|
|
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
|
+
}
|
|
246
254
|
const description = positional.join(" ").trim();
|
|
247
255
|
if (!description) {
|
|
248
256
|
throw new UserError(
|
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
|
}
|