feat(automation): export workspace automations as reviewable YAML (#2898)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
nova28
2026-08-22 05:03:35 +08:00
committed by GitHub
parent d2518a1fb9
commit 66efcb88e1
66 changed files with 7496 additions and 41 deletions
@@ -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)
@@ -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
@@ -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 {
@@ -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
}
@@ -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)
}
}
@@ -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) {
@@ -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
@@ -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"`
}
@@ -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)
}
}
}
@@ -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)
}
})
}
}
@@ -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))
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)})
}
@@ -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: <err>" 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())
}
}
@@ -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)
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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)
}
}
}
@@ -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 "<name>: <message>"
// 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
}
@@ -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)
}
}
}
@@ -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
}
@@ -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:])
}
}
@@ -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
// "<slug>" in ".kandev/automations/<slug>.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]
}
@@ -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
}
}
@@ -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
}
@@ -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))
}
}
@@ -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),
}
}
@@ -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)
}
}
@@ -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:
// `<escaped automation name>: <message>`, 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
}
@@ -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
// `<name>: <message>` 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 "<escaped automation name>: <message>".
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)
}
}
@@ -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
}
@@ -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/<slug>.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
}
@@ -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/<slug>.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))
}
}
@@ -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.
+115 -8
View File
@@ -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
@@ -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
+13 -1
View File
@@ -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{
@@ -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
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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<T>() {
let resolve!: (value: T) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise<T>((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(<AutomationsExportButton workspaceId="ws-empty" />);
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(<AutomationsExportButton workspaceId="ws-1" />);
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<Blob>();
mockExportAutomationsZip.mockReturnValue(pending.promise);
render(<AutomationsExportButton workspaceId="ws-1" />);
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(<AutomationsExportButton workspaceId="ws-1" />);
const button = getButton();
fireEvent.click(button);
await waitFor(() => expect(mockToastError).toHaveBeenCalledWith("workspace not found"));
expect(button.disabled).toBe(false);
expect(mockTriggerBlobDownload).not.toHaveBeenCalled();
});
});
@@ -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 (
<Button
type="button"
variant="outline"
size="sm"
data-testid="export-automations-button"
className="min-h-11 cursor-pointer md:min-h-7"
onClick={() => void handleExport()}
disabled={downloading}
>
<IconDownload className="h-4 w-4 mr-2" />
{t("automations:exportZip")}
</Button>
);
}
@@ -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={
<Button
type="button"
size="sm"
data-testid="new-automation-button"
className="cursor-pointer"
onClick={() => router.push(`/settings/workspaces/${workspaceId}/automations/new`)}
>
<IconPlus className="h-4 w-4 mr-2" />
{t("automations:newAutomation")}
</Button>
<div className="flex items-center gap-2">
<AutomationsExportButton workspaceId={workspaceId} />
<Button
type="button"
size="sm"
data-testid="new-automation-button"
className="cursor-pointer"
onClick={() => router.push(`/settings/workspaces/${workspaceId}/automations/new`)}
>
<IconPlus className="h-4 w-4 mr-2" />
{t("automations:newAutomation")}
</Button>
</div>
}
/>
<Separator />
+2
View File
@@ -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");
@@ -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 <a download> 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/<slug>.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();
});
});
@@ -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`));
});
});
+68 -4
View File
@@ -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();
});
});
+23
View File
@@ -90,6 +90,29 @@ export async function fetchJson<T>(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<Blob> {
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 {
@@ -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);
});
});
@@ -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<Blob> {
return fetchBlob(`${BASE(workspaceId)}/zip`);
}
+15 -1
View File
@@ -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");
});
});
+25 -12
View File
@@ -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);
}
+2
View File
@@ -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",
@@ -61,6 +61,8 @@
"excludeDraftPrs": "Ēxćĺũďē ďŕàƒţ ƤŔś",
"executorProfileLabel": "Ēxēćũţōŕ Ƥŕōƒĩĺē",
"executorProfilePlaceholder": "Śēĺēćţ ēxēćũţōŕ",
"exportFailed": "Ćōũĺď ńōţ ēxƥōŕţ àũţōḿàţĩōńś",
"exportZip": "Ēxƥōŕţ",
"failedToDeleteAutomation": "Ƒàĩĺēď ţō ďēĺēţē àũţōḿàţĩōń",
"failedToDeleteRun": "Ƒàĩĺēď ţō ďēĺēţē ŕũń",
"failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś",
@@ -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",
@@ -61,6 +61,8 @@
"excludeDraftPrs": "排除草稿 PR",
"executorProfileLabel": "执行器配置",
"executorProfilePlaceholder": "选择执行器",
"exportFailed": "无法导出自动化",
"exportZip": "导出",
"failedToDeleteAutomation": "删除自动化失败",
"failedToDeleteRun": "删除运行记录失败",
"failedToDeleteRuns": "删除运行记录失败",
@@ -61,6 +61,8 @@
"excludeDraftPrs": "排除草稿 PR",
"executorProfileLabel": "執行器設定檔",
"executorProfilePlaceholder": "選擇執行器",
"exportFailed": "無法匯出自動化",
"exportZip": "匯出",
"failedToDeleteAutomation": "刪除自動化失敗",
"failedToDeleteRun": "刪除執行記錄失敗",
"failedToDeleteRuns": "刪除執行記錄失敗",
@@ -61,6 +61,8 @@
"excludeDraftPrs": "排除草稿 PR",
"executorProfileLabel": "執行器設定檔",
"executorProfilePlaceholder": "選擇執行器",
"exportFailed": "無法匯出自動化",
"exportZip": "匯出",
"failedToDeleteAutomation": "刪除自動化失敗",
"failedToDeleteRun": "刪除執行記錄失敗",
"failedToDeleteRuns": "刪除執行記錄失敗",
+6
View File
@@ -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/<slug>.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.
+1
View File
@@ -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 |
---
File diff suppressed because it is too large Load Diff