473 lines
14 KiB
Go
473 lines
14 KiB
Go
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"
|
|
)
|
|
|
|
// CreateSkill creates a new skill.
|
|
func (r *Repository) CreateSkill(ctx context.Context, skill *models.Skill) error {
|
|
if skill.ID == "" {
|
|
skill.ID = uuid.New().String()
|
|
}
|
|
now := time.Now().UTC()
|
|
skill.CreatedAt = now
|
|
skill.UpdatedAt = now
|
|
|
|
if skill.DefaultForRoles == "" {
|
|
skill.DefaultForRoles = "[]"
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
INSERT INTO office_skills (
|
|
id, workspace_id, name, slug, description, source_type,
|
|
source_locator, content, file_inventory, version, content_hash,
|
|
approval_state, created_by_agent_profile_id,
|
|
is_system, system_version, default_for_roles,
|
|
created_at, updated_at
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
`), skill.ID, skill.WorkspaceID, skill.Name, skill.Slug, skill.Description,
|
|
skill.SourceType, skill.SourceLocator, skill.Content, skill.FileInventory,
|
|
skill.Version, skill.ContentHash, skill.ApprovalState,
|
|
skill.CreatedByAgentProfileID,
|
|
skill.IsSystem, skill.SystemVersion, skill.DefaultForRoles,
|
|
skill.CreatedAt, skill.UpdatedAt)
|
|
return err
|
|
}
|
|
|
|
// GetSkill returns a skill by ID.
|
|
func (r *Repository) GetSkill(ctx context.Context, id string) (*models.Skill, error) {
|
|
var skill models.Skill
|
|
query := `SELECT * FROM office_skills WHERE id = ?`
|
|
if err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(query), id).StructScan(&skill); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("skill not found: %s", id)
|
|
}
|
|
return nil, err
|
|
}
|
|
return &skill, nil
|
|
}
|
|
|
|
// GetSkillBySlug returns a skill by workspace+slug.
|
|
func (r *Repository) GetSkillBySlug(
|
|
ctx context.Context, workspaceID, slug string,
|
|
) (*models.Skill, error) {
|
|
var skill models.Skill
|
|
err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(
|
|
`SELECT * FROM office_skills WHERE workspace_id = ? AND slug = ?`),
|
|
workspaceID, slug).StructScan(&skill)
|
|
if err == sql.ErrNoRows {
|
|
return nil, fmt.Errorf("skill not found: %s", slug)
|
|
}
|
|
return &skill, err
|
|
}
|
|
|
|
// ListSkills returns all skills for a workspace, ordered by name.
|
|
// An empty workspaceID returns rows from all workspaces.
|
|
func (r *Repository) ListSkills(ctx context.Context, workspaceID string) ([]*models.Skill, error) {
|
|
var (
|
|
skills []*models.Skill
|
|
err error
|
|
)
|
|
if workspaceID == "" {
|
|
err = r.ro.SelectContext(ctx, &skills,
|
|
`SELECT * FROM office_skills ORDER BY name`)
|
|
} else {
|
|
err = r.ro.SelectContext(ctx, &skills, r.ro.Rebind(
|
|
`SELECT * FROM office_skills WHERE workspace_id = ? ORDER BY name`), workspaceID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skills == nil {
|
|
return []*models.Skill{}, nil
|
|
}
|
|
return skills, nil
|
|
}
|
|
|
|
// ListSystemSkills returns all is_system = true skills for a
|
|
// workspace, ordered by slug for deterministic startup logs. An
|
|
// empty workspaceID returns system rows across every workspace.
|
|
func (r *Repository) ListSystemSkills(
|
|
ctx context.Context, workspaceID string,
|
|
) ([]*models.Skill, error) {
|
|
var (
|
|
skills []*models.Skill
|
|
err error
|
|
)
|
|
if workspaceID == "" {
|
|
err = r.ro.SelectContext(ctx, &skills,
|
|
`SELECT * FROM office_skills WHERE is_system = 1 ORDER BY workspace_id, slug`)
|
|
} else {
|
|
err = r.ro.SelectContext(ctx, &skills, r.ro.Rebind(
|
|
`SELECT * FROM office_skills WHERE workspace_id = ? AND is_system = 1 ORDER BY slug`),
|
|
workspaceID)
|
|
}
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if skills == nil {
|
|
return []*models.Skill{}, nil
|
|
}
|
|
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()
|
|
if skill.DefaultForRoles == "" {
|
|
skill.DefaultForRoles = "[]"
|
|
}
|
|
_, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE office_skills SET
|
|
name = ?, slug = ?, description = ?, source_type = ?,
|
|
source_locator = ?, content = ?, file_inventory = ?,
|
|
version = ?, content_hash = ?, approval_state = ?,
|
|
created_by_agent_profile_id = ?,
|
|
is_system = ?, system_version = ?, default_for_roles = ?,
|
|
updated_at = ?
|
|
WHERE id = ?
|
|
`), skill.Name, skill.Slug, skill.Description, skill.SourceType,
|
|
skill.SourceLocator, skill.Content, skill.FileInventory,
|
|
skill.Version, skill.ContentHash, skill.ApprovalState,
|
|
skill.CreatedByAgentProfileID,
|
|
skill.IsSystem, skill.SystemVersion, skill.DefaultForRoles,
|
|
skill.UpdatedAt, skill.ID)
|
|
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 {
|
|
Name string
|
|
Description string
|
|
SourceType models.SkillSourceType
|
|
Content string
|
|
}
|
|
|
|
// UpdateSkillConfigFields updates only the columns a config import owns
|
|
// (name, description, source type, content) and the content_hash derived from
|
|
// content plus the current package metadata. It leaves source_locator,
|
|
// file_inventory, version, approval_state, and the other columns untouched
|
|
// instead of reverting them to a stale read-then-write snapshot.
|
|
func (r *Repository) UpdateSkillConfigFields(
|
|
ctx context.Context, id string, fields SkillConfigFields,
|
|
) error {
|
|
const maxMetadataReadAttempts = 3
|
|
type packageMetadata struct {
|
|
SourceLocator string `db:"source_locator"`
|
|
FileInventory string `db:"file_inventory"`
|
|
}
|
|
|
|
for range maxMetadataReadAttempts {
|
|
var metadata packageMetadata
|
|
if err := r.db.QueryRowxContext(ctx, r.db.Rebind(`
|
|
SELECT COALESCE(source_locator, '') AS source_locator,
|
|
COALESCE(file_inventory, '') AS file_inventory
|
|
FROM office_skills
|
|
WHERE id = ?
|
|
`), id).StructScan(&metadata); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return fmt.Errorf("skill not found: %s", id)
|
|
}
|
|
return err
|
|
}
|
|
|
|
contentHash := models.SkillPackageContentHash(
|
|
fields.Content, metadata.FileInventory, metadata.SourceLocator,
|
|
)
|
|
result, err := r.db.ExecContext(ctx, r.db.Rebind(`
|
|
UPDATE office_skills SET
|
|
name = ?, description = ?, source_type = ?, content = ?,
|
|
content_hash = ?, updated_at = ?
|
|
WHERE id = ?
|
|
AND COALESCE(source_locator, '') = ?
|
|
AND COALESCE(file_inventory, '') = ?
|
|
`), fields.Name, fields.Description, fields.SourceType, fields.Content,
|
|
contentHash, time.Now().UTC(), id, metadata.SourceLocator, metadata.FileInventory)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updated, err := result.RowsAffected()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if updated == 1 {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
return fmt.Errorf("update skill %s: package metadata changed concurrently", id)
|
|
}
|
|
|
|
// DeleteSkill deletes a skill by ID.
|
|
func (r *Repository) DeleteSkill(ctx context.Context, id string) error {
|
|
return r.deleteSkill(ctx, r.db, id)
|
|
}
|
|
|
|
// DeleteSkillTx is DeleteSkill scoped to a caller-owned transaction, letting
|
|
// config sync delete a removed-upstream skill and its ownership manifest row
|
|
// atomically (AC-OFFICE-CONFIG-SYNC-003.14).
|
|
func (r *Repository) DeleteSkillTx(ctx context.Context, tx *sqlx.Tx, id string) error {
|
|
return r.deleteSkill(ctx, tx, id)
|
|
}
|
|
|
|
func (r *Repository) deleteSkill(ctx context.Context, ext sqlx.ExtContext, id string) error {
|
|
_, err := ext.ExecContext(ctx, r.db.Rebind(
|
|
`DELETE FROM office_skills WHERE id = ?`), id)
|
|
return err
|
|
}
|
|
|
|
// CreateRunSkillSnapshots records the immutable skill set used by a run.
|
|
func (r *Repository) CreateRunSkillSnapshots(ctx context.Context, snapshots []models.RunSkillSnapshot) error {
|
|
if len(snapshots) == 0 {
|
|
return nil
|
|
}
|
|
tx, err := r.db.BeginTxx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = tx.Rollback() }()
|
|
stmt, err := tx.PreparexContext(ctx, r.db.Rebind(`
|
|
INSERT INTO office_run_skills (
|
|
run_id, skill_id, display_name, slug, label_source, version, content_hash, materialized_path
|
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
ON CONFLICT (run_id, skill_id) DO UPDATE SET
|
|
display_name = excluded.display_name,
|
|
slug = excluded.slug,
|
|
label_source = excluded.label_source,
|
|
version = excluded.version,
|
|
content_hash = excluded.content_hash,
|
|
materialized_path = excluded.materialized_path
|
|
`))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer func() { _ = stmt.Close() }()
|
|
for _, snap := range snapshots {
|
|
if _, err := stmt.ExecContext(ctx,
|
|
snap.RunID, snap.SkillID, snap.DisplayName, snap.Slug,
|
|
snap.LabelSource, snap.Version, snap.ContentHash, snap.MaterializedPath,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// ListRunSkillSnapshots returns skill snapshots for a run.
|
|
func (r *Repository) ListRunSkillSnapshots(ctx context.Context, runID string) ([]models.RunSkillSnapshot, error) {
|
|
var snapshots []models.RunSkillSnapshot
|
|
err := r.ro.SelectContext(ctx, &snapshots, r.ro.Rebind(`
|
|
SELECT * FROM office_run_skills
|
|
WHERE run_id = ?
|
|
ORDER BY skill_id
|
|
`), runID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if snapshots == nil {
|
|
return []models.RunSkillSnapshot{}, nil
|
|
}
|
|
return snapshots, nil
|
|
}
|