eve-hub 0.16.21 → 0.16.23
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/execution-context.mjs +6 -2
- package/github-connector.mjs +1 -7
- package/kit-modules.json +0 -1
- package/package.json +1 -1
- package/prepare.mjs +10 -20
- package/stubs.mjs +0 -3
- package/templates/execution-context-hosted-main.md +0 -1
- package/templates/execution-context-local-main.md +1 -4
- package/tools/dash_update.js +2 -3
- package/schedules/dash-classify.mjs +0 -367
- package/schedules/dash-decisions.mjs +0 -21
- package/schedules/dash-pass.mjs +0 -57
- package/schedules/dash-plan-schema.mjs +0 -17
- package/schedules/dash-plan.mjs +0 -332
- package/schedules/dash-rules.mjs +0 -347
- package/schedules/dash-run.mjs +0 -167
- package/schedules/dash.d.ts +0 -11
- package/schedules/dash.js +0 -30
- package/schedules/hub.d.mts +0 -28
- package/schedules/hub.mjs +0 -164
- package/schedules/lane-facts.mjs +0 -136
- package/schedules/thread-brief.mjs +0 -110
- package/schedules/track-summaries.mjs +0 -108
package/schedules/dash-rules.mjs
DELETED
|
@@ -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
|
-
}
|
package/schedules/dash-run.mjs
DELETED
|
@@ -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
|
-
}
|
package/schedules/dash.d.ts
DELETED
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() });
|
package/schedules/hub.d.mts
DELETED
|
@@ -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 }>;
|