Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 22 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,17 +80,24 @@ store.Repository**.
`viewer | publisher | admin`, permissions `read | edit | delete`. A missing read permission is
surfaced as 404, never 403, so frame existence does not leak.
- `backend/internal/frames` owns the Frame content model:
- `schema.go` - the `Doc`/`Slots` YAML types. Parsing uses `KnownFields(true)`: the slot schema is
fixed and unknown keys are an error.
- `slots.go` - `SlotTable` is the single source of truth for slot keys, markdown headings, and
content shape (terms / list / prose). Add or rename a slot here only; the `.frame.md` codec and
the MCP composer both read it.
- `framemd.go` - the Frame Spec v0.2 `.frame.md` codec (YAML frontmatter plus one `##` section per
slot). Round-trip fidelity matters: `examples/*.yaml` and `examples/*.frame.md` are checked-in
conformance fixtures asserted by `examples_test.go`.
- `resolver.go` - inheritance merge over `extends`/`excludes`. Later parents win, the child's own
slots win last, cycles produce `CycleError`, and an unreadable ancestor propagates
`ErrParentUnreadable` rather than silently dropping content.
- `schema.go` - the `Doc` YAML type. A Frame's content is a single free-form markdown `Body`,
matching Frame Spec v0.2, which requires four frontmatter fields and defines no body structure.
Parsing uses `KnownFields(true)`: the document schema is fixed and unknown keys are an error.
- `legacy.go` - read-only support for documents published under the retired ten-slot schema.
`Parse` folds a `slots:` block into `Body`; nothing ever writes that shape again. Stored versions
are immutable, so this has to stay readable forever. It is mirrored once, in
`web/src/lib/frame-yaml.ts`, because the web app renders a stored legacy version without a
server round trip - and that render can become canonical content when a legacy version is
restored. Both sides are pinned to `testdata/legacy-slots/`; change one and you must
regenerate `expected.md` and update the other.
- `framemd.go` - the Frame Spec v0.2 `.frame.md` codec: YAML frontmatter, then the body verbatim.
The frontmatter delimiter matches only at column 0, since an indented `---` inside a multi-line
YAML value would otherwise truncate the document. Round-trip fidelity matters: `examples/*.yaml`
and `examples/*.frame.md` are checked-in conformance fixtures asserted by `examples_test.go`.
- `resolver.go` - inheritance merge over `extends`/`excludes`. Ancestors' bodies are concatenated
ancestors-first so the child's own guidance reads last, each `ref@version` is included once,
cycles produce `CycleError`, and an unreadable ancestor propagates `ErrParentUnreadable` rather
than silently dropping content.
- `service.go` - `publish` is the single write path behind both front doors. It authorizes before
parsing caller-supplied content, enforces `MaxContentBytes` on the stored document, refuses a
version that does not advance `latest_version`, and refuses a write whose declared base version
Expand All @@ -110,7 +117,7 @@ store.Repository**.
Connect API uses, so the adapter itself performs no permission or validation logic. Two rules
matter when changing it. `update_frame` merges onto the frame's own document from `SourceDoc` and
never onto the composed form `get_frame` returns by default - merging onto a resolved document
would copy every parent's slots into the child and drop its `extends` edges. And the base version
would bake every ancestor's body into the child and drop its `extends` edges. And the base version
it asserts against comes from the caller (`base_version`, read via `get_frame source=true`), never
from a fresh server-side read, which would always match and make the check inert. Request bodies
are capped at `mcp.MaxRequestBytes`; the cap must wrap the outermost handler, since the bearer
Expand Down Expand Up @@ -141,8 +148,9 @@ store.Repository**.
- A test that asserts only "this was rejected" usually proves nothing: an unrelated 401, or a parse
failure, satisfies it just as well. Assert the specific code or message, and pair a rejection with
a control that must succeed. Reflective guards in `backend/internal/mcp/resources_test.go` walk
`frames.SlotTable` and `frames.Doc`, so adding a slot without wiring it through the MCP input
fails rather than silently dropping data.
`frames.Doc`, so adding a document field without wiring it through the MCP input fails rather than
silently dropping data - the guard names the field and asks for a decision rather than defaulting
to one.
- Comments in this repo explain *why* a constraint exists (pinned CI versions, fail-closed
readiness, the vite `@bufbuild/protobuf` aliases). Preserve that rationale when editing near it,
and keep new comments in the same register.
Expand Down
16 changes: 8 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,13 @@ See [Background §1.1 in the migration design doc](docs/design/2026-05-21-nebari

### Frame Spec conformance

Frames are stored in a slot-typed YAML schema with inheritance and RBAC, which is richer than
[Frame Spec v0.2](https://github.com/openteams-ai/frame-spec) describes. The registry interoperates
with the spec rather than replacing it: every Frame **imports and exports as a conformant
`.frame.md` document** (`type: frame [0.2]`, YAML frontmatter, one `##` section per slot), and the
web app's authoring page offers that document as a second editor alongside the typed form. Exports
pass the spec project's own `tools/validate_frames.py`; `examples/*.frame.md` are checked-in
examples of the output.
A Frame's content is a single free-form markdown body, exactly as
[Frame Spec v0.2](https://github.com/openteams-ai/frame-spec) defines it; the registry adds
metadata, versioning, inheritance, and RBAC around that body. Every Frame **imports and exports as
a conformant `.frame.md` document** (`type: frame [0.2]`, YAML frontmatter, then the body
verbatim), and the web app's authoring page offers that document as a second editor alongside the
form. Exports pass the spec project's own `tools/validate_frames.py`; `examples/*.frame.md` are
checked-in examples of the output.

The spec's optional `visibility`, `scope`, and `maintainer` fields travel with the document so a
Frame survives a round trip through other tooling. **`visibility` is declared intent, not an access
Expand All @@ -36,7 +36,7 @@ control** - who may read a Frame is decided by this registry's roles and grants.
make dev
```

Runs the backend (dev mode, no OIDC) on `:8080` and the Vite dev server on `:5173`, seeded with representative sample data (an org, members across roles, and frames with full slot content, multi-level inheritance, and versions). Open **http://localhost:5173**; UI edits hot-reload. Ctrl-C stops both.
Runs the backend (dev mode, no OIDC) on `:8080` and the Vite dev server on `:5173`, seeded with representative sample data (an org, members across roles, and frames with real body content, multi-level inheritance, and versions). Open **http://localhost:5173**; UI edits hot-reload. Ctrl-C stops both.

There is no login step in this loop: dev mode disables OIDC and injects a fixed identity, so you land straight in the app as `dev-user`, an org admin - and never hit the "No organization access" screen (see [Troubleshooting](#troubleshooting)).

Expand Down
139 changes: 32 additions & 107 deletions backend/internal/devfixture/devfixture.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ type fixtureFrame struct {
id string
name string
description string
isTemplate bool
versions []frameVersion // oldest first
}

Expand Down Expand Up @@ -82,9 +83,8 @@ func mustMarshalDoc(doc *frames.Doc) string {
// └── team-notebook (v1.0.0) extends pytorch-gpu@1.0.0
// standalone-frame (v1.0.0) no parents
//
// Every frame fills real content slots (terminology, rules, skills, prompts,
// and the free-text goals/style/norms/architecture/business_process) so the
// detail view and the inheritance resolver have representative data to render.
// Every frame carries a real free-form markdown body so the detail view and
// the inheritance resolver have representative data to render.
func buildFrames(orgSlug string) []fixtureFrame {
return []fixtureFrame{
{
Expand All @@ -98,54 +98,25 @@ func buildFrames(orgSlug string) []fixtureFrame {
Visibility: "internal",
Scope: "company",
Maintainer: "platform engineering",
Slots: frames.Slots{
Terminology: []frames.Term{
{Term: "Frame", Definition: "A versioned, composable unit of environment and agent context that other frames can extend."},
{Term: "Environment", Definition: "The reproducible set of pinned dependencies (conda/pip) a workload runs against."},
},
Rules: []string{
"Pin every dependency to an exact version; never use unbounded ranges.",
"Reproducibility is non-negotiable: the same frame must resolve identically on every machine.",
"Prefer conda-forge as the primary channel for scientific packages.",
},
Skills: []string{
"Resolving and locking conda environments.",
"Reading and writing reproducible dependency manifests.",
},
Goals: "Provide a stable, reproducible foundation so downstream frames can focus on their specialization rather than base setup.",
Style: "Terse, explicit, and reproducible. Favor declarative configuration over imperative setup scripts.",
Norms: "Changes to pinned tooling require a version bump and a changelog entry.",
},
Body: `Pin every dependency to an exact version; never use unbounded ranges. Reproducibility is non-negotiable: the same frame must resolve identically on every machine.

Prefer conda-forge as the primary channel for scientific packages.

Changes to pinned tooling require a version bump and a changelog entry. Favor declarative configuration over imperative setup scripts.`,
})},
{version: "2.0.0", changelog: "Bump pinned tooling (Python 3.12) and add packaging skill",
{version: "2.0.0", changelog: "Bump pinned tooling (Python 3.12)",
content: mustMarshalDoc(&frames.Doc{
Name: "base-ml-env",
Description: "Base machine-learning environment: shared conventions every ML frame inherits.",
Version: "2.0.0",
Visibility: "internal",
Scope: "company",
Maintainer: "platform engineering",
Slots: frames.Slots{
Terminology: []frames.Term{
{Term: "Frame", Definition: "A versioned, composable unit of environment and agent context that other frames can extend."},
{Term: "Environment", Definition: "The reproducible set of pinned dependencies (conda/pip) a workload runs against."},
{Term: "Lockfile", Definition: "A fully-resolved, hash-pinned snapshot of an environment used for byte-identical rebuilds."},
},
Rules: []string{
"Pin every dependency to an exact version; never use unbounded ranges.",
"Reproducibility is non-negotiable: the same frame must resolve identically on every machine.",
"Prefer conda-forge as the primary channel for scientific packages.",
"Target Python 3.12 unless a downstream frame pins otherwise.",
},
Skills: []string{
"Resolving and locking conda environments.",
"Reading and writing reproducible dependency manifests.",
"Building and publishing conda packages to a private channel.",
},
Goals: "Provide a stable, reproducible foundation so downstream frames can focus on their specialization rather than base setup.",
Style: "Terse, explicit, and reproducible. Favor declarative configuration over imperative setup scripts.",
Norms: "Changes to pinned tooling require a version bump and a changelog entry.",
},
Body: `Pin every dependency to an exact version; never use unbounded ranges. Reproducibility is non-negotiable: the same frame must resolve identically on every machine. Rebuilds should be byte-identical from the lockfile.

Prefer conda-forge as the primary channel for scientific packages. Target Python 3.12 unless a downstream frame pins otherwise. Build and publish internal conda packages to the private channel.

Changes to pinned tooling require a version bump and a changelog entry. Favor declarative configuration over imperative setup scripts.`,
})},
},
},
Expand All @@ -163,29 +134,11 @@ func buildFrames(orgSlug string) []fixtureFrame {
Extends: []frames.ExtendRef{
{Ref: orgSlug + "/base-ml-env", Version: "2.0.0"},
},
Slots: frames.Slots{
Terminology: []frames.Term{
{Term: "CUDA", Definition: "NVIDIA's parallel computing platform used to run PyTorch tensor ops on the GPU."},
{Term: "Mixed precision", Definition: "Training with a mix of float16 and float32 to cut memory use and speed up compute."},
},
Rules: []string{
"Pin the CUDA toolkit version to match the target driver; mismatches fail silently at runtime.",
"Always guard GPU code with a CPU fallback so tests run in CI without a GPU.",
"Enable mixed precision for training runs unless numerical stability requires float32.",
},
Skills: []string{
"Configuring PyTorch for a specific CUDA/cuDNN version.",
"Diagnosing out-of-memory errors and tuning batch size and gradient accumulation.",
"Profiling GPU utilization to find data-loading bottlenecks.",
},
Prompts: []string{
"Given a training script, suggest the largest batch size that fits in the available GPU memory.",
"Review this model code and flag any operations that will silently fall back to CPU.",
},
ToolSpecs: "torch>=2.2, torchvision, cuda-toolkit 12.1, cudnn. Expose `nvidia-smi` for GPU introspection.",
Goals: "Give ML engineers a ready-to-train GPU environment with sane defaults for CUDA, so they iterate on models rather than plumbing.",
Architecture: "Single-node, single-or-multi-GPU. Data loaders run on CPU workers feeding the GPU; checkpoints written to the shared volume.",
},
Body: `The stack is torch>=2.2 with torchvision on cuda-toolkit 12.1 and cudnn; ` + "`nvidia-smi`" + ` is available for GPU introspection. Single-node, single-or-multi-GPU: data loaders run on CPU workers feeding the GPU, and checkpoints are written to the shared volume.

Pin the CUDA toolkit version to match the target driver; mismatches fail silently at runtime. Always guard GPU code with a CPU fallback so tests run in CI without a GPU.

Enable mixed precision for training runs unless numerical stability requires float32. When memory is tight, tune batch size and gradient accumulation before reaching for a bigger GPU, and profile GPU utilization to find data-loading bottlenecks.`,
}),
extends: []store.ParentEdge{{ParentFrameID: idBaseMLEnv, ParentVersion: "2.0.0", OrderIndex: 0}}},
},
Expand All @@ -204,34 +157,18 @@ func buildFrames(orgSlug string) []fixtureFrame {
Extends: []frames.ExtendRef{
{Ref: orgSlug + "/pytorch-gpu", Version: "1.0.0"},
},
Slots: frames.Slots{
Terminology: []frames.Term{
{Term: "Notebook profile", Definition: "A JupyterLab configuration bundle (kernels, extensions, resource limits) applied to a team's servers."},
},
Rules: []string{
"Commit notebooks with cleared outputs; large embedded outputs bloat the repo.",
"Shared datasets live under /shared/data (read-only); never copy them into a home directory.",
"Long-running jobs belong in the batch queue, not in an interactive notebook kernel.",
},
Skills: []string{
"Using the team's shared JupyterLab extensions and kernels.",
"Moving an exploratory notebook into a reproducible pipeline.",
},
Prompts: []string{
"Convert this exploratory notebook cell into a parameterized, testable function.",
"Suggest where in this notebook to checkpoint intermediate results to the shared volume.",
},
Goals: "Let the data-science team share one reproducible, GPU-ready notebook environment with agreed-upon conventions.",
Style: "Collaborative and review-friendly: notebooks should read like documented experiments, not scratch pads.",
Norms: "New shared extensions are proposed in the team channel and added here via a version bump.",
BusinessProcess: "Exploration happens in notebooks; promising results are promoted to a tracked pipeline before any production use.",
},
Body: `Commit notebooks with cleared outputs; large embedded outputs bloat the repo. Notebooks should read like documented experiments, not scratch pads.

Shared datasets live under /shared/data (read-only); never copy them into a home directory. Long-running jobs belong in the batch queue, not in an interactive notebook kernel.

Exploration happens in notebooks; promising results are promoted to a tracked, reproducible pipeline before any production use. New shared JupyterLab extensions are proposed in the team channel and added here via a version bump.`,
}),
extends: []store.ParentEdge{{ParentFrameID: idPyTorchGPU, ParentVersion: "1.0.0", OrderIndex: 0}}},
},
},
{
id: idStandalone, name: "standalone-frame", description: "Standalone data-cleaning frame with no parents, for exercising the non-inheriting case.",
isTemplate: true, // exercises the "start from a template" picker
versions: []frameVersion{
{version: "1.0.0", changelog: "Initial standalone frame",
content: mustMarshalDoc(&frames.Doc{
Expand All @@ -241,25 +178,12 @@ func buildFrames(orgSlug string) []fixtureFrame {
Visibility: "shared",
Scope: "project",
Maintainer: "data science",
Slots: frames.Slots{
Terminology: []frames.Term{
{Term: "Tidy data", Definition: "A table where each variable is a column, each observation a row, and each cell a single value."},
},
Rules: []string{
"Never mutate the raw input in place; write cleaned output to a new location.",
"Record every transformation so the cleaning run is fully auditable.",
},
Skills: []string{
"Profiling a dataset for missing values, outliers, and type inconsistencies.",
"Writing idempotent, re-runnable data-cleaning transforms.",
},
Prompts: []string{
"Given this dataframe schema, propose a set of validation checks to run before cleaning.",
},
Goals: "Provide a self-contained frame for tabular data cleaning that depends on nothing else.",
Style: "Defensive and explicit: validate assumptions loudly and fail fast on malformed input.",
BusinessProcess: "Raw data lands, is validated, is cleaned into a tidy table, and only then is handed to downstream analysis.",
},
Template: true,
Body: `Never mutate the raw input in place; write cleaned output to a new location, and record every transformation so the cleaning run is fully auditable.

Profile a dataset for missing values, outliers, and type inconsistencies before cleaning it, and write idempotent, re-runnable transforms. Validate assumptions loudly and fail fast on malformed input.

Raw data lands, is validated, is cleaned into a tidy table (each variable a column, each observation a row), and only then is handed to downstream analysis.`,
})},
},
},
Expand Down Expand Up @@ -317,6 +241,7 @@ func loadFrame(ctx context.Context, repo store.Repository, org *framesv1.Org, f
Frame: &framesv1.Frame{
Id: f.id, OrgId: org.Id, Name: f.name, Description: f.description,
OwnerSub: ownerSub, LatestVersion: v.version, CreatedAt: now, UpdatedAt: now,
IsTemplate: f.isTemplate,
},
Version: &framesv1.FrameVersion{
Version: v.version, Changelog: v.changelog, Content: content,
Expand Down
Loading
Loading