agent build
This commit is contained in:
236
go-api/internal/repo/versions.go
Normal file
236
go-api/internal/repo/versions.go
Normal file
@@ -0,0 +1,236 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/domain"
|
||||
)
|
||||
|
||||
// The immutable side of the registry.
|
||||
//
|
||||
// §3: "Immutable versions. Editing publishes a new version. Running
|
||||
// conversations pin the version they started with." Two halves, and the second
|
||||
// is the one that costs something to get right.
|
||||
//
|
||||
// The first half is a snapshot on publish, which is this file's Snapshot.
|
||||
//
|
||||
// The second half is why the snapshot is worth taking. Every run records the
|
||||
// agent version it ran under, and until now that number pointed at a definition
|
||||
// that had since been edited — so "which agent answered this?" was
|
||||
// unanswerable, and worse, a confirmation approved against version 3 would be
|
||||
// carried out by version 4's tool list. A person approves what they were shown.
|
||||
// Resolving that number back to the definition it named is what makes the
|
||||
// approval mean the thing they approved.
|
||||
|
||||
// VersionKind distinguishes the two definition types.
|
||||
//
|
||||
// One table for both, because agents and skills version identically and two
|
||||
// tables with the same columns and the same rules are two places to fix the
|
||||
// next rule.
|
||||
type VersionKind string
|
||||
|
||||
const (
|
||||
KindAgent VersionKind = "agent"
|
||||
KindSkill VersionKind = "skill"
|
||||
)
|
||||
|
||||
// VersionsRepo reads and appends published versions.
|
||||
type VersionsRepo struct {
|
||||
db Querier
|
||||
}
|
||||
|
||||
// NewVersionsRepo builds a repository over a pool or transaction.
|
||||
func NewVersionsRepo(db Querier) *VersionsRepo { return &VersionsRepo{db: db} }
|
||||
|
||||
// Version is one published snapshot.
|
||||
type Version struct {
|
||||
Kind VersionKind `json:"kind"`
|
||||
DefinitionID string `json:"definitionId"`
|
||||
Version int `json:"version"`
|
||||
Markdown string `json:"markdown"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Pages []string `json:"pages"`
|
||||
PublishedAt string `json:"publishedAt"`
|
||||
}
|
||||
|
||||
// SnapshotInput is what a publish records.
|
||||
type SnapshotInput struct {
|
||||
Kind VersionKind
|
||||
DefinitionID string
|
||||
Version int
|
||||
Markdown string
|
||||
Name string
|
||||
Description string
|
||||
Pages []string
|
||||
}
|
||||
|
||||
// Snapshot records a published version.
|
||||
//
|
||||
// Idempotent by construction: republishing the same version number with the
|
||||
// same content is a no-op rather than an error, because the honest reading of
|
||||
// "publish version 3 again" is that version 3 already exists and says this.
|
||||
//
|
||||
// Republishing the same number with DIFFERENT content is refused, and that
|
||||
// refusal is the whole point of the table. It is the moment somebody would
|
||||
// otherwise have rewritten what a person approved, and it fails loudly with the
|
||||
// version number in the message rather than silently taking the newer text.
|
||||
func (r *VersionsRepo) Snapshot(ctx context.Context, ident authctx.Identity, in SnapshotInput) error {
|
||||
if strings.TrimSpace(ident.OrgID) == "" {
|
||||
return domain.Internal(errors.New("a version needs an organization"))
|
||||
}
|
||||
if in.Version < 1 {
|
||||
return domain.Validation("a published version must be at least 1", nil)
|
||||
}
|
||||
if strings.TrimSpace(in.Markdown) == "" {
|
||||
return domain.Validation("a published version needs a definition", nil)
|
||||
}
|
||||
|
||||
pages := in.Pages
|
||||
if pages == nil {
|
||||
pages = []string{}
|
||||
}
|
||||
|
||||
var existing string
|
||||
err := r.db.QueryRow(ctx, `
|
||||
INSERT INTO definition_versions
|
||||
(kind, org_id, definition_id, version, markdown, name, description, pages, published_by)
|
||||
VALUES ($1, $2::uuid, $3, $4, $5, $6, $7, $8::text[], $9)
|
||||
ON CONFLICT (org_id, kind, definition_id, version) DO NOTHING
|
||||
RETURNING markdown`,
|
||||
string(in.Kind), ident.OrgID, in.DefinitionID, in.Version,
|
||||
in.Markdown, in.Name, in.Description, pages, nullUUID(ident.UserID),
|
||||
).Scan(&existing)
|
||||
|
||||
if err == nil {
|
||||
return nil // inserted
|
||||
}
|
||||
if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return translate(err)
|
||||
}
|
||||
|
||||
// The conflict path: this version already exists. Whether that is fine
|
||||
// depends entirely on whether it says the same thing.
|
||||
var stored string
|
||||
if err := r.db.QueryRow(ctx, `
|
||||
SELECT markdown FROM definition_versions
|
||||
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3 AND version = $4`,
|
||||
ident.OrgID, string(in.Kind), in.DefinitionID, in.Version,
|
||||
).Scan(&stored); err != nil {
|
||||
return translate(err)
|
||||
}
|
||||
if stored == in.Markdown {
|
||||
return nil
|
||||
}
|
||||
return domain.Conflict(fmt.Sprintf(
|
||||
"version %d of %q is already published and says something different; "+
|
||||
"publish a new version rather than changing this one",
|
||||
in.Version, in.DefinitionID))
|
||||
}
|
||||
|
||||
// Load returns one published version.
|
||||
//
|
||||
// Tenant-scoped in the query, so a version from another organization is absent
|
||||
// rather than forbidden — the same rule every other row in this service follows,
|
||||
// and for the same reason: a distinguishable refusal is a way to enumerate.
|
||||
func (r *VersionsRepo) Load(ctx context.Context, ident authctx.Identity,
|
||||
kind VersionKind, definitionID string, version int) (*Version, error) {
|
||||
|
||||
if strings.TrimSpace(ident.OrgID) == "" {
|
||||
return nil, domain.NotFound("version", definitionID)
|
||||
}
|
||||
|
||||
var v Version
|
||||
err := r.db.QueryRow(ctx, `
|
||||
SELECT kind, definition_id, version, markdown, name, description, pages,
|
||||
to_char(published_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM definition_versions
|
||||
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3 AND version = $4`,
|
||||
ident.OrgID, string(kind), definitionID, version,
|
||||
).Scan(&v.Kind, &v.DefinitionID, &v.Version, &v.Markdown, &v.Name,
|
||||
&v.Description, &v.Pages, &v.PublishedAt)
|
||||
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, domain.NotFound("version", fmt.Sprintf("%s v%d", definitionID, version))
|
||||
}
|
||||
if err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
return &v, nil
|
||||
}
|
||||
|
||||
// History lists a definition's published versions, newest first.
|
||||
func (r *VersionsRepo) History(ctx context.Context, ident authctx.Identity,
|
||||
kind VersionKind, definitionID string, limit int) ([]Version, error) {
|
||||
|
||||
if strings.TrimSpace(ident.OrgID) == "" {
|
||||
return []Version{}, nil
|
||||
}
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
|
||||
rows, err := r.db.Query(ctx, `
|
||||
SELECT kind, definition_id, version, markdown, name, description, pages,
|
||||
to_char(published_at, 'YYYY-MM-DD"T"HH24:MI:SS"Z"')
|
||||
FROM definition_versions
|
||||
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3
|
||||
ORDER BY version DESC
|
||||
LIMIT $4`,
|
||||
ident.OrgID, string(kind), definitionID, limit)
|
||||
if err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
out := []Version{}
|
||||
for rows.Next() {
|
||||
var v Version
|
||||
if err := rows.Scan(&v.Kind, &v.DefinitionID, &v.Version, &v.Markdown,
|
||||
&v.Name, &v.Description, &v.Pages, &v.PublishedAt); err != nil {
|
||||
return nil, translate(err)
|
||||
}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// LatestVersion is the highest published version number, or 0 for none.
|
||||
//
|
||||
// Used to decide what a new publish should be numbered. Reading the CURRENT
|
||||
// definition's version would be wrong: a draft can carry any number its author
|
||||
// typed, and the next published version has to follow what was actually
|
||||
// published rather than what somebody wrote in the frontmatter.
|
||||
func (r *VersionsRepo) LatestVersion(ctx context.Context, ident authctx.Identity,
|
||||
kind VersionKind, definitionID string) (int, error) {
|
||||
|
||||
if strings.TrimSpace(ident.OrgID) == "" {
|
||||
return 0, nil
|
||||
}
|
||||
var latest *int
|
||||
if err := r.db.QueryRow(ctx, `
|
||||
SELECT max(version) FROM definition_versions
|
||||
WHERE org_id = $1::uuid AND kind = $2 AND definition_id = $3`,
|
||||
ident.OrgID, string(kind), definitionID,
|
||||
).Scan(&latest); err != nil {
|
||||
return 0, translate(err)
|
||||
}
|
||||
if latest == nil {
|
||||
return 0, nil
|
||||
}
|
||||
return *latest, nil
|
||||
}
|
||||
|
||||
// nullUUID keeps an empty principal id out of a uuid column.
|
||||
func nullUUID(s string) any {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
212
go-api/internal/repo/versions_test.go
Normal file
212
go-api/internal/repo/versions_test.go
Normal file
@@ -0,0 +1,212 @@
|
||||
package repo_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/krow/krow-backend/go-api/internal/authctx"
|
||||
"github.com/krow/krow-backend/go-api/internal/repo"
|
||||
"github.com/krow/krow-backend/go-api/internal/testutil"
|
||||
)
|
||||
|
||||
// Version immutability.
|
||||
//
|
||||
// §3 states it in one sentence — "specs are immutable once published" — and the
|
||||
// whole value of it is what it makes possible downstream: a run records the
|
||||
// version it answered under, and that number is only worth recording if it can
|
||||
// still be resolved to the definition that actually answered.
|
||||
//
|
||||
// The tests below are mostly about the ways that guarantee can be lost quietly.
|
||||
|
||||
func fixture(t *testing.T, slug string) (*testutil.Harness, authctx.Identity, *repo.VersionsRepo) {
|
||||
t.Helper()
|
||||
h := testutil.New(t)
|
||||
|
||||
var orgID string
|
||||
if err := h.Pool.QueryRow(context.Background(),
|
||||
`INSERT INTO organizations (name, slug) VALUES ($1, $2) RETURNING id::text`,
|
||||
slug, slug).Scan(&orgID); err != nil {
|
||||
t.Fatalf("create org: %v", err)
|
||||
}
|
||||
var userID string
|
||||
if err := h.Pool.QueryRow(context.Background(), `
|
||||
INSERT INTO users (org_id, email, full_name, role)
|
||||
VALUES ($1::uuid, $2, 'Author', 'admin') RETURNING id::text`,
|
||||
orgID, fmt.Sprintf("author-%s@example.test", slug)).Scan(&userID); err != nil {
|
||||
t.Fatalf("create user: %v", err)
|
||||
}
|
||||
|
||||
ident := authctx.Identity{
|
||||
UserID: userID, OrgID: orgID, Role: "admin",
|
||||
Email: fmt.Sprintf("author-%s@example.test", slug),
|
||||
}
|
||||
return h, ident, repo.NewVersionsRepo(h.Pool)
|
||||
}
|
||||
|
||||
func snapshot(id string, version int, markdown string) repo.SnapshotInput {
|
||||
return repo.SnapshotInput{
|
||||
Kind: repo.KindAgent, DefinitionID: id, Version: version,
|
||||
Markdown: markdown, Name: "Test Agent", Pages: []string{"activity"},
|
||||
}
|
||||
}
|
||||
|
||||
func TestAPublishedVersionCanBeReadBackExactly(t *testing.T) {
|
||||
// The property everything else rests on: a version number resolves to the
|
||||
// definition that answered under it.
|
||||
h, ident, versions := fixture(t, "ver-readback")
|
||||
ctx := context.Background()
|
||||
_ = h
|
||||
|
||||
md := "---\nid: a\nname: Test Agent\nversion: 1\n---\n\n## Instructions\nOriginal."
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 1, md)); err != nil {
|
||||
t.Fatalf("snapshot: %v", err)
|
||||
}
|
||||
|
||||
got, err := versions.Load(ctx, ident, repo.KindAgent, "a", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if got.Markdown != md {
|
||||
t.Errorf("the definition came back changed:\n want %q\n got %q", md, got.Markdown)
|
||||
}
|
||||
if got.Version != 1 {
|
||||
t.Errorf("version = %d", got.Version)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepublishingTheSameVersionWithDifferentContentIsRefused(t *testing.T) {
|
||||
// The moment somebody would otherwise rewrite what a person approved.
|
||||
// Refused loudly, with the version number in the message, rather than
|
||||
// silently taking the newer text.
|
||||
h, ident, versions := fixture(t, "ver-rewrite")
|
||||
ctx := context.Background()
|
||||
_ = h
|
||||
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 1, "original")); err != nil {
|
||||
t.Fatalf("first publish: %v", err)
|
||||
}
|
||||
|
||||
err := versions.Snapshot(ctx, ident, snapshot("a", 1, "rewritten"))
|
||||
if err == nil {
|
||||
t.Fatal("republishing version 1 with different content was accepted")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "1") {
|
||||
t.Errorf("the refusal does not name the version: %v", err)
|
||||
}
|
||||
|
||||
// And the original survives.
|
||||
got, _ := versions.Load(ctx, ident, repo.KindAgent, "a", 1)
|
||||
if got == nil || got.Markdown != "original" {
|
||||
t.Errorf("the stored version changed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepublishingIdenticalContentIsANoOp(t *testing.T) {
|
||||
// "Publish version 1 again" when version 1 already says exactly this is not
|
||||
// an error — it is a restatement of a true thing. Treating it as a conflict
|
||||
// would make every idempotent import fail on its second run.
|
||||
h, ident, versions := fixture(t, "ver-idempotent")
|
||||
ctx := context.Background()
|
||||
_ = h
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 1, "same")); err != nil {
|
||||
t.Fatalf("publish %d: %v", i+1, err)
|
||||
}
|
||||
}
|
||||
history, err := versions.History(ctx, ident, repo.KindAgent, "a", 10)
|
||||
if err != nil {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
if len(history) != 1 {
|
||||
t.Errorf("%d versions after three identical publishes, want 1", len(history))
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditingPublishesANewVersionAndKeepsTheOld(t *testing.T) {
|
||||
// §3's sentence, asserted: editing publishes a NEW version, and the old one
|
||||
// is still there afterwards.
|
||||
h, ident, versions := fixture(t, "ver-newversion")
|
||||
ctx := context.Background()
|
||||
_ = h
|
||||
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 1, "v1 text")); err != nil {
|
||||
t.Fatalf("v1: %v", err)
|
||||
}
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 2, "v2 text")); err != nil {
|
||||
t.Fatalf("v2: %v", err)
|
||||
}
|
||||
|
||||
one, err := versions.Load(ctx, ident, repo.KindAgent, "a", 1)
|
||||
if err != nil {
|
||||
t.Fatalf("v1 is gone after publishing v2: %v", err)
|
||||
}
|
||||
if one.Markdown != "v1 text" {
|
||||
t.Errorf("v1 changed when v2 was published: %q", one.Markdown)
|
||||
}
|
||||
|
||||
latest, err := versions.LatestVersion(ctx, ident, repo.KindAgent, "a")
|
||||
if err != nil || latest != 2 {
|
||||
t.Errorf("latest = %d (err %v), want 2", latest, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnotherTenantsVersionIsAbsent(t *testing.T) {
|
||||
// I5. A version from another organization is not forbidden, it is absent —
|
||||
// the same rule every other row follows, and for the same reason.
|
||||
h, mine, versions := fixture(t, "ver-mine")
|
||||
ctx := context.Background()
|
||||
|
||||
var otherOrg string
|
||||
if err := h.Pool.QueryRow(ctx,
|
||||
`INSERT INTO organizations (name, slug) VALUES ('Other', 'ver-other') RETURNING id::text`,
|
||||
).Scan(&otherOrg); err != nil {
|
||||
t.Fatalf("create other org: %v", err)
|
||||
}
|
||||
theirs := authctx.Identity{UserID: "", OrgID: otherOrg, Role: "admin", Email: "x@other.test"}
|
||||
|
||||
if err := versions.Snapshot(ctx, mine, snapshot("shared-id", 1, "mine")); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
if _, err := versions.Load(ctx, theirs, repo.KindAgent, "shared-id", 1); err == nil {
|
||||
t.Fatal("another tenant read a version that was not theirs")
|
||||
}
|
||||
// And the same id in their own tenant is a different definition entirely.
|
||||
if err := versions.Snapshot(ctx, theirs, snapshot("shared-id", 1, "theirs")); err != nil {
|
||||
t.Fatalf("their own publish was refused: %v", err)
|
||||
}
|
||||
got, _ := versions.Load(ctx, mine, repo.KindAgent, "shared-id", 1)
|
||||
if got == nil || got.Markdown != "mine" {
|
||||
t.Errorf("one tenant's publish overwrote another's: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheDatabaseRefusesToRewriteAVersion(t *testing.T) {
|
||||
// The repository has no update path, but the repository is not the only
|
||||
// thing that can reach the table — a migration, a console session and a
|
||||
// future service all can. This asserts the guarantee where it actually
|
||||
// lives.
|
||||
h, ident, versions := fixture(t, "ver-trigger")
|
||||
ctx := context.Background()
|
||||
|
||||
if err := versions.Snapshot(ctx, ident, snapshot("a", 1, "original")); err != nil {
|
||||
t.Fatalf("publish: %v", err)
|
||||
}
|
||||
|
||||
_, err := h.Pool.Exec(ctx,
|
||||
`UPDATE definition_versions SET markdown = 'rewritten' WHERE definition_id = 'a'`)
|
||||
if err == nil {
|
||||
t.Fatal("the database allowed a published version to be rewritten")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "append-only") {
|
||||
t.Errorf("the refusal does not explain itself: %v", err)
|
||||
}
|
||||
|
||||
_, err = h.Pool.Exec(ctx, `DELETE FROM definition_versions WHERE definition_id = 'a'`)
|
||||
if err == nil {
|
||||
t.Fatal("the database allowed a published version to be deleted")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user