0df2bbac0a
* 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>
161 lines
5.5 KiB
Go
161 lines
5.5 KiB
Go
package plugins
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"time"
|
|
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
|
|
"github.com/kandev/kandev/internal/events/bus"
|
|
"github.com/kandev/kandev/internal/plugins/manifest"
|
|
"github.com/kandev/kandev/internal/plugins/state"
|
|
"github.com/kandev/kandev/pkg/pluginsdk"
|
|
)
|
|
|
|
// pluginHost implements pluginsdk.Host (the kandev.plugin.v1 Host RPCs, §3
|
|
// of docs/plans/plugins/GRPC-CONTRACT.md) for exactly one plugin. Service
|
|
// hands runtime.Manager a fresh pluginHost per spawn/restart via
|
|
// hostForPlugin, bound to that plugin's id and its manifest-declared
|
|
// capabilities at spawn time. Every method is capability-gated: State
|
|
// methods (GetState/SetState/DeleteState/ListState) require
|
|
// capabilities.state, RevealSecret requires capabilities.secrets. EmitEvent
|
|
// is intentionally ungated — the frozen contract's capability-gating list
|
|
// (§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)
|
|
|
|
// permissionDenied builds the gRPC error RemotePlugin/Host RPCs return for
|
|
// an undeclared capability, matching the wire-level message from
|
|
// docs/specs/plugins/spec.md ("Permissions"): "capability '<name>' not
|
|
// declared".
|
|
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")
|
|
}
|
|
raw, found, err := h.state.Get(ctx, h.pluginID, scope, scopeID, key)
|
|
if err != nil || !found {
|
|
return nil, found, err
|
|
}
|
|
value, err := unmarshalStateValue(raw)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return value, true, nil
|
|
}
|
|
|
|
func (h *pluginHost) SetState(ctx context.Context, scope, scopeID, key string, value map[string]any) error {
|
|
if !h.capabilities.State {
|
|
return permissionDenied("state")
|
|
}
|
|
raw, err := json.Marshal(value)
|
|
if err != nil {
|
|
return fmt.Errorf("plugins: marshal state value: %w", err)
|
|
}
|
|
return h.state.Set(ctx, h.pluginID, scope, scopeID, key, raw)
|
|
}
|
|
|
|
func (h *pluginHost) DeleteState(ctx context.Context, scope, scopeID, key string) error {
|
|
if !h.capabilities.State {
|
|
return permissionDenied("state")
|
|
}
|
|
return h.state.Delete(ctx, h.pluginID, scope, scopeID, key)
|
|
}
|
|
|
|
func (h *pluginHost) ListState(ctx context.Context, scope, scopeID string) ([]pluginsdk.StateEntry, error) {
|
|
if !h.capabilities.State {
|
|
return nil, permissionDenied("state")
|
|
}
|
|
entries, err := h.state.List(ctx, h.pluginID, scope, scopeID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]pluginsdk.StateEntry, len(entries))
|
|
for i, e := range entries {
|
|
value, err := unmarshalStateValue(e.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[i] = pluginsdk.StateEntry{
|
|
Key: e.Key,
|
|
Value: value,
|
|
UpdatedAt: e.UpdatedAt.UTC().Format(time.RFC3339),
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func (h *pluginHost) RevealSecret(ctx context.Context, ref string) (string, error) {
|
|
if !h.capabilities.Secrets {
|
|
return "", permissionDenied("secrets")
|
|
}
|
|
if h.secrets == nil {
|
|
return "", errors.New("plugins: secret vault not configured")
|
|
}
|
|
return h.secrets.Reveal(ctx, ref)
|
|
}
|
|
|
|
// EmitEvent publishes a plugin-originated event onto the bus, subject
|
|
// "plugin.<id>.<name>" (per the task's build instructions). A no-op if no
|
|
// event bus was wired (e.g. early boot, or a test Service without one).
|
|
func (h *pluginHost) EmitEvent(ctx context.Context, name string, payload map[string]any) error {
|
|
if h.bus == nil {
|
|
return nil
|
|
}
|
|
subject := "plugin." + h.pluginID + "." + name
|
|
event := bus.NewEvent(subject, "plugin:"+h.pluginID, payload)
|
|
return h.bus.Publish(ctx, subject, event)
|
|
}
|
|
|
|
// unmarshalStateValue decodes a plugin_state row's JSON value into a
|
|
// Go-native map, matching pluginsdk's Struct<->map[string]any convention.
|
|
func unmarshalStateValue(raw json.RawMessage) (map[string]any, error) {
|
|
var value map[string]any
|
|
if err := json.Unmarshal(raw, &value); err != nil {
|
|
return nil, fmt.Errorf("plugins: unmarshal state value: %w", err)
|
|
}
|
|
return value, nil
|
|
}
|