From d96c88d89a7dcfcd02ef99d4363338327bdda056 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Thu, 11 Jun 2026 04:14:15 +0200 Subject: [PATCH] feat(config): hard-break dashboard project schema --- apps/cli/src/commands/results/remote.ts | 8 +- apps/cli/test/commands/results/serve.test.ts | 42 ++--- apps/dashboard/src/routes/index.tsx | 2 +- .../src/content/docs/docs/tools/dashboard.mdx | 66 ++++++-- .../evaluation/validation/config-validator.ts | 154 +++++++++++++++++- packages/core/src/index.ts | 3 +- packages/core/src/project-sync.ts | 29 ++-- packages/core/src/projects.ts | 85 +++++----- .../validation/config-validator.test.ts | 98 +++++++++-- packages/core/test/project-sync.test.ts | 20 +-- packages/core/test/projects.test.ts | 56 ++++--- 11 files changed, 418 insertions(+), 145 deletions(-) diff --git a/apps/cli/src/commands/results/remote.ts b/apps/cli/src/commands/results/remote.ts index 91dffdae1..dc576e564 100644 --- a/apps/cli/src/commands/results/remote.ts +++ b/apps/cli/src/commands/results/remote.ts @@ -153,10 +153,10 @@ async function loadNormalizedResultsConfig( : (getProjectForPath(repoRoot) ?? getProjectForPath(cwd)); const projectResults = project?.results ? { - mode: project.results.mode, - repo: project.results.repo, - path: project.results.path, - auto_push: project.results.autoPush, + mode: 'github' as const, + repo: project.results.repository, + path: project.results.localPath, + auto_push: project.results.sync?.autoPush, branch_prefix: project.results.branchPrefix, } : undefined; diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index ca0e2f97f..37c20a53c 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -1272,10 +1272,9 @@ describe('serve app', () => { name: 'Project No Publish', path: projectDir, results: { - mode: 'github', - repo: `file://${remoteDir}`, - path: missingCloneDir, - autoPush: true, + repository: `file://${remoteDir}`, + localPath: missingCloneDir, + sync: { autoPush: true }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', @@ -1413,10 +1412,9 @@ describe('serve app', () => { name: 'AgentV', path: projectDir, results: { - mode: 'github', - repo: 'EntityProcess/agentv-examples-eval-results', - path: '/home/entity/projects/EntityProcess/agentv-examples-eval-results', - autoPush: true, + repository: 'EntityProcess/agentv-examples-eval-results', + localPath: '/home/entity/projects/EntityProcess/agentv-examples-eval-results', + sync: { autoPush: true }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', @@ -1466,10 +1464,9 @@ describe('serve app', () => { name: 'Project Sync Pull', path: projectDir, results: { - mode: 'github', - repo: `file://${remoteDir}`, - path: cloneDir, - autoPush: false, + repository: `file://${remoteDir}`, + localPath: cloneDir, + sync: { autoPush: false }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', @@ -1546,10 +1543,9 @@ describe('serve app', () => { name: 'Project Sync Push', path: projectDir, results: { - mode: 'github', - repo: `file://${remoteDir}`, - path: cloneDir, - autoPush: true, + repository: `file://${remoteDir}`, + localPath: cloneDir, + sync: { autoPush: true }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', @@ -1614,10 +1610,9 @@ describe('serve app', () => { name: 'Project Sync Offline', path: projectDir, results: { - mode: 'github', - repo: `file://${remoteDir}`, - path: cloneDir, - autoPush: true, + repository: `file://${remoteDir}`, + localPath: cloneDir, + sync: { autoPush: true }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', @@ -1674,10 +1669,9 @@ describe('serve app', () => { name: 'Project Sync Conflict', path: projectDir, results: { - mode: 'github', - repo: `file://${remoteDir}`, - path: cloneDir, - autoPush: true, + repository: `file://${remoteDir}`, + localPath: cloneDir, + sync: { autoPush: true }, }, addedAt: '2026-01-01T00:00:00.000Z', lastOpenedAt: '2026-01-01T00:00:00.000Z', diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index bec70db11..583ef5942 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -427,7 +427,7 @@ function RunsTabContent({

Sync remote results or run an eval with{' '} - auto_push: true + sync.auto_push: true {' '} in your config.

diff --git a/apps/web/src/content/docs/docs/tools/dashboard.mdx b/apps/web/src/content/docs/docs/tools/dashboard.mdx index f1df5f576..5dad71e2a 100644 --- a/apps/web/src/content/docs/docs/tools/dashboard.mdx +++ b/apps/web/src/content/docs/docs/tools/dashboard.mdx @@ -189,16 +189,15 @@ agentv dashboard --add /path/to/other-evals Each path must contain a `.agentv/` directory. Registered projects are stored under `projects:` in `$AGENTV_HOME/config.yaml`, or `~/.agentv/config.yaml` when `AGENTV_HOME` is unset. -To register a remote repo and keep it synced automatically, add a `source` block to the entry in `$AGENTV_HOME/config.yaml`: +To register a remote repo and keep it synced automatically, add `repository` and `ref` to the entry in `$AGENTV_HOME/config.yaml`. `repository` uses GitHub's standard owner/name form and AgentV resolves it to `https://github.com//.git` for clone and pull operations: ```yaml projects: - id: my-evals name: My Evals + repository: example/my-evals path: /srv/agentv/my-evals - source: - url: https://github.com/example/my-evals - ref: main + ref: main ``` On each Dashboard startup, AgentV clones the repo if the path is empty (`git clone --depth 1`) or pulls the latest if a clone already exists (`git pull --ff-only`). You can also trigger a sync manually from the Dashboard UI's **Sync** button. @@ -256,15 +255,17 @@ For a registered project, put results repo settings on that project's entry in ` projects: - id: agentv name: AgentV + repository: EntityProcess/agentv path: /home/entity/projects/EntityProcess/agentv + ref: main results: - mode: github - repo: EntityProcess/agentv-examples-eval-results - path: /home/entity/projects/EntityProcess/agentv-examples-eval-results - auto_push: true + repository: EntityProcess/agentv-examples-eval-results + local_path: /home/entity/projects/EntityProcess/agentv-examples-eval-results + sync: + auto_push: true ``` -`results.path` is the filesystem location of the local clone AgentV manages for the results repo. It is **not** a subdirectory inside the remote repo. +`results.local_path` is the filesystem location of the local clone AgentV manages for the results repo. It is **not** a subdirectory inside the remote repo. `results.repository` uses GitHub owner/name form and resolves to `https://github.com//.git` for clone and push operations. You can also set a top-level global fallback in the same file. This is used when the current project is not registered or its registry entry has no `results` block: @@ -278,10 +279,47 @@ results: Project-local `.agentv/config.yaml` is for portable eval defaults such as `execution`, `eval_patterns`, and `dashboard`. Do not put `projects` in project-local config; AgentV warns and ignores it there. `results_by_project` is deprecated; use `projects[].results` in `$AGENTV_HOME/config.yaml`. -The `source` block and the `results` block sync different repositories: +The project `repository` and the `results` block sync different repositories: + +- `projects[].repository` is the eval source project. Dashboard startup clones or fast-forwards the project checkout so eval YAML, scripts, and project-local `.agentv/config.yaml` stay current. +- `projects[].results.repository` is the git-backed results store. **Sync Project** fetches, fast-forwards, and, when configured, pushes run artifacts and mutable metadata in that results repo clone. + +#### Migration from the legacy project schema + +Before: + +```yaml +projects: + - id: agentv + name: AgentV + path: /home/entity/projects/EntityProcess/agentv + source: + url: https://github.com/EntityProcess/agentv + ref: main + results: + mode: github + repo: EntityProcess/agentv-eval-results + path: /home/entity/projects/EntityProcess/agentv-eval-results + auto_push: true +``` + +After: + +```yaml +projects: + - id: agentv + name: AgentV + repository: EntityProcess/agentv + path: /home/entity/projects/EntityProcess/agentv + ref: main + results: + repository: EntityProcess/agentv-eval-results + local_path: /home/entity/projects/EntityProcess/agentv-eval-results + sync: + auto_push: true +``` -- `projects[].source` is the eval source project. Dashboard startup clones or fast-forwards the project checkout so eval YAML, scripts, and project-local `.agentv/config.yaml` stay current. -- `projects[].results` is the git-backed results store. **Sync Project** fetches, fast-forwards, and, when configured, pushes run artifacts and mutable metadata in that results repo clone. +Legacy project fields (`source`, `results.mode`, `results.repo`, `results.path`, and `results.auto_push`) fail validation with migration guidance. Use project-level **Sync Project** as the results exchange workflow. It handles pulled remote runs, locally edited metadata, dirty state, and blocked conflict feedback in one project-scoped action. @@ -327,8 +365,8 @@ After sync, newly fetched remote runs appear in the list with a **remote** sourc **Sync Project** fetches the results repo and only changes the clone when Git says it is safe: - A clean clone that is behind the remote is fast-forwarded. -- Safe uncommitted changes under `.agentv/results/**`, such as remote tag metadata overlays, are committed and pushed when `auto_push: true`. -- A local results repo that is ahead is pushed when `auto_push: true` and the committed paths are all under `.agentv/results/**`. +- Safe uncommitted changes under `.agentv/results/**`, such as remote tag metadata overlays, are committed and pushed when `sync.auto_push: true`. +- A local results repo that is ahead is pushed when `sync.auto_push: true` and the committed paths are all under `.agentv/results/**`. - Dirty non-results files, dirty metadata plus remote changes, diverged history, unresolved conflicts, missing upstream branches, non-results commits ahead, and rejected pushes are blocked instead of reset. When sync is blocked, Dashboard keeps the local clone intact and shows the `block_reason`, `dirty_paths` or `conflicted_paths`, `git_status`, and a compact `git_diff_summary` so you can resolve the results repo manually before syncing again. diff --git a/packages/core/src/evaluation/validation/config-validator.ts b/packages/core/src/evaluation/validation/config-validator.ts index f1a086ceb..18f7e88db 100644 --- a/packages/core/src/evaluation/validation/config-validator.ts +++ b/packages/core/src/evaluation/validation/config-validator.ts @@ -169,7 +169,30 @@ function validateProjects(errors: ValidationError[], filePath: string, projects: validateRequiredString(errors, filePath, projectRecord.id, `${location}.id`); validateRequiredString(errors, filePath, projectRecord.name, `${location}.name`); validateRequiredString(errors, filePath, projectRecord.path, `${location}.path`); - validateResultsConfig(errors, filePath, projectRecord.results, `${location}.results`); + + if (projectRecord.source !== undefined) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.source`, + message: `Field '${location}.source' was removed. Move 'source.url' to '${location}.repository' as a GitHub owner/name value (for example, 'example/repo') and move 'source.ref' to '${location}.ref'.`, + }); + } + + if (projectRecord.repository !== undefined) { + validateGitHubRepository( + errors, + filePath, + projectRecord.repository, + `${location}.repository`, + ); + } + + if (projectRecord.ref !== undefined) { + validateRequiredString(errors, filePath, projectRecord.ref, `${location}.ref`); + } + + validateProjectResultsConfig(errors, filePath, projectRecord.results, `${location}.results`); }); } @@ -189,6 +212,135 @@ function validateRequiredString( } } +function validateGitHubRepository( + errors: ValidationError[], + filePath: string, + value: unknown, + location: string, +): void { + if (typeof value !== 'string' || value.trim().length === 0) { + errors.push({ + severity: 'error', + filePath, + location, + message: `Field '${location}' must be a non-empty GitHub owner/name repository (e.g., EntityProcess/agentv)`, + }); + return; + } + + const repository = value.trim(); + if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) { + errors.push({ + severity: 'error', + filePath, + location, + message: `Field '${location}' must use GitHub owner/name format (e.g., EntityProcess/agentv), not a URL. It resolves to https://github.com//.git for git operations.`, + }); + } +} + +function validateProjectResultsConfig( + errors: ValidationError[], + filePath: string, + rawResults: unknown, + location: string, +): void { + if (rawResults === undefined) { + return; + } + + if (typeof rawResults !== 'object' || rawResults === null || Array.isArray(rawResults)) { + errors.push({ + severity: 'error', + filePath, + location, + message: `Field '${location}' must be an object`, + }); + return; + } + + const resultsRecord = rawResults as Record; + + const removedFields: Record = { + mode: `Remove '${location}.mode'; project results are GitHub-backed by '${location}.repository'.`, + repo: `Field '${location}.repo' was removed. Use '${location}.repository' with GitHub owner/name format instead.`, + path: `Field '${location}.path' was removed. Use '${location}.local_path' for the local clone path instead.`, + auto_push: `Field '${location}.auto_push' was removed. Use '${location}.sync.auto_push' instead.`, + }; + + for (const [field, message] of Object.entries(removedFields)) { + if (resultsRecord[field] !== undefined) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.${field}`, + message, + }); + } + } + + validateGitHubRepository(errors, filePath, resultsRecord.repository, `${location}.repository`); + + if (resultsRecord.local_path !== undefined) { + if ( + typeof resultsRecord.local_path !== 'string' || + resultsRecord.local_path.trim().length === 0 + ) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.local_path`, + message: `Field '${location}.local_path' must be a non-empty string`, + }); + } else if (!isFilesystemPath(resultsRecord.local_path.trim())) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.local_path`, + message: `'${location}.local_path' must be an absolute or home-relative filesystem path (e.g., ~/data/agentv-results).`, + }); + } + } + + if (resultsRecord.sync !== undefined) { + if ( + typeof resultsRecord.sync !== 'object' || + resultsRecord.sync === null || + Array.isArray(resultsRecord.sync) + ) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.sync`, + message: `Field '${location}.sync' must be an object`, + }); + } else { + const syncRecord = resultsRecord.sync as Record; + if (syncRecord.auto_push !== undefined && typeof syncRecord.auto_push !== 'boolean') { + errors.push({ + severity: 'error', + filePath, + location: `${location}.sync.auto_push`, + message: `Field '${location}.sync.auto_push' must be a boolean`, + }); + } + } + } + + if ( + resultsRecord.branch_prefix !== undefined && + (typeof resultsRecord.branch_prefix !== 'string' || + resultsRecord.branch_prefix.trim().length === 0) + ) { + errors.push({ + severity: 'error', + filePath, + location: `${location}.branch_prefix`, + message: `Field '${location}.branch_prefix' must be a non-empty string`, + }); + } +} + function validateResultsConfig( errors: ValidationError[], filePath: string, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7fc9e5861..f42746bb2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -97,7 +97,6 @@ export { } from './paths.js'; export { type ProjectEntry, - type ProjectSource, type ProjectRegistry, loadProjectRegistry, saveProjectRegistry, @@ -110,7 +109,7 @@ export { deriveProjectId, getProjectsRegistryPath, } from './projects.js'; -export { syncProject, syncProjects } from './project-sync.js'; +export { syncProject, syncProjects, resolveGitHubRepositoryUrl } from './project-sync.js'; export { trimBaselineResult } from './evaluation/baseline.js'; export { DEFAULT_CATEGORY, deriveCategory } from './evaluation/category.js'; export * from './observability/index.js'; diff --git a/packages/core/src/project-sync.ts b/packages/core/src/project-sync.ts index 761e17834..64cb1f73c 100644 --- a/packages/core/src/project-sync.ts +++ b/packages/core/src/project-sync.ts @@ -1,5 +1,5 @@ /** - * Project sync — pulls remote git repos to the local path declared in the + * Project sync — pulls remote GitHub repos to the local path declared in the * project registry before Dashboard/eval startup. * * Sync is oneshot only, triggered by the Dashboard UI "Sync" button or the @@ -18,17 +18,24 @@ import { existsSync } from 'node:fs'; import type { ProjectEntry } from './projects.js'; +export function resolveGitHubRepositoryUrl(repository: string): string { + return `https://github.com/${repository.trim().replace(/\.git$/, '')}.git`; +} + /** - * Clone or pull a single project entry from its declared source. + * Clone or pull a single project entry from its declared repository. * - No .git present: shallow clone into entry.path. * - .git present: git pull --ff-only to update in place. - * Throws on git error or missing source. + * Throws on git error or missing repository/ref. */ export async function syncProject(entry: ProjectEntry): Promise { - if (!entry.source) { - throw new Error(`Project '${entry.id}' has no source defined`); + if (!entry.repository) { + throw new Error(`Project '${entry.id}' has no repository defined`); + } + if (!entry.ref) { + throw new Error(`Project '${entry.id}' has no ref defined`); } - const { url, ref } = entry.source; + const url = resolveGitHubRepositoryUrl(entry.repository); const dest = entry.path; if (existsSync(`${dest}/.git`)) { @@ -36,20 +43,20 @@ export async function syncProject(entry: ProjectEntry): Promise { } else { childProcess.execFileSync( 'git', - ['clone', '--depth', '1', '--filter=blob:none', '--branch', ref, url, dest], + ['clone', '--depth', '1', '--filter=blob:none', '--branch', entry.ref, url, dest], { stdio: 'inherit' }, ); } } /** - * Iterate project entries and sync any that have a source declared. - * Entries without source are skipped silently. + * Iterate project entries and sync any that have a repository declared. + * Entries without repository are skipped silently. */ export async function syncProjects(entries: ProjectEntry[]): Promise { for (const entry of entries) { - if (!entry.source) continue; - console.log(`Syncing project '${entry.id}' from ${entry.source.url}...`); + if (!entry.repository) continue; + console.log(`Syncing project '${entry.id}' from ${entry.repository}...`); await syncProject(entry); console.log(`Project '${entry.id}' synced.`); } diff --git a/packages/core/src/projects.ts b/packages/core/src/projects.ts index cc224c546..93504734c 100644 --- a/packages/core/src/projects.ts +++ b/packages/core/src/projects.ts @@ -16,19 +16,18 @@ * projects: * - id: my-app * name: My App + * repository: example/my-app * path: /home/user/projects/my-app - * source: - * url: ${{ PROJECT_REPO_URL }} - * ref: ${{ PROJECT_REPO_REF:-main }} + * ref: main * results: - * mode: github - * repo: example/my-app-results - * path: /srv/agentv/results/my-app - * auto_push: true + * repository: example/my-app-results + * local_path: /srv/agentv/results/my-app + * sync: + * auto_push: true * added_at: "2026-03-20T10:00:00Z" * last_opened_at: "2026-03-30T14:00:00Z" * - * The optional `source` field enables remote sync via syncProjects(): + * The optional `repository` field enables remote sync via syncProjects(): * first run — git clone --depth 1 --filter=blob:none * subsequent runs — git pull --ff-only * @@ -57,26 +56,25 @@ import { getAgentvConfigDir } from './paths.js'; // ── Types ─────────────────────────────────────────────────────────────── -export interface ProjectResultsConfig { - mode: 'github'; - repo: string; - path?: string; +export interface ProjectResultsSyncConfig { autoPush?: boolean; - branchPrefix?: string; } -export interface ProjectSource { - url: string; - ref: string; +export interface ProjectResultsConfig { + repository: string; + localPath?: string; + sync?: ProjectResultsSyncConfig; + branchPrefix?: string; } export interface ProjectEntry { id: string; name: string; + repository?: string; path: string; + ref?: string; addedAt: string; lastOpenedAt: string; - source?: ProjectSource; results?: ProjectResultsConfig; } @@ -95,26 +93,25 @@ export function getProjectsRegistryPath(): string { // internals stay camelCase. fromYaml / toYaml handle the translation; every // other function in this module works in camelCase only. -interface ProjectSourceYaml { - url: string; - ref: string; +interface ProjectResultsSyncYaml { + auto_push?: boolean; } interface ProjectResultsYaml { - mode: 'github'; - repo: string; - path?: string; - auto_push?: boolean; + repository: string; + local_path?: string; + sync?: ProjectResultsSyncYaml; branch_prefix?: string; } interface ProjectEntryYaml { id: string; name: string; + repository?: string; path: string; + ref?: string; added_at: string; last_opened_at: string; - source?: ProjectSourceYaml; results?: ProjectResultsYaml; } @@ -131,20 +128,24 @@ function fromYaml(raw: unknown): ProjectEntry | null { addedAt: typeof e.added_at === 'string' ? e.added_at : '', lastOpenedAt: typeof e.last_opened_at === 'string' ? e.last_opened_at : '', }; - if (e.source && typeof e.source === 'object') { - const s = e.source as Partial; - if (typeof s.url === 'string' && typeof s.ref === 'string') { - entry.source = { url: s.url, ref: s.ref }; - } + if (typeof e.repository === 'string' && e.repository.trim().length > 0) { + entry.repository = e.repository.trim(); + } + if (typeof e.ref === 'string' && e.ref.trim().length > 0) { + entry.ref = e.ref.trim(); } if (e.results && typeof e.results === 'object') { const r = e.results as Partial; - if (r.mode === 'github' && typeof r.repo === 'string' && r.repo.trim().length > 0) { + if (typeof r.repository === 'string' && r.repository.trim().length > 0) { + const sync = r.sync && typeof r.sync === 'object' ? r.sync : undefined; entry.results = { - mode: 'github', - repo: r.repo.trim(), - ...(typeof r.path === 'string' && r.path.trim().length > 0 ? { path: r.path.trim() } : {}), - ...(typeof r.auto_push === 'boolean' ? { autoPush: r.auto_push } : {}), + repository: r.repository.trim(), + ...(typeof r.local_path === 'string' && r.local_path.trim().length > 0 + ? { localPath: r.local_path.trim() } + : {}), + ...(sync && typeof sync.auto_push === 'boolean' + ? { sync: { autoPush: sync.auto_push } } + : {}), ...(typeof r.branch_prefix === 'string' && r.branch_prefix.trim().length > 0 ? { branchPrefix: r.branch_prefix.trim() } : {}), @@ -158,19 +159,19 @@ function toYaml(entry: ProjectEntry): ProjectEntryYaml { const yaml: ProjectEntryYaml = { id: entry.id, name: entry.name, + ...(entry.repository !== undefined && { repository: entry.repository }), path: entry.path, + ...(entry.ref !== undefined && { ref: entry.ref }), added_at: entry.addedAt, last_opened_at: entry.lastOpenedAt, }; - if (entry.source) { - yaml.source = { url: entry.source.url, ref: entry.source.ref }; - } if (entry.results) { yaml.results = { - mode: entry.results.mode, - repo: entry.results.repo, - ...(entry.results.path !== undefined && { path: entry.results.path }), - ...(entry.results.autoPush !== undefined && { auto_push: entry.results.autoPush }), + repository: entry.results.repository, + ...(entry.results.localPath !== undefined && { local_path: entry.results.localPath }), + ...(entry.results.sync?.autoPush !== undefined && { + sync: { auto_push: entry.results.sync.autoPush }, + }), ...(entry.results.branchPrefix !== undefined && { branch_prefix: entry.results.branchPrefix, }), diff --git a/packages/core/test/evaluation/validation/config-validator.test.ts b/packages/core/test/evaluation/validation/config-validator.test.ts index 8b2cc5d27..08cf317e3 100644 --- a/packages/core/test/evaluation/validation/config-validator.test.ts +++ b/packages/core/test/evaluation/validation/config-validator.test.ts @@ -86,12 +86,14 @@ describe('validateConfigFile', () => { `projects: - id: agentv name: AgentV + repository: EntityProcess/agentv path: /srv/agentv + ref: main results: - mode: github - repo: EntityProcess/agentv-results - path: /srv/agentv-results - auto_push: true + repository: EntityProcess/agentv-results + local_path: /srv/agentv-results + sync: + auto_push: true branch_prefix: eval-results `, ); @@ -158,12 +160,14 @@ describe('validateConfigFile', () => { `projects: - id: "" name: 42 + repository: https://github.com/EntityProcess/agentv path: + ref: "" results: - mode: local - repo: "" - path: repo/subdir - auto_push: yes + repository: https://github.com/EntityProcess/results + local_path: repo/subdir + sync: + auto_push: yes branch_prefix: "" - not-an-object `, @@ -176,13 +180,14 @@ describe('validateConfigFile', () => { expect.arrayContaining([ expect.objectContaining({ severity: 'error', location: 'projects[0].id' }), expect.objectContaining({ severity: 'error', location: 'projects[0].name' }), + expect.objectContaining({ severity: 'error', location: 'projects[0].repository' }), expect.objectContaining({ severity: 'error', location: 'projects[0].path' }), - expect.objectContaining({ severity: 'error', location: 'projects[0].results.mode' }), - expect.objectContaining({ severity: 'error', location: 'projects[0].results.repo' }), - expect.objectContaining({ severity: 'error', location: 'projects[0].results.path' }), + expect.objectContaining({ severity: 'error', location: 'projects[0].ref' }), + expect.objectContaining({ severity: 'error', location: 'projects[0].results.repository' }), + expect.objectContaining({ severity: 'error', location: 'projects[0].results.local_path' }), expect.objectContaining({ severity: 'error', - location: 'projects[0].results.auto_push', + location: 'projects[0].results.sync.auto_push', }), expect.objectContaining({ severity: 'error', @@ -193,6 +198,75 @@ describe('validateConfigFile', () => { ); }); + it.each([ + { + field: 'source', + yaml: `source: + url: https://github.com/example/repo + ref: main`, + location: 'projects[0].source', + migration: 'Move', + }, + { + field: 'results.mode', + yaml: `results: + mode: github + repository: example/results`, + location: 'projects[0].results.mode', + migration: 'Remove', + }, + { + field: 'results.repo', + yaml: `results: + repository: example/results + repo: example/legacy-results`, + location: 'projects[0].results.repo', + migration: 'Use', + }, + { + field: 'results.path', + yaml: `results: + repository: example/results + path: /srv/results`, + location: 'projects[0].results.path', + migration: 'local_path', + }, + { + field: 'results.auto_push', + yaml: `results: + repository: example/results + auto_push: true`, + location: 'projects[0].results.auto_push', + migration: 'sync.auto_push', + }, + ])('errors on removed legacy project field $field with migration guidance', async (legacy) => { + const filePath = path.join(tempDir, `global-config-legacy-${legacy.field}.yaml`); + await writeFile( + filePath, + `projects: + - id: legacy + name: Legacy + repository: example/repo + path: /srv/legacy + ref: main + ${legacy.yaml} +`, + ); + + const result = await validateConfigFile(filePath, { scope: 'global' }); + + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + severity: 'error', + location: legacy.location, + }), + ); + expect(result.errors.find((e) => e.location === legacy.location)?.message).toContain( + legacy.migration, + ); + }); + it('warns on deprecated results_by_project', async () => { const filePath = path.join(tempDir, 'deprecated-results-by-project.yaml'); await writeFile( diff --git a/packages/core/test/project-sync.test.ts b/packages/core/test/project-sync.test.ts index afe988b27..53312ad93 100644 --- a/packages/core/test/project-sync.test.ts +++ b/packages/core/test/project-sync.test.ts @@ -30,9 +30,9 @@ describe('syncProject', () => { mock.restore(); }); - it('throws when entry has no source', async () => { + it('throws when entry has no repository', async () => { const entry = makeEntry({ path: tmpDir }); - await expect(syncProject(entry)).rejects.toThrow(/no source defined/); + await expect(syncProject(entry)).rejects.toThrow(/no repository defined/); }); it('runs git clone when .git does not exist', async () => { @@ -40,7 +40,8 @@ describe('syncProject', () => { const dest = path.join(tmpDir, 'repo'); const entry = makeEntry({ path: dest, - source: { url: 'https://github.com/example/repo', ref: 'main' }, + repository: 'example/repo', + ref: 'main', }); await syncProject(entry); expect(spy).toHaveBeenCalledWith( @@ -52,7 +53,7 @@ describe('syncProject', () => { '--filter=blob:none', '--branch', 'main', - 'https://github.com/example/repo', + 'https://github.com/example/repo.git', dest, ], expect.objectContaining({ stdio: 'inherit' }), @@ -64,7 +65,8 @@ describe('syncProject', () => { const spy = spyOn(childProcess, 'execFileSync').mockReturnValue(Buffer.from('')); const entry = makeEntry({ path: tmpDir, - source: { url: 'https://github.com/example/repo', ref: 'main' }, + repository: 'example/repo', + ref: 'main', }); await syncProject(entry); expect(spy).toHaveBeenCalledWith( @@ -80,17 +82,15 @@ describe('syncProjects', () => { mock.restore(); }); - it('skips entries with no source', async () => { + it('skips entries with no repository', async () => { const spy = spyOn(childProcess, 'execFileSync').mockReturnValue(Buffer.from('')); await syncProjects([makeEntry()]); expect(spy).not.toHaveBeenCalled(); }); - it('syncs entries that have a source', async () => { + it('syncs entries that have a repository', async () => { const spy = spyOn(childProcess, 'execFileSync').mockReturnValue(Buffer.from('')); - const entries = [ - makeEntry({ source: { url: 'https://github.com/example/repo', ref: 'main' } }), - ]; + const entries = [makeEntry({ repository: 'example/repo', ref: 'main' })]; await syncProjects(entries); expect(spy).toHaveBeenCalled(); }); diff --git a/packages/core/test/projects.test.ts b/packages/core/test/projects.test.ts index 4d77e321a..d12171d67 100644 --- a/packages/core/test/projects.test.ts +++ b/packages/core/test/projects.test.ts @@ -3,6 +3,7 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod import os from 'node:os'; import path from 'node:path'; +import { resolveGitHubRepositoryUrl } from '../src/project-sync.js'; import { addProject, getProject, @@ -109,7 +110,7 @@ describe('projects registry', () => { expect(yamlOnDisk).toContain('projects:'); }); - it('round-trips source field through YAML', () => { + it('round-trips repository and ref fields through YAML', () => { const registryPath = getProjectsRegistryPath(); mkdirSync(path.dirname(registryPath), { recursive: true }); writeFileSync( @@ -117,10 +118,9 @@ describe('projects registry', () => { `projects: - id: remote-bench name: Remote Bench + repository: example/repo path: /srv/agentv/repo - source: - url: https://github.com/example/repo - ref: main + ref: main added_at: "2026-01-01T00:00:00Z" last_opened_at: "2026-01-01T00:00:00Z" `, @@ -130,7 +130,8 @@ describe('projects registry', () => { const registry = loadProjectRegistry(); expect(registry.projects).toHaveLength(1); const entry = registry.projects[0]; - expect(entry.source).toEqual({ url: 'https://github.com/example/repo', ref: 'main' }); + expect(entry.repository).toBe('example/repo'); + expect(entry.ref).toBe('main'); }); it('round-trips project results config through YAML', () => { @@ -143,10 +144,10 @@ describe('projects registry', () => { name: Results Project path: /srv/agentv/repo results: - mode: github - repo: EntityProcess/results-project-runs - path: /srv/agentv/results/results-project - auto_push: true + repository: EntityProcess/results-project-runs + local_path: /srv/agentv/results/results-project + sync: + auto_push: true branch_prefix: eval-results added_at: "2026-01-01T00:00:00Z" last_opened_at: "2026-01-01T00:00:00Z" @@ -156,17 +157,18 @@ describe('projects registry', () => { const registry = loadProjectRegistry(); expect(registry.projects[0].results).toEqual({ - mode: 'github', - repo: 'EntityProcess/results-project-runs', - path: '/srv/agentv/results/results-project', - autoPush: true, + repository: 'EntityProcess/results-project-runs', + localPath: '/srv/agentv/results/results-project', + sync: { autoPush: true }, branchPrefix: 'eval-results', }); saveProjectRegistry(registry); const yamlOnDisk = readFileSync(registryPath, 'utf-8'); + expect(yamlOnDisk).toContain('local_path: /srv/agentv/results/results-project'); expect(yamlOnDisk).toContain('auto_push: true'); expect(yamlOnDisk).toContain('branch_prefix: eval-results'); + expect(yamlOnDisk).not.toContain('localPath:'); expect(yamlOnDisk).not.toContain('autoPush:'); expect(yamlOnDisk).not.toContain('branchPrefix:'); }); @@ -194,34 +196,40 @@ dashboard: expect(yamlOnDisk).toContain('projects:'); }); - it('interpolates env vars in source url', () => { + it('interpolates env vars in repository', () => { const registryPath = getProjectsRegistryPath(); mkdirSync(path.dirname(registryPath), { recursive: true }); // Use concatenation to avoid JS template literal evaluating ${{ ... }} const d = '$'; writeFileSync( registryPath, - `projects:\n - id: env-bench\n name: Env Bench\n path: /srv/agentv/repo\n source:\n url: "${d}{{ BENCH_URL }}"\n ref: main\n added_at: "2026-01-01T00:00:00Z"\n last_opened_at: "2026-01-01T00:00:00Z"\n`, + `projects:\n - id: env-bench\n name: Env Bench\n repository: "${d}{{ BENCH_REPOSITORY }}"\n path: /srv/agentv/repo\n ref: main\n added_at: "2026-01-01T00:00:00Z"\n last_opened_at: "2026-01-01T00:00:00Z"\n`, 'utf-8', ); - const origUrl = process.env.BENCH_URL; + const origRepository = process.env.BENCH_REPOSITORY; try { - process.env.BENCH_URL = 'https://github.com/example/bench-repo'; + process.env.BENCH_REPOSITORY = 'example/bench-repo'; const registry = loadProjectRegistry(); - expect(registry.projects[0].source?.url).toBe('https://github.com/example/bench-repo'); + expect(registry.projects[0].repository).toBe('example/bench-repo'); } finally { - if (origUrl === undefined) process.env.BENCH_URL = undefined; - else process.env.BENCH_URL = origUrl; + if (origRepository === undefined) process.env.BENCH_REPOSITORY = undefined; + else process.env.BENCH_REPOSITORY = origRepository; } }); - it('entries without source work unchanged', () => { - const repoPath = makeRepo('no-source'); + it('entries without repository work unchanged', () => { + const repoPath = makeRepo('no-repository'); const entry = addProject(repoPath); - expect(entry.source).toBeUndefined(); + expect(entry.repository).toBeUndefined(); const reloaded = loadProjectRegistry().projects.find((p) => p.id === entry.id); - expect(reloaded?.source).toBeUndefined(); + expect(reloaded?.repository).toBeUndefined(); + }); + + it('resolves GitHub owner/name repositories to clone URLs', () => { + expect(resolveGitHubRepositoryUrl('EntityProcess/agentv')).toBe( + 'https://github.com/EntityProcess/agentv.git', + ); }); });