Repository Public
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