@meffecta/agent 1.0.3 → 1.0.7

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.
@@ -32,12 +32,13 @@ set -euo pipefail
32
32
  # is restarted for them anyway. scripts/deploy.sh does it automatically.
33
33
  #
34
34
  # Usage:
35
- # scripts/setup-scheduler.sh # from the content repo (./deployment.env)
36
- # scripts/setup-scheduler.sh --dry-run # print what it would create, change nothing
35
+ # scripts/sync-triggers.sh # from the content repo (./deployment.env)
36
+ # scripts/sync-triggers.sh --dry-run # print what it would create, change nothing
37
37
  #
38
38
  # Which deployment: ./deployment.env, or --config <file>. See deployment.env.example.
39
39
 
40
40
  . "$(dirname "${BASH_SOURCE[0]}")/lib/deployment.sh"
41
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/tasks-queue.sh"
41
42
 
42
43
  DRY_RUN=false
43
44
  QUEUE_NAME=""
@@ -133,7 +134,7 @@ for SECRET_VAR in AGENT_API_SECRET AGENT_WEBHOOK_SECRET; do
133
134
  done
134
135
  if [ -z "${API_SECRET}" ]; then
135
136
  echo "Could not resolve AGENT_API_SECRET (or the old AGENT_WEBHOOK_SECRET) from ${SERVICE}." >&2
136
- echo " scripts/set-secret.sh AGENT_API_SECRET --random" >&2
137
+ echo " $(cmd set-secret) AGENT_API_SECRET --random" >&2
137
138
  echo "Refusing to create triggers that would 401 on every firing." >&2
138
139
  exit 1
139
140
  fi
@@ -148,25 +149,20 @@ if [ "${MIN_SCALE}" != "0" ] || [ "${CPU_THROTTLING}" = "false" ]; then
148
149
  ⚠️ ${SERVICE} still runs always-on (min-instances=${MIN_SCALE}, cpu-throttling=${CPU_THROTTLING}).
149
150
  External triggers work either way, but the saving does not arrive until the service
150
151
  scales to zero — and until then in-process timers and Cloud Scheduler would BOTH fire,
151
- so every cron runs twice. The shape lives in deployment.env and deploy.sh asserts it:
152
+ so every cron runs twice. The shape lives in deployment.env, and is asserted on every
153
+ rollout:
152
154
 
153
155
  SCALING=scale-to-zero # in deployment.env, then
154
- scripts/deploy.sh # rolls it out (--dry-run to see the shape first)
156
+ $(cmd deploy) # rolls it out (--dry-run to see the shape first)
155
157
 
156
158
  EOF
157
159
  fi
158
160
 
161
+ # Normally already there — setup-infrastructure.sh creates it with the deployment's other
162
+ # static resources. Ensured here too for a deployment provisioned before that was true, and
163
+ # because this script is also run on its own. See lib/tasks-queue.sh.
159
164
  echo "📥 Cloud Tasks queue ${QUEUE}..."
160
- if ! ${GC} tasks queues describe "${QUEUE}" --location="${REGION}" >/dev/null 2>&1; then
161
- run ${GC} tasks queues create "${QUEUE}" --location="${REGION}" --quiet
162
- fi
163
- # max-concurrent-dispatches leaves room for several held attempts at once; the engine's own
164
- # queue is serial, so this bounds requests waiting, not jobs running.
165
- run ${GC} tasks queues update "${QUEUE}" --location="${REGION}" \
166
- --max-attempts=4 --min-backoff=5s --max-backoff=30s --max-doublings=2 \
167
- --max-concurrent-dispatches=8 --max-dispatches-per-second=2 --quiet
168
- run ${GC} tasks queues add-iam-policy-binding "${QUEUE}" --location="${REGION}" \
169
- --member="serviceAccount:${RUNTIME_SA}" --role="roles/cloudtasks.enqueuer" --quiet
165
+ ensure_tasks_queue "${QUEUE}"
170
166
 
171
167
  CURRENT_QUEUE=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_TASKS_QUEUE | cut -f2)
172
168
  CURRENT_URL=$(printf '%s' "${SERVICE_JSON}" | svc_field AGENT_PUBLIC_URL | cut -f2)
@@ -185,7 +181,7 @@ The engine is the only thing that knows which jobs exist — it reads them from
185
181
  clone of the content repo. So the service has to be deployed and healthy first:
186
182
 
187
183
  curl -fsS ${AGENT_URL}/health
188
- scripts/deploy.sh
184
+ $(cmd deploy)
189
185
 
190
186
  A first request after a scale-to-zero idle also has to wait out a cold start, which
191
187
  includes cloning the content repo.
@@ -30,6 +30,8 @@ set -euo pipefail
30
30
  # Artifact Registry). A deployment using an AR remote repository instead still needs it
31
31
  # here — once per engine version, rather than once per deploy.
32
32
 
33
+ . "$(dirname "${BASH_SOURCE[0]}")/lib/cli-names.sh"
34
+
33
35
  GHCR_IMAGE="${GHCR_IMAGE:-ghcr.io/meffecta/agent}"
34
36
  TAG="latest"
35
37
  IMAGE=""
@@ -114,5 +116,5 @@ fi
114
116
  # This script is one of the files being overwritten, and bash reads a script as it runs it.
115
117
  # exec hands the process over so nothing further is read from the file underneath us.
116
118
  echo "✅ Updated. Review with \`git diff\`, then deploy the matching engine:"
117
- echo " scripts/deploy.sh${TAG:+ --tag ${TAG}}"
119
+ echo " $(cmd deploy)${TAG:+ --tag ${TAG}}"
118
120
  exec cp -R "${TMP}/scripts/." "${DEST}/"
@@ -8,6 +8,8 @@
8
8
  // Never prints a credential value. Exits 1 if anything configured actually fails.
9
9
 
10
10
  import { execFile } from "node:child_process";
11
+ import { readFileSync } from "node:fs";
12
+ import { fileURLToPath } from "node:url";
11
13
 
12
14
  const env = process.env;
13
15
  const results = [];
@@ -45,12 +47,21 @@ async function metadataToken(scopes) {
45
47
 
46
48
  async function checkGithub() {
47
49
  if (!env.GITHUB_TOKEN) {
48
- return skip("GITHUB_TOKEN", "unset");
50
+ // Not a shrug: the service clones the content repo at boot, so unset is an outage.
51
+ return fail("GITHUB_TOKEN", "unset — the service cannot clone its content repo without it");
49
52
  }
50
- // The content repo (from GIT_REPO_URL, when it's a GitHub URL), plus the working
51
- // repos this deployment's jobs clone (deployment-specificderive from jobs/ one day).
53
+ // The content repo (from GIT_REPO_URL, when it is a GitHub URL), plus any working repo
54
+ // this deployment names in a *_GIT_URL variable its jobs clone those, so the token has
55
+ // to reach them. Nothing here may name a company: this script ships to every deployment.
52
56
  const contentRepo = env.GIT_REPO_URL?.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1];
53
- const repos = [...(contentRepo ? [contentRepo] : []), "meffecta/sweepos"];
57
+ const named = Object.entries(env)
58
+ .filter(([k]) => k.endsWith("_GIT_URL"))
59
+ .map(([, v]) => v?.match(/github\.com\/([^/]+\/[^/.]+)/)?.[1])
60
+ .filter(Boolean);
61
+ const repos = [...new Set([...(contentRepo ? [contentRepo] : []), ...named])];
62
+ if (repos.length === 0) {
63
+ return skip("GITHUB_TOKEN", "set, but no GitHub repo to check it against");
64
+ }
54
65
  const codes = await Promise.all(
55
66
  repos.map(
56
67
  async (r) =>
@@ -87,35 +98,38 @@ async function checkResend() {
87
98
  : fail("RESEND_API_KEY", `domains → ${status}`);
88
99
  }
89
100
 
90
- async function checkInternalApi() {
91
- if (!env.SWEEPOS_API_URL || !env.INTERNAL_API_KEY) {
92
- return skip("SWEEPOS internal API", "SWEEPOS_API_URL/INTERNAL_API_KEY not set");
93
- }
94
- const { status } = await http(`${env.SWEEPOS_API_URL}/v1/internal/leads?limit=1`, {
95
- headers: { Authorization: `Bearer ${env.INTERNAL_API_KEY}` },
96
- });
97
- status === 200 ? ok("SWEEPOS internal API") : fail("SWEEPOS internal API", `leads ${status}`);
98
- }
99
-
101
+ /**
102
+ * Every read-only Postgres URL this deployment declares, by the documented convention
103
+ * <WORLD>_POSTGRES_URL_READONLY — the variable names belong to the deployment, so they are
104
+ * discovered rather than listed here.
105
+ */
106
+ // There is deliberately no check for a deployment's own product API. Probing one means
107
+ // knowing its path and its response shape — /v1/internal/leads was SweepOS's — and this
108
+ // script ships to every deployment. A credential the engine's own skills use is fair game
109
+ // here; a company's API is that company's tooling.
100
110
  function checkPostgres() {
101
- return new Promise((resolve) => {
102
- if (!env.SWEEPOS_POSTGRES_URL_READONLY) {
103
- skip("SWEEPOS_POSTGRES_URL_READONLY", "unset");
104
- return resolve();
105
- }
106
- execFile("psql", [env.SWEEPOS_POSTGRES_URL_READONLY, "-Atc", "SELECT 1"], { timeout: 10_000 }, (err, stdout) => {
107
- if (err) {
108
- err.code === "ENOENT"
109
- ? skip("SWEEPOS_POSTGRES_URL_READONLY", "psql not installed here")
110
- : fail("SWEEPOS_POSTGRES_URL_READONLY", err.message.split("\n")[0].slice(0, 120));
111
- } else {
112
- stdout.trim() === "1"
113
- ? ok("SWEEPOS_POSTGRES_URL_READONLY")
114
- : fail("SWEEPOS_POSTGRES_URL_READONLY", "unexpected result");
115
- }
116
- resolve();
117
- });
118
- });
111
+ const urls = Object.keys(env).filter((k) => k.endsWith("_POSTGRES_URL_READONLY") && env[k]);
112
+ if (urls.length === 0) {
113
+ skip("POSTGRES (read-only)", "no <WORLD>_POSTGRES_URL_READONLY set");
114
+ return Promise.resolve();
115
+ }
116
+ return Promise.all(
117
+ urls.map(
118
+ (name) =>
119
+ new Promise((resolve) => {
120
+ execFile("psql", [env[name], "-Atc", "SELECT 1"], { timeout: 10_000 }, (err, stdout) => {
121
+ if (err) {
122
+ err.code === "ENOENT"
123
+ ? skip(name, "psql not installed here")
124
+ : fail(name, err.message.split("\n")[0].slice(0, 120));
125
+ } else {
126
+ stdout.trim() === "1" ? ok(name) : fail(name, "unexpected result");
127
+ }
128
+ resolve();
129
+ });
130
+ }),
131
+ ),
132
+ );
119
133
  }
120
134
 
121
135
  async function checkPosthog() {
@@ -138,11 +152,13 @@ async function checkAhrefs() {
138
152
  if (!env.AHREFS_API_KEY) {
139
153
  return skip("AHREFS_API_KEY", "unset");
140
154
  }
141
- const { status, json } = await http("https://api.ahrefs.com/v3/public/domain-rating-free?target=sweepos.app", {
155
+ // example.com rather than a domain belonging to any deployment: this only proves the key
156
+ // is accepted, and the answer for a real domain is a job's business, not a doctor's.
157
+ const { status, json } = await http("https://api.ahrefs.com/v3/public/domain-rating-free?target=example.com", {
142
158
  headers: { Authorization: `Bearer ${env.AHREFS_API_KEY}` },
143
159
  });
144
160
  status === 200
145
- ? ok("AHREFS_API_KEY", `sweepos.app DR ${json?.domain_rating?.domain_rating ?? "?"}`)
161
+ ? ok("AHREFS_API_KEY", `example.com DR ${json?.domain_rating?.domain_rating ?? "?"}`)
146
162
  : fail("AHREFS_API_KEY", `domain-rating-free → ${status}`);
147
163
  }
148
164
 
@@ -508,8 +524,11 @@ async function checkGa4() {
508
524
  }
509
525
 
510
526
  async function checkGoogleAds() {
511
- if (!env.GOOGLE_ADS_DEVELOPER_TOKEN || !env.SWEEPOS_GOOGLE_ADS_CUSTOMER_ID) {
512
- return skip("GOOGLE ADS", "developer token / customer id not set");
527
+ // Any world's customer id proves the developer token and the manager link; which world
528
+ // is this deployment's business, so take the first one it declares.
529
+ const customerId = Object.entries(env).find(([k, v]) => k.endsWith("_GOOGLE_ADS_CUSTOMER_ID") && v)?.[1];
530
+ if (!env.GOOGLE_ADS_DEVELOPER_TOKEN || !customerId) {
531
+ return skip("GOOGLE ADS", "developer token / <WORLD>_GOOGLE_ADS_CUSTOMER_ID not set");
513
532
  }
514
533
  if (!onCloudRun) {
515
534
  return skip("GOOGLE ADS", "needs the metadata server — run on Cloud Run");
@@ -525,7 +544,7 @@ async function checkGoogleAds() {
525
544
  headers["login-customer-id"] = env.GOOGLE_ADS_LOGIN_CUSTOMER_ID;
526
545
  }
527
546
  const { status, json } = await http(
528
- `https://googleads.googleapis.com/v25/customers/${env.SWEEPOS_GOOGLE_ADS_CUSTOMER_ID}/googleAds:searchStream`,
547
+ `https://googleads.googleapis.com/v25/customers/${customerId}/googleAds:searchStream`,
529
548
  { method: "POST", headers, body: JSON.stringify({ query: "SELECT customer.id FROM customer LIMIT 1" }) },
530
549
  );
531
550
  status === 200
@@ -554,9 +573,46 @@ async function checkDeploymentsContext() {
554
573
  : fail("Deployments context (run.viewer)", `list → ${status}`);
555
574
  }
556
575
 
576
+ /**
577
+ * The handful of credentials without which there is no agent at all — as opposed to an
578
+ * integration being unavailable, which degrades one job. A deployment can be missing every
579
+ * other credential here and still work; missing one of these and it is simply down.
580
+ *
581
+ * These are also the ones a shipped script CAN test properly, because they are the
582
+ * engine's own and mean the same thing in every deployment.
583
+ */
584
+ const ESSENTIAL = new Set(["AGENT_API_SECRET", "CLAUDE_CODE_OAUTH_TOKEN", "GITHUB_TOKEN", "GIT_REPO_URL"]);
585
+
586
+ /**
587
+ * Inside a run, three of the four are proven by the fact that this output exists at all —
588
+ * a stronger check than any probe could be. The run is happening, so the Claude credential
589
+ * works; the working directory is the content clone, so GIT_REPO_URL and the token that
590
+ * cloned it work; the request that started it was authenticated, so the API secret works.
591
+ */
592
+ async function checkEssentials() {
593
+ const insideRun = Boolean(env.AGENT_SPAWN_TOKEN);
594
+ if (!env.GIT_REPO_URL) {
595
+ fail("GIT_REPO_URL", "unset — the service has no content repo to clone, and will not boot");
596
+ } else if (insideRun) {
597
+ ok("GIT_REPO_URL", "this run's own clone came from it");
598
+ } else {
599
+ ok("GIT_REPO_URL", "set (not verifiable from here)");
600
+ }
601
+ }
602
+
557
603
  function checkPresence() {
558
- for (const k of ["CLAUDE_CODE_OAUTH_TOKEN"]) {
559
- env[k] ? ok(k, "present (not remotely testable)") : skip(k, "unset");
604
+ // The token that authorises the whole deployment, and the one credential this check
605
+ // cannot see when it matters most. Run inside a job, `claude` strips its own credential
606
+ // from the environment of every subprocess it spawns — a run cannot read the token that
607
+ // started it, which is exactly right, and means "unset" here would be a lie: the run
608
+ // doing the reporting is itself the proof it is set. AGENT_SPAWN_TOKEN is how we know we
609
+ // are inside a run; the engine sets that one, so `claude` has no reason to strip it.
610
+ if (env.CLAUDE_CODE_OAUTH_TOKEN) {
611
+ ok("CLAUDE_CODE_OAUTH_TOKEN", "present (not remotely testable)");
612
+ } else if (env.AGENT_SPAWN_TOKEN) {
613
+ ok("CLAUDE_CODE_OAUTH_TOKEN", "in use by this very run — a run cannot read it, by design");
614
+ } else {
615
+ skip("CLAUDE_CODE_OAUTH_TOKEN", "unset");
560
616
  }
561
617
  // Either name satisfies the engine; say which one is actually in play.
562
618
  if (env.AGENT_API_SECRET) {
@@ -585,8 +641,7 @@ const CHECKS = [
585
641
  ["GITHUB_TOKEN", checkGithub],
586
642
  ["GRAFANA (Loki)", checkGrafana],
587
643
  ["RESEND_API_KEY", checkResend],
588
- ["SWEEPOS internal API", checkInternalApi],
589
- ["SWEEPOS_POSTGRES_URL_READONLY", checkPostgres],
644
+ ["POSTGRES (read-only)", checkPostgres],
590
645
  ["POSTHOG", checkPosthog],
591
646
  ["AHREFS_API_KEY", checkAhrefs],
592
647
  ["SERPER_API_KEY", checkSerper],
@@ -602,6 +657,7 @@ const CHECKS = [
602
657
  ["DWD drive+docs+slides", checkDwdDrive],
603
658
  ["DWD calendar (write scope)", checkDwdCalendar],
604
659
  ["SEARCH CONSOLE", checkSearchConsole],
660
+ ["Essentials", checkEssentials],
605
661
  ["GA4", checkGa4],
606
662
  ["GOOGLE ADS", checkGoogleAds],
607
663
  ["Deployments context (run.viewer)", checkDeploymentsContext],
@@ -613,16 +669,79 @@ await Promise.all(
613
669
  );
614
670
 
615
671
  results.sort((a, b) => a.name.localeCompare(b.name));
616
- const pad = Math.max(...results.map((r) => r.name.length)) + 2;
672
+
673
+ // Configured surfaces get a line each. Unconfigured ones get a single line between them,
674
+ // because this script ships to EVERY deployment and knows every integration the engine
675
+ // supports — which is always more than any one deployment uses. Listing them individually
676
+ // asks the operator "why is Kleer in my report?", and the honest answer is that it is not
677
+ // theirs and never was. The deployment's own register of what it can reach is
678
+ // ENVIRONMENT.md in its content repo; the catalogue of what it could add is `connect`.
679
+ // Credentials this script has never heard of.
680
+ //
681
+ // A deployment adds its own systems, and their keys are just secrets bound to the service:
682
+ // ACME_CRM_TOKEN means nothing to an engine that ships to everyone. Testing one needs its
683
+ // endpoint, its auth header and a notion of what a good answer looks like — none of which
684
+ // can be in here. Silence would be the wrong answer though, because "26 working" reads as
685
+ // "everything works" while a set-but-never-exercised key sits beside it.
686
+ //
687
+ // So: name them, say plainly that nothing here can test them, and let the run that called
688
+ // this reconcile them against ENVIRONMENT.md — the deployment's own register does know
689
+ // what they are and which skill reaches them.
690
+ //
691
+ // Known-ness is decided from this file's own source, so the list maintains itself: if the
692
+ // script mentions the variable, or the family it belongs to, it knows about it.
693
+ // Comments stripped first: a variable named in a comment — including the example two
694
+ // paragraphs up — is not a variable this script can test, and counting it as known is how
695
+ // a real unknown gets silently swallowed.
696
+ const selfSource = readFileSync(fileURLToPath(import.meta.url), "utf8")
697
+ .split("\n")
698
+ .filter((line) => !line.trim().startsWith("//"))
699
+ .join("\n");
700
+ const CREDENTIAL_SHAPED = /_(TOKEN|KEY|SECRET|PASSWORD|CREDENTIALS)$/;
701
+ const unknownCredentials = Object.keys(env)
702
+ .filter((name) => CREDENTIAL_SHAPED.test(name))
703
+ .filter((name) => !selfSource.includes(name) && !selfSource.includes(`${name.split("_")[0]}_`))
704
+ .sort();
705
+
706
+ // An essential can never be "not set up here" — that is the whole point of the tier. Do
707
+ // this BEFORE partitioning, or a promoted failure is counted and never printed.
708
+ for (const missing of results.filter((r) => r.status === "skip" && ESSENTIAL.has(r.name))) {
709
+ missing.status = "FAIL";
710
+ missing.detail = `${missing.detail} — the agent cannot run without it`;
711
+ }
712
+ const configured = results.filter((r) => r.status !== "skip");
713
+ const unconfigured = results.filter((r) => r.status === "skip");
714
+ const pad = Math.max(...configured.map((r) => r.name.length), 1) + 2;
617
715
  console.log(
618
- `\nCredential check — ${onCloudRun ? "Cloud Run (all surfaces)" : "local (metadata-server surfaces skipped)"}\n`,
716
+ `\nCredential check — ${onCloudRun ? "Cloud Run (all surfaces)" : "local (metadata-server surfaces skipped)"}`,
619
717
  );
620
- for (const r of results) {
621
- const icon = r.status === "ok" ? "✅" : r.status === "FAIL" ? "❌" : "⏭️ ";
622
- console.log(`${icon} ${r.name.padEnd(pad)} ${r.status === "ok" ? r.detail : r.detail && `— ${r.detail}`}`);
718
+ const line = (r) => {
719
+ const icon = r.status === "ok" ? "✅" : "❌";
720
+ console.log(` ${icon} ${r.name.padEnd(pad)} ${r.status === "ok" ? r.detail : r.detail && `— ${r.detail}`}`);
721
+ };
722
+ // Split, because a failure in the first group is an outage and a failure in the second is
723
+ // one job doing less. Reading them in one list makes those look like the same news.
724
+ const essential = configured.filter((r) => ESSENTIAL.has(r.name));
725
+ const integrations = configured.filter((r) => !ESSENTIAL.has(r.name));
726
+ if (essential.length) {
727
+ console.log("\nWithout these there is no agent at all");
728
+ essential.forEach(line);
729
+ }
730
+ if (integrations.length) {
731
+ console.log("\nWhat it can reach");
732
+ integrations.forEach(line);
623
733
  }
624
734
  const failures = results.filter((r) => r.status === "FAIL");
625
- console.log(
626
- `\n${results.filter((r) => r.status === "ok").length} ok, ${failures.length} failed, ${results.filter((r) => r.status === "skip").length} skipped`,
627
- );
735
+ console.log(`\n${configured.length - failures.length} working, ${failures.length} failing.`);
736
+ if (unknownCredentials.length) {
737
+ // Names only. This script never prints a value, and that holds hardest for the ones it
738
+ // does not understand.
739
+ console.log(`\nSet, but nothing here knows how to test them (${unknownCredentials.length}):`);
740
+ console.log(` ${unknownCredentials.join(", ")}`);
741
+ console.log(" Each should have a row in ENVIRONMENT.md saying which skill reaches it.");
742
+ }
743
+ if (unconfigured.length) {
744
+ console.log(`\nNot set up here (${unconfigured.length}): ${unconfigured.map((r) => r.name).join(", ")}.`);
745
+ console.log("The engine ships more integrations than any deployment uses — `connect` lists them.");
746
+ }
628
747
  process.exit(failures.length ? 1 : 0);