eve-hub 0.6.9 → 0.6.10

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/CHANGELOG.md CHANGED
@@ -1,5 +1,11 @@
1
1
  # eve-hub
2
2
 
3
+ ## 0.6.10
4
+
5
+ ### Patch Changes
6
+
7
+ - The change-explainer is gone: its skill and the `artifact_publish`, `artifact_status` and `artifact_list` tools are no longer kit files (sync and the hosted deploy remove them from every bot), and the protocol no longer tells reviewers to publish an explainer.
8
+
3
9
  ## 0.6.9
4
10
 
5
11
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "eve-hub",
3
- "version": "0.6.9",
3
+ "version": "0.6.10",
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",
@@ -12,7 +12,6 @@
12
12
  "./channels/*": "./channels/*.js",
13
13
  "./instructions/*": "./instructions/*.js",
14
14
  "./memory/*": "./memory/*.js",
15
- "./skills/*": "./skills/*.js",
16
15
  "./host-sandbox": "./host-sandbox.js",
17
16
  "./host-browser": "./host-browser.js",
18
17
  "./roster-model": "./roster-model.mjs",
package/prepare.mjs CHANGED
@@ -19,7 +19,7 @@
19
19
  import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, rmdirSync, unlinkSync, writeFileSync } from "node:fs";
20
20
  import { basename, dirname, join } from "node:path";
21
21
  import { GITHUB_PATH } from "./github-path.mjs";
22
- import { KIT_FILES, MAIN_KIT_FILES } from "./stubs.mjs";
22
+ import { KIT_FILES, MAIN_KIT_FILES, REMOVED_KIT_FILES } from "./stubs.mjs";
23
23
  import { deploymentFacts, hostedContext } from "./execution-context.mjs";
24
24
  import { botDomains, domainFolders, readBotMemory, readMemory, validateMemory } from "./memory.mjs";
25
25
  import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspace.mjs";
@@ -37,9 +37,8 @@ import { agentRunsOn, readWorkspaceFile, removeWorkspaceAgent } from "./workspac
37
37
  // roster by commit), memory (the workspace's files), and main's schedules.
38
38
  export const DEPLOYED_KIT_FILES = new Set([
39
39
  "channels/eve.ts",
40
- "skills/change-explainer.ts",
41
40
  ...["react", "show", "update_thread", "list_threads", "read_thread", "post_message"].map((t) => `tools/${t}.ts`),
42
- ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "tag_threads", "dash_update", "apply_tags", "artifact_publish", "artifact_status", "artifact_list"].map((t) => `tools/${t}.ts`),
41
+ ...["create_thread", "set_summary", "move_path", "archive_thread", "unarchive_thread", "list_agents", "agent_send", "tag_threads", "dash_update", "apply_tags"].map((t) => `tools/${t}.ts`),
43
42
  ]);
44
43
 
45
44
 
@@ -187,6 +186,7 @@ export function prepareDeployment(dir) {
187
186
  if (!file.endsWith("/agent.ts")) continue;
188
187
  const bot = dirname(join(dir, "agents", file));
189
188
  for (const stub of Object.keys({ ...MAIN_KIT_FILES, "memory/workspace.ts": 0, "memory/bot.ts": 0 })) if (!DEPLOYED_KIT_FILES.has(stub) && existsSync(join(bot, stub))) removeStub(bot, stub);
189
+ for (const stub of REMOVED_KIT_FILES) if (existsSync(join(bot, stub))) removeStub(bot, stub);
190
190
  // learning-review is not in MAIN_KIT_FILES, but also requires the host.
191
191
  if (existsSync(join(bot, "schedules/learning-review.ts"))) removeStub(bot, "schedules/learning-review.ts");
192
192
  const name = basename(dirname(bot));
package/stubs.mjs CHANGED
@@ -1,6 +1,10 @@
1
1
  // eve finds tools, hooks, and channels by file (the tool name is the file
2
2
  // name), so each bot keeps a one-line stub per kit module. Bot file -> what it
3
3
  // is, and which kit module it re-exports (default: the same path).
4
+ // Kit files whose module is gone. Sync retires them from every bot, and the
5
+ // hosted deploy prunes them (the change-explainer: its skill and artifact tools).
6
+ export const REMOVED_KIT_FILES = Object.freeze(["tools/artifact_publish.ts", "tools/artifact_status.ts", "tools/artifact_list.ts", "skills/change-explainer.ts"]);
7
+
4
8
  export const KIT_FILES = {
5
9
  "channels/eve.ts": ["Queued eve channel that resolves the registry's human as a user.", "channels/eve-roster-user.ts"],
6
10
  "hooks/sandbox.ts": ["Migrates legacy Docker sessions to the host sandbox."],
@@ -14,10 +18,6 @@ export const KIT_FILES = {
14
18
  "tools/update_thread.ts": ["Update a thread this bot is in: title, track, domain and summary."],
15
19
  "tools/list_threads.ts": ["Every workspace thread, from any session."],
16
20
  "tools/read_thread.ts": ["Read any workspace thread."],
17
- "tools/artifact_publish.ts": ["Publish the next version of an artifact (a change explainer, ...) and show its card in the thread."],
18
- "tools/artifact_status.ts": ["Whether an artifact this bot published still describes its source."],
19
- "tools/artifact_list.ts": ["The artifacts this bot owns, or every artifact in the workspace."],
20
- "skills/change-explainer.ts": ["The change-explainer skill (packages/explainer/skills/change-explainer/SKILL.md), which artifact_publish's change explainers follow."],
21
21
  "tools/post_message.ts": ["Post into an existing thread or a bot's DM; with `status`, report the thread's phase."],
22
22
  };
23
23
 
@@ -30,7 +30,7 @@ When several bots work on one piece of work, coordinate through posts; don't rou
30
30
  2. When you push, tell the bots your change affects: the owner of a branch stacked on yours, and the reviewer. Use `@all` if you can't tell who. To reach a bot in another thread, use `post_message`. A branch has one owner; anyone else works on a branch stacked on top, or the owner hands it over.
31
31
  3. When the thread's phase changes (planning → working → reviewing → done; a review can send it back to working or planning), post a status: `post_message` into this thread with `status` set to the new phase, as the text at most 8 plain words on what is happening now ("Building the input-count fix", not "Plan approved by Rui; builder starting PR 1 (count only new input)": no names, no restating the phase, no parentheticals), and `metadata: { pr, sha }` when there are any (the PR URL and sha go there, never in the text). A status is a phase marker, not a report: test results, branch names and caveats go in a separate message. It wakes nobody unless you @-mention someone, so it doesn't replace your message. The workspace state shows the thread's current phase.
32
32
 
33
- A reviewer records its verdict on the same kind of post: `verdict` (`ship`, `fix-first` or `rethink`) with `metadata: { pr, sha }`, the PR as its full GitHub URL (like every status post) and the sha you reviewed; the text is the one-line what-changed. Add `decisions`, a list of `{ question, options: [{ label, pickedBy? }] }` for what the human has to pick (`[]` if nothing; a `ship` without the list is refused). Publish the change explainer with `artifact_publish` at the first ship and reuse it on later passes; the Dash row says "no explainer" without one. CI and the PR's head come from GitHub, not from your post; don't state them as facts there.
33
+ A reviewer records its verdict on the same kind of post: `verdict` (`ship`, `fix-first` or `rethink`) with `metadata: { pr, sha }`, the PR as its full GitHub URL (like every status post) and the sha you reviewed; the text is the one-line what-changed. Add `decisions`, a list of `{ question, options: [{ label, pickedBy? }] }` for what the human has to pick (`[]` if nothing; a `ship` without the list is refused). CI and the PR's head come from GitHub, not from your post; don't state them as facts there.
34
34
  4. When you need another specialty or more help, ask main, or the bot whose role fits.
35
35
 
36
36
  On a conflict, read the push posts and ask the bot that pushed.
@@ -3,7 +3,7 @@ import { z } from "zod";
3
3
  import { registryFetch } from "../registry-fetch.mjs";
4
4
  // Posts as you into a thread or DM, from whichever session you're in. With no
5
5
  // target it posts into the thread you're in, as part of the running turn: it
6
- // carries the turn's id and the images and artifact cards collected so far,
6
+ // carries the turn's id and the images collected so far,
7
7
  // and if it's your turn's last act, your end-of-turn text isn't posted. Into
8
8
  // another thread, mentions wake bots exactly as your replies do; posting into
9
9
  // a bot's DM wakes that bot; archived threads refuse it. Your session in the
package/world.mjs CHANGED
@@ -413,29 +413,10 @@ function reduceChild(member, { type, data = {}, meta: meta4 }, at = Date.parse(m
413
413
  return cardChanged || ["reasoning.appended", "message.appended", "message.completed", "actions.requested", "agent.started", "task.started", "task.settled", "action.result", "turn.completed", "session.waiting", "task.progress", "task.blocked", "task.updated"].includes(type);
414
414
  }
415
415
 
416
- // ../../registry/artifacts.mjs
417
- var ID = /^[a-z0-9][a-z0-9-]{0,39}:[\w.-]{1,40}$/;
418
- var STATUSES = /* @__PURE__ */ new Set(["fresh", "stale", "updating", "failed", "merged", "closed"]);
419
- var isStatus = (status) => STATUSES.has(status);
420
- var PROJECT = process.env.HUB_ARTIFACTS_PROJECT ?? "hub-artifacts";
421
- var DOMAIN = process.env.HUB_ARTIFACTS_DOMAIN ?? "playground-vercel.tools";
422
- var MAX_FILE_BYTES = 4 * 1024 * 1024;
423
- var validId = (id) => typeof id === "string" && ID.test(id);
424
- var SITE_CONFIG = new TextEncoder().encode(JSON.stringify({ headers: [{ source: "/(.*)", headers: [{ key: "x-robots-tag", value: "noindex" }, { key: "referrer-policy", value: "no-referrer" }] }] }));
425
-
426
416
  // ../../hosted-hub/src/store/db.ts
427
417
  import { createClient } from "@libsql/client";
428
418
  var s = (sql, ...args) => ({ sql, args });
429
419
 
430
- // ../../hosted-hub/src/store/artifacts.ts
431
- var ARTIFACT_READ = s("SELECT id, record FROM artifacts WHERE record IS NOT NULL");
432
- var ARTIFACT_LEASE_MS = 15 * 6e4;
433
- function statusStatement(checked) {
434
- return s(`UPDATE artifacts SET record = json_patch(record, ?)
435
- WHERE id = ? AND json_extract(record, '$.version') = ?`, JSON.stringify({ status: checked.status, ...checked.checkedAt ? { checkedAt: checked.checkedAt.slice(0, 40) } : {} }), checked.id, checked.version);
436
- }
437
- var cardsTo = (turnId2, messageId2) => s("UPDATE artifact_links SET message_id = ? WHERE turn_id = ? AND message_id IS NULL AND EXISTS (SELECT 1 FROM messages WHERE id = ?)", messageId2, turnId2, messageId2);
438
-
439
420
  // subagent-cap.mjs
440
421
  var DEFAULT_SUBAGENT_CAP = Object.freeze({
441
422
  model: "anthropic/claude-fable-5.1",
@@ -545,7 +526,7 @@ var TAG_FIELDS = ["project", "issueType"];
545
526
  var LABEL_FIELDS = [...TAG_FIELDS, "path"];
546
527
 
547
528
  // ../../lib/subscriptions.mjs
548
- var STATUSES2 = Object.freeze(["idle", "working", "waiting"]);
529
+ var STATUSES = Object.freeze(["idle", "working", "waiting"]);
549
530
 
550
531
  // ../../hosted-hub/src/store/dash.ts
551
532
  var COLUMNS = { importance: "importance", importanceBy: "importance_by", lane: "lane", delay: "delay", reason: "reason", basis: "basis", deferred: "deferred", updatedAt: "classified_at", by: "classified_by" };
@@ -26609,8 +26590,6 @@ var Message = object2({
26609
26590
  event: maybe(MessageEvent),
26610
26591
  attachments: maybe(array2(Attachment)),
26611
26592
  taskCard: maybe(TaskCard),
26612
- /** Artifact ids its turn published; the cards read Snapshot.artifacts. */
26613
- artifacts: maybe(array2(string5())),
26614
26593
  hop: maybe(int2()),
26615
26594
  /** Handles (lowercase) that resolved to a person when this was posted, so an old @handle still links after a rename. */
26616
26595
  mentionAliases: maybe(map2("MentionAliases", string5())),
@@ -26850,7 +26829,6 @@ var DecisionPackage = object2({
26850
26829
  ci: string5(),
26851
26830
  /** The thread's latest verdict on this PR; `current`: it was given at the head (null: head unknown). `rounds`: non-ship verdicts so far. */
26852
26831
  review: maybe(object2({ verdict: string5(), sha: string5(), at: string5(), by: string5(), messageId: string5(), current: boolean5().nullable(), rounds: int2() })),
26853
- explainer: maybe(object2({ id: string5(), sha: string5().nullable(), at: string5(), url: maybe(string5()), behind: boolean5().nullable() })),
26854
26832
  /** The latest verdict is a ship at the current head: the input of the Ready lane. */
26855
26833
  ready: boolean5(),
26856
26834
  /** The agent that posted last before the verdict, the reviewer aside: who the human's approval is addressed to. */
@@ -26893,23 +26871,6 @@ var HubTrack = object2({
26893
26871
  closed: maybe(boolean5()),
26894
26872
  locked: maybe(boolean5())
26895
26873
  }).meta({ id: "HubTrack" });
26896
- var ArtifactCard = object2({
26897
- title: string5(),
26898
- gist: maybe(string5()),
26899
- /** Change explainer: "repo #PR". */
26900
- subtitle: maybe(string5()),
26901
- /** Change explainer: "base..head". */
26902
- ref: maybe(string5())
26903
- }).meta({ id: "ArtifactCard" });
26904
- var Artifact = object2({
26905
- id: string5(),
26906
- version: int2(),
26907
- /** fresh | stale | updating | failed | merged | closed */
26908
- status: string5(),
26909
- card: ArtifactCard,
26910
- /** The stable link, moved to each new version; what a click opens. */
26911
- alias: maybe(string5())
26912
- }).meta({ id: "Artifact" });
26913
26874
  var Snapshot = object2({
26914
26875
  type: literal2("state"),
26915
26876
  proto: maybe(int2()),
@@ -26947,9 +26908,7 @@ var Snapshot = object2({
26947
26908
  usageHoursSince: maybe(string5()),
26948
26909
  server: maybe(ServerID),
26949
26910
  /** Track summaries by path; the web client reads them (the app doesn't), the hosted hub sends them. */
26950
- pathSummaries: maybe(map2("PathSummaries", object2({ text: string5(), by: string5(), at: string5() }).meta({ id: "PathSummary" }))),
26951
- /** Published artifacts by id, each at its latest version. */
26952
- artifacts: maybe(map2("Artifacts", Artifact))
26911
+ pathSummaries: maybe(map2("PathSummaries", object2({ text: string5(), by: string5(), at: string5() }).meta({ id: "PathSummary" })))
26953
26912
  }).meta({ id: "Snapshot" });
26954
26913
 
26955
26914
  // ../../hosted-hub/src/store/workspace-repo.ts
@@ -27104,28 +27063,10 @@ function fold(state, chunks, now = Date.now()) {
27104
27063
  break;
27105
27064
  }
27106
27065
  case "action.result": {
27107
- const tool = open3.activity.tools.find((t) => t.callId === data.result?.callId);
27108
- const toolName = data.result?.toolName ?? tool?.name;
27109
- const output2 = data.result?.output;
27110
- const artifact = output2 && typeof output2 === "object" && "artifact" in output2 ? output2.artifact : void 0;
27111
- if (data.status === "completed" && artifact && typeof artifact === "object" && "id" in artifact && typeof artifact.id === "string" && validId(artifact.id)) {
27112
- if (toolName === "artifact_publish")
27113
- stmts.push(s("INSERT OR IGNORE INTO artifact_links (turn_id,id) VALUES (?,?)", open3.id, artifact.id));
27114
- if (toolName === "artifact_status" && "version" in artifact && typeof artifact.version === "number" && "status" in artifact && typeof artifact.status === "string" && isStatus(artifact.status)) {
27115
- stmts.push(statusStatement({
27116
- id: artifact.id,
27117
- version: artifact.version,
27118
- status: artifact.status,
27119
- ..."checkedAt" in artifact && typeof artifact.checkedAt === "string" ? { checkedAt: artifact.checkedAt } : {}
27120
- }));
27121
- log("artifact.updated", { id: artifact.id });
27122
- }
27123
- }
27124
27066
  const ls = open3.lastStep, callId = data.result?.callId;
27125
27067
  if (!ls || !callId || ls.calls[callId] !== null) break;
27126
27068
  const postOutput = data.result?.output;
27127
27069
  ls.calls[callId] = data.status === "completed" && postOutput?.own === true;
27128
- if (ls.calls[callId] && typeof postOutput?.messageId === "string") stmts.push(cardsTo(open3.id, postOutput.messageId));
27129
27070
  touched.set(open3.id, open3);
27130
27071
  break;
27131
27072
  }
@@ -27139,10 +27080,9 @@ function fold(state, chunks, now = Date.now()) {
27139
27080
  if (open3.status !== "cancelling" && !lastActWasOwnPost(open3.lastStep)) {
27140
27081
  const messageId2 = derivedMessageId(open3.id);
27141
27082
  stmts.push(s(`INSERT OR IGNORE INTO messages (id, thread_id, author, author_kind, text, turn_id, created_at)
27142
- SELECT ?, ?, ?, 'agent', ?, ?, ? WHERE (? <> '' OR EXISTS (SELECT 1 FROM artifact_links WHERE turn_id = ? AND message_id IS NULL))
27143
- AND EXISTS (SELECT 1 FROM turns WHERE id = ? AND status NOT IN ('cancelling','cancelled') AND NOT (${REVOKED_TURN}))`, messageId2, session.thread, session.bot, text, open3.id, at, text, open3.id, open3.id));
27083
+ SELECT ?, ?, ?, 'agent', ?, ?, ? WHERE ? <> ''
27084
+ AND EXISTS (SELECT 1 FROM turns WHERE id = ? AND status NOT IN ('cancelling','cancelled') AND NOT (${REVOKED_TURN}))`, messageId2, session.thread, session.bot, text, open3.id, at, text, open3.id));
27144
27085
  log("message.created", { messageId: messageId2, author: session.bot, authorKind: "agent" }, IF_INSERTED);
27145
- stmts.push(cardsTo(open3.id, messageId2));
27146
27086
  const to = recipients(state.roster, text, { name: session.bot, kind: "agent" }, session.thread);
27147
27087
  stmts.push(...routingStatements(messageId2, session.thread, to, at));
27148
27088
  deliver.push(messageId2);
@@ -1,73 +0,0 @@
1
- // Artifacts: a versioned static site a bot publishes and keeps current, such
2
- // as a change explainer. The core is type-agnostic. A type (artifacts/types/)
3
- // builds the site's files and its card from a source, in the bot's sandbox
4
- // (where the repo is), and says whether a published version is still current.
5
- //
6
- // The hub owns the record and the deploy (registry/artifacts.mjs). Publishing
7
- // a version:
8
- // 1. the type builds the site: { files: [{ file, data }], card, meta }
9
- // 2. each file goes to the hub: PUT /api/artifacts/:id/files/:sha
10
- // 3. POST /api/artifacts/:id/v/:n creates version n (the hub refuses with
11
- // 409 when another session got there first), deploys it and moves the
12
- // stable link; its answer is the record.
13
- import { createHash } from "node:crypto";
14
- import changeExplainer from "./types/change-explainer.mjs";
15
- import { registryFetch } from "../registry-fetch.mjs";
16
-
17
- export const TYPES = { "change-explainer": changeExplainer };
18
- export const artifactId = (type, key) => `${type}:${key}`;
19
- const UPLOADS_AT_ONCE = 8;
20
-
21
- export function typeOf(name) {
22
- const type = TYPES[name];
23
- if (!type) throw new Error(`unknown artifact type ${JSON.stringify(name)} (known: ${Object.keys(TYPES).join(", ")})`);
24
- return type;
25
- }
26
-
27
- async function hub(path, init, fetchImpl) {
28
- const res = await registryFetch(path, init, { fetchImpl });
29
- const body = await res.json().catch(() => ({}));
30
- if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path.split("?")[0]}: ${body.error ?? res.status}`);
31
- return body;
32
- }
33
-
34
- // The hub's record of an artifact, or null when it has none.
35
- export async function registryRecord(id, fetchImpl = fetch) {
36
- const res = await registryFetch(`/api/artifacts/${encodeURIComponent(id)}`, {}, { fetchImpl });
37
- if (res.status === 404) return null;
38
- if (!res.ok) throw new Error(`GET /api/artifacts/${id}: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
39
- return res.json();
40
- }
41
-
42
- /**
43
- * Publishes the next version of `type:key` from `source` and returns the
44
- * hub's record. `sandbox` is the session's (ctx.getSandbox()).
45
- */
46
- export async function publish({ type: typeName, key, source }, { sandbox, sessionId, fetch: fetchImpl = fetch }) {
47
- const type = typeOf(typeName);
48
- const id = artifactId(typeName, key);
49
- const at = `/api/artifacts/${encodeURIComponent(id)}`;
50
- const prior = await registryRecord(id, fetchImpl);
51
- const version = (prior?.version ?? 0) + 1;
52
- const built = await type.build(source, { sandbox, id, version });
53
- const files = built.files.map(({ file, data }) => ({ file, data, sha: createHash("sha1").update(data).digest("hex"), size: data.length }));
54
- const unique = [...new Map(files.map((f) => [f.sha, f])).values()];
55
- for (let i = 0; i < unique.length; i += UPLOADS_AT_ONCE) {
56
- await Promise.all(unique.slice(i, i + UPLOADS_AT_ONCE).map((f) =>
57
- // A file body is the bytes alone, so the session rides in the header.
58
- hub(`${at}/files/${f.sha}`, { method: "PUT", headers: { "x-hub-session": sessionId, "content-type": "application/octet-stream" }, body: f.data, signal: AbortSignal.timeout(120_000) }, fetchImpl),
59
- ));
60
- }
61
- return hub(`${at}/v/${version}`, {
62
- method: "POST",
63
- headers: { "content-type": "application/json" },
64
- body: JSON.stringify({ sessionId, type: typeName, key, source, card: built.card, meta: built.meta ?? {}, files: files.map(({ file, sha, size }) => ({ file, sha, size })) }),
65
- signal: AbortSignal.timeout(600_000),
66
- }, fetchImpl);
67
- }
68
-
69
- /** What the type says about a published record: { status, reasons[] }. */
70
- export async function status(record, { sandbox }) {
71
- const type = typeOf(record.type);
72
- return type.status ? type.status(record, { sandbox }) : { status: record.status ?? "fresh", reasons: [] };
73
- }
@@ -1,83 +0,0 @@
1
- // The change-explainer artifact type: a narrated walkthrough of a PR or a
2
- // stack (packages/explainer), built from a fixture (fixture.json,
3
- // overview.json, sequence.json) and a read-only checkout of the repo.
4
- //
5
- // source: { fixture: <fixture dir>, repo: <git checkout>, scip?: <index.scip>, branch?: <ref the change lives on> }
6
- import { randomUUID } from "node:crypto";
7
- import { posix as path } from "node:path";
8
- import { appFiles, scripts } from "./explainer-assets.gen.mjs";
9
-
10
- const quote = (value) => "'" + String(value).replaceAll("'", "'\\''") + "'";
11
- async function run(sandbox, args) {
12
- const result = await sandbox.run({ command: args.map(quote).join(" ") });
13
- if (result.exitCode !== 0) throw new Error(`${args[0]} failed: ${result.stderr || result.stdout}`);
14
- return result.stdout.trim();
15
- }
16
- const git = (sandbox, repo, ...args) => run(sandbox, ["git", "-C", repo, ...args]);
17
- const readJson = async (sandbox, file) => JSON.parse(await sandbox.readTextFile({ path: file }));
18
-
19
- export function firstSentence(text = "") {
20
- const sentence = String(text).trim().split(/(?<=[.!?])\s+(?=[A-Z"'`(])/, 1)[0] ?? "";
21
- return sentence.length > 220 ? `${sentence.slice(0, 219).replace(/\s+\S*$/, "")}…` : sentence;
22
- }
23
-
24
- // Card fields from the fixture: what the change is, and which commits. A
25
- // change with no PRs (a local repo) names its branch instead.
26
- export function cardOf(fixture, overview, branch) {
27
- const short = (sha) => String(sha ?? "").slice(0, 9);
28
- const prs = (fixture.prs ?? []).filter((p) => p.number !== undefined).map((p) => `#${p.number}`);
29
- return {
30
- title: fixture.title ?? fixture.id,
31
- gist: firstSentence(overview?.thesis),
32
- subtitle: [fixture.repo, ...(prs.length ? prs : [branch])].filter(Boolean).join(" "),
33
- ref: `${short(fixture.base)}..${short(fixture.head)}`,
34
- };
35
- }
36
-
37
- async function build(source, { sandbox }) {
38
- const work = `/tmp/hub-explainer/${randomUUID()}`;
39
- try {
40
- await run(sandbox, ["mkdir", "-p", work]);
41
- for (const [name, text] of Object.entries(scripts)) await sandbox.writeTextFile({ path: `${work}/${name}.mjs`, content: text });
42
- const fixture = await readJson(sandbox, path.join(source.fixture, "fixture.json"));
43
- await run(sandbox, ["node", `${work}/lint-fixture.mjs`, source.fixture]);
44
- const data = `${work}/data`;
45
- await run(sandbox, ["node", `${work}/build-data.mjs`, "--fixture", source.fixture, "--repo", source.repo, ...(source.scip ? ["--scip", source.scip] : []), "--out", data]);
46
- const overview = await readJson(sandbox, path.join(source.fixture, "overview.json"));
47
- const [base, head] = await Promise.all([git(sandbox, source.repo, "rev-parse", `${fixture.base}^{commit}`), git(sandbox, source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
48
- const files = appFiles.map(({ file, base64 }) => {
49
- let bytes = Buffer.from(base64, "base64");
50
- if (file === "index.html") bytes = Buffer.from(bytes.toString("utf8").replace("</title>", `</title>\n <meta name="explainer-fixture" content="${fixture.id.replace(/[^\w.-]/g, "")}" />`));
51
- return { file, data: bytes };
52
- });
53
- const listing = await run(sandbox, ["find", data, "-type", "f", "-print0"]);
54
- for (const file of listing.split("\0").filter(Boolean)) files.push({ file: `data/${file.slice(data.length + 1)}`, data: await sandbox.readBinaryFile({ path: file }) });
55
- return { files, card: cardOf(fixture, overview, source.branch), meta: { repo: fixture.repo, base, head, prs: (fixture.prs ?? []).flatMap((p) => p.number ?? []) } };
56
- } finally {
57
- await sandbox.removePath({ path: work, recursive: true, force: true });
58
- }
59
- }
60
-
61
- // Whether the published version still describes the change. This checks the
62
- // pinned range against the fixture and, with source.branch, the branch head;
63
- // which parts went stale (snippet hashes, re-anchoring) is the next step.
64
- async function status(record, { sandbox }) {
65
- const { source = {}, meta = {} } = record;
66
- const reasons = [];
67
- if (record.status === "merged" || record.status === "closed") return { status: record.status, reasons };
68
- try {
69
- const fixture = await readJson(sandbox, path.join(source.fixture, "fixture.json"));
70
- const [base, head] = await Promise.all([git(sandbox, source.repo, "rev-parse", `${fixture.base}^{commit}`), git(sandbox, source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
71
- if (base !== meta.base) reasons.push(`the fixture's base is now ${base.slice(0, 9)} (published at ${String(meta.base).slice(0, 9)})`);
72
- if (head !== meta.head) reasons.push(`the fixture's head is now ${head.slice(0, 9)} (published at ${String(meta.head).slice(0, 9)})`);
73
- if (source.branch) {
74
- const tip = await git(sandbox, source.repo, "rev-parse", `${source.branch}^{commit}`);
75
- if (tip !== meta.head) reasons.push(`${source.branch} is at ${tip.slice(0, 9)}, ahead of the published head ${String(meta.head).slice(0, 9)}`);
76
- }
77
- } catch (error) {
78
- reasons.push(`couldn't read the source: ${error.message}`);
79
- }
80
- return { status: reasons.length ? "stale" : "fresh", reasons };
81
- }
82
-
83
- export default { name: "change-explainer", build, status };