eve-hub 0.16.13 → 0.16.15

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.
@@ -0,0 +1,9 @@
1
+ // The Vercel Connect connector that holds the workspace human's GitHub token (the eve-hub-github
2
+ // App). The bots' `git__gh` (prepare.mjs) and main's dash pass (schedules/dash-run.mjs) ask it for
3
+ // the same subject, so both act as the human. Its own module so a schedule can import it without
4
+ // prepare.mjs's build-time imports.
5
+ export const GITHUB_CONNECTOR = "github/eve-hub-github";
6
+
7
+ // The subject eve's connections resolve for a hub turn (channels/eve-roster-user.ts: principal
8
+ // `roster:<human>`, authenticator `roster`, no issuer of its own, so eve uses the authenticator).
9
+ export const rosterSubject = (human) => ({ type: "user", id: `roster:${human}`, issuer: "roster" });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.16.13",
3
+ "version": "0.16.15",
4
4
  "license": "Apache-2.0",
5
5
  "description": "Bot-side code shared by every hub agent: registry tools, the roster hook, the eve channel, the host sandbox and browser, and model helpers.",
6
6
  "type": "module",
package/prepare.mjs CHANGED
@@ -28,6 +28,8 @@ import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory }
28
28
  import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
29
29
  import { architectureText } from "./team.mjs";
30
30
  import { sandboxReady } from "./sandbox-migrate.mjs";
31
+ import { GITHUB_CONNECTOR } from "./github-connector.mjs";
32
+ export { GITHUB_CONNECTOR };
31
33
 
32
34
  // eve's git extension, for each bot of a build from a workspace repo that edits files: its `git__gh` tool runs
33
35
  // git, gh and gh-signed-commit against the workspace's org with the human's own GitHub token,
@@ -36,7 +38,6 @@ import { sandboxReady } from "./sandbox-migrate.mjs";
36
38
  // hub turn runs as the workspace's human (channels/eve-roster-user.ts), so pushes, PRs and commits
37
39
  // are theirs. The shell holds no credential. A build without a repo (an uploaded deployment)
38
40
  // mounts none.
39
- export const GITHUB_CONNECTOR = "github/eve-hub-github";
40
41
  export const GIT_EXTENSION_FILE = "extensions/git.ts";
41
42
  // The read-only preset's stub (lib/agent-template.mjs): eve's write_file, disabled.
42
43
  const READ_ONLY_FILE = "tools/write_file.ts";
@@ -23,6 +23,8 @@ export const PLAN_TIMEOUT_MS = 240_000;
23
23
  const MS_DAY = 86_400_000;
24
24
  const PRS_PER_CARD = 6;
25
25
  export const LINE_MAX = 90;
26
+ // The wire's PLAN_BASIS_MAX (SetDashPlan.basis); test/dash-plan.test.mjs keeps the two equal.
27
+ export const PLAN_BASIS_MAX = 100_000;
26
28
 
27
29
  const humanOf = (snap) => snap?.humans?.[0]?.name ?? "me";
28
30
  const isHuman = (m, human) => m.author === human || m.authorKind === "human";
@@ -308,6 +310,12 @@ export async function runPlan(hub, { generate, decisions = "", sizes = {}, now =
308
310
  const reason = replanReason(s, now);
309
311
  if (!reason) return { planned: null, reason: "plan is current" };
310
312
  if (!waitingThreads(s).length) return { planned: null, reason: "nothing waits on the human" };
313
+ const basis = planBasis(s, now);
314
+ if (basis.length > PLAN_BASIS_MAX) {
315
+ const why = `${JSON.parse(basis).asks.length} asks exceed the plan basis limit`;
316
+ log?.warn?.(`dash plan: skipped, ${why}`);
317
+ return { planned: null, reason: why };
318
+ }
311
319
  let plan;
312
320
  try {
313
321
  const cards = planCards(s, { now, sizes });
@@ -318,7 +326,7 @@ export async function runPlan(hub, { generate, decisions = "", sizes = {}, now =
318
326
  return { planned: null, reason: String(err?.message ?? err) };
319
327
  }
320
328
  if (!plan.chunks.length && !plan.later) return { planned: null, reason: "no asks" };
321
- const { id } = await hub.dashPlan(planBasis(s, now), plan);
329
+ const { id } = await hub.dashPlan(basis, plan);
322
330
  log?.log?.(`dash plan: ${id} (${reason}): ${plan.chunks.length} chunks, ${planned(plan).length} rows`);
323
331
  return { planned: id, reason };
324
332
  }
@@ -1,15 +1,23 @@
1
- // One tick of main's dash schedule (schedules/dash.ts): the `gh` probe and
1
+ // One tick of main's dash schedule (schedules/dash.ts): the GitHub probe and
2
2
  // the run around it. A failed GitHub call never fails the tick: the probe is
3
3
  // retried once on a 5xx or a timeout, and if it still fails every PR keeps
4
4
  // what was last known (nothing said of any), threads and waits are still
5
5
  // scanned and posted, and the post carries a one-line note of what was
6
6
  // skipped. Only then is the Dash's checkedAt left alone, so the sidebar shows
7
- // the Dash stale while GitHub stays unreachable (gh logged out, offline).
7
+ // the Dash stale while GitHub stays unreachable (no token, offline).
8
+ //
9
+ // The probe is one GraphQL query over HTTPS, as the workspace human: the
10
+ // search is `author:@me`, so it needs their own user token, never an App's.
11
+ // A hosted hub's function has no `gh`, so it asks Vercel Connect for the
12
+ // token the bots' `git__gh` gets (github-connector.mjs); a local hub asks
13
+ // `gh auth token`. The token goes in the request header and nowhere else.
8
14
  //
9
15
  // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
10
16
  // tests (plain node) import the same module.
11
17
  import { execFile } from "node:child_process";
12
18
  import { createHash } from "node:crypto";
19
+ import { NoValidTokenError, UserAuthorizationRequiredError, getToken } from "@vercel/connect";
20
+ import { GITHUB_CONNECTOR, rosterSubject } from "../github-connector.mjs";
13
21
  import { runOnce } from "./hub.mjs";
14
22
  import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
15
23
 
@@ -17,12 +25,16 @@ import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs
17
25
  // SCHEDULE_DISPATCH_TIMEOUT_MS): two 20 s tries and the registry calls fit.
18
26
  export const GH_TIMEOUT_MS = 20_000;
19
27
  export const GH_RETRY_DELAY_MS = 1_000;
28
+ // The token comes first, once a tick, and leaves the two tries their 20 s.
29
+ export const TOKEN_TIMEOUT_MS = 5_000;
30
+ export const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
20
31
 
21
32
  // A failure worth one more try: GitHub's 5xx, or no answer in time.
22
33
  export const retryable = (err) => !!(err?.timedOut || /HTTP 5\d\d|timed? ?out|ETIMEDOUT|ECONNRESET|unexpected EOF/i.test(String(err?.message ?? "")));
23
34
 
24
- // What a failure is called in the tick's note: "gh 504", "gh timeout", …
35
+ // What a failure is called in the tick's note: "gh 504", "gh timeout", "GitHub approval needed", …
25
36
  export function ghReason(err) {
37
+ if (err instanceof UserAuthorizationRequiredError || err instanceof NoValidTokenError) return "GitHub approval needed";
26
38
  const msg = String(err?.message ?? "");
27
39
  const http = msg.match(/HTTP (\d{3})/);
28
40
  if (http) return `gh ${http[1]}`;
@@ -32,19 +44,23 @@ export function ghReason(err) {
32
44
  return "gh failed";
33
45
  }
34
46
 
35
- // One `gh api graphql` call: the parsed answer, or an Error (`timedOut` set
36
- // when gh was killed for taking too long). A PR asked about by name that's
37
- // gone fails only its own alias: gh exits non-zero but the rest of the
38
- // answer is good, so any answer with the search in it counts.
39
- export function ghGraphql(query, { timeoutMs = GH_TIMEOUT_MS, bin = "gh" } = {}) {
47
+ const timedOut = (what) => Object.assign(new Error(`gh: ${what} timed out`), { timedOut: true });
48
+
49
+ // The workspace human's GitHub user token. Hosted: Vercel Connect's, for the
50
+ // subject `git__gh` resolves (`roster:<human>`); its errors pass through, so
51
+ // a missing approval reads as one. Local: `gh auth token`.
52
+ export function githubToken(human, { hosted = Boolean(process.env.VERCEL), connect = getToken, bin = "gh", timeoutMs = TOKEN_TIMEOUT_MS } = {}) {
53
+ if (hosted) {
54
+ if (!human) return Promise.reject(new Error("gh: the hub names no human to ask GitHub as"));
55
+ let timer;
56
+ const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(timedOut("Vercel Connect")), timeoutMs); });
57
+ return Promise.race([connect(GITHUB_CONNECTOR, { subject: rosterSubject(human) }), deadline]).finally(() => clearTimeout(timer));
58
+ }
40
59
  return new Promise((resolve, reject) => {
41
- execFile(bin, ["api", "graphql", "-f", `query=${query}`], { timeout: timeoutMs, maxBuffer: 4 << 20 }, (error, stdout, stderr) => {
42
- let body = null;
43
- try {
44
- body = JSON.parse(stdout);
45
- } catch {}
46
- if (body?.data?.search) return resolve(body);
47
- const err = new Error(`gh: ${String(stderr || error?.message || "no answer").trim().replace(/^gh: /, "").slice(0, 300)}`);
60
+ execFile(bin, ["auth", "token"], { timeout: timeoutMs }, (error, stdout, stderr) => {
61
+ const token = String(stdout ?? "").trim();
62
+ if (!error && token) return resolve(token);
63
+ const err = new Error(`gh: ${String(stderr || error?.message || "no token").trim().replace(/^gh: /, "").slice(0, 300)}`);
48
64
  if (error?.killed || error?.signal) err.timedOut = true;
49
65
  if (error?.code === "ENOENT") err.code = "ENOENT";
50
66
  reject(err);
@@ -52,6 +68,44 @@ export function ghGraphql(query, { timeoutMs = GH_TIMEOUT_MS, bin = "gh" } = {})
52
68
  });
53
69
  }
54
70
 
71
+ // One GraphQL query: the parsed answer, or an Error ("gh: HTTP 502: …", or
72
+ // `timedOut` set when GitHub didn't answer in time). A PR asked about by name
73
+ // that's gone fails only its own alias and the rest of the answer is good, so
74
+ // any answer with the search in it counts.
75
+ export async function ghGraphql(query, { token, timeoutMs = GH_TIMEOUT_MS, fetchImpl = fetch } = {}) {
76
+ let res;
77
+ let text;
78
+ try {
79
+ res = await fetchImpl(GITHUB_GRAPHQL_URL, {
80
+ method: "POST",
81
+ headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "eve-hub-dash" },
82
+ body: JSON.stringify({ query }),
83
+ signal: AbortSignal.timeout(timeoutMs),
84
+ });
85
+ text = await res.text();
86
+ } catch (cause) {
87
+ if (cause?.name === "TimeoutError" || cause?.name === "AbortError") throw timedOut("GitHub");
88
+ throw new Error(`gh: ${String(cause?.message ?? cause).slice(0, 200)}${cause?.cause?.code ? ` (${cause.cause.code})` : ""}`);
89
+ }
90
+ let body = null;
91
+ try {
92
+ body = JSON.parse(text);
93
+ } catch {}
94
+ if (body?.data?.search) return body;
95
+ const why = String(body?.errors?.[0]?.message ?? body?.message ?? text ?? "").trim() || "no answer";
96
+ throw new Error(`gh: ${res.ok ? "" : `HTTP ${res.status}: `}${why.slice(0, 300)}`);
97
+ }
98
+
99
+ // The tick's probe: the token asked for once (again only after it failed), each query retried once.
100
+ export function githubProbe(tokenOf, { delayMs = GH_RETRY_DELAY_MS, fetchImpl = fetch, timeoutMs = GH_TIMEOUT_MS } = {}) {
101
+ let token = null;
102
+ const once = () => (token ??= tokenOf().catch((err) => {
103
+ token = null;
104
+ throw err;
105
+ }));
106
+ return (query) => withRetry(async () => ghGraphql(query, { token: await once(), fetchImpl, timeoutMs }), { delayMs });
107
+ }
108
+
55
109
  // `call` retried once (after `delayMs`) when it fails in a way worth retrying.
56
110
  export async function withRetry(call, { delayMs = GH_RETRY_DELAY_MS } = {}) {
57
111
  try {
@@ -66,16 +120,18 @@ export async function withRetry(call, { delayMs = GH_RETRY_DELAY_MS } = {}) {
66
120
  const hashOf = (text) => createHash("sha256").update(text).digest("base64url").slice(0, 22);
67
121
  const plural = (n) => `${n} PR${n === 1 ? "" : "s"}`;
68
122
 
69
- // One tick. `probe(query)` -> the GraphQL answer (rejects on failure).
123
+ // One tick. `probe(query)` -> the GraphQL answer (rejects on failure); by default GitHub's, as the snapshot's human
124
+ // (`token(human)`: githubToken).
70
125
  // Returns { ok, sizes }: ok is false when the PRs couldn't be fetched at all; sizes is each PR's title and
71
126
  // diff size ("owner/repo#12" -> { title, add, del, files }) as GitHub said this tick, for the plan's cards.
72
- export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)), now = Date.now(), log = console } = {}) {
127
+ export async function runDash(hub, { probe, token = githubToken, now = Date.now(), log = console } = {}) {
73
128
  let ok = true;
74
129
  let prFacts = {};
75
130
  const sizes = {};
76
131
  await runOnce(hub, "dash", async (snap, saved, at) => {
77
132
  const { refs, reviewed, keep: waited } = dashWatchList(snap, saved);
78
133
  const tracked = new Set(Object.keys(dashPrs(saved)));
134
+ const ask = probe ?? githubProbe(() => token(snap?.humans?.[0]?.name));
79
135
  const facts = {};
80
136
  let prs = null;
81
137
  let skip = new Set();
@@ -83,7 +139,7 @@ export async function runDash(hub, { probe = (q) => withRetry(() => ghGraphql(q)
83
139
  try {
84
140
  const chunks = [];
85
141
  for (let i = 0; i < Math.max(refs.length, 1); i += REFS_PER_QUERY) chunks.push(refs.slice(i, i + REFS_PER_QUERY));
86
- const answers = await Promise.all(chunks.map((c) => probe(dashQuery(c))));
142
+ const answers = await Promise.all(chunks.map((c) => ask(dashQuery(c))));
87
143
  const found = answers.flatMap(prsFromSearch);
88
144
  const searched = new Set(answers.flatMap((a) => prsFromSearch({ data: { search: a?.data?.search } })).map((p) => p.key));
89
145
  prs = found.filter((p) => !reviewed.has(p.key) || searched.has(p.key) || tracked.has(p.key) || waited.has(p.key));
@@ -21,7 +21,7 @@ export type Hub = {
21
21
  dashPlan(basis: string, plan: object): Promise<Ok & { id: string }>;
22
22
  dashEntries(entries: object[], remove?: string[], notice?: Notice | null): Promise<Ok & { entries: DashEntries }>;
23
23
  };
24
- export function createHub(options?: { base?: string; agent?: string; fetchImpl?: typeof fetch }): Hub;
24
+ export function createHub(options?: { base?: string; agent?: string; fetchImpl?: typeof fetch; log?: Pick<Console, "log"> }): Hub;
25
25
  export const POST_MAX_BYTES: number;
26
26
  export const POSTS_PER_COMMIT: number;
27
27
  export function toPosts(notices: string[]): string[];
package/schedules/hub.mjs CHANGED
@@ -8,7 +8,7 @@
8
8
  import { createHash } from "node:crypto";
9
9
  import { registryBase, registryFetch } from "../registry-fetch.mjs";
10
10
 
11
- export function createHub({ base = registryBase(), agent = "main", fetchImpl = fetch } = {}) {
11
+ export function createHub({ base = registryBase(), agent = "main", fetchImpl = fetch, log = console } = {}) {
12
12
  const call = async (path, init = {}) => {
13
13
  const res = await registryFetch(path, { ...init, headers: { "content-type": "application/json", ...(init.headers ?? {}) } }, { base, fetchImpl, deadlineMs: 10_000 });
14
14
  const text = await res.text();
@@ -45,7 +45,10 @@ export function createHub({ base = registryBase(), agent = "main", fetchImpl = f
45
45
  track: async (id, fn) => {
46
46
  const started = await call(`${statePath(id).slice(0, -"/state".length)}/runs`, { method: "POST", body: "{}" }).catch(() => null);
47
47
  if (!started?.ok) return fn();
48
- if (started.body.paused) return;
48
+ if (started.body.paused) {
49
+ log.log(`schedule ${agent}/${id} skipped: paused`);
50
+ return;
51
+ }
49
52
  run = { id, run: started.body.run };
50
53
  const end = (error) => call(`${statePath(id).slice(0, -"/state".length)}/runs/${encodeURIComponent(run.run)}/end`, { method: "POST", body: JSON.stringify(error ? { error: error.slice(0, 2000) } : {}) }).catch(() => null);
51
54
  try {