Files
kandev/apps/backend/pkg/pluginsdk/host_data_test.go
T
João Salavisa 0df2bbac0a 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>
2026-07-17 13:43:58 +01:00

233 lines
8.7 KiB
Go

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))
}