From a71a9fc0b35854cd534c224bf06b69dc753629dc Mon Sep 17 00:00:00 2001 From: Drew Stone Date: Wed, 1 Jul 2026 16:14:24 -0600 Subject: [PATCH] feat(campaign): resolve bare runDir to ~/.tangle/traces//runs (stop repo-tree pollution) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campaign/benchmark run bundles were written to a caller-supplied runDir that was often repo-root-relative, so every run littered the working tree with untracked output (product-benchmark-records.jsonl, source-runs/, raw-events/ — what got swept into stashes across repos). Now runCampaign/planCampaignRun resolve a BARE runDir name to the shared, out-of-repo home root ~/.tangle/traces//runs/; absolute paths are honored unchanged. Callers pass a name, not a path — bundles never touch a repo tree, no per-repo .gitignore needed. New: resolveRunDir()/tangleTracesRoot() (exported); +repo option. tsc 0; 4/4 tests. --- src/campaign/index.ts | 1 + src/campaign/run-campaign.ts | 12 +++++++++++- src/campaign/run-dir.test.ts | 30 ++++++++++++++++++++++++++++++ src/campaign/run-dir.ts | 21 +++++++++++++++++++++ 4 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/campaign/run-dir.test.ts create mode 100644 src/campaign/run-dir.ts diff --git a/src/campaign/index.ts b/src/campaign/index.ts index 4c5ede1..cb17f90 100644 --- a/src/campaign/index.ts +++ b/src/campaign/index.ts @@ -210,6 +210,7 @@ export { type RunCampaignOptions, runCampaign, } from './run-campaign' +export { resolveRunDir, tangleTracesRoot } from './run-dir.js' export { type CampaignBreakdown, campaignBreakdown, campaignMeanComposite } from './score-utils' export { type ApplySkillPatchResult, diff --git a/src/campaign/run-campaign.ts b/src/campaign/run-campaign.ts index f2b66fa..8e06138 100644 --- a/src/campaign/run-campaign.ts +++ b/src/campaign/run-campaign.ts @@ -13,6 +13,7 @@ import { join } from 'node:path' import { BackendIntegrityError, type BackendIntegrityReport } from '../integrity/backend-integrity' import { confidenceInterval } from '../statistics' import { contentHash } from '../verdict-cache' +import { resolveRunDir } from './run-dir' import { type CampaignStorage, fsCampaignStorage } from './storage' import type { CampaignAggregates, @@ -71,8 +72,13 @@ export interface RunCampaignOptions { * every loop/CI job above it) forever. `undefined`/`0` = unbounded (legacy). */ dispatchTimeoutMs?: number - /** Required: where artifacts + traces land. */ + /** Required: where artifacts + traces land. A bare name (not an absolute path) + * resolves to the shared `~/.tangle/traces//runs/` root so run + * bundles never pollute a repo working tree. Pass an absolute path to override. */ runDir: string + /** Subject repo for the shared run-dir root (defaults to the CWD basename). + * Only consulted when `runDir` is a bare name. */ + repo?: string /** Tracing posture. Default is the substrate's `FileSystemTraceStore` rooted * at `/traces/`. `'off'` disables capture entirely — substrate * refuses this when the caller wires `autoOnPromote !== 'none'`. */ @@ -130,6 +136,7 @@ export async function runCampaign( if (typeof opts.runDir !== 'string' || opts.runDir.trim().length === 0) { throw new Error('runCampaign: runDir is required and must be a non-empty string') } + opts.runDir = resolveRunDir(opts.runDir, opts.repo) storage.ensureDir(opts.runDir) const manifestHash = computeManifestHash({ @@ -434,6 +441,8 @@ export interface PlanCampaignRunOptions { reps?: number resumable?: boolean runDir: string + /** Subject repo for the shared run-dir root (see RunCampaignOptions.repo). */ + repo?: string storage?: CampaignStorage } @@ -448,6 +457,7 @@ export function planCampaignRun( if (typeof opts.runDir !== 'string' || opts.runDir.trim().length === 0) { throw new Error('planCampaignRun: runDir is required and must be a non-empty string') } + opts.runDir = resolveRunDir(opts.runDir, opts.repo) const manifestHash = computeManifestHash({ scenarios: opts.scenarios, diff --git a/src/campaign/run-dir.test.ts b/src/campaign/run-dir.test.ts new file mode 100644 index 0000000..e27ec5f --- /dev/null +++ b/src/campaign/run-dir.test.ts @@ -0,0 +1,30 @@ +import { homedir } from 'node:os' +import { basename, isAbsolute, join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { resolveRunDir, tangleTracesRoot } from './run-dir' + +describe('resolveRunDir', () => { + it('places a bare name under the shared home root, namespaced by repo', () => { + expect(resolveRunDir('tax-legal-research-r220', 'traces')).toBe( + join(homedir(), '.tangle', 'traces', 'traces', 'runs', 'tax-legal-research-r220'), + ) + }) + + it('defaults the repo segment to the CWD basename', () => { + expect(resolveRunDir('r1')).toBe( + join(tangleTracesRoot(), basename(process.cwd()), 'runs', 'r1'), + ) + }) + + it('honors an absolute path unchanged (explicit override)', () => { + const abs = join(homedir(), 'somewhere', 'else') + expect(isAbsolute(abs)).toBe(true) + expect(resolveRunDir(abs, 'traces')).toBe(abs) + }) + + it('never lands inside the current repo tree for a bare name', () => { + const resolved = resolveRunDir('r1', 'traces') + expect(resolved.startsWith(process.cwd())).toBe(false) + expect(resolved.startsWith(tangleTracesRoot())).toBe(true) + }) +}) diff --git a/src/campaign/run-dir.ts b/src/campaign/run-dir.ts new file mode 100644 index 0000000..7bcb575 --- /dev/null +++ b/src/campaign/run-dir.ts @@ -0,0 +1,21 @@ +import { homedir } from 'node:os' +import { basename, isAbsolute, join } from 'node:path' + +/** The shared, out-of-repo root for campaign/benchmark run bundles. Keeping run + * outputs here means they never land in a repo working tree (no per-repo + * gitignore, no clutter, no accidental commits). Layout: + * ~/.tangle/traces//runs// + * where disambiguates runs across repos in one place. */ +export function tangleTracesRoot(): string { + return join(homedir(), '.tangle', 'traces') +} + +/** Resolve a campaign `runDir`. An absolute path is honored as-is (the caller + * chose an explicit location). A bare name is placed under the shared home root + * so bundles never pollute a repo working tree — the default the harness should + * compute so callers pass a *name*, not a path. */ +export function resolveRunDir(runDir: string, repo?: string): string { + if (isAbsolute(runDir)) return runDir + const r = repo && repo.trim().length > 0 ? repo : basename(process.cwd()) + return join(tangleTracesRoot(), r, 'runs', runDir) +}