@meffecta/agent 1.0.9 → 1.0.11
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/README.md +0 -1
- package/bin/meffecta-agent.js +3 -48
- package/deployment.env.example +4 -15
- package/engine.json +2 -2
- package/lib/commands.js +138 -32
- package/lib/doctor.js +1 -1
- package/package.json +1 -1
- package/scripts/deploy.sh +55 -68
- package/scripts/lib/deployment.sh +4 -6
- package/scripts/set-artifact-cleanup.sh +1 -1
- package/scripts/setup-infrastructure.sh +20 -27
- package/scripts/update-tooling.sh +4 -5
- package/scripts/verify-credentials.mjs +2 -3
package/README.md
CHANGED
|
@@ -92,7 +92,6 @@ not consent. Secret values are never printed, by any command.
|
|
|
92
92
|
## Operating it
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
|
-
npx @meffecta/agent steps # the set-up, in order
|
|
96
95
|
npx @meffecta/agent help # every command — each also takes --help
|
|
97
96
|
npx @meffecta/agent doctor # the one to run when something feels wrong
|
|
98
97
|
```
|
package/bin/meffecta-agent.js
CHANGED
|
@@ -18,40 +18,14 @@ import { installMethod } from "../lib/version.js";
|
|
|
18
18
|
|
|
19
19
|
// Tools first: every command below needs gcloud, and the very first one already does. The
|
|
20
20
|
// claude CLI is the odd one out — it is used once, to mint the token, and never again.
|
|
21
|
-
const STEPS = [
|
|
22
|
-
[
|
|
23
|
-
"",
|
|
24
|
-
"Install gcloud, docker, node 24+ and git. Also the claude CLI — it is\n used once, for the token two steps down, and not after that.",
|
|
25
|
-
],
|
|
26
|
-
[
|
|
27
|
-
"create-project",
|
|
28
|
-
"A new GCP project. This and the next step are per GCP account, not\n per deployment — skip both if you are adding a second agent to a project you\n already have.",
|
|
29
|
-
],
|
|
30
|
-
["link-billing", "Attach a billing account, or nothing can run."],
|
|
31
|
-
[
|
|
32
|
-
"mint-gmail",
|
|
33
|
-
"Credentials: `claude setup-token`, a GitHub token that can clone your repos,\n and — only if this agent reads or sends mail — one token per mailbox.",
|
|
34
|
-
],
|
|
35
|
-
[
|
|
36
|
-
"init",
|
|
37
|
-
"Create your content repo — jobs/, SYSTEM.md, systems/, and a deployment.env in it.\n Run everything from there; it is what says which deployment you mean.",
|
|
38
|
-
],
|
|
39
|
-
["setup-infra", "Provision GCP, the run queue, and the Cloud Run service shell.\n --dry-run shows it first."],
|
|
40
|
-
["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN."],
|
|
41
|
-
["set-env", "GIT_REPO_URL=… — the service will not boot without it."],
|
|
42
|
-
[
|
|
43
|
-
"deploy",
|
|
44
|
-
"Roll out the engine image, then create its triggers — scaled to zero it fires\n nothing without them, and only the running engine knows which jobs exist.",
|
|
45
|
-
],
|
|
46
|
-
["doctor", 'Check it. Then: jobs, run <job>, ask "…".'],
|
|
47
|
-
];
|
|
48
21
|
|
|
49
22
|
function help() {
|
|
50
23
|
const { tag, pinned } = engineTag();
|
|
51
24
|
console.log("meffecta-agent — set up and operate a Meffecta Agent deployment\n");
|
|
52
25
|
console.log("Usage: meffecta-agent <command> [options]");
|
|
53
26
|
console.log(" npx @meffecta/agent <command> [options]\n");
|
|
54
|
-
console.log("Run it from your content repo — the directory holding deployment.env and jobs
|
|
27
|
+
console.log("Run it from your content repo — the directory holding deployment.env and jobs/.");
|
|
28
|
+
console.log("Setting one up for the first time: https://agent.meffecta.com\n");
|
|
55
29
|
for (const group of GROUPS) {
|
|
56
30
|
console.log(`${group.title}:`);
|
|
57
31
|
const width = Math.max(...group.commands.map(([name]) => name.length));
|
|
@@ -63,7 +37,6 @@ function help() {
|
|
|
63
37
|
}
|
|
64
38
|
console.log("");
|
|
65
39
|
}
|
|
66
|
-
console.log(" steps The whole set-up, in order, with the command for each");
|
|
67
40
|
console.log(" version This CLI and the engine build it deploys");
|
|
68
41
|
console.log(" upgrade Whether a newer one is out, and what to run\n");
|
|
69
42
|
console.log("Every command takes --help, and every option it documents — so");
|
|
@@ -71,20 +44,6 @@ function help() {
|
|
|
71
44
|
console.log(`Engine: ghcr.io/meffecta/agent:${tag}${pinned ? " (pinned to this release)" : ""}`);
|
|
72
45
|
}
|
|
73
46
|
|
|
74
|
-
function steps() {
|
|
75
|
-
console.log("Setting up a deployment, in order.\n");
|
|
76
|
-
STEPS.forEach(([command, note], index) => {
|
|
77
|
-
const label = `${String(index + 1).padStart(2)}.`;
|
|
78
|
-
console.log(`${label} ${note}`);
|
|
79
|
-
if (command) {
|
|
80
|
-
console.log(` meffecta-agent ${command}`);
|
|
81
|
-
}
|
|
82
|
-
console.log("");
|
|
83
|
-
});
|
|
84
|
-
console.log("Each step is checkable: `meffecta-agent doctor` after the last one should come");
|
|
85
|
-
console.log("back clean, and `meffecta-agent jobs` should list your jobs.");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
47
|
function version() {
|
|
89
48
|
const { tag, pinned } = engineTag();
|
|
90
49
|
const deployment = loadDeployment();
|
|
@@ -104,7 +63,7 @@ const { analytics, firstRun } = openAnalytics({ version: cliVersion() });
|
|
|
104
63
|
// The name is matched against the known commands before it is ever recorded. Whatever an
|
|
105
64
|
// operator actually typed is theirs — a mistyped command could be anything, including
|
|
106
65
|
// something they would not want leaving the machine.
|
|
107
|
-
const BUILT_IN = new Set(["help", "
|
|
66
|
+
const BUILT_IN = new Set(["help", "version"]);
|
|
108
67
|
function recorded(candidate) {
|
|
109
68
|
if (!candidate) {
|
|
110
69
|
return "help";
|
|
@@ -146,10 +105,6 @@ if (!name || name === "help" || name === "--help" || name === "-h") {
|
|
|
146
105
|
help();
|
|
147
106
|
await finish(0, "ok");
|
|
148
107
|
}
|
|
149
|
-
if (name === "steps") {
|
|
150
|
-
steps();
|
|
151
|
-
await finish(0, "ok");
|
|
152
|
-
}
|
|
153
108
|
if (name === "version" || name === "--version" || name === "-v") {
|
|
154
109
|
version();
|
|
155
110
|
await finish(0, "ok");
|
package/deployment.env.example
CHANGED
|
@@ -20,20 +20,12 @@ PROJECT=acme-agent-123456
|
|
|
20
20
|
# The Cloud Run service.
|
|
21
21
|
SERVICE=acme-agent
|
|
22
22
|
|
|
23
|
-
#
|
|
23
|
+
# A repository in that project standing in front of ghcr.io. Cloud Run can only pull from
|
|
24
|
+
# Artifact Registry, so this is how it reaches the engine image: AR fetches the public
|
|
25
|
+
# image itself, on demand, and caches it. Nothing is copied by hand and no docker is
|
|
26
|
+
# needed on your machine at any point.
|
|
24
27
|
ARTIFACT_REPO=acme-agent-images
|
|
25
28
|
|
|
26
|
-
# How the engine image reaches Cloud Run, which can only pull from Artifact Registry.
|
|
27
|
-
# proxy (default) ARTIFACT_REPO is a remote repository in front of ghcr.io. AR fetches
|
|
28
|
-
# the public engine image on demand — nothing is copied, and no docker is needed
|
|
29
|
-
# on your machine at any point.
|
|
30
|
-
# mirror the older path: pull from ghcr.io here, retag, push. Needs docker running and
|
|
31
|
-
# `gcloud auth configure-docker <region>-docker.pkg.dev`, and pushes several GB
|
|
32
|
-
# per engine version. Use it only if your policy forbids AR reaching the internet.
|
|
33
|
-
# A repository's mode is fixed when it is created, so changing this means a new
|
|
34
|
-
# ARTIFACT_REPO name and another setup-infrastructure run.
|
|
35
|
-
REGISTRY_MODE=proxy
|
|
36
|
-
|
|
37
29
|
REGION=europe-west1
|
|
38
30
|
|
|
39
31
|
# Optional. Unset = whichever account gcloud is logged in as. Set it when you work across
|
|
@@ -43,9 +35,6 @@ REGION=europe-west1
|
|
|
43
35
|
# Optional. The service's TIMEZONE env var, which is what cron schedules are read in.
|
|
44
36
|
# TIMEZONE=Europe/Stockholm
|
|
45
37
|
|
|
46
|
-
# Optional. Where published engine images come from.
|
|
47
|
-
# GHCR_IMAGE=ghcr.io/meffecta/agent
|
|
48
|
-
|
|
49
38
|
# Optional. The runtime shape asserted on every rollout, so the deployed service
|
|
50
39
|
# matches this file rather than whatever was last changed by hand. The defaults below are
|
|
51
40
|
# what `meffecta-agent setup-infra` provisions, so leaving them out is the same as setting
|
package/engine.json
CHANGED
package/lib/commands.js
CHANGED
|
@@ -52,8 +52,8 @@ export function engineTag() {
|
|
|
52
52
|
const { engineTag: tag, builtFrom } = JSON.parse(readFileSync(file, "utf8"));
|
|
53
53
|
// Only a release is pinned. `npm pack` run by hand writes this file into the working
|
|
54
54
|
// tree with builtFrom "local", where it would otherwise survive and pin `deploy` to a
|
|
55
|
-
// version that was never published
|
|
56
|
-
//
|
|
55
|
+
// version that was never published, where the pull fails with nothing to say why that
|
|
56
|
+
// tag was chosen.
|
|
57
57
|
if (!tag || builtFrom === "local" || existsSync(resolve(packageRoot, "..", "src", "index.ts"))) {
|
|
58
58
|
return { tag: "latest", pinned: false };
|
|
59
59
|
}
|
|
@@ -186,8 +186,35 @@ async function status() {
|
|
|
186
186
|
);
|
|
187
187
|
const external = Boolean(env.AGENT_TASKS_QUEUE && env.AGENT_PUBLIC_URL);
|
|
188
188
|
|
|
189
|
+
// What is RUNNING, asked of the service rather than inferred from the image tag — which
|
|
190
|
+
// is a moving target: `latest` names a different engine every time main is pushed, so the
|
|
191
|
+
// tag on the revision says what was requested and not what answers. /health is
|
|
192
|
+
// unauthenticated, so this needs no credential; a service that cannot be reached simply
|
|
193
|
+
// does not get the line.
|
|
194
|
+
let engine = "";
|
|
195
|
+
if (svc.status?.url) {
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch(`${svc.status.url}/health`, { signal: AbortSignal.timeout(25_000) });
|
|
198
|
+
const health = await res.json();
|
|
199
|
+
if (health?.version) {
|
|
200
|
+
engine = health.build ? `${health.version} (build ${health.build})` : health.version;
|
|
201
|
+
} else {
|
|
202
|
+
engine = "up, but too old to report its version";
|
|
203
|
+
}
|
|
204
|
+
} catch {
|
|
205
|
+
// Short timeout on purpose: this is a status command, and asking wakes a
|
|
206
|
+
// scale-to-zero service, so it waits about as long as a cold start and then says
|
|
207
|
+
// nothing rather than hanging. Not a verdict on health — `doctor` is where that is
|
|
208
|
+
// decided.
|
|
209
|
+
engine = "no answer within 25s (a sleeping service can take longer to wake)";
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
189
213
|
console.log(`▶ ${d.SERVICE} (${d.PROJECT} / ${d.REGION})`);
|
|
190
214
|
console.log(` url ${svc.status?.url ?? "?"}`);
|
|
215
|
+
if (engine) {
|
|
216
|
+
console.log(` engine ${engine}`);
|
|
217
|
+
}
|
|
191
218
|
console.log(` revision ${svc.status?.latestReadyRevisionName ?? "?"}`);
|
|
192
219
|
console.log(` image ${container.image}`);
|
|
193
220
|
console.log(
|
|
@@ -557,13 +584,65 @@ Set the values with \`meffecta-agent set-secret <NAME>\` (credentials) or
|
|
|
557
584
|
systems the engine already has skills for and walks through each.
|
|
558
585
|
`;
|
|
559
586
|
|
|
587
|
+
/**
|
|
588
|
+
* The deployment prompt, put in front of every job's own prompt. A starter rather than an
|
|
589
|
+
* empty file, because the engine's baseline already states how a run works, what a run may
|
|
590
|
+
* act on and what it may never say — so what is left is the half only this organisation can
|
|
591
|
+
* write, and a blank file gives no hint which half that is.
|
|
592
|
+
*/
|
|
593
|
+
const SYSTEM_EXAMPLE = `# Who you work for
|
|
594
|
+
|
|
595
|
+
Replace all of this. It goes in front of every job's prompt, so it is for what holds across
|
|
596
|
+
all of them and only that — rules, not facts. What the agent should KNOW about a business
|
|
597
|
+
belongs in \`worlds/\`.
|
|
598
|
+
|
|
599
|
+
You work for **Acme AB**. You report to **Alex Doe, alex@acme.com**, and your output is for
|
|
600
|
+
Alex unless the job you are running names someone else.
|
|
601
|
+
|
|
602
|
+
## How to write
|
|
603
|
+
|
|
604
|
+
- Lead with what you found. No preamble, and do not restate the task.
|
|
605
|
+
- Short. This gets read between meetings.
|
|
606
|
+
- One thing you are unsure of, said plainly, is worth more than three you are not.
|
|
607
|
+
|
|
608
|
+
## People
|
|
609
|
+
|
|
610
|
+
- Alex Doe, alex@acme.com — runs the company. Everything comes here by default.
|
|
611
|
+
`;
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* A first job that proves the whole path — clone, system prompt, register, memory — and
|
|
615
|
+
* reaches nothing real while doing it. Named to match \`systems/example.md\`: both are the
|
|
616
|
+
* shape of the thing, shown once, and deleted when a real one exists.
|
|
617
|
+
*
|
|
618
|
+
* No \`cron:\`, deliberately. A scaffolded job that started firing on a schedule the moment
|
|
619
|
+
* the service booted would be the wrong first impression of what these files do.
|
|
620
|
+
*/
|
|
621
|
+
const JOB_EXAMPLE = `---
|
|
622
|
+
model: sonnet
|
|
623
|
+
---
|
|
624
|
+
|
|
625
|
+
A first run, to prove the plumbing. It reaches no real system and changes nothing.
|
|
626
|
+
|
|
627
|
+
Report, in under ten lines:
|
|
628
|
+
|
|
629
|
+
1. Who you work for — one line, from \`SYSTEM.md\`.
|
|
630
|
+
2. What you can reach — every file in \`systems/\`, and for each one whether the variables it
|
|
631
|
+
requires are set. **Names and set/unset only, never a value.** A system whose variables
|
|
632
|
+
are missing is one you would report as unavailable.
|
|
633
|
+
3. Where your memory directory is, and whether \`MEMORY.md\` is in it yet.
|
|
634
|
+
|
|
635
|
+
Then add one dated line to your \`MEMORY.md\` saying this ran.
|
|
636
|
+
|
|
637
|
+
When you have a real job, write it in a file of its own and delete this one. The frontmatter
|
|
638
|
+
above the \`---\` is the settings; everything below it is what you would have said to a
|
|
639
|
+
colleague.
|
|
640
|
+
`;
|
|
641
|
+
|
|
560
642
|
async function init(args) {
|
|
561
|
-
const { copyFileSync, existsSync: exists, writeFileSync } = await import("node:fs");
|
|
562
|
-
const
|
|
563
|
-
|
|
564
|
-
console.log(`deployment.env already exists here. Leaving it alone (--force to replace).`);
|
|
565
|
-
return 0;
|
|
566
|
-
}
|
|
643
|
+
const { copyFileSync, existsSync: exists, mkdirSync, writeFileSync } = await import("node:fs");
|
|
644
|
+
const force = args.includes("--force");
|
|
645
|
+
|
|
567
646
|
const template = [
|
|
568
647
|
resolve(packageRoot, "deployment.env.example"),
|
|
569
648
|
resolve(packageRoot, "..", "deployment.env.example"),
|
|
@@ -571,28 +650,55 @@ async function init(args) {
|
|
|
571
650
|
if (!template) {
|
|
572
651
|
throw new UserError("The deployment.env template is missing from this install — reinstall @meffecta/agent.");
|
|
573
652
|
}
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
653
|
+
|
|
654
|
+
// Four artifacts, each decided on its own. `init` is run in a repo that already holds
|
|
655
|
+
// some of them — after an upgrade, or because a first attempt stopped halfway — and one
|
|
656
|
+
// file being present must not decide the fate of the other three.
|
|
657
|
+
const SCAFFOLD = [
|
|
658
|
+
["deployment.env", "which GCP project and service this deployment is", (to) => copyFileSync(template, to)],
|
|
659
|
+
[
|
|
660
|
+
"SYSTEM.md",
|
|
661
|
+
"who the agent works for — read at the start of every run",
|
|
662
|
+
(to) => writeFileSync(to, SYSTEM_EXAMPLE),
|
|
663
|
+
],
|
|
664
|
+
[
|
|
665
|
+
"systems/example.md",
|
|
666
|
+
"one file per system it may reach, and the variables for each",
|
|
667
|
+
(to) => writeFileSync(to, REGISTER_EXAMPLE),
|
|
668
|
+
],
|
|
669
|
+
["jobs/example.md", "one file per job: a few settings, then the prompt", (to) => writeFileSync(to, JOB_EXAMPLE)],
|
|
670
|
+
];
|
|
671
|
+
|
|
672
|
+
const wrote = [];
|
|
673
|
+
const kept = [];
|
|
674
|
+
for (const [name, purpose, produce] of SCAFFOLD) {
|
|
675
|
+
const to = resolve(process.cwd(), name);
|
|
676
|
+
if (exists(to) && !force) {
|
|
677
|
+
kept.push([name, purpose]);
|
|
678
|
+
continue;
|
|
679
|
+
}
|
|
680
|
+
mkdirSync(dirname(to), { recursive: true });
|
|
681
|
+
produce(to);
|
|
682
|
+
wrote.push([name, purpose]);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const pad = Math.max(...SCAFFOLD.map(([name]) => name.length)) + 2;
|
|
686
|
+
for (const [name, purpose] of wrote) {
|
|
687
|
+
console.log(`✅ ${name.padEnd(pad)}${purpose}`);
|
|
688
|
+
}
|
|
689
|
+
for (const [name, purpose] of kept) {
|
|
690
|
+
console.log(`• ${name.padEnd(pad)}${purpose} — already here, left alone`);
|
|
691
|
+
}
|
|
692
|
+
if (kept.length && !force) {
|
|
693
|
+
console.log("\n--force replaces the ones already here.");
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
console.log("\nFill in PROJECT and SERVICE in deployment.env — those two are never guessed —");
|
|
697
|
+
console.log("and say who the agent works for in SYSTEM.md. Both example files are the shape of");
|
|
698
|
+
console.log("the thing: rename them after something real, and delete what is left.");
|
|
699
|
+
console.log("\nThen commit all of it. None of it is secret, and a clone of this repo is");
|
|
700
|
+
console.log("everything an operator needs.");
|
|
701
|
+
console.log("\nThe set-up in order, with the command for each part: https://agent.meffecta.com");
|
|
596
702
|
return 0;
|
|
597
703
|
}
|
|
598
704
|
|
|
@@ -613,7 +719,7 @@ export const GROUPS = [
|
|
|
613
719
|
// next group so a first deployment is not a shopping list.
|
|
614
720
|
title: "Stand one up (once, in order)",
|
|
615
721
|
commands: [
|
|
616
|
-
["init", "
|
|
722
|
+
["init", "Scaffold a content repo here: deployment.env, SYSTEM.md, systems/, jobs/", init],
|
|
617
723
|
[
|
|
618
724
|
"setup-infra",
|
|
619
725
|
"Provision GCP: registry, buckets, service account, run queue, service shell",
|
|
@@ -641,7 +747,7 @@ export const GROUPS = [
|
|
|
641
747
|
script("mint-gmail-token.mjs"),
|
|
642
748
|
],
|
|
643
749
|
["mint-graph", "A Microsoft Graph refresh token, per mailbox", script("mint-graph-token.mjs")],
|
|
644
|
-
["artifact-cleanup", "Expire
|
|
750
|
+
["artifact-cleanup", "Expire cached images in the deployment's registry", script("set-artifact-cleanup.sh")],
|
|
645
751
|
],
|
|
646
752
|
note: [
|
|
647
753
|
"A new credential is `set-secret` again, plus `set-env` for whatever names the",
|
package/lib/doctor.js
CHANGED
|
@@ -439,7 +439,7 @@ export async function doctor() {
|
|
|
439
439
|
} else {
|
|
440
440
|
r.warn(
|
|
441
441
|
"No registry cleanup policy",
|
|
442
|
-
"
|
|
442
|
+
"Cached engine images accumulate; they are a copy of GHCR and can be expired freely.",
|
|
443
443
|
"meffecta-agent artifact-cleanup",
|
|
444
444
|
);
|
|
445
445
|
}
|
package/package.json
CHANGED
package/scripts/deploy.sh
CHANGED
|
@@ -7,18 +7,10 @@ set -euo pipefail
|
|
|
7
7
|
# new engine version is the deployment's call, not the engine repo's. This script is
|
|
8
8
|
# that call, and it is the same script for the vendor's own instance and for a client's.
|
|
9
9
|
#
|
|
10
|
-
# Cloud Run can only pull from Artifact Registry, never from GHCR directly
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
#
|
|
14
|
-
# plus `gcloud auth configure-docker <region>-docker.pkg.dev` once, and — for a
|
|
15
|
-
# private GHCR package — `docker login ghcr.io` with a PAT that has read:packages.
|
|
16
|
-
# 2. AR remote repository: a one-time pull-through proxy of GHCR in the deployment's
|
|
17
|
-
# project, after which no local docker is involved. Then point --image straight at
|
|
18
|
-
# the proxied path and skip the mirror:
|
|
19
|
-
# gcloud artifacts repositories create ghcr-remote --repository-format=docker \
|
|
20
|
-
# --mode=remote-repository --location=<region> \
|
|
21
|
-
# --remote-docker-repo=https://ghcr.io # + upstream creds for a private package
|
|
10
|
+
# Cloud Run can only pull from Artifact Registry, never from GHCR directly. ARTIFACT_REPO
|
|
11
|
+
# is a remote repository standing in front of ghcr.io, so a tag resolves to a path inside
|
|
12
|
+
# it and Artifact Registry fetches the public engine image itself, on demand. Nothing is
|
|
13
|
+
# copied by hand and no docker is involved — not here, not in CI.
|
|
22
14
|
#
|
|
23
15
|
# What it writes, and what it deliberately does not.
|
|
24
16
|
#
|
|
@@ -71,62 +63,14 @@ require_gcloud
|
|
|
71
63
|
resolve_deployment
|
|
72
64
|
announce_target "Deploying ${SERVICE}"
|
|
73
65
|
|
|
74
|
-
# --image is taken as-is. Otherwise --tag resolves
|
|
75
|
-
#
|
|
76
|
-
#
|
|
77
|
-
#
|
|
78
|
-
if [ -z "${IMAGE}" ]
|
|
79
|
-
# A remote repository preserves the upstream path, so ghcr.io/meffecta/agent is served at
|
|
80
|
-
# <repo>/meffecta/agent. Nothing is copied and nothing is pushed: the first pull populates
|
|
81
|
-
# AR's cache, and Cloud Run's own pull is what triggers it.
|
|
66
|
+
# --image is taken as-is. Otherwise --tag resolves to a path inside the deployment's own
|
|
67
|
+
# remote repository, which preserves the upstream one: ghcr.io/meffecta/agent is served at
|
|
68
|
+
# <repo>/meffecta/agent. Nothing is copied and nothing is pushed — Cloud Run's own pull is
|
|
69
|
+
# what populates the cache.
|
|
70
|
+
if [ -z "${IMAGE}" ]; then
|
|
82
71
|
IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${GHCR_PATH}:${TAG}"
|
|
83
72
|
echo "🔗 Through the registry proxy: ${IMAGE}"
|
|
84
73
|
echo " (Artifact Registry fetches ${GHCR_IMAGE}:${TAG} on demand — no local docker)"
|
|
85
|
-
elif [ -z "${IMAGE}" ]; then
|
|
86
|
-
SOURCE="${GHCR_IMAGE}:${TAG}"
|
|
87
|
-
IMAGE="${REGION}-docker.pkg.dev/${PROJECT}/${ARTIFACT_REPO}/${SERVICE}:${TAG}"
|
|
88
|
-
command -v docker >/dev/null || {
|
|
89
|
-
echo "docker is required to mirror ${SOURCE} into Artifact Registry." >&2
|
|
90
|
-
echo "REGISTRY_MODE=proxy in deployment.env avoids it entirely — Artifact Registry" >&2
|
|
91
|
-
echo "fetches from ghcr.io itself. After changing it, re-run:" >&2
|
|
92
|
-
echo " $(cmd setup-infra)" >&2
|
|
93
|
-
exit 1
|
|
94
|
-
}
|
|
95
|
-
echo "📥 Mirroring ${SOURCE} → ${IMAGE}"
|
|
96
|
-
if [ "${DRY_RUN}" = true ]; then
|
|
97
|
-
echo " (dry run: would pull, retag and push)"
|
|
98
|
-
else
|
|
99
|
-
if ! docker pull "${SOURCE}"; then
|
|
100
|
-
cat >&2 <<EOF
|
|
101
|
-
|
|
102
|
-
Could not pull ${SOURCE}.
|
|
103
|
-
|
|
104
|
-
The published engine image is public, so this needs no login — check the tag exists
|
|
105
|
-
and that the host can reach ghcr.io:
|
|
106
|
-
|
|
107
|
-
curl -sS "https://ghcr.io/token?scope=repository:meffecta/agent:pull&service=ghcr.io"
|
|
108
|
-
|
|
109
|
-
If you are pointing GHCR_IMAGE at a private package of your own instead, that one does
|
|
110
|
-
need a token with the read:packages scope, which the gh CLI does not request by default:
|
|
111
|
-
|
|
112
|
-
gh auth refresh -h github.com -s read:packages
|
|
113
|
-
gh auth token | docker login ghcr.io -u <your-github-username> --password-stdin
|
|
114
|
-
EOF
|
|
115
|
-
exit 1
|
|
116
|
-
fi
|
|
117
|
-
docker tag "${SOURCE}" "${IMAGE}"
|
|
118
|
-
if ! docker push "${IMAGE}"; then
|
|
119
|
-
cat >&2 <<EOF
|
|
120
|
-
|
|
121
|
-
Could not push to ${IMAGE}.
|
|
122
|
-
|
|
123
|
-
Artifact Registry needs docker configured for this region once:
|
|
124
|
-
|
|
125
|
-
gcloud auth configure-docker ${REGION}-docker.pkg.dev
|
|
126
|
-
EOF
|
|
127
|
-
exit 1
|
|
128
|
-
fi
|
|
129
|
-
fi
|
|
130
74
|
fi
|
|
131
75
|
|
|
132
76
|
# scale-to-zero is the default and what sync-triggers.sh's triggers assume: no CPU
|
|
@@ -160,6 +104,51 @@ gcloud run deploy "${SERVICE}" \
|
|
|
160
104
|
--execution-environment gen2 \
|
|
161
105
|
"${SCALE_FLAGS[@]}"
|
|
162
106
|
|
|
107
|
+
# What actually landed. `--tag latest` is a request, not an answer: it names whatever GHCR
|
|
108
|
+
# was serving a moment ago, and after this it names whatever it serves next. The digest is
|
|
109
|
+
# the immutable identity — and the one thing that makes a rollback to *this* engine possible
|
|
110
|
+
# in a month, when the tag has moved on.
|
|
111
|
+
read -r URL REVISION < <(gcloud run services describe "${SERVICE}" \
|
|
112
|
+
--project "${PROJECT}" --region "${REGION}" \
|
|
113
|
+
--format 'value[separator=" "](status.url, status.latestReadyRevisionName)' 2>/dev/null || echo " ")
|
|
114
|
+
DIGEST=""
|
|
115
|
+
if [ -n "${REVISION}" ]; then
|
|
116
|
+
DIGEST=$(gcloud run revisions describe "${REVISION}" \
|
|
117
|
+
--project "${PROJECT}" --region "${REGION}" \
|
|
118
|
+
--format 'value(status.imageDigest)' 2>/dev/null || true)
|
|
119
|
+
fi
|
|
120
|
+
|
|
121
|
+
# And which engine is answering. The digest says the image is different; only the service
|
|
122
|
+
# says what it is. This is also the first real request to the new revision, so a rollout
|
|
123
|
+
# that produced an image which cannot boot says so here rather than at the next cron —
|
|
124
|
+
# generously timed, because a scale-to-zero service is cold and this image is a large pull.
|
|
125
|
+
HEALTH=""
|
|
126
|
+
if [ -n "${URL}" ]; then
|
|
127
|
+
HEALTH=$(curl -fsS --max-time 180 "${URL}/health" 2>/dev/null || true)
|
|
128
|
+
fi
|
|
129
|
+
VERSION=$(printf '%s' "${HEALTH}" | sed -n 's/.*"version":"\([^"]*\)".*/\1/p')
|
|
130
|
+
BUILD=$(printf '%s' "${HEALTH}" | sed -n 's/.*"build":"\([^"]*\)".*/\1/p')
|
|
131
|
+
|
|
132
|
+
echo ""
|
|
133
|
+
if [ -n "${VERSION}" ]; then
|
|
134
|
+
echo "✅ Running engine ${VERSION}${BUILD:+ (build ${BUILD})}"
|
|
135
|
+
elif [ -n "${HEALTH}" ]; then
|
|
136
|
+
# It answered, but without a version: an engine from before /health reported one.
|
|
137
|
+
echo "✅ Service is up — this engine predates version reporting, so it cannot say which it is"
|
|
138
|
+
else
|
|
139
|
+
echo "⚠️ Deployed, but ${SERVICE} did not answer /health within 3 minutes"
|
|
140
|
+
echo " Check it before assuming the rollout is good:"
|
|
141
|
+
if has_cli; then
|
|
142
|
+
echo " $(cmd logs)"
|
|
143
|
+
else
|
|
144
|
+
echo " gcloud run services logs read ${SERVICE} --project ${PROJECT} --region ${REGION} --limit 50"
|
|
145
|
+
fi
|
|
146
|
+
fi
|
|
147
|
+
[ -n "${REVISION}" ] && echo " revision ${REVISION}"
|
|
148
|
+
[ -n "${DIGEST}" ] && echo " image ${DIGEST##*@}"
|
|
149
|
+
[ -n "${DIGEST}" ] && echo " roll back to this exact engine later with:"
|
|
150
|
+
[ -n "${DIGEST}" ] && echo " $(cmd deploy) --image ${DIGEST}"
|
|
151
|
+
|
|
163
152
|
# Only for a deployment that has opted into external triggers — AGENT_TASKS_QUEUE on the
|
|
164
153
|
# service is what says so. Creating Scheduler jobs for an always-on one would mean every
|
|
165
154
|
# cron fires twice, once from outside and once from the in-process timer.
|
|
@@ -170,10 +159,8 @@ if [ "${SYNC_SCHEDULER}" = true ] &&
|
|
|
170
159
|
"$(dirname "${BASH_SOURCE[0]}")/sync-triggers.sh" ${CONFIG_FILE:+--config "${CONFIG_FILE}"}
|
|
171
160
|
fi
|
|
172
161
|
|
|
173
|
-
URL=$(gcloud run services describe "${SERVICE}" \
|
|
174
|
-
--project "${PROJECT}" --region "${REGION}" \
|
|
175
|
-
--format 'value(status.url)' 2>/dev/null || true)
|
|
176
162
|
if [ -n "${URL}" ]; then
|
|
163
|
+
echo ""
|
|
177
164
|
echo "🌍 ${SERVICE} → ${URL}"
|
|
178
165
|
echo " Liveness: curl -fsS ${URL}/health"
|
|
179
166
|
echo " Ask it: ${URL}/ask (any username, AGENT_API_SECRET as the password)"
|
|
@@ -64,13 +64,11 @@ resolve_deployment() {
|
|
|
64
64
|
load_deployment_config
|
|
65
65
|
REGION="${REGION:-europe-west1}"
|
|
66
66
|
ARTIFACT_REPO="${ARTIFACT_REPO:-${SERVICE:-agent}-images}"
|
|
67
|
+
# Where the published engine images are. An environment override exists for building
|
|
68
|
+
# against a fork of the engine, and is deliberately not a deployment.env setting: a
|
|
69
|
+
# deployment points at the engine, and choosing a different one is not a thing to
|
|
70
|
+
# configure by editing a committed file.
|
|
67
71
|
GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
|
|
68
|
-
# How the engine image reaches Cloud Run, which can only ever pull from Artifact
|
|
69
|
-
# Registry. `proxy` makes ARTIFACT_REPO a remote repository in front of ghcr.io, so AR
|
|
70
|
-
# fetches on demand and no local docker is involved at all. `mirror` is the older path:
|
|
71
|
-
# pull from ghcr.io on the operator's machine, retag, push — which needs docker, and
|
|
72
|
-
# pushes several gigabytes for every engine version.
|
|
73
|
-
REGISTRY_MODE="${REGISTRY_MODE:-proxy}"
|
|
74
72
|
GHCR_PATH="${GHCR_IMAGE#*/}" # ghcr.io/meffecta/agent → meffecta/agent
|
|
75
73
|
TIMEZONE="${TIMEZONE:-Europe/Stockholm}"
|
|
76
74
|
|
|
@@ -4,7 +4,7 @@ set -euo pipefail
|
|
|
4
4
|
# Stop a deployment's Artifact Registry filling up with engine images it will never use
|
|
5
5
|
# again.
|
|
6
6
|
#
|
|
7
|
-
# The images there are a **cache**, not an archive:
|
|
7
|
+
# The images there are a **cache**, not an archive: Artifact Registry fetches them from
|
|
8
8
|
# ghcr.io/meffecta/agent because Cloud Run cannot pull from GHCR directly. GHCR keeps the
|
|
9
9
|
# real copies, so anything deleted here comes back on the next `deploy.sh --tag <that one>`.
|
|
10
10
|
# That is what makes an aggressive policy correct for this repo and wrong for a repo whose
|
|
@@ -119,33 +119,26 @@ run ${GC} services enable \
|
|
|
119
119
|
analyticsdata.googleapis.com \
|
|
120
120
|
iam.googleapis.com
|
|
121
121
|
|
|
122
|
-
echo "🐳 Artifact Registry repo
|
|
122
|
+
echo "🐳 Artifact Registry repo..."
|
|
123
123
|
if ! ${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" &>/dev/null; then
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
--description="Proxy of ${GHCR_IMAGE%%/*} — the engine image is fetched on demand"
|
|
133
|
-
else
|
|
134
|
-
run ${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" --repository-format=docker
|
|
135
|
-
fi
|
|
124
|
+
# A remote repository standing in front of ghcr.io. Cloud Run can only pull from
|
|
125
|
+
# Artifact Registry, and this is how that requirement is met without any docker: AR
|
|
126
|
+
# fetches the public engine image itself, on demand, and caches it. No upstream
|
|
127
|
+
# credentials, because the engine package is public.
|
|
128
|
+
run ${GC} artifacts repositories create "${ARTIFACT_REPO}" --location="${REGION}" \
|
|
129
|
+
--repository-format=docker --mode=remote-repository \
|
|
130
|
+
--remote-docker-repo="https://${GHCR_IMAGE%%/*}" \
|
|
131
|
+
--description="Proxy of ${GHCR_IMAGE%%/*} — the engine image is fetched on demand"
|
|
136
132
|
else
|
|
137
133
|
ACTUAL_MODE=$(${GC} artifacts repositories describe "${ARTIFACT_REPO}" --location="${REGION}" --format='value(mode)')
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
echo " name a different ARTIFACT_REPO in deployment.env and re-run this." >&2
|
|
147
|
-
;;
|
|
148
|
-
esac
|
|
134
|
+
if [ "${ACTUAL_MODE}" != "REMOTE_REPOSITORY" ]; then
|
|
135
|
+
# A repository's mode is fixed at creation, so this is not something to "fix" here —
|
|
136
|
+
# deploy.sh would resolve an image path the repository cannot serve, and the failure
|
|
137
|
+
# would arrive as a Cloud Run pull error with nothing pointing back to this.
|
|
138
|
+
echo "⚠ ${ARTIFACT_REPO} is a ${ACTUAL_MODE}, which cannot serve the engine image." >&2
|
|
139
|
+
echo " A repository's mode cannot be changed. Name a different ARTIFACT_REPO in" >&2
|
|
140
|
+
echo " deployment.env and re-run this." >&2
|
|
141
|
+
fi
|
|
149
142
|
fi
|
|
150
143
|
# The images here are a cache of ghcr.io/meffecta/agent, not an archive, so they are worth
|
|
151
144
|
# expiring — see set-artifact-cleanup.sh for why that is safe and what the policy keeps.
|
|
@@ -198,8 +191,8 @@ if ! ${GC} iam service-accounts describe "${DEPLOY_SA}" &>/dev/null; then
|
|
|
198
191
|
sleep 5
|
|
199
192
|
done
|
|
200
193
|
fi
|
|
201
|
-
# What a DEPLOY needs, and nothing a BUILD would: roll out a revision, pull
|
|
202
|
-
#
|
|
194
|
+
# What a DEPLOY needs, and nothing a BUILD would: roll out a revision, pull the engine
|
|
195
|
+
# image, and read this project's enabled services.
|
|
203
196
|
#
|
|
204
197
|
# Deliberately NOT granted: secretmanager.secretAccessor. deploy.sh re-syncs the Cloud
|
|
205
198
|
# Scheduler triggers, and that step reads the API secret — so a CI deploy runs
|
|
@@ -334,7 +327,7 @@ if has_cli; then
|
|
|
334
327
|
echo ""
|
|
335
328
|
echo " ${MEFFECTA_CLI} connect # lists them; connect <name> walks through one"
|
|
336
329
|
echo ""
|
|
337
|
-
echo "
|
|
330
|
+
echo " The whole set-up, in order: https://agent.meffecta.com"
|
|
338
331
|
echo " ${MEFFECTA_CLI} doctor what is still missing, once something is running"
|
|
339
332
|
fi
|
|
340
333
|
if [ "${SETUP_CI_DEPLOY}" = "true" ]; then
|
|
@@ -6,8 +6,8 @@ set -euo pipefail
|
|
|
6
6
|
# The engine's source repo is private; the image is not. The operator tooling — these
|
|
7
7
|
# scripts and the config template — ships inside ghcr.io/meffecta/agent, so a deployment run
|
|
8
8
|
# by someone outside Meffecta gets its tooling from the same public artifact it gets its
|
|
9
|
-
# engine from, and never needs the source.
|
|
10
|
-
#
|
|
9
|
+
# engine from, and never needs the source. A deployment is documented by
|
|
10
|
+
# https://agent.meffecta.com and by the CLI's own `meffecta-agent help`.
|
|
11
11
|
#
|
|
12
12
|
# The tooling belongs in your content repo, committed next to jobs/ and deployment.env.
|
|
13
13
|
# That is what makes a fresh clone of that repo enough to operate the deployment, and it
|
|
@@ -26,9 +26,8 @@ set -euo pipefail
|
|
|
26
26
|
# scripts/update-tooling.sh --tag sha-558a144 # match a specific one, e.g. what you run
|
|
27
27
|
# scripts/update-tooling.sh --dry-run # show what would change, touch nothing
|
|
28
28
|
#
|
|
29
|
-
# Needs docker,
|
|
30
|
-
#
|
|
31
|
-
# here — once per engine version, rather than once per deploy.
|
|
29
|
+
# Needs docker, and is the only thing here that does: the files are lifted out of the
|
|
30
|
+
# published image with `docker cp`. Once per engine version, and never during a deploy.
|
|
32
31
|
|
|
33
32
|
. "$(dirname "${BASH_SOURCE[0]}")/lib/cli-names.sh"
|
|
34
33
|
|
|
@@ -742,9 +742,8 @@ if (unknownCredentials.length) {
|
|
|
742
742
|
// knowing anything about the systems themselves: the register travels into every run, so
|
|
743
743
|
// it is right here in the working directory. A credential named in it is deliberate, and
|
|
744
744
|
// proving it works is Part 2's job; one named nowhere is a key no skill will ever reach
|
|
745
|
-
// for, however valid it is.
|
|
746
|
-
//
|
|
747
|
-
// past.
|
|
745
|
+
// for, however valid it is. Splitting them matters because a real gap listed among
|
|
746
|
+
// things that are already fine is a gap that gets skimmed past.
|
|
748
747
|
const registered = new Set();
|
|
749
748
|
const registerDir = join(process.cwd(), "systems");
|
|
750
749
|
if (existsSync(registerDir)) {
|