diff --git a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go index a13e328856..f0dc2e894f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/models" "github.com/kandev/kandev/internal/agent/settings/store" @@ -102,6 +104,12 @@ func (m *MockRepository) GetAgentProfile(ctx context.Context, id string) (*model return nil, errors.New("profile not found") } +// GetAgentProfileTx is not exercised by this package's tests; it exists only +// to satisfy store.Repository. +func (m *MockRepository) GetAgentProfileTx(_ context.Context, _ *sqlx.Tx, _ string) (*models.AgentProfile, bool, error) { + return nil, false, errors.New("GetAgentProfileTx not implemented") +} + func (m *MockRepository) GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) { if m.GetAgentProfileIncludingDeletedFn != nil { return m.GetAgentProfileIncludingDeletedFn(ctx, id) diff --git a/apps/backend/internal/agent/settings/controller/reconciler_test.go b/apps/backend/internal/agent/settings/controller/reconciler_test.go index c0a0b66e53..1737e7e4bb 100644 --- a/apps/backend/internal/agent/settings/controller/reconciler_test.go +++ b/apps/backend/internal/agent/settings/controller/reconciler_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" "go.uber.org/zap" "go.uber.org/zap/zapcore" "go.uber.org/zap/zaptest/observer" @@ -340,6 +341,12 @@ func (f *fakeStore) GetAgentProfile(_ context.Context, id string) (*models.Agent return nil, sql.ErrNoRows } +// GetAgentProfileTx is not exercised by this package's tests; it exists only +// to satisfy store.Repository. +func (f *fakeStore) GetAgentProfileTx(_ context.Context, _ *sqlx.Tx, _ string) (*models.AgentProfile, bool, error) { + return nil, false, errors.New("GetAgentProfileTx not implemented") +} + func (f *fakeStore) GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) { if p, err := f.GetAgentProfile(ctx, id); err == nil { return p, nil diff --git a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go index d88449bee4..9ed760b670 100644 --- a/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go +++ b/apps/backend/internal/agent/settings/handlers/profile_duplicate_handlers_test.go @@ -14,6 +14,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/agent/discovery" "github.com/kandev/kandev/internal/agent/registry" "github.com/kandev/kandev/internal/agent/settings/controller" @@ -108,6 +109,12 @@ func (r *fakeSettingsRepo) GetAgentProfileIncludingDeleted(ctx context.Context, return r.GetAgentProfile(ctx, id) } +// GetAgentProfileTx is not exercised by this package's tests; it exists only +// to satisfy store.Repository. +func (r *fakeSettingsRepo) GetAgentProfileTx(_ context.Context, _ *sqlx.Tx, _ string) (*models.AgentProfile, bool, error) { + return nil, false, fmt.Errorf("GetAgentProfileTx not implemented") +} + // CreateAgentProfile implements the settings store interface for the handler tests. func (r *fakeSettingsRepo) CreateAgentProfile(_ context.Context, p *models.AgentProfile) error { if err := r.errs["CreateAgentProfile"]; err != nil { diff --git a/apps/backend/internal/agent/settings/store/export_lookup.go b/apps/backend/internal/agent/settings/store/export_lookup.go new file mode 100644 index 0000000000..b6c4cf0959 --- /dev/null +++ b/apps/backend/internal/agent/settings/store/export_lookup.go @@ -0,0 +1,61 @@ +package store + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +// GetAgentProfileTx is GetAgentProfile's transaction-accepting counterpart: +// the automations YAML export (AC-29) opens one read transaction spanning +// several stores and passes it through here rather than letting this method +// open its own, so the profile read observes the same snapshot as every +// other read in the export. A missing row is reported as found=false rather +// than an error - AC-19's partial-resolution rule decides what that means. +// +// The legacy-backfill path (applyLegacyBackfillTx) must receive this same +// tx too: a legacy Auggie-shaped row's backfill issues a nested read of its +// own (the agent's name), and that nested read is a row the export resolves +// a portable descriptor from just as much as the profile row itself, so +// AC-29 covers it. Routing it through r.ro instead would let it observe a +// different snapshot than the rest of the export. +func (r *sqliteRepository) GetAgentProfileTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.AgentProfile, bool, error) { + row := tx.QueryRowContext(ctx, tx.Rebind(agentProfileSelectColumns+` WHERE id = ? AND deleted_at IS NULL`), id) + profile, err := scanAgentProfile(row) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return r.applyLegacyBackfillTx(ctx, tx, profile), true, nil +} + +// GetAgentTx is GetAgent's transaction-accepting counterpart, used only by +// applyLegacyBackfillTx so the legacy backfill's nested agent-name lookup +// observes the same snapshot as the rest of an AC-29 export. +func (r *sqliteRepository) GetAgentTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.Agent, error) { + row := tx.QueryRowContext(ctx, tx.Rebind(` + SELECT id, name, workspace_id, supports_mcp, mcp_config_path, tui_config, created_at, updated_at + FROM agents WHERE id = ? + `), id) + return scanAgent(row) +} + +// applyLegacyBackfillTx is applyLegacyBackfill's transaction-accepting +// counterpart. See GetAgentProfileTx for why the nested read needs the tx. +func (r *sqliteRepository) applyLegacyBackfillTx(ctx context.Context, tx *sqlx.Tx, profile *models.AgentProfile) *models.AgentProfile { + if profile == nil || profile.CLIFlags != nil { + return profile + } + if !profile.AllowIndexing { + profile.CLIFlags = []models.CLIFlag{} + return profile + } + agent, err := r.GetAgentTx(ctx, tx, profile.AgentID) + profile.CLIFlags = legacyCLIFlagsForAgent(agent, err) + return profile +} diff --git a/apps/backend/internal/agent/settings/store/export_lookup_test.go b/apps/backend/internal/agent/settings/store/export_lookup_test.go new file mode 100644 index 0000000000..a1340f916a --- /dev/null +++ b/apps/backend/internal/agent/settings/store/export_lookup_test.go @@ -0,0 +1,112 @@ +package store + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/agent/settings/models" +) + +// AC-29: the automations YAML export opens one read transaction spanning +// several stores. GetAgentProfileTx is GetAgentProfile's counterpart that +// accepts the caller's *sqlx.Tx instead of opening its own, and reports a +// three-outcome (value, found, err) result so a missing profile is +// distinguishable from a lookup failure without string-matching an error. + +// TestGetAgentProfileTx_LegacyBackfillReadsThroughTransaction proves AC-29's +// single-snapshot guarantee holds for the legacy Auggie backfill path too: +// GetAgentProfileTx's nested agent-name lookup (applyLegacyBackfill's +// r.GetAgent call) must read through the caller's *sqlx.Tx, not through +// r.ro directly. If the nested read used r.ro while this tx is still open, +// the connection pool would have to open a second physical connection to a +// bare (non-shared-cache) ":memory:" database to serve it — landing on an +// empty database with no "agents" table at all, since sqlite3's ":memory:" +// DSN gives each connection its own private, independent database. That +// failure mode (either a hard "no such table" error swallowed into an empty +// CLIFlags backfill, or simply the wrong CLIFlags) is exactly what a +// snapshot leak looks like from the caller's side: the profile silently +// loses its legacy --allow-indexing flag depending on which physical +// connection happened to serve the nested read. +func TestGetAgentProfileTx_LegacyBackfillReadsThroughTransaction(t *testing.T) { + repo := newFreshRepo(t) + ctx := context.Background() + + if err := repo.CreateAgent(ctx, &models.Agent{Name: "auggie"}); err != nil { + t.Fatalf("create agent: %v", err) + } + agent, err := repo.GetAgentByName(ctx, "auggie") + if err != nil { + t.Fatalf("get agent: %v", err) + } + + // A pre-migration row: cli_flags NULL, allow_indexing = 1, matching + // TestCLIFlags_LegacyBackfill's fixture exactly. + _, err = repo.db.Exec(`INSERT INTO agent_profiles + (id, agent_id, name, agent_display_name, model, mode, auto_approve, + dangerously_skip_permissions, allow_indexing, cli_passthrough, + user_modified, plan, cli_flags, created_at, updated_at) + VALUES ('legacy-tx-1', ?, 'Auggie', 'Auggie', '', NULL, 0, 0, 1, 0, 0, '', NULL, + datetime('now'), datetime('now'))`, agent.ID) + if err != nil { + t.Fatalf("seed legacy row: %v", err) + } + + tx, err := repo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + got, found, err := repo.GetAgentProfileTx(ctx, tx, "legacy-tx-1") + if err != nil { + t.Fatalf("GetAgentProfileTx: %v", err) + } + if !found { + t.Fatal("found = false, want true") + } + if len(got.CLIFlags) != 1 || got.CLIFlags[0].Flag != "--allow-indexing" || !got.CLIFlags[0].Enabled { + t.Errorf("CLIFlags = %+v, want the backfilled --allow-indexing flag (nested agent lookup must read through the same tx, not r.ro)", got.CLIFlags) + } +} + +func TestGetAgentProfileTx_FoundAndMissing(t *testing.T) { + repo := newTestRepo(t) + ctx := context.Background() + + profile := &models.AgentProfile{AgentID: "agent-1", Name: "Default"} + if err := repo.CreateAgentProfile(ctx, profile); err != nil { + t.Fatalf("CreateAgentProfile: %v", err) + } + + sqliteRepo, ok := repo.(*sqliteRepository) + if !ok { + t.Fatalf("repo is %T, want *sqliteRepository", repo) + } + tx, err := sqliteRepo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + got, found, err := repo.GetAgentProfileTx(ctx, tx, profile.ID) + if err != nil { + t.Fatalf("GetAgentProfileTx(%s): %v", profile.ID, err) + } + if !found { + t.Fatal("found = false, want true") + } + if got == nil || got.ID != profile.ID { + t.Errorf("profile = %+v, want ID %s", got, profile.ID) + } + + missing, found, err := repo.GetAgentProfileTx(ctx, tx, "does-not-exist") + if err != nil { + t.Fatalf("GetAgentProfileTx(missing): %v", err) + } + if found { + t.Error("found = true, want false") + } + if missing != nil { + t.Errorf("profile = %+v, want nil", missing) + } +} diff --git a/apps/backend/internal/agent/settings/store/sqlite.go b/apps/backend/internal/agent/settings/store/sqlite.go index 0613cf8588..28db73b66e 100644 --- a/apps/backend/internal/agent/settings/store/sqlite.go +++ b/apps/backend/internal/agent/settings/store/sqlite.go @@ -1079,16 +1079,23 @@ func (r *sqliteRepository) applyLegacyBackfill(ctx context.Context, profile *mod return profile } agent, err := r.GetAgent(ctx, profile.AgentID) + profile.CLIFlags = legacyCLIFlagsForAgent(agent, err) + return profile +} + +// legacyCLIFlagsForAgent is applyLegacyBackfill/applyLegacyBackfillTx's +// shared decision: only a resolved "auggie" agent backfills the +// --allow-indexing flag; a lookup failure, a missing agent, or any other +// agent name backfills an empty list instead. +func legacyCLIFlagsForAgent(agent *models.Agent, err error) []models.CLIFlag { if err != nil || agent == nil || agent.Name != "auggie" { - profile.CLIFlags = []models.CLIFlag{} - return profile + return []models.CLIFlag{} } - profile.CLIFlags = []models.CLIFlag{{ + return []models.CLIFlag{{ Description: "Allow workspace indexing without confirmation", Flag: "--allow-indexing", Enabled: true, }} - return profile } func (r *sqliteRepository) ListTUIAgents(ctx context.Context) ([]*models.Agent, error) { diff --git a/apps/backend/internal/agent/settings/store/store.go b/apps/backend/internal/agent/settings/store/store.go index 4a1f78c5a5..073f7daa4c 100644 --- a/apps/backend/internal/agent/settings/store/store.go +++ b/apps/backend/internal/agent/settings/store/store.go @@ -5,6 +5,8 @@ import ( "errors" "time" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/agent/settings/models" ) @@ -59,6 +61,14 @@ type Repository interface { // automations) pointing at removed profiles. ErrAgentProfileDeleted is // only used by callers of ProfileResolver, which wraps this method. GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) + // GetAgentProfileTx is GetAgentProfile's transaction-accepting counterpart: + // the automations YAML export (AC-29) opens one read transaction spanning + // several stores and passes it through here rather than letting this + // method open its own, so the profile read observes the same snapshot as + // every other read in the export. A missing row is reported as + // found=false rather than an error - AC-19's partial-resolution rule + // decides what that means, not this method. + GetAgentProfileTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.AgentProfile, bool, error) ListAgentProfiles(ctx context.Context, agentID string) ([]*models.AgentProfile, error) // HasDeletedAgentProfiles reports whether the agent has any soft-deleted // profile rows. Seeding paths use this to distinguish a fresh agent that diff --git a/apps/backend/internal/automation/export.go b/apps/backend/internal/automation/export.go new file mode 100644 index 0000000000..453fb537a9 --- /dev/null +++ b/apps/backend/internal/automation/export.go @@ -0,0 +1,78 @@ +package automation + +import "gopkg.in/yaml.v3" + +// exportDocumentVersion and exportDocumentType are the fixed values of the export +// document's `version` and `type` keys. See docs/specs/automations-yaml-export/spec.md +// § Data model. +const ( + exportDocumentVersion = 1 + exportDocumentType = "kandev_automations" +) + +// exportDocument is the top-level shape of a workspace's YAML automation export. Key +// order is pinned by AC-40: version, type, automations, warnings. +type exportDocument struct { + Version int `yaml:"version"` + Type string `yaml:"type"` + Automations []exportAutomation `yaml:"automations"` + Warnings []string `yaml:"warnings,omitempty"` +} + +// exportAutomation is a single automation's exported form. Key order is pinned by +// AC-40: name, description, enabled, max_concurrent_runs, task_title_template, prompt, +// agent_profile, executor_profile, workflow, repositories, triggers. +// +// Prompt and every trigger's Config are *yaml.Node rather than string/json.RawMessage +// because both need explicit control over emitted Tag/Style that struct-tag marshaling +// cannot express: Prompt per the prompt-fidelity rules (AC-15/16/17/46/47/49), Config +// per the per-JSON-type node table (AC-8, AC-41). +type exportAutomation struct { + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Enabled bool `yaml:"enabled"` + MaxConcurrentRuns int `yaml:"max_concurrent_runs"` + TaskTitleTemplate string `yaml:"task_title_template,omitempty"` + Prompt *yaml.Node `yaml:"prompt,omitempty"` + AgentProfile *exportAgentProfile `yaml:"agent_profile,omitempty"` + ExecutorProfile *exportExecutorProfile `yaml:"executor_profile,omitempty"` + Workflow *exportWorkflow `yaml:"workflow,omitempty"` + Repositories []string `yaml:"repositories,omitempty"` + Triggers []exportTrigger `yaml:"triggers"` +} + +// exportAgentProfile is the portable {agent_name, model, mode} descriptor resolved +// from Automation.AgentProfileID. Defined locally rather than reusing +// workflow/models.AgentProfilePortable: that type's omitempty tags and shape were +// decided for a different feature (workflow export/import) and are not vetted against +// this spec's own AC-4/AC-22 disposition contract. +type exportAgentProfile struct { + AgentName string `yaml:"agent_name"` + Model string `yaml:"model"` + Mode string `yaml:"mode"` +} + +// exportExecutorProfile is the portable {executor, name} descriptor resolved from +// Automation.ExecutorProfileID. +type exportExecutorProfile struct { + Executor string `yaml:"executor"` + Name string `yaml:"name"` +} + +// exportWorkflow is the portable {name, step} descriptor resolved from +// Automation.WorkflowID / WorkflowStepID. Step is omitted (not merely empty) when the +// workflow resolves but the step does not, or when no step was ever referenced +// (AC-19). +type exportWorkflow struct { + Name string `yaml:"name"` + Step string `yaml:"step,omitempty"` +} + +// exportTrigger is a single trigger's exported form. Key order is pinned by AC-40: +// type, enabled, config. Config is never omitted: an empty config is still emitted as +// an empty mapping (data model: "possibly empty"). +type exportTrigger struct { + Type string `yaml:"type"` + Enabled bool `yaml:"enabled"` + Config *yaml.Node `yaml:"config"` +} diff --git a/apps/backend/internal/automation/export_ac22_test.go b/apps/backend/internal/automation/export_ac22_test.go new file mode 100644 index 0000000000..62b40fcd40 --- /dev/null +++ b/apps/backend/internal/automation/export_ac22_test.go @@ -0,0 +1,86 @@ +package automation + +import ( + "reflect" + "testing" +) + +// fieldDisposition records what AC-22 calls a field's "disposition": either exported +// under a named YAML key, or excluded for a stated reason. See +// docs/specs/automations-yaml-export/spec.md § Round-trip completeness, the field +// disposition table under "Data model". +// +// This is the anti-`reports_to` guard: Office's config export declared and emitted an +// agent field that was silently dropped on import because no test asserted every field +// had a disposition. A field added to Automation or AutomationTrigger without an entry +// here fails TestAC22_* immediately, forcing an explicit export/exclude decision. +type fieldDisposition struct { + exported bool + yamlKey string // required when exported; documents the emitted key/path +} + +var automationFieldDispositions = map[string]fieldDisposition{ + "Name": {exported: true, yamlKey: "name"}, + "Description": {exported: true, yamlKey: "description"}, + "Prompt": {exported: true, yamlKey: "prompt"}, + "TaskTitleTemplate": {exported: true, yamlKey: "task_title_template"}, + "Enabled": {exported: true, yamlKey: "enabled"}, + "MaxConcurrentRuns": {exported: true, yamlKey: "max_concurrent_runs"}, + "Triggers": {exported: true, yamlKey: "triggers"}, + "WorkflowID": {exported: true, yamlKey: "workflow.name"}, + "WorkflowStepID": {exported: true, yamlKey: "workflow.step"}, + "AgentProfileID": {exported: true, yamlKey: "agent_profile"}, + "ExecutorProfileID": {exported: true, yamlKey: "executor_profile"}, + "RepositoryIDs": {exported: true, yamlKey: "repositories"}, + + "WebhookSecret": {exported: false}, // secret + "ID": {exported: false}, // instance identity + "WorkspaceID": {exported: false}, // instance identity + "LastTriggeredAt": {exported: false}, // runtime state / fire anchor + "CreatedAt": {exported: false}, // runtime state / fire anchor + "UpdatedAt": {exported: false}, // runtime state + "LegacyBoardCard": {exported: false}, // derived from withdrawn execution_mode column +} + +var automationTriggerFieldDispositions = map[string]fieldDisposition{ + "Type": {exported: true, yamlKey: "type"}, + "Enabled": {exported: true, yamlKey: "enabled"}, + "Config": {exported: true, yamlKey: "config"}, // decoded form + + "ConfigJSON": {exported: false}, // raw storage of Config; same value + "ID": {exported: false}, // instance identity + "AutomationID": {exported: false}, // instance identity + "LastEvaluatedAt": {exported: false}, // runtime state / fire anchor + "CreatedAt": {exported: false}, // runtime state / fire anchor + "UpdatedAt": {exported: false}, // runtime state +} + +func TestAC22_AutomationFieldDisposition(t *testing.T) { + assertFieldDispositionsCoverStruct(t, reflect.TypeOf(Automation{}), automationFieldDispositions) +} + +func TestAC22_AutomationTriggerFieldDisposition(t *testing.T) { + assertFieldDispositionsCoverStruct(t, reflect.TypeOf(AutomationTrigger{}), automationTriggerFieldDispositions) +} + +// assertFieldDispositionsCoverStruct fails when a struct field has no recorded +// disposition (drift: a new field shipped without an export decision) or when a +// recorded disposition names a field that no longer exists (drift: a stale entry +// survived a rename/removal). +func assertFieldDispositionsCoverStruct(t *testing.T, typ reflect.Type, dispositions map[string]fieldDisposition) { + t.Helper() + seen := make(map[string]bool, typ.NumField()) + for i := 0; i < typ.NumField(); i++ { + name := typ.Field(i).Name + if _, ok := dispositions[name]; !ok { + t.Errorf("field %s.%s has no recorded AC-22 disposition; add it to the export field-disposition table and either export it under a yamlKey or exclude it with a reason", typ.Name(), name) + continue + } + seen[name] = true + } + for name := range dispositions { + if !seen[name] { + t.Errorf("recorded AC-22 disposition for %s.%s does not match any struct field; remove the stale entry", typ.Name(), name) + } + } +} diff --git a/apps/backend/internal/automation/export_ac33_ac34_test.go b/apps/backend/internal/automation/export_ac33_ac34_test.go new file mode 100644 index 0000000000..a52041ae57 --- /dev/null +++ b/apps/backend/internal/automation/export_ac33_ac34_test.go @@ -0,0 +1,116 @@ +package automation + +import ( + "context" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// AC-33: an automation whose stored name is the empty string emits +// `name: ""` (not omitted — Name carries no yaml `omitempty` tag and is not +// in AC-4's omit-when-empty list) and its zip entry falls back to +// `automation.yml` via AC-26's empty-slug fallback. Written directly through +// svc.store.CreateAutomation, bypassing Service.CreateAutomation's +// empty-name rejection, exactly as the spec's own AC-33 note describes: "a +// row written by any other path is representable". +func TestExportAutomationsDocument_AC33_EmptyNameEmitsEmptyStringNameKey(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + if err := svc.store.CreateAutomation(context.Background(), &Automation{ + ID: "auto-empty-name", + WorkspaceID: "ws-1", + Name: "", + MaxConcurrentRuns: 1, + }); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 1 { + t.Fatalf("got %d automations, want 1", len(doc.Automations)) + } + if doc.Automations[0].Name != "" { + t.Errorf("Name = %q, want empty string", doc.Automations[0].Name) + } + if !strings.Contains(string(body), "name: \"\"") { + t.Errorf("expected literal `name: \"\"` in marshalled output, got:\n%s", body) + } +} + +func TestExportAutomationsZip_AC33_EmptyNameFallsBackToAutomationSlug(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + if err := svc.store.CreateAutomation(context.Background(), &Automation{ + ID: "auto-empty-name", + WorkspaceID: "ws-1", + Name: "", + MaxConcurrentRuns: 1, + }); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + data, err := svc.ExportAutomationsZip(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsZip: %v", err) + } + names := zipEntryNames(t, data) + want := []string{".kandev/automations/automation.yml"} + if len(names) != len(want) || names[0] != want[0] { + t.Errorf("entries = %v, want %v", names, want) + } +} + +// AC-34: an automation whose stored max_concurrent_runs is zero or negative +// is exported unchanged, not substituted with the service's create-time +// default of 1. Written directly through svc.store.CreateAutomation, since +// Service.CreateAutomation itself defaults a non-positive request value to 1 +// (service.go's own AC-34 rationale: "export reports what is in the +// database; it is not a validation pass"). +func TestExportAutomationsDocument_AC34_NonPositiveMaxConcurrentRunsPassesThroughUnchanged(t *testing.T) { + cases := []struct { + name string + want int + }{ + {name: "zero", want: 0}, + {name: "negative", want: -5}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + if err := svc.store.CreateAutomation(context.Background(), &Automation{ + ID: "auto-" + c.name, + WorkspaceID: "ws-1", + Name: "Automation " + c.name, + MaxConcurrentRuns: c.want, + }); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 1 { + t.Fatalf("got %d automations, want 1", len(doc.Automations)) + } + if doc.Automations[0].MaxConcurrentRuns != c.want { + t.Errorf("MaxConcurrentRuns = %d, want %d (unchanged from stored value, not substituted with the service default of 1)", + doc.Automations[0].MaxConcurrentRuns, c.want) + } + }) + } +} diff --git a/apps/backend/internal/automation/export_determinism_test.go b/apps/backend/internal/automation/export_determinism_test.go new file mode 100644 index 0000000000..fdd99beee8 --- /dev/null +++ b/apps/backend/internal/automation/export_determinism_test.go @@ -0,0 +1,200 @@ +package automation + +import ( + "bytes" + "context" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "go.uber.org/zap" + "gopkg.in/yaml.v3" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" + "github.com/kandev/kandev/internal/events/bus" +) + +// fileBackedExportServiceFixture builds a Service backed by real writer and +// reader connection pools on a temp-file SQLite database, using the same +// DSNs internal/db.OpenSQLite/OpenSQLiteReader use in production (WAL, +// shared cache). AC-13 and AC-30 are claims about genuine concurrent +// transactions on separate connections; the single shared :memory: handle +// exportServiceTestFixture uses cannot exercise that. +func fileBackedExportServiceFixture(t *testing.T) (*Service, *fakeExportWorkspaceLookup) { + t.Helper() + dbPath := filepath.Join(t.TempDir(), "automation.db") + + writerRaw, err := db.OpenSQLite(dbPath) + if err != nil { + t.Fatalf("open writer: %v", err) + } + writer := sqlx.NewDb(writerRaw, "sqlite3") + t.Cleanup(func() { _ = writer.Close() }) + + readerRaw, err := db.OpenSQLiteReader(dbPath) + if err != nil { + t.Fatalf("open reader: %v", err) + } + reader := sqlx.NewDb(readerRaw, "sqlite3") + t.Cleanup(func() { _ = reader.Close() }) + + store, err := NewStore(writer, reader) + if err != nil { + t.Fatalf("new store: %v", err) + } + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + svc := NewService(store, bus.NewMemoryEventBus(log), log) + + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{}} + svc.SetExportWorkspaceLookup(wsLookup) + return svc, wsLookup +} + +// AC-9: exporting the same workspace twice with no intervening database +// change produces byte-identical output, for both the document and zip +// forms. + +func TestExportAutomationsDocument_AC9_RepeatedExportIsByteIdentical(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync", Prompt: "Hello"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Weekly Digest"}) + + first, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("first export: %v", err) + } + second, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("second export: %v", err) + } + if !bytes.Equal(first, second) { + t.Errorf("repeated export was not byte-identical:\n--- first ---\n%s\n--- second ---\n%s", first, second) + } +} + +func TestExportAutomationsZip_AC9_RepeatedExportIsByteIdentical(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Weekly Digest"}) + + first, err := svc.ExportAutomationsZip(context.Background(), "ws-1") + if err != nil { + t.Fatalf("first export: %v", err) + } + second, err := svc.ExportAutomationsZip(context.Background(), "ws-1") + if err != nil { + t.Fatalf("second export: %v", err) + } + if !bytes.Equal(first, second) { + t.Error("repeated zip export was not byte-identical") + } +} + +// AC-13: two exports of the same workspace running concurrently, with no +// mutation committing between the moment each one's read transaction +// establishes its snapshot, produce byte-identical output to each other. +// Run on the file-backed fixture so the two exports genuinely execute on +// separate connections rather than serializing on one shared handle. + +func TestExportAutomationsDocument_AC13_ConcurrentExportsNoMutation_ByteIdentical(t *testing.T) { + svc, wsLookup := fileBackedExportServiceFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync", Prompt: "Hello"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Weekly Digest"}) + + const runs = 4 + results := make([][]byte, runs) + errs := make([]error, runs) + var wg sync.WaitGroup + wg.Add(runs) + for i := 0; i < runs; i++ { + i := i + go func() { + defer wg.Done() + results[i], errs[i] = svc.ExportAutomationsDocument(context.Background(), "ws-1") + }() + } + wg.Wait() + + for i, err := range errs { + if err != nil { + t.Fatalf("concurrent export %d: %v", i, err) + } + } + for i := 1; i < runs; i++ { + if !bytes.Equal(results[0], results[i]) { + t.Errorf("concurrent export %d differs from export 0:\n--- 0 ---\n%s\n--- %d ---\n%s", i, results[0], i, results[i]) + } + } +} + +// AC-30: while an export is in flight, a concurrent mutation of the same +// workspace's automations neither blocks nor is blocked by it, and the +// export's already-open read transaction continues to observe the snapshot +// from its start rather than the concurrent write. Mirrors the spec's own +// probe: open a read transaction, first-read to establish the snapshot, +// write on the writer pool, and check both the write's latency and what the +// still-open transaction sees. + +func TestExportAutomationsDocument_AC30_ConcurrentMutationNeitherBlocksNorIsReflected(t *testing.T) { + svc, wsLookup := fileBackedExportServiceFixture(t) + wsLookup.exists["ws-1"] = true + ctx := context.Background() + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Existing"}) + + tx, err := svc.store.BeginReadTx(ctx) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + before, err := svc.store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx (before): %v", err) + } + if len(before) != 1 { + t.Fatalf("got %d automations before the concurrent write, want 1", len(before)) + } + + writeDone := make(chan error, 1) + go func() { + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Added During Export"}) + writeDone <- err + }() + select { + case err := <-writeDone: + if err != nil { + t.Fatalf("concurrent CreateAutomation failed: %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("concurrent CreateAutomation blocked on the in-flight export's read transaction") + } + + after, err := svc.store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx (after): %v", err) + } + if len(after) != 1 { + t.Fatalf("the open read transaction observed the concurrent write; snapshot isolation broken: got %d automations, want 1", len(after)) + } + + body, err := svc.ExportAutomationsDocument(ctx, "ws-1") + if err != nil { + t.Fatalf("fresh export: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 2 { + t.Errorf("a fresh export (new transaction) should observe the committed write: got %d automations, want 2", len(doc.Automations)) + } +} diff --git a/apps/backend/internal/automation/export_document.go b/apps/backend/internal/automation/export_document.go new file mode 100644 index 0000000000..1bd06b0700 --- /dev/null +++ b/apps/backend/internal/automation/export_document.go @@ -0,0 +1,42 @@ +package automation + +import ( + "bytes" + "io" + + "gopkg.in/yaml.v3" +) + +// newExportDocument builds the top-level export document with its fixed version/type +// values (AC-40's `version`, `type` keys). +func newExportDocument(automations []exportAutomation, warnings []string) *exportDocument { + return &exportDocument{ + Version: exportDocumentVersion, + Type: exportDocumentType, + Automations: automations, + Warnings: warnings, + } +} + +// newPinnedYAMLEncoder returns an Encoder configured the one way this package ever +// emits YAML: 2-space indent (AC-12; yaml.Marshal's package-level default is 4). The +// prompt-fidelity probes (export_prompt.go) reuse this so a probe's encoder +// configuration can never drift from the real document encoder's. +func newPinnedYAMLEncoder(w io.Writer) *yaml.Encoder { + enc := yaml.NewEncoder(w) + enc.SetIndent(2) + return enc +} + +// marshalExportDocument renders a document to YAML bytes with the pinned encoder. +func marshalExportDocument(doc *exportDocument) ([]byte, error) { + var buf bytes.Buffer + enc := newPinnedYAMLEncoder(&buf) + if err := enc.Encode(doc); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + return buf.Bytes(), nil +} diff --git a/apps/backend/internal/automation/export_document_test.go b/apps/backend/internal/automation/export_document_test.go new file mode 100644 index 0000000000..25050fb330 --- /dev/null +++ b/apps/backend/internal/automation/export_document_test.go @@ -0,0 +1,165 @@ +package automation + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +func fullyPopulatedExportAutomation(t *testing.T) exportAutomation { + t.Helper() + configNode, err := jsonToYAMLNode(map[string]any{"cron_expression": "0 9 * * *"}) + if err != nil { + t.Fatalf("jsonToYAMLNode: %v", err) + } + promptNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "Do the thing"} + return exportAutomation{ + Name: "Daily Review", + Description: "Runs every morning", + Enabled: true, + MaxConcurrentRuns: 1, + TaskTitleTemplate: "Daily Review ({{trigger.timestamp}})", + Prompt: promptNode, + AgentProfile: &exportAgentProfile{AgentName: "Claude Code", Model: "opus[1m]", Mode: "auto"}, + ExecutorProfile: &exportExecutorProfile{Executor: "exec-worktree", Name: "Worktree"}, + Workflow: &exportWorkflow{Name: "Kanban", Step: "In Progress"}, + Repositories: []string{"kegmil-offline-first"}, + Triggers: []exportTrigger{ + {Type: "scheduled", Enabled: true, Config: configNode}, + }, + } +} + +// AC-40: top-level keys in fixed order version, type, automations, warnings (warnings +// omitted here since empty); automation keys in fixed order name, description, +// enabled, max_concurrent_runs, task_title_template, prompt, agent_profile, +// executor_profile, workflow, repositories, triggers; trigger keys in fixed order +// type, enabled, config. +func TestMarshalExportDocument_AC40KeyOrder(t *testing.T) { + doc := newExportDocument([]exportAutomation{fullyPopulatedExportAutomation(t)}, nil) + out, err := marshalExportDocument(doc) + if err != nil { + t.Fatalf("marshalExportDocument: %v", err) + } + + wantOrder := []string{ + "version:", + "type:", + "automations:", + "name:", + "description:", + "enabled:", + "max_concurrent_runs:", + "task_title_template:", + "prompt:", + "agent_profile:", + "executor_profile:", + "workflow:", + "repositories:", + "triggers:", + "type:", + "enabled:", + "config:", + } + assertKeysAppearInOrder(t, string(out), wantOrder) + + if strings.Contains(string(out), "warnings:") { + t.Errorf("expected no warnings key when Warnings is nil, got:\n%s", out) + } +} + +// AC-40: warnings key present, in the fixed top-level position after automations, +// when non-empty. +func TestMarshalExportDocument_WarningsKeyPresentWhenNonEmpty(t *testing.T) { + doc := newExportDocument([]exportAutomation{fullyPopulatedExportAutomation(t)}, []string{"unresolved workflow"}) + out, err := marshalExportDocument(doc) + if err != nil { + t.Fatalf("marshalExportDocument: %v", err) + } + assertKeysAppearInOrder(t, string(out), []string{"automations:", "warnings:"}) +} + +// AC-4: keys omitted when empty are skipped without disturbing the order of the rest. +func TestMarshalExportDocument_OmitsEmptyOptionalFields(t *testing.T) { + doc := newExportDocument([]exportAutomation{{ + Name: "Minimal", + Enabled: false, + MaxConcurrentRuns: 1, + Triggers: []exportTrigger{}, + }}, nil) + out, err := marshalExportDocument(doc) + if err != nil { + t.Fatalf("marshalExportDocument: %v", err) + } + s := string(out) + for _, absent := range []string{"description:", "task_title_template:", "prompt:", "agent_profile:", "executor_profile:", "workflow:", "repositories:"} { + if strings.Contains(s, absent) { + t.Errorf("expected %q omitted for empty field, got:\n%s", absent, s) + } + } + assertKeysAppearInOrder(t, s, []string{"name:", "enabled:", "max_concurrent_runs:", "triggers:"}) +} + +// AC-12: indentation is 2 spaces per nesting level, not yaml.v3's package-level +// Marshal default of 4. Nested levels legitimately accumulate (a mapping key one +// level below a sequence item sits at 4 spaces, one level below that at 6, and so +// on), so this asserts the per-level increment directly rather than banning any +// particular total indent width. +func TestMarshalExportDocument_TwoSpaceIndent(t *testing.T) { + doc := newExportDocument([]exportAutomation{fullyPopulatedExportAutomation(t)}, nil) + out, err := marshalExportDocument(doc) + if err != nil { + t.Fatalf("marshalExportDocument: %v", err) + } + s := string(out) + + // automations: (0 indent) -> " - name:" (2-space sequence indent). + if !strings.Contains(s, "automations:\n - name:") { + t.Errorf("expected 2-space indent between automations: and its first item, got:\n%s", s) + } + // A mapping one level below the sequence item's own keys: agent_profile: + // (4-space, aligned with the other keys inside "- name: ...") -> its own + // agent_name key one level deeper still, at 6 spaces (4 + 2). + if !strings.Contains(s, "\n agent_profile:\n agent_name:") { + t.Errorf("expected 2-space increment from agent_profile: to agent_name:, got:\n%s", s) + } + + // A default (unpinned) yaml.v3 Marshal call would use 4-space indent + // end-to-end; confirm that shape is NOT what we produced, so this test would + // fail if SetIndent(2) were ever dropped. + defaultOut, err := yaml.Marshal(doc) + if err != nil { + t.Fatalf("yaml.Marshal (default indent): %v", err) + } + if s == string(defaultOut) { + t.Errorf("pinned 2-space output is byte-identical to the default-indent Marshal output; SetIndent(2) had no effect") + } +} + +// AC-40's own commentary: order must not rest on struct field declaration order being +// an implementation detail — pin it with a version/type/automations/warnings +// constructor test independent of newExportDocument's shape. +func TestNewExportDocument_FixedVersionAndType(t *testing.T) { + doc := newExportDocument(nil, nil) + if doc.Version != 1 { + t.Errorf("Version = %d, want 1", doc.Version) + } + if doc.Type != "kandev_automations" { + t.Errorf("Type = %q, want kandev_automations", doc.Type) + } +} + +// assertKeysAppearInOrder checks that each key substring appears in out, in the given +// relative order (not necessarily contiguous), by scanning forward through out. +func assertKeysAppearInOrder(t *testing.T, out string, keysInOrder []string) { + t.Helper() + cursor := 0 + for _, key := range keysInOrder { + idx := strings.Index(out[cursor:], key) + if idx == -1 { + t.Fatalf("expected key %q after position %d, not found in:\n%s", key, cursor, out) + } + cursor += idx + len(key) + } +} diff --git a/apps/backend/internal/automation/export_handlers.go b/apps/backend/internal/automation/export_handlers.go new file mode 100644 index 0000000000..90e9931a57 --- /dev/null +++ b/apps/backend/internal/automation/export_handlers.go @@ -0,0 +1,70 @@ +package automation + +import ( + "errors" + "net/http" + + "github.com/gin-gonic/gin" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// ExportHandler serves the two read-only YAML/zip automation export +// endpoints (see docs/specs/automations-yaml-export/spec.md § API surface). +type ExportHandler struct { + svc *Service + log *logger.Logger +} + +// NewExportHandler constructs an ExportHandler. +func NewExportHandler(svc *Service, log *logger.Logger) *ExportHandler { + return &ExportHandler{svc: svc, log: log} +} + +// exportStatus applies AC-44's classification: errors.Is(err, +// repoerrors.ErrWorkspaceNotFound) is a 404 (AC-31, AC-35, covering both +// step 1's authorizer and step 2's workspace lookup); any other non-nil +// error is a 500 (AC-45). +func exportStatus(err error) int { + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + return http.StatusNotFound + } + return http.StatusInternalServerError +} + +// ExportDocument serves GET /api/v1/workspaces/:id/automations/export. +// The body is built completely in memory by Service.ExportAutomationsDocument +// before c.Data writes the status line and body (AC-48). +func (h *ExportHandler) ExportDocument(c *gin.Context) { + body, err := h.svc.ExportAutomationsDocument(c.Request.Context(), c.Param("id")) + if err != nil { + h.respondExportError(c, err) + return + } + c.Data(http.StatusOK, "application/yaml", body) +} + +// ExportZip serves GET /api/v1/workspaces/:id/automations/export/zip. The +// archive is built completely in memory by Service.ExportAutomationsZip +// before c.Data writes the status line and body (AC-48) — deliberately not +// Office's io.Copy streaming pattern, which cannot retract a 200 already +// sent once a mid-copy failure occurs. +func (h *ExportHandler) ExportZip(c *gin.Context) { + body, err := h.svc.ExportAutomationsZip(c.Request.Context(), c.Param("id")) + if err != nil { + h.respondExportError(c, err) + return + } + c.Header("Content-Disposition", "attachment; filename=kandev-automations.zip") + c.Data(http.StatusOK, "application/zip", body) +} + +func (h *ExportHandler) respondExportError(c *gin.Context, err error) { + status := exportStatus(err) + if status == http.StatusInternalServerError { + h.log.Error("automation export failed", zap.Error(err)) + } + c.JSON(status, gin.H{responseErrorKey: http.StatusText(status)}) +} diff --git a/apps/backend/internal/automation/export_handlers_test.go b/apps/backend/internal/automation/export_handlers_test.go new file mode 100644 index 0000000000..2cb649eecc --- /dev/null +++ b/apps/backend/internal/automation/export_handlers_test.go @@ -0,0 +1,172 @@ +package automation + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// newExportTestRouter mounts only the export routes over svc, mirroring +// office/config's newTestRouter helper. +func newExportTestRouter(svc *Service) *gin.Engine { + gin.SetMode(gin.TestMode) + router := gin.New() + registerHTTPRoutes(router, svc, logger.Default()) + return router +} + +func doExportRequest(router *gin.Engine, path string) *httptest.ResponseRecorder { + req := httptest.NewRequest(http.MethodGet, path, nil) + rec := httptest.NewRecorder() + router.ServeHTTP(rec, req) + return rec +} + +// API surface: GET .../automations/export returns 200, +// Content-Type: application/yaml, body is the YAML document. + +func TestExportDocumentHandler_Success_ReturnsYAMLBody(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync"}) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-1/automations/export") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/yaml" { + t.Errorf("Content-Type = %q, want application/yaml", ct) + } + if rec.Body.Len() == 0 { + t.Error("expected a non-empty body") + } +} + +// API surface: GET .../automations/export/zip returns 200, +// Content-Type: application/zip, Content-Disposition attachment. + +func TestExportZipHandler_Success_ReturnsZipBodyWithDisposition(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync"}) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-1/automations/export/zip") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } + if ct := rec.Header().Get("Content-Type"); ct != "application/zip" { + t.Errorf("Content-Type = %q, want application/zip", ct) + } + want := "attachment; filename=kandev-automations.zip" + if cd := rec.Header().Get("Content-Disposition"); cd != want { + t.Errorf("Content-Disposition = %q, want %q", cd, want) + } + if rec.Body.Len() == 0 { + t.Error("expected a non-empty body") + } +} + +// AC-32: an existing empty workspace is 200 with an empty automations list, +// not an error status. + +func TestExportDocumentHandler_EmptyWorkspace_ReturnsOK(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-empty"] = true + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-empty/automations/export") + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body.String()) + } +} + +// AC-31/AC-35/AC-44: both a denied caller and a nonexistent workspace +// return 404 with no distinguishing text. + +func TestExportDocumentHandler_WorkspaceNotFound_Returns404(t *testing.T) { + svc, _ := exportServiceTestFixture(t) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-missing/automations/export") + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", rec.Code, rec.Body.String()) + } +} + +func TestExportDocumentHandler_AuthorizeDenied_Returns404(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { + return errors.Join(errors.New("denied"), repoerrors.ErrWorkspaceNotFound) + }) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-1/automations/export") + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", rec.Code, rec.Body.String()) + } +} + +// AC-45: any other failure (here, an unwired workspace lookup) is 500. + +func TestExportDocumentHandler_WorkspaceLookupUnavailable_Returns500(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-1/automations/export") + + if rec.Code != http.StatusInternalServerError { + t.Fatalf("status = %d, want 500 (body: %s)", rec.Code, rec.Body.String()) + } +} + +// AC-35: the two 404 paths above must be indistinguishable in body, not just +// status. Round 7 testing proved this was previously uncovered: mutating +// respondExportError to leak "workspace access denied: " into the +// denied-caller body passed the full suite before this test existed. + +func TestExportDocumentHandler_DeniedAndNotFound_ProduceIdenticalBody(t *testing.T) { + notFoundSvc, _ := exportServiceTestFixture(t) + notFoundRouter := newExportTestRouter(notFoundSvc) + notFoundRec := doExportRequest(notFoundRouter, "/api/v1/workspaces/ws-missing/automations/export") + + deniedSvc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + deniedSvc.SetWorkspaceAuthorizer(func(context.Context, string) error { + return errors.Join(errors.New("denied"), repoerrors.ErrWorkspaceNotFound) + }) + deniedRouter := newExportTestRouter(deniedSvc) + deniedRec := doExportRequest(deniedRouter, "/api/v1/workspaces/ws-1/automations/export") + + if notFoundRec.Code != http.StatusNotFound || deniedRec.Code != http.StatusNotFound { + t.Fatalf("status codes = %d, %d, want both 404", notFoundRec.Code, deniedRec.Code) + } + if notFoundRec.Body.String() != deniedRec.Body.String() { + t.Errorf("bodies differ: not-found=%q, denied=%q; AC-35 requires them indistinguishable", notFoundRec.Body.String(), deniedRec.Body.String()) + } +} + +func TestExportZipHandler_WorkspaceNotFound_Returns404(t *testing.T) { + svc, _ := exportServiceTestFixture(t) + router := newExportTestRouter(svc) + + rec := doExportRequest(router, "/api/v1/workspaces/ws-missing/automations/export/zip") + + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (body: %s)", rec.Code, rec.Body.String()) + } +} diff --git a/apps/backend/internal/automation/export_lookup.go b/apps/backend/internal/automation/export_lookup.go new file mode 100644 index 0000000000..6175d0a32e --- /dev/null +++ b/apps/backend/internal/automation/export_lookup.go @@ -0,0 +1,58 @@ +package automation + +import ( + "context" + + "github.com/jmoiron/sqlx" + + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + taskmodels "github.com/kandev/kandev/internal/task/models" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// ExportAgentProfileLookup resolves the full agent profile row an +// automation's agent_profile_id references, within the single read +// transaction AC-29 opens for the whole export. Satisfied directly by +// agent/settings/store's concrete repository (GetAgentProfileTx), which +// already returns the three-outcome (value, found, err) shape AC-19 +// requires — this interface exists only to let the automation package +// depend on that method without importing the settings store package. +// +// Deliberately not the existing AgentProfileLookup: that interface only +// answers whether a profile exists, and the export needs AgentName/Model/Mode +// to build exportAgentProfile. +type ExportAgentProfileLookup interface { + GetAgentProfileTx(ctx context.Context, tx *sqlx.Tx, id string) (*settingsmodels.AgentProfile, bool, error) +} + +// ExportExecutorProfileLookup resolves the full executor profile row an +// automation's executor_profile_id references, within the export's shared +// transaction. No existing interface in this package answers this at all — +// nothing here validates executor_profile_id today. +type ExportExecutorProfileLookup interface { + GetExecutorProfileTx(ctx context.Context, tx *sqlx.Tx, id string) (*taskmodels.ExecutorProfile, bool, error) +} + +// ExportWorkflowLookup resolves the full workflow row an automation's +// workflow_id references. Deliberately not WorkflowLocator: that interface +// returns only the owning workspace ID for an authorization check, not the +// workflow's name. +type ExportWorkflowLookup interface { + GetWorkflowTx(ctx context.Context, tx *sqlx.Tx, id string) (*taskmodels.Workflow, bool, error) +} + +// ExportWorkflowStepLookup resolves the full workflow step row an +// automation's workflow_step_id references. +type ExportWorkflowStepLookup interface { + GetStepTx(ctx context.Context, tx *sqlx.Tx, id string) (*workflowmodels.WorkflowStep, bool, error) +} + +// ExportRepositoryLookup resolves the full repository row a repository_ids +// entry references. Deliberately not the existing RepositoryLookup: that +// interface returns (workspaceID, defaultBranch, ok) for cross-workspace +// validation, not the repository's name, and its two-valued (value, ok) shape +// cannot distinguish "not found" from "lookup failed" — the collapse AC-19 +// forbids for the export path. +type ExportRepositoryLookup interface { + GetRepositoryTx(ctx context.Context, tx *sqlx.Tx, id string) (*taskmodels.Repository, bool, error) +} diff --git a/apps/backend/internal/automation/export_node.go b/apps/backend/internal/automation/export_node.go new file mode 100644 index 0000000000..040176e34b --- /dev/null +++ b/apps/backend/internal/automation/export_node.go @@ -0,0 +1,108 @@ +package automation + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strconv" + + "gopkg.in/yaml.v3" +) + +// yamlStrTag is the explicit YAML 1.1 string tag, used wherever a scalar must +// stay a string on emission rather than retyping to bool/int/etc. (AC-23). +const yamlStrTag = "!!str" + +// decodeJSONWithNumbers decodes JSON bytes into Go values using json.Number for +// numeric literals rather than float64, so AC-41's byte-for-byte numeric fidelity +// (e.g. 18446744073709551616, which overflows both int64 and uint64, or 1e400, which +// overflows float64) survives the decode step untouched. +// +// json.Decoder.Decode only consumes one JSON value and stops; unlike +// json.Unmarshal it does not reject trailing bytes after that value. A +// trailing-data check is required so `{"a":1} garbage` is treated as +// invalid JSON (AC-39) rather than silently accepted as `{"a":1}`. +func decodeJSONWithNumbers(raw []byte) (any, error) { + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.UseNumber() + var v any + if err := dec.Decode(&v); err != nil { + return nil, err + } + if _, err := dec.Token(); err != io.EOF { + return nil, fmt.Errorf("automation: trailing data after JSON value") + } + return v, nil +} + +// jsonToYAMLNode converts a JSON value decoded with UseNumber into a *yaml.Node, +// following the per-JSON-type table AC-8 and AC-41 require: +// +// - object (map[string]any) -> MappingNode, keys sorted byte-wise ascending, at +// every nesting depth — never yaml.v3's own map-key sorter, which orders "v1", +// "v2", "v10" instead of the byte-wise "v1", "v10", "v2". +// - array ([]any) -> SequenceNode, stored order preserved, never sorted. +// - string -> ScalarNode with an explicit !!str tag, so a string +// like "true" or "12" is quoted on emission and re-parses as a string rather than +// retyping to bool/int (AC-23). +// - json.Number -> ScalarNode with Tag left unset and Value set to the +// original lexeme. Tag must stay unset: setting !!int/!!float explicitly makes +// the encoder compare against its own re-resolution of Value and print the tag +// whenever they disagree, which happens for values outside int64/uint64/float64 +// range. Never convert through int64/float64 — that is exactly the rounding +// AC-41 forbids. +// - bool -> ScalarNode, Tag !!bool. +// - nil -> ScalarNode, Tag !!null, Value "null". +func jsonToYAMLNode(v any) (*yaml.Node, error) { + switch val := v.(type) { + case map[string]any: + return jsonObjectToYAMLNode(val) + case []any: + return jsonArrayToYAMLNode(val) + case string: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Value: val}, nil + case json.Number: + return &yaml.Node{Kind: yaml.ScalarNode, Value: val.String()}, nil + case bool: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!bool", Value: strconv.FormatBool(val)}, nil + case nil: + return &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!null", Value: "null"}, nil + default: + return nil, fmt.Errorf("automation: unsupported JSON value type %T for YAML export", v) + } +} + +func jsonObjectToYAMLNode(obj map[string]any) (*yaml.Node, error) { + keys := make([]string, 0, len(obj)) + for k := range obj { + keys = append(keys, k) + } + sort.Strings(keys) // byte-wise ascending; Go string comparison is byte-wise UTF-8. + + node := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + for _, k := range keys { + valueNode, err := jsonToYAMLNode(obj[k]) + if err != nil { + return nil, err + } + node.Content = append(node.Content, + &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Value: k}, + valueNode, + ) + } + return node, nil +} + +func jsonArrayToYAMLNode(arr []any) (*yaml.Node, error) { + node := &yaml.Node{Kind: yaml.SequenceNode, Tag: "!!seq"} + for _, item := range arr { + itemNode, err := jsonToYAMLNode(item) + if err != nil { + return nil, err + } + node.Content = append(node.Content, itemNode) + } + return node, nil +} diff --git a/apps/backend/internal/automation/export_node_test.go b/apps/backend/internal/automation/export_node_test.go new file mode 100644 index 0000000000..3f92a1294b --- /dev/null +++ b/apps/backend/internal/automation/export_node_test.go @@ -0,0 +1,144 @@ +package automation + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +// decodeJSONNode is a test helper: decode raw JSON with UseNumber, build the YAML +// node via jsonToYAMLNode, marshal it, and return the marshaled bytes for assertion. +func marshalJSONAsYAMLNode(t *testing.T, rawJSON string) string { + t.Helper() + v, err := decodeJSONWithNumbers([]byte(rawJSON)) + if err != nil { + t.Fatalf("decodeJSONWithNumbers(%q): %v", rawJSON, err) + } + node, err := jsonToYAMLNode(v) + if err != nil { + t.Fatalf("jsonToYAMLNode(%v): %v", v, err) + } + out, err := yaml.Marshal(node) + if err != nil { + t.Fatalf("yaml.Marshal(node): %v", err) + } + return string(out) +} + +// AC-8: object keys are sorted byte-wise ascending, not by yaml.v3's own digit-aware +// map sorter (which would order v1, v2, v10 — wrong for a byte-wise contract). +func TestJSONToYAMLNode_ByteWiseKeyOrder(t *testing.T) { + got := marshalJSONAsYAMLNode(t, `{"v2": 1, "v10": 2, "v1": 3}`) + want := "v1: 3\nv10: 2\nv2: 1\n" + if got != want { + t.Errorf("byte-wise key order mismatch:\ngot: %q\nwant: %q", got, want) + } +} + +// AC-8: nested objects are sorted at every depth, not just the top level. +func TestJSONToYAMLNode_SortedAtEveryDepth(t *testing.T) { + got := marshalJSONAsYAMLNode(t, `{"outer": {"z": 1, "a": 2}}`) + want := "outer:\n a: 2\n z: 1\n" + if got != want { + t.Errorf("nested sort mismatch:\ngot: %q\nwant: %q", got, want) + } +} + +// AC-8: arrays preserve stored order and are never sorted. +func TestJSONToYAMLNode_ArrayOrderPreserved(t *testing.T) { + got := marshalJSONAsYAMLNode(t, `["z", "a", "m"]`) + want := "- z\n- a\n- m\n" + if got != want { + t.Errorf("array order mismatch:\ngot: %q\nwant: %q", got, want) + } +} + +// AC-41: numbers are emitted unquoted, untagged, and character-identical to the +// stored lexeme — including values outside int64/uint64/float64 range, which must +// never be rounded, reformatted, or coerced through a Go numeric type. +func TestJSONToYAMLNode_NumberFidelity(t *testing.T) { + cases := []string{ + "0", + "12", + "-7", + "1.50", + "0.0", + "18446744073709551616", // overflows both int64 and uint64 + "1e400", // overflows float64 + "-0", + } + for _, lexeme := range cases { + t.Run(lexeme, func(t *testing.T) { + v, err := decodeJSONWithNumbers([]byte(lexeme)) + if err != nil { + t.Fatalf("decodeJSONWithNumbers(%q): %v", lexeme, err) + } + node, err := jsonToYAMLNode(v) + if err != nil { + t.Fatalf("jsonToYAMLNode(%v): %v", v, err) + } + if node.Tag != "" { + t.Errorf("number node Tag = %q, want unset (empty)", node.Tag) + } + if node.Value != lexeme { + t.Errorf("number node Value = %q, want %q (lexeme preserved)", node.Value, lexeme) + } + }) + } +} + +// AC-8/AC-23: a string whose text would resolve to another YAML type must carry an +// explicit !!str tag so it re-parses as a string, never retyped to bool/null/int/float. +func TestJSONToYAMLNode_AmbiguousStringsStayStrings(t *testing.T) { + cases := []string{"true", "false", "null", "1.0", "0755", "12", "~", "yes", "no"} + for _, s := range cases { + t.Run(s, func(t *testing.T) { + v, err := decodeJSONWithNumbers([]byte(`"` + s + `"`)) + if err != nil { + t.Fatalf("decodeJSONWithNumbers: %v", err) + } + node, err := jsonToYAMLNode(v) + if err != nil { + t.Fatalf("jsonToYAMLNode: %v", err) + } + out, err := yaml.Marshal(node) + if err != nil { + t.Fatalf("yaml.Marshal: %v", err) + } + var roundTrip yaml.Node + if err := yaml.Unmarshal(out, &roundTrip); err != nil { + t.Fatalf("yaml.Unmarshal(%q): %v", out, err) + } + // Document node wraps the actual scalar. + scalar := &roundTrip + if roundTrip.Kind == yaml.DocumentNode { + scalar = roundTrip.Content[0] + } + if scalar.Tag != "!!str" { + t.Errorf("round-tripped tag = %q, want !!str (emitted: %q)", scalar.Tag, out) + } + if scalar.Value != s { + t.Errorf("round-tripped value = %q, want %q", scalar.Value, s) + } + }) + } +} + +// AC-8: bool and null get their standard explicit tags. +func TestJSONToYAMLNode_BoolAndNull(t *testing.T) { + boolNode, err := jsonToYAMLNode(true) + if err != nil { + t.Fatalf("jsonToYAMLNode(true): %v", err) + } + if boolNode.Tag != "!!bool" || boolNode.Value != "true" { + t.Errorf("bool node = {Tag:%q Value:%q}, want {Tag:!!bool Value:true}", boolNode.Tag, boolNode.Value) + } + + nullNode, err := jsonToYAMLNode(nil) + if err != nil { + t.Fatalf("jsonToYAMLNode(nil): %v", err) + } + if nullNode.Tag != "!!null" || nullNode.Value != "null" { + t.Errorf("null node = {Tag:%q Value:%q}, want {Tag:!!null Value:null}", nullNode.Tag, nullNode.Value) + } +} diff --git a/apps/backend/internal/automation/export_prompt.go b/apps/backend/internal/automation/export_prompt.go new file mode 100644 index 0000000000..6f1d9c6f87 --- /dev/null +++ b/apps/backend/internal/automation/export_prompt.go @@ -0,0 +1,110 @@ +package automation + +import ( + "bytes" + "encoding/base64" + "fmt" + "strings" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +// yamlNewlineChars is YAML 1.2's line-break set, per +// docs/specs/automations-yaml-export/spec.md's Definitions section: +// {U+000A, U+000D, U+0085, U+2028, U+2029}. AC-17 and AC-46 are stated against this +// set, not against "\n" alone — a CR-only, NEL-only, LS-only, or PS-only prompt still +// forces a non-literal emission and must still warn under AC-17. +const yamlNewlineChars = "\n\r\u0085\u2028\u2029" + +func containsYAMLNewline(s string) bool { + return strings.ContainsAny(s, yamlNewlineChars) +} + +const ( + warnPromptInvalidUTF8 = "prompt not emitted as a block scalar: invalid UTF-8" + warnPromptNotBlockScalar = "prompt not emitted as a block scalar" + warnPromptRequoted = "prompt re-quoted to preserve bytes" +) + +// buildPromptNode resolves a non-empty, exported automation prompt to its final +// *yaml.Node form and an optional warning, following the ordered classification +// AC-15/16/17/46/47/49 require. Callers must not invoke this for an empty prompt: +// AC-4/AC-46 omit the `prompt` key entirely in that case, before any fidelity rule +// applies. +// +// Exactly one classification branch is taken, evaluated in this fixed order: +// 1. Not valid UTF-8 -> AC-47: emit as !!binary (base64), warn, never a failure. +// 2. Valid UTF-8, but the pinned marshaller's default-form probe does not decode +// back byte-for-byte -> AC-49: re-probe an explicit double-quoted !!str form; if +// that round-trips, emit it and warn "prompt re-quoted to preserve bytes" +// (this suppresses any AC-17 warning for this prompt); if it too fails to +// round-trip, that is a serialization failure (AC-36/AC-45) with no node. +// 3. Default form round-trips, but its emitted Style is not a literal block scalar +// and the prompt contains a YAML newline -> AC-17: emit the default form and +// warn, without overriding style. +// 4. Otherwise -> AC-16/AC-46: emit the default form, no warning. +// +// Both probes marshal the prompt alone through an encoder configured identically to +// the real document encoder (AC-12's 2-space indent, via newPinnedYAMLEncoder) and +// re-parse the result to read Tag/Style/Value — "observe, don't predict" per the +// spec's decision record, not an enumeration of which input characters are assumed +// to degrade. +func buildPromptNode(prompt string) (node *yaml.Node, warning string, err error) { + if !utf8.ValidString(prompt) { + return &yaml.Node{ + Kind: yaml.ScalarNode, + Tag: "!!binary", + Value: base64.StdEncoding.EncodeToString([]byte(prompt)), + }, warnPromptInvalidUTF8, nil + } + + defaultNode, err := probeScalarEmission(prompt) + if err != nil { + return nil, "", err + } + + if defaultNode.Value != prompt { + requoted := &yaml.Node{Kind: yaml.ScalarNode, Tag: yamlStrTag, Style: yaml.DoubleQuotedStyle, Value: prompt} + reprobed, err := probeScalarEmission(requoted) + if err != nil { + return nil, "", err + } + if reprobed.Value != prompt { + return nil, "", fmt.Errorf("automation: prompt does not round-trip under the default or double-quoted emission form") + } + return requoted, warnPromptRequoted, nil + } + + if defaultNode.Style != yaml.LiteralStyle && containsYAMLNewline(prompt) { + return defaultNode, warnPromptNotBlockScalar, nil + } + return defaultNode, "", nil +} + +// probeScalarEmission marshals v (a string or a *yaml.Node) through the pinned +// encoder, re-parses the result, and returns the resulting scalar node — from which +// Tag, Style, and Value can all be read. This is the probe step both branches of +// buildPromptNode share. +func probeScalarEmission(v any) (*yaml.Node, error) { + var buf bytes.Buffer + enc := newPinnedYAMLEncoder(&buf) + if err := enc.Encode(v); err != nil { + return nil, err + } + if err := enc.Close(); err != nil { + return nil, err + } + + var doc yaml.Node + if err := yaml.Unmarshal(buf.Bytes(), &doc); err != nil { + return nil, err + } + if doc.Kind == yaml.DocumentNode { + if len(doc.Content) == 0 { + return nil, fmt.Errorf("automation: pinned encoder produced an empty document") + } + return doc.Content[0], nil + } + return &doc, nil +} diff --git a/apps/backend/internal/automation/export_prompt_test.go b/apps/backend/internal/automation/export_prompt_test.go new file mode 100644 index 0000000000..b167eec33e --- /dev/null +++ b/apps/backend/internal/automation/export_prompt_test.go @@ -0,0 +1,210 @@ +package automation + +import ( + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// decodePromptNode marshals node the same way the real document encoder would (as +// part of a document, not standalone), then decodes the prompt scalar back to a Go +// string. Used to assert AC-49's round trip independent of buildPromptNode's own +// internals. +func decodePromptNode(t *testing.T, node *yaml.Node) string { + t.Helper() + doc := newExportDocument([]exportAutomation{{ + Name: "Round Trip", + Enabled: true, + MaxConcurrentRuns: 1, + Prompt: node, + Triggers: []exportTrigger{}, + }}, nil) + out, err := marshalExportDocument(doc) + if err != nil { + t.Fatalf("marshalExportDocument: %v", err) + } + var decoded struct { + Automations []struct { + Prompt string `yaml:"prompt"` + } `yaml:"automations"` + } + if err := yaml.Unmarshal(out, &decoded); err != nil { + t.Fatalf("yaml.Unmarshal(%q): %v", out, err) + } + if len(decoded.Automations) != 1 { + t.Fatalf("expected 1 automation, got %d", len(decoded.Automations)) + } + return decoded.Automations[0].Prompt +} + +// AC-16/AC-46: ordinary multi-line prompts emit as a literal block scalar, no warning, +// no fidelity loss. +func TestBuildPromptNode_MultilineLiteralNoWarning(t *testing.T) { + cases := []string{ + "line one\nline two\n", + "line one\nline two", + "line one\nline two\n\n", + "line one\ta\nline two\n", + } + for _, prompt := range cases { + t.Run(prompt, func(t *testing.T) { + node, warning, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode(%q): %v", prompt, err) + } + if warning != "" { + t.Errorf("warning = %q, want none", warning) + } + if node.Style != yaml.LiteralStyle { + t.Errorf("Style = %v, want LiteralStyle", node.Style) + } + if got := decodePromptNode(t, node); got != prompt { + t.Errorf("round trip = %q, want %q", got, prompt) + } + }) + } +} + +// AC-17: a prompt containing a YAML newline (## Definitions' 5-character set +// {U+000A, U+000D, U+0085, U+2028, U+2029}, not just LF) that the pinned marshaller +// does not emit as a literal block scalar gets the "prompt not emitted as a block +// scalar" warning. Tested as a biconditional against the node's observed Style, not +// against an enumerated list of "bad" characters (spec.md AC-17 testability note) — +// these are exactly the cases from the spec's own probed reference table that degrade +// to double-quoted. +func TestBuildPromptNode_NonLiteralWithNewlineWarns(t *testing.T) { + cases := []string{ + "line one \nline two\n", // trailing space before break + "line one\nline two ", // trailing space, no final break + "line one\r\nline two\r\n", // CR + "line one\na\U0001F389b\n", // astral character + "line one\na\x1Bb\n", // C0 control (ESC) + "line one\na
b\n", // U+2028 LINE SEPARATOR + "line one\u0085line two", // U+0085 NEL, no LF at all + "line one
line two", // U+2029 PARAGRAPH SEPARATOR, no LF at all + } + for _, prompt := range cases { + t.Run(prompt, func(t *testing.T) { + node, warning, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode(%q): %v", prompt, err) + } + isLiteral := node.Style == yaml.LiteralStyle + hasWarning := warning == warnPromptNotBlockScalar + if isLiteral == hasWarning { + t.Errorf("biconditional violated: Style=%v (literal=%v) but warning=%q (matches=%v)", node.Style, isLiteral, warning, hasWarning) + } + if got := decodePromptNode(t, node); got != prompt { + t.Errorf("round trip = %q, want %q", got, prompt) + } + }) + } +} + +// AC-46: single-line prompts never warn, regardless of the specific form the +// marshaller chooses (plain, single-quoted, etc). +func TestBuildPromptNode_SingleLineNoWarning(t *testing.T) { + cases := []string{ + "Do the thing", + "Do the thing ", + "true", + } + for _, prompt := range cases { + t.Run(prompt, func(t *testing.T) { + node, warning, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode(%q): %v", prompt, err) + } + if warning != "" { + t.Errorf("warning = %q, want none", warning) + } + if got := decodePromptNode(t, node); got != prompt { + t.Errorf("round trip = %q, want %q", got, prompt) + } + }) + } +} + +// AC-47: invalid UTF-8 emits as !!binary, warns, and is never treated as a +// serialization failure. +func TestBuildPromptNode_InvalidUTF8(t *testing.T) { + prompt := "line one\nbad\xff\xfebyte\n" + node, warning, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode: %v", err) + } + if node.Tag != "!!binary" { + t.Errorf("Tag = %q, want !!binary", node.Tag) + } + if warning != warnPromptInvalidUTF8 { + t.Errorf("warning = %q, want %q", warning, warnPromptInvalidUTF8) + } +} + +// AC-49: prompts whose default-form probe does not round-trip byte-for-byte get +// re-quoted as an explicit double-quoted !!str node, with a distinct warning. These +// are the leading-newline cases the spec calls out explicitly: they would otherwise +// silently emit as Literal (satisfying AC-16 with no AC-17 warning) while dropping the +// leading newline — exactly the gap AC-49 exists to close. The observable is the round +// trip itself, per the spec's own testability note. +func TestBuildPromptNode_LeadingNewlineRequoted(t *testing.T) { + cases := []string{ + "\nhello", + "\n", + "\n\nhello", + "\n\n", + } + for _, prompt := range cases { + t.Run(prompt, func(t *testing.T) { + node, warning, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode(%q): %v", prompt, err) + } + if warning != warnPromptRequoted { + t.Errorf("warning = %q, want %q", warning, warnPromptRequoted) + } + if node.Style != yaml.DoubleQuotedStyle { + t.Errorf("Style = %v, want DoubleQuotedStyle", node.Style) + } + if node.Tag != "!!str" { + t.Errorf("Tag = %q, want !!str", node.Tag) + } + if got := decodePromptNode(t, node); got != prompt { + t.Errorf("round trip = %q, want %q", got, prompt) + } + }) + } +} + +// containsYAMLNewline must match the exact 5-character YAML 1.2 line-break set from +// spec.md's Definitions section, not just LF. +func TestContainsYAMLNewline(t *testing.T) { + yes := []string{"\n", "\r", "\u0085", "\u2028", "\u2029", "a\u2028b"} + for _, s := range yes { + if !containsYAMLNewline(s) { + t.Errorf("containsYAMLNewline(%q) = false, want true", s) + } + } + no := []string{"", "plain text", "no breaks here \t tab only"} + for _, s := range no { + if containsYAMLNewline(s) { + t.Errorf("containsYAMLNewline(%q) = true, want false", s) + } + } +} + +// Guard against a regression where the probe encoder configuration drifts from +// marshalExportDocument's: both must use the same 2-space, pinned encoder (AC-12), +// or a future indent change could make a prompt round-trip differently standalone +// than it does inside the real document. +func TestBuildPromptNode_UsesPinnedEncoderConfig(t *testing.T) { + prompt := "line one\nline two\n" + node, _, err := buildPromptNode(prompt) + if err != nil { + t.Fatalf("buildPromptNode: %v", err) + } + if !strings.Contains(node.Value, "line one") { + t.Fatalf("unexpected node value %q", node.Value) + } +} diff --git a/apps/backend/internal/automation/export_redaction_test.go b/apps/backend/internal/automation/export_redaction_test.go new file mode 100644 index 0000000000..ad3812f428 --- /dev/null +++ b/apps/backend/internal/automation/export_redaction_test.go @@ -0,0 +1,435 @@ +package automation + +import ( + "bytes" + "context" + "fmt" + "strings" + "testing" + "time" + + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + taskmodels "github.com/kandev/kandev/internal/task/models" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" + "gopkg.in/yaml.v3" +) + +// exportBothForms exports workspaceID as both the document and zip forms +// and returns the document bytes plus every zip entry's bytes, so a single +// assertion pass can cover both output shapes (AC-6, AC-43). +func exportBothForms(t *testing.T, svc *Service, workspaceID string) (doc []byte, zipEntries [][]byte) { + t.Helper() + doc, err := svc.ExportAutomationsDocument(context.Background(), workspaceID) + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + zipBody, err := svc.ExportAutomationsZip(context.Background(), workspaceID) + if err != nil { + t.Fatalf("ExportAutomationsZip: %v", err) + } + for _, name := range zipEntryNames(t, zipBody) { + data, ok := readZipFile(t, zipBody, name) + if !ok { + t.Fatalf("zip entry %q listed but not readable", name) + } + zipEntries = append(zipEntries, data) + } + return doc, zipEntries +} + +// AC-6: WebhookSecret must never appear in either export form's bytes, even +// though it is a non-empty, attacker-recognizable value. AC-22 only proves +// the field is classified `exported: false`; it does not prove the DTO +// omits the value at serialization time. +func TestExportRedaction_AC6_WebhookSecretNeverAppearsInOutput(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{"ws-1": true}} + svc.SetExportWorkspaceLookup(wsLookup) + + const sentinel = "sentinel-webhook-secret-do-not-leak-9f3a1c" + if err := svc.store.CreateAutomation(context.Background(), &Automation{ + ID: "auto-secret", + WorkspaceID: "ws-1", + Name: "Secret Bearer", + WebhookSecret: sentinel, + }); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + doc, zipEntries := exportBothForms(t, svc, "ws-1") + + if bytes.Contains(doc, []byte(sentinel)) { + t.Errorf("document export leaked WebhookSecret: %s", doc) + } + for _, entry := range zipEntries { + if bytes.Contains(entry, []byte(sentinel)) { + t.Errorf("zip entry leaked WebhookSecret: %s", entry) + } + } +} + +// AC-23: every field automationFieldDispositions / automationTriggerFieldDispositions +// mark `exported: true` must actually reach the DTO at its declared yaml key +// with the source value, for a fully-populated automation. AC-22 only proves +// the field is classified `exported: true`; it does not prove the value +// survives marshaling. +func TestExportRedaction_AC23_FullyPopulatedAutomationRoundTripsEveryExportedField(t *testing.T) { + svc, agentLookup, executorLookup, workflowLookup, stepLookup, repoLookup := resolveTestFixture(t) + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{"ws-1": true}} + svc.SetExportWorkspaceLookup(wsLookup) + + agentLookup.profiles["agent-1"] = &settingsmodels.AgentProfile{Name: "Reviewer", AgentDisplayName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + executorLookup.profiles["exec-1"] = &taskmodels.ExecutorProfile{ExecutorID: "local_docker", Name: "Default Executor"} + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "New Feature Dev"} + stepLookup.steps["step-1"] = &workflowmodels.WorkflowStep{Name: "Build", WorkflowID: "wf-1"} + repoLookup.repositories["repo-1"] = &taskmodels.Repository{Name: "kandev"} + repoLookup.repositories["repo-2"] = &taskmodels.Repository{Name: "kandev-web"} + + a := &Automation{ + ID: "auto-full", + WorkspaceID: "ws-1", + Name: "Fully Populated Automation", + Description: "Exercises every exported field at once", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + Prompt: "Review the diff and leave comments", + TaskTitleTemplate: "Review: {{.PRTitle}}", + Enabled: true, + MaxConcurrentRuns: 3, + RepositoryIDs: []string{"repo-1", "repo-2"}, + } + if err := svc.store.CreateAutomation(context.Background(), a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + if err := svc.store.CreateTrigger(context.Background(), &AutomationTrigger{ + ID: "trig-scheduled", + AutomationID: a.ID, + Type: TriggerTypeScheduled, + // dry_run's value ("true") is a string whose bare text would resolve to + // !!bool if emitted without an explicit tag, and retries is a JSON + // number that must NOT carry !!str — both required by AC-23's Config + // clause so the tag half of the assertion below is not vacuous. + Config: []byte(`{"cron":"0 9 * * 1-5","dry_run":"true","retries":3}`), + Enabled: true, + }); err != nil { + t.Fatalf("CreateTrigger scheduled: %v", err) + } + if err := svc.store.CreateTrigger(context.Background(), &AutomationTrigger{ + ID: "trig-webhook", + AutomationID: a.ID, + Type: TriggerTypeWebhook, + Config: []byte(`{"branch":"main"}`), + Enabled: false, + }); err != nil { + t.Fatalf("CreateTrigger webhook: %v", err) + } + + doc, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + + var root yaml.Node + if err := yaml.Unmarshal(doc, &root); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + automation := lookupMapKey(t, singleAutomationNode(t, &root), "") + + assertScalarField(t, automation, "name", "Fully Populated Automation") + assertScalarField(t, automation, "description", "Exercises every exported field at once") + assertScalarField(t, automation, "prompt", "Review the diff and leave comments") + assertScalarField(t, automation, "task_title_template", "Review: {{.PRTitle}}") + assertScalarField(t, automation, "enabled", "true") + assertScalarField(t, automation, "max_concurrent_runs", "3") + + workflow := requireMapKey(t, automation, "workflow") + assertScalarField(t, workflow, "name", "New Feature Dev") + assertScalarField(t, workflow, "step", "Build") + + agentProfile := requireMapKey(t, automation, "agent_profile") + assertScalarField(t, agentProfile, "agent_name", "Claude Code") + assertScalarField(t, agentProfile, "model", "claude-sonnet-5") + assertScalarField(t, agentProfile, "mode", "plan") + + executorProfile := requireMapKey(t, automation, "executor_profile") + assertScalarField(t, executorProfile, "executor", "local_docker") + assertScalarField(t, executorProfile, "name", "Default Executor") + + repositories := requireMapKey(t, automation, "repositories") + wantRepos := []string{"kandev", "kandev-web"} + if len(repositories.Content) != len(wantRepos) { + t.Fatalf("repositories has %d entries, want %d (%v)", len(repositories.Content), len(wantRepos), repositories.Content) + } + for i, want := range wantRepos { + if got := repositories.Content[i].Value; got != want { + t.Errorf("repositories[%d] = %q, want %q", i, got, want) + } + } + + triggers := requireMapKey(t, automation, "triggers") + if len(triggers.Content) != 2 { + t.Fatalf("triggers has %d entries, want 2", len(triggers.Content)) + } + // buildExportTriggers orders by type then id: "scheduled" < "webhook". + scheduled := triggers.Content[0] + assertScalarField(t, scheduled, "type", "scheduled") + assertScalarField(t, scheduled, "enabled", "true") + scheduledConfig := requireMapKey(t, scheduled, "config") + // AC-23's Config clause: every scalar checked on both lexeme and re-parsed + // Tag. "cron" and "dry_run" are JSON strings (dry_run's text is ambiguous + // and would resolve to !!bool if emitted bare) so both must carry !!str; + // "retries" is a JSON number and must NOT carry !!str. + assertConfigScalar(t, scheduledConfig, "cron", "0 9 * * 1-5", true) + assertConfigScalar(t, scheduledConfig, "dry_run", "true", true) + assertConfigScalar(t, scheduledConfig, "retries", "3", false) + + webhook := triggers.Content[1] + assertScalarField(t, webhook, "type", "webhook") + assertScalarField(t, webhook, "enabled", "false") + webhookConfig := requireMapKey(t, webhook, "config") + assertConfigScalar(t, webhookConfig, "branch", "main", true) +} + +// assertConfigScalar checks a trigger Config scalar's re-parsed lexeme +// (Value) and type tag (Tag) together, per AC-23's Config clause: a +// lexeme-only comparison passes even when a string has been silently +// retyped (e.g. a JSON string "true" emitted bare re-parses with +// Value == "true" but Tag == "!!bool"), so both must be asserted. Pass +// wantStrTag=true for JSON strings/keys (expected Tag "!!str") and false for +// JSON numbers (expected Tag anything but "!!str", i.e. AC-41's unquoted +// number rule observed from the re-parsed side). +func assertConfigScalar(t *testing.T, mapping *yaml.Node, key, wantValue string, wantStrTag bool) { + t.Helper() + v := requireMapKey(t, mapping, key) + if v.Kind != yaml.ScalarNode { + t.Fatalf("config field %q is not a scalar (kind %v)", key, v.Kind) + } + if v.Value != wantValue { + t.Errorf("config field %q value = %q, want %q", key, v.Value, wantValue) + } + if wantStrTag && v.Tag != "!!str" { + t.Errorf("config field %q tag = %q, want !!str", key, v.Tag) + } + if !wantStrTag && v.Tag == "!!str" { + t.Errorf("config field %q tag = !!str, want a non-string tag", key) + } +} + +// AC-43: none of the excluded columns may leak into either export form, +// under any key-name mangling, outside a trigger's freeform config subtree. +// This is distinct from AC-6 (a single secret's byte-absence): AC-43 covers +// every excluded column at once, plus a structural scan of every mapping +// key at every depth, since a leak could surface under a renamed or nested +// key that a plain byte-substring check on the field's own name would miss. +func TestExportRedaction_AC43_NoExcludedColumnLeaksUnderAnyKeyOrValue(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{"ws-1": true}} + svc.SetExportWorkspaceLookup(wsLookup) + + const ( + sentinelWebhookSecret = "sentinel-webhook-secret-ac43-7d2e" + sentinelWorkspaceID = "sentinel-workspace-id-ac43-4b1f" + sentinelAutomationID = "sentinel-automation-id-ac43-6c9a" + sentinelExecMode = "sentinel-execution-mode-ac43-2e8b" + sentinelLegacyRepoID = "sentinel-legacy-repository-id-ac43-9a3d" + ) + + a := &Automation{ + ID: sentinelAutomationID, + WorkspaceID: sentinelWorkspaceID, + Name: "Excluded Column Probe", + WebhookSecret: sentinelWebhookSecret, + } + if err := svc.store.CreateAutomation(context.Background(), a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + // The normal write path hardcodes execution_mode and never writes the + // legacy repository_id column; both need a raw UPDATE to seed a sentinel. + if _, err := svc.store.db.ExecContext(context.Background(), + `UPDATE automations SET execution_mode = ?, repository_id = ? WHERE id = ?`, + sentinelExecMode, sentinelLegacyRepoID, a.ID); err != nil { + t.Fatalf("seed legacy columns: %v", err) + } + if err := svc.store.CreateTrigger(context.Background(), &AutomationTrigger{ + ID: sentinelAutomationID, // reused for automation_id/id column coverage + AutomationID: a.ID, + Type: TriggerTypeWebhook, + Config: []byte(`{"branch":"main"}`), + Enabled: true, + LastEvaluatedAt: timePtr(time.Now().UTC()), + }); err != nil { + t.Fatalf("CreateTrigger: %v", err) + } + // wsLookup keys ws-1, but the automation's real WorkspaceID is the + // sentinel; register the sentinel workspace so export can find it. + wsLookup.exists[sentinelWorkspaceID] = true + + doc, zipEntries := exportBothForms(t, svc, sentinelWorkspaceID) + + sentinels := []string{ + sentinelWebhookSecret, + sentinelWorkspaceID, + sentinelAutomationID, + sentinelExecMode, + sentinelLegacyRepoID, + } + checkBytes := func(label string, body []byte) { + for _, s := range sentinels { + if bytes.Contains(body, []byte(s)) { + t.Errorf("%s leaked sentinel %q: %s", label, s, body) + } + } + } + checkBytes("document", doc) + for i, entry := range zipEntries { + checkBytes(fmt.Sprintf("zip entry %d", i), entry) + } + + excludedColumnNames := []string{ + "webhook_secret", "last_evaluated_at", "last_triggered_at", + "created_at", "updated_at", "workspace_id", "automation_id", + "id", "execution_mode", "repository_id", "legacy_board_card", + } + var root yaml.Node + if err := yaml.Unmarshal(doc, &root); err != nil { + t.Fatalf("yaml.Unmarshal(document): %v", err) + } + assertNoExcludedKeys(t, "document", &root, excludedColumnNames) + + for i, entry := range zipEntries { + var entryRoot yaml.Node + if err := yaml.Unmarshal(entry, &entryRoot); err != nil { + t.Fatalf("yaml.Unmarshal(zip entry %d): %v", i, err) + } + assertNoExcludedKeys(t, "zip entry", &entryRoot, excludedColumnNames) + } +} + +func timePtr(t time.Time) *time.Time { return &t } + +// singleAutomationNode descends the export document's root to the sole +// entry of its `automations` list. +func singleAutomationNode(t *testing.T, root *yaml.Node) *yaml.Node { + t.Helper() + doc := root + if doc.Kind == yaml.DocumentNode { + if len(doc.Content) != 1 { + t.Fatalf("document root has %d children, want 1", len(doc.Content)) + } + doc = doc.Content[0] + } + automations := requireMapKey(t, doc, "automations") + if len(automations.Content) != 1 { + t.Fatalf("automations has %d entries, want 1", len(automations.Content)) + } + return automations.Content[0] +} + +// requireMapKey returns the value node for key in a YAML mapping node, +// failing the test if the mapping has no such key. +func requireMapKey(t *testing.T, mapping *yaml.Node, key string) *yaml.Node { + t.Helper() + v := lookupMapKey(t, mapping, key) + if v == nil { + t.Fatalf("key %q not found in mapping (keys present: %v)", key, mapKeys(mapping)) + } + return v +} + +// lookupMapKey returns the value node for key, or nil if absent. Passing an +// empty key returns mapping itself, for chaining with singleAutomationNode. +func lookupMapKey(t *testing.T, mapping *yaml.Node, key string) *yaml.Node { + t.Helper() + if key == "" { + return mapping + } + if mapping.Kind != yaml.MappingNode { + t.Fatalf("expected a mapping node, got kind %v", mapping.Kind) + } + for i := 0; i+1 < len(mapping.Content); i += 2 { + if mapping.Content[i].Value == key { + return mapping.Content[i+1] + } + } + return nil +} + +func mapKeys(mapping *yaml.Node) []string { + if mapping.Kind != yaml.MappingNode { + return nil + } + var keys []string + for i := 0; i+1 < len(mapping.Content); i += 2 { + keys = append(keys, mapping.Content[i].Value) + } + return keys +} + +func assertScalarField(t *testing.T, mapping *yaml.Node, key, want string) { + t.Helper() + v := requireMapKey(t, mapping, key) + if v.Kind != yaml.ScalarNode { + t.Fatalf("field %q is not a scalar (kind %v)", key, v.Kind) + } + if v.Value != want { + t.Errorf("field %q = %q, want %q", key, v.Value, want) + } +} + +// assertNoExcludedKeys walks every mapping key at every depth of node +// EXCEPT inside any subtree rooted at a `config` key (trigger configs are +// freeform and may coincidentally contain a key that normalizes to an +// excluded column name without that being a leak), and fails the test if +// any visited key, normalized by lowercasing and stripping `_`/`-`, +// matches the same normalization of any excludedColumns entry. +func assertNoExcludedKeys(t *testing.T, label string, node *yaml.Node, excludedColumns []string) { + t.Helper() + normalizedExcluded := make(map[string]string, len(excludedColumns)) + for _, c := range excludedColumns { + normalizedExcluded[normalizeKey(c)] = c + } + walkKeysExceptConfig(node, func(key string) { + norm := normalizeKey(key) + if original, bad := normalizedExcluded[norm]; bad { + t.Errorf("%s: key %q normalizes to excluded column %q", label, key, original) + } + }) +} + +func normalizeKey(s string) string { + s = strings.ToLower(s) + s = strings.ReplaceAll(s, "_", "") + s = strings.ReplaceAll(s, "-", "") + return s +} + +// walkKeysExceptConfig visits every mapping key in node at every depth, +// calling visit(key) for each one, but does not descend into the value of +// any key literally named "config". +func walkKeysExceptConfig(node *yaml.Node, visit func(key string)) { + if node == nil { + return + } + switch node.Kind { + case yaml.DocumentNode: + for _, c := range node.Content { + walkKeysExceptConfig(c, visit) + } + case yaml.MappingNode: + for i := 0; i+1 < len(node.Content); i += 2 { + key := node.Content[i] + value := node.Content[i+1] + visit(key.Value) + if key.Value == "config" { + continue + } + walkKeysExceptConfig(value, visit) + } + case yaml.SequenceNode: + for _, c := range node.Content { + walkKeysExceptConfig(c, visit) + } + } +} diff --git a/apps/backend/internal/automation/export_resolve.go b/apps/backend/internal/automation/export_resolve.go new file mode 100644 index 0000000000..38cf9e749d --- /dev/null +++ b/apps/backend/internal/automation/export_resolve.go @@ -0,0 +1,206 @@ +package automation + +import ( + "context" + "errors" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// ErrExportLookupUnavailable is returned when an automation references an +// agent profile, executor profile, workflow, workflow step, or repository but +// the matching Export*Lookup was never wired via the Service's setters. +// Fails closed rather than treating the reference as absent: AC-19's "absent" +// path is for a reference that was actually looked up and not found, and an +// unwired lookup has looked up nothing, so reporting "absent" here would +// silently drop real references the moment a startup wire is missing. +var ErrExportLookupUnavailable = errors.New("automation: export descriptor lookup is not available") + +// warnUnresolvedWorkflowStep is emitted when a workflow resolves but its +// referenced step does not (AC-19's non-silent case). +const warnUnresolvedWorkflowStep = "unresolved workflow step" + +// exportWarning is one unformatted warning captured during export: an +// automation name/ID pair plus a message (already carrying any AC-42-escaped +// interpolation, such as a trigger type, that a trigger-scoped message +// embeds) and a DedupKey scoping AC-42's de-duplication rule — the +// automation ID for an automation-scoped message, the trigger ID for a +// trigger-scoped one. Ordering (AC-21) and the final ": " +// rendering (AC-42) are buildWarningsList's job, not this one's. +type exportWarning struct { + AutomationName string + AutomationID string + DedupKey string + Message string +} + +// resolvedDescriptors is one automation's resolved agent profile, executor +// profile, workflow/step, and repository names — everything resolveDescriptors +// produces from an Automation's *ID fields. A nil/empty field means the +// reference was never made or did not resolve; see resolveDescriptors. +type resolvedDescriptors struct { + AgentProfile *exportAgentProfile + ExecutorProfile *exportExecutorProfile + Workflow *exportWorkflow + Repositories []string +} + +// resolveDescriptors resolves a.AgentProfileID, a.ExecutorProfileID, +// a.WorkflowID/a.WorkflowStepID, and a.RepositoryIDs into their portable +// descriptors within tx, applying AC-19's partial-resolution rules. A +// resolution failure — as opposed to an absent reference — aborts the whole +// export: the caller must not fall back to a partial document or a warning +// for an error returned here (AC-19, AC-45). +func (s *Service) resolveDescriptors(ctx context.Context, tx *sqlx.Tx, a *Automation) (resolvedDescriptors, []exportWarning, error) { + agentProfile, agentWarning, err := s.resolveAgentProfile(ctx, tx, a) + if err != nil { + return resolvedDescriptors{}, nil, err + } + executorProfile, executorWarning, err := s.resolveExecutorProfile(ctx, tx, a) + if err != nil { + return resolvedDescriptors{}, nil, err + } + workflow, workflowWarning, err := s.resolveWorkflow(ctx, tx, a) + if err != nil { + return resolvedDescriptors{}, nil, err + } + repositories, repoWarnings, err := s.resolveRepositories(ctx, tx, a) + if err != nil { + return resolvedDescriptors{}, nil, err + } + + var warnings []exportWarning + addWarning := func(msg string) { + if msg == "" { + return + } + warnings = append(warnings, exportWarning{AutomationName: a.Name, AutomationID: a.ID, DedupKey: a.ID, Message: msg}) + } + addWarning(agentWarning) + addWarning(executorWarning) + addWarning(workflowWarning) + for _, msg := range repoWarnings { + addWarning(msg) + } + + return resolvedDescriptors{ + AgentProfile: agentProfile, + ExecutorProfile: executorProfile, + Workflow: workflow, + Repositories: repositories, + }, warnings, nil +} + +// resolveAgentProfile resolves a.AgentProfileID. An empty ID references +// nothing, so it returns no descriptor and no warning. +// +// validateAgentProfileID (service.go) only checks that the profile row +// exists, not that it belongs to the automation's own workspace — unlike +// RepositoryLookup, which fails closed on a cross-workspace reference at +// create/update time. A workspace-scoped profile (non-empty WorkspaceID) +// that does not match a.WorkspaceID is therefore treated as unresolved here, +// the same as a missing row: the export must not leak another workspace's +// profile name/model/mode just because a stale or crafted ID happens to +// resolve. A global profile (WorkspaceID == "") is unaffected. +func (s *Service) resolveAgentProfile(ctx context.Context, tx *sqlx.Tx, a *Automation) (*exportAgentProfile, string, error) { + if a.AgentProfileID == "" { + return nil, "", nil + } + if s.exportAgentProfileLookup == nil { + return nil, "", fmt.Errorf("%w: agent profile", ErrExportLookupUnavailable) + } + profile, found, err := s.exportAgentProfileLookup.GetAgentProfileTx(ctx, tx, a.AgentProfileID) + if err != nil { + return nil, "", fmt.Errorf("resolve agent profile %q: %w", a.AgentProfileID, err) + } + if !found || (profile.WorkspaceID != "" && profile.WorkspaceID != a.WorkspaceID) { + return nil, "unresolved agent profile", nil + } + return &exportAgentProfile{AgentName: profile.AgentDisplayName, Model: profile.Model, Mode: profile.Mode}, "", nil +} + +// resolveExecutorProfile resolves a.ExecutorProfileID. An empty ID +// references nothing, so it returns no descriptor and no warning. +func (s *Service) resolveExecutorProfile(ctx context.Context, tx *sqlx.Tx, a *Automation) (*exportExecutorProfile, string, error) { + if a.ExecutorProfileID == "" { + return nil, "", nil + } + if s.exportExecutorProfileLookup == nil { + return nil, "", fmt.Errorf("%w: executor profile", ErrExportLookupUnavailable) + } + profile, found, err := s.exportExecutorProfileLookup.GetExecutorProfileTx(ctx, tx, a.ExecutorProfileID) + if err != nil { + return nil, "", fmt.Errorf("resolve executor profile %q: %w", a.ExecutorProfileID, err) + } + if !found { + return nil, "unresolved executor profile", nil + } + return &exportExecutorProfile{Executor: profile.ExecutorID, Name: profile.Name}, "", nil +} + +// resolveWorkflow resolves a.WorkflowID and, if present, a.WorkflowStepID. +// An empty WorkflowID references nothing. A resolved workflow with an empty +// WorkflowStepID is AC-19's silent case: the step was never referenced, so +// the workflow descriptor carries only name and no warning is produced. +func (s *Service) resolveWorkflow(ctx context.Context, tx *sqlx.Tx, a *Automation) (*exportWorkflow, string, error) { + if a.WorkflowID == "" { + return nil, "", nil + } + if s.exportWorkflowLookup == nil { + return nil, "", fmt.Errorf("%w: workflow", ErrExportLookupUnavailable) + } + wf, found, err := s.exportWorkflowLookup.GetWorkflowTx(ctx, tx, a.WorkflowID) + if err != nil { + return nil, "", fmt.Errorf("resolve workflow %q: %w", a.WorkflowID, err) + } + if !found { + // The whole descriptor is omitted, including any step: a step name + // without its workflow names nothing, and this AC's "step does not + // resolve" warning covers only a workflow that did resolve. + return nil, "unresolved workflow", nil + } + result := &exportWorkflow{Name: wf.Name} + if a.WorkflowStepID == "" { + return result, "", nil + } + if s.exportWorkflowStepLookup == nil { + return nil, "", fmt.Errorf("%w: workflow step", ErrExportLookupUnavailable) + } + step, stepFound, err := s.exportWorkflowStepLookup.GetStepTx(ctx, tx, a.WorkflowStepID) + if err != nil { + return nil, "", fmt.Errorf("resolve workflow step %q: %w", a.WorkflowStepID, err) + } + if !stepFound || step == nil || step.WorkflowID != a.WorkflowID { + return result, warnUnresolvedWorkflowStep, nil + } + result.Step = step.Name + return result, "", nil +} + +// resolveRepositories resolves a.RepositoryIDs, dropping unresolved members +// and keeping resolved ones in their stored (AC-8) order. Each unresolved +// member warns by its 0-based position, since the export carries no UUID to +// name it by (AC-18). +func (s *Service) resolveRepositories(ctx context.Context, tx *sqlx.Tx, a *Automation) ([]string, []string, error) { + if len(a.RepositoryIDs) == 0 { + return nil, nil, nil + } + if s.exportRepositoryLookup == nil { + return nil, nil, fmt.Errorf("%w: repository", ErrExportLookupUnavailable) + } + var names []string + var warnings []string + for position, id := range a.RepositoryIDs { + repo, found, err := s.exportRepositoryLookup.GetRepositoryTx(ctx, tx, id) + if err != nil { + return nil, nil, fmt.Errorf("resolve repository %q: %w", id, err) + } + if !found { + warnings = append(warnings, fmt.Sprintf("unresolved repository at position %d", position)) + continue + } + names = append(names, repo.Name) + } + return names, warnings, nil +} diff --git a/apps/backend/internal/automation/export_resolve_test.go b/apps/backend/internal/automation/export_resolve_test.go new file mode 100644 index 0000000000..c2ce07a5af --- /dev/null +++ b/apps/backend/internal/automation/export_resolve_test.go @@ -0,0 +1,490 @@ +package automation + +import ( + "context" + "errors" + "testing" + + "github.com/jmoiron/sqlx" + + settingsmodels "github.com/kandev/kandev/internal/agent/settings/models" + taskmodels "github.com/kandev/kandev/internal/task/models" + workflowmodels "github.com/kandev/kandev/internal/workflow/models" +) + +// AC-19: resolveDescriptors resolves an automation's agent profile, executor +// profile, workflow/step, and repository references within a shared read +// transaction, applying the partial-resolution rules verbatim: an absent +// reference is omitted with a warning, a failed lookup aborts the whole +// export, and a reference that was never made (empty ID, or a workflow step +// left unset on a workflow that does resolve) produces neither. + +type fakeExportAgentProfileLookup struct { + profiles map[string]*settingsmodels.AgentProfile + err error + // gotTx records the transaction handle the most recent call received, + // for AC-29's handle-identity assertion. + gotTx *sqlx.Tx +} + +func (f *fakeExportAgentProfileLookup) GetAgentProfileTx(_ context.Context, tx *sqlx.Tx, id string) (*settingsmodels.AgentProfile, bool, error) { + f.gotTx = tx + if f.err != nil { + return nil, false, f.err + } + p, ok := f.profiles[id] + return p, ok, nil +} + +type fakeExportExecutorProfileLookup struct { + profiles map[string]*taskmodels.ExecutorProfile + err error + gotTx *sqlx.Tx +} + +func (f *fakeExportExecutorProfileLookup) GetExecutorProfileTx(_ context.Context, tx *sqlx.Tx, id string) (*taskmodels.ExecutorProfile, bool, error) { + f.gotTx = tx + if f.err != nil { + return nil, false, f.err + } + p, ok := f.profiles[id] + return p, ok, nil +} + +type fakeExportWorkflowLookup struct { + workflows map[string]*taskmodels.Workflow + err error + gotTx *sqlx.Tx +} + +func (f *fakeExportWorkflowLookup) GetWorkflowTx(_ context.Context, tx *sqlx.Tx, id string) (*taskmodels.Workflow, bool, error) { + f.gotTx = tx + if f.err != nil { + return nil, false, f.err + } + w, ok := f.workflows[id] + return w, ok, nil +} + +type fakeExportWorkflowStepLookup struct { + steps map[string]*workflowmodels.WorkflowStep + err error + gotTx *sqlx.Tx +} + +func (f *fakeExportWorkflowStepLookup) GetStepTx(_ context.Context, tx *sqlx.Tx, id string) (*workflowmodels.WorkflowStep, bool, error) { + f.gotTx = tx + if f.err != nil { + return nil, false, f.err + } + s, ok := f.steps[id] + return s, ok, nil +} + +type fakeExportRepositoryLookup struct { + repositories map[string]*taskmodels.Repository + err error + gotTx *sqlx.Tx +} + +func (f *fakeExportRepositoryLookup) GetRepositoryTx(_ context.Context, tx *sqlx.Tx, id string) (*taskmodels.Repository, bool, error) { + f.gotTx = tx + if f.err != nil { + return nil, false, f.err + } + r, ok := f.repositories[id] + return r, ok, nil +} + +// resolveTestFixture wires every Export*Lookup on a fresh service so each +// test only needs to populate the maps it cares about. +func resolveTestFixture(t *testing.T) (*Service, *fakeExportAgentProfileLookup, *fakeExportExecutorProfileLookup, *fakeExportWorkflowLookup, *fakeExportWorkflowStepLookup, *fakeExportRepositoryLookup) { + t.Helper() + svc := newTestService(t) + agentLookup := &fakeExportAgentProfileLookup{profiles: map[string]*settingsmodels.AgentProfile{}} + executorLookup := &fakeExportExecutorProfileLookup{profiles: map[string]*taskmodels.ExecutorProfile{}} + workflowLookup := &fakeExportWorkflowLookup{workflows: map[string]*taskmodels.Workflow{}} + stepLookup := &fakeExportWorkflowStepLookup{steps: map[string]*workflowmodels.WorkflowStep{}} + repoLookup := &fakeExportRepositoryLookup{repositories: map[string]*taskmodels.Repository{}} + svc.SetExportAgentProfileLookup(agentLookup) + svc.SetExportExecutorProfileLookup(executorLookup) + svc.SetExportWorkflowLookup(workflowLookup) + svc.SetExportWorkflowStepLookup(stepLookup) + svc.SetExportRepositoryLookup(repoLookup) + return svc, agentLookup, executorLookup, workflowLookup, stepLookup, repoLookup +} + +func beginTestReadTx(t *testing.T, svc *Service) *sqlx.Tx { + t.Helper() + tx, err := svc.store.BeginReadTx(context.Background()) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + t.Cleanup(func() { _ = tx.Rollback() }) + return tx +} + +func TestResolveDescriptors_NoReferencesMade_NoDescriptorsNoWarnings(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review"} + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if got.AgentProfile != nil || got.ExecutorProfile != nil || got.Workflow != nil || got.Repositories != nil { + t.Errorf("expected all descriptors nil/empty, got %+v", got) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestResolveDescriptors_AgentProfileResolved(t *testing.T) { + svc, agentLookup, _, _, _, _ := resolveTestFixture(t) + // Name (user-assigned label) and AgentDisplayName (underlying agent's display + // name) are deliberately different here: exportAgentProfile.AgentName must come + // from AgentDisplayName, matching the established wfmodels.AgentProfilePortable + // pattern in backendapp.buildAgentProfileResolver, not from the user's label. + agentLookup.profiles["agent-1"] = &settingsmodels.AgentProfile{Name: "My Reviewer Profile", AgentDisplayName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review", AgentProfileID: "agent-1"} + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportAgentProfile{AgentName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + if got.AgentProfile == nil || *got.AgentProfile != *want { + t.Errorf("AgentProfile = %+v, want %+v", got.AgentProfile, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestResolveDescriptors_AgentProfileUnresolved_OmitsAndWarns(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review", AgentProfileID: "missing-agent"} + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if got.AgentProfile != nil { + t.Errorf("expected nil AgentProfile, got %+v", got.AgentProfile) + } + want := []exportWarning{{AutomationName: "Daily Review", AutomationID: "auto-1", DedupKey: "auto-1", Message: "unresolved agent profile"}} + if len(warnings) != 1 || warnings[0] != want[0] { + t.Errorf("warnings = %v, want %v", warnings, want) + } +} + +// A workspace-scoped agent profile belonging to a different workspace than +// the automation must never be exported: resolveAgentProfile has no +// upstream guarantee that AgentProfileID was validated against the +// automation's own workspace (validateAgentProfileID checks existence only, +// unlike RepositoryLookup's fail-closed cross-workspace guard), so the +// export path must defend the boundary itself rather than leak a foreign +// workspace's profile name/model/mode through the descriptor. +func TestResolveDescriptors_AgentProfileFromDifferentWorkspace_OmitsAndWarns(t *testing.T) { + svc, agentLookup, _, _, _, _ := resolveTestFixture(t) + agentLookup.profiles["agent-1"] = &settingsmodels.AgentProfile{WorkspaceID: "other-workspace", Name: "Foreign Profile", AgentDisplayName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review", WorkspaceID: "ws-1", AgentProfileID: "agent-1"} + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if got.AgentProfile != nil { + t.Errorf("expected nil AgentProfile for a cross-workspace binding, got %+v", got.AgentProfile) + } + want := []exportWarning{{AutomationName: "Daily Review", AutomationID: "auto-1", DedupKey: "auto-1", Message: "unresolved agent profile"}} + if len(warnings) != 1 || warnings[0] != want[0] { + t.Errorf("warnings = %v, want %v", warnings, want) + } +} + +// A global agent profile (WorkspaceID == "") is legitimately shared across +// every workspace and must still resolve normally. +func TestResolveDescriptors_GlobalAgentProfile_ResolvesAcrossWorkspaces(t *testing.T) { + svc, agentLookup, _, _, _, _ := resolveTestFixture(t) + agentLookup.profiles["agent-1"] = &settingsmodels.AgentProfile{WorkspaceID: "", Name: "Global Profile", AgentDisplayName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review", WorkspaceID: "ws-1", AgentProfileID: "agent-1"} + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportAgentProfile{AgentName: "Claude Code", Model: "claude-sonnet-5", Mode: "plan"} + if got.AgentProfile == nil || *got.AgentProfile != *want { + t.Errorf("AgentProfile = %+v, want %+v", got.AgentProfile, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings for a global profile, got %v", warnings) + } +} + +func TestResolveDescriptors_AgentProfileLookupFails_AbortsWithNoPartialResultAndNoWarning(t *testing.T) { + svc, agentLookup, _, _, _, _ := resolveTestFixture(t) + sentinel := errors.New("boom") + agentLookup.err = sentinel + tx := beginTestReadTx(t, svc) + a := &Automation{ID: "auto-1", Name: "Daily Review", AgentProfileID: "agent-1"} + + _, warnings, err := svc.resolveDescriptors(context.Background(), tx, a) + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want wrapping %v", err, sentinel) + } + if warnings != nil { + t.Errorf("expected no warnings on failure, got %v", warnings) + } +} + +func TestResolveDescriptors_ExecutorProfileResolvedAndUnresolved(t *testing.T) { + svc, _, executorLookup, _, _, _ := resolveTestFixture(t) + executorLookup.profiles["exec-1"] = &taskmodels.ExecutorProfile{ExecutorID: "local_docker", Name: "Default"} + tx := beginTestReadTx(t, svc) + + resolved, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", ExecutorProfileID: "exec-1"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportExecutorProfile{Executor: "local_docker", Name: "Default"} + if resolved.ExecutorProfile == nil || *resolved.ExecutorProfile != *want { + t.Errorf("ExecutorProfile = %+v, want %+v", resolved.ExecutorProfile, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } + + resolved, warnings, err = svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a2", Name: "B", ExecutorProfileID: "missing-exec"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if resolved.ExecutorProfile != nil { + t.Errorf("expected nil ExecutorProfile, got %+v", resolved.ExecutorProfile) + } + if len(warnings) != 1 || warnings[0].Message != "unresolved executor profile" { + t.Errorf("warnings = %v, want [unresolved executor profile]", warnings) + } +} + +func TestResolveDescriptors_WorkflowResolvedStepResolved(t *testing.T) { + svc, _, _, workflowLookup, stepLookup, _ := resolveTestFixture(t) + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "Review Flow"} + stepLookup.steps["step-1"] = &workflowmodels.WorkflowStep{Name: "In Review", WorkflowID: "wf-1"} + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "step-1"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportWorkflow{Name: "Review Flow", Step: "In Review"} + if got.Workflow == nil || *got.Workflow != *want { + t.Errorf("Workflow = %+v, want %+v", got.Workflow, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestResolveDescriptors_WorkflowResolvedStepFromDifferentWorkflow_OmitsStepAndWarns(t *testing.T) { + svc, _, _, workflowLookup, stepLookup, _ := resolveTestFixture(t) + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "Review Flow"} + stepLookup.steps["step-1"] = &workflowmodels.WorkflowStep{Name: "Foreign", WorkflowID: "wf-2"} + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ + ID: "a1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "step-1", + }) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if got.Workflow == nil || got.Workflow.Step != "" { + t.Errorf("expected workflow name without foreign step, got %+v", got.Workflow) + } + if len(warnings) != 1 || warnings[0].Message != warnUnresolvedWorkflowStep { + t.Errorf("warnings = %v, want [unresolved workflow step]", warnings) + } +} + +// AC-19's silent case: workflow_id set, workflow_step_id empty is not +// "absent" — nothing was referenced. Same shape as the unresolved-step case +// (workflow name only, no step) but with no warning at all. +func TestResolveDescriptors_WorkflowResolvedStepNeverReferenced_NoWarning(t *testing.T) { + svc, _, _, workflowLookup, _, _ := resolveTestFixture(t) + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "Review Flow"} + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", WorkflowID: "wf-1"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportWorkflow{Name: "Review Flow"} + if got.Workflow == nil || *got.Workflow != *want { + t.Errorf("Workflow = %+v, want %+v", got.Workflow, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings for an unreferenced step, got %v", warnings) + } +} + +func TestResolveDescriptors_WorkflowResolvedStepUnresolved_OmitsStepAndWarns(t *testing.T) { + svc, _, _, workflowLookup, _, _ := resolveTestFixture(t) + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "Review Flow"} + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "missing-step"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + want := &exportWorkflow{Name: "Review Flow"} + if got.Workflow == nil || *got.Workflow != *want { + t.Errorf("Workflow = %+v, want %+v", got.Workflow, want) + } + if len(warnings) != 1 || warnings[0].Message != warnUnresolvedWorkflowStep { + t.Errorf("warnings = %v, want [unresolved workflow step]", warnings) + } +} + +func TestResolveDescriptors_WorkflowUnresolved_OmitsWholeDescriptorNoStepWarning(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", WorkflowID: "missing-wf", WorkflowStepID: "step-1"}) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if got.Workflow != nil { + t.Errorf("expected nil Workflow, got %+v", got.Workflow) + } + // Exactly one warning: "unresolved workflow". Not two, and never + // "unresolved workflow step" — a step name without its workflow names + // nothing. + if len(warnings) != 1 || warnings[0].Message != "unresolved workflow" { + t.Errorf("warnings = %v, want [unresolved workflow]", warnings) + } +} + +func TestResolveDescriptors_RepositoriesPartialResolution_DropsUnresolvedKeepsOrderWarnsByPosition(t *testing.T) { + svc, _, _, _, _, repoLookup := resolveTestFixture(t) + repoLookup.repositories["repo-a"] = &taskmodels.Repository{Name: "repo-a-name"} + repoLookup.repositories["repo-c"] = &taskmodels.Repository{Name: "repo-c-name"} + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ + ID: "a1", Name: "A", RepositoryIDs: []string{"repo-a", "repo-b", "repo-c"}, + }) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + wantRepos := []string{"repo-a-name", "repo-c-name"} + if len(got.Repositories) != 2 || got.Repositories[0] != wantRepos[0] || got.Repositories[1] != wantRepos[1] { + t.Errorf("Repositories = %v, want %v", got.Repositories, wantRepos) + } + if len(warnings) != 1 || warnings[0].Message != "unresolved repository at position 1" { + t.Errorf("warnings = %v, want [unresolved repository at position 1]", warnings) + } +} + +func TestResolveDescriptors_RepositoriesAllUnresolved_EmptyList(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ + ID: "a1", Name: "A", RepositoryIDs: []string{"repo-a", "repo-b"}, + }) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if len(got.Repositories) != 0 { + t.Errorf("Repositories = %v, want empty", got.Repositories) + } + if len(warnings) != 2 { + t.Errorf("warnings = %v, want 2 entries", warnings) + } +} + +func TestResolveDescriptors_LookupNotWired_FailsClosed(t *testing.T) { + svc := newTestService(t) + tx := beginTestReadTx(t, svc) + + _, _, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ID: "a1", Name: "A", AgentProfileID: "agent-1"}) + if !errors.Is(err, ErrExportLookupUnavailable) { + t.Fatalf("err = %v, want wrapping ErrExportLookupUnavailable", err) + } +} + +// AC-29: the export path must obtain its read-transaction snapshot once and +// pass that exact handle to every read — the automation store's own read +// and every descriptor lookup alike — never a nil handle, never a distinct +// one. Service.store is a concrete *Store (not an interface; introducing +// one purely for this test would be an out-of-scope architectural change), +// so this test uses the real store's own BeginReadTx to obtain the one +// handle under test, then asserts every lookup double recorded that exact +// pointer. This is the direct, mechanical version of AC-29's own text: "the +// test asserts, with a store double and four lookup doubles that each +// record the handle they were given, that every recorded handle is the +// same one and that no read arrived with no handle." +func TestResolveDescriptors_AC29_SameTransactionHandleReachesStoreAndEveryLookup(t *testing.T) { + svc, agentLookup, executorLookup, workflowLookup, stepLookup, repoLookup := resolveTestFixture(t) + agentLookup.profiles["agent-1"] = &settingsmodels.AgentProfile{Name: "Reviewer", Model: "claude-sonnet-5", Mode: "plan"} + executorLookup.profiles["exec-1"] = &taskmodels.ExecutorProfile{ExecutorID: "local_docker", Name: "Default"} + workflowLookup.workflows["wf-1"] = &taskmodels.Workflow{Name: "Review Flow"} + stepLookup.steps["step-1"] = &workflowmodels.WorkflowStep{Name: "In Review", WorkflowID: "wf-1"} + repoLookup.repositories["repo-a"] = &taskmodels.Repository{Name: "repo-a-name"} + + ctx := context.Background() + a := &Automation{ + WorkspaceID: "ws-1", + Name: "Daily Review", + Enabled: true, + MaxConcurrentRuns: 1, + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + RepositoryIDs: []string{"repo-a"}, + } + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + // This is the one handle under test: the store's own read (mirroring + // export_store_test.go) and every descriptor lookup below must all see + // this exact *sqlx.Tx. + tx := beginTestReadTx(t, svc) + + automations, err := svc.store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx: %v", err) + } + if len(automations) != 1 { + t.Fatalf("expected 1 automation, got %d", len(automations)) + } + + if _, _, err := svc.resolveDescriptors(ctx, tx, automations[0]); err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + + for name, got := range map[string]*sqlx.Tx{ + "agent profile": agentLookup.gotTx, + "executor profile": executorLookup.gotTx, + "workflow": workflowLookup.gotTx, + "workflow step": stepLookup.gotTx, + "repository": repoLookup.gotTx, + } { + if got == nil { + t.Errorf("%s lookup received a nil transaction handle, want the store's snapshot handle", name) + continue + } + if got != tx { + t.Errorf("%s lookup received a different transaction handle than the store read used", name) + } + } +} diff --git a/apps/backend/internal/automation/export_service.go b/apps/backend/internal/automation/export_service.go new file mode 100644 index 0000000000..c7c26f1b3d --- /dev/null +++ b/apps/backend/internal/automation/export_service.go @@ -0,0 +1,176 @@ +package automation + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/jmoiron/sqlx" + "gopkg.in/yaml.v3" +) + +// ErrExportWorkspaceLookupUnavailable is returned when AC-44 step 2's +// workspace-existence check has no lookup wired via +// SetExportWorkspaceLookup. Distinct from repoerrors.ErrWorkspaceNotFound so +// the HTTP layer's errors.Is classification (AC-44) reports this as a 500, +// never a 404 — an unwired lookup is a construction error, not a deployment +// mode. +var ErrExportWorkspaceLookupUnavailable = errors.New("automation: export workspace lookup is not available") + +// ExportAutomationsDocument renders workspaceID's automations as the single +// YAML export document (the non-zip form of AC-1 through AC-49), applying +// AC-44's three-step evaluation order. The returned error classifies via +// errors.Is(err, repoerrors.ErrWorkspaceNotFound) for the HTTP layer's 404 +// (AC-31, AC-35); any other non-nil error is a 500 (AC-45). +func (s *Service) ExportAutomationsDocument(ctx context.Context, workspaceID string) ([]byte, error) { + entries, warnings, err := s.collectExportAutomations(ctx, workspaceID) + if err != nil { + return nil, err + } + automations := make([]exportAutomation, len(entries)) + for i, e := range entries { + automations[i] = e.Automation + } + body, err := marshalExportDocument(newExportDocument(automations, warnings)) + if err != nil { + return nil, fmt.Errorf("marshal export document: %w", err) + } + return body, nil +} + +// ExportAutomationsZip renders workspaceID's automations as the zip export +// (AC-24 through AC-28), applying the same AC-44 evaluation order and error +// classification as ExportAutomationsDocument. +func (s *Service) ExportAutomationsZip(ctx context.Context, workspaceID string) ([]byte, error) { + entries, warnings, err := s.collectExportAutomations(ctx, workspaceID) + if err != nil { + return nil, err + } + body, err := buildAutomationZip(entries, warnings) + if err != nil { + return nil, fmt.Errorf("build export zip: %w", err) + } + return body, nil +} + +// collectExportAutomations implements AC-44's three-step evaluation +// (authorize, resolve workspace existence, export) and AC-29's +// single-transaction orchestration: every automation is read and assembled +// within one read transaction whose snapshot establishes at the first read +// (AC-13, AC-30). The result is sorted by automation name ascending, +// tiebroken by id ascending (AC-8), with warnings deduped, ordered, and +// rendered by buildWarningsList. +func (s *Service) collectExportAutomations(ctx context.Context, workspaceID string) ([]automationZipEntry, []string, error) { + if err := s.authorizeWs(ctx, workspaceID); err != nil { + return nil, nil, err + } + if s.exportWorkspaceLookup == nil { + return nil, nil, ErrExportWorkspaceLookupUnavailable + } + if err := s.exportWorkspaceLookup.WorkspaceExists(ctx, workspaceID); err != nil { + return nil, nil, err + } + + tx, err := s.store.BeginReadTx(ctx) + if err != nil { + return nil, nil, fmt.Errorf("begin export read tx: %w", err) + } + defer func() { _ = tx.Rollback() }() + + automations, err := s.store.ListAutomationsForExportTx(ctx, tx, workspaceID) + if err != nil { + return nil, nil, fmt.Errorf("list automations for export: %w", err) + } + + entries := make([]automationZipEntry, 0, len(automations)) + var warnings []exportWarning + for _, a := range automations { + exp, aWarnings, err := s.buildExportAutomation(ctx, tx, a) + if err != nil { + return nil, nil, err + } + warnings = append(warnings, aWarnings...) + entries = append(entries, automationZipEntry{ID: a.ID, Name: a.Name, Automation: exp}) + } + + sort.Slice(entries, func(i, j int) bool { + if entries[i].Name != entries[j].Name { + return entries[i].Name < entries[j].Name + } + return entries[i].ID < entries[j].ID + }) + + return entries, buildWarningsList(warnings), nil +} + +// buildExportAutomation assembles one automation's exportAutomation value: +// resolved descriptors (resolveDescriptors), its prompt node (AC-4 omits the +// key entirely for an empty prompt, before any fidelity rule applies), and +// each trigger's config node, with triggers ordered by type then id (AC-8). +func (s *Service) buildExportAutomation(ctx context.Context, tx *sqlx.Tx, a *Automation) (exportAutomation, []exportWarning, error) { + resolved, warnings, err := s.resolveDescriptors(ctx, tx, a) + if err != nil { + return exportAutomation{}, nil, fmt.Errorf("resolve descriptors for automation %q: %w", a.ID, err) + } + + var promptNode *yaml.Node + if a.Prompt != "" { + node, warning, err := buildPromptNode(a.Prompt) + if err != nil { + return exportAutomation{}, nil, fmt.Errorf("build prompt for automation %q: %w", a.ID, err) + } + promptNode = node + if warning != "" { + warnings = append(warnings, exportWarning{AutomationName: a.Name, AutomationID: a.ID, DedupKey: a.ID, Message: warning}) + } + } + + triggers, triggerWarnings, err := s.buildExportTriggers(a) + if err != nil { + return exportAutomation{}, nil, err + } + warnings = append(warnings, triggerWarnings...) + + return exportAutomation{ + Name: a.Name, + Description: a.Description, + Enabled: a.Enabled, + MaxConcurrentRuns: a.MaxConcurrentRuns, + TaskTitleTemplate: a.TaskTitleTemplate, + Prompt: promptNode, + AgentProfile: resolved.AgentProfile, + ExecutorProfile: resolved.ExecutorProfile, + Workflow: resolved.Workflow, + Repositories: resolved.Repositories, + Triggers: triggers, + }, warnings, nil +} + +// buildExportTriggers orders a's triggers by type ascending, tiebroken by id +// ascending (AC-8), and builds each one's exported form, including its +// AC-39 config-validation warning when its stored config fails the +// UTF-8/JSON/object-shape checks. +func (s *Service) buildExportTriggers(a *Automation) ([]exportTrigger, []exportWarning, error) { + sorted := append([]AutomationTrigger(nil), a.Triggers...) + sort.Slice(sorted, func(i, j int) bool { + if sorted[i].Type != sorted[j].Type { + return sorted[i].Type < sorted[j].Type + } + return sorted[i].ID < sorted[j].ID + }) + + triggers := make([]exportTrigger, 0, len(sorted)) + var warnings []exportWarning + for _, trig := range sorted { + node, warning, err := buildTriggerConfigNode(trig.Config) + if err != nil { + return nil, nil, fmt.Errorf("build trigger config for trigger %q: %w", trig.ID, err) + } + if warning != "" { + warnings = append(warnings, newTriggerConfigWarning(a, trig, warning)) + } + triggers = append(triggers, exportTrigger{Type: string(trig.Type), Enabled: trig.Enabled, Config: node}) + } + return triggers, warnings, nil +} diff --git a/apps/backend/internal/automation/export_service_test.go b/apps/backend/internal/automation/export_service_test.go new file mode 100644 index 0000000000..61fe91a2d9 --- /dev/null +++ b/apps/backend/internal/automation/export_service_test.go @@ -0,0 +1,369 @@ +package automation + +import ( + "context" + "errors" + "fmt" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/kandev/kandev/internal/task/repository/repoerrors" +) + +// fakeExportWorkspaceLookup answers AC-44 step 2's workspace-existence +// check: exists[id] true -> nil, otherwise an error wrapping +// repoerrors.ErrWorkspaceNotFound, unless err is set (an infrastructure +// failure unrelated to existence). +type fakeExportWorkspaceLookup struct { + exists map[string]bool + err error +} + +func (f *fakeExportWorkspaceLookup) WorkspaceExists(_ context.Context, workspaceID string) error { + if f.err != nil { + return f.err + } + if f.exists[workspaceID] { + return nil + } + return fmt.Errorf("workspace %q: %w", workspaceID, repoerrors.ErrWorkspaceNotFound) +} + +// exportServiceTestFixture wires every Export*Lookup (including the +// workspace-existence one) on a fresh service, so ExportAutomationsDocument +// / ExportAutomationsZip can run end to end through the real store. +func exportServiceTestFixture(t *testing.T) (*Service, *fakeExportWorkspaceLookup) { + t.Helper() + svc, _, _, _, _, _ := resolveTestFixture(t) + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{}} + svc.SetExportWorkspaceLookup(wsLookup) + return svc, wsLookup +} + +func createExportTestAutomation(t *testing.T, svc *Service, req *CreateAutomationRequest) *Automation { + t.Helper() + a, err := svc.CreateAutomation(context.Background(), req) + if err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + return a +} + +// AC-32: an authorized, existing workspace with no automations exports a +// valid document with an empty automations list, not an error. + +func TestExportAutomationsDocument_NoAutomations_EmptyListAC32(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-empty"] = true + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-empty") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if doc.Version != exportDocumentVersion || doc.Type != exportDocumentType { + t.Errorf("envelope = %+v, want version=%d type=%q", doc, exportDocumentVersion, exportDocumentType) + } + if len(doc.Automations) != 0 { + t.Errorf("Automations = %v, want empty", doc.Automations) + } +} + +// AC-8: automations are ordered by name ascending, tiebroken by id +// ascending — independent of creation order or the store's own ordering. + +func TestExportAutomationsDocument_OrdersAutomationsByNameAscending(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Zeta"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Alpha"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Mid"}) + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + want := []string{"Alpha", "Mid", "Zeta"} + if len(doc.Automations) != len(want) { + t.Fatalf("got %d automations, want %d", len(doc.Automations), len(want)) + } + for i, name := range want { + if doc.Automations[i].Name != name { + t.Errorf("index %d: got %q, want %q", i, doc.Automations[i].Name, name) + } + } +} + +// AC-2: only the target workspace's automations are exported. + +func TestExportAutomationsDocument_OnlyTargetWorkspaceAutomations(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + wsLookup.exists["ws-2"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Mine"}) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-2", Name: "Theirs"}) + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 1 || doc.Automations[0].Name != "Mine" { + t.Errorf("Automations = %+v, want only [Mine]", doc.Automations) + } +} + +// AC-4: an empty prompt omits the prompt key entirely, before any fidelity +// rule applies. + +func TestExportAutomationsDocument_EmptyPromptOmitsPromptKey(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "No Prompt"}) + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 1 { + t.Fatalf("got %d automations, want 1", len(doc.Automations)) + } + if doc.Automations[0].Prompt != nil { + t.Errorf("Prompt = %+v, want nil (omitted)", doc.Automations[0].Prompt) + } +} + +// AC-31/AC-44 step 1: an authorizer denial classifies as +// repoerrors.ErrWorkspaceNotFound so the HTTP layer can return 404, never a +// different status or distinguishing text. + +func TestExportAutomationsDocument_AuthorizeDenied_ClassifiesAsWorkspaceNotFound(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { + return fmt.Errorf("denied: %w", repoerrors.ErrWorkspaceNotFound) + }) + + _, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("err = %v, want wrapping repoerrors.ErrWorkspaceNotFound", err) + } +} + +// AC-45: an authorizer failure for a reason other than denial is a 500, not +// a 404 — the sentinel, not mere non-nil-ness, is what the HTTP layer keys +// on (AC-44 step 1). + +func TestExportAutomationsDocument_AuthorizeOtherError_NotClassifiedAsNotFound(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + sentinel := errors.New("boom") + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return sentinel }) + + _, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want wrapping sentinel", err) + } + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("err = %v, must not classify as ErrWorkspaceNotFound", err) + } +} + +// AC-44 step 2: the workspace lookup not being wired is a construction +// error (500), never a 404 — it must not be reported through the +// ErrWorkspaceNotFound sentinel. + +func TestExportAutomationsDocument_WorkspaceLookupNotWired_ReturnsUnavailableError(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + + _, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if !errors.Is(err, ErrExportWorkspaceLookupUnavailable) { + t.Fatalf("err = %v, want wrapping ErrExportWorkspaceLookupUnavailable", err) + } + if errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("err = %v, must not classify as ErrWorkspaceNotFound", err) + } +} + +// AC-35/AC-44 step 2: a workspace that does not exist classifies as +// repoerrors.ErrWorkspaceNotFound, indistinguishable from the AC-31 denial. + +func TestExportAutomationsDocument_WorkspaceDoesNotExist_ClassifiesAsWorkspaceNotFound(t *testing.T) { + svc, _ := exportServiceTestFixture(t) + + _, err := svc.ExportAutomationsDocument(context.Background(), "ws-nonexistent") + if !errors.Is(err, repoerrors.ErrWorkspaceNotFound) { + t.Fatalf("err = %v, want wrapping repoerrors.ErrWorkspaceNotFound", err) + } +} + +// AC-19/AC-45: a descriptor lookup failure (as opposed to reporting +// "absent") aborts the whole export rather than degrading to a warning. + +func TestExportAutomationsDocument_DescriptorLookupFailure_AbortsExport(t *testing.T) { + svc, agentLookup, _, _, _, _ := resolveTestFixture(t) + wsLookup := &fakeExportWorkspaceLookup{exists: map[string]bool{"ws-1": true}} + svc.SetExportWorkspaceLookup(wsLookup) + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "A", AgentProfileID: "agent-1"}) + sentinel := errors.New("boom") + agentLookup.err = sentinel + + _, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if !errors.Is(err, sentinel) { + t.Fatalf("err = %v, want wrapping sentinel", err) + } +} + +// AC-24: the zip form produces one entry per automation, at the AC-25/26/27 +// slug path, built from the same orchestration as the document form. + +func TestExportAutomationsZip_OneEntryPerAutomation(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{WorkspaceID: "ws-1", Name: "Daily Sync"}) + + data, err := svc.ExportAutomationsZip(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsZip: %v", err) + } + if len(data) == 0 { + t.Fatal("expected non-empty zip bytes") + } + names := zipEntryNames(t, data) + want := []string{".kandev/automations/daily-sync.yml"} + if len(names) != len(want) || names[0] != want[0] { + t.Errorf("entries = %v, want %v", names, want) + } +} + +// AC-39/AC-42: a trigger with malformed config still exports the trigger +// (empty mapping) and surfaces exactly one rendered warning line. + +func TestExportAutomationsDocument_TriggerBadConfig_EmitsRenderedWarning(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "Hook", + Triggers: []CreateTriggerSpec{ + {Type: TriggerTypeWebhook, Config: []byte("not-json"), Enabled: true}, + }, + }) + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + want := "Hook: trigger webhook: config is not valid JSON" + if len(doc.Warnings) != 1 || doc.Warnings[0] != want { + t.Errorf("Warnings = %v, want [%q]", doc.Warnings, want) + } + if len(doc.Automations) != 1 || len(doc.Automations[0].Triggers) != 1 { + t.Fatalf("expected the trigger still exported despite bad config, got %+v", doc.Automations) + } +} + +// AC-10: two automations whose stored trigger config JSON differs only in +// whitespace must export identical config YAML, end to end through the real +// service and store (not just the buildTriggerConfigNode helper in +// isolation). + +func TestExportAutomationsDocument_AC10_WhitespaceOnlyConfigDifferenceProducesIdenticalOutput(t *testing.T) { + svc, wsLookup := exportServiceTestFixture(t) + wsLookup.exists["ws-1"] = true + createExportTestAutomation(t, svc, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "Compact", + Triggers: []CreateTriggerSpec{ + {Type: TriggerTypeScheduled, Config: []byte(`{"cron_expression":"0 9 * * *","timezone":"Asia/Singapore"}`), Enabled: true}, + }, + }) + createExportTestAutomation(t, svc, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "Spaced", + Triggers: []CreateTriggerSpec{ + {Type: TriggerTypeScheduled, Config: []byte(`{"cron_expression": "0 9 * * *", "timezone": "Asia/Singapore"}`), Enabled: true}, + }, + }) + + body, err := svc.ExportAutomationsDocument(context.Background(), "ws-1") + if err != nil { + t.Fatalf("ExportAutomationsDocument: %v", err) + } + var doc exportDocument + if err := yaml.Unmarshal(body, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if len(doc.Automations) != 2 { + t.Fatalf("got %d automations, want 2", len(doc.Automations)) + } + // Name-ascending order (AC-8): "Compact" before "Spaced". + compactConfig, err := yaml.Marshal(doc.Automations[0].Triggers[0].Config) + if err != nil { + t.Fatalf("yaml.Marshal(compact config): %v", err) + } + spacedConfig, err := yaml.Marshal(doc.Automations[1].Triggers[0].Config) + if err != nil { + t.Fatalf("yaml.Marshal(spaced config): %v", err) + } + if string(compactConfig) != string(spacedConfig) { + t.Errorf("exported config differs by stored whitespace alone:\ncompact: %q\nspaced: %q", compactConfig, spacedConfig) + } +} + +// AC-8: triggers within an automation are ordered by type ascending, +// tiebroken by id ascending — tested directly since trigger ids are +// service-generated and not caller-controllable through CreateAutomation. + +func TestBuildExportTriggers_OrdersByTypeThenID(t *testing.T) { + svc, _, _, _, _, _ := resolveTestFixture(t) + a := &Automation{ + ID: "auto-1", + Name: "A", + Triggers: []AutomationTrigger{ + // Enabled distinguishes t-a from t-b in the output, since + // exportTrigger carries no id. + {ID: "t-b", AutomationID: "auto-1", Type: TriggerTypeWebhook, Config: []byte("{}"), Enabled: false}, + {ID: "t-a", AutomationID: "auto-1", Type: TriggerTypeWebhook, Config: []byte("{}"), Enabled: true}, + {ID: "t-c", AutomationID: "auto-1", Type: TriggerTypeScheduled, Config: []byte("{}"), Enabled: true}, + }, + } + + triggers, _, err := svc.buildExportTriggers(a) + if err != nil { + t.Fatalf("buildExportTriggers: %v", err) + } + wantTypes := []string{string(TriggerTypeScheduled), string(TriggerTypeWebhook), string(TriggerTypeWebhook)} + if len(triggers) != len(wantTypes) { + t.Fatalf("got %d triggers, want %d", len(triggers), len(wantTypes)) + } + for i, wt := range wantTypes { + if triggers[i].Type != wt { + t.Errorf("index %d: got type %q, want %q", i, triggers[i].Type, wt) + } + } + // Within the two webhook triggers (same type), id "t-a" (Enabled=true) + // sorts before "t-b" (Enabled=false). + if !triggers[1].Enabled || triggers[2].Enabled { + t.Errorf("expected t-a (enabled) before t-b (disabled), got %+v", triggers[1:]) + } +} diff --git a/apps/backend/internal/automation/export_slug.go b/apps/backend/internal/automation/export_slug.go new file mode 100644 index 0000000000..955dd0b230 --- /dev/null +++ b/apps/backend/internal/automation/export_slug.go @@ -0,0 +1,123 @@ +package automation + +import "strings" + +// automationSlugFallback is AC-26's slug for a name that derives to an empty +// string (no ASCII letters or digits at all). +const automationSlugFallback = "automation" + +// deriveAutomationSlug renders an automation name into an AC-25/26 zip entry +// slug: lowercase, every character outside [a-z0-9] becomes '-', consecutive +// '-' collapse to one, leading and trailing '-' trim, then truncate to 64 +// characters followed by a further trailing-'-' trim. An empty result (the +// name has no ASCII letters or digits at all) falls back to +// automationSlugFallback. +func deriveAutomationSlug(name string) string { + lower := strings.ToLower(name) + + var b strings.Builder + b.Grow(len(lower)) + lastDash := false + for _, r := range lower { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + lastDash = false + continue + } + if !lastDash { + b.WriteByte('-') + lastDash = true + } + } + + slug := strings.Trim(b.String(), "-") + if len(slug) > 64 { + slug = strings.TrimRight(slug[:64], "-") + } + if slug == "" { + return automationSlugFallback + } + return slug +} + +// automationSlugSource is what uniqueAutomationSlugs needs per automation to +// derive its base slug (AC-25/26) and, on collision, its AC-27 suffix. +type automationSlugSource struct { + ID string + Name string +} + +// uniqueAutomationSlugs resolves each automation's zip-entry slug — the +// "" in ".kandev/automations/.yml" — applying AC-27's +// suffix-and-widen collision algorithm: every automation sharing a base slug +// with another is suffixed with the first 8 characters of its id, applied to +// every member of the group so none keeps the bare slug. If any resulting +// name still collides with any other entry in the export — suffixed or +// not — the still-colliding entries' suffixes widen by 8 more characters of +// id, up to the full id, until every entry is unique. +// +// Termination is guaranteed by id uniqueness: once an entry's suffix reaches +// its automation's full id, no other automation can produce that same +// suffixed name, so the number of still-colliding entries can only shrink +// across rounds. +func uniqueAutomationSlugs(sources []automationSlugSource) map[string]string { + base := make(map[string]string, len(sources)) + for _, s := range sources { + base[s.ID] = deriveAutomationSlug(s.Name) + } + + counts := make(map[string]int, len(sources)) + for _, s := range sources { + counts[base[s.ID]]++ + } + + level := make(map[string]int, len(sources)) + for _, s := range sources { + if counts[base[s.ID]] > 1 { + level[s.ID] = 8 + } + } + + for { + names := make(map[string][]string, len(sources)) + for _, s := range sources { + name := suffixedAutomationSlug(base[s.ID], s.ID, level[s.ID]) + names[name] = append(names[name], s.ID) + } + + collided := false + for _, ids := range names { + if len(ids) <= 1 { + continue + } + collided = true + for _, id := range ids { + next := level[id] + 8 + if next > len(id) { + next = len(id) + } + level[id] = next + } + } + if !collided { + break + } + } + + result := make(map[string]string, len(sources)) + for _, s := range sources { + result[s.ID] = suffixedAutomationSlug(base[s.ID], s.ID, level[s.ID]) + } + return result +} + +func suffixedAutomationSlug(base, id string, level int) string { + if level <= 0 { + return base + } + n := level + if n > len(id) { + n = len(id) + } + return base + "-" + id[:n] +} diff --git a/apps/backend/internal/automation/export_slug_test.go b/apps/backend/internal/automation/export_slug_test.go new file mode 100644 index 0000000000..a2601cc1f3 --- /dev/null +++ b/apps/backend/internal/automation/export_slug_test.go @@ -0,0 +1,186 @@ +package automation + +import ( + "strings" + "testing" +) + +// AC-25/26: deriveAutomationSlug lowercases, replaces every character outside +// [a-z0-9] with '-', collapses consecutive '-', trims leading/trailing '-', +// truncates to 64 characters followed by a further trailing-'-' trim, and +// falls back to "automation" when the result is empty. + +func TestDeriveAutomationSlug_LowercasesAndReplacesNonAlnum(t *testing.T) { + got := deriveAutomationSlug("Daily Sync!") + want := "daily-sync" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDeriveAutomationSlug_CollapsesConsecutiveDashes(t *testing.T) { + got := deriveAutomationSlug("Daily Sync -- Now") + want := "daily-sync-now" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDeriveAutomationSlug_TrimsLeadingAndTrailingDashes(t *testing.T) { + got := deriveAutomationSlug("!!!Daily Sync!!!") + want := "daily-sync" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestDeriveAutomationSlug_TruncatesTo64ThenTrimsTrailingDash(t *testing.T) { + // 63 alnum chars, then a run of non-alnum that lands the 64th output + // character on a '-': truncation must then trim that trailing dash. + name := strings.Repeat("a", 63) + " " + "bbbb" + got := deriveAutomationSlug(name) + want := strings.Repeat("a", 63) + if got != want { + t.Errorf("got %q (len %d), want %q (len %d)", got, len(got), want, len(want)) + } +} + +func TestDeriveAutomationSlug_TruncatesTo64ExactBoundary(t *testing.T) { + name := strings.Repeat("a", 70) + got := deriveAutomationSlug(name) + if len(got) != 64 { + t.Errorf("got len %d, want 64", len(got)) + } + if got != strings.Repeat("a", 64) { + t.Errorf("got %q, want 64 a's", got) + } +} + +func TestDeriveAutomationSlug_EmptyResultFallsBackToAutomation(t *testing.T) { + // AC-26's worked example: a name with no [a-zA-Z0-9] characters at all. + got := deriveAutomationSlug("日次レビュー") + if got != automationSlugFallback { + t.Errorf("got %q, want %q", got, automationSlugFallback) + } +} + +func TestDeriveAutomationSlug_EmptyName(t *testing.T) { + got := deriveAutomationSlug("") + if got != automationSlugFallback { + t.Errorf("got %q, want %q", got, automationSlugFallback) + } +} + +func TestDeriveAutomationSlug_SpecWorkedExamples(t *testing.T) { + cases := []struct { + name string + want string + }{ + {"Daily Review — @kegmil/offline-first", "daily-review-kegmil-offline-first"}, + {"Daily km-mobile-app-v2 repo drift --all", "daily-km-mobile-app-v2-repo-drift-all"}, + } + for _, c := range cases { + if got := deriveAutomationSlug(c.name); got != c.want { + t.Errorf("deriveAutomationSlug(%q) = %q, want %q", c.name, got, c.want) + } + } +} + +// AC-27: automations sharing a base slug are suffixed with the first 8 +// characters of their id; a still-colliding result widens the suffix by 8 +// more characters of id, up to the full id, until every entry is unique. + +func TestUniqueAutomationSlugs_NoCollision_KeepsBareSlug(t *testing.T) { + got := uniqueAutomationSlugs([]automationSlugSource{ + {ID: "id-1", Name: "Daily Sync"}, + {ID: "id-2", Name: "Weekly Review"}, + }) + want := map[string]string{"id-1": "daily-sync", "id-2": "weekly-review"} + for id, wantSlug := range want { + if got[id] != wantSlug { + t.Errorf("id %s: got %q, want %q", id, got[id], wantSlug) + } + } +} + +func TestUniqueAutomationSlugs_TwoCollide_BothSuffixedWithFirst8OfID(t *testing.T) { + got := uniqueAutomationSlugs([]automationSlugSource{ + {ID: "aaaaaaaa1111", Name: "Daily Sync"}, + {ID: "bbbbbbbb2222", Name: "Daily Sync"}, + }) + want := map[string]string{ + "aaaaaaaa1111": "daily-sync-aaaaaaaa", + "bbbbbbbb2222": "daily-sync-bbbbbbbb", + } + for id, wantSlug := range want { + if got[id] != wantSlug { + t.Errorf("id %s: got %q, want %q", id, got[id], wantSlug) + } + } + if got["aaaaaaaa1111"] == got["bbbbbbbb2222"] { + t.Fatalf("collision not resolved: both got %q", got["aaaaaaaa1111"]) + } +} + +func TestUniqueAutomationSlugs_ThreeCollide_AllSuffixedNoneKeepsBareSlug(t *testing.T) { + got := uniqueAutomationSlugs([]automationSlugSource{ + {ID: "aaaaaaaa1111", Name: "Daily Sync"}, + {ID: "bbbbbbbb2222", Name: "Daily Sync"}, + {ID: "cccccccc3333", Name: "Daily Sync"}, + }) + seen := make(map[string]bool, 3) + for _, id := range []string{"aaaaaaaa1111", "bbbbbbbb2222", "cccccccc3333"} { + slug := got[id] + if slug == "daily-sync" { + t.Errorf("id %s kept the bare slug %q; every member of a colliding group must be suffixed", id, slug) + } + if seen[slug] { + t.Fatalf("duplicate slug %q produced", slug) + } + seen[slug] = true + } +} + +func TestUniqueAutomationSlugs_SuffixStillCollides_WidensToFullID(t *testing.T) { + // Both ids share their first 8 characters, so the level-8 suffix also + // collides; the algorithm must widen to the full (12-character) id. + got := uniqueAutomationSlugs([]automationSlugSource{ + {ID: "abcdefgh1111", Name: "Daily Sync"}, + {ID: "abcdefgh2222", Name: "Daily Sync"}, + }) + want := map[string]string{ + "abcdefgh1111": "daily-sync-abcdefgh1111", + "abcdefgh2222": "daily-sync-abcdefgh2222", + } + for id, wantSlug := range want { + if got[id] != wantSlug { + t.Errorf("id %s: got %q, want %q", id, got[id], wantSlug) + } + } +} + +func TestUniqueAutomationSlugs_UnsuffixedEntryCollidesWithSuffixedGroup(t *testing.T) { + // D's own bare slug happens to equal what E and F's level-8 suffixed + // names collide to. AC-27 requires resolving this even though D was + // never part of E/F's originally-colliding base-slug group. + got := uniqueAutomationSlugs([]automationSlugSource{ + {ID: "dddddddd1111", Name: "Foo Aaaaaaaa"}, + {ID: "aaaaaaaa1111", Name: "Foo"}, + {ID: "aaaaaaaa2222", Name: "Foo"}, + }) + names := map[string]string{ + "dddddddd1111": got["dddddddd1111"], + "aaaaaaaa1111": got["aaaaaaaa1111"], + "aaaaaaaa2222": got["aaaaaaaa2222"], + } + seen := make(map[string]bool, 3) + for id, slug := range names { + if slug == "" { + t.Fatalf("id %s got no slug", id) + } + if seen[slug] { + t.Fatalf("duplicate slug %q produced across %v", slug, names) + } + seen[slug] = true + } +} diff --git a/apps/backend/internal/automation/export_store.go b/apps/backend/internal/automation/export_store.go new file mode 100644 index 0000000000..4019c384ac --- /dev/null +++ b/apps/backend/internal/automation/export_store.go @@ -0,0 +1,120 @@ +package automation + +import ( + "context" + "database/sql" + "fmt" + + "github.com/jmoiron/sqlx" +) + +// BeginReadTx opens a transaction on the reader pool. AC-29's export +// orchestration opens exactly one of these per export request and passes it +// through every store read it needs — this package's +// ListAutomationsForExportTx plus the sibling GetWorkflowTx/GetRepositoryTx/ +// GetExecutorProfileTx (task/repository/sqlite), GetAgentProfileTx +// (agent/settings/store), and GetStepTx (workflow/repository) — so the whole +// export observes one consistent snapshot. Every store in scope shares the +// same underlying reader pool (see internal/backendapp/services.go and +// storage.go), so a *sqlx.Tx opened here is a transaction against the exact +// pool those other stores' methods also read through. +// +// RepeatableRead + ReadOnly is requested explicitly rather than relying on +// the driver default: SQLite's mattn driver ignores TxOptions entirely (its +// WAL-mode read transactions already snapshot at BEGIN regardless), but a +// Postgres reader pool defaults to READ COMMITTED, which takes a fresh +// snapshot per statement — silently breaking AC-29's one-snapshot promise +// for this export's multiple sequential reads. Requesting RepeatableRead is +// a no-op on SQLite and the correct fix on Postgres. +func (s *Store) BeginReadTx(ctx context.Context) (*sqlx.Tx, error) { + return s.ro.BeginTxx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) +} + +// ListAutomationsForExportTx is ListAutomations' transaction-accepting +// counterpart: it reads automations, triggers, and repository links within +// tx instead of opening its own reads, so the export's snapshot covers this +// table too. +func (s *Store) ListAutomationsForExportTx(ctx context.Context, tx *sqlx.Tx, workspaceID string) ([]*Automation, error) { + var automations []*Automation + err := sqlx.SelectContext(ctx, tx, &automations, tx.Rebind( + `SELECT `+automationColumns+` FROM automations WHERE workspace_id = ? ORDER BY created_at DESC`), workspaceID) + if err != nil { + return nil, err + } + if len(automations) == 0 { + return automations, nil + } + + ids := make([]string, len(automations)) + for i, a := range automations { + ids[i] = a.ID + } + triggersByAutomation, err := listTriggersForAutomationsTx(ctx, tx, ids) + if err != nil { + return nil, fmt.Errorf("hydrate triggers: %w", err) + } + repoIDsByAutomation, err := listRepositoryIDsForAutomationsTx(ctx, tx, ids) + if err != nil { + return nil, fmt.Errorf("hydrate repository_ids: %w", err) + } + for _, a := range automations { + a.Triggers = triggersByAutomation[a.ID] + a.RepositoryIDs = repoIDsByAutomation[a.ID] + } + return automations, nil +} + +// listTriggersForAutomationsTx mirrors listTriggersForAutomations, reading +// within tx. +func listTriggersForAutomationsTx(ctx context.Context, tx *sqlx.Tx, automationIDs []string) (map[string][]AutomationTrigger, error) { + if len(automationIDs) == 0 { + return make(map[string][]AutomationTrigger), nil + } + query, args, err := sqlx.In( + `SELECT * FROM automation_triggers WHERE automation_id IN (?) ORDER BY created_at`, automationIDs) + if err != nil { + return nil, err + } + query = tx.Rebind(query) + var triggers []AutomationTrigger + if err := sqlx.SelectContext(ctx, tx, &triggers, query, args...); err != nil { + return nil, err + } + hydrateTriggers(triggers) + result := make(map[string][]AutomationTrigger, len(automationIDs)) + for i := range triggers { + result[triggers[i].AutomationID] = append(result[triggers[i].AutomationID], triggers[i]) + } + return result, nil +} + +// listRepositoryIDsForAutomationsTx mirrors listRepositoryIDsForAutomations, +// reading within tx. +func listRepositoryIDsForAutomationsTx(ctx context.Context, tx *sqlx.Tx, automationIDs []string) (map[string][]string, error) { + if len(automationIDs) == 0 { + return make(map[string][]string), nil + } + query, args, err := sqlx.In( + `SELECT automation_id, repository_id FROM automation_repositories + WHERE automation_id IN (?) ORDER BY automation_id, position, repository_id`, automationIDs) + if err != nil { + return nil, err + } + query = tx.Rebind(query) + type row struct { + AutomationID string `db:"automation_id"` + RepositoryID string `db:"repository_id"` + } + var rows []row + if err := sqlx.SelectContext(ctx, tx, &rows, query, args...); err != nil { + return nil, err + } + result := make(map[string][]string, len(automationIDs)) + for _, id := range automationIDs { + result[id] = []string{} + } + for _, r := range rows { + result[r.AutomationID] = append(result[r.AutomationID], r.RepositoryID) + } + return result, nil +} diff --git a/apps/backend/internal/automation/export_store_test.go b/apps/backend/internal/automation/export_store_test.go new file mode 100644 index 0000000000..3c2b4dfb24 --- /dev/null +++ b/apps/backend/internal/automation/export_store_test.go @@ -0,0 +1,162 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// AC-29: the automations YAML export reads automations, triggers, and +// repository links within a single transaction opened on the store's reader +// handle, so the export observes one consistent snapshot even though the +// same read spans several other stores' tables too. BeginReadTx opens that +// transaction; ListAutomationsForExportTx is ListAutomations' counterpart +// that reads within it instead of opening its own. + +func TestBeginReadTx_ReadsCommittedData(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "Daily Review", Enabled: true, MaxConcurrentRuns: 1} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + tx, err := store.BeginReadTx(ctx) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + automations, err := store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx: %v", err) + } + if len(automations) != 1 { + t.Fatalf("expected 1 automation, got %d", len(automations)) + } + if automations[0].ID != a.ID { + t.Errorf("ID = %q, want %q", automations[0].ID, a.ID) + } +} + +func TestListAutomationsForExportTx_HydratesTriggersAndRepositoryIDs(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{ + WorkspaceID: "ws-1", + Name: "Daily Review", + Enabled: true, + MaxConcurrentRuns: 1, + RepositoryIDs: []string{"repo-a", "repo-b"}, + } + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + trigger := &AutomationTrigger{ + AutomationID: a.ID, + Type: "scheduled", + Enabled: true, + Config: json.RawMessage(`{"cron_expression":"0 9 * * *"}`), + } + if err := store.CreateTrigger(ctx, trigger); err != nil { + t.Fatalf("CreateTrigger: %v", err) + } + + tx, err := store.BeginReadTx(ctx) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + automations, err := store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx: %v", err) + } + if len(automations) != 1 { + t.Fatalf("expected 1 automation, got %d", len(automations)) + } + got := automations[0] + if len(got.Triggers) != 1 || got.Triggers[0].ID != trigger.ID { + t.Errorf("Triggers = %+v, want [%s]", got.Triggers, trigger.ID) + } + if len(got.RepositoryIDs) != 2 || got.RepositoryIDs[0] != "repo-a" || got.RepositoryIDs[1] != "repo-b" { + t.Errorf("RepositoryIDs = %v, want [repo-a repo-b]", got.RepositoryIDs) + } +} + +// AC-8: repositories are ordered by automation_repositories.position +// ascending, tiebroken by automation_repositories.repository_id ascending. +// The schema's only uniqueness constraint is UNIQUE(automation_id, +// repository_id) — nothing prevents two different repositories on the same +// automation from sharing a position — so a real tie is reachable data, not +// a hypothetical. Insert the tied rows directly (insertAutomationRepositories +// always assigns sequential positions, so it cannot produce a tie) in +// repository_id-descending insertion order, deliberately opposite the +// expected output order, so a query lacking the tiebreak would only pass by +// accident of SQLite's unspecified tie order. +func TestListAutomationsForExportTx_AC8_RepositoryPositionTieBreaksByRepositoryID(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "Daily Review", Enabled: true, MaxConcurrentRuns: 1} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + now := time.Now().UTC() + for _, repoID := range []string{"repo-z", "repo-m", "repo-a"} { + _, err := store.db.ExecContext(ctx, ` + INSERT INTO automation_repositories (id, automation_id, repository_id, position, created_at) + VALUES (?, ?, ?, 0, ?)`, "arow-"+repoID, a.ID, repoID, now) + if err != nil { + t.Fatalf("seed tied automation_repositories row %s: %v", repoID, err) + } + } + + tx, err := store.BeginReadTx(ctx) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + automations, err := store.ListAutomationsForExportTx(ctx, tx, "ws-1") + if err != nil { + t.Fatalf("ListAutomationsForExportTx: %v", err) + } + if len(automations) != 1 { + t.Fatalf("expected 1 automation, got %d", len(automations)) + } + want := []string{"repo-a", "repo-m", "repo-z"} + got := automations[0].RepositoryIDs + if len(got) != len(want) { + t.Fatalf("RepositoryIDs = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("RepositoryIDs = %v, want %v (repository_id-ascending tiebreak on position tie)", got, want) + break + } + } +} + +func TestListAutomationsForExportTx_EmptyWorkspace(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + tx, err := store.BeginReadTx(ctx) + if err != nil { + t.Fatalf("BeginReadTx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + automations, err := store.ListAutomationsForExportTx(ctx, tx, "ws-empty") + if err != nil { + t.Fatalf("ListAutomationsForExportTx: %v", err) + } + if len(automations) != 0 { + t.Errorf("expected 0 automations, got %d", len(automations)) + } +} diff --git a/apps/backend/internal/automation/export_trigger_config.go b/apps/backend/internal/automation/export_trigger_config.go new file mode 100644 index 0000000000..641d6476bc --- /dev/null +++ b/apps/backend/internal/automation/export_trigger_config.go @@ -0,0 +1,65 @@ +package automation + +import ( + "encoding/json" + "fmt" + "unicode/utf8" + + "gopkg.in/yaml.v3" +) + +// AC-39's three warning messages, checked and emitted in this exact order: +// UTF-8 validity is checked on the raw stored bytes before any decoding, so +// it takes precedence over a JSON error a subsequent decode might also +// report for the same bytes; JSON validity is checked before the +// decoded-value's shape. +const ( + warnTriggerConfigInvalidUTF8 = "config is not valid UTF-8" + warnTriggerConfigInvalidJSON = "config is not valid JSON" + warnTriggerConfigNotObject = "config is not a JSON object" +) + +// buildTriggerConfigNode renders a trigger's stored config as a YAML mapping +// node (AC-39). A config that is not valid UTF-8, not valid JSON, or valid +// JSON that does not decode to an object is rendered as an empty mapping +// instead of any partial or corrupted value — a partially-mangled config in +// a committed artifact is worse than an absent one — paired with exactly one +// warning naming the first-failing condition. Export never fails because of +// a bad trigger config. +func buildTriggerConfigNode(raw json.RawMessage) (*yaml.Node, string, error) { + emptyMapping := &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} + + if !utf8.Valid(raw) { + return emptyMapping, warnTriggerConfigInvalidUTF8, nil + } + + decoded, err := decodeJSONWithNumbers(raw) + if err != nil { + return emptyMapping, warnTriggerConfigInvalidJSON, nil + } + + obj, ok := decoded.(map[string]any) + if !ok { + return emptyMapping, warnTriggerConfigNotObject, nil + } + + node, err := jsonObjectToYAMLNode(obj) + if err != nil { + return nil, "", fmt.Errorf("build trigger config node: %w", err) + } + return node, "", nil +} + +// newTriggerConfigWarning builds a trigger-scoped exportWarning for +// buildTriggerConfigNode's warning string. DedupKey is the trigger's ID, not +// the automation's: AC-42 scopes trigger-message dedup per-trigger, so two +// different triggers on the same automation that happen to produce the same +// message each keep their own warning line. +func newTriggerConfigWarning(a *Automation, trigger AutomationTrigger, kind string) exportWarning { + return exportWarning{ + AutomationName: a.Name, + AutomationID: a.ID, + DedupKey: trigger.ID, + Message: fmt.Sprintf("trigger %s: %s", escapeWarningText(string(trigger.Type)), kind), + } +} diff --git a/apps/backend/internal/automation/export_trigger_config_test.go b/apps/backend/internal/automation/export_trigger_config_test.go new file mode 100644 index 0000000000..544c882748 --- /dev/null +++ b/apps/backend/internal/automation/export_trigger_config_test.go @@ -0,0 +1,181 @@ +package automation + +import ( + "encoding/json" + "testing" + + "gopkg.in/yaml.v3" +) + +// AC-39: a trigger's stored config is emitted as an empty mapping, with a +// warning naming which of three conditions occurred, when it is not valid +// UTF-8 (checked on the raw bytes, before decoding, and taking precedence +// over the other two), is not valid JSON, or is valid JSON that is not an +// object. + +func TestBuildTriggerConfigNode_ValidObject(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage(`{"b":2,"a":1}`)) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "" { + t.Errorf("warning = %q, want none", warning) + } + if node.Kind != yaml.MappingNode || len(node.Content) != 4 { + t.Fatalf("node = %+v, want a 2-entry mapping", node) + } + // AC-8: keys sorted byte-wise ascending. + if node.Content[0].Value != "a" || node.Content[2].Value != "b" { + t.Errorf("keys not sorted: %q, %q", node.Content[0].Value, node.Content[2].Value) + } +} + +func TestBuildTriggerConfigNode_EmptyObject(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage(`{}`)) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "" { + t.Errorf("warning = %q, want none", warning) + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } +} + +func TestBuildTriggerConfigNode_InvalidUTF8(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage("{\"a\":\"b\xffc\"}")) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "config is not valid UTF-8" { + t.Errorf("warning = %q, want %q", warning, "config is not valid UTF-8") + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } +} + +func TestBuildTriggerConfigNode_InvalidJSON(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage(`{not json`)) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "config is not valid JSON" { + t.Errorf("warning = %q, want %q", warning, "config is not valid JSON") + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } +} + +// AC-39: a valid JSON value followed by trailing bytes is not valid JSON as a +// whole. json.Decoder.Decode only consumes one JSON value and stops, so a +// naive decode-and-check-error implementation silently accepts trailing +// garbage. Assert the "config is not valid JSON" warning path is taken, not +// the value itself, since a decoder that behaves this way would otherwise +// export the leading value as if the stored bytes were entirely valid. +func TestBuildTriggerConfigNode_TrailingGarbageAfterValidJSON(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage(`{"cron_expression":"0 9 * * *"} trailing garbage`)) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "config is not valid JSON" { + t.Errorf("warning = %q, want %q", warning, "config is not valid JSON") + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } +} + +func TestBuildTriggerConfigNode_ValidJSONNotAnObject(t *testing.T) { + cases := map[string]string{ + "null": `null`, + "array": `[1,2]`, + "string": `"str"`, + "number": `42`, + "bool": `true`, + } + for name, raw := range cases { + t.Run(name, func(t *testing.T) { + node, warning, err := buildTriggerConfigNode(json.RawMessage(raw)) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "config is not a JSON object" { + t.Errorf("warning = %q, want %q", warning, "config is not a JSON object") + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } + }) + } +} + +func TestBuildTriggerConfigNode_InvalidUTF8TakesPrecedenceOverInvalidJSON(t *testing.T) { + // Invalid UTF-8 bytes that also fail to parse as JSON: exactly one + // warning, and it must be the UTF-8 one, so precedence is unambiguous. + node, warning, err := buildTriggerConfigNode(json.RawMessage("not json \xff")) + if err != nil { + t.Fatalf("buildTriggerConfigNode: %v", err) + } + if warning != "config is not valid UTF-8" { + t.Errorf("warning = %q, want %q", warning, "config is not valid UTF-8") + } + if node.Kind != yaml.MappingNode || len(node.Content) != 0 { + t.Errorf("node = %+v, want an empty mapping", node) + } +} + +// AC-10: two stored configs that differ only in JSON whitespace must produce +// identical config YAML. The live store contains both a compact and a +// space-separated serialization of the same scheduled trigger config. +func TestBuildTriggerConfigNode_AC10_WhitespaceInsensitive(t *testing.T) { + compact := json.RawMessage(`{"cron_expression":"0 9 * * *","timezone":"Asia/Singapore"}`) + spaced := json.RawMessage(`{"cron_expression": "0 9 * * *", "timezone": "Asia/Singapore"}`) + + compactNode, warning, err := buildTriggerConfigNode(compact) + if err != nil { + t.Fatalf("buildTriggerConfigNode(compact): %v", err) + } + if warning != "" { + t.Fatalf("warning = %q, want none", warning) + } + spacedNode, warning, err := buildTriggerConfigNode(spaced) + if err != nil { + t.Fatalf("buildTriggerConfigNode(spaced): %v", err) + } + if warning != "" { + t.Fatalf("warning = %q, want none", warning) + } + + compactOut, err := yaml.Marshal(compactNode) + if err != nil { + t.Fatalf("yaml.Marshal(compactNode): %v", err) + } + spacedOut, err := yaml.Marshal(spacedNode) + if err != nil { + t.Fatalf("yaml.Marshal(spacedNode): %v", err) + } + if string(compactOut) != string(spacedOut) { + t.Errorf("config YAML differs by input whitespace alone:\ncompact: %q\nspaced: %q", compactOut, spacedOut) + } +} + +func TestNewTriggerConfigWarning_EscapesTypeAndScopesDedupToTrigger(t *testing.T) { + a := &Automation{ID: "auto-1", Name: "Daily Sync"} + trigger := AutomationTrigger{ID: "trig-1", Type: TriggerType("weird\ntype")} + + w := newTriggerConfigWarning(a, trigger, "config is not valid JSON") + + if w.AutomationName != "Daily Sync" || w.AutomationID != "auto-1" { + t.Errorf("automation identity = %+v", w) + } + if w.DedupKey != "trig-1" { + t.Errorf("DedupKey = %q, want trigger ID", w.DedupKey) + } + want := `trigger weird\ntype: config is not valid JSON` + if w.Message != want { + t.Errorf("Message = %q, want %q", w.Message, want) + } +} diff --git a/apps/backend/internal/automation/export_warnings.go b/apps/backend/internal/automation/export_warnings.go new file mode 100644 index 0000000000..9a5439100e --- /dev/null +++ b/apps/backend/internal/automation/export_warnings.go @@ -0,0 +1,91 @@ +package automation + +import ( + "fmt" + "sort" + "strings" + "unicode/utf8" +) + +// escapeWarningText applies AC-42's four rendering rules to automation.name +// or automation_triggers.type before either is interpolated into a warning +// message. The rules operate on disjoint inputs (control characters below +// U+0080 plus U+0085, exactly U+2028/U+2029, and invalid UTF-8 bytes), so the +// order they are applied in cannot change the result. +func escapeWarningText(s string) string { + var b strings.Builder + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + fmt.Fprintf(&b, `\x%02x`, s[i]) + i++ + continue + } + switch { + case r == '\n': + b.WriteString(`\n`) + case r == '\r': + b.WriteString(`\r`) + case r == '\u2028' || r == '\u2029': + fmt.Fprintf(&b, `\u%04x`, r) + case r <= 0x1F || r == 0x7F || r == 0x85: + fmt.Fprintf(&b, `\x%02x`, r) + default: + b.WriteRune(r) + } + i += size + } + return b.String() +} + +// buildWarningsList deduplicates, orders, and renders the warnings collected +// during export into the strings that populate the exported document's +// top-level `warnings` sequence (AC-20) / WARNINGS.txt. +// +// Dedup (per exportWarning.DedupKey) precedes ordering: AC-21's sort must see +// only the surviving, de-duplicated set. Ordering is AC-21: automation name +// ascending (raw, matching AC-8's own automation sort), tiebroken by +// automations.id ascending, then by the warning message ascending — which +// only ever discriminates between two warnings for the very same automation, +// since name+id together already identify it uniquely. Rendering is AC-42: +// `: `, where message already carries any +// AC-42-escaped interpolation (trigger type) a caller embedded in it. +func buildWarningsList(warnings []exportWarning) []string { + deduped := dedupExportWarnings(warnings) + sort.Slice(deduped, func(i, j int) bool { + a, b := deduped[i], deduped[j] + if a.AutomationName != b.AutomationName { + return a.AutomationName < b.AutomationName + } + if a.AutomationID != b.AutomationID { + return a.AutomationID < b.AutomationID + } + return a.Message < b.Message + }) + lines := make([]string, len(deduped)) + for i, w := range deduped { + lines[i] = fmt.Sprintf("%s: %s", escapeWarningText(w.AutomationName), w.Message) + } + return lines +} + +// dedupExportWarnings drops a warning whose (DedupKey, Message) pair has +// already been seen. The scope is per DedupKey — an automation ID for +// automation-scoped messages, a trigger ID for trigger-scoped ones — never +// global: automation names are not unique and trigger types are not unique +// per automation, so two entities that happen to produce byte-identical +// warning strings each keep their own line. +func dedupExportWarnings(warnings []exportWarning) []exportWarning { + type key struct{ dedupKey, message string } + seen := make(map[key]bool, len(warnings)) + out := make([]exportWarning, 0, len(warnings)) + for _, w := range warnings { + k := key{w.DedupKey, w.Message} + if seen[k] { + continue + } + seen[k] = true + out = append(out, w) + } + return out +} diff --git a/apps/backend/internal/automation/export_warnings_test.go b/apps/backend/internal/automation/export_warnings_test.go new file mode 100644 index 0000000000..58d9a81559 --- /dev/null +++ b/apps/backend/internal/automation/export_warnings_test.go @@ -0,0 +1,134 @@ +package automation + +import "testing" + +// AC-42: automation name and trigger type are escaped by four disjoint rules +// before being interpolated into a warning message, so the rendered +// `: ` line always contains no newline as `## Definitions` +// defines it (LF, CR, NEL, LS, PS) and is always valid UTF-8. + +func TestEscapeWarningText_LFAndCR(t *testing.T) { + got := escapeWarningText("Daily\nSync\r") + want := `Daily\nSync\r` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEscapeWarningText_OtherC0AndDELAndNEL(t *testing.T) { + got := escapeWarningText("a\x01b\x7fc\u0085d") + want := `a\x01b\x7fc\x85d` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEscapeWarningText_LineSeparatorAndParagraphSeparator(t *testing.T) { + got := escapeWarningText("Daily
 Sync
") + want := "Daily\\u2028 Sync\\u2029" + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEscapeWarningText_InvalidUTF8Byte(t *testing.T) { + got := escapeWarningText("abc\xffdef") + want := `abc\xffdef` + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestEscapeWarningText_OrdinaryTextUnchanged(t *testing.T) { + got := escapeWarningText("plain name") + if got != "plain name" { + t.Errorf("got %q, want unchanged", got) + } +} + +func TestEscapeWarningText_EmptyString(t *testing.T) { + if got := escapeWarningText(""); got != "" { + t.Errorf("got %q, want empty", got) + } +} + +// AC-20/21/42: buildWarningsList dedups per its scope key, orders by +// automation name asc / automations.id asc / message asc, and renders each +// surviving warning as ": ". + +func TestBuildWarningsList_Empty(t *testing.T) { + got := buildWarningsList(nil) + if len(got) != 0 { + t.Errorf("got %v, want empty", got) + } +} + +func TestBuildWarningsList_OrdersByNameThenIDThenMessage(t *testing.T) { + warnings := []exportWarning{ + {AutomationName: "Zeta", AutomationID: "z1", DedupKey: "z1", Message: "unresolved workflow"}, + {AutomationName: "Alpha", AutomationID: "a2", DedupKey: "a2", Message: "unresolved workflow"}, + {AutomationName: "Alpha", AutomationID: "a1", DedupKey: "a1", Message: "unresolved agent profile"}, + } + got := buildWarningsList(warnings) + want := []string{ + "Alpha: unresolved agent profile", + "Alpha: unresolved workflow", + "Zeta: unresolved workflow", + } + if len(got) != len(want) { + t.Fatalf("got %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("index %d: got %q, want %q", i, got[i], want[i]) + } + } +} + +func TestBuildWarningsList_SameAutomationMultipleMessagesOrderedByText(t *testing.T) { + warnings := []exportWarning{ + {AutomationName: "A", AutomationID: "a1", DedupKey: "a1", Message: "unresolved workflow"}, + {AutomationName: "A", AutomationID: "a1", DedupKey: "a1", Message: "unresolved agent profile"}, + } + got := buildWarningsList(warnings) + want := []string{"A: unresolved agent profile", "A: unresolved workflow"} + if len(got) != 2 || got[0] != want[0] || got[1] != want[1] { + t.Errorf("got %v, want %v", got, want) + } +} + +func TestBuildWarningsList_DedupsWithinSameScopeKey(t *testing.T) { + warnings := []exportWarning{ + {AutomationName: "A", AutomationID: "a1", DedupKey: "a1", Message: "unresolved workflow"}, + {AutomationName: "A", AutomationID: "a1", DedupKey: "a1", Message: "unresolved workflow"}, + } + got := buildWarningsList(warnings) + if len(got) != 1 { + t.Errorf("got %v, want 1 entry after dedup", got) + } +} + +func TestBuildWarningsList_KeepsIdenticalMessagesAcrossDifferentScopeKeys(t *testing.T) { + // Two different automations (or two different triggers on the same + // automation) that produce byte-identical messages each keep their own + // warning: dedup is scoped to DedupKey, never global. + warnings := []exportWarning{ + {AutomationName: "Daily Sync", AutomationID: "a1", DedupKey: "a1", Message: "unresolved agent profile"}, + {AutomationName: "Daily Sync", AutomationID: "a2", DedupKey: "a2", Message: "unresolved agent profile"}, + } + got := buildWarningsList(warnings) + if len(got) != 2 { + t.Errorf("got %v, want 2 entries (dedup must not cross scope keys)", got) + } +} + +func TestBuildWarningsList_EscapesAutomationNameOnRender(t *testing.T) { + warnings := []exportWarning{ + {AutomationName: "Daily\nSync", AutomationID: "a1", DedupKey: "a1", Message: "unresolved workflow"}, + } + got := buildWarningsList(warnings) + want := `Daily\nSync: unresolved workflow` + if len(got) != 1 || got[0] != want { + t.Errorf("got %v, want [%q]", got, want) + } +} diff --git a/apps/backend/internal/automation/export_workspace_lookup.go b/apps/backend/internal/automation/export_workspace_lookup.go new file mode 100644 index 0000000000..d2b029f41e --- /dev/null +++ b/apps/backend/internal/automation/export_workspace_lookup.go @@ -0,0 +1,22 @@ +package automation + +import "context" + +// ExportWorkspaceLookup answers whether a workspace exists, for AC-44 step 2's +// existence check. Deliberately not WorkflowLocator or RepositoryLookup: +// those answer ownership questions this package can already ask through +// authorizeWorkspace or the store. This lookup exists for the unscoped +// caller — Kandev's default mode — where step 1's authorizer returns nil +// without reading anything and Service.ListAutomations cannot distinguish a +// real but empty workspace from one that was never there (AC-35). +// +// Implementations return nil when the workspace exists, an error wrapping +// repoerrors.ErrWorkspaceNotFound when it does not, and any other error for +// an infrastructure failure. Satisfied by an adapter over the task service, +// injected via SetExportWorkspaceLookup rather than imported directly, to +// avoid a cyclic import — the same reason WorkflowLocator and +// RepositoryLookup are interfaces here rather than concrete task-service +// types. +type ExportWorkspaceLookup interface { + WorkspaceExists(ctx context.Context, workspaceID string) error +} diff --git a/apps/backend/internal/automation/export_zip.go b/apps/backend/internal/automation/export_zip.go new file mode 100644 index 0000000000..a864b181b2 --- /dev/null +++ b/apps/backend/internal/automation/export_zip.go @@ -0,0 +1,79 @@ +package automation + +import ( + "archive/zip" + "bytes" + "fmt" + "sort" +) + +// automationZipEntry is one automation prepared for the zip export: enough +// identity (ID, Name) to derive a unique slug (AC-25/26/27) plus the +// exportAutomation payload that becomes that entry's single-element +// automations list. +type automationZipEntry struct { + ID string + Name string + Automation exportAutomation +} + +// buildAutomationZip renders the zip export: one file per automation at +// .kandev/automations/.yml, each the full envelope (version, type, +// automations) with a single-element automations list and no warnings key +// (AC-24, AC-27). Entries — including .kandev/automations/WARNINGS.txt when +// warnings is non-empty (AC-20) — are ordered by entry path ascending, with +// no modification time set so the archive is byte-identical across builds of +// the same content (AC-28). +func buildAutomationZip(entries []automationZipEntry, warnings []string) ([]byte, error) { + sources := make([]automationSlugSource, len(entries)) + for i, e := range entries { + sources[i] = automationSlugSource{ID: e.ID, Name: e.Name} + } + slugs := uniqueAutomationSlugs(sources) + + type zipFile struct { + path string + content []byte + } + files := make([]zipFile, 0, len(entries)+1) + for _, e := range entries { + doc := newExportDocument([]exportAutomation{e.Automation}, nil) + content, err := marshalExportDocument(doc) + if err != nil { + return nil, fmt.Errorf("marshal automation %q for zip: %w", e.ID, err) + } + files = append(files, zipFile{ + path: fmt.Sprintf(".kandev/automations/%s.yml", slugs[e.ID]), + content: content, + }) + } + if len(warnings) > 0 { + var buf bytes.Buffer + for _, w := range warnings { + buf.WriteString(w) + buf.WriteByte('\n') + } + files = append(files, zipFile{path: ".kandev/automations/WARNINGS.txt", content: buf.Bytes()}) + } + + sort.Slice(files, func(i, j int) bool { return files[i].path < files[j].path }) + + var out bytes.Buffer + zw := zip.NewWriter(&out) + for _, f := range files { + // zip.Writer.Create leaves FileHeader.Modified unset, unlike + // CreateHeader with an explicit Modified value — this is what keeps + // the archive free of any wall-clock timestamp. + w, err := zw.Create(f.path) + if err != nil { + return nil, fmt.Errorf("create zip entry %q: %w", f.path, err) + } + if _, err := w.Write(f.content); err != nil { + return nil, fmt.Errorf("write zip entry %q: %w", f.path, err) + } + } + if err := zw.Close(); err != nil { + return nil, fmt.Errorf("close zip writer: %w", err) + } + return out.Bytes(), nil +} diff --git a/apps/backend/internal/automation/export_zip_test.go b/apps/backend/internal/automation/export_zip_test.go new file mode 100644 index 0000000000..5d447697de --- /dev/null +++ b/apps/backend/internal/automation/export_zip_test.go @@ -0,0 +1,237 @@ +package automation + +import ( + "archive/zip" + "bytes" + "testing" + + "gopkg.in/yaml.v3" +) + +func readZipFile(t *testing.T, data []byte, name string) ([]byte, bool) { + t.Helper() + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + for _, f := range r.File { + if f.Name != name { + continue + } + rc, err := f.Open() + if err != nil { + t.Fatalf("open %q: %v", name, err) + } + defer func() { _ = rc.Close() }() + var buf bytes.Buffer + if _, err := buf.ReadFrom(rc); err != nil { + t.Fatalf("read %q: %v", name, err) + } + return buf.Bytes(), true + } + return nil, false +} + +func zipEntryNames(t *testing.T, data []byte) []string { + t.Helper() + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatalf("zip.NewReader: %v", err) + } + names := make([]string, len(r.File)) + for i, f := range r.File { + names[i] = f.Name + } + return names +} + +// AC-24: one file per automation at .kandev/automations/.yml, each the +// full envelope with a single-element automations list. + +func TestBuildAutomationZip_OneEntryPerAutomationAtSlugPath(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + + content, ok := readZipFile(t, data, ".kandev/automations/daily-sync.yml") + if !ok { + t.Fatalf("entry .kandev/automations/daily-sync.yml not found; entries: %v", zipEntryNames(t, data)) + } + + var doc exportDocument + if err := yaml.Unmarshal(content, &doc); err != nil { + t.Fatalf("yaml.Unmarshal: %v", err) + } + if doc.Version != exportDocumentVersion || doc.Type != exportDocumentType { + t.Errorf("envelope = %+v, want version=%d type=%q", doc, exportDocumentVersion, exportDocumentType) + } + if len(doc.Automations) != 1 || doc.Automations[0].Name != "Daily Sync" { + t.Errorf("automations = %+v, want single-element list with Daily Sync", doc.Automations) + } + if len(doc.Warnings) != 0 { + t.Errorf("warnings = %v, want none embedded in a per-automation zip entry", doc.Warnings) + } +} + +func TestBuildAutomationZip_MultipleAutomations_OneFileEach(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + {ID: "id-2", Name: "Weekly Review", Automation: exportAutomation{Name: "Weekly Review", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + names := zipEntryNames(t, data) + want := []string{".kandev/automations/daily-sync.yml", ".kandev/automations/weekly-review.yml"} + if len(names) != len(want) { + t.Fatalf("got entries %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("index %d: got %q, want %q", i, names[i], want[i]) + } + } +} + +// AC-28: entries ordered by entry path ascending. + +func TestBuildAutomationZip_EntriesOrderedByPathAscending(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Zeta", Automation: exportAutomation{Name: "Zeta", Triggers: []exportTrigger{}}}, + {ID: "id-2", Name: "Alpha", Automation: exportAutomation{Name: "Alpha", Triggers: []exportTrigger{}}}, + {ID: "id-3", Name: "Mid", Automation: exportAutomation{Name: "Mid", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + names := zipEntryNames(t, data) + want := []string{".kandev/automations/alpha.yml", ".kandev/automations/mid.yml", ".kandev/automations/zeta.yml"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("index %d: got %q, want %q", i, names[i], want[i]) + } + } +} + +// AC-27: colliding automation names get unique slugs in the zip. + +func TestBuildAutomationZip_CollidingNamesGetUniqueEntries(t *testing.T) { + entries := []automationZipEntry{ + {ID: "aaaaaaaa1111", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + {ID: "bbbbbbbb2222", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + names := zipEntryNames(t, data) + want := []string{".kandev/automations/daily-sync-aaaaaaaa.yml", ".kandev/automations/daily-sync-bbbbbbbb.yml"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("index %d: got %q, want %q", i, names[i], want[i]) + } + } +} + +// AC-20/AC-21/AC-42: warnings live in .kandev/automations/WARNINGS.txt for +// the zip form, one already-rendered line per warning, absent when there are +// none. + +func TestBuildAutomationZip_NoWarnings_NoWarningsFile(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + if _, ok := readZipFile(t, data, ".kandev/automations/WARNINGS.txt"); ok { + t.Error("WARNINGS.txt present with no warnings; want absent") + } +} + +func TestBuildAutomationZip_WithWarnings_WritesWarningsTxt(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + } + warnings := []string{"Alpha: unresolved agent profile", "Zeta: unresolved workflow"} + data, err := buildAutomationZip(entries, warnings) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + content, ok := readZipFile(t, data, ".kandev/automations/WARNINGS.txt") + if !ok { + t.Fatalf("WARNINGS.txt not found; entries: %v", zipEntryNames(t, data)) + } + want := "Alpha: unresolved agent profile\nZeta: unresolved workflow\n" + if string(content) != want { + t.Errorf("got %q, want %q", content, want) + } +} + +func TestBuildAutomationZip_WarningsFileSortsWithOtherEntries(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "9-lives", Automation: exportAutomation{Name: "9-lives", Triggers: []exportTrigger{}}}, + } + data, err := buildAutomationZip(entries, []string{"9-lives: unresolved workflow"}) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + names := zipEntryNames(t, data) + // '9' (0x39) sorts before 'W' (0x57): the digit-led slug entry comes + // first, WARNINGS.txt second — proving no special-cased placement. + want := []string{".kandev/automations/9-lives.yml", ".kandev/automations/WARNINGS.txt"} + if len(names) != len(want) { + t.Fatalf("got %v, want %v", names, want) + } + for i := range want { + if names[i] != want[i] { + t.Errorf("index %d: got %q, want %q", i, names[i], want[i]) + } + } +} + +// AC-28: no wall-clock value enters the archive — building the same input +// twice, with real time elapsing between builds, must be byte-identical. + +func TestBuildAutomationZip_ReproducibleAcrossBuilds(t *testing.T) { + entries := []automationZipEntry{ + {ID: "id-1", Name: "Daily Sync", Automation: exportAutomation{Name: "Daily Sync", Triggers: []exportTrigger{}}}, + {ID: "id-2", Name: "Weekly Review", Automation: exportAutomation{Name: "Weekly Review", Triggers: []exportTrigger{}}}, + } + warnings := []string{"Daily Sync: unresolved agent profile"} + + first, err := buildAutomationZip(entries, warnings) + if err != nil { + t.Fatalf("buildAutomationZip (first): %v", err) + } + second, err := buildAutomationZip(entries, warnings) + if err != nil { + t.Fatalf("buildAutomationZip (second): %v", err) + } + if !bytes.Equal(first, second) { + t.Error("two builds of the same input produced different zip bytes") + } +} + +func TestBuildAutomationZip_EmptyInput_ProducesEmptyZip(t *testing.T) { + data, err := buildAutomationZip(nil, nil) + if err != nil { + t.Fatalf("buildAutomationZip: %v", err) + } + if len(zipEntryNames(t, data)) != 0 { + t.Errorf("got %v, want no entries", zipEntryNames(t, data)) + } +} diff --git a/apps/backend/internal/automation/handlers.go b/apps/backend/internal/automation/handlers.go index 650e29f09f..444a63ead4 100644 --- a/apps/backend/internal/automation/handlers.go +++ b/apps/backend/internal/automation/handlers.go @@ -41,6 +41,10 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log func registerHTTPRoutes(router *gin.Engine, svc *Service, log *logger.Logger) { wh := NewWebhookHandler(svc, log) router.POST("/api/v1/automations/webhook/:id", wh.Handle) + + eh := NewExportHandler(svc, log) + router.GET("/api/v1/workspaces/:id/automations/export", eh.ExportDocument) + router.GET("/api/v1/workspaces/:id/automations/export/zip", eh.ExportZip) } // parseMap parses the WS message payload into a map. diff --git a/apps/backend/internal/automation/service.go b/apps/backend/internal/automation/service.go index 94902be680..abab1d05a7 100644 --- a/apps/backend/internal/automation/service.go +++ b/apps/backend/internal/automation/service.go @@ -72,6 +72,13 @@ type WorkflowLocator interface { WorkflowWorkspaceID(ctx context.Context, workflowID string) (string, error) } +// WorkflowStepLocator verifies that a workflow step belongs to a workflow in +// the same workspace. It is separate from WorkflowLocator because the task and +// workflow services own these two records in different repositories. +type WorkflowStepLocator interface { + WorkflowStepBelongs(ctx context.Context, workspaceID, workflowID, stepID string) (bool, error) +} + // AgentProfileLookup answers whether an agent profile ID resolves to a live // (not soft-deleted) profile row. Satisfied by an adapter over the agent // settings store and injected rather than imported, for the same reason @@ -101,6 +108,9 @@ type Service struct { // workflowLocator gates workflow ownership. Optional: when nil (isolated // tests) ownership is not enforced. workflowLocator WorkflowLocator + // workflowStepLocator gates the relationship between a workflow and its + // selected step. Optional for isolated tests, but wired in production. + workflowStepLocator WorkflowStepLocator // repoLookup validates repository_ids on create/update — every ID must // resolve to a repository belonging to the automation's workspace. Nil @@ -122,6 +132,26 @@ type Service struct { // disabled). Set via SetWorkspaceAuthorizer. authorizeWorkspace func(ctx context.Context, workspaceID string) error + // exportAgentProfileLookup, exportExecutorProfileLookup, + // exportWorkflowLookup, exportWorkflowStepLookup, and + // exportRepositoryLookup resolve an automation's descriptor references + // for the AC-29 YAML export. Unlike the validation-only lookups above, + // nil is not "skip enforcement" — resolveDescriptors fails closed with + // ErrExportLookupUnavailable for any reference it cannot resolve because + // the matching lookup was never wired. + exportAgentProfileLookup ExportAgentProfileLookup + exportExecutorProfileLookup ExportExecutorProfileLookup + exportWorkflowLookup ExportWorkflowLookup + exportWorkflowStepLookup ExportWorkflowStepLookup + exportRepositoryLookup ExportRepositoryLookup + + // exportWorkspaceLookup answers AC-44 step 2's workspace-existence check. + // Nil is a construction error, not "skip enforcement": the endpoint + // cannot honour AC-35 without it, so collectExportAutomations fails + // closed with ErrExportWorkspaceLookupUnavailable rather than falling + // through to a 200 for a workspace that may never have existed. + exportWorkspaceLookup ExportWorkspaceLookup + // runLocks serializes run creation (RecordRun, the concurrency-cap skip // insert) against DeleteAllRuns per automation ID. Without this, a run // created between DeleteAllRuns' task-id snapshot and its final row @@ -156,6 +186,11 @@ func (s *Service) SetWorkflowLocator(l WorkflowLocator) { s.workflowLocator = l } +// SetWorkflowStepLocator wires the workflow-step relationship check. +func (s *Service) SetWorkflowStepLocator(l WorkflowStepLocator) { + s.workflowStepLocator = l +} + // authorizeWorkflowOwnership rejects a workflow belonging to a workspace other // than the one the automation is being saved into. func (s *Service) authorizeWorkflowOwnership(ctx context.Context, workspaceID, workflowID string) error { @@ -172,6 +207,28 @@ func (s *Service) authorizeWorkflowOwnership(ctx context.Context, workspaceID, w return nil } +// authorizeWorkflowStepOwnership rejects a step that is not part of the +// selected workflow. A step without a workflow is never a valid binding. +func (s *Service) authorizeWorkflowStepOwnership(ctx context.Context, workspaceID, workflowID, stepID string) error { + if stepID == "" { + return nil + } + if workflowID == "" { + return fmt.Errorf("workflow step requires a workflow") + } + if s.workflowStepLocator == nil { + return nil + } + belongs, err := s.workflowStepLocator.WorkflowStepBelongs(ctx, workspaceID, workflowID, stepID) + if err != nil { + return fmt.Errorf("resolve workflow step: %w", err) + } + if !belongs { + return fmt.Errorf("workflow step does not belong to this workflow") + } + return nil +} + // SetAgentProfileLookup wires the agent-profile existence check applied to // agent_profile_id on create/update. func (s *Service) SetAgentProfileLookup(l AgentProfileLookup) { @@ -244,6 +301,42 @@ func (s *Service) SetRepositoryLookup(lookup RepositoryLookup) { s.repoLookup = lookup } +// SetExportAgentProfileLookup wires the agent profile resolver the YAML +// export uses to build exportAgentProfile descriptors. +func (s *Service) SetExportAgentProfileLookup(l ExportAgentProfileLookup) { + s.exportAgentProfileLookup = l +} + +// SetExportExecutorProfileLookup wires the executor profile resolver the +// YAML export uses to build exportExecutorProfile descriptors. +func (s *Service) SetExportExecutorProfileLookup(l ExportExecutorProfileLookup) { + s.exportExecutorProfileLookup = l +} + +// SetExportWorkflowLookup wires the workflow resolver the YAML export uses +// to build exportWorkflow descriptors. +func (s *Service) SetExportWorkflowLookup(l ExportWorkflowLookup) { + s.exportWorkflowLookup = l +} + +// SetExportWorkflowStepLookup wires the workflow step resolver the YAML +// export uses to populate exportWorkflow.Step. +func (s *Service) SetExportWorkflowStepLookup(l ExportWorkflowStepLookup) { + s.exportWorkflowStepLookup = l +} + +// SetExportRepositoryLookup wires the repository resolver the YAML export +// uses to resolve repository_ids to names. +func (s *Service) SetExportRepositoryLookup(l ExportRepositoryLookup) { + s.exportRepositoryLookup = l +} + +// SetExportWorkspaceLookup wires the workspace-existence check the YAML +// export uses for AC-44 step 2. +func (s *Service) SetExportWorkspaceLookup(l ExportWorkspaceLookup) { + s.exportWorkspaceLookup = l +} + func (s *Service) authorizeWs(ctx context.Context, workspaceID string) error { if s.authorizeWorkspace == nil { return nil @@ -292,11 +385,14 @@ func (s *Service) CreateAutomation(ctx context.Context, req *CreateAutomationReq } // Workflow + step are optional for every automation: no automation run is - // placed on a board, so no automation needs a starting column. Ownership - // is still enforced when one is supplied. + // placed on a board, so no automation needs a starting column. When a step + // is supplied, it must belong to the selected workflow. if err := s.authorizeWorkflowOwnership(ctx, req.WorkspaceID, req.WorkflowID); err != nil { return nil, err } + if err := s.authorizeWorkflowStepOwnership(ctx, req.WorkspaceID, req.WorkflowID, req.WorkflowStepID); err != nil { + return nil, err + } a := &Automation{ WorkspaceID: req.WorkspaceID, Name: req.Name, @@ -388,11 +484,11 @@ func (s *Service) UpdateAutomation(ctx context.Context, id string, req *UpdateAu } // authorizeUpdatedReferences checks the fields of req that name something the -// automation's workspace must own — its repositories and its workflow. Both -// need the stored automation to learn that workspace, so it is loaded once -// here rather than by each check. +// automation's workspace must own — its repositories, workflow, and workflow +// step. All three need the stored automation to learn that workspace, so it is +// loaded once here rather than by each check. func (s *Service) authorizeUpdatedReferences(ctx context.Context, id string, req *UpdateAutomationRequest) error { - if req.RepositoryIDs == nil && req.WorkflowID == nil { + if req.RepositoryIDs == nil && req.WorkflowID == nil && req.WorkflowStepID == nil { return nil } existing, err := s.store.GetAutomation(ctx, id) @@ -407,10 +503,21 @@ func (s *Service) authorizeUpdatedReferences(ctx context.Context, id string, req return err } } - if req.WorkflowID == nil { + if req.WorkflowID == nil && req.WorkflowStepID == nil { return nil } - return s.authorizeWorkflowOwnership(ctx, existing.WorkspaceID, *req.WorkflowID) + workflowID := existing.WorkflowID + if req.WorkflowID != nil { + workflowID = *req.WorkflowID + } + if err := s.authorizeWorkflowOwnership(ctx, existing.WorkspaceID, workflowID); err != nil { + return err + } + stepID := existing.WorkflowStepID + if req.WorkflowStepID != nil { + stepID = *req.WorkflowStepID + } + return s.authorizeWorkflowStepOwnership(ctx, existing.WorkspaceID, workflowID, stepID) } // validateRepositoryIDs rejects a duplicate entry, or any ID that isn't a diff --git a/apps/backend/internal/automation/service_validation_test.go b/apps/backend/internal/automation/service_validation_test.go index 998ba5ad07..9d89def130 100644 --- a/apps/backend/internal/automation/service_validation_test.go +++ b/apps/backend/internal/automation/service_validation_test.go @@ -53,6 +53,15 @@ func (s stubWorkflowLocator) WorkflowWorkspaceID(context.Context, string) (strin return s.workspaceID, nil } +type stubWorkflowStepLocator struct { + belongs bool + err error +} + +func (s stubWorkflowStepLocator) WorkflowStepBelongs(context.Context, string, string, string) (bool, error) { + return s.belongs, s.err +} + // The editor filtering foreign workflows out of a dropdown is not a boundary — // a request naming one directly has to be refused server-side. func TestCreateAutomation_RejectsAForeignWorkflow(t *testing.T) { @@ -80,6 +89,20 @@ func TestCreateAutomation_AcceptsItsOwnWorkflow(t *testing.T) { } } +func TestCreateAutomation_RejectsAStepFromADifferentWorkflow(t *testing.T) { + svc := newTestService(t) + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-1"}) + svc.SetWorkflowStepLocator(stubWorkflowStepLocator{belongs: false}) + ctx := context.Background() + + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "cross-step", WorkflowID: "wf-local", WorkflowStepID: "step-foreign", + }) + if err == nil { + t.Fatal("expected a workflow step from another workflow to be rejected") + } +} + // The update path has the same boundary as create and is the one a long-lived // automation actually travels: it is edited far more often than it is created. // A cron the scheduler cannot parse was accepted at creation and only rejected @@ -162,6 +185,27 @@ func TestUpdateAutomation_AcceptsItsOwnWorkflow(t *testing.T) { } } +func TestUpdateAutomation_RejectsAStepFromADifferentWorkflow(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "local", WorkflowID: "wf-local", WorkflowStepID: "step-local", + }) + if err != nil { + t.Fatalf("seed automation: %v", err) + } + + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-1"}) + svc.SetWorkflowStepLocator(stubWorkflowStepLocator{belongs: false}) + foreignStep := "step-foreign" + if _, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + WorkflowStepID: &foreignStep, + }); err == nil { + t.Fatal("expected a workflow step from another workflow to be rejected on update") + } +} + // fakeAgentProfileLookup implements AgentProfileLookup: live names the profile // IDs that resolve, err short-circuits every call to simulate a driver // failure, and asked records what was actually looked up so the empty-ID diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index bbf723d7c9..1398a60614 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -229,7 +229,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories automationComponents.Service.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) // A UI filter is not an authorization boundary: reject a workflow owned // by another workspace even when a request names it directly. - automationComponents.Service.SetWorkflowLocator(&automationWorkflowLocatorAdapter{svc: taskSvc}) + automationWorkflowLocator := &automationWorkflowLocatorAdapter{svc: taskSvc, workflows: workflowSvc} + automationComponents.Service.SetWorkflowLocator(automationWorkflowLocator) + automationComponents.Service.SetWorkflowStepLocator(automationWorkflowLocator) automationComponents.Service.SetTaskOriginLookup(&automationTaskOriginLookupAdapter{svc: taskSvc, log: log}) // Profile deletion disables the automations bound to a profile before // the row goes, but nothing ever checked that the binding pointed at a @@ -237,6 +239,16 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories // that never existed produced the same orphan without any delete // involved. automationComponents.Service.SetAgentProfileLookup(&automationAgentProfileLookupAdapter{store: repos.AgentSettings}) + // YAML export descriptor resolution (AC-29): each Set* below is + // satisfied directly by an existing repository's Tx-accepting method, + // so the export's single read transaction can pass straight through + // without an adapter shim. + automationComponents.Service.SetExportAgentProfileLookup(repos.AgentSettings) + automationComponents.Service.SetExportExecutorProfileLookup(repos.Task) + automationComponents.Service.SetExportWorkflowLookup(repos.Task) + automationComponents.Service.SetExportWorkflowStepLookup(repos.Workflow) + automationComponents.Service.SetExportRepositoryLookup(repos.Task) + automationComponents.Service.SetExportWorkspaceLookup(&automationExportWorkspaceLookupAdapter{svc: taskSvc}) } services := &Services{ diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 4646b165fe..d3314663ea 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -175,7 +175,8 @@ func (a *taskDeleterAdapter) translateDeleteErr(err error) error { // workflow belongs to the workspace an automation is saved into, without the // automation package importing the task service. type automationWorkflowLocatorAdapter struct { - svc *taskservice.Service + svc *taskservice.Service + workflows *workflowservice.Service } func (a *automationWorkflowLocatorAdapter) WorkflowWorkspaceID(ctx context.Context, workflowID string) (string, error) { @@ -189,6 +190,27 @@ func (a *automationWorkflowLocatorAdapter) WorkflowWorkspaceID(ctx context.Conte return wf.WorkspaceID, nil } +// WorkflowStepBelongs satisfies automation.WorkflowStepLocator. Workflow +// ownership and step ownership are checked together so a crafted automation +// request cannot pair a valid workflow with a step from another workflow. +func (a *automationWorkflowLocatorAdapter) WorkflowStepBelongs(ctx context.Context, workspaceID, workflowID, stepID string) (bool, error) { + wf, err := a.svc.GetWorkflow(ctx, workflowID) + if err != nil { + return false, err + } + if wf == nil || wf.WorkspaceID != workspaceID { + return false, nil + } + if a.workflows == nil { + return false, fmt.Errorf("workflow step lookup is unavailable") + } + step, err := a.workflows.GetStep(ctx, stepID) + if err != nil { + return false, err + } + return step != nil && step.WorkflowID == workflowID, nil +} + // automationAgentProfileLookupAdapter satisfies automation.AgentProfileLookup // over the agent settings store, so the automation service can refuse a // binding to a profile that isn't there without importing the settings @@ -258,6 +280,21 @@ func (a *automationTaskOriginLookupAdapter) TaskWorkspaceAndAutomationOrigin(ctx return task.WorkspaceID, task.Origin == models.TaskOriginAutomationRun, true } +// automationExportWorkspaceLookupAdapter satisfies automation.ExportWorkspaceLookup +// over the task service's GetWorkspace, which already returns +// repoerrors.ErrWorkspaceNotFound (wrapped) both for a missing row and for a +// workspace the caller's scoped identity cannot see - exactly the +// errors.Is-classifiable shape AC-44 step 2 needs, with no translation +// required. +type automationExportWorkspaceLookupAdapter struct { + svc *taskservice.Service +} + +func (a *automationExportWorkspaceLookupAdapter) WorkspaceExists(ctx context.Context, workspaceID string) error { + _, err := a.svc.GetWorkspace(ctx, workspaceID) + return err +} + // repositoryLookupAdapter satisfies the linear/jira/sentry RepositoryLookup // interface over the task service. It is the validation seam for a watcher's // optional repository binding. The task service's GetRepository filters diff --git a/apps/backend/internal/task/repository/sqlite/export_lookup.go b/apps/backend/internal/task/repository/sqlite/export_lookup.go new file mode 100644 index 0000000000..57c9e6668e --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/export_lookup.go @@ -0,0 +1,93 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" +) + +// GetWorkflowTx, GetRepositoryTx, and GetExecutorProfileTx are the +// transaction-accepting counterparts to GetWorkflow/GetRepository/ +// GetExecutorProfile that the automations YAML export (AC-29) needs: the +// export service opens one read transaction on the shared reader pool and +// passes it through every store's lookup, so all reads observe the same +// snapshot. Unlike the plain methods, a missing row is not an error - the +// export's own AC-19 partial-resolution rule decides what a missing +// reference means, so these report a three-outcome (value, found, err) +// result instead. + +// GetWorkflowTx retrieves a workflow by ID within tx. +func (r *Repository) GetWorkflowTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.Workflow, bool, error) { + workflow, err := scanWorkflowRow(tx.QueryRowContext(ctx, tx.Rebind(fmt.Sprintf(` + SELECT %s + FROM workflows WHERE id = ? + `, workflowSelectColumns)), id)) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return workflow, true, nil +} + +// GetRepositoryTx retrieves a repository by ID within tx. It does not attach +// secret bindings - the export never renders them (AC-19's secrets carve-out). +func (r *Repository) GetRepositoryTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.Repository, bool, error) { + repository := &models.Repository{} + + err := tx.QueryRowContext(ctx, tx.Rebind(` + SELECT id, workspace_id, name, source_type, local_path, provider, provider_repo_id, provider_host, provider_scope, provider_owner, + provider_name, remote_url, default_branch, worktree_branch_prefix, worktree_branch_template, pull_before_worktree, setup_script, cleanup_script, dev_script, copy_files, created_at, updated_at, deleted_at + FROM repositories WHERE id = ? AND deleted_at IS NULL + `), id).Scan( + &repository.ID, &repository.WorkspaceID, &repository.Name, &repository.SourceType, &repository.LocalPath, + &repository.Provider, &repository.ProviderRepoID, &repository.ProviderHost, &repository.ProviderScope, &repository.ProviderOwner, &repository.ProviderName, &repository.RemoteURL, + &repository.DefaultBranch, &repository.WorktreeBranchPrefix, &repository.WorktreeBranchTemplate, &repository.PullBeforeWorktree, &repository.SetupScript, &repository.CleanupScript, &repository.DevScript, &repository.CopyFiles, &repository.CreatedAt, &repository.UpdatedAt, &repository.DeletedAt, + ) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return repository, true, nil +} + +// GetExecutorProfileTx retrieves an executor profile by ID within tx. +func (r *Repository) GetExecutorProfileTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.ExecutorProfile, bool, error) { + profile := &models.ExecutorProfile{} + var configJSON string + var envVarsJSON sql.NullString + + err := tx.QueryRowContext(ctx, tx.Rebind(` + SELECT id, executor_id, name, mcp_policy, config, prepare_script, cleanup_script, env_vars, created_at, updated_at + FROM executor_profiles WHERE id = ? + `), id).Scan( + &profile.ID, &profile.ExecutorID, &profile.Name, &profile.McpPolicy, + &configJSON, &profile.PrepareScript, &profile.CleanupScript, &envVarsJSON, &profile.CreatedAt, &profile.UpdatedAt, + ) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + + if configJSON != "" && configJSON != "{}" { + if err := json.Unmarshal([]byte(configJSON), &profile.Config); err != nil { + return nil, false, fmt.Errorf("failed to deserialize profile config: %w", err) + } + } + if envVarsJSON.Valid && envVarsJSON.String != "" && envVarsJSON.String != jsonNull { + if err := json.Unmarshal([]byte(envVarsJSON.String), &profile.EnvVars); err != nil { + return nil, false, fmt.Errorf("failed to deserialize profile env_vars: %w", err) + } + } + return profile, true, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/export_lookup_test.go b/apps/backend/internal/task/repository/sqlite/export_lookup_test.go new file mode 100644 index 0000000000..64154d0352 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/export_lookup_test.go @@ -0,0 +1,127 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" +) + +// AC-29: automations export needs tx-accepting reads of workflows, +// repositories, and executor_profiles so all four store reads share one +// snapshot. These mirror GetWorkflow/GetRepository/GetExecutorProfile but +// accept the caller's *sqlx.Tx instead of opening their own, and report a +// three-outcome (value, found, err) result instead of a not-found error - +// AC-19 needs to tell "row missing" apart from "lookup failed" without +// string-matching an error. + +func TestGetWorkflowTx_FoundAndMissing(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-1") + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Kanban"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + + tx, err := repo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + workflow, found, err := repo.GetWorkflowTx(ctx, tx, "wf-1") + if err != nil { + t.Fatalf("GetWorkflowTx(wf-1): %v", err) + } + if !found { + t.Fatal("found = false, want true") + } + if workflow == nil || workflow.ID != "wf-1" { + t.Errorf("workflow = %+v, want ID wf-1", workflow) + } + + missing, found, err := repo.GetWorkflowTx(ctx, tx, "does-not-exist") + if err != nil { + t.Fatalf("GetWorkflowTx(missing): %v", err) + } + if found { + t.Error("found = true, want false") + } + if missing != nil { + t.Errorf("workflow = %+v, want nil", missing) + } +} + +func TestGetRepositoryTx_FoundAndMissing(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-1") + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "kandev"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + + tx, err := repo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + got, found, err := repo.GetRepositoryTx(ctx, tx, "repo-1") + if err != nil { + t.Fatalf("GetRepositoryTx(repo-1): %v", err) + } + if !found { + t.Fatal("found = false, want true") + } + if got == nil || got.ID != "repo-1" { + t.Errorf("repository = %+v, want ID repo-1", got) + } + + missing, found, err := repo.GetRepositoryTx(ctx, tx, "does-not-exist") + if err != nil { + t.Fatalf("GetRepositoryTx(missing): %v", err) + } + if found { + t.Error("found = true, want false") + } + if missing != nil { + t.Errorf("repository = %+v, want nil", missing) + } +} + +func TestGetExecutorProfileTx_FoundAndMissing(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedExecutorForProfiles(t, repo, "exec-1") + if err := repo.CreateExecutorProfile(ctx, &models.ExecutorProfile{ID: "profile-1", ExecutorID: "exec-1", Name: "Worktree"}); err != nil { + t.Fatalf("CreateExecutorProfile: %v", err) + } + + tx, err := repo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + got, found, err := repo.GetExecutorProfileTx(ctx, tx, "profile-1") + if err != nil { + t.Fatalf("GetExecutorProfileTx(profile-1): %v", err) + } + if !found { + t.Fatal("found = false, want true") + } + if got == nil || got.ID != "profile-1" { + t.Errorf("profile = %+v, want ID profile-1", got) + } + + missing, found, err := repo.GetExecutorProfileTx(ctx, tx, "does-not-exist") + if err != nil { + t.Fatalf("GetExecutorProfileTx(missing): %v", err) + } + if found { + t.Error("found = true, want false") + } + if missing != nil { + t.Errorf("profile = %+v, want nil", missing) + } +} diff --git a/apps/backend/internal/workflow/repository/export_lookup.go b/apps/backend/internal/workflow/repository/export_lookup.go new file mode 100644 index 0000000000..6261777253 --- /dev/null +++ b/apps/backend/internal/workflow/repository/export_lookup.go @@ -0,0 +1,32 @@ +package repository + +import ( + "context" + "database/sql" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/workflow/models" +) + +// GetStepTx is GetStep's transaction-accepting counterpart: the automations +// YAML export (AC-29) opens one read transaction spanning several stores and +// passes it through here rather than letting this method open its own, so +// the step read observes the same snapshot as every other read in the +// export. A missing row is reported as found=false rather than an error - +// AC-19's partial-resolution rule decides what that means, not this method. +func (r *Repository) GetStepTx(ctx context.Context, tx *sqlx.Tx, id string) (*models.WorkflowStep, bool, error) { + row := tx.QueryRowContext(ctx, tx.Rebind(` + SELECT `+stepSelectColumns+` + FROM workflow_steps WHERE id = ? + `), id) + + step, err := r.scanStep(row) + if err == sql.ErrNoRows { + return nil, false, nil + } + if err != nil { + return nil, false, err + } + return step, true, nil +} diff --git a/apps/backend/internal/workflow/repository/export_lookup_test.go b/apps/backend/internal/workflow/repository/export_lookup_test.go new file mode 100644 index 0000000000..d75cd7b878 --- /dev/null +++ b/apps/backend/internal/workflow/repository/export_lookup_test.go @@ -0,0 +1,52 @@ +package repository + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/workflow/models" +) + +// AC-29: the automations YAML export opens one read transaction spanning +// several stores. GetStepTx is GetStep's counterpart that accepts the +// caller's *sqlx.Tx instead of opening its own, and reports a three-outcome +// (value, found, err) result so a missing step is distinguishable from a +// lookup failure without string-matching an error. + +func TestGetStepTx_FoundAndMissing(t *testing.T) { + repo := setupTestRepo(t) + ctx := context.Background() + + step := &models.WorkflowStep{WorkflowID: "wf-test", Name: "In Progress", Position: 0} + if err := repo.CreateStep(ctx, step); err != nil { + t.Fatalf("CreateStep: %v", err) + } + + tx, err := repo.ro.BeginTxx(ctx, nil) + if err != nil { + t.Fatalf("BeginTxx: %v", err) + } + defer func() { _ = tx.Rollback() }() + + got, found, err := repo.GetStepTx(ctx, tx, step.ID) + if err != nil { + t.Fatalf("GetStepTx(%s): %v", step.ID, err) + } + if !found { + t.Fatal("found = false, want true") + } + if got == nil || got.ID != step.ID { + t.Errorf("step = %+v, want ID %s", got, step.ID) + } + + missing, found, err := repo.GetStepTx(ctx, tx, "does-not-exist") + if err != nil { + t.Fatalf("GetStepTx(missing): %v", err) + } + if found { + t.Error("found = true, want false") + } + if missing != nil { + t.Errorf("step = %+v, want nil", missing) + } +} diff --git a/apps/web/components/automations/automations-export-button.test.tsx b/apps/web/components/automations/automations-export-button.test.tsx new file mode 100644 index 0000000000..ce27a659cb --- /dev/null +++ b/apps/web/components/automations/automations-export-button.test.tsx @@ -0,0 +1,102 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mockExportAutomationsZip = vi.fn(); +vi.mock("@/lib/api/domains/automations-export-api", () => ({ + exportAutomationsZip: (...args: unknown[]) => mockExportAutomationsZip(...args), +})); + +const mockTriggerBlobDownload = vi.fn(); +vi.mock("@/lib/utils/file-download", () => ({ + triggerBlobDownload: (...args: unknown[]) => mockTriggerBlobDownload(...args), +})); + +const mockToastError = vi.fn(); +vi.mock("@/lib/toast/sonner", () => ({ + toast: { + error: (...args: unknown[]) => mockToastError(...args), + success: vi.fn(), + info: vi.fn(), + }, +})); + +// window.open / anchor href navigation are the pinned anti-pattern (AC-37); a +// call to either here would mean the control regressed to Office's mechanism. +const windowOpenSpy = vi.spyOn(window, "open").mockImplementation(() => null); + +import { AutomationsExportButton } from "./automations-export-button"; + +const BUTTON_TEST_ID = "export-automations-button"; + +function getButton(): HTMLButtonElement { + return screen.getByTestId(BUTTON_TEST_ID) as HTMLButtonElement; +} + +function deferred() { + let resolve!: (value: T) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +beforeEach(() => { + mockExportAutomationsZip.mockReset(); + mockTriggerBlobDownload.mockReset(); + mockToastError.mockReset(); + windowOpenSpy.mockClear(); +}); + +afterEach(() => { + cleanup(); +}); + +describe("AutomationsExportButton", () => { + it("is enabled up front, including for a workspace with no automations (AC-32/AC-37)", () => { + render(); + + expect(getButton().disabled).toBe(false); + }); + + it("fetches the zip and triggers a download named kandev-automations.zip on success", async () => { + const blob = new Blob(["zip-bytes"], { type: "application/zip" }); + mockExportAutomationsZip.mockResolvedValue(blob); + + render(); + fireEvent.click(getButton()); + + await waitFor(() => + expect(mockTriggerBlobDownload).toHaveBeenCalledWith(blob, "kandev-automations.zip"), + ); + expect(mockExportAutomationsZip).toHaveBeenCalledWith("ws-1"); + expect(windowOpenSpy).not.toHaveBeenCalled(); + }); + + it("disables the control from the moment the request is issued until it resolves", async () => { + const pending = deferred(); + mockExportAutomationsZip.mockReturnValue(pending.promise); + + render(); + const button = getButton(); + fireEvent.click(button); + + await waitFor(() => expect(button.disabled).toBe(true)); + + pending.resolve(new Blob(["zip-bytes"])); + await waitFor(() => expect(button.disabled).toBe(false)); + }); + + it("surfaces a non-blocking error and re-enables the control on a non-200 response", async () => { + mockExportAutomationsZip.mockRejectedValue(new Error("workspace not found")); + + render(); + const button = getButton(); + fireEvent.click(button); + + await waitFor(() => expect(mockToastError).toHaveBeenCalledWith("workspace not found")); + expect(button.disabled).toBe(false); + expect(mockTriggerBlobDownload).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/components/automations/automations-export-button.tsx b/apps/web/components/automations/automations-export-button.tsx new file mode 100644 index 0000000000..41e21c40a9 --- /dev/null +++ b/apps/web/components/automations/automations-export-button.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconDownload } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { toast } from "@/lib/toast/sonner"; +import { exportAutomationsZip } from "@/lib/api/domains/automations-export-api"; +import { triggerBlobDownload } from "@/lib/utils/file-download"; + +// i18n-exempt: fixed download file name, not user-facing copy. +const EXPORT_FILE_NAME = "kandev-automations.zip"; + +type AutomationsExportButtonProps = { + workspaceId: string; +}; + +/** + * AC-37: fetches the workspace's automations as a zip and hands it to the + * browser via an object-URL download. Deliberately does not use `window.open` + * or an anchor `href` navigation to the export endpoint directly — that would + * give no completion signal to disable on and no access to the response + * status to detect a failure with. + */ +export function AutomationsExportButton({ workspaceId }: AutomationsExportButtonProps) { + const { t } = useTranslation(); + const [downloading, setDownloading] = useState(false); + + const handleExport = async () => { + setDownloading(true); + try { + const blob = await exportAutomationsZip(workspaceId); + triggerBlobDownload(blob, EXPORT_FILE_NAME); + } catch (error) { + toast.error(error instanceof Error ? error.message : t("automations:exportFailed")); + } finally { + setDownloading(false); + } + }; + + return ( + + ); +} diff --git a/apps/web/components/automations/automations-list-page.tsx b/apps/web/components/automations/automations-list-page.tsx index 9d896b2cd2..2a6bd28639 100644 --- a/apps/web/components/automations/automations-list-page.tsx +++ b/apps/web/components/automations/automations-list-page.tsx @@ -9,6 +9,7 @@ import { toast } from "@/lib/toast/sonner"; import { useAutomations } from "@/hooks/domains/settings/use-automations"; import { AutomationsTable } from "./automations-table"; import { AutomationBoardMoveNotice } from "./board-move-notice"; +import { AutomationsExportButton } from "./automations-export-button"; import { useAutomationEnabledDrafts } from "./use-automation-enabled-drafts"; import { WorkspaceSectionHeader } from "@/components/settings/workspaces/workspace-section-header"; @@ -52,16 +53,19 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) { tab="automations" description={t("automations:listDescription")} action={ - +
+ + +
} /> diff --git a/apps/web/e2e/pages/automations-page.ts b/apps/web/e2e/pages/automations-page.ts index a0d74f9a8c..6b42afdd63 100644 --- a/apps/web/e2e/pages/automations-page.ts +++ b/apps/web/e2e/pages/automations-page.ts @@ -3,6 +3,7 @@ import { type Locator, type Page } from "@playwright/test"; export class AutomationsPage { readonly listPage: Locator; readonly newAutomationButton: Locator; + readonly exportButton: Locator; readonly table: Locator; readonly emptyState: Locator; readonly editor: Locator; @@ -24,6 +25,7 @@ export class AutomationsPage { ) { this.listPage = page.getByTestId("automations-list-page"); this.newAutomationButton = page.getByTestId("new-automation-button"); + this.exportButton = page.getByTestId("export-automations-button"); this.table = page.getByTestId("automations-table"); this.emptyState = page.getByTestId("automations-empty"); this.editor = page.getByTestId("automation-editor"); diff --git a/apps/web/e2e/tests/automations-export.spec.ts b/apps/web/e2e/tests/automations-export.spec.ts new file mode 100644 index 0000000000..c54f929a2d --- /dev/null +++ b/apps/web/e2e/tests/automations-export.spec.ts @@ -0,0 +1,143 @@ +import path from "node:path"; +import fs from "node:fs"; +import { execFileSync } from "node:child_process"; +import { test, expect } from "../fixtures/test-base"; +import { AutomationsPage } from "../pages/automations-page"; +import { injectLatency } from "../helpers/causal-waits"; + +// AC-37/38: AutomationsExportButton (components/automations/automations-export-button.tsx) +// fetches the workspace's zip export, hands it to the browser as a Blob via a +// temporary anchor, and never navigates the endpoint directly. +// That anchor click is a real download, so Playwright's "download" event is +// the correct signal — the same pattern file-tree-download.spec.ts uses. + +const EXPORT_ROUTE = "**/automations/export/zip"; + +test.describe("Automations export", () => { + test("empty workspace still offers an enabled control that downloads a valid archive", async ({ + testPage, + seedData, + backend, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.goto(); + await expect(automations.emptyState).toBeVisible({ timeout: 10_000 }); + + // AC-32/AC-37: no automations is a valid export, not an error — the + // control stays enabled rather than being disabled for an empty workspace. + await expect(automations.exportButton).toBeEnabled(); + + const downloadPromise = testPage.waitForEvent("download"); + await automations.exportButton.click(); + const download = await downloadPromise; + + // The filename comes from the object URL's `download` attribute, not + // Content-Disposition — AC-37 pins this because a raw navigation would + // only expose the header form. + expect(download.suggestedFilename()).toBe("kandev-automations.zip"); + + const savedPath = path.join(backend.tmpDir, `export-empty-${Date.now()}.zip`); + await download.saveAs(savedPath); + + // A zero-entry zip is still a valid archive: it is exactly the 22-byte + // End Of Central Directory record, signature `PK\x05\x06`. `unzip -l` + // exits non-zero and prints a "zipfile is empty" warning for this shape + // (verified against the real `archive/zip` writer), so it is not a usable + // validity signal here — check the archive's own magic bytes instead. + const bytes = fs.readFileSync(savedPath); + expect(bytes.length).toBe(22); + expect(bytes.subarray(0, 4).toString("hex")).toBe("504b0506"); + }); + + test("workspace with automations exports one entry per automation", async ({ + testPage, + seedData, + apiClient, + backend, + }) => { + await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Export Me One", + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + }); + await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Export Me Two", + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + }); + + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.goto(); + await expect(automations.table).toBeVisible({ timeout: 10_000 }); + + const downloadPromise = testPage.waitForEvent("download"); + await automations.exportButton.click(); + const download = await downloadPromise; + + const savedPath = path.join(backend.tmpDir, `export-two-${Date.now()}.zip`); + await download.saveAs(savedPath); + const listing = execFileSync("unzip", ["-l", savedPath], { encoding: "utf8" }); + + // One .kandev/automations/.yml entry per automation (AC-24/25). + const entryLines = listing + .split("\n") + .filter((line) => line.includes(".kandev/automations/") && line.endsWith(".yml")); + expect(entryLines).toHaveLength(2); + }); + + test("control disables for the whole request span and re-enables once the download hands off", async ({ + testPage, + seedData, + backend, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.goto(); + await expect(automations.emptyState).toBeVisible({ timeout: 10_000 }); + + // The delay is the stimulus that makes the in-flight disabled state + // observable, not a synchronization wait — real latency injection per + // e2e/helpers/causal-waits.ts. + await testPage.route(EXPORT_ROUTE, async (route) => { + await injectLatency(500, "make the export control's disabled-in-flight window observable"); + await route.continue(); + }); + + const downloadPromise = testPage.waitForEvent("download"); + await automations.exportButton.click(); + await expect(automations.exportButton).toBeDisabled(); + + const download = await downloadPromise; + await download.saveAs(path.join(backend.tmpDir, `export-inflight-${Date.now()}.zip`)); + + await expect(automations.exportButton).toBeEnabled(); + }); + + test("surfaces a non-blocking error and leaves the list page unchanged on a non-200 response", async ({ + testPage, + seedData, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.goto(); + await expect(automations.emptyState).toBeVisible({ timeout: 10_000 }); + + await testPage.route(EXPORT_ROUTE, async (route) => { + await route.fulfill({ + status: 500, + contentType: "application/json", + body: JSON.stringify({ error: "simulated export failure" }), + }); + }); + + await automations.exportButton.click(); + + await expect(testPage.locator("[data-sonner-toast]")).toContainText("simulated export failure"); + + // "Leaving the page otherwise unchanged" per AC-37: still on the listings + // page, still showing the empty state, control re-enabled for a retry. + await expect(automations.listPage).toBeVisible(); + await expect(automations.emptyState).toBeVisible(); + await expect(automations.exportButton).toBeEnabled(); + }); +}); diff --git a/apps/web/e2e/tests/mobile-automations-export.spec.ts b/apps/web/e2e/tests/mobile-automations-export.spec.ts new file mode 100644 index 0000000000..06c38b8be1 --- /dev/null +++ b/apps/web/e2e/tests/mobile-automations-export.spec.ts @@ -0,0 +1,33 @@ +import path from "node:path"; +import { test, expect } from "../fixtures/test-base"; +import { AutomationsPage } from "../pages/automations-page"; + +// AC-37/38: mirrors the desktop happy-path coverage in +// automations-export.spec.ts, but taps the control on a coarse-pointer +// viewport and asserts the touch target itself — the button's mobile +// `min-h-11` class (components/automations/automations-export-button.tsx) +// must resolve to a real ~44px tap target, not just look right in markup. + +test.describe("Automations export on mobile", () => { + test("export control meets the mobile touch target and downloads on tap", async ({ + testPage, + seedData, + backend, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.goto(); + await expect(automations.emptyState).toBeVisible({ timeout: 10_000 }); + await expect(automations.exportButton).toBeEnabled(); + + const box = await automations.exportButton.boundingBox(); + expect(box).not.toBeNull(); + expect(box!.height).toBeGreaterThanOrEqual(44); + + const downloadPromise = testPage.waitForEvent("download"); + await automations.exportButton.tap(); + const download = await downloadPromise; + + expect(download.suggestedFilename()).toBe("kandev-automations.zip"); + await download.saveAs(path.join(backend.tmpDir, `export-mobile-${Date.now()}.zip`)); + }); +}); diff --git a/apps/web/lib/api/client.test.ts b/apps/web/lib/api/client.test.ts index cf72876705..b6cc8f76fb 100644 --- a/apps/web/lib/api/client.test.ts +++ b/apps/web/lib/api/client.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; -import { ApiError, fetchJson, setOnUnauthorized } from "./client"; +import { ApiError, fetchBlob, fetchJson, setOnUnauthorized } from "./client"; const interlockToken = "replayable-per-boot-value"; +const BACKEND_URL = "http://backend.test"; describe("fetchJson", () => { afterEach(() => { @@ -18,11 +19,11 @@ describe("fetchJson", () => { vi.stubGlobal("fetch", fetcher); await fetchJson("/api/v1/agents", { - baseUrl: "http://backend.test", + baseUrl: BACKEND_URL, init: { method: "POST", body: "{}" }, }); - expect(fetcher).toHaveBeenCalledWith("http://backend.test/api/v1/agents", expect.any(Object)); + expect(fetcher).toHaveBeenCalledWith(`${BACKEND_URL}/api/v1/agents`, expect.any(Object)); expect( new Headers(fetcher.mock.calls[0][1]?.headers).get("X-Kandev-Interim-Settings-Interlock"), ).toBe(interlockToken); @@ -36,7 +37,7 @@ describe("fetchJson", () => { vi.stubGlobal("fetch", fetcher); await fetchJson("/api/v1/agents", { - baseUrl: "http://backend.test", + baseUrl: BACKEND_URL, init: { method: "POST", body: "{}", @@ -108,3 +109,66 @@ describe("fetchJson", () => { expect(unauthorized).not.toHaveBeenCalled(); }); }); + +describe("fetchBlob", () => { + afterEach(() => { + setOnUnauthorized(null); + vi.unstubAllGlobals(); + }); + + it("returns the response body as a Blob on success", async () => { + const body = new Blob(["zip-bytes"], { type: "application/zip" }); + const fetcher = vi.fn().mockResolvedValue(new Response(body, { status: 200 })); + vi.stubGlobal("fetch", fetcher); + + const result = await fetchBlob("/api/v1/workspaces/ws-1/automations/export/zip", { + baseUrl: BACKEND_URL, + }); + + expect(fetcher).toHaveBeenCalledWith( + `${BACKEND_URL}/api/v1/workspaces/ws-1/automations/export/zip`, + expect.any(Object), + ); + expect(result).toBeInstanceOf(Blob); + expect(await result.text()).toBe("zip-bytes"); + }); + + it("throws ApiError and never returns a Blob on a non-2xx status", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "workspace not found" }), { + status: 404, + headers: { "Content-Type": "application/json" }, + }), + ), + ); + + await expect( + fetchBlob("/api/v1/workspaces/ws-missing/automations/export/zip", { + baseUrl: BACKEND_URL, + }), + ).rejects.toMatchObject({ name: "ApiError", status: 404, message: "workspace not found" }); + }); + + it("notifies the app for a Kandev session challenge, like fetchJson", async () => { + const unauthorized = vi.fn(); + setOnUnauthorized(unauthorized); + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + new Response(JSON.stringify({ error: "authentication required" }), { + status: 401, + headers: { "Content-Type": "application/json", "WWW-Authenticate": "Bearer" }, + }), + ), + ); + + await expect( + fetchBlob("/api/v1/workspaces/ws-1/automations/export/zip", { + baseUrl: BACKEND_URL, + }), + ).rejects.toBeInstanceOf(ApiError); + expect(unauthorized).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/lib/api/client.ts b/apps/web/lib/api/client.ts index 2ce7998d27..376797f39a 100644 --- a/apps/web/lib/api/client.ts +++ b/apps/web/lib/api/client.ts @@ -90,6 +90,29 @@ export async function fetchJson(pathOrUrl: string, options?: ApiRequestOption return JSON.parse(text) as T; } +/** + * Fetches a binary response body (e.g. a zip download) as a Blob. Unlike + * fetchJson, no Content-Type is forced on the request and the body is never + * parsed as JSON — the caller consumes raw bytes. Non-2xx responses reject + * with the same ApiError classification fetchJson uses, so callers get one + * error shape to handle regardless of which fetch helper they used. + */ +export async function fetchBlob(pathOrUrl: string, options?: ApiRequestOptions): Promise { + const baseUrl = options?.baseUrl ?? getBackendConfig().apiBaseUrl; + const url = resolveUrl(pathOrUrl, baseUrl); + const response = await fetch(url, { + ...options?.init, + cache: options?.cache, + credentials: "include", + headers: requestHeaders(options?.init?.headers), + }); + if (!response.ok) { + if (response.status === 401 && isKandevAuthChallenge(response)) onUnauthorized?.(); + await throwFromResponse(response); + } + return response.blob(); +} + // buildRequestHeaders assembles the JSON content-type header plus the // interim-settings interlock token on mutating requests. function buildRequestHeaders(options?: ApiRequestOptions): Headers { diff --git a/apps/web/lib/api/domains/automations-export-api.test.ts b/apps/web/lib/api/domains/automations-export-api.test.ts new file mode 100644 index 0000000000..e3df449948 --- /dev/null +++ b/apps/web/lib/api/domains/automations-export-api.test.ts @@ -0,0 +1,24 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFetchBlob = vi.fn(); +vi.mock("../client", () => ({ + fetchBlob: (...args: unknown[]) => mockFetchBlob(...args), +})); + +import { exportAutomationsZip } from "./automations-export-api"; + +beforeEach(() => { + mockFetchBlob.mockReset(); +}); + +describe("exportAutomationsZip", () => { + it("fetches the workspace's zip export endpoint", async () => { + const blob = new Blob(["zip-bytes"], { type: "application/zip" }); + mockFetchBlob.mockResolvedValue(blob); + + const result = await exportAutomationsZip("ws-1"); + + expect(mockFetchBlob).toHaveBeenCalledWith("/api/v1/workspaces/ws-1/automations/export/zip"); + expect(result).toBe(blob); + }); +}); diff --git a/apps/web/lib/api/domains/automations-export-api.ts b/apps/web/lib/api/domains/automations-export-api.ts new file mode 100644 index 0000000000..d1bdaa7da8 --- /dev/null +++ b/apps/web/lib/api/domains/automations-export-api.ts @@ -0,0 +1,8 @@ +import { fetchBlob } from "../client"; + +const BASE = (workspaceId: string) => `/api/v1/workspaces/${workspaceId}/automations/export`; + +/** Fetches the workspace's automations as a zip archive (one file per automation). */ +export async function exportAutomationsZip(workspaceId: string): Promise { + return fetchBlob(`${BASE(workspaceId)}/zip`); +} diff --git a/apps/web/lib/utils/file-download.test.ts b/apps/web/lib/utils/file-download.test.ts index ab3ea09968..812e7e8ad0 100644 --- a/apps/web/lib/utils/file-download.test.ts +++ b/apps/web/lib/utils/file-download.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { triggerFileDownload } from "./file-download"; +import { triggerBlobDownload, triggerFileDownload } from "./file-download"; const createObjectURLMock = vi.fn((_blob: Blob): string => "blob:mock"); const revokeObjectURLMock = vi.fn((_url: string): void => undefined); @@ -71,3 +71,17 @@ describe("triggerFileDownload", () => { expect(download()).toBe("notes.md"); }); }); + +describe("triggerBlobDownload", () => { + it("downloads an already-built blob under the exact given file name", () => { + const { click, download } = spyAnchorClick(); + const blob = new Blob(["zip-bytes"], { type: "application/zip" }); + + triggerBlobDownload(blob, "kandev-automations.zip"); + + expect(createObjectURLMock).toHaveBeenCalledWith(blob); + expect(click).toHaveBeenCalledTimes(1); + expect(download()).toBe("kandev-automations.zip"); + expect(revokeObjectURLMock).toHaveBeenCalledWith("blob:mock"); + }); +}); diff --git a/apps/web/lib/utils/file-download.ts b/apps/web/lib/utils/file-download.ts index f1ee3eb48d..577a57e149 100644 --- a/apps/web/lib/utils/file-download.ts +++ b/apps/web/lib/utils/file-download.ts @@ -18,6 +18,20 @@ function base64ToArrayBuffer(base64: string): ArrayBuffer { return buffer; } +function downloadBlobViaAnchor(blob: Blob, fileName: string): void { + const url = URL.createObjectURL(blob); + try { + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + } finally { + URL.revokeObjectURL(url); + } +} + /** * Trigger a browser download for the given file content by creating a Blob * and clicking a temporary anchor. Binary content is expected to be @@ -31,16 +45,15 @@ export function triggerFileDownload({ const blob = isBinary ? new Blob([base64ToArrayBuffer(content)], { type: "application/octet-stream" }) : new Blob([content], { type: "text/plain;charset=utf-8" }); - - const url = URL.createObjectURL(blob); - try { - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = fileBasename(fileName); - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - } finally { - URL.revokeObjectURL(url); - } + downloadBlobViaAnchor(blob, fileBasename(fileName)); +} + +/** + * Trigger a browser download for a Blob already in hand (e.g. a fetch + * response body), under an explicit file name. Unlike triggerFileDownload, + * the name is used as-is rather than reduced to a basename, since callers + * pass a download name directly rather than a source path. + */ +export function triggerBlobDownload(blob: Blob, fileName: string): void { + downloadBlobViaAnchor(blob, fileName); } diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json index 140cc7b0d4..a659336079 100644 --- a/apps/web/src/locales/en/automations.json +++ b/apps/web/src/locales/en/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "Exclude draft PRs", "executorProfileLabel": "Executor Profile", "executorProfilePlaceholder": "Select executor", + "exportFailed": "Could not export automations", + "exportZip": "Export", "failedToDeleteAutomation": "Failed to delete automation", "failedToDeleteRun": "Failed to delete run", "failedToDeleteRuns": "Failed to delete runs", diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json index bcfa3cf62b..eb1d542dfd 100644 --- a/apps/web/src/locales/pseudo/automations.json +++ b/apps/web/src/locales/pseudo/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "Ēxćĺũďē ďŕàƒţ ƤŔś", "executorProfileLabel": "Ēxēćũţōŕ Ƥŕōƒĩĺē", "executorProfilePlaceholder": "Śēĺēćţ ēxēćũţōŕ", + "exportFailed": "Ćōũĺď ńōţ ēxƥōŕţ àũţōḿàţĩōńś", + "exportZip": "Ēxƥōŕţ", "failedToDeleteAutomation": "Ƒàĩĺēď ţō ďēĺēţē àũţōḿàţĩōń", "failedToDeleteRun": "Ƒàĩĺēď ţō ďēĺēţē ŕũń", "failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś", diff --git a/apps/web/src/locales/pt-pt/automations.json b/apps/web/src/locales/pt-pt/automations.json index f273ffe513..fc748a539c 100644 --- a/apps/web/src/locales/pt-pt/automations.json +++ b/apps/web/src/locales/pt-pt/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "Excluir PRs em rascunho", "executorProfileLabel": "Perfil de executor", "executorProfilePlaceholder": "Selecionar executor", + "exportFailed": "Não foi possível exportar as automatizações", + "exportZip": "Exportar", "failedToDeleteAutomation": "Não foi possível eliminar a automatização", "failedToDeleteRun": "Não foi possível eliminar a execução", "failedToDeleteRuns": "Não foi possível eliminar as execuções", diff --git a/apps/web/src/locales/zh-cn/automations.json b/apps/web/src/locales/zh-cn/automations.json index 2acc745288..edbbed0bf0 100644 --- a/apps/web/src/locales/zh-cn/automations.json +++ b/apps/web/src/locales/zh-cn/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "排除草稿 PR", "executorProfileLabel": "执行器配置", "executorProfilePlaceholder": "选择执行器", + "exportFailed": "无法导出自动化", + "exportZip": "导出", "failedToDeleteAutomation": "删除自动化失败", "failedToDeleteRun": "删除运行记录失败", "failedToDeleteRuns": "删除运行记录失败", diff --git a/apps/web/src/locales/zh-hk/automations.json b/apps/web/src/locales/zh-hk/automations.json index 07b85a4ffd..df40291471 100644 --- a/apps/web/src/locales/zh-hk/automations.json +++ b/apps/web/src/locales/zh-hk/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "排除草稿 PR", "executorProfileLabel": "執行器設定檔", "executorProfilePlaceholder": "選擇執行器", + "exportFailed": "無法匯出自動化", + "exportZip": "匯出", "failedToDeleteAutomation": "刪除自動化失敗", "failedToDeleteRun": "刪除執行記錄失敗", "failedToDeleteRuns": "刪除執行記錄失敗", diff --git a/apps/web/src/locales/zh-tw/automations.json b/apps/web/src/locales/zh-tw/automations.json index ebba63bda6..bb4085e9e5 100644 --- a/apps/web/src/locales/zh-tw/automations.json +++ b/apps/web/src/locales/zh-tw/automations.json @@ -61,6 +61,8 @@ "excludeDraftPrs": "排除草稿 PR", "executorProfileLabel": "執行器設定檔", "executorProfilePlaceholder": "選擇執行器", + "exportFailed": "無法匯出自動化", + "exportZip": "匯出", "failedToDeleteAutomation": "刪除自動化失敗", "failedToDeleteRun": "刪除執行記錄失敗", "failedToDeleteRuns": "刪除執行記錄失敗", diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 3970a1a8c8..273cf6fa5e 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -177,6 +177,12 @@ A run that produced a task opens its conversation. A run that never produced one Deleting one run also deletes its associated task. **Delete all runs** deletes all associated tasks and history for that automation and is irreversible. +## Export automations + +The automations settings page (**Settings > Workspaces > _Workspace_ > Automations**) has an **Export** control next to **New Automation**. It downloads every automation in the workspace as a zip, one YAML file per automation at `.kandev/automations/.yml`, ready to read, diff, or check into a repository. A workspace with no automations still downloads a (empty) zip rather than showing an error. + +The same data is available directly over REST for scripting: `GET /api/v1/workspaces/:workspaceId/automations/export` returns one YAML document (`application/yaml`) listing every automation, and `GET /api/v1/workspaces/:workspaceId/automations/export/zip` returns the same per-file zip the UI control downloads (`application/zip`). Both are read-only and deterministic: exporting an unchanged workspace twice produces byte-identical output. A workspace you cannot access and a workspace that does not exist both return `404` with no distinguishing detail; any other failure returns `500`. + ## Task MCP Kandev automatically injects a task-aware MCP server into supported agent sessions. You do not need to add it to the profile. It lets the active agent use current IDs and structured operations instead of inferring board state from text. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index 93d2e44ffa..942cdd9f10 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -308,6 +308,7 @@ System pages (Radarr/Sonarr-style) for status, disk usage, database maintenance, | [board-step-visibility-filter](board-step-visibility-filter/spec.md) | draft | | [shutdown-turn-failure-suppression](shutdown-turn-failure-suppression/spec.md) | draft | | [executor-profile-env-precedence](executor-profile-env-precedence/spec.md) | building | +| [automations-yaml-export](automations-yaml-export/spec.md) | building | | [task-launch-failure-recovery](task-launch-failure-recovery/spec.md) | draft | --- diff --git a/docs/specs/automations-yaml-export/spec.md b/docs/specs/automations-yaml-export/spec.md new file mode 100644 index 0000000000..fdc9e0e5ff --- /dev/null +++ b/docs/specs/automations-yaml-export/spec.md @@ -0,0 +1,1596 @@ +--- +status: draft +created: 2026-08-19 +updated: 2026-08-20 +owner: tbd +--- + +# Automations YAML Export + +## Why + +A workspace's automations are operational prose with no version control. Measured +against the live store on 2026-08-19: **7 automations, 78,589 bytes of prompt across +1,338 lines, spread over 3 workspaces**. The largest single automation prompt is 404 +lines, longer than most workflow steps. All 7 carry a `webhook_secret`. + +None of it is reviewable. There is no diff, no history, no rollback, no way to see what +a prompt said last week. Changing a 56-line automation prompt required dumping the row +out of SQLite to a temp file by hand purely to produce something a human could read as a +diff. + +Workflows already solved this: `internal/workflowsync` keeps workflow definitions in a +git repository and applies them on a poll. Automations, which carry far more prose than +workflows do, got nothing. + +This spec covers **export only**: rendering a workspace's automations as YAML a human +can commit, diff, review, and roll back by hand. The applier that reads that YAML back +is deliberately out of scope (see `## Out of scope`). + +## What + +A user can export a workspace's automations as YAML, in two forms: + +- **A single YAML document** containing every automation in the workspace, for reading + and for API consumers. +- **A zip of one file per automation**, laid out as `.kandev/automations/.yml`, + which is the shape a human unpacks into a repository so each automation gets its own + diff. + +The exported YAML is *round-trip readable*: everything needed to recreate the automation +by hand is present. Secrets and runtime state are not. + +Decisions taken (rationale in `## Decisions`): + +- **`webhook_secret` is never exported**, and the export type is a purpose-built DTO + rather than the `Automation` domain struct, because the existing `json:"-"` tag does + not redact under a YAML marshaller. +- **Scheduler state is never exported.** `last_evaluated_at`, `created_at`, + `last_triggered_at`, and `updated_at` are excluded. The first two are the cron + scheduler's fire anchor. +- **Foreign keys are exported as portable descriptors, not UUIDs.** A UUID is not + "enough to recreate the automation by hand". +- **Trigger config is carried as an order-normalized generic mapping**, not decoded + into per-type structs, so a config key the exporter does not know about survives. +- **Output is byte-deterministic** for a given database state, with every ordering + resolved by a named column. This binds the zip archive's bytes as well as the YAML. +- **Numbers in trigger config are copied character-for-character**, never converted through a Go + numeric type, because every conversion route corrupts some class of input — rounding large + integers, adding exponents to plain ones, or quoting all of them. +- **Prompt style and prompt fidelity are checked separately.** Whether a prompt is readable in the + diff and whether its bytes survived are independent questions; a prompt beginning with a newline + fails the second while passing the first. Where the marshaller's default choice would lose + bytes, the export re-quotes that prompt into a form probed to preserve them, and says so. +- **Every JSON type in trigger config has one stated node form.** Numbers are untagged so their + lexeme survives; strings are explicitly `!!str` so a value reading `true` stays a string. +- **Denial and absence are the same observable response** (`404`), so the endpoint cannot + be used to enumerate which workspaces exist. + +## Definitions + +Two terms are load-bearing in more than one acceptance criterion. They are defined once, +here, and every AC that uses them means exactly this and nothing else. + +**newline** — any character in the set `{U+000A, U+000D, U+0085, U+2028, U+2029}`. A string +"contains a newline" when at least one of its characters is in that set; it "contains no +newline" when none is. Used by AC-17, AC-46 and AC-42. + +*(This is YAML 1.2's own line-break set, and it is exactly the `is_break` set in yaml.v3's +`yamlprivateh.go` that `## Decisions` already quotes. Two properties make it safe to name here +where the deleted degradation-condition list was not. It is **normative and fixed** — it comes +from the YAML specification rather than from an attempt to predict an emitter's behaviour, so it +cannot drift out of date the way a list of "causes of degradation" did across three revisions. +And it answers a different question: not "will this emit as a block scalar" (which stays an +observation, per AC-17) but "is this prompt multi-line at all", which is a property of the string +and of nothing else.* + +*The narrower reading — U+000A alone — was rejected and the reason is measured. Probed, a +CR-only, NEL-only, LS-only or PS-only prompt all emit **non-literal**: a prompt a human wrote as +several lines collapses to one quoted line. Under the narrow reading those prompts satisfy AC-46, +which mandates **no** warning, so the single most useful diagnostic this feature offers would be +withheld from exactly the prompts that need it. Under this definition they reach AC-17 and warn.* + +*Consequence for AC-42, made explicit rather than left to a reader: because the set is wider than +`{LF, CR}`, AC-42's rendering rules escape all five, and its "No other character is altered" +clause is stated against the completed four-rule list below rather than against a two-rule one.)* + +**start** (of an export) — the moment the export's read transaction **establishes its snapshot**, +which is the moment it issues its first read inside that transaction, not the moment `BeginTxx` +returns. AC-29 requires the export to establish its snapshot immediately upon opening the +transaction, before any other work, so the two moments are adjacent and the term is unambiguous. +Used by AC-13 and AC-30. + +*(SQLite's deferred transaction takes no snapshot at `BEGIN`; the first read does. Left undefined, +AC-13 was falsifiable by a correct implementation: export A opens at t0 and first-reads at t0.5, +export B opens at t1 and first-reads at t3, a write commits at t2. Nothing committed between the +two `BEGIN`s, so AC-13's antecedent held and demanded byte-identical output, while A and B held +different snapshots and were required by AC-30 to differ. Pinning "start" to snapshot +establishment, and requiring establishment to happen immediately, makes both ACs true together.)* + +## Decisions + +### Export is additive and read-only + +This card is scoped "no backend change". That is read as: no applier, no schema +migration, and no change to any existing backend behaviour. The export path itself is +new backend code, because: + +- Phase 2 is specified as mirroring `internal/workflowsync`, a backend package. An + exporter living outside the backend would give that mirror nothing to agree with. +- The artifact is a product capability delivered to a user, not a one-off script. + +No existing behaviour is modified. The export reads; it never writes to `automations`, +`automation_triggers`, `automation_repositories`, or `automation_runs`. + +"Additive" is about behaviour, not about file count, and it does **not** forbid new code in +existing packages. AC-29 requires a single read transaction and the automation store has no seam +for one — every read path issues directly against the reader handle and takes no transaction — so +the export needs **new** transaction-accepting read methods on `Store`. Adding them is in scope. + +**Four stores are in scope for that addition, not one.** AC-29 covers the rows a portable +descriptor is resolved from as well as the automation's own rows, and those live in three other +packages, so new exported transaction-accepting read methods are authorized on the **automation +store, the agent-settings store, the workflow repository, and the repository store**. Each new +method is a read, takes the transaction as a parameter, and sits beside the existing signature +rather than replacing it. + +What is out of scope, in all four, is changing what any existing method does to any existing +caller: the current signatures stay, the new methods sit beside them, and no firing path, +scheduler path, or WS handler changes behaviour. + +*(An earlier revision scoped this sentence to `Store` alone — automation's own — while AC-29 +simultaneously required descriptor rows to be read inside the same transaction. Since no existing +method on the other three stores accepts a transaction, the two statements could not both be +honoured, and the builder was left to choose between exceeding the stated scope and writing query +text into `backendapp`. Naming the four stores here is what makes AC-29's mechanism authorized +rather than improvised.)* + +### `webhook_secret` needs an explicit exclusion, not an inherited one + +`Automation.WebhookSecret` is tagged `json:"-" db:"webhook_secret"`. It carries **no** +`yaml` tag. `gopkg.in/yaml.v3` — the marshaller `workflowsync` already uses — does not +read `json` tags. Verified by marshalling the field layout through yaml.v3: + +```text +id: abc +name: "n" +webhooksecret: SENTINEL-SECRET-VALUE +lasttriggered: null +``` + +Two failures in four lines. The secret is emitted under `webhooksecret`, and +`json:"...,omitempty"` is ignored too, so nil runtime timestamps serialize as explicit +`null` rather than being dropped. All 7 live automations carry a 32-character secret, so +the first is not a hypothetical. + +The export type is a separate DTO with explicit `yaml` tags on every field. The domain +struct is never marshalled directly. AC-6 and AC-7 exist to hold this closed. + +### Scheduler state is excluded because it is the fire anchor + +`CronScheduler.shouldFire` computes the next fire time from an anchor that is +`trigger.LastEvaluatedAt`, falling back to `trigger.CreatedAt` when the trigger has never +been evaluated. Both fields are therefore live scheduler state, not definition. Four of +the seven live triggers have `last_evaluated_at` set; the other three are anchored on +`created_at`. + +Excluding both from the export means a future applier physically cannot carry a stale +anchor into the database, which is the drift-and-double-fire failure mode called out for +Phase 2. Their exclusion also keeps the diff quiet: `last_evaluated_at` changes on every +tick, so an export that included it would show a diff every hour for an hourly +automation, and the signal would be lost. + +### Foreign keys become portable descriptors + +An automation references five things by UUID. Exporting the UUIDs would satisfy nothing: +a UUID is neither reviewable in a diff nor sufficient to recreate the automation by hand. + +| Reference | Exported as | Rationale | +|---|---|---| +| `agent_profile_id` | `{agent_name, model, mode}` | Reuses the existing `wfmodels.AgentProfilePortable` triple, already the established portable identity for an agent profile (`backendapp/services.go`). Profile **names are not unique** — the live store has two profiles named `Opus - Low` — so name alone is not viable; the triple separates them (`opus[1m]` vs `default`). | +| `executor_profile_id` | `{executor, name}` | No portable type exists for executor profiles; this spec defines one. `executor_id` alone is insufficient because it is a stable slug for built-ins (`exec-local`, `exec-worktree`) but a UUID for custom executors — the live store has one such profile, `neo`. Both fields are emitted so a reader can match on either. | +| `workflow_id` / `workflow_step_id` | `{name, step}` | Workflows are already git-synced and already identified by name in `ApplySyncedWorkflows`. Name reference is consistent with the entity's own sync identity. | +| `repository_ids` | ordered list of repository names | Repository name is the human handle shown in the UI. | + +Where a referenced row cannot be resolved, see AC-19: the export does not fail. + +### Trigger config is a generic mapping, not a typed struct + +`automation_triggers.config` is stored as raw JSON text and is **not** canonicalized on +write. The live store holds two different serializations of the same shape: + +```text +{"cron_expression":"0 9 * * *","timezone":"Asia/Singapore"} +{"cron_expression": "@daily", "timezone": "Asia/Singapore"} +``` + +Emitting the stored text verbatim would make byte-identical automations diff against each +other. Decoding into the typed per-trigger structs (`ScheduledTriggerConfig` and friends) +would normalize the formatting but would **silently drop any key the struct does not +declare**. + +That second failure is precisely the defect shipped by Office's config export/import, +where `reports_to` is present in the DTO and emitted by export but dropped on import. The +lesson is taken rather than repeated: config is decoded to a generic mapping and +re-emitted with keys sorted, which normalizes formatting *and* preserves unknown keys. + +A consequence worth stating: a new trigger type needs no exporter change. + +### Numbers are copied, not converted + +Decoding trigger config into a generic mapping puts every JSON number through Go's `any`, and every +route that *converts* the number corrupts some class of input. Probed against +`{"big":9007199254740993,"huge":9223372036854775808,"round":1000000000,"exp":1e9,"onepoint":1.0}`: + +```text +json.Unmarshal -> map[string]any big -> 9.007199254740992e+15 VALUE CHANGED + round-> 1e+09 reformatted + +UseNumber + Int64()/Float64() exp -> 1e+09 an integer inside int64, now exponential + huge -> 9.223372036854776e+18 VALUE CHANGED + onepoint -> 1 a float, now an integer + +UseNumber, no conversion big -> "9007199254740993" NOW A STRING + round-> "1000000000" +``` + +Plain decoding makes every number a `float64`, which rounds `9007199254740993` and reformats +`1000000000` as `1e+09`. Emitting the `json.Number` directly fixes the precision loss and +introduces a worse bug: `json.Number` is declared `type Number string`, so yaml.v3 emits it +**quoted** and `max_retries: 3` comes back as `max_retries: "3"`. The `Int64()`-then-`Float64()` +route looks like the fix and is not: it branches on *lexical* form — `strconv.ParseInt` rejects +`1e9` and `1.0` outright, and cannot hold `9223372036854775808` — so three separate input classes +each come out wrong, two of them silently. + +The mechanism that survives all of them is not to convert at all. Decode with `UseNumber` to keep +the original characters, then hand yaml.v3 a `*yaml.Node` scalar carrying that exact lexeme. + +**Leave `Tag` unset.** An earlier revision of this spec assigned `!!float` when the lexeme +contained `.`, `e` or `E` and `!!int` otherwise, on the theory that each lexeme resolves to its tag +implicitly so nothing would be printed. That is true only inside `[-2^63, 2^64)`. yaml.v3's encoder +drops an assigned tag only when its own `resolve()` of the raw lexeme returns the same tag; for a +20-digit integer `resolve()` falls through `ParseInt` and `ParseUint` to `!!float`, and for `1e400` +`ParseFloat` returns `ErrRange` and it falls through to `!!str`. Either way the assigned tag now +disagrees and is printed. Probed side by side: + +```text +lexeme Tag unset Tag assigned !!int/!!float +1000000000 1000000000 1000000000 +1e9 1e9 1e9 +1.0 1.0 1.0 +9007199254740993 9007199254740993 9007199254740993 +9223372036854775808 9223372036854775808 9223372036854775808 +18446744073709551616 18446744073709551616 !!int 18446744073709551616 +99999999999999999999999999 99999999999999999999999999 !!int 99999999999999999999999999 +1e400 1e400 !!float 1e400 +-12345678901234567890 -12345678901234567890 !!int -12345678901234567890 +``` + +Unset wins on every row and loses on none. It is also safe: a JSON number lexeme can never collide +with YAML's keyword set (`true`, `false`, `null`, `yes`, `on`, `~`) because the JSON grammar +excludes them, and `encoding/json` rejects `NaN`, `Infinity`, a leading `+`, leading zeros and +`0x` forms during scanning, so those never reach the exporter as numbers at all — a config +containing them is not valid JSON and routes to AC-39 instead. Deleting the tag assignment is +therefore strictly simpler than the rule it replaces and removes the last class of altered output. + +This is the same principle the spec already applies to prompts (AC-15) and to unknown config keys +(AC-11): the export reports what is in the database rather than normalizing it, and AC-34 already +states that outright. A number is not a special case; it only looked like one because the obvious +implementations reach for a Go numeric type on the way past. + +### Ordering is explicit because the store's ordering is not stable + +`ListAutomations` orders by `created_at DESC` and trigger hydration orders by +`created_at`, neither with a tiebreak. Three of the seven live automations were created +within **5 milliseconds** of each other by a bulk path, so equal timestamps are a +realistic collision, and SQLite's row order for a tie is not defined. + +A git-committed artifact whose row order can shuffle between runs produces phantom diffs. +Every ordering in the export is therefore pinned to a named column with a named tiebreak +(AC-8). + +### Prompts are emitted faithfully, and degradation is observed rather than predicted + +The whole value of this feature is a readable diff of a long prose prompt. yaml.v3 emits a +multi-line string as a literal block scalar (`|`) only when the string qualifies. When it does +not, the entire value collapses to one double-quoted line — applied to the 404-line prompt in the +live store that is a 24 KB single line: syntactically valid, completely unreviewable, and it +happens silently. + +**The export therefore observes the emitted form rather than predicting it.** That is a +correction to three earlier revisions of this spec, and the reason is measured rather than +stylistic. Each revision tried to enumerate the conditions under which yaml.v3 declines a block +scalar, and each enumeration was found incomplete by the next review: + +| Revision | Enumerated | Missed | +|---|---|---| +| First | trailing-whitespace line, CR, invalid UTF-8 | every astral character, every C0/C1 control, DEL, BOM | +| Second | + C0 controls, DEL, astral characters | the C1 controls U+0080–U+009F, U+FEFF, U+FFFE, U+FFFF | +| Third | + C1 controls, BOM, U+FFFE, U+FFFF, "a U+0020 followed by a line break" | U+2028 and U+2029 | + +The third miss settles the argument. `emitterc.go` › `yaml_emitter_analyze_scalar` computes +`block_allowed = !(trailing_space || space_break || special_characters)`, where `space_break` is a +U+0020 followed by any character in `is_break` — `{U+000A, U+000D, U+0085, U+2028, U+2029}` +(`yamlprivateh.go`) — while `is_printable` **admits** U+2028 and U+2029, so `special_characters` +does not catch them either. A prompt containing a space immediately followed by U+2028 thus +matched no enumerated condition and still lost its block scalar. Probed against v3.0.1: it emits +double-quoted on one line. + +Each round the list was extended and each round a new gap appeared, because the spec was +maintaining by hand a predicate the library computes from three interacting expressions over five +character classes. The structural fix is to stop maintaining it. + +**Mechanism.** The emitted style is directly observable: yaml.v3 records `Style` on the node it +parses, so re-parsing emitted output into a `yaml.Node` and testing `Style & yaml.LiteralStyle` +answers the question exactly, with no predicate to keep in sync. Probed against v3.0.1: + +```text +prompt value Style Tag literal? bytes preserved? +"line one\nline two\n" Literal !!str yes yes (emits |) +"line one\nline two" Literal !!str yes yes (emits |-) +"line one\nline two\n\n" Literal !!str yes yes (emits |+) +"line one\ta\nline two\n" Literal !!str yes yes (TAB does not degrade) +"line one \nline two\n" DoubleQuoted !!str no yes (space before a break) +"line one\nline two " DoubleQuoted !!str no yes (trailing space) +"line one\r\nline two\r\n" DoubleQuoted !!str no yes (CR) +"line one\nab\n" DoubleQuoted !!str no yes (astral) +"line one\nab\n" DoubleQuoted !!str no yes (C0 control) +"line one\nab\n" DoubleQuoted !!str no yes (the miss above) +"Do the thing" Plain !!str no yes (single line; not a degradation) +"Do the thing " SingleQuoted !!str no yes (single line; not a degradation) +"line one\nbad\xff\xfebyte\n" — !!binary no yes (base64; AC-47) +"\nhello" Literal !!str yes NO -> "hello" (AC-49) +"\n" Literal !!str yes NO -> "" (AC-49) +"\n\nhello" Literal !!str yes NO -> "\nhello" (AC-49) +"\n\n" Literal !!str yes NO -> "\n" (AC-49) +``` + +**The last four rows are the reason this spec has an AC-49.** A prompt that *begins* with a +newline loses that newline, and the emitted scalar is a perfectly ordinary literal block scalar, +so a style check sees nothing wrong. Style answers "is this readable"; it does not answer "are +these the same bytes". The two questions are independent and the export must ask both. + +**Order of operations, because the naive reading is circular.** The warning lives inside the +document (AC-20), but the style is only knowable once something has been emitted. The export +therefore determines each prompt's disposition **before** building the final document: for each +automation it marshals that prompt alone through an encoder configured identically to the real one +(AC-12's 2-space indent, block context), re-parses that throwaway output, and reads **three** +things from the resulting node — its `Tag`, its `Style`, and its `Value`. The final document is +then built once, warnings included, and marshalled once. It shall **not** be marshalled, +inspected, amended and re-marshalled — a two-pass emit makes AC-9's byte-determinism depend on +the two passes agreeing, and gives AC-48 a body that existed in an earlier form. + +**Classification is ordered, and the order is normative.** Exactly one branch is taken per +prompt, so exactly one prompt warning is possible per automation: + +1. **`Tag` is `!!binary`** → AC-47. Do not override the style; do not apply the fidelity test in + step 2, whose comparison is meaningless here (the re-parsed node's `Value` is the base64 text, + not the prompt, so a naive comparison reports a loss that has not occurred). Fidelity for this + branch is established by base64-decoding the emitted value, which is probed byte-identical. +2. **`Value` differs from the stored prompt** → AC-49. Emit this prompt in the final document as + a `*yaml.Node` carrying `Tag: "!!str"` and `Style: yaml.DoubleQuotedStyle`, and warn. +3. **`Style` is not literal and the prompt contains a newline** (as `## Definitions` defines it) + → AC-17. Warn; do not override the style. +4. **Otherwise** → no prompt warning. + +Steps 1 and 2 are ordered relative to each other by measurement, not by preference: forcing +`Tag: "!!str"` and a double-quoted style onto an invalid-UTF-8 prompt produces output that +**fails to re-parse at all**, so applying step 2 before step 1 would turn a handled case into a +broken document. + +The standalone probe is faithful because `yaml_emitter_analyze_scalar` derives `block_allowed` +from the scalar's own bytes, and `encode.go` selects the literal style for any string containing a +newline **except in flow context**. This export emits block context throughout and never sets flow +style, so the probe and the real emission cannot disagree. A conforming implementation may instead +inspect the final document and assert agreement, but shall not emit a body twice. + +**The mechanism for step 2's override, and why it disturbs nothing else.** The DTO's `prompt` +field is declared `any`. In branches 1, 3 and 4 it holds the prompt as a plain Go `string`, which +is what the export does today; only in branch 2 does it hold a `*yaml.Node`. This matters because +a field whose type changed would put every prompt on a new code path: probed across twelve prompt +classes — leading newline, bare newline, ordinary multi-line, trailing space, CR-only, NEL-only, +LS-only, C0 control, single line, and a single-line prompt reading `true` — an `any`-typed field +holding a `string` emits **byte-identical output to a `string`-typed field in all twelve**. The +override is therefore inert everywhere except the branch that needs it. + +`Tag: "!!str"` is required on that node and is not decoration. A `*yaml.Node` scalar with `Tag` +unset is re-resolved by the encoder against its own value, which is exactly the mechanism AC-41 +exploits for numbers and exactly the mechanism that would retype a prompt whose entire text is +`true` or `null`. AC-41's "leave `Tag` unset" rule is scoped to JSON numbers in trigger config +and applies nowhere else; see AC-8 for the same distinction on config strings. + +Probed on all twelve classes: the forced double-quoted form round-trips **byte-identical in every +one**, including the four leading-newline cases the default emission loses. The forced form is +less readable than a block scalar, which is why AC-49 warns; it is not less faithful, which is +why AC-49 is a fix rather than only a diagnostic. + +Three things fall out of that table and each is load-bearing. All three chomping indicators — +`|`, `|-`, `|+` — are `LiteralStyle`, so the test is a style check and never a prefix match on +the emitted text. `!!binary` is identifiable by its **tag**, which is what lets AC-47 take +precedence without re-testing the bytes. And a single-line prompt is `Plain` or `SingleQuoted` +rather than `Literal`, so "not a literal block scalar" is not by itself a degradation — a block +scalar is unreachable for a string with no newline, which is why AC-17's antecedent still +requires one. + +**Byte fidelity survives every *degraded* case, but degradation is not the only way to lose +bytes.** Probed: every non-literal value above, plus the BOM, C1-control and U+10FFFD cases, +decodes back byte-identical — for those, degradation costs readability and never data. + +**An earlier revision of this spec generalized that into "AC-15 is never in tension with AC-17", +and that claim was false.** It was falsified by measurement, not by argument: a prompt beginning +with a newline loses that newline, and does so while emitting as a literal block scalar, which is +precisely the case AC-17 is defined not to warn about. The 13-case table this claim was drawn +from contained no prompt that *starts* with a newline, so the generalization was made over a +sample that excluded the counterexample. + +The correction is structural rather than a patch to the sentence. **Style and fidelity are two +independent observables and the export checks both** — style answers "will a human be able to +read this diff" (AC-16, AC-17), fidelity answers "are these the same bytes" (AC-15, AC-49). A +value can fail either without failing the other. Where the default emission would lose bytes, +AC-49 requires the export to emit that prompt in a form probed to preserve them, so **AC-15 holds +as an outcome and not merely as a promise about the exporter's own restraint**. + +**What causes degradation, non-normatively.** No acceptance criterion depends on the following +list and no test may be written against it; it exists so a human reading a warning has somewhere +to start looking. As of v3.0.1 the usual causes are a trailing space, a space before a line break, +a carriage return (a CRLF prompt), a control character, an emoji or other astral character, and +the byte-order mark. **This list is illustrative and is permitted to be incomplete** — that is +precisely the point of observing the emitted style instead of predicting it. If it drifts out of +date, nothing breaks. + +Two options are rejected: + +- **Normalizing on export** (stripping trailing whitespace, folding CRLF, dropping astral + characters) would make the export an inexact copy. A later applier would then rewrite the stored + prompt on the first sync. An export that quietly changes the thing it exports is the same class + of defect as Office dropping `reports_to` — different direction, same betrayal. +- **Emitting as-is and saying nothing** leaves the user with an unreadable diff and no + explanation. + +The export therefore emits the prompt byte-faithfully and adds exactly one warning when the +emitted form is not a literal block scalar (AC-17). Fidelity is preserved; the degradation is +visible rather than mysterious. + +Measured against the live corpus: **0 of 7 prompts** degrade — every one serializes as a block +scalar today. All 7 lack a final newline and so use `|-`, which is equally readable and is still +`LiteralStyle`. The corpus is clean; an emoji pasted into a prompt is a likelier way to break it +than a trailing space is. + +### The encoder is pinned + +yaml.v3's package-level `Marshal` defaults to a 4-space indent; `yaml.Encoder.SetIndent` +changes it. Office's `writeYAMLFile` uses the default and never pins it. Byte-determinism +(AC-9) requires the indent to be part of the contract rather than a library default that +a dependency bump can move, so the export pins 2-space indentation explicitly. + +## Data model + +The exported document. Field names are the YAML keys. + +```yaml +version: 1 +type: kandev_automations +automations: + - name: Daily Review — @kegmil/offline-first + enabled: false + max_concurrent_runs: 1 + task_title_template: Daily Review — offline-first ({{trigger.timestamp}}) + prompt: | + ... + agent_profile: + agent_name: Claude Code + model: opus[1m] + mode: auto + executor_profile: + executor: exec-worktree + name: Worktree + workflow: + name: Kanban + step: In Progress + repositories: + - kegmil-offline-first + triggers: + - type: scheduled + enabled: true + config: + cron_expression: 0 9 * * * + timezone: Asia/Singapore +``` + +**Included, always emitted:** `name`, `enabled`, `max_concurrent_runs`, `triggers` +(possibly empty), and per trigger `type`, `enabled`, `config` (possibly empty). + +**Included, omitted when empty:** `description`, `prompt`, `task_title_template`, +`agent_profile`, `executor_profile`, `workflow`, `repositories`, and the document-level +`warnings` (AC-20). + +**Excluded — secret:** `webhook_secret`. + +**Excluded — runtime state:** `last_triggered_at`, `created_at`, `updated_at` (automation); +`last_evaluated_at`, `created_at`, `updated_at` (trigger); everything in `automation_runs`. + +**Excluded — instance identity:** `id`, `workspace_id`, `automation_id`, `trigger.id`. + +**Excluded — withdrawn or legacy columns:** `execution_mode` (withdrawn; live values are +`''` on six rows and `'run'` on one, and no firing path reads it), `automations.repository_id` +(inert legacy column superseded by `automation_repositories`), and the derived +`legacy_board_card` field. + +### Field disposition + +This table is the contract AC-22 tests against. Every field of `Automation` and +`AutomationTrigger` appears exactly once. `exported` names the YAML key it becomes; +`excluded` names why. + +| Struct | Field | Disposition | +|---|---|---| +| `Automation` | `Name` | exported → `name` | +| `Automation` | `Description` | exported → `description` | +| `Automation` | `Prompt` | exported → `prompt` | +| `Automation` | `TaskTitleTemplate` | exported → `task_title_template` | +| `Automation` | `Enabled` | exported → `enabled` | +| `Automation` | `MaxConcurrentRuns` | exported → `max_concurrent_runs` | +| `Automation` | `Triggers` | exported → `triggers` | +| `Automation` | `WorkflowID` | exported → `workflow.name` (renamed; resolved to a descriptor) | +| `Automation` | `WorkflowStepID` | exported → `workflow.step` (renamed; resolved to a descriptor) | +| `Automation` | `AgentProfileID` | exported → `agent_profile` (renamed; resolved to a descriptor) | +| `Automation` | `ExecutorProfileID` | exported → `executor_profile` (renamed; resolved to a descriptor) | +| `Automation` | `RepositoryIDs` | exported → `repositories` (renamed; resolved to names) | +| `Automation` | `WebhookSecret` | excluded — secret | +| `Automation` | `ID` | excluded — instance identity | +| `Automation` | `WorkspaceID` | excluded — instance identity | +| `Automation` | `LastTriggeredAt` | excluded — runtime state | +| `Automation` | `CreatedAt` | excluded — runtime state / fire anchor | +| `Automation` | `UpdatedAt` | excluded — runtime state | +| `Automation` | `LegacyBoardCard` | excluded — derived from a withdrawn column | +| `AutomationTrigger` | `Type` | exported → `type` | +| `AutomationTrigger` | `Enabled` | exported → `enabled` | +| `AutomationTrigger` | `Config` | exported → `config` (the decoded form; see AC-11, AC-39, AC-41) | +| `AutomationTrigger` | `ConfigJSON` | excluded — raw storage of `Config`, same value, not a second concept | +| `AutomationTrigger` | `ID` | excluded — instance identity | +| `AutomationTrigger` | `AutomationID` | excluded — instance identity | +| `AutomationTrigger` | `LastEvaluatedAt` | excluded — runtime state / fire anchor | +| `AutomationTrigger` | `CreatedAt` | excluded — runtime state / fire anchor | +| `AutomationTrigger` | `UpdatedAt` | excluded — runtime state | + +Two notes a builder needs. `AutomationTrigger` carries **two** fields for one concept — +`Config json.RawMessage` (tagged `db:"-"`) and `ConfigJSON string` (tagged `db:"config"`) — +and the DTO has a single `config`; the table settles which is which rather than leaving it +to a tag convention. And the columns excluded above that are **not** struct fields +(`execution_mode`, `automations.repository_id`) are deliberately absent from this table, +because reflection cannot see them; they are covered by AC-43 instead. + +## API surface + +Two read-only endpoints, mirroring the shape Office's config export already established +(`GET /workspaces/:wsId/config/export` and `.../export/zip`): + +| Method | Path | Response | +|---|---|---| +| `GET` | `/api/v1/workspaces/:wsId/automations/export` | `200`, `Content-Type: application/yaml`, body is the single YAML document | +| `GET` | `/api/v1/workspaces/:wsId/automations/export/zip` | `200`, `Content-Type: application/zip`, `Content-Disposition: attachment; filename=kandev-automations.zip` | + +REST rather than a WebSocket action: every other automation operation is a WS action because +it is interactive state, but an export is a bulk byte stream a client retrieves at a URL — a plain +HTTP `GET` a `fetch` can read a status and a `Blob` from, and that a human or a script can also +curl directly. `Content-Disposition` is set for that direct-access case; it is **not** the +mechanism the in-app control uses, which is `fetch` + `Blob` per AC-37. Office set the REST +precedent for the same job, though not the frontend one. + +## Acceptance criteria + +### Export content + +**AC-1** — When a user requests the export for a workspace, the system shall return a +YAML document whose `version` is `1` and whose `type` is `kandev_automations`. + +**AC-2** — When a user requests the export for a workspace, the system shall include +exactly the automations belonging to that workspace, and no automation belonging to any +other workspace. + +**AC-3** — When an automation is exported, the system shall emit `name`, `enabled`, +`max_concurrent_runs`, and `triggers` unconditionally, including when `triggers` is empty. + +**AC-4** — When an automation is exported, the system shall omit `description`, `prompt`, +`task_title_template`, `agent_profile`, `executor_profile`, `workflow`, and `repositories` +when the underlying value is empty, and emit them otherwise. + +**AC-5** — When a trigger is exported, the system shall emit its `type`, its `enabled` +flag, and its `config`, and shall emit `config` as a mapping even when the stored config +is `{}`. + +**AC-39** — If a trigger's stored `config` **is not valid UTF-8**, is not valid JSON, or is valid +JSON that is not an object (`null`, an array, a string, a number, or a boolean), then the system +shall emit `config` as an empty mapping, add a warning naming the automation, the trigger type, and +which of the three conditions occurred, and still export the trigger and its automation. +Export shall not fail. + +The UTF-8 check shall be made **on the raw stored bytes, before decoding**, and takes precedence +over the other two conditions, so exactly one warning is emitted for such a trigger. + +*(`automation_triggers.config` is raw TEXT; nothing enforces that it is UTF-8 or that it decodes to +an object. Probed: `null`, `[1,2]`, `"str"`, `42` and `true` all decode without error and none is a +`map[string]any`, so a type check is required, not just an error check.* + +*The UTF-8 condition is separate and is the subtle one, because the failure is silent rather than +loud: `encoding/json` does **not** reject invalid UTF-8 in a string value — it substitutes U+FFFD +and returns no error. Without this clause a config string containing an invalid byte decodes +"successfully", passes the JSON check and the object check, and is emitted with its bytes changed, +while the export reports nothing. That is a direct contradiction of AC-34, which states the export +reports what is in the database rather than normalizing it, and it is the same class of silent +alteration the spec already refuses for prompts (AC-47) and for numbers (AC-41, character-for- +character). Checking the raw bytes first is what keeps the three conditions to one warning apiece +and keeps this one detectable at all — after decoding, the evidence is gone. Emitting an empty +mapping rather than the corrupted values is deliberate: a partially-mangled config in a committed +artifact is worse than an absent one, because a human reviewing the diff cannot tell which values +are real.)* + +### Redaction and exclusion + +**AC-6** — The exported YAML shall not contain the automation's `webhook_secret` under +any key, in either output form. *(Test with a non-empty secret containing a +recognizable sentinel and assert the sentinel is absent from the serialized bytes, not +merely that a named field is unset — the leak this guards against is a mis-keyed field, +not an unset one.)* + +**AC-7** — The exported YAML shall not contain `id`, `workspace_id`, `automation_id`, +`created_at`, `updated_at`, `last_triggered_at`, `last_evaluated_at`, `execution_mode`, +`legacy_board_card`, or `automations.repository_id`, for any automation or trigger. + +### Determinism + +**AC-8** — The system shall order the export deterministically: + +- automations by `name` ascending, tiebroken by `automations.id` ascending; +- triggers within an automation by `automation_triggers.type` ascending, tiebroken by + `automation_triggers.id` ascending; +- repositories by `automation_repositories.position` ascending, tiebroken by + `automation_repositories.repository_id` ascending; +- keys within a trigger `config` mapping by key ascending, at every nesting depth. + +Sort order is byte-wise ascending on the UTF-8 encoding of the key. + +**The `config` mapping shall be emitted as an explicitly ordered `*yaml.Node` of kind +`MappingNode` whose key/value pairs the exporter has already sorted byte-wise, at every nesting +depth.** It shall **not** be emitted by handing a Go `map[string]any` to yaml.v3 and relying on the +marshaller to sort. Sequence order is the stored JSON array order and is never sorted. + +**Every JSON string in `config` — every key, and every value whose JSON type is string — shall be +emitted as a `*yaml.Node` scalar carrying an explicit `Tag: "!!str"`, at every nesting depth.** +This is the exact opposite of AC-41's rule for numbers, and the two are stated together here so +neither is applied to the other's type. + +**Every JSON type has exactly one node form, and the table below is the whole rule.** Once AC-8 +requires an explicit `MappingNode`, every descendant is builder-constructed, so leaving any type +unstated forces a guess: + +| JSON type | Go type after `UseNumber` decode | Node form | +|---|---|---| +| object | `map[string]any` | `MappingNode`, keys sorted byte-wise per this AC | +| array | `[]any` | `SequenceNode`, stored order preserved, never sorted | +| string (and every key) | `string` | `ScalarNode`, `Tag: "!!str"` | +| number | `json.Number` | `ScalarNode`, **`Tag` unset**, `Value` the stored lexeme (AC-41) | +| `true` / `false` | `bool` | `ScalarNode`, `Tag: "!!bool"`, `Value` `true` or `false` | +| `null` | `nil` | `ScalarNode`, `Tag: "!!null"`, `Value` `null` | + +*(The Go column is measured, not assumed: probed against +`{"s":"true","n":12,"b":true,"z":null,"arr":[...],"o":{...}}` decoded with `UseNumber`, the six +types land as `string`, `json.Number`, `bool`, `nil`, `[]any` and `map[string]any` respectively. +The `!!bool` and `!!null` rows are the only two where the explicit tag changes nothing — probed, +a bool or null node emits and re-parses identically with the tag set or unset, because their +lexemes resolve to themselves. They are stated anyway so the table is total: a builder who finds +four of six types specified reasonably infers the other two are someone else's problem, and the +one type where that inference is catastrophic is `string`.)* + +*(Pinned because the natural implementation corrupts data silently and the spec's own round-trip +test does not catch it. A `*yaml.Node` container's children must themselves be `*yaml.Node`s, so +once AC-8 requires an explicit `MappingNode` **every** scalar is builder-constructed, strings +included — and AC-41 is otherwise the only node-construction rule this spec states. Applied +uniformly, its `Tag`-unset instruction hands yaml.v3 a bare lexeme to re-resolve. Probed:* + +``` +stored JSON string emitted re-parsed as with Tag: "!!str" +"true" -> true -> !!bool -> "true" (!!str) +"null" -> null -> !!null -> "null" (!!str) +"1.0" -> 1.0 -> !!float -> "1.0" (!!str) +"0755" -> 0755 -> !!int -> "0755" (!!str) +"12" -> 12 -> !!int -> "12" (!!str) +"~" -> ~ -> !!null -> "~" (!!str) +"yes" / "on" / "no" / "ordinary" already !!str, and stay unquoted with the tag set +``` + +*So a trigger config of `{"mode":"true"}` exports as `mode: true` and a later applier reads a +boolean. `Tag: "!!str"` fixes every row and over-quotes none: yaml.v3 quotes only the values whose +bare form would resolve to another type, and YAML 1.2's core schema does not treat `yes`/`on`/`no` +as booleans, so those stay bare. Numbers keep AC-41's `Tag`-unset treatment for the separate +reason given there — an assigned numeric tag gets printed whenever the encoder's own resolution +disagrees. The rule is therefore per-JSON-type, never uniform.* + +*AC-23 is the test that ought to catch this and, as originally written, could not: it compared +config scalars by lexeme, and the re-parsed `true` node's lexeme is still `true`. That gap is +closed in AC-23 itself.)* + +*(Pinned because the natural implementation silently violates the clause above it. yaml.v3 sorts +map keys with its own comparator (`sorter.go`), which is digit-aware and letter-aware rather than +byte-wise. Probed: yaml emits `v1, v2, v10` where byte-wise is `v1, v10, v2`; `_beta, Alpha` where +byte-wise is `Alpha, _beta`; `step1, step2, step10` where byte-wise is `step1, step10, step2`. +AC-11 guarantees an unknown key survives verbatim, so digit-bearing and punctuation-leading keys +are squarely in scope, and a test fixture using only lowercase-letter keys passes against a +non-compliant export. The same node-based container is what AC-41's scalars are placed into, so +this costs nothing extra.)* + +**AC-40** — The system shall emit the document's top-level keys in the fixed order +`version`, `type`, `automations`, `warnings`, and each automation's keys in the fixed order +`name`, `description`, `enabled`, `max_concurrent_runs`, `task_title_template`, `prompt`, +`agent_profile`, `executor_profile`, `workflow`, `repositories`, `triggers`, and each +trigger's keys in the fixed order `type`, `enabled`, `config`. Keys omitted under AC-4 are +skipped without disturbing the order of the rest. + +*(Every other ordering in this spec is pinned to a named column, but key order within a +mapping was not, and AC-9 makes it part of the artifact's bytes. Declaring it here means the +determinism contract does not rest on the marshaller emitting DTO struct fields in +declaration order — true of yaml.v3 today, but an undeclared dependency on a library +detail is exactly what AC-12 already refuses to accept for indentation.)* + +**AC-9** — When the same workspace is exported twice with no intervening database change, +the system shall produce byte-identical output both times. This binds **both** output +forms: the single YAML document, and the complete bytes of the zip archive. + +**AC-10** — When two automations differ only in the whitespace of their stored trigger +`config` JSON, the system shall produce identical `config` YAML for both. *(The live store +contains both a compact and a space-separated serialization of the scheduled trigger +config.)* + +**AC-11** — When a trigger's stored `config` contains a key the exporter has no typed +field for, the system shall emit that key and its value in the exported `config`. + +**AC-41** — When a trigger's stored `config` contains a JSON number at any nesting depth, +the system shall emit it as an unquoted YAML number whose **characters are exactly the +characters of the number as stored in the JSON**, with no explicit tag. It shall not reformat, +round, re-base, add or remove an exponent, add or remove a trailing `.0`, or emit the number as a +quoted string. A stored `1000000000` emits as `1000000000`; a stored `1e9` emits as `1e9`; a +stored `1.0` emits as `1.0`; a stored `9223372036854775808` emits as `9223372036854775808`; a +stored `18446744073709551616` emits as `18446744073709551616`. + +**This AC's `Tag`-unset rule applies to JSON numbers and to nothing else.** JSON strings in the +same mapping take the opposite treatment — an explicit `Tag: "!!str"` — under AC-8. Applying the +rule below to a string retypes it; applying AC-8's rule to a number prints an explicit tag. The +two rules are per-JSON-type by construction, and a builder who unifies them breaks one or the +other. + +*(Mechanism, pinned because four implementations were probed and three corrupt or annotate the +output — see `## Decisions` › "Numbers are copied, not converted". Decode with +`json.Decoder.UseNumber()`, then emit each `json.Number` as a `*yaml.Node{Kind: ScalarNode, +Value: n.String()}` with **`Tag` left unset**. Two details are load-bearing and were each probed: +the node must be a **pointer** — a bare `yaml.Node` value inside a generic container panics with +`interface conversion: interface {} is *interface {}, not *yaml.Node` — and the **`Tag` must not be +assigned**. Setting `Tag` to `!!int`/`!!float` makes the encoder compare it against yaml.v3's own +re-resolution of the lexeme and print the tag explicitly whenever they disagree, which happens for +every integer outside `[-2^63, 2^64)` and every exponent outside float64 range: `!!int +18446744073709551616`, `!!float 1e400`. Leaving `Tag` unset skips that comparison and emits the +lexeme plain in every case. Do **not** convert through `int64` or `float64`: that path branches on +lexical form while this AC is framed by characters.)* + +**AC-12** — The system shall serialize with an explicitly configured 2-space indent +rather than relying on the marshaller's default. + +**AC-13** — When two exports of the same workspace run concurrently **and no mutation of +that workspace's automations commits between the start of the first and the start of the +second** — *start* as `## Definitions` fixes it, the moment each export's read transaction +establishes its snapshot — each shall produce byte-identical output to the other. *(The qualifier is +load-bearing and matches AC-9's. Without it this AC contradicts AC-30: AC-30 gives each +export the snapshot open at its own start and explicitly permits a concurrent mutation, so +a commit landing between the two snapshots makes differing output required by AC-30 and +forbidden by AC-13. What AC-13 actually guards is that concurrency itself introduces no +nondeterminism — no map-iteration order, no shared buffer, no racing sort.)* + +**AC-14** — The export shall be free of side effects, so a client may retry a failed or +interrupted request without consequence. *(It is a `GET`; this records that no counter, +timestamp, or run row is touched — see `## Persistence guarantees`.)* + +### Prompt fidelity + +**AC-15** — When an automation's prompt is exported, the system shall emit it +byte-for-byte, applying no trailing-whitespace stripping, no line-ending conversion, and +no newline insertion or removal. This binds the **emitted document**, not merely the exporter's +own restraint: for every prompt that is valid UTF-8, parsing the exported document back shall +yield the stored prompt exactly. Where the marshaller's default choice would not satisfy this, +AC-49 governs. Where the prompt is not valid UTF-8, AC-47 governs and fidelity is established by +base64-decoding the emitted value. + +*(The second sentence was added because the first, alone, was satisfied by an export that lost +data. "The system applies no conversion" is a claim about the exporter; "the bytes survive" is a +claim about the artifact, and only the second is what a human relying on this diff needs. yaml.v3 +drops a prompt's leading newline while emitting a flawless block scalar, so an exporter that did +nothing wrong still produced a document that failed to round-trip. Stating the outcome makes the +AC testable against the artifact rather than against the implementation's intentions.)* + +**AC-16** — When a prompt contains at least one newline and is valid UTF-8, the system shall emit +it through the pinned marshaller **without selecting or overriding the scalar style — except +where AC-49 requires an override to preserve the prompt's bytes** — and shall add **no** warning +when the emitted scalar is a literal block scalar. All three chomping indicators — `|`, `|-`, +`|+` — are literal block scalars and satisfy this AC equally. "Contains at least one newline" is +`## Definitions`' newline. + +*(The exception is narrow and its boundary is observable, not a matter of judgement: it applies +only on AC-49's branch, which is entered only when the marshaller's own output has already been +shown to differ from the stored prompt. This AC's purpose is unchanged — the export does not +second-guess the marshaller's readability decisions, and does not cry wolf when a block scalar +was produced. What it never meant to say is that the export must accept a silently altered +prompt in order to keep its hands off the style, which is what the unqualified wording required.)* + +*(This AC no longer mandates a block scalar, and that change is deliberate. Whether a block scalar +is producible is the marshaller's decision given the bytes, not the exporter's; three earlier +revisions mandated the outcome and were each falsified by an input the spec's condition list did +not name — see `## Decisions` › "Prompts are emitted faithfully". What the export can promise, and +what this AC now states, is that it does nothing to prevent one and does not cry wolf when one was +produced. All 7 live prompts satisfy this; the readable diff is the feature.)* + +**AC-17** — If a prompt contains at least one newline (as `## Definitions` defines it), is valid +UTF-8, the emitted scalar is **not** a literal block scalar, **and AC-49 has not been triggered +for that prompt**, then the system shall add **exactly one** warning naming the automation, +carrying the message AC-42's vocabulary table defines for this condition. Export shall +not fail, and the prompt shall not be modified to make it qualify. AC-42 owns the exact message +bytes; this AC owns when the message is emitted, and the two are stated in one place each so they +cannot drift. + +*(The AC-49 clause keeps "exactly one" true. AC-49's branch deliberately emits a double-quoted +scalar, which is not a literal block scalar, so without the exclusion both ACs would fire on the +same prompt and the automation would carry two prompt warnings describing one problem. AC-49's +message already tells the human the prompt is not in block form; the ordered classification in +`## Decisions` is what makes the exclusion mechanical rather than a judgement call.)* + +**The condition shall be determined by inspecting the style of the emitted scalar — not by testing +the prompt's characters against any list of degradation conditions.** A conforming implementation +observes what the marshaller produced; it does not predict it. + +*(One warning with one fixed reason replaces the previous three-condition table and its +one-warning-per-condition rule. The table was the defect: it was rewritten in three consecutive +review rounds and found incomplete each time, most recently because U+2028 is simultaneously a +line break to `is_break` and printable to `is_printable`, so it satisfied no listed condition +while still costing the block scalar. A fixed single reason cannot drift, keeps AC-42's vocabulary +finite, and keeps AC-9 deterministic. The diagnostic value given up is real but small: the human +reading the warning knows the prompt is unreadable in the diff and can see the prompt, and +`## Decisions` carries a non-normative list of usual causes that no test may depend on.)* + +*(Testability, stated because the obvious test re-implements the bug: a test for this AC shall +parse the **exported document**, locate the automation's `prompt` node, and assert the biconditional +— a warning with this reason is present **if and only if** that node's style is not literal, given +the prompt has a newline and is valid UTF-8. Asserting instead that a particular input character +produces a warning rebuilds the enumeration this AC exists to delete, and will drift the same way.)* + +**AC-46** — When a **non-empty** prompt contains no newline at all (as `## Definitions` defines +it) and is valid UTF-8, the system shall emit it in whatever single-line form YAML requires — +plain, single-quoted, or double-quoted as the value demands — and shall **not** add a warning. + +*(Probed against yaml.v3: `"Do the thing"` emits as the plain scalar `prompt: Do the thing`, and +`"Do the thing "` as `prompt: 'Do the thing '`. Neither is a literal block scalar, and neither is a +degradation: a block scalar is unreachable for a string with no newline, so reporting one would +misdescribe a structural impossibility as a fault. This is why AC-17's antecedent requires a +newline — not, as an earlier draft of this rationale claimed, to stop AC-17 firing on every +single-line prompt in the store; AC-17's newline clause already prevents that on its own. +The **non-empty** qualifier is load-bearing in the other direction: the empty string has no newline +and is valid UTF-8, so without it this AC would require emitting `prompt: ""` for a prompt AC-4 +requires be omitted entirely, and the two would mandate different documents. AC-4 wins — an empty +prompt is omitted and no prompt AC applies to it. The valid-UTF-8 qualifier is load-bearing too: +without it a single-line invalid-UTF-8 prompt would satisfy this AC and AC-47 simultaneously and +they mandate different bytes. AC-47 wins, and says so.)* + +**AC-47** — When a prompt is not valid UTF-8, the system shall emit it as the `!!binary` value +yaml.v3 produces, add a warning naming the automation and carrying the `invalid UTF-8` message +AC-42's vocabulary table defines for this condition, and still return `200`. This applies **whether or not the prompt contains a newline**; it takes precedence +over both AC-46 and AC-17; and `invalid UTF-8` shall be the **only** reason emitted for that +prompt — AC-17's style check is not additionally applied, because yaml.v3 has already left the +text representation entirely. This condition shall **not** be treated as a serialization failure +under AC-36. + +*(Probed: `"line one\nbad\xff\xfebyte\n"` does not error — yaml.v3 emits +`prompt: !!binary bGluZSBvbmUKYmFk//5ieXRlCg==` — and the no-newline form +`"bad\xff\xfebyte no newline"` emits `prompt: !!binary YmFk//5ieXRlIG5vIG5ld2xpbmU=`. The emitted +node carries **tag `!!binary`**, which is how this case is told apart from AC-17's without +re-examining the bytes; `utf8.ValidString` on the source is equivalent and either is conforming. +That second case is why the precedence is stated outright: `!!binary` is none of the three +single-line forms AC-46 permits, so without an explicit winner the two ACs mandate different bytes +for one input. The prompt column is SQLite TEXT read into a plain Go `string` and nothing enforces +UTF-8 validity, so a row written by direct SQL or an external tool is representable. Byte fidelity +under AC-15 is preserved by the base64 — probed, it decodes back byte-identical; what is lost is +readability, which is exactly the AC-17 degradation class. Failing the whole workspace's export +over one bad row would be the wrong trade.)* + +**AC-49** — If a prompt is valid UTF-8 and the scalar the pinned marshaller emits for it does +**not** decode back to the stored prompt byte-for-byte, then the system shall emit that prompt in +the final document as a double-quoted scalar carrying an explicit `!!str` tag, shall verify that +this form decodes back byte-for-byte, and shall add **exactly one** warning naming the automation +and carrying the `prompt re-quoted to preserve bytes` message AC-42's vocabulary table defines. +Export shall not fail, and the stored prompt shall not be altered to make the default form work. + +Both the default form and the double-quoted form are checked **in the probe phase**, on throwaway +single-prompt marshals, before the final document is built. `## Decisions`' prohibition on +marshalling, inspecting, amending and re-marshalling binds the **final document body** only; the +per-prompt probes are not that body and a second probe of the re-quoted form is required rather +than merely permitted. + +If the double-quoted form also fails to decode back byte-for-byte, that is a serialization +failure under AC-36 and the export shall return `500` with no partial document. No prompt whose +bytes could not be preserved is ever emitted. + +*(This AC exists because style and fidelity are independent, and four review rounds checked only +style. Probed: `"\nhello"` exports and decodes back as `"hello"`, `"\n"` as `""`, `"\n\nhello"` as +`"\nhello"`, `"\n\n"` as `"\n"` — and in every one of those the emitted scalar is a **literal +block scalar**, so AC-16 is satisfied and AC-17's antecedent is false. Nothing warned, nothing +failed, and the committed artifact silently differed from the database. A prompt pasted with a +leading blank line is ordinary user behaviour, so this is not an edge case reachable only by +direct SQL.* + +*The remedy preserves rather than merely reports, because the card requires the export be "enough +to recreate the automation by hand" and a lost leading newline defeats that no matter how loudly +it is announced. Probed across twelve prompt classes, the double-quoted form round-trips +byte-identical in **all twelve**, including the four the default emission loses; the `!!str` tag +is required so a prompt whose entire text is `true` or `null` is not re-resolved to another type.* + +*The `500` clause is a backstop that no probed input reaches. It is stated because the +alternative to failing, in a case where both forms lose bytes, is committing corrupted data to a +git repository — and the whole feature exists to stop that. It is deliberately narrower than +AC-47, which returns `200`: an invalid-UTF-8 prompt is losslessly representable as `!!binary`, so +there is nothing to fail about.* + +*Testability: a test for this AC shall parse the **exported document**, decode the automation's +`prompt` node, and assert equality with the stored prompt, over a fixture corpus that includes +`"\nhello"`, `"\n"`, `"\n\nhello"` and `"\n\n"` alongside prompts that round-trip under the +default emission. Asserting only that a warning appears for a named input would re-create the +enumeration problem AC-17 exists to avoid; the observable is the round trip itself.)* + +### Portable references + +**AC-18** — When an automation references an agent profile, an executor profile, a +workflow and step, or repositories, the system shall emit the portable descriptors defined +in `## Data model` and shall emit no UUID for any of them. + +**AC-19** — If a referenced agent profile, executor profile, workflow, workflow step, or +repository **is absent**, then the system shall omit that reference from the +automation, emit a warning naming the automation and the unresolved reference, and still +export the automation. Export shall not fail because a reference is dangling. Partial +resolution is resolved as follows, and each case emits its own warning: + +- **Workflow resolves, step does not:** emit `workflow` with `name` only and no `step` key. + The workflow reference is still true and still useful to a human recreating the automation. +- **Workflow does not resolve:** omit the whole `workflow` descriptor, including any step. + A step name without its workflow names nothing. +- **Repositories, some members unresolved:** drop only the unresolved members and keep the + resolved ones in their AC-8 order. If every member is unresolved the list becomes empty + and is then omitted under AC-4. +- **Agent or executor profile unresolved:** omit that whole descriptor. Neither is + meaningful partially populated. + +**This AC applies only to a reference that is actually made.** A foreign-key column holding the +empty string references nothing, is not "absent", and shall produce **no** warning. Specifically: +when `workflow_id` is non-empty and `workflow_step_id` **is** empty, the system shall emit +`workflow` with `name` only and no `step` key, and shall add no warning — the same document shape +as the unresolved-step case above, but silent. When `agent_profile_id`, `executor_profile_id` or +`workflow_id` is empty, AC-4 omits that descriptor and no warning is emitted. An empty +`repository_ids` list is likewise AC-4's business, not this AC's. + +*(Stated because `Automation.WorkflowStepID` is a plain `string` and +`internal/automation/models.go:79` documents the pair outright: "WorkflowID / WorkflowStepID are +optional: no automation run is placed…". A workflow without a step is therefore an ordinary, +service-supported configuration, not a damaged row. AC-4's key list is flat and names `workflow` +as a whole, so it cannot express the nested `step`, and this AC's own list covered only a step +that was referenced and could not be found — leaving the common case matching neither. A builder +resolving that silence toward "warn" would emit `unresolved workflow step` for every correctly +configured step-less automation, filling `warnings` with false positives in an artifact whose +entire value is a quiet, reviewable diff. The distinction is between "you asked for something that +is not there", which is worth a human's attention, and "you asked for nothing", which is not.)* + +**"Absent" and "the lookup failed" are different outcomes and this AC covers only the first.** +Every descriptor lookup shall report three outcomes distinguishably — **resolved**, **absent**, and +**failed**. Absent takes the path above. **Failed does not**: an error from a descriptor lookup is +an infrastructure failure and returns `500` under AC-45, with no partial document and no warning. + +*(Stated because the spec applies exactly this discipline to the workspace authorizer and the +workspace lookup — AC-44 steps 1 and 2, both classifying on `repoerrors.ErrWorkspaceNotFound` — +and applied nothing equivalent one level down, leaving "cannot be resolved" to cover both. Without +the distinction a database error while reading `agent_profiles` is silently reported as a dangling +reference: the export returns `200`, the committed artifact loses a reference that was never +actually dangling, and a later applier recreates the automation without it. The precedent a builder +would reach for makes this concrete rather than theoretical — +`buildAgentProfileResolver` (`backendapp/services.go`) is written +`if err != nil || profile == nil { return nil }`, collapsing both outcomes into one nil and calling +`context.Background()` besides. **It must not be reused for this export**, and the lookups defined +in AC-29 exist partly to replace it. A two-valued `(value, ok)` signature cannot express this AC and +is non-conforming; `(value, found bool, err error)`, or a `(value, error)` whose absence is a +distinguishable sentinel the caller matches with `errors.Is`, both can. Which of the two is the +builder's choice — what this AC fixes is that absence and failure must be separable by the caller +without inspecting an error string.)* + +**AC-20** — The system shall carry warnings inside the exported artifact itself, not in a +transport-level sidecar: as a top-level `warnings` sequence in the single-document form, +and as `.kandev/automations/WARNINGS.txt` in the zip form. Where there are no warnings the +`warnings` key shall be omitted and the file shall be absent. + +*(The body is `application/yaml`, so there is no envelope to put a sidecar in, and a +response header cannot carry multi-line text. Keeping warnings in the artifact also means +a dangling reference shows up in the committed diff, which is where the reviewer will +actually see it.)* + +**AC-21** — The system shall order warnings by automation name ascending, tiebroken by +`automations.id` ascending, then by the warning text ascending, so that AC-9 holds when +warnings are present. + +**AC-42** — Each warning shall be a single-line YAML **string scalar** — a string, never a +mapping — of the form `: `, containing no newline as `## Definitions` +defines it. The `warnings` value +is a sequence of those strings. `WARNINGS.txt` shall contain the same strings in the same AC-21 +order, one per line, UTF-8, each line terminated by `\n` including the last. + +**The emitted style is yaml.v3's choice and is not a plain scalar.** Every warning contains +`": "`, which makes a plain scalar impossible, so yaml.v3 single-quotes it. Probed: +`- 'Daily Sync: unresolved agent profile'`, and an entirely benign name is quoted identically +(`- 'plain name: unresolved workflow'`). A test for this AC shall assert on the **decoded string +value** and on the node being a scalar rather than a mapping; it shall **not** assert a plain +style, which no compliant export can produce for any warning this spec defines. + +**Name and type rendering.** `automations.name` is unconstrained `TEXT`, and +`automation_triggers.type` is `TEXT NOT NULL` with no CHECK constraint +(`internal/automation/store.go`), so both are unconstrained in practice regardless of what the +service accepts. Before interpolation, **both** the automation name and the trigger type shall be +rendered by applying all four of the following, and nothing else: + +1. U+000A becomes `\n` and U+000D becomes `\r`. +2. Every other character in U+0000–U+001F, U+007F, **and U+0085** becomes `\x` followed by + exactly two lowercase hex digits. +3. **U+2028 and U+2029 become `\u` followed by exactly four lowercase hex digits** — that + is, the six ASCII characters `\u2028` and `\u2029` respectively. +4. **Every byte that is not part of a well-formed UTF-8 sequence becomes `\x` followed by exactly + two lowercase hex digits.** After this step the rendered value is valid UTF-8 by construction. + +No other character is altered. + +The four rules operate on disjoint inputs, so the order in which they are applied cannot change +the result: rules 1 and 2 touch only characters below U+0080 plus U+0085, rule 3 touches exactly +two characters, and rule 4 touches only bytes that are part of no well-formed sequence — which are +all at or above 0x80 and, U+0085 being well-formed `C2 85`, never the characters rule 2 names. + +*(Rules 2 and 3 gained U+0085, U+2028 and U+2029 when `## Definitions` fixed **newline** as YAML's +five-character line-break set. Before that, this AC required each warning to contain "no newline" +while its rendering rules preserved three characters that are newlines under that definition, and +closed with "No other character is altered" — so the AC forbade the only edit that could satisfy +it. The failure was not theoretical in either output form. Probed: a name carrying U+2028 emits as +`- 'Daily Sync: unresolved agent profile'` — the raw character survives into the file and +yaml.v3 folds the line around it — and the same raw bytes are written to `WARNINGS.txt`, where a +reader that honours the YAML and Unicode line-break set sees two lines where this AC promised one. +Escaping them keeps one warning on one line under every reading.* + +*Rules 1 and 2 otherwise exist because a stored newline in either value makes "containing no +newline" and "of the form `: `" jointly unsatisfiable. Rule 4 exists +because both +columns are `TEXT` and SQLite does not validate encoding, so a row written by direct SQL can carry +an invalid byte — and without it that byte reaches the warning string, at which point yaml.v3 +emits the **whole warning** as `!!binary` by exactly the mechanism AC-47 documents for prompts. +That would break this AC twice over: a base64 blob is neither a string scalar of the stated form +nor a line of the UTF-8 `WARNINGS.txt` promised above. Escaping the bytes keeps the warning +readable, keeps both output forms well-formed, and keeps the byte sequence recoverable by a human +reading the diff. Note the asymmetry with AC-47, which is deliberate: an invalid-UTF-8 **prompt** +is preserved exactly and allowed to become `!!binary`, because the prompt is the payload and +fidelity outranks readability; an invalid-UTF-8 **name inside a warning** is escaped, because the +warning is diagnostic text whose whole job is to stay readable.)* + +**De-duplication is scoped to the entity the message describes**, per the `Scope` column of the +vocabulary table below. Two identical messages for the same **automation** are emitted once; two +automations that produce byte-identical warning strings each keep their own. The AC-39 messages +describe a **trigger**, so their scope is the trigger: two triggers on one automation that +produce the same string each keep their own line. + +*(The scope of the dedup is the whole point, at both levels. Automation names are not unique — +neither `automations` nor `Service.CreateAutomation` enforces it — so two automations named "Daily +Sync" that both have an unresolved agent profile produce the same string, and a global dedup would +silently drop the second, contradicting AC-19's promise that each unresolved reference emits its +own warning. The identical argument applies one level down: the AC-39 messages name a trigger +**type**, never a trigger identity, and `automation_triggers` has no UNIQUE constraint on +`(automation_id, type)` — AC-8 tiebreaks triggers by `automation_triggers.id` precisely because two +triggers can share a type. An automation-scoped dedup would therefore collapse two malformed +same-type triggers into one line and report one problem where there are two, which is the exact +silent loss this feature exists to prevent. Per-trigger scope costs a repeated line and keeps the +count. AC-9 is unaffected: identical strings sort identically under AC-21, so the bytes stay +deterministic. The prompt messages are automation-scoped and AC-17 now emits at most one of them +per prompt, so for those the dedup is a no-op.)* + +**Message vocabulary.** The message half is fixed text, because AC-21 sorts warnings by it and +AC-9 makes it part of the artifact's bytes; two reasonable wordings would otherwise produce two +different committed files. The messages are exactly: + +| Condition | Scope | Message | +|---|---|---| +| Agent profile unresolved (AC-19) | automation | `unresolved agent profile` | +| Executor profile unresolved (AC-19) | automation | `unresolved executor profile` | +| Workflow unresolved (AC-19) | automation | `unresolved workflow` | +| Workflow resolved, step unresolved (AC-19) | automation | `unresolved workflow step` | +| Repository unresolved (AC-19) | automation | `unresolved repository at position ` | +| Trigger config not valid UTF-8 (AC-39) | trigger | `trigger : config is not valid UTF-8` | +| Trigger config not valid JSON (AC-39) | trigger | `trigger : config is not valid JSON` | +| Trigger config valid JSON but not an object (AC-39) | trigger | `trigger : config is not a JSON object` | +| Prompt not emitted as a block scalar (AC-17) | automation | `prompt not emitted as a block scalar` | +| Prompt not valid UTF-8 (AC-47) | automation | `prompt not emitted as a block scalar: invalid UTF-8` | +| Prompt re-quoted to preserve bytes (AC-49) | automation | `prompt re-quoted to preserve bytes` | + +*(The repository message names a `position` rather than an id because AC-18 keeps UUIDs out of the +artifact and the unresolved repository has no name to give; `automation_repositories.position` is +the stable handle that survives. The `Scope` column is what the dedup rule above keys on. The three +prompt-degradation rows this table previously carried — one each for trailing space, carriage +return and non-printable character — are replaced by the single row above: AC-17 no longer +classifies **why** a block scalar was lost, only **that** it was, so there is exactly one message +and it cannot drift out of step with the library. `invalid UTF-8` survives as its own row because +AC-47 detects it from the emitted tag rather than from any character list, so it carries no drift +risk and is worth telling a human apart from an ordinary degradation.)* + +### Round-trip completeness + +**AC-22** — The system shall hold a Go disposition table — a literal in the test package listing +every field of `Automation` and `AutomationTrigger` with its disposition — and a test that +enumerates both structs by reflection and asserts each field is accounted for by exactly one entry +in that table. Adding a field to either domain struct without adding it to the Go table shall fail +the test. The markdown table in `## Data model` › "Field disposition" is documentation **of** the +Go table and must be updated alongside it; the Go table is the test oracle. + +*(This is the anti-`reports_to` guard. Office's config export declares `reports_to` in its DTO, +emits it, and drops it on import; nothing failed. A field that is neither exported nor consciously +excluded is the defect. The oracle is a Go literal rather than the markdown table parsed at +runtime because a test that reads a docs path is fragile in a way that gets it deleted; the cost +is that markdown drift is a documentation bug rather than a test failure, which is the right +trade for a guard whose job is preventing silent data loss. The match is against an explicit +disposition list, not against DTO field names, because five domain fields are deliberately renamed +on the way out and one concept is carried by two struct fields — a name-identity test cannot +express either, and would fail on first run for bookkeeping reasons, which is the surest way to +get the guard loosened.)* + +*(Division of labour, stated because it is easy to assume this AC does more than it does: AC-22 +proves every field is consciously **classified**. It does not prove the DTO actually marshals an +exported field to its key — that is AC-23 — and it cannot see a column that never becomes a +struct field — that is AC-43. All three are required.)* + +**AC-43** — The system shall hold a test over a fixture in which every excluded column +(`webhook_secret`, `last_evaluated_at`, `last_triggered_at`, `created_at`, `updated_at`, +`workspace_id`, `automation_id`, `id`, `execution_mode`, `repository_id`, `legacy_board_card`) +holds a distinctive sentinel value where its type permits one, and shall assert **both** of the +following against both output forms: + +1. **No sentinel value appears** anywhere in the serialized bytes. +2. **No key resolves to an excluded column**, checked structurally: parse the output, walk every + mapping key at every depth *outside the `config` subtrees*, normalize each key by lowercasing + it and removing `_` and `-`, and assert none equals the same normalization of any excluded + column name. Every name in that list is written **bare**, with no table qualifier: the + normalization strips only `_` and `-`, so a qualified `automations.repository_id` would + normalize to `automations.repositoryid`, retain its `.`, and be unable to match any YAML key + this export can produce — leaving the one column half 2 exists for with no coverage at all + while the check appeared to pass. + +*(Both halves are load-bearing and neither subsumes the other. Half 2 exists because the failure +this backstops is yaml.v3 **mangling key names**: marshalling the domain struct emits +`webhooksecret` and `lasttriggered`, so a fire-anchor leak would appear under `lastevaluatedat` +and a test grepping for the literal string `last_evaluated_at` would pass while leaking. Half 1 +exists because a leak can also arrive under a key nobody predicted, where only the value gives it +away. The `config` subtrees are excluded from half 2 because AC-11 requires an unknown config key +to survive verbatim, and a user's config key legitimately named `webhook_secret` is preserved +data, not a leak; the sentinel check in half 1 still covers the values. Normalizing keys before +comparison is what makes half 2 robust to the mangling instead of blind to it, and matching on +normalized keys rather than raw substrings is what stops an ordinary prompt or description +containing the words "created at" from failing a compliant export.)* + +*(`legacy_board_card` is on the list for half 2 only, and the "where its type permits one" clause +above is what exempts it from half 1. It is a Go `bool` derived from the withdrawn `execution_mode` +column, and a bool has no distinctive sentinel value — `true` appears legitimately throughout the +document as `enabled`. Half 2 still applies in full and is the half that matters here: AC-7 forbids +this key under any name, and marshalling the domain struct would emit it as `legacyboardcard`, +which normalizes to exactly what half 2 compares against. Omitting it from this list entirely, as +an earlier revision did, left the only structural guard in the spec blind to a column AC-7 names +explicitly — AC-22 proves it was consciously classified, which its own parenthetical is careful to +say is not the same thing.)* + +*(AC-22 cannot cover all of these. `automations.repository_id` and `execution_mode` are **not +fields on the `Automation` struct at all** — `repository_id` is touched only by raw SQL in the +legacy backfill path, and `execution_mode` reaches Go solely as the derived `legacy_board_card` +alias. A reflection pass over struct fields structurally cannot enumerate a column that never +becomes a field, so the two exclusions this spec singles out by name would have had zero coverage +from the mechanism named as their enforcer, and any future SQL-only column would slip the same +way.)* + +**AC-23** — The system shall hold a test that exports a fully-populated automation — every +optional field set, at least two triggers, at least two repositories — parses the exported +document back with `yaml.Unmarshal` into a generic `yaml.Node` tree, and asserts that for every +row in the AC-22 disposition table marked `exported`, the value reachable at its YAML key equals +the **expected value declared literally in the test**. Absence of a key whose source value was +non-empty shall fail the test. + +The expected value is defined per row, because six rows are transforms and equality with the raw +source field is false by design for all six: + +- **Untransformed rows** (`name`, `description`, `prompt`, `task_title_template`, `enabled`, + `max_concurrent_runs`, and each trigger's `type` and `enabled`): the expected value is the + source field itself. +- **The five reference rows** (`WorkflowID`, `WorkflowStepID`, `AgentProfileID`, + `ExecutorProfileID`, `RepositoryIDs`): the expected value is the **descriptor the fixture's + referenced rows imply**, written into the test as a literal — e.g. the agent profile fixture's + `{agent_name, model, mode}` triple spelled out, not read back from the profile row and not + produced by calling any exporter helper. AC-18 forbids emitting the UUID, so comparing against + the raw foreign key would fail against a correct implementation. +- **`Config`**: compared as a tree, with every scalar compared on **both** its **lexeme** — the + `yaml.Node.Value` string — **and its re-parsed `yaml.Node.Tag`**, against the corresponding + number or string as it appears in the stored JSON. The expected tag is `!!str` for every JSON + string and every mapping key; for a JSON number the assertion is that the tag is **not** + `!!str`, which is the observable form of AC-41's "emitted as an unquoted YAML number". The + fixture shall include at least one string value whose text would resolve to another YAML type + if emitted bare — `"true"`, `"null"`, `"1.0"`, `"0755"`, `"12"` or `"~"`. + This is why the parse target is `yaml.Node` and not `map[string]any`: a + `map[string]any` decode discards the emitted characters (`1.0` reads back as `float64(1)`) and + would silently pass an AC-41 violation. + + *(The tag half is not redundant with the lexeme half and the fixture requirement is not + decoration — together they are what makes this test able to fail. A JSON string `"true"` + emitted bare re-parses with `Value == "true"` and `Tag == "!!bool"`: the lexeme matches the + stored string exactly, so a lexeme-only comparison passes on corrupted output. And a fixture + built only from ordinary strings never produces a scalar whose bare form is ambiguous, so the + tag assertion would pass vacuously. The defect this guards is a type change with no textual + trace, which is precisely the class the lexeme comparison was chosen to catch for numbers and + is structurally blind to for strings.)* + +*(Recovery is pinned to a generic node parse rather than to a typed decode on purpose: a typed +decode would be a second implementation of the exporter's own field list and would inherit its +blind spots — a field dropped from both sides would still pass. For the same reason the expected +descriptors must be literals in the test rather than computed by exporter code; a shared +projection reproduces the exporter's omission and the guard passes for the wrong reason. The +applier is out of scope, so "recoverable" has no other reader to define it.)* + +### File layout and naming + +**AC-24** — When the zip export is requested, the system shall write one file per +automation at `.kandev/automations/.yml`, each containing the full envelope +(`version`, `type`, `automations`) with a single-element `automations` list. + +**AC-25** — The system shall derive `` from the automation name by lowercasing, +replacing every character outside `[a-z0-9]` with `-`, collapsing consecutive `-`, +trimming leading and trailing `-`, and truncating to 64 characters followed by a further +trailing-`-` trim. + +*(Verified by running this algorithm over all 7 live names: every one yields a distinct +slug, the longest being 53 characters, so the 64-character truncation is not exercised by +the current corpus. `Daily Review — @kegmil/offline-first` yields +`daily-review-kegmil-offline-first`; `Daily km-mobile-app-v2 repo drift --all` yields +`daily-km-mobile-app-v2-repo-drift-all`. Office's config export writes +`.kandev/agents/.yml` using the raw name with no sanitization at all; **3 of the 7** +live automation names contain `/`, which under that approach silently creates a nested +path instead of a file.)* + +**AC-26** — If the derived slug is empty, then the system shall use `automation` as the +slug. *(Reachable and verified: the name `日次レビュー` collapses to the empty string under +AC-25 and must fall back.)* + +**AC-27** — If two automations in one export derive the same slug, then the system shall +append `-` followed by the first 8 characters of each colliding automation's `id`, applied +to every member of the colliding group so no member keeps the bare slug. If any resulting +name still collides with another entry in the same export — whether with a suffixed or an +unsuffixed one — the system shall lengthen the suffix for the still-colliding entries, 8 +characters at a time, up to the full `id`, until every entry path in the archive is unique. +*(Automation names are not unique: neither `automations` nor `Service.CreateAutomation` +enforces uniqueness — only non-emptiness. Two `id` values sharing their first 8 characters +is vanishingly unlikely in a real workspace, and `id` is unique, so widening to the full +value always terminates. This is specified for the same reason AC-26 specifies the +empty-slug fallback for a case the live corpus does not contain: AC-24 promises one file +per automation, and a silent overwrite would break that promise by losing an automation +entirely rather than by failing.)* + +**AC-28** — When the zip export is requested, the system shall order zip entries by entry +path ascending, and shall leave each entry's modification time unset so no wall-clock value +enters the archive. *(Entry order alone does not make a zip reproducible — timestamps do +too. Probed: `zip.Writer.Create` leaves `FileHeader.Modified` zero and produces +byte-identical archives across runs for identical content, and is byte-identical to +`CreateHeader` with `Modified` unset. Office's export is therefore reproducible by accident +rather than by contract; AC-9 now binds the archive bytes, so this is pinned deliberately.)* + +### Consistency, permissions, failure + +**AC-29** — When an export is served, the system shall read the automations, their triggers, +their repository links, and every row it resolves a portable descriptor from, within a **single +read transaction opened on the store's reader handle**, so a concurrent create, update, or delete +cannot produce a document containing a trigger whose automation is absent, an automation missing +triggers it holds, or a descriptor resolved against a different snapshot than the row referencing it. + +**The export shall establish that transaction's snapshot immediately upon opening it**, by issuing +its first read inside the transaction before performing any other work. This is what makes +`## Definitions`' *start* a single well-defined moment rather than an interval, and it is what +AC-13 and AC-30 are stated against. + +*(SQLite's deferred transaction takes its snapshot at the first read, not at `BEGIN`. Left to the +builder, the gap between the two is unbounded — descriptor-lookup wiring, slug precomputation and +warning-buffer setup could all run inside it — and any commit landing in that gap is visible to an +export that had already "started". AC-13 was falsifiable that way by an implementation doing +nothing wrong; see `## Definitions` for the trace. Closing the gap costs one read.)* + +Satisfying this **requires new read methods on the automation store** that accept the transaction, +and that work is in scope for this card. `Store` today splits `db` (writer) and `ro` (reader), +every read path takes only a `ctx` and issues against `s.ro` directly, `BeginTxx` is called only on +the writer, and no read transaction on the reader handle exists anywhere in the backend — so there +is nothing to reuse. See `## Decisions` › "Export is additive and read-only" for why adding them +does not contradict that decision. + +**Descriptor rows are covered by this AC too, and the existing lookup seams cannot carry them.** +The automation package's four injected lookups return the wrong data for AC-18 and none accepts a +transaction: + +| Existing seam | Returns | Why it cannot serve the export | +|---|---|---| +| `AgentProfileLookup.AgentProfileExists(ctx, id)` | `(bool, error)` | AC-18 needs `{agent_name, model, mode}` | +| `WorkflowLocator.WorkflowWorkspaceID(ctx, id)` | `(string, error)` | AC-18 needs the workflow **name** and the **step name** | +| `RepositoryLookup.GetRepository(ctx, id)` | `(workspaceID, defaultBranch string, ok bool)` | AC-18 needs the repository **name**; also two-valued, which AC-19 forbids | +| *(none)* | — | **No executor-profile lookup exists on the automation service at all** | + +The export shall therefore define **new, transaction-accepting descriptor lookups** — one each for +agent profile, executor profile, workflow-and-step, and repository — following the established +`SetWorkflowLocator` / `SetRepositoryLookup` injection pattern: the interfaces are declared in the +automation package, each method takes the export's read transaction — the `*sqlx.Tx` opened on the +store's reader handle — as its first argument after `ctx`, and each reports the three outcomes +AC-19 requires. + +**Where the SQL lives is part of this contract.** Each new lookup shall be satisfied by a **new +exported, transaction-accepting read method on the store that owns the table** — the +agent-settings store, the workflow repository, and the repository store respectively — and the +adapter constructed in `backendapp` shall do nothing but call that method, passing the transaction +through. **No SQL text shall be written in `backendapp`.** + +*(This is stated because the AC is otherwise satisfiable two ways and only one of them is +consistent with the codebase. Verified: `internal/backendapp` today contains no raw SQL and no +`*sqlx.Tx` usage, every existing `Set*Lookup` adapter delegates to the owning package's exported +method, and the only `*sqlx.Tx` parameters in `internal/workflow/repository` and +`internal/agent/settings/store` are unexported helpers operating inside those packages' own +transactions. An adapter issuing its own `SELECT` against `agent_profiles`, `workflows`, +`workflow_steps` or `repositories` would be the first of its kind in the tree, and would put query +text for four tables in a package that owns none of them — with no compile-time signal when an +owning package renames a column. That is precisely the silent drift AC-22 and AC-43 exist to +prevent for this export's own fields, reintroduced one layer out. Delegation keeps each query +beside the schema it reads and keeps the seam doing what every other seam here does.* + +*The seams exist so the automation package does not import the task, agent-settings or repository +services and create a cyclic import — AC-44 step 2 relies on the same rationale — so moving the +SQL into the automation package is not available either. Passing the transaction across the seam +costs nothing new: the automation package already depends on `sqlx` for its own `Store`, every one +of these tables lives in the same SQLite database, and one read transaction can read all of them.* + +*The alternative considered and rejected was to exempt descriptor rows from this AC, which would +mean a descriptor resolved against a different snapshot than the automation referencing it — +exactly the inconsistency the AC exists to prevent, and the one a git-committed artifact would +preserve forever.)* + +This AC is observed by a unit test, not by a race: the export path shall obtain its snapshot once +and pass it to every read — store reads and descriptor lookups alike — and the test asserts, with a +store double and four lookup doubles that each record the handle they were given, that every +recorded handle is the same one and that no read arrived with no handle. + +*(Stated because a race is not deterministically testable, and an AC whose only verification is +"run it and hope" is one a builder can satisfy by doing nothing. Making the shared handle the +observable turns a timing property into a structural one. The lookup doubles are named explicitly +because a test that instruments only the store passes while every descriptor read runs outside the +transaction, which was true of an earlier draft of this AC.)* + +**AC-30** — While an export is in flight, a concurrent mutation of the same workspace's +automations shall neither block nor be blocked by it; the export reflects the snapshot +open at its *start*, as `## Definitions` fixes that term — the moment its read transaction +establishes its snapshot, which AC-29 requires to happen immediately upon opening the +transaction. + +*(Probed, not reasoned, because an earlier round cleared this claim on general grounds that turned +out to be incomplete. The probe replicates both DSNs from `internal/db/sqlite.go` exactly — writer +`_foreign_keys=on&_mode=rwc&_busy_timeout=5000&_journal_mode=WAL&_synchronous=NORMAL&_cache=shared`, +reader `_foreign_keys=on&_mode=ro&_busy_timeout=5000&_cache=shared` — opens a read transaction on +the reader pool, establishes its snapshot with a first read, and then writes the same table on the +writer pool. Result: `journal_mode = wal`; the `UPDATE` and a subsequent `INSERT` each **succeeded +immediately with no block**; and the still-open read transaction continued to see the pre-write +value, so snapshot isolation holds. A read transaction opened after the write also did not block. +AC-30 stands, with evidence.* + +*One dependency is worth naming, because it is not what it appears to be. Both DSNs carry +`_cache=shared`, and SQLite shared-cache mode uses table-level locks under which this AC would +fail. It does not fail, because **`_cache=shared` is inert**: SQLite's URI parameter is `cache`, +and the driver's own parameters are `_busy_timeout`, `_journal_mode` and friends — neither honours +`_cache`, so shared cache is never enabled. Probed side by side against the same code path: +`_cache=shared` and no cache parameter at all behave identically (write succeeds), while a true +`cache=shared` fails the write immediately with `database table is locked: automations` — and +immediately, because `busy_timeout` does not retry `SQLITE_LOCKED`. **If that DSN is ever +"corrected" to `cache=shared`, this AC breaks at once and hard.** That is a pre-existing +observation about the database configuration, not a defect this card introduces or fixes; it is +recorded here so the dependency is visible rather than accidental.)* + +**AC-31** — When a user without access to the target workspace requests an export, the system +shall deny the request through the same workspace authorizer used by every other automation +operation (`Service.authorizeWorkspace`, wired to `taskSvc.AuthorizeWorkspaceAccess`), return +`404`, and return no automation data and no error text distinguishing denial from absence. + +*(`404` and not `403`, deliberately. `ErrAutomationNotFound` is documented as covering "both a +missing automation and one in a workspace the caller cannot reach, so authorization never leaks +which of the two it is". A `403`/`404` split on this endpoint would reintroduce exactly the +existence leak that sentinel exists to prevent: a caller could enumerate which workspace IDs are +real by reading the status code.)* + +*(Testing note, because the obvious fixture observes the opposite: the authorizer returns `nil` +without checking anything when the caller context carries no user scope, which is Kandev's default +single-user mode. A test for this AC must supply a **scoped** caller context; run unscoped it +observes `200` and the AC looks wrong.)* + +**AC-44** — The system shall evaluate the request in this order, and shall emit no automation +data unless every step passes: + +1. **Authorize** the workspace through `Service.authorizeWorkspace`. Classify the result by + sentinel, not by position: `nil` → continue; an error satisfying + `errors.Is(err, repoerrors.ErrWorkspaceNotFound)` → `404` (AC-31); **any other non-nil error** + → `500` (AC-45). +2. **Resolve the workspace** through a workspace lookup injected into the automation service at + construction, following the existing `SetWorkflowLocator` / `SetRepositoryLookup` injection + precedent — the seam exists so the package does not import the task service and create a cyclic + import. `repoerrors.ErrWorkspaceNotFound` → `404` (AC-35); any other non-nil error → `500` + (AC-45); lookup not wired → `500`, since the endpoint cannot honour AC-35 without it and an + unwired lookup is a construction error rather than a deployment mode. +3. **Export.** An authorized, existing workspace holding no automations → `200` with an empty list + (AC-32). + +*(The sentinel rule in step 1 is spelled out because the wired authorizer makes the naive reading +unsafe. `taskSvc.AuthorizeWorkspaceAccess` returns **the same `repoerrors.ErrWorkspaceNotFound` +for a denied workspace and for a workspace that does not exist**, and returns any infrastructure +error from its own workspace read bare on the same path. A builder who reads "denial" as "the +authorizer said no" and treats a not-found error as "a failure that is not a denial" returns `500` +where AC-31 and AC-35 both mandate `404` — which re-opens, with the status code as the oracle, +precisely the enumeration leak AC-31 exists to close. Classifying on the sentinel makes denial and +absence indistinguishable, which is the required behaviour, and leaves `500` for the errors that +genuinely are failures.)* + +*(Why step 2 exists, stated precisely because the general claim is false: for a **scoped** caller +step 1 already answers existence, since the authorizer's own workspace read produces +`ErrWorkspaceNotFound` for a workspace that is not there. Step 2 is the path that carries AC-35 +for an **unscoped** caller — Kandev's default mode — where step 1 returns `nil` without reading +anything and `Service.ListAutomations` cannot help: the store returns an empty slice for a +workspace ID that never existed, indistinguishable from a real but empty workspace. The lookup is +therefore required and is not dead code, even though a scoped-context test will never reach it. +The automation package holds no workspace lookup of any kind today. Office's config export, which +this endpoint otherwise mirrors, never returns `404` at all — it returns `500` on any error — so +the precedent is not usable here.)* + +*(Step 2 and step 3 read from different snapshots and the gap between them is not closed. If the +workspace is deleted after step 2 succeeds, the export returns `200` with whatever the AC-29 +snapshot holds, which for a cascaded delete is an empty list. This is an accepted race: the +existence check is a gate against a workspace ID that was never real, not a guarantee that the +workspace outlives the request, and paying for the stronger guarantee would mean holding the +workspace row in the export's own transaction across a service boundary the injection seam exists +to avoid.)* + +**AC-32** — When a workspace has no automations, the system shall return a valid document +with `version`, `type`, and an empty `automations` list, and the zip form shall be a valid +archive containing no automation files. This is not an error. + +**AC-33** — When an automation's stored `name` is the empty string, the system shall emit +`name: ""` and derive its filename through the AC-26 fallback. *(`Service.CreateAutomation` +rejects an empty name, but the `automations.name` column permits one, so a row written by +any other path is representable.)* + +**AC-34** — When an automation's stored `max_concurrent_runs` is zero or negative, the +system shall emit the stored value unchanged rather than substituting the service's +`1` default. *(Export reports what is in the database; it is not a validation pass.)* + +**AC-35** — If the target workspace does not exist, then the system shall return `404` and +no partial document, indistinguishable in status and body from the AC-31 denial. The +existence answer comes from the lookup named in AC-44 step 2. + +**AC-36** — If serialization of any automation fails, then the system shall return `500` +and no partial document. A partially-written body is never emitted. + +**AC-45** — If the read transaction cannot be opened, any query fails, the workspace lookup +fails for a reason other than not-found, the authorizer fails for a reason other than +denial, or the zip archive cannot be written, then the system shall return `500` with no +automation data and no partial body. + +**AC-48** — The system shall build each response body completely in memory before writing +any of it to the response, and shall write the status line only once the body is known to +be complete. + +*(AC-36 and AC-45 both promise "no partial body", and that promise is unimplementable in +the streaming shape Office uses: `exportConfigZip` sets `200` and the `Content-Disposition` +header and then `io.Copy`s the archive to the writer, so a failure mid-copy has already sent +`200` and cannot retract it — the client receives a truncated archive under a success +status. Buffering is what makes the guarantee real. The measured corpus is ~78 KB of prompt +across 7 automations, so the memory cost of buffering the whole artifact is not a concern at +this scale.)* + +### User surface + +**AC-37** — When a user opens a workspace's automations settings page, the system shall offer an +export control that downloads the zip form. The control shall be present when the page itself is +(a user who can open the page has passed the same workspace check the export authorizes against), +shall be disabled from the moment the request is issued until the download has been handed to the +browser or an error has been surfaced, and shall surface a non-blocking error message if the +response status is not `200`, leaving the page otherwise unchanged. A workspace with no automations +still shows the control enabled — AC-32 makes that a valid export, not an error. + +The control shall issue the request with `fetch`, check the response status, read the body to a +`Blob`, and trigger the download from an object URL with an explicit filename of +`kandev-automations.zip`. It shall **not** navigate to the endpoint with `window.open` or an anchor +`href`. + +*(The mechanism is pinned because the two halves of this AC are unimplementable without it, and the +precedent this spec otherwise mirrors gets it wrong. Office's equivalent control is +`window.open(officeApi.exportConfigZipUrl(activeWorkspaceId), "_blank")`: a raw navigation gives the +calling code no completion signal and no access to the response status, so there is nothing to +disable on and nothing to test for. Worse, a navigation to a non-`200` renders the error body in a +blank tab, which is the opposite of "leaving the page otherwise unchanged". Under `fetch` the +filename comes from the object URL's `download` attribute rather than from `Content-Disposition`; +the header stays on the response for anyone hitting the URL directly, but it is not what names this +download. "In flight" is defined above as the whole span from issue to hand-off precisely because +"until headers arrive" and "until the body is read" are different windows and only the wider one +prevents the second click AC-37 is guarding against.)* + +**AC-38** — All copy introduced by the export control shall be resolved through `t()` or +`` and shall be present in `en`, `pt-pt`, `zh-cn`, `zh-hk`, and `zh-tw`. + +## Failure modes + +Every row cites the AC that makes it testable. A row without one is a shadow path. + +| Condition | Behaviour | +|---|---| +| Workspace has no automations | `200`, empty `automations` list (AC-32) | +| Workspace does not exist | `404`, body indistinguishable from denial (AC-35, AC-44) | +| Caller lacks workspace access | `404`, no data, no distinguishing text (AC-31, AC-44) | +| Workspace lookup not wired | `500` (AC-44 step 2) | +| Agent/executor profile or repository row is **absent** | Reference omitted, warning emitted, export succeeds (AC-19) | +| A descriptor lookup **fails** rather than reporting absence | `500`, no partial document, **no** warning — absence and failure are distinct outcomes (AC-19, AC-45) | +| Workflow resolves but its step does not | `workflow.name` emitted without `step`, warning emitted (AC-19) | +| Some repositories resolve, others do not | Unresolved members dropped, resolved members kept in order, warning emitted (AC-19) | +| Trigger `config` is not valid UTF-8 | Empty `config` mapping, `config is not valid UTF-8` warning; checked on raw bytes before decoding and takes precedence over the next two rows, so exactly one warning is emitted (AC-39) | +| Trigger `config` is not valid JSON | Empty `config` mapping, warning naming automation + trigger type + condition; export succeeds (AC-39) | +| Trigger `config` is valid JSON but not an object | Same as above (AC-39) | +| Trigger `config` holds a number | Emitted unquoted and untagged, character-identical to storage; never reformatted, rounded, quoted, or given an explicit `!!int`/`!!float` tag (AC-41) | +| Trigger `config` keys contain digits or punctuation | Sorted byte-wise via an explicit `MappingNode`, not by yaml.v3's map sorter (AC-8) | +| Trigger `config` holds a **string** whose text would resolve to another YAML type (`"true"`, `"null"`, `"1.0"`, `"0755"`, `"12"`, `"~"`) | Emitted with an explicit `!!str` tag, so yaml.v3 quotes it and it re-parses as a string; never retyped to bool, null, int or float (AC-8, AC-23) | +| Multi-line valid-UTF-8 prompt, emitted scalar **is** a literal block scalar (any chomping indicator) | Emitted as-is, **no** warning (AC-16) | +| Multi-line valid-UTF-8 prompt, emitted scalar is **not** a literal block scalar | Emitted faithfully in whatever form yaml.v3 chose, exactly one `prompt not emitted as a block scalar` warning; export succeeds. The condition is read from the emitted node's style, never from the prompt's characters (AC-17) | +| Multi-line prompt contains a TAB | Block scalar retained, **no** warning — a TAB does not clear `block_allowed`, and this row is a consequence of the style check rather than a special case in it (AC-16) | +| Prompt contains invalid UTF-8 | Emitted as `!!binary`, `invalid UTF-8` warning added, `200` — not a serialization failure; wins over AC-46 and AC-17 whether or not the prompt has a newline, and is the only reason emitted (AC-47) | +| Single-line non-empty prompt, valid UTF-8 | Emitted as YAML requires, **no** warning — a block scalar is unreachable without a newline, so this is not a degradation (AC-46) | +| Prompt is the empty string | `prompt` omitted entirely; no prompt AC applies and no warning is emitted (AC-4, AC-46) | +| Valid-UTF-8 prompt whose default emission does **not** decode back byte-for-byte (a prompt beginning with a newline is the reachable case) | Re-emitted as a double-quoted `!!str` scalar, which is probed to round-trip; exactly one `prompt re-quoted to preserve bytes` warning; AC-17 suppressed for that prompt so only one prompt warning is emitted; `200` (AC-49, AC-15, AC-16, AC-17) | +| A prompt that round-trips under neither the default nor the double-quoted form | `500`, no partial document — corrupt bytes are never committed. No probed input reaches this (AC-49, AC-36) | +| Prompt is invalid UTF-8 **and** would fail the fidelity test | AC-47 wins and is evaluated first; fidelity comes from base64-decoding, not from comparing the re-parsed scalar, whose value is the base64 text (AC-47, AC-49) | +| Multi-line prompt whose only line break is CR, U+0085, U+2028 or U+2029 | Treated as multi-line, since `## Definitions` fixes *newline* as YAML's five-character break set; emits non-literal, so exactly one `prompt not emitted as a block scalar` warning (AC-17, AC-46) | +| `workflow_id` set, `workflow_step_id` empty | `workflow` emitted with `name` only, **no** warning — nothing was referenced, so nothing is unresolved (AC-19, AC-4) | +| Two automations derive the same slug | Suffixed with `id` prefix, lengthened until unique (AC-27) | +| Authorizer returns a non-`ErrWorkspaceNotFound` error | `500` — not `404`; the sentinel is what distinguishes denial (AC-44 step 1, AC-45) | +| Workspace deleted between the existence check and the snapshot | `200` with an empty list; accepted race (AC-44) | +| Two same-named automations emit an identical warning | Both kept — dedup is never global (AC-42, AC-19) | +| Two same-type triggers on one automation both have bad `config` | Both warnings kept — AC-39 messages dedup per trigger, not per automation, so the count is not lost (AC-42, AC-39) | +| Automation name **or trigger type** contains a newline or control character | Escaped before interpolation so the warning stays one line. "Newline" is the five-character set, so U+0085 escapes as `\x85` and U+2028/U+2029 as `\u2028`/`\u2029`; without this a raw U+2028 survives into both the YAML scalar and `WARNINGS.txt`, where a reader honouring YAML's break set sees two lines (AC-42) | +| Automation name **or trigger type** contains an invalid UTF-8 byte | Escaped as `\xNN` before interpolation, so the warning stays a UTF-8 string scalar and does not become `!!binary` (AC-42) | +| A concurrent mutation lands while an export is in flight | Neither blocks the other; the export keeps its snapshot. Probed against both real DSNs (AC-30) | +| A mutation commits between two exports opening their transactions | Each export's *start* is its snapshot establishment, and AC-29 requires that to happen immediately on opening, so the window is one read wide and AC-13 is stated against a defined moment (AC-13, AC-29, AC-30) | +| Read transaction, query, or zip write failure | `500`, no partial body (AC-45, AC-48) | +| Serialization error | `500`, no partial body (AC-36, AC-48) | + +## Persistence guarantees + +The export performs no writes. No table is modified, no `last_evaluated_at` is touched, +no run row is created. Exporting an automation has no effect on when it next fires. + +## Out of scope + +Named exclusions, not omissions: + +- **The applier (Phase 2).** Reading exported YAML back into the database is not + specified here. It is additionally gated on an unresolved upstream question — whether + `kdlbs/kandev` considers automations a git-syncable entity at all — tracked in Office + discovery task `ba70553d`. If the answer is no, Phase 2 is a carried topic branch + rather than an upstream contribution. +- **Two-way sync** (Kandev writing back to git). +- **A poller.** Nothing is fetched from a repository on an interval. +- **Migrating agent or executor profiles into git.** They stay database-resident; the + export references them by descriptor, which is why AC-19 needs a warning path. +- **`automation_runs`.** Run history is observability, not definition, and is never + exported. +- **Importing Office `ConfigBundle` routines.** A different entity in a different package. +- **Fixing the Office `reports_to` import defect.** Referenced as a caution; it has its + own card. +- **Cross-workspace export.** Every endpoint is scoped to one workspace. +- **Secret rotation or a secret-bearing export variant.** `webhook_secret` has a + dedicated reveal endpoint and stays there.