SPB Git

spb/autoscholar

Public
5.6 KB · 157 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 buf = await readFile(p);81  return { sha256: createHash("sha256").update(buf).digest("hex"), bytes: buf.length };82}8384/** Uploads every new/changed file under the workspace (excluding platform dirs). Tracks state in .autoscholar/uploaded.json. */85export async function syncArtifacts(86  workspace: string,87  stage: StageKey,88  upload: (path: string, kind: ArtifactKind, contentType: string, buf: Buffer) => Promise<void>,89  emit: (kind: string, message: string, data?: Record<string, unknown>) => void,90  opts: { synthetic?: boolean } = {},91): Promise<UploadRecord[]> {92  const statePath = join(workspace, ".autoscholar/uploaded.json");93  let state: Record<string, string> = {};94  try {95    state = JSON.parse(await readFile(statePath, "utf8"));96  } catch {97    /* first upload */98  }99  const files = await listFiles(workspace);100  const uploaded: UploadRecord[] = [];101  let skipped = 0;102  for (const rel of files) {103    if (SKIP.some((r) => r.test(rel))) continue;104    if (105      rel.startsWith("home/") ||106      rel.startsWith(".autoscholar/") ||107      rel.startsWith(".claude/") ||108      rel === "CLAUDE.md"109    )110      continue;111    const abs = join(workspace, rel);112    const st = await stat(abs);113    if (st.size > MAX_ARTIFACT_BYTES) {114      skipped++;115      emit("artifact.skipped", `skipped ${rel} (${st.size} bytes > limit)`, { path: rel, bytes: st.size });116      continue;117    }118    const buf = await readFile(abs);119    const sha256 = createHash("sha256").update(buf).digest("hex");120    if (state[rel] === sha256) continue;121    await upload(rel, kindFor(rel), contentTypeFor(rel), buf);122    state[rel] = sha256;123    uploaded.push({ path: rel, sha256, bytes: buf.length, stage });124  }125  await mkdir(join(workspace, ".autoscholar"), { recursive: true });126  await writeFile(statePath, JSON.stringify(state, null, 1));127  if (uploaded.length || skipped)128    emit(129      "artifact.sync",130      `uploaded ${uploaded.length} artifact(s) after ${stage}${skipped ? `, ${skipped} skipped` : ""}`,131      { stage, uploaded: uploaded.length, skipped, synthetic: !!opts.synthetic },132    );133  return uploaded;134}135136/** Writes provenance/checksums.sha256 for every artifact file (excluding itself and platform dirs). */137export async function writeChecksums(workspace: string): Promise<string> {138  const files = (await listFiles(workspace)).filter(139    (f) =>140      !SKIP.some((r) => r.test(f)) &&141      !f.startsWith("home/") &&142      !f.startsWith(".autoscholar/") &&143      !f.startsWith(".claude/") &&144      f !== "CLAUDE.md" &&145      f !== "provenance/checksums.sha256",146  );147  const lines: string[] = [];148  for (const f of files.sort()) {149    const { sha256 } = await hashFile(join(workspace, f));150    lines.push(`${sha256}  ${f}`);151  }152  await mkdir(join(workspace, "provenance"), { recursive: true });153  const out = `${lines.join("\n")}\n`;154  await writeFile(join(workspace, "provenance/checksums.sha256"), out);155  return out;156}157
Files · 4ee0eb55785162e74bf5de92a13ac2a30a1c4528
Menu

Theme