Skip to content

feat(evaluator): add content-addressed revisions for tasks and tasksets - #1023

Draft
SandyChapman wants to merge 1 commit into
mainfrom
schapman/harbor-cli-dataset-endpoint-45cb1d
Draft

feat(evaluator): add content-addressed revisions for tasks and tasksets#1023
SandyChapman wants to merge 1 commit into
mainfrom
schapman/harbor-cli-dataset-endpoint-45cb1d

Conversation

@SandyChapman

Copy link
Copy Markdown
Contributor

What

Stored tasks and tasksets are now versioned. Creating one publishes revision 1; replacing its content publishes the next. Earlier revisions stay readable, so an evaluation can be re-run against exactly the content it ran against before.

This is phase 0 of backing a Harbor-compatible dataset publish endpoint with NeMo entities — Harbor pins dataset members by digest, which we had no way to represent.

How it works

A revision is an immutable child entity named rev.<n>, addressed by a SHA-256 digest of its content. The head record keeps current content plus mutable pointers (latest_revision, and a tag → ordinal map). Digest lookups are a real (parent, data.content_hash) query, not a denormalized index.

References gain the platform's standard # sub-entity fragment — workspace/name#<tag-or-digest> — with an absent fragment meaning latest. This uses the same convention filesets already use for a contained file.

Taskset membership is resolved to exact digests on write, and taskset expansion at eval time loads each member's pinned revision. That second half matters: without it, membership was pinned in storage and ignored at runtime, so a suite looked reproducible and wasn't.

API

Method Path
POST /{tasks,tasksets}/{name} Create + publish revision 1 (409 if taken)
PUT /{tasks,tasksets}/{name} Replace + publish; 201 if a revision was cut, 200 if content was unchanged
GET /{tasks,tasksets}/{name}/revisions List published revisions, newest first (paginated)
GET /{tasks,tasksets}/{name}/revisions/{revision} Read content as of a digest or tag
PUT /{tasks,tasksets}/{name}/tags/{tag}?revision= Point a tag at an existing revision

Publishing is idempotent — identical content cuts no revision but still applies tags, which is how you tag a revision after the fact.

Invariants

  • Every record has at least one revision. Create rolls back if its first publish fails, so there's no draft state for consumers to handle.
  • Published taskset membership is digest-pinned, enforced on the field rather than in the publish path.
  • latest only moves forward and can't be moved by hand. Ordinal allocation is serialized by the child create and retries on contention.

Testing

  • 1166 unit tests
  • 9 integration tests against a real platform (RUN_AGENT_EVAL_INTEGRATION=1), covering what only real persistence confirms: parent-scoped ordinals, the digest query, server-side ordering, FK cascade, cross-record isolation
  • Coverage on new modules: content_hash 100%, task_refs 100%, revisions 96%, services 94%
  • tools/lint/lint-all.sh: 13/13 pass

Notes for review

  • No back-compat shims — there are no known users of the task/taskset endpoints, confirmed before starting.
  • Deleting an individual revision is deliberately not exposed. A pinned reference would become unresolvable. Registry convention (and Harbor's own model) is yank, not delete; not built, since nothing needs it.
  • Known limitation: nothing prevents deleting a task that a taskset pins. The reference dangles and fails loudly on read. Harbor has the same hole; preventing it needs a reverse index we don't have.
  • plugins/nemo-customizer/openapi/openapi.yaml is deliberately not included. make refresh-openapi regenerates ~1100 lines there from pre-existing drift unrelated to this change; reverted to keep the diff focused. Worth a separate cleanup PR.

🤖 Generated with Claude Code

Stored tasks and tasksets are now versioned. Creating one publishes revision 1;
replacing its content publishes the next. Earlier revisions stay readable, so an
evaluation can be re-run against exactly the content it ran against before.

A revision is an immutable child entity named `rev.<n>`, addressed by a SHA-256
digest of its content. The head record keeps the current content plus mutable
pointers (`latest_revision` and a tag -> ordinal map); digest lookups are a real
`(parent, data.content_hash)` query rather than a denormalized index.

References gain the platform's standard `#` sub-entity fragment —
`workspace/name#<tag-or-digest>` — with an absent fragment meaning `latest`.

Taskset membership is resolved to exact digests on write, and taskset expansion
at eval time now loads each member's *pinned* revision rather than its current
content. Without that, membership was pinned in storage and ignored at runtime,
so a suite looked reproducible and was not.

API:
  POST   /{tasks,tasksets}/{name}                     create + publish rev 1
  PUT    /{tasks,tasksets}/{name}                     replace + publish (201/200)
  GET    /{tasks,tasksets}/{name}/revisions           list, newest first
  GET    /{tasks,tasksets}/{name}/revisions/{rev}     read a pinned revision
  PUT    /{tasks,tasksets}/{name}/tags/{tag}          tag an existing revision

Publishing is idempotent: identical content cuts no revision but still applies
tags. Ordinal allocation is serialized by the child create and retries on
contention; `latest` only moves forward. Creating a record rolls back if its
first publish fails, so no head ever exists without a revision.

Signed-off-by: Sandy Chapman <schapman@nvidia.com>
@github-actions github-actions Bot added the feat label Jul 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

@github-actions

Copy link
Copy Markdown
Contributor
Suite Lines Covered Line Rate Branch Rate
Unit Tests 29441/37448 78.6% 63.2%
Integration Tests 17381/36166 48.1% 20.6%

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant