eve-hub 0.4.3 → 0.5.0

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,48 +1,22 @@
1
1
  // Artifacts: a versioned static site a bot publishes and keeps current, such
2
2
  // as a change explainer. The core is type-agnostic. A type (artifacts/types/)
3
- // builds the site and its card from a source, and says whether a published
4
- // version is still current.
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
5
  //
6
- // The session that publishes an artifact owns it. Its record lives in that
7
- // session's state (the tools keep it in defineState("hub.artifacts")), and
8
- // every deployment also carries its own artifact.json with the full history.
9
- // The tool result carries the record, so the registry folds it into its
10
- // read-only index (GET /api/artifacts) and the thread's card.
11
- //
12
- // Publishing a version:
13
- // 1. the type builds the site, its card fields, and a thumbnail
14
- // 2. artifact.json (record + history) is written into the site
15
- // 3. `vercel deploy --prebuilt` to the hub-artifacts project, tagged with
16
- // --meta artifact=<slug> --meta version=<n>
17
- // 4. the stable alias <slug>.<domain> moves to it, unless another session
18
- // published a newer version meanwhile
19
- // 5. the site goes to the registry, which serves it at /artifacts/<id>/v<n>/
20
- // for the hub's inline preview (no Vercel sign-in)
21
- // 6. deployments older than the last KEEP versions are removed
22
- import { execFile } from "node:child_process";
23
- import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
- import { homedir, tmpdir } from "node:os";
25
- import { join } from "node:path";
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";
26
14
  import changeExplainer from "./types/change-explainer.mjs";
27
-
28
15
  import { registryFetch } from "../registry-fetch.mjs";
29
- const SCOPE = process.env.HUB_ARTIFACTS_SCOPE ?? "vercel-internal-playground";
30
- const PROJECT = process.env.HUB_ARTIFACTS_PROJECT ?? "hub-artifacts";
31
- const DOMAIN = process.env.HUB_ARTIFACTS_DOMAIN ?? "playground-vercel.tools";
32
- const WORK = process.env.HUB_ARTIFACTS_WORK ?? join(process.env.HUB_HOME ?? join(homedir(), ".hub"), "artifacts");
33
- export const KEEP = 5;
34
16
 
35
17
  export const TYPES = { "change-explainer": changeExplainer };
36
- export const STATE_NAME = "hub.artifacts";
37
-
38
18
  export const artifactId = (type, key) => `${type}:${key}`;
39
- // A DNS label (at most 63 characters): the alias host and the --meta tag.
40
- export function slugOf(id) {
41
- const slug = id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
42
- return slug.length <= 63 ? slug : slug.slice(0, 63).replace(/-+$/, "");
43
- }
44
- export const aliasOf = (id) => `${slugOf(id)}.${DOMAIN}`;
45
- export const previewPath = (id, version) => `/artifacts/${encodeURIComponent(id)}/v${version}/`;
19
+ const UPLOADS_AT_ONCE = 8;
46
20
 
47
21
  export function typeOf(name) {
48
22
  const type = TYPES[name];
@@ -50,172 +24,49 @@ export function typeOf(name) {
50
24
  return type;
51
25
  }
52
26
 
53
- export function run(cmd, args, { cwd, input, timeoutMs = 600_000 } = {}) {
54
- return new Promise((resolve, reject) => {
55
- const child = execFile(cmd, args, { cwd, maxBuffer: 64 << 20, timeout: timeoutMs, env: { ...process.env, FORCE_COLOR: "0" } }, (error, stdout, stderr) => {
56
- if (error) {
57
- const tail = String(stderr || stdout || "").trim().split("\n").slice(-6).join("\n");
58
- reject(new Error(`${cmd} ${args[0] ?? ""} failed: ${tail || error.message}`));
59
- } else resolve(String(stdout));
60
- });
61
- if (input !== undefined) child.stdin.end(input);
62
- else child.stdin.end();
63
- });
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;
64
32
  }
65
33
 
66
- // The registry's copy of an artifact (owner, latest version), or null.
34
+ // The hub's record of an artifact, or null when it has none.
67
35
  export async function registryRecord(id, fetchImpl = fetch) {
68
- const res = await registryFetch(`/api/artifacts/${encodeURIComponent(id)}`, {}, { fetchImpl }).catch(() => null);
69
- if (!res?.ok) return null;
70
- return res.json().catch(() => null);
71
- }
72
-
73
- // The newest record this session can see: its own state or, for a fork or
74
- // another session picking an artifact up, the registry's index.
75
- export async function latestRecord(id, stateRecords, fetchImpl = fetch) {
76
- const mine = stateRecords?.[id] ?? null;
77
- const hub = await registryRecord(id, fetchImpl);
78
- if (!hub) return mine;
79
- if (!mine) return stripOwner(hub);
80
- return (hub.version ?? 0) > (mine.version ?? 0) ? stripOwner(hub) : mine;
81
- }
82
- const stripOwner = ({ owner, ...record }) => record;
83
-
84
- function projectLink() {
85
- const dir = join(WORK, "vercel-link");
86
- const file = join(dir, ".vercel", "project.json");
87
- return { dir, file };
88
- }
89
-
90
- async function ensureProject() {
91
- const { dir, file } = projectLink();
92
- if (existsSync(file)) return file;
93
- mkdirSync(dir, { recursive: true });
94
- await run("vercel", ["link", "--yes", "--project", PROJECT, "--scope", SCOPE], { cwd: dir });
95
- if (!existsSync(file)) throw new Error(`vercel link didn't write ${file}`);
96
- return file;
97
- }
98
-
99
- // A Build Output API v3 tree around the site: static files, no SPA fallback
100
- // (artifact sites route with the hash), the target pinned to preview.
101
- function stage(site, slug, version) {
102
- const dir = mkdtempSync(join(tmpdir(), `artifact-${slug}-v${version}-`));
103
- const output = join(dir, ".vercel", "output");
104
- cpSync(site, join(output, "static"), { recursive: true });
105
- writeFileSync(
106
- join(output, "config.json"),
107
- JSON.stringify({
108
- version: 3,
109
- routes: [{ src: "/(.*)", headers: { "x-robots-tag": "noindex", "referrer-policy": "no-referrer" }, continue: true }, { handle: "filesystem" }],
110
- }),
111
- );
112
- writeFileSync(join(output, "builds.json"), JSON.stringify({ target: "preview", builds: [] }));
113
- return dir;
114
- }
115
-
116
- async function deploy(site, slug, version) {
117
- const link = await ensureProject();
118
- const dir = stage(site, slug, version);
119
- try {
120
- cpSync(link, join(dir, ".vercel", "project.json"));
121
- const out = await run("vercel", ["deploy", "--prebuilt", "--target", "preview", "--scope", SCOPE, "--yes", "--meta", `artifact=${slug}`, "--meta", `version=${version}`], { cwd: dir });
122
- const url = out.trim().split(/\s+/).reverse().find((w) => /^https:\/\/\S+$/.test(w));
123
- if (!url) throw new Error(`vercel deploy printed no URL: ${out.slice(-300)}`);
124
- return url;
125
- } finally {
126
- rmSync(dir, { recursive: true, force: true });
127
- }
128
- }
129
-
130
- async function uploadBundle(site, id, version, fetchImpl) {
131
- const tar = await new Promise((resolve, reject) =>
132
- execFile("tar", ["-czf", "-", "-C", site, "."], { encoding: "buffer", maxBuffer: 1 << 30 }, (error, stdout) => (error ? reject(error) : resolve(stdout))),
133
- );
134
- const res = await registryFetch(`/api/artifacts/${encodeURIComponent(id)}/v/${version}/bundle`, {
135
- method: "POST",
136
- headers: { "content-type": "application/gzip" },
137
- body: tar,
138
- signal: AbortSignal.timeout(120_000),
139
- }, { fetchImpl });
140
- if (!res.ok) throw new Error(`preview upload failed: ${(await res.json().catch(() => ({}))).error ?? res.status}`);
141
- return previewPath(id, version);
142
- }
143
-
144
- async function uploadThumbnail(path, fetchImpl) {
145
- if (!path || !existsSync(path)) return undefined;
146
- const res = await registryFetch(`/api/uploads`, {
147
- method: "POST",
148
- headers: { "content-type": "application/octet-stream", "x-filename": "thumbnail.png" },
149
- body: readFileSync(path),
150
- signal: AbortSignal.timeout(30_000),
151
- }, { fetchImpl }).catch(() => null);
152
- if (!res?.ok) return undefined;
153
- return (await res.json()).id;
154
- }
155
-
156
- // Removes deployments older than the last KEEP versions; best effort.
157
- async function prune(history) {
158
- const old = history.slice(0, Math.max(0, history.length - KEEP)).filter((h) => h.url && !h.removed);
159
- for (const h of old) {
160
- await run("vercel", ["rm", h.url, "--yes", "--scope", SCOPE], { timeoutMs: 60_000 }).then(
161
- () => (h.removed = true),
162
- () => {},
163
- );
164
- }
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();
165
40
  }
166
41
 
167
42
  /**
168
- * Publishes the next version of `type:key` from `source`. `prior` is the
169
- * newest record this session knows (latestRecord). Returns the new record.
170
- * `deps` swaps the side effects out in tests.
43
+ * Publishes the next version of `type:key` from `source` and returns the
44
+ * hub's record. `sandbox` is the session's (ctx.getSandbox()).
171
45
  */
172
- export async function publish({ type: typeName, key, source }, prior, deps = {}) {
46
+ export async function publish({ type: typeName, key, source }, { sandbox, sessionId, fetch: fetchImpl = fetch }) {
173
47
  const type = typeOf(typeName);
174
48
  const id = artifactId(typeName, key);
175
- const slug = slugOf(id);
176
- const fetchImpl = deps.fetch ?? fetch;
49
+ const at = `/api/artifacts/${encodeURIComponent(id)}`;
50
+ const prior = await registryRecord(id, fetchImpl);
177
51
  const version = (prior?.version ?? 0) + 1;
178
- const work = mkdtempSync(join(tmpdir(), `artifact-build-${slug}-`));
179
- try {
180
- const built = await (deps.build ?? type.build)(source, { work, id, version });
181
- const now = new Date().toISOString();
182
- const history = [...(prior?.history ?? []), { version, at: now, ...(built.meta?.head ? { head: built.meta.head } : {}) }];
183
- const record = {
184
- id,
185
- type: typeName,
186
- key,
187
- version,
188
- status: "fresh",
189
- card: { ...built.card, updatedAt: now },
190
- source,
191
- meta: built.meta ?? {},
192
- history,
193
- updatedAt: now,
194
- };
195
- writeFileSync(join(built.site, "artifact.json"), JSON.stringify(record, null, 2));
196
- const url = await (deps.deploy ?? deploy)(built.site, slug, version);
197
- history.at(-1).url = url;
198
- record.url = url;
199
- // Another session may have moved the alias on while this one built.
200
- const current = await registryRecord(id, fetchImpl);
201
- if ((current?.version ?? 0) >= version) {
202
- throw new Error(`${id} v${current.version} was published by ${current.owner?.bot ?? "another session"} while this one built v${version}; run artifact_status and publish again`);
203
- }
204
- const alias = aliasOf(id);
205
- await (deps.alias ?? ((u, a) => run("vercel", ["alias", "set", u, a, "--scope", SCOPE])))(url, alias);
206
- record.alias = `https://${alias}`;
207
- record.preview = await (deps.uploadBundle ?? uploadBundle)(built.site, id, version, fetchImpl);
208
- const thumbnail = await (deps.uploadThumbnail ?? uploadThumbnail)(built.thumbnail, fetchImpl);
209
- if (thumbnail) record.card.thumbnail = thumbnail;
210
- await (deps.prune ?? prune)(history);
211
- return record;
212
- } finally {
213
- rmSync(work, { recursive: true, force: true });
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
+ hub(`${at}/files/${f.sha}`, { method: "PUT", headers: { "content-type": "application/octet-stream" }, body: f.data, signal: AbortSignal.timeout(120_000) }, fetchImpl),
58
+ ));
214
59
  }
60
+ return hub(`${at}/v/${version}`, {
61
+ method: "POST",
62
+ headers: { "content-type": "application/json" },
63
+ body: JSON.stringify({ sessionId, type: typeName, key, source, card: built.card, meta: built.meta ?? {}, files: files.map(({ file, sha, size }) => ({ file, sha, size })) }),
64
+ signal: AbortSignal.timeout(600_000),
65
+ }, fetchImpl);
215
66
  }
216
67
 
217
68
  /** What the type says about a published record: { status, reasons[] }. */
218
- export async function status(record) {
69
+ export async function status(record, { sandbox }) {
219
70
  const type = typeOf(record.type);
220
- return type.status ? type.status(record) : { status: record.status ?? "fresh", reasons: [] };
71
+ return type.status ? type.status(record, { sandbox }) : { status: record.status ?? "fresh", reasons: [] };
221
72
  }
@@ -3,51 +3,24 @@
3
3
  // overview.json, sequence.json) and a read-only checkout of the repo.
4
4
  //
5
5
  // source: { fixture: <fixture dir>, repo: <git checkout>, scip?: <index.scip>, branch?: <ref the change lives on> }
6
- import { execFile } from "node:child_process";
7
- import { existsSync, readFileSync } from "node:fs";
8
- import { join } from "node:path";
9
- import { hubPath } from "../../hub-path.mjs";
6
+ import { randomUUID } from "node:crypto";
7
+ import { posix as path } from "node:path";
8
+ import { appFiles, scripts } from "./explainer-assets.gen.mjs";
10
9
 
11
- // packages/explainer, resolved per call: eve bundles this module into the
12
- // workspace's module cache, so import.meta.dirname isn't in the hub checkout.
13
- const explainerDir = () => hubPath(join("packages", "explainer"), { probe: join("scripts", "build-data.mjs") });
14
-
15
- // packages/explainer has its own lockfile, outside the hub's pnpm workspace,
16
- // so the hub's install doesn't cover it. Say so up front rather than failing
17
- // halfway through build-data on a missing import.
18
- function explainerReady() {
19
- const dir = explainerDir();
20
- if (!existsSync(join(dir, "node_modules", "@sourcegraph", "scip-typescript"))) {
21
- throw new Error(`the explainer's dependencies aren't installed: run \`pnpm install --ignore-workspace --frozen-lockfile\` in ${dir}`);
22
- }
23
- return dir;
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();
24
15
  }
25
-
26
- const node = (script, args, dir = explainerDir()) => {
27
- return new Promise((ok, fail) =>
28
- execFile(process.execPath, [join(dir, "scripts", script), ...args], { cwd: dir, maxBuffer: 64 << 20, timeout: 900_000 }, (error, stdout, stderr) =>
29
- error ? fail(new Error(`${script} failed: ${String(stderr || error.message).trim().split("\n").slice(-6).join("\n")}`)) : ok(String(stdout)),
30
- ),
31
- );
32
- };
33
-
34
- const git = (repo, ...args) =>
35
- new Promise((ok, fail) => execFile("git", ["-C", repo, ...args], (error, stdout) => (error ? fail(error) : ok(String(stdout).trim()))));
36
-
37
- const readJson = (path) => JSON.parse(readFileSync(path, "utf8"));
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 }));
38
18
 
39
19
  export function firstSentence(text = "") {
40
20
  const sentence = String(text).trim().split(/(?<=[.!?])\s+(?=[A-Z"'`(])/, 1)[0] ?? "";
41
21
  return sentence.length > 220 ? `${sentence.slice(0, 219).replace(/\s+\S*$/, "")}…` : sentence;
42
22
  }
43
23
 
44
- function checkSource(source) {
45
- if (!source || typeof source !== "object") throw new Error("change-explainer source must be { fixture, repo, scip?, branch? }");
46
- for (const key of ["fixture", "repo"]) if (typeof source[key] !== "string" || !existsSync(source[key])) throw new Error(`change-explainer source.${key}: no such path ${JSON.stringify(source[key])}`);
47
- if (!existsSync(join(source.fixture, "fixture.json"))) throw new Error(`no fixture.json in ${source.fixture}`);
48
- if (source.scip && !existsSync(source.scip)) throw new Error(`change-explainer source.scip: no such file ${source.scip}`);
49
- }
50
-
51
24
  // Card fields from the fixture: what the change is, and which commits.
52
25
  export function cardOf(fixture, overview) {
53
26
  const short = (sha) => String(sha ?? "").slice(0, 9);
@@ -60,41 +33,44 @@ export function cardOf(fixture, overview) {
60
33
  };
61
34
  }
62
35
 
63
- async function build(source, { work }) {
64
- checkSource(source);
65
- const fixture = readJson(join(source.fixture, "fixture.json"));
66
- const overviewPath = join(source.fixture, "overview.json");
67
- const overview = existsSync(overviewPath) ? readJson(overviewPath) : null;
68
- const data = join(work, "data");
69
- const site = join(work, "site");
70
- const dir = explainerReady();
71
- await node("build-data.mjs", ["--fixture", source.fixture, "--repo", source.repo, ...(source.scip ? ["--scip", source.scip] : []), "--out", data], dir);
72
- await node("build.mjs", ["--out", site, "--data", data, "--fixture", fixture.id], dir);
73
- const thumbnail = join(work, "thumbnail.png");
74
- await node("thumbnail.mjs", [site, thumbnail], dir).catch(() => {});
75
- const [base, head] = await Promise.all([git(source.repo, "rev-parse", `${fixture.base}^{commit}`), git(source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
76
- return {
77
- site,
78
- thumbnail: existsSync(thumbnail) ? thumbnail : undefined,
79
- card: cardOf(fixture, overview),
80
- meta: { repo: fixture.repo, base, head, prs: (fixture.prs ?? []).map((p) => p.number) },
81
- };
36
+ async function build(source, { sandbox }) {
37
+ const work = `/tmp/hub-explainer/${randomUUID()}`;
38
+ try {
39
+ await run(sandbox, ["mkdir", "-p", work]);
40
+ for (const [name, text] of Object.entries(scripts)) await sandbox.writeTextFile({ path: `${work}/${name}.mjs`, content: text });
41
+ const fixture = await readJson(sandbox, path.join(source.fixture, "fixture.json"));
42
+ await run(sandbox, ["node", `${work}/lint-fixture.mjs`, source.fixture]);
43
+ const data = `${work}/data`;
44
+ await run(sandbox, ["node", `${work}/build-data.mjs`, "--fixture", source.fixture, "--repo", source.repo, ...(source.scip ? ["--scip", source.scip] : []), "--out", data]);
45
+ const overview = await readJson(sandbox, path.join(source.fixture, "overview.json"));
46
+ const [base, head] = await Promise.all([git(sandbox, source.repo, "rev-parse", `${fixture.base}^{commit}`), git(sandbox, source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
47
+ const files = appFiles.map(({ file, base64 }) => {
48
+ let bytes = Buffer.from(base64, "base64");
49
+ if (file === "index.html") bytes = Buffer.from(bytes.toString("utf8").replace("</title>", `</title>\n <meta name="explainer-fixture" content="${fixture.id.replace(/[^\w.-]/g, "")}" />`));
50
+ return { file, data: bytes };
51
+ });
52
+ const listing = await run(sandbox, ["find", data, "-type", "f", "-print0"]);
53
+ for (const file of listing.split("\0").filter(Boolean)) files.push({ file: `data/${file.slice(data.length + 1)}`, data: await sandbox.readBinaryFile({ path: file }) });
54
+ return { files, card: cardOf(fixture, overview), meta: { repo: fixture.repo, base, head, prs: (fixture.prs ?? []).map((p) => p.number) } };
55
+ } finally {
56
+ await sandbox.removePath({ path: work, recursive: true, force: true });
57
+ }
82
58
  }
83
59
 
84
60
  // Whether the published version still describes the change. This checks the
85
61
  // pinned range against the fixture and, with source.branch, the branch head;
86
62
  // which parts went stale (snippet hashes, re-anchoring) is the next step.
87
- async function status(record) {
63
+ async function status(record, { sandbox }) {
88
64
  const { source = {}, meta = {} } = record;
89
65
  const reasons = [];
90
66
  if (record.status === "merged" || record.status === "closed") return { status: record.status, reasons };
91
67
  try {
92
- const fixture = readJson(join(source.fixture, "fixture.json"));
93
- const [base, head] = await Promise.all([git(source.repo, "rev-parse", `${fixture.base}^{commit}`), git(source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
68
+ const fixture = await readJson(sandbox, path.join(source.fixture, "fixture.json"));
69
+ const [base, head] = await Promise.all([git(sandbox, source.repo, "rev-parse", `${fixture.base}^{commit}`), git(sandbox, source.repo, "rev-parse", `${fixture.head}^{commit}`)]);
94
70
  if (base !== meta.base) reasons.push(`the fixture's base is now ${base.slice(0, 9)} (published at ${String(meta.base).slice(0, 9)})`);
95
71
  if (head !== meta.head) reasons.push(`the fixture's head is now ${head.slice(0, 9)} (published at ${String(meta.head).slice(0, 9)})`);
96
72
  if (source.branch) {
97
- const tip = await git(source.repo, "rev-parse", `${source.branch}^{commit}`);
73
+ const tip = await git(sandbox, source.repo, "rev-parse", `${source.branch}^{commit}`);
98
74
  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)}`);
99
75
  }
100
76
  } catch (error) {