SPB Git

Repository Public

artifacts.ts 3.5 KB 87 lines typescript

1/**2 * Artifact collection after a task ends: `artifact_globs` are resolved relative to the task cwd3 * by listing directories through the runtime (`/fs/list`, which never leaves the root). Bounded:4 * at most 400 directory listings and 200 artifacts per task. Only metadata is stored; the bytes5 * stay where the task wrote them and are streamed on download.6 */7import type { FileEntry } from '@cr/shared';8import type { Db } from '../../db.js';9import type { RuntimeClient } from '../../runtime-client.js';1011const MAX_LISTINGS = 400;12const MAX_ARTIFACTS = 200;1314/** Glob → RegExp over a `/`-separated relative path. Supports `*`, `?`, and `**`. */15export function globToRegExp(glob: string): RegExp {16  let re = '';17  for (let i = 0; i < glob.length; i++) {18    const c = glob[i] as string;19    if (c === '*') {20      if (glob[i + 1] === '*') {21        i++;22        if (glob[i + 1] === '/') {23          i++;24          re += '(?:.*/)?';25        } else re += '.*';26      } else re += '[^/]*';27    } else if (c === '?') re += '[^/]';28    else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');29  }30  return new RegExp(`^${re}$`);31}3233function cleanGlob(glob: string): string | null {34  const parts = glob.split('/').filter((s) => s !== '' && s !== '.');35  if (glob.includes('\0') || glob.startsWith('/') || parts.includes('..') || parts.length === 0) return null;36  return parts.join('/');37}3839export async function collectArtifacts(40  db: Db,41  rt: RuntimeClient,42  task: { id: string; cwd_abs: string; artifact_globs: string[] },43): Promise<number> {44  const globs = task.artifact_globs.map(cleanGlob).filter((g): g is string => g !== null);45  if (globs.length === 0) return 0;46  const patterns = globs.map(globToRegExp);47  // Walk only below the literal directory prefix of each glob; recurse only when it uses `**`.48  const starts = new Map<string, boolean>();49  for (const g of globs) {50    const segs = g.split('/');51    const firstWild = segs.findIndex((s) => /[*?]/.test(s));52    const prefix = (firstWild === -1 ? segs.slice(0, -1) : segs.slice(0, firstWild)).join('/');53    const deep = g.includes('**') || (firstWild !== -1 && firstWild < segs.length - 1);54    starts.set(prefix, (starts.get(prefix) ?? false) || deep);55  }56  const found = new Map<string, FileEntry>();57  const seenDirs = new Set<string>();58  let listings = 0;59  const walk = async (dir: string, deep: boolean): Promise<void> => {60    if (seenDirs.has(dir) || listings >= MAX_LISTINGS || found.size >= MAX_ARTIFACTS) return;61    seenDirs.add(dir);62    listings++;63    let entries: FileEntry[];64    try {65      entries = await rt.json<FileEntry[]>('POST', '/fs/list', { root: task.cwd_abs, rel: dir });66    } catch {67      return; // missing directory or runtime problem: nothing to register68    }69    if (!Array.isArray(entries)) return;70    for (const e of entries) {71      const rel = dir ? `${dir}/${e.name}` : e.name;72      if (e.type === 'file' && patterns.some((p) => p.test(rel)) && found.size < MAX_ARTIFACTS) found.set(rel, e);73      else if (e.type === 'dir' && deep && e.name !== '.git' && e.name !== 'node_modules') await walk(rel, deep);74    }75  };76  for (const [prefix, deep] of starts) await walk(prefix, deep);7778  for (const [rel, e] of found) {79    await db.q(80      `INSERT INTO task_artifacts (task_id, name, rel_path, size)81       SELECT $1, $2, $3, $4 WHERE NOT EXISTS (SELECT 1 FROM task_artifacts WHERE task_id = $1 AND rel_path = $3)`,82      [task.id, rel.split('/').pop() ?? rel, rel, Math.max(0, Math.trunc(e.size))],83    );84  }85  return found.size;86}87
Files main
Menu

Theme