SPB Git

spb/autoscholar

Public
6.9 KB · 192 lines typescript
1import { createHash } from "node:crypto";2import { mkdir, readFile, stat, writeFile } from "node:fs/promises";3import { join } from "node:path";4import type { ArtifactKind, StageKey } from "@autoscholar/schemas";5import { listFiles } from "./latex.js";67export const MAX_ARTIFACT_BYTES = 256 * 1024 * 1024;89export function kindFor(path: string): ArtifactKind {10  if (path === "input/CLAUDE.md") return "instruction";11  if (path === "input/run.json") return "manifest";12  if (path.startsWith("plan/")) return "plan";13  if (path.startsWith("sources/")) return "source";14  if (path.startsWith("data/")) return "data";15  if (path.startsWith("code/")) return "code";16  if (path.startsWith("results/figures/")) return "figure";17  if (path.startsWith("results/tables/")) return "table";18  if (path.startsWith("results/")) return "result";19  if (path === "manuscript/main.pdf") return "pdf";20  if (path.startsWith("manuscript/")) return "manuscript";21  if (path.startsWith("review/")) return "review";22  if (path.startsWith("provenance/checksums")) return "checksum";23  if (path.startsWith("provenance/")) return "provenance";24  if (path.startsWith("logs/")) return "log";25  return "other";26}2728export function contentTypeFor(path: string): string {29  const ext = path.split(".").pop()?.toLowerCase();30  const map: Record<string, string> = {31    pdf: "application/pdf",32    md: "text/markdown; charset=utf-8",33    txt: "text/plain; charset=utf-8",34    json: "application/json",35    jsonl: "application/x-ndjson",36    tex: "text/x-tex; charset=utf-8",37    bib: "text/x-bibtex; charset=utf-8",38    cls: "text/x-tex; charset=utf-8",39    sty: "text/x-tex; charset=utf-8",40    bst: "text/plain; charset=utf-8",41    csv: "text/csv; charset=utf-8",42    png: "image/png",43    jpg: "image/jpeg",44    jpeg: "image/jpeg",45    svg: "image/svg+xml",46    py: "text/x-python; charset=utf-8",47    r: "text/plain; charset=utf-8",48    jl: "text/plain; charset=utf-8",49    sh: "text/x-shellscript; charset=utf-8",50    yaml: "text/yaml",51    yml: "text/yaml",52    toml: "text/plain; charset=utf-8",53    log: "text/plain; charset=utf-8",54    sha256: "text/plain; charset=utf-8",55    parquet: "application/vnd.apache.parquet",56    zip: "application/zip",57    lean: "text/plain; charset=utf-8",58    html: "text/html; charset=utf-8",59    ipynb: "application/x-ipynb+json",60  };61  return map[ext ?? ""] ?? "application/octet-stream";62}6364const SKIP = [65  /(^|\/)\.DS_Store$/,66  /\.(aux|toc|out|bbl|blg|fls|fdb_latexmk|synctex\.gz|xdv|nav|snm|lof|lot)$/,67  /(^|\/)\.pytest_cache\//,68  /\.pyc$/,69  /(^|\/)\.ipynb_checkpoints\//,70];7172export interface UploadRecord {73  path: string;74  sha256: string;75  bytes: number;76  stage: StageKey;77}7879export async function hashFile(p: string): Promise<{ sha256: string; bytes: number }> {80  const { createReadStream } = await import("node:fs");81  const h = createHash("sha256");82  let bytes = 0;83  for await (const chunk of createReadStream(p)) {84    h.update(chunk as Buffer);85    bytes += (chunk as Buffer).length;86  }87  return { sha256: h.digest("hex"), bytes };88}8990/** Uploads every new/changed file under the workspace (excluding platform dirs). Tracks state in .autoscholar/uploaded.json. */91export async function syncArtifacts(92  workspace: string,93  stage: StageKey,94  upload: (path: string, kind: ArtifactKind, contentType: string, buf: Buffer) => Promise<void>,95  emit: (kind: string, message: string, data?: Record<string, unknown>) => void,96  opts: { synthetic?: boolean } = {},97): Promise<UploadRecord[]> {98  const statePath = join(workspace, ".autoscholar/uploaded.json");99  let state: Record<string, string> = {};100  try {101    state = JSON.parse(await readFile(statePath, "utf8"));102  } catch {103    /* first upload */104  }105  const files = await listFiles(workspace);106  const uploaded: UploadRecord[] = [];107  let skipped = 0;108  for (const rel of files) {109    if (SKIP.some((r) => r.test(rel))) continue;110    if (111      rel.startsWith("home/") ||112      rel.startsWith(".autoscholar/") ||113      rel.startsWith(".claude/") ||114      rel === "CLAUDE.md"115    )116      continue;117    const abs = join(workspace, rel);118    const st = await stat(abs);119    if (st.size > MAX_ARTIFACT_BYTES) {120      skipped++;121      emit("artifact.skipped", `skipped ${rel} (${st.size} bytes > limit)`, { path: rel, bytes: st.size });122      continue;123    }124    const buf = await readFile(abs);125    const sha256 = createHash("sha256").update(buf).digest("hex");126    if (state[rel] === sha256) continue;127    await upload(rel, kindFor(rel), contentTypeFor(rel), buf);128    state[rel] = sha256;129    uploaded.push({ path: rel, sha256, bytes: buf.length, stage });130  }131  await mkdir(join(workspace, ".autoscholar"), { recursive: true });132  await writeFile(statePath, JSON.stringify(state, null, 1));133  if (uploaded.length || skipped)134    emit(135      "artifact.sync",136      `uploaded ${uploaded.length} artifact(s) after ${stage}${skipped ? `, ${skipped} skipped` : ""}`,137      { stage, uploaded: uploaded.length, skipped, synthetic: !!opts.synthetic },138    );139  return uploaded;140}141142/** Writes provenance/checksums.sha256 for every artifact file (excluding itself and platform dirs). */143/**144 * Writes provenance/checksums.sha256. When an upload-state file is given, digests come from what was actually uploaded145 * (consistent with the artifact store even if files change afterwards); otherwise files are hashed in place.146 */147export async function writeChecksums(workspace: string, uploadStatePath?: string): Promise<string> {148  if (uploadStatePath) {149    try {150      const state = JSON.parse(await readFile(uploadStatePath, "utf8")) as Record<string, string>;151      const lines = Object.entries(state)152        .filter(([p]) => p !== "provenance/checksums.sha256")153        .sort(([a], [b]) => (a < b ? -1 : 1))154        .map(([p, sha]) => `${sha}  ${p}`);155      await mkdir(join(workspace, "provenance"), { recursive: true });156      const out = `${lines.join("\n")}\n`;157      await writeFile(join(workspace, "provenance/checksums.sha256"), out);158      return out;159    } catch {160      /* fall back to hashing */161    }162  }163  const files = (await listFiles(workspace)).filter(164    (f) =>165      !SKIP.some((r) => r.test(f)) &&166      !f.startsWith("home/") &&167      !f.startsWith(".autoscholar/") &&168      !f.startsWith(".claude/") &&169      f !== "CLAUDE.md" &&170      f !== "provenance/checksums.sha256",171  );172  const lines: string[] = [];173  const oversize: string[] = [];174  for (const f of files.sort()) {175    try {176      const st = await stat(join(workspace, f));177      if (st.size > MAX_ARTIFACT_BYTES) {178        oversize.push(`# not archived (oversize ${st.size} bytes): ${f}`);179        continue;180      }181      const { sha256 } = await hashFile(join(workspace, f));182      lines.push(`${sha256}  ${f}`);183    } catch (e) {184      oversize.push(`# unreadable: ${f} (${(e as Error).message.slice(0, 80)})`);185    }186  }187  await mkdir(join(workspace, "provenance"), { recursive: true });188  const out = `${[...lines, ...oversize].join("\n")}\n`;189  await writeFile(join(workspace, "provenance/checksums.sha256"), out);190  return out;191}192
Files · main
Menu

Theme