@meffecta/agent 1.1.0 → 1.1.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/engine.json +2 -2
- package/lib/commands.js +7 -0
- package/lib/create-system.js +390 -0
- package/package.json +1 -1
package/engine.json
CHANGED
package/lib/commands.js
CHANGED
|
@@ -4,6 +4,7 @@ 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";
|
|
@@ -772,6 +773,7 @@ export const GROUPS = [
|
|
|
772
773
|
["triggers", "The Cloud Scheduler jobs and task queue that drive it", triggers],
|
|
773
774
|
["logs", "Recent service logs (--limit N)", logs],
|
|
774
775
|
["check-jobs", "Validate the job files here — after you have edited one by hand", checkJobs],
|
|
776
|
+
["check-systems", "Validate the register here, and every job's systems: against it", checkSystems],
|
|
775
777
|
["analytics", "What anonymous usage data is sent, and how to turn it off", analytics],
|
|
776
778
|
["resources", "Everything the set-up built in Google Cloud, and what each part is for", resources],
|
|
777
779
|
],
|
|
@@ -780,6 +782,11 @@ export const GROUPS = [
|
|
|
780
782
|
title: "Operate it",
|
|
781
783
|
commands: [
|
|
782
784
|
["create-job", 'Write a new job from a description: create-job "a report every Wednesday 2pm"', createJob],
|
|
785
|
+
[
|
|
786
|
+
"create-system",
|
|
787
|
+
'Write a systems/ register entry: create-system "our HubSpot CRM" — it finds the variables',
|
|
788
|
+
createSystem,
|
|
789
|
+
],
|
|
783
790
|
["run", "Trigger one job now, or --in <seconds>", runJob],
|
|
784
791
|
["ask", "Ask it something as a one-off run", ask],
|
|
785
792
|
["sweep", "Run the housekeeping sweep now", sweep],
|
|
@@ -0,0 +1,390 @@
|
|
|
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 } = parseFlags(args, { name: {}, model: {}, "dry-run": { type: "boolean" } });
|
|
246
|
+
const description = positional.join(" ").trim();
|
|
247
|
+
if (!description) {
|
|
248
|
+
throw new UserError(
|
|
249
|
+
'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"',
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
const d = requireDeployment();
|
|
253
|
+
const systemsDir = resolve(process.cwd(), "systems");
|
|
254
|
+
const register = readLocalSystems(systemsDir) ?? [];
|
|
255
|
+
const existing = register.map((entry) => entry.name);
|
|
256
|
+
const groups = [...new Set(register.flatMap((entry) => entry.groups))];
|
|
257
|
+
|
|
258
|
+
console.error(
|
|
259
|
+
`Asking ${d.SERVICE} to write it — only it can see which variables are actually set. This takes a minute.`,
|
|
260
|
+
);
|
|
261
|
+
const params = new URLSearchParams({ prompt: buildPrompt(description, { existing, groups }) });
|
|
262
|
+
if (flags.model) {
|
|
263
|
+
params.set("model", String(flags.model));
|
|
264
|
+
}
|
|
265
|
+
const answer = await api(d, `/test?${params}`, { accept: "text/markdown" });
|
|
266
|
+
|
|
267
|
+
const extracted = extractSystemFile(answer);
|
|
268
|
+
if (!extracted?.file) {
|
|
269
|
+
throw new UserError(
|
|
270
|
+
`The deployment did not answer in the expected shape, so nothing was written.\n\n${answer.slice(0, 600)}`,
|
|
271
|
+
);
|
|
272
|
+
}
|
|
273
|
+
const name = String(flags.name ?? extracted.name ?? "").trim();
|
|
274
|
+
const problems = validateSystem({ name, file: extracted.file, register, configured: configuredVariables(d) });
|
|
275
|
+
const notes = problems.filter((p) => p.startsWith("__note__")).map((p) => p.slice("__note__".length));
|
|
276
|
+
const errors = problems.filter((p) => !p.startsWith("__note__"));
|
|
277
|
+
|
|
278
|
+
console.log("");
|
|
279
|
+
console.log(`── systems/${name || "?"}.md ${"─".repeat(Math.max(0, 57 - name.length))}`);
|
|
280
|
+
console.log(extracted.file.trimEnd());
|
|
281
|
+
console.log("─".repeat(72));
|
|
282
|
+
|
|
283
|
+
if (extracted.notes && extracted.notes.toLowerCase() !== "none") {
|
|
284
|
+
console.log(`\nIt says: ${extracted.notes}`);
|
|
285
|
+
}
|
|
286
|
+
for (const note of notes) {
|
|
287
|
+
console.log(`\nℹ ${note}`);
|
|
288
|
+
}
|
|
289
|
+
if (errors.length) {
|
|
290
|
+
console.log("");
|
|
291
|
+
for (const problem of errors) {
|
|
292
|
+
console.log(`✖ ${problem}`);
|
|
293
|
+
}
|
|
294
|
+
throw new UserError("\nNot written. Re-run to try again, or fix it by hand from the text above.");
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (flags["dry-run"]) {
|
|
298
|
+
console.log("\n(dry run — nothing written)");
|
|
299
|
+
return 0;
|
|
300
|
+
}
|
|
301
|
+
mkdirSync(systemsDir, { recursive: true });
|
|
302
|
+
writeFileSync(resolve(systemsDir, `${name}.md`), extracted.file);
|
|
303
|
+
console.log(`\n✔ Written to systems/${name}.md — read it before you commit it. It decides what a job may reach.`);
|
|
304
|
+
console.log("");
|
|
305
|
+
console.log(" git add systems/ && git commit && git push live on the next run");
|
|
306
|
+
console.log(` meffecta-agent doctor checks the register against what is set`);
|
|
307
|
+
console.log(` systems: ${name}${" ".repeat(Math.max(1, 36 - name.length))}name it in a job to scope that job to it`);
|
|
308
|
+
return 0;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* `check-systems` — the same validation, on the entries that already exist.
|
|
313
|
+
*
|
|
314
|
+
* `create-system` writes a file and stops so the operator reads it first, which means the
|
|
315
|
+
* next thing that happens is a hand-edit. And the register only became load-bearing
|
|
316
|
+
* recently: every entry written while it was documentation has never been checked at all,
|
|
317
|
+
* on a deployment where a job's `systems:` now decides what reaches the run.
|
|
318
|
+
*
|
|
319
|
+
* It also checks the other direction — every job's `systems:` against the register — because
|
|
320
|
+
* this is the command that has both loaded. Those are the two failures that do not announce
|
|
321
|
+
* themselves: a group named like a file stops the service booting, and a name matching
|
|
322
|
+
* nothing leaves a job running with no credentials and reporting every system unavailable.
|
|
323
|
+
*
|
|
324
|
+
* No gcloud, no deployment: this runs on the files in front of you.
|
|
325
|
+
*/
|
|
326
|
+
export async function checkSystems() {
|
|
327
|
+
const systemsDir = resolve(process.cwd(), "systems");
|
|
328
|
+
const register = readLocalSystems(systemsDir);
|
|
329
|
+
if (!register) {
|
|
330
|
+
throw new UserError(`No systems/ directory in ${process.cwd()}. Run this from your content repo.`);
|
|
331
|
+
}
|
|
332
|
+
if (register.length === 0) {
|
|
333
|
+
console.log('No register entries yet. Write one: meffecta-agent create-system "..."');
|
|
334
|
+
return 0;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let failed = 0;
|
|
338
|
+
for (const entry of [...register].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
339
|
+
const problems = validateSystem({
|
|
340
|
+
name: entry.name,
|
|
341
|
+
file: readFileSync(resolve(systemsDir, `${entry.name}.md`), "utf8"),
|
|
342
|
+
// Excluding itself, or every entry collides with its own filename.
|
|
343
|
+
register: register.filter((other) => other.name !== entry.name),
|
|
344
|
+
});
|
|
345
|
+
const errors = problems.filter((p) => !p.startsWith("__note__"));
|
|
346
|
+
const notes = problems.filter((p) => p.startsWith("__note__"));
|
|
347
|
+
if (errors.length === 0 && notes.length === 0) {
|
|
348
|
+
console.log(`✔ ${entry.name}`);
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
console.log(`${errors.length ? "✖" : "·"} ${entry.name}`);
|
|
352
|
+
for (const problem of [...errors, ...notes.map((n) => n.slice("__note__".length))]) {
|
|
353
|
+
console.log(` ${problem}`);
|
|
354
|
+
}
|
|
355
|
+
failed += errors.length ? 1 : 0;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// The other direction: what the jobs ask for.
|
|
359
|
+
const names = new Set(register.map((entry) => entry.name));
|
|
360
|
+
const groups = new Set(register.flatMap((entry) => entry.groups));
|
|
361
|
+
const jobs = readLocalJobs(resolve(process.cwd(), "jobs")) ?? [];
|
|
362
|
+
const scoped = jobs.filter((job) => job.systems && job.systems !== "*");
|
|
363
|
+
const unresolved = scoped
|
|
364
|
+
.map((job) => ({
|
|
365
|
+
job: job.name,
|
|
366
|
+
missing: list(job.systems).filter((n) => !names.has(n) && !groups.has(n)),
|
|
367
|
+
}))
|
|
368
|
+
.filter((entry) => entry.missing.length);
|
|
369
|
+
console.log("");
|
|
370
|
+
if (unresolved.length) {
|
|
371
|
+
for (const { job, missing } of unresolved) {
|
|
372
|
+
console.log(`✖ jobs/${job}.md is scoped to ${missing.join(", ")} — no entry and no group of that name`);
|
|
373
|
+
}
|
|
374
|
+
console.log(" Such a job runs with the credentials of the systems it DID name, and reports the rest");
|
|
375
|
+
console.log(" unavailable. It looks healthy while doing nothing.");
|
|
376
|
+
console.log("");
|
|
377
|
+
failed += unresolved.length;
|
|
378
|
+
} else if (scoped.length) {
|
|
379
|
+
console.log(`${scoped.length} of ${jobs.length} job(s) scoped, all naming systems that exist.`);
|
|
380
|
+
} else if (jobs.length) {
|
|
381
|
+
console.log(`No job declares systems:, so every run is given every credential this deployment holds.`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
console.log(
|
|
385
|
+
failed
|
|
386
|
+
? `${failed} problem(s). A scoped run gets exactly what its entries declare, so these decide what works.`
|
|
387
|
+
: `${register.length} register ${register.length === 1 ? "entry" : "entries"} valid. \`meffecta-agent doctor\` also checks them against what is actually set.`,
|
|
388
|
+
);
|
|
389
|
+
return failed ? 1 : 0;
|
|
390
|
+
}
|