Files
kandev/docs/plans/plugins/HOST-DATA-API.proto
T
João Salavisa 279e15b318 feat(plugins): implement Host data API write RPCs (CreateTask/UpdateTask/SendMessage) (#1970)
* 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>
2026-07-27 10:54:12 +01:00

231 lines
10 KiB
Protocol Buffer

// kandev.plugin.v1 Host data API — folded into
// apps/backend/proto/kandev/plugin/v1/plugin.proto and fully wired (reads and
// writes both implemented). This file is the frozen design draft kept for
// reference alongside the ADRs.
//
// Extends the existing capability-gated Host service so plugins read AND 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 SendMessage(SendMessageRequest) returns (SendMessageResponse);
}
// ── 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 (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; }
// SendMessage delivers a prompt to a task session (api_write:messages) through
// the orchestrator — the same delivery path message_task uses. The server
// records a user message stamped source = "plugin:<id>" and resolves session_id
// (empty → the task's primary session), queueing when the session is running
// and resuming/starting it otherwise.
message SendMessageRequest {
string task_id = 1;
string session_id = 2; // empty → the task's primary session
string text = 3;
}
message SendMessageResponse {
string session_id = 1; // the session the message was delivered to
string status = 2; // "queued" | "sent" | "started"
}