From 0df2bbac0afc1b63da3d1255c8843d763ad9bb71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Salavisa?= Date: Fri, 17 Jul 2026 13:43:58 +0100 Subject: [PATCH] feat(plugins): typed capability-gated Host data API (ADR 0043) (#1753) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs(plugins): draft Host data API proto (read + write DTOs) Co-Authored-By: Claude Fable 5 * docs(plugins): ADR + spec + plan for the Host data API Plugins access kandev data via capability-gated Host gRPC RPCs returning hand-mapped DTOs, never the DB directly. ADR 0042. Co-Authored-By: Claude Fable 5 * feat(plugins): add Host data API RPCs and DTOs to plugin proto Extends the existing service Host (per ADR 0042) with read RPCs (ListTasks/GetTask, ListWorkspaces, ListWorkflows, ListWorkflowSteps, ListAgentProfiles, ListRepositories, ListSessions, ListSessionCodeStats) and declares the deferred phase-2 write RPCs (CreateTask, UpdateTask, CreateComment) so the contract is stable for later tasks. Regenerates plugin.pb.go/plugin_grpc.pb.go via `make -C apps/backend proto`. Co-Authored-By: Claude Opus 4.8 * feat(plugins): Host data API SDK accessors + session LOC aggregation Wave 1-2 of ADR 0042: pkg/pluginsdk gains typed reader accessors (host.Tasks(), host.Sessions().CodeStats(), host.Workflows(), ...) over the extended Host gRPC service, with proto<->native DTO conversions and an UnimplementedHostData seam for the kandev-side impl. internal/analytics gains the per-session LOC aggregation those readers surface. Backend handlers + capability gating land in the next wave. Co-Authored-By: Claude Fable 5 * feat(plugins): capability-gated Host data read handlers (ADR 0042) Implement pluginHost Tasks/Sessions/Workspaces/Workflows/AgentProfiles/ Repositories readers: each gated on api_read: (PermissionDenied otherwise), backed by the service layer via narrow injected interfaces, and hand-mapped to the pluginsdk DTOs. Sessions().CodeStats routes through the analytics aggregation; acp_session_id preserves the metadata->resume_token fallback. Wired through plugins.Service.SetDataSources + backendapp. Co-Authored-By: Claude Fable 5 * test(plugins): wire test for Host data reads + docs (ADR 0042 accepted) End-to-end test over the real go-plugin transport: session data round-trips, per-resource api_read gating returns PermissionDenied over the wire, cursor pagination. Document the Host data API + SDK accessors in GRPC-CONTRACT.md, reconcile the spec to shipped code, mark ADR 0042 accepted. Co-Authored-By: Claude Fable 5 * feat(plugins): full session history + snapshot profile name in data API Sessions().List now fetches tasks with includeArchived=true — an archived task's sessions are still real sessions, and CodeStats already made the same call at the SQL layer, so the two session reads were inconsistent (26 of 359 sessions visible on a real instance). Tasks().List keeps archived tasks excluded. Session gains agent_profile_name (proto field 10) sourced from the profile snapshot, so sessions of since-uninstalled agents keep a human label — the AgentProfiles registry only lists live agents. Co-Authored-By: Claude Fable 5 * docs(plugins): renumber Host data API ADR 0042 -> 0043 main merged its own ADR 0042 (project shell output); renumber this one to the next free slot and update all references. Co-Authored-By: Claude Fable 5 * fix(plugins): address Host data API review (perf, pagination, filters, proto) - paginate before DTO conversion so per-session ACP lookups run <=limit times - fetch all source pages (no silent 1000/workspace truncation); accurate HasMore - stable id tiebreaker on paginated sorts - AND SessionFilter.TaskIDs with WorkspaceIDs; exclude config-mode sessions from CodeStats (shared dialect.ExcludeConfigModePredicate) so List/CodeStats match - Get returns codes.NotFound consistently in-process and over the wire - wrap bare-DTO RPC responses (GetTaskResponse etc.) for Buf response uniqueness Co-Authored-By: Claude Fable 5 * docs(plugins): mark Host data API plan tasks done --------- Co-authored-by: Kandev Agent Co-authored-by: Claude Fable 5 --- apps/backend/cmd/plugin-fixture/main_test.go | 6 +- .../internal/analytics/models/models.go | 34 + .../analytics/repository/interface.go | 1 + .../repository/sqlite/repository_test.go | 8 + .../sqlite/session_code_stats_test.go | 299 ++ .../analytics/repository/sqlite/stats.go | 168 + .../internal/analytics/service/service.go | 34 + .../analytics/service/service_test.go | 93 + apps/backend/internal/backendapp/services.go | 4 + .../internal/db/dialect/dialect_test.go | 11 + apps/backend/internal/db/dialect/json.go | 19 + apps/backend/internal/plugins/goleak_test.go | 18 +- apps/backend/internal/plugins/host.go | 26 + apps/backend/internal/plugins/host_data.go | 869 +++++ .../internal/plugins/host_data_test.go | 728 +++++ .../internal/plugins/host_data_wire_test.go | 249 ++ .../internal/plugins/manifest/capability.go | 21 +- .../internal/plugins/runtime/manager_test.go | 7 +- apps/backend/internal/plugins/service.go | 55 +- .../internal/task/repository/sqlite/task.go | 10 +- .../internal/task/service/service_sessions.go | 14 + .../task/service/service_sessions_test.go | 48 + apps/backend/pkg/pluginsdk/data_types.go | 677 ++++ apps/backend/pkg/pluginsdk/data_types_test.go | 222 ++ apps/backend/pkg/pluginsdk/host.go | 391 +++ apps/backend/pkg/pluginsdk/host_data_test.go | 232 ++ apps/backend/pkg/pluginsdk/host_test.go | 6 +- apps/backend/pkg/pluginsdk/plugin_test.go | 7 +- .../proto/kandev/plugin/v1/plugin.pb.go | 2878 ++++++++++++++++- .../proto/kandev/plugin/v1/plugin.proto | 291 +- .../proto/kandev/plugin/v1/plugin_grpc.pb.go | 534 ++- docs/decisions/0043-plugin-host-data-api.md | 160 + docs/decisions/INDEX.md | 1 + docs/plans/plugins/GRPC-CONTRACT.md | 208 +- docs/plans/plugins/HOST-DATA-API.proto | 222 ++ docs/plans/plugins/host-data-api/plan.md | 154 + .../host-data-api/task-01-proto-contract.md | 60 + .../task-02-session-loc-aggregation.md | 66 + .../task-03-sdk-data-accessors.md | 65 + .../host-data-api/task-04-host-data-impl.md | 77 + .../task-05-rewrite-agent-stats-plugin.md | 59 + .../plugins/host-data-api/task-06-tests.md | 59 + .../plugins/host-data-api/task-07-docs.md | 133 + docs/specs/plugins/spec.md | 136 +- 44 files changed, 9250 insertions(+), 110 deletions(-) create mode 100644 apps/backend/internal/analytics/repository/sqlite/session_code_stats_test.go create mode 100644 apps/backend/internal/analytics/service/service.go create mode 100644 apps/backend/internal/analytics/service/service_test.go create mode 100644 apps/backend/internal/plugins/host_data.go create mode 100644 apps/backend/internal/plugins/host_data_test.go create mode 100644 apps/backend/internal/plugins/host_data_wire_test.go create mode 100644 apps/backend/internal/task/service/service_sessions_test.go create mode 100644 apps/backend/pkg/pluginsdk/data_types.go create mode 100644 apps/backend/pkg/pluginsdk/data_types_test.go create mode 100644 apps/backend/pkg/pluginsdk/host_data_test.go create mode 100644 docs/decisions/0043-plugin-host-data-api.md create mode 100644 docs/plans/plugins/HOST-DATA-API.proto create mode 100644 docs/plans/plugins/host-data-api/plan.md create mode 100644 docs/plans/plugins/host-data-api/task-01-proto-contract.md create mode 100644 docs/plans/plugins/host-data-api/task-02-session-loc-aggregation.md create mode 100644 docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md create mode 100644 docs/plans/plugins/host-data-api/task-04-host-data-impl.md create mode 100644 docs/plans/plugins/host-data-api/task-05-rewrite-agent-stats-plugin.md create mode 100644 docs/plans/plugins/host-data-api/task-06-tests.md create mode 100644 docs/plans/plugins/host-data-api/task-07-docs.md 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).