diff --git a/apps/cli/src/commands/results/remote.ts b/apps/cli/src/commands/results/remote.ts
index 91dffdae..dc576e56 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 ca0e2f97..37c20a53 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 bec70db1..583ef594 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 f1df5f57..5dad71e2 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 f1a086ce..18f7e88d 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 7fc9e586..f42746bb 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 761e1783..64cb1f73 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 cc224c54..93504734 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 8b2cc5d2..08cf317e 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 afe988b2..53312ad9 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 4d77e321..d12171d6 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',
+ );
});
});