Skip to content

Commit 907ac6d

Browse files
authored
feat(config): hard-break dashboard project schema (#1356)
1 parent f790a85 commit 907ac6d

11 files changed

Lines changed: 418 additions & 145 deletions

File tree

apps/cli/src/commands/results/remote.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,10 @@ async function loadNormalizedResultsConfig(
153153
: (getProjectForPath(repoRoot) ?? getProjectForPath(cwd));
154154
const projectResults = project?.results
155155
? {
156-
mode: project.results.mode,
157-
repo: project.results.repo,
158-
path: project.results.path,
159-
auto_push: project.results.autoPush,
156+
mode: 'github' as const,
157+
repo: project.results.repository,
158+
path: project.results.localPath,
159+
auto_push: project.results.sync?.autoPush,
160160
branch_prefix: project.results.branchPrefix,
161161
}
162162
: undefined;

apps/cli/test/commands/results/serve.test.ts

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1272,10 +1272,9 @@ describe('serve app', () => {
12721272
name: 'Project No Publish',
12731273
path: projectDir,
12741274
results: {
1275-
mode: 'github',
1276-
repo: `file://${remoteDir}`,
1277-
path: missingCloneDir,
1278-
autoPush: true,
1275+
repository: `file://${remoteDir}`,
1276+
localPath: missingCloneDir,
1277+
sync: { autoPush: true },
12791278
},
12801279
addedAt: '2026-01-01T00:00:00.000Z',
12811280
lastOpenedAt: '2026-01-01T00:00:00.000Z',
@@ -1413,10 +1412,9 @@ describe('serve app', () => {
14131412
name: 'AgentV',
14141413
path: projectDir,
14151414
results: {
1416-
mode: 'github',
1417-
repo: 'EntityProcess/agentv-examples-eval-results',
1418-
path: '/home/entity/projects/EntityProcess/agentv-examples-eval-results',
1419-
autoPush: true,
1415+
repository: 'EntityProcess/agentv-examples-eval-results',
1416+
localPath: '/home/entity/projects/EntityProcess/agentv-examples-eval-results',
1417+
sync: { autoPush: true },
14201418
},
14211419
addedAt: '2026-01-01T00:00:00.000Z',
14221420
lastOpenedAt: '2026-01-01T00:00:00.000Z',
@@ -1466,10 +1464,9 @@ describe('serve app', () => {
14661464
name: 'Project Sync Pull',
14671465
path: projectDir,
14681466
results: {
1469-
mode: 'github',
1470-
repo: `file://${remoteDir}`,
1471-
path: cloneDir,
1472-
autoPush: false,
1467+
repository: `file://${remoteDir}`,
1468+
localPath: cloneDir,
1469+
sync: { autoPush: false },
14731470
},
14741471
addedAt: '2026-01-01T00:00:00.000Z',
14751472
lastOpenedAt: '2026-01-01T00:00:00.000Z',
@@ -1546,10 +1543,9 @@ describe('serve app', () => {
15461543
name: 'Project Sync Push',
15471544
path: projectDir,
15481545
results: {
1549-
mode: 'github',
1550-
repo: `file://${remoteDir}`,
1551-
path: cloneDir,
1552-
autoPush: true,
1546+
repository: `file://${remoteDir}`,
1547+
localPath: cloneDir,
1548+
sync: { autoPush: true },
15531549
},
15541550
addedAt: '2026-01-01T00:00:00.000Z',
15551551
lastOpenedAt: '2026-01-01T00:00:00.000Z',
@@ -1614,10 +1610,9 @@ describe('serve app', () => {
16141610
name: 'Project Sync Offline',
16151611
path: projectDir,
16161612
results: {
1617-
mode: 'github',
1618-
repo: `file://${remoteDir}`,
1619-
path: cloneDir,
1620-
autoPush: true,
1613+
repository: `file://${remoteDir}`,
1614+
localPath: cloneDir,
1615+
sync: { autoPush: true },
16211616
},
16221617
addedAt: '2026-01-01T00:00:00.000Z',
16231618
lastOpenedAt: '2026-01-01T00:00:00.000Z',
@@ -1674,10 +1669,9 @@ describe('serve app', () => {
16741669
name: 'Project Sync Conflict',
16751670
path: projectDir,
16761671
results: {
1677-
mode: 'github',
1678-
repo: `file://${remoteDir}`,
1679-
path: cloneDir,
1680-
autoPush: true,
1672+
repository: `file://${remoteDir}`,
1673+
localPath: cloneDir,
1674+
sync: { autoPush: true },
16811675
},
16821676
addedAt: '2026-01-01T00:00:00.000Z',
16831677
lastOpenedAt: '2026-01-01T00:00:00.000Z',

apps/dashboard/src/routes/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -427,7 +427,7 @@ function RunsTabContent({
427427
<p className="mt-2 text-sm text-gray-500">
428428
Sync remote results or run an eval with{' '}
429429
<code className="rounded bg-gray-800 px-2 py-1 text-cyan-400">
430-
auto_push: true
430+
sync.auto_push: true
431431
</code>{' '}
432432
in your config.
433433
</p>

apps/web/src/content/docs/docs/tools/dashboard.mdx

Lines changed: 52 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -189,16 +189,15 @@ agentv dashboard --add /path/to/other-evals
189189

190190
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.
191191

192-
To register a remote repo and keep it synced automatically, add a `source` block to the entry in `$AGENTV_HOME/config.yaml`:
192+
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/<owner>/<name>.git` for clone and pull operations:
193193

194194
```yaml
195195
projects:
196196
- id: my-evals
197197
name: My Evals
198+
repository: example/my-evals
198199
path: /srv/agentv/my-evals
199-
source:
200-
url: https://github.com/example/my-evals
201-
ref: main
200+
ref: main
202201
```
203202

204203
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 `
256255
projects:
257256
- id: agentv
258257
name: AgentV
258+
repository: EntityProcess/agentv
259259
path: /home/entity/projects/EntityProcess/agentv
260+
ref: main
260261
results:
261-
mode: github
262-
repo: EntityProcess/agentv-examples-eval-results
263-
path: /home/entity/projects/EntityProcess/agentv-examples-eval-results
264-
auto_push: true
262+
repository: EntityProcess/agentv-examples-eval-results
263+
local_path: /home/entity/projects/EntityProcess/agentv-examples-eval-results
264+
sync:
265+
auto_push: true
265266
```
266267

267-
`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.
268+
`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/<owner>/<name>.git` for clone and push operations.
268269

269270
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:
270271

@@ -278,10 +279,47 @@ results:
278279

279280
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`.
280281

281-
The `source` block and the `results` block sync different repositories:
282+
The project `repository` and the `results` block sync different repositories:
283+
284+
- `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.
285+
- `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.
286+
287+
#### Migration from the legacy project schema
288+
289+
Before:
290+
291+
```yaml
292+
projects:
293+
- id: agentv
294+
name: AgentV
295+
path: /home/entity/projects/EntityProcess/agentv
296+
source:
297+
url: https://github.com/EntityProcess/agentv
298+
ref: main
299+
results:
300+
mode: github
301+
repo: EntityProcess/agentv-eval-results
302+
path: /home/entity/projects/EntityProcess/agentv-eval-results
303+
auto_push: true
304+
```
305+
306+
After:
307+
308+
```yaml
309+
projects:
310+
- id: agentv
311+
name: AgentV
312+
repository: EntityProcess/agentv
313+
path: /home/entity/projects/EntityProcess/agentv
314+
ref: main
315+
results:
316+
repository: EntityProcess/agentv-eval-results
317+
local_path: /home/entity/projects/EntityProcess/agentv-eval-results
318+
sync:
319+
auto_push: true
320+
```
282321

283-
- `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.
284-
- `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.
322+
Legacy project fields (`source`, `results.mode`, `results.repo`, `results.path`, and `results.auto_push`) fail validation with migration guidance.
285323

286324
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.
287325

@@ -327,8 +365,8 @@ After sync, newly fetched remote runs appear in the list with a **remote** sourc
327365
**Sync Project** fetches the results repo and only changes the clone when Git says it is safe:
328366

329367
- A clean clone that is behind the remote is fast-forwarded.
330-
- Safe uncommitted changes under `.agentv/results/**`, such as remote tag metadata overlays, are committed and pushed when `auto_push: true`.
331-
- A local results repo that is ahead is pushed when `auto_push: true` and the committed paths are all under `.agentv/results/**`.
368+
- Safe uncommitted changes under `.agentv/results/**`, such as remote tag metadata overlays, are committed and pushed when `sync.auto_push: true`.
369+
- A local results repo that is ahead is pushed when `sync.auto_push: true` and the committed paths are all under `.agentv/results/**`.
332370
- 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.
333371

334372
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.

packages/core/src/evaluation/validation/config-validator.ts

Lines changed: 153 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -169,7 +169,30 @@ function validateProjects(errors: ValidationError[], filePath: string, projects:
169169
validateRequiredString(errors, filePath, projectRecord.id, `${location}.id`);
170170
validateRequiredString(errors, filePath, projectRecord.name, `${location}.name`);
171171
validateRequiredString(errors, filePath, projectRecord.path, `${location}.path`);
172-
validateResultsConfig(errors, filePath, projectRecord.results, `${location}.results`);
172+
173+
if (projectRecord.source !== undefined) {
174+
errors.push({
175+
severity: 'error',
176+
filePath,
177+
location: `${location}.source`,
178+
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'.`,
179+
});
180+
}
181+
182+
if (projectRecord.repository !== undefined) {
183+
validateGitHubRepository(
184+
errors,
185+
filePath,
186+
projectRecord.repository,
187+
`${location}.repository`,
188+
);
189+
}
190+
191+
if (projectRecord.ref !== undefined) {
192+
validateRequiredString(errors, filePath, projectRecord.ref, `${location}.ref`);
193+
}
194+
195+
validateProjectResultsConfig(errors, filePath, projectRecord.results, `${location}.results`);
173196
});
174197
}
175198

@@ -189,6 +212,135 @@ function validateRequiredString(
189212
}
190213
}
191214

215+
function validateGitHubRepository(
216+
errors: ValidationError[],
217+
filePath: string,
218+
value: unknown,
219+
location: string,
220+
): void {
221+
if (typeof value !== 'string' || value.trim().length === 0) {
222+
errors.push({
223+
severity: 'error',
224+
filePath,
225+
location,
226+
message: `Field '${location}' must be a non-empty GitHub owner/name repository (e.g., EntityProcess/agentv)`,
227+
});
228+
return;
229+
}
230+
231+
const repository = value.trim();
232+
if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository)) {
233+
errors.push({
234+
severity: 'error',
235+
filePath,
236+
location,
237+
message: `Field '${location}' must use GitHub owner/name format (e.g., EntityProcess/agentv), not a URL. It resolves to https://github.com/<owner>/<name>.git for git operations.`,
238+
});
239+
}
240+
}
241+
242+
function validateProjectResultsConfig(
243+
errors: ValidationError[],
244+
filePath: string,
245+
rawResults: unknown,
246+
location: string,
247+
): void {
248+
if (rawResults === undefined) {
249+
return;
250+
}
251+
252+
if (typeof rawResults !== 'object' || rawResults === null || Array.isArray(rawResults)) {
253+
errors.push({
254+
severity: 'error',
255+
filePath,
256+
location,
257+
message: `Field '${location}' must be an object`,
258+
});
259+
return;
260+
}
261+
262+
const resultsRecord = rawResults as Record<string, unknown>;
263+
264+
const removedFields: Record<string, string> = {
265+
mode: `Remove '${location}.mode'; project results are GitHub-backed by '${location}.repository'.`,
266+
repo: `Field '${location}.repo' was removed. Use '${location}.repository' with GitHub owner/name format instead.`,
267+
path: `Field '${location}.path' was removed. Use '${location}.local_path' for the local clone path instead.`,
268+
auto_push: `Field '${location}.auto_push' was removed. Use '${location}.sync.auto_push' instead.`,
269+
};
270+
271+
for (const [field, message] of Object.entries(removedFields)) {
272+
if (resultsRecord[field] !== undefined) {
273+
errors.push({
274+
severity: 'error',
275+
filePath,
276+
location: `${location}.${field}`,
277+
message,
278+
});
279+
}
280+
}
281+
282+
validateGitHubRepository(errors, filePath, resultsRecord.repository, `${location}.repository`);
283+
284+
if (resultsRecord.local_path !== undefined) {
285+
if (
286+
typeof resultsRecord.local_path !== 'string' ||
287+
resultsRecord.local_path.trim().length === 0
288+
) {
289+
errors.push({
290+
severity: 'error',
291+
filePath,
292+
location: `${location}.local_path`,
293+
message: `Field '${location}.local_path' must be a non-empty string`,
294+
});
295+
} else if (!isFilesystemPath(resultsRecord.local_path.trim())) {
296+
errors.push({
297+
severity: 'error',
298+
filePath,
299+
location: `${location}.local_path`,
300+
message: `'${location}.local_path' must be an absolute or home-relative filesystem path (e.g., ~/data/agentv-results).`,
301+
});
302+
}
303+
}
304+
305+
if (resultsRecord.sync !== undefined) {
306+
if (
307+
typeof resultsRecord.sync !== 'object' ||
308+
resultsRecord.sync === null ||
309+
Array.isArray(resultsRecord.sync)
310+
) {
311+
errors.push({
312+
severity: 'error',
313+
filePath,
314+
location: `${location}.sync`,
315+
message: `Field '${location}.sync' must be an object`,
316+
});
317+
} else {
318+
const syncRecord = resultsRecord.sync as Record<string, unknown>;
319+
if (syncRecord.auto_push !== undefined && typeof syncRecord.auto_push !== 'boolean') {
320+
errors.push({
321+
severity: 'error',
322+
filePath,
323+
location: `${location}.sync.auto_push`,
324+
message: `Field '${location}.sync.auto_push' must be a boolean`,
325+
});
326+
}
327+
}
328+
}
329+
330+
if (
331+
resultsRecord.branch_prefix !== undefined &&
332+
(typeof resultsRecord.branch_prefix !== 'string' ||
333+
resultsRecord.branch_prefix.trim().length === 0)
334+
) {
335+
errors.push({
336+
severity: 'error',
337+
filePath,
338+
location: `${location}.branch_prefix`,
339+
message: `Field '${location}.branch_prefix' must be a non-empty string`,
340+
});
341+
}
342+
}
343+
192344
function validateResultsConfig(
193345
errors: ValidationError[],
194346
filePath: string,

packages/core/src/index.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,6 @@ export {
9797
} from './paths.js';
9898
export {
9999
type ProjectEntry,
100-
type ProjectSource,
101100
type ProjectRegistry,
102101
loadProjectRegistry,
103102
saveProjectRegistry,
@@ -110,7 +109,7 @@ export {
110109
deriveProjectId,
111110
getProjectsRegistryPath,
112111
} from './projects.js';
113-
export { syncProject, syncProjects } from './project-sync.js';
112+
export { syncProject, syncProjects, resolveGitHubRepositoryUrl } from './project-sync.js';
114113
export { trimBaselineResult } from './evaluation/baseline.js';
115114
export { DEFAULT_CATEGORY, deriveCategory } from './evaluation/category.js';
116115
export * from './observability/index.js';

0 commit comments

Comments
 (0)