diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go index be0961d24f..c8dc1ec6a9 100644 --- a/apps/backend/cmd/plugin-fixture/main_test.go +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -159,8 +159,12 @@ type setStateCall struct { } // fakeHost is a minimal pluginsdk.Host test double that only records -// SetState calls; the fixture plugin never exercises the other methods. +// SetState calls; the fixture plugin never exercises the other methods. It +// embeds UnimplementedHostData to satisfy the Host data API (ADR 0043) +// sub-accessors without wiring them. type fakeHost struct { + pluginsdk.UnimplementedHostData + setStateCalls []setStateCall setStateErr error } diff --git a/apps/backend/internal/analytics/models/models.go b/apps/backend/internal/analytics/models/models.go index 42777dc280..b5c04056a4 100644 --- a/apps/backend/internal/analytics/models/models.go +++ b/apps/backend/internal/analytics/models/models.go @@ -89,3 +89,37 @@ type GitStats struct { TotalInsertions int `json:"total_insertions"` TotalDeletions int `json:"total_deletions"` } + +// SessionCodeStats is the per-session lines-of-code aggregation: committed +// sums from task_session_commits, plus the PEAK pending-diff seen across the +// session's task_session_git_snapshots (not the latest snapshot, which is +// usually a clean tree after a commit/merge/archive). Committed and pending +// are reported separately and are never summed together here — a caller that +// wants a single "effective lines" number (the larger of the two, to avoid +// double-counting work that was later committed) computes that itself, the +// same way the source plugin's effectiveLines helper does. +type SessionCodeStats struct { + SessionID string `json:"session_id"` + LinesAddedCommitted int64 `json:"lines_added_committed"` + LinesDeletedCommitted int64 `json:"lines_deleted_committed"` + LinesAddedPeakPending int64 `json:"lines_added_peak_pending"` + LinesDeletedPeakPending int64 `json:"lines_deleted_peak_pending"` +} + +// SessionCodeStatsFilter narrows ListSessionCodeStats to a subset of +// sessions. Each non-empty list field is an OR-list (standard SQL IN +// semantics); the fields themselves are ANDed together. All fields are +// optional; an entirely empty filter matches every session, bounded by +// Limit/Offset. +type SessionCodeStatsFilter struct { + SessionIDs []string + TaskIDs []string + WorkspaceIDs []string + // States filters by task_sessions.state (e.g. "COMPLETED", "RUNNING"). + States []string + // Limit caps the number of returned rows. <= 0 uses the repository's + // default page size. + Limit int + // Offset skips this many rows (after ORDER BY), for simple pagination. + Offset int +} diff --git a/apps/backend/internal/analytics/repository/interface.go b/apps/backend/internal/analytics/repository/interface.go index 23f81271e0..7681b0c22d 100644 --- a/apps/backend/internal/analytics/repository/interface.go +++ b/apps/backend/internal/analytics/repository/interface.go @@ -16,4 +16,5 @@ type Repository interface { GetAgentUsage(ctx context.Context, workspaceID string, limit int, start *time.Time) ([]*models.AgentUsage, error) GetRepositoryStats(ctx context.Context, workspaceID string, start *time.Time) ([]*models.RepositoryStats, error) GetGitStats(ctx context.Context, workspaceID string, start *time.Time) (*models.GitStats, error) + ListSessionCodeStats(ctx context.Context, filter models.SessionCodeStatsFilter) ([]*models.SessionCodeStats, error) } diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index d268fdf056..b37fe0a5ae 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -60,6 +60,7 @@ func createTestDB(t *testing.T) *sqlx.DB { title TEXT NOT NULL, state TEXT DEFAULT 'TODO', is_ephemeral INTEGER NOT NULL DEFAULT 0, + metadata TEXT DEFAULT '{}', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL @@ -120,6 +121,13 @@ func createTestDB(t *testing.T) *sqlx.DB { deletions INTEGER DEFAULT 0, created_at TIMESTAMP NOT NULL ); + CREATE TABLE IF NOT EXISTS task_session_git_snapshots ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + snapshot_type TEXT NOT NULL DEFAULT '', + files TEXT DEFAULT '{}', + created_at TIMESTAMP NOT NULL + ); ` if _, err := sqlxDB.Exec(schema); err != nil { t.Fatalf("failed to create schema: %v", err) diff --git a/apps/backend/internal/analytics/repository/sqlite/session_code_stats_test.go b/apps/backend/internal/analytics/repository/sqlite/session_code_stats_test.go new file mode 100644 index 0000000000..0bf61e083d --- /dev/null +++ b/apps/backend/internal/analytics/repository/sqlite/session_code_stats_test.go @@ -0,0 +1,299 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/analytics/models" +) + +func TestListSessionCodeStats_CommittedSumsOnly(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + // Two commits on the same session: 10+20 insertions, 2+3 deletions. + execOrFatal(t, dbConn, `INSERT INTO task_session_commits (id, session_id, commit_sha, committed_at, files_changed, insertions, deletions, created_at) VALUES ('c-1', 'sess-1', 'sha1', ?, 2, 10, 2, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_commits (id, session_id, commit_sha, committed_at, files_changed, insertions, deletions, created_at) VALUES ('c-2', 'sess-1', 'sha2', ?, 1, 20, 3, ?)`, nowStr, nowStr) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + stat := results[0] + if stat.SessionID != "sess-1" { + t.Errorf("SessionID: want sess-1, got %s", stat.SessionID) + } + if stat.LinesAddedCommitted != 30 { + t.Errorf("LinesAddedCommitted: want 30, got %d", stat.LinesAddedCommitted) + } + if stat.LinesDeletedCommitted != 5 { + t.Errorf("LinesDeletedCommitted: want 5, got %d", stat.LinesDeletedCommitted) + } + if stat.LinesAddedPeakPending != 0 { + t.Errorf("LinesAddedPeakPending: want 0 (no snapshots), got %d", stat.LinesAddedPeakPending) + } + if stat.LinesDeletedPeakPending != 0 { + t.Errorf("LinesDeletedPeakPending: want 0 (no snapshots), got %d", stat.LinesDeletedPeakPending) + } +} + +func TestListSessionCodeStats_PeakPendingWinsOverLatestSnapshot(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + // Earlier snapshot: large pending diff (the peak). + earlier := now.Add(-time.Hour).Format(time.RFC3339) + execOrFatal(t, dbConn, `INSERT INTO task_session_git_snapshots (id, session_id, snapshot_type, files, created_at) VALUES (?, 'sess-1', 'working_tree', ?, ?)`, + "snap-early", + `{"a.go":{"additions":100,"deletions":40},"b.go":{"additions":50,"deletions":10}}`, + earlier, + ) + // Latest snapshot: clean tree (as it usually is after commit/archive). + execOrFatal(t, dbConn, `INSERT INTO task_session_git_snapshots (id, session_id, snapshot_type, files, created_at) VALUES (?, 'sess-1', 'working_tree', ?, ?)`, + "snap-latest", + `{}`, + nowStr, + ) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + stat := results[0] + // Peak snapshot (snap-early) has additions 100+50=150, deletions 40+10=50 — + // the latest (clean) snapshot must NOT win just because it's newest. + if stat.LinesAddedPeakPending != 150 { + t.Errorf("LinesAddedPeakPending: want 150 (peak, not latest), got %d", stat.LinesAddedPeakPending) + } + if stat.LinesDeletedPeakPending != 50 { + t.Errorf("LinesDeletedPeakPending: want 50 (peak, not latest), got %d", stat.LinesDeletedPeakPending) + } + // No commits — committed sums stay at zero and are NOT conflated with pending. + if stat.LinesAddedCommitted != 0 { + t.Errorf("LinesAddedCommitted: want 0, got %d", stat.LinesAddedCommitted) + } + if stat.LinesDeletedCommitted != 0 { + t.Errorf("LinesDeletedCommitted: want 0, got %d", stat.LinesDeletedCommitted) + } +} + +func TestListSessionCodeStats_CommittedAndPendingReportedSeparately(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + execOrFatal(t, dbConn, `INSERT INTO task_session_commits (id, session_id, commit_sha, committed_at, files_changed, insertions, deletions, created_at) VALUES ('c-1', 'sess-1', 'sha1', ?, 1, 10, 4, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_session_git_snapshots (id, session_id, snapshot_type, files, created_at) VALUES (?, 'sess-1', 'working_tree', ?, ?)`, + "snap-1", + `{"a.go":{"additions":7,"deletions":1}}`, + nowStr, + ) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + stat := results[0] + // Committed and pending must be reported as distinct fields, not summed + // (10 committed + 7 pending must NOT collapse into 17 anywhere). + if stat.LinesAddedCommitted != 10 { + t.Errorf("LinesAddedCommitted: want 10, got %d", stat.LinesAddedCommitted) + } + if stat.LinesAddedPeakPending != 7 { + t.Errorf("LinesAddedPeakPending: want 7, got %d", stat.LinesAddedPeakPending) + } + if stat.LinesDeletedCommitted != 4 { + t.Errorf("LinesDeletedCommitted: want 4, got %d", stat.LinesDeletedCommitted) + } + if stat.LinesDeletedPeakPending != 1 { + t.Errorf("LinesDeletedPeakPending: want 1, got %d", stat.LinesDeletedPeakPending) + } +} + +func TestListSessionCodeStats_NoCommitsNoSnapshotsReturnsZeros(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'CREATED', ?, ?)`, nowStr, nowStr) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + stat := results[0] + if stat.LinesAddedCommitted != 0 || stat.LinesDeletedCommitted != 0 || + stat.LinesAddedPeakPending != 0 || stat.LinesDeletedPeakPending != 0 { + t.Errorf("expected all-zero stats for session with no commits/snapshots, got %+v", stat) + } +} + +func TestListSessionCodeStats_EmptyFilterMatchesNoRows(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + results, err := repo.ListSessionCodeStats(context.Background(), models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 0 { + t.Fatalf("expected 0 sessions on an empty database, got %d", len(results)) + } +} + +func TestListSessionCodeStats_FiltersBySessionIDs(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-2', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{SessionIDs: []string{"sess-2"}}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + if results[0].SessionID != "sess-2" { + t.Errorf("SessionID: want sess-2, got %s", results[0].SessionID) + } +} + +// TestListSessionCodeStats_ExcludesConfigModeTaskSessions proves +// ListSessionCodeStats applies the same office config-mode exclusion that +// Sessions().List applies (via fetchTasksForWorkspaces' excludeConfig=true): +// a session belonging to a config-mode task (internal bookkeeping, not a +// plugin-visible work item) must not appear in CodeStats, so the two Host +// data API session reads cover the same session set. +func TestListSessionCodeStats_ExcludesConfigModeTaskSessions(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, metadata, created_at, updated_at) VALUES ('task-normal', 'ws-1', 'board-1', '', 'Normal task', 0, '{}', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, metadata, created_at, updated_at) VALUES ('task-config', 'ws-1', 'board-1', '', 'Config task', 0, '{"config_mode":true}', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-normal', 'task-normal', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-config', 'task-config', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session (config-mode task's session excluded), got %d: %+v", len(results), results) + } + if results[0].SessionID != "sess-normal" { + t.Errorf("SessionID: want sess-normal, got %s", results[0].SessionID) + } +} + +func TestListSessionCodeStats_FiltersByWorkspaceIDs(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + now := time.Now().UTC() + nowStr := now.Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-2', 'Test2', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-2', 'ws-2', 'Board2', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-1', 'ws-1', 'board-1', '', 'T1', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, created_at, updated_at) VALUES ('task-2', 'ws-2', 'board-2', '', 'T2', 0, ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-1', 'task-1', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-2', 'task-2', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + results, err := repo.ListSessionCodeStats(ctx, models.SessionCodeStatsFilter{WorkspaceIDs: []string{"ws-2"}}) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if len(results) != 1 { + t.Fatalf("expected 1 session, got %d", len(results)) + } + if results[0].SessionID != "sess-2" { + t.Errorf("SessionID: want sess-2, got %s", results[0].SessionID) + } +} diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index 5766c6cf3b..ca7bbd0a96 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -4,8 +4,11 @@ import ( "context" "database/sql" "fmt" + "strings" "time" + "github.com/jmoiron/sqlx" + "github.com/kandev/kandev/internal/analytics/models" "github.com/kandev/kandev/internal/db/dialect" ) @@ -577,3 +580,168 @@ func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start return &stats, nil } + +// defaultSessionCodeStatsLimit bounds ListSessionCodeStats when the caller +// does not specify one, mirroring GetTaskStats' default page size. +const defaultSessionCodeStatsLimit = 500 + +// ListSessionCodeStats returns, per session, committed LOC (summed from +// task_session_commits) and PEAK pending-diff LOC (the largest single +// task_session_git_snapshots snapshot, not the latest — the latest snapshot +// is usually a clean tree after a commit, merge, or archive). This is the +// per-session line-of-code aggregation the kandev-plugin-agent-stats plugin +// used to compute by reading the SQLite file directly (see ADR 0043); the +// SQL here mirrors that plugin's sessionsQuery. +// +// Portability: the committed-sum half of this query is plain SQL and works +// unchanged on both drivers. The peak-pending half must walk each snapshot's +// `files` JSON object (keyed by file path, see task/models.GitSnapshot.Files) +// to sum per-file additions/deletions — SQLite does this with json_each, +// Postgres with jsonb_each on the same object; peakPendingSnapshotSubquery +// branches on driver to build the equivalent fragment for each. Both paths +// are covered by SQLite unit tests here; the Postgres path is exercised by +// the ADR 0027-style env-gated Postgres suite (KANDEV_TEST_POSTGRES_DSN) at +// the task/repository layer for the underlying schema, not re-verified here +// against a live Postgres instance — if jsonb_each ever proves not to match +// SQLite's json_each semantics for this shape, guard the Postgres branch +// with a clear error instead of returning silently-wrong pending numbers. +func (r *Repository) ListSessionCodeStats( + ctx context.Context, + filter models.SessionCodeStatsFilter, +) ([]*models.SessionCodeStats, error) { + driver := r.ro.DriverName() + where, args := buildSessionCodeStatsFilter(filter) + // Exclude office config-mode tasks' sessions (internal bookkeeping, not + // plugin-visible work items) — the same exclusion Sessions().List applies + // via fetchTasksForWorkspaces' excludeConfig=true, so the Host data API's + // List and CodeStats reads cover the same session set. + where += " AND " + dialect.ExcludeConfigModePredicate(driver, "t.metadata") + query := fmt.Sprintf(` + SELECT + ts.id AS session_id, + COALESCE(commit_stats.insertions, 0) AS lines_added_committed, + COALESCE(commit_stats.deletions, 0) AS lines_deleted_committed, + COALESCE(peak_stats.peak_additions, 0) AS lines_added_peak_pending, + COALESCE(peak_stats.peak_deletions, 0) AS lines_deleted_peak_pending + FROM task_sessions ts + JOIN tasks t ON t.id = ts.task_id + LEFT JOIN ( + SELECT c.session_id, + SUM(c.insertions) AS insertions, + SUM(c.deletions) AS deletions + FROM task_session_commits c + GROUP BY c.session_id + ) commit_stats ON commit_stats.session_id = ts.id + LEFT JOIN (%s) peak_stats ON peak_stats.session_id = ts.id + WHERE %s + ORDER BY ts.started_at ASC, ts.id ASC + LIMIT ? OFFSET ? + `, peakPendingSnapshotSubquery(driver), where) + + inQuery, inArgs, err := sqlx.In(query, args...) + if err != nil { + return nil, err + } + inQuery = r.ro.Rebind(inQuery) + + rows, err := r.ro.QueryContext(ctx, inQuery, inArgs...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + return scanSessionCodeStats(rows) +} + +// buildSessionCodeStatsFilter turns a SessionCodeStatsFilter into a WHERE +// clause (against the ts/t aliases used by ListSessionCodeStats) and its +// positional args, including the trailing LIMIT/OFFSET values. +func buildSessionCodeStatsFilter(filter models.SessionCodeStatsFilter) (string, []any) { + var conds []string + var args []any + if len(filter.SessionIDs) > 0 { + conds = append(conds, "ts.id IN (?)") + args = append(args, filter.SessionIDs) + } + if len(filter.TaskIDs) > 0 { + conds = append(conds, "ts.task_id IN (?)") + args = append(args, filter.TaskIDs) + } + if len(filter.WorkspaceIDs) > 0 { + conds = append(conds, "t.workspace_id IN (?)") + args = append(args, filter.WorkspaceIDs) + } + if len(filter.States) > 0 { + conds = append(conds, "ts.state IN (?)") + args = append(args, filter.States) + } + where := "1 = 1" + if len(conds) > 0 { + where = strings.Join(conds, " AND ") + } + + limit := filter.Limit + if limit <= 0 { + limit = defaultSessionCodeStatsLimit + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + args = append(args, limit, offset) + + return where, args +} + +// scanSessionCodeStats reads all rows of a ListSessionCodeStats query result. +func scanSessionCodeStats(rows *sql.Rows) ([]*models.SessionCodeStats, error) { + var results []*models.SessionCodeStats + for rows.Next() { + var stat models.SessionCodeStats + if err := rows.Scan( + &stat.SessionID, + &stat.LinesAddedCommitted, &stat.LinesDeletedCommitted, + &stat.LinesAddedPeakPending, &stat.LinesDeletedPeakPending, + ); err != nil { + return nil, err + } + results = append(results, &stat) + } + return results, rows.Err() +} + +// peakPendingSnapshotSubquery returns a derived-table SELECT (session_id, +// peak_additions, peak_deletions) that finds, per session, the MAX single +// git-snapshot total across task_session_git_snapshots.files. additions and +// deletions are maximized independently (matching the source plugin), so the +// reported peak-additions and peak-deletions snapshots need not be the same +// snapshot. +func peakPendingSnapshotSubquery(drv string) string { + if dialect.IsPostgres(drv) { + return ` + SELECT snap.session_id, + MAX(snap.additions) AS peak_additions, + MAX(snap.deletions) AS peak_deletions + FROM ( + SELECT g.id AS snapshot_id, g.session_id, + SUM(COALESCE((f.jvalue->>'additions')::numeric, 0)) AS additions, + SUM(COALESCE((f.jvalue->>'deletions')::numeric, 0)) AS deletions + FROM task_session_git_snapshots g, + jsonb_each(g.files::jsonb) AS f(jkey, jvalue) + GROUP BY g.id, g.session_id + ) snap + GROUP BY snap.session_id` + } + return ` + SELECT snap.session_id, + MAX(snap.additions) AS peak_additions, + MAX(snap.deletions) AS peak_deletions + FROM ( + SELECT g.id AS snapshot_id, g.session_id, + SUM(COALESCE(json_extract(f.value, '$.additions'), 0)) AS additions, + SUM(COALESCE(json_extract(f.value, '$.deletions'), 0)) AS deletions + FROM task_session_git_snapshots g, json_each(g.files) f + GROUP BY g.id, g.session_id + ) snap + GROUP BY snap.session_id` +} diff --git a/apps/backend/internal/analytics/service/service.go b/apps/backend/internal/analytics/service/service.go new file mode 100644 index 0000000000..7da34ab997 --- /dev/null +++ b/apps/backend/internal/analytics/service/service.go @@ -0,0 +1,34 @@ +// Package service provides the business-logic seam between callers (HTTP +// handlers, and — per ADR 0043 — the plugin Host data API) and the analytics +// repository. Callers that need analytics/session aggregation data should +// depend on Service rather than reaching into repository.Repository +// directly, so future access rules or derived fields have one place to live. +package service + +import ( + "context" + + "github.com/kandev/kandev/internal/analytics/models" + "github.com/kandev/kandev/internal/analytics/repository" +) + +// Service exposes analytics/session aggregation operations backed by a +// repository.Repository. +type Service struct { + repo repository.Repository +} + +// New creates a Service wrapping the given repository. +func New(repo repository.Repository) *Service { + return &Service{repo: repo} +} + +// ListSessionCodeStats returns per-session committed and peak-pending +// lines-of-code stats matching filter. See models.SessionCodeStats and +// models.SessionCodeStatsFilter for field semantics. +func (s *Service) ListSessionCodeStats( + ctx context.Context, + filter models.SessionCodeStatsFilter, +) ([]*models.SessionCodeStats, error) { + return s.repo.ListSessionCodeStats(ctx, filter) +} diff --git a/apps/backend/internal/analytics/service/service_test.go b/apps/backend/internal/analytics/service/service_test.go new file mode 100644 index 0000000000..cbda806c3e --- /dev/null +++ b/apps/backend/internal/analytics/service/service_test.go @@ -0,0 +1,93 @@ +package service + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/analytics/models" +) + +// fakeRepository is a minimal repository.Repository stub for exercising +// Service's delegation. Only ListSessionCodeStats is meaningfully +// implemented; the rest satisfy the interface and are unused by these tests. +type fakeRepository struct { + filter models.SessionCodeStatsFilter + stats []*models.SessionCodeStats + err error + filterCalls int +} + +func (f *fakeRepository) GetTaskStats(context.Context, string, *time.Time, int) ([]*models.TaskStats, error) { + return nil, nil +} + +func (f *fakeRepository) GetGlobalStats(context.Context, string, *time.Time) (*models.GlobalStats, error) { + return nil, nil +} + +func (f *fakeRepository) GetDailyActivity(context.Context, string, int) ([]*models.DailyActivity, error) { + return nil, nil +} + +func (f *fakeRepository) GetCompletedTaskActivity(context.Context, string, int) ([]*models.CompletedTaskActivity, error) { + return nil, nil +} + +func (f *fakeRepository) GetAgentUsage(context.Context, string, int, *time.Time) ([]*models.AgentUsage, error) { + return nil, nil +} + +func (f *fakeRepository) GetRepositoryStats(context.Context, string, *time.Time) ([]*models.RepositoryStats, error) { + return nil, nil +} + +func (f *fakeRepository) GetGitStats(context.Context, string, *time.Time) (*models.GitStats, error) { + return nil, nil +} + +func (f *fakeRepository) ListSessionCodeStats( + _ context.Context, filter models.SessionCodeStatsFilter, +) ([]*models.SessionCodeStats, error) { + f.filterCalls++ + f.filter = filter + if f.err != nil { + return nil, f.err + } + return f.stats, nil +} + +func TestService_ListSessionCodeStats_DelegatesToRepositoryAndReturnsResults(t *testing.T) { + want := []*models.SessionCodeStats{ + {SessionID: "sess-1", LinesAddedCommitted: 10, LinesDeletedCommitted: 2, LinesAddedPeakPending: 5, LinesDeletedPeakPending: 1}, + } + repo := &fakeRepository{stats: want} + svc := New(repo) + + filter := models.SessionCodeStatsFilter{WorkspaceIDs: []string{"ws-1"}} + got, err := svc.ListSessionCodeStats(context.Background(), filter) + if err != nil { + t.Fatalf("ListSessionCodeStats failed: %v", err) + } + if repo.filterCalls != 1 { + t.Fatalf("expected repository to be called once, got %d", repo.filterCalls) + } + if len(repo.filter.WorkspaceIDs) != 1 || repo.filter.WorkspaceIDs[0] != "ws-1" { + t.Errorf("expected filter to be passed through unchanged, got %+v", repo.filter) + } + if len(got) != 1 || got[0].SessionID != "sess-1" { + t.Errorf("expected repository results to be returned unchanged, got %+v", got) + } +} + +func TestService_ListSessionCodeStats_PropagatesRepositoryError(t *testing.T) { + wantErr := errors.New("boom") + repo := &fakeRepository{err: wantErr} + svc := New(repo) + + _, err := svc.ListSessionCodeStats(context.Background(), models.SessionCodeStatsFilter{}) + if !errors.Is(err, wantErr) { + t.Fatalf("expected error %v to propagate, got %v", wantErr, err) + } +} diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index b947f6ff8c..b1bea6b825 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -13,6 +13,7 @@ import ( agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" agentusage "github.com/kandev/kandev/internal/agent/usage" agentctlutil "github.com/kandev/kandev/internal/agentctl/server/utility" + analyticsservice "github.com/kandev/kandev/internal/analytics/service" "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/logger" @@ -110,6 +111,9 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories slackSvc := initSlackService(dbPool, repos.Secrets, log) workflowSyncSvc := initWorkflowSyncService(dbPool, githubSvc, workflowSvc, taskSvc, log) pluginsSvc := initPluginsService(cfg, dbPool, eventBus, repos.Secrets, log) + if pluginsSvc != nil { + pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics)) + } shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) // Plumb GitHub branch listing into the task service so provider-backed diff --git a/apps/backend/internal/db/dialect/dialect_test.go b/apps/backend/internal/db/dialect/dialect_test.go index d3b3d6e871..8dfabec0ed 100644 --- a/apps/backend/internal/db/dialect/dialect_test.go +++ b/apps/backend/internal/db/dialect/dialect_test.go @@ -69,6 +69,17 @@ func TestJSONSet(t *testing.T) { } } +func TestExcludeConfigModePredicate(t *testing.T) { + got := ExcludeConfigModePredicate(SQLite3, "metadata") + if got != "json_extract(metadata, '$.config_mode') IS NOT 1" { + t.Errorf("sqlite: got %q", got) + } + got = ExcludeConfigModePredicate(PGX, "metadata") + if got != "COALESCE(metadata::jsonb->>'config_mode', '') NOT IN ('true', '1')" { + t.Errorf("pgx: got %q", got) + } +} + func TestDurationMs(t *testing.T) { got := DurationMs(SQLite3, "completed_at", "started_at") if got != "(julianday(completed_at) - julianday(started_at)) * 86400000" { diff --git a/apps/backend/internal/db/dialect/json.go b/apps/backend/internal/db/dialect/json.go index f91f09a2fe..f3afe82628 100644 --- a/apps/backend/internal/db/dialect/json.go +++ b/apps/backend/internal/db/dialect/json.go @@ -31,3 +31,22 @@ func JSONSet(driver, col, path, value string) string { } return fmt.Sprintf("json_set(%s, '$.%s', '%s')", col, path, value) } + +// ExcludeConfigModePredicate returns a WHERE-clause fragment excluding rows +// whose col JSON has a truthy "config_mode" key — office config-mode tasks +// are internal bookkeeping, not user-visible work items, so every task-scoped +// read that should match kandev's normal task-list semantics (the Host data +// API's Tasks/Sessions/CodeStats readers, the task list/search endpoints) +// applies this against the tasks table's metadata column. +// +// SQLite: json_extract(col, '$.config_mode') IS NOT 1 +// Postgres: COALESCE(col::jsonb->>'config_mode', '') NOT IN ('true', '1') +func ExcludeConfigModePredicate(driver, col string) string { + if IsPostgres(driver) { + // Repository writes always marshal metadata as JSON; dirty Postgres + // rows with malformed JSON should fail loudly instead of being + // silently skipped. + return fmt.Sprintf("COALESCE(%s, '') NOT IN ('true', '1')", JSONExtract(driver, col, "config_mode")) + } + return fmt.Sprintf("%s IS NOT 1", JSONExtract(driver, col, "config_mode")) +} diff --git a/apps/backend/internal/plugins/goleak_test.go b/apps/backend/internal/plugins/goleak_test.go index c296ee2da5..ac15adf857 100644 --- a/apps/backend/internal/plugins/goleak_test.go +++ b/apps/backend/internal/plugins/goleak_test.go @@ -10,6 +10,22 @@ import ( // process. HealthMonitor owns a single ticker loop guarded by Start/Stop — // goleak catches regressions where a Start path forgets to register on the // WaitGroup, or a Stop path returns before the loop drains. +// +// host_data_wire_test.go's real-transport tests (hcplugin.TestPluginGRPCConn, +// mirroring pkg/pluginsdk/serve_test.go) dial a second gRPC connection back +// to kandev's Host server over the go-plugin broker — the same connection a +// real plugin subprocess uses to call host.Sessions()/host.Tasks()/etc. +// pkg/pluginsdk's serve.go (dialBrokerWithRetry / newHostClient) has no +// Close() hook for that connection: in production it is only ever torn down +// by killing the plugin subprocess, which drops the connection and its +// goroutines for free. Our in-process test harness has no subprocess to +// kill, so grpc's per-ClientConn balancer/resolver CallbackSerializer +// goroutines for that one connection outlive the test. This is a test-harness +// artifact of go-plugin's process-death cleanup model, not a leak in +// production or in code this package owns — see docs/decisions/0042 and +// pkg/pluginsdk/serve.go's "Host injection" file header. func TestMain(m *testing.M) { - goleak.VerifyTestMain(m) + goleak.VerifyTestMain(m, + goleak.IgnoreTopFunction("google.golang.org/grpc/internal/grpcsync.(*CallbackSerializer).run"), + ) } diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 85bedeed37..63eeb981e8 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -27,12 +27,30 @@ import ( // (§5) only names state and secrets (api_read/api_write reserved for // future); event emission has no boolean capability to gate on. type pluginHost struct { + // UnimplementedHostData is embedded so pluginHost satisfies + // pluginsdk.Host even when one of the data-source fields below is nil + // (e.g. a test pluginHost built without SetDataSources' wiring, or a + // capability the manifest doesn't declare — see host_data.go's denied + // readers for the capability-gated path). Every accessor + // (Tasks/Sessions/Workspaces/Workflows/AgentProfiles/Repositories) is + // overridden with a real, capability-gated implementation in + // host_data.go; this embed only remains as defense-in-depth. + pluginsdk.UnimplementedHostData + pluginID string capabilities manifest.Capabilities state *state.Store secrets SecretRevealer bus bus.EventBus + + // Host data API (ADR 0043) service-layer dependencies, wired by + // Service.hostForPlugin from Service.SetDataSources. See host_data.go. + taskData taskDataSource + workflows workflowLister + workflowSteps workflowStepLister + agentProfiles agentProfileDataSource + sessionCodeStats sessionCodeStatsSource } var _ pluginsdk.Host = (*pluginHost)(nil) @@ -45,6 +63,14 @@ func permissionDenied(capability string) error { return status.Errorf(codes.PermissionDenied, "capability '%s' not declared", capability) } +// taskNotFound builds the gRPC error taskReader.Get returns for an id that +// doesn't resolve to a task — the SAME error, in-process or over the wire +// (grpcHostServer.GetTask forwards it as-is), so a plugin never observes a +// (nil, nil) success for a missing task. +func taskNotFound(id string) error { + return status.Errorf(codes.NotFound, "task %q not found", id) +} + func (h *pluginHost) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) { if !h.capabilities.State { return nil, false, permissionDenied("state") diff --git a/apps/backend/internal/plugins/host_data.go b/apps/backend/internal/plugins/host_data.go new file mode 100644 index 0000000000..0078a265a2 --- /dev/null +++ b/apps/backend/internal/plugins/host_data.go @@ -0,0 +1,869 @@ +// host_data.go implements pluginHost's Tasks/Sessions/Workspaces/Workflows/ +// AgentProfiles/Repositories accessors — the Host data API (ADR 0043: +// docs/decisions/0043-plugin-host-data-api.md). Each accessor is +// capability-gated at the point it is called: a plugin whose manifest lacks +// the resource's api_read: capability gets back a reader whose +// every method returns gRPC PermissionDenied, so a real reader's methods +// never need to re-check the gate themselves. +// +// Reads never touch a repository directly — each real reader is backed by a +// narrow interface (taskDataSource, workflowLister, workflowStepLister, +// agentProfileDataSource, sessionCodeStatsSource) satisfied structurally by +// the real internal/task/service.Service, internal/workflow/service.Service, +// internal/agent/settings/controller.Controller, and +// internal/analytics/service.Service that backendapp wires in via +// Service.SetDataSources — mirroring how internal/plugins/delivery declares +// its own small Transport/PluginLister interfaces instead of importing this +// package's full surface. +package plugins + +import ( + "context" + "errors" + "fmt" + "sort" + "strconv" + "time" + + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + analyticsmodels "github.com/kandev/kandev/internal/analytics/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + + taskmodels "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// Resource names gating the Host data API's read RPCs, per ADR 0043: each +// accessor requires "api_read:" in the plugin's manifest. +const ( + resourceTasks = "tasks" + resourceSessions = "sessions" + resourceWorkspaces = "workspaces" + resourceWorkflows = "workflows" + resourceAgentProfiles = "agent_profiles" + resourceRepositories = "repositories" +) + +// apiReadCapability formats resource as the api_read: capability +// name permissionDenied expects. +func apiReadCapability(resource string) string { + return "api_read:" + resource +} + +// Pagination: Page.Cursor is a decimal string offset into the server-side +// result set. It is an implementation detail plugins must treat as opaque +// (per ADR 0043's "opaque cursor" convention) — nothing here promises it +// stays a plain offset. defaultPageLimit/maxPageLimit bound Page.Limit. +const ( + defaultPageLimit = 50 + maxPageLimit = 200 +) + +// normalizePageLimit clamps limit to [1, maxPageLimit], defaulting to +// defaultPageLimit when unset or invalid. +func normalizePageLimit(limit int32) int { + l := int(limit) + if l <= 0 { + return defaultPageLimit + } + if l > maxPageLimit { + return maxPageLimit + } + return l +} + +// pageOffset decodes cursor as the decimal offset it was encoded as; an +// empty, invalid, or negative cursor starts back at offset 0. +func pageOffset(cursor string) int { + if cursor == "" { + return 0 + } + n, err := strconv.Atoi(cursor) + if err != nil || n < 0 { + return 0 + } + return n +} + +// paginate slices an already-fetched, already-ordered items slice per page's +// offset/limit and builds the PageInfo the RPC hands back. +func paginate[T any](items []T, page pluginsdk.Page) ([]T, *pluginsdk.PageInfo) { + limit := normalizePageLimit(page.Limit) + offset := pageOffset(page.Cursor) + if offset >= len(items) { + return []T{}, &pluginsdk.PageInfo{} + } + end := offset + limit + hasMore := end < len(items) + if end > len(items) { + end = len(items) + } + info := &pluginsdk.PageInfo{HasMore: hasMore} + if hasMore { + info.NextCursor = strconv.Itoa(end) + } + return items[offset:end], info +} + +// ── Narrow data-source interfaces ─────────────────────────────────────── +// +// Each interface names exactly the methods this file calls, satisfied +// structurally (no adapter type needed) by the real service kandev already +// constructs: internal/task/service.Service already has every taskDataSource +// and workflowLister method; internal/workflow/service.Service already has +// ListStepsByWorkflow; internal/agent/settings/controller.Controller already +// has ListAgents; internal/analytics/service.Service already has +// ListSessionCodeStats. + +// taskDataSource is the narrow slice of internal/task/service.Service the +// Tasks/Workspaces/Repositories/Sessions readers need. +type taskDataSource interface { + ListWorkspaces(ctx context.Context) ([]*taskmodels.Workspace, error) + ListTasksByWorkspace(ctx context.Context, workspaceID, workflowID, repositoryID, query string, page, pageSize int, sort string, includeArchived, includeEphemeral, onlyEphemeral, excludeConfig bool) ([]*taskmodels.Task, int, error) + GetTask(ctx context.Context, id string) (*taskmodels.Task, error) + ListRepositories(ctx context.Context, workspaceID string) ([]*taskmodels.Repository, error) + ListTaskSessions(ctx context.Context, taskID string) ([]*taskmodels.TaskSession, error) + GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*taskmodels.ExecutorRunning, error) +} + +// workflowLister is the narrow slice of internal/task/service.Service the +// Workflows().List RPC needs (workflows themselves are owned by the task +// service, not internal/workflow/service — only steps are). +type workflowLister interface { + ListWorkflows(ctx context.Context, workspaceID string, includeHidden bool) ([]*taskmodels.Workflow, error) +} + +// workflowStepLister is the narrow slice of internal/workflow/service.Service +// the Workflows().ListSteps RPC needs. +type workflowStepLister interface { + ListStepsByWorkflow(ctx context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error) +} + +// agentProfileDataSource is the narrow slice of +// internal/agent/settings/controller.Controller the AgentProfiles().List RPC +// needs. ListAgents already filters out workspace-scoped (office) profiles +// (see filterGlobalProfiles), matching the resource's global-instance scope. +type agentProfileDataSource interface { + ListAgents(ctx context.Context) (*agentsettingsdto.ListAgentsResponse, error) +} + +// sessionCodeStatsSource is the narrow slice of +// internal/analytics/service.Service the Sessions().CodeStats RPC needs. +type sessionCodeStatsSource interface { + ListSessionCodeStats(ctx context.Context, filter analyticsmodels.SessionCodeStatsFilter) ([]*analyticsmodels.SessionCodeStats, error) +} + +// ── pluginHost accessors ──────────────────────────────────────────────── +// +// These shadow the Unimplemented* defaults embedded via +// pluginsdk.UnimplementedHostData: each checks the resource's api_read +// capability once, then hands back either the real, service-backed reader or +// a denied stub whose methods all return PermissionDenied. If the capability +// is granted but the corresponding data source was never wired (e.g. +// Service.SetDataSources not called — some tests build a bare pluginHost), +// this falls back to the embedded Unimplemented reader rather than a nil +// pointer dereference. + +func (h *pluginHost) Tasks() pluginsdk.TaskReader { + if !h.capabilities.CanRead(resourceTasks) { + return deniedTaskReader{} + } + if h.taskData == nil { + return h.UnimplementedHostData.Tasks() + } + return taskReader{host: h} +} + +func (h *pluginHost) Sessions() pluginsdk.SessionReader { + if !h.capabilities.CanRead(resourceSessions) { + return deniedSessionReader{} + } + if h.taskData == nil || h.sessionCodeStats == nil { + return h.UnimplementedHostData.Sessions() + } + return sessionReader{host: h} +} + +func (h *pluginHost) Workspaces() pluginsdk.WorkspaceReader { + if !h.capabilities.CanRead(resourceWorkspaces) { + return deniedWorkspaceReader{} + } + if h.taskData == nil { + return h.UnimplementedHostData.Workspaces() + } + return workspaceReader{host: h} +} + +func (h *pluginHost) Workflows() pluginsdk.WorkflowReader { + if !h.capabilities.CanRead(resourceWorkflows) { + return deniedWorkflowReader{} + } + if h.workflows == nil || h.workflowSteps == nil { + return h.UnimplementedHostData.Workflows() + } + return workflowReader{host: h} +} + +func (h *pluginHost) AgentProfiles() pluginsdk.AgentProfileReader { + if !h.capabilities.CanRead(resourceAgentProfiles) { + return deniedAgentProfileReader{} + } + if h.agentProfiles == nil { + return h.UnimplementedHostData.AgentProfiles() + } + return agentProfileReader{host: h} +} + +func (h *pluginHost) Repositories() pluginsdk.RepositoryReader { + if !h.capabilities.CanRead(resourceRepositories) { + return deniedRepositoryReader{} + } + if h.taskData == nil { + return h.UnimplementedHostData.Repositories() + } + return repositoryReader{host: h} +} + +// ── Denied readers ────────────────────────────────────────────────────── + +type deniedTaskReader struct{} + +func (deniedTaskReader) List(context.Context, pluginsdk.TaskFilter, pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceTasks)) +} + +func (deniedTaskReader) Get(context.Context, string) (*pluginsdk.Task, error) { + return nil, permissionDenied(apiReadCapability(resourceTasks)) +} + +type deniedSessionReader struct{} + +func (deniedSessionReader) List(context.Context, pluginsdk.SessionFilter, pluginsdk.Page) ([]pluginsdk.Session, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceSessions)) +} + +func (deniedSessionReader) CodeStats(context.Context, pluginsdk.SessionFilter, pluginsdk.Page) ([]pluginsdk.SessionCodeStats, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceSessions)) +} + +type deniedWorkspaceReader struct{} + +func (deniedWorkspaceReader) List(context.Context, pluginsdk.Page) ([]pluginsdk.Workspace, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceWorkspaces)) +} + +type deniedWorkflowReader struct{} + +func (deniedWorkflowReader) List(context.Context, string, pluginsdk.Page) ([]pluginsdk.Workflow, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceWorkflows)) +} + +func (deniedWorkflowReader) ListSteps(context.Context, string) ([]pluginsdk.WorkflowStep, error) { + return nil, permissionDenied(apiReadCapability(resourceWorkflows)) +} + +type deniedAgentProfileReader struct{} + +func (deniedAgentProfileReader) List(context.Context, pluginsdk.Page) ([]pluginsdk.AgentProfile, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceAgentProfiles)) +} + +type deniedRepositoryReader struct{} + +func (deniedRepositoryReader) List(context.Context, string, pluginsdk.Page) ([]pluginsdk.Repository, *pluginsdk.PageInfo, error) { + return nil, nil, permissionDenied(apiReadCapability(resourceRepositories)) +} + +// ── Real readers ──────────────────────────────────────────────────────── +// +// Only ever returned once the resource's capability gate has passed (see the +// accessors above), so none of these re-check it. + +// taskFetchPageSize bounds each individual ListTasksByWorkspace call made +// while assembling a workspace's tasks for in-memory filter/sort/paginate. +// fetchTasksForWorkspaces loops pagination to completion per workspace (see +// its doc comment), so this only bounds one round trip's page size — it does +// NOT bound how many tasks a workspace can have before Host data API reads +// start silently dropping them. +const taskFetchPageSize = 1000 + +type taskReader struct{ host *pluginHost } + +func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) { + workspaceIDs, err := r.host.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs) + if err != nil { + return nil, nil, err + } + tasks, err := r.host.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, false) + if err != nil { + return nil, nil, err + } + tasks = filterTasks(tasks, filter) + sortTasksNewestFirst(tasks) + items, info := paginate(tasksToDTOs(tasks), page) + return items, info, nil +} + +// Get returns a gRPC NotFound error (not a (nil, nil) success) when id +// doesn't resolve to a task, so the in-process contract matches exactly what +// a real plugin observes over the wire via grpcHostServer.GetTask. +func (r taskReader) Get(ctx context.Context, id string) (*pluginsdk.Task, error) { + task, err := r.host.taskData.GetTask(ctx, id) + if err != nil { + if errors.Is(err, repoerrors.ErrTaskNotFound) { + return nil, taskNotFound(id) + } + return nil, err + } + dto := taskModelToDTO(task) + return &dto, nil +} + +type sessionReader struct{ host *pluginHost } + +// List paginates the raw, already-sorted sessions BEFORE converting to DTOs: +// sessionToDTO resolves ACPSessionID via resolveACPSessionID, which issues a +// GetExecutorRunningBySessionID query for any session lacking the id in its +// metadata. Converting every fetched session (as opposed to just the +// returned page) would turn that into an O(N) fan-out of DB queries per read +// instead of O(limit). +func (r sessionReader) List(ctx context.Context, filter pluginsdk.SessionFilter, page pluginsdk.Page) ([]pluginsdk.Session, *pluginsdk.PageInfo, error) { + sessions, err := r.host.fetchSessionsForFilter(ctx, filter) + if err != nil { + return nil, nil, err + } + sessions = filterSessionsByState(sessions, filter.States) + sortSessionsNewestFirst(sessions) + + pageSessions, info := paginate(sessions, page) + dtos := make([]pluginsdk.Session, len(pageSessions)) + for i, s := range pageSessions { + dtos[i] = r.host.sessionToDTO(ctx, s) + } + return dtos, info, nil +} + +// CodeStats delegates straight to the analytics service, which already +// paginates via SQL Limit/Offset (per ADR 0043(b), computed on demand — no +// in-memory fetch-everything like the other readers). It asks for one extra +// row past the requested limit to derive HasMore without a second count +// query; NextCursor is offset+limit exactly like the in-memory paginate +// helper, keeping cursor semantics uniform across every Host data reader. +func (r sessionReader) CodeStats(ctx context.Context, filter pluginsdk.SessionFilter, page pluginsdk.Page) ([]pluginsdk.SessionCodeStats, *pluginsdk.PageInfo, error) { + limit := normalizePageLimit(page.Limit) + offset := pageOffset(page.Cursor) + + stats, err := r.host.sessionCodeStats.ListSessionCodeStats(ctx, analyticsmodels.SessionCodeStatsFilter{ + TaskIDs: filter.TaskIDs, + WorkspaceIDs: filter.WorkspaceIDs, + States: filter.States, + Limit: limit + 1, + Offset: offset, + }) + if err != nil { + return nil, nil, err + } + + hasMore := len(stats) > limit + if hasMore { + stats = stats[:limit] + } + dtos := make([]pluginsdk.SessionCodeStats, len(stats)) + for i, s := range stats { + dtos[i] = sessionCodeStatsModelToDTO(s) + } + info := &pluginsdk.PageInfo{HasMore: hasMore} + if hasMore { + info.NextCursor = strconv.Itoa(offset + limit) + } + return dtos, info, nil +} + +type workspaceReader struct{ host *pluginHost } + +func (r workspaceReader) List(ctx context.Context, page pluginsdk.Page) ([]pluginsdk.Workspace, *pluginsdk.PageInfo, error) { + workspaces, err := r.host.taskData.ListWorkspaces(ctx) + if err != nil { + return nil, nil, err + } + dtos := make([]pluginsdk.Workspace, len(workspaces)) + for i, w := range workspaces { + dtos[i] = workspaceModelToDTO(w) + } + items, info := paginate(dtos, page) + return items, info, nil +} + +type workflowReader struct{ host *pluginHost } + +// List does not surface hidden workflows (e.g. system-only flows like +// Improve Kandev) — the Host data API's WorkflowReader has no includeHidden +// filter yet, so this reader defaults to the same "hidden by default" +// behavior most kandev UI listings use. +func (r workflowReader) List(ctx context.Context, workspaceID string, page pluginsdk.Page) ([]pluginsdk.Workflow, *pluginsdk.PageInfo, error) { + workflows, err := r.host.workflows.ListWorkflows(ctx, workspaceID, false) + if err != nil { + return nil, nil, err + } + dtos := make([]pluginsdk.Workflow, len(workflows)) + for i, w := range workflows { + dtos[i] = workflowModelToDTO(w) + } + items, info := paginate(dtos, page) + return items, info, nil +} + +func (r workflowReader) ListSteps(ctx context.Context, workflowID string) ([]pluginsdk.WorkflowStep, error) { + steps, err := r.host.workflowSteps.ListStepsByWorkflow(ctx, workflowID) + if err != nil { + return nil, err + } + dtos := make([]pluginsdk.WorkflowStep, len(steps)) + for i, s := range steps { + dtos[i] = workflowStepModelToDTO(s) + } + return dtos, nil +} + +type agentProfileReader struct{ host *pluginHost } + +func (r agentProfileReader) List(ctx context.Context, page pluginsdk.Page) ([]pluginsdk.AgentProfile, *pluginsdk.PageInfo, error) { + resp, err := r.host.agentProfiles.ListAgents(ctx) + if err != nil { + return nil, nil, err + } + var dtos []pluginsdk.AgentProfile + for _, agent := range resp.Agents { + for _, profile := range agent.Profiles { + dtos = append(dtos, agentProfileDTOToSDK(profile)) + } + } + items, info := paginate(dtos, page) + return items, info, nil +} + +type repositoryReader struct{ host *pluginHost } + +func (r repositoryReader) List(ctx context.Context, workspaceID string, page pluginsdk.Page) ([]pluginsdk.Repository, *pluginsdk.PageInfo, error) { + repos, err := r.host.taskData.ListRepositories(ctx, workspaceID) + if err != nil { + return nil, nil, err + } + dtos := make([]pluginsdk.Repository, len(repos)) + for i, repository := range repos { + dtos[i] = repositoryModelToDTO(repository) + } + items, info := paginate(dtos, page) + return items, info, nil +} + +// ── Fetch/filter/sort helpers (v1 scoping: ADR 0043(a) "global-with-hook") ─ + +// resolveWorkspaceIDs returns requested unchanged when non-empty (an +// explicit filter always narrows), otherwise every workspace the instance +// holds — this is the "global reads, filters narrow results" v1 scoping +// rule, and the single hook a future per-plugin/per-user workspace +// restriction would replace. +func (h *pluginHost) resolveWorkspaceIDs(ctx context.Context, requested []string) ([]string, error) { + if len(requested) > 0 { + return requested, nil + } + workspaces, err := h.taskData.ListWorkspaces(ctx) + if err != nil { + return nil, fmt.Errorf("plugins: list workspaces: %w", err) + } + ids := make([]string, len(workspaces)) + for i, w := range workspaces { + ids[i] = w.ID + } + return ids, nil +} + +// fetchTasksForWorkspaces concatenates up to taskFetchPageSize tasks per +// workspace in workspaceIDs. excludeConfig is always true: office config-mode +// tasks (json_extract(metadata, '$.config_mode')) are internal bookkeeping, +// not plugin-visible work items. +func (h *pluginHost) fetchTasksForWorkspaces(ctx context.Context, workspaceIDs []string, includeEphemeral, includeArchived bool) ([]*taskmodels.Task, error) { + var all []*taskmodels.Task + for _, workspaceID := range workspaceIDs { + tasks, err := h.fetchAllTasksForWorkspace(ctx, workspaceID, includeEphemeral, includeArchived) + if err != nil { + return nil, err + } + all = append(all, tasks...) + } + return all, nil +} + +// fetchAllTasksForWorkspace loops ListTasksByWorkspace's page/pageSize +// pagination to completion for a single workspace, so a workspace with more +// tasks than one taskFetchPageSize page is never silently truncated (and +// this reader's downstream HasMore/paginate stays accurate). Bounded by +// ListTasksByWorkspace's own returned total, plus a break on an empty page +// as a defensive guard against ever looping forever on an inconsistent total. +func (h *pluginHost) fetchAllTasksForWorkspace(ctx context.Context, workspaceID string, includeEphemeral, includeArchived bool) ([]*taskmodels.Task, error) { + var out []*taskmodels.Task + for page := 1; ; page++ { + tasks, total, err := h.taskData.ListTasksByWorkspace( + ctx, workspaceID, "", "", "", page, taskFetchPageSize, "", + includeArchived, includeEphemeral, false, true, + ) + if err != nil { + return nil, fmt.Errorf("plugins: list tasks for workspace %q: %w", workspaceID, err) + } + if len(tasks) == 0 { + break + } + out = append(out, tasks...) + if len(out) >= total { + break + } + } + return out, nil +} + +// filterTasks applies TaskFilter's WorkflowIDs/States/ParentID narrowing +// on top of the already-workspace-scoped tasks fetchTasksForWorkspaces +// returned (WorkspaceIDs and IncludeEphemeral are applied earlier, at fetch +// time). +func filterTasks(tasks []*taskmodels.Task, filter pluginsdk.TaskFilter) []*taskmodels.Task { + if len(filter.WorkflowIDs) == 0 && len(filter.States) == 0 && filter.ParentID == nil { + return tasks + } + workflowSet := toSet(filter.WorkflowIDs) + stateSet := toSet(filter.States) + out := make([]*taskmodels.Task, 0, len(tasks)) + for _, t := range tasks { + if len(workflowSet) > 0 && !workflowSet[t.WorkflowID] { + continue + } + if len(stateSet) > 0 && !stateSet[string(t.State)] { + continue + } + if filter.ParentID != nil && t.ParentID != *filter.ParentID { + continue + } + out = append(out, t) + } + return out +} + +func toSet(values []string) map[string]bool { + if len(values) == 0 { + return nil + } + set := make(map[string]bool, len(values)) + for _, v := range values { + set[v] = true + } + return set +} + +// sortTasksNewestFirst orders by CreatedAt descending, tie-broken by ID +// ascending: sort.Slice is unstable, and offset-cursor pagination needs a +// total order across calls — two tasks with equal CreatedAt (a plausible +// seed/batch-import scenario) must land in the same relative position on +// every call, or an offset page can skip or duplicate one across reads. +func sortTasksNewestFirst(tasks []*taskmodels.Task) { + sort.Slice(tasks, func(i, j int) bool { + if !tasks[i].CreatedAt.Equal(tasks[j].CreatedAt) { + return tasks[i].CreatedAt.After(tasks[j].CreatedAt) + } + return tasks[i].ID < tasks[j].ID + }) +} + +func tasksToDTOs(tasks []*taskmodels.Task) []pluginsdk.Task { + out := make([]pluginsdk.Task, len(tasks)) + for i, t := range tasks { + out[i] = taskModelToDTO(t) + } + return out +} + +// fetchSessionsForFilter resolves the task ids to list sessions for (see +// resolveSessionTaskIDs) and lists each task's sessions — a Host data API +// session read is, unavoidably, an N+1 fan-out over the resolved tasks in v1 +// (no session listing endpoint spans multiple tasks directly at the service +// layer today). +func (h *pluginHost) fetchSessionsForFilter(ctx context.Context, filter pluginsdk.SessionFilter) ([]*taskmodels.TaskSession, error) { + taskIDs, err := h.resolveSessionTaskIDs(ctx, filter) + if err != nil { + return nil, err + } + + var sessions []*taskmodels.TaskSession + for _, taskID := range taskIDs { + s, err := h.taskData.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("plugins: list sessions for task %q: %w", taskID, err) + } + sessions = append(sessions, s...) + } + return sessions, nil +} + +// resolveSessionTaskIDs returns the task ids fetchSessionsForFilter should +// list sessions for. filter.TaskIDs and filter.WorkspaceIDs are ANDed +// together, never one bypassing the other: with both set, a task id whose +// task lives outside the requested workspaces is dropped, so it can't leak +// sessions from a workspace the caller didn't ask for. With only TaskIDs set, +// they're used as-is (unscoped by design — an explicit task id is itself a +// narrowing filter). With neither set (or only WorkspaceIDs), every task +// across resolveWorkspaceIDs(filter.WorkspaceIDs) is enumerated. +// includeEphemeral AND includeArchived when enumerating by workspace: a +// session is still a real session from the Sessions resource's point of view +// whether its task is a quick-chat or has since been archived. CodeStats +// makes the same call at the SQL layer (no task-state filtering), so the two +// session reads stay consistent. +func (h *pluginHost) resolveSessionTaskIDs(ctx context.Context, filter pluginsdk.SessionFilter) ([]string, error) { + if len(filter.TaskIDs) == 0 { + workspaceIDs, err := h.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs) + if err != nil { + return nil, err + } + tasks, err := h.fetchTasksForWorkspaces(ctx, workspaceIDs, true, true) + if err != nil { + return nil, err + } + taskIDs := make([]string, len(tasks)) + for i, t := range tasks { + taskIDs[i] = t.ID + } + return taskIDs, nil + } + if len(filter.WorkspaceIDs) == 0 { + return filter.TaskIDs, nil + } + return h.filterTaskIDsByWorkspace(ctx, filter.TaskIDs, filter.WorkspaceIDs) +} + +// filterTaskIDsByWorkspace keeps only the taskIDs whose task's WorkspaceID is +// in workspaceIDs; a taskID that no longer resolves to a task is dropped +// (not an error) — a session read shouldn't fail just because a stale id was +// passed in TaskIDs. +func (h *pluginHost) filterTaskIDsByWorkspace(ctx context.Context, taskIDs, workspaceIDs []string) ([]string, error) { + allowed := toSet(workspaceIDs) + out := make([]string, 0, len(taskIDs)) + for _, taskID := range taskIDs { + task, err := h.taskData.GetTask(ctx, taskID) + if err != nil { + if errors.Is(err, repoerrors.ErrTaskNotFound) { + continue + } + return nil, fmt.Errorf("plugins: get task %q: %w", taskID, err) + } + if allowed[task.WorkspaceID] { + out = append(out, taskID) + } + } + return out, nil +} + +func filterSessionsByState(sessions []*taskmodels.TaskSession, states []string) []*taskmodels.TaskSession { + if len(states) == 0 { + return sessions + } + set := toSet(states) + out := make([]*taskmodels.TaskSession, 0, len(sessions)) + for _, s := range sessions { + if set[string(s.State)] { + out = append(out, s) + } + } + return out +} + +// sortSessionsNewestFirst mirrors sortTasksNewestFirst's ID tie-break, for +// the same offset-cursor pagination stability reason. +func sortSessionsNewestFirst(sessions []*taskmodels.TaskSession) { + sort.Slice(sessions, func(i, j int) bool { + if !sessions[i].StartedAt.Equal(sessions[j].StartedAt) { + return sessions[i].StartedAt.After(sessions[j].StartedAt) + } + return sessions[i].ID < sessions[j].ID + }) +} + +// sessionToDTO maps a TaskSession to the Go-native Session DTO, resolving +// ACPSessionID via resolveACPSessionID's metadata-then-executors_running +// fallback. +func (h *pluginHost) sessionToDTO(ctx context.Context, s *taskmodels.TaskSession) pluginsdk.Session { + return pluginsdk.Session{ + ID: s.ID, + TaskID: s.TaskID, + AgentProfileID: s.AgentProfileID, + AgentDisplayName: sessionSnapshotString(s.AgentProfileSnapshot, "agent_display_name"), + AgentProfileName: sessionSnapshotString(s.AgentProfileSnapshot, "name"), + Model: sessionSnapshotModel(s.AgentProfileSnapshot), + ACPSessionID: h.resolveACPSessionID(ctx, s), + State: string(s.State), + StartedAt: s.StartedAt.UTC().Format(time.RFC3339), + EndedAt: timePtrToRFC3339(s.CompletedAt), + } +} + +// resolveACPSessionID replicates the source agent-stats plugin's join key +// (docs/decisions/0043-plugin-host-data-api.md, "A real plugin exposed the +// gap"): the agent CLI's own session UUID at +// TaskSession.Metadata["acp"]["session_id"], populated once the agent emits +// a session_info frame. executors_running.resume_token carries the same id +// and survives on sessions that never got that far, so it is a best-effort +// fallback — a lookup failure (including "no such row") is silently treated +// as "no id available" rather than failing the whole read. +func (h *pluginHost) resolveACPSessionID(ctx context.Context, s *taskmodels.TaskSession) string { + if id := acpSessionIDFromMetadata(s.Metadata); id != "" { + return id + } + running, err := h.taskData.GetExecutorRunningBySessionID(ctx, s.ID) + if err != nil || running == nil { + return "" + } + return running.ResumeToken +} + +func acpSessionIDFromMetadata(metadata map[string]any) string { + acp, ok := metadata["acp"].(map[string]any) + if !ok { + return "" + } + id, _ := acp["session_id"].(string) + return id +} + +func sessionSnapshotString(snapshot map[string]any, key string) string { + if snapshot == nil { + return "" + } + v, _ := snapshot[key].(string) + return v +} + +// sessionSnapshotModel mirrors the source plugin's fallback chain for the +// agent-profile snapshot's model field, which has varied key names across +// agent types over time. +func sessionSnapshotModel(snapshot map[string]any) string { + for _, key := range []string{"model", "model_name", "llm"} { + if v := sessionSnapshotString(snapshot, key); v != "" { + return v + } + } + return "" +} + +func timePtrToRFC3339(t *time.Time) *string { + if t == nil { + return nil + } + s := t.UTC().Format(time.RFC3339) + return &s +} + +func stringPtrOrNil(s string) *string { + if s == "" { + return nil + } + return &s +} + +// ── Internal model → pluginsdk DTO mapping ────────────────────────────── + +func taskModelToDTO(t *taskmodels.Task) pluginsdk.Task { + repos := make([]pluginsdk.TaskRepository, len(t.Repositories)) + for i, r := range t.Repositories { + repos[i] = pluginsdk.TaskRepository{ + ID: r.ID, + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + Position: int32(r.Position), + } + } + return pluginsdk.Task{ + ID: t.ID, + WorkspaceID: t.WorkspaceID, + WorkflowID: t.WorkflowID, + Title: t.Title, + Description: t.Description, + State: string(t.State), + Priority: t.Priority, + // CreatedBy: kandev's Task model has no creating-user column — Origin + // ("manual"/"agent_created"/"routine"/"automation_run") is the + // closest analogue and is what this surfaces. + CreatedBy: t.Origin, + CreatedAt: t.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: t.UpdatedAt.UTC().Format(time.RFC3339), + // StartedAt/CompletedAt: the Task model has no started_at/completed_at + // columns (ArchivedAt is a different concept); left nil in v1. + ParentID: stringPtrOrNil(t.ParentID), + Identifier: t.Identifier, + IsEphemeral: t.IsEphemeral, + Repositories: repos, + Metadata: t.Metadata, + } +} + +func workspaceModelToDTO(w *taskmodels.Workspace) pluginsdk.Workspace { + return pluginsdk.Workspace{ + ID: w.ID, + Name: w.Name, + Description: stringPtrOrNil(w.Description), + OwnerID: w.OwnerID, + DefaultExecutorID: w.DefaultExecutorID, + DefaultAgentProfileID: w.DefaultAgentProfileID, + CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func workflowModelToDTO(w *taskmodels.Workflow) pluginsdk.Workflow { + return pluginsdk.Workflow{ + ID: w.ID, + WorkspaceID: w.WorkspaceID, + Name: w.Name, + Description: stringPtrOrNil(w.Description), + SortOrder: int32(w.SortOrder), + CreatedAt: w.CreatedAt.UTC().Format(time.RFC3339), + UpdatedAt: w.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func workflowStepModelToDTO(s *wfmodels.WorkflowStep) pluginsdk.WorkflowStep { + return pluginsdk.WorkflowStep{ + ID: s.ID, + WorkflowID: s.WorkflowID, + Name: s.Name, + Position: int32(s.Position), + StageType: string(s.StageType), + } +} + +func repositoryModelToDTO(r *taskmodels.Repository) pluginsdk.Repository { + return pluginsdk.Repository{ + ID: r.ID, + WorkspaceID: r.WorkspaceID, + Name: r.Name, + DefaultBranch: stringPtrOrNil(r.DefaultBranch), + } +} + +func agentProfileDTOToSDK(p agentsettingsdto.AgentProfileDTO) pluginsdk.AgentProfile { + return pluginsdk.AgentProfile{ + ID: p.ID, + AgentID: p.AgentID, + DisplayName: p.AgentDisplayName, + Name: p.Name, + Model: p.Model, + Mode: p.Mode, + } +} + +func sessionCodeStatsModelToDTO(s *analyticsmodels.SessionCodeStats) pluginsdk.SessionCodeStats { + return pluginsdk.SessionCodeStats{ + SessionID: s.SessionID, + LinesAddedCommitted: s.LinesAddedCommitted, + LinesDeletedCommitted: s.LinesDeletedCommitted, + LinesAddedPeakPending: s.LinesAddedPeakPending, + LinesDeletedPeakPending: s.LinesDeletedPeakPending, + } +} diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go new file mode 100644 index 0000000000..2f098aebb1 --- /dev/null +++ b/apps/backend/internal/plugins/host_data_test.go @@ -0,0 +1,728 @@ +package plugins + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + agentsettingsdto "github.com/kandev/kandev/internal/agent/settings/dto" + analyticsmodels "github.com/kandev/kandev/internal/analytics/models" + "github.com/kandev/kandev/internal/plugins/manifest" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + v1 "github.com/kandev/kandev/pkg/api/v1" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ── fakes for the narrow Host data API interfaces ─────────────────────── + +type fakeTaskDataSource struct { + workspaces []*taskmodels.Workspace + tasksByWorkspace map[string][]*taskmodels.Task + tasksByID map[string]*taskmodels.Task + repositories map[string][]*taskmodels.Repository + sessionsByTask map[string][]*taskmodels.TaskSession + executorRunning map[string]*taskmodels.ExecutorRunning + + // gotIncludeArchived records the includeArchived flag of every + // ListTasksByWorkspace call, in call order. + gotIncludeArchived []bool + + // executorRunningCalls counts GetExecutorRunningBySessionID calls, so + // tests can prove Sessions().List only resolves ACPSessionID for the + // page it actually returns, not every session it fetched. + executorRunningCalls int + + // listTasksByWorkspaceCalls counts ListTasksByWorkspace calls, so tests + // can prove a workspace with more tasks than a single page issues + // multiple calls instead of returning a truncated first page. + listTasksByWorkspaceCalls int +} + +func (f *fakeTaskDataSource) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) { + return f.workspaces, nil +} + +// ListTasksByWorkspace mirrors the real repository's page/pageSize semantics +// (1-based page, offset = (page-1)*pageSize, total = the full unpaginated +// count) so tests can prove callers that need every task actually loop +// pagination to completion instead of assuming a single page has it all. +func (f *fakeTaskDataSource) ListTasksByWorkspace( + _ context.Context, workspaceID, _, _, _ string, page, pageSize int, _ string, includeArchived, _, _, _ bool, +) ([]*taskmodels.Task, int, error) { + f.gotIncludeArchived = append(f.gotIncludeArchived, includeArchived) + f.listTasksByWorkspaceCalls++ + all := f.tasksByWorkspace[workspaceID] + total := len(all) + offset := (page - 1) * pageSize + if offset < 0 || offset >= total { + return []*taskmodels.Task{}, total, nil + } + end := offset + pageSize + if end > total { + end = total + } + return all[offset:end], total, nil +} + +func (f *fakeTaskDataSource) GetTask(_ context.Context, id string) (*taskmodels.Task, error) { + task, ok := f.tasksByID[id] + if !ok { + return nil, repoerrors.ErrTaskNotFound + } + return task, nil +} + +func (f *fakeTaskDataSource) ListRepositories(_ context.Context, workspaceID string) ([]*taskmodels.Repository, error) { + return f.repositories[workspaceID], nil +} + +func (f *fakeTaskDataSource) ListTaskSessions(_ context.Context, taskID string) ([]*taskmodels.TaskSession, error) { + return f.sessionsByTask[taskID], nil +} + +func (f *fakeTaskDataSource) GetExecutorRunningBySessionID(_ context.Context, sessionID string) (*taskmodels.ExecutorRunning, error) { + f.executorRunningCalls++ + running, ok := f.executorRunning[sessionID] + if !ok { + return nil, taskmodels.ErrExecutorRunningNotFound + } + return running, nil +} + +type fakeWorkflowLister struct { + workflows map[string][]*taskmodels.Workflow +} + +func (f *fakeWorkflowLister) ListWorkflows(_ context.Context, workspaceID string, _ bool) ([]*taskmodels.Workflow, error) { + return f.workflows[workspaceID], nil +} + +type fakeWorkflowStepLister struct { + steps map[string][]*wfmodels.WorkflowStep +} + +func (f *fakeWorkflowStepLister) ListStepsByWorkflow(_ context.Context, workflowID string) ([]*wfmodels.WorkflowStep, error) { + return f.steps[workflowID], nil +} + +type fakeAgentProfileDataSource struct { + resp *agentsettingsdto.ListAgentsResponse +} + +func (f *fakeAgentProfileDataSource) ListAgents(context.Context) (*agentsettingsdto.ListAgentsResponse, error) { + return f.resp, nil +} + +type fakeSessionCodeStatsSource struct { + calls int + lastFilter analyticsmodels.SessionCodeStatsFilter + stats []*analyticsmodels.SessionCodeStats + err error +} + +func (f *fakeSessionCodeStatsSource) ListSessionCodeStats( + _ context.Context, filter analyticsmodels.SessionCodeStatsFilter, +) ([]*analyticsmodels.SessionCodeStats, error) { + f.calls++ + f.lastFilter = filter + if f.err != nil { + return nil, f.err + } + return f.stats, nil +} + +// testDataHost bundles a pluginHost with every fake it was wired from, so +// tests can both drive Host calls and assert against the fakes' recorded +// state. +type testDataHost struct { + host *pluginHost + tasks *fakeTaskDataSource + workflows *fakeWorkflowLister + steps *fakeWorkflowStepLister + profiles *fakeAgentProfileDataSource + codeStats *fakeSessionCodeStatsSource +} + +// newTestDataHost builds a fully-wired pluginHost (every Host data API +// dependency set, even if a given test's capabilities don't grant every +// resource) so each test only needs to vary caps. +func newTestDataHost(caps manifest.Capabilities) *testDataHost { + d := &testDataHost{ + tasks: &fakeTaskDataSource{}, + workflows: &fakeWorkflowLister{}, + steps: &fakeWorkflowStepLister{}, + profiles: &fakeAgentProfileDataSource{resp: &agentsettingsdto.ListAgentsResponse{}}, + codeStats: &fakeSessionCodeStatsSource{}, + } + d.host = &pluginHost{ + pluginID: "p1", + capabilities: caps, + taskData: d.tasks, + workflows: d.workflows, + workflowSteps: d.steps, + agentProfiles: d.profiles, + sessionCodeStats: d.codeStats, + } + return d +} + +// ── capability gating: denied without api_read: ────────────── + +func TestPluginHost_Tasks_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:tasks") + + _, err = d.host.Tasks().Get(context.Background(), "task-1") + assertPermissionDenied(t, err, "api_read:tasks") +} + +func TestPluginHost_Sessions_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:sessions") + + _, _, err = d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:sessions") +} + +func TestPluginHost_Workspaces_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:workspaces") +} + +func TestPluginHost_Workflows_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:workflows") + + _, err = d.host.Workflows().ListSteps(context.Background(), "wf-1") + assertPermissionDenied(t, err, "api_read:workflows") +} + +func TestPluginHost_AgentProfiles_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:agent_profiles") +} + +func TestPluginHost_Repositories_DeniedWithoutCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + _, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:repositories") +} + +// ── capability gating: succeeds with api_read: ─────────────── + +func TestPluginHost_Tasks_SucceedsWithCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{ + "ws-1": {{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Task 1", State: v1.TaskStateTODO}}, + } + d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": d.tasks.tasksByWorkspace["ws-1"][0]} + + tasks, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(tasks) != 1 || tasks[0].ID != "task-1" { + t.Fatalf("List() = %+v, want one task-1", tasks) + } + if info == nil || info.HasMore { + t.Fatalf("PageInfo = %+v, want HasMore=false", info) + } + + got, err := d.host.Tasks().Get(context.Background(), "task-1") + if err != nil { + t.Fatalf("Get() unexpected error: %v", err) + } + if got == nil || got.Title != "Task 1" { + t.Fatalf("Get() = %+v, want Task 1", got) + } + + // A missing task returns a gRPC NotFound error directly, matching what a + // real plugin observes over the wire (grpcHostServer.GetTask forwards + // this error as-is) rather than a (nil, nil) success that only the + // in-process caller would ever see. + got, err = d.host.Tasks().Get(context.Background(), "no-such-task") + if got != nil { + t.Fatalf("Get() for missing task = (%+v, %v), want (nil, NotFound)", got, err) + } + if status.Code(err) != codes.NotFound { + t.Fatalf("Get() for missing task error = %v, want codes.NotFound", err) + } +} + +func TestPluginHost_Workspaces_SucceedsWithCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workspaces"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1", Name: "Workspace 1", OwnerID: "user-1"}} + + workspaces, _, err := d.host.Workspaces().List(context.Background(), pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(workspaces) != 1 || workspaces[0].Name != "Workspace 1" { + t.Fatalf("List() = %+v, want one Workspace 1", workspaces) + } +} + +func TestPluginHost_Workflows_SucceedsWithCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"workflows"}}) + d.workflows.workflows = map[string][]*taskmodels.Workflow{ + "ws-1": {{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}}, + } + d.steps.steps = map[string][]*wfmodels.WorkflowStep{ + "wf-1": {{ID: "step-1", WorkflowID: "wf-1", Name: "Todo", Position: 0, StageType: wfmodels.StageType("work")}}, + } + + workflows, _, err := d.host.Workflows().List(context.Background(), "ws-1", pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(workflows) != 1 || workflows[0].ID != "wf-1" { + t.Fatalf("List() = %+v, want one wf-1", workflows) + } + + steps, err := d.host.Workflows().ListSteps(context.Background(), "wf-1") + if err != nil { + t.Fatalf("ListSteps() unexpected error: %v", err) + } + if len(steps) != 1 || steps[0].StageType != "work" { + t.Fatalf("ListSteps() = %+v, want one step with StageType=work", steps) + } +} + +func TestPluginHost_AgentProfiles_SucceedsWithCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"agent_profiles"}}) + d.profiles.resp = &agentsettingsdto.ListAgentsResponse{ + Agents: []agentsettingsdto.AgentDTO{ + { + ID: "agent-1", + Profiles: []agentsettingsdto.AgentProfileDTO{ + {ID: "profile-1", AgentID: "agent-1", Name: "Default", AgentDisplayName: "Claude", Model: "claude-x", Mode: "code"}, + }, + }, + }, + } + + profiles, _, err := d.host.AgentProfiles().List(context.Background(), pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(profiles) != 1 || profiles[0].DisplayName != "Claude" || profiles[0].Model != "claude-x" { + t.Fatalf("List() = %+v, want one Claude/claude-x profile", profiles) + } +} + +func TestPluginHost_Repositories_SucceedsWithCapability(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"repositories"}}) + branch := "main" + d.tasks.repositories = map[string][]*taskmodels.Repository{ + "ws-1": {{ID: "repo-1", WorkspaceID: "ws-1", Name: "kandev", DefaultBranch: branch}}, + } + + repos, _, err := d.host.Repositories().List(context.Background(), "ws-1", pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(repos) != 1 || repos[0].Name != "kandev" || repos[0].DefaultBranch == nil || *repos[0].DefaultBranch != "main" { + t.Fatalf("List() = %+v, want one kandev repo on main", repos) + } +} + +// TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize proves a +// workspace with more tasks than a single taskFetchPageSize page is fully +// enumerated, not silently cut off at the first page. +func TestFetchTasksForWorkspaces_DoesNotTruncateBeyondPageSize(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + const total = taskFetchPageSize + 1 + tasks := make([]*taskmodels.Task, total) + for i := 0; i < total; i++ { + tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"} + } + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks} + + got, err := d.host.fetchTasksForWorkspaces(context.Background(), []string{"ws-1"}, false, false) + if err != nil { + t.Fatalf("fetchTasksForWorkspaces() unexpected error: %v", err) + } + if len(got) != total { + t.Fatalf("fetchTasksForWorkspaces() returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", len(got), total) + } + if d.tasks.listTasksByWorkspaceCalls < 2 { + t.Fatalf("ListTasksByWorkspace called %d times, want >= 2 (a single %d-task page can't hold %d tasks)", + d.tasks.listTasksByWorkspaceCalls, taskFetchPageSize, total) + } +} + +// TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize is the +// end-to-end version of the above: Tasks().List's HasMore/pagination must +// stay accurate even when the underlying per-workspace fetch spans more than +// one taskFetchPageSize page. +func TestPluginHost_Tasks_List_DoesNotTruncateBeyondPageSize(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + const total = taskFetchPageSize + 1 + tasks := make([]*taskmodels.Task, total) + for i := 0; i < total; i++ { + tasks[i] = &taskmodels.Task{ID: fmt.Sprintf("task-%04d", i), WorkspaceID: "ws-1"} + } + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": tasks} + + // maxPageLimit caps a single RPC page at 200 items, so walk every page + // via cursor and count the total returned across the whole read. + seen := 0 + cursor := "" + for { + page, info, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{WorkspaceIDs: []string{"ws-1"}}, pluginsdk.Page{Limit: 200, Cursor: cursor}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + seen += len(page) + if info == nil || !info.HasMore { + break + } + cursor = info.NextCursor + } + if seen != total { + t.Fatalf("Tasks().List() across all pages returned %d tasks, want %d (must not silently truncate at taskFetchPageSize)", seen, total) + } +} + +// ── Session DTO mapping: acp_session_id sourcing ──────────────────────── + +func TestPluginHost_Sessions_ACPSessionIDFromMetadata(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{ + "task-1": {{ + ID: "session-1", + TaskID: "task-1", + State: taskmodels.TaskSessionStateRunning, + StartedAt: time.Now(), + Metadata: map[string]any{"acp": map[string]any{"session_id": "acp-from-metadata"}}, + }}, + } + // Also seed an executors_running row so the test proves metadata wins + // over the fallback when both are present. + d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{ + "session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"}, + } + + sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-metadata" { + t.Fatalf("List() = %+v, want ACPSessionID=acp-from-metadata", sessions) + } +} + +func TestPluginHost_Sessions_ACPSessionIDFallsBackToExecutorRunning(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{ + "task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}}, + } + d.tasks.executorRunning = map[string]*taskmodels.ExecutorRunning{ + "session-1": {SessionID: "session-1", ResumeToken: "acp-from-fallback"}, + } + + sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(sessions) != 1 || sessions[0].ACPSessionID != "acp-from-fallback" { + t.Fatalf("List() = %+v, want ACPSessionID=acp-from-fallback", sessions) + } +} + +func TestPluginHost_Sessions_ACPSessionIDEmptyWhenNeitherSourceHasIt(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{ + "task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}}, + } + // No executorRunning entry for session-1: GetExecutorRunningBySessionID + // returns ErrExecutorRunningNotFound, which must be swallowed. + + sessions, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(sessions) != 1 || sessions[0].ACPSessionID != "" { + t.Fatalf("List() = %+v, want empty ACPSessionID", sessions) + } +} + +// TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID proves +// Sessions().List resolves ACPSessionID (a per-item DB call via +// resolveACPSessionID's GetExecutorRunningBySessionID fallback) only for the +// page it returns, not for every session it fetched — pagination must happen +// on the raw, already-sorted sessions BEFORE the per-item DTO conversion, or +// a Page{Limit: 2} read over 5 sessions would issue 5 lookups instead of 2. +func TestPluginHost_Sessions_PaginatesBeforeResolvingACPSessionID(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + + const total = 5 + sessions := make([]*taskmodels.TaskSession, total) + base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC) + for i := 0; i < total; i++ { + // No Metadata: resolveACPSessionID always falls through to + // GetExecutorRunningBySessionID for every one of these sessions. + sessions[i] = &taskmodels.TaskSession{ + ID: fmt.Sprintf("session-%d", i), + TaskID: "task-1", + State: taskmodels.TaskSessionStateRunning, + StartedAt: base.Add(time.Duration(total-i) * time.Hour), // session-0 newest + } + } + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{"task-1": sessions} + + const limit = 2 + page, info, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: limit}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(page) != limit { + t.Fatalf("List() returned %d sessions, want %d", len(page), limit) + } + if info == nil || !info.HasMore { + t.Fatalf("PageInfo = %+v, want HasMore=true", info) + } + if d.tasks.executorRunningCalls != limit { + t.Fatalf("GetExecutorRunningBySessionID called %d times, want %d (only the returned page, not all %d fetched sessions)", + d.tasks.executorRunningCalls, limit, total) + } +} + +// TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService proves +// Sessions().CodeStats reaches the injected analytics service with the +// filter translated 1:1, and maps its results back unchanged (ADR 0043(b): +// SessionCodeStats is a stable, computed shape returned as-is). +func TestPluginHost_SessionsCodeStats_DelegatesToAnalyticsService(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.codeStats.stats = []*analyticsmodels.SessionCodeStats{ + {SessionID: "session-1", LinesAddedCommitted: 10, LinesDeletedCommitted: 2, LinesAddedPeakPending: 5, LinesDeletedPeakPending: 1}, + } + + filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1"}, WorkspaceIDs: []string{"ws-1"}, States: []string{"RUNNING"}} + stats, info, err := d.host.Sessions().CodeStats(context.Background(), filter, pluginsdk.Page{Limit: 10}) + if err != nil { + t.Fatalf("CodeStats() unexpected error: %v", err) + } + if d.codeStats.calls != 1 { + t.Fatalf("analytics service called %d times, want 1", d.codeStats.calls) + } + if len(d.codeStats.lastFilter.TaskIDs) != 1 || d.codeStats.lastFilter.TaskIDs[0] != "task-1" { + t.Errorf("filter.TaskIDs = %v, want [task-1]", d.codeStats.lastFilter.TaskIDs) + } + if len(d.codeStats.lastFilter.WorkspaceIDs) != 1 || d.codeStats.lastFilter.WorkspaceIDs[0] != "ws-1" { + t.Errorf("filter.WorkspaceIDs = %v, want [ws-1]", d.codeStats.lastFilter.WorkspaceIDs) + } + if len(d.codeStats.lastFilter.States) != 1 || d.codeStats.lastFilter.States[0] != "RUNNING" { + t.Errorf("filter.States = %v, want [RUNNING]", d.codeStats.lastFilter.States) + } + // Limit is requested as limit+1 (the HasMore probe row) — see + // sessionReader.CodeStats' doc comment. + if d.codeStats.lastFilter.Limit != 11 { + t.Errorf("filter.Limit = %d, want 11 (requested 10 + 1 probe row)", d.codeStats.lastFilter.Limit) + } + if len(stats) != 1 || stats[0].SessionID != "session-1" || stats[0].LinesAddedCommitted != 10 { + t.Fatalf("CodeStats() = %+v, want session-1 passed through unchanged", stats) + } + if info == nil || info.HasMore { + t.Fatalf("PageInfo = %+v, want HasMore=false", info) + } +} + +// TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned proves +// the limit+1 probe-row trick correctly reports HasMore and trims the extra +// row before returning results. +func TestPluginHost_SessionsCodeStats_HasMoreWhenExtraProbeRowReturned(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + d.codeStats.stats = []*analyticsmodels.SessionCodeStats{ + {SessionID: "session-1"}, {SessionID: "session-2"}, + } + + stats, info, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 1}) + if err != nil { + t.Fatalf("CodeStats() unexpected error: %v", err) + } + if len(stats) != 1 || stats[0].SessionID != "session-1" { + t.Fatalf("CodeStats() = %+v, want exactly one trimmed result", stats) + } + if info == nil || !info.HasMore || info.NextCursor != "1" { + t.Fatalf("PageInfo = %+v, want HasMore=true NextCursor=1", info) + } +} + +func TestPluginHost_SessionsCodeStats_PropagatesAnalyticsError(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + wantErr := errors.New("boom") + d.codeStats.err = wantErr + + _, _, err := d.host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + if !errors.Is(err, wantErr) { + t.Fatalf("CodeStats() error = %v, want %v", err, wantErr) + } +} + +// TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs proves SessionFilter's +// TaskIDs and WorkspaceIDs are ANDed together: a task id that resolves to a +// task outside the requested workspaces must not leak its sessions, even +// though the id itself was explicitly requested. +func TestPluginHost_Sessions_TaskIDsScopedToWorkspaceIDs(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + task1 := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"} + task2 := &taskmodels.Task{ID: "task-2", WorkspaceID: "ws-2"} + d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task1, "task-2": task2} + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{ + "task-1": {{ID: "session-1", TaskID: "task-1", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}}, + "task-2": {{ID: "session-2", TaskID: "task-2", State: taskmodels.TaskSessionStateRunning, StartedAt: time.Now()}}, + } + + filter := pluginsdk.SessionFilter{TaskIDs: []string{"task-1", "task-2"}, WorkspaceIDs: []string{"ws-1"}} + sessions, _, err := d.host.Sessions().List(context.Background(), filter, pluginsdk.Page{}) + if err != nil { + t.Fatalf("List() unexpected error: %v", err) + } + if len(sessions) != 1 || sessions[0].ID != "session-1" { + t.Fatalf("List() = %+v, want only session-1 (task-2 is outside the requested workspace)", sessions) + } +} + +// ── Stable sort: deterministic pagination across equal timestamps ─────── + +// TestSortTasksNewestFirst_TiesBrokenByID proves sortTasksNewestFirst orders +// equal-CreatedAt tasks deterministically by ID, not by whatever order +// sort.Slice's unstable algorithm happens to leave them in — an offset-based +// paginated read of tasks sharing a CreatedAt second (a plausible seed/batch +// scenario) must return the same order on every call, or successive pages +// can skip or duplicate a task. +func TestSortTasksNewestFirst_TiesBrokenByID(t *testing.T) { + same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + tasks := []*taskmodels.Task{ + {ID: "c", CreatedAt: same}, + {ID: "a", CreatedAt: same}, + {ID: "b", CreatedAt: same}, + } + + sortTasksNewestFirst(tasks) + + got := []string{tasks[0].ID, tasks[1].ID, tasks[2].ID} + want := []string{"a", "b", "c"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sortTasksNewestFirst() order = %v, want %v (equal CreatedAt must tie-break by ID)", got, want) + } + } +} + +// TestSortSessionsNewestFirst_TiesBrokenByID mirrors +// TestSortTasksNewestFirst_TiesBrokenByID for sessions (StartedAt instead of +// CreatedAt). +func TestSortSessionsNewestFirst_TiesBrokenByID(t *testing.T) { + same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + sessions := []*taskmodels.TaskSession{ + {ID: "c", StartedAt: same}, + {ID: "a", StartedAt: same}, + {ID: "b", StartedAt: same}, + } + + sortSessionsNewestFirst(sessions) + + got := []string{sessions[0].ID, sessions[1].ID, sessions[2].ID} + want := []string{"a", "b", "c"} + for i := range want { + if got[i] != want[i] { + t.Fatalf("sortSessionsNewestFirst() order = %v, want %v (equal StartedAt must tie-break by ID)", got, want) + } + } +} + +// ── DTO mapping ────────────────────────────────────────────────────────── + +func TestTaskModelToDTO_MapsFields(t *testing.T) { + created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC) + task := &taskmodels.Task{ + ID: "task-1", + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Fix bug", + Description: "details", + State: v1.TaskStateInProgress, + Priority: "high", + Origin: "agent_created", + CreatedAt: created, + UpdatedAt: created, + ParentID: "parent-1", + Identifier: "KAN-1", + IsEphemeral: false, + Repositories: []*taskmodels.TaskRepository{ + {ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0}, + }, + Metadata: map[string]any{"k": "v"}, + } + + dto := taskModelToDTO(task) + + if dto.ID != "task-1" || dto.State != "IN_PROGRESS" || dto.CreatedBy != "agent_created" { + t.Fatalf("taskModelToDTO() = %+v, unexpected core fields", dto) + } + if dto.CreatedAt != created.Format(time.RFC3339) { + t.Errorf("CreatedAt = %q, want RFC3339 %q", dto.CreatedAt, created.Format(time.RFC3339)) + } + if dto.ParentID == nil || *dto.ParentID != "parent-1" { + t.Errorf("ParentID = %v, want parent-1", dto.ParentID) + } + if len(dto.Repositories) != 1 || dto.Repositories[0].RepositoryID != "repo-1" { + t.Errorf("Repositories = %+v, want one repo-1", dto.Repositories) + } + if dto.Metadata["k"] != "v" { + t.Errorf("Metadata = %+v, want k=v", dto.Metadata) + } +} + +func TestTaskModelToDTO_EmptyParentIDIsNil(t *testing.T) { + dto := taskModelToDTO(&taskmodels.Task{ID: "task-1"}) + if dto.ParentID != nil { + t.Fatalf("ParentID = %v, want nil for a root task", dto.ParentID) + } +} + +// ── Archived-task visibility ──────────────────────────────────────────── + +// Sessions().List must fetch tasks WITH archived included (an archived +// task's sessions are still real sessions), while Tasks().List keeps +// archived tasks out of plugin-visible work items. +func TestPluginHost_ArchivedTaskFetchFlags(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions", "tasks"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + + if _, _, err := d.host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}); err != nil { + t.Fatalf("Sessions().List unexpected error: %v", err) + } + if _, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}); err != nil { + t.Fatalf("Tasks().List unexpected error: %v", err) + } + + want := []bool{true, false} // sessions read first (archived in), tasks read second (archived out) + if len(d.tasks.gotIncludeArchived) != 2 || + d.tasks.gotIncludeArchived[0] != want[0] || d.tasks.gotIncludeArchived[1] != want[1] { + t.Fatalf("includeArchived per call = %v, want %v", d.tasks.gotIncludeArchived, want) + } +} diff --git a/apps/backend/internal/plugins/host_data_wire_test.go b/apps/backend/internal/plugins/host_data_wire_test.go new file mode 100644 index 0000000000..953029adf3 --- /dev/null +++ b/apps/backend/internal/plugins/host_data_wire_test.go @@ -0,0 +1,249 @@ +// host_data_wire_test.go proves the Host data API (ADR 0043) end to end over +// the REAL go-plugin gRPC transport: a plugin-side pluginsdk.Host call +// travels through proto -> a real grpc.Server/Client pair with a real +// GRPCBroker (hcplugin.TestPluginGRPCConn — no subprocess, but the same +// transport code production uses) -> this package's real *pluginHost, which +// enforces the api_read: capability gate and calls the injected +// (fake) data sources. +// +// This complements two narrower test layers that already exist: +// - pkg/pluginsdk/host_data_test.go exercises grpcHostServer/grpcHostClient +// over a bufconn with a fake pluginsdk.Host — proves the SDK's wire +// plumbing, not kandev's real capability gate or DTO mapping. +// - internal/plugins/host_data_test.go calls *pluginHost's methods +// directly (no transport) — proves the gate and DTO mapping in-process. +// +// Only this file drives a real *pluginHost from the plugin side of a real +// broker connection, so it is the one place proving a plugin cannot read +// undeclared resources no matter how it talks to kandev. +// +// Placement: this lives in internal/plugins (not pkg/pluginsdk) because it +// needs to construct a real *pluginHost, which imports pluginsdk — importing +// internal/plugins from a pkg/pluginsdk test would invert that dependency +// direction. internal/plugins already imports pluginsdk and hashicorp/go-plugin +// transitively, so the harness reuse (hcplugin.TestPluginGRPCConn) is a +// same-direction import here. +package plugins + +import ( + "context" + "testing" + "time" + + hcplugin "github.com/hashicorp/go-plugin" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + analyticsmodels "github.com/kandev/kandev/internal/analytics/models" + "github.com/kandev/kandev/internal/plugins/manifest" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// wireAuthorPlugin is a minimal author-facing pluginsdk.Plugin. It only +// exists to receive the Host injected over the broker (via the embedded +// UnimplementedPlugin's HostSetter/Host() accessor — see serve.go's "Host +// injection" doc), so these tests can call host.Sessions()... exactly as a +// real plugin author would from InvokeTool/OnEvent/HandleWebhook. +type wireAuthorPlugin struct { + pluginsdk.UnimplementedPlugin +} + +// dialPluginHostOverWire serves host (a real, capability-gated *pluginHost) +// as the kandev.plugin.v1 Host service over a real go-plugin gRPC broker — +// hcplugin.TestPluginGRPCConn wires a real unix-socket grpc.Server/Client +// pair with a real GRPCBroker, matching production transport behavior +// (pkg/pluginsdk/serve_test.go uses the identical harness). It returns the +// plugin-side pluginsdk.Host handle, so callers exercise the full +// SDK-call -> proto -> grpc -> broker -> grpcHostServer -> *pluginHost path. +func dialPluginHostOverWire(t *testing.T, host *pluginHost) pluginsdk.Host { + t.Helper() + author := &wireAuthorPlugin{} + gp := &pluginsdk.GRPCPlugin{Impl: author, Host: host, HostDialTimeout: 5 * time.Second} + + client, server := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{ + pluginsdk.PluginMapKey: gp, + }) + // Registered in the same order as pkg/pluginsdk/serve_test.go's + // deferred cleanup (server.Stop before client.Close): t.Cleanup runs + // LIFO, so registering Close first means Stop runs first at teardown. + t.Cleanup(func() { _ = client.Close() }) + t.Cleanup(server.Stop) + + raw, err := client.Dispense(pluginsdk.PluginMapKey) + require.NoError(t, err) + _, ok := raw.(*pluginsdk.RemotePlugin) + require.True(t, ok, "Dispense(%q) should return *pluginsdk.RemotePlugin, got %T", pluginsdk.PluginMapKey, raw) + + require.Eventually(t, func() bool { + return author.Host() != nil + }, 5*time.Second, 10*time.Millisecond, "plugin-side Host should be injected via the broker") + return author.Host() +} + +// seedSessionsWireFixture wires d's fake task data source with a single +// workspace/task and n canned sessions (newest first: session-0 started +// most recently), plus n matching SessionCodeStats rows in the analytics +// fake, and a distinguishing acp_session_id on the first session so the DTO +// round-trip assertion has something to check besides IDs. +func seedSessionsWireFixture(d *testDataHost, n int) { + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}} + d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}} + + base := time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC) + sessions := make([]*taskmodels.TaskSession, n) + stats := make([]*analyticsmodels.SessionCodeStats, n) + for i := 0; i < n; i++ { + id := "session-" + string(rune('0'+i)) + sessions[i] = &taskmodels.TaskSession{ + ID: id, + TaskID: "task-1", + State: taskmodels.TaskSessionStateRunning, + StartedAt: base.Add(time.Duration(n-i) * time.Hour), // session-0 newest + } + if i == 0 { + sessions[i].Metadata = map[string]any{"acp": map[string]any{"session_id": "acp-session-0"}} + } + stats[i] = &analyticsmodels.SessionCodeStats{ + SessionID: id, + LinesAddedCommitted: int64(10 * (i + 1)), + } + } + d.tasks.sessionsByTask = map[string][]*taskmodels.TaskSession{"task-1": sessions} + d.codeStats.stats = stats +} + +// TestPluginHostData_Wire_GetTaskNotFound proves a missing task surfaces as +// gRPC NotFound over the real broker transport (GetTaskResponse's wrapper +// doesn't change that), matching *pluginHost's in-process taskReader.Get +// contract exactly — see host_data_test.go's +// TestPluginHost_Tasks_SucceedsWithCapability for the in-process half. +func TestPluginHostData_Wire_GetTaskNotFound(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + host := dialPluginHostOverWire(t, d.host) + + task, err := host.Tasks().Get(context.Background(), "no-such-task") + require.Nil(t, task) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "expected a gRPC status error, got %v", err) + require.Equal(t, codes.NotFound, st.Code()) +} + +// TestPluginHostData_Wire_SessionsRoundTrip proves canned Sessions + +// SessionCodeStats data round-trips correctly, over the real broker +// transport, through a *pluginHost whose manifest declares api_read:sessions +// — the concrete "plugin reads kandev data over gRPC" scenario ADR 0043 +// exists for. +func TestPluginHostData_Wire_SessionsRoundTrip(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + seedSessionsWireFixture(d, 1) + + host := dialPluginHostOverWire(t, d.host) + + sessions, pageInfo, err := host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + require.NoError(t, err) + require.Len(t, sessions, 1) + require.Equal(t, "session-0", sessions[0].ID) + require.Equal(t, "task-1", sessions[0].TaskID) + require.Equal(t, "acp-session-0", sessions[0].ACPSessionID, "acp_session_id should round-trip from TaskSession.Metadata") + require.Equal(t, "RUNNING", sessions[0].State) + require.NotNil(t, pageInfo) + require.False(t, pageInfo.HasMore) + + stats, _, err := host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + require.NoError(t, err) + require.Len(t, stats, 1) + require.Equal(t, "session-0", stats[0].SessionID) + require.Equal(t, int64(10), stats[0].LinesAddedCommitted) +} + +// TestPluginHostData_Wire_PermissionDeniedPerResource is the security +// assertion: a *pluginHost whose manifest does NOT declare api_read:sessions +// returns gRPC PermissionDenied with the exact "capability +// 'api_read:sessions' not declared" wire message when a plugin calls +// Sessions().CodeStats over the real transport — proving the gate is +// enforced host-side, not something a malicious or buggy plugin could route +// around by talking to the wire protocol directly. Run twice, with two +// different capability sets, to prove the gate is per-resource (not a single +// on/off switch): a manifest with only api_read:tasks still denies sessions, +// while its own declared resource (tasks) succeeds over the same connection. +func TestPluginHostData_Wire_PermissionDeniedPerResource(t *testing.T) { + t.Run("NoCapabilitiesDeclared", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + seedSessionsWireFixture(d, 1) + host := dialPluginHostOverWire(t, d.host) + + _, _, err := host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "expected a gRPC status error, got %v", err) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "capability 'api_read:sessions' not declared", st.Message()) + }) + + t.Run("OnlyTasksCapabilityDeclared_TasksWorkSessionsDenied", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + seedSessionsWireFixture(d, 1) + // seedSessionsWireFixture wires a bare task-1 fixture (Sessions() + // needs one to hang sessions off of); give it a Title so the Tasks() + // assertion below has something distinguishing to check. + d.tasks.tasksByWorkspace["ws-1"][0].Title = "Fix the bug" + host := dialPluginHostOverWire(t, d.host) + + // The declared resource (tasks) succeeds over the wire. + tasks, _, err := host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}) + require.NoError(t, err) + require.Len(t, tasks, 1) + require.Equal(t, "Fix the bug", tasks[0].Title) + + // The undeclared resource (sessions) is denied, per-resource, on the + // exact same connection/host. + _, _, err = host.Sessions().CodeStats(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + require.Error(t, err) + st, ok := status.FromError(err) + require.True(t, ok, "expected a gRPC status error, got %v", err) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "capability 'api_read:sessions' not declared", st.Message()) + + _, _, err = host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{}) + require.Error(t, err) + st, ok = status.FromError(err) + require.True(t, ok, "expected a gRPC status error, got %v", err) + require.Equal(t, codes.PermissionDenied, st.Code()) + require.Equal(t, "capability 'api_read:sessions' not declared", st.Message()) + }) +} + +// TestPluginHostData_Wire_SessionsPagination seeds three sessions and drives +// two Sessions().List calls over the real broker connection: Page{Limit: 2} +// must return exactly 2 items plus a non-empty PageInfo.NextCursor, and a +// follow-up call passing that cursor back must return the remaining item +// with HasMore=false — proving the opaque-cursor pagination contract (ADR +// 0042 "Conventions") survives the real proto round trip, not just the +// in-process paginate() helper. +func TestPluginHostData_Wire_SessionsPagination(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"sessions"}}) + seedSessionsWireFixture(d, 3) + host := dialPluginHostOverWire(t, d.host) + + firstPage, pageInfo, err := host.Sessions().List(context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 2}) + require.NoError(t, err) + require.Len(t, firstPage, 2) + require.Equal(t, "session-0", firstPage[0].ID) + require.Equal(t, "session-1", firstPage[1].ID) + require.NotNil(t, pageInfo) + require.True(t, pageInfo.HasMore) + require.NotEmpty(t, pageInfo.NextCursor) + + secondPage, secondPageInfo, err := host.Sessions().List( + context.Background(), pluginsdk.SessionFilter{}, pluginsdk.Page{Limit: 2, Cursor: pageInfo.NextCursor}, + ) + require.NoError(t, err) + require.Len(t, secondPage, 1) + require.Equal(t, "session-2", secondPage[0].ID) + require.NotNil(t, secondPageInfo) + require.False(t, secondPageInfo.HasMore) + require.Empty(t, secondPageInfo.NextCursor) +} diff --git a/apps/backend/internal/plugins/manifest/capability.go b/apps/backend/internal/plugins/manifest/capability.go index 01b280c54e..f86da9740f 100644 --- a/apps/backend/internal/plugins/manifest/capability.go +++ b/apps/backend/internal/plugins/manifest/capability.go @@ -15,13 +15,30 @@ func (m *Manifest) HasEvent(name string) bool { // CanRead reports whether the manifest declares read access to resource via // Capabilities.APIRead. func (m *Manifest) CanRead(resource string) bool { - return containsString(m.Capabilities.APIRead, resource) + return m.Capabilities.CanRead(resource) } // CanWrite reports whether the manifest declares write access to resource // via Capabilities.APIWrite. func (m *Manifest) CanWrite(resource string) bool { - return containsString(m.Capabilities.APIWrite, resource) + return m.Capabilities.CanWrite(resource) +} + +// CanRead reports whether c declares read access to resource via APIRead +// (ADR 0043's api_read: capabilities, e.g. "tasks", "sessions"). +// Exposed on Capabilities directly (not just Manifest) so callers that only +// hold a plugin's currently-registered Capabilities snapshot — such as +// internal/plugins.pluginHost, bound at spawn time — can gate without a full +// Manifest. +func (c Capabilities) CanRead(resource string) bool { + return containsString(c.APIRead, resource) +} + +// CanWrite reports whether c declares write access to resource via +// APIWrite. See CanRead's doc comment for why this also lives on +// Capabilities directly. +func (c Capabilities) CanWrite(resource string) bool { + return containsString(c.APIWrite, resource) } // HasUIBundle reports whether the manifest declares a native UI bundle via diff --git a/apps/backend/internal/plugins/runtime/manager_test.go b/apps/backend/internal/plugins/runtime/manager_test.go index 591cdd17cf..a56eb41b90 100644 --- a/apps/backend/internal/plugins/runtime/manager_test.go +++ b/apps/backend/internal/plugins/runtime/manager_test.go @@ -20,8 +20,13 @@ import ( // fakeHost is a minimal in-memory pluginsdk.Host for end-to-end tests: it // only implements SetState (with a channel so tests can synchronize on -// delivery without a sleep), and errors on everything else. +// delivery without a sleep), and errors on everything else. It embeds +// UnimplementedHostData to satisfy the Host data API (ADR 0043) +// sub-accessors without wiring them; see docs/plans/plugins/host-data-api/ +// task-04-host-data-impl.md for the real implementation. type fakeHost struct { + pluginsdk.UnimplementedHostData + mu sync.Mutex states map[string]map[string]any setCh chan struct{} diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 2fb4f88db4..2fea88d8ba 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -81,6 +81,18 @@ type Service struct { runtime PluginRuntime secrets SecretRevealer + // Host data API (ADR 0043) service-layer dependencies, wired via + // SetDataSources and handed to every pluginHost hostForPlugin builds. + // nil until backendapp calls SetDataSources (see its doc comment); a + // pluginHost built before that falls back to Unimplemented for these + // accessors regardless of declared capabilities (see host_data.go's + // accessor nil-checks). + taskData taskDataSource + workflows workflowLister + workflowSteps workflowStepLister + agentProfiles agentProfileDataSource + sessionCodeStats sessionCodeStatsSource + // kandevVersion is the currently running kandev build version, used to // enforce a package's manifest.min_kandev_version at Install (see // SetKandevVersion / checkMinKandevVersion). Empty (the default) means @@ -167,6 +179,34 @@ func (s *Service) SetSecrets(v SecretRevealer) { s.secrets = v } +// SetDataSources wires the Host data API's (ADR 0043) service-layer +// dependencies, following the same post-construction "SetX" pattern as +// SetDeliverer/SetSecrets (see the "Extension points" doc comment on +// Service). backendapp calls this once, passing its already-constructed +// task, workflow, agent-settings, and analytics services directly — each +// argument's interface is a narrow slice of one of those services +// (host_data.go's taskDataSource/workflowLister/workflowStepLister/ +// agentProfileDataSource/sessionCodeStatsSource), satisfied structurally, so +// no adapter type is needed. Not called by Provide itself: the plugins +// package cannot import internal/task/service, internal/workflow/service, +// etc. without an import cycle, mirroring why event delivery is wired the +// same way. Every pluginHost hostForPlugin builds after this call gets +// these dependencies; one built before (e.g. very early boot) falls back to +// Unimplemented for the Host data API regardless of declared capabilities. +func (s *Service) SetDataSources( + tasks taskDataSource, + workflows workflowLister, + workflowSteps workflowStepLister, + agentProfiles agentProfileDataSource, + sessionCodeStats sessionCodeStatsSource, +) { + s.taskData = tasks + s.workflows = workflows + s.workflowSteps = workflowSteps + s.agentProfiles = agentProfiles + s.sessionCodeStats = sessionCodeStats +} + // SetKandevVersion wires the currently running kandev build version, // enabling Install to enforce a package's manifest.min_kandev_version // (checkMinKandevVersion): a package requiring a newer kandev is rejected @@ -252,11 +292,16 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host { rec = &store.Record{} // every capability check below denies; should not happen in practice } return &pluginHost{ - pluginID: pluginID, - capabilities: rec.Capabilities, - state: s.state, - secrets: s.secrets, - bus: s.eventBus, + pluginID: pluginID, + capabilities: rec.Capabilities, + state: s.state, + secrets: s.secrets, + bus: s.eventBus, + taskData: s.taskData, + workflows: s.workflows, + workflowSteps: s.workflowSteps, + agentProfiles: s.agentProfiles, + sessionCodeStats: s.sessionCodeStats, } } diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 5534622d69..8e2b4c268a 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -106,13 +106,11 @@ func isFromOfficeProjection(alias string) string { )` } +// excludeConfigModePredicate delegates to the shared dialect helper (also +// used by internal/analytics/repository/sqlite's CodeStats read, so both +// cover the same office config-mode exclusion). func excludeConfigModePredicate(driver, col string) string { - if dialect.IsPostgres(driver) { - // Repository writes always marshal metadata as JSON; dirty Postgres rows - // with malformed JSON should fail loudly instead of being silently skipped. - return fmt.Sprintf("COALESCE(%s, '') NOT IN ('true', '1')", dialect.JSONExtract(driver, col, "config_mode")) - } - return fmt.Sprintf("%s IS NOT 1", dialect.JSONExtract(driver, col, "config_mode")) + return dialect.ExcludeConfigModePredicate(driver, col) } // runnerProjection produces the correlated subquery (without alias) that diff --git a/apps/backend/internal/task/service/service_sessions.go b/apps/backend/internal/task/service/service_sessions.go index 7dc5bc2c40..97674d7386 100644 --- a/apps/backend/internal/task/service/service_sessions.go +++ b/apps/backend/internal/task/service/service_sessions.go @@ -63,6 +63,20 @@ func (s *Service) GetTaskSession(ctx context.Context, sessionID string) (*models return s.sessions.GetTaskSession(ctx, sessionID) } +// GetExecutorRunningBySessionID returns the live executor row for sessionID, +// or models.ErrExecutorRunningNotFound if the session has none (e.g. it +// never started, or has since completed and been cleaned up). Exposed at +// the service layer — rather than requiring callers to reach into +// repository.ExecutorRepository directly — for the Host data API's +// acp_session_id fallback (ADR 0043): a session's ACP conversation id is +// normally read from TaskSession.Metadata["acp"]["session_id"], but that key +// is only populated once the agent has emitted a session_info frame; +// executors_running.resume_token carries the same id and survives on +// sessions that never got that far. +func (s *Service) GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*models.ExecutorRunning, error) { + return s.executors.GetExecutorRunningBySessionID(ctx, sessionID) +} + func (s *Service) DismissLastAgentError(ctx context.Context, sessionID, stamp string) (*models.TaskSession, error) { session, err := s.sessions.GetTaskSession(ctx, sessionID) if err != nil { diff --git a/apps/backend/internal/task/service/service_sessions_test.go b/apps/backend/internal/task/service/service_sessions_test.go new file mode 100644 index 0000000000..ad40e234d9 --- /dev/null +++ b/apps/backend/internal/task/service/service_sessions_test.go @@ -0,0 +1,48 @@ +package service + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agentruntime" + "github.com/kandev/kandev/internal/task/models" +) + +// TestService_GetExecutorRunningBySessionID_DelegatesToRepository proves the +// service-layer accessor (added for the Host data API's acp_session_id +// fallback, ADR 0043) reaches the real executor repository rather than +// returning a stub, and surfaces the same not-found sentinel repository +// callers already rely on. +func TestService_GetExecutorRunningBySessionID_DelegatesToRepository(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + + _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) + _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "Workflow"}) + _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", Title: "Test", Priority: "medium"}) + _ = repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateRunning}) + if err := repo.UpsertExecutorRunning(ctx, &models.ExecutorRunning{ + ID: "session-1", + SessionID: "session-1", + TaskID: "task-1", + ExecutorID: "executor-1", + Runtime: agentruntime.RuntimeStandalone, + Status: models.ExecutorRunningStatusRunning, + ResumeToken: "acp-resume-token", + }); err != nil { + t.Fatalf("seed executor running: %v", err) + } + + got, err := svc.GetExecutorRunningBySessionID(ctx, "session-1") + if err != nil { + t.Fatalf("GetExecutorRunningBySessionID() unexpected error: %v", err) + } + if got.ResumeToken != "acp-resume-token" { + t.Fatalf("ResumeToken = %q, want %q", got.ResumeToken, "acp-resume-token") + } + + if _, err := svc.GetExecutorRunningBySessionID(ctx, "no-such-session"); !errors.Is(err, models.ErrExecutorRunningNotFound) { + t.Fatalf("GetExecutorRunningBySessionID() for missing session = %v, want %v", err, models.ErrExecutorRunningNotFound) + } +} diff --git a/apps/backend/pkg/pluginsdk/data_types.go b/apps/backend/pkg/pluginsdk/data_types.go new file mode 100644 index 0000000000..7f8f03dd88 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/data_types.go @@ -0,0 +1,677 @@ +// data_types.go defines the Go-native mirrors of the Host data API messages +// (ADR 0043: docs/decisions/0043-plugin-host-data-api.md) plus the +// proto<->Go conversion helpers used by host.go's data accessors. As with +// the rest of types.go, authors and kandev's runtime manager only ever see +// these Go-native structs; the pluginv1 message types never leak past the +// package boundary. +// +// Conventions (mirroring the wire contract): +// - Timestamps are RFC3339 strings, matching the JSON API and Event +// envelope. +// - Optional/nullable fields use *string, matching proto3 `optional`, so +// "absent" is distinguishable from "empty". +// - Free-form metadata uses map[string]any via mapToStruct/structToMap, +// exactly like StateEntry.Value and Event.Payload. +package pluginsdk + +import ( + "fmt" + + pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" +) + +// Page is the Go-native mirror of kandev.plugin.v1.Page: an opaque-cursor +// pagination request. A zero Page requests the server's default page. +type Page struct { + Limit int32 + Cursor string +} + +func (p Page) toProto() *pluginv1.Page { + return &pluginv1.Page{Limit: p.Limit, Cursor: p.Cursor} +} + +func pageFromProto(p *pluginv1.Page) Page { + if p == nil { + return Page{} + } + return Page{Limit: p.GetLimit(), Cursor: p.GetCursor()} +} + +// PageInfo is the Go-native mirror of kandev.plugin.v1.PageInfo, returned +// alongside every paginated Host data read. +type PageInfo struct { + NextCursor string + HasMore bool +} + +func (p *PageInfo) toProto() *pluginv1.PageInfo { + if p == nil { + return nil + } + return &pluginv1.PageInfo{NextCursor: p.NextCursor, HasMore: p.HasMore} +} + +func pageInfoFromProto(p *pluginv1.PageInfo) *PageInfo { + if p == nil { + return nil + } + return &PageInfo{NextCursor: p.GetNextCursor(), HasMore: p.GetHasMore()} +} + +// TaskRepository is the Go-native mirror of kandev.plugin.v1.TaskRepository. +type TaskRepository struct { + ID string + RepositoryID string + BaseBranch string + Position int32 +} + +func (r TaskRepository) toProto() *pluginv1.TaskRepository { + return &pluginv1.TaskRepository{ + Id: r.ID, + RepositoryId: r.RepositoryID, + BaseBranch: r.BaseBranch, + Position: r.Position, + } +} + +func taskRepositoryFromProto(p *pluginv1.TaskRepository) TaskRepository { + if p == nil { + return TaskRepository{} + } + return TaskRepository{ + ID: p.GetId(), + RepositoryID: p.GetRepositoryId(), + BaseBranch: p.GetBaseBranch(), + Position: p.GetPosition(), + } +} + +// Task is the Go-native mirror of kandev.plugin.v1.Task. +type Task struct { + ID string + WorkspaceID string + WorkflowID string + Title string + Description string + State string + Priority string + CreatedBy string + CreatedAt string + UpdatedAt string + StartedAt *string + CompletedAt *string + ParentID *string + Identifier string + IsEphemeral bool + Repositories []TaskRepository + Metadata map[string]any +} + +func (t Task) toProto() (*pluginv1.Task, error) { + metadata, err := mapToStruct(t.Metadata) + if err != nil { + return nil, fmt.Errorf("pluginsdk: task metadata: %w", err) + } + var repos []*pluginv1.TaskRepository + if len(t.Repositories) > 0 { + repos = make([]*pluginv1.TaskRepository, len(t.Repositories)) + for i := range t.Repositories { + repos[i] = t.Repositories[i].toProto() + } + } + return &pluginv1.Task{ + Id: t.ID, + WorkspaceId: t.WorkspaceID, + WorkflowId: t.WorkflowID, + Title: t.Title, + Description: t.Description, + State: t.State, + Priority: t.Priority, + CreatedBy: t.CreatedBy, + CreatedAt: t.CreatedAt, + UpdatedAt: t.UpdatedAt, + StartedAt: t.StartedAt, + CompletedAt: t.CompletedAt, + ParentId: t.ParentID, + Identifier: t.Identifier, + IsEphemeral: t.IsEphemeral, + Repositories: repos, + Metadata: metadata, + }, nil +} + +func taskFromProto(p *pluginv1.Task) (Task, error) { + if p == nil { + return Task{}, nil + } + metadata, err := structToMap(p.GetMetadata()) + if err != nil { + return Task{}, fmt.Errorf("pluginsdk: task metadata: %w", err) + } + var repos []TaskRepository + if len(p.GetRepositories()) > 0 { + repos = make([]TaskRepository, len(p.GetRepositories())) + for i, r := range p.GetRepositories() { + repos[i] = taskRepositoryFromProto(r) + } + } + return Task{ + ID: p.GetId(), + WorkspaceID: p.GetWorkspaceId(), + WorkflowID: p.GetWorkflowId(), + Title: p.GetTitle(), + Description: p.GetDescription(), + State: p.GetState(), + Priority: p.GetPriority(), + CreatedBy: p.GetCreatedBy(), + CreatedAt: p.GetCreatedAt(), + UpdatedAt: p.GetUpdatedAt(), + StartedAt: p.StartedAt, + CompletedAt: p.CompletedAt, + ParentID: p.ParentId, + Identifier: p.GetIdentifier(), + IsEphemeral: p.GetIsEphemeral(), + Repositories: repos, + Metadata: metadata, + }, nil +} + +func tasksFromProto(items []*pluginv1.Task) ([]Task, error) { + if len(items) == 0 { + return nil, nil + } + out := make([]Task, len(items)) + for i, item := range items { + converted, err := taskFromProto(item) + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil +} + +func tasksToProto(items []Task) ([]*pluginv1.Task, error) { + if len(items) == 0 { + return nil, nil + } + out := make([]*pluginv1.Task, len(items)) + for i := range items { + converted, err := items[i].toProto() + if err != nil { + return nil, err + } + out[i] = converted + } + return out, nil +} + +// TaskFilter is the Go-native mirror of kandev.plugin.v1.TaskFilter. +type TaskFilter struct { + WorkspaceIDs []string + WorkflowIDs []string + States []string + ParentID *string + IncludeEphemeral bool +} + +func (f TaskFilter) toProto() *pluginv1.TaskFilter { + return &pluginv1.TaskFilter{ + WorkspaceIds: f.WorkspaceIDs, + WorkflowIds: f.WorkflowIDs, + States: f.States, + ParentId: f.ParentID, + IncludeEphemeral: f.IncludeEphemeral, + } +} + +func taskFilterFromProto(p *pluginv1.TaskFilter) TaskFilter { + if p == nil { + return TaskFilter{} + } + return TaskFilter{ + WorkspaceIDs: p.GetWorkspaceIds(), + WorkflowIDs: p.GetWorkflowIds(), + States: p.GetStates(), + ParentID: p.ParentId, + IncludeEphemeral: p.GetIncludeEphemeral(), + } +} + +// Workspace is the Go-native mirror of kandev.plugin.v1.Workspace. +type Workspace struct { + ID string + Name string + Description *string + OwnerID string + DefaultExecutorID *string + DefaultAgentProfileID *string + CreatedAt string + UpdatedAt string +} + +func (w Workspace) toProto() *pluginv1.Workspace { + return &pluginv1.Workspace{ + Id: w.ID, + Name: w.Name, + Description: w.Description, + OwnerId: w.OwnerID, + DefaultExecutorId: w.DefaultExecutorID, + DefaultAgentProfileId: w.DefaultAgentProfileID, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +func workspaceFromProto(p *pluginv1.Workspace) Workspace { + if p == nil { + return Workspace{} + } + return Workspace{ + ID: p.GetId(), + Name: p.GetName(), + Description: p.Description, + OwnerID: p.GetOwnerId(), + DefaultExecutorID: p.DefaultExecutorId, + DefaultAgentProfileID: p.DefaultAgentProfileId, + CreatedAt: p.GetCreatedAt(), + UpdatedAt: p.GetUpdatedAt(), + } +} + +func workspacesFromProto(items []*pluginv1.Workspace) []Workspace { + if len(items) == 0 { + return nil + } + out := make([]Workspace, len(items)) + for i, item := range items { + out[i] = workspaceFromProto(item) + } + return out +} + +func workspacesToProto(items []Workspace) []*pluginv1.Workspace { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.Workspace, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// Workflow is the Go-native mirror of kandev.plugin.v1.Workflow. +type Workflow struct { + ID string + WorkspaceID string + Name string + Description *string + SortOrder int32 + CreatedAt string + UpdatedAt string +} + +func (w Workflow) toProto() *pluginv1.Workflow { + return &pluginv1.Workflow{ + Id: w.ID, + WorkspaceId: w.WorkspaceID, + Name: w.Name, + Description: w.Description, + SortOrder: w.SortOrder, + CreatedAt: w.CreatedAt, + UpdatedAt: w.UpdatedAt, + } +} + +func workflowFromProto(p *pluginv1.Workflow) Workflow { + if p == nil { + return Workflow{} + } + return Workflow{ + ID: p.GetId(), + WorkspaceID: p.GetWorkspaceId(), + Name: p.GetName(), + Description: p.Description, + SortOrder: p.GetSortOrder(), + CreatedAt: p.GetCreatedAt(), + UpdatedAt: p.GetUpdatedAt(), + } +} + +func workflowsFromProto(items []*pluginv1.Workflow) []Workflow { + if len(items) == 0 { + return nil + } + out := make([]Workflow, len(items)) + for i, item := range items { + out[i] = workflowFromProto(item) + } + return out +} + +func workflowsToProto(items []Workflow) []*pluginv1.Workflow { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.Workflow, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// WorkflowStep is the Go-native mirror of kandev.plugin.v1.WorkflowStep. +type WorkflowStep struct { + ID string + WorkflowID string + Name string + Position int32 + StageType string +} + +func (s WorkflowStep) toProto() *pluginv1.WorkflowStep { + return &pluginv1.WorkflowStep{ + Id: s.ID, + WorkflowId: s.WorkflowID, + Name: s.Name, + Position: s.Position, + StageType: s.StageType, + } +} + +func workflowStepFromProto(p *pluginv1.WorkflowStep) WorkflowStep { + if p == nil { + return WorkflowStep{} + } + return WorkflowStep{ + ID: p.GetId(), + WorkflowID: p.GetWorkflowId(), + Name: p.GetName(), + Position: p.GetPosition(), + StageType: p.GetStageType(), + } +} + +func workflowStepsFromProto(items []*pluginv1.WorkflowStep) []WorkflowStep { + if len(items) == 0 { + return nil + } + out := make([]WorkflowStep, len(items)) + for i, item := range items { + out[i] = workflowStepFromProto(item) + } + return out +} + +func workflowStepsToProto(items []WorkflowStep) []*pluginv1.WorkflowStep { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.WorkflowStep, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// AgentProfile is the Go-native mirror of kandev.plugin.v1.AgentProfile. +type AgentProfile struct { + ID string + AgentID string + DisplayName string + Name string + Model string + Mode string +} + +func (a AgentProfile) toProto() *pluginv1.AgentProfile { + return &pluginv1.AgentProfile{ + Id: a.ID, + AgentId: a.AgentID, + DisplayName: a.DisplayName, + Name: a.Name, + Model: a.Model, + Mode: a.Mode, + } +} + +func agentProfileFromProto(p *pluginv1.AgentProfile) AgentProfile { + if p == nil { + return AgentProfile{} + } + return AgentProfile{ + ID: p.GetId(), + AgentID: p.GetAgentId(), + DisplayName: p.GetDisplayName(), + Name: p.GetName(), + Model: p.GetModel(), + Mode: p.GetMode(), + } +} + +func agentProfilesFromProto(items []*pluginv1.AgentProfile) []AgentProfile { + if len(items) == 0 { + return nil + } + out := make([]AgentProfile, len(items)) + for i, item := range items { + out[i] = agentProfileFromProto(item) + } + return out +} + +func agentProfilesToProto(items []AgentProfile) []*pluginv1.AgentProfile { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.AgentProfile, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// Repository is the Go-native mirror of kandev.plugin.v1.Repository. +type Repository struct { + ID string + WorkspaceID string + Name string + DefaultBranch *string +} + +func (r Repository) toProto() *pluginv1.Repository { + return &pluginv1.Repository{ + Id: r.ID, + WorkspaceId: r.WorkspaceID, + Name: r.Name, + DefaultBranch: r.DefaultBranch, + } +} + +func repositoryFromProto(p *pluginv1.Repository) Repository { + if p == nil { + return Repository{} + } + return Repository{ + ID: p.GetId(), + WorkspaceID: p.GetWorkspaceId(), + Name: p.GetName(), + DefaultBranch: p.DefaultBranch, + } +} + +func repositoriesFromProto(items []*pluginv1.Repository) []Repository { + if len(items) == 0 { + return nil + } + out := make([]Repository, len(items)) + for i, item := range items { + out[i] = repositoryFromProto(item) + } + return out +} + +func repositoriesToProto(items []Repository) []*pluginv1.Repository { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.Repository, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// Session is the Go-native mirror of kandev.plugin.v1.Session. +type Session struct { + ID string + TaskID string + AgentProfileID string + AgentDisplayName string + Model string + ACPSessionID string + State string + StartedAt string + EndedAt *string + AgentProfileName string // profile name from the snapshot at run time +} + +func (s Session) toProto() *pluginv1.Session { + return &pluginv1.Session{ + Id: s.ID, + TaskId: s.TaskID, + AgentProfileId: s.AgentProfileID, + AgentDisplayName: s.AgentDisplayName, + Model: s.Model, + AcpSessionId: s.ACPSessionID, + State: s.State, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + AgentProfileName: s.AgentProfileName, + } +} + +func sessionFromProto(p *pluginv1.Session) Session { + if p == nil { + return Session{} + } + return Session{ + ID: p.GetId(), + TaskID: p.GetTaskId(), + AgentProfileID: p.GetAgentProfileId(), + AgentDisplayName: p.GetAgentDisplayName(), + Model: p.GetModel(), + ACPSessionID: p.GetAcpSessionId(), + State: p.GetState(), + StartedAt: p.GetStartedAt(), + EndedAt: p.EndedAt, + AgentProfileName: p.GetAgentProfileName(), + } +} + +func sessionsFromProto(items []*pluginv1.Session) []Session { + if len(items) == 0 { + return nil + } + out := make([]Session, len(items)) + for i, item := range items { + out[i] = sessionFromProto(item) + } + return out +} + +func sessionsToProto(items []Session) []*pluginv1.Session { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.Session, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} + +// SessionFilter is the Go-native mirror of kandev.plugin.v1.SessionFilter. +type SessionFilter struct { + TaskIDs []string + WorkspaceIDs []string + States []string +} + +func (f SessionFilter) toProto() *pluginv1.SessionFilter { + return &pluginv1.SessionFilter{ + TaskIds: f.TaskIDs, + WorkspaceIds: f.WorkspaceIDs, + States: f.States, + } +} + +func sessionFilterFromProto(p *pluginv1.SessionFilter) SessionFilter { + if p == nil { + return SessionFilter{} + } + return SessionFilter{ + TaskIDs: p.GetTaskIds(), + WorkspaceIDs: p.GetWorkspaceIds(), + States: p.GetStates(), + } +} + +// SessionCodeStats is the Go-native mirror of +// kandev.plugin.v1.SessionCodeStats — a computed per-session code-change +// summary, never the raw commit/snapshot rows. +type SessionCodeStats struct { + SessionID string + LinesAddedCommitted int64 + LinesDeletedCommitted int64 + LinesAddedPeakPending int64 + LinesDeletedPeakPending int64 +} + +func (s SessionCodeStats) toProto() *pluginv1.SessionCodeStats { + return &pluginv1.SessionCodeStats{ + SessionId: s.SessionID, + LinesAddedCommitted: s.LinesAddedCommitted, + LinesDeletedCommitted: s.LinesDeletedCommitted, + LinesAddedPeakPending: s.LinesAddedPeakPending, + LinesDeletedPeakPending: s.LinesDeletedPeakPending, + } +} + +func sessionCodeStatsFromProto(p *pluginv1.SessionCodeStats) SessionCodeStats { + if p == nil { + return SessionCodeStats{} + } + return SessionCodeStats{ + SessionID: p.GetSessionId(), + LinesAddedCommitted: p.GetLinesAddedCommitted(), + LinesDeletedCommitted: p.GetLinesDeletedCommitted(), + LinesAddedPeakPending: p.GetLinesAddedPeakPending(), + LinesDeletedPeakPending: p.GetLinesDeletedPeakPending(), + } +} + +func sessionCodeStatsSliceFromProto(items []*pluginv1.SessionCodeStats) []SessionCodeStats { + if len(items) == 0 { + return nil + } + out := make([]SessionCodeStats, len(items)) + for i, item := range items { + out[i] = sessionCodeStatsFromProto(item) + } + return out +} + +func sessionCodeStatsSliceToProto(items []SessionCodeStats) []*pluginv1.SessionCodeStats { + if len(items) == 0 { + return nil + } + out := make([]*pluginv1.SessionCodeStats, len(items)) + for i := range items { + out[i] = items[i].toProto() + } + return out +} diff --git a/apps/backend/pkg/pluginsdk/data_types_test.go b/apps/backend/pkg/pluginsdk/data_types_test.go new file mode 100644 index 0000000000..fc5e2be373 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/data_types_test.go @@ -0,0 +1,222 @@ +package pluginsdk + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func strPtr(s string) *string { return &s } + +func TestPageProtoRoundTrip(t *testing.T) { + p := Page{Limit: 25, Cursor: "cursor-1"} + proto := p.toProto() + require.Equal(t, int32(25), proto.GetLimit()) + require.Equal(t, "cursor-1", proto.GetCursor()) + require.Equal(t, p, pageFromProto(proto)) +} + +func TestPageInfoProtoRoundTrip(t *testing.T) { + pi := &PageInfo{NextCursor: "next-1", HasMore: true} + proto := pi.toProto() + require.Equal(t, "next-1", proto.GetNextCursor()) + require.True(t, proto.GetHasMore()) + require.Equal(t, pi, pageInfoFromProto(proto)) + + // nil PageInfo converts to a nil proto and back to nil. + var nilPI *PageInfo + require.Nil(t, nilPI.toProto()) + require.Nil(t, pageInfoFromProto(nil)) +} + +func TestTaskProtoRoundTrip(t *testing.T) { + task := Task{ + ID: "task-1", + WorkspaceID: "ws-1", + WorkflowID: "wf-1", + Title: "Fix the bug", + Description: "Details here", + State: "in_progress", + Priority: "high", + CreatedBy: "user-1", + CreatedAt: "2026-07-15T12:00:00Z", + UpdatedAt: "2026-07-15T12:05:00Z", + StartedAt: strPtr("2026-07-15T12:01:00Z"), + CompletedAt: nil, + ParentID: strPtr("task-0"), + Identifier: "PROJ-42", + IsEphemeral: false, + Repositories: []TaskRepository{ + {ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0}, + {ID: "tr-2", RepositoryID: "repo-2", BaseBranch: "develop", Position: 1}, + }, + Metadata: map[string]any{"source": "plugin:agent-stats", "count": float64(3)}, + } + + proto, err := task.toProto() + require.NoError(t, err) + require.Equal(t, "task-1", proto.GetId()) + require.Equal(t, "2026-07-15T12:01:00Z", proto.GetStartedAt()) + require.Nil(t, proto.CompletedAt) + + back, err := taskFromProto(proto) + require.NoError(t, err) + require.Equal(t, task, back) +} + +func TestTaskProtoRoundTrip_NilOptionalsAndEmptyMetadata(t *testing.T) { + task := Task{ID: "task-2", Title: "Bare task"} + + proto, err := task.toProto() + require.NoError(t, err) + require.Nil(t, proto.StartedAt) + require.Nil(t, proto.CompletedAt) + require.Nil(t, proto.ParentId) + require.Nil(t, proto.GetMetadata()) + require.Nil(t, proto.GetRepositories()) + + back, err := taskFromProto(proto) + require.NoError(t, err) + require.Equal(t, task, back) +} + +func TestTaskFilterProtoRoundTrip(t *testing.T) { + filter := TaskFilter{ + WorkspaceIDs: []string{"ws-1", "ws-2"}, + WorkflowIDs: []string{"wf-1"}, + States: []string{"todo", "in_progress"}, + ParentID: strPtr("task-0"), + IncludeEphemeral: true, + } + proto := filter.toProto() + require.Equal(t, filter, taskFilterFromProto(proto)) + + // nil filter proto converts to the zero value. + require.Equal(t, TaskFilter{}, taskFilterFromProto(nil)) +} + +func TestWorkspaceProtoRoundTrip(t *testing.T) { + ws := Workspace{ + ID: "ws-1", + Name: "Acme", + Description: strPtr("Acme workspace"), + OwnerID: "user-1", + DefaultExecutorID: strPtr("exec-1"), + DefaultAgentProfileID: nil, + CreatedAt: "2026-07-15T12:00:00Z", + UpdatedAt: "2026-07-15T12:05:00Z", + } + proto := ws.toProto() + require.Nil(t, proto.DefaultAgentProfileId) + require.Equal(t, ws, workspaceFromProto(proto)) +} + +func TestWorkflowProtoRoundTrip(t *testing.T) { + wf := Workflow{ + ID: "wf-1", + WorkspaceID: "ws-1", + Name: "Default", + Description: nil, + SortOrder: 2, + CreatedAt: "2026-07-15T12:00:00Z", + UpdatedAt: "2026-07-15T12:05:00Z", + } + proto := wf.toProto() + require.Equal(t, wf, workflowFromProto(proto)) +} + +func TestWorkflowStepProtoRoundTrip(t *testing.T) { + step := WorkflowStep{ + ID: "step-1", + WorkflowID: "wf-1", + Name: "Review", + Position: 1, + StageType: "review", + } + proto := step.toProto() + require.Equal(t, step, workflowStepFromProto(proto)) +} + +func TestAgentProfileProtoRoundTrip(t *testing.T) { + profile := AgentProfile{ + ID: "profile-1", + AgentID: "claude", + DisplayName: "Claude Sonnet", + Name: "claude-sonnet", + Model: "claude-sonnet-5", + Mode: "code", + } + proto := profile.toProto() + require.Equal(t, profile, agentProfileFromProto(proto)) +} + +func TestRepositoryProtoRoundTrip(t *testing.T) { + repo := Repository{ + ID: "repo-1", + WorkspaceID: "ws-1", + Name: "kdlbs/kandev", + DefaultBranch: strPtr("main"), + } + proto := repo.toProto() + require.Equal(t, repo, repositoryFromProto(proto)) + + repoNoBranch := Repository{ID: "repo-2", WorkspaceID: "ws-1", Name: "kdlbs/other"} + protoNoBranch := repoNoBranch.toProto() + require.Nil(t, protoNoBranch.DefaultBranch) + require.Equal(t, repoNoBranch, repositoryFromProto(protoNoBranch)) +} + +func TestSessionProtoRoundTrip(t *testing.T) { + session := Session{ + ID: "session-1", + TaskID: "task-1", + AgentProfileID: "profile-1", + AgentDisplayName: "Claude Sonnet", + Model: "claude-sonnet-5", + ACPSessionID: "acp-1", + State: "running", + StartedAt: "2026-07-15T12:00:00Z", + EndedAt: nil, + } + proto := session.toProto() + require.Nil(t, proto.EndedAt) + require.Equal(t, session, sessionFromProto(proto)) + + ended := session + ended.EndedAt = strPtr("2026-07-15T13:00:00Z") + protoEnded := ended.toProto() + require.Equal(t, ended, sessionFromProto(protoEnded)) +} + +func TestSessionFilterProtoRoundTrip(t *testing.T) { + filter := SessionFilter{ + TaskIDs: []string{"task-1", "task-2"}, + WorkspaceIDs: []string{"ws-1"}, + States: []string{"running"}, + } + proto := filter.toProto() + require.Equal(t, filter, sessionFilterFromProto(proto)) + require.Equal(t, SessionFilter{}, sessionFilterFromProto(nil)) +} + +func TestSessionCodeStatsProtoRoundTrip(t *testing.T) { + stats := SessionCodeStats{ + SessionID: "session-1", + LinesAddedCommitted: 120, + LinesDeletedCommitted: 40, + LinesAddedPeakPending: 15, + LinesDeletedPeakPending: 3, + } + proto := stats.toProto() + require.Equal(t, stats, sessionCodeStatsFromProto(proto)) +} + +func TestTasksSliceProtoRoundTrip_EmptyIsNil(t *testing.T) { + tasks, err := tasksFromProto(nil) + require.NoError(t, err) + require.Nil(t, tasks) + + protoTasks, err := tasksToProto(nil) + require.NoError(t, err) + require.Nil(t, protoTasks) +} diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index 26b223249b..dd97bd26c7 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -22,6 +22,8 @@ import ( pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) // Host is the set of operations kandev exposes back to a running plugin, @@ -51,6 +53,83 @@ type Host interface { // EmitEvent publishes a plugin-originated event onto kandev's bus. EmitEvent(ctx context.Context, name string, payload map[string]any) error + + // Tasks returns the reader for the Host data API's task RPCs + // (capability api_read:tasks per ADR 0043). + Tasks() TaskReader + + // Sessions returns the reader for the Host data API's session and + // session-code-stats RPCs (capability api_read:sessions). + Sessions() SessionReader + + // Workspaces returns the reader for the Host data API's workspace RPCs + // (capability api_read:workspaces). + Workspaces() WorkspaceReader + + // Workflows returns the reader for the Host data API's workflow and + // workflow-step RPCs (capability api_read:workflows). + Workflows() WorkflowReader + + // AgentProfiles returns the reader for the Host data API's agent + // profile RPCs (capability api_read:agent_profiles). + AgentProfiles() AgentProfileReader + + // Repositories returns the reader for the Host data API's repository + // RPCs (capability api_read:repositories). + Repositories() RepositoryReader +} + +// TaskReader is the read-only accessor behind Host.Tasks(), mirroring the +// Host data API's ListTasks/GetTask RPCs (ADR 0043). Write methods +// (CreateTask/UpdateTask) are deferred to a later phase and intentionally +// not part of this interface yet. +type TaskReader interface { + // List returns tasks matching filter, newest page first per page. + List(ctx context.Context, filter TaskFilter, page Page) ([]Task, *PageInfo, error) + + // Get returns a single task by id. + Get(ctx context.Context, id string) (*Task, error) +} + +// SessionReader is the read-only accessor behind Host.Sessions(), mirroring +// the Host data API's ListSessions/ListSessionCodeStats RPCs. +type SessionReader interface { + // List returns sessions matching filter. + List(ctx context.Context, filter SessionFilter, page Page) ([]Session, *PageInfo, error) + + // CodeStats returns computed per-session code-change stats matching + // filter. SessionCodeStats is a stable, computed shape — never raw + // commit/snapshot rows. + CodeStats(ctx context.Context, filter SessionFilter, page Page) ([]SessionCodeStats, *PageInfo, error) +} + +// WorkspaceReader is the read-only accessor behind Host.Workspaces(), +// mirroring the Host data API's ListWorkspaces RPC. +type WorkspaceReader interface { + List(ctx context.Context, page Page) ([]Workspace, *PageInfo, error) +} + +// WorkflowReader is the read-only accessor behind Host.Workflows(), +// mirroring the Host data API's ListWorkflows/ListWorkflowSteps RPCs. +type WorkflowReader interface { + // List returns workflows for workspaceID. + List(ctx context.Context, workspaceID string, page Page) ([]Workflow, *PageInfo, error) + + // ListSteps returns the steps for workflowID, in position order. + ListSteps(ctx context.Context, workflowID string) ([]WorkflowStep, error) +} + +// AgentProfileReader is the read-only accessor behind Host.AgentProfiles(), +// mirroring the Host data API's ListAgentProfiles RPC. +type AgentProfileReader interface { + List(ctx context.Context, page Page) ([]AgentProfile, *PageInfo, error) +} + +// RepositoryReader is the read-only accessor behind Host.Repositories(), +// mirroring the Host data API's ListRepositories RPC. +type RepositoryReader interface { + // List returns repositories for workspaceID. + List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error) } // newHostClient wraps a *grpc.ClientConn (dialed over the go-plugin broker) @@ -117,8 +196,135 @@ func (h *grpcHostClient) EmitEvent(ctx context.Context, name string, payload map return err } +func (h *grpcHostClient) Tasks() TaskReader { return grpcTaskReader{client: h.client} } + +func (h *grpcHostClient) Sessions() SessionReader { return grpcSessionReader{client: h.client} } + +func (h *grpcHostClient) Workspaces() WorkspaceReader { return grpcWorkspaceReader{client: h.client} } + +func (h *grpcHostClient) Workflows() WorkflowReader { return grpcWorkflowReader{client: h.client} } + +func (h *grpcHostClient) AgentProfiles() AgentProfileReader { + return grpcAgentProfileReader{client: h.client} +} + +func (h *grpcHostClient) Repositories() RepositoryReader { + return grpcRepositoryReader{client: h.client} +} + var _ Host = (*grpcHostClient)(nil) +// grpcTaskReader implements TaskReader on the plugin side, calling the +// generated pluginv1.HostClient and converting proto<->Go-native. +type grpcTaskReader struct { + client pluginv1.HostClient +} + +func (r grpcTaskReader) List(ctx context.Context, filter TaskFilter, page Page) ([]Task, *PageInfo, error) { + resp, err := r.client.ListTasks(ctx, &pluginv1.ListTasksRequest{Filter: filter.toProto(), Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + tasks, err := tasksFromProto(resp.GetTasks()) + if err != nil { + return nil, nil, err + } + return tasks, pageInfoFromProto(resp.GetPageInfo()), nil +} + +func (r grpcTaskReader) Get(ctx context.Context, id string) (*Task, error) { + resp, err := r.client.GetTask(ctx, &pluginv1.GetTaskRequest{Id: id}) + if err != nil { + return nil, err + } + task, err := taskFromProto(resp.GetTask()) + if err != nil { + return nil, err + } + return &task, nil +} + +// grpcSessionReader implements SessionReader on the plugin side. +type grpcSessionReader struct { + client pluginv1.HostClient +} + +func (r grpcSessionReader) List(ctx context.Context, filter SessionFilter, page Page) ([]Session, *PageInfo, error) { + resp, err := r.client.ListSessions(ctx, &pluginv1.ListSessionsRequest{Filter: filter.toProto(), Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return sessionsFromProto(resp.GetSessions()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +func (r grpcSessionReader) CodeStats(ctx context.Context, filter SessionFilter, page Page) ([]SessionCodeStats, *PageInfo, error) { + resp, err := r.client.ListSessionCodeStats(ctx, &pluginv1.ListSessionCodeStatsRequest{Filter: filter.toProto(), Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return sessionCodeStatsSliceFromProto(resp.GetStats()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +// grpcWorkspaceReader implements WorkspaceReader on the plugin side. +type grpcWorkspaceReader struct { + client pluginv1.HostClient +} + +func (r grpcWorkspaceReader) List(ctx context.Context, page Page) ([]Workspace, *PageInfo, error) { + resp, err := r.client.ListWorkspaces(ctx, &pluginv1.ListWorkspacesRequest{Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return workspacesFromProto(resp.GetWorkspaces()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +// grpcWorkflowReader implements WorkflowReader on the plugin side. +type grpcWorkflowReader struct { + client pluginv1.HostClient +} + +func (r grpcWorkflowReader) List(ctx context.Context, workspaceID string, page Page) ([]Workflow, *PageInfo, error) { + resp, err := r.client.ListWorkflows(ctx, &pluginv1.ListWorkflowsRequest{WorkspaceId: workspaceID, Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return workflowsFromProto(resp.GetWorkflows()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +func (r grpcWorkflowReader) ListSteps(ctx context.Context, workflowID string) ([]WorkflowStep, error) { + resp, err := r.client.ListWorkflowSteps(ctx, &pluginv1.ListWorkflowStepsRequest{WorkflowId: workflowID}) + if err != nil { + return nil, err + } + return workflowStepsFromProto(resp.GetSteps()), nil +} + +// grpcAgentProfileReader implements AgentProfileReader on the plugin side. +type grpcAgentProfileReader struct { + client pluginv1.HostClient +} + +func (r grpcAgentProfileReader) List(ctx context.Context, page Page) ([]AgentProfile, *PageInfo, error) { + resp, err := r.client.ListAgentProfiles(ctx, &pluginv1.ListAgentProfilesRequest{Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return agentProfilesFromProto(resp.GetProfiles()), pageInfoFromProto(resp.GetPageInfo()), nil +} + +// grpcRepositoryReader implements RepositoryReader on the plugin side. +type grpcRepositoryReader struct { + client pluginv1.HostClient +} + +func (r grpcRepositoryReader) List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error) { + resp, err := r.client.ListRepositories(ctx, &pluginv1.ListRepositoriesRequest{WorkspaceId: workspaceID, Page: page.toProto()}) + if err != nil { + return nil, nil, err + } + return repositoriesFromProto(resp.GetRepositories()), pageInfoFromProto(resp.GetPageInfo()), nil +} + // registerHostServer registers a grpc server that dispatches // kandev.plugin.v1.Host RPCs to impl (kandev's Go-native Host // implementation), converting proto<->Go-native types at the boundary. @@ -199,4 +405,189 @@ func (s *grpcHostServer) EmitEvent(ctx context.Context, req *pluginv1.EmitEventR return &pluginv1.EmitEventResponse{}, nil } +// ── Host data API reads (ADR 0043) ────────────────────────────────────── +// +// Each method below dispatches to the injected Go-native impl's resource +// accessor (impl.Tasks(), impl.Sessions(), ...) and converts proto<->native +// at the boundary, exactly like GetState/ListState above. Capability +// gating and the real service-layer calls live in the impl kandev's +// runtime manager provides (internal/plugins), not here — this adapter +// only wires the RPC to whatever impl.Tasks()/impl.Sessions()/... does, so +// it compiles against any Host, including one that embeds +// UnimplementedHostData and returns Unimplemented for every accessor. + +func (s *grpcHostServer) ListTasks(ctx context.Context, req *pluginv1.ListTasksRequest) (*pluginv1.ListTasksResponse, error) { + filter := taskFilterFromProto(req.GetFilter()) + page := pageFromProto(req.GetPage()) + tasks, pageInfo, err := s.impl.Tasks().List(ctx, filter, page) + if err != nil { + return nil, err + } + protoTasks, err := tasksToProto(tasks) + if err != nil { + return nil, err + } + return &pluginv1.ListTasksResponse{Tasks: protoTasks, PageInfo: pageInfo.toProto()}, nil +} + +// GetTask dispatches to the impl's TaskReader.Get and wraps the result in +// GetTaskResponse (Buf RPC_RESPONSE_STANDARD_NAME/RPC_REQUEST_RESPONSE_UNIQUE: +// no bare-DTO RPC responses). The Host contract is that a missing task is a +// gRPC NotFound *error* from Get, never a (nil, nil) success — the nil check +// below is defense-in-depth for a Host implementation that doesn't follow +// that contract, so a plugin still gets NotFound instead of a zero-value +// GetTaskResponse. +func (s *grpcHostServer) GetTask(ctx context.Context, req *pluginv1.GetTaskRequest) (*pluginv1.GetTaskResponse, error) { + task, err := s.impl.Tasks().Get(ctx, req.GetId()) + if err != nil { + return nil, err + } + if task == nil { + return nil, status.Error(codes.NotFound, "task not found") + } + protoTask, err := task.toProto() + if err != nil { + return nil, err + } + return &pluginv1.GetTaskResponse{Task: protoTask}, nil +} + +func (s *grpcHostServer) ListWorkspaces(ctx context.Context, req *pluginv1.ListWorkspacesRequest) (*pluginv1.ListWorkspacesResponse, error) { + page := pageFromProto(req.GetPage()) + workspaces, pageInfo, err := s.impl.Workspaces().List(ctx, page) + if err != nil { + return nil, err + } + return &pluginv1.ListWorkspacesResponse{Workspaces: workspacesToProto(workspaces), PageInfo: pageInfo.toProto()}, nil +} + +func (s *grpcHostServer) ListWorkflows(ctx context.Context, req *pluginv1.ListWorkflowsRequest) (*pluginv1.ListWorkflowsResponse, error) { + page := pageFromProto(req.GetPage()) + workflows, pageInfo, err := s.impl.Workflows().List(ctx, req.GetWorkspaceId(), page) + if err != nil { + return nil, err + } + return &pluginv1.ListWorkflowsResponse{Workflows: workflowsToProto(workflows), PageInfo: pageInfo.toProto()}, nil +} + +func (s *grpcHostServer) ListWorkflowSteps(ctx context.Context, req *pluginv1.ListWorkflowStepsRequest) (*pluginv1.ListWorkflowStepsResponse, error) { + steps, err := s.impl.Workflows().ListSteps(ctx, req.GetWorkflowId()) + if err != nil { + return nil, err + } + return &pluginv1.ListWorkflowStepsResponse{Steps: workflowStepsToProto(steps)}, nil +} + +func (s *grpcHostServer) ListAgentProfiles(ctx context.Context, req *pluginv1.ListAgentProfilesRequest) (*pluginv1.ListAgentProfilesResponse, error) { + page := pageFromProto(req.GetPage()) + profiles, pageInfo, err := s.impl.AgentProfiles().List(ctx, page) + if err != nil { + return nil, err + } + return &pluginv1.ListAgentProfilesResponse{Profiles: agentProfilesToProto(profiles), PageInfo: pageInfo.toProto()}, nil +} + +func (s *grpcHostServer) ListRepositories(ctx context.Context, req *pluginv1.ListRepositoriesRequest) (*pluginv1.ListRepositoriesResponse, error) { + page := pageFromProto(req.GetPage()) + repos, pageInfo, err := s.impl.Repositories().List(ctx, req.GetWorkspaceId(), page) + if err != nil { + return nil, err + } + return &pluginv1.ListRepositoriesResponse{Repositories: repositoriesToProto(repos), PageInfo: pageInfo.toProto()}, nil +} + +func (s *grpcHostServer) ListSessions(ctx context.Context, req *pluginv1.ListSessionsRequest) (*pluginv1.ListSessionsResponse, error) { + filter := sessionFilterFromProto(req.GetFilter()) + page := pageFromProto(req.GetPage()) + sessions, pageInfo, err := s.impl.Sessions().List(ctx, filter, page) + if err != nil { + return nil, err + } + return &pluginv1.ListSessionsResponse{Sessions: sessionsToProto(sessions), PageInfo: pageInfo.toProto()}, nil +} + +func (s *grpcHostServer) ListSessionCodeStats(ctx context.Context, req *pluginv1.ListSessionCodeStatsRequest) (*pluginv1.ListSessionCodeStatsResponse, error) { + filter := sessionFilterFromProto(req.GetFilter()) + page := pageFromProto(req.GetPage()) + stats, pageInfo, err := s.impl.Sessions().CodeStats(ctx, filter, page) + if err != nil { + return nil, err + } + return &pluginv1.ListSessionCodeStatsResponse{Stats: sessionCodeStatsSliceToProto(stats), PageInfo: pageInfo.toProto()}, nil +} + var _ pluginv1.HostServer = (*grpcHostServer)(nil) + +// UnimplementedHostData is an embeddable default for the Host data API +// (ADR 0043) sub-accessors: Tasks/Sessions/Workspaces/Workflows/ +// AgentProfiles/Repositories. Embed it in a Go-native Host implementation +// to satisfy the interface before wiring real data access — every method +// on every returned reader returns a gRPC Unimplemented error. Override +// individual accessor methods (e.g. define your own Tasks() on the +// embedding type) as real capability-gated, service-backed logic lands; +// unlike UnimplementedPlugin's per-RPC methods, these are per-resource +// accessors because each one fans out to multiple reader methods. +type UnimplementedHostData struct{} + +func (UnimplementedHostData) Tasks() TaskReader { return unimplementedTaskReader{} } +func (UnimplementedHostData) Sessions() SessionReader { return unimplementedSessionReader{} } +func (UnimplementedHostData) Workspaces() WorkspaceReader { return unimplementedWorkspaceReader{} } +func (UnimplementedHostData) Workflows() WorkflowReader { return unimplementedWorkflowReader{} } +func (UnimplementedHostData) AgentProfiles() AgentProfileReader { + return unimplementedAgentProfileReader{} +} +func (UnimplementedHostData) Repositories() RepositoryReader { + return unimplementedRepositoryReader{} +} + +func errUnimplementedHostData(resource string) error { + return status.Errorf(codes.Unimplemented, "pluginsdk: Host data API %q not implemented", resource) +} + +type unimplementedTaskReader struct{} + +func (unimplementedTaskReader) List(context.Context, TaskFilter, Page) ([]Task, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("tasks") +} + +func (unimplementedTaskReader) Get(context.Context, string) (*Task, error) { + return nil, errUnimplementedHostData("tasks") +} + +type unimplementedSessionReader struct{} + +func (unimplementedSessionReader) List(context.Context, SessionFilter, Page) ([]Session, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("sessions") +} + +func (unimplementedSessionReader) CodeStats(context.Context, SessionFilter, Page) ([]SessionCodeStats, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("sessions") +} + +type unimplementedWorkspaceReader struct{} + +func (unimplementedWorkspaceReader) List(context.Context, Page) ([]Workspace, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("workspaces") +} + +type unimplementedWorkflowReader struct{} + +func (unimplementedWorkflowReader) List(context.Context, string, Page) ([]Workflow, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("workflows") +} + +func (unimplementedWorkflowReader) ListSteps(context.Context, string) ([]WorkflowStep, error) { + return nil, errUnimplementedHostData("workflows") +} + +type unimplementedAgentProfileReader struct{} + +func (unimplementedAgentProfileReader) List(context.Context, Page) ([]AgentProfile, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("agent_profiles") +} + +type unimplementedRepositoryReader struct{} + +func (unimplementedRepositoryReader) List(context.Context, string, Page) ([]Repository, *PageInfo, error) { + return nil, nil, errUnimplementedHostData("repositories") +} diff --git a/apps/backend/pkg/pluginsdk/host_data_test.go b/apps/backend/pkg/pluginsdk/host_data_test.go new file mode 100644 index 0000000000..41d8c5db96 --- /dev/null +++ b/apps/backend/pkg/pluginsdk/host_data_test.go @@ -0,0 +1,232 @@ +package pluginsdk + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// dataRecordingHost is a fake Go-native Host implementation whose Host data +// API (ADR 0043) accessors are backed by in-memory fixtures, used to prove +// grpcHostServer -> grpcHostClient reachability for the read RPCs added in +// task-03. State/secrets/emit are inherited (unimplemented) from +// UnimplementedHostData since these tests only exercise the data accessors. +type dataRecordingHost struct { + UnimplementedHostData + + tasks map[string]Task + taskList []Task + taskPageInfo *PageInfo + sessions []Session + sessionPage *PageInfo + codeStats []SessionCodeStats + codeStatsPage *PageInfo + workspaces []Workspace + workflows []Workflow + workflowSteps []WorkflowStep + agentProfiles []AgentProfile + repositories []Repository + + lastTaskFilter TaskFilter + lastSessionFilter SessionFilter + lastWorkspaceID string + lastWorkflowID string +} + +func (h *dataRecordingHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { + return nil, false, nil +} +func (h *dataRecordingHost) SetState(context.Context, string, string, string, map[string]any) error { + return nil +} +func (h *dataRecordingHost) DeleteState(context.Context, string, string, string) error { return nil } +func (h *dataRecordingHost) ListState(context.Context, string, string) ([]StateEntry, error) { + return nil, nil +} +func (h *dataRecordingHost) RevealSecret(context.Context, string) (string, error) { return "", nil } +func (h *dataRecordingHost) EmitEvent(context.Context, string, map[string]any) error { + return nil +} + +func (h *dataRecordingHost) Tasks() TaskReader { return dataRecordingTaskReader{h} } +func (h *dataRecordingHost) Sessions() SessionReader { return dataRecordingSessionReader{h} } +func (h *dataRecordingHost) Workspaces() WorkspaceReader { return dataRecordingWorkspaceReader{h} } +func (h *dataRecordingHost) Workflows() WorkflowReader { return dataRecordingWorkflowReader{h} } +func (h *dataRecordingHost) AgentProfiles() AgentProfileReader { + return dataRecordingAgentProfileReader{h} +} +func (h *dataRecordingHost) Repositories() RepositoryReader { + return dataRecordingRepositoryReader{h} +} + +type dataRecordingTaskReader struct{ h *dataRecordingHost } + +func (r dataRecordingTaskReader) List(_ context.Context, filter TaskFilter, _ Page) ([]Task, *PageInfo, error) { + r.h.lastTaskFilter = filter + return r.h.taskList, r.h.taskPageInfo, nil +} + +func (r dataRecordingTaskReader) Get(_ context.Context, id string) (*Task, error) { + task, ok := r.h.tasks[id] + if !ok { + return nil, status.Errorf(codes.NotFound, "task %q not found", id) + } + return &task, nil +} + +type dataRecordingSessionReader struct{ h *dataRecordingHost } + +func (r dataRecordingSessionReader) List(_ context.Context, filter SessionFilter, _ Page) ([]Session, *PageInfo, error) { + r.h.lastSessionFilter = filter + return r.h.sessions, r.h.sessionPage, nil +} + +func (r dataRecordingSessionReader) CodeStats(_ context.Context, filter SessionFilter, _ Page) ([]SessionCodeStats, *PageInfo, error) { + r.h.lastSessionFilter = filter + return r.h.codeStats, r.h.codeStatsPage, nil +} + +type dataRecordingWorkspaceReader struct{ h *dataRecordingHost } + +func (r dataRecordingWorkspaceReader) List(context.Context, Page) ([]Workspace, *PageInfo, error) { + return r.h.workspaces, nil, nil +} + +type dataRecordingWorkflowReader struct{ h *dataRecordingHost } + +func (r dataRecordingWorkflowReader) List(_ context.Context, workspaceID string, _ Page) ([]Workflow, *PageInfo, error) { + r.h.lastWorkspaceID = workspaceID + return r.h.workflows, nil, nil +} + +func (r dataRecordingWorkflowReader) ListSteps(_ context.Context, workflowID string) ([]WorkflowStep, error) { + r.h.lastWorkflowID = workflowID + return r.h.workflowSteps, nil +} + +type dataRecordingAgentProfileReader struct{ h *dataRecordingHost } + +func (r dataRecordingAgentProfileReader) List(context.Context, Page) ([]AgentProfile, *PageInfo, error) { + return r.h.agentProfiles, nil, nil +} + +type dataRecordingRepositoryReader struct{ h *dataRecordingHost } + +func (r dataRecordingRepositoryReader) List(_ context.Context, workspaceID string, _ Page) ([]Repository, *PageInfo, error) { + r.h.lastWorkspaceID = workspaceID + return r.h.repositories, nil, nil +} + +func TestHostData_TasksListAndGet(t *testing.T) { + impl := &dataRecordingHost{ + tasks: map[string]Task{ + "task-1": {ID: "task-1", Title: "Fix the bug", State: "todo"}, + }, + taskList: []Task{{ID: "task-1", Title: "Fix the bug", State: "todo"}}, + taskPageInfo: &PageInfo{NextCursor: "next-1", HasMore: true}, + } + host := dialHostOverBufconn(t, impl) + + tasks, pageInfo, err := host.Tasks().List(context.Background(), TaskFilter{States: []string{"todo"}}, Page{Limit: 10}) + require.NoError(t, err) + require.Equal(t, []Task{{ID: "task-1", Title: "Fix the bug", State: "todo"}}, tasks) + require.Equal(t, &PageInfo{NextCursor: "next-1", HasMore: true}, pageInfo) + require.Equal(t, TaskFilter{States: []string{"todo"}}, impl.lastTaskFilter) + + task, err := host.Tasks().Get(context.Background(), "task-1") + require.NoError(t, err) + require.Equal(t, "Fix the bug", task.Title) + + _, err = host.Tasks().Get(context.Background(), "missing") + require.Error(t, err) + require.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestHostData_SessionsListAndCodeStats(t *testing.T) { + impl := &dataRecordingHost{ + sessions: []Session{{ID: "session-1", TaskID: "task-1", State: "running"}}, + codeStats: []SessionCodeStats{{SessionID: "session-1", LinesAddedCommitted: 10}}, + } + host := dialHostOverBufconn(t, impl) + + sessions, _, err := host.Sessions().List(context.Background(), SessionFilter{TaskIDs: []string{"task-1"}}, Page{}) + require.NoError(t, err) + require.Equal(t, impl.sessions, sessions) + require.Equal(t, SessionFilter{TaskIDs: []string{"task-1"}}, impl.lastSessionFilter) + + stats, _, err := host.Sessions().CodeStats(context.Background(), SessionFilter{TaskIDs: []string{"task-1"}}, Page{}) + require.NoError(t, err) + require.Equal(t, impl.codeStats, stats) +} + +func TestHostData_Workspaces(t *testing.T) { + impl := &dataRecordingHost{workspaces: []Workspace{{ID: "ws-1", Name: "Acme"}}} + host := dialHostOverBufconn(t, impl) + + workspaces, _, err := host.Workspaces().List(context.Background(), Page{}) + require.NoError(t, err) + require.Equal(t, impl.workspaces, workspaces) +} + +func TestHostData_WorkflowsAndSteps(t *testing.T) { + impl := &dataRecordingHost{ + workflows: []Workflow{{ID: "wf-1", WorkspaceID: "ws-1", Name: "Default"}}, + workflowSteps: []WorkflowStep{{ID: "step-1", WorkflowID: "wf-1", Name: "Review"}}, + } + host := dialHostOverBufconn(t, impl) + + workflows, _, err := host.Workflows().List(context.Background(), "ws-1", Page{}) + require.NoError(t, err) + require.Equal(t, impl.workflows, workflows) + require.Equal(t, "ws-1", impl.lastWorkspaceID) + + steps, err := host.Workflows().ListSteps(context.Background(), "wf-1") + require.NoError(t, err) + require.Equal(t, impl.workflowSteps, steps) + require.Equal(t, "wf-1", impl.lastWorkflowID) +} + +func TestHostData_AgentProfiles(t *testing.T) { + impl := &dataRecordingHost{agentProfiles: []AgentProfile{{ID: "profile-1", DisplayName: "Claude"}}} + host := dialHostOverBufconn(t, impl) + + profiles, _, err := host.AgentProfiles().List(context.Background(), Page{}) + require.NoError(t, err) + require.Equal(t, impl.agentProfiles, profiles) +} + +func TestHostData_Repositories(t *testing.T) { + impl := &dataRecordingHost{repositories: []Repository{{ID: "repo-1", WorkspaceID: "ws-1", Name: "kdlbs/kandev"}}} + host := dialHostOverBufconn(t, impl) + + repos, _, err := host.Repositories().List(context.Background(), "ws-1", Page{}) + require.NoError(t, err) + require.Equal(t, impl.repositories, repos) + require.Equal(t, "ws-1", impl.lastWorkspaceID) +} + +// TestHostData_UnimplementedHostData_ReturnsUnimplemented proves that a Host +// implementation embedding UnimplementedHostData (task-03's default for +// task-04 to override) compiles against the Host interface and, over the +// wire, returns a gRPC Unimplemented status for every Host data API RPC — +// so a plugin author calling an unwired accessor gets a clear error rather +// than a panic or a zero-value success. +func TestHostData_UnimplementedHostData_ReturnsUnimplemented(t *testing.T) { + impl := &recordingHost{} + host := dialHostOverBufconn(t, impl) + + _, _, err := host.Tasks().List(context.Background(), TaskFilter{}, Page{}) + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, err = host.Tasks().Get(context.Background(), "task-1") + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, _, err = host.Sessions().CodeStats(context.Background(), SessionFilter{}, Page{}) + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) +} diff --git a/apps/backend/pkg/pluginsdk/host_test.go b/apps/backend/pkg/pluginsdk/host_test.go index 0e16417090..176500bfb4 100644 --- a/apps/backend/pkg/pluginsdk/host_test.go +++ b/apps/backend/pkg/pluginsdk/host_test.go @@ -13,8 +13,12 @@ import ( ) // recordingHost is a fake Go-native Host implementation used to drive the -// grpcHostServer adapter without a real state store. +// grpcHostServer adapter without a real state store. It embeds +// UnimplementedHostData so it satisfies the extended Host interface without +// wiring the Host data API (ADR 0043) accessors — those are covered by +// dataRecordingHost in host_data_test.go. type recordingHost struct { + UnimplementedHostData getStateFn func(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) setState struct { scope, scopeID, key string diff --git a/apps/backend/pkg/pluginsdk/plugin_test.go b/apps/backend/pkg/pluginsdk/plugin_test.go index d6e017b58c..d97832eb4f 100644 --- a/apps/backend/pkg/pluginsdk/plugin_test.go +++ b/apps/backend/pkg/pluginsdk/plugin_test.go @@ -33,8 +33,11 @@ func TestUnimplementedPlugin_SetHostThenHost(t *testing.T) { // fakeHost is a minimal Host implementation used only to prove SetHost/Host // wiring; broker-backed Host behavior is covered by the integration test in -// serve_test.go. -type fakeHost struct{} +// serve_test.go. It embeds UnimplementedHostData to satisfy the Host data +// API (ADR 0043) sub-accessors without wiring them. +type fakeHost struct { + UnimplementedHostData +} func (f *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { return nil, false, nil diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index 5fc549bc20..3b62cc7634 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -1102,6 +1102,2434 @@ func (*EmitEventResponse) Descriptor() ([]byte, []int) { return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{19} } +// ── Host data API (ADR 0043) ───────────────────────────────────────────────── +// Pagination is opaque-cursor based so the server can change the underlying +// ordering/store without breaking plugins. +type Page struct { + state protoimpl.MessageState `protogen:"open.v1"` + Limit int32 `protobuf:"varint,1,opt,name=limit,proto3" json:"limit,omitempty"` // 0 → server default (e.g. 50); server caps the max + Cursor string `protobuf:"bytes,2,opt,name=cursor,proto3" json:"cursor,omitempty"` // empty → first page; echo next_cursor to continue + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Page) Reset() { + *x = Page{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Page) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Page) ProtoMessage() {} + +func (x *Page) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[20] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Page.ProtoReflect.Descriptor instead. +func (*Page) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{20} +} + +func (x *Page) GetLimit() int32 { + if x != nil { + return x.Limit + } + return 0 +} + +func (x *Page) GetCursor() string { + if x != nil { + return x.Cursor + } + return "" +} + +type PageInfo struct { + state protoimpl.MessageState `protogen:"open.v1"` + NextCursor string `protobuf:"bytes,1,opt,name=next_cursor,json=nextCursor,proto3" json:"next_cursor,omitempty"` + HasMore bool `protobuf:"varint,2,opt,name=has_more,json=hasMore,proto3" json:"has_more,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *PageInfo) Reset() { + *x = PageInfo{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *PageInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PageInfo) ProtoMessage() {} + +func (x *PageInfo) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[21] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PageInfo.ProtoReflect.Descriptor instead. +func (*PageInfo) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{21} +} + +func (x *PageInfo) GetNextCursor() string { + if x != nil { + return x.NextCursor + } + return "" +} + +func (x *PageInfo) GetHasMore() bool { + if x != nil { + return x.HasMore + } + return false +} + +// ── Task ──────────────────────────────────────────────────────────────────── +type Task struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + WorkspaceId string `protobuf:"bytes,2,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + WorkflowId string `protobuf:"bytes,3,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` + State string `protobuf:"bytes,6,opt,name=state,proto3" json:"state,omitempty"` // TaskState enum value as a string, stable + Priority string `protobuf:"bytes,7,opt,name=priority,proto3" json:"priority,omitempty"` + CreatedBy string `protobuf:"bytes,8,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"` + CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339 + UpdatedAt string `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + StartedAt *string `protobuf:"bytes,11,opt,name=started_at,json=startedAt,proto3,oneof" json:"started_at,omitempty"` + CompletedAt *string `protobuf:"bytes,12,opt,name=completed_at,json=completedAt,proto3,oneof" json:"completed_at,omitempty"` + ParentId *string `protobuf:"bytes,13,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"` + Identifier string `protobuf:"bytes,14,opt,name=identifier,proto3" json:"identifier,omitempty"` + IsEphemeral bool `protobuf:"varint,15,opt,name=is_ephemeral,json=isEphemeral,proto3" json:"is_ephemeral,omitempty"` + Repositories []*TaskRepository `protobuf:"bytes,16,rep,name=repositories,proto3" json:"repositories,omitempty"` + Metadata *structpb.Struct `protobuf:"bytes,17,opt,name=metadata,proto3" json:"metadata,omitempty"` // free-form; plugin-tolerant + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Task) Reset() { + *x = Task{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Task) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Task) ProtoMessage() {} + +func (x *Task) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[22] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Task.ProtoReflect.Descriptor instead. +func (*Task) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{22} +} + +func (x *Task) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Task) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *Task) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *Task) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *Task) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *Task) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Task) GetPriority() string { + if x != nil { + return x.Priority + } + return "" +} + +func (x *Task) GetCreatedBy() string { + if x != nil { + return x.CreatedBy + } + return "" +} + +func (x *Task) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *Task) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +func (x *Task) GetStartedAt() string { + if x != nil && x.StartedAt != nil { + return *x.StartedAt + } + return "" +} + +func (x *Task) GetCompletedAt() string { + if x != nil && x.CompletedAt != nil { + return *x.CompletedAt + } + return "" +} + +func (x *Task) GetParentId() string { + if x != nil && x.ParentId != nil { + return *x.ParentId + } + return "" +} + +func (x *Task) GetIdentifier() string { + if x != nil { + return x.Identifier + } + return "" +} + +func (x *Task) GetIsEphemeral() bool { + if x != nil { + return x.IsEphemeral + } + return false +} + +func (x *Task) GetRepositories() []*TaskRepository { + if x != nil { + return x.Repositories + } + return nil +} + +func (x *Task) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +type TaskRepository struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + RepositoryId string `protobuf:"bytes,2,opt,name=repository_id,json=repositoryId,proto3" json:"repository_id,omitempty"` + BaseBranch string `protobuf:"bytes,3,opt,name=base_branch,json=baseBranch,proto3" json:"base_branch,omitempty"` + Position int32 `protobuf:"varint,4,opt,name=position,proto3" json:"position,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TaskRepository) Reset() { + *x = TaskRepository{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TaskRepository) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskRepository) ProtoMessage() {} + +func (x *TaskRepository) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[23] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskRepository.ProtoReflect.Descriptor instead. +func (*TaskRepository) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{23} +} + +func (x *TaskRepository) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TaskRepository) GetRepositoryId() string { + if x != nil { + return x.RepositoryId + } + return "" +} + +func (x *TaskRepository) GetBaseBranch() string { + if x != nil { + return x.BaseBranch + } + return "" +} + +func (x *TaskRepository) GetPosition() int32 { + if x != nil { + return x.Position + } + return 0 +} + +type TaskFilter struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceIds []string `protobuf:"bytes,1,rep,name=workspace_ids,json=workspaceIds,proto3" json:"workspace_ids,omitempty"` // empty → all the plugin may see + WorkflowIds []string `protobuf:"bytes,2,rep,name=workflow_ids,json=workflowIds,proto3" json:"workflow_ids,omitempty"` + States []string `protobuf:"bytes,3,rep,name=states,proto3" json:"states,omitempty"` + ParentId *string `protobuf:"bytes,4,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"` + IncludeEphemeral bool `protobuf:"varint,5,opt,name=include_ephemeral,json=includeEphemeral,proto3" json:"include_ephemeral,omitempty"` // default false (kanban-visible only) + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *TaskFilter) Reset() { + *x = TaskFilter{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *TaskFilter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskFilter) ProtoMessage() {} + +func (x *TaskFilter) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[24] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskFilter.ProtoReflect.Descriptor instead. +func (*TaskFilter) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{24} +} + +func (x *TaskFilter) GetWorkspaceIds() []string { + if x != nil { + return x.WorkspaceIds + } + return nil +} + +func (x *TaskFilter) GetWorkflowIds() []string { + if x != nil { + return x.WorkflowIds + } + return nil +} + +func (x *TaskFilter) GetStates() []string { + if x != nil { + return x.States + } + return nil +} + +func (x *TaskFilter) GetParentId() string { + if x != nil && x.ParentId != nil { + return *x.ParentId + } + return "" +} + +func (x *TaskFilter) GetIncludeEphemeral() bool { + if x != nil { + return x.IncludeEphemeral + } + return false +} + +type ListTasksRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filter *TaskFilter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTasksRequest) Reset() { + *x = ListTasksRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTasksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTasksRequest) ProtoMessage() {} + +func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[25] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTasksRequest.ProtoReflect.Descriptor instead. +func (*ListTasksRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{25} +} + +func (x *ListTasksRequest) GetFilter() *TaskFilter { + if x != nil { + return x.Filter + } + return nil +} + +func (x *ListTasksRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListTasksResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Tasks []*Task `protobuf:"bytes,1,rep,name=tasks,proto3" json:"tasks,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListTasksResponse) Reset() { + *x = ListTasksResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListTasksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTasksResponse) ProtoMessage() {} + +func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[26] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTasksResponse.ProtoReflect.Descriptor instead. +func (*ListTasksResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{26} +} + +func (x *ListTasksResponse) GetTasks() []*Task { + if x != nil { + return x.Tasks + } + return nil +} + +func (x *ListTasksResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +type GetTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTaskRequest) Reset() { + *x = GetTaskRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTaskRequest) ProtoMessage() {} + +func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[27] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTaskRequest.ProtoReflect.Descriptor instead. +func (*GetTaskRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{27} +} + +func (x *GetTaskRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +type GetTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Task *Task `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetTaskResponse) Reset() { + *x = GetTaskResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTaskResponse) ProtoMessage() {} + +func (x *GetTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[28] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTaskResponse.ProtoReflect.Descriptor instead. +func (*GetTaskResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{28} +} + +func (x *GetTaskResponse) GetTask() *Task { + if x != nil { + return x.Task + } + return nil +} + +// ── Workspace / Workflow / Steps ───────────────────────────────────────────── +type Workspace struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + Description *string `protobuf:"bytes,3,opt,name=description,proto3,oneof" json:"description,omitempty"` + OwnerId string `protobuf:"bytes,4,opt,name=owner_id,json=ownerId,proto3" json:"owner_id,omitempty"` + DefaultExecutorId *string `protobuf:"bytes,5,opt,name=default_executor_id,json=defaultExecutorId,proto3,oneof" json:"default_executor_id,omitempty"` + DefaultAgentProfileId *string `protobuf:"bytes,6,opt,name=default_agent_profile_id,json=defaultAgentProfileId,proto3,oneof" json:"default_agent_profile_id,omitempty"` + CreatedAt string `protobuf:"bytes,7,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,8,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Workspace) Reset() { + *x = Workspace{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Workspace) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Workspace) ProtoMessage() {} + +func (x *Workspace) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[29] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Workspace.ProtoReflect.Descriptor instead. +func (*Workspace) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{29} +} + +func (x *Workspace) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Workspace) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Workspace) GetDescription() string { + if x != nil && x.Description != nil { + return *x.Description + } + return "" +} + +func (x *Workspace) GetOwnerId() string { + if x != nil { + return x.OwnerId + } + return "" +} + +func (x *Workspace) GetDefaultExecutorId() string { + if x != nil && x.DefaultExecutorId != nil { + return *x.DefaultExecutorId + } + return "" +} + +func (x *Workspace) GetDefaultAgentProfileId() string { + if x != nil && x.DefaultAgentProfileId != nil { + return *x.DefaultAgentProfileId + } + return "" +} + +func (x *Workspace) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *Workspace) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type ListWorkspacesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Page *Page `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkspacesRequest) Reset() { + *x = ListWorkspacesRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkspacesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkspacesRequest) ProtoMessage() {} + +func (x *ListWorkspacesRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[30] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkspacesRequest.ProtoReflect.Descriptor instead. +func (*ListWorkspacesRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{30} +} + +func (x *ListWorkspacesRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListWorkspacesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workspaces []*Workspace `protobuf:"bytes,1,rep,name=workspaces,proto3" json:"workspaces,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkspacesResponse) Reset() { + *x = ListWorkspacesResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkspacesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkspacesResponse) ProtoMessage() {} + +func (x *ListWorkspacesResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[31] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkspacesResponse.ProtoReflect.Descriptor instead. +func (*ListWorkspacesResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{31} +} + +func (x *ListWorkspacesResponse) GetWorkspaces() []*Workspace { + if x != nil { + return x.Workspaces + } + return nil +} + +func (x *ListWorkspacesResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +type Workflow struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + WorkspaceId string `protobuf:"bytes,2,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Description *string `protobuf:"bytes,4,opt,name=description,proto3,oneof" json:"description,omitempty"` + SortOrder int32 `protobuf:"varint,5,opt,name=sort_order,json=sortOrder,proto3" json:"sort_order,omitempty"` + CreatedAt string `protobuf:"bytes,6,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + UpdatedAt string `protobuf:"bytes,7,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Workflow) Reset() { + *x = Workflow{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Workflow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Workflow) ProtoMessage() {} + +func (x *Workflow) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[32] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Workflow.ProtoReflect.Descriptor instead. +func (*Workflow) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{32} +} + +func (x *Workflow) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Workflow) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *Workflow) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Workflow) GetDescription() string { + if x != nil && x.Description != nil { + return *x.Description + } + return "" +} + +func (x *Workflow) GetSortOrder() int32 { + if x != nil { + return x.SortOrder + } + return 0 +} + +func (x *Workflow) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +func (x *Workflow) GetUpdatedAt() string { + if x != nil { + return x.UpdatedAt + } + return "" +} + +type ListWorkflowsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkflowsRequest) Reset() { + *x = ListWorkflowsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkflowsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkflowsRequest) ProtoMessage() {} + +func (x *ListWorkflowsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[33] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkflowsRequest.ProtoReflect.Descriptor instead. +func (*ListWorkflowsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{33} +} + +func (x *ListWorkflowsRequest) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *ListWorkflowsRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListWorkflowsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Workflows []*Workflow `protobuf:"bytes,1,rep,name=workflows,proto3" json:"workflows,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkflowsResponse) Reset() { + *x = ListWorkflowsResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkflowsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkflowsResponse) ProtoMessage() {} + +func (x *ListWorkflowsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[34] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkflowsResponse.ProtoReflect.Descriptor instead. +func (*ListWorkflowsResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{34} +} + +func (x *ListWorkflowsResponse) GetWorkflows() []*Workflow { + if x != nil { + return x.Workflows + } + return nil +} + +func (x *ListWorkflowsResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +type WorkflowStep struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` + Position int32 `protobuf:"varint,4,opt,name=position,proto3" json:"position,omitempty"` + StageType string `protobuf:"bytes,5,opt,name=stage_type,json=stageType,proto3" json:"stage_type,omitempty"` // work | review | approval | custom + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WorkflowStep) Reset() { + *x = WorkflowStep{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WorkflowStep) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WorkflowStep) ProtoMessage() {} + +func (x *WorkflowStep) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[35] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WorkflowStep.ProtoReflect.Descriptor instead. +func (*WorkflowStep) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{35} +} + +func (x *WorkflowStep) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *WorkflowStep) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *WorkflowStep) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *WorkflowStep) GetPosition() int32 { + if x != nil { + return x.Position + } + return 0 +} + +func (x *WorkflowStep) GetStageType() string { + if x != nil { + return x.StageType + } + return "" +} + +type ListWorkflowStepsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkflowId string `protobuf:"bytes,1,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkflowStepsRequest) Reset() { + *x = ListWorkflowStepsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkflowStepsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkflowStepsRequest) ProtoMessage() {} + +func (x *ListWorkflowStepsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[36] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkflowStepsRequest.ProtoReflect.Descriptor instead. +func (*ListWorkflowStepsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{36} +} + +func (x *ListWorkflowStepsRequest) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +type ListWorkflowStepsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Steps []*WorkflowStep `protobuf:"bytes,1,rep,name=steps,proto3" json:"steps,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListWorkflowStepsResponse) Reset() { + *x = ListWorkflowStepsResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListWorkflowStepsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListWorkflowStepsResponse) ProtoMessage() {} + +func (x *ListWorkflowStepsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[37] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListWorkflowStepsResponse.ProtoReflect.Descriptor instead. +func (*ListWorkflowStepsResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{37} +} + +func (x *ListWorkflowStepsResponse) GetSteps() []*WorkflowStep { + if x != nil { + return x.Steps + } + return nil +} + +// ── Agent profiles / Repositories (read-only context) ──────────────────────── +type AgentProfile struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + AgentId string `protobuf:"bytes,2,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"` + DisplayName string `protobuf:"bytes,3,opt,name=display_name,json=displayName,proto3" json:"display_name,omitempty"` + Name string `protobuf:"bytes,4,opt,name=name,proto3" json:"name,omitempty"` + Model string `protobuf:"bytes,5,opt,name=model,proto3" json:"model,omitempty"` + Mode string `protobuf:"bytes,6,opt,name=mode,proto3" json:"mode,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AgentProfile) Reset() { + *x = AgentProfile{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AgentProfile) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentProfile) ProtoMessage() {} + +func (x *AgentProfile) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[38] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentProfile.ProtoReflect.Descriptor instead. +func (*AgentProfile) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{38} +} + +func (x *AgentProfile) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AgentProfile) GetAgentId() string { + if x != nil { + return x.AgentId + } + return "" +} + +func (x *AgentProfile) GetDisplayName() string { + if x != nil { + return x.DisplayName + } + return "" +} + +func (x *AgentProfile) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentProfile) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *AgentProfile) GetMode() string { + if x != nil { + return x.Mode + } + return "" +} + +type ListAgentProfilesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Page *Page `protobuf:"bytes,1,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentProfilesRequest) Reset() { + *x = ListAgentProfilesRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentProfilesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentProfilesRequest) ProtoMessage() {} + +func (x *ListAgentProfilesRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[39] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentProfilesRequest.ProtoReflect.Descriptor instead. +func (*ListAgentProfilesRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{39} +} + +func (x *ListAgentProfilesRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListAgentProfilesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Profiles []*AgentProfile `protobuf:"bytes,1,rep,name=profiles,proto3" json:"profiles,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListAgentProfilesResponse) Reset() { + *x = ListAgentProfilesResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListAgentProfilesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListAgentProfilesResponse) ProtoMessage() {} + +func (x *ListAgentProfilesResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[40] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListAgentProfilesResponse.ProtoReflect.Descriptor instead. +func (*ListAgentProfilesResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{40} +} + +func (x *ListAgentProfilesResponse) GetProfiles() []*AgentProfile { + if x != nil { + return x.Profiles + } + return nil +} + +func (x *ListAgentProfilesResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +type Repository struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + WorkspaceId string `protobuf:"bytes,2,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` // owner/repo slug when known, else name + DefaultBranch *string `protobuf:"bytes,4,opt,name=default_branch,json=defaultBranch,proto3,oneof" json:"default_branch,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Repository) Reset() { + *x = Repository{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Repository) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Repository) ProtoMessage() {} + +func (x *Repository) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[41] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Repository.ProtoReflect.Descriptor instead. +func (*Repository) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{41} +} + +func (x *Repository) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Repository) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *Repository) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Repository) GetDefaultBranch() string { + if x != nil && x.DefaultBranch != nil { + return *x.DefaultBranch + } + return "" +} + +type ListRepositoriesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRepositoriesRequest) Reset() { + *x = ListRepositoriesRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRepositoriesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRepositoriesRequest) ProtoMessage() {} + +func (x *ListRepositoriesRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[42] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRepositoriesRequest.ProtoReflect.Descriptor instead. +func (*ListRepositoriesRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{42} +} + +func (x *ListRepositoriesRequest) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *ListRepositoriesRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListRepositoriesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Repositories []*Repository `protobuf:"bytes,1,rep,name=repositories,proto3" json:"repositories,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListRepositoriesResponse) Reset() { + *x = ListRepositoriesResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListRepositoriesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListRepositoriesResponse) ProtoMessage() {} + +func (x *ListRepositoriesResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[43] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListRepositoriesResponse.ProtoReflect.Descriptor instead. +func (*ListRepositoriesResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{43} +} + +func (x *ListRepositoriesResponse) GetRepositories() []*Repository { + if x != nil { + return x.Repositories + } + return nil +} + +func (x *ListRepositoriesResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +// ── Sessions ───────────────────────────────────────────────────────────────── +// Identity + agent context for one agent run on a task. acp_session_id is the +// join key an external usage tool (e.g. tokscale) uses to attribute tokens — +// so kandev exposes it and stays out of the token business entirely. +// +// acp_session_id resolution (host-side, not the plugin's concern): read +// session metadata key `$.acp.session_id` first; if absent, fall back to +// `executors_running.resume_token` for the session's agent execution. Either +// source may be empty for a session that never reached the ACP handshake, in +// which case acp_session_id is returned empty. +type Session struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + AgentProfileId string `protobuf:"bytes,3,opt,name=agent_profile_id,json=agentProfileId,proto3" json:"agent_profile_id,omitempty"` + AgentDisplayName string `protobuf:"bytes,4,opt,name=agent_display_name,json=agentDisplayName,proto3" json:"agent_display_name,omitempty"` // from the profile snapshot at run time + Model string `protobuf:"bytes,5,opt,name=model,proto3" json:"model,omitempty"` // resolved model at run time + AcpSessionId string `protobuf:"bytes,6,opt,name=acp_session_id,json=acpSessionId,proto3" json:"acp_session_id,omitempty"` // external usage-attribution join key + State string `protobuf:"bytes,7,opt,name=state,proto3" json:"state,omitempty"` + StartedAt string `protobuf:"bytes,8,opt,name=started_at,json=startedAt,proto3" json:"started_at,omitempty"` + EndedAt *string `protobuf:"bytes,9,opt,name=ended_at,json=endedAt,proto3,oneof" json:"ended_at,omitempty"` + AgentProfileName string `protobuf:"bytes,10,opt,name=agent_profile_name,json=agentProfileName,proto3" json:"agent_profile_name,omitempty"` // profile name from the snapshot at run time + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Session) Reset() { + *x = Session{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Session) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Session) ProtoMessage() {} + +func (x *Session) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[44] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Session.ProtoReflect.Descriptor instead. +func (*Session) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{44} +} + +func (x *Session) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Session) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *Session) GetAgentProfileId() string { + if x != nil { + return x.AgentProfileId + } + return "" +} + +func (x *Session) GetAgentDisplayName() string { + if x != nil { + return x.AgentDisplayName + } + return "" +} + +func (x *Session) GetModel() string { + if x != nil { + return x.Model + } + return "" +} + +func (x *Session) GetAcpSessionId() string { + if x != nil { + return x.AcpSessionId + } + return "" +} + +func (x *Session) GetState() string { + if x != nil { + return x.State + } + return "" +} + +func (x *Session) GetStartedAt() string { + if x != nil { + return x.StartedAt + } + return "" +} + +func (x *Session) GetEndedAt() string { + if x != nil && x.EndedAt != nil { + return *x.EndedAt + } + return "" +} + +func (x *Session) GetAgentProfileName() string { + if x != nil { + return x.AgentProfileName + } + return "" +} + +type SessionFilter struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskIds []string `protobuf:"bytes,1,rep,name=task_ids,json=taskIds,proto3" json:"task_ids,omitempty"` + WorkspaceIds []string `protobuf:"bytes,2,rep,name=workspace_ids,json=workspaceIds,proto3" json:"workspace_ids,omitempty"` + States []string `protobuf:"bytes,3,rep,name=states,proto3" json:"states,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionFilter) Reset() { + *x = SessionFilter{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionFilter) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionFilter) ProtoMessage() {} + +func (x *SessionFilter) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[45] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionFilter.ProtoReflect.Descriptor instead. +func (*SessionFilter) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{45} +} + +func (x *SessionFilter) GetTaskIds() []string { + if x != nil { + return x.TaskIds + } + return nil +} + +func (x *SessionFilter) GetWorkspaceIds() []string { + if x != nil { + return x.WorkspaceIds + } + return nil +} + +func (x *SessionFilter) GetStates() []string { + if x != nil { + return x.States + } + return nil +} + +type ListSessionsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filter *SessionFilter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsRequest) Reset() { + *x = ListSessionsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsRequest) ProtoMessage() {} + +func (x *ListSessionsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[46] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{46} +} + +func (x *ListSessionsRequest) GetFilter() *SessionFilter { + if x != nil { + return x.Filter + } + return nil +} + +func (x *ListSessionsRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListSessionsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Sessions []*Session `protobuf:"bytes,1,rep,name=sessions,proto3" json:"sessions,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionsResponse) Reset() { + *x = ListSessionsResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionsResponse) ProtoMessage() {} + +func (x *ListSessionsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[47] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionsResponse.ProtoReflect.Descriptor instead. +func (*ListSessionsResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{47} +} + +func (x *ListSessionsResponse) GetSessions() []*Session { + if x != nil { + return x.Sessions + } + return nil +} + +func (x *ListSessionsResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +// Computed code metrics per session — the aggregate the agent-stats plugin +// re-derived by hand from commits + git snapshots. Exposed as a stable shape +// so plugins never touch task_session_commits / task_session_git_snapshots. +type SessionCodeStats struct { + state protoimpl.MessageState `protogen:"open.v1"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` + LinesAddedCommitted int64 `protobuf:"varint,2,opt,name=lines_added_committed,json=linesAddedCommitted,proto3" json:"lines_added_committed,omitempty"` // SUM of commit insertions + LinesDeletedCommitted int64 `protobuf:"varint,3,opt,name=lines_deleted_committed,json=linesDeletedCommitted,proto3" json:"lines_deleted_committed,omitempty"` // SUM of commit deletions + LinesAddedPeakPending int64 `protobuf:"varint,4,opt,name=lines_added_peak_pending,json=linesAddedPeakPending,proto3" json:"lines_added_peak_pending,omitempty"` // peak uncommitted diff additions across snapshots + LinesDeletedPeakPending int64 `protobuf:"varint,5,opt,name=lines_deleted_peak_pending,json=linesDeletedPeakPending,proto3" json:"lines_deleted_peak_pending,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SessionCodeStats) Reset() { + *x = SessionCodeStats{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SessionCodeStats) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SessionCodeStats) ProtoMessage() {} + +func (x *SessionCodeStats) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[48] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SessionCodeStats.ProtoReflect.Descriptor instead. +func (*SessionCodeStats) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{48} +} + +func (x *SessionCodeStats) GetSessionId() string { + if x != nil { + return x.SessionId + } + return "" +} + +func (x *SessionCodeStats) GetLinesAddedCommitted() int64 { + if x != nil { + return x.LinesAddedCommitted + } + return 0 +} + +func (x *SessionCodeStats) GetLinesDeletedCommitted() int64 { + if x != nil { + return x.LinesDeletedCommitted + } + return 0 +} + +func (x *SessionCodeStats) GetLinesAddedPeakPending() int64 { + if x != nil { + return x.LinesAddedPeakPending + } + return 0 +} + +func (x *SessionCodeStats) GetLinesDeletedPeakPending() int64 { + if x != nil { + return x.LinesDeletedPeakPending + } + return 0 +} + +type ListSessionCodeStatsRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Filter *SessionFilter `protobuf:"bytes,1,opt,name=filter,proto3" json:"filter,omitempty"` + Page *Page `protobuf:"bytes,2,opt,name=page,proto3" json:"page,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionCodeStatsRequest) Reset() { + *x = ListSessionCodeStatsRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionCodeStatsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionCodeStatsRequest) ProtoMessage() {} + +func (x *ListSessionCodeStatsRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[49] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionCodeStatsRequest.ProtoReflect.Descriptor instead. +func (*ListSessionCodeStatsRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{49} +} + +func (x *ListSessionCodeStatsRequest) GetFilter() *SessionFilter { + if x != nil { + return x.Filter + } + return nil +} + +func (x *ListSessionCodeStatsRequest) GetPage() *Page { + if x != nil { + return x.Page + } + return nil +} + +type ListSessionCodeStatsResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Stats []*SessionCodeStats `protobuf:"bytes,1,rep,name=stats,proto3" json:"stats,omitempty"` + PageInfo *PageInfo `protobuf:"bytes,2,opt,name=page_info,json=pageInfo,proto3" json:"page_info,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ListSessionCodeStatsResponse) Reset() { + *x = ListSessionCodeStatsResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ListSessionCodeStatsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListSessionCodeStatsResponse) ProtoMessage() {} + +func (x *ListSessionCodeStatsResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[50] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListSessionCodeStatsResponse.ProtoReflect.Descriptor instead. +func (*ListSessionCodeStatsResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{50} +} + +func (x *ListSessionCodeStatsResponse) GetStats() []*SessionCodeStats { + if x != nil { + return x.Stats + } + return nil +} + +func (x *ListSessionCodeStatsResponse) GetPageInfo() *PageInfo { + if x != nil { + return x.PageInfo + } + return nil +} + +// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +type CreateTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` + WorkflowId string `protobuf:"bytes,2,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"` + WorkflowStepId *string `protobuf:"bytes,3,opt,name=workflow_step_id,json=workflowStepId,proto3,oneof" json:"workflow_step_id,omitempty"` + Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"` + Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"` + ParentId *string `protobuf:"bytes,6,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"` + StartAgent bool `protobuf:"varint,7,opt,name=start_agent,json=startAgent,proto3" json:"start_agent,omitempty"` // kandev stamps source = "plugin:" server-side; not settable here. + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTaskRequest) Reset() { + *x = CreateTaskRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTaskRequest) ProtoMessage() {} + +func (x *CreateTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[51] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTaskRequest.ProtoReflect.Descriptor instead. +func (*CreateTaskRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{51} +} + +func (x *CreateTaskRequest) GetWorkspaceId() string { + if x != nil { + return x.WorkspaceId + } + return "" +} + +func (x *CreateTaskRequest) GetWorkflowId() string { + if x != nil { + return x.WorkflowId + } + return "" +} + +func (x *CreateTaskRequest) GetWorkflowStepId() string { + if x != nil && x.WorkflowStepId != nil { + return *x.WorkflowStepId + } + return "" +} + +func (x *CreateTaskRequest) GetTitle() string { + if x != nil { + return x.Title + } + return "" +} + +func (x *CreateTaskRequest) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *CreateTaskRequest) GetParentId() string { + if x != nil && x.ParentId != nil { + return *x.ParentId + } + return "" +} + +func (x *CreateTaskRequest) GetStartAgent() bool { + if x != nil { + return x.StartAgent + } + return false +} + +type CreateTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Task *Task `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateTaskResponse) Reset() { + *x = CreateTaskResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateTaskResponse) ProtoMessage() {} + +func (x *CreateTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[52] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateTaskResponse.ProtoReflect.Descriptor instead. +func (*CreateTaskResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{52} +} + +func (x *CreateTaskResponse) GetTask() *Task { + if x != nil { + return x.Task + } + return nil +} + +type UpdateTaskRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + Title *string `protobuf:"bytes,2,opt,name=title,proto3,oneof" json:"title,omitempty"` + Description *string `protobuf:"bytes,3,opt,name=description,proto3,oneof" json:"description,omitempty"` + State *string `protobuf:"bytes,4,opt,name=state,proto3,oneof" json:"state,omitempty"` + WorkflowStepId *string `protobuf:"bytes,5,opt,name=workflow_step_id,json=workflowStepId,proto3,oneof" json:"workflow_step_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTaskRequest) Reset() { + *x = UpdateTaskRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTaskRequest) ProtoMessage() {} + +func (x *UpdateTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[53] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTaskRequest.ProtoReflect.Descriptor instead. +func (*UpdateTaskRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{53} +} + +func (x *UpdateTaskRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *UpdateTaskRequest) GetTitle() string { + if x != nil && x.Title != nil { + return *x.Title + } + return "" +} + +func (x *UpdateTaskRequest) GetDescription() string { + if x != nil && x.Description != nil { + return *x.Description + } + return "" +} + +func (x *UpdateTaskRequest) GetState() string { + if x != nil && x.State != nil { + return *x.State + } + return "" +} + +func (x *UpdateTaskRequest) GetWorkflowStepId() string { + if x != nil && x.WorkflowStepId != nil { + return *x.WorkflowStepId + } + return "" +} + +type UpdateTaskResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Task *Task `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UpdateTaskResponse) Reset() { + *x = UpdateTaskResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UpdateTaskResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UpdateTaskResponse) ProtoMessage() {} + +func (x *UpdateTaskResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[54] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UpdateTaskResponse.ProtoReflect.Descriptor instead. +func (*UpdateTaskResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{54} +} + +func (x *UpdateTaskResponse) GetTask() *Task { + if x != nil { + return x.Task + } + return nil +} + +type Comment struct { + state protoimpl.MessageState `protogen:"open.v1"` + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` + Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"` // "plugin:" + CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Comment) Reset() { + *x = Comment{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Comment) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Comment) ProtoMessage() {} + +func (x *Comment) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[55] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Comment.ProtoReflect.Descriptor instead. +func (*Comment) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{55} +} + +func (x *Comment) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Comment) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *Comment) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +func (x *Comment) GetSource() string { + if x != nil { + return x.Source + } + return "" +} + +func (x *Comment) GetCreatedAt() string { + if x != nil { + return x.CreatedAt + } + return "" +} + +type CreateCommentRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCommentRequest) Reset() { + *x = CreateCommentRequest{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCommentRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCommentRequest) ProtoMessage() {} + +func (x *CreateCommentRequest) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[56] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateCommentRequest.ProtoReflect.Descriptor instead. +func (*CreateCommentRequest) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{56} +} + +func (x *CreateCommentRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *CreateCommentRequest) GetBody() string { + if x != nil { + return x.Body + } + return "" +} + +type CreateCommentResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Comment *Comment `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CreateCommentResponse) Reset() { + *x = CreateCommentResponse{} + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CreateCommentResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CreateCommentResponse) ProtoMessage() {} + +func (x *CreateCommentResponse) ProtoReflect() protoreflect.Message { + mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[57] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CreateCommentResponse.ProtoReflect.Descriptor instead. +func (*CreateCommentResponse) Descriptor() ([]byte, []int) { + return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{57} +} + +func (x *CreateCommentResponse) GetComment() *Comment { + if x != nil { + return x.Comment + } + return nil +} + var File_kandev_plugin_v1_plugin_proto protoreflect.FileDescriptor const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + @@ -1186,19 +3614,248 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\n" + "event_name\x18\x01 \x01(\tR\teventName\x121\n" + "\apayload\x18\x02 \x01(\v2\x17.google.protobuf.StructR\apayload\"\x13\n" + - "\x11EmitEventResponse2\xf0\x01\n" + + "\x11EmitEventResponse\"4\n" + + "\x04Page\x12\x14\n" + + "\x05limit\x18\x01 \x01(\x05R\x05limit\x12\x16\n" + + "\x06cursor\x18\x02 \x01(\tR\x06cursor\"F\n" + + "\bPageInfo\x12\x1f\n" + + "\vnext_cursor\x18\x01 \x01(\tR\n" + + "nextCursor\x12\x19\n" + + "\bhas_more\x18\x02 \x01(\bR\ahasMore\"\xfb\x04\n" + + "\x04Task\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\fworkspace_id\x18\x02 \x01(\tR\vworkspaceId\x12\x1f\n" + + "\vworkflow_id\x18\x03 \x01(\tR\n" + + "workflowId\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\x12\x14\n" + + "\x05state\x18\x06 \x01(\tR\x05state\x12\x1a\n" + + "\bpriority\x18\a \x01(\tR\bpriority\x12\x1d\n" + + "\n" + + "created_by\x18\b \x01(\tR\tcreatedBy\x12\x1d\n" + + "\n" + + "created_at\x18\t \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\n" + + " \x01(\tR\tupdatedAt\x12\"\n" + + "\n" + + "started_at\x18\v \x01(\tH\x00R\tstartedAt\x88\x01\x01\x12&\n" + + "\fcompleted_at\x18\f \x01(\tH\x01R\vcompletedAt\x88\x01\x01\x12 \n" + + "\tparent_id\x18\r \x01(\tH\x02R\bparentId\x88\x01\x01\x12\x1e\n" + + "\n" + + "identifier\x18\x0e \x01(\tR\n" + + "identifier\x12!\n" + + "\fis_ephemeral\x18\x0f \x01(\bR\visEphemeral\x12D\n" + + "\frepositories\x18\x10 \x03(\v2 .kandev.plugin.v1.TaskRepositoryR\frepositories\x123\n" + + "\bmetadata\x18\x11 \x01(\v2\x17.google.protobuf.StructR\bmetadataB\r\n" + + "\v_started_atB\x0f\n" + + "\r_completed_atB\f\n" + + "\n" + + "_parent_id\"\x82\x01\n" + + "\x0eTaskRepository\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12#\n" + + "\rrepository_id\x18\x02 \x01(\tR\frepositoryId\x12\x1f\n" + + "\vbase_branch\x18\x03 \x01(\tR\n" + + "baseBranch\x12\x1a\n" + + "\bposition\x18\x04 \x01(\x05R\bposition\"\xc9\x01\n" + + "\n" + + "TaskFilter\x12#\n" + + "\rworkspace_ids\x18\x01 \x03(\tR\fworkspaceIds\x12!\n" + + "\fworkflow_ids\x18\x02 \x03(\tR\vworkflowIds\x12\x16\n" + + "\x06states\x18\x03 \x03(\tR\x06states\x12 \n" + + "\tparent_id\x18\x04 \x01(\tH\x00R\bparentId\x88\x01\x01\x12+\n" + + "\x11include_ephemeral\x18\x05 \x01(\bR\x10includeEphemeralB\f\n" + + "\n" + + "_parent_id\"t\n" + + "\x10ListTasksRequest\x124\n" + + "\x06filter\x18\x01 \x01(\v2\x1c.kandev.plugin.v1.TaskFilterR\x06filter\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"z\n" + + "\x11ListTasksResponse\x12,\n" + + "\x05tasks\x18\x01 \x03(\v2\x16.kandev.plugin.v1.TaskR\x05tasks\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\" \n" + + "\x0eGetTaskRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\"=\n" + + "\x0fGetTaskResponse\x12*\n" + + "\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"\xe7\x02\n" + + "\tWorkspace\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" + + "\x04name\x18\x02 \x01(\tR\x04name\x12%\n" + + "\vdescription\x18\x03 \x01(\tH\x00R\vdescription\x88\x01\x01\x12\x19\n" + + "\bowner_id\x18\x04 \x01(\tR\aownerId\x123\n" + + "\x13default_executor_id\x18\x05 \x01(\tH\x01R\x11defaultExecutorId\x88\x01\x01\x12<\n" + + "\x18default_agent_profile_id\x18\x06 \x01(\tH\x02R\x15defaultAgentProfileId\x88\x01\x01\x12\x1d\n" + + "\n" + + "created_at\x18\a \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\b \x01(\tR\tupdatedAtB\x0e\n" + + "\f_descriptionB\x16\n" + + "\x14_default_executor_idB\x1b\n" + + "\x19_default_agent_profile_id\"C\n" + + "\x15ListWorkspacesRequest\x12*\n" + + "\x04page\x18\x01 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x8e\x01\n" + + "\x16ListWorkspacesResponse\x12;\n" + + "\n" + + "workspaces\x18\x01 \x03(\v2\x1b.kandev.plugin.v1.WorkspaceR\n" + + "workspaces\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\xe5\x01\n" + + "\bWorkflow\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\fworkspace_id\x18\x02 \x01(\tR\vworkspaceId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12%\n" + + "\vdescription\x18\x04 \x01(\tH\x00R\vdescription\x88\x01\x01\x12\x1d\n" + + "\n" + + "sort_order\x18\x05 \x01(\x05R\tsortOrder\x12\x1d\n" + + "\n" + + "created_at\x18\x06 \x01(\tR\tcreatedAt\x12\x1d\n" + + "\n" + + "updated_at\x18\a \x01(\tR\tupdatedAtB\x0e\n" + + "\f_description\"e\n" + + "\x14ListWorkflowsRequest\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x8a\x01\n" + + "\x15ListWorkflowsResponse\x128\n" + + "\tworkflows\x18\x01 \x03(\v2\x1a.kandev.plugin.v1.WorkflowR\tworkflows\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\x8e\x01\n" + + "\fWorkflowStep\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x1f\n" + + "\vworkflow_id\x18\x02 \x01(\tR\n" + + "workflowId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12\x1a\n" + + "\bposition\x18\x04 \x01(\x05R\bposition\x12\x1d\n" + + "\n" + + "stage_type\x18\x05 \x01(\tR\tstageType\";\n" + + "\x18ListWorkflowStepsRequest\x12\x1f\n" + + "\vworkflow_id\x18\x01 \x01(\tR\n" + + "workflowId\"Q\n" + + "\x19ListWorkflowStepsResponse\x124\n" + + "\x05steps\x18\x01 \x03(\v2\x1e.kandev.plugin.v1.WorkflowStepR\x05steps\"\x9a\x01\n" + + "\fAgentProfile\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + + "\bagent_id\x18\x02 \x01(\tR\aagentId\x12!\n" + + "\fdisplay_name\x18\x03 \x01(\tR\vdisplayName\x12\x12\n" + + "\x04name\x18\x04 \x01(\tR\x04name\x12\x14\n" + + "\x05model\x18\x05 \x01(\tR\x05model\x12\x12\n" + + "\x04mode\x18\x06 \x01(\tR\x04mode\"F\n" + + "\x18ListAgentProfilesRequest\x12*\n" + + "\x04page\x18\x01 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x90\x01\n" + + "\x19ListAgentProfilesResponse\x12:\n" + + "\bprofiles\x18\x01 \x03(\v2\x1e.kandev.plugin.v1.AgentProfileR\bprofiles\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\x92\x01\n" + + "\n" + + "Repository\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12!\n" + + "\fworkspace_id\x18\x02 \x01(\tR\vworkspaceId\x12\x12\n" + + "\x04name\x18\x03 \x01(\tR\x04name\x12*\n" + + "\x0edefault_branch\x18\x04 \x01(\tH\x00R\rdefaultBranch\x88\x01\x01B\x11\n" + + "\x0f_default_branch\"h\n" + + "\x17ListRepositoriesRequest\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x95\x01\n" + + "\x18ListRepositoriesResponse\x12@\n" + + "\frepositories\x18\x01 \x03(\v2\x1c.kandev.plugin.v1.RepositoryR\frepositories\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\xd6\x02\n" + + "\aSession\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12(\n" + + "\x10agent_profile_id\x18\x03 \x01(\tR\x0eagentProfileId\x12,\n" + + "\x12agent_display_name\x18\x04 \x01(\tR\x10agentDisplayName\x12\x14\n" + + "\x05model\x18\x05 \x01(\tR\x05model\x12$\n" + + "\x0eacp_session_id\x18\x06 \x01(\tR\facpSessionId\x12\x14\n" + + "\x05state\x18\a \x01(\tR\x05state\x12\x1d\n" + + "\n" + + "started_at\x18\b \x01(\tR\tstartedAt\x12\x1e\n" + + "\bended_at\x18\t \x01(\tH\x00R\aendedAt\x88\x01\x01\x12,\n" + + "\x12agent_profile_name\x18\n" + + " \x01(\tR\x10agentProfileNameB\v\n" + + "\t_ended_at\"g\n" + + "\rSessionFilter\x12\x19\n" + + "\btask_ids\x18\x01 \x03(\tR\ataskIds\x12#\n" + + "\rworkspace_ids\x18\x02 \x03(\tR\fworkspaceIds\x12\x16\n" + + "\x06states\x18\x03 \x03(\tR\x06states\"z\n" + + "\x13ListSessionsRequest\x127\n" + + "\x06filter\x18\x01 \x01(\v2\x1f.kandev.plugin.v1.SessionFilterR\x06filter\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x86\x01\n" + + "\x14ListSessionsResponse\x125\n" + + "\bsessions\x18\x01 \x03(\v2\x19.kandev.plugin.v1.SessionR\bsessions\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\x93\x02\n" + + "\x10SessionCodeStats\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x122\n" + + "\x15lines_added_committed\x18\x02 \x01(\x03R\x13linesAddedCommitted\x126\n" + + "\x17lines_deleted_committed\x18\x03 \x01(\x03R\x15linesDeletedCommitted\x127\n" + + "\x18lines_added_peak_pending\x18\x04 \x01(\x03R\x15linesAddedPeakPending\x12;\n" + + "\x1alines_deleted_peak_pending\x18\x05 \x01(\x03R\x17linesDeletedPeakPending\"\x82\x01\n" + + "\x1bListSessionCodeStatsRequest\x127\n" + + "\x06filter\x18\x01 \x01(\v2\x1f.kandev.plugin.v1.SessionFilterR\x06filter\x12*\n" + + "\x04page\x18\x02 \x01(\v2\x16.kandev.plugin.v1.PageR\x04page\"\x91\x01\n" + + "\x1cListSessionCodeStatsResponse\x128\n" + + "\x05stats\x18\x01 \x03(\v2\".kandev.plugin.v1.SessionCodeStatsR\x05stats\x127\n" + + "\tpage_info\x18\x02 \x01(\v2\x1a.kandev.plugin.v1.PageInfoR\bpageInfo\"\xa4\x02\n" + + "\x11CreateTaskRequest\x12!\n" + + "\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12\x1f\n" + + "\vworkflow_id\x18\x02 \x01(\tR\n" + + "workflowId\x12-\n" + + "\x10workflow_step_id\x18\x03 \x01(\tH\x00R\x0eworkflowStepId\x88\x01\x01\x12\x14\n" + + "\x05title\x18\x04 \x01(\tR\x05title\x12 \n" + + "\vdescription\x18\x05 \x01(\tR\vdescription\x12 \n" + + "\tparent_id\x18\x06 \x01(\tH\x01R\bparentId\x88\x01\x01\x12\x1f\n" + + "\vstart_agent\x18\a \x01(\bR\n" + + "startAgentB\x13\n" + + "\x11_workflow_step_idB\f\n" + + "\n" + + "_parent_id\"@\n" + + "\x12CreateTaskResponse\x12*\n" + + "\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"\xe8\x01\n" + + "\x11UpdateTaskRequest\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" + + "\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12%\n" + + "\vdescription\x18\x03 \x01(\tH\x01R\vdescription\x88\x01\x01\x12\x19\n" + + "\x05state\x18\x04 \x01(\tH\x02R\x05state\x88\x01\x01\x12-\n" + + "\x10workflow_step_id\x18\x05 \x01(\tH\x03R\x0eworkflowStepId\x88\x01\x01B\b\n" + + "\x06_titleB\x0e\n" + + "\f_descriptionB\b\n" + + "\x06_stateB\x13\n" + + "\x11_workflow_step_id\"@\n" + + "\x12UpdateTaskResponse\x12*\n" + + "\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"}\n" + + "\aComment\x12\x0e\n" + + "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + + "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x12\n" + + "\x04body\x18\x03 \x01(\tR\x04body\x12\x16\n" + + "\x06source\x18\x04 \x01(\tR\x06source\x12\x1d\n" + + "\n" + + "created_at\x18\x05 \x01(\tR\tcreatedAt\"C\n" + + "\x14CreateCommentRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x12\n" + + "\x04body\x18\x02 \x01(\tR\x04body\"L\n" + + "\x15CreateCommentResponse\x123\n" + + "\acomment\x18\x01 \x01(\v2\x19.kandev.plugin.v1.CommentR\acomment2\xf0\x01\n" + "\x06Plugin\x12C\n" + "\fDeliverEvent\x12\x17.kandev.plugin.v1.Event\x1a\x1a.kandev.plugin.v1.EventAck\x12K\n" + "\n" + "InvokeTool\x12\x1d.kandev.plugin.v1.ToolRequest\x1a\x1e.kandev.plugin.v1.ToolResponse\x12T\n" + - "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\x93\x04\n" + + "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\xb1\r\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + "\vDeleteState\x12$.kandev.plugin.v1.DeleteStateRequest\x1a%.kandev.plugin.v1.DeleteStateResponse\x12T\n" + "\tListState\x12\".kandev.plugin.v1.ListStateRequest\x1a#.kandev.plugin.v1.ListStateResponse\x12]\n" + "\fRevealSecret\x12%.kandev.plugin.v1.RevealSecretRequest\x1a&.kandev.plugin.v1.RevealSecretResponse\x12T\n" + - "\tEmitEvent\x12\".kandev.plugin.v1.EmitEventRequest\x1a#.kandev.plugin.v1.EmitEventResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" + "\tEmitEvent\x12\".kandev.plugin.v1.EmitEventRequest\x1a#.kandev.plugin.v1.EmitEventResponse\x12T\n" + + "\tListTasks\x12\".kandev.plugin.v1.ListTasksRequest\x1a#.kandev.plugin.v1.ListTasksResponse\x12N\n" + + "\aGetTask\x12 .kandev.plugin.v1.GetTaskRequest\x1a!.kandev.plugin.v1.GetTaskResponse\x12c\n" + + "\x0eListWorkspaces\x12'.kandev.plugin.v1.ListWorkspacesRequest\x1a(.kandev.plugin.v1.ListWorkspacesResponse\x12`\n" + + "\rListWorkflows\x12&.kandev.plugin.v1.ListWorkflowsRequest\x1a'.kandev.plugin.v1.ListWorkflowsResponse\x12l\n" + + "\x11ListWorkflowSteps\x12*.kandev.plugin.v1.ListWorkflowStepsRequest\x1a+.kandev.plugin.v1.ListWorkflowStepsResponse\x12l\n" + + "\x11ListAgentProfiles\x12*.kandev.plugin.v1.ListAgentProfilesRequest\x1a+.kandev.plugin.v1.ListAgentProfilesResponse\x12i\n" + + "\x10ListRepositories\x12).kandev.plugin.v1.ListRepositoriesRequest\x1a*.kandev.plugin.v1.ListRepositoriesResponse\x12]\n" + + "\fListSessions\x12%.kandev.plugin.v1.ListSessionsRequest\x1a&.kandev.plugin.v1.ListSessionsResponse\x12u\n" + + "\x14ListSessionCodeStats\x12-.kandev.plugin.v1.ListSessionCodeStatsRequest\x1a..kandev.plugin.v1.ListSessionCodeStatsResponse\x12W\n" + + "\n" + + "CreateTask\x12#.kandev.plugin.v1.CreateTaskRequest\x1a$.kandev.plugin.v1.CreateTaskResponse\x12W\n" + + "\n" + + "UpdateTask\x12#.kandev.plugin.v1.UpdateTaskRequest\x1a$.kandev.plugin.v1.UpdateTaskResponse\x12`\n" + + "\rCreateComment\x12&.kandev.plugin.v1.CreateCommentRequest\x1a'.kandev.plugin.v1.CreateCommentResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" var ( file_kandev_plugin_v1_plugin_proto_rawDescOnce sync.Once @@ -1212,67 +3869,160 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 22) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 60) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ - (*Event)(nil), // 0: kandev.plugin.v1.Event - (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck - (*ToolRequest)(nil), // 2: kandev.plugin.v1.ToolRequest - (*ToolContext)(nil), // 3: kandev.plugin.v1.ToolContext - (*ToolResponse)(nil), // 4: kandev.plugin.v1.ToolResponse - (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest - (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse - (*GetStateRequest)(nil), // 7: kandev.plugin.v1.GetStateRequest - (*GetStateResponse)(nil), // 8: kandev.plugin.v1.GetStateResponse - (*SetStateRequest)(nil), // 9: kandev.plugin.v1.SetStateRequest - (*SetStateResponse)(nil), // 10: kandev.plugin.v1.SetStateResponse - (*DeleteStateRequest)(nil), // 11: kandev.plugin.v1.DeleteStateRequest - (*DeleteStateResponse)(nil), // 12: kandev.plugin.v1.DeleteStateResponse - (*ListStateRequest)(nil), // 13: kandev.plugin.v1.ListStateRequest - (*ListStateResponse)(nil), // 14: kandev.plugin.v1.ListStateResponse - (*StateEntry)(nil), // 15: kandev.plugin.v1.StateEntry - (*RevealSecretRequest)(nil), // 16: kandev.plugin.v1.RevealSecretRequest - (*RevealSecretResponse)(nil), // 17: kandev.plugin.v1.RevealSecretResponse - (*EmitEventRequest)(nil), // 18: kandev.plugin.v1.EmitEventRequest - (*EmitEventResponse)(nil), // 19: kandev.plugin.v1.EmitEventResponse - nil, // 20: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 21: kandev.plugin.v1.WebhookResponse.HeadersEntry - (*structpb.Struct)(nil), // 22: google.protobuf.Struct + (*Event)(nil), // 0: kandev.plugin.v1.Event + (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck + (*ToolRequest)(nil), // 2: kandev.plugin.v1.ToolRequest + (*ToolContext)(nil), // 3: kandev.plugin.v1.ToolContext + (*ToolResponse)(nil), // 4: kandev.plugin.v1.ToolResponse + (*WebhookRequest)(nil), // 5: kandev.plugin.v1.WebhookRequest + (*WebhookResponse)(nil), // 6: kandev.plugin.v1.WebhookResponse + (*GetStateRequest)(nil), // 7: kandev.plugin.v1.GetStateRequest + (*GetStateResponse)(nil), // 8: kandev.plugin.v1.GetStateResponse + (*SetStateRequest)(nil), // 9: kandev.plugin.v1.SetStateRequest + (*SetStateResponse)(nil), // 10: kandev.plugin.v1.SetStateResponse + (*DeleteStateRequest)(nil), // 11: kandev.plugin.v1.DeleteStateRequest + (*DeleteStateResponse)(nil), // 12: kandev.plugin.v1.DeleteStateResponse + (*ListStateRequest)(nil), // 13: kandev.plugin.v1.ListStateRequest + (*ListStateResponse)(nil), // 14: kandev.plugin.v1.ListStateResponse + (*StateEntry)(nil), // 15: kandev.plugin.v1.StateEntry + (*RevealSecretRequest)(nil), // 16: kandev.plugin.v1.RevealSecretRequest + (*RevealSecretResponse)(nil), // 17: kandev.plugin.v1.RevealSecretResponse + (*EmitEventRequest)(nil), // 18: kandev.plugin.v1.EmitEventRequest + (*EmitEventResponse)(nil), // 19: kandev.plugin.v1.EmitEventResponse + (*Page)(nil), // 20: kandev.plugin.v1.Page + (*PageInfo)(nil), // 21: kandev.plugin.v1.PageInfo + (*Task)(nil), // 22: kandev.plugin.v1.Task + (*TaskRepository)(nil), // 23: kandev.plugin.v1.TaskRepository + (*TaskFilter)(nil), // 24: kandev.plugin.v1.TaskFilter + (*ListTasksRequest)(nil), // 25: kandev.plugin.v1.ListTasksRequest + (*ListTasksResponse)(nil), // 26: kandev.plugin.v1.ListTasksResponse + (*GetTaskRequest)(nil), // 27: kandev.plugin.v1.GetTaskRequest + (*GetTaskResponse)(nil), // 28: kandev.plugin.v1.GetTaskResponse + (*Workspace)(nil), // 29: kandev.plugin.v1.Workspace + (*ListWorkspacesRequest)(nil), // 30: kandev.plugin.v1.ListWorkspacesRequest + (*ListWorkspacesResponse)(nil), // 31: kandev.plugin.v1.ListWorkspacesResponse + (*Workflow)(nil), // 32: kandev.plugin.v1.Workflow + (*ListWorkflowsRequest)(nil), // 33: kandev.plugin.v1.ListWorkflowsRequest + (*ListWorkflowsResponse)(nil), // 34: kandev.plugin.v1.ListWorkflowsResponse + (*WorkflowStep)(nil), // 35: kandev.plugin.v1.WorkflowStep + (*ListWorkflowStepsRequest)(nil), // 36: kandev.plugin.v1.ListWorkflowStepsRequest + (*ListWorkflowStepsResponse)(nil), // 37: kandev.plugin.v1.ListWorkflowStepsResponse + (*AgentProfile)(nil), // 38: kandev.plugin.v1.AgentProfile + (*ListAgentProfilesRequest)(nil), // 39: kandev.plugin.v1.ListAgentProfilesRequest + (*ListAgentProfilesResponse)(nil), // 40: kandev.plugin.v1.ListAgentProfilesResponse + (*Repository)(nil), // 41: kandev.plugin.v1.Repository + (*ListRepositoriesRequest)(nil), // 42: kandev.plugin.v1.ListRepositoriesRequest + (*ListRepositoriesResponse)(nil), // 43: kandev.plugin.v1.ListRepositoriesResponse + (*Session)(nil), // 44: kandev.plugin.v1.Session + (*SessionFilter)(nil), // 45: kandev.plugin.v1.SessionFilter + (*ListSessionsRequest)(nil), // 46: kandev.plugin.v1.ListSessionsRequest + (*ListSessionsResponse)(nil), // 47: kandev.plugin.v1.ListSessionsResponse + (*SessionCodeStats)(nil), // 48: kandev.plugin.v1.SessionCodeStats + (*ListSessionCodeStatsRequest)(nil), // 49: kandev.plugin.v1.ListSessionCodeStatsRequest + (*ListSessionCodeStatsResponse)(nil), // 50: kandev.plugin.v1.ListSessionCodeStatsResponse + (*CreateTaskRequest)(nil), // 51: kandev.plugin.v1.CreateTaskRequest + (*CreateTaskResponse)(nil), // 52: kandev.plugin.v1.CreateTaskResponse + (*UpdateTaskRequest)(nil), // 53: kandev.plugin.v1.UpdateTaskRequest + (*UpdateTaskResponse)(nil), // 54: kandev.plugin.v1.UpdateTaskResponse + (*Comment)(nil), // 55: kandev.plugin.v1.Comment + (*CreateCommentRequest)(nil), // 56: kandev.plugin.v1.CreateCommentRequest + (*CreateCommentResponse)(nil), // 57: kandev.plugin.v1.CreateCommentResponse + nil, // 58: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 59: kandev.plugin.v1.WebhookResponse.HeadersEntry + (*structpb.Struct)(nil), // 60: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 22, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 22, // 1: kandev.plugin.v1.ToolRequest.input:type_name -> google.protobuf.Struct + 60, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 60, // 1: kandev.plugin.v1.ToolRequest.input:type_name -> google.protobuf.Struct 3, // 2: kandev.plugin.v1.ToolRequest.context:type_name -> kandev.plugin.v1.ToolContext - 22, // 3: kandev.plugin.v1.ToolResponse.output:type_name -> google.protobuf.Struct - 20, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 21, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry - 22, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 22, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 60, // 3: kandev.plugin.v1.ToolResponse.output:type_name -> google.protobuf.Struct + 58, // 4: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 59, // 5: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 60, // 6: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 60, // 7: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct 15, // 8: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 22, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 22, // 10: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct - 0, // 11: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 2, // 12: kandev.plugin.v1.Plugin.InvokeTool:input_type -> kandev.plugin.v1.ToolRequest - 5, // 13: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 7, // 14: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 9, // 15: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 11, // 16: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 13, // 17: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 16, // 18: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 18, // 19: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 1, // 20: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 4, // 21: kandev.plugin.v1.Plugin.InvokeTool:output_type -> kandev.plugin.v1.ToolResponse - 6, // 22: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 8, // 23: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 10, // 24: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 12, // 25: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 14, // 26: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 17, // 27: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 19, // 28: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 20, // [20:29] is the sub-list for method output_type - 11, // [11:20] is the sub-list for method input_type - 11, // [11:11] is the sub-list for extension type_name - 11, // [11:11] is the sub-list for extension extendee - 0, // [0:11] is the sub-list for field type_name + 60, // 9: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 60, // 10: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 23, // 11: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository + 60, // 12: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 24, // 13: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter + 20, // 14: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page + 22, // 15: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task + 21, // 16: kandev.plugin.v1.ListTasksResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 22, // 17: kandev.plugin.v1.GetTaskResponse.task:type_name -> kandev.plugin.v1.Task + 20, // 18: kandev.plugin.v1.ListWorkspacesRequest.page:type_name -> kandev.plugin.v1.Page + 29, // 19: kandev.plugin.v1.ListWorkspacesResponse.workspaces:type_name -> kandev.plugin.v1.Workspace + 21, // 20: kandev.plugin.v1.ListWorkspacesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 20, // 21: kandev.plugin.v1.ListWorkflowsRequest.page:type_name -> kandev.plugin.v1.Page + 32, // 22: kandev.plugin.v1.ListWorkflowsResponse.workflows:type_name -> kandev.plugin.v1.Workflow + 21, // 23: kandev.plugin.v1.ListWorkflowsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 35, // 24: kandev.plugin.v1.ListWorkflowStepsResponse.steps:type_name -> kandev.plugin.v1.WorkflowStep + 20, // 25: kandev.plugin.v1.ListAgentProfilesRequest.page:type_name -> kandev.plugin.v1.Page + 38, // 26: kandev.plugin.v1.ListAgentProfilesResponse.profiles:type_name -> kandev.plugin.v1.AgentProfile + 21, // 27: kandev.plugin.v1.ListAgentProfilesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 20, // 28: kandev.plugin.v1.ListRepositoriesRequest.page:type_name -> kandev.plugin.v1.Page + 41, // 29: kandev.plugin.v1.ListRepositoriesResponse.repositories:type_name -> kandev.plugin.v1.Repository + 21, // 30: kandev.plugin.v1.ListRepositoriesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 45, // 31: kandev.plugin.v1.ListSessionsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 20, // 32: kandev.plugin.v1.ListSessionsRequest.page:type_name -> kandev.plugin.v1.Page + 44, // 33: kandev.plugin.v1.ListSessionsResponse.sessions:type_name -> kandev.plugin.v1.Session + 21, // 34: kandev.plugin.v1.ListSessionsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 45, // 35: kandev.plugin.v1.ListSessionCodeStatsRequest.filter:type_name -> kandev.plugin.v1.SessionFilter + 20, // 36: kandev.plugin.v1.ListSessionCodeStatsRequest.page:type_name -> kandev.plugin.v1.Page + 48, // 37: kandev.plugin.v1.ListSessionCodeStatsResponse.stats:type_name -> kandev.plugin.v1.SessionCodeStats + 21, // 38: kandev.plugin.v1.ListSessionCodeStatsResponse.page_info:type_name -> kandev.plugin.v1.PageInfo + 22, // 39: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 22, // 40: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task + 55, // 41: kandev.plugin.v1.CreateCommentResponse.comment:type_name -> kandev.plugin.v1.Comment + 0, // 42: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 2, // 43: kandev.plugin.v1.Plugin.InvokeTool:input_type -> kandev.plugin.v1.ToolRequest + 5, // 44: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 7, // 45: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 9, // 46: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 11, // 47: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 13, // 48: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 16, // 49: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 18, // 50: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 25, // 51: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 27, // 52: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 30, // 53: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 33, // 54: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 36, // 55: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 39, // 56: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 42, // 57: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 46, // 58: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 49, // 59: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 51, // 60: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 53, // 61: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 56, // 62: kandev.plugin.v1.Host.CreateComment:input_type -> kandev.plugin.v1.CreateCommentRequest + 1, // 63: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 4, // 64: kandev.plugin.v1.Plugin.InvokeTool:output_type -> kandev.plugin.v1.ToolResponse + 6, // 65: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 8, // 66: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 10, // 67: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 12, // 68: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 14, // 69: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 17, // 70: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 19, // 71: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 26, // 72: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 28, // 73: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 31, // 74: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 34, // 75: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 37, // 76: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 40, // 77: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 43, // 78: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 47, // 79: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 50, // 80: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 52, // 81: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 54, // 82: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 57, // 83: kandev.plugin.v1.Host.CreateComment:output_type -> kandev.plugin.v1.CreateCommentResponse + 63, // [63:84] is the sub-list for method output_type + 42, // [42:63] is the sub-list for method input_type + 42, // [42:42] is the sub-list for extension type_name + 42, // [42:42] is the sub-list for extension extendee + 0, // [0:42] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -1280,13 +4030,21 @@ func file_kandev_plugin_v1_plugin_proto_init() { if File_kandev_plugin_v1_plugin_proto != nil { return } + file_kandev_plugin_v1_plugin_proto_msgTypes[22].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[24].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[29].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[32].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[41].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[44].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[51].OneofWrappers = []any{} + file_kandev_plugin_v1_plugin_proto_msgTypes[53].OneofWrappers = []any{} type x struct{} out := protoimpl.TypeBuilder{ File: protoimpl.DescBuilder{ GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 22, + NumMessages: 60, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index f3c594c6f9..1361b16518 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -14,7 +14,28 @@ service Plugin { } // Implemented by KANDEV (served back over the go-plugin broker). -// Every RPC is capability-gated server-side (§5). +// Reads (§5) are capability-gated server-side by an inline per-resource +// check at the start of each handler — there is no unary interceptor +// enforcing this centrally. The deferred write RPCs below currently return +// gRPC Unimplemented unconditionally, regardless of the caller's declared +// capabilities, until their handlers land. +// +// Host data API (ADR 0043): the read/write data RPCs below live on this same +// `service Host` rather than a separate `service HostData`. They reuse the +// single broker connection; splitting into a second service would only +// duplicate that for no benefit. Reads require manifest capability +// `api_read:`; writes require `api_write:` (e.g. +// `api_read:tasks`, `api_write:comments`). An undeclared capability on a read +// RPC returns gRPC PermissionDenied with `capability 'api_read:tasks' not +// declared`. +// +// DTOs below are a HAND-DEFINED public contract: the backend maps internal +// models → these messages explicitly, never generates them from domain +// structs, and never marshals domain structs through google.protobuf.Struct. +// Fields are additive-only after merge (ADR 0043); removing or renaming a +// field is a breaking change requiring a new api_version. Read handlers call +// the relevant service (never a repository directly); write handlers call +// service methods that publish task.* events (never repository.TaskRepository). service Host { rpc GetState(GetStateRequest) returns (GetStateResponse); rpc SetState(SetStateRequest) returns (SetStateResponse); @@ -22,6 +43,31 @@ service Host { rpc ListState(ListStateRequest) returns (ListStateResponse); rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); + + // Reads — capability api_read: + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse); + rpc GetTask(GetTaskRequest) returns (GetTaskResponse); + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse); + rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse); + rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse); + rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse); + + // Sessions + code stats — capability api_read:sessions. Driven by a real + // plugin (kandev-plugin-agent-stats) that otherwise read task_sessions, + // task_session_commits, and task_session_git_snapshots straight from the + // SQLite file. SessionCodeStats is a COMPUTED, stable DTO — deliberately + // not the raw commit/snapshot rows, whose JSON schema churns. + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse); + + // Writes — capability api_write:. Declared now for a stable + // contract; handlers land in a later phase (not implemented by this RPC + // addition). Route through service methods so the corresponding task.* + // events fire — the whole reason not to let plugins write the DB. + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse); + rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse); + rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse); } message Event { @@ -110,3 +156,246 @@ message EmitEventRequest { google.protobuf.Struct payload = 2; } message EmitEventResponse {} + +// ── Host data API (ADR 0043) ───────────────────────────────────────────────── +// Pagination is opaque-cursor based so the server can change the underlying +// ordering/store without breaking plugins. +message Page { + int32 limit = 1; // 0 → server default (e.g. 50); server caps the max + string cursor = 2; // empty → first page; echo next_cursor to continue +} +message PageInfo { + string next_cursor = 1; + bool has_more = 2; +} + +// Timestamps are RFC3339 strings (matches the JSON API + the Event envelope +// convention above), not protobuf Timestamp, to keep one time representation +// across the whole plugin contract. Optional string fields use `optional` so +// absent (NULL) is distinguishable from empty. + +// ── Task ──────────────────────────────────────────────────────────────────── +message Task { + string id = 1; + string workspace_id = 2; + string workflow_id = 3; + string title = 4; + string description = 5; + string state = 6; // TaskState enum value as a string, stable + string priority = 7; + string created_by = 8; + string created_at = 9; // RFC3339 + string updated_at = 10; + optional string started_at = 11; + optional string completed_at = 12; + optional string parent_id = 13; + string identifier = 14; + bool is_ephemeral = 15; + repeated TaskRepository repositories = 16; + google.protobuf.Struct metadata = 17; // free-form; plugin-tolerant +} +message TaskRepository { + string id = 1; + string repository_id = 2; + string base_branch = 3; + int32 position = 4; +} + +message TaskFilter { + repeated string workspace_ids = 1; // empty → all the plugin may see + repeated string workflow_ids = 2; + repeated string states = 3; + optional string parent_id = 4; + bool include_ephemeral = 5; // default false (kanban-visible only) +} +message ListTasksRequest { + TaskFilter filter = 1; + Page page = 2; +} +message ListTasksResponse { + repeated Task tasks = 1; + PageInfo page_info = 2; +} +message GetTaskRequest { + string id = 1; +} +message GetTaskResponse { + Task task = 1; +} + +// ── Workspace / Workflow / Steps ───────────────────────────────────────────── +message Workspace { + string id = 1; + string name = 2; + optional string description = 3; + string owner_id = 4; + optional string default_executor_id = 5; + optional string default_agent_profile_id = 6; + string created_at = 7; + string updated_at = 8; +} +message ListWorkspacesRequest { + Page page = 1; +} +message ListWorkspacesResponse { + repeated Workspace workspaces = 1; + PageInfo page_info = 2; +} + +message Workflow { + string id = 1; + string workspace_id = 2; + string name = 3; + optional string description = 4; + int32 sort_order = 5; + string created_at = 6; + string updated_at = 7; +} +message ListWorkflowsRequest { + string workspace_id = 1; + Page page = 2; +} +message ListWorkflowsResponse { + repeated Workflow workflows = 1; + PageInfo page_info = 2; +} + +message WorkflowStep { + string id = 1; + string workflow_id = 2; + string name = 3; + int32 position = 4; + string stage_type = 5; // work | review | approval | custom +} +message ListWorkflowStepsRequest { + string workflow_id = 1; +} +message ListWorkflowStepsResponse { + repeated WorkflowStep steps = 1; +} + +// ── Agent profiles / Repositories (read-only context) ──────────────────────── +message AgentProfile { + string id = 1; + string agent_id = 2; + string display_name = 3; + string name = 4; + string model = 5; + string mode = 6; +} +message ListAgentProfilesRequest { + Page page = 1; +} +message ListAgentProfilesResponse { + repeated AgentProfile profiles = 1; + PageInfo page_info = 2; +} + +message Repository { + string id = 1; + string workspace_id = 2; + string name = 3; // owner/repo slug when known, else name + optional string default_branch = 4; +} +message ListRepositoriesRequest { + string workspace_id = 1; + Page page = 2; +} +message ListRepositoriesResponse { + repeated Repository repositories = 1; + PageInfo page_info = 2; +} + +// ── Sessions ───────────────────────────────────────────────────────────────── +// Identity + agent context for one agent run on a task. acp_session_id is the +// join key an external usage tool (e.g. tokscale) uses to attribute tokens — +// so kandev exposes it and stays out of the token business entirely. +// +// acp_session_id resolution (host-side, not the plugin's concern): read +// session metadata key `$.acp.session_id` first; if absent, fall back to +// `executors_running.resume_token` for the session's agent execution. Either +// source may be empty for a session that never reached the ACP handshake, in +// which case acp_session_id is returned empty. +message Session { + string id = 1; + string task_id = 2; + string agent_profile_id = 3; + string agent_display_name = 4; // from the profile snapshot at run time + string model = 5; // resolved model at run time + string acp_session_id = 6; // external usage-attribution join key + string state = 7; + string started_at = 8; + optional string ended_at = 9; + string agent_profile_name = 10; // profile name from the snapshot at run time +} +message SessionFilter { + repeated string task_ids = 1; + repeated string workspace_ids = 2; + repeated string states = 3; +} +message ListSessionsRequest { + SessionFilter filter = 1; + Page page = 2; +} +message ListSessionsResponse { + repeated Session sessions = 1; + PageInfo page_info = 2; +} + +// Computed code metrics per session — the aggregate the agent-stats plugin +// re-derived by hand from commits + git snapshots. Exposed as a stable shape +// so plugins never touch task_session_commits / task_session_git_snapshots. +message SessionCodeStats { + string session_id = 1; + int64 lines_added_committed = 2; // SUM of commit insertions + int64 lines_deleted_committed = 3; // SUM of commit deletions + int64 lines_added_peak_pending = 4; // peak uncommitted diff additions across snapshots + int64 lines_deleted_peak_pending = 5; +} +message ListSessionCodeStatsRequest { + SessionFilter filter = 1; + Page page = 2; +} +message ListSessionCodeStatsResponse { + repeated SessionCodeStats stats = 1; + PageInfo page_info = 2; +} + +// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +message CreateTaskRequest { + string workspace_id = 1; + string workflow_id = 2; + optional string workflow_step_id = 3; + string title = 4; + string description = 5; + optional string parent_id = 6; + bool start_agent = 7; + // kandev stamps source = "plugin:" server-side; not settable here. +} +message CreateTaskResponse { + Task task = 1; +} +message UpdateTaskRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + optional string state = 4; + optional string workflow_step_id = 5; +} +message UpdateTaskResponse { + Task task = 1; +} +message Comment { + string id = 1; + string task_id = 2; + string body = 3; + string source = 4; // "plugin:" + string created_at = 5; +} +message CreateCommentRequest { + string task_id = 1; + string body = 2; +} +message CreateCommentResponse { + Comment comment = 1; +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index b072991cad..020d710b49 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -201,12 +201,24 @@ var Plugin_ServiceDesc = grpc.ServiceDesc{ } const ( - Host_GetState_FullMethodName = "/kandev.plugin.v1.Host/GetState" - Host_SetState_FullMethodName = "/kandev.plugin.v1.Host/SetState" - Host_DeleteState_FullMethodName = "/kandev.plugin.v1.Host/DeleteState" - Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState" - Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret" - Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent" + Host_GetState_FullMethodName = "/kandev.plugin.v1.Host/GetState" + Host_SetState_FullMethodName = "/kandev.plugin.v1.Host/SetState" + Host_DeleteState_FullMethodName = "/kandev.plugin.v1.Host/DeleteState" + Host_ListState_FullMethodName = "/kandev.plugin.v1.Host/ListState" + Host_RevealSecret_FullMethodName = "/kandev.plugin.v1.Host/RevealSecret" + Host_EmitEvent_FullMethodName = "/kandev.plugin.v1.Host/EmitEvent" + Host_ListTasks_FullMethodName = "/kandev.plugin.v1.Host/ListTasks" + Host_GetTask_FullMethodName = "/kandev.plugin.v1.Host/GetTask" + Host_ListWorkspaces_FullMethodName = "/kandev.plugin.v1.Host/ListWorkspaces" + Host_ListWorkflows_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflows" + Host_ListWorkflowSteps_FullMethodName = "/kandev.plugin.v1.Host/ListWorkflowSteps" + Host_ListAgentProfiles_FullMethodName = "/kandev.plugin.v1.Host/ListAgentProfiles" + Host_ListRepositories_FullMethodName = "/kandev.plugin.v1.Host/ListRepositories" + Host_ListSessions_FullMethodName = "/kandev.plugin.v1.Host/ListSessions" + Host_ListSessionCodeStats_FullMethodName = "/kandev.plugin.v1.Host/ListSessionCodeStats" + Host_CreateTask_FullMethodName = "/kandev.plugin.v1.Host/CreateTask" + Host_UpdateTask_FullMethodName = "/kandev.plugin.v1.Host/UpdateTask" + Host_CreateComment_FullMethodName = "/kandev.plugin.v1.Host/CreateComment" ) // HostClient is the client API for Host service. @@ -214,7 +226,28 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. // // Implemented by KANDEV (served back over the go-plugin broker). -// Every RPC is capability-gated server-side (§5). +// Reads (§5) are capability-gated server-side by an inline per-resource +// check at the start of each handler — there is no unary interceptor +// enforcing this centrally. The deferred write RPCs below currently return +// gRPC Unimplemented unconditionally, regardless of the caller's declared +// capabilities, until their handlers land. +// +// Host data API (ADR 0043): the read/write data RPCs below live on this same +// `service Host` rather than a separate `service HostData`. They reuse the +// single broker connection; splitting into a second service would only +// duplicate that for no benefit. Reads require manifest capability +// `api_read:`; writes require `api_write:` (e.g. +// `api_read:tasks`, `api_write:comments`). An undeclared capability on a read +// RPC returns gRPC PermissionDenied with `capability 'api_read:tasks' not +// declared`. +// +// DTOs below are a HAND-DEFINED public contract: the backend maps internal +// models → these messages explicitly, never generates them from domain +// structs, and never marshals domain structs through google.protobuf.Struct. +// Fields are additive-only after merge (ADR 0043); removing or renaming a +// field is a breaking change requiring a new api_version. Read handlers call +// the relevant service (never a repository directly); write handlers call +// service methods that publish task.* events (never repository.TaskRepository). type HostClient interface { GetState(ctx context.Context, in *GetStateRequest, opts ...grpc.CallOption) (*GetStateResponse, error) SetState(ctx context.Context, in *SetStateRequest, opts ...grpc.CallOption) (*SetStateResponse, error) @@ -222,6 +255,28 @@ type HostClient interface { ListState(ctx context.Context, in *ListStateRequest, opts ...grpc.CallOption) (*ListStateResponse, error) RevealSecret(ctx context.Context, in *RevealSecretRequest, opts ...grpc.CallOption) (*RevealSecretResponse, error) EmitEvent(ctx context.Context, in *EmitEventRequest, opts ...grpc.CallOption) (*EmitEventResponse, error) + // Reads — capability api_read: + ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) + GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*GetTaskResponse, error) + ListWorkspaces(ctx context.Context, in *ListWorkspacesRequest, opts ...grpc.CallOption) (*ListWorkspacesResponse, error) + ListWorkflows(ctx context.Context, in *ListWorkflowsRequest, opts ...grpc.CallOption) (*ListWorkflowsResponse, error) + ListWorkflowSteps(ctx context.Context, in *ListWorkflowStepsRequest, opts ...grpc.CallOption) (*ListWorkflowStepsResponse, error) + ListAgentProfiles(ctx context.Context, in *ListAgentProfilesRequest, opts ...grpc.CallOption) (*ListAgentProfilesResponse, error) + ListRepositories(ctx context.Context, in *ListRepositoriesRequest, opts ...grpc.CallOption) (*ListRepositoriesResponse, error) + // Sessions + code stats — capability api_read:sessions. Driven by a real + // plugin (kandev-plugin-agent-stats) that otherwise read task_sessions, + // task_session_commits, and task_session_git_snapshots straight from the + // SQLite file. SessionCodeStats is a COMPUTED, stable DTO — deliberately + // not the raw commit/snapshot rows, whose JSON schema churns. + ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) + ListSessionCodeStats(ctx context.Context, in *ListSessionCodeStatsRequest, opts ...grpc.CallOption) (*ListSessionCodeStatsResponse, error) + // Writes — capability api_write:. Declared now for a stable + // contract; handlers land in a later phase (not implemented by this RPC + // addition). Route through service methods so the corresponding task.* + // events fire — the whole reason not to let plugins write the DB. + CreateTask(ctx context.Context, in *CreateTaskRequest, opts ...grpc.CallOption) (*CreateTaskResponse, error) + UpdateTask(ctx context.Context, in *UpdateTaskRequest, opts ...grpc.CallOption) (*UpdateTaskResponse, error) + CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error) } type hostClient struct { @@ -292,12 +347,153 @@ func (c *hostClient) EmitEvent(ctx context.Context, in *EmitEventRequest, opts . return out, nil } +func (c *hostClient) ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListTasksResponse) + err := c.cc.Invoke(ctx, Host_ListTasks_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*GetTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetTaskResponse) + err := c.cc.Invoke(ctx, Host_GetTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListWorkspaces(ctx context.Context, in *ListWorkspacesRequest, opts ...grpc.CallOption) (*ListWorkspacesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWorkspacesResponse) + err := c.cc.Invoke(ctx, Host_ListWorkspaces_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListWorkflows(ctx context.Context, in *ListWorkflowsRequest, opts ...grpc.CallOption) (*ListWorkflowsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWorkflowsResponse) + err := c.cc.Invoke(ctx, Host_ListWorkflows_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListWorkflowSteps(ctx context.Context, in *ListWorkflowStepsRequest, opts ...grpc.CallOption) (*ListWorkflowStepsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListWorkflowStepsResponse) + err := c.cc.Invoke(ctx, Host_ListWorkflowSteps_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListAgentProfiles(ctx context.Context, in *ListAgentProfilesRequest, opts ...grpc.CallOption) (*ListAgentProfilesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListAgentProfilesResponse) + err := c.cc.Invoke(ctx, Host_ListAgentProfiles_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListRepositories(ctx context.Context, in *ListRepositoriesRequest, opts ...grpc.CallOption) (*ListRepositoriesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListRepositoriesResponse) + err := c.cc.Invoke(ctx, Host_ListRepositories_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListSessions(ctx context.Context, in *ListSessionsRequest, opts ...grpc.CallOption) (*ListSessionsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSessionsResponse) + err := c.cc.Invoke(ctx, Host_ListSessions_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) ListSessionCodeStats(ctx context.Context, in *ListSessionCodeStatsRequest, opts ...grpc.CallOption) (*ListSessionCodeStatsResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(ListSessionCodeStatsResponse) + err := c.cc.Invoke(ctx, Host_ListSessionCodeStats_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) CreateTask(ctx context.Context, in *CreateTaskRequest, opts ...grpc.CallOption) (*CreateTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateTaskResponse) + err := c.cc.Invoke(ctx, Host_CreateTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) UpdateTask(ctx context.Context, in *UpdateTaskRequest, opts ...grpc.CallOption) (*UpdateTaskResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(UpdateTaskResponse) + err := c.cc.Invoke(ctx, Host_UpdateTask_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *hostClient) CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(CreateCommentResponse) + err := c.cc.Invoke(ctx, Host_CreateComment_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + // HostServer is the server API for Host service. // All implementations must embed UnimplementedHostServer // for forward compatibility. // // Implemented by KANDEV (served back over the go-plugin broker). -// Every RPC is capability-gated server-side (§5). +// Reads (§5) are capability-gated server-side by an inline per-resource +// check at the start of each handler — there is no unary interceptor +// enforcing this centrally. The deferred write RPCs below currently return +// gRPC Unimplemented unconditionally, regardless of the caller's declared +// capabilities, until their handlers land. +// +// Host data API (ADR 0043): the read/write data RPCs below live on this same +// `service Host` rather than a separate `service HostData`. They reuse the +// single broker connection; splitting into a second service would only +// duplicate that for no benefit. Reads require manifest capability +// `api_read:`; writes require `api_write:` (e.g. +// `api_read:tasks`, `api_write:comments`). An undeclared capability on a read +// RPC returns gRPC PermissionDenied with `capability 'api_read:tasks' not +// declared`. +// +// DTOs below are a HAND-DEFINED public contract: the backend maps internal +// models → these messages explicitly, never generates them from domain +// structs, and never marshals domain structs through google.protobuf.Struct. +// Fields are additive-only after merge (ADR 0043); removing or renaming a +// field is a breaking change requiring a new api_version. Read handlers call +// the relevant service (never a repository directly); write handlers call +// service methods that publish task.* events (never repository.TaskRepository). type HostServer interface { GetState(context.Context, *GetStateRequest) (*GetStateResponse, error) SetState(context.Context, *SetStateRequest) (*SetStateResponse, error) @@ -305,6 +501,28 @@ type HostServer interface { ListState(context.Context, *ListStateRequest) (*ListStateResponse, error) RevealSecret(context.Context, *RevealSecretRequest) (*RevealSecretResponse, error) EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error) + // Reads — capability api_read: + ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) + GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error) + ListWorkspaces(context.Context, *ListWorkspacesRequest) (*ListWorkspacesResponse, error) + ListWorkflows(context.Context, *ListWorkflowsRequest) (*ListWorkflowsResponse, error) + ListWorkflowSteps(context.Context, *ListWorkflowStepsRequest) (*ListWorkflowStepsResponse, error) + ListAgentProfiles(context.Context, *ListAgentProfilesRequest) (*ListAgentProfilesResponse, error) + ListRepositories(context.Context, *ListRepositoriesRequest) (*ListRepositoriesResponse, error) + // Sessions + code stats — capability api_read:sessions. Driven by a real + // plugin (kandev-plugin-agent-stats) that otherwise read task_sessions, + // task_session_commits, and task_session_git_snapshots straight from the + // SQLite file. SessionCodeStats is a COMPUTED, stable DTO — deliberately + // not the raw commit/snapshot rows, whose JSON schema churns. + ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) + ListSessionCodeStats(context.Context, *ListSessionCodeStatsRequest) (*ListSessionCodeStatsResponse, error) + // Writes — capability api_write:. Declared now for a stable + // contract; handlers land in a later phase (not implemented by this RPC + // addition). Route through service methods so the corresponding task.* + // events fire — the whole reason not to let plugins write the DB. + CreateTask(context.Context, *CreateTaskRequest) (*CreateTaskResponse, error) + UpdateTask(context.Context, *UpdateTaskRequest) (*UpdateTaskResponse, error) + CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error) mustEmbedUnimplementedHostServer() } @@ -333,6 +551,42 @@ func (UnimplementedHostServer) RevealSecret(context.Context, *RevealSecretReques func (UnimplementedHostServer) EmitEvent(context.Context, *EmitEventRequest) (*EmitEventResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method EmitEvent not implemented") } +func (UnimplementedHostServer) ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTasks not implemented") +} +func (UnimplementedHostServer) GetTask(context.Context, *GetTaskRequest) (*GetTaskResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTask not implemented") +} +func (UnimplementedHostServer) ListWorkspaces(context.Context, *ListWorkspacesRequest) (*ListWorkspacesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListWorkspaces not implemented") +} +func (UnimplementedHostServer) ListWorkflows(context.Context, *ListWorkflowsRequest) (*ListWorkflowsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListWorkflows not implemented") +} +func (UnimplementedHostServer) ListWorkflowSteps(context.Context, *ListWorkflowStepsRequest) (*ListWorkflowStepsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListWorkflowSteps not implemented") +} +func (UnimplementedHostServer) ListAgentProfiles(context.Context, *ListAgentProfilesRequest) (*ListAgentProfilesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListAgentProfiles not implemented") +} +func (UnimplementedHostServer) ListRepositories(context.Context, *ListRepositoriesRequest) (*ListRepositoriesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListRepositories not implemented") +} +func (UnimplementedHostServer) ListSessions(context.Context, *ListSessionsRequest) (*ListSessionsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSessions not implemented") +} +func (UnimplementedHostServer) ListSessionCodeStats(context.Context, *ListSessionCodeStatsRequest) (*ListSessionCodeStatsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListSessionCodeStats not implemented") +} +func (UnimplementedHostServer) CreateTask(context.Context, *CreateTaskRequest) (*CreateTaskResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateTask not implemented") +} +func (UnimplementedHostServer) UpdateTask(context.Context, *UpdateTaskRequest) (*UpdateTaskResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method UpdateTask not implemented") +} +func (UnimplementedHostServer) CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateComment not implemented") +} func (UnimplementedHostServer) mustEmbedUnimplementedHostServer() {} func (UnimplementedHostServer) testEmbeddedByValue() {} @@ -462,6 +716,222 @@ func _Host_EmitEvent_Handler(srv interface{}, ctx context.Context, dec func(inte return interceptor(ctx, in, info, handler) } +func _Host_ListTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTasksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListTasks_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListTasks(ctx, req.(*ListTasksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_GetTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).GetTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_GetTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).GetTask(ctx, req.(*GetTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListWorkspaces_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWorkspacesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListWorkspaces(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListWorkspaces_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListWorkspaces(ctx, req.(*ListWorkspacesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListWorkflows_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWorkflowsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListWorkflows(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListWorkflows_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListWorkflows(ctx, req.(*ListWorkflowsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListWorkflowSteps_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListWorkflowStepsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListWorkflowSteps(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListWorkflowSteps_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListWorkflowSteps(ctx, req.(*ListWorkflowStepsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListAgentProfiles_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListAgentProfilesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListAgentProfiles(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListAgentProfiles_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListAgentProfiles(ctx, req.(*ListAgentProfilesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListRepositories_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListRepositoriesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListRepositories(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListRepositories_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListRepositories(ctx, req.(*ListRepositoriesRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListSessions_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSessionsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListSessions(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListSessions_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListSessions(ctx, req.(*ListSessionsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_ListSessionCodeStats_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListSessionCodeStatsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).ListSessionCodeStats(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_ListSessionCodeStats_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).ListSessionCodeStats(ctx, req.(*ListSessionCodeStatsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_CreateTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).CreateTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_CreateTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).CreateTask(ctx, req.(*CreateTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_UpdateTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UpdateTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).UpdateTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_UpdateTask_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).UpdateTask(ctx, req.(*UpdateTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Host_CreateComment_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CreateCommentRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(HostServer).CreateComment(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Host_CreateComment_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(HostServer).CreateComment(ctx, req.(*CreateCommentRequest)) + } + return interceptor(ctx, in, info, handler) +} + // Host_ServiceDesc is the grpc.ServiceDesc for Host service. // It's only intended for direct use with grpc.RegisterService, // and not to be introspected or modified (even as a copy) @@ -493,6 +963,54 @@ var Host_ServiceDesc = grpc.ServiceDesc{ MethodName: "EmitEvent", Handler: _Host_EmitEvent_Handler, }, + { + MethodName: "ListTasks", + Handler: _Host_ListTasks_Handler, + }, + { + MethodName: "GetTask", + Handler: _Host_GetTask_Handler, + }, + { + MethodName: "ListWorkspaces", + Handler: _Host_ListWorkspaces_Handler, + }, + { + MethodName: "ListWorkflows", + Handler: _Host_ListWorkflows_Handler, + }, + { + MethodName: "ListWorkflowSteps", + Handler: _Host_ListWorkflowSteps_Handler, + }, + { + MethodName: "ListAgentProfiles", + Handler: _Host_ListAgentProfiles_Handler, + }, + { + MethodName: "ListRepositories", + Handler: _Host_ListRepositories_Handler, + }, + { + MethodName: "ListSessions", + Handler: _Host_ListSessions_Handler, + }, + { + MethodName: "ListSessionCodeStats", + Handler: _Host_ListSessionCodeStats_Handler, + }, + { + MethodName: "CreateTask", + Handler: _Host_CreateTask_Handler, + }, + { + MethodName: "UpdateTask", + Handler: _Host_UpdateTask_Handler, + }, + { + MethodName: "CreateComment", + Handler: _Host_CreateComment_Handler, + }, }, Streams: []grpc.StreamDesc{}, Metadata: "kandev/plugin/v1/plugin.proto", diff --git a/docs/decisions/0043-plugin-host-data-api.md b/docs/decisions/0043-plugin-host-data-api.md new file mode 100644 index 0000000000..9a0edc503c --- /dev/null +++ b/docs/decisions/0043-plugin-host-data-api.md @@ -0,0 +1,160 @@ +# 0043 — Plugins read/write kandev data via capability-gated Host gRPC RPCs + +- Status: accepted +- Date: 2026-07-17 +- Area: backend, protocol +- Related: [docs/specs/plugins/spec.md](../specs/plugins/spec.md) ("Host data API"), + [docs/plans/plugins/GRPC-CONTRACT.md](../plans/plugins/GRPC-CONTRACT.md), + [docs/plans/plugins/host-data-api/plan.md](../plans/plugins/host-data-api/plan.md) + +## Context + +The plugin system (spec `docs/specs/plugins/spec.md`) gives plugins a +capability-gated `Host` gRPC service — `GetState`/`SetState`/`DeleteState`/ +`ListState`/`RevealSecret`/`EmitEvent` — but **no sanctioned way to read or write +kandev's own domain data** (tasks, sessions, workspaces, workflows, agent +profiles, repositories, comments). The manifest already reserves +`capabilities.api_read` / `capabilities.api_write` (string lists) for this, and +`manifest.CanRead(resource)` / `CanWrite(resource)` already exist, but no Host +RPC consumes them. + +A real plugin exposed the gap. `kandev-plugin-agent-stats` +(`/home/jcfs/.kandev/tasks/tokens-per-task-loc_9tk/kandev-plugin-agent-stats/`) +needs per-session lines-of-code plus each session's agent/model/`acp_session_id` +to attribute externally-measured token usage (from `tokscale`, keyed by +`acp_session_id`) to agents. With no data API, it **opens the kandev SQLite file +directly, read-only** (`~/.kandev/data/kandev.db`) and runs SQL against +`task_sessions`, `task_session_commits`, `task_session_git_snapshots`, and +`executors_running`. + +Direct DB access is only possible because the backend spawns plugin subprocesses +unsandboxed on the same host, and the plugin can guess the default DB path. It is +wrong on every axis: + +- **Schema coupling.** The plugin's SQL hard-codes column names, the + `agent_profile_snapshot` JSON shape, and the `task_session_git_snapshots.files` + JSON shape. Any internal schema change silently breaks it. +- **No events on write.** kandev's convention (`apps/backend/CLAUDE.md`: "any code + path that mutates a task row must publish `task.*` events") cannot hold for a + plugin writing SQL — it would bypass the event bus and break WS-driven UI. +- **Breaks on Postgres.** kandev supports a Postgres backend; a plugin that opens + a SQLite file has no data there at all. +- **No auth or scoping.** The plugin reads every row in the database regardless of + what it declared or should see. + +We want a "carrot" that makes the sanctioned path strictly better than touching +the file, so we can then pursue the "stick" (env-scrubbing, not shipping the DB +path into the subprocess, sandboxing) without stranding a legitimate use case. + +## Decision + +Extend the existing `kandev.plugin.v1` `Host` service with typed data RPCs. +Plugins read (phase 1) and later write (phase 2) kandev data over the same +capability-gated Host gRPC channel they already use for state and secrets. They +never open the database file and never receive internal domain structs over the +wire. + +Concretely: + +1. **New RPCs on the Host surface.** Add read RPCs now — `ListTasks`, `GetTask`, + `ListWorkspaces`, `ListWorkflows`, `ListWorkflowSteps`, `ListAgentProfiles`, + `ListRepositories`, `ListSessions`, `ListSessionCodeStats` — and defer write + RPCs (`CreateTask`, `UpdateTask`, `CreateComment`) to phase 2. The frozen + draft lives at `docs/plans/plugins/HOST-DATA-API.proto`; it is folded into + `apps/backend/proto/kandev/plugin/v1/plugin.proto`. (Whether these land on the + existing `service Host` or a sibling `service HostData` served on the same + broker connection is an implementation detail settled in the plan; the wire + package and capability model are identical either way.) + +2. **Capability gating per resource.** Each read RPC requires + `api_read:` in the plugin's manifest; each write RPC requires + `api_write:` (e.g. `api_read:tasks`, `api_read:sessions`, + `api_write:tasks`, `api_write:comments`). Enforcement reuses the existing + per-plugin binding and `manifest.CanRead`/`CanWrite`; an undeclared capability + returns gRPC `PermissionDenied` with `capability 'api_read:tasks' not declared` + — identical to today's state/secrets gating. The gate lives at the RPC handler + (or the existing unary interceptor), not in the plugin. + +3. **Reads go through the service layer; writes go through service methods that + publish events.** Read handlers call the relevant service (`taskSvc`, + workflow service, analytics/session aggregation), never a repository directly, + so future access rules and derived fields stay in one place. Write handlers + call `Service.CreateTask` / `UpdateTask` / comment-create methods that publish + `task.*` events — never `repository.TaskRepository`. Publishing events is the + whole reason plugins must not write the DB. + +4. **Hand-mapped, versioned DTOs — never internal structs.** The proto messages + are a stable public contract. The backend maps internal models → these DTOs + with explicit code; that mapping *is* the decoupling. We do not marshal domain + structs through `structpb`, do not generate DTOs from models, and do not + expose raw table rows. `SessionCodeStats` is a deliberately **computed** shape + (committed insertions/deletions + peak pending-diff additions/deletions), + never the raw `task_session_commits` / `task_session_git_snapshots` rows whose + JSON schema churns. + +5. **Conventions across the contract.** + - **Pagination:** opaque cursor (`Page{limit, cursor}` → `PageInfo{next_cursor, + has_more}`), so the server can change ordering or store without breaking + plugins. + - **Timestamps:** RFC3339 strings, matching the `Event` envelope and the JSON + API — one time representation across the whole plugin contract. + - **Nullables:** proto `optional` so absent (NULL) is distinguishable from + empty. + - **Write provenance:** the server stamps `source = "plugin:"` on created + rows/comments; a plugin cannot set it. + +## Open decisions (recorded, with recommendation) + +- **(a) Workspace scoping.** Plugins are instance-global today (installed by the + operator, no per-user access). Reads could either require a `workspace_id` / + become workspace-scoped, or stay global with a filter hook. **Recommendation + for v1: global-with-hook** — reads return across all workspaces the instance + holds, filters (`workspace_ids`, `task_ids`) narrow results, and a single + server-side scoping hook is left in place so a future per-plugin or per-user + workspace restriction can be enforced without a contract change. Matches the + existing "plugins are global to the instance" permission model in the spec. + +- **(b) SessionCodeStats freshness.** Computed **on demand per request** in v1 + (one aggregation query per `ListSessionCodeStats` call, filtered and paginated), + not precomputed or cached. The agent-stats plugin already caches its own report + for 60s, so per-request compute is acceptable; a materialized/precomputed path + is future work if the query proves hot. + +## Consequences + +- Plugins get a first-class, stable data path; the agent-stats plugin is + rewritten to use it (`host.Sessions().List(...)` + + `host.Sessions().CodeStats(...)`) and stops opening the DB file — the proof the + carrot is sufficient. +- The proto is a public contract: DTO fields are additive-only thereafter; + removing or renaming a field is a breaking change requiring a new api_version. +- A **new read-only per-session LOC aggregation must be added** at the service/ + repository layer. `internal/analytics/repository/sqlite/stats.go` aggregates git + stats at workspace and per-repository granularity only; nothing computes + per-session committed LOC + peak pending-diff, and the peak-pending snapshot + aggregation (`MAX` over per-snapshot `SUM` of `json_each(files).additions`) does + not exist in-tree. This is the one net-new query the decision requires. +- Enables the security "stick": once the sanctioned path exists, we can scrub + `KANDEV_*` env from the plugin subprocess, stop making the DB path derivable, + and pursue sandboxing without breaking a real plugin. Tracked as the isolation + follow-ups referenced from the plugin-system buildout. +- Write RPCs inherit kandev's event-publishing invariant for free by routing + through service methods; no plugin can mutate a task without `task.*` firing. + +## Alternatives considered + +- **Raw DB access (status quo).** What the agent-stats plugin does today. + Rejected: schema coupling, no write events, breaks on Postgres, no auth/scoping, + and only possible because the subprocess is unsandboxed. This is the failure the + ADR exists to close. +- **REST passthrough with the plugin's own HTTP client.** Let plugins call + kandev's HTTP API. Rejected: reintroduces credential/auth machinery the gRPC + contract deliberately removed (`GRPC-CONTRACT.md` §1), couples plugins to the + backend's network topology and base URL, and duplicates the transport the + Host channel already provides securely (spawn relationship + AutoMTLS). +- **Expose internal structs via `structpb`.** Marshal domain models as generic + structs over the existing Struct-typed fields. Rejected: recreates the exact + schema coupling of raw DB access, just one layer up — plugins would depend on + internal field names and shapes with no stable contract boundary. + + diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 73995000b4..d0b683a74a 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -48,3 +48,4 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 0040 | [Separate updater integrity from OS publisher identity](0040-separate-updater-integrity-from-os-publisher-identity.md) | accepted | desktop, infra, workflow | 2026-07-15 | | 0041 | [Backend-owned portable user settings](0041-backend-owned-portable-user-settings.md) | accepted | backend, frontend | 2026-07-15 | | 0042 | [Project shell output and fetch it on demand](0042-project-shell-output-and-fetch-on-demand.md) | accepted | backend, frontend, protocol | 2026-07-16 | +| 0043 | [Plugins read/write kandev data via capability-gated Host gRPC RPCs](0043-plugin-host-data-api.md) | accepted | backend, protocol | 2026-07-17 | diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index 13a20e4548..653a462f96 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -53,6 +53,25 @@ service Host { rpc ListState(ListStateRequest) returns (ListStateResponse); rpc RevealSecret(RevealSecretRequest) returns (RevealSecretResponse); rpc EmitEvent(EmitEventRequest) returns (EmitEventResponse); + + // Host data API (ADR 0043, §3a below) — reads, capability api_read:. + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse); + rpc GetTask(GetTaskRequest) returns (Task); + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse); + rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse); + rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse); + rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse); + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse); + + // Host data API — writes, capability api_write:. Declared in the + // proto for a stable contract; handlers are NOT wired yet (§3a "Deferred + // writes") — calling any of these three today returns gRPC Unimplemented + // regardless of capabilities. + rpc CreateTask(CreateTaskRequest) returns (Task); + rpc UpdateTask(UpdateTaskRequest) returns (Task); + rpc CreateComment(CreateCommentRequest) returns (Comment); } message Event { @@ -104,6 +123,93 @@ Notes: scope ∈ instance|workspace|task|agent (empty scope_id for instance — matches the state store). The plugin never passes its own id; the Host service instance is bound to the plugin's record at spawn time. +### 3a. Host data API (ADR 0043) + +Read/write RPCs let plugins read (and, later, write) kandev's own domain data — +tasks, sessions, workspaces, workflows, agent profiles, repositories, comments — +over the same Host gRPC channel used for state/secrets, instead of opening the +kandev database file directly. Full message definitions (`Page`, `PageInfo`, +`Task`, `TaskFilter`, `Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, +`Repository`, `Session`, `SessionFilter`, `SessionCodeStats`, and the deferred +`CreateTaskRequest`/`UpdateTaskRequest`/`Comment`/`CreateCommentRequest`) live in +the real proto — `apps/backend/proto/kandev/plugin/v1/plugin.proto` — and are not +duplicated here; this section covers the RPC list (added to `service Host` above), +capability gating, and cross-cutting conventions. See ADR 0043 +(`docs/decisions/0043-plugin-host-data-api.md`) for the design rationale. + +**Readable resources.** Each read RPC requires `api_read:` in the +plugin's manifest: + +| RPC | Capability | Resource | +|---|---|---| +| `ListTasks` / `GetTask` | `api_read:tasks` | tasks | +| `ListWorkspaces` | `api_read:workspaces` | workspaces | +| `ListWorkflows` | `api_read:workflows` | workflows | +| `ListWorkflowSteps` | `api_read:workflows` | workflows | +| `ListAgentProfiles` | `api_read:agent_profiles` | agent_profiles | +| `ListRepositories` | `api_read:repositories` | repositories | +| `ListSessions` | `api_read:sessions` | sessions | +| `ListSessionCodeStats` | `api_read:sessions` | sessions | + +An undeclared capability returns gRPC `PermissionDenied` with message +`capability 'api_read:tasks' not declared` (substituting the actual resource) — +identical in shape to the existing state/secrets gating. Declaring the resource +grants every RPC listed against it; there is no finer-grained gate within a +resource (e.g. `api_read:workflows` covers both `ListWorkflows` and +`ListWorkflowSteps`). + +**Deferred writes.** `CreateTask`, `UpdateTask`, and `CreateComment` are declared +on `service Host` and in the proto (frozen shape for `api_write:tasks` / +`api_write:comments`) but have no server-side handler yet — calling any of them +returns gRPC `Unimplemented` today regardless of what `api_write` declares. When +implemented, writes will route through the task service's `CreateTask`/ +`UpdateTask`/comment-create methods (never a repository) so the standard +`task.*` events fire, and the server will stamp `source = "plugin:"` on the +created row — a plugin cannot set it itself. + +**Reads go through the service layer, never a repository.** Each read handler +calls the relevant internal service (task service, workflow service, the +analytics service for `ListSessionCodeStats`) so derived fields and future +access rules stay centralized, exactly as writes will route through +event-publishing service methods once implemented. + +**DTOs are a hand-mapped, versioned contract — never internal structs.** The +backend maps internal models to the proto messages above with explicit +conversion code; it never marshals domain structs through +`google.protobuf.Struct` and never generates the messages from models. Fields +are additive-only after merge — removing or renaming one is a breaking change +requiring a new `api_version`. `SessionCodeStats` in particular is a +deliberately **computed** shape (`lines_added_committed` / +`lines_deleted_committed` from commit sums, `lines_added_peak_pending` / +`lines_deleted_peak_pending` from the peak uncommitted diff across snapshots), +computed on demand per request — plugins never see the raw +`task_session_commits` / `task_session_git_snapshots` rows those numbers are +derived from. + +**Conventions.** + +- **Pagination:** opaque-cursor. A request carries `Page{limit, cursor}` (0 limit + → server default, currently 50, capped at 200); a list response carries + `PageInfo{next_cursor, has_more}`. An empty cursor is the first page; echo + `next_cursor` to continue. Plugins MUST NOT interpret cursor contents — the + current server encodes it as a decimal offset, but that is an implementation + detail, not part of the contract. +- **Timestamps:** RFC3339 strings (`created_at`, `updated_at`, `started_at`, + `occurred_at`, ...), matching the `Event` envelope and the JSON API — one time + representation across the whole plugin contract, never protobuf `Timestamp`. +- **Nullables:** optional string fields use proto3 `optional` (e.g. + `Task.started_at`, `Task.completed_at`, `Task.parent_id`, + `Session.ended_at`), so an absent (NULL) value is distinguishable from an + empty string. +- **Scoping (v1):** reads are global to the kandev instance — plugins are + installed instance-wide, not per-workspace. Filters (`workspace_ids`, + `task_ids`, `states` on `TaskFilter`/`SessionFilter`) narrow results but do + not themselves confer or restrict visibility; a server-side scoping hook is + reserved for a future per-plugin/per-user restriction without a contract + change (ADR 0043, open decision (a)). +- **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the + request sets `TaskFilter.include_ephemeral`. + ## 4. SDK (`apps/backend/pkg/pluginsdk`) Public Go module surface (authors import only this): @@ -118,14 +224,105 @@ type Host interface { // injected before GetState/SetState/DeleteState/ListState(...) RevealSecret(ctx, ref string) (string, error) EmitEvent(ctx, name string, payload map[string]any) error + + // Host data API (ADR 0043, §3a) — each accessor is capability-gated by + // the corresponding api_read:; see "Host data API accessors" + // below for the reader interfaces and Go-native DTOs. + Tasks() TaskReader + Sessions() SessionReader + Workspaces() WorkspaceReader + Workflows() WorkflowReader + AgentProfiles() AgentProfileReader + Repositories() RepositoryReader } func Serve(p Plugin, opts ...Option) // blocks; wires go-plugin server + broker // Optional embeddable no-op base: sdk.UnimplementedPlugin +// Optional embeddable no-op base for Host data accessors (every method +// PermissionDenied/Unimplemented): sdk.UnimplementedHostData — used on the +// kandev side, not by plugin authors. ``` SDK types mirror proto but use `map[string]any` for Struct fields. The SDK owns all go-plugin/grpc plumbing (handshake, broker for Host, conversions). +### Host data API accessors (`apps/backend/pkg/pluginsdk/host.go`, `data_types.go`) + +Each `Host.()` call above returns a small reader interface. All +methods take `context.Context`; list methods take a Go-native `Page{Limit +int32; Cursor string}` and return `(items []T, *PageInfo, error)` where +`PageInfo{NextCursor string; HasMore bool}` — the Go-native mirror of the wire +`Page`/`PageInfo` messages (§3a). A resource whose capability isn't declared +still returns a non-nil reader; every method on it returns a gRPC +`PermissionDenied` error instead of a zero value. + +```go +type TaskReader interface { + List(ctx context.Context, filter TaskFilter, page Page) ([]Task, *PageInfo, error) + Get(ctx context.Context, id string) (*Task, error) +} + +type SessionReader interface { + List(ctx context.Context, filter SessionFilter, page Page) ([]Session, *PageInfo, error) + CodeStats(ctx context.Context, filter SessionFilter, page Page) ([]SessionCodeStats, *PageInfo, error) +} + +type WorkspaceReader interface { + List(ctx context.Context, page Page) ([]Workspace, *PageInfo, error) +} + +type WorkflowReader interface { + List(ctx context.Context, workspaceID string, page Page) ([]Workflow, *PageInfo, error) + ListSteps(ctx context.Context, workflowID string) ([]WorkflowStep, error) +} + +type AgentProfileReader interface { + List(ctx context.Context, page Page) ([]AgentProfile, *PageInfo, error) +} + +type RepositoryReader interface { + List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error) +} +``` + +`Task`, `Session`, `SessionCodeStats`, `Workspace`, `Workflow`, `WorkflowStep`, +`AgentProfile`, `Repository`, `TaskFilter`, `SessionFilter` are Go-native +structs in `pluginsdk` (field-for-field mirrors of the proto messages, PascalCase +Go names for the proto's snake_case fields, `*string` for `optional string`) — +authors never see the generated `pluginv1.*` types. + +**Authoring example** — a plugin declaring `api_read: ["sessions"]` and reading +computed per-session code stats instead of opening the kandev database: + +```yaml +# manifest.yaml +capabilities: + api_read: ["sessions"] +``` + +```go +func (p *statsPlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { + stats, pageInfo, err := p.host.Sessions().CodeStats(ctx, pluginsdk.SessionFilter{ + WorkspaceIDs: []string{e.WorkspaceID}, + }, pluginsdk.Page{Limit: 100}) + if err != nil { + return err // e.g. gRPC PermissionDenied if api_read:sessions isn't declared + } + for _, s := range stats { + log.Printf("session %s: +%d/-%d committed, +%d/-%d peak pending", + s.SessionID, s.LinesAddedCommitted, s.LinesDeletedCommitted, + s.LinesAddedPeakPending, s.LinesDeletedPeakPending) + } + _ = pageInfo.HasMore // paginate via pageInfo.NextCursor when true + return nil +} +``` + +`kandev-plugin-agent-stats` is the plugin ADR 0043 was written for: it +originally opened `~/.kandev/data/kandev.db` read-only and hand-aggregated +`task_session_commits`/`task_session_git_snapshots` to get exactly the numbers +`Sessions().CodeStats(...)` now returns as a stable, computed DTO — read via +the API, never the DB. + ## 5. Delivery / tools / webhooks semantics (unchanged from HTTP era) - **DeliverEvent**: unary. Per-plugin sequential queue, 10s timeout, 3 retries @@ -138,9 +335,14 @@ all go-plugin/grpc plumbing (handshake, broker for Host, conversions). failures → status `error` (+ restart attempt with backoff), recovery → `active` + delivery flush. Crash (process exit) → immediate restart with backoff (max 5 attempts, then `error`). -- **Capability gating**: a unary server interceptor on Host checks the plugin's - manifest capabilities (state, secrets; api_read/api_write reserved) and returns - PermissionDenied with `capability '' not declared`. +- **Capability gating**: each Host RPC checks the plugin's manifest capabilities + before doing any work — `state` for `GetState`/`SetState`/`DeleteState`/ + `ListState`, `secrets` for `RevealSecret`, `api_read:` for each Host + data API read RPC (§3a) — and returns PermissionDenied with + `capability '' not declared` on a miss. `EmitEvent` is ungated. + `api_write:` is accepted in the manifest but the write RPCs + (`CreateTask`/`UpdateTask`/`CreateComment`) aren't implemented yet, so they + return gRPC `Unimplemented` regardless of capability. ## 6. Package format (`-.tar.gz`) diff --git a/docs/plans/plugins/HOST-DATA-API.proto b/docs/plans/plugins/HOST-DATA-API.proto new file mode 100644 index 0000000000..8425de7672 --- /dev/null +++ b/docs/plans/plugins/HOST-DATA-API.proto @@ -0,0 +1,222 @@ +// PROPOSED — kandev.plugin.v1 Host data API (draft for review, not wired yet). +// +// Extends the existing capability-gated Host service so plugins read (and, in a +// second phase, write) kandev data through a typed, versioned contract instead +// of touching the SQLite file directly. These messages are a HAND-DEFINED public +// contract: the backend maps internal models → these DTOs explicitly. Do NOT +// generate them from domain structs — the mapping IS the decoupling. +// +// Enforcement (host side): each RPC checks the caller plugin's manifest +// capabilities. Reads require `api_read: [""]`; writes require +// `api_write: [""]`. Undeclared → gRPC PermissionDenied +// "capability 'api_read:tasks' not declared". Writes go through the service +// layer (which publishes task.* events), never the repository. + +syntax = "proto3"; +package kandev.plugin.v1; + +import "google/protobuf/struct.proto"; + +// ── Added to the existing `service Host` (state/secrets/emit already there) ── +service HostData { + // Reads — capability api_read: + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse); + rpc GetTask(GetTaskRequest) returns (GetTaskResponse); + rpc ListWorkspaces(ListWorkspacesRequest) returns (ListWorkspacesResponse); + rpc ListWorkflows(ListWorkflowsRequest) returns (ListWorkflowsResponse); + rpc ListWorkflowSteps(ListWorkflowStepsRequest) returns (ListWorkflowStepsResponse); + rpc ListAgentProfiles(ListAgentProfilesRequest) returns (ListAgentProfilesResponse); + rpc ListRepositories(ListRepositoriesRequest) returns (ListRepositoriesResponse); + + // Sessions + code stats — capability api_read:sessions. Driven by a real + // plugin (kandev-plugin-agent-stats) that otherwise read task_sessions, + // task_session_commits, and task_session_git_snapshots straight from the + // SQLite file. SessionCodeStats is a COMPUTED, stable DTO — deliberately not + // the raw commit/snapshot rows, whose JSON schema churns. + rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); + rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse); + + // Writes — capability api_write:. Route through service methods so + // the corresponding task.* events fire; this is the whole reason not to let + // plugins write the DB. + rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse); + rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse); + rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse); +} + +// ── Common ────────────────────────────────────────────────────────────────── +// Pagination is opaque-cursor based so the server can change the underlying +// ordering/store without breaking plugins. +message Page { + int32 limit = 1; // 0 → server default (e.g. 50); server caps the max + string cursor = 2; // empty → first page; echo next_cursor to continue +} +message PageInfo { string next_cursor = 1; bool has_more = 2; } + +// Timestamps are RFC3339 strings (matches the JSON API + the Event envelope +// convention), not protobuf Timestamp, to keep one time representation across +// the whole plugin contract. Optional string fields use `optional` so absent +// (NULL) is distinguishable from empty. + +// ── Task ──────────────────────────────────────────────────────────────────── +message Task { + string id = 1; + string workspace_id = 2; + string workflow_id = 3; + string title = 4; + string description = 5; + string state = 6; // TaskState enum value as a string, stable + string priority = 7; + string created_by = 8; + string created_at = 9; // RFC3339 + string updated_at = 10; + optional string started_at = 11; + optional string completed_at = 12; + optional string parent_id = 13; + string identifier = 14; + bool is_ephemeral = 15; + repeated TaskRepository repositories = 16; + google.protobuf.Struct metadata = 17; // free-form; plugin-tolerant +} +message TaskRepository { + string id = 1; + string repository_id = 2; + string base_branch = 3; + int32 position = 4; +} + +message TaskFilter { + repeated string workspace_ids = 1; // empty → all the plugin may see + repeated string workflow_ids = 2; + repeated string states = 3; + optional string parent_id = 4; + bool include_ephemeral = 5; // default false (kanban-visible only) +} +message ListTasksRequest { TaskFilter filter = 1; Page page = 2; } +message ListTasksResponse { repeated Task tasks = 1; PageInfo page_info = 2; } +message GetTaskRequest { string id = 1; } +message GetTaskResponse { Task task = 1; } + +// ── Workspace / Workflow / Steps ───────────────────────────────────────────── +message Workspace { + string id = 1; + string name = 2; + optional string description = 3; + string owner_id = 4; + optional string default_executor_id = 5; + optional string default_agent_profile_id = 6; + string created_at = 7; + string updated_at = 8; +} +message ListWorkspacesRequest { Page page = 1; } +message ListWorkspacesResponse { repeated Workspace workspaces = 1; PageInfo page_info = 2; } + +message Workflow { + string id = 1; + string workspace_id = 2; + string name = 3; + optional string description = 4; + int32 sort_order = 5; + string created_at = 6; + string updated_at = 7; +} +message ListWorkflowsRequest { string workspace_id = 1; Page page = 2; } +message ListWorkflowsResponse { repeated Workflow workflows = 1; PageInfo page_info = 2; } + +message WorkflowStep { + string id = 1; + string workflow_id = 2; + string name = 3; + int32 position = 4; + string stage_type = 5; // work | review | approval | custom +} +message ListWorkflowStepsRequest { string workflow_id = 1; } +message ListWorkflowStepsResponse { repeated WorkflowStep steps = 1; } + +// ── Agent profiles / Repositories (read-only context) ──────────────────────── +message AgentProfile { + string id = 1; + string agent_id = 2; + string display_name = 3; + string name = 4; + string model = 5; + string mode = 6; +} +message ListAgentProfilesRequest { Page page = 1; } +message ListAgentProfilesResponse { repeated AgentProfile profiles = 1; PageInfo page_info = 2; } + +message Repository { + string id = 1; + string workspace_id = 2; + string name = 3; // owner/repo slug when known, else name + optional string default_branch = 4; +} +message ListRepositoriesRequest { string workspace_id = 1; Page page = 2; } +message ListRepositoriesResponse { repeated Repository repositories = 1; PageInfo page_info = 2; } + +// ── Sessions ───────────────────────────────────────────────────────────────── +// Identity + agent context for one agent run on a task. acp_session_id is the +// join key an external usage tool (e.g. tokscale) uses to attribute tokens — so +// kandev exposes it and stays out of the token business entirely. +message Session { + string id = 1; + string task_id = 2; + string agent_profile_id = 3; + string agent_display_name = 4; // from the profile snapshot at run time + string model = 5; // resolved model at run time + string acp_session_id = 6; // external usage-attribution join key + string state = 7; + string started_at = 8; + optional string ended_at = 9; + string agent_profile_name = 10; // profile name from the snapshot at run time +} +message SessionFilter { + repeated string task_ids = 1; + repeated string workspace_ids = 2; + repeated string states = 3; +} +message ListSessionsRequest { SessionFilter filter = 1; Page page = 2; } +message ListSessionsResponse { repeated Session sessions = 1; PageInfo page_info = 2; } + +// Computed code metrics per session — the aggregate the agent-stats plugin +// re-derived by hand from commits + git snapshots. Exposed as a stable shape so +// plugins never touch task_session_commits / task_session_git_snapshots. +message SessionCodeStats { + string session_id = 1; + int64 lines_added_committed = 2; // SUM of commit insertions + int64 lines_deleted_committed = 3; // SUM of commit deletions + int64 lines_added_peak_pending = 4; // peak uncommitted diff additions across snapshots + int64 lines_deleted_peak_pending = 5; +} +message ListSessionCodeStatsRequest { SessionFilter filter = 1; Page page = 2; } +message ListSessionCodeStatsResponse { repeated SessionCodeStats stats = 1; PageInfo page_info = 2; } + +// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +message CreateTaskRequest { + string workspace_id = 1; + string workflow_id = 2; + optional string workflow_step_id = 3; + string title = 4; + string description = 5; + optional string parent_id = 6; + bool start_agent = 7; + // kandev stamps source = "plugin:" server-side; not settable here. +} +message CreateTaskResponse { Task task = 1; } +message UpdateTaskRequest { + string id = 1; + optional string title = 2; + optional string description = 3; + optional string state = 4; + optional string workflow_step_id = 5; +} +message UpdateTaskResponse { Task task = 1; } +message Comment { + string id = 1; + string task_id = 2; + string body = 3; + string source = 4; // "plugin:" + string created_at = 5; +} +message CreateCommentRequest { string task_id = 1; string body = 2; } +message CreateCommentResponse { Comment comment = 1; } diff --git a/docs/plans/plugins/host-data-api/plan.md b/docs/plans/plugins/host-data-api/plan.md new file mode 100644 index 0000000000..8c061a3d4b --- /dev/null +++ b/docs/plans/plugins/host-data-api/plan.md @@ -0,0 +1,154 @@ +--- +spec: docs/specs/plugins/spec.md +adr: docs/decisions/0043-plugin-host-data-api.md +created: 2026-07-17 +status: implemented +--- + +# Implementation Plan: Plugin Host data API + +## Overview + +Give plugins a typed, capability-gated path to read (phase 1) kandev domain data +over the existing `kandev.plugin.v1` Host gRPC channel, replacing direct SQLite +access. Land the contract first (proto + regen) and the one net-new aggregation +(per-session LOC), then the SDK's Go-native accessors, then kandev's server-side +implementation, and finally the proof (rewrite the agent-stats plugin) plus tests +and docs. Write RPCs (`CreateTask`/`UpdateTask`/`CreateComment`) are specified in +the proto but deferred; handlers return `Unimplemented` this phase. + +The draft contract at `docs/plans/plugins/HOST-DATA-API.proto` is largely settled +— refine field-level details against the real DTOs (`apps/backend/pkg/api/v1/`), +do not redesign. + +--- + +## Backend + +### Proto contract (`apps/backend/proto/kandev/plugin/v1/plugin.proto`) +Fold the read + (deferred) write RPCs and DTOs from +`docs/plans/plugins/HOST-DATA-API.proto` into the frozen plugin proto. Decide +placement: add the RPCs to the existing `service Host` (simplest — reuses the +single Host broker connection and the existing capability interceptor) rather +than a separate `service HostData`, unless a separate service is needed to keep +the interceptor's per-RPC capability lookup clean. Regenerate stubs with +`make -C apps/backend proto`. Read RPCs: `ListTasks`, `GetTask`, `ListWorkspaces`, +`ListWorkflows`, `ListWorkflowSteps`, `ListAgentProfiles`, `ListRepositories`, +`ListSessions`, `ListSessionCodeStats`. DTOs mirror +`apps/backend/pkg/api/v1/{task,workflow,agent,workspace}.go` field-for-field with +RFC3339 string timestamps and `optional` nullables. + +### Per-session LOC aggregation (`internal/analytics/repository/sqlite/`) +No existing method computes per-session committed + peak-pending LOC. +`internal/analytics/repository/sqlite/stats.go` aggregates git stats at workspace +and per-repository granularity only. Add a read-only aggregation returning, per +session id: `SUM(insertions)` / `SUM(deletions)` from `task_session_commits`, and +the peak pending-diff (`MAX` over each snapshot's `SUM(json_each(files).additions +/ .deletions)`) from `task_session_git_snapshots`. This mirrors the SQL the +agent-stats plugin runs today (`stats.go:sessionsQuery`). Use the `dialect` +helpers for SQLite/Postgres portability; filter by session-id set / workspace and +support pagination bounds. Expose it through the analytics (or task) service so +the Host handler calls a service method, not the repository. + +### Host data RPC implementation (`internal/plugins/`) +Extend `pluginHost` (or add a sibling under `internal/plugins/`) to implement the +new Host data interface methods. Each read handler: +1. checks `manifest.CanRead("")` — reuse the existing per-plugin + binding and `permissionDenied("api_read:")` helper shape; +2. calls the relevant service method (`taskSvc` for tasks/workspaces/repositories/ + sessions, workflow service for workflows/steps, agent settings for profiles, + the new aggregation for code stats) — never a repository directly; +3. maps internal models → the Go-native DTOs with explicit code. +Wire the new service dependencies into `plugins.Service` / +`plugins.NewService` / `Provide`, and thread them into `hostForPlugin`. Write +handlers return `codes.Unimplemented`. + +--- + +## Frontend + +None. This feature has no SPA surface; it is a plugin-facing gRPC contract. + +--- + +## SDK (`apps/backend/pkg/pluginsdk/`) + +Extend the Go-native Host surface authors and kandev share: +- Add Go-native DTO structs (`Task`, `Session`, `SessionCodeStats`, `Workspace`, + `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`, page types) mirroring + the proto, with `toProto`/`fromProto` helpers alongside the existing ones in + `types.go`. +- Add data methods to the `Host` interface (or a `HostData` sub-interface exposed + via `host.Tasks()`, `host.Sessions()`, ...), with `grpcHostClient` (plugin-side) + and `grpcHostServer` (kandev-side) conversions in `host.go`. +- Author-facing ergonomics: typed accessors returning Go-native structs, e.g. + `host.Tasks().List(ctx, filter, page)`, `host.Sessions().List(...)`, + `host.Sessions().CodeStats(...)`. + +--- + +## Tests + +- **DTO mapping (`internal/plugins/*_test.go`):** table-driven — internal model → + DTO for each resource, including nullable/optional and timestamp formatting. +- **Capability gating (`internal/plugins/host_test.go`):** each read RPC returns + `PermissionDenied` with `capability 'api_read:' not declared` when the + manifest omits the resource, and succeeds when it declares it. Write RPC returns + `Unimplemented`. +- **Per-session LOC aggregation (`internal/analytics/repository/sqlite/*_test.go`):** + real SQLite fixture with commits + snapshots; assert committed sums and the + peak-pending `MAX` semantics (peak, not latest; not double-counted). +- **SDK conversions (`pkg/pluginsdk/*_test.go`):** proto↔native round-trip for the + new DTOs. +- **Integration:** a plugin (via the SDK Host client over the broker) calls + `ListSessions` + `ListSessionCodeStats` and gets rows without DB access — extend + the existing `internal/plugins/host_test.go` / integration harness. + +--- + +## E2E Tests + +None. No user-visible UI surface. The integration test above covers the plugin↔host +path. + +--- + +## Implementation Waves + +``` +Wave 1 (parallel): +- [x] [task-01-proto-contract](task-01-proto-contract.md) +- [x] [task-02-session-loc-aggregation](task-02-session-loc-aggregation.md) + +Wave 2: +- [x] [task-03-sdk-data-accessors](task-03-sdk-data-accessors.md) + +Wave 3: +- [x] [task-04-host-data-impl](task-04-host-data-impl.md) + +Wave 4 (parallel): +- [x] [task-05-rewrite-agent-stats-plugin](task-05-rewrite-agent-stats-plugin.md) +- [x] [task-06-tests](task-06-tests.md) +- [x] [task-07-docs](task-07-docs.md) +``` + +Rationale: task-01 (contract) and task-02 (aggregation) touch disjoint packages +and can run in parallel. The SDK (task-03) needs generated stubs from task-01. The +kandev impl (task-04) needs the stubs (task-01), the aggregation (task-02), and +the Go-native interface/DTOs (task-03). Wave 4 tasks touch disjoint trees (a +separate plugin repo, backend tests, public docs) and parallelize. + +--- + +## Open Questions + +- **Proto placement:** RPCs on the existing `service Host` vs a new + `service HostData` served on the same broker connection. Recommendation: extend + `service Host` to reuse the existing capability interceptor and single broker + wiring; revisit only if the interceptor's per-RPC capability mapping gets + unwieldy. Settle in task-01. +- **Aggregation home:** `internal/analytics` (where git stats already live) vs a + new method on the task repository's `git_snapshots.go`. Recommendation: + analytics repository, consistent with `GetGitStats`/`GetRepositoryStats`. + Settle in task-02. + diff --git a/docs/plans/plugins/host-data-api/task-01-proto-contract.md b/docs/plans/plugins/host-data-api/task-01-proto-contract.md new file mode 100644 index 0000000000..a3d68a0b89 --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-01-proto-contract.md @@ -0,0 +1,60 @@ +--- +id: "01-proto-contract" +title: "Host data proto contract + regenerated stubs" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 01: Host data proto contract + regenerated stubs + +Fold the Host data RPCs and DTOs from `docs/plans/plugins/HOST-DATA-API.proto` +into the frozen plugin proto and regenerate Go stubs. This is the contract every +later task compiles against. + +## Scope +- Read RPCs: `ListTasks`, `GetTask`, `ListWorkspaces`, `ListWorkflows`, + `ListWorkflowSteps`, `ListAgentProfiles`, `ListRepositories`, `ListSessions`, + `ListSessionCodeStats`. +- Deferred write RPCs (declared in proto, not implemented later this phase): + `CreateTask`, `UpdateTask`, `CreateComment`. +- DTOs + `Page`/`PageInfo`/filter messages from the draft. +- Decide placement: add RPCs to the existing `service Host` (recommended — reuses + the single broker connection and the capability interceptor) rather than a new + `service HostData`. Record the choice in a proto comment. +- Verify DTO fields against the real shapes in + `apps/backend/pkg/api/v1/{task,workflow,agent,workspace}.go`; keep RFC3339 + string timestamps and `optional` nullables per ADR 0043. + +## Acceptance +- `apps/backend/proto/kandev/plugin/v1/plugin.proto` contains the read + deferred + write RPCs and DTOs; existing `Host`/`Plugin` RPCs are unchanged. +- `make -C apps/backend proto` regenerates `plugin.pb.go` / `plugin_grpc.pb.go` + with no manual edits, and the tree builds. + +## Verification +- `make -C apps/backend proto` +- `cd apps/backend && go build ./...` + +## Files likely touched +- `apps/backend/proto/kandev/plugin/v1/plugin.proto` +- `apps/backend/proto/kandev/plugin/v1/plugin.pb.go` (generated) +- `apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go` (generated) + +## Inputs +- Spec: "Host data API" section, capability table. +- Draft: `docs/plans/plugins/HOST-DATA-API.proto`. +- DTO shapes: `apps/backend/pkg/api/v1/{task,workflow,agent,workspace}.go`. +- ADR 0043 (DTO discipline, conventions). + +## Dependencies +None. + +## Output contract +Summary of RPC placement decision, files changed (incl. regenerated stubs), +`make proto` + build result, any DTO field deviations from the draft, and status +update here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-02-session-loc-aggregation.md b/docs/plans/plugins/host-data-api/task-02-session-loc-aggregation.md new file mode 100644 index 0000000000..cde6c05e23 --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-02-session-loc-aggregation.md @@ -0,0 +1,66 @@ +--- +id: "02-session-loc-aggregation" +title: "Per-session LOC aggregation (committed + peak-pending)" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 02: Per-session LOC aggregation (committed + peak-pending) + +Add the one net-new read-only query the Host data API needs: per-session code +stats. No existing method computes this. `internal/analytics/repository/sqlite/ +stats.go` aggregates git stats at workspace and per-repository granularity only, +and the peak-pending snapshot aggregation exists nowhere in-tree. + +## Scope +Add a repository method (recommended home: `internal/analytics/repository/sqlite/`, +alongside `GetGitStats`/`GetRepositoryStats`) returning, per session id: +- `lines_added_committed` = `SUM(insertions)` over `task_session_commits`. +- `lines_deleted_committed` = `SUM(deletions)`. +- `lines_added_peak_pending` = `MAX` over each snapshot's + `SUM(json_extract(json_each(files).value,'$.additions'))` from + `task_session_git_snapshots` (peak, not latest — the latest is usually a clean + tree). +- `lines_deleted_peak_pending` = the deletions equivalent. + +Mirror the SQL in the agent-stats plugin +(`/home/jcfs/.kandev/tasks/tokens-per-task-loc_9tk/kandev-plugin-agent-stats/server/stats.go`, +`sessionsQuery`). Use `internal/db/dialect` helpers (`JSONExtract`, etc.) so the +query is portable to Postgres. Support filtering by a session-id set and/or +workspace, plus a limit/offset (or cursor bound) for pagination. Expose it via a +service method so the Host handler (task-04) calls the service, not the repository. + +## Acceptance +- New repository + service method returns per-session committed sums and the + peak-pending `MAX` semantics, filterable by session ids / workspace. +- Peak-pending uses the peak snapshot, not the latest, and does not double-count + committed work (matches `effectiveLines` intent in the source plugin). +- Table-driven SQLite test with commits + multiple snapshots asserts both. + +## Verification +- `cd apps/backend && go test ./internal/analytics/...` + +## Files likely touched +- `apps/backend/internal/analytics/repository/sqlite/stats.go` +- `apps/backend/internal/analytics/models/` (new `SessionCodeStats` model) +- analytics service file exposing the method +- `apps/backend/internal/analytics/repository/sqlite/*_test.go` + +## Inputs +- Source SQL: agent-stats `server/stats.go` `sessionsQuery` (committed sums + + snapshot peak). +- Patterns: `GetGitStats` / `buildRepositoryStatsQuery` in + `internal/analytics/repository/sqlite/stats.go`; `dialect` helpers. +- Spec: "Host data API" → `ListSessionCodeStats` row. + +## Dependencies +None. Independent of the proto task (pure Go/SQL). + +## Output contract +Summary, method signatures added, chosen home (analytics vs task repo), test +result, portability notes (SQLite/Postgres), and status update here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md b/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md new file mode 100644 index 0000000000..66e607cd73 --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md @@ -0,0 +1,65 @@ +--- +id: "03-sdk-data-accessors" +title: "SDK Go-native data DTOs, Host interface, and author accessors" +status: done +wave: 2 +depends_on: ["01-proto-contract"] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 03: SDK Go-native data DTOs, Host interface, and author accessors + +Extend `pkg/pluginsdk` so authors read kandev data through Go-native typed structs +— proto types never leak past the package boundary, matching the existing +state/secrets pattern in `host.go`/`types.go`. + +## Scope +- **Go-native DTOs** in `types.go` mirroring the proto: `Task`, `TaskRepository`, + `Session`, `SessionCodeStats`, `Workspace`, `Workflow`, `WorkflowStep`, + `AgentProfile`, `Repository`, plus `Page`/`PageInfo`/filter structs, each with + `toProto`/`fromProto` helpers alongside the existing ones. +- **Host interface extension** (`host.go`): add data read methods. Prefer grouping + behind sub-accessors exposed from the `Host` interface — `host.Tasks()`, + `host.Sessions()`, `host.Workspaces()`, `host.Workflows()`, + `host.AgentProfiles()`, `host.Repositories()` — each returning a small typed + accessor (e.g. `TasksAPI.List(ctx, filter, page)`, `TasksAPI.Get(ctx, id)`, + `SessionsAPI.List(...)`, `SessionsAPI.CodeStats(...)`). Keep write methods off + the surface this phase (deferred). +- **Client + server conversions**: `grpcHostClient` (plugin side) calls the + generated data RPCs and converts responses to Go-native; `grpcHostServer` + (kandev side) dispatches to the Go-native interface. Follow the existing + `GetState`/`ListState` conversion shape exactly. +- Keep the existing `Host` state/secrets/emit methods intact. + +## Acceptance +- Author can call `host.Sessions().List(ctx, filter, page)` and + `host.Sessions().CodeStats(ctx, filter, page)` (etc.) receiving Go-native + structs; proto types are not exposed. +- `grpcHostServer` satisfies the generated server interface for the new RPCs; + `grpcHostClient` satisfies the extended `Host` interface. +- Round-trip conversion unit tests pass. + +## Verification +- `cd apps/backend && go test ./pkg/pluginsdk/...` +- `cd apps/backend && go build ./...` + +## Files likely touched +- `apps/backend/pkg/pluginsdk/types.go` +- `apps/backend/pkg/pluginsdk/host.go` +- `apps/backend/pkg/pluginsdk/*_test.go` + +## Inputs +- Generated stubs from task-01. +- Existing pattern: `pkg/pluginsdk/host.go` (grpcHostClient/grpcHostServer), + `types.go` (toProto/fromProto, `mapToStruct`/`structToMap`). +- Spec: "Host data API" resource/capability table. + +## Dependencies +Task 01 (needs regenerated proto stubs). + +## Output contract +Summary, the accessor shape chosen (sub-accessors vs flat methods), files changed, +test result, and status update here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-04-host-data-impl.md b/docs/plans/plugins/host-data-api/task-04-host-data-impl.md new file mode 100644 index 0000000000..ee35dd001f --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-04-host-data-impl.md @@ -0,0 +1,77 @@ +--- +id: "04-host-data-impl" +title: "kandev-side Host data RPC implementation + capability gating + wiring" +status: done +wave: 3 +depends_on: ["01-proto-contract", "02-session-loc-aggregation", "03-sdk-data-accessors"] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 04: kandev-side Host data RPC implementation + +Implement the Go-native Host data interface (from task-03) inside +`internal/plugins`, backed by the service layer, with per-resource capability +gating, and wire the new service dependencies through `plugins.Service`. + +## Scope +- Implement each read method on `pluginHost` (or a sibling type in + `internal/plugins/`) satisfying the extended `pluginsdk.Host` interface: + 1. **Gate:** check `manifest.CanRead("")`; on miss return + `permissionDenied("api_read:")` (extend the existing helper — + `status.Errorf(codes.PermissionDenied, "capability '%s' not declared", ...)`). + 2. **Service call:** tasks/workspaces/repositories/sessions → `taskSvc` + methods (e.g. `ListTaskSessions`, workspace + repository listers); workflows/ + steps → workflow service (`ListStepsByWorkflow`, workflow lister); agent + profiles → agent settings store; code stats → the task-02 aggregation + service method. Never call a repository directly from the handler. + 3. **Map:** explicit internal model → Go-native DTO mapping (RFC3339 strings, + optional/NULL handling). Session DTO must carry `acp_session_id` (from + session metadata / `executors_running` fallback, as the source plugin did). +- Apply the v1 scoping rule: global reads, filters narrow results, ephemeral + tasks excluded unless requested. Leave a single scoping hook per ADR 0043(a). +- Implement opaque-cursor pagination (server-defined cursor encoding + max cap). +- Write RPC handlers return `codes.Unimplemented`. +- **Wiring:** add the new service deps to `plugins.Service`, `plugins.NewService`, + and `Provide`; thread them into `hostForPlugin` so each spawned `pluginHost` + can serve data RPCs. Confirm the new RPCs are served over the existing Host + broker connection (`runtime/manager.go` spawn path, `registerHostServer`). + +## Acceptance +- A plugin declaring `api_read:sessions` gets `ListSessions` + + `ListSessionCodeStats` results via the Host channel; one without it gets + `PermissionDenied` naming `api_read:sessions`. +- All read RPCs map internal models to DTOs with no proto/domain-struct leakage; + write RPCs return `Unimplemented`. +- `make -C apps/backend proto` not required here (done in task-01); package builds + and tests pass. + +## Verification +- `cd apps/backend && go test ./internal/plugins/...` +- `cd apps/backend && go build ./...` + +## Files likely touched +- `apps/backend/internal/plugins/host.go` +- `apps/backend/internal/plugins/service.go` (deps, `NewService`, `Provide`, + `hostForPlugin`) +- possibly a new `apps/backend/internal/plugins/host_data.go` +- `apps/backend/internal/plugins/host_test.go` + +## Inputs +- Extended `Host` interface + Go-native DTOs from task-03. +- Aggregation service method from task-02. +- Existing gating pattern: `internal/plugins/host.go` (`permissionDenied`, + capability checks), `service.go` `hostForPlugin`, `manifest.CanRead`/`CanWrite`. +- Service methods: `internal/task/service/service_sessions.go`, + `internal/workflow/service/service.go`, `internal/agent/settings/store`. +- Spec: "Host data API"; ADR 0043 (service-layer reads, DTO discipline, scoping). + +## Dependencies +Tasks 01, 02, 03. + +## Output contract +Summary, per-resource service method used for each RPC, capability-gating result, +wiring changes, `acp_session_id` sourcing note, test result, and status update +here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-05-rewrite-agent-stats-plugin.md b/docs/plans/plugins/host-data-api/task-05-rewrite-agent-stats-plugin.md new file mode 100644 index 0000000000..3c3e54e0b1 --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-05-rewrite-agent-stats-plugin.md @@ -0,0 +1,59 @@ +--- +id: "05-rewrite-agent-stats-plugin" +title: "Rewrite kandev-plugin-agent-stats to use the SDK data API" +status: done +wave: 4 +depends_on: ["03-sdk-data-accessors", "04-host-data-impl"] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 05: Rewrite kandev-plugin-agent-stats to use the SDK data API + +The proof the carrot is sufficient: replace the agent-stats plugin's direct SQLite +access with the Host data API. This is a **separate repository** at +`/home/jcfs/.kandev/tasks/tokens-per-task-loc_9tk/kandev-plugin-agent-stats/` — do +not vendor it into the kandev repo; reference it. + +## Scope +- Delete `readSessions` + `sessionsQuery` and the `modernc.org/sqlite` dependency + from `server/stats.go`. Stop opening `~/.kandev/data/kandev.db`. +- Fetch sessions via `host.Sessions().List(...)` and per-session LOC via + `host.Sessions().CodeStats(...)`. Map the returned `Session` + + `SessionCodeStats` into the existing `sessionRow` shape (agent display, model, + `acp_session_id`, committed + peak-pending lines). +- Keep `effectiveLines` semantics (max of committed and peak-pending) on the DTO + fields; keep the tokscale join on `acp_session_id` unchanged (external tool, + unaffected). +- Declare `capabilities.api_read: ["sessions"]` in the plugin `manifest.yaml`. +- Remove the `KANDEV_AGENT_STATS_DB` config path (no DB path needed); keep the + tokscale override. + +## Acceptance +- The plugin no longer imports a SQLite driver or opens any kandev DB file. +- Stats report still renders sessions + LOC + tokscale ratios using data obtained + solely through the Host data API. +- `manifest.yaml` declares `api_read: ["sessions"]`. + +## Verification +- In the plugin repo: `go build ./...` and its existing `go test ./...`. + +## Files likely touched (separate repo) +- `.../kandev-plugin-agent-stats/server/stats.go` +- `.../kandev-plugin-agent-stats/server/plugin.go` +- `.../kandev-plugin-agent-stats/manifest.yaml` +- `.../kandev-plugin-agent-stats/go.mod` (drop sqlite dep) + +## Inputs +- SDK data accessors from task-03; live host impl from task-04. +- Current plugin: `server/stats.go` (`sessionRow`, `aggregate`, `effectiveLines`), + `server/plugin.go` (`loadConfig`, `collect`). + +## Dependencies +Tasks 03, 04. + +## Output contract +Summary, confirmation the SQLite import is gone, mapping notes (DTO → +`sessionRow`), build/test result, and status update here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-06-tests.md b/docs/plans/plugins/host-data-api/task-06-tests.md new file mode 100644 index 0000000000..a07f580e0c --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-06-tests.md @@ -0,0 +1,59 @@ +--- +id: "06-tests" +title: "Capability-gating, DTO-mapping, and plugin-reads-sessions integration tests" +status: done +wave: 4 +depends_on: ["04-host-data-impl"] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 06: Host data API tests + +Cover the spec scenarios for the Host data API at the host boundary. (Aggregation +SQL and SDK round-trip conversions are unit-tested in tasks 02/03; this task owns +the host RPC behavior and the end-to-end plugin path.) + +## Scope +- **Capability gating (`internal/plugins/host_test.go`):** for each read RPC, + assert `PermissionDenied` with `capability 'api_read:' not declared` + when the manifest omits the resource, and success when it declares it. Assert + write RPCs return `Unimplemented`. +- **DTO mapping:** table-driven internal-model → DTO assertions per resource, + covering optional/NULL fields and RFC3339 timestamp formatting; assert the + Session DTO carries `acp_session_id`. +- **Pagination:** `ListTasks` (or another list RPC) returns `has_more` + a + `next_cursor` that yields the next page and terminates. +- **Ephemeral exclusion:** `ListTasks` without `include_ephemeral` omits ephemeral + tasks. +- **Integration:** drive a Host client (SDK `grpcHostClient` over the broker, or + the existing plugins test harness) to call `ListSessions` + + `ListSessionCodeStats` against a seeded DB and assert rows come back without any + file-level DB access — the concrete "plugin reads sessions via the API" scenario. + +## Acceptance +- Tests exist for each spec scenario listed above and pass. +- No test opens the SQLite file to stand in for the plugin path — the plugin-facing + assertion goes through the Host RPC surface. + +## Verification +- `cd apps/backend && go test ./internal/plugins/...` + +## Files likely touched +- `apps/backend/internal/plugins/host_test.go` +- possibly `apps/backend/internal/plugins/host_data_test.go` + +## Inputs +- Host impl from task-04; existing test harness in + `internal/plugins/host_test.go` / `service_test.go`. +- Spec: "Host data API" scenarios. +- Backend testing conventions in `apps/backend/CLAUDE.md` (synctest, goleak). + +## Dependencies +Task 04. + +## Output contract +Summary, list of scenarios covered, test result, coverage gaps if any, and status +update here + in `plan.md`. + diff --git a/docs/plans/plugins/host-data-api/task-07-docs.md b/docs/plans/plugins/host-data-api/task-07-docs.md new file mode 100644 index 0000000000..9ded6e8cb3 --- /dev/null +++ b/docs/plans/plugins/host-data-api/task-07-docs.md @@ -0,0 +1,133 @@ +--- +id: "07-docs" +title: "Author docs: Host data API + manifest api_read/api_write resource list" +status: done +wave: 4 +depends_on: ["04-host-data-impl"] +plan: "plan.md" +spec: "../../../specs/plugins/spec.md" +adr: "../../../decisions/0043-plugin-host-data-api.md" +--- + +# Task 07: Author docs for the Host data API + +Document the Host data API for plugin authors: which resources are readable, the +`api_read`/`api_write` capability vocabulary, and the SDK accessor surface. + +## Scope +- **Wire reference (`docs/plans/plugins/GRPC-CONTRACT.md`):** add the Host data + RPCs to the `service Host` block (§3) and note capability gating uses + `api_read:` / `api_write:` (§5), replacing the "reserved" + language. Note write RPCs are deferred (`Unimplemented`). +- **SDK reference (`docs/plans/plugins/GRPC-CONTRACT.md` §4 and/or + `PLUGIN-API.md`):** document the author accessors (`host.Tasks().List(...)`, + `host.Sessions().List(...)`, `host.Sessions().CodeStats(...)`, etc.) and that + they return Go-native structs. +- **Manifest reference:** wherever the manifest `capabilities` block is documented + for authors, list the `api_read` / `api_write` resource vocabulary (`tasks`, + `sessions`, `workspaces`, `workflows`, `agents`, `repositories`, `comments`) and + what each unlocks, with the `api_read: ["sessions"]` example from the agent-stats + plugin. +- **Public docs:** if a user-facing plugin authoring page exists under + `docs/public/**` at implementation time, add the resource list there too; there + is none today, so the authoritative author docs are the plans references above. + Do not invent a new public page in this task. + +## Acceptance +- `GRPC-CONTRACT.md` lists the Host data RPCs and the per-resource capability + gating; the "reserved / not implemented" wording for `api_read`/`api_write` is + removed or scoped to the deferred write RPCs. +- The manifest capability vocabulary is documented with the readable-resource + list and an example. + +## Verification +- Markdown-only; no build. `cd apps && pnpm --filter @kandev/web lint` only if a + `docs/public` page is touched (none expected). + +## Files likely touched +- `docs/plans/plugins/GRPC-CONTRACT.md` +- `docs/plans/plugins/PLUGIN-API.md` (if the SDK/author surface is documented + there) + +## Inputs +- Final RPC set + accessor names from tasks 01/03/04. +- Spec: "Host data API"; ADR 0043. + +## Dependencies +Task 04 (behavior finalized). + +## Output contract +Summary, files changed, and status update here + in `plan.md`. + +## Output + +**Summary:** Documented the Host data API (ADR 0043) in the wire/SDK contract +reference and reconciled the spec section against the shipped code. + +**Files changed:** +- `docs/plans/plugins/GRPC-CONTRACT.md` — added the 9 read RPCs + 3 deferred + write RPCs to the `service Host` block (§3); added a new "§3a. Host data API + (ADR 0043)" subsection covering the resource/capability table, deferred + writes, service-layer/DTO conventions, pagination/timestamp/nullable/scoping + conventions; added the `Host` interface's 6 data accessors plus the 6 reader + interfaces (`TaskReader`, `SessionReader`, `WorkspaceReader`, + `WorkflowReader`, `AgentProfileReader`, `RepositoryReader`) with their real Go + signatures, and an authoring example (manifest snippet + `Sessions().CodeStats` + call) to §4; rewrote the §5 "Capability gating" bullet to remove the stale + "api_read/api_write reserved" wording and describe the real per-RPC gating + plus the write RPCs' current `Unimplemented` behavior. +- `docs/specs/plugins/spec.md` — fixed capability-vocabulary drift against code + (see below) and added a "Declaring data access" note under the manifest + capabilities section; reworded the interceptor description to match the + actual per-RPC inline checks (no shared unary interceptor exists in code); + updated the readable-resources table's `ListAgentProfiles` row. +- `docs/plans/plugins/host-data-api/plan.md` — marked task-07 done in the wave + list. + +**Code-vs-doc drift found and corrected (spec.md only; code is source of +truth, left unchanged):** +- The `ListAgentProfiles` capability is `api_read:agent_profiles` in code + (`internal/plugins/host_data.go: resourceAgentProfiles = "agent_profiles"`, + confirmed by `host_data_test.go`), not `api_read:agents` as the spec had it + in the manifest example, the resource-vocabulary sentence, and the readable- + resources table. Fixed all three. +- The spec's manifest example listed `"projects"` as an `api_read` resource; + there is no `ListProjects` RPC, resource constant, or reader anywhere in the + proto or `internal/plugins/host_data.go`. Removed it from the example. +- The spec described `api_read`/`api_write` as "reserved for future Host RPCs" + in the manifest example comments and said the capability-gating interceptor + covers "state, secrets; api_read/api_write reserved" — both predate this + ADR shipping; `api_read` is live now (write RPCs remain deferred/ + `Unimplemented`). Reworded both. +- The spec attributed capability gating to "a unary server interceptor on + Host"; there is no `grpc.UnaryServerInterceptor` in `internal/plugins` or + `pkg/pluginsdk` — each Host/Host-data method checks its own capability + inline at the top of the handler (`host.go`, `host_data.go`). Reworded to + describe the actual per-RPC check without implying a shared interceptor + layer (this was pre-existing drift, unrelated to ADR 0043, caught while + editing the same paragraph). +- `GRPC-CONTRACT.md`'s pre-ADR text already read "api_read/api_write reserved + for future" in §5 and the `service Host` proto snippet only listed the 6 + pre-ADR RPCs; both were stale relative to the shipped proto and are now + updated in place. + +**Not touched (out of scope / already correct):** +- `docs/decisions/0043-plugin-host-data-api.md` — read for context per the + task's Inputs; its `Status: proposed` front matter looks stale given the + code is fully implemented, but the task's edit scope was spec.md, not the + ADR, so it is left for whoever finalizes the ADR's status. +- `docs/plans/plugins/PLUGIN-API.md` — this is the native frontend (JS) UI + plugin contract; it has no backend Host/SDK content and the Host data API + has no UI surface, so nothing there needed updating. The SDK reference went + into `GRPC-CONTRACT.md` §4 per the task's "and/or" framing. +- `docs/public/plugins*.md` — do not exist on this branch (confirmed); per the + task instructions these live on `feature/kandev-plugin-system-docs` and are + out of scope here. +- `apps/backend/internal/plugins/manifest/manifest_test.go` has an unrelated + test fixture using `api_read: ["tasks", "agents"]` as a manifest capability + example — manifest capability values are freeform strings with no vocabulary + validation at that layer (confirmed: no enum/allow-list check in + `internal/plugins/manifest`), so this is a test fixture choice, not a + functional bug; flagged here as a code-side echo of the same naming drift, + left unchanged since it's code, not docs. + diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 7e75b04bee..38fde8421a 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -105,8 +105,8 @@ min_kandev_version: "0.78.0" # optional capabilities: events: ["task.created", "task.state_changed", "agent.completed"] - api_read: ["tasks", "agents", "projects"] # reserved for future Host RPCs (see below) - api_write: ["tasks", "comments"] # reserved for future Host RPCs (see below) + api_read: ["tasks", "sessions"] # Host data API reads (see below); live now + api_write: ["tasks", "comments"] # Host data API writes; deferred, no effect yet state: true secrets: true @@ -146,9 +146,21 @@ installed_at: "2026-04-26T10:00:00Z" restart_count: 0 ``` -`capabilities.api_read` / `capabilities.api_write` are reserved for **future** Host -RPCs (e.g. a `CreateTask` RPC) — they are unrelated to office and are not implemented -by any Host RPC today; see "Read/write kandev data". +`capabilities.api_read` / `capabilities.api_write` gate the **Host data API** Host +RPCs (read RPCs live now; write RPCs are deferred) — the vocabulary is a list of +resource names: `tasks`, `sessions`, `workspaces`, `workflows`, `agent_profiles`, +`repositories` for `api_read`, plus `comments` for `api_write` only (there is no +`ListComments` read RPC). They are unrelated to office. See "Host data API". + +**Declaring data access.** Listing a resource under `api_read` grants the +corresponding Host data reads for that resource only — e.g. `api_read: +["sessions"]` unlocks `Host.Sessions().List(...)` and +`Host.Sessions().CodeStats(...)` (backed by `ListSessions` / +`ListSessionCodeStats`) but not `Host.Tasks()`. A resource left off the list +still resolves to a reader/accessor (no nil pointer), but every method on it +returns gRPC `PermissionDenied` with message `capability 'api_read:' +not declared`. `api_write` entries are accepted and stored but currently have no +effect (see "Write phase (deferred)"). ### Install pipeline @@ -334,19 +346,76 @@ service Host { internal event bus for delivery to subscribers (replaces the old `POST /api/plugins/{plugin_id}/events/emit` HTTP endpoint). -Every Host RPC is capability-gated by a **unary server interceptor** that checks the -plugin's manifest capabilities (`state`, `secrets`; `api_read`/`api_write` reserved) -before the handler runs, and returns gRPC status `PermissionDenied` with message -`capability '' not declared` on a miss. +Every Host RPC is capability-gated: `GetState`/`SetState`/`DeleteState`/`ListState` +check `capabilities.state`, `RevealSecret` checks `capabilities.secrets`, and each +Host data API read RPC checks `capabilities.api_read` for its resource (see "Host +data API" below) — all before the handler runs, returning gRPC status +`PermissionDenied` with message `capability '' not declared` on a miss. +`EmitEvent` is ungated (no boolean capability applies). The Host data API write +RPCs are not implemented yet, so they return gRPC `Unimplemented` unconditionally +and never reach an `api_write` capability check (see "Write phase (deferred)"). -### Read/write kandev data (future) +### Host data API (plugin -> kandev, gRPC) -`capabilities.api_read` and `capabilities.api_write` are reserved in the manifest -schema for **future** Host RPCs (e.g. `CreateTask`, `ListTasks`, `AddComment`) that -would let a plugin read/write kandev's own data (tasks, agents, comments) the same way -it reads/writes its own KV state. These RPCs do not exist yet — declaring the -capability today has no effect. This is unrelated to office; it is a general -extension point for any future kandev resource. See "Out of scope". +Plugins read (and, in a later phase, write) kandev's own domain data — tasks, +sessions, workspaces, workflows, agent profiles, repositories, comments — over the +same capability-gated Host gRPC channel they use for state and secrets, instead of +opening the kandev database file. The wire contract is the `kandev.plugin.v1` +Host data RPCs; DTOs are hand-mapped, versioned proto messages, never internal +domain structs. See [ADR 0043](../../decisions/0043-plugin-host-data-api.md) and +`docs/plans/plugins/HOST-DATA-API.proto`. + +**Readable resources (v1).** Each is gated by an `api_read:` capability: + +| RPC | Capability | Returns | +|---|---|---| +| `ListTasks` / `GetTask` | `api_read:tasks` | Tasks (id, workspace, workflow, title, description, state, priority, timestamps, parent, identifier, repositories, metadata) | +| `ListWorkspaces` | `api_read:workspaces` | Workspaces (id, name, owner, defaults, timestamps) | +| `ListWorkflows` | `api_read:workflows` | Workflows for a workspace | +| `ListWorkflowSteps` | `api_read:workflows` | Steps for a workflow (id, name, position, stage type) | +| `ListAgentProfiles` | `api_read:agent_profiles` | Agent profiles (id, agent id, display name, model, mode) | +| `ListRepositories` | `api_read:repositories` | Repositories for a workspace (id, name, default branch) | +| `ListSessions` | `api_read:sessions` | Session identity + agent context (id, task, agent profile, resolved display name + model, `acp_session_id`, state, timestamps) | +| `ListSessionCodeStats` | `api_read:sessions` | **Computed** per-session code metrics: committed lines added/deleted, peak pending-diff lines added/deleted | + +`acp_session_id` on a session is the external usage-attribution join key (e.g. +`tokscale`): kandev exposes the session identity and code stats but stays out of +the token business. `SessionCodeStats` is a deliberately computed shape — the +aggregate the agent-stats plugin previously re-derived by hand from +`task_session_commits` and `task_session_git_snapshots` — so plugins never touch +those raw rows. + +**Write phase (deferred).** `CreateTask`, `UpdateTask`, and `CreateComment` are +specified in the proto but not implemented in this phase. When added, each is +gated by `api_write:` (`api_write:tasks`, `api_write:comments`), routes +through the task service methods that publish `task.*` events (never a +repository), and the server stamps `source = "plugin:"` on the created +row/comment — a plugin cannot set provenance itself. Declaring an `api_write` +capability has no effect until the write RPCs ship. + +**Conventions.** + +- **Pagination** is opaque-cursor based: a request carries `Page{limit, cursor}` + and a list response carries `PageInfo{next_cursor, has_more}`. `limit: 0` means + the server default; the server caps the maximum. An empty cursor is the first + page; echoing `next_cursor` continues. Plugins MUST NOT interpret cursor + contents. +- **Timestamps** are RFC3339 strings. +- **Nullable** string fields use proto `optional`, so absent (NULL) is + distinguishable from empty. +- **Scoping (v1):** reads are global to the kandev instance (plugins are + instance-global; see "Permissions"). Filters (`workspace_ids`, `task_ids`, + `states`) narrow results but do not confer or restrict visibility. A + server-side scoping hook is reserved for a future per-plugin/per-user + restriction without a contract change. +- **Ephemeral tasks** (quick-chat) are excluded from `ListTasks` unless the + request sets `include_ephemeral`. + +Every Host data RPC is capability-gated the same way as state/secrets: an +undeclared capability returns gRPC `PermissionDenied` with message +`capability '' not declared` before the handler runs. Because the Host +service instance is bound to the plugin's own ID at spawn time, the check +evaluates directly against that plugin's installed manifest. ## Filesystem sideloading & sync @@ -561,6 +630,30 @@ restart with backoff (max 5 attempts, then `error`). Next successful handshake/` **THEN** the state is persisted in SQLite. A subsequent `Host.GetState` call with the same scope/key returns `"PROJ-123"`. +- **GIVEN** a plugin whose manifest declares `api_read: ["sessions"]`, **WHEN** the + plugin calls `ListSessionCodeStats`, **THEN** kandev returns per-session committed + and peak-pending line counts (plus, via `ListSessions`, each session's + `acp_session_id`) computed from the service layer, without the plugin opening the + kandev database file. + +- **GIVEN** a plugin whose manifest does **not** declare `tasks` in `api_read`, + **WHEN** the plugin calls `ListTasks`, **THEN** kandev returns gRPC status + `PermissionDenied` with message `capability 'api_read:tasks' not declared`, before + the handler runs. + +- **GIVEN** a plugin with `api_read: ["tasks"]` and more tasks than one page, + **WHEN** it calls `ListTasks` with `Page{limit: 50}` and then again with the + returned `PageInfo.next_cursor`, **THEN** the second call returns the next page and + `has_more` is false once the last page is reached. + +- **GIVEN** a plugin with `api_read: ["tasks"]`, **WHEN** it calls `ListTasks` + without `include_ephemeral`, **THEN** quick-chat ephemeral tasks are excluded from + the results. + +- **GIVEN** a plugin whose manifest declares `api_write: ["tasks"]` in this phase, + **WHEN** it calls `CreateTask`, **THEN** kandev returns gRPC status `Unimplemented` + (write RPCs are deferred), and declaring the capability has no other effect. + ## Out of scope - **Remote / operator-hosted plugin tier.** The earlier `base_url` registration model, @@ -589,6 +682,11 @@ restart with backoff (max 5 attempts, then `error`). Next successful handshake/` - **Multi-instance plugins.** Each plugin ID maps to exactly one supervised subprocess. - **Rate limiting.** No per-plugin rate limits in v1. Misbehaving plugins can be disabled manually. - **Plugin database namespaces.** Plugins do not get their own SQLite schemas. KV state is sufficient for v1. -- **`api_read`/`api_write` Host RPCs.** The capability vocabulary is reserved in the - manifest schema, but no Host RPC reads or writes kandev's own data (tasks, agents, - comments) yet — see "Read/write kandev data". +- **Host data API write RPCs.** `CreateTask`, `UpdateTask`, and `CreateComment` are + specified but deferred to a later phase; only the read RPCs ship in v1. See "Host + data API". +- **Per-session code-stats precomputation.** `SessionCodeStats` is computed on + demand per request in v1; a materialized or cached aggregation is future work. +- **Workspace-scoped plugin data access.** v1 reads are global to the instance with + a reserved scoping hook; per-plugin or per-user workspace restriction is future + work (see ADR 0043 open decisions).