eve-hub 0.16.21 → 0.16.22

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.
@@ -1,347 +0,0 @@
1
- // The decisions behind main's dash schedule (schedules/dash.ts), pure: each
2
- // takes the registry's /api/state snapshot (`snap`), the schedule's saved
3
- // state and the time. No I/O here; schedules/hub.mjs does that.
4
- //
5
- // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
6
- // tests (plain node) import the same module.
7
-
8
- const parse = (iso) => {
9
- const ms = Date.parse(iso ?? "");
10
- return Number.isFinite(ms) ? ms : null;
11
- };
12
- const link = (thread) => `[${String(thread?.title ?? thread?.id ?? "?").replace(/[[\]]/g, "")}](${thread?.id})`;
13
- import { said, lastSaid } from "./thread-brief.mjs";
14
- import { coordinatorOf } from "../workspace.mjs";
15
-
16
- // ---------------------------------------------------------------------------
17
- // dash: what changed since the last scan that could put something on the
18
- // human's Dash (hosted-hub/src/domain/dash.mjs), with no model call. Four parts, each kept
19
- // exactly (by id, not time) in the saved state:
20
- // p the human's PRs: CI, review, draft, merge conflicts, open/merged/closed,
21
- // and every PR a Dash item waits on, whoever wrote it. A PR that leaves
22
- // the search's page while still open stays tracked (asked by name) until
23
- // it merges or closes. { "owner/repo": { "12": packed } }
24
- // n threads with a pending request to the human (a question or an
25
- // approval a bot asked for). { threadId: "w:" + the pending request
26
- // ids }. A bot's `waiting` status alone isn't one: it also means
27
- // waiting on its own tasks. A thread that waits on the human in what was said is
28
- // the classifier's call (the Dash model's verdict, see asksNotice in
29
- // ./dash-classify.mjs), not this scan's. While a bot works there the
30
- // previous entry is kept, so a request made meanwhile posts once the
31
- // thread settles.
32
- // d each Dash item's thread once it settles (nobody working): its last
33
- // message, so main hears when a thread it listed moved on. An item new
34
- // since the last scan is compared with what was said after main added
35
- // it (item.addedAt).
36
- // w Dash items whose waitingOn is met: the PR merged (or closed) or went
37
- // green, the thread got a message, the date passed. { threadId: reason }
38
- // The saved state is all four plus their hash; a notice goes out only when
39
- // one of them says something new (the hash changes on any change, so the
40
- // state is saved then even when there's nothing to say, e.g. a bot started).
41
- // The schedule-state cap is 256 KB (registry/server.mjs): about 2,000 PRs or
42
- // 8,000 asking threads. Over it, PR titles go first; past that the commit is
43
- // refused, nothing posts and the sidebar shows the Dash stale.
44
- const prUrl = (key) => {
45
- const m = key.match(/^([^/]+\/[^#]+)#(\d+)$/);
46
- return m ? `https://github.com/${m[1]}/pull/${m[2]}` : "";
47
- };
48
- const short = (pr) => ({ t: String(pr.title ?? "").replace(/\s+/g, " ").trim().slice(0, 40), d: !!pr.draft, c: pr.ci ?? "none", r: pr.review ?? "none", ...(pr.mergeable ? { m: pr.mergeable } : {}) });
49
- // A PR is packed as "c r d m x title": one-letter codes, `-` for none.
50
- const CODES = {
51
- c: { success: "s", failure: "f", pending: "p", error: "e", expected: "x", none: "-" },
52
- r: { approved: "a", changes_requested: "c", review_required: "r", none: "-" },
53
- m: { mergeable: "m", conflicting: "c" },
54
- x: { merged: "m", closed: "c" },
55
- };
56
- const DECODE = Object.fromEntries(Object.entries(CODES).map(([f, map]) => [f, Object.fromEntries(Object.entries(map).map(([k, v]) => [v, k]))]));
57
- const enc = (f, v) => (v == null ? "-" : CODES[f][v] ?? (String(v).replace(/\s+/g, "_") || "-"));
58
- const dec = (f, v) => DECODE[f][v] ?? v;
59
- export const packPr = (s, title = true) => `${enc("c", s.c)} ${enc("r", s.r)} ${s.d ? 1 : 0} ${enc("m", s.m)} ${enc("x", s.x)}${title && s.t ? ` ${s.t}` : ""}`;
60
- export function unpackPr(v) {
61
- if (v && typeof v === "object") return v; // saved before packing
62
- const [c, r, d, m, x, ...t] = String(v).split(" ");
63
- return { c: dec("c", c), r: dec("r", r), d: d === "1", ...(m !== "-" ? { m: dec("m", m) } : {}), ...(x !== "-" ? { x: dec("x", x) } : {}), t: t.join(" ") };
64
- }
65
- // { "owner/repo": { "12": v } } <-> { "owner/repo#12": v }; flat keys are the older form.
66
- const flatPrs = (p) => {
67
- const out = {};
68
- for (const [k, v] of Object.entries(p ?? {})) {
69
- if (k.includes("#")) out[k] = v;
70
- else for (const [n, x] of Object.entries(v ?? {})) out[`${k}#${n}`] = x;
71
- }
72
- return out;
73
- };
74
- const nestPrs = (flat) => {
75
- const out = {};
76
- for (const [key, v] of Object.entries(flat)) {
77
- const i = key.lastIndexOf("#");
78
- (out[key.slice(0, i)] ??= {})[key.slice(i + 1)] = v;
79
- }
80
- return out;
81
- };
82
- // A saved dash state's PRs, unpacked: { "owner/repo#12": { c, r, d, m?, x?, t } }.
83
- export const dashPrs = (state) => Object.fromEntries(Object.entries(flatPrs(state?.p ?? state?.snapshot)).map(([k, v]) => [k, unpackPr(v)]));
84
- export const DASH_STATE_MAX = 256 * 1024;
85
-
86
- // The PR part. `prev`: the saved `p`; `prs`: [{ key, title, url, draft, ci,
87
- // review, mergeable?, state? }] from the probe, or null when the probe failed
88
- // (GitHub down, gh logged out): then every PR keeps what was last known and
89
- // nothing is said of any. `keep`: keys tracked even once merged or closed
90
- // (what Dash items wait on); `skip`: keys the probe couldn't answer this time
91
- // (their alias failed for a reason other than gone), kept as last known.
92
- // Returns the lines, `snapshot` (the new `p`), `seen` (unpacked, for waitMet)
93
- // and `untitled()`.
94
- export function dashDiff(prev, prs, keep = new Set(), skip = new Set()) {
95
- const before = dashPrs({ p: prev });
96
- const seen = {};
97
- const lines = [];
98
- const pack = (title) => nestPrs(Object.fromEntries(Object.entries(seen).map(([k, s]) => [k, packPr(s, title)])));
99
- if (prs == null) {
100
- Object.assign(seen, before);
101
- return { lines, seen, snapshot: pack(true), untitled: () => pack(false) };
102
- }
103
- const label = (key, s) => `[${key}](${s.u ?? prUrl(key)})${s.t ? ` ${s.t}` : ""}`;
104
- const got = new Map();
105
- for (const pr of prs) if (!got.has(pr.key) || pr.state) got.set(pr.key, pr); // the alias answer has state; search's doesn't need it
106
- for (const [key, pr] of got) {
107
- const was = before[key];
108
- // Skipped before anything is read: GraphQL can answer a PR whose failed
109
- // field came back null (commits: null reads as CI none), so a skipped
110
- // PR's node is never trusted. It keeps what was last known, or stays
111
- // untracked, and nothing is said of it.
112
- if (skip.has(key)) {
113
- if (was) seen[key] = was;
114
- continue;
115
- }
116
- const s = short(pr);
117
- const shown = { ...s, u: pr.url, t: String(pr.title ?? "").slice(0, 60) };
118
- // GitHub answers UNKNOWN while it computes mergeability: keep what we knew.
119
- if (s.m === "unknown" && was?.m) s.m = was.m;
120
- if (s.m === "unknown") delete s.m;
121
- const closed = pr.state === "merged" || pr.state === "closed";
122
- if (closed) {
123
- if (was && !was.x) lines.push(`${pr.state}: ${label(key, shown)}`);
124
- if (keep.has(key)) seen[key] = { ...s, x: pr.state };
125
- continue;
126
- }
127
- seen[key] = s;
128
- if (!was) {
129
- lines.push(`new: ${label(key, shown)} (CI ${s.c}, review ${s.r}${s.d ? ", draft" : ""}${s.m === "conflicting" ? ", merge conflicts" : ""})`);
130
- continue;
131
- }
132
- const changes = [];
133
- if (was.x) changes.push("reopened");
134
- if (was.c !== s.c) changes.push(`CI ${was.c} → ${s.c}`);
135
- if (was.r !== s.r) changes.push(`review ${was.r} → ${s.r}`);
136
- if (was.d !== s.d) changes.push(s.d ? "back to draft" : "ready for review");
137
- if (was.m && s.m && was.m !== s.m) changes.push(s.m === "conflicting" ? "merge conflicts" : "conflicts resolved");
138
- if (!was.m && s.m === "conflicting") changes.push("merge conflicts");
139
- if (changes.length) lines.push(`${label(key, shown)}: ${changes.join(", ")}`);
140
- }
141
- // Tracked and not answered at all (deleted, access lost, or the probe's
142
- // alias failed): told once, then dropped.
143
- for (const [key, s] of Object.entries(before)) {
144
- if (got.has(key)) continue;
145
- if (skip.has(key)) seen[key] = s;
146
- else if (!s.x) lines.push(`closed or merged: ${label(key, s)}`);
147
- }
148
- return { lines, seen, snapshot: pack(true), untitled: () => pack(false) };
149
- }
150
-
151
- const excerpt = (text, n = 100) => {
152
- const t = String(text ?? "").replace(/\s+/g, " ").trim();
153
- return t.length > n ? `${t.slice(0, n - 1)}…` : t;
154
- };
155
-
156
- // Threads with a pending request to the human: [{ id, sig, working }]. `sig` names the wait exactly: "w:" + the thread's
157
- // pending request ids.
158
- function asking(snap) {
159
- const pending = {};
160
- for (const r of Object.values(snap?.requests ?? {})) if (r.status === "pending" && r.threadId) (pending[r.threadId] ??= []).push(r.id);
161
- const out = [];
162
- for (const t of Object.values(snap?.threads ?? {})) {
163
- if (t.archived || t.schedule || t.kind === "dm") continue; // the sidebar's Dash lists threads, not DMs
164
- const status = Object.values(t.status ?? {});
165
- const working = status.includes("working");
166
- if (pending[t.id]?.length) out.push({ id: t.id, sig: `w:${(pending[t.id] ?? []).sort().join(",")}`, working });
167
- }
168
- return out;
169
- }
170
-
171
- // A Dash item's wait, met or not: the reason, or null. A PR wait is met when
172
- // it merges or closes, or (waitingOn.for "green") when its CI passes.
173
- function waitMet(item, snap, prs, nowMs) {
174
- const w = item.waitingOn;
175
- if (!w) return null;
176
- if (w.pr) {
177
- const s = prs[w.pr];
178
- if (s?.x) return `${w.pr} ${s.x}`;
179
- if (w.for === "green" && s?.c === "success") return `${w.pr} is green`;
180
- }
181
- if (w.thread) {
182
- const t = snap?.threads?.[w.thread];
183
- const since = parse(w.since) ?? 0;
184
- const coordinator = coordinatorOf(snap?.agents);
185
- const m = t && [...(t.messages ?? [])].reverse().find((x) => said(x) && x.author !== coordinator && (parse(x.at) ?? 0) > since);
186
- if (m) return `${link(t)} got a message from ${m.author}`;
187
- }
188
- if (w.until && (parse(w.until) ?? Infinity) <= nowMs) return `${w.until.slice(0, 16).replace("T", " ")} passed`;
189
- return null;
190
- }
191
-
192
- // The PRs the probe must ask about by name: what Dash items wait on, and
193
- // every PR tracked last time (so a merge is told apart from a close, and one
194
- // off the search's page is still watched).
195
- export function dashWatchList(snap, saved) {
196
- const waited = new Set((snap?.dash?.items ?? []).map((i) => i.waitingOn?.pr).filter(Boolean));
197
- const tracked = Object.keys(flatPrs(saved?.p ?? saved?.snapshot));
198
- // The PRs a thread's latest review is about (entry.package.pr): asked in the same query so the
199
- // Ready rule reads their head and CI, but not tracked, so their changes never post.
200
- const reviewed = Object.values(snap?.dash?.entries ?? {}).map((e) => e.package?.pr).filter(Boolean);
201
- return { keep: waited, refs: [...new Set([...waited, ...tracked, ...reviewed])], reviewed: new Set(reviewed) };
202
- }
203
-
204
- // `prs` null: the probe failed, PRs keep what was last known (dashDiff).
205
- // `skip`: PR keys whose own alias failed this time.
206
- export function dashScan(snap, saved, prs, nowMs, hashOf, skip = new Set()) {
207
- const { keep } = dashWatchList(snap, saved);
208
- const pr = dashDiff(saved?.p ?? saved?.snapshot ?? null, prs, keep, skip);
209
- const lines = [...pr.lines];
210
- const threads = snap?.threads ?? {};
211
- const items = snap?.dash?.items ?? [];
212
- const coordinator = coordinatorOf(snap?.agents);
213
- const legacy = saved && saved.na !== undefined; // the short-lived time-based form: start over
214
-
215
- // Asks, by identity. A thread a bot is working in keeps its previous entry
216
- // (or none), so an ask made meanwhile is judged, and posted, once it settles.
217
- // Only request waits ("w:"): asks read from messages (a message id, the
218
- // older form) are the classifier's now, dropped without a notice.
219
- const wasN = legacy ? {} : Object.fromEntries(Object.entries(saved?.n ?? {}).filter(([, sig]) => String(sig).startsWith("w:")));
220
- const n = {};
221
- const askingNow = new Set();
222
- for (const a of asking(snap)) {
223
- if (a.working) {
224
- if (wasN[a.id] !== undefined) n[a.id] = wasN[a.id];
225
- continue;
226
- }
227
- n[a.id] = a.sig;
228
- askingNow.add(a.id);
229
- if (wasN[a.id] === a.sig) continue;
230
- const t = threads[a.id];
231
- lines.push(`asks you: ${link(t)}, a bot is waiting on your answer or approval`);
232
- }
233
- // A thread that asked and no longer does keeps no entry, unless a bot is
234
- // working there: then it's judged when it settles.
235
- for (const [id, sig] of Object.entries(wasN)) if (n[id] === undefined && Object.values(threads[id]?.status ?? {}).includes("working")) n[id] = sig;
236
- const noLonger = new Set();
237
- const shown = new Map(items.map((i) => [i.threadId, i]));
238
- for (const id of Object.keys(wasN)) if (!n[id] && shown.get(id)?.ball === "you" && threads[id]) {
239
- noLonger.add(id);
240
- lines.push(`no longer asking you: ${link(threads[id])}`);
241
- }
242
-
243
- const d = {};
244
- for (const item of items) {
245
- const t = threads[item.threadId];
246
- if (!t) continue;
247
- const working = Object.values(t.status ?? {}).includes("working");
248
- let was = legacy ? undefined : saved?.d?.[item.threadId];
249
- // An item new since the last scan: its baseline is the thread as the coordinator
250
- // saw it when adding it, so anything said since (by anyone else) counts.
251
- if (was === undefined && saved && item.addedAt) {
252
- const since = parse(item.addedAt) ?? Infinity;
253
- was = (t.messages ?? []).some((x) => said(x) && x.author !== coordinator && (parse(x.at) ?? 0) > since) ? "~" : undefined;
254
- }
255
- // Working: judged once it settles. A thread first seen working is marked
256
- // "~", so its first settled message counts as moving on.
257
- if (working) {
258
- d[item.threadId] = was ?? "~";
259
- continue;
260
- }
261
- const m = lastSaid(t);
262
- d[item.threadId] = t.archived ? "archived" : (m?.id ?? "-");
263
- if (was === undefined || was === d[item.threadId]) continue;
264
- if (t.archived) lines.push(`archived: ${link(t)} is still on the Dash`);
265
- else if (m && !askingNow.has(item.threadId) && !noLonger.has(item.threadId) && m.author !== coordinator) lines.push(`moved on: ${link(t)} (${item.ball}), ${m.author}: "${excerpt(m.text, 80)}"`);
266
- }
267
-
268
- const w = {};
269
- for (const item of items) {
270
- const reason = waitMet(item, snap, pr.seen, nowMs);
271
- if (!reason) continue;
272
- w[item.threadId] = reason;
273
- if (saved?.w?.[item.threadId] !== reason) lines.push(`wait met: ${link(threads[item.threadId] ?? { id: item.threadId })}, ${reason}`);
274
- }
275
-
276
- // PR titles only label notices: dropped first if the state nears the cap.
277
- let parts = { p: pr.snapshot, n, d, w };
278
- if (Buffer.byteLength(JSON.stringify(parts)) > DASH_STATE_MAX - 1024) parts = { ...parts, p: pr.untitled() };
279
- const hash = hashOf(JSON.stringify(parts));
280
- const state = { ...parts, hash };
281
- return { lines, state, changed: hash !== saved?.hash };
282
- }
283
-
284
- // The probe's GraphQL answer -> dashDiff's `prs`: the search's open PRs and
285
- // each PR asked about by name (aliases p0, p1, …: repository.pullRequest).
286
- const prOf = (n) => ({
287
- key: `${n.repository?.nameWithOwner ?? "?"}#${n.number}`,
288
- title: n.title,
289
- url: n.url,
290
- head: n.headRefOid ?? null,
291
- ...(Number.isInteger(n.changedFiles) ? { size: { add: n.additions, del: n.deletions, files: n.changedFiles } } : {}),
292
- draft: !!n.isDraft,
293
- ci: String(n.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? "none").toLowerCase(),
294
- review: String(n.reviewDecision ?? "none").toLowerCase(),
295
- ...(n.mergeable ? { mergeable: String(n.mergeable).toLowerCase() } : {}),
296
- ...(n.state ? { state: n.merged || n.state === "MERGED" ? "merged" : String(n.state).toLowerCase() } : {}),
297
- });
298
-
299
- // The PR keys whose answer failed, wholly or in part, for a reason other
300
- // than the PR or repo being gone. GraphQL errors carry the path: an alias
301
- // ("p3", dashQuery's order of `refs`) or a search node ("search", "nodes", 4).
302
- // NOT_FOUND / FORBIDDEN mean gone or no access (told closed, then dropped);
303
- // anything else (a timeout, an internal error) is a failed fetch, skipped,
304
- // even when the node came back with only the failed field nulled.
305
- const GONE = new Set(["NOT_FOUND", "FORBIDDEN"]);
306
- export function failedRefs(data, refs = []) {
307
- const asked = refs.filter((ref) => REF.test(ref)); // dashQuery's aliases, in order
308
- const nodes = data?.data?.search?.nodes ?? [];
309
- const out = new Set();
310
- for (const e of data?.errors ?? []) {
311
- if (GONE.has(e?.type)) continue;
312
- const path = e?.path ?? [];
313
- const m = /^p(\d+)$/.exec(String(path[0] ?? ""));
314
- if (m && asked[Number(m[1])]) out.add(asked[Number(m[1])]);
315
- else if (path[0] === "search" && path[1] === "nodes" && Number.isInteger(path[2])) {
316
- const n = nodes[path[2]];
317
- if (n?.number && n.repository?.nameWithOwner) out.add(`${n.repository.nameWithOwner}#${n.number}`);
318
- }
319
- }
320
- return out;
321
- }
322
-
323
- export function prsFromSearch(data) {
324
- const nodes = data?.data?.search?.nodes ?? [];
325
- const named = Object.entries(data?.data ?? {})
326
- .filter(([k]) => /^p\d+$/.test(k))
327
- .map(([, v]) => v?.pullRequest);
328
- return [...nodes, ...named].filter((n) => n && n.number).map(prOf);
329
- }
330
-
331
- // The probe's query: the human's open PRs, plus `refs` ("owner/repo#12") by name.
332
- const PR_FIELDS = "number title url headRefOid additions deletions changedFiles isDraft state merged mergeable reviewDecision repository { nameWithOwner } commits(last: 1) { nodes { commit { statusCheckRollup { state } } } }";
333
- const REF = /^([\w.-]+)\/([\w.-]+)#(\d+)$/;
334
- // GitHub gives up on a GraphQL request after ~10 s (502/504); a chunk of 40
335
- // names takes ~4 s.
336
- export const REFS_PER_QUERY = 40;
337
- export function dashQuery(refs = []) {
338
- const named = refs
339
- .map((ref) => ref.match(REF))
340
- .filter(Boolean)
341
- .map(([, owner, name, number], i) => ` p${i}: repository(owner: "${owner}", name: "${name}") { pullRequest(number: ${number}) { ...F } }`);
342
- return `query {
343
- search(query: "is:pr is:open author:@me archived:false sort:updated-desc", type: ISSUE, first: 50) { nodes { ...F } }
344
- ${named.join("\n")}
345
- }
346
- fragment F on PullRequest { ${PR_FIELDS} }`;
347
- }
@@ -1,167 +0,0 @@
1
- // One tick of main's dash schedule (schedules/dash.ts): the GitHub probe and
2
- // the run around it. A failed GitHub call never fails the tick: the probe is
3
- // retried once on a 5xx or a timeout, and if it still fails every PR keeps
4
- // what was last known (nothing said of any), threads and waits are still
5
- // scanned and posted, and the post carries a one-line note of what was
6
- // skipped. Only then is the Dash's checkedAt left alone, so the sidebar shows
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.
14
- //
15
- // Plain JS on purpose: the schedule (TypeScript, bundled by eve) and the
16
- // tests (plain node) import the same module.
17
- import { execFile } from "node:child_process";
18
- import { createHash } from "node:crypto";
19
- import { NoValidTokenError, UserAuthorizationRequiredError, getToken } from "@vercel/connect";
20
- import { GITHUB_CONNECTOR, rosterSubject } from "../github-connector.mjs";
21
- import { runOnce } from "./hub.mjs";
22
- import { coordinatorOf } from "../workspace.mjs";
23
- import { REFS_PER_QUERY, dashPrs, dashQuery, dashScan, dashWatchList, failedRefs, prsFromSearch } from "./dash-rules.mjs";
24
-
25
- // The hub gives a schedule 60 s (registry/server.mjs
26
- // SCHEDULE_DISPATCH_TIMEOUT_MS): two 20 s tries and the registry calls fit.
27
- export const GH_TIMEOUT_MS = 20_000;
28
- export const GH_RETRY_DELAY_MS = 1_000;
29
- // The token comes first, once a tick, and leaves the two tries their 20 s.
30
- export const TOKEN_TIMEOUT_MS = 5_000;
31
- export const GITHUB_GRAPHQL_URL = "https://api.github.com/graphql";
32
-
33
- // A failure worth one more try: GitHub's 5xx, or no answer in time.
34
- export const retryable = (err) => !!(err?.timedOut || /HTTP 5\d\d|timed? ?out|ETIMEDOUT|ECONNRESET|unexpected EOF/i.test(String(err?.message ?? "")));
35
-
36
- // What a failure is called in the tick's note: "gh 504", "gh timeout", "GitHub approval needed", …
37
- export function ghReason(err) {
38
- if (err instanceof UserAuthorizationRequiredError || err instanceof NoValidTokenError) return "GitHub approval needed";
39
- const msg = String(err?.message ?? "");
40
- const http = msg.match(/HTTP (\d{3})/);
41
- if (http) return `gh ${http[1]}`;
42
- if (err?.timedOut || /timed? ?out|ETIMEDOUT/i.test(msg)) return "gh timeout";
43
- if (err?.code === "ENOENT") return "gh not installed";
44
- if (/auth|logged in|gh auth login/i.test(msg)) return "gh not logged in";
45
- return "gh failed";
46
- }
47
-
48
- const timedOut = (what) => Object.assign(new Error(`gh: ${what} timed out`), { timedOut: true });
49
-
50
- // The workspace human's GitHub user token. Hosted: Vercel Connect's, for the
51
- // subject `git__gh` resolves (`roster:<human>`); its errors pass through, so
52
- // a missing approval reads as one. Local: `gh auth token`.
53
- export function githubToken(human, { hosted = Boolean(process.env.VERCEL), connect = getToken, bin = "gh", timeoutMs = TOKEN_TIMEOUT_MS } = {}) {
54
- if (hosted) {
55
- if (!human) return Promise.reject(new Error("gh: the hub names no human to ask GitHub as"));
56
- let timer;
57
- const deadline = new Promise((_, reject) => { timer = setTimeout(() => reject(timedOut("Vercel Connect")), timeoutMs); });
58
- return Promise.race([connect(GITHUB_CONNECTOR, { subject: rosterSubject(human) }), deadline]).finally(() => clearTimeout(timer));
59
- }
60
- return new Promise((resolve, reject) => {
61
- execFile(bin, ["auth", "token"], { timeout: timeoutMs }, (error, stdout, stderr) => {
62
- const token = String(stdout ?? "").trim();
63
- if (!error && token) return resolve(token);
64
- const err = new Error(`gh: ${String(stderr || error?.message || "no token").trim().replace(/^gh: /, "").slice(0, 300)}`);
65
- if (error?.killed || error?.signal) err.timedOut = true;
66
- if (error?.code === "ENOENT") err.code = "ENOENT";
67
- reject(err);
68
- });
69
- });
70
- }
71
-
72
- // One GraphQL query: the parsed answer, or an Error ("gh: HTTP 502: …", or
73
- // `timedOut` set when GitHub didn't answer in time). A PR asked about by name
74
- // that's gone fails only its own alias and the rest of the answer is good, so
75
- // any answer with the search in it counts.
76
- export async function ghGraphql(query, { token, timeoutMs = GH_TIMEOUT_MS, fetchImpl = fetch } = {}) {
77
- let res;
78
- let text;
79
- try {
80
- res = await fetchImpl(GITHUB_GRAPHQL_URL, {
81
- method: "POST",
82
- headers: { authorization: `bearer ${token}`, "content-type": "application/json", "user-agent": "eve-hub-dash" },
83
- body: JSON.stringify({ query }),
84
- signal: AbortSignal.timeout(timeoutMs),
85
- });
86
- text = await res.text();
87
- } catch (cause) {
88
- if (cause?.name === "TimeoutError" || cause?.name === "AbortError") throw timedOut("GitHub");
89
- throw new Error(`gh: ${String(cause?.message ?? cause).slice(0, 200)}${cause?.cause?.code ? ` (${cause.cause.code})` : ""}`);
90
- }
91
- let body = null;
92
- try {
93
- body = JSON.parse(text);
94
- } catch {}
95
- if (body?.data?.search) return body;
96
- const why = String(body?.errors?.[0]?.message ?? body?.message ?? text ?? "").trim() || "no answer";
97
- throw new Error(`gh: ${res.ok ? "" : `HTTP ${res.status}: `}${why.slice(0, 300)}`);
98
- }
99
-
100
- // The tick's probe: the token asked for once (again only after it failed), each query retried once.
101
- export function githubProbe(tokenOf, { delayMs = GH_RETRY_DELAY_MS, fetchImpl = fetch, timeoutMs = GH_TIMEOUT_MS } = {}) {
102
- let token = null;
103
- const once = () => (token ??= tokenOf().catch((err) => {
104
- token = null;
105
- throw err;
106
- }));
107
- return (query) => withRetry(async () => ghGraphql(query, { token: await once(), fetchImpl, timeoutMs }), { delayMs });
108
- }
109
-
110
- // `call` retried once (after `delayMs`) when it fails in a way worth retrying.
111
- export async function withRetry(call, { delayMs = GH_RETRY_DELAY_MS } = {}) {
112
- try {
113
- return await call();
114
- } catch (err) {
115
- if (!retryable(err)) throw err;
116
- await new Promise((r) => setTimeout(r, delayMs));
117
- return call();
118
- }
119
- }
120
-
121
- const hashOf = (text) => createHash("sha256").update(text).digest("base64url").slice(0, 22);
122
- const plural = (n) => `${n} PR${n === 1 ? "" : "s"}`;
123
-
124
- // One tick. `probe(query)` -> the GraphQL answer (rejects on failure); by default GitHub's, as the snapshot's human
125
- // (`token(human)`: githubToken).
126
- // Returns { ok, sizes }: ok is false when the PRs couldn't be fetched at all; sizes is each PR's title and
127
- // diff size ("owner/repo#12" -> { title, add, del, files }) as GitHub said this tick, for the plan's cards.
128
- export async function runDash(hub, { probe, token = githubToken, now = Date.now(), log = console } = {}) {
129
- let ok = true;
130
- let prFacts = {};
131
- const sizes = {};
132
- await runOnce(hub, "dash", async (snap, saved, at) => {
133
- const { refs, reviewed, keep: waited } = dashWatchList(snap, saved);
134
- const tracked = new Set(Object.keys(dashPrs(saved)));
135
- const ask = probe ?? githubProbe(() => token(snap?.humans?.[0]?.name));
136
- const facts = {};
137
- let prs = null;
138
- let skip = new Set();
139
- let note = null;
140
- try {
141
- const chunks = [];
142
- for (let i = 0; i < Math.max(refs.length, 1); i += REFS_PER_QUERY) chunks.push(refs.slice(i, i + REFS_PER_QUERY));
143
- const answers = await Promise.all(chunks.map((c) => ask(dashQuery(c))));
144
- const found = answers.flatMap(prsFromSearch);
145
- const searched = new Set(answers.flatMap((a) => prsFromSearch({ data: { search: a?.data?.search } })).map((p) => p.key));
146
- prs = found.filter((p) => !reviewed.has(p.key) || searched.has(p.key) || tracked.has(p.key) || waited.has(p.key));
147
- for (const p of found) if (p.size) sizes[p.key] = { title: p.title, ...p.size };
148
- for (const p of found) if (reviewed.has(p.key)) facts[p.key] = { head: p.head ?? null, ci: p.ci };
149
- skip = new Set(answers.flatMap((a, i) => [...failedRefs(a, chunks[i])]));
150
- if (skip.size) note = `${plural(skip.size)} skipped: GitHub couldn't answer for ${[...skip].slice(0, 3).join(", ")}${skip.size > 3 ? ", …" : ""}`;
151
- } catch (err) {
152
- ok = false;
153
- const known = Object.keys(dashPrs(saved)).length;
154
- note = `${known ? `${plural(known)} skipped` : "PRs skipped"}: ${ghReason(err)}`;
155
- log?.warn?.(`dash: PRs skipped this tick: ${err?.message ?? err}`);
156
- }
157
- prFacts = facts;
158
- const { lines, state, changed } = dashScan(snap, saved, prs, at, hashOf, skip);
159
- // The note rides on a post that's going out anyway: a skip alone doesn't
160
- // wake main (a lasting outage shows as the Dash going stale).
161
- const body = lines.length ? (lines.length === 1 ? lines[0] : `${lines.length} changes\n${lines.map((l) => `- ${l}`).join("\n")}`) : null;
162
- const text = body ? `@${coordinatorOf(snap?.agents)} dash: ${body}${note ? `\n(${note})` : ""}` : null;
163
- return { posts: text ? [text] : [], state: changed ? { ...state, ...(saved?.pass ? { pass: saved.pass } : {}) } : saved, changed };
164
- }, { now });
165
- if (ok) await hub.dashChecked(prFacts);
166
- return { ok, sizes };
167
- }
@@ -1,11 +0,0 @@
1
- export declare function dashSchedule({ decisions }: {
2
- decisions: () => string;
3
- }): {
4
- cron: string;
5
- run(): Promise<void>;
6
- };
7
- declare const _default: {
8
- cron: string;
9
- run(): Promise<void>;
10
- };
11
- export default _default;
package/schedules/dash.js DELETED
@@ -1,30 +0,0 @@
1
- // main's dash schedule: every 8 minutes, and off-cron as soon as a bot's turn
2
- // ends (the hub fires it, so a question shows on the sidebar within seconds),
3
- // the one pass behind the Dash: the human's PRs, a model call per thread
4
- // whose facts changed (openai/gpt-6-luna-decisions through eve`s `decide`,
5
- // eve's gateway credentials) and a model call per track with news
6
- // (anthropic/claude-sonnet-5.5 through the AI SDK). The pass: runPass in
7
- // ./dash-pass.mjs. `decisions` gives the human's standing decisions for the plan: read from the
8
- // workspace's memory/ on the hub's machine (the default export), embedded at build time on a
9
- // hosted main, whose function has no workspace files (prepare.mjs writes that stub).
10
- import { defineSchedule } from "eve/schedules";
11
- import { decide } from "eve/ai";
12
- import { generateText, Output } from "ai";
13
- import { createHub } from "./hub.mjs";
14
- import { modelClassify } from "./dash-classify.mjs";
15
- import { dashPass } from "./dash-pass.mjs";
16
- import { PLAN_MODEL, PLAN_TIMEOUT_MS } from "./dash-plan.mjs";
17
- import { PlanOut } from "./dash-plan-schema.mjs";
18
- import { standingDecisions } from "./dash-decisions.mjs";
19
- import { modelSummarize } from "./track-summaries.mjs";
20
- export function dashSchedule({ decisions }) {
21
- return defineSchedule({
22
- cron: "*/8 * * * *",
23
- async run() {
24
- const hub = createHub();
25
- const plan = async (prompt) => (await generateText({ model: PLAN_MODEL, prompt, output: Output.object({ schema: PlanOut }), maxRetries: 1, abortSignal: AbortSignal.timeout(PLAN_TIMEOUT_MS) })).output;
26
- await hub.track("dash", () => dashPass(hub, { classify: modelClassify({ evaluate: decide }), summarize: modelSummarize({ generateText }), plan, decisions: decisions() }));
27
- },
28
- });
29
- }
30
- export default dashSchedule({ decisions: () => standingDecisions() });
@@ -1,28 +0,0 @@
1
- // Types for hub.mjs: main's schedules' client of the hub's HTTP API. Bodies
2
- // the schedules only pass through are `unknown`.
3
- import type { DashEntries } from '../../../hosted-hub/src/domain/dash.mjs';
4
-
5
- type Notice = { schedule: string; text: string; clientId?: string };
6
- type Post = { text: string; clientId?: string };
7
- type Ok = { ok: true };
8
- export type Hub = {
9
- snapshot(): Promise<Record<string, unknown>>;
10
- roster(): Promise<unknown>;
11
- load(id: string): Promise<unknown>;
12
- loadVersioned(id: string): Promise<{ state: unknown; savedAt: string | null; paused: boolean }>;
13
- save(id: string, state: unknown): Promise<unknown>;
14
- commit(id: string, withState: { state?: unknown }, posts: Post[]): Promise<unknown>;
15
- post(id: string, text: string, key?: string): Promise<unknown>;
16
- track<T>(id: string, fn: () => Promise<T>): Promise<T | undefined>;
17
- dashChecked(prFacts?: Record<string, { head: string | null; ci: string }>): Promise<Ok & { checkedAt: string }>;
18
- setSummary(path: string, text: string): Promise<unknown>;
19
- dashClassified(): Promise<Ok & { classifiedAt: string }>;
20
- fileThread(threadId: string, path: string): Promise<unknown>;
21
- dashPlan(basis: string, plan: object): Promise<Ok & { id: string }>;
22
- dashEntries(entries: object[], remove?: string[], notice?: Notice | null): Promise<Ok & { entries: DashEntries }>;
23
- };
24
- export function createHub(options?: { base?: string; agent?: string; fetchImpl?: typeof fetch; log?: Pick<Console, "log"> }): Hub;
25
- export const POST_MAX_BYTES: number;
26
- export const POSTS_PER_COMMIT: number;
27
- export function toPosts(notices: string[]): string[];
28
- export function runOnce(hub: Hub, id: string, decide: (snap: Record<string, unknown> | null, saved: unknown, now: number) => unknown, options?: { now?: number; snapshot?: boolean }): Promise<{ posts: Post[]; state: unknown }>;