feat(plugins): typed capability-gated Host data API (ADR 0043) (#1753)

* docs(plugins): draft Host data API proto (read + write DTOs)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* feat(plugins): capability-gated Host data read handlers (ADR 0042)

Implement pluginHost Tasks/Sessions/Workspaces/Workflows/AgentProfiles/
Repositories readers: each gated on api_read:<resource> (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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* docs(plugins): mark Host data API plan tasks done

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
João Salavisa
2026-07-17 13:43:58 +01:00
committed by GitHub
parent 2bb11bacb1
commit 0df2bbac0a
44 changed files with 9250 additions and 110 deletions
+5 -1
View File
@@ -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
}
@@ -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
}
@@ -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)
}
@@ -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)
@@ -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)
}
}
@@ -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`
}
@@ -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)
}
@@ -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)
}
}
@@ -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
@@ -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" {
+19
View File
@@ -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"))
}
+17 -1
View File
@@ -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"),
)
}
+26
View File
@@ -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")
+869
View File
@@ -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:<resource> 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:<resource>" 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:<resource> 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,
}
}
@@ -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:<resource> ──────────────
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:<resource> ───────────────
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)
}
}
@@ -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:<resource> 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)
}
@@ -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:<resource> 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
@@ -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{}
+50 -5
View File
@@ -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,
}
}
@@ -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
@@ -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 {
@@ -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)
}
}
+677
View File
@@ -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
}
@@ -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)
}
+391
View File
@@ -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")
}
@@ -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))
}
+5 -1
View File
@@ -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
+5 -2
View File
@@ -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
File diff suppressed because it is too large Load Diff
@@ -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:<resource>`; writes require `api_write:<resource>` (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:<resource>
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:<resource>. 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:<id>" 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:<id>"
string created_at = 5;
}
message CreateCommentRequest {
string task_id = 1;
string body = 2;
}
message CreateCommentResponse {
Comment comment = 1;
}
@@ -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:<resource>`; writes require `api_write:<resource>` (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:<resource>
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:<resource>. 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:<resource>`; writes require `api_write:<resource>` (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:<resource>
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:<resource>. 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",
+160
View File
@@ -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:<resource>` in the plugin's manifest; each write RPC requires
`api_write:<resource>` (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:<id>"` 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.
</content>
</invoke>
+1
View File
@@ -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 |
+205 -3
View File
@@ -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:<resource>.
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:<resource>. 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:<resource>` 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:<id>"` 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:<resource>; 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.<Resource>()` 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 '<name>' 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:<resource>` for each Host
data API read RPC (§3a) — and returns PermissionDenied with
`capability '<name>' not declared` on a miss. `EmitEvent` is ungated.
`api_write:<resource>` 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 (`<id>-<version>.tar.gz`)
+222
View File
@@ -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: ["<resource>"]`; writes require
// `api_write: ["<resource>"]`. 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:<resource>
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:<resource>. 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:<id>" 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:<id>"
string created_at = 5;
}
message CreateCommentRequest { string task_id = 1; string body = 2; }
message CreateCommentResponse { Comment comment = 1; }
+154
View File
@@ -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("<resource>")` — reuse the existing per-plugin
binding and `permissionDenied("api_read:<resource>")` 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:<resource>' 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.
</content>
@@ -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`.
</content>
@@ -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`.
</content>
@@ -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`.
</content>
@@ -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("<resource>")`; on miss return
`permissionDenied("api_read:<resource>")` (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`.
</content>
@@ -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`.
</content>
@@ -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:<resource>' 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`.
</content>
@@ -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:<resource>` / `api_write:<resource>` (§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.
</content>
+117 -19
View File
@@ -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:<resource>'
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 '<name>' 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 '<name>' 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:<resource>` 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:<resource>` (`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:<id>"` 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 '<name>' 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).