mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-09-10 15:05:40 +00:00
feat(store,server,web): collection kernel traits — de-hardcode conventions/playbooks slugs (TASK-2657, BUG-2702) (#1171)
Implements SPEC-5 §Collection traits (approved v1.1) — the first unit of
PLAN-2656 phase 0. Three kernel behaviors were keyed on the literal collection
slugs "conventions" and "playbooks": what the agent bootstrap loads, which
items route by invocation slug, and which items export as portable artifacts.
Collections now DECLARE those behaviors and the kernel resolves them from the
declarations.
Fixes the KERNEL half of BUG-2702, which stays open for the rest (see below).
A slug is not a stable identifier —
UpdateCollection re-slugs on any name change, and renaming a collection is a
documented onboarding step (TASK-1510) — so renaming either collection silently
detached all three behaviors from it, with the items still present and no error
anywhere. Measured on origin/main before the fix: conventions and
convention_index dropped 1 -> 0, playbooks 1 -> 0, and GET /playbooks/{slug}
went 200 -> 404, so `/pad ship` stopped resolving with no sign the playbook
still existed. Both halves are locked by regression tests observed failing on
unfixed code.
BUG-2702 is NOT fully closed here, deliberately. Every kernel behavior follows
the trait, and library activation on the MCP dispatcher and CLI was converted
too — but the pack's own dedicated web routes (/conventions, /playbooks list and
detail, /library) still address their collection by literal slug and render
empty after a rename. Filed as BUG-2705 with the route paths and the likely fix
shape; 2702 closes when that lands. Degradation there is bounded: no data loss,
and the collection stays usable at its own /[collection] route and in the
sidebar.
SPEC-5 was amended to v1.1 BEFORE any code, per the spec tree's own discipline:
bootstrap_include becomes a LIST of {mode, filter, key} because v1.0 could not
express convention_index at all; the conventions filter is now normative and
includes status=active, which v1.0's shorthand omitted and which the
implementation does enforce (implementing v1.0 literally would have leaked
draft conventions into every agent's boot payload); v1 filters are field-
equality maps with query/1 named as the widening path, since SPEC-2 is phase 1
and PLAN-2656 forbids growing toward it; and invocation_field is constrained to
the literal `invocation_slug`, because any other field name falls outside the
partial unique indexes in migrations/054 and pgmigrations/033 that are the real
uniqueness guard.
Traits get their own column rather than a key inside the schema JSON. The
schema column is overwritten wholesale on update and every client rebuilds it
fields-only, so a traits key stored there is destroyed by one ordinary
collection edit — measured during this task, not assumed. Trait authority
cannot rest on a value an unrelated UI save deletes. UpdateCollection writes
traits only when explicitly supplied, so pre-existing clients leave them alone;
an explicit "{}" still clears.
Bootstrap keeps its three payload keys as first-party views fed from the
declarations, and gains a generic bootstrap_includes array for any other
declared key — so the boot surface is genuinely generic rather than three
hardcoded payloads, and no consumer breaks.
Existing workspaces are backfilled slug-keyed in both dialects, guarded on
traits='{}' so a re-run cannot clobber a workspace's own declarations. The
backfill inherits today's blind spot (a workspace that renamed the collection
before upgrading is not reached) but cannot do worse than the status quo, which
is itself slug-keyed; from the backfill forward the hazard is structurally gone.
Malformed declarations are refused at create and update rather than stored:
an unparseable blob degrades to "declares nothing", which is silently the wrong
behavior instead of a loud error (SPEC-0 L6).
Web groups agent-facing collections by bootstrap_include presence, replacing a
hardcoded two-slug array repeated at five call sites.
Not done, deliberately: no MCP catalog change (traits are first-party kernel
declarations, no agent needs to set them, and the separate column means
pad_collection.update passes through harmlessly — no ToolSurfaceVersion bump);
bootstrap's collections[] projection does not carry traits (PLAN-1410 trimmed
that payload and nothing consumes them there); prefix.go's NormalizeSlug is
untouched (a pure function with no workspace context, and de-hardcoding two of
its six slugs would make it less coherent, not more).
Eight Codex review rounds found nineteen real defects, all fixed here. The
serious one:
bootstrap_include filter keys FAIL OPEN. The item store's field-filter path
drops any key its sanitizer rejects, removing the predicate rather than matching
nothing, so a declaration filtering on `"stat us"` would narrow nothing and ship
every convention — drafts included — to every agent at boot, defeating the
status=active guarantee this change makes normative. Filter keys are now
validated against the store's own sanitizer shape and pinned by a cross-package
agreement test, since models cannot import store and a future divergence would
silently reopen it. SPEC-5 amended to v1.2 with the rule and its fail-open /
fail-closed asymmetry. Also fixed: an unknown declared artifact_kind reached
artifact.Encode and surfaced as a 500 (now a 400 at the export boundary, since
SPEC-5 permits unknown kinds as legal non-round-tripping declarations); and
workspace import validated traits as JSON only, so an archive could persist a
declaration that degrades to "declares nothing" (now validated, degrading to
"{}" with a warning rather than refusing an import that may be the only copy).
Later rounds found more, and several were defects this change itself created.
A hidden collection could SHADOW a visible one: resolution used to name exactly
one collection, so with several declaring, resolving across all of them and
rejecting afterwards on visibility made a visible playbook unreachable behind a
hidden one — candidates are now filtered by visibility before selection, in both
playbook resolution and artifact import. Importing a pre-traits archive produced
an INERT workspace: the migration backfill cannot reach rows inserted long after
it ran, so conventions/playbooks arrived declaring nothing, and canonical
declarations are now inferred from the slug when a collection declares none
(never overriding declarations that survived the round trip). The generic
include path had no L4 boot budget and is now capped with an overflow count.
Trait parsing claimed to be strict but json.Decoder ignores trailing bytes, so
`{...} garbage` parsed cleanly. First-party payload keys are now mode-pinned,
since their projections have fixed shapes and declaring the other mode would be
silently ignored. Duplicate artifact_kind / invocation_field declarations are
refused at the collection API, and a conflicting archive warns on import.
Agent-facing text was updated with the rest, not after it: SKILL.md,
instructions.md and the MCP catalog said the literal slugs, which is exactly the
artifact an agent acts on. ToolSurfaceVersion 0.24 -> 0.25 for the
pad_library.activate behaviour change.
Trait uniqueness is a documented BEST-EFFORT gate, not an invariant, by lead
ruling. The gate reads then writes without a lock, import bypasses it, and a
rename can mint a duplicate without touching that path. The database-level
enforcement (partial unique indexes on the extracted traits) cannot ship first:
existing deployments can already hold duplicates via rename-then-reseed, so the
index would fail the migration on precisely the databases that most need
repairing. TASK-2710 carries the de-duplication pass and the indexes; SPEC-5
v1.3 records the deferral and the reason. L6's requirement that conflicts fail
loud is met by the refusal plus the warning — the mechanism is deferred, the
principle is not.
Gates: build · make lint 0 issues · go test ./internal/... · make test-pg ·
svelte-check 0 errors · vitest 99 files / 1734 tests. Mutation-verified across
four matrices, 20 mutations, 19 caught; the survivor is a seeding path whose
trait-vs-slug difference is unreachable today (SeedCollectionsFromTemplate
creates any missing template collection before it seeds items), recorded on the
task trail rather than papered over with a test that proves nothing.
Claude-Session: https://claude.ai/code/session_017jD6t1zjxGSq47SQpZfp1V
This commit is contained in:
@@ -349,7 +349,7 @@ directory for `claude-code`, and an `[mcp_servers.pad]` table in
|
||||
project-scoped, it's install-on-request only — `--all` and `pad mcp status` cover
|
||||
the per-user clients (including Codex) and skip it.
|
||||
|
||||
**Tool catalog (v0.24)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`. `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies):
|
||||
**Tool catalog (v0.25)** — ten resource × action tools plus `pad_set_workspace` (eleven total), no flat verb explosion. Undeclared input keys are rejected with a structured error rather than silently dropped. `pad_item` create/update accept field values as a `fields` object (the same shape reads return) as an equivalent to the dedicated params / `field: ["key=value"]`. `pad_item.list` accepts `unparented: true` (mutually exclusive with `parent`) to select items with no parent or implements relationship, and is summary-shaped by default on both transports (`full: true` opts into complete content bodies):
|
||||
|
||||
| Tool | Actions |
|
||||
|---|---|
|
||||
@@ -378,7 +378,7 @@ initialize handshake under `capabilities.experimental.padCmdhelp` and
|
||||
`pad://_meta/version`):
|
||||
|
||||
- `cmdhelp_version: "0.1"` — CLI help-tree contract (used at dispatch time)
|
||||
- `tool_surface_version: "0.24"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); see `internal/mcp/version.go` for the full changelog)
|
||||
- `tool_surface_version: "0.25"` — MCP tool catalog contract (v0.5 added `pad_library`; v0.6 `pad_item.backlinks`; v0.7 `pad_item` `export`/`import`; v0.8 `pad_workspace` `deleted`/`restore`; v0.9 made `pad_item.list` summary-shaped by default with a default+max result cap; v0.10 enforced the draft-playbook gate server-side on `pad_playbook.run` with an `allow_draft` escape hatch; v0.11 added the read-only `pad_attachment` tool (`list`/`show`); v0.12 added `pad_project.activity` (agent-accessible non-streaming activity feed); v0.13 added `pad_project` `ready`/`stale` (agent-oriented backlog + attention queries); v0.14 added `pad_item` `history` + optimistic concurrency (TASK-2022); v0.15 added the `pad_item.list` `unparented` parameter (TASK-2096); v0.16 made an empty-string `assigned_user_id` / `agent_role_id` CLEAR the assignment instead of being silently dropped, so an agent can finally unassign an item (TASK-2571); v0.17 carried that to the LOCAL STDIO transport by teaching the CLI to lift those keys onto their columns instead of into the fields blob (BUG-2583); v0.18 added `clear_assigned_user` / `clear_agent_role` booleans — the canonical, schema-discoverable way to unassign, backed by new `--clear-assigned-user` / `--clear-agent-role` flags on `pad item update` (IDEA-2584); v0.19 added a `clear_parent` boolean — the canonical, schema-discoverable way to detach an item from its parent, backed by a new `--clear-parent` flag on `pad item update` (BUG-2078); v0.20 gave every tool an explicit annotation block derived from the catalog’s read-only knowledge — fully-read-only tools advertise `readOnlyHint: true` / `destructiveHint: false`, all-additive-write tools (`pad_workspace`, `pad_library`) drop `destructiveHint`, overwrite/delete-capable tools stay conservatively destructive, `openWorldHint: false` everywhere — replacing mcp-go’s defaults that marked every tool destructive (BUG-2302), and made `pad_item.list` summary-shaped on the remote HTTP transport too, with a declared `full` boolean as the opt-in for complete bodies on both transports (BUG-2305); v0.21 bounded `pad_item.history`, which was unbounded on every surface — `limit` now covers it (default 50, max 300, the NEWEST N; no `offset`, because reverse-patch storage makes only a newest-end window cheap), applied in the catalog action so it lands on both transports, and summary mode now asks the server to skip patch resolution rather than resolving bodies the dispatcher discards (BUG-2608); v0.22 stopped `pad_item.move` destroying an item’s system metadata — implementation notes, decision log, linked PR and convention data now survive a move, any field the destination schema has no home for is REPORTED in the move’s activity entry rather than vanishing, and a `field` setter naming one of those reserved keys is refused with `malformed_override` instead of writing it (BUG-2674); v0.23 closed the same door on the ordinary update — a `field` setter naming `implementation_notes`, `decision_log` or `convention` is now refused on every transport at once (`validation_error` on HTTP, surfaced to MCP clients as `validation_failed`); the one gate covers the CLI, remote MCP and stdio MCP at once because all three lower a `field` setter into the same `fields_patch`; `github_pr` is deliberately exempt ON UPDATE (move and copy still refuse it), since `pad github link` cannot run on remote MCP and refusing it would leave those agents with no door at all (that door is itself broken — BUG-2696); item CREATE stays open, deliberately, because its full-`fields` payload is shared with Pad’s own writers. v0.23 also added the retry-hostile `stored_state_unreadable` error code so an agent told its target item’s stored data is unreadable stops instead of retrying a permanent failure (BUG-2627 / BUG-2675); v0.24 made the `pad_item` `fields` object a real write form on create/update — reads return `fields` as a native object, and writing that shape back was a silent no-op (accepted, never mapped, dropped while the PATCH still bumped `updated_at`) — merging it into the same path as `field`/the dedicated params with conflicting duplicate keys refused, and made input validation strict across all catalog tools: undeclared top-level keys now fail with a structured error instead of being silently dropped (#1066); v0.25 made `pad_library.activate` resolve its DESTINATION collection from the target’s declared artifact kind (SPEC-5 collection traits) rather than the literal `conventions` / `playbooks` slugs, so activating into a workspace that renamed either collection lands correctly instead of failing not-found with the collection sitting right there (BUG-2702); a lookup ERROR is now surfaced rather than silently falling back to the canonical slug, because falling back on an error means writing to a slug nothing was confirmed about (TASK-2657); see `internal/mcp/version.go` for the full changelog)
|
||||
|
||||
External agents pin against these so a future rename doesn't break them
|
||||
silently. Errors come back as structured envelopes (`{error: {code,
|
||||
|
||||
+40
-3
@@ -8,11 +8,40 @@ import (
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/artifact"
|
||||
"github.com/PerpetualSoftware/pad/internal/cli"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// libraryTargetSlug resolves where a library entry of the given artifact kind
|
||||
// should be activated: whichever collection DECLARES that kind (SPEC-5
|
||||
// artifact_kind), not the collection that happens to be named "conventions" or
|
||||
// "playbooks". Activating into a renamed collection used to fail with a
|
||||
// not-found on a workspace whose collection was sitting right there
|
||||
// ([[BUG-2702]]). Falls back to the canonical slug when the workspace predates
|
||||
// traits and the lookup finds nothing, so activation still works on a
|
||||
// deployment that has not run the backfill. TASK-2657.
|
||||
//
|
||||
// A LISTING ERROR IS NOT A FALLBACK CASE. Falling back on an error means
|
||||
// writing to a slug we never confirmed anything about — and a workspace may
|
||||
// legitimately have an ordinary collection sitting on the canonical slug (say
|
||||
// the rules collection was renamed to `house-rules` and something unrelated
|
||||
// later took `conventions`), so the guess can land a library entry in a
|
||||
// collection that has nothing to do with it. An error is propagated instead;
|
||||
// the fallback applies ONLY when the lookup SUCCEEDED and simply found no
|
||||
// declaring collection, which is the genuine pre-backfill case. Codex round 5.
|
||||
func libraryTargetSlug(client *cli.Client, ws, kind, fallback string) (string, error) {
|
||||
colls, err := client.ListCollections(ws)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve target collection for %s activation: %w", kind, err)
|
||||
}
|
||||
if slug := collections.SlugForArtifactKind(colls, kind); slug != "" {
|
||||
return slug, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func libraryCmd() *cobra.Command {
|
||||
var categoryFilter string
|
||||
var typeFilter string
|
||||
@@ -351,7 +380,11 @@ Examples:
|
||||
Fields: string(fieldsJSON),
|
||||
}
|
||||
|
||||
item, err := client.CreateItem(ws, "conventions", input)
|
||||
target, err := libraryTargetSlug(client, ws, string(artifact.KindConvention), "conventions")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := client.CreateItem(ws, target, input)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*cli.APIError); ok {
|
||||
if apiErr.AsPlanLimit() != nil {
|
||||
@@ -414,7 +447,11 @@ Examples:
|
||||
Fields: string(fieldsJSON),
|
||||
}
|
||||
|
||||
item, err := client.CreateItem(ws, "playbooks", input)
|
||||
target, err := libraryTargetSlug(client, ws, string(artifact.KindPlaybook), "playbooks")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
item, err := client.CreateItem(ws, target, input)
|
||||
if err != nil {
|
||||
if apiErr, ok := err.(*cli.APIError); ok {
|
||||
if apiErr.AsPlanLimit() != nil {
|
||||
|
||||
@@ -12,8 +12,14 @@ type DefaultCollection struct {
|
||||
Description string
|
||||
Schema models.CollectionSchema
|
||||
Settings models.CollectionSettings
|
||||
SortOrder int
|
||||
IsSystem bool // System collections (conventions, playbooks) are always visible to members
|
||||
// Traits declares the collection's kernel traits (SPEC-5). Templates
|
||||
// declare them so a NEW workspace gets them at seed time; existing
|
||||
// workspaces are backfilled by migration 080 / pg 058. Between the two,
|
||||
// no kernel behavior is inferred from a collection's slug any more.
|
||||
// TASK-2657.
|
||||
Traits models.CollectionTraits
|
||||
SortOrder int
|
||||
IsSystem bool // System collections (conventions, playbooks) are always visible to members
|
||||
}
|
||||
|
||||
// Defaults returns the six default collections for a new workspace.
|
||||
|
||||
@@ -432,6 +432,17 @@ func conventionsCollection(sortOrder int, triggerOptions, scopeOptions []string)
|
||||
Icon: "\U0001F4CF",
|
||||
Description: "Project rules and conventions that guide agent behavior",
|
||||
SortOrder: sortOrder,
|
||||
// SPEC-5 §Collection traits. TWO bootstrap includes: the always-on
|
||||
// rules ship as full bodies because the agent must follow them, and
|
||||
// every active convention also ships as a body-less index entry so
|
||||
// triggered rules are discoverable without their bodies flooding the
|
||||
// boot payload. status=active is load-bearing in both — without it,
|
||||
// draft conventions reach agents as if they were policy.
|
||||
//
|
||||
// Defined once in CanonicalTraitsForSlug so the template, the
|
||||
// import-time compatibility inference, and the migration backfill
|
||||
// cannot drift apart.
|
||||
Traits: CanonicalTraitsForSlug("conventions"),
|
||||
Schema: models.CollectionSchema{
|
||||
Fields: []models.FieldDef{
|
||||
{
|
||||
@@ -488,6 +499,14 @@ func playbooksCollection(sortOrder int, triggerOptions, scopeOptions []string) D
|
||||
Icon: "\U0001F4D8",
|
||||
Description: "Multi-step workflows that agents follow for specific actions",
|
||||
SortOrder: sortOrder,
|
||||
// SPEC-5 §Collection traits. ONE metadata include with NO filter:
|
||||
// draft and deprecated playbooks are listed deliberately, so an agent
|
||||
// can see that a half-written playbook exists. The run gate refuses
|
||||
// non-active ones separately (BUG-2020) — listing is not permission.
|
||||
// invocation_field marks this collection as routing by invocation
|
||||
// slug; v1 constrains the value to the literal field name that the
|
||||
// partial unique indexes guard. Single definition, see above.
|
||||
Traits: CanonicalTraitsForSlug("playbooks"),
|
||||
Schema: models.CollectionSchema{
|
||||
Fields: []models.FieldDef{
|
||||
{
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package collections
|
||||
|
||||
import (
|
||||
"github.com/PerpetualSoftware/pad/internal/artifact"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// Bootstrap payload keys — the `key` half of a bootstrap_include declaration
|
||||
// (SPEC-5 §Collection traits). A key names the payload a declaration feeds, so
|
||||
// these are the canonical names of the FIRST-PARTY boot surfaces.
|
||||
//
|
||||
// They are the same three payloads the pre-trait bootstrap hardcoded, kept as
|
||||
// first-party views rather than replaced by a purely generic array: the
|
||||
// binding day-45 ruling was that the boot MECHANISM goes generic, which the
|
||||
// trait declarations deliver, and nothing in it required breaking the CLI,
|
||||
// skill, MCP, and web consumers that read these keys. Any collection may now
|
||||
// declare an include feeding any key, including a new one of its own.
|
||||
// Aliases of the grammar constants in models, kept so callers in this package
|
||||
// and its consumers read naturally. The canonical definitions live with the
|
||||
// trait grammar because validation must know them — see
|
||||
// models.firstPartyKeyModes.
|
||||
const (
|
||||
// BootstrapKeyConventions carries full bodies of the always-on rules.
|
||||
BootstrapKeyConventions = models.BootstrapKeyConventions
|
||||
// BootstrapKeyConventionIndex carries body-less metadata for every active
|
||||
// convention, so triggered rules are discoverable without their bodies.
|
||||
BootstrapKeyConventionIndex = models.BootstrapKeyConventionIndex
|
||||
// BootstrapKeyPlaybooks carries playbook metadata.
|
||||
BootstrapKeyPlaybooks = models.BootstrapKeyPlaybooks
|
||||
)
|
||||
|
||||
// TraitedCollection pairs a stored collection's identity with its parsed
|
||||
// kernel traits. Consumers resolve behavior by asking which collection
|
||||
// DECLARES a trait rather than by naming a slug.
|
||||
type TraitedCollection struct {
|
||||
ID string
|
||||
Slug string
|
||||
Traits models.CollectionTraits
|
||||
}
|
||||
|
||||
// FindByArtifactKind returns the collection declaring the given artifact kind.
|
||||
// Returns nil when no collection declares it — an unknown kind imports as a
|
||||
// plain item rather than failing (SPEC-5 §artifact_kind).
|
||||
//
|
||||
// When several collections declare the same kind the first in the supplied
|
||||
// order wins. Do NOT read that as a defined tie-break: ListTraitedCollections
|
||||
// orders by sort_order then created_at, and template-seeded collections share
|
||||
// both, so which one wins is effectively arbitrary. Two collections declaring
|
||||
// one kind is a misconfiguration, not a supported shape — SPEC-0 L6 wants it
|
||||
// rejected at install time, which is pack-installer territory (phase 3) and
|
||||
// not reachable from here. Until then this resolves to *a* collection rather
|
||||
// than failing, because refusing to export an item is worse than exporting it
|
||||
// under one of two identical declarations.
|
||||
func FindByArtifactKind(colls []TraitedCollection, kind string) *TraitedCollection {
|
||||
if kind == "" {
|
||||
return nil
|
||||
}
|
||||
for i := range colls {
|
||||
if colls[i].Traits.ArtifactKind != nil && colls[i].Traits.ArtifactKind.Kind == kind {
|
||||
return &colls[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindByInvocationField returns the collections that route by invocation slug
|
||||
// — those declaring the invocation_field trait, in the supplied order.
|
||||
func FindByInvocationField(colls []TraitedCollection) []TraitedCollection {
|
||||
var out []TraitedCollection
|
||||
for _, c := range colls {
|
||||
if c.Traits.InvocationField != "" {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FindBootstrapIncludes returns every (collection, declaration) pair feeding
|
||||
// the named bootstrap payload. More than one collection MAY feed the same key
|
||||
// — that is the mechanism by which a pack contributes to an existing boot
|
||||
// surface instead of shadowing it (SPEC-5's slots-coexist posture applied to
|
||||
// boot payloads).
|
||||
func FindBootstrapIncludes(colls []TraitedCollection, key string) []BootstrapSource {
|
||||
var out []BootstrapSource
|
||||
for _, c := range colls {
|
||||
if inc := c.Traits.BootstrapIncludeForKey(key); inc != nil {
|
||||
out = append(out, BootstrapSource{Collection: c, Include: *inc})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// BootstrapSource is one collection's declaration feeding one boot payload.
|
||||
type BootstrapSource struct {
|
||||
Collection TraitedCollection
|
||||
Include models.BootstrapInclude
|
||||
}
|
||||
|
||||
// TraitedFromCollections adapts stored collection models into the trait view.
|
||||
// Lets callers that already hold []models.Collection (CLI, in-process HTTP
|
||||
// dispatchers) reuse the same lookups as the store-side path without a second
|
||||
// round-trip. A collection whose traits blob doesn't parse contributes an
|
||||
// empty declaration set rather than failing the batch — same degradation rule
|
||||
// as Store.ListTraitedCollections.
|
||||
func TraitedFromCollections(colls []models.Collection) []TraitedCollection {
|
||||
out := make([]TraitedCollection, 0, len(colls))
|
||||
for _, c := range colls {
|
||||
traits, err := models.ParseCollectionTraits(c.Traits)
|
||||
if err != nil {
|
||||
traits = models.CollectionTraits{}
|
||||
}
|
||||
out = append(out, TraitedCollection{ID: c.ID, Slug: c.Slug, Traits: traits})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// SlugForArtifactKind returns the slug of the collection declaring the given
|
||||
// artifact kind, or "" when none does.
|
||||
func SlugForArtifactKind(colls []models.Collection, kind string) string {
|
||||
if c := FindByArtifactKind(TraitedFromCollections(colls), kind); c != nil {
|
||||
return c.Slug
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// CanonicalTraitsForSlug returns the kernel-trait declarations that a
|
||||
// collection with the given slug carries by default, or the zero value when
|
||||
// the slug has no canonical declarations.
|
||||
//
|
||||
// This is the ONE definition of "what conventions/playbooks declare", shared by
|
||||
// three surfaces that would otherwise drift: the template definitions (new
|
||||
// workspaces), the workspace-import compatibility inference (archives written
|
||||
// before traits existed), and — pinned by test rather than shared directly,
|
||||
// since it is SQL — the migration backfill for existing workspaces.
|
||||
//
|
||||
// Slug-keyed, and necessarily so: it is only ever consulted for rows that
|
||||
// carry NO declarations, where the slug is the only evidence of intent that
|
||||
// exists. Once a collection declares traits, the declaration is authoritative
|
||||
// and this is never asked. TASK-2657.
|
||||
func CanonicalTraitsForSlug(slug string) models.CollectionTraits {
|
||||
switch slug {
|
||||
case "conventions":
|
||||
return models.CollectionTraits{
|
||||
BootstrapInclude: []models.BootstrapInclude{
|
||||
{
|
||||
Mode: models.BootstrapModeBodies,
|
||||
Filter: map[string]string{"status": "active", "trigger": "always"},
|
||||
Key: BootstrapKeyConventions,
|
||||
},
|
||||
{
|
||||
Mode: models.BootstrapModeMetadata,
|
||||
Filter: map[string]string{"status": "active"},
|
||||
Key: BootstrapKeyConventionIndex,
|
||||
},
|
||||
},
|
||||
ArtifactKind: &models.ArtifactKindTrait{Kind: string(artifact.KindConvention)},
|
||||
}
|
||||
case "playbooks":
|
||||
return models.CollectionTraits{
|
||||
BootstrapInclude: []models.BootstrapInclude{
|
||||
{Mode: models.BootstrapModeMetadata, Key: BootstrapKeyPlaybooks},
|
||||
},
|
||||
InvocationField: models.InvocationSlugField,
|
||||
ArtifactKind: &models.ArtifactKindTrait{Kind: string(artifact.KindPlaybook)},
|
||||
}
|
||||
}
|
||||
return models.CollectionTraits{}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ var padLibraryTool = ToolDef{
|
||||
},
|
||||
}
|
||||
|
||||
const padLibraryToolDescription = `Convention + playbook library — global catalog of pre-built entries that workspaces activate into their own conventions/playbooks collections.
|
||||
const padLibraryToolDescription = `Convention + playbook library — global catalog of pre-built entries that workspaces activate into whichever of their collections declares the matching artifact kind (by default, conventions and playbooks).
|
||||
|
||||
Actions:
|
||||
list — Browse the library. By default playbooks come back as metadata + a short
|
||||
@@ -74,10 +74,12 @@ Actions:
|
||||
then playbooks — same precedence activate uses, so a title resolves to the
|
||||
same kind in both surfaces.
|
||||
Required: title.
|
||||
activate — Create a workspace item from a library entry by title. Conventions land
|
||||
in the conventions collection, playbooks land in the playbooks collection,
|
||||
with all fields (trigger, scope, surfaces, enforcement, invocation_slug,
|
||||
arguments) carried through from the library definition.
|
||||
activate — Create a workspace item from a library entry by title. The destination is
|
||||
whichever collection DECLARES that artifact kind — normally the workspace's
|
||||
conventions / playbooks collections, but it follows them if they were
|
||||
renamed rather than assuming the name. All fields (trigger, scope, surfaces,
|
||||
enforcement, invocation_slug, arguments) carry through from the library
|
||||
definition.
|
||||
Required: workspace, title.
|
||||
|
||||
The library itself is workspace-agnostic — list/get don't need workspace context. The
|
||||
|
||||
@@ -5,10 +5,12 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
|
||||
"github.com/mark3labs/mcp-go/mcp"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/artifact"
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
@@ -33,7 +35,8 @@ import (
|
||||
// - The CLI uses `models.BuildConventionItemFields` for
|
||||
// conventions (deals with surfaces/enforcement/commands metadata)
|
||||
// but builds the playbook fields by hand. We match exactly.
|
||||
// - The CLI's "conventions" / "playbooks" target collection slugs
|
||||
// - The target collection, resolved from each entry's artifact kind via the
|
||||
// collection that DECLARES it (SPEC-5), falling back to the canonical slug
|
||||
// are hardcoded; we do the same. Workspaces from non-software
|
||||
// templates may not have these collections, in which case the
|
||||
// POST will 404 — same UX the CLI delivers.
|
||||
@@ -65,7 +68,11 @@ func (d *HTTPHandlerDispatcher) dispatchLibraryActivate(
|
||||
if err != nil {
|
||||
return dispatcherErrorResult(cmdKey, "build convention fields", err), nil
|
||||
}
|
||||
return d.postLibraryItem(ctx, user, workspace, "conventions", cmdKey, conv.Title, conv.Content, fieldsJSON)
|
||||
target, terr := d.libraryTargetCollection(ctx, user, workspace, string(artifact.KindConvention), "conventions")
|
||||
if terr != nil {
|
||||
return dispatcherErrorResult(cmdKey, "resolve target collection", terr), nil
|
||||
}
|
||||
return d.postLibraryItem(ctx, user, workspace, target, cmdKey, conv.Title, conv.Content, fieldsJSON)
|
||||
}
|
||||
|
||||
if pb := collections.GetLibraryPlaybook(title); pb != nil {
|
||||
@@ -88,7 +95,11 @@ func (d *HTTPHandlerDispatcher) dispatchLibraryActivate(
|
||||
if err != nil {
|
||||
return dispatcherErrorResult(cmdKey, "encode playbook fields", err), nil
|
||||
}
|
||||
return d.postLibraryItem(ctx, user, workspace, "playbooks", cmdKey, pb.Title, pb.Content, string(fieldsJSON))
|
||||
target, terr := d.libraryTargetCollection(ctx, user, workspace, string(artifact.KindPlaybook), "playbooks")
|
||||
if terr != nil {
|
||||
return dispatcherErrorResult(cmdKey, "resolve target collection", terr), nil
|
||||
}
|
||||
return d.postLibraryItem(ctx, user, workspace, target, cmdKey, pb.Title, pb.Content, string(fieldsJSON))
|
||||
}
|
||||
|
||||
return NewErrorResult(ErrorPayload{
|
||||
@@ -102,6 +113,44 @@ func (d *HTTPHandlerDispatcher) dispatchLibraryActivate(
|
||||
// collection's items endpoint. Shared between conventions /
|
||||
// playbooks branches of dispatchLibraryActivate so the URL +
|
||||
// envelope shape stays in lockstep.
|
||||
|
||||
// libraryTargetCollection resolves where a library entry of the given artifact
|
||||
// kind should be activated: whichever collection DECLARES that kind (SPEC-5
|
||||
// artifact_kind), not the one that happens to be named "conventions" or
|
||||
// "playbooks". Activating into a workspace whose collection was renamed used
|
||||
// to fail not-found with the collection sitting right there ([[BUG-2702]]).
|
||||
//
|
||||
// A LOOKUP FAILURE IS NOT A FALLBACK CASE. Falling back on an error means
|
||||
// writing to a slug we never confirmed anything about, and a workspace may
|
||||
// legitimately have an ordinary collection sitting on the canonical slug, so
|
||||
// the guess can land a library entry somewhere unrelated. Errors are returned;
|
||||
// the fallback applies ONLY when the read SUCCEEDED and no collection declares
|
||||
// the kind, which is the genuine pre-backfill case. Codex round 5.
|
||||
func (d *HTTPHandlerDispatcher) libraryTargetCollection(
|
||||
ctx context.Context,
|
||||
user *models.User,
|
||||
workspace, kind, fallback string,
|
||||
) (string, error) {
|
||||
req, err := d.buildAuthedRequest(ctx, http.MethodGet,
|
||||
"/api/v1/workspaces/"+url.PathEscape(workspace)+"/collections", nil, user)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve target collection: %w", err)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
d.Handler.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusOK {
|
||||
return "", fmt.Errorf("resolve target collection: listing collections returned %d", rec.Code)
|
||||
}
|
||||
var colls []models.Collection
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &colls); err != nil {
|
||||
return "", fmt.Errorf("resolve target collection: decode collections: %w", err)
|
||||
}
|
||||
if slug := collections.SlugForArtifactKind(colls, kind); slug != "" {
|
||||
return slug, nil
|
||||
}
|
||||
return fallback, nil
|
||||
}
|
||||
|
||||
func (d *HTTPHandlerDispatcher) postLibraryItem(
|
||||
ctx context.Context,
|
||||
user *models.User,
|
||||
|
||||
@@ -11,6 +11,49 @@ import (
|
||||
|
||||
// --- library activate ---
|
||||
|
||||
// Destination collection slugs used by the activate tests. They are
|
||||
// deliberately NOT "conventions"/"playbooks": with the canonical names, an
|
||||
// implementation that ignored traits entirely and always posted to the
|
||||
// hardcoded fallback would pass every assertion, so the test would prove
|
||||
// nothing about the behaviour it exists to cover (CONVE-12 — an end state two
|
||||
// mechanisms both produce is not evidence). Codex round 6 caught exactly that
|
||||
// in the first version of these tests.
|
||||
const (
|
||||
renamedConventionsSlug = "house-rules"
|
||||
renamedPlaybooksSlug = "procedures"
|
||||
)
|
||||
|
||||
// serveCollectionsFor registers the collections listing the activate path
|
||||
// consults to resolve its destination from declared artifact kinds
|
||||
// (TASK-2657). The real server always serves this endpoint; a fake that omits
|
||||
// it makes activation look like a lookup failure, which the dispatcher
|
||||
// deliberately refuses rather than falling back on.
|
||||
//
|
||||
// The listing reports RENAMED collections carrying the canonical declarations,
|
||||
// which is the whole point: activation must follow the declaration, not the
|
||||
// name.
|
||||
func serveCollectionsFor(mux *http.ServeMux, workspace string) {
|
||||
mux.HandleFunc("/api/v1/workspaces/"+workspace+"/collections", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`[
|
||||
{"id":"c1","slug":"` + renamedConventionsSlug + `","traits":"{\"artifact_kind\":{\"kind\":\"convention\"}}"},
|
||||
{"id":"c2","slug":"` + renamedPlaybooksSlug + `","traits":"{\"artifact_kind\":{\"kind\":\"playbook\"}}"}
|
||||
]`))
|
||||
})
|
||||
}
|
||||
|
||||
// trapCanonicalSlugs fails the test if activation posts to the hardcoded
|
||||
// fallback slugs. Without this, a regression to slug-hardcoding would surface
|
||||
// only as a missing POST rather than as a named failure.
|
||||
func trapCanonicalSlugs(t *testing.T, mux *http.ServeMux, workspace string) {
|
||||
t.Helper()
|
||||
for _, slug := range []string{"conventions", "playbooks"} {
|
||||
mux.HandleFunc("/api/v1/workspaces/"+workspace+"/collections/"+slug+"/items", func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Errorf("activation posted to the canonical %q collection; it must follow the artifact_kind declaration to the renamed collection", slug)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDispatch_LibraryActivate_ConventionByTitle(t *testing.T) {
|
||||
// Pick a known convention from the seed library — tied to the
|
||||
// convention_library.go constants.
|
||||
@@ -18,7 +61,7 @@ func TestDispatch_LibraryActivate_ConventionByTitle(t *testing.T) {
|
||||
|
||||
mux := http.NewServeMux()
|
||||
posted := ""
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/conventions/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/"+renamedConventionsSlug+"/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
t.Errorf("expected POST; got %s", r.Method)
|
||||
}
|
||||
@@ -30,10 +73,12 @@ func TestDispatch_LibraryActivate_ConventionByTitle(t *testing.T) {
|
||||
_, _ = w.Write([]byte(`{"id":"item-1","title":"Conventional commit format"}`))
|
||||
})
|
||||
// Playbook endpoint MUST NOT be hit (we found a convention).
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/playbooks/items", func(_ http.ResponseWriter, _ *http.Request) {
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/"+renamedPlaybooksSlug+"/items", func(_ http.ResponseWriter, _ *http.Request) {
|
||||
t.Errorf("playbook endpoint should not be hit when convention matches")
|
||||
})
|
||||
|
||||
serveCollectionsFor(mux, "docapp")
|
||||
trapCanonicalSlugs(t, mux, "docapp")
|
||||
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
||||
res, err := d.Dispatch(
|
||||
WithDispatchInput(context.Background(), map[string]any{
|
||||
@@ -82,7 +127,7 @@ func TestDispatch_LibraryActivate_PlaybookByTitle_FallsThroughConventionLookup(t
|
||||
|
||||
mux := http.NewServeMux()
|
||||
posted := ""
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/playbooks/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
mux.HandleFunc("/api/v1/workspaces/docapp/collections/"+renamedPlaybooksSlug+"/items", func(w http.ResponseWriter, r *http.Request) {
|
||||
buf := make([]byte, r.ContentLength)
|
||||
_, _ = r.Body.Read(buf)
|
||||
posted = string(buf)
|
||||
@@ -91,6 +136,8 @@ func TestDispatch_LibraryActivate_PlaybookByTitle_FallsThroughConventionLookup(t
|
||||
_, _ = w.Write([]byte(`{"id":"pb-1","title":"Ship tasks"}`))
|
||||
})
|
||||
|
||||
serveCollectionsFor(mux, "docapp")
|
||||
trapCanonicalSlugs(t, mux, "docapp")
|
||||
d := &HTTPHandlerDispatcher{Handler: mux, UserResolver: fixedUserResolver(&models.User{ID: "u"})}
|
||||
res, err := d.Dispatch(
|
||||
WithDispatchInput(context.Background(), map[string]any{
|
||||
|
||||
@@ -6,7 +6,7 @@ Pad is a project tracker for developers and AI agents — issues (TASK, BUG), pl
|
||||
|
||||
If the user is asking general code questions with no project-management thread, you don't need this server.
|
||||
|
||||
## Tool surface (v0.24)
|
||||
## Tool surface (v0.25)
|
||||
|
||||
Ten resource × action tools, plus `pad_set_workspace` (which takes a `workspace` slug only — no action enum). Eleven tools total.
|
||||
|
||||
@@ -81,6 +81,8 @@ pad_item.action: list, collection: "conventions", status: "active"
|
||||
|
||||
Filter by trigger (`always`, `on-implement`, `on-task-complete`, etc.) when relevant — the `convention_index` triggers tell you which filters are worth running.
|
||||
|
||||
`"conventions"` above is the DEFAULT collection slug and a workspace may have renamed it, in which case that literal returns nothing while `convention_index` still lists entries — the bootstrap payload resolves by declaration, not by name. When the two disagree, address the items by the `ref`s the index gave you, or find the collection with `pad_collection.action: list`. The same applies to `"playbooks"`; `pad_playbook` resolves by declaration and is unaffected.
|
||||
|
||||
## Adding a workspace to this connection
|
||||
|
||||
If the user references a workspace this connection can't see (you'll get a 403 from workspace tools, or the workspace won't appear in `pad_workspace.list`), tell the user you can't see that workspace with your current permissions, then walk them through how to grant access: open Pad in their browser → switch to that workspace → avatar menu → "Connect project..." A 6-digit claim code will appear. Have them paste it back in chat, then call `pad_workspace.claim` with `{workspace: "<slug>", code: "<6 digits>"}`. The workspace joins this connection's allow-list and stays until the user revokes it via `/console/connected-apps`. No re-auth required.
|
||||
|
||||
+28
-2
@@ -620,7 +620,33 @@ const CmdhelpVersion = "0.1"
|
||||
// condition would stop matching. That client was retrying a
|
||||
// permanent failure.
|
||||
|
||||
// - "0.24" — current. #1066: the pad_item `fields` OBJECT is now a
|
||||
// - "0.25" — current. TASK-2657: `pad_library.activate` resolves its
|
||||
// DESTINATION collection from the target's declared artifact kind
|
||||
// (SPEC-5 collection traits) rather than from the literal slugs
|
||||
// "conventions" / "playbooks".
|
||||
//
|
||||
// BEHAVIOR bump, not a shape one — no tool name, action enum, or
|
||||
// parameter changed. Same grounds as v0.9 (list return shape) and
|
||||
// v0.16 (empty-string clear semantics): what the tool DOES changed
|
||||
// while its signature did not, and a consumer reasoning about where
|
||||
// an activation lands needs to know.
|
||||
//
|
||||
// Before: activation posted to the literal slug, so a workspace that
|
||||
// had renamed either collection got a not-found with the collection
|
||||
// sitting right there (BUG-2702). After: it posts to whichever
|
||||
// collection declares that artifact kind, falling back to the
|
||||
// canonical slug ONLY when the lookup SUCCEEDS and finds no
|
||||
// declaration — the genuine pre-backfill case. A lookup ERROR is now
|
||||
// surfaced rather than silently falling back, because falling back on
|
||||
// an error means writing to a slug nothing was confirmed about.
|
||||
//
|
||||
// Also in this change, not itself a surface bump: `pad_item.list`
|
||||
// against a renamed conventions/playbooks collection still needs the
|
||||
// current slug — the trait moves the KERNEL behaviors, not the
|
||||
// addressing of an explicit collection query. instructions.md says so
|
||||
// now, at the point an agent would otherwise trust the literal.
|
||||
//
|
||||
// - "0.24" — #1066: the pad_item `fields` OBJECT is now a
|
||||
// real write form, and undeclared input keys fail loudly. Two
|
||||
// halves, one contract change:
|
||||
//
|
||||
@@ -652,7 +678,7 @@ const CmdhelpVersion = "0.1"
|
||||
// That reliance was indistinguishable from a bug in the caller
|
||||
// (the key never did anything), so the break is the fix. Single
|
||||
// bump covers both halves; they are one contract change.
|
||||
const ToolSurfaceVersion = "0.24"
|
||||
const ToolSurfaceVersion = "0.25"
|
||||
|
||||
// MetaVersionURI is the canonical URI of the queryable version document.
|
||||
// Lives outside the pad://workspace/{ws}/... namespace because it's a
|
||||
|
||||
@@ -44,21 +44,28 @@ type CollectionSettings struct {
|
||||
}
|
||||
|
||||
type Collection struct {
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Schema string `json:"schema"` // JSON string in DB, parsed via methods
|
||||
Settings string `json:"settings"` // JSON string in DB
|
||||
Prefix string `json:"prefix"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
ID string `json:"id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Icon string `json:"icon"`
|
||||
Description string `json:"description"`
|
||||
Schema string `json:"schema"` // JSON string in DB, parsed via methods
|
||||
Settings string `json:"settings"` // JSON string in DB
|
||||
// Traits is the collection's kernel-trait declaration set (SPEC-5), a
|
||||
// JSON string in the DB parsed via ParseCollectionTraits. Its own column
|
||||
// rather than a key inside Schema on purpose: Schema is overwritten
|
||||
// wholesale on update and every client rebuilds it fields-only, so a
|
||||
// traits key stored there is destroyed by any ordinary collection edit.
|
||||
// See collection_traits.go for the full rationale. TASK-2657.
|
||||
Traits string `json:"traits"`
|
||||
Prefix string `json:"prefix"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
|
||||
// Computed (not stored)
|
||||
ItemCount int `json:"item_count"`
|
||||
@@ -73,6 +80,7 @@ type CollectionCreate struct {
|
||||
Description string `json:"description,omitempty"`
|
||||
Schema string `json:"schema,omitempty"`
|
||||
Settings string `json:"settings,omitempty"`
|
||||
Traits string `json:"traits,omitempty"`
|
||||
IsDefault bool `json:"is_default,omitempty"`
|
||||
IsSystem bool `json:"is_system,omitempty"`
|
||||
}
|
||||
@@ -85,14 +93,19 @@ type FieldMigration struct {
|
||||
}
|
||||
|
||||
type CollectionUpdate struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Prefix *string `json:"prefix,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Schema *string `json:"schema,omitempty"`
|
||||
Settings *string `json:"settings,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
Migrations []FieldMigration `json:"migrations,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Prefix *string `json:"prefix,omitempty"`
|
||||
Icon *string `json:"icon,omitempty"`
|
||||
Description *string `json:"description,omitempty"`
|
||||
Schema *string `json:"schema,omitempty"`
|
||||
Settings *string `json:"settings,omitempty"`
|
||||
// Traits, when non-nil, replaces the collection's kernel-trait
|
||||
// declarations. Nil leaves them untouched — which is the case for every
|
||||
// client that rebuilds a collection's schema/settings blobs without
|
||||
// knowing traits exist, and is why traits survive an ordinary edit.
|
||||
Traits *string `json:"traits,omitempty"`
|
||||
SortOrder *int `json:"sort_order,omitempty"`
|
||||
Migrations []FieldMigration `json:"migrations,omitempty"`
|
||||
// ExpectedUpdatedAt, when non-empty, opts into optimistic-concurrency
|
||||
// control (BUG-2265, mirroring the item pattern from IDEA-1480): the
|
||||
// store re-reads the row's updated_at under the workspace write lock and
|
||||
@@ -121,6 +134,7 @@ func (c *CollectionCreate) UnmarshalJSON(data []byte) error {
|
||||
type alias CollectionCreate
|
||||
aux := struct {
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Traits json.RawMessage `json:"traits,omitempty"`
|
||||
*alias
|
||||
}{alias: (*alias)(c)}
|
||||
|
||||
@@ -134,6 +148,15 @@ func (c *CollectionCreate) UnmarshalJSON(data []byte) error {
|
||||
c.Settings = *settingsStr
|
||||
}
|
||||
|
||||
// `traits` gets the same both-shapes treatment as `settings`: the field
|
||||
// is a JSON string in storage, but any reasonable client sends a nested
|
||||
// object. TASK-2657.
|
||||
if traitsStr, err := flexJSONToString(aux.Traits, '{', ErrInvalidTraitsType); err != nil {
|
||||
return err
|
||||
} else if traitsStr != nil {
|
||||
c.Traits = *traitsStr
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -149,6 +172,7 @@ func (u *CollectionUpdate) UnmarshalJSON(data []byte) error {
|
||||
type alias CollectionUpdate
|
||||
aux := struct {
|
||||
Settings json.RawMessage `json:"settings,omitempty"`
|
||||
Traits json.RawMessage `json:"traits,omitempty"`
|
||||
*alias
|
||||
}{alias: (*alias)(u)}
|
||||
|
||||
@@ -162,5 +186,14 @@ func (u *CollectionUpdate) UnmarshalJSON(data []byte) error {
|
||||
}
|
||||
u.Settings = settingsStr
|
||||
|
||||
// Absent `traits` stays nil, which UpdateCollection reads as "leave the
|
||||
// declarations alone" — the case for every client that predates traits.
|
||||
// TASK-2657.
|
||||
traitsStr, err := flexJSONToString(aux.Traits, '{', ErrInvalidTraitsType)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Traits = traitsStr
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Collection traits — SPEC-5 §Collection traits (approved v1.1, PLAN-2656
|
||||
// phase 0 / TASK-2657).
|
||||
//
|
||||
// A trait is a DECLARATION that a collection carries kernel behavior. Before
|
||||
// traits, three kernel behaviors were keyed on the literal collection slugs
|
||||
// "conventions" and "playbooks": what the agent bootstrap loads, which items
|
||||
// route by invocation slug, and which items export as portable artifacts. A
|
||||
// slug is not a stable identifier — Store.UpdateCollection re-slugs on any
|
||||
// name change — so those behaviors silently detached from a renamed
|
||||
// collection ([[BUG-2702]]). A trait travels with the collection.
|
||||
//
|
||||
// WHY TRAITS ARE THEIR OWN COLUMN and not a key inside the schema JSON:
|
||||
// the schema column is overwritten wholesale on update, and every client
|
||||
// rebuilds it fields-only (EditCollectionModal.svelte, the webmcp dispatcher,
|
||||
// QuickActionsMenu). A traits key stored there is destroyed by any ordinary
|
||||
// collection edit — measured, not assumed, during TASK-2657. Trait authority
|
||||
// cannot rest on a value that an unrelated UI save deletes.
|
||||
//
|
||||
// The kernel trait set is CLOSED and versioned (SPEC-0: adding one is a kernel
|
||||
// change). Packs carry kernel traits; they cannot invent them. Open-vocabulary
|
||||
// LABEL traits (`publisher/name`) are a separate SPEC-5 tier with zero kernel
|
||||
// semantics and are not implemented in phase 0.
|
||||
|
||||
// BootstrapIncludeMode enumerates how a bootstrap_include declaration
|
||||
// projects its items. Closed set — see SPEC-5 §Collection traits.
|
||||
const (
|
||||
// BootstrapModeBodies ships full item content. Expensive per item; the
|
||||
// L4 boot budget is the host-imposed limit.
|
||||
BootstrapModeBodies = "bodies"
|
||||
// BootstrapModeMetadata ships item metadata with content omitted.
|
||||
BootstrapModeMetadata = "metadata"
|
||||
)
|
||||
|
||||
// First-party bootstrap payload keys. These name the three boot surfaces that
|
||||
// existed before traits and that agents already consume, so each has a FIXED
|
||||
// projection shape in the server — bodies for the always-on rules, metadata for
|
||||
// the index and for playbooks.
|
||||
//
|
||||
// They live here, in the grammar, rather than only in the server, because
|
||||
// validation has to know them: a declaration is free to feed any key, but
|
||||
// feeding one of THESE with the wrong mode is a contradiction the projection
|
||||
// cannot honour. Declaring `{mode: metadata, key: conventions}` would still
|
||||
// emit bodies, because the conventions projection always does. Rather than
|
||||
// letting a declaration mean something different from what it says, that
|
||||
// combination is refused. Codex round 7.
|
||||
const (
|
||||
BootstrapKeyConventions = "conventions"
|
||||
BootstrapKeyConventionIndex = "convention_index"
|
||||
BootstrapKeyPlaybooks = "playbooks"
|
||||
)
|
||||
|
||||
// firstPartyKeyModes pins each first-party key to the only mode its projection
|
||||
// implements. A key absent from this map is an ordinary generic payload and may
|
||||
// declare either mode.
|
||||
var firstPartyKeyModes = map[string]string{
|
||||
BootstrapKeyConventions: BootstrapModeBodies,
|
||||
BootstrapKeyConventionIndex: BootstrapModeMetadata,
|
||||
BootstrapKeyPlaybooks: BootstrapModeMetadata,
|
||||
}
|
||||
|
||||
// InvocationSlugField is the ONLY field name invocation_field may reference
|
||||
// in v1 (SPEC-5 v1.1 amendment 4).
|
||||
//
|
||||
// The trait selects WHICH collections route by invocation; it does not yet
|
||||
// rename the field. Uniqueness has two guards and only one is generic: the
|
||||
// application pre-check (checkUniqueFields on FieldDef.UniqueScope) is
|
||||
// field-name-agnostic, but the actual race guard is a partial unique index on
|
||||
// this literal name in both dialects (migrations/054, pgmigrations/033). A
|
||||
// trait naming any other field would drop out of index coverage and degrade to
|
||||
// the TOCTOU pre-check that 054's own comment calls insufficient. Phase 1 may
|
||||
// widen this — deliberately, with the index question answered first.
|
||||
const InvocationSlugField = "invocation_slug"
|
||||
|
||||
// BootstrapInclude is one bootstrap_include declaration: a set of this
|
||||
// collection's items that surfaces in the agent bootstrap payload.
|
||||
//
|
||||
// A collection may declare SEVERAL. The conventions collection declares two —
|
||||
// full bodies of the always-on rules, plus a body-less index of every active
|
||||
// rule so triggered conventions are discoverable without flooding the payload.
|
||||
// SPEC-5 v1.0 allowed only one declaration and could not express that at all.
|
||||
type BootstrapInclude struct {
|
||||
// Mode is bodies or metadata.
|
||||
Mode string `json:"mode"`
|
||||
// Filter selects which items participate, as a field-equality map.
|
||||
//
|
||||
// v1 is equality-only, NOT the SPEC-2 query/1 where-fragment the v1.0
|
||||
// spec named: query/1 is phase 1 and unbuilt, and PLAN-2656 forbids
|
||||
// growing phase 0 toward it. An equality map is a strict subset of any
|
||||
// future where-fragment, so query/1 is a widening path rather than a
|
||||
// rewrite — and it is already the shape ItemListParams.Fields consumes.
|
||||
//
|
||||
// An empty or nil filter means every item in the collection. That is
|
||||
// deliberate for playbooks, which list draft and deprecated entries so
|
||||
// an agent can see a half-written playbook exists.
|
||||
Filter map[string]string `json:"filter,omitempty"`
|
||||
// Key names the bootstrap payload this declaration feeds.
|
||||
Key string `json:"key"`
|
||||
}
|
||||
|
||||
// ArtifactKindTrait declares the portable artifact kind items of this
|
||||
// collection export as. Import maps the kind back to whichever local
|
||||
// collection declares it; an unknown kind imports as a plain item.
|
||||
type ArtifactKindTrait struct {
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// CollectionTraits is the closed kernel-trait set a collection may declare.
|
||||
// The zero value declares nothing, which is correct for ordinary collections:
|
||||
// traits are opt-in and absence is never an error.
|
||||
type CollectionTraits struct {
|
||||
BootstrapInclude []BootstrapInclude `json:"bootstrap_include,omitempty"`
|
||||
InvocationField string `json:"invocation_field,omitempty"`
|
||||
ArtifactKind *ArtifactKindTrait `json:"artifact_kind,omitempty"`
|
||||
}
|
||||
|
||||
// IsZero reports whether the collection declares no kernel traits at all.
|
||||
func (t CollectionTraits) IsZero() bool {
|
||||
return len(t.BootstrapInclude) == 0 && t.InvocationField == "" && t.ArtifactKind == nil
|
||||
}
|
||||
|
||||
// BootstrapIncludeForKey returns the declaration feeding the named bootstrap
|
||||
// payload, or nil when this collection feeds no such payload.
|
||||
func (t CollectionTraits) BootstrapIncludeForKey(key string) *BootstrapInclude {
|
||||
for i := range t.BootstrapInclude {
|
||||
if t.BootstrapInclude[i].Key == key {
|
||||
return &t.BootstrapInclude[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrInvalidTraitsType is returned when an inbound `traits` value is neither a
|
||||
// JSON object nor a JSON-encoded string. Mirrors ErrInvalidSettingsType.
|
||||
var ErrInvalidTraitsType = errors.New(`"traits" must be a JSON object or a JSON-encoded string`)
|
||||
|
||||
// isStoreSafeFieldKey mirrors the item store's field-key sanitizer
|
||||
// (store.isValidFieldKey). Duplicated rather than imported because models must
|
||||
// not depend on store; the two are pinned together by
|
||||
// TestFilterKeyRuleMatchesStoreSanitizer.
|
||||
func isStoreSafeFieldKey(key string) bool {
|
||||
if key == "" {
|
||||
return false
|
||||
}
|
||||
for _, c := range key {
|
||||
if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// bootstrapKeyPattern constrains a bootstrap payload key to a conservative
|
||||
// identifier shape. The key becomes a JSON object key in the bootstrap
|
||||
// response, so it must be predictable for agents reading the payload.
|
||||
var bootstrapKeyPattern = regexp.MustCompile(`^[a-z][a-z0-9_]*$`)
|
||||
|
||||
// ParseCollectionTraits decodes a stored traits JSON blob. An empty or "null"
|
||||
// blob yields the zero value with no error — a collection that declares no
|
||||
// traits is the common case, not a failure.
|
||||
//
|
||||
// Parsing is STRICT about shape (unknown fields are rejected) so a typo in a
|
||||
// pack manifest surfaces at declaration time rather than as a kernel behavior
|
||||
// that silently never fires. That is SPEC-0 L6's fail-loud posture applied to
|
||||
// the trait grammar.
|
||||
func ParseCollectionTraits(raw string) (CollectionTraits, error) {
|
||||
var t CollectionTraits
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "null" || trimmed == "{}" {
|
||||
return t, nil
|
||||
}
|
||||
dec := json.NewDecoder(strings.NewReader(trimmed))
|
||||
dec.DisallowUnknownFields()
|
||||
if err := dec.Decode(&t); err != nil {
|
||||
return CollectionTraits{}, fmt.Errorf("parse collection traits: %w", err)
|
||||
}
|
||||
// Decode stops at the end of the FIRST JSON value and ignores whatever
|
||||
// follows, so `{"artifact_kind":{"kind":"playbook"}} garbage` would parse
|
||||
// cleanly and activate behavior from a blob that is not valid JSON. That
|
||||
// makes "strict" a false claim, which is worse than being lenient on
|
||||
// purpose. Reject trailing content. Codex round 7.
|
||||
if _, err := dec.Token(); err != io.EOF {
|
||||
return CollectionTraits{}, fmt.Errorf("parse collection traits: unexpected trailing content after the JSON object")
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
// Validate enforces the kernel trait grammar. Returns a nil error for the zero
|
||||
// value: declaring no traits is always legal.
|
||||
func (t CollectionTraits) Validate() error {
|
||||
seenKeys := make(map[string]bool, len(t.BootstrapInclude))
|
||||
for i, inc := range t.BootstrapInclude {
|
||||
switch inc.Mode {
|
||||
case BootstrapModeBodies, BootstrapModeMetadata:
|
||||
case "":
|
||||
return fmt.Errorf("bootstrap_include[%d]: mode is required (%q or %q)", i, BootstrapModeBodies, BootstrapModeMetadata)
|
||||
default:
|
||||
return fmt.Errorf("bootstrap_include[%d]: unknown mode %q (want %q or %q)", i, inc.Mode, BootstrapModeBodies, BootstrapModeMetadata)
|
||||
}
|
||||
if inc.Key == "" {
|
||||
return fmt.Errorf("bootstrap_include[%d]: key is required", i)
|
||||
}
|
||||
if !bootstrapKeyPattern.MatchString(inc.Key) {
|
||||
return fmt.Errorf("bootstrap_include[%d]: key %q must match %s", i, inc.Key, bootstrapKeyPattern)
|
||||
}
|
||||
// Two declarations feeding the same payload from one collection is
|
||||
// always a mistake — the second would silently overwrite or
|
||||
// double-append depending on assembly order. Fail loud (SPEC-0 L6).
|
||||
if seenKeys[inc.Key] {
|
||||
return fmt.Errorf("bootstrap_include[%d]: duplicate key %q in the same collection", i, inc.Key)
|
||||
}
|
||||
seenKeys[inc.Key] = true
|
||||
// A first-party key's projection shape is fixed, so a declaration
|
||||
// naming one with the other mode would be quietly ignored — the
|
||||
// payload would come out in the projection's shape regardless. Refuse
|
||||
// rather than accept a declaration that does not mean what it says.
|
||||
if want, ok := firstPartyKeyModes[inc.Key]; ok && inc.Mode != want {
|
||||
return fmt.Errorf("bootstrap_include[%d]: key %q is a first-party boot payload with a fixed %q projection and cannot be declared %q", i, inc.Key, want, inc.Mode)
|
||||
}
|
||||
for k, v := range inc.Filter {
|
||||
if strings.TrimSpace(k) == "" {
|
||||
return fmt.Errorf("bootstrap_include[%d]: filter has an empty field name (value %q)", i, v)
|
||||
}
|
||||
// The key must match the shape the item store will accept, and
|
||||
// this check FAILS OPEN if it is missing, which is why it is here
|
||||
// rather than left to the query layer: ListItems sanitizes field
|
||||
// keys and silently `continue`s past any it rejects, dropping that
|
||||
// predicate from the WHERE clause entirely. A declaration filtering
|
||||
// on `"stat us"` would therefore not narrow anything — the
|
||||
// conventions bodies include would ship EVERY convention, drafts
|
||||
// included, to every agent at boot. Refuse the declaration instead.
|
||||
//
|
||||
// (A well-shaped key naming a field that doesn't exist is a
|
||||
// different and safe case: the predicate is applied and matches
|
||||
// nothing, so the payload is empty rather than unfiltered. Not
|
||||
// rejected here — a collection may legitimately declare a filter
|
||||
// on a field its items don't all carry.)
|
||||
if !isStoreSafeFieldKey(k) {
|
||||
return fmt.Errorf("bootstrap_include[%d]: filter key %q must be alphanumeric with underscores or hyphens; the item store drops keys it cannot sanitize, which would silently apply NO filter", i, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if t.InvocationField != "" && t.InvocationField != InvocationSlugField {
|
||||
return fmt.Errorf("invocation_field: v1 supports only %q, got %q (SPEC-5 v1.1 amendment 4: any other field falls outside the partial unique indexes that guard invocation-slug uniqueness)", InvocationSlugField, t.InvocationField)
|
||||
}
|
||||
|
||||
if t.ArtifactKind != nil && strings.TrimSpace(t.ArtifactKind.Kind) == "" {
|
||||
return fmt.Errorf("artifact_kind: kind is required when the trait is declared")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// JSON serializes traits for storage. The zero value serializes to "{}" so the
|
||||
// column is never NULL and never an empty string.
|
||||
func (t CollectionTraits) JSON() (string, error) {
|
||||
b, err := json.Marshal(t)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal collection traits: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestParseCollectionTraitsEmptyForms locks the "declares nothing" cases. A
|
||||
// collection with no traits is the common case, so none of these may error —
|
||||
// an error here would fail bootstrap for every ordinary collection.
|
||||
func TestParseCollectionTraitsEmptyForms(t *testing.T) {
|
||||
for _, raw := range []string{"", " ", "null", "{}"} {
|
||||
got, err := ParseCollectionTraits(raw)
|
||||
if err != nil {
|
||||
t.Errorf("ParseCollectionTraits(%q) errored: %v", raw, err)
|
||||
}
|
||||
if !got.IsZero() {
|
||||
t.Errorf("ParseCollectionTraits(%q) = %+v, want zero", raw, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCollectionTraitsRejectsUnknownFields is the fail-loud half
|
||||
// (SPEC-0 L6). A typo in a declaration must surface AS a parse failure rather
|
||||
// than as a kernel behavior that silently never fires — the latter is the
|
||||
// exact failure mode traits exist to eliminate.
|
||||
func TestParseCollectionTraitsRejectsUnknownFields(t *testing.T) {
|
||||
_, err := ParseCollectionTraits(`{"bootstrap_includes":[{"mode":"bodies","key":"conventions"}]}`)
|
||||
if err == nil {
|
||||
t.Fatal("misspelled trait key parsed cleanly; a typo would silently disable the behavior")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "bootstrap_includes") {
|
||||
t.Errorf("error should name the offending key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionTraitsValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
traits CollectionTraits
|
||||
wantErr string // substring; empty means must validate
|
||||
}{
|
||||
{
|
||||
name: "zero value declares nothing",
|
||||
traits: CollectionTraits{},
|
||||
},
|
||||
{
|
||||
name: "the conventions declaration",
|
||||
traits: CollectionTraits{
|
||||
BootstrapInclude: []BootstrapInclude{
|
||||
{Mode: BootstrapModeBodies, Filter: map[string]string{"status": "active", "trigger": "always"}, Key: "conventions"},
|
||||
{Mode: BootstrapModeMetadata, Filter: map[string]string{"status": "active"}, Key: "convention_index"},
|
||||
},
|
||||
ArtifactKind: &ArtifactKindTrait{Kind: "convention"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "the playbooks declaration",
|
||||
traits: CollectionTraits{
|
||||
BootstrapInclude: []BootstrapInclude{{Mode: BootstrapModeMetadata, Key: "playbooks"}},
|
||||
InvocationField: InvocationSlugField,
|
||||
ArtifactKind: &ArtifactKindTrait{Kind: "playbook"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "unknown mode",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{{Mode: "summaries", Key: "x"}}},
|
||||
wantErr: "unknown mode",
|
||||
},
|
||||
{
|
||||
name: "missing mode",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{{Key: "x"}}},
|
||||
wantErr: "mode is required",
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{{Mode: BootstrapModeBodies}}},
|
||||
wantErr: "key is required",
|
||||
},
|
||||
{
|
||||
name: "key with illegal shape",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{{Mode: BootstrapModeBodies, Key: "My Key"}}},
|
||||
wantErr: "must match",
|
||||
},
|
||||
{
|
||||
// Two declarations feeding one payload from one collection is
|
||||
// always a mistake: assembly order would decide which wins.
|
||||
name: "duplicate key within a collection",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{
|
||||
{Mode: BootstrapModeBodies, Key: "conventions"},
|
||||
{Mode: BootstrapModeMetadata, Key: "conventions"},
|
||||
}},
|
||||
wantErr: "duplicate key",
|
||||
},
|
||||
{
|
||||
name: "empty filter field name",
|
||||
traits: CollectionTraits{BootstrapInclude: []BootstrapInclude{{Mode: BootstrapModeBodies, Key: "x", Filter: map[string]string{"": "active"}}}},
|
||||
wantErr: "empty field name",
|
||||
},
|
||||
{
|
||||
// SPEC-5 v1.1 amendment 4: any other field falls outside the
|
||||
// partial unique indexes that guard invocation-slug uniqueness.
|
||||
name: "invocation_field naming another field",
|
||||
traits: CollectionTraits{InvocationField: "route_slug"},
|
||||
wantErr: "v1 supports only",
|
||||
},
|
||||
{
|
||||
name: "artifact_kind with no kind",
|
||||
traits: CollectionTraits{ArtifactKind: &ArtifactKindTrait{Kind: " "}},
|
||||
wantErr: "kind is required",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := tc.traits.Validate()
|
||||
if tc.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("Validate() = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil {
|
||||
t.Fatalf("Validate() = nil, want error containing %q", tc.wantErr)
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.wantErr) {
|
||||
t.Errorf("Validate() = %q, want it to contain %q", err, tc.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBootstrapIncludeForKey covers the lookup every consumer routes through.
|
||||
func TestBootstrapIncludeForKey(t *testing.T) {
|
||||
traits := CollectionTraits{BootstrapInclude: []BootstrapInclude{
|
||||
{Mode: BootstrapModeBodies, Key: "conventions", Filter: map[string]string{"trigger": "always"}},
|
||||
{Mode: BootstrapModeMetadata, Key: "convention_index"},
|
||||
}}
|
||||
|
||||
got := traits.BootstrapIncludeForKey("conventions")
|
||||
if got == nil {
|
||||
t.Fatal("BootstrapIncludeForKey(conventions) = nil")
|
||||
}
|
||||
if got.Mode != BootstrapModeBodies || got.Filter["trigger"] != "always" {
|
||||
t.Errorf("returned the wrong declaration: %+v", got)
|
||||
}
|
||||
// The second declaration on the same collection must be independently
|
||||
// reachable — the whole reason bootstrap_include is a list.
|
||||
if idx := traits.BootstrapIncludeForKey("convention_index"); idx == nil || idx.Mode != BootstrapModeMetadata {
|
||||
t.Errorf("BootstrapIncludeForKey(convention_index) = %+v, want the metadata declaration", idx)
|
||||
}
|
||||
if miss := traits.BootstrapIncludeForKey("nope"); miss != nil {
|
||||
t.Errorf("BootstrapIncludeForKey(nope) = %+v, want nil", miss)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectionTraitsRoundTrip guards the storage contract: what Validate
|
||||
// accepts must survive a write/read cycle through the traits column.
|
||||
func TestCollectionTraitsRoundTrip(t *testing.T) {
|
||||
original := CollectionTraits{
|
||||
BootstrapInclude: []BootstrapInclude{
|
||||
{Mode: BootstrapModeBodies, Filter: map[string]string{"status": "active", "trigger": "always"}, Key: "conventions"},
|
||||
{Mode: BootstrapModeMetadata, Filter: map[string]string{"status": "active"}, Key: "convention_index"},
|
||||
},
|
||||
InvocationField: InvocationSlugField,
|
||||
ArtifactKind: &ArtifactKindTrait{Kind: "convention"},
|
||||
}
|
||||
|
||||
encoded, err := original.JSON()
|
||||
if err != nil {
|
||||
t.Fatalf("JSON() errored: %v", err)
|
||||
}
|
||||
decoded, err := ParseCollectionTraits(encoded)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseCollectionTraits() errored: %v", err)
|
||||
}
|
||||
|
||||
a, _ := json.Marshal(original)
|
||||
b, _ := json.Marshal(decoded)
|
||||
if string(a) != string(b) {
|
||||
t.Errorf("round trip lost data:\n before: %s\n after: %s", a, b)
|
||||
}
|
||||
}
|
||||
|
||||
// TestZeroTraitsSerializeToEmptyObject pins the NOT NULL storage invariant:
|
||||
// the column must never receive "" or "null".
|
||||
func TestZeroTraitsSerializeToEmptyObject(t *testing.T) {
|
||||
got, err := CollectionTraits{}.JSON()
|
||||
if err != nil {
|
||||
t.Fatalf("JSON() errored: %v", err)
|
||||
}
|
||||
if got != "{}" {
|
||||
t.Errorf("zero traits serialized to %q, want %q", got, "{}")
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseCollectionTraitsRejectsTrailingContent closes the gap between the
|
||||
// "strict parsing" claim and what json.Decoder actually does: Decode stops at
|
||||
// the end of the first JSON value and ignores the rest, so a blob that is not
|
||||
// valid JSON would parse cleanly and activate behavior. Codex round 7.
|
||||
func TestParseCollectionTraitsRejectsTrailingContent(t *testing.T) {
|
||||
cases := []string{
|
||||
`{"artifact_kind":{"kind":"playbook"}} garbage`,
|
||||
`{"artifact_kind":{"kind":"playbook"}}{"invocation_field":"invocation_slug"}`,
|
||||
`{"invocation_field":"invocation_slug"} null`,
|
||||
}
|
||||
for _, raw := range cases {
|
||||
got, err := ParseCollectionTraits(raw)
|
||||
if err == nil {
|
||||
t.Errorf("ParseCollectionTraits(%q) accepted trailing content and returned %+v", raw, got)
|
||||
}
|
||||
}
|
||||
|
||||
// Control leg: the same leading object WITHOUT trailing content parses,
|
||||
// so the rejection above is about the trailing bytes and not the object.
|
||||
if _, err := ParseCollectionTraits(`{"artifact_kind":{"kind":"playbook"}}`); err != nil {
|
||||
t.Errorf("control leg failed: the bare object should parse, got %v", err)
|
||||
}
|
||||
// Trailing WHITESPACE is not trailing content.
|
||||
if _, err := ParseCollectionTraits("{\"invocation_field\":\"invocation_slug\"}\n \t"); err != nil {
|
||||
t.Errorf("trailing whitespace rejected: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -64,12 +64,24 @@ type CollectionExport struct {
|
||||
Description string `json:"description"`
|
||||
Schema string `json:"schema"`
|
||||
Settings string `json:"settings"`
|
||||
Prefix string `json:"prefix"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
// Traits carries the collection's kernel-trait declarations (SPEC-5).
|
||||
// omitempty so an archive from a deployment where nothing declares traits
|
||||
// omits the key entirely rather than carrying a noise "{}" on every
|
||||
// collection, and so an import of an older archive is unambiguous (absent,
|
||||
// not empty).
|
||||
//
|
||||
// This does NOT make a pre-TASK-2657 archive round-trip byte-identically:
|
||||
// import defaults a missing value to "{}" in the column, and any collection
|
||||
// the migration then backfills re-exports WITH declarations. Round-tripping
|
||||
// an old archive through a current deployment is expected to gain traits —
|
||||
// that is the backfill working, not export drift.
|
||||
Traits string `json:"traits,omitempty"`
|
||||
Prefix string `json:"prefix"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsSystem bool `json:"is_system"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ItemExport holds an item's data for export.
|
||||
|
||||
@@ -12,20 +12,43 @@ import (
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// kindForCollectionSlug maps a collection slug to the artifact Kind it
|
||||
// exports as. Only the playbooks and conventions collections carry the
|
||||
// structured frontmatter the artifact format understands; everything else
|
||||
// is not exportable as an artifact. Returns (kind, true) for a supported
|
||||
// slug, ("", false) otherwise.
|
||||
func kindForCollectionSlug(slug string) (artifact.Kind, bool) {
|
||||
switch slug {
|
||||
case "playbooks":
|
||||
return artifact.KindPlaybook, true
|
||||
case "conventions":
|
||||
return artifact.KindConvention, true
|
||||
default:
|
||||
return "", false
|
||||
// kindForCollection resolves the artifact Kind a collection exports as from
|
||||
// its artifact_kind trait (SPEC-5 §Collection traits). Returns (kind, true)
|
||||
// when the collection declares one, ("", false) otherwise — a collection that
|
||||
// declares no artifact kind is not exportable as an artifact.
|
||||
//
|
||||
// Replaces a slug switch on "playbooks"/"conventions". The switch broke as
|
||||
// soon as either collection was renamed, silently making its items
|
||||
// unexportable ([[BUG-2702]]); the trait travels with the collection.
|
||||
// TASK-2657.
|
||||
// The returned string is a human-facing reason when ok is false: the two
|
||||
// refusals are different facts and an operator debugging an export needs to
|
||||
// know WHICH. Collapsing them was the round-3 finding — a collection that
|
||||
// declares `widget` was told it declares nothing at all.
|
||||
func kindForCollection(coll *models.Collection) (artifact.Kind, bool, string) {
|
||||
if coll == nil {
|
||||
return "", false, "Collection not found"
|
||||
}
|
||||
traits, err := models.ParseCollectionTraits(coll.Traits)
|
||||
if err != nil {
|
||||
return "", false, "This collection's trait declarations could not be read, so its artifact kind is unknown"
|
||||
}
|
||||
if traits.ArtifactKind == nil || traits.ArtifactKind.Kind == "" {
|
||||
return "", false, "This collection does not declare an artifact kind, so its items cannot be exported as artifacts"
|
||||
}
|
||||
kind := artifact.Kind(traits.ArtifactKind.Kind)
|
||||
// The declared kind must be one the artifact format actually knows how to
|
||||
// serialize. SPEC-5 permits a collection to declare any kind string —
|
||||
// unknown kinds are legal, they simply don't round-trip — so this is not
|
||||
// rejected at declaration time. But without this check an unknown kind
|
||||
// would sail through to artifact.Encode, which returns ErrUnknownKind and
|
||||
// gets reported as a 500. A collection whose kind this build can't encode
|
||||
// is "not exportable as an artifact", which is a 400, exactly like a
|
||||
// collection that declares no kind at all.
|
||||
if _, err := artifact.FieldKeysForKind(kind); err != nil {
|
||||
return "", false, fmt.Sprintf("This collection declares artifact kind %q, which this version of Pad cannot export", kind)
|
||||
}
|
||||
return kind, true, ""
|
||||
}
|
||||
|
||||
// handleExportItemArtifact serializes a single playbook or convention item to
|
||||
@@ -65,10 +88,9 @@ func (s *Server) handleExportItemArtifact(w http.ResponseWriter, r *http.Request
|
||||
writeInternalError(w, fmt.Errorf("export: item %s has no collection", item.Slug))
|
||||
return
|
||||
}
|
||||
kind, ok := kindForCollectionSlug(coll.Slug)
|
||||
kind, ok, reason := kindForCollection(coll)
|
||||
if !ok {
|
||||
writeError(w, http.StatusBadRequest, "unsupported_collection",
|
||||
"Only playbooks and conventions can be exported as artifacts")
|
||||
writeError(w, http.StatusBadRequest, "unsupported_collection", reason)
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/artifact"
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
@@ -18,17 +19,42 @@ type artifactImportResponse struct {
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// collectionSlugForKind maps an artifact Kind to the destination collection
|
||||
// slug in a workspace.
|
||||
func collectionSlugForKind(k artifact.Kind) (string, bool) {
|
||||
switch k {
|
||||
case artifact.KindPlaybook:
|
||||
return "playbooks", true
|
||||
case artifact.KindConvention:
|
||||
return "conventions", true
|
||||
default:
|
||||
return "", false
|
||||
// collectionIDForKind resolves an artifact Kind to the destination collection
|
||||
// in a workspace: whichever collection DECLARES that kind via its
|
||||
// artifact_kind trait (SPEC-5 §Collection traits).
|
||||
//
|
||||
// Replaces a hardcoded kind→slug map. The map assumed the destination was
|
||||
// always named "playbooks"/"conventions", so importing into a workspace that
|
||||
// had renamed either collection 404'd even though the collection was right
|
||||
// there ([[BUG-2702]]). TASK-2657.
|
||||
//
|
||||
// visibleCollIDs restricts the candidates to collections the caller can see;
|
||||
// nil means an unrestricted caller. Filtering BEFORE selection matters for the
|
||||
// same reason it does in resolvePlaybook: with more than one collection
|
||||
// declaring a kind, picking the first and then failing the visibility check
|
||||
// lets a hidden collection shadow a visible one, so an import would be refused
|
||||
// even though a destination the caller can write to exists. Codex round 2.
|
||||
//
|
||||
// Returns ("", nil) when no visible collection declares the kind — the caller
|
||||
// reports that as a workspace with nowhere to put this artifact.
|
||||
func (s *Server) collectionIDForKind(workspaceID string, k artifact.Kind, visibleCollIDs []string) (string, error) {
|
||||
traited, err := s.store.ListTraitedCollections(workspaceID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
candidates := traited
|
||||
if visibleCollIDs != nil {
|
||||
candidates = traited[:0:0]
|
||||
for _, c := range traited {
|
||||
if isCollectionVisible(c.ID, visibleCollIDs) {
|
||||
candidates = append(candidates, c)
|
||||
}
|
||||
}
|
||||
}
|
||||
if c := collections.FindByArtifactKind(candidates, string(k)); c != nil {
|
||||
return c.ID, nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// handleImportArtifact imports a single playbook/convention artifact (Markdown
|
||||
@@ -64,21 +90,29 @@ func (s *Server) handleImportArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
collSlug, ok := collectionSlugForKind(art.Kind)
|
||||
if !ok {
|
||||
// Decode already validates the kind, so this is defensive.
|
||||
writeError(w, http.StatusBadRequest, "unknown_kind", "Unknown artifact kind")
|
||||
importVisibleIDs, ivErr := s.visibleCollectionIDs(r, workspaceID)
|
||||
if ivErr != nil {
|
||||
writeInternalError(w, ivErr)
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := s.store.GetCollectionBySlug(workspaceID, collSlug)
|
||||
collID, err := s.collectionIDForKind(workspaceID, art.Kind, importVisibleIDs)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if collID == "" {
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
fmt.Sprintf("This workspace has no collection that accepts %q artifacts", art.Kind))
|
||||
return
|
||||
}
|
||||
coll, err := s.store.GetCollection(collID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
if coll == nil {
|
||||
writeError(w, http.StatusNotFound, "not_found",
|
||||
fmt.Sprintf("This workspace has no %q collection to import into", collSlug))
|
||||
fmt.Sprintf("This workspace has no collection that accepts %q artifacts", art.Kind))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -87,12 +121,11 @@ func (s *Server) handleImportArtifact(w http.ResponseWriter, r *http.Request) {
|
||||
if !s.requireEditPermission(w, r, workspaceID, "", coll.ID) {
|
||||
return
|
||||
}
|
||||
visibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
|
||||
if visErr != nil {
|
||||
writeInternalError(w, visErr)
|
||||
return
|
||||
}
|
||||
if !isCollectionVisible(coll.ID, visibleIDs) {
|
||||
// Belt-and-braces: collectionIDForKind already selected from the visible
|
||||
// set, so this cannot fail for a restricted caller. Kept because the
|
||||
// resolution above may change and this is the gate handleCreateItem
|
||||
// applies; it reuses the same set rather than re-querying.
|
||||
if !isCollectionVisible(coll.ID, importVisibleIDs) {
|
||||
writeError(w, http.StatusNotFound, "not_found", "Collection not found")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
@@ -37,7 +38,19 @@ type AgentBootstrap struct {
|
||||
ConventionIndex []AgentBootstrapConventionMeta `json:"convention_index"`
|
||||
Roles []BootstrapRole `json:"roles"`
|
||||
Playbooks []AgentBootstrapPlaybookMeta `json:"playbooks"`
|
||||
Dashboard *BootstrapDashboard `json:"dashboard,omitempty"`
|
||||
// BootstrapIncludes carries every boot payload a workspace declares
|
||||
// beyond the three first-party ones above (SPEC-5 bootstrap_include).
|
||||
//
|
||||
// The three named keys are first-party VIEWS with bespoke projections
|
||||
// that the CLI, skill, MCP, and web already consume; they keep their
|
||||
// shape and are fed from the same declarations. This array is what makes
|
||||
// the surface generic rather than three hardcoded payloads: a collection
|
||||
// declaring an include under any other key reaches agents through it with
|
||||
// no kernel change. Empty in every workspace that declares only the
|
||||
// defaults, which is why it is omitempty — a payload nobody declared
|
||||
// should not cost bytes at every boot. TASK-2657.
|
||||
BootstrapIncludes []BootstrapIncludeGroup `json:"bootstrap_includes,omitempty"`
|
||||
Dashboard *BootstrapDashboard `json:"dashboard,omitempty"`
|
||||
// NeedsOnboarding is true when the workspace has ZERO user-created
|
||||
// items — i.e. nothing beyond what SeedCollectionsFromTemplate seeded
|
||||
// at init time. The agent skill reads this on every /pad invocation
|
||||
@@ -395,20 +408,6 @@ const (
|
||||
bootstrapByRoleCap = 5
|
||||
)
|
||||
|
||||
// isCollectionSlugVisible reports whether the named collection survived
|
||||
// the visibility filter. Used by the bootstrap path to gate
|
||||
// convention/playbook queries on whether the caller can see those
|
||||
// collections at all. The slice we're checking is already-filtered, so
|
||||
// presence implies visibility.
|
||||
func isCollectionSlugVisible(filtered []models.Collection, slug string) bool {
|
||||
for _, c := range filtered {
|
||||
if c.Slug == slug {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// BuildAgentBootstrap assembles the bootstrap blob from store queries.
|
||||
// This is the single canonical code path; the HTTP handler, the MCP
|
||||
// resource handler, and the MCP `pad_set_workspace` embed all call this.
|
||||
@@ -494,29 +493,41 @@ func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *h
|
||||
subItemIDs = grantedItemIDs
|
||||
}
|
||||
|
||||
// Resolve which collections DECLARE boot payloads (SPEC-5
|
||||
// bootstrap_include). This replaced the hardcoded "conventions" /
|
||||
// "playbooks" slugs: bootstrap now asks the workspace what feeds each
|
||||
// payload, so a renamed collection keeps feeding it ([[BUG-2702]]) and a
|
||||
// collection that declares an include participates without kernel changes.
|
||||
// TASK-2657.
|
||||
//
|
||||
// Derived from the collections ALREADY loaded above rather than re-queried.
|
||||
// A second read would cost a full collection scan per bootstrap for data
|
||||
// this request is holding, and — worse — would let the payload mix two
|
||||
// snapshots, so a concurrent collection edit could produce a response whose
|
||||
// declarations disagree with the collection metadata beside them. Codex
|
||||
// round 3. Note this list is the VISIBILITY-FILTERED one, which is why the
|
||||
// per-source visibility gate below is belt-and-braces for restricted
|
||||
// callers rather than the only check.
|
||||
traited := traitedFromModelCollections(collections)
|
||||
|
||||
// Conventions — only the always-on, active set, restricted by the
|
||||
// caller's authorized view. A guest with a grant to one specific
|
||||
// convention item gets only that item, not the whole always-on set.
|
||||
conventionsCollVisible := visibleIDs == nil || isCollectionSlugVisible(collections, "conventions")
|
||||
if conventionsCollVisible {
|
||||
convs, cerr := s.collectAlwaysOnConventions(workspaceID, subCollIDs, subItemIDs)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
out.Conventions = convs
|
||||
|
||||
// convention_index: metadata-only catalog of EVERY active
|
||||
// convention (all triggers), so the triggered set is discoverable
|
||||
// without shipping bodies. See AgentBootstrapConventionMeta.
|
||||
idx, ierr := s.collectConventionIndex(workspaceID, subCollIDs, subItemIDs)
|
||||
if ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
out.ConventionIndex = idx
|
||||
} else {
|
||||
out.Conventions = []AgentBootstrapConvention{}
|
||||
out.ConventionIndex = []AgentBootstrapConventionMeta{}
|
||||
// The always-on/active filter is the DECLARATION's, not this code's.
|
||||
convItems, cerr := s.collectBootstrapSourceItems(workspaceID, traited, bootstrapKeyConventions, true, visibleIDs, subCollIDs, subItemIDs)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
out.Conventions = projectBootstrapConventions(convItems)
|
||||
|
||||
// convention_index: metadata-only catalog of EVERY active
|
||||
// convention (all triggers), so the triggered set is discoverable
|
||||
// without shipping bodies. See AgentBootstrapConventionMeta.
|
||||
idxItems, ierr := s.collectBootstrapSourceItems(workspaceID, traited, bootstrapKeyConventionIndex, false, visibleIDs, subCollIDs, subItemIDs)
|
||||
if ierr != nil {
|
||||
return nil, ierr
|
||||
}
|
||||
out.ConventionIndex = projectConventionIndex(idxItems)
|
||||
|
||||
// Agent roles — workspace-scoped, not collection-bound. Item counts
|
||||
// MUST be recomputed below for restricted callers from the same
|
||||
@@ -594,16 +605,23 @@ func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *h
|
||||
// Playbooks (metadata only) — restricted to the caller's authorized
|
||||
// view. A guest granted one specific playbook item sees that one,
|
||||
// not the whole collection.
|
||||
playbooksCollVisible := visibleIDs == nil || isCollectionSlugVisible(collections, "playbooks")
|
||||
if playbooksCollVisible {
|
||||
playbooks, perr := s.collectPlaybookMetadata(workspaceID, subCollIDs, subItemIDs)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
out.Playbooks = playbooks
|
||||
} else {
|
||||
out.Playbooks = []AgentBootstrapPlaybookMeta{}
|
||||
pbItems, perr := s.collectBootstrapSourceItems(workspaceID, traited, bootstrapKeyPlaybooks, true, visibleIDs, subCollIDs, subItemIDs)
|
||||
if perr != nil {
|
||||
return nil, perr
|
||||
}
|
||||
out.Playbooks = projectPlaybookMetadata(pbItems)
|
||||
|
||||
// Any OTHER declared payload key. The three above are first-party VIEWS
|
||||
// with bespoke projections that agents already consume; every other key a
|
||||
// workspace declares surfaces here in the generic shape. This is what
|
||||
// makes the boot surface genuinely generic rather than three hardcoded
|
||||
// payloads wearing a trait costume — a collection can declare an include
|
||||
// under a new key and it reaches agents with no kernel change.
|
||||
extra, xerr := s.collectGenericBootstrapIncludes(workspaceID, traited, visibleIDs, subCollIDs, subItemIDs)
|
||||
if xerr != nil {
|
||||
return nil, xerr
|
||||
}
|
||||
out.BootstrapIncludes = extra
|
||||
|
||||
// Dashboard — recreate via the existing handler logic if a request
|
||||
// context is available, then wrap in BootstrapDashboard so the
|
||||
@@ -640,19 +658,55 @@ func (s *Server) BuildAgentBootstrap(workspaceID string, user *models.User, r *h
|
||||
// means "no restriction" (full member); non-nil collIDs + non-nil
|
||||
// itemIDs is the guest-with-item-grants shape from guestResourceFilter.
|
||||
// A guest granted access to a single convention only sees that one.
|
||||
func (s *Server) collectAlwaysOnConventions(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapConvention, error) {
|
||||
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "conventions",
|
||||
CollectionIDs: collIDs,
|
||||
ItemIDs: itemIDs,
|
||||
Fields: map[string]string{
|
||||
"status": "active",
|
||||
"trigger": "always",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// collectBootstrapSourceItems runs every declaration feeding `key` and returns
|
||||
// the union of their items.
|
||||
//
|
||||
// This is the one query that replaced three slug-bound ones. What used to be
|
||||
// hardcoded here — which collection, and which field filter — now comes from
|
||||
// the declarations: the collection is whichever one declares the key, and the
|
||||
// filter is the declaration's own equality map. Nothing in this function names
|
||||
// a collection or a field value.
|
||||
//
|
||||
// More than one collection may feed a key; results are concatenated in
|
||||
// collection order, and each caller's projection sorts the union.
|
||||
//
|
||||
// Per-caller visibility is preserved exactly as before: a declaring collection
|
||||
// the caller can't see contributes nothing, and collIDs/itemIDs scope the
|
||||
// underlying query for guests with item-level grants. TASK-2657.
|
||||
func (s *Server) collectBootstrapSourceItems(workspaceID string, traited []collections.TraitedCollection, key string, needsContent bool, visibleIDs []string, collIDs []string, itemIDs []string) ([]models.Item, error) {
|
||||
sources := collections.FindBootstrapIncludes(traited, key)
|
||||
var out []models.Item
|
||||
for _, src := range sources {
|
||||
// Collection-level visibility gate, matching the pre-trait behavior:
|
||||
// nil visibleIDs means a full member (no restriction).
|
||||
if visibleIDs != nil && !isCollectionVisible(src.Collection.ID, visibleIDs) {
|
||||
continue
|
||||
}
|
||||
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: src.Collection.Slug,
|
||||
CollectionIDs: collIDs,
|
||||
ItemIDs: itemIDs,
|
||||
Fields: src.Include.Filter,
|
||||
// NoContent is a QUERY optimization and is NOT the same question
|
||||
// as the declaration's mode. Mode decides whether bodies are
|
||||
// SHIPPED; some metadata projections still have to READ the body
|
||||
// to derive a field from it — the playbook projection computes
|
||||
// `summary` from the markdown it never emits. Deciding this from
|
||||
// mode alone silently emptied every playbook summary, so the
|
||||
// caller that owns the projection decides.
|
||||
NoContent: !needsContent,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, items...)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// projectBootstrapConventions renders convention items into the bodies-mode
|
||||
// bootstrap shape, sorted by priority (must > should > nice-to-have) then ref.
|
||||
func projectBootstrapConventions(items []models.Item) []AgentBootstrapConvention {
|
||||
out := make([]AgentBootstrapConvention, 0, len(items))
|
||||
for _, it := range items {
|
||||
fields := map[string]any{}
|
||||
@@ -680,39 +734,19 @@ func (s *Server) collectAlwaysOnConventions(workspaceID string, collIDs []string
|
||||
}
|
||||
return out[i].Ref < out[j].Ref
|
||||
})
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// collectConventionIndex returns the metadata-only catalog of EVERY
|
||||
// active convention in the workspace (all triggers, always-on included),
|
||||
// projected into AgentBootstrapConventionMeta. Bodies are NOT loaded —
|
||||
// the query uses NoContent so the potentially-large markdown column never
|
||||
// leaves the DB. This backs AgentBootstrap.ConventionIndex; see that
|
||||
// field + AgentBootstrapConventionMeta for why a body-less index is the
|
||||
// point (triggered conventions must be discoverable without their bodies
|
||||
// flooding the bootstrap). TASK-2004.
|
||||
//
|
||||
// collIDs / itemIDs scope the underlying ListItems call the same way
|
||||
// collectAlwaysOnConventions does: nil collIDs means "no restriction"
|
||||
// (full member); non-nil collIDs + non-nil itemIDs is the
|
||||
// guest-with-item-grants shape. A guest granted one convention sees only
|
||||
// that one in the index.
|
||||
// projectConventionIndex renders convention items into the metadata-only
|
||||
// catalog shape. Bodies are NOT included — the declaration feeding this key
|
||||
// runs in metadata mode, so the markdown column never leaves the DB. See
|
||||
// AgentBootstrap.ConventionIndex + AgentBootstrapConventionMeta for why a
|
||||
// body-less index is the point (triggered conventions must be discoverable
|
||||
// without their bodies flooding the bootstrap). TASK-2004.
|
||||
//
|
||||
// Sorted by trigger, then ref for a stable, grouped order so an agent can
|
||||
// eyeball "how many on-implement conventions exist" at a glance.
|
||||
func (s *Server) collectConventionIndex(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapConventionMeta, error) {
|
||||
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "conventions",
|
||||
CollectionIDs: collIDs,
|
||||
ItemIDs: itemIDs,
|
||||
NoContent: true,
|
||||
Fields: map[string]string{
|
||||
"status": "active",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
func projectConventionIndex(items []models.Item) []AgentBootstrapConventionMeta {
|
||||
out := make([]AgentBootstrapConventionMeta, 0, len(items))
|
||||
for _, it := range items {
|
||||
fields := map[string]any{}
|
||||
@@ -734,7 +768,7 @@ func (s *Server) collectConventionIndex(workspaceID string, collIDs []string, it
|
||||
}
|
||||
return out[i].Ref < out[j].Ref
|
||||
})
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// conventionPriorityRank ranks convention priority strings
|
||||
@@ -755,22 +789,160 @@ func conventionPriorityRank(p string) int {
|
||||
}
|
||||
}
|
||||
|
||||
// collectPlaybookMetadata returns every playbook in the workspace projected
|
||||
// down to the metadata shape. Bodies are NOT included.
|
||||
// bootstrapGenericIncludeCap bounds how many items ONE declared payload may
|
||||
// contribute to the boot response.
|
||||
//
|
||||
// collIDs / itemIDs scope the underlying ListItems call: nil collIDs
|
||||
// means "no restriction" (full member); non-nil collIDs + non-nil
|
||||
// itemIDs is the guest-with-item-grants shape from guestResourceFilter.
|
||||
// A guest granted access to a single playbook only sees that one.
|
||||
func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, itemIDs []string) ([]AgentBootstrapPlaybookMeta, error) {
|
||||
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "playbooks",
|
||||
CollectionIDs: collIDs,
|
||||
ItemIDs: itemIDs,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
// SPEC-0 L4: boot-visible content is budgeted, and overflow degrades to
|
||||
// on-demand loading rather than an unbounded payload. Without a cap here, a
|
||||
// single declaration — `{mode: bodies, filter: {}}` on a collection with ten
|
||||
// thousand items — makes every agent's boot arbitrarily large, and in bodies
|
||||
// mode that is every item's full markdown. Nothing in the trait grammar stops
|
||||
// a workspace from declaring that, and it should not have to: the budget is
|
||||
// the HOST's to impose, per L4.
|
||||
//
|
||||
// The three first-party payloads are deliberately NOT capped here. They have
|
||||
// shipped uncapped since before traits, and silently truncating an existing
|
||||
// workspace's always-on conventions would be a behavior change well outside
|
||||
// this unit — an agent losing rules it has been following is worse than a
|
||||
// large payload. Their budget is a separate question, noted rather than
|
||||
// bundled. Codex round 5.
|
||||
const bootstrapGenericIncludeCap = 50
|
||||
|
||||
// BootstrapIncludeGroup is one declared boot payload rendered generically:
|
||||
// which collection declared it, in which mode, and the items it selected.
|
||||
type BootstrapIncludeGroup struct {
|
||||
Key string `json:"key"`
|
||||
Collection string `json:"collection"`
|
||||
Mode string `json:"mode"`
|
||||
Items []BootstrapIncludeItem `json:"items"`
|
||||
// OverflowCount is how many further items the declaration selected but
|
||||
// the boot budget dropped. Non-zero tells an agent the payload is a
|
||||
// prefix and the rest is available on demand, rather than letting it read
|
||||
// a truncated list as the complete set. Mirrors the dashboard's
|
||||
// `*_overflow_count` fields.
|
||||
OverflowCount int `json:"overflow_count,omitempty"`
|
||||
}
|
||||
|
||||
// BootstrapIncludeItem is the generic item projection for a declared payload.
|
||||
// Content is present only for a bodies-mode declaration.
|
||||
type BootstrapIncludeItem struct {
|
||||
Ref string `json:"ref"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content,omitempty"`
|
||||
Fields map[string]string `json:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// traitedFromModelCollections wraps collections.TraitedFromCollections for
|
||||
// callers that shadow the package name with a local `collections` variable.
|
||||
func traitedFromModelCollections(colls []models.Collection) []collections.TraitedCollection {
|
||||
return collections.TraitedFromCollections(colls)
|
||||
}
|
||||
|
||||
// Bootstrap payload keys, re-exported at package scope. buildAgentBootstrap
|
||||
// shadows the `collections` package name with a local []models.Collection, so
|
||||
// the constants are unreachable there under their own qualifier.
|
||||
const (
|
||||
bootstrapKeyConventions = collections.BootstrapKeyConventions
|
||||
bootstrapKeyConventionIndex = collections.BootstrapKeyConventionIndex
|
||||
bootstrapKeyPlaybooks = collections.BootstrapKeyPlaybooks
|
||||
)
|
||||
|
||||
// firstPartyBootstrapKeys are the payloads with bespoke top-level projections.
|
||||
// A declaration feeding one of these is rendered by that projection, not by
|
||||
// the generic path, so the two never double-report the same items.
|
||||
var firstPartyBootstrapKeys = map[string]bool{
|
||||
collections.BootstrapKeyConventions: true,
|
||||
collections.BootstrapKeyConventionIndex: true,
|
||||
collections.BootstrapKeyPlaybooks: true,
|
||||
}
|
||||
|
||||
// collectGenericBootstrapIncludes renders every declared payload that is NOT
|
||||
// one of the three first-party keys.
|
||||
//
|
||||
// Ordering is deterministic: collections in their stored order, and each
|
||||
// collection's declarations in declaration order. Two collections declaring
|
||||
// the same key produce two groups rather than a merged one — the group names
|
||||
// its source collection, so an agent can tell contributions apart.
|
||||
func (s *Server) collectGenericBootstrapIncludes(workspaceID string, traited []collections.TraitedCollection, visibleIDs []string, collIDs []string, itemIDs []string) ([]BootstrapIncludeGroup, error) {
|
||||
var out []BootstrapIncludeGroup
|
||||
for _, coll := range traited {
|
||||
if visibleIDs != nil && !isCollectionVisible(coll.ID, visibleIDs) {
|
||||
continue
|
||||
}
|
||||
for _, inc := range coll.Traits.BootstrapInclude {
|
||||
if firstPartyBootstrapKeys[inc.Key] {
|
||||
continue
|
||||
}
|
||||
wantBodies := inc.Mode == models.BootstrapModeBodies
|
||||
// Ask for one more than the cap so overflow is DETECTED rather
|
||||
// than inferred from a full page — len(items) == cap is
|
||||
// ambiguous on its own.
|
||||
items, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: coll.Slug,
|
||||
CollectionIDs: collIDs,
|
||||
ItemIDs: itemIDs,
|
||||
Fields: inc.Filter,
|
||||
NoContent: !wantBodies,
|
||||
Limit: bootstrapGenericIncludeCap + 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
overflow := 0
|
||||
if len(items) > bootstrapGenericIncludeCap {
|
||||
// The count is a floor, not a total: the query stopped at
|
||||
// cap+1, so all we honestly know is that at least one more
|
||||
// exists. Reported as 1 rather than a fabricated total.
|
||||
overflow = len(items) - bootstrapGenericIncludeCap
|
||||
items = items[:bootstrapGenericIncludeCap]
|
||||
}
|
||||
group := BootstrapIncludeGroup{
|
||||
Key: inc.Key,
|
||||
Collection: coll.Slug,
|
||||
Mode: inc.Mode,
|
||||
Items: make([]BootstrapIncludeItem, 0, len(items)),
|
||||
}
|
||||
for _, it := range items {
|
||||
entry := BootstrapIncludeItem{Ref: it.Ref, Title: it.Title}
|
||||
if wantBodies {
|
||||
entry.Content = it.Content
|
||||
}
|
||||
// Structured fields are flattened to strings: an agent reads
|
||||
// them to route, not to compute, and a stable string map is
|
||||
// cheaper to describe than arbitrary JSON. Non-scalar values
|
||||
// are omitted rather than rendered as Go syntax — see
|
||||
// BUG-2628 for what a raw Go map looks like when it reaches a
|
||||
// user-facing surface.
|
||||
fields := map[string]any{}
|
||||
_ = json.Unmarshal([]byte(it.Fields), &fields)
|
||||
if len(fields) > 0 {
|
||||
flat := make(map[string]string, len(fields))
|
||||
for k, v := range fields {
|
||||
switch tv := v.(type) {
|
||||
case string:
|
||||
flat[k] = tv
|
||||
case bool:
|
||||
flat[k] = strconv.FormatBool(tv)
|
||||
case float64:
|
||||
flat[k] = strconv.FormatFloat(tv, 'f', -1, 64)
|
||||
}
|
||||
}
|
||||
if len(flat) > 0 {
|
||||
entry.Fields = flat
|
||||
}
|
||||
}
|
||||
group.Items = append(group.Items, entry)
|
||||
}
|
||||
group.OverflowCount = overflow
|
||||
out = append(out, group)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// projectPlaybookMetadata renders playbook items down to the metadata shape.
|
||||
// Bodies are NOT included — the agent loads a full body only when invoking.
|
||||
func projectPlaybookMetadata(items []models.Item) []AgentBootstrapPlaybookMeta {
|
||||
out := make([]AgentBootstrapPlaybookMeta, 0, len(items))
|
||||
for _, it := range items {
|
||||
fields := map[string]any{}
|
||||
@@ -817,7 +989,7 @@ func (s *Server) collectPlaybookMetadata(workspaceID string, collIDs []string, i
|
||||
}
|
||||
return out[i].Title < out[j].Title
|
||||
})
|
||||
return out, nil
|
||||
return out
|
||||
}
|
||||
|
||||
// capBootstrapDashboard wraps a DashboardResponse with the bootstrap's
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestConventionsCollectionSurvivesRename locks BUG-2702's conventions half.
|
||||
//
|
||||
// Before collection traits, bootstrap queried the literal slug "conventions".
|
||||
// Store.UpdateCollection re-slugs on any name change, and renaming a
|
||||
// collection is a documented onboarding step (TASK-1510; the onboard playbook
|
||||
// tells agents to adapt collection names to the project), so a rename
|
||||
// silently emptied every agent's always-on rule set with no error anywhere.
|
||||
//
|
||||
// OBSERVED FAILING on origin/main 6e7d34c6 before the fix: conventions 1 -> 0
|
||||
// and convention_index 1 -> 0 after the rename, with the item still present in
|
||||
// the renamed collection. The control leg below is what makes the failure
|
||||
// meaningful — it proves the payload was populated before the rename, so a
|
||||
// zero afterwards is the rename's doing and not an empty fixture.
|
||||
func TestConventionsCollectionSurvivesRename(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
// Seed one active, always-on convention — the exact shape bootstrap
|
||||
// promises to hand every agent at boot.
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/conventions/items", map[string]interface{}{
|
||||
"title": "Use conventional commits",
|
||||
"content": "Commit messages follow the conventional-commit format.",
|
||||
"fields": map[string]string{"status": "active", "trigger": "always"},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create convention: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Control leg: before the rename, the convention is in the bootstrap.
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("bootstrap (before): expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var before AgentBootstrap
|
||||
parseJSON(t, rr, &before)
|
||||
if len(before.Conventions) != 1 {
|
||||
t.Fatalf("control leg failed: conventions before rename = %d, want 1", len(before.Conventions))
|
||||
}
|
||||
if len(before.ConventionIndex) != 1 {
|
||||
t.Fatalf("control leg failed: convention_index before rename = %d, want 1", len(before.ConventionIndex))
|
||||
}
|
||||
|
||||
// The documented onboarding mutation: rename the collection to fit the
|
||||
// project's vocabulary (TASK-1510; the onboard playbook tells agents to
|
||||
// do exactly this). UpdateCollection re-slugs from the new name.
|
||||
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/conventions", map[string]interface{}{
|
||||
"name": "Rules",
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("rename collection: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// The item is untouched — still active, still always-on, still there.
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/collections/rules/items", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("list renamed collection: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var items []map[string]interface{}
|
||||
parseJSON(t, rr, &items)
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("renamed collection lost its items: got %d, want 1", len(items))
|
||||
}
|
||||
|
||||
// The defect: bootstrap queries the literal slug "conventions", which no
|
||||
// longer resolves, so the agent's always-on rules silently vanish.
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("bootstrap (after): expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var after AgentBootstrap
|
||||
parseJSON(t, rr, &after)
|
||||
if len(after.Conventions) != 1 {
|
||||
t.Errorf("regression: conventions after rename = %d, want 1", len(after.Conventions))
|
||||
}
|
||||
if len(after.ConventionIndex) != 1 {
|
||||
t.Errorf("regression: convention_index after rename = %d, want 1", len(after.ConventionIndex))
|
||||
}
|
||||
}
|
||||
|
||||
// TestPlaybooksCollectionSurvivesRename locks BUG-2702's playbooks half, which
|
||||
// is the worse of the two: a rename unregistered every invokable playbook, so
|
||||
// `/pad ship` fell through to natural-language routing with no sign that the
|
||||
// playbook still existed.
|
||||
//
|
||||
// OBSERVED FAILING on origin/main 6e7d34c6 before the fix: playbooks 1 -> 0
|
||||
// and GET /playbooks/ship 200 -> 404.
|
||||
func TestPlaybooksCollectionSurvivesRename(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/playbooks/items", map[string]interface{}{
|
||||
"title": "Ship a list of tasks",
|
||||
"content": "## Steps\n\n1. Do the thing.",
|
||||
"fields": map[string]string{"status": "active", "invocation_slug": "ship"},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create playbook: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Control leg: before the rename, the playbook is both listed in
|
||||
// bootstrap and resolvable by its invocation slug.
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
||||
var before AgentBootstrap
|
||||
parseJSON(t, rr, &before)
|
||||
if len(before.Playbooks) != 1 {
|
||||
t.Fatalf("control leg failed: playbooks before rename = %d, want 1", len(before.Playbooks))
|
||||
}
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/playbooks/ship", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("control leg failed: resolve playbook before rename = %d, want 200", rr.Code)
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/playbooks", map[string]interface{}{
|
||||
"name": "Procedures",
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("rename collection: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
||||
var after AgentBootstrap
|
||||
parseJSON(t, rr, &after)
|
||||
if len(after.Playbooks) != 1 {
|
||||
t.Errorf("regression: playbooks after rename = %d, want 1", len(after.Playbooks))
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/playbooks/ship", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("regression: resolve playbook after rename = %d, want 200", rr.Code)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,512 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// TestCollectionTraitsSurviveOrdinarySchemaEdit is the property that decided
|
||||
// traits get their own column instead of a key inside the schema JSON.
|
||||
//
|
||||
// Every client rebuilds the schema blob as {"fields":[...]} — the web
|
||||
// collection editor, the webmcp dispatcher, the quick-actions menu — and the
|
||||
// store overwrites the column wholesale. A traits key stored in there was
|
||||
// measurably destroyed by one ordinary edit during TASK-2657, which would
|
||||
// have made kernel behavior something an unrelated UI save could silently
|
||||
// disarm. This test locks the fix: an update that says nothing about traits
|
||||
// leaves them exactly as they were.
|
||||
func TestCollectionTraitsSurviveOrdinarySchemaEdit(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
before := collectionTraits(t, srv, slug, "conventions")
|
||||
if len(before.BootstrapInclude) != 2 {
|
||||
t.Fatalf("control leg failed: seeded conventions should declare 2 bootstrap includes, got %d", len(before.BootstrapInclude))
|
||||
}
|
||||
|
||||
// An ordinary schema edit, in the exact shape EditCollectionModal sends.
|
||||
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/conventions", map[string]interface{}{
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active","draft","disabled"]}]}`,
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("schema edit: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
after := collectionTraits(t, srv, slug, "conventions")
|
||||
if len(after.BootstrapInclude) != len(before.BootstrapInclude) {
|
||||
t.Errorf("bootstrap_include declarations lost: %d before, %d after", len(before.BootstrapInclude), len(after.BootstrapInclude))
|
||||
}
|
||||
if after.ArtifactKind == nil || before.ArtifactKind == nil || after.ArtifactKind.Kind != before.ArtifactKind.Kind {
|
||||
t.Errorf("artifact_kind lost: %+v before, %+v after", before.ArtifactKind, after.ArtifactKind)
|
||||
}
|
||||
|
||||
// And the behavior those declarations drive still works end to end.
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/agent/bootstrap", nil)
|
||||
var b AgentBootstrap
|
||||
parseJSON(t, rr, &b)
|
||||
if b.ConventionIndex == nil {
|
||||
t.Error("convention_index went nil after an unrelated schema edit")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectionTraitsExplicitClear proves the preservation above is not a
|
||||
// one-way door: a caller that MEANS to clear declarations still can.
|
||||
func TestCollectionTraitsExplicitClear(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/conventions", map[string]interface{}{
|
||||
"traits": map[string]interface{}{},
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("clear traits: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
after := collectionTraits(t, srv, slug, "conventions")
|
||||
if !after.IsZero() {
|
||||
t.Errorf("explicit clear left declarations behind: %+v", after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectionTraitsRejectMalformed locks the fail-loud gate (SPEC-0 L6).
|
||||
// A declaration that cannot be parsed or validated must be REFUSED, because a
|
||||
// stored-but-unparseable blob degrades to "declares nothing" — silently the
|
||||
// wrong behavior rather than a loud error, which is the exact failure mode
|
||||
// traits exist to remove.
|
||||
func TestCollectionTraitsRejectMalformed(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
traits interface{}
|
||||
}{
|
||||
{
|
||||
name: "unknown mode",
|
||||
traits: map[string]interface{}{"bootstrap_include": []map[string]interface{}{{"mode": "summaries", "key": "x"}}},
|
||||
},
|
||||
{
|
||||
name: "missing key",
|
||||
traits: map[string]interface{}{"bootstrap_include": []map[string]interface{}{{"mode": "bodies"}}},
|
||||
},
|
||||
{
|
||||
name: "invocation_field naming another field",
|
||||
traits: map[string]interface{}{"invocation_field": "route_slug"},
|
||||
},
|
||||
{
|
||||
name: "misspelled trait name",
|
||||
traits: map[string]interface{}{"bootstrap_includes": []map[string]interface{}{{"mode": "bodies", "key": "x"}}},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/conventions", map[string]interface{}{
|
||||
"traits": tc.traits,
|
||||
})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for %s, got %d: %s", tc.name, rr.Code, rr.Body.String())
|
||||
}
|
||||
// The refusal must not have partially applied: the seeded
|
||||
// declarations are still intact.
|
||||
if got := collectionTraits(t, srv, slug, "conventions"); len(got.BootstrapInclude) != 2 {
|
||||
t.Errorf("a refused update damaged the stored declarations: %+v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestSeededCollectionsDeclareTraits pins that templates — not just the
|
||||
// migration — declare the traits, so a NEWLY created workspace never depends
|
||||
// on the backfill having run.
|
||||
func TestSeededCollectionsDeclareTraits(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
conv := collectionTraits(t, srv, slug, "conventions")
|
||||
if conv.BootstrapIncludeForKey("conventions") == nil {
|
||||
t.Error("conventions collection declares no bodies include")
|
||||
}
|
||||
if conv.BootstrapIncludeForKey("convention_index") == nil {
|
||||
t.Error("conventions collection declares no metadata index include")
|
||||
}
|
||||
if conv.ArtifactKind == nil || conv.ArtifactKind.Kind != "convention" {
|
||||
t.Errorf("conventions artifact_kind = %+v, want convention", conv.ArtifactKind)
|
||||
}
|
||||
// The bodies include must carry status=active: without it, DRAFT
|
||||
// conventions reach agents as though they were policy.
|
||||
if inc := conv.BootstrapIncludeForKey("conventions"); inc != nil {
|
||||
if inc.Filter["status"] != "active" || inc.Filter["trigger"] != "always" {
|
||||
t.Errorf("conventions bodies filter = %+v, want status=active trigger=always", inc.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
pb := collectionTraits(t, srv, slug, "playbooks")
|
||||
if pb.InvocationField != models.InvocationSlugField {
|
||||
t.Errorf("playbooks invocation_field = %q, want %q", pb.InvocationField, models.InvocationSlugField)
|
||||
}
|
||||
if pb.ArtifactKind == nil || pb.ArtifactKind.Kind != "playbook" {
|
||||
t.Errorf("playbooks artifact_kind = %+v, want playbook", pb.ArtifactKind)
|
||||
}
|
||||
// Playbooks deliberately carries NO status filter — an agent needs to see
|
||||
// that a draft playbook exists (the run gate refuses it separately).
|
||||
if inc := pb.BootstrapIncludeForKey("playbooks"); inc == nil {
|
||||
t.Error("playbooks collection declares no include")
|
||||
} else if len(inc.Filter) != 0 {
|
||||
t.Errorf("playbooks include filter = %+v, want empty", inc.Filter)
|
||||
}
|
||||
}
|
||||
|
||||
// collectionTraits fetches a collection over the API and parses its traits.
|
||||
func collectionTraits(t *testing.T, srv *Server, wsSlug, collSlug string) models.CollectionTraits {
|
||||
t.Helper()
|
||||
rr := doRequest(srv, "GET", "/api/v1/workspaces/"+wsSlug+"/collections/"+collSlug, nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("get collection %s: expected 200, got %d: %s", collSlug, rr.Code, rr.Body.String())
|
||||
}
|
||||
var coll models.Collection
|
||||
parseJSON(t, rr, &coll)
|
||||
traits, err := models.ParseCollectionTraits(coll.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse traits for %s: %v (raw: %q)", collSlug, err, coll.Traits)
|
||||
}
|
||||
return traits
|
||||
}
|
||||
|
||||
// TestArtifactExportRefusesUnknownDeclaredKind covers the gap between "any
|
||||
// non-empty kind is a legal declaration" (SPEC-5) and "this build knows how to
|
||||
// serialize it". An unknown kind used to reach artifact.Encode, which returns
|
||||
// ErrUnknownKind and surfaced as a 500. A collection whose declared kind this
|
||||
// build cannot encode is simply not exportable — a 400, exactly like a
|
||||
// collection declaring no kind at all. Codex round 1.
|
||||
func TestArtifactExportRefusesUnknownDeclaredKind(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
slug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+slug+"/collections/conventions", map[string]interface{}{
|
||||
"traits": map[string]interface{}{"artifact_kind": map[string]string{"kind": "widget"}},
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("declare unknown kind: expected 200, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+slug+"/collections/conventions/items", map[string]interface{}{
|
||||
"title": "Some rule",
|
||||
"fields": map[string]string{"status": "active"},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create item: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var created map[string]interface{}
|
||||
parseJSON(t, rr, &created)
|
||||
itemSlug, _ := created["slug"].(string)
|
||||
if itemSlug == "" {
|
||||
t.Fatal("created item has no slug")
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+slug+"/items/"+itemSlug+"/export", nil)
|
||||
if rr.Code == http.StatusInternalServerError {
|
||||
t.Errorf("unknown declared kind produced a 500; want a 4xx refusal: %s", rr.Body.String())
|
||||
}
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("export with unknown declared kind = %d, want 400: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePlaybookIgnoresInvisibleCollections locks the shadowing fix from
|
||||
// Codex round 2.
|
||||
//
|
||||
// Before traits this was structurally impossible: resolution named one
|
||||
// collection. Now any number may declare invocation_field, so resolving across
|
||||
// all of them and rejecting afterwards on visibility lets a HIDDEN collection
|
||||
// shadow a visible one — the resolver returns the hidden item, the caller's
|
||||
// visibility check refuses it, and the visible playbook carrying the same
|
||||
// invocation slug becomes unreachable.
|
||||
//
|
||||
// The setup deliberately makes the HIDDEN collection sort FIRST (it is the
|
||||
// seeded `playbooks`, created before the second one), so unfixed code returns
|
||||
// the hidden item and the test fails. If the visible collection sorted first
|
||||
// the assertion would pass either way and prove nothing.
|
||||
func TestResolvePlaybookIgnoresInvisibleCollections(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
wsSlug := createWSWithCollections(t, srv)
|
||||
|
||||
// A second collection that also routes by invocation slug.
|
||||
//
|
||||
// Created WITHOUT traits through the API and then given them via the
|
||||
// store, because the API deliberately REFUSES a second invocation-routing
|
||||
// declaration (409, SPEC-0 L6). A duplicate can still arrive by routes the
|
||||
// gate does not sit on — a workspace import, or a rename that frees the
|
||||
// canonical slug for a new collection — and this test is about what
|
||||
// resolution does when one exists, not about how it got there.
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections", map[string]interface{}{
|
||||
"name": "Procedures",
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active","draft"]},{"key":"invocation_slug","type":"text","unique_scope":"workspace_collection"}]}`,
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create second routing collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var second models.Collection
|
||||
parseJSON(t, rr, &second)
|
||||
secondTraits := `{"bootstrap_include":[{"mode":"metadata","key":"playbooks"}],"invocation_field":"invocation_slug"}`
|
||||
if _, err := srv.store.UpdateCollection(second.ID, models.CollectionUpdate{Traits: &secondTraits}); err != nil {
|
||||
t.Fatalf("attach traits to second collection: %v", err)
|
||||
}
|
||||
|
||||
// Same invocation slug in both collections. The partial unique index is
|
||||
// per (collection_id, invocation_slug), so this is legal.
|
||||
for _, collSlug := range []string{"playbooks", second.Slug} {
|
||||
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections/"+collSlug+"/items", map[string]interface{}{
|
||||
"title": "Ship from " + collSlug,
|
||||
"fields": map[string]string{"status": "active", "invocation_slug": "ship"},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create playbook in %s: expected 201, got %d: %s", collSlug, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
wsID := workspaceIDForSlug(t, srv, wsSlug)
|
||||
|
||||
// Control leg: unrestricted resolution finds something at all, and finds
|
||||
// the FIRST declaring collection — the seeded playbooks one. If this ever
|
||||
// returns the second collection's item the test below is vacuous.
|
||||
unrestricted, err := srv.resolvePlaybook(wsID, "ship", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("control leg failed: unrestricted resolve errored: %v", err)
|
||||
}
|
||||
if unrestricted.CollectionSlug != "playbooks" {
|
||||
t.Fatalf("control leg failed: unrestricted resolve returned %q, expected the seeded playbooks collection to sort first; the visibility assertion below would not discriminate", unrestricted.CollectionSlug)
|
||||
}
|
||||
|
||||
// Now resolve as a caller who can see ONLY the second collection. The
|
||||
// seeded playbooks collection must be invisible to resolution, not merely
|
||||
// unreadable afterwards.
|
||||
got, err := srv.resolvePlaybook(wsID, "ship", []string{second.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("restricted resolve errored: %v — a hidden collection shadowed the visible one", err)
|
||||
}
|
||||
if got.CollectionSlug != second.Slug {
|
||||
t.Errorf("restricted resolve returned the playbook in %q; want the one in the only visible collection %q", got.CollectionSlug, second.Slug)
|
||||
}
|
||||
|
||||
// A caller who can see NEITHER declaring collection gets a not-found,
|
||||
// never someone else's item.
|
||||
if _, err := srv.resolvePlaybook(wsID, "ship", []string{}); err == nil {
|
||||
t.Error("resolve with no visible routing collections returned an item; want not-found")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCollectionIDForKindIgnoresInvisibleCollections is the artifact-import
|
||||
// half of the round-2 shadowing fix. Same failure shape as playbook
|
||||
// resolution: with two collections declaring one artifact kind, selecting the
|
||||
// first and checking visibility afterwards means a hidden collection makes the
|
||||
// import fail even though a destination the caller can write to exists.
|
||||
//
|
||||
// As above, the HIDDEN collection is the seeded one so it sorts first —
|
||||
// otherwise the assertion passes with or without the fix.
|
||||
func TestCollectionIDForKindIgnoresInvisibleCollections(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
wsSlug := createWSWithCollections(t, srv)
|
||||
|
||||
// Same shape as the playbook test above: the API refuses a duplicate
|
||||
// artifact_kind declaration, so the ambiguous state is built through the
|
||||
// store, which is how it genuinely arises (import, or a rename freeing the
|
||||
// canonical slug).
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections", map[string]interface{}{
|
||||
"name": "House Rules",
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active","draft"]}]}`,
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create second declaring collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var second models.Collection
|
||||
parseJSON(t, rr, &second)
|
||||
secondTraits := `{"artifact_kind":{"kind":"convention"}}`
|
||||
if _, err := srv.store.UpdateCollection(second.ID, models.CollectionUpdate{Traits: &secondTraits}); err != nil {
|
||||
t.Fatalf("attach traits to second collection: %v", err)
|
||||
}
|
||||
|
||||
wsID := workspaceIDForSlug(t, srv, wsSlug)
|
||||
|
||||
seeded := doRequest(srv, "GET", "/api/v1/workspaces/"+wsSlug+"/collections/conventions", nil)
|
||||
var seededColl models.Collection
|
||||
parseJSON(t, seeded, &seededColl)
|
||||
|
||||
// Control leg: unrestricted selection picks the seeded collection, so the
|
||||
// restricted assertion below genuinely discriminates.
|
||||
unrestricted, err := srv.collectionIDForKind(wsID, "convention", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("control leg failed: %v", err)
|
||||
}
|
||||
if unrestricted != seededColl.ID {
|
||||
t.Fatalf("control leg failed: unrestricted selection returned %q, expected the seeded conventions collection %q to sort first", unrestricted, seededColl.ID)
|
||||
}
|
||||
|
||||
got, err := srv.collectionIDForKind(wsID, "convention", []string{second.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("restricted selection errored: %v", err)
|
||||
}
|
||||
if got != second.ID {
|
||||
t.Errorf("restricted selection returned %q; want the only visible declaring collection %q — a hidden collection shadowed it", got, second.ID)
|
||||
}
|
||||
|
||||
if none, err := srv.collectionIDForKind(wsID, "convention", []string{}); err != nil || none != "" {
|
||||
t.Errorf("selection with no visible declaring collection = (%q, %v); want (\"\", nil)", none, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenericBootstrapIncludeIsBudgeted locks the SPEC-0 L4 boot budget on the
|
||||
// generic include path (Codex round 5). Without a cap, one declaration —
|
||||
// bodies mode, no filter, on a large collection — makes every agent's boot
|
||||
// arbitrarily large, and nothing in the trait grammar prevents declaring it.
|
||||
//
|
||||
// Also asserts the overflow signal: an agent must be able to tell a truncated
|
||||
// payload from a complete one, or it reads a prefix as the whole set.
|
||||
func TestGenericBootstrapIncludeIsBudgeted(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
wsSlug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections", map[string]interface{}{
|
||||
"name": "Notes",
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active","draft"]}]}`,
|
||||
"traits": map[string]interface{}{
|
||||
"bootstrap_include": []map[string]interface{}{{"mode": "bodies", "key": "notes"}},
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create declaring collection: expected 201, got %d: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var notes models.Collection
|
||||
parseJSON(t, rr, ¬es)
|
||||
|
||||
const created = bootstrapGenericIncludeCap + 5
|
||||
for i := 0; i < created; i++ {
|
||||
rr = doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections/"+notes.Slug+"/items", map[string]interface{}{
|
||||
"title": fmt.Sprintf("Note %d", i),
|
||||
"content": "body",
|
||||
"fields": map[string]string{"status": "active"},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("create note %d: expected 201, got %d: %s", i, rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
rr = doRequest(srv, "GET", "/api/v1/workspaces/"+wsSlug+"/agent/bootstrap", nil)
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("bootstrap: expected 200, got %d", rr.Code)
|
||||
}
|
||||
var b AgentBootstrap
|
||||
parseJSON(t, rr, &b)
|
||||
|
||||
var group *BootstrapIncludeGroup
|
||||
for i := range b.BootstrapIncludes {
|
||||
if b.BootstrapIncludes[i].Key == "notes" {
|
||||
group = &b.BootstrapIncludes[i]
|
||||
}
|
||||
}
|
||||
if group == nil {
|
||||
t.Fatal("control leg failed: the declared payload did not appear in bootstrap at all")
|
||||
}
|
||||
if len(group.Items) > bootstrapGenericIncludeCap {
|
||||
t.Errorf("generic include shipped %d items; the boot budget caps it at %d", len(group.Items), bootstrapGenericIncludeCap)
|
||||
}
|
||||
if group.OverflowCount == 0 {
|
||||
t.Errorf("payload was truncated (%d of %d items) but overflow_count is 0 — an agent would read a prefix as the complete set", len(group.Items), created)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTraitConflictsRefused locks SPEC-0 L6 at the one surface that can MINT
|
||||
// an ambiguous declaration. Two collections declaring the same artifact_kind,
|
||||
// or two declaring invocation_field, leave the resolvers picking by collection
|
||||
// order — sort_order then created_at — which makes where an imported artifact
|
||||
// lands, or which collection answers an invocation slug, a coin flip. Refused
|
||||
// with a conflict rather than resolved silently. Codex round 7.
|
||||
func TestTraitConflictsRefused(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
wsSlug := createWSWithCollections(t, srv)
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
traits map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "duplicate artifact_kind",
|
||||
traits: map[string]interface{}{"artifact_kind": map[string]string{"kind": "convention"}},
|
||||
},
|
||||
{
|
||||
name: "second invocation-routing collection",
|
||||
traits: map[string]interface{}{"invocation_field": "invocation_slug"},
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections", map[string]interface{}{
|
||||
"name": "Rival " + tc.name,
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active"]}]}`,
|
||||
"traits": tc.traits,
|
||||
})
|
||||
if rr.Code != http.StatusConflict {
|
||||
t.Errorf("expected 409 for %s, got %d: %s", tc.name, rr.Code, rr.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Control leg: a declaration that conflicts with NOTHING is accepted, so
|
||||
// the refusals above are about the conflict and not about traits on
|
||||
// create generally.
|
||||
rr := doRequest(srv, "POST", "/api/v1/workspaces/"+wsSlug+"/collections", map[string]interface{}{
|
||||
"name": "Notes",
|
||||
"schema": `{"fields":[{"key":"status","type":"select","options":["active"]}]}`,
|
||||
"traits": map[string]interface{}{
|
||||
"bootstrap_include": []map[string]interface{}{{"mode": "bodies", "key": "notes"}},
|
||||
"artifact_kind": map[string]string{"kind": "note"},
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusCreated {
|
||||
t.Fatalf("control leg failed: a non-conflicting declaration was refused: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// A collection must not conflict with ITSELF on update.
|
||||
var notes models.Collection
|
||||
parseJSON(t, rr, ¬es)
|
||||
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+wsSlug+"/collections/"+notes.Slug, map[string]interface{}{
|
||||
"traits": map[string]interface{}{
|
||||
"bootstrap_include": []map[string]interface{}{{"mode": "metadata", "key": "notes"}},
|
||||
"artifact_kind": map[string]string{"kind": "note"},
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("a collection conflicted with its own declarations on update: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestFirstPartyKeyModeIsPinned covers the other round-7 contract gap: the
|
||||
// three first-party payloads have fixed projection shapes, so a declaration
|
||||
// naming one with the other mode would be quietly ignored — the payload comes
|
||||
// out in the projection's shape regardless of what the declaration said.
|
||||
func TestFirstPartyKeyModeIsPinned(t *testing.T) {
|
||||
srv := testServer(t)
|
||||
wsSlug := createWSWithCollections(t, srv)
|
||||
|
||||
rr := doRequest(srv, "PATCH", "/api/v1/workspaces/"+wsSlug+"/collections/conventions", map[string]interface{}{
|
||||
"traits": map[string]interface{}{
|
||||
"bootstrap_include": []map[string]interface{}{{"mode": "metadata", "key": "conventions"}},
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusBadRequest {
|
||||
t.Errorf("metadata mode on the bodies-shaped `conventions` payload = %d, want 400: %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
|
||||
// Control leg: the same key with its own mode is accepted.
|
||||
rr = doRequest(srv, "PATCH", "/api/v1/workspaces/"+wsSlug+"/collections/conventions", map[string]interface{}{
|
||||
"traits": map[string]interface{}{
|
||||
"bootstrap_include": []map[string]interface{}{{"mode": "bodies", "key": "conventions"}},
|
||||
},
|
||||
})
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Errorf("control leg failed: bodies mode on `conventions` was refused: %d %s", rr.Code, rr.Body.String())
|
||||
}
|
||||
}
|
||||
@@ -102,6 +102,74 @@ func (s *Server) handleListCollections(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, colls)
|
||||
}
|
||||
|
||||
// validateCollectionTraits parses and validates an inbound traits blob.
|
||||
// An empty blob is legal — most collections declare nothing. TASK-2657.
|
||||
func validateCollectionTraits(raw string) error {
|
||||
if strings.TrimSpace(raw) == "" {
|
||||
return nil
|
||||
}
|
||||
traits, err := models.ParseCollectionTraits(raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return traits.Validate()
|
||||
}
|
||||
|
||||
// checkTraitConflicts refuses a declaration that would make a workspace-level
|
||||
// resolution ambiguous: two collections declaring the same artifact_kind, or
|
||||
// two declaring invocation_field.
|
||||
//
|
||||
// SPEC-0 L6 — conflicts fail loud, never silent merges. Without this the
|
||||
// resolvers still have to pick one, and they pick by collection order, so
|
||||
// which collection receives an imported artifact or answers `/pad <slug>`
|
||||
// would depend on sort_order and creation time. That is a coin flip wearing a
|
||||
// rule's clothes. Codex round 7.
|
||||
//
|
||||
// BEST-EFFORT, NOT AN INVARIANT — say so plainly rather than let the name
|
||||
// imply more than it delivers (Codex round 8). This reads and then writes
|
||||
// without holding a lock across both, so two concurrent owner-level writes can
|
||||
// both pass and mint a duplicate. Workspace IMPORT bypasses it entirely by
|
||||
// design, and a rename that frees a canonical slug can produce a duplicate
|
||||
// with no write to this path at all.
|
||||
//
|
||||
// The database-level version — a partial unique index on the extracted trait,
|
||||
// the shape migration 054 already uses for invocation_slug — is deliberately
|
||||
// NOT added in phase 0: existing deployments can already hold duplicates (the
|
||||
// rename-then-reseed path produces one), so creating such an index would fail
|
||||
// the migration on exactly the databases that most need fixing. That wants a
|
||||
// de-duplication pass first, which is its own unit.
|
||||
//
|
||||
// So this gate closes the common case — a user or agent declaring a duplicate
|
||||
// through the API — and the resolvers keep their documented order-dependent
|
||||
// behaviour for duplicates arriving any other way, because refusing to resolve
|
||||
// at read time would break a workspace rather than a request.
|
||||
//
|
||||
// excludeCollID is the collection being updated, so a collection never
|
||||
// conflicts with itself.
|
||||
func (s *Server) checkTraitConflicts(workspaceID, raw, excludeCollID string) error {
|
||||
traits, err := models.ParseCollectionTraits(raw)
|
||||
if err != nil || traits.IsZero() {
|
||||
return nil
|
||||
}
|
||||
existing, err := s.store.ListTraitedCollections(workspaceID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, other := range existing {
|
||||
if other.ID == excludeCollID {
|
||||
continue
|
||||
}
|
||||
if traits.ArtifactKind != nil && other.Traits.ArtifactKind != nil &&
|
||||
other.Traits.ArtifactKind.Kind == traits.ArtifactKind.Kind {
|
||||
return fmt.Errorf("collection %q already declares artifact_kind %q; two collections declaring one kind would make imports and exports depend on collection order", other.Slug, traits.ArtifactKind.Kind)
|
||||
}
|
||||
if traits.InvocationField != "" && other.Traits.InvocationField != "" {
|
||||
return fmt.Errorf("collection %q already declares invocation_field; two invocation-routing collections would make playbook resolution depend on collection order", other.Slug)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireMinRole(w, r, "owner") {
|
||||
return
|
||||
@@ -121,6 +189,10 @@ func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidSettingsType.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, models.ErrInvalidTraitsType) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidTraitsType.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
@@ -142,6 +214,19 @@ func (s *Server) handleCreateCollection(w http.ResponseWriter, r *http.Request)
|
||||
}
|
||||
}
|
||||
|
||||
// Kernel traits are declarations that switch on kernel behavior, so a
|
||||
// malformed one must be refused rather than stored: a stored blob that
|
||||
// fails to parse degrades to "declares nothing", which is silently the
|
||||
// wrong behavior instead of a loud error (SPEC-0 L6). TASK-2657.
|
||||
if err := validateCollectionTraits(input.Traits); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.checkTraitConflicts(workspaceID, input.Traits, ""); err != nil {
|
||||
writeError(w, http.StatusConflict, "conflict", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
coll, err := s.store.CreateCollection(workspaceID, input)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "UNIQUE constraint") {
|
||||
@@ -219,10 +304,29 @@ func (s *Server) handleUpdateCollection(w http.ResponseWriter, r *http.Request)
|
||||
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidSettingsType.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, models.ErrInvalidTraitsType) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", models.ErrInvalidTraitsType.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
// Same fail-loud gate as create: a malformed declaration is refused, not
|
||||
// stored as a blob that silently parses to "declares nothing". A nil
|
||||
// Traits (every pre-TASK-2657 client) skips this and leaves the stored
|
||||
// declarations untouched.
|
||||
if input.Traits != nil {
|
||||
if err := validateCollectionTraits(*input.Traits); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.checkTraitConflicts(workspaceID, *input.Traits, coll.ID); err != nil {
|
||||
writeError(w, http.StatusConflict, "conflict", err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// BUG-2265: validate the optimistic-concurrency token's format at the
|
||||
// boundary so a malformed value is a clean 400 rather than surfacing from
|
||||
// the store as a generic 500. The store re-parses (guaranteed to succeed)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
@@ -97,12 +98,21 @@ func (s *Server) handleListPlaybooks(w http.ResponseWriter, r *http.Request) {
|
||||
subCollIDs = fullCollIDs
|
||||
subItemIDs = grantedItemIDs
|
||||
}
|
||||
meta, err := s.collectPlaybookMetadata(workspaceID, subCollIDs, subItemIDs)
|
||||
// Same trait-driven source as the bootstrap's playbooks payload, so the
|
||||
// two can never disagree about which collection holds playbooks — and so
|
||||
// `pad playbook list` keeps working when that collection is renamed
|
||||
// ([[BUG-2702]]). TASK-2657.
|
||||
traited, err := s.store.ListTraitedCollections(workspaceID)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, meta)
|
||||
items, err := s.collectBootstrapSourceItems(workspaceID, traited, bootstrapKeyPlaybooks, true, visibleIDs, subCollIDs, subItemIDs)
|
||||
if err != nil {
|
||||
writeInternalError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, projectPlaybookMetadata(items))
|
||||
}
|
||||
|
||||
// handleShowPlaybook returns the full playbook item identified by ref,
|
||||
@@ -120,7 +130,12 @@ func (s *Server) handleShowPlaybook(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ref required")
|
||||
return
|
||||
}
|
||||
item, err := s.resolvePlaybook(workspaceID, identifier)
|
||||
resolveVisibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
|
||||
if visErr != nil {
|
||||
writeInternalError(w, visErr)
|
||||
return
|
||||
}
|
||||
item, err := s.resolvePlaybook(workspaceID, identifier, resolveVisibleIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
@@ -148,7 +163,12 @@ func (s *Server) handleRunPlaybook(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "bad_request", "ref required")
|
||||
return
|
||||
}
|
||||
item, err := s.resolvePlaybook(workspaceID, identifier)
|
||||
resolveVisibleIDs, visErr := s.visibleCollectionIDs(r, workspaceID)
|
||||
if visErr != nil {
|
||||
writeInternalError(w, visErr)
|
||||
return
|
||||
}
|
||||
item, err := s.resolvePlaybook(workspaceID, identifier, resolveVisibleIDs)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "not_found", err.Error())
|
||||
return
|
||||
@@ -294,30 +314,74 @@ func extractAllowDraft(rawArgs []string, current bool) ([]string, bool) {
|
||||
// resolvePlaybook finds a playbook by either its invocation_slug, its
|
||||
// item slug, or its issue ref. invocation_slug takes precedence — that's
|
||||
// the user-facing identifier callers will type most often.
|
||||
func (s *Server) resolvePlaybook(workspaceID, identifier string) (*models.Item, error) {
|
||||
// First, try invocation_slug. If we find an exact match, return it.
|
||||
bySlug, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: "playbooks",
|
||||
Fields: map[string]string{"invocation_slug": identifier},
|
||||
Limit: 1,
|
||||
})
|
||||
// Which collections participate is resolved from the invocation_field trait
|
||||
// (SPEC-5), not from the literal slug "playbooks": renaming the collection
|
||||
// used to unregister every invokable playbook at once, so `/pad ship` fell
|
||||
// through to natural-language routing with no sign the playbook still existed
|
||||
// ([[BUG-2702]]). TASK-2657.
|
||||
func (s *Server) resolvePlaybook(workspaceID, identifier string, visibleCollIDs []string) (*models.Item, error) {
|
||||
traited, err := s.store.ListTraitedCollections(workspaceID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bySlug) == 1 {
|
||||
return &bySlug[0], nil
|
||||
routing := collections.FindByInvocationField(traited)
|
||||
// Consider only collections the CALLER can see. Before traits this was
|
||||
// structurally impossible — resolution named one collection — but any
|
||||
// number may now declare invocation_field, and resolving across all of
|
||||
// them then rejecting on visibility lets a hidden collection SHADOW a
|
||||
// visible one: the resolver returns the hidden item, the caller's
|
||||
// visibility check refuses it, and the visible playbook with the same
|
||||
// invocation slug becomes unreachable. Filtering first makes the hidden
|
||||
// collection invisible to resolution rather than merely unreadable, so
|
||||
// shadowing cannot occur and no 404 is contingent on a row the caller was
|
||||
// never allowed to know about. Codex round 2.
|
||||
//
|
||||
// nil visibleCollIDs means an unrestricted caller (full member) — the
|
||||
// same convention every other bootstrap/list path in this package uses.
|
||||
if visibleCollIDs != nil {
|
||||
filtered := routing[:0:0]
|
||||
for _, c := range routing {
|
||||
if isCollectionVisible(c.ID, visibleCollIDs) {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
routing = filtered
|
||||
}
|
||||
if len(routing) == 0 {
|
||||
return nil, fmt.Errorf("playbook %q not found: this workspace has no collection that routes by invocation slug", identifier)
|
||||
}
|
||||
|
||||
// First, try the invocation field. If we find an exact match, return it.
|
||||
// The FIELD NAME comes from the declaration; v1 constrains it to
|
||||
// invocation_slug so it stays covered by the partial unique indexes that
|
||||
// guard uniqueness (SPEC-5 v1.1 amendment 4).
|
||||
for _, coll := range routing {
|
||||
bySlug, err := s.store.ListItems(workspaceID, models.ItemListParams{
|
||||
CollectionSlug: coll.Slug,
|
||||
Fields: map[string]string{coll.Traits.InvocationField: identifier},
|
||||
Limit: 1,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(bySlug) == 1 {
|
||||
return &bySlug[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to standard item resolution (UUID, ref, or item slug),
|
||||
// then verify it lives in the playbooks collection so a stray
|
||||
// then verify it lives in a routing collection so a stray
|
||||
// TASK-5 doesn't surface here.
|
||||
item, err := s.store.ResolveItem(workspaceID, identifier)
|
||||
if err != nil || item == nil {
|
||||
return nil, fmt.Errorf("playbook %q not found", identifier)
|
||||
}
|
||||
if item.CollectionSlug != "playbooks" {
|
||||
return nil, fmt.Errorf("item %s is not a playbook", item.Ref)
|
||||
for _, coll := range routing {
|
||||
if item.CollectionSlug == coll.Slug {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return item, nil
|
||||
return nil, fmt.Errorf("item %s is not a playbook", item.Ref)
|
||||
}
|
||||
|
||||
// parsePlaybookArguments pulls the `arguments` field out of the
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
// TestCollectionTraitsBackfill exercises the backfill half of migration
|
||||
// 080 (SQLite) / 058 (Postgres) — the part that protects EXISTING workspaces.
|
||||
//
|
||||
// Ordinary tests can't reach it: migrations run before any collection exists,
|
||||
// and every collection created afterwards gets its traits from the template.
|
||||
// So the backfill statements — the only thing standing between an upgraded
|
||||
// deployment and a silently trait-less conventions collection — would ship
|
||||
// with zero coverage. This test manufactures the pre-upgrade state (traits
|
||||
// reset to '{}') and re-runs the migration's UPDATE against it.
|
||||
//
|
||||
// Runs against whichever dialect testStore provides, so `make test-pg` covers
|
||||
// the Postgres statements and the default suite covers SQLite.
|
||||
func TestCollectionTraitsBackfill(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Backfill Test")
|
||||
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
conv, err := s.GetCollectionBySlug(ws.ID, "conventions")
|
||||
if err != nil || conv == nil {
|
||||
t.Fatalf("no conventions collection: %v", err)
|
||||
}
|
||||
pb, err := s.GetCollectionBySlug(ws.ID, "playbooks")
|
||||
if err != nil || pb == nil {
|
||||
t.Fatalf("no playbooks collection: %v", err)
|
||||
}
|
||||
|
||||
// Manufacture the pre-upgrade state: a workspace whose system collections
|
||||
// carry no declarations, exactly as every workspace created before
|
||||
// TASK-2657 does.
|
||||
for _, id := range []string{conv.ID, pb.ID} {
|
||||
if _, err := s.db.Exec(s.q(`UPDATE collections SET traits = '{}' WHERE id = ?`), id); err != nil {
|
||||
t.Fatalf("reset traits: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Control leg: confirm the reset actually landed. Without this the
|
||||
// assertions below would pass on a no-op reset, proving nothing.
|
||||
if got := mustTraits(t, s, conv.ID); !got.IsZero() {
|
||||
t.Fatalf("control leg failed: traits still populated after reset: %+v", got)
|
||||
}
|
||||
|
||||
runBackfillStatements(t, s)
|
||||
|
||||
convTraits := mustTraits(t, s, conv.ID)
|
||||
if len(convTraits.BootstrapInclude) != 2 {
|
||||
t.Errorf("conventions backfill: got %d bootstrap includes, want 2", len(convTraits.BootstrapInclude))
|
||||
}
|
||||
bodies := convTraits.BootstrapIncludeForKey("conventions")
|
||||
if bodies == nil {
|
||||
t.Fatal("conventions backfill: no bodies include")
|
||||
}
|
||||
if bodies.Mode != models.BootstrapModeBodies {
|
||||
t.Errorf("conventions bodies include mode = %q, want %q", bodies.Mode, models.BootstrapModeBodies)
|
||||
}
|
||||
// status=active is the amendment that keeps DRAFT conventions out of the
|
||||
// boot payload; a backfill that omits it silently ships draft rules to
|
||||
// every agent in an upgraded workspace.
|
||||
if bodies.Filter["status"] != "active" || bodies.Filter["trigger"] != "always" {
|
||||
t.Errorf("conventions bodies filter = %+v, want status=active trigger=always", bodies.Filter)
|
||||
}
|
||||
if idx := convTraits.BootstrapIncludeForKey("convention_index"); idx == nil {
|
||||
t.Error("conventions backfill: no convention_index include")
|
||||
} else if idx.Mode != models.BootstrapModeMetadata || idx.Filter["status"] != "active" {
|
||||
t.Errorf("convention_index include = %+v, want metadata/status=active", idx)
|
||||
}
|
||||
if convTraits.ArtifactKind == nil || convTraits.ArtifactKind.Kind != "convention" {
|
||||
t.Errorf("conventions artifact_kind = %+v, want convention", convTraits.ArtifactKind)
|
||||
}
|
||||
|
||||
pbTraits := mustTraits(t, s, pb.ID)
|
||||
if pbTraits.InvocationField != models.InvocationSlugField {
|
||||
t.Errorf("playbooks invocation_field = %q, want %q", pbTraits.InvocationField, models.InvocationSlugField)
|
||||
}
|
||||
if pbTraits.ArtifactKind == nil || pbTraits.ArtifactKind.Kind != "playbook" {
|
||||
t.Errorf("playbooks artifact_kind = %+v, want playbook", pbTraits.ArtifactKind)
|
||||
}
|
||||
if inc := pbTraits.BootstrapIncludeForKey("playbooks"); inc == nil {
|
||||
t.Error("playbooks backfill: no include")
|
||||
} else if len(inc.Filter) != 0 {
|
||||
t.Errorf("playbooks include filter = %+v, want empty (draft playbooks are listed deliberately)", inc.Filter)
|
||||
}
|
||||
|
||||
// Everything the backfill writes must satisfy the same grammar the API
|
||||
// enforces — otherwise upgraded workspaces carry declarations that a
|
||||
// later edit through the handler would reject.
|
||||
if err := convTraits.Validate(); err != nil {
|
||||
t.Errorf("backfilled conventions traits fail validation: %v", err)
|
||||
}
|
||||
if err := pbTraits.Validate(); err != nil {
|
||||
t.Errorf("backfilled playbooks traits fail validation: %v", err)
|
||||
}
|
||||
|
||||
// Re-running must not disturb populated declarations — the statements are
|
||||
// guarded on traits='{}' so an operator re-applying a migration, or a
|
||||
// partially-applied upgrade, can't clobber a workspace's own edits.
|
||||
//
|
||||
// The workspace's declarations have to DIFFER from what the backfill
|
||||
// writes for this to prove anything: re-running an unguarded backfill
|
||||
// would write the identical canonical value, so asserting "still 2
|
||||
// includes" passes with or without the guard.
|
||||
custom := models.CollectionTraits{
|
||||
BootstrapInclude: []models.BootstrapInclude{
|
||||
{Mode: models.BootstrapModeBodies, Filter: map[string]string{"status": "active"}, Key: "conventions"},
|
||||
},
|
||||
ArtifactKind: &models.ArtifactKindTrait{Kind: "convention"},
|
||||
}
|
||||
customJSON, err := custom.JSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal custom traits: %v", err)
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`UPDATE collections SET traits = ? WHERE id = ?`), customJSON, conv.ID); err != nil {
|
||||
t.Fatalf("write custom traits: %v", err)
|
||||
}
|
||||
|
||||
runBackfillStatements(t, s)
|
||||
|
||||
again := mustTraits(t, s, conv.ID)
|
||||
if len(again.BootstrapInclude) != 1 {
|
||||
t.Errorf("re-running the backfill clobbered a workspace's own declarations: got %d includes, want the 1 it had set", len(again.BootstrapInclude))
|
||||
}
|
||||
if inc := again.BootstrapIncludeForKey("conventions"); inc != nil && inc.Filter["trigger"] == "always" {
|
||||
t.Error("re-running the backfill overwrote the workspace's filter with the canonical one")
|
||||
}
|
||||
}
|
||||
|
||||
// runBackfillStatements replays the migration's UPDATE statements. The
|
||||
// migration itself is version-tracked and won't re-run, so the statements are
|
||||
// read out of the embedded migration file rather than duplicated here — a copy
|
||||
// would drift from the SQL that actually ships.
|
||||
func runBackfillStatements(t *testing.T, s *Store) {
|
||||
t.Helper()
|
||||
|
||||
name, dir := "migrations/080_collection_traits.sql", migrationsFS
|
||||
if s.dialect.Driver() == DriverPostgres {
|
||||
name = "pgmigrations/058_collection_traits.sql"
|
||||
dir = pgMigrationsFS
|
||||
}
|
||||
raw, err := dir.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", name, err)
|
||||
}
|
||||
|
||||
// Strip comment lines before splitting. The migration's prose contains
|
||||
// semicolons, so a naive split on ";" tears statements apart mid-comment.
|
||||
// (The production runner sidesteps this by consuming leading comment lines
|
||||
// before it looks for a statement end — see execMulti.)
|
||||
var sb strings.Builder
|
||||
for _, line := range strings.Split(string(raw), "\n") {
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "--") {
|
||||
continue
|
||||
}
|
||||
sb.WriteString(line)
|
||||
sb.WriteString("\n")
|
||||
}
|
||||
|
||||
var ran int
|
||||
for _, stmt := range strings.Split(sb.String(), ";") {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
// Only the backfill UPDATEs — the ALTER TABLE already ran as part of
|
||||
// normal migration and would error on a second application.
|
||||
if !strings.Contains(strings.ToUpper(trimmed), "UPDATE COLLECTIONS") {
|
||||
continue
|
||||
}
|
||||
if _, err := s.db.Exec(trimmed); err != nil {
|
||||
t.Fatalf("backfill statement failed: %v\n%s", err, trimmed)
|
||||
}
|
||||
ran++
|
||||
}
|
||||
// Guard against the test silently exercising nothing if the migration is
|
||||
// ever restructured (renamed, split, statements reworded).
|
||||
if ran != 2 {
|
||||
t.Fatalf("expected 2 backfill UPDATE statements in %s, ran %d", name, ran)
|
||||
}
|
||||
}
|
||||
|
||||
func mustTraits(t *testing.T, s *Store, collID string) models.CollectionTraits {
|
||||
t.Helper()
|
||||
coll, err := s.GetCollection(collID)
|
||||
if err != nil || coll == nil {
|
||||
t.Fatalf("get collection %s: %v", collID, err)
|
||||
}
|
||||
traits, err := models.ParseCollectionTraits(coll.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse traits: %v (raw %q)", err, coll.Traits)
|
||||
}
|
||||
return traits
|
||||
}
|
||||
|
||||
// TestFilterKeyRuleMatchesStoreSanitizer pins CollectionTraits.Validate's
|
||||
// filter-key rule to the store's own field-key sanitizer.
|
||||
//
|
||||
// The two are duplicated on purpose — models cannot import store — and the
|
||||
// duplication is load-bearing in the fail-OPEN direction: ListItems silently
|
||||
// drops any field key isValidFieldKey rejects, removing that predicate from
|
||||
// the WHERE clause instead of matching nothing. If the store's rule ever grows
|
||||
// stricter than the validator's, a declaration would pass validation and then
|
||||
// have its filter silently discarded, shipping an unfiltered payload. This
|
||||
// test fails the moment the two rules disagree.
|
||||
func TestFilterKeyRuleMatchesStoreSanitizer(t *testing.T) {
|
||||
keys := []string{
|
||||
"status", "trigger", "invocation_slug", "agent-role", "a1", "_leading",
|
||||
"", " ", "stat us", "status;drop", "sta.tus", "status'", "état", "a\tb",
|
||||
"status\n", "$status", "sta/tus",
|
||||
}
|
||||
for _, k := range keys {
|
||||
storeAccepts := isValidFieldKey(k)
|
||||
validatorAccepts := models.CollectionTraits{
|
||||
BootstrapInclude: []models.BootstrapInclude{
|
||||
{Mode: models.BootstrapModeBodies, Key: "x", Filter: map[string]string{k: "active"}},
|
||||
},
|
||||
}.Validate() == nil
|
||||
if storeAccepts != validatorAccepts {
|
||||
t.Errorf("key %q: store sanitizer accepts=%v, trait validator accepts=%v — a disagreement here lets a declaration pass validation and then lose its filter silently", k, storeAccepts, validatorAccepts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportLegacyArchiveInfersTraits locks the compatibility inference for
|
||||
// archives written before the traits column existed (Codex round 4, P1).
|
||||
//
|
||||
// The migration backfill cannot help here: it ran long before these rows were
|
||||
// inserted, and nothing re-runs it per import. So without inference an imported
|
||||
// pre-TASK-2657 workspace comes up with a conventions collection that declares
|
||||
// NOTHING — no always-on rules in bootstrap, no playbook invocation routing, no
|
||||
// artifact export. The archive looks fine and the workspace is quietly inert:
|
||||
// exactly the BUG-2702 failure mode, reintroduced through a different door.
|
||||
func TestImportLegacyArchiveInfersTraits(t *testing.T) {
|
||||
s := testStore(t)
|
||||
owner := createTestUser(t, s, "legacy-archive-owner@test.com", "Legacy Owner", "password123")
|
||||
src := createTestWorkspace(t, s, "Legacy Archive Source")
|
||||
if err := s.SeedCollectionsFromTemplate(src.ID, "startup"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
exp, err := s.ExportWorkspace(src.Slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportWorkspace: %v", err)
|
||||
}
|
||||
|
||||
// Simulate an archive written before the column existed: the key is
|
||||
// absent, which decodes to "".
|
||||
var sawConventions bool
|
||||
for i := range exp.Collections {
|
||||
if exp.Collections[i].Slug == "conventions" {
|
||||
sawConventions = true
|
||||
}
|
||||
exp.Collections[i].Traits = ""
|
||||
}
|
||||
if !sawConventions {
|
||||
t.Fatal("control leg failed: export carried no conventions collection to strip")
|
||||
}
|
||||
|
||||
imported, err := s.ImportWorkspace(exp, "legacy-archive-target", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportWorkspace: %v", err)
|
||||
}
|
||||
|
||||
conv, err := s.GetCollectionBySlug(imported.ID, "conventions")
|
||||
if err != nil || conv == nil {
|
||||
t.Fatalf("imported workspace has no conventions collection: %v", err)
|
||||
}
|
||||
traits, err := models.ParseCollectionTraits(conv.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse imported traits: %v (raw %q)", err, conv.Traits)
|
||||
}
|
||||
if traits.IsZero() {
|
||||
t.Fatal("imported conventions collection declares nothing — a legacy archive imports as an inert workspace")
|
||||
}
|
||||
if len(traits.BootstrapInclude) != 2 {
|
||||
t.Errorf("imported conventions: got %d bootstrap includes, want 2", len(traits.BootstrapInclude))
|
||||
}
|
||||
if bodies := traits.BootstrapIncludeForKey("conventions"); bodies == nil {
|
||||
t.Error("imported conventions declares no bodies include")
|
||||
} else if bodies.Filter["status"] != "active" {
|
||||
t.Errorf("imported conventions bodies filter = %+v, want status=active", bodies.Filter)
|
||||
}
|
||||
|
||||
pb, err := s.GetCollectionBySlug(imported.ID, "playbooks")
|
||||
if err != nil || pb == nil {
|
||||
t.Fatalf("imported workspace has no playbooks collection: %v", err)
|
||||
}
|
||||
pbTraits, err := models.ParseCollectionTraits(pb.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse imported playbook traits: %v", err)
|
||||
}
|
||||
if pbTraits.InvocationField != models.InvocationSlugField {
|
||||
t.Errorf("imported playbooks invocation_field = %q, want %q — invocation routing would be dead", pbTraits.InvocationField, models.InvocationSlugField)
|
||||
}
|
||||
|
||||
// Inference must not leak onto a collection that has no canonical set.
|
||||
ordinary, err := s.GetCollectionBySlug(imported.ID, "tasks")
|
||||
if err != nil || ordinary == nil {
|
||||
t.Fatalf("imported workspace has no tasks collection: %v", err)
|
||||
}
|
||||
if ot, err := models.ParseCollectionTraits(ordinary.Traits); err != nil || !ot.IsZero() {
|
||||
t.Errorf("inference leaked onto an ordinary collection: %+v (err %v)", ot, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestImportDoesNotOverrideSurvivingTraits is the other half of the inference
|
||||
// rule: an archive that DOES carry declarations keeps them verbatim. Inference
|
||||
// is a compatibility shim for archives written before the column existed, not
|
||||
// a normalizer — silently rewriting a workspace's own declarations to the
|
||||
// canonical set on every import would make export/import lossy in a way no
|
||||
// caller could see.
|
||||
//
|
||||
// The custom declarations below differ from the canonical set on purpose; a
|
||||
// test using the canonical values would pass whether or not the guard exists.
|
||||
func TestImportDoesNotOverrideSurvivingTraits(t *testing.T) {
|
||||
s := testStore(t)
|
||||
owner := createTestUser(t, s, "custom-traits-owner@test.com", "Custom Owner", "password123")
|
||||
src := createTestWorkspace(t, s, "Custom Traits Source")
|
||||
if err := s.SeedCollectionsFromTemplate(src.ID, "startup"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
|
||||
// Legal but NOT canonical. It must be legal because import validates
|
||||
// before inferring — an invalid declaration is discarded and then
|
||||
// replaced by the canonical set, which is correct behaviour but would
|
||||
// make this test assert the opposite of its name. (An earlier version
|
||||
// used `mode: metadata` on the `conventions` key, which round 7's
|
||||
// first-party mode pinning subsequently made invalid; the test caught
|
||||
// the interaction.) It must differ from canonical or the assertion
|
||||
// cannot tell "kept" from "overwritten".
|
||||
custom := models.CollectionTraits{
|
||||
BootstrapInclude: []models.BootstrapInclude{
|
||||
{Mode: models.BootstrapModeBodies, Filter: map[string]string{"status": "active"}, Key: "conventions"},
|
||||
},
|
||||
}
|
||||
customJSON, err := custom.JSON()
|
||||
if err != nil {
|
||||
t.Fatalf("marshal custom traits: %v", err)
|
||||
}
|
||||
// Control leg: the custom set must actually differ from the canonical
|
||||
// one, or the assertion below cannot discriminate.
|
||||
canonicalJSON, _ := collections.CanonicalTraitsForSlug("conventions").JSON()
|
||||
if customJSON == canonicalJSON {
|
||||
t.Fatal("control leg failed: the 'custom' traits are identical to the canonical set")
|
||||
}
|
||||
|
||||
exp, err := s.ExportWorkspace(src.Slug)
|
||||
if err != nil {
|
||||
t.Fatalf("ExportWorkspace: %v", err)
|
||||
}
|
||||
for i := range exp.Collections {
|
||||
if exp.Collections[i].Slug == "conventions" {
|
||||
exp.Collections[i].Traits = customJSON
|
||||
}
|
||||
}
|
||||
|
||||
imported, err := s.ImportWorkspace(exp, "custom-traits-target", owner.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ImportWorkspace: %v", err)
|
||||
}
|
||||
conv, err := s.GetCollectionBySlug(imported.ID, "conventions")
|
||||
if err != nil || conv == nil {
|
||||
t.Fatalf("imported workspace has no conventions collection: %v", err)
|
||||
}
|
||||
got, err := models.ParseCollectionTraits(conv.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse imported traits: %v", err)
|
||||
}
|
||||
gotJSON, _ := got.JSON()
|
||||
if gotJSON != customJSON {
|
||||
t.Errorf("import rewrote the workspace's own declarations\n got: %s\nwant: %s", gotJSON, customJSON)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBackfillSQLMatchesCanonicalTraits pins the migration's hardcoded JSON to
|
||||
// the single Go definition every other surface uses. The SQL cannot import the
|
||||
// Go constant, so this is the only thing stopping the two from drifting — and
|
||||
// a drift would be silent, since both sides independently produce valid traits.
|
||||
func TestBackfillSQLMatchesCanonicalTraits(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ws := createTestWorkspace(t, s, "Backfill Parity")
|
||||
if err := s.SeedCollectionsFromTemplate(ws.ID, "startup"); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
for _, slug := range []string{"conventions", "playbooks"} {
|
||||
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
||||
if err != nil || coll == nil {
|
||||
t.Fatalf("no %s collection: %v", slug, err)
|
||||
}
|
||||
if _, err := s.db.Exec(s.q(`UPDATE collections SET traits = '{}' WHERE id = ?`), coll.ID); err != nil {
|
||||
t.Fatalf("reset traits: %v", err)
|
||||
}
|
||||
}
|
||||
runBackfillStatements(t, s)
|
||||
|
||||
for _, slug := range []string{"conventions", "playbooks"} {
|
||||
coll, err := s.GetCollectionBySlug(ws.ID, slug)
|
||||
if err != nil || coll == nil {
|
||||
t.Fatalf("no %s collection: %v", slug, err)
|
||||
}
|
||||
fromSQL, err := models.ParseCollectionTraits(coll.Traits)
|
||||
if err != nil {
|
||||
t.Fatalf("parse backfilled %s traits: %v", slug, err)
|
||||
}
|
||||
want := collections.CanonicalTraitsForSlug(slug)
|
||||
gotJSON, _ := fromSQL.JSON()
|
||||
wantJSON, _ := want.JSON()
|
||||
if gotJSON != wantJSON {
|
||||
t.Errorf("%s: migration backfill has drifted from CanonicalTraitsForSlug\n SQL: %s\n Go: %s", slug, gotJSON, wantJSON)
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
-15
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/artifact"
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
@@ -45,6 +46,13 @@ func (s *Store) CreateCollection(workspaceID string, input models.CollectionCrea
|
||||
if settings == "" {
|
||||
settings = "{}"
|
||||
}
|
||||
// Traits default to "{}" (declares nothing), which is correct for every
|
||||
// ordinary collection — kernel traits are opt-in and absence is never an
|
||||
// error. The column is NOT NULL, so the empty case must be a real object.
|
||||
traits := input.Traits
|
||||
if strings.TrimSpace(traits) == "" {
|
||||
traits = "{}"
|
||||
}
|
||||
icon := input.Icon
|
||||
description := input.Description
|
||||
|
||||
@@ -73,9 +81,9 @@ func (s *Store) CreateCollection(workspaceID string, input models.CollectionCrea
|
||||
}
|
||||
|
||||
_, err = s.db.Exec(s.q(`
|
||||
INSERT INTO collections (id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, is_system, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, 0, s.dialect.BoolToInt(input.IsDefault), s.dialect.BoolToInt(input.IsSystem), ts, ts)
|
||||
INSERT INTO collections (id, workspace_id, name, slug, prefix, icon, description, schema, settings, traits, sort_order, is_default, is_system, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`), id, workspaceID, input.Name, slug, prefix, icon, description, schema, settings, traits, 0, s.dialect.BoolToInt(input.IsDefault), s.dialect.BoolToInt(input.IsSystem), ts, ts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("insert collection: %w", err)
|
||||
}
|
||||
@@ -104,7 +112,7 @@ func (s *Store) CreateCollection(workspaceID string, input models.CollectionCrea
|
||||
// not this model: it omits workspace_id and deleted_at because an import
|
||||
// assigns a fresh workspace and an export skips deleted rows. A column added
|
||||
// here but not there hydrates everywhere and still vanishes on export/import.
|
||||
const collectionColumns = `id, workspace_id, name, slug, prefix, icon, description, schema, settings, sort_order, is_default, is_system, created_at, updated_at, deleted_at`
|
||||
const collectionColumns = `id, workspace_id, name, slug, prefix, icon, description, schema, settings, traits, sort_order, is_default, is_system, created_at, updated_at, deleted_at`
|
||||
|
||||
// collectionSelect is the shared prefix each single-row accessor completes
|
||||
// with its own predicate. Assembled from constants, so the full statement is
|
||||
@@ -149,7 +157,7 @@ func (s *Store) scanCollectionRow(q rowQueryer, query string, args ...any) (*mod
|
||||
|
||||
err := q.QueryRow(s.q(query), args...).Scan(
|
||||
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
|
||||
&c.Schema, &c.Settings, &c.SortOrder, &isDefault, &c.IsSystem,
|
||||
&c.Schema, &c.Settings, &c.Traits, &c.SortOrder, &isDefault, &c.IsSystem,
|
||||
&createdAt, &updatedAt, &deletedAt,
|
||||
)
|
||||
if err == sql.ErrNoRows {
|
||||
@@ -254,10 +262,47 @@ func (s *Store) ListCollectionsMinimal(workspaceID string) ([]models.Collection,
|
||||
return result, rows.Err()
|
||||
}
|
||||
|
||||
// ListTraitedCollections returns every live collection in the workspace paired
|
||||
// with its parsed kernel traits (SPEC-5). This is the lookup that replaced
|
||||
// slug literals: a consumer asks which collection DECLARES a behavior rather
|
||||
// than naming "conventions" or "playbooks", so the behavior survives a rename
|
||||
// ([[BUG-2702]]).
|
||||
//
|
||||
// A collection whose traits blob fails to parse is returned with EMPTY traits
|
||||
// rather than failing the whole call. One malformed declaration must not take
|
||||
// down bootstrap, export, or seeding for the entire workspace — it degrades to
|
||||
// "this collection declares nothing", which is the same as the pre-trait
|
||||
// behavior for any collection that isn't conventions or playbooks. Declarations
|
||||
// are validated on the way IN (create/update/seed), so a stored blob that
|
||||
// doesn't parse means something wrote around those gates. TASK-2657.
|
||||
func (s *Store) ListTraitedCollections(workspaceID string) ([]collections.TraitedCollection, error) {
|
||||
rows, err := s.db.Query(
|
||||
s.q(`SELECT id, slug, traits FROM collections WHERE workspace_id = ? AND deleted_at IS NULL ORDER BY sort_order ASC, created_at ASC`),
|
||||
workspaceID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list traited collections: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []collections.TraitedCollection
|
||||
for rows.Next() {
|
||||
var id, slug, raw string
|
||||
if err := rows.Scan(&id, &slug, &raw); err != nil {
|
||||
return nil, fmt.Errorf("scan traited collection: %w", err)
|
||||
}
|
||||
traits, perr := models.ParseCollectionTraits(raw)
|
||||
if perr != nil {
|
||||
traits = models.CollectionTraits{}
|
||||
}
|
||||
out = append(out, collections.TraitedCollection{ID: id, Slug: slug, Traits: traits})
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error) {
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT c.id, c.workspace_id, c.name, c.slug, c.prefix, c.icon, c.description,
|
||||
c.schema, c.settings, c.sort_order, c.is_default, c.is_system, c.created_at, c.updated_at,
|
||||
c.schema, c.settings, c.traits, c.sort_order, c.is_default, c.is_system, c.created_at, c.updated_at,
|
||||
COUNT(i.id) as item_count
|
||||
FROM collections c
|
||||
LEFT JOIN items i ON i.collection_id = c.id AND i.deleted_at IS NULL
|
||||
@@ -277,7 +322,7 @@ func (s *Store) ListCollections(workspaceID string) ([]models.Collection, error)
|
||||
var isDefault bool
|
||||
if err := rows.Scan(
|
||||
&c.ID, &c.WorkspaceID, &c.Name, &c.Slug, &c.Prefix, &c.Icon, &c.Description,
|
||||
&c.Schema, &c.Settings, &c.SortOrder, &isDefault, &c.IsSystem,
|
||||
&c.Schema, &c.Settings, &c.Traits, &c.SortOrder, &isDefault, &c.IsSystem,
|
||||
&createdAt, &updatedAt, &c.ItemCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
@@ -374,6 +419,20 @@ func (s *Store) UpdateCollection(id string, input models.CollectionUpdate) (*mod
|
||||
sets = append(sets, "schema = ?")
|
||||
args = append(args, *input.Schema)
|
||||
}
|
||||
// Traits are updated ONLY when explicitly supplied. Every existing client
|
||||
// rebuilds schema/settings without knowing traits exist, so a nil here is
|
||||
// overwhelmingly "this caller doesn't know about traits" rather than
|
||||
// "clear them" — and treating it as a clear would silently disarm the
|
||||
// kernel behaviors a collection declares. Clearing is still reachable:
|
||||
// send an explicit "{}". TASK-2657.
|
||||
if input.Traits != nil {
|
||||
traits := strings.TrimSpace(*input.Traits)
|
||||
if traits == "" {
|
||||
traits = "{}"
|
||||
}
|
||||
sets = append(sets, "traits = ?")
|
||||
args = append(args, traits)
|
||||
}
|
||||
if input.Settings != nil {
|
||||
// Normalize the empty-string sentinel to a valid JSON object before
|
||||
// writing. The NOT NULL DEFAULT '{}' constraint (IDEA-1484) only
|
||||
@@ -788,6 +847,18 @@ func (s *Store) SeedCollectionsFromTemplate(workspaceID string, templateName str
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal settings for %s: %w", def.Slug, err)
|
||||
}
|
||||
// Validate the template's own trait declarations on the way in. A
|
||||
// malformed declaration in first-party template code would otherwise
|
||||
// seed a workspace whose kernel behaviors silently never fire — the
|
||||
// exact failure mode traits exist to remove. Fail at seed time
|
||||
// instead (SPEC-0 L6, fail loud). TASK-2657.
|
||||
if err := def.Traits.Validate(); err != nil {
|
||||
return fmt.Errorf("invalid traits for %s: %w", def.Slug, err)
|
||||
}
|
||||
traitsJSON, err := def.Traits.JSON()
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal traits for %s: %w", def.Slug, err)
|
||||
}
|
||||
|
||||
_, err = s.CreateCollection(workspaceID, models.CollectionCreate{
|
||||
Name: def.Name,
|
||||
@@ -797,6 +868,7 @@ func (s *Store) SeedCollectionsFromTemplate(workspaceID string, templateName str
|
||||
Description: def.Description,
|
||||
Schema: string(schemaJSON),
|
||||
Settings: string(settingsJSON),
|
||||
Traits: traitsJSON,
|
||||
IsDefault: true,
|
||||
IsSystem: def.IsSystem,
|
||||
})
|
||||
@@ -859,16 +931,37 @@ func (s *Store) SeedCollectionsFromTemplate(workspaceID string, templateName str
|
||||
return err
|
||||
}
|
||||
}
|
||||
// Starter conventions and playbooks route by TRAIT, not by slug: the
|
||||
// destination is whichever collection declares it holds items of that
|
||||
// artifact kind. A template that renames its conventions collection still
|
||||
// gets its starter pack seeded into the right place. TASK-2657.
|
||||
traited, err := s.ListTraitedCollections(workspaceID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve collection traits for seeding: %w", err)
|
||||
}
|
||||
conventionsSlug := ""
|
||||
if c := collections.FindByArtifactKind(traited, string(artifact.KindConvention)); c != nil {
|
||||
conventionsSlug = c.Slug
|
||||
}
|
||||
playbooksSlug := ""
|
||||
if c := collections.FindByArtifactKind(traited, string(artifact.KindPlaybook)); c != nil {
|
||||
playbooksSlug = c.Slug
|
||||
}
|
||||
|
||||
// Starter conventions
|
||||
for _, conv := range seedConventions {
|
||||
if err := seedItem("conventions", conv.Title, conv.Content, conv.Fields); err != nil {
|
||||
return err
|
||||
if conventionsSlug != "" {
|
||||
for _, conv := range seedConventions {
|
||||
if err := seedItem(conventionsSlug, conv.Title, conv.Content, conv.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Starter playbooks
|
||||
for _, pb := range seedPlaybooks {
|
||||
if err := seedItem("playbooks", pb.Title, pb.Content, pb.Fields); err != nil {
|
||||
return err
|
||||
if playbooksSlug != "" {
|
||||
for _, pb := range seedPlaybooks {
|
||||
if err := seedItem(playbooksSlug, pb.Title, pb.Content, pb.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -889,9 +982,9 @@ func (s *Store) SeedCollectionsFromTemplate(workspaceID string, templateName str
|
||||
// Seeded last so any future template that ships its own
|
||||
// "Onboard a workspace"-titled playbook can take precedence —
|
||||
// the seedItem helper is idempotent by title inside a collection.
|
||||
if templateName != "" {
|
||||
if templateName != "" && playbooksSlug != "" {
|
||||
onboardSeed := collections.OnboardSeedPlaybook()
|
||||
if err := seedItem("playbooks", onboardSeed.Title, onboardSeed.Content, onboardSeed.Fields); err != nil {
|
||||
if err := seedItem(playbooksSlug, onboardSeed.Title, onboardSeed.Content, onboardSeed.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
+115
-5
@@ -7,6 +7,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/PerpetualSoftware/pad/internal/collections"
|
||||
"github.com/PerpetualSoftware/pad/internal/models"
|
||||
)
|
||||
|
||||
@@ -85,7 +86,7 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
|
||||
// Collections
|
||||
rows, err := s.db.Query(s.q(`
|
||||
SELECT id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, is_system, created_at, updated_at
|
||||
SELECT id, name, slug, icon, description, schema, settings, traits, prefix, sort_order, is_default, is_system, created_at, updated_at
|
||||
FROM collections WHERE workspace_id = ? AND deleted_at IS NULL
|
||||
ORDER BY sort_order, name`), ws.ID)
|
||||
if err != nil {
|
||||
@@ -95,7 +96,7 @@ func (s *Store) ExportWorkspace(slug string) (*models.WorkspaceExport, error) {
|
||||
for rows.Next() {
|
||||
var c models.CollectionExport
|
||||
var isDefault, isSystem bool
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.Prefix, &c.SortOrder, &isDefault, &isSystem, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
if err := rows.Scan(&c.ID, &c.Name, &c.Slug, &c.Icon, &c.Description, &c.Schema, &c.Settings, &c.Traits, &c.Prefix, &c.SortOrder, &isDefault, &isSystem, &c.CreatedAt, &c.UpdatedAt); err != nil {
|
||||
return nil, fmt.Errorf("scan collection: %w", err)
|
||||
}
|
||||
c.IsDefault = isDefault
|
||||
@@ -243,6 +244,39 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string, ow
|
||||
itemMap := make(map[string]string)
|
||||
|
||||
// Import collections
|
||||
// Workspace-level trait conflicts in the ARCHIVE. The API gate refuses a
|
||||
// duplicate artifact_kind / invocation_field at create and update, but an
|
||||
// archive is written elsewhere and arrives whole, so it can carry a pair
|
||||
// the gate never saw. Import does not REFUSE it — an archive is often the
|
||||
// only copy of a workspace, and rejecting the whole restore over an
|
||||
// ambiguity the resolvers can still work through would be the wrong
|
||||
// trade. What must not happen is that it lands silently: with two
|
||||
// declarations live, which collection receives an imported artifact or
|
||||
// answers an invocation slug depends on collection order. Warn so the
|
||||
// operator can fix it. Codex round 8.
|
||||
seenKinds := map[string]string{}
|
||||
invocationCollection := ""
|
||||
for _, c := range data.Collections {
|
||||
if t, err := models.ParseCollectionTraits(c.Traits); err == nil {
|
||||
if t.ArtifactKind != nil && t.ArtifactKind.Kind != "" {
|
||||
if prev, dup := seenKinds[t.ArtifactKind.Kind]; dup {
|
||||
slog.Warn("import: archive declares one artifact kind on two collections; artifact routing will depend on collection order until one is changed",
|
||||
"kind", t.ArtifactKind.Kind, "collections", prev+","+c.Slug, "workspace_id", ws.ID)
|
||||
} else {
|
||||
seenKinds[t.ArtifactKind.Kind] = c.Slug
|
||||
}
|
||||
}
|
||||
if t.InvocationField != "" {
|
||||
if invocationCollection != "" {
|
||||
slog.Warn("import: archive declares invocation routing on two collections; playbook resolution will depend on collection order until one is changed",
|
||||
"collections", invocationCollection+","+c.Slug, "workspace_id", ws.ID)
|
||||
} else {
|
||||
invocationCollection = c.Slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range data.Collections {
|
||||
newCollID := newID()
|
||||
collMap[c.ID] = newCollID
|
||||
@@ -260,11 +294,87 @@ func (s *Store) ImportWorkspace(data *models.WorkspaceExport, newName string, ow
|
||||
// boundary rather than at the schema level. IDEA-1488 extends
|
||||
// this to log-and-coerce on non-empty malformed JSON.
|
||||
settings := coerceJSONForImport(c.Settings, "{}", "collections.settings", c.ID, ws.ID, true)
|
||||
// Same coercion for traits, and for the same reason: this INSERT
|
||||
// supplies the column explicitly, so the NOT NULL DEFAULT '{}' never
|
||||
// fires. Archives written before TASK-2657 carry no traits key at
|
||||
// all, which lands here as "" — a pre-traits archive imports as a
|
||||
// collection that declares nothing, which is the honest reading.
|
||||
traits := coerceJSONForImport(c.Traits, "{}", "collections.traits", c.ID, ws.ID, true)
|
||||
// coerceJSONForImport only guarantees the blob is valid JSON. Traits
|
||||
// are declarations that switch kernel behavior on, so an import is
|
||||
// held to the same grammar the API enforces — otherwise a
|
||||
// hand-edited or foreign archive can persist a declaration that
|
||||
// parses as JSON, fails the trait parse, and degrades to "declares
|
||||
// nothing", silently disabling bootstrap or invocation routing for
|
||||
// the imported workspace.
|
||||
//
|
||||
// Degrade rather than reject: an archive is often the only copy of a
|
||||
// workspace, and refusing the whole import over one bad declaration
|
||||
// would be worse than importing it with that collection declaring
|
||||
// nothing — which is exactly what a pre-traits archive does anyway.
|
||||
// Logged so it isn't silent. TASK-2657.
|
||||
// Reject declarations that don't parse or don't validate. The log
|
||||
// deliberately does NOT claim what the collection ends up with —
|
||||
// inference below may still give it the canonical set, and an earlier
|
||||
// version of this said "importing with no declarations" and was then
|
||||
// contradicted three lines later. Codex round 6.
|
||||
discarded := false
|
||||
if parsed, perr := models.ParseCollectionTraits(traits); perr != nil {
|
||||
slog.Warn("import: collection traits could not be parsed; discarding them",
|
||||
"collection", c.Slug, "workspace_id", ws.ID, "error", perr)
|
||||
traits = "{}"
|
||||
discarded = true
|
||||
} else if verr := parsed.Validate(); verr != nil {
|
||||
slog.Warn("import: collection traits failed validation; discarding them",
|
||||
"collection", c.Slug, "workspace_id", ws.ID, "error", verr)
|
||||
traits = "{}"
|
||||
discarded = true
|
||||
}
|
||||
|
||||
// COMPATIBILITY INFERENCE for archives written before traits existed.
|
||||
//
|
||||
// Without this, importing a pre-TASK-2657 export silently reproduces
|
||||
// exactly the defect traits were introduced to fix ([[BUG-2702]]): the
|
||||
// migration backfill cannot help, because it ran long before these
|
||||
// rows were inserted, so the imported conventions/playbooks
|
||||
// collections would carry no declarations and the workspace would
|
||||
// come up with no always-on rules, no playbook invocation routing and
|
||||
// no artifact export. The archive looks fine and the workspace is
|
||||
// quietly inert.
|
||||
//
|
||||
// Only applied when the collection declares NOTHING. A declaration
|
||||
// that survived the round trip is authoritative, including the
|
||||
// deliberate empty one a user can set — but an archive that predates
|
||||
// the column cannot be distinguished from that case, and restoring a
|
||||
// working workspace is worth more than honouring a deliberate clear
|
||||
// that only round-trips through an export. Slug-keyed for the same
|
||||
// reason the migration is: on a row with no declarations, the slug is
|
||||
// the only evidence of intent that exists. Codex round 4.
|
||||
if inferred := collections.CanonicalTraitsForSlug(c.Slug); !inferred.IsZero() {
|
||||
if current, err := models.ParseCollectionTraits(traits); err == nil && current.IsZero() {
|
||||
if encoded, err := inferred.JSON(); err == nil {
|
||||
// Two different situations reach here and the log must
|
||||
// distinguish them: an archive that never had traits
|
||||
// (expected, benign) versus one whose declarations were
|
||||
// just thrown away (a data problem the operator should
|
||||
// know about, even though the outcome is a working
|
||||
// collection either way).
|
||||
if discarded {
|
||||
slog.Warn("import: collection's invalid declarations were discarded; substituting the canonical set for its slug",
|
||||
"collection", c.Slug, "workspace_id", ws.ID)
|
||||
} else {
|
||||
slog.Info("import: collection carried no trait declarations; inferring the canonical set from its slug",
|
||||
"collection", c.Slug, "workspace_id", ws.ID)
|
||||
}
|
||||
traits = encoded
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_, err := tx.Exec(s.q(`
|
||||
INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, prefix, sort_order, is_default, is_system, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, settings, c.Prefix, c.SortOrder, s.dialect.BoolToInt(c.IsDefault), s.dialect.BoolToInt(c.IsSystem),
|
||||
INSERT INTO collections (id, workspace_id, name, slug, icon, description, schema, settings, traits, prefix, sort_order, is_default, is_system, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
newCollID, ws.ID, c.Name, c.Slug, c.Icon, c.Description, c.Schema, settings, traits, c.Prefix, c.SortOrder, s.dialect.BoolToInt(c.IsDefault), s.dialect.BoolToInt(c.IsSystem),
|
||||
c.CreatedAt, c.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("import collection %s: %w", c.Name, err)
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
-- Collection kernel traits (SPEC-5 §Collection traits, PLAN-2656 phase 0 /
|
||||
-- TASK-2657). Three kernel behaviors — what the agent bootstrap loads, which
|
||||
-- items route by invocation slug, and which items export as portable
|
||||
-- artifacts — were keyed on the literal collection slugs 'conventions' and
|
||||
-- 'playbooks'. A slug is not a stable identifier (UpdateCollection re-slugs on
|
||||
-- any name change), so renaming either collection silently detached all three
|
||||
-- behaviors from it: BUG-2702. A trait travels with the collection.
|
||||
--
|
||||
-- Its own column rather than a key inside `schema` deliberately: the schema
|
||||
-- column is overwritten wholesale on update and every client rebuilds it
|
||||
-- fields-only, so a traits key stored there is destroyed by any ordinary
|
||||
-- collection edit (measured during TASK-2657). Trait authority cannot rest on
|
||||
-- a value an unrelated UI save deletes.
|
||||
ALTER TABLE collections ADD COLUMN traits TEXT NOT NULL DEFAULT '{}';
|
||||
|
||||
-- Backfill: declare on existing workspaces the traits that the kernel used to
|
||||
-- infer from these slugs, so behavior is unchanged across the upgrade.
|
||||
--
|
||||
-- Necessarily slug-keyed — the slug is the only identifier these rows carry,
|
||||
-- and there is no record of what a collection used to be called. That means
|
||||
-- this backfill does NOT reach a workspace that already renamed its
|
||||
-- conventions collection before upgrading. It cannot do worse than the status
|
||||
-- quo, because the status quo is itself slug-keyed and already broken for
|
||||
-- exactly those workspaces (BUG-2702). From here forward the hazard is
|
||||
-- structurally gone: the trait moves with the collection.
|
||||
--
|
||||
-- Guarded on traits = '{}' so a re-run, or a workspace that somehow already
|
||||
-- carries declarations, is never overwritten.
|
||||
|
||||
-- Conventions declares TWO bootstrap includes. The bodies include is the
|
||||
-- always-on rule set every agent must follow; the metadata include is the
|
||||
-- body-less index of every ACTIVE convention (all triggers), so triggered
|
||||
-- rules are discoverable without their bodies flooding the boot payload.
|
||||
-- status=active appears in both: the pre-trait implementation filtered on it,
|
||||
-- and omitting it would leak draft conventions into agent boot.
|
||||
UPDATE collections
|
||||
SET traits = json('{"bootstrap_include":[{"mode":"bodies","filter":{"status":"active","trigger":"always"},"key":"conventions"},{"mode":"metadata","filter":{"status":"active"},"key":"convention_index"}],"artifact_kind":{"kind":"convention"}}')
|
||||
WHERE slug = 'conventions'
|
||||
AND (traits IS NULL OR traits = '' OR traits = '{}');
|
||||
|
||||
-- Playbooks declares one metadata include with NO filter. Draft and deprecated
|
||||
-- playbooks are listed today, deliberately — an agent needs to see that a
|
||||
-- half-written playbook exists (and the run gate refuses non-active ones
|
||||
-- separately, BUG-2020). invocation_field marks this collection as routing by
|
||||
-- invocation slug; v1 constrains the value to the literal 'invocation_slug'
|
||||
-- because the partial unique indexes that guard uniqueness name that field.
|
||||
UPDATE collections
|
||||
SET traits = json('{"bootstrap_include":[{"mode":"metadata","key":"playbooks"}],"invocation_field":"invocation_slug","artifact_kind":{"kind":"playbook"}}')
|
||||
WHERE slug = 'playbooks'
|
||||
AND (traits IS NULL OR traits = '' OR traits = '{}');
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Collection kernel traits — Postgres mirror of
|
||||
-- migrations/080_collection_traits.sql. See the SQLite migration for the full
|
||||
-- rationale (BUG-2702; why traits get their own column rather than a key
|
||||
-- inside `schema`; why the backfill is necessarily slug-keyed).
|
||||
--
|
||||
-- Note the guard differs from the SQLite migration's: JSONB cannot be compared
|
||||
-- against '' (Postgres rejects it as invalid JSON input), and the column is
|
||||
-- NOT NULL with a default, so the empty-string arm is both unreachable and
|
||||
-- illegal here.
|
||||
--
|
||||
-- JSONB, matching this table's other JSON blob columns on Postgres
|
||||
-- (collections.schema and collections.settings are both JSONB here, even
|
||||
-- though they are TEXT in the SQLite schema). Go scans JSONB into a string
|
||||
-- exactly as it already does for settings, so the store's scan path is
|
||||
-- unchanged; the gain is that Postgres validates the JSON at write time,
|
||||
-- which is the right floor for a column whose parse failure silently
|
||||
-- degrades a collection to "declares nothing".
|
||||
ALTER TABLE collections ADD COLUMN IF NOT EXISTS traits JSONB NOT NULL DEFAULT '{}';
|
||||
|
||||
UPDATE collections
|
||||
SET traits = '{"bootstrap_include":[{"mode":"bodies","filter":{"status":"active","trigger":"always"},"key":"conventions"},{"mode":"metadata","filter":{"status":"active"},"key":"convention_index"}],"artifact_kind":{"kind":"convention"}}'
|
||||
WHERE slug = 'conventions'
|
||||
AND (traits IS NULL OR traits = '{}'::jsonb);
|
||||
|
||||
UPDATE collections
|
||||
SET traits = '{"bootstrap_include":[{"mode":"metadata","key":"playbooks"}],"invocation_field":"invocation_slug","artifact_kind":{"kind":"playbook"}}'
|
||||
WHERE slug = 'playbooks'
|
||||
AND (traits IS NULL OR traits = '{}'::jsonb);
|
||||
+3
-2
@@ -33,9 +33,10 @@ The returned `AgentBootstrap` blob carries everything the skill needs to start a
|
||||
- `user { name, email, id }` — who's talking
|
||||
- `collections [...]` — schemas (drives `pad item create`/`update` field validation)
|
||||
- `conventions [...]` — full bodies of `trigger=always, status=active` items. **Must-follow project rules.**
|
||||
- `convention_index [...]` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; NO bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist — e.g. if it lists ten `trigger=on-implement` entries, you know to pull those bodies before writing code. Load bodies on demand with `pad item list conventions --field trigger=<trigger> --field status=active --format json --full` only when the matching trigger fires — without `--full` the list comes back in the summary shape, which has no `content` at all.
|
||||
- `convention_index [...]` — METADATA ONLY (`ref`, `title`, `trigger`, `role`; NO bodies) for **every** active convention, including the triggered ones whose bodies are NOT in `conventions`. This is your map of what triggered rules exist — e.g. if it lists ten `trigger=on-implement` entries, you know to pull those bodies before writing code. Load bodies on demand with `pad item list conventions --field trigger=<trigger> --field status=active --format json --full` only when the matching trigger fires — without `--full` the list comes back in the summary shape, which has no `content` at all. **`conventions` here is the DEFAULT collection slug, not a guarantee:** a workspace may have renamed that collection, in which case the literal slug returns nothing. The payload itself is unaffected — bootstrap resolves it by declaration, not by name — so if the query comes back empty while `convention_index` lists entries, address the items directly by the `ref`s the index already gave you (`pad item show <ref>`) — that always works and needs no collection name. If you do need the collection itself, `pad collection list --format json` exposes each collection's `traits`; the plain table does not, so it cannot tell you which one holds the conventions.
|
||||
- `roles [...]` — agent roles configured in the workspace
|
||||
- `playbooks [...]` — METADATA ONLY: `ref`, `title`, `slug`, `invocation_slug`, `trigger`, `scope`, `status`, `has_arguments`, `summary`. Full bodies load on invocation via `pad playbook show <slug>`.
|
||||
- `playbooks [...]` — METADATA ONLY: `ref`, `title`, `slug`, `invocation_slug`, `trigger`, `scope`, `status`, `has_arguments`, `summary`. Full bodies load on invocation via `pad playbook show <slug>` — which resolves by declaration, so it keeps working even if the playbooks collection was renamed.
|
||||
- `bootstrap_includes [...]` — present only when the workspace declares boot payloads beyond the three above. Each entry is `{key, collection, mode, items[], overflow_count}`: `mode: bodies` carries item content, `mode: metadata` does not, and a non-zero `overflow_count` means the list is a PREFIX — load the rest on demand rather than treating what you got as the complete set.
|
||||
- `dashboard {...}` — active items, attention, suggested next, recent activity. Five sub-arrays are capped to 5 entries each (`attention`, `recent_activity`, `active_items`, `active_plans`, `by_role`); each pairs with a `<name>_overflow_count` int field surfaced when truncation kicked in. Use `pad project dashboard` to pull the full set when any overflow > 0.
|
||||
- `needs_onboarding: bool` — true when the workspace has zero user-created items (template seeds don't count). PLAN-1496 / TASK-1504. **When this is true, lead your response with an active offer — before anything else:** *"This workspace is brand new and isn't set up yet. Want me to set it up? I'll ask a few quick questions and adapt it to your project."* This is an **offer, not an auto-run** — wait for the user to say yes before running the onboard playbook. If they say yes, run it (see the Onboarding routing entry). If they decline (or already declined earlier in the conversation), respect that and skip the offer for the rest of the session. Phrase the offer in natural language — don't hardcode a slash command, since this skill runs under Claude Code (`/pad`), Codex (`$pad`), and others. After offering, proceed with whatever else the user asked. The flag flips to false the moment any user/agent-created item exists; don't nag past that point.
|
||||
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { Editor } from '@tiptap/core';
|
||||
import type { Collection, Item } from '$lib/types';
|
||||
import { parseSchema } from '$lib/types';
|
||||
import { parseSchema, isAgentCollection } from '$lib/types';
|
||||
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { localIndex } from '$lib/stores/localIndex.svelte';
|
||||
|
||||
const AGENT_SLUGS = ['conventions', 'playbooks'];
|
||||
|
||||
let {
|
||||
editor,
|
||||
wsSlug,
|
||||
@@ -36,10 +34,11 @@
|
||||
|
||||
let menuEl = $state<HTMLDivElement>();
|
||||
|
||||
// Sort collections: non-agent first, agent slugs last
|
||||
// Sort collections: non-agent first, agent-facing last. Membership comes
|
||||
// from the bootstrap_include trait (SPEC-5), not a slug list. TASK-2657.
|
||||
let sortedCollections = $derived.by(() => {
|
||||
const normal = collections.filter((c) => !AGENT_SLUGS.includes(c.slug));
|
||||
const agent = collections.filter((c) => AGENT_SLUGS.includes(c.slug));
|
||||
const normal = collections.filter((c) => !isAgentCollection(c));
|
||||
const agent = collections.filter((c) => isAgentCollection(c));
|
||||
return [...normal, ...agent];
|
||||
});
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { parseSchema, parseSettings, itemUrlId } from '$lib/types';
|
||||
import { parseSchema, parseSettings, itemUrlId, isAgentCollection } from '$lib/types';
|
||||
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
|
||||
|
||||
let {
|
||||
@@ -36,9 +36,9 @@
|
||||
|
||||
// Agent/system collections (conventions, playbooks) are structured forms,
|
||||
// not quick-capture targets — mirror the Sidebar's regularCollections split.
|
||||
const agentSlugs = ['conventions', 'playbooks'];
|
||||
// Grouped by the bootstrap_include trait, not a slug list. TASK-2657.
|
||||
let collections = $derived(
|
||||
collectionStore.collections.filter((c) => !agentSlugs.includes(c.slug))
|
||||
collectionStore.collections.filter((c) => !isAgentCollection(c))
|
||||
);
|
||||
|
||||
let selectedSlug = $state('');
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { api, isPlanLimitError, planLimitMessage } from '$lib/api/client';
|
||||
import { parseSchema, parseSettings, itemUrlId } from '$lib/types';
|
||||
import { parseSchema, parseSettings, itemUrlId, isAgentCollection } from '$lib/types';
|
||||
import { getActiveKey } from '$lib/nav/destinations';
|
||||
import type { Collection } from '$lib/types';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
@@ -73,13 +73,15 @@
|
||||
let reorderGeneration = 0;
|
||||
const flipDurationMs = 150;
|
||||
|
||||
const agentSlugs = ['conventions', 'playbooks'];
|
||||
|
||||
// Agent-facing collections are those DECLARING a bootstrap_include trait
|
||||
// (SPEC-5) — i.e. whose items load into an agent's boot context — rather
|
||||
// than a hardcoded slug list, which mis-grouped any workspace that renamed
|
||||
// them and could never see a third one. TASK-2657.
|
||||
let regularCollections = $derived(
|
||||
collectionStore.collections.filter(c => !agentSlugs.includes(c.slug))
|
||||
collectionStore.collections.filter(c => !isAgentCollection(c))
|
||||
);
|
||||
let agentCollections = $derived(
|
||||
collectionStore.collections.filter(c => agentSlugs.includes(c.slug))
|
||||
collectionStore.collections.filter(c => isAgentCollection(c))
|
||||
);
|
||||
|
||||
let pickerCollections = $derived(regularCollections);
|
||||
@@ -595,7 +597,7 @@
|
||||
{/if}
|
||||
</nav>
|
||||
|
||||
{#if !agentSlugs.includes(activeCollectionSlug ?? '') && activeCollectionSlug && activeColl}
|
||||
{#if activeCollectionSlug && activeColl && !isAgentCollection(activeColl)}
|
||||
<div class="actions">
|
||||
<button
|
||||
class="new-item-btn"
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
import { goto, afterNavigate } from '$app/navigation';
|
||||
import { workspaceStore } from '$lib/stores/workspace.svelte';
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { isAgentCollection } from '$lib/types';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { getPrimaryDestinations, getActiveKey } from '$lib/nav/destinations';
|
||||
import DockedSheet from '$lib/components/layout/DockedSheet.svelte';
|
||||
@@ -34,12 +35,12 @@
|
||||
.filter((d) => !(d.guestHidden && isGuest))
|
||||
);
|
||||
|
||||
const agentSlugs = ['conventions', 'playbooks'];
|
||||
// Grouped by the bootstrap_include trait, not a slug list. TASK-2657.
|
||||
let regularCollections = $derived(
|
||||
collectionStore.collections.filter((c) => !agentSlugs.includes(c.slug))
|
||||
collectionStore.collections.filter((c) => !isAgentCollection(c))
|
||||
);
|
||||
let agentCollections = $derived(
|
||||
collectionStore.collections.filter((c) => agentSlugs.includes(c.slug))
|
||||
collectionStore.collections.filter((c) => isAgentCollection(c))
|
||||
);
|
||||
|
||||
// Close on navigation (the tiles/collections navigate on select).
|
||||
|
||||
@@ -413,6 +413,12 @@ export interface Collection {
|
||||
description: string;
|
||||
schema: string;
|
||||
settings: string;
|
||||
/**
|
||||
* Kernel-trait declarations (SPEC-5), as a JSON string like `schema` and
|
||||
* `settings`. Parse with `parseTraits`. Optional because archives and
|
||||
* fixtures predating TASK-2657 don't carry it.
|
||||
*/
|
||||
traits?: string;
|
||||
sort_order: number;
|
||||
is_default: boolean;
|
||||
is_system: boolean;
|
||||
@@ -423,6 +429,49 @@ export interface Collection {
|
||||
prefix: string;
|
||||
}
|
||||
|
||||
/** One bootstrap_include declaration — see SPEC-5 §Collection traits. */
|
||||
export interface BootstrapInclude {
|
||||
mode: 'bodies' | 'metadata';
|
||||
filter?: Record<string, string>;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface CollectionTraits {
|
||||
bootstrap_include?: BootstrapInclude[];
|
||||
invocation_field?: string;
|
||||
artifact_kind?: { kind: string };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a collection's traits blob. Returns an empty object for absent,
|
||||
* empty, or malformed values — a collection that declares nothing is the
|
||||
* common case, not an error worth surfacing in the UI.
|
||||
*/
|
||||
export function parseTraits(collection: Pick<Collection, 'traits'>): CollectionTraits {
|
||||
if (!collection.traits) return {};
|
||||
try {
|
||||
const parsed = JSON.parse(collection.traits);
|
||||
return parsed && typeof parsed === 'object' ? (parsed as CollectionTraits) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a collection's items are loaded into an agent's boot context —
|
||||
* i.e. it declares at least one bootstrap_include (SPEC-5).
|
||||
*
|
||||
* This is what the sidebar and the editor's collection pickers group under
|
||||
* "Agent" instead of listing beside ordinary work collections. It replaced a
|
||||
* hardcoded `['conventions', 'playbooks']` array repeated at five call sites,
|
||||
* which mis-grouped any workspace that renamed either collection and could
|
||||
* never recognise a third agent-facing collection. TASK-2657.
|
||||
*/
|
||||
export function isAgentCollection(collection: Pick<Collection, 'traits'>): boolean {
|
||||
const traits = parseTraits(collection);
|
||||
return (traits.bootstrap_include?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export interface CollectionCreate {
|
||||
name: string;
|
||||
slug?: string;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import { collectionStore } from '$lib/stores/collections.svelte';
|
||||
import { uiStore } from '$lib/stores/ui.svelte';
|
||||
import { toastStore } from '$lib/stores/toast.svelte';
|
||||
import { itemUrlId } from '$lib/types';
|
||||
import { itemUrlId, isAgentCollection } from '$lib/types';
|
||||
import { createScrollRestoration } from '$lib/scroll/restore.svelte';
|
||||
import type { Item, Collection, RoleBoardLane, AgentRole } from '$lib/types';
|
||||
import ItemCard from '$lib/components/collections/ItemCard.svelte';
|
||||
@@ -80,7 +80,7 @@
|
||||
// doesn't qualify (Codex round 2).
|
||||
let eligibleCollections = $derived(
|
||||
collectionStore.collections.filter(
|
||||
(c) => !['conventions', 'playbooks'].includes(c.slug)
|
||||
(c) => !isAgentCollection(c)
|
||||
&& workspaceStore.canEditCollection(c.id)
|
||||
)
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user