279e15b318
* feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/CreateComment) The api_write manifest capability was a stored no-op. Wire the deferred phase-2 write RPCs (ADR 0043) so closed-loop plugins (Sentry->task, Slack slash-command task creation, refactor fan-out) can create and update tasks and post comments. - SDK: Host.Tasks() gains Create/Update; new Host.Comments() writer; CreateTaskInput/UpdateTaskInput/Comment DTOs (pkg/pluginsdk). - Gating: api_write:tasks gates Create/UpdateTask, api_write:comments gates CreateComment; undeclared -> gRPC PermissionDenied, per-method and independent of the read caps on the same resource. - Writes route through the first-party service layer (task service for tasks, office service for comments) via backendapp adapters, never a repository -- so task.*/comment-created events fire and WS updates. Server stamps source="plugin:<id>"; CreateTask resolves workspace/ workflow defaults and best-effort honors start_agent; UpdateTask uses a conservative title/description/state/workflow_step_id mask. - Fixture plugins exercise a CreateTask+CreateComment round-trip; in-process, real-broker wire, and subprocess tests cover the round-trip and per-capability denial. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments moved writes deferred -> implemented; ADR 0043 amended with the concrete service-layer routing decision. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(plugins): send a message to a task session via Host SendMessage (replaces CreateComment) The office TaskComment write was the wrong primitive — it's an office/ dashboard construct, not a way to message the agent working a task, and it only did anything under office reactivity. Replace the CreateComment write RPC with SendMessage, which delivers a prompt to a task session through the orchestrator's real delivery path (the same one message_task uses). - Proto: swap CreateComment/Comment for SendMessage(SendMessageRequest) returns SendMessageResponse{session_id, status}; regenerated stubs. - SDK: Host.Comments() removed; Host.Messages().Send(taskID, sessionID, text) added (pairs with Messages().List), returning MessageDispatch. - Gating: api_write:comments dropped; SendMessage gated api_write:messages (independent of api_read:messages on the same accessor, like Tasks()). - Delivery: a new pluginsTaskMessengerAdapter (backendapp) resolves the target session (explicit id verified against the task, or the primary session), records a user message stamped source="plugin:<id>", then dispatches by state — running → queue; idle/completed → prompt (resume if the agent process is gone); never-started → launch; failed dispatch deletes the recorded message. Wired via SetWriteDeps (live-read). - Tests: adapter state-machine unit tests (queue/start/prompt/resume/ delete-on-failure/belongs-to-task/no-primary/terminal); SDK, in-process gating, wire, and subprocess round-trips updated to SendMessage. - Docs: spec, GRPC-CONTRACT, HOST-DATA-API.proto, proto comments, and the ADR 0043 amendment updated from comment-create to message-send. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): address PR review — nil guards, detached contexts, state validation Follow-up fixes from the #1970 review (Claude + CodeRabbit bots): - grpcHostServer.CreateTask/UpdateTask/SendMessage: nil-guard the returned value before toProto() so a custom/test Host returning (nil, nil) yields a gRPC Internal error, not a server panic (matches GetTask's pattern). - host.go: fix stale server doc comment (Comments()/CreateComment → Messages()/SendMessage). - startTaskBestEffort and the post-resume wait+retry now run on context.WithoutCancel(ctx): the task/resume is already committed, so a plugin's incoming deadline must not silently abort the launch/resume. - pluginsTaskWriterAdapter.UpdateTask validates the state string against the known task-state enum (excluding orchestrator-owned SCHEDULING) and returns InvalidArgument, so a plugin typo can't forward a bogus state to the service. - Tests: add the resume-wait-timeout path (message deleted so a retry can't duplicate), and a dedicated pluginsTaskWriterAdapter test covering source→metadata mapping and state validation (both adapters now depend on narrow interfaces for testability). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(plugins): launch start_agent asynchronously so CreateTask doesn't block Review nit follow-up: startTaskBestEffort called StartTask synchronously, so with the detached context the plugin's CreateTask RPC blocked on the orchestrator's full inline launch path (office lookup, state update, workflow step move, task fetch) with no bound — and the "asynchronous" comment was wrong. Move the fire-and-forget into pluginsTaskStarterAdapter (backendapp, no goleak), mirroring MCP's launchAutoStartTask: the launch runs in a goroutine on a context.WithTimeout(context.WithoutCancel(ctx), AgentLaunchTimeout), and StartTask returns immediately. The plugin host stays a simple synchronous call (its taskStarter contract now documents "returns promptly, launches async"). Adds adapters_plugin_starter_test.go: launch invoked, returns before the launch completes, detaches from a cancelled caller context, and swallows launch errors. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(plugins): document write validation + SendMessage rollback; harden starter test cleanup Addresses the final PR #1970 review nits: - adapters_plugin_starter_test.go: register the block-channel release via t.Cleanup (sync.Once-guarded) before the goroutine can block, per AGENTS.md. - GRPC-CONTRACT.md: document CreateTask/UpdateTask validation + error rules (title required, id required, NotFound on missing task, invalid-state reject). - spec.md: document that a failed SendMessage dispatch deletes the recorded message, plus the required-field/NotFound/terminal-session rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
209 lines
12 KiB
Markdown
209 lines
12 KiB
Markdown
# 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.
|
||
|
||
## Update — 2026-07-26: writes implemented
|
||
|
||
The phase-2 write RPCs are now wired, following this ADR's decision points 3–5
|
||
verbatim. Concretely:
|
||
|
||
- **`CreateTask` / `UpdateTask`** (capability `api_write:tasks`) route through
|
||
`internal/task/service.Service.CreateTask` / `UpdateTask` — the same
|
||
event-publishing methods the REST/MCP API uses — via a backendapp adapter
|
||
(`pluginsTaskWriterAdapter`) that translates the SDK's plugins-local input
|
||
structs into `service.CreateTaskRequest` / `UpdateTaskRequest` (the service
|
||
types can't be referenced from `internal/plugins` without the import cycle
|
||
this ADR's `SetDataSources` note already calls out). `CreateTask` resolves
|
||
placement defaults (single workspace; that workspace's first workflow;
|
||
ambiguous → `InvalidArgument`) and honors an optional `start_agent` that
|
||
best-effort launches an agent through the orchestrator (a launch failure never
|
||
fails the create, matching REST/MCP's async auto-start). `UpdateTask` exposes
|
||
a **conservative field mask** — `title` / `description` / `state` /
|
||
`workflow_step_id`, each optional — deliberately smaller than the full REST
|
||
update surface.
|
||
- **`SendMessage`** (capability `api_write:messages`) replaced the originally
|
||
planned `CreateComment`: an office `TaskComment` is an office/dashboard
|
||
construct, not a way to message the agent working a task. SendMessage delivers
|
||
a prompt to a task session through the orchestrator's real delivery path — the
|
||
same one `message_task` uses — via a backendapp adapter
|
||
(`pluginsTaskMessengerAdapter`) that resolves the target session (explicit id,
|
||
verified to belong to the task, or the task's primary session), records a user
|
||
message stamped `source = "plugin:<id>"`, and dispatches by session state: a
|
||
running session queues the prompt; an idle/completed one is prompted (resuming
|
||
the agent process if it has gone); a never-started one is launched with the
|
||
prompt as its first turn. A failed dispatch deletes the recorded message so no
|
||
orphan prompt is left. The adapter and the orchestrator task-starter are
|
||
reached via a late `SetWriteDeps` hook read live at call time, because the
|
||
orchestrator is constructed after boot-active plugins spawn — the same
|
||
live-read pattern the utility agent (ADR 0048) uses.
|
||
- **Provenance.** The server stamps `source = "plugin:<id>"` — task metadata
|
||
`source` for created tasks (the Task model has no source column), and the
|
||
recorded user message's metadata for SendMessage. A plugin cannot set it.
|
||
- **Gating** is per-method on the shared accessors: `Tasks()` gates List/Get on
|
||
`api_read:tasks` and Create/Update on `api_write:tasks`; `Messages()` gates
|
||
List on `api_read:messages` and Send on `api_write:messages` — each pair
|
||
independent, so a plugin may hold one without the other. Undeclared → gRPC
|
||
`PermissionDenied`, identical to reads.
|
||
|
||
This closes the "writes deferred to phase 2" language in the Decision above.
|
||
The comment-create RPC named there was dropped in favor of SendMessage.
|
||
Broadening the write surface (delete/archive tasks, sessions/workspaces, a
|
||
wider update mask, delivery-mode/interrupt on SendMessage) remains future work.
|
||
|
||
## 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>
|