fix(office): normalize skill slugs to canonical form on write and sync (#3257)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-02 08:47:35 +00:00
committed by GitHub
parent a876c3c780
commit 4b40cd37a0
12 changed files with 1667 additions and 62 deletions
@@ -2,17 +2,15 @@ package skill
import (
"path/filepath"
"regexp"
"strings"
"github.com/kandev/kandev/internal/common/skillslug"
)
// validSlugRe matches slugs that are safe for use in shell commands
// and on-disk paths. Anything outside this set is dropped during
// isValidSlug reports whether the given slug is safe for use in shell
// commands and on-disk paths. Anything outside this set is dropped during
// delivery to avoid path-traversal or shell-quoting hazards.
var validSlugRe = regexp.MustCompile(`^[a-zA-Z0-9_-]+$`)
// isValidSlug reports whether the given slug is safe.
func isValidSlug(s string) bool { return s != "" && validSlugRe.MatchString(s) }
func isValidSlug(s string) bool { return skillslug.WellFormed(s) }
// isValidPathComponent reports whether the given filename is a single
// safe path component (no separators, no traversal). Used when writing
@@ -3,10 +3,13 @@ package sqlite
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/google/uuid"
"github.com/jmoiron/sqlx"
"github.com/kandev/kandev/internal/office/models"
)
@@ -117,6 +120,27 @@ func (r *Repository) ListSystemSkills(
return skills, nil
}
// ListNonSystemSkills returns all is_system = false skills for a
// workspace, ordered by slug. Used by the system-skill sync's
// slug-migration pass: user/provider-imported rows that need a
// well-formed-but-non-canonical slug normalized to canonical, and the
// conflict check before inserting a newly-bundled canonical slug.
func (r *Repository) ListNonSystemSkills(
ctx context.Context, workspaceID string,
) ([]*models.Skill, error) {
var skills []*models.Skill
err := r.ro.SelectContext(ctx, &skills, r.ro.Rebind(
`SELECT * FROM office_skills WHERE workspace_id = ? AND is_system = 0 ORDER BY slug`),
workspaceID)
if err != nil {
return nil, err
}
if skills == nil {
return []*models.Skill{}, nil
}
return skills, nil
}
// UpdateSkill updates an existing skill.
func (r *Repository) UpdateSkill(ctx context.Context, skill *models.Skill) error {
skill.UpdatedAt = time.Now().UTC()
@@ -141,6 +165,176 @@ func (r *Repository) UpdateSkill(ctx context.Context, skill *models.Skill) error
return err
}
// NormalizeSkillSlug atomically changes a non-system skill's slug and
// rewrites matching desired_skills references on all active office agents.
// The skill ID and old slug are both part of the update predicate, so a
// concurrent writer cannot cause references to be rewritten for a different
// row. It returns false when the row no longer matches that identity.
func (r *Repository) NormalizeSkillSlug(
ctx context.Context,
workspaceID, skillID, oldSlug, newSlug string,
) (bool, error) {
tx, err := r.db.BeginTxx(ctx, nil)
if err != nil {
return false, fmt.Errorf("begin skill slug normalization: %w", err)
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, tx.Rebind(`
UPDATE office_skills
SET slug = ?, updated_at = ?
WHERE id = ? AND workspace_id = ? AND slug = ? AND is_system = 0
`), newSlug, time.Now().UTC(), skillID, workspaceID, oldSlug)
if err != nil {
return false, err
}
updated, err := result.RowsAffected()
if err != nil {
return false, fmt.Errorf("check skill slug normalization: %w", err)
}
if updated == 0 {
return false, nil
}
updates, err := collectAgentDesiredSkillUpdates(ctx, tx, workspaceID, oldSlug, newSlug)
if err != nil {
return false, err
}
if err := applyAgentDesiredSkillUpdates(ctx, tx, workspaceID, updates); err != nil {
return false, err
}
if err := tx.Commit(); err != nil {
return false, fmt.Errorf("commit skill slug normalization: %w", err)
}
return true, nil
}
type agentDesiredSkillUpdate struct {
id string
original string
desired string
}
func collectAgentDesiredSkillUpdates(
ctx context.Context,
tx *sqlx.Tx,
workspaceID, oldSlug, newSlug string,
) ([]agentDesiredSkillUpdate, error) {
rows, err := tx.QueryxContext(ctx, tx.Rebind(`
SELECT id, COALESCE(desired_skills, '') AS desired_skills
FROM agent_profiles
WHERE workspace_id = ? AND deleted_at IS NULL
`), workspaceID)
if err != nil {
return nil, fmt.Errorf("list agents for skill slug normalization: %w", err)
}
defer func() { _ = rows.Close() }()
updates := make([]agentDesiredSkillUpdate, 0)
for rows.Next() {
var update agentDesiredSkillUpdate
if err := rows.Scan(&update.id, &update.original); err != nil {
return nil, fmt.Errorf("scan agent for skill slug normalization: %w", err)
}
update.desired, _ = replaceSkillSlugInAgentList(update.original, oldSlug, newSlug)
if update.desired != update.original {
updates = append(updates, update)
}
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("read agents for skill slug normalization: %w", err)
}
if err := rows.Close(); err != nil {
return nil, fmt.Errorf("close agents for skill slug normalization: %w", err)
}
return updates, nil
}
func applyAgentDesiredSkillUpdates(
ctx context.Context,
tx *sqlx.Tx,
workspaceID string,
updates []agentDesiredSkillUpdate,
) error {
now := time.Now().UTC()
for _, update := range updates {
result, err := tx.ExecContext(ctx, tx.Rebind(`
UPDATE agent_profiles
SET desired_skills = ?, updated_at = ?
WHERE id = ? AND workspace_id = ? AND deleted_at IS NULL
AND COALESCE(desired_skills, '') = ?
`), update.desired, now, update.id, workspaceID, update.original)
if err != nil {
return fmt.Errorf("update agent %s for skill slug normalization: %w", update.id, err)
}
rowsAffected, err := result.RowsAffected()
if err != nil {
return fmt.Errorf("check agent %s for skill slug normalization: %w", update.id, err)
}
if rowsAffected != 1 {
return fmt.Errorf("agent %s changed during skill slug normalization", update.id)
}
}
return nil
}
// replaceSkillSlugInAgentList mirrors the office desired_skills reader. It
// accepts both the canonical JSON-array format and the legacy CSV format, and
// emits a deduplicated JSON array only when a value changes.
func replaceSkillSlugInAgentList(raw, oldSlug, newSlug string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return raw, false
}
values, ok := parseAgentSkillList(raw)
if !ok {
return raw, false
}
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
changed := false
for _, value := range values {
if value == oldSlug {
value = newSlug
changed = true
}
if value == "" || seen[value] {
if value != "" {
changed = true
}
continue
}
seen[value] = true
out = append(out, value)
}
if !changed {
return raw, false
}
encoded, err := json.Marshal(out)
if err != nil {
return raw, false
}
return string(encoded), true
}
func parseAgentSkillList(raw string) ([]string, bool) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, false
}
return values, true
}
parts := strings.Split(raw, ",")
values := make([]string, len(parts))
for i, part := range parts {
values[i] = strings.TrimSpace(part)
}
return values, true
}
// SkillConfigFields is the subset of office_skills columns a config import
// owns (see UpdateSkillConfigFields).
type SkillConfigFields struct {
@@ -0,0 +1,123 @@
package sqlite_test
import (
"context"
"encoding/json"
"testing"
"github.com/kandev/kandev/internal/office/models"
)
func TestNormalizeSkillSlugAtomicallyUpdatesAgentReferences(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
skill := &models.Skill{
ID: "skill-1",
WorkspaceID: "ws-1",
Name: "Custom skill",
Slug: "custom-skill",
SourceType: "inline",
}
if err := repo.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create skill: %v", err)
}
agent := &models.AgentInstance{
ID: "agent-1",
WorkspaceID: "ws-1",
Name: "Agent",
DesiredSkills: `["custom-skill","other-skill","custom-skill"]`,
}
if err := repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
changed, err := repo.NormalizeSkillSlug(ctx, "ws-1", skill.ID, "custom-skill", "kandev-custom-skill")
if err != nil {
t.Fatalf("normalize skill slug: %v", err)
}
if !changed {
t.Fatal("normalize skill slug reported no change")
}
gotSkill, err := repo.GetSkillBySlug(ctx, "ws-1", "kandev-custom-skill")
if err != nil {
t.Fatalf("get normalized skill: %v", err)
}
if gotSkill.ID != skill.ID {
t.Fatalf("normalized skill ID = %q, want %q", gotSkill.ID, skill.ID)
}
if _, err := repo.GetSkillBySlug(ctx, "ws-1", "custom-skill"); err == nil {
t.Fatal("old skill slug still resolves after normalization")
}
gotAgent, err := repo.GetAgentInstance(ctx, agent.ID)
if err != nil {
t.Fatalf("get agent: %v", err)
}
var desired []string
if err := json.Unmarshal([]byte(gotAgent.DesiredSkills), &desired); err != nil {
t.Fatalf("decode desired_skills %q: %v", gotAgent.DesiredSkills, err)
}
want := []string{"kandev-custom-skill", "other-skill"}
if len(desired) != len(want) || desired[0] != want[0] || desired[1] != want[1] {
t.Fatalf("desired_skills = %v, want %v", desired, want)
}
}
func TestNormalizeSkillSlugRollsBackWhenCanonicalSlugIsTaken(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
oldSkill := &models.Skill{
ID: "skill-old",
WorkspaceID: "ws-1",
Name: "Custom skill",
Slug: "custom-skill",
SourceType: "inline",
}
takenSkill := &models.Skill{
ID: "skill-taken",
WorkspaceID: "ws-1",
Name: "Canonical skill",
Slug: "kandev-custom-skill",
SourceType: "inline",
}
for _, skill := range []*models.Skill{oldSkill, takenSkill} {
if err := repo.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create skill %s: %v", skill.ID, err)
}
}
agent := &models.AgentInstance{
ID: "agent-1",
WorkspaceID: "ws-1",
Name: "Agent",
DesiredSkills: `["custom-skill"]`,
}
if err := repo.CreateAgentInstance(ctx, agent); err != nil {
t.Fatalf("create agent: %v", err)
}
changed, err := repo.NormalizeSkillSlug(ctx, "ws-1", oldSkill.ID, "custom-skill", "kandev-custom-skill")
if err == nil {
t.Fatal("normalization should fail when the canonical slug is taken")
}
if changed {
t.Fatal("normalization reported a change after a rolled-back transaction")
}
gotOld, err := repo.GetSkillBySlug(ctx, "ws-1", "custom-skill")
if err != nil {
t.Fatalf("old skill row was not preserved: %v", err)
}
if gotOld.ID != oldSkill.ID {
t.Fatalf("old skill ID = %q, want %q", gotOld.ID, oldSkill.ID)
}
gotAgent, err := repo.GetAgentInstance(ctx, agent.ID)
if err != nil {
t.Fatalf("get agent: %v", err)
}
if gotAgent.DesiredSkills != agent.DesiredSkills {
t.Fatalf("desired_skills = %q after rollback, want %q", gotAgent.DesiredSkills, agent.DesiredSkills)
}
}
+23 -4
View File
@@ -8,6 +8,7 @@ import (
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/skillslug"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/shared"
@@ -25,6 +26,8 @@ type skillRepo interface {
GetSkillBySlug(ctx context.Context, workspaceID, slug string) (*models.Skill, error)
ListSkills(ctx context.Context, workspaceID string) ([]*models.Skill, error)
ListSystemSkills(ctx context.Context, workspaceID string) ([]*models.Skill, error)
ListNonSystemSkills(ctx context.Context, workspaceID string) ([]*models.Skill, error)
NormalizeSkillSlug(ctx context.Context, workspaceID, skillID, oldSlug, newSlug string) (bool, error)
UpdateSkill(ctx context.Context, skill *models.Skill) error
DeleteSkill(ctx context.Context, id string) error
@@ -130,13 +133,22 @@ func (s *SkillService) countSkillUsage(ctx context.Context) map[string]int {
// ValidateAndPrepareSkill validates and prepares a skill for creation.
// Call this before CreateSkill to auto-generate slug and validate uniqueness.
//
// Slug handling follows AC-001.11/AC-001.12: a caller-supplied slug must be
// well-formed or the request is rejected outright (no coercion); a
// well-formed slug is then normalized to its canonical (kandev-prefixed)
// form before the uniqueness check runs, so both the persisted slug and the
// duplicate check operate on the same canonical value.
func (s *SkillService) ValidateAndPrepareSkill(ctx context.Context, skill *models.Skill) error {
if skill.Name == "" {
return fmt.Errorf("skill name is required")
}
if skill.Slug == "" {
skill.Slug = GenerateSlug(skill.Name)
} else if !skillslug.WellFormed(skill.Slug) {
return fmt.Errorf("invalid skill slug %q: must contain only letters, digits, underscore, and hyphen", skill.Slug)
}
skill.Slug = skillslug.Normalize(skill.Slug)
if err := s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, ""); err != nil {
return err
}
@@ -156,12 +168,19 @@ func prepareSkillPackageMetadata(skill *models.Skill) {
)
}
// ValidateSkillUpdate validates a skill update for slug uniqueness.
// ValidateSkillUpdate validates a skill update for slug uniqueness. Slug
// handling mirrors ValidateAndPrepareSkill: reject a not-well-formed slug
// outright (AC-001.11), otherwise normalize to canonical before the
// uniqueness check (AC-001.12).
func (s *SkillService) ValidateSkillUpdate(ctx context.Context, skill *models.Skill) error {
if skill.Slug != "" {
return s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, skill.ID)
if skill.Slug == "" {
return nil
}
return nil
if !skillslug.WellFormed(skill.Slug) {
return fmt.Errorf("invalid skill slug %q: must contain only letters, digits, underscore, and hyphen", skill.Slug)
}
skill.Slug = skillslug.Normalize(skill.Slug)
return s.validateSlugUnique(ctx, skill.WorkspaceID, skill.Slug, skill.ID)
}
func (s *SkillService) validateSlugUnique(ctx context.Context, workspaceID, slug, excludeID string) error {
@@ -13,6 +13,7 @@ import (
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
settingsstore "github.com/kandev/kandev/internal/agent/settings/store"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
@@ -35,6 +36,10 @@ func newTestSkillService(t *testing.T) *skills.SkillService {
}
t.Cleanup(func() { _ = db.Close() })
if _, _, err := settingsstore.Provide(db, db, nil); err != nil {
t.Fatalf("provide settings store: %v", err)
}
repo, err := sqlite.NewWithDB(db, db, nil)
if err != nil {
t.Fatalf("new repo: %v", err)
@@ -86,8 +91,92 @@ func TestValidateAndPrepareSkill_AutoGeneratesSlug(t *testing.T) {
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if skill.Slug != "code-review" {
t.Errorf("slug = %q, want %q", skill.Slug, "code-review")
if skill.Slug != "kandev-code-review" {
t.Errorf("slug = %q, want %q", skill.Slug, "kandev-code-review")
}
}
// TestValidateAndPrepareSkill_NormalizesExplicitSlugToCanonical covers
// AC-001.12: a well-formed but non-canonical caller-supplied slug is
// normalized to canonical before persisting.
func TestValidateAndPrepareSkill_NormalizesExplicitSlugToCanonical(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{
WorkspaceID: "ws-1",
Name: "My Skill",
Slug: "my-skill",
SourceType: "inline",
}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if skill.Slug != "kandev-my-skill" {
t.Errorf("slug = %q, want %q", skill.Slug, "kandev-my-skill")
}
}
// TestValidateAndPrepareSkill_CanonicalSlugUnchanged covers AC-001.6: a
// slug that is already canonical is left byte-identical.
func TestValidateAndPrepareSkill_CanonicalSlugUnchanged(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{
WorkspaceID: "ws-1",
Name: "Already Canonical",
Slug: "kandev-already-canonical",
SourceType: "inline",
}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if skill.Slug != "kandev-already-canonical" {
t.Errorf("slug = %q, want %q", skill.Slug, "kandev-already-canonical")
}
}
// TestValidateAndPrepareSkill_RejectsNotWellFormedSlug covers AC-001.11: a
// not-well-formed caller-supplied slug is rejected outright, never coerced.
func TestValidateAndPrepareSkill_RejectsNotWellFormedSlug(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{
WorkspaceID: "ws-1",
Name: "Bad Slug",
Slug: "not a valid slug!",
SourceType: "inline",
}
err := svc.ValidateAndPrepareSkill(ctx, skill)
if err == nil {
t.Fatal("expected error for not-well-formed slug")
}
if skill.Slug != "not a valid slug!" {
t.Errorf("slug was coerced from its original input: %q", skill.Slug)
}
}
// TestValidateAndPrepareSkill_UniquenessCheckedOnCanonicalValue covers the
// second half of AC-001.12: a request whose canonical slug already exists
// is rejected, even when the request itself supplied the non-canonical
// form.
func TestValidateAndPrepareSkill_UniquenessCheckedOnCanonicalValue(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
existing := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-my-skill", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, existing); err != nil {
t.Fatalf("validate existing: %v", err)
}
if err := svc.CreateSkill(ctx, existing); err != nil {
t.Fatalf("create existing: %v", err)
}
colliding := &models.Skill{WorkspaceID: "ws-1", Name: "Colliding", Slug: "my-skill", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, colliding); err == nil {
t.Fatal("expected duplicate slug error against the canonical form")
}
}
@@ -143,6 +232,47 @@ func TestValidateAndPrepareSkill_RejectsSameSlugInSameWorkspace(t *testing.T) {
}
}
// --- ValidateSkillUpdate tests ---
func TestValidateSkillUpdate_NormalizesWellFormedSlugToCanonical(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
skill.Slug = "renamed"
if err := svc.ValidateSkillUpdate(ctx, skill); err != nil {
t.Fatalf("update: %v", err)
}
if skill.Slug != "kandev-renamed" {
t.Errorf("slug = %q, want %q", skill.Slug, "kandev-renamed")
}
}
func TestValidateSkillUpdate_RejectsNotWellFormedSlug(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
skill := &models.Skill{WorkspaceID: "ws-1", Name: "Existing", Slug: "kandev-existing", SourceType: "inline"}
if err := svc.ValidateAndPrepareSkill(ctx, skill); err != nil {
t.Fatalf("validate: %v", err)
}
if err := svc.CreateSkill(ctx, skill); err != nil {
t.Fatalf("create: %v", err)
}
skill.Slug = "not a valid slug!"
if err := svc.ValidateSkillUpdate(ctx, skill); err == nil {
t.Fatal("expected error for not-well-formed slug")
}
}
func TestValidateAndPrepareSkill_RejectsInvalidSourceType(t *testing.T) {
svc := newTestSkillService(t)
ctx := context.Background()
@@ -167,24 +297,27 @@ func TestListSkillsWithUsage(t *testing.T) {
t.Fatalf("create: %v", err)
}
// The lazy system-skill sync fires on the first ListSkills call
// for a workspace, populating bundled system rows alongside the
// user skill created above. The test asserts the *user* skill
// appears with the correct usage count; the system rows are
// validated separately in system_sync_test.go.
// The lazy system-skill sync fires on the first ListSkills call for
// a workspace, populating bundled system rows alongside the user
// skill created above. It also normalizes the well-formed but
// non-canonical "my-skill" slug to "kandev-my-skill" (AC-003.8),
// so the test looks for the row under its normalized slug. The
// normalization mechanics themselves are covered in
// system_sync_migration_test.go; this test only asserts the *user*
// skill survives sync with the correct usage count.
list, err := svc.ListSkillsWithUsage(ctx, "ws-1")
if err != nil {
t.Fatalf("list: %v", err)
}
var userRow *skills.SkillWithUsage
for _, row := range list {
if row.Slug == "my-skill" {
if row.Slug == "kandev-my-skill" {
userRow = row
break
}
}
if userRow == nil {
t.Fatalf("user skill not found in list of %d rows", len(list))
t.Fatalf("normalized user skill not found in list of %d rows", len(list))
}
if userRow.UsedByCount != 0 {
t.Errorf("used_by_count = %d, want 0", userRow.UsedByCount)
@@ -5,16 +5,20 @@ import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"sync"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgconn"
"go.uber.org/zap"
"gopkg.in/yaml.v3"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/common/skillslug"
"github.com/kandev/kandev/internal/office/configloader"
"github.com/kandev/kandev/internal/office/models"
)
@@ -34,6 +38,7 @@ var retiredDefaultSkillReplacements = map[string]string{
"kandev-task-comment": "kandev-task-ops",
"kandev-tasks": "kandev-task-ops",
"kandev-team": "kandev-team-admin",
"memory": "kandev-memory",
}
// SystemSkillSpec is the parsed view of a single embedded SKILL.md
@@ -61,6 +66,18 @@ type SystemSyncRepo interface {
UpdateSkill(ctx context.Context, skill *models.Skill) error
DeleteSkill(ctx context.Context, id string) error
// ListNonSystemSkills returns every user/provider-imported row
// (is_system = false) in the workspace. Used by the bundled-insert
// slug-conflict pre-check (AC-003.3) and the user-skill slug
// normalization pass (AC-003.8/AC-003.9).
ListNonSystemSkills(ctx context.Context, workspaceID string) ([]*models.Skill, error)
// NormalizeSkillSlug atomically changes a non-system skill's slug and
// rewrites all matching agent desired_skills references. It returns
// false when the row no longer matches the supplied identity, which
// means another writer already handled or removed it.
NormalizeSkillSlug(ctx context.Context, workspaceID, skillID, oldSlug, newSlug string) (bool, error)
// Agent-profile access used to scrub a deleted system skill's ID
// out of every agent_profiles.skill_ids JSON array in the same
// workspace, so retiring a bundled skill doesn't leave dangling
@@ -72,10 +89,44 @@ type SystemSyncRepo interface {
// SyncReport summarises one sync pass across all workspaces. The
// startup caller surfaces this as a single log line so operators can
// see exactly which slugs landed where after a kandev upgrade.
//
// Every per-workspace list is sorted lexicographically by slug; the
// scoped `<workspace_id>:<slug>` entries in the aggregate report are
// sorted by workspace ID then slug (AC-003.5).
type SyncReport struct {
Inserted []string
Updated []string
Removed []string
// Blocked lists retired system skills whose configured replacement
// was not found in this workspace this pass (e.g. its insert was
// withheld by a slug conflict). The retired row is left in place
// rather than deleted, so no agent silently loses the capability
// (AC-003.1); a later pass retries once the conflict resolves.
Blocked []string
// Normalized lists user-skill slugs rewritten from a well-formed
// but non-canonical form to their canonical kandev-prefixed form,
// as "<old>->new>" entries (AC-003.8).
Normalized []string
// Conflicted lists slugs left untouched because normalizing (or
// inserting a bundled row) would collide with another row's
// current slug (AC-003.3/AC-003.9).
Conflicted []string
}
// workspaceSyncLocks serializes SyncSystemSkills passes per
// workspace. SyncSystemSkills has multiple concurrent call sites in
// one process (lazy per-workspace sync, startup sync, config import,
// etc.); a pass that can't acquire the lock waits rather than
// skipping, so two concurrent passes over the same workspace never
// interleave their insert/retire/normalize phases (AC-003.7).
var workspaceSyncLocks sync.Map // map[string]*sync.Mutex
func lockForWorkspace(wsID string) *sync.Mutex {
v, _ := workspaceSyncLocks.LoadOrStore(wsID, &sync.Mutex{})
return v.(*sync.Mutex)
}
// SyncSystemSkills idempotently reconciles the office_skills table
@@ -112,45 +163,123 @@ func SyncSystemSkills(
var report SyncReport
for _, wsID := range workspaceIDs {
ins, upd, rem, err := syncWorkspace(ctx, repo, wsID, bundledBySlug)
result, err := syncWorkspace(ctx, repo, wsID, bundledBySlug, log)
if err != nil {
log.Error("system skill sync failed for workspace",
zap.String("workspace_id", wsID), zap.Error(err))
continue
}
report.Inserted = append(report.Inserted, scope(wsID, ins)...)
report.Updated = append(report.Updated, scope(wsID, upd)...)
report.Removed = append(report.Removed, scope(wsID, rem)...)
report.Inserted = append(report.Inserted, scope(wsID, result.Inserted)...)
report.Updated = append(report.Updated, scope(wsID, result.Updated)...)
report.Removed = append(report.Removed, scope(wsID, result.Removed)...)
report.Blocked = append(report.Blocked, scope(wsID, result.Blocked)...)
report.Normalized = append(report.Normalized, scope(wsID, result.Normalized)...)
report.Conflicted = append(report.Conflicted, scope(wsID, result.Conflicted)...)
}
sort.Strings(report.Inserted)
sort.Strings(report.Updated)
sort.Strings(report.Removed)
sort.Strings(report.Blocked)
sort.Strings(report.Normalized)
sort.Strings(report.Conflicted)
log.Info("system skills synced",
zap.Int("workspaces", len(workspaceIDs)),
zap.Int("bundled", len(specs)),
zap.Strings("inserted", report.Inserted),
zap.Strings("updated", report.Updated),
zap.Strings("removed", report.Removed),
zap.Strings("blocked", report.Blocked),
zap.Strings("normalized", report.Normalized),
zap.Strings("conflicted", report.Conflicted),
)
return report, nil
}
// syncWorkspace handles one workspace. Returns the (insert, update,
// remove) slug lists for the report. Errors propagate; the caller
// logs and continues to the next workspace so one bad row doesn't
// gate the rest.
// workspaceSyncResult carries one workspace's sync outcome between
// syncWorkspace's phases and back to SyncSystemSkills, which scopes
// each list into the aggregate SyncReport.
type workspaceSyncResult struct {
Inserted []string
Updated []string
Removed []string
Blocked []string
Normalized []string
Conflicted []string
}
// syncWorkspace handles one workspace: reconcile bundled system
// skills (insert/update), retire orphaned system rows, then normalize
// non-canonical user-skill slugs. Errors propagate; the caller logs
// and continues to the next workspace so one bad row doesn't gate the
// rest. Holds the per-workspace lock for the whole pass so concurrent
// SyncSystemSkills call sites never interleave these phases
// (AC-003.7).
func syncWorkspace(
ctx context.Context,
repo SystemSyncRepo,
wsID string,
bundled map[string]SystemSkillSpec,
) (inserted, updated, removed []string, err error) {
log *logger.Logger,
) (workspaceSyncResult, error) {
mu := lockForWorkspace(wsID)
mu.Lock()
defer mu.Unlock()
var result workspaceSyncResult
existing, err := repo.ListSystemSkills(ctx, wsID)
if err != nil {
return nil, nil, nil, err
return result, err
}
existingBySlug := make(map[string]*models.Skill, len(existing))
for _, s := range existing {
existingBySlug[s.Slug] = s
}
nonSystem, err := repo.ListNonSystemSkills(ctx, wsID)
if err != nil {
return result, fmt.Errorf("list non-system skills: %w", err)
}
nonSystemBySlug := make(map[string]*models.Skill, len(nonSystem))
for _, s := range nonSystem {
nonSystemBySlug[s.Slug] = s
}
if err := reconcileBundledSkills(ctx, repo, wsID, bundled, existingBySlug, nonSystemBySlug, &result); err != nil {
return result, err
}
if err := retireOrphanedSystemSkills(ctx, repo, wsID, bundled, existingBySlug, &result); err != nil {
return result, err
}
if err := normalizeUserSkillSlugs(ctx, repo, wsID, nonSystem, existingBySlug, nonSystemBySlug, &result, log); err != nil {
return result, err
}
sort.Strings(result.Inserted)
sort.Strings(result.Updated)
sort.Strings(result.Removed)
sort.Strings(result.Blocked)
sort.Strings(result.Normalized)
sort.Strings(result.Conflicted)
return result, nil
}
// reconcileBundledSkills walks the bundled spec set in sorted order,
// inserting missing rows and updating drifted ones. A bundled slug
// not yet present as a system row but already held by a non-system
// row is skipped and recorded as a conflict rather than inserted
// (AC-003.3); the CreateSkill unique-constraint fallback below covers
// the same outcome for the race between this pre-check and the
// insert.
func reconcileBundledSkills(
ctx context.Context,
repo SystemSyncRepo,
wsID string,
bundled map[string]SystemSkillSpec,
existingBySlug map[string]*models.Skill,
nonSystemBySlug map[string]*models.Skill,
result *workspaceSyncResult,
) error {
// Walk bundled slugs in sorted order so log output is stable.
slugs := make([]string, 0, len(bundled))
for slug := range bundled {
@@ -162,12 +291,20 @@ func syncWorkspace(
spec := bundled[slug]
cur, ok := existingBySlug[slug]
if !ok {
if _, taken := nonSystemBySlug[slug]; taken {
result.Conflicted = append(result.Conflicted, slug)
continue
}
row := newSystemSkillRow(wsID, spec)
if err := repo.CreateSkill(ctx, row); err != nil {
return inserted, updated, removed, fmt.Errorf("insert %s: %w", slug, err)
if isSlugUniqueConstraintErr(err) {
result.Conflicted = append(result.Conflicted, slug)
continue
}
return fmt.Errorf("insert %s: %w", slug, err)
}
existingBySlug[slug] = row
inserted = append(inserted, slug)
result.Inserted = append(result.Inserted, slug)
continue
}
if systemSkillUpToDate(cur, spec) {
@@ -175,37 +312,162 @@ func syncWorkspace(
}
applySystemSkillUpdate(cur, spec)
if err := repo.UpdateSkill(ctx, cur); err != nil {
return inserted, updated, removed, fmt.Errorf("update %s: %w", slug, err)
return fmt.Errorf("update %s: %w", slug, err)
}
updated = append(updated, slug)
result.Updated = append(result.Updated, slug)
}
return nil
}
for slug, cur := range existingBySlug {
// retireOrphanedSystemSkills deletes system rows whose slug is no
// longer in the bundle. A retirement with a configured replacement
// (retiredDefaultSkillReplacements) only proceeds once that
// replacement row actually exists in this workspace: deleting the
// retired row first would strand every agent that referenced it with
// no rewritten reference (AC-003.1). When the replacement is missing
// this pass (e.g. its insert was withheld by a slug conflict above),
// the retired row is left in place and reported as blocked so a later
// pass can retry once the conflict resolves.
//
// A retired row is removed from existingBySlug along with the DB row:
// normalizeUserSkillSlugs runs after this pass and treats existingBySlug
// as the set of slugs currently taken, so a stale entry for a slug this
// same pass just freed would report a false conflict for a user skill
// that could otherwise normalize onto it.
func retireOrphanedSystemSkills(
ctx context.Context,
repo SystemSyncRepo,
wsID string,
bundled map[string]SystemSkillSpec,
existingBySlug map[string]*models.Skill,
result *workspaceSyncResult,
) error {
// The map is a snapshot, but the order of retirement still matters when
// one retired slug names another as its replacement. Walk it in a stable
// order so the outcome does not depend on Go's map iteration seed.
slugs := make([]string, 0, len(existingBySlug))
for slug := range existingBySlug {
slugs = append(slugs, slug)
}
sort.Strings(slugs)
for _, slug := range slugs {
cur, present := existingBySlug[slug]
if !present {
continue
}
if _, kept := bundled[slug]; kept {
continue
}
if replacement := replacementSystemSkill(existingBySlug, slug); replacement != nil {
replacementSlug, hasReplacement := retiredDefaultSkillReplacements[slug]
if hasReplacement {
if _, bundledReplacement := bundled[replacementSlug]; !bundledReplacement {
result.Blocked = append(result.Blocked, slug)
continue
}
replacement, found := existingBySlug[replacementSlug]
if !found {
result.Blocked = append(result.Blocked, slug)
continue
}
if err := replaceSkillOnAgents(ctx, repo, wsID, cur, replacement); err != nil {
return inserted, updated, removed, fmt.Errorf("replace %s: %w", slug, err)
return fmt.Errorf("replace %s: %w", slug, err)
}
}
if err := repo.DeleteSkill(ctx, cur.ID); err != nil {
return inserted, updated, removed, fmt.Errorf("delete %s: %w", slug, err)
return fmt.Errorf("delete %s: %w", slug, err)
}
if err := detachSkillFromAgents(ctx, repo, wsID, cur.ID); err != nil {
return inserted, updated, removed, fmt.Errorf("detach %s: %w", slug, err)
return fmt.Errorf("detach %s: %w", slug, err)
}
removed = append(removed, slug)
delete(existingBySlug, slug)
result.Removed = append(result.Removed, slug)
}
return inserted, updated, removed, nil
return nil
}
func replacementSystemSkill(skills map[string]*models.Skill, retiredSlug string) *models.Skill {
replacementSlug, ok := retiredDefaultSkillReplacements[retiredSlug]
if !ok {
return nil
// normalizeUserSkillSlugs rewrites a well-formed but non-canonical
// user/provider skill slug to its canonical kandev-prefixed form
// (AC-003.8). The row ID is preserved, so skill_ids needs no
// rewrite — only slug-keyed desired_skills references are updated. A
// not-well-formed slug (a legacy artifact predating write-time
// validation) is left untouched and logged, never normalized
// (AC-003.9). A normalized value already held by another row is left
// untouched on both sides and logged as a conflict.
//
// The repository performs the row rename and agent-reference rewrite in
// one transaction. This prevents a concurrent writer from claiming the
// canonical slug after the snapshot and leaving references pointed at a
// different row, while also preserving retryability after a failed write.
func normalizeUserSkillSlugs(
ctx context.Context,
repo SystemSyncRepo,
wsID string,
nonSystem []*models.Skill,
existingBySlug map[string]*models.Skill,
nonSystemBySlug map[string]*models.Skill,
result *workspaceSyncResult,
log *logger.Logger,
) error {
taken := make(map[string]bool, len(existingBySlug)+len(nonSystemBySlug))
for slug := range existingBySlug {
taken[slug] = true
}
return skills[replacementSlug]
for slug := range nonSystemBySlug {
taken[slug] = true
}
for _, row := range nonSystem {
if skillslug.Canonical(row.Slug) {
continue
}
if !skillslug.WellFormed(row.Slug) {
log.Warn("system skill sync: leaving not-well-formed user skill slug untouched",
zap.String("workspace_id", wsID), zap.String("skill_id", row.ID), zap.String("slug", row.Slug))
continue
}
normalized := skillslug.Normalize(row.Slug)
if taken[normalized] {
log.Warn("system skill sync: leaving conflicting user skill slug unnormalized",
zap.String("workspace_id", wsID), zap.String("skill_id", row.ID),
zap.String("slug", row.Slug), zap.String("normalized_slug", normalized))
result.Conflicted = append(result.Conflicted, row.Slug)
continue
}
oldSlug := row.Slug
changed, err := repo.NormalizeSkillSlug(ctx, wsID, row.ID, oldSlug, normalized)
if err != nil {
if isSlugUniqueConstraintErr(err) {
result.Conflicted = append(result.Conflicted, oldSlug)
continue
}
return fmt.Errorf("normalize slug %s: %w", oldSlug, err)
}
if !changed {
continue
}
taken[normalized] = true
result.Normalized = append(result.Normalized, oldSlug+"->"+normalized)
}
return nil
}
// isSlugUniqueConstraintErr reports whether err is a UNIQUE constraint
// violation for a skill slug. SQLite's driver requires string matching,
// while pgx exposes a typed SQLSTATE 23505 error. The PostgreSQL
// constraint-name check avoids treating an unrelated duplicate (for
// example, a primary-key collision) as a slug conflict.
func isSlugUniqueConstraintErr(err error) bool {
if err == nil {
return false
}
var pgErr *pgconn.PgError
if errors.As(err, &pgErr) {
return pgErr.Code == "23505" &&
(pgErr.ConstraintName == "" || pgErr.ConstraintName == "office_skills_workspace_id_slug_key")
}
msg := err.Error()
return strings.Contains(msg, "UNIQUE constraint failed") ||
strings.Contains(msg, "constraint failed: UNIQUE")
}
func replaceSkillOnAgents(
@@ -273,13 +535,22 @@ func systemSkillUpToDate(cur *models.Skill, spec SystemSkillSpec) bool {
cur.SystemVersion == spec.Version
}
// replaceJSONArrayValue rewrites every occurrence of oldValue to
// newValue in a JSON-array-encoded agent_profiles column, also
// de-duplicating and dropping empties. raw may also be a legacy
// comma-separated value (see ParseDesiredSlugs in injection.go, which
// reads both formats); a legacy value is parsed the same way and, if
// changed, re-encoded as the canonical JSON-array form — this is how
// a legacy desired_skills column migrates format the next time a
// rename or replacement touches one of its entries. An unchanged
// legacy value is left exactly as found (no unnecessary rewrite).
func replaceJSONArrayValue(raw, oldValue, newValue string) (string, bool) {
raw = strings.TrimSpace(raw)
if raw == "" || raw == "null" {
return raw, false
}
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
values, ok := parseJSONOrLegacyCSVArray(raw)
if !ok {
return raw, false
}
out := make([]string, 0, len(values))
@@ -309,6 +580,28 @@ func replaceJSONArrayValue(raw, oldValue, newValue string) (string, bool) {
return string(encoded), true
}
// parseJSONOrLegacyCSVArray parses raw as a JSON string array, falling
// back to splitting it as a legacy comma-separated value when raw
// isn't a JSON array. Mirrors ParseDesiredSlugs's format detection
// (injection.go) so both readers of these columns agree on what
// counts as "legacy". Returns ok=false only for malformed JSON input
// (a corrupt column stays a safe no-op for the caller).
func parseJSONOrLegacyCSVArray(raw string) ([]string, bool) {
if strings.HasPrefix(raw, "[") {
var values []string
if err := json.Unmarshal([]byte(raw), &values); err != nil {
return nil, false
}
return values, true
}
parts := strings.Split(raw, ",")
out := make([]string, len(parts))
for i, p := range parts {
out[i] = strings.TrimSpace(p)
}
return out, true
}
// removeIDFromJSONArray parses a JSON-array string, removes every
// occurrence of `id`, and returns the re-encoded array along with a
// flag indicating whether anything was removed. Malformed input is
@@ -0,0 +1,574 @@
package skills_test
import (
"context"
"strings"
"testing"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/skills"
)
// TestSyncSystemSkills_ReplacesRetiredMemorySkillReference pins the
// concrete REQ-003 migration case this feature adds: once the bundled
// `memory` skill is renamed to `kandev-memory` for naming-scheme
// consistency (pending, tracked separately), a workspace with the old
// row must retire it and end up with the new canonical slug in its
// place. This test injects a synthetic bundled spec to exercise that
// path ahead of the real rename.
func TestSyncSystemSkills_ReplacesRetiredMemorySkillReference(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"memory": {
ID: "skill-old-memory",
WorkspaceID: "ws-1",
Slug: "memory",
Name: "Memory",
IsSystem: true,
SourceType: skills.SourceTypeSystem,
},
}
bundled := []skills.SystemSkillSpec{{
Slug: "kandev-memory",
Name: "Memory",
Description: "Memory guidance",
Version: "1.0.0",
Content: "---\nname: kandev-memory\ndescription: Memory guidance\n---\n# Memory\n",
ContentHash: "hash-kandev-memory",
}}
report, err := skills.SyncSystemSkills(context.Background(), repo, []string{"ws-1"}, bundled, log)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Removed) != 1 || !strings.HasSuffix(report.Removed[0], "memory") {
t.Fatalf("expected retirement of memory, got removed=%v", report.Removed)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "memory"); err == nil {
t.Error("retired memory row still present after sync")
}
got, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-memory")
if err != nil {
t.Fatalf("replacement kandev-memory missing: %v", err)
}
if !got.IsSystem {
t.Error("kandev-memory replacement missing is_system flag")
}
}
// TestSyncSystemSkills_RetirementBlockedWhenReplacementMissing pins the
// F19-round fix: a retired skill with a configured replacement must
// not be deleted (and its agent references detached) until the
// replacement row actually exists in the workspace. Deleting first
// would strand every agent that referenced it with no rewritten
// reference. When the replacement did not land this pass, the row
// stays in place and is reported as blocked for a later pass to
// retry.
func TestSyncSystemSkills_RetirementBlockedWhenReplacementMissing(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
const oldTasksID = "skill-old-tasks"
repo.rows["ws-1"] = map[string]*models.Skill{
"kandev-tasks": {
ID: oldTasksID,
WorkspaceID: "ws-1",
Slug: "kandev-tasks",
Name: "Tasks",
IsSystem: true,
SourceType: skills.SourceTypeSystem,
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
SkillIDs: mustJSONArray(t, []string{oldTasksID}),
DesiredSkills: mustJSONArray(t, []string{"kandev-tasks"}),
},
}
// kandev-task-ops (the configured replacement for kandev-tasks) is
// neither an existing row nor part of this pass's bundle, so it
// cannot be created this pass either.
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Removed) != 0 {
t.Fatalf("expected no removal while replacement is missing, got %v", report.Removed)
}
if len(report.Blocked) != 1 || !strings.HasSuffix(report.Blocked[0], "kandev-tasks") {
t.Fatalf("expected kandev-tasks reported as blocked, got %v", report.Blocked)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-tasks"); err != nil {
t.Error("blocked retirement must leave the office_skills row in place")
}
agent1 := repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.SkillIDs); len(got) != 1 || got[0] != oldTasksID {
t.Errorf("blocked retirement must leave agent skill_ids untouched, got %v", got)
}
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "kandev-tasks" {
t.Errorf("blocked retirement must leave agent desired_skills untouched, got %v", got)
}
}
// TestSyncSystemSkills_RetirementBlockedWhenReplacementIsNotBundled ensures
// an old system row is not retired merely because a replacement row happens
// to exist in the database. The replacement must be part of the current
// bundle; otherwise this pass can delete both rows and strand references.
func TestSyncSystemSkills_RetirementBlockedWhenReplacementIsNotBundled(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
const oldTasksID = "skill-old-tasks"
repo.rows["ws-1"] = map[string]*models.Skill{
"kandev-tasks": {
ID: oldTasksID,
WorkspaceID: "ws-1",
Slug: "kandev-tasks",
Name: "Tasks",
IsSystem: true,
SourceType: skills.SourceTypeSystem,
},
"kandev-task-ops": {
ID: "skill-task-ops",
WorkspaceID: "ws-1",
Slug: "kandev-task-ops",
Name: "Task operations",
IsSystem: true,
SourceType: skills.SourceTypeSystem,
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
SkillIDs: mustJSONArray(t, []string{oldTasksID}),
DesiredSkills: mustJSONArray(t, []string{"kandev-tasks"}),
},
}
// Neither existing row is present in this pass's bundle. The configured
// replacement is therefore not a valid destination for retirement.
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Blocked) != 1 || !strings.HasSuffix(report.Blocked[0], "kandev-tasks") {
t.Fatalf("expected kandev-tasks reported as blocked, got %v", report.Blocked)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-tasks"); err != nil {
t.Fatalf("blocked retired row must remain: %v", err)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-task-ops"); err == nil {
t.Fatal("unbundled replacement row should be retired independently")
}
agent1 := repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.SkillIDs); len(got) != 1 || got[0] != oldTasksID {
t.Errorf("blocked retirement must leave agent skill_ids untouched, got %v", got)
}
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "kandev-tasks" {
t.Errorf("blocked retirement must leave agent desired_skills untouched, got %v", got)
}
}
// TestSyncSystemSkills_BundledInsertConflictsWithExistingUserSkill pins
// AC-003.3: a bundled slug not yet backed by a system row, but already
// held by a non-system (user or provider-imported) row, must not be
// inserted over that row. It is skipped and recorded as a conflict so
// the mismatch is visible without silently overwriting user data.
func TestSyncSystemSkills_BundledInsertConflictsWithExistingUserSkill(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"kandev-custom-thing": {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: "kandev-custom-thing",
Name: "My Custom Thing",
IsSystem: false,
SourceType: "inline",
},
}
bundled := []skills.SystemSkillSpec{{
Slug: "kandev-custom-thing",
Name: "Custom Thing",
Description: "Bundled version",
Version: "1.0.0",
Content: "---\nname: kandev-custom-thing\ndescription: Bundled version\n---\n# Custom Thing\n",
ContentHash: "hash-custom-thing",
}}
report, err := skills.SyncSystemSkills(context.Background(), repo, []string{"ws-1"}, bundled, log)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Inserted) != 0 {
t.Fatalf("expected no insert over an existing user skill, got %v", report.Inserted)
}
if len(report.Conflicted) != 1 || !strings.HasSuffix(report.Conflicted[0], "kandev-custom-thing") {
t.Fatalf("expected kandev-custom-thing reported as conflicted, got %v", report.Conflicted)
}
got, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-custom-thing")
if err != nil {
t.Fatalf("existing user row missing after sync: %v", err)
}
if got.IsSystem {
t.Error("existing user row must not become a system row")
}
if got.ID != "user-skill-1" {
t.Errorf("existing user row ID changed: now %s", got.ID)
}
}
// TestSyncSystemSkills_NormalizesWellFormedUserSlugToCanonical pins
// AC-003.8: a well-formed but non-canonical user/provider skill slug
// is rewritten to its canonical kandev- prefixed form in place,
// preserving the row ID, and every agent's desired_skills reference
// is rewritten to match.
func TestSyncSystemSkills_NormalizesWellFormedUserSlugToCanonical(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"my-custom-skill": {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: "my-custom-skill",
Name: "My Custom Skill",
IsSystem: false,
SourceType: "inline",
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
DesiredSkills: mustJSONArray(t, []string{"my-custom-skill"}),
},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 1 || !strings.HasSuffix(report.Normalized[0], "my-custom-skill->kandev-my-custom-skill") {
t.Fatalf("expected my-custom-skill normalized, got %v", report.Normalized)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "my-custom-skill"); err == nil {
t.Error("old slug should no longer resolve after normalization")
}
got, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-my-custom-skill")
if err != nil {
t.Fatalf("normalized row missing: %v", err)
}
if got.ID != "user-skill-1" {
t.Errorf("normalization must preserve row ID, got %s", got.ID)
}
agent1 := repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "kandev-my-custom-skill" {
t.Errorf("agent-1.desired_skills = %v, want [kandev-my-custom-skill]", got)
}
}
// TestSyncSystemSkills_LeavesNotWellFormedUserSlugUntouched pins
// AC-003.9: a legacy row whose slug predates write-time validation
// and is not well-formed (contains characters outside the safe path
// component set) must never be normalized — there is no safe
// canonical rewrite for it, so the sync leaves it exactly as it was
// found.
func TestSyncSystemSkills_LeavesNotWellFormedUserSlugUntouched(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
const badSlug = "legacy slug!"
repo.rows["ws-1"] = map[string]*models.Skill{
badSlug: {
ID: "user-skill-legacy",
WorkspaceID: "ws-1",
Slug: badSlug,
Name: "Legacy",
IsSystem: false,
SourceType: "inline",
},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 0 {
t.Fatalf("not-well-formed slug must never be normalized, got %v", report.Normalized)
}
if len(report.Conflicted) != 0 {
t.Fatalf("not-well-formed slug is not a conflict, got %v", report.Conflicted)
}
got, err := repo.GetSkillBySlug(context.Background(), "ws-1", badSlug)
if err != nil {
t.Fatalf("legacy row missing after sync: %v", err)
}
if got.Slug != badSlug {
t.Errorf("legacy row slug changed: now %q", got.Slug)
}
}
// TestSyncSystemSkills_LeavesConflictingNormalizedUserSlugUntouched
// pins the collision branch of AC-003.8: when the canonical form a
// well-formed slug would normalize to is already held by another row,
// neither row is touched and the collision is reported so an operator
// can resolve it manually.
func TestSyncSystemSkills_LeavesConflictingNormalizedUserSlugUntouched(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"my-thing": {
ID: "user-skill-a",
WorkspaceID: "ws-1",
Slug: "my-thing",
Name: "My Thing (legacy)",
IsSystem: false,
SourceType: "inline",
},
"kandev-my-thing": {
ID: "user-skill-b",
WorkspaceID: "ws-1",
Slug: "kandev-my-thing",
Name: "My Thing (canonical)",
IsSystem: false,
SourceType: "inline",
},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 0 {
t.Fatalf("colliding slug must not be normalized, got %v", report.Normalized)
}
if len(report.Conflicted) != 1 || !strings.HasSuffix(report.Conflicted[0], "my-thing") {
t.Fatalf("expected my-thing reported as conflicted, got %v", report.Conflicted)
}
a, err := repo.GetSkillBySlug(context.Background(), "ws-1", "my-thing")
if err != nil || a.ID != "user-skill-a" {
t.Errorf("legacy row must stay at its original slug, got %+v err=%v", a, err)
}
b, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-my-thing")
if err != nil || b.ID != "user-skill-b" {
t.Errorf("canonical row must be untouched, got %+v err=%v", b, err)
}
}
// TestSyncSystemSkills_NormalizationRetriesAfterFailedReferenceRewrite
// pins the recovery contract in
// docs/specs/agents/system-design/injected-skill-naming-migration.md
// ("## Failure and recovery"): a normalization interrupted between the
// row rename and the agent-reference rewrite must be fully retried on
// the next pass, not silently left half-done. If the row were renamed
// to its canonical slug before the agent-reference rewrite is
// confirmed, a later pass would see the row as already canonical and
// skip it forever, stranding the stale agent reference.
func TestSyncSystemSkills_NormalizationRetriesAfterFailedReferenceRewrite(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"my-custom-skill": {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: "my-custom-skill",
Name: "My Custom Skill",
IsSystem: false,
SourceType: "inline",
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
DesiredSkills: mustJSONArray(t, []string{"my-custom-skill"}),
},
}
repo.failUpdateAgentFor["agent-1"] = true
// SyncSystemSkills logs and continues past a per-workspace failure
// (so one bad workspace doesn't block the rest) rather than
// returning it, so the top-level call succeeds; the failure is
// checked via the row/agent state left behind instead.
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 0 {
t.Fatalf("normalization must not be reported while the reference rewrite failed, got %v", report.Normalized)
}
// The row must NOT have been committed to its canonical slug: a
// half-done normalization (row renamed, reference not rewritten)
// is exactly the state the recovery contract forbids, because the
// canonical-slug gate would then skip this row on every later pass.
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "my-custom-skill"); err != nil {
t.Fatalf("row must stay at its original slug after a failed rewrite, got err=%v", err)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-my-custom-skill"); err == nil {
t.Fatal("row must not be committed to its canonical slug before the reference rewrite succeeds")
}
agent1 := repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "my-custom-skill" {
t.Errorf("agent-1.desired_skills must stay untouched after a failed rewrite, got %v", got)
}
// A later pass, with the transient failure gone, must fully
// converge: this is the "next pass repeats the reference rewrite"
// half of the recovery contract.
repo.failUpdateAgentFor["agent-1"] = false
report, err = skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("retry sync: %v", err)
}
if len(report.Normalized) != 1 || !strings.HasSuffix(report.Normalized[0], "my-custom-skill->kandev-my-custom-skill") {
t.Fatalf("expected my-custom-skill normalized on retry, got %v", report.Normalized)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-my-custom-skill"); err != nil {
t.Fatalf("row must be canonical after the retry succeeds: %v", err)
}
agent1 = repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != "kandev-my-custom-skill" {
t.Errorf("agent-1.desired_skills = %v, want [kandev-my-custom-skill] after retry", got)
}
}
// TestSyncSystemSkills_RetiredSystemSlugFreedForSamePassNormalization
// pins a sequencing edge in syncWorkspace: retireOrphanedSystemSkills
// runs before normalizeUserSkillSlugs, and a system row it retires in
// this same pass must not block a user skill from normalizing onto
// that now-freed slug. Treating the just-deleted row as still "taken"
// would report a false conflict for a slug that is actually free.
func TestSyncSystemSkills_RetiredSystemSlugFreedForSamePassNormalization(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
// A stale system row for a slug no longer in the bundle, with
// no configured replacement, so it retires outright this pass.
"kandev-legacy-widget": {
ID: "system-skill-legacy",
WorkspaceID: "ws-1",
Slug: "kandev-legacy-widget",
Name: "Legacy Widget",
IsSystem: true,
SourceType: skills.SourceTypeSystem,
},
// A user skill whose canonical target is exactly the slug the
// system row above is retiring in this same pass.
"legacy-widget": {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: "legacy-widget",
Name: "My Legacy Widget",
IsSystem: false,
SourceType: "inline",
},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Removed) != 1 || !strings.HasSuffix(report.Removed[0], "kandev-legacy-widget") {
t.Fatalf("expected kandev-legacy-widget retired, got removed=%v", report.Removed)
}
if len(report.Conflicted) != 0 {
t.Fatalf("slug freed by same-pass retirement must not be reported as conflicted, got %v", report.Conflicted)
}
if len(report.Normalized) != 1 || !strings.HasSuffix(report.Normalized[0], "legacy-widget->kandev-legacy-widget") {
t.Fatalf("expected legacy-widget normalized onto the freed slug, got %v", report.Normalized)
}
got, err := repo.GetSkillBySlug(context.Background(), "ws-1", "kandev-legacy-widget")
if err != nil {
t.Fatalf("normalized row missing: %v", err)
}
if got.ID != "user-skill-1" {
t.Errorf("normalization must preserve the user row's ID, got %s", got.ID)
}
if got.IsSystem {
t.Error("normalized user row must not become a system row")
}
}
// TestSyncSystemSkills_RenameRewritesLegacyCommaSeparatedDesiredSkills
// pins a format gap in the agent-reference rewrite: desired_skills
// predates the JSON-array persistence format on some rows and is
// still a legacy comma-separated string (ParseDesiredSlugs reads
// both; see injection.go). A slug rename must rewrite that legacy
// value too — otherwise the agent is left holding a reference to a
// slug that no longer exists once the row is renamed to its
// canonical form.
func TestSyncSystemSkills_RenameRewritesLegacyCommaSeparatedDesiredSkills(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
repo.rows["ws-1"] = map[string]*models.Skill{
"my-custom-skill": {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: "my-custom-skill",
Name: "My Custom Skill",
IsSystem: false,
SourceType: "inline",
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
DesiredSkills: "my-custom-skill,other-skill",
},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 1 || !strings.HasSuffix(report.Normalized[0], "my-custom-skill->kandev-my-custom-skill") {
t.Fatalf("expected my-custom-skill normalized, got %v", report.Normalized)
}
agent1 := repo.agents["ws-1"]["agent-1"]
got := decodeIDs(t, agent1.DesiredSkills)
want := []string{"kandev-my-custom-skill", "other-skill"}
if len(got) != len(want) || got[0] != want[0] || got[1] != want[1] {
t.Errorf("agent-1.desired_skills = %q (parsed %v), want %v", agent1.DesiredSkills, got, want)
}
}
@@ -0,0 +1,217 @@
package skills_test
import (
"context"
"encoding/json"
"errors"
"strings"
"sync"
"testing"
"github.com/jackc/pgx/v5/pgconn"
settingsmodels "github.com/kandev/kandev/internal/agent/settings/models"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/skills"
)
var syncTestHooks = struct {
sync.Mutex
createSkillErr map[*stubSyncRepo]map[string]error
normalizeConflictFor map[*stubSyncRepo]map[string]bool
}{
createSkillErr: map[*stubSyncRepo]map[string]error{},
normalizeConflictFor: map[*stubSyncRepo]map[string]bool{},
}
func syncTestCreateSkillError(repo *stubSyncRepo, slug string) error {
syncTestHooks.Lock()
defer syncTestHooks.Unlock()
return syncTestHooks.createSkillErr[repo][slug]
}
func setSyncTestCreateSkillError(repo *stubSyncRepo, slug string, err error) {
syncTestHooks.Lock()
defer syncTestHooks.Unlock()
if syncTestHooks.createSkillErr[repo] == nil {
syncTestHooks.createSkillErr[repo] = map[string]error{}
}
syncTestHooks.createSkillErr[repo][slug] = err
}
func setSyncTestNormalizeConflict(repo *stubSyncRepo, slug string) {
syncTestHooks.Lock()
defer syncTestHooks.Unlock()
if syncTestHooks.normalizeConflictFor[repo] == nil {
syncTestHooks.normalizeConflictFor[repo] = map[string]bool{}
}
syncTestHooks.normalizeConflictFor[repo][slug] = true
}
func syncTestNormalizeConflict(repo *stubSyncRepo, slug string) bool {
syncTestHooks.Lock()
defer syncTestHooks.Unlock()
return syncTestHooks.normalizeConflictFor[repo][slug]
}
// NormalizeSkillSlug mirrors the repository contract: the slug row and
// every affected desired_skills profile are committed as one operation.
// The in-memory implementation applies all changes only after it has
// confirmed that every profile update can succeed.
func (s *stubSyncRepo) NormalizeSkillSlug(
_ context.Context, workspaceID, skillID, oldSlug, newSlug string,
) (bool, error) {
ws := s.rows[workspaceID]
row, ok := ws[oldSlug]
if !ok || row.ID != skillID || row.IsSystem {
return false, nil
}
if syncTestNormalizeConflict(s, oldSlug) {
ws[newSlug] = &models.Skill{
ID: "concurrent-" + newSlug,
WorkspaceID: workspaceID,
Slug: newSlug,
IsSystem: false,
}
return false, &pgconn.PgError{
Code: "23505",
ConstraintName: "office_skills_workspace_id_slug_key",
}
}
if _, exists := ws[newSlug]; exists {
return false, &pgconn.PgError{
Code: "23505",
ConstraintName: "office_skills_workspace_id_slug_key",
}
}
type agentUpdate struct {
id string
desired string
}
updates := make([]agentUpdate, 0)
for id, agent := range s.agents[workspaceID] {
desired, changed := replaceStubJSONArrayValue(agent.DesiredSkills, oldSlug, newSlug)
if !changed {
continue
}
if s.failUpdateAgentFor[id] {
return false, errors.New("injected failure: agent update unavailable")
}
updates = append(updates, agentUpdate{id: id, desired: desired})
}
delete(ws, oldSlug)
copyRow := *row
copyRow.Slug = newSlug
ws[newSlug] = &copyRow
for _, update := range updates {
copyAgent := *s.agents[workspaceID][update.id]
copyAgent.DesiredSkills = update.desired
s.agents[workspaceID][update.id] = &copyAgent
}
return true, nil
}
func replaceStubJSONArrayValue(raw, oldValue, newValue string) (string, bool) {
values := skills.ParseDesiredSlugs(raw)
if len(values) == 0 {
return raw, false
}
out := make([]string, 0, len(values))
seen := make(map[string]bool, len(values))
changed := false
for _, value := range values {
if value == oldValue {
value = newValue
changed = true
}
if value == "" || seen[value] {
if value != "" {
changed = true
}
continue
}
seen[value] = true
out = append(out, value)
}
if !changed {
return raw, false
}
encoded, err := json.Marshal(out)
if err != nil {
return raw, false
}
return string(encoded), true
}
func TestSyncSystemSkills_NormalizationConflictLeavesReferencesUntouched(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
const oldSlug = "my-custom-skill"
repo.rows["ws-1"] = map[string]*models.Skill{
oldSlug: {
ID: "user-skill-1",
WorkspaceID: "ws-1",
Slug: oldSlug,
Name: "My Custom Skill",
IsSystem: false,
SourceType: "inline",
},
}
repo.agents["ws-1"] = map[string]*settingsmodels.AgentProfile{
"agent-1": {
ID: "agent-1",
WorkspaceID: "ws-1",
DesiredSkills: mustJSONArray(t, []string{oldSlug}),
},
}
setSyncTestNormalizeConflict(repo, oldSlug)
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, []skills.SystemSkillSpec{}, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Normalized) != 0 {
t.Fatalf("conflicting normalization must not be reported as complete: %v", report.Normalized)
}
if len(report.Conflicted) != 1 || !strings.HasSuffix(report.Conflicted[0], oldSlug) {
t.Fatalf("expected %s reported as conflicted, got %v", oldSlug, report.Conflicted)
}
if _, err := repo.GetSkillBySlug(context.Background(), "ws-1", oldSlug); err != nil {
t.Fatalf("original user row must remain after conflict: %v", err)
}
agent1 := repo.agents["ws-1"]["agent-1"]
if got := decodeIDs(t, agent1.DesiredSkills); len(got) != 1 || got[0] != oldSlug {
t.Errorf("agent desired_skills changed despite atomic conflict: %v", got)
}
}
func TestSyncSystemSkills_ContinuesAfterPostgresSlugConflict(t *testing.T) {
repo := newStubSyncRepo()
log := logger.Default()
setSyncTestCreateSkillError(repo, "kandev-conflicting", &pgconn.PgError{
Code: "23505",
ConstraintName: "office_skills_workspace_id_slug_key",
})
bundled := []skills.SystemSkillSpec{
{Slug: "kandev-conflicting", Name: "Conflict", ContentHash: "hash-conflict"},
{Slug: "kandev-next", Name: "Next", ContentHash: "hash-next"},
}
report, err := skills.SyncSystemSkills(
context.Background(), repo, []string{"ws-1"}, bundled, log,
)
if err != nil {
t.Fatalf("sync: %v", err)
}
if len(report.Conflicted) != 1 || !strings.HasSuffix(report.Conflicted[0], "kandev-conflicting") {
t.Fatalf("expected PostgreSQL slug conflict to be reported, got %v", report.Conflicted)
}
if len(report.Inserted) != 1 || !strings.HasSuffix(report.Inserted[0], "kandev-next") {
t.Fatalf("sync should continue after PostgreSQL conflict, inserted=%v", report.Inserted)
}
}
@@ -21,12 +21,19 @@ import (
type stubSyncRepo struct {
rows map[string]map[string]*models.Skill // workspaceID → slug → row
agents map[string]map[string]*settingsmodels.AgentProfile // workspaceID → agentID → profile
// failUpdateAgentFor injects an error from UpdateAgentInstance for
// the named agent ID, so a test can simulate a mid-sequence failure
// (e.g. the agent-reference rewrite half of a slug normalization)
// without it actually persisting.
failUpdateAgentFor map[string]bool
}
func newStubSyncRepo() *stubSyncRepo {
return &stubSyncRepo{
rows: map[string]map[string]*models.Skill{},
agents: map[string]map[string]*settingsmodels.AgentProfile{},
rows: map[string]map[string]*models.Skill{},
agents: map[string]map[string]*settingsmodels.AgentProfile{},
failUpdateAgentFor: map[string]bool{},
}
}
@@ -44,6 +51,20 @@ func (s *stubSyncRepo) ListSystemSkills(
return out, nil
}
func (s *stubSyncRepo) ListNonSystemSkills(
_ context.Context, workspaceID string,
) ([]*models.Skill, error) {
ws := s.rows[workspaceID]
out := make([]*models.Skill, 0, len(ws))
for _, sk := range ws {
if !sk.IsSystem {
out = append(out, sk)
}
}
sort.Slice(out, func(i, j int) bool { return out[i].Slug < out[j].Slug })
return out, nil
}
func (s *stubSyncRepo) GetSkillBySlug(
_ context.Context, workspaceID, slug string,
) (*models.Skill, error) {
@@ -56,6 +77,9 @@ func (s *stubSyncRepo) GetSkillBySlug(
}
func (s *stubSyncRepo) CreateSkill(_ context.Context, skill *models.Skill) error {
if err := syncTestCreateSkillError(s, skill.Slug); err != nil {
return err
}
if _, ok := s.rows[skill.WorkspaceID]; !ok {
s.rows[skill.WorkspaceID] = map[string]*models.Skill{}
}
@@ -67,9 +91,19 @@ func (s *stubSyncRepo) CreateSkill(_ context.Context, skill *models.Skill) error
return nil
}
// UpdateSkill mirrors the real repository's update-by-ID semantics
// (sqlite.Repository.UpdateSkill writes `WHERE id = ?`): it locates
// the row by ID, not by the caller-supplied slug, so a slug rewrite
// (e.g. normalizeUserSkillSlugs) re-keys the map instead of failing
// to find a row under its now-stale old slug.
func (s *stubSyncRepo) UpdateSkill(_ context.Context, skill *models.Skill) error {
if ws, ok := s.rows[skill.WorkspaceID]; ok {
if _, ok := ws[skill.Slug]; ok {
ws, ok := s.rows[skill.WorkspaceID]
if !ok {
return errors.New("not found for update")
}
for slug, sk := range ws {
if sk.ID == skill.ID {
delete(ws, slug)
copy := *skill
ws[skill.Slug] = &copy
return nil
@@ -90,13 +124,18 @@ func (s *stubSyncRepo) DeleteSkill(_ context.Context, id string) error {
return errors.New("not found for delete")
}
// ListAgentInstances returns copies, matching the real repository's
// read-from-DB semantics: a caller mutating a returned profile (as
// renameSkillSlugOnAgents does before its UpdateAgentInstance call)
// must not silently affect stored state until that write succeeds.
func (s *stubSyncRepo) ListAgentInstances(
_ context.Context, workspaceID string,
) ([]*settingsmodels.AgentProfile, error) {
ws := s.agents[workspaceID]
out := make([]*settingsmodels.AgentProfile, 0, len(ws))
for _, a := range ws {
out = append(out, a)
copy := *a
out = append(out, &copy)
}
sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID })
return out, nil
@@ -105,6 +144,9 @@ func (s *stubSyncRepo) ListAgentInstances(
func (s *stubSyncRepo) UpdateAgentInstance(
_ context.Context, agent *settingsmodels.AgentProfile,
) error {
if s.failUpdateAgentFor[agent.ID] {
return errors.New("injected failure: agent update unavailable")
}
ws, ok := s.agents[agent.WorkspaceID]
if !ok {
return errors.New("workspace not found")
@@ -30,11 +30,15 @@ test("skill injection writes assigned skill to session worktree on launch", asyn
slug,
content,
})) as { id: string; slug: string };
expect(skill.slug).toBe(slug);
// A caller-supplied slug is normalized to its canonical kandev-prefixed
// form on write (AC-001.12); the persisted identity downstream is this
// canonical slug, not the one the request supplied.
const canonicalSlug = `kandev-${slug}`;
expect(skill.slug).toBe(canonicalSlug);
// 2. Assign the skill to the seed agent profile via the test harness.
// desired_skills is read from agent_profiles at launch time.
await apiClient.setProfileDesiredSkills(seedData.agentProfileId, [slug]);
await apiClient.setProfileDesiredSkills(seedData.agentProfileId, [canonicalSlug]);
try {
// 3. Launch a session via createTaskWithAgent (start_agent: true).
@@ -65,12 +69,14 @@ test("skill injection writes assigned skill to session worktree on launch", asyn
.not.toBe("");
// 5. Skill landed at the spec-defined location with the right content.
const skillFile = path.join(worktreePath, PROJECT_SKILL_DIR, `kandev-${slug}`, "SKILL.md");
const skillFile = path.join(worktreePath, PROJECT_SKILL_DIR, canonicalSlug, "SKILL.md");
await expect
.poll(() => fs.existsSync(skillFile), { timeout: 15_000, message: skillFile })
.toBe(true);
const skillFileContent = fs.readFileSync(skillFile, "utf8");
expect(skillFileContent).toContain(`---\nname: ${slug}\ndescription: ${slug}\n---\n`);
expect(skillFileContent).toContain(
`---\nname: ${canonicalSlug}\ndescription: ${canonicalSlug}\n---\n`,
);
expect(skillFileContent).toContain(`Marker: ${slug}-content`);
// 6. .git/info/exclude (resolved through .git file for linked worktrees)
@@ -49,7 +49,9 @@ test.describe("Office skills - Monaco read-only mode", () => {
slug: unique,
content: "# Editable\n\nWorkspace-scope skill content.\n",
})) as { id?: string; slug?: string };
expect(created.slug).toBe(unique);
// A caller-supplied slug is normalized to its canonical kandev-prefixed
// form on write (AC-001.12).
expect(created.slug).toBe(`kandev-${unique}`);
await testPage.goto("/office/workspace/skills");
+8 -4
View File
@@ -10,7 +10,9 @@ test.describe("Skills", () => {
slug: "test-skill",
content: "# Test\n\nThis is a test skill.",
});
expect((skill as Record<string, unknown>).slug).toBe("test-skill");
// A caller-supplied slug is normalized to its canonical kandev-prefixed
// form on write (AC-001.12).
expect((skill as Record<string, unknown>).slug).toBe("kandev-test-skill");
const skills = await officeApi.listSkills(officeSeed.workspaceId);
const list =
@@ -18,7 +20,7 @@ test.describe("Skills", () => {
(skills as unknown as Record<string, unknown>[]);
expect(
Array.isArray(list)
? list.some((s) => (s as Record<string, unknown>).slug === "test-skill")
? list.some((s) => (s as Record<string, unknown>).slug === "kandev-test-skill")
: false,
).toBe(true);
});
@@ -32,7 +34,7 @@ test.describe("Skills", () => {
const id = (created as Record<string, unknown>).id as string;
const fetched = await officeApi.getSkill(id);
expect((fetched as Record<string, unknown>).id).toBe(id);
expect((fetched as Record<string, unknown>).slug).toBe("get-skill");
expect((fetched as Record<string, unknown>).slug).toBe("kandev-get-skill");
});
test("delete skill removes it from list", async ({ officeApi, officeSeed }) => {
@@ -84,7 +86,9 @@ test.describe("Skills", () => {
const imported = await officeApi.importUserHomeSkill(officeSeed.workspaceId, provider, key);
expect(imported.name).toBe(name);
expect(imported.slug).toBe(key);
// A discovered provider-skill key is a well-formed slug and gets the
// same write-time canonicalization as any other slug (AC-001.12).
expect(imported.slug).toBe(`kandev-${key}`);
expect(imported.source_type).toBe("user_home");
expect(imported.source_locator).toBe(`${provider}:${key}`);