diff --git a/apps/backend/cmd/plugin-fixture/main_test.go b/apps/backend/cmd/plugin-fixture/main_test.go index 6672ab0939..9ff4c3e338 100644 --- a/apps/backend/cmd/plugin-fixture/main_test.go +++ b/apps/backend/cmd/plugin-fixture/main_test.go @@ -128,6 +128,39 @@ func TestHandleWebhook_AppendsJSONLineAndRespondsOK(t *testing.T) { require.Equal(t, "test-hook", recs[0].WebhookKey) } +// TestHandleWebhook_WriteKeyRoundTripsHostWrites proves the "write" webhook +// drives the Host data API write RPCs (CreateTask then SendMessage to the +// returned task) and records the outcome to write-probe.json. +func TestHandleWebhook_WriteKeyRoundTripsHostWrites(t *testing.T) { + dir := t.TempDir() + p := &fixturePlugin{dataDir: dir} + host := &fakeHost{createdTaskID: "task-42"} + p.SetHost(host) + + resp, err := p.HandleWebhook(context.Background(), &pluginsdk.WebhookRequest{WebhookKey: "write"}) + require.NoError(t, err) + require.Equal(t, "ok", string(resp.Body)) + + require.Equal(t, "fixture write probe", host.lastCreateInput.Title) + require.Equal(t, "task-42", host.lastSendTask, "the message must target the task the Host returned") + require.Equal(t, "fixture probe message", host.lastSendText) + + rec := readSingleJSON[writeProbeRecord](t, filepath.Join(dir, "write-probe.json")) + require.Equal(t, "task-42", rec.TaskID) + require.Equal(t, "queued", rec.MessageStatus) + require.Empty(t, rec.TaskError) +} + +// readSingleJSON reads path and decodes its whole contents as a single T. +func readSingleJSON[T any](t *testing.T, path string) T { + t.Helper() + data, err := os.ReadFile(path) + require.NoError(t, err) + var v T + require.NoError(t, json.Unmarshal(data, &v)) + return v +} + func TestResolveDataDir_UsesEnvWhenSet(t *testing.T) { t.Setenv("KANDEV_PLUGIN_DATA_DIR", "/tmp/kandev-plugin-e2e-data") require.Equal(t, "/tmp/kandev-plugin-e2e-data", resolveDataDir()) @@ -155,6 +188,42 @@ type fakeHost struct { setStateCalls []setStateCall setStateErr error + + // Host data API write recording (ADR 0043 phase 2). + createdTaskID string + lastCreateInput pluginsdk.CreateTaskInput + lastSendTask string + lastSendText string +} + +func (h *fakeHost) Tasks() pluginsdk.TaskReader { return fakeHostTaskReader{h} } +func (h *fakeHost) Messages() pluginsdk.MessageReader { return fakeHostMessageReader{h} } + +type fakeHostTaskReader struct{ h *fakeHost } + +func (fakeHostTaskReader) List(context.Context, pluginsdk.TaskFilter, pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) { + return nil, nil, nil +} +func (fakeHostTaskReader) Get(context.Context, string) (*pluginsdk.Task, error) { return nil, nil } +func (fakeHostTaskReader) Update(context.Context, pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) { + return nil, nil +} + +func (r fakeHostTaskReader) Create(_ context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) { + r.h.lastCreateInput = in + return &pluginsdk.Task{ID: r.h.createdTaskID, Title: in.Title}, nil +} + +type fakeHostMessageReader struct{ h *fakeHost } + +func (fakeHostMessageReader) List(context.Context, pluginsdk.MessageFilter, pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) { + return nil, nil, nil +} + +func (r fakeHostMessageReader) Send(_ context.Context, taskID, sessionID, text string) (*pluginsdk.MessageDispatch, error) { + r.h.lastSendTask = taskID + r.h.lastSendText = text + return &pluginsdk.MessageDispatch{SessionID: "session-1", Status: "queued"}, nil } func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { diff --git a/apps/backend/cmd/plugin-fixture/plugin.go b/apps/backend/cmd/plugin-fixture/plugin.go index 7275708cd0..f3db7637b0 100644 --- a/apps/backend/cmd/plugin-fixture/plugin.go +++ b/apps/backend/cmd/plugin-fixture/plugin.go @@ -19,6 +19,12 @@ const ( webhooksFileName = "webhooks.jsonl" configSnapshotFileName = "config.json" secretProbeFileName = "secret-probe.json" + writeProbeFileName = "write-probe.json" + + // writeProbeWebhookKey triggers the Host data API write round-trip + // (CreateTask + CreateComment). Gated on this key so unrelated webhook + // deliveries don't attempt writes. + writeProbeWebhookKey = "write" ) // deliveryRecord is one recorded OnEvent delivery, appended as a JSON line @@ -126,9 +132,57 @@ func (p *fixturePlugin) HandleWebhook(ctx context.Context, req *pluginsdk.Webhoo } p.snapshotConfigBestEffort(ctx) p.snapshotSecretProbeBestEffort(ctx) + if req.WebhookKey == writeProbeWebhookKey { + p.snapshotWriteProbeBestEffort(ctx) + } return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil } +// writeProbeRecord captures the outcome of the Host data API write round-trip +// so tests can poll write-probe.json as evidence a plugin created a task and +// sent a message to its session over the real gRPC transport (or was denied — +// the error is recorded). +type writeProbeRecord struct { + TaskID string `json:"task_id,omitempty"` + TaskError string `json:"task_error,omitempty"` + MessageStatus string `json:"message_status,omitempty"` + MessageError string `json:"message_error,omitempty"` +} + +// snapshotWriteProbeBestEffort exercises the write RPCs end to end: Host +// CreateTask then, on success, Host SendMessage to the new task, writing the +// result (task id, dispatch status, or the error) to write-probe.json. +// Best-effort — the Host may deny the write when the fixture's manifest lacks +// api_write, which is itself useful evidence. +func (p *fixturePlugin) snapshotWriteProbeBestEffort(ctx context.Context) { + host := p.Host() + if host == nil { + return + } + rec := writeProbeRecord{} + task, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-probe", + WorkflowID: "wf-probe", + Title: "fixture write probe", + Description: "created by plugin-fixture over the Host data API", + }) + if err != nil { + rec.TaskError = err.Error() + } else if task != nil { + rec.TaskID = task.ID + if dispatch, merr := host.Messages().Send(ctx, task.ID, "", "fixture probe message"); merr != nil { + rec.MessageError = merr.Error() + } else if dispatch != nil { + rec.MessageStatus = dispatch.Status + } + } + data, err := json.Marshal(rec) + if err != nil { + return + } + _ = os.WriteFile(filepath.Join(p.dataDir, writeProbeFileName), data, 0o600) +} + // snapshotSecretProbeBestEffort exercises the plugin-scoped secret // primitives end to end: SetSecret then GetSecret through the Host, writing // the read-back value to secret-probe.json as evidence for e2e that a diff --git a/apps/backend/internal/backendapp/adapters_plugin_messenger.go b/apps/backend/internal/backendapp/adapters_plugin_messenger.go new file mode 100644 index 0000000000..81687040e0 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_plugin_messenger.go @@ -0,0 +1,193 @@ +package backendapp + +import ( + "context" + "errors" + "fmt" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/plugins" + taskmodels "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// messengerTaskSvc / messengerOrch are the narrow slices of the task service +// and orchestrator the messenger needs, so its delivery state machine can be +// unit-tested with fakes. *taskservice.Service and *orchestrator.Service +// satisfy them structurally. +type messengerTaskSvc interface { + GetTaskSession(ctx context.Context, sessionID string) (*taskmodels.TaskSession, error) + GetPrimarySession(ctx context.Context, taskID string) (*taskmodels.TaskSession, error) + CreateMessage(ctx context.Context, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) + DeleteMessage(ctx context.Context, id string) error + WaitForSessionReady(ctx context.Context, sessionID string) error +} + +type messengerOrch interface { + GetMessageQueue() *messagequeue.Service + StartCreatedSession(ctx context.Context, taskID, sessionID, agentProfileID, prompt string, skipMessageRecord, planMode, autoStart bool, attachments []v1.MessageAttachment, references []v1.EntityReference) (*orchexecutor.TaskExecution, error) + PromptTask(ctx context.Context, taskID, sessionID, prompt, model string, planMode bool, attachments []v1.MessageAttachment, dispatchOnly bool) (*orchestrator.PromptResult, error) + ResumeTaskSession(ctx context.Context, taskID, sessionID string) (*orchexecutor.TaskExecution, error) +} + +// pluginsTaskMessengerAdapter adapts the task service + orchestrator to the +// plugins package's taskMessenger interface (Host data API SendMessage RPC, ADR +// 0043 phase 2). It delivers a plugin's prompt to a task session through the +// orchestrator's real delivery path — the same behavior message_task uses, +// minus the peer-agent attribution and interrupt/parent semantics: resolve the +// target session (explicit id or the task's primary session), then dispatch by +// state — a running session queues; an idle/completed one is prompted (resuming +// if the agent process is gone); a never-started one is launched with the +// message as its first prompt. Lives here (the composition root) because it +// needs both the task service and the orchestrator, which no single service +// owns; internal/plugins can't reach either without an import cycle. +type pluginsTaskMessengerAdapter struct { + tasks messengerTaskSvc + orch messengerOrch + log *logger.Logger +} + +func (a pluginsTaskMessengerAdapter) SendMessage(ctx context.Context, taskID, sessionID, text, source string) (plugins.PluginMessageResult, error) { + session, err := a.resolveSession(ctx, taskID, sessionID) + if err != nil { + return plugins.PluginMessageResult{}, err + } + metadata := map[string]interface{}{"source": source} + switch session.State { + case taskmodels.TaskSessionStateFailed, taskmodels.TaskSessionStateCancelled: + return plugins.PluginMessageResult{}, status.Errorf(codes.FailedPrecondition, "session is %s — cannot send message", session.State) + case taskmodels.TaskSessionStateRunning, taskmodels.TaskSessionStateStarting: + return a.queueMessage(ctx, taskID, session, text, metadata) + default: + return a.startOrPromptSession(ctx, taskID, session, text, metadata) + } +} + +// resolveSession returns the session a message targets: the explicit session +// (verified to belong to taskID, so a plugin can't reach another task's session +// through a mismatched pair) or the task's primary session. +func (a pluginsTaskMessengerAdapter) resolveSession(ctx context.Context, taskID, sessionID string) (*taskmodels.TaskSession, error) { + if sessionID != "" { + session, err := a.tasks.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, status.Errorf(codes.NotFound, "session %q not found", sessionID) + } + if session.TaskID != taskID { + return nil, status.Errorf(codes.NotFound, "session %q does not belong to task %q", sessionID, taskID) + } + return session, nil + } + session, err := a.tasks.GetPrimarySession(ctx, taskID) + if err != nil { + if errors.Is(err, tasksqlite.ErrNoPrimarySession) { + return nil, status.Errorf(codes.NotFound, "task %q has no active session to message", taskID) + } + return nil, err + } + return session, nil +} + +// queueMessage appends the prompt to a running/starting session's FIFO queue +// for delivery at its next turn boundary. +func (a pluginsTaskMessengerAdapter) queueMessage(ctx context.Context, taskID string, session *taskmodels.TaskSession, text string, metadata map[string]interface{}) (plugins.PluginMessageResult, error) { + queue := a.orch.GetMessageQueue() + if queue == nil { + return plugins.PluginMessageResult{}, errors.New("message queue not available") + } + if _, err := queue.QueueMessageWithMetadata(ctx, session.ID, taskID, text, "", messagequeue.QueuedByUser, false, nil, metadata); err != nil { + return plugins.PluginMessageResult{}, fmt.Errorf("failed to queue message: %w", err) + } + return plugins.PluginMessageResult{SessionID: session.ID, Status: "queued"}, nil +} + +// startOrPromptSession records the user message (so it's tied to the turn the +// launch/prompt produces) then either starts a never-launched session or +// prompts an idle/completed one, deleting the recorded message if dispatch +// fails so a failed send leaves no orphan prompt behind. +func (a pluginsTaskMessengerAdapter) startOrPromptSession(ctx context.Context, taskID string, session *taskmodels.TaskSession, text string, metadata map[string]interface{}) (plugins.PluginMessageResult, error) { + recorded := a.recordUserMessage(ctx, taskID, session.ID, text, metadata) + if shouldStartMessagedSession(session) { + if _, err := a.orch.StartCreatedSession(ctx, taskID, session.ID, session.AgentProfileID, text, true, false, true, nil, nil); err != nil { + a.deleteRecordedMessage(ctx, recorded) + return plugins.PluginMessageResult{}, fmt.Errorf("failed to start session: %w", err) + } + return plugins.PluginMessageResult{SessionID: session.ID, Status: "started"}, nil + } + if err := a.promptWithResume(ctx, taskID, session.ID, text); err != nil { + a.deleteRecordedMessage(ctx, recorded) + return plugins.PluginMessageResult{}, err + } + return plugins.PluginMessageResult{SessionID: session.ID, Status: "sent"}, nil +} + +// promptWithResume dispatches the prompt, resuming the agent process first when +// its execution has gone (e.g. after a backend restart), mirroring the MCP +// message_task path's auto-resume. +// +// Once ResumeTaskSession succeeds the resume is a committed side effect (the +// agent is starting), so the subsequent wait+retry run on a detached context — +// the plugin's incoming deadline must not abort a session start already in +// flight, which would leave the agent awake with no prompt to act on. +func (a pluginsTaskMessengerAdapter) promptWithResume(ctx context.Context, taskID, sessionID, text string) error { + _, err := a.orch.PromptTask(ctx, taskID, sessionID, text, "", false, nil, true) + if err == nil { + return nil + } + if !errors.Is(err, orchexecutor.ErrExecutionNotFound) { + return fmt.Errorf("failed to send prompt: %w", err) + } + if _, resumeErr := a.orch.ResumeTaskSession(ctx, taskID, sessionID); resumeErr != nil { + return fmt.Errorf("failed to resume session: %w", resumeErr) + } + resumeCtx := context.WithoutCancel(ctx) + if waitErr := a.tasks.WaitForSessionReady(resumeCtx, sessionID); waitErr != nil { + return fmt.Errorf("session not ready after resume: %w", waitErr) + } + if _, retryErr := a.orch.PromptTask(resumeCtx, taskID, sessionID, text, "", false, nil, true); retryErr != nil { + return fmt.Errorf("failed to send prompt after resume: %w", retryErr) + } + return nil +} + +func (a pluginsTaskMessengerAdapter) recordUserMessage(ctx context.Context, taskID, sessionID, text string, metadata map[string]interface{}) *taskmodels.Message { + message, err := a.tasks.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + Content: text, + AuthorType: "user", + Metadata: metadata, + }) + if err != nil { + a.log.Warn("plugins: failed to record user message for SendMessage") + return nil + } + return message +} + +func (a pluginsTaskMessengerAdapter) deleteRecordedMessage(ctx context.Context, message *taskmodels.Message) { + if message == nil { + return + } + if err := a.tasks.DeleteMessage(ctx, message.ID); err != nil { + a.log.Warn("plugins: failed to delete recorded message after failed SendMessage") + } +} + +// shouldStartMessagedSession reports whether a message targets a session that +// has never launched an agent (CREATED, or a WAITING_FOR_INPUT session that +// never bound an execution) and therefore needs the launch path rather than a +// prompt/resume. +func shouldStartMessagedSession(session *taskmodels.TaskSession) bool { + if session.State == taskmodels.TaskSessionStateCreated { + return true + } + return session.State == taskmodels.TaskSessionStateWaitingForInput && session.AgentExecutionID == "" +} diff --git a/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go b/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go new file mode 100644 index 0000000000..ec160a52bc --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_plugin_messenger_test.go @@ -0,0 +1,209 @@ +package backendapp + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" + "github.com/kandev/kandev/internal/orchestrator/messagequeue" + taskmodels "github.com/kandev/kandev/internal/task/models" + tasksqlite "github.com/kandev/kandev/internal/task/repository/sqlite" + taskservice "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakeMessengerTaskSvc struct { + primary *taskmodels.TaskSession + primaryErr error + byID map[string]*taskmodels.TaskSession + created *taskmodels.Message + deleted []string + waitErr error +} + +func (f *fakeMessengerTaskSvc) GetTaskSession(_ context.Context, id string) (*taskmodels.TaskSession, error) { + s, ok := f.byID[id] + if !ok { + return nil, errors.New("session not found") + } + return s, nil +} + +func (f *fakeMessengerTaskSvc) GetPrimarySession(_ context.Context, _ string) (*taskmodels.TaskSession, error) { + return f.primary, f.primaryErr +} + +func (f *fakeMessengerTaskSvc) CreateMessage(_ context.Context, req *taskservice.CreateMessageRequest) (*taskmodels.Message, error) { + f.created = &taskmodels.Message{ID: "msg-1", TaskSessionID: req.TaskSessionID, TaskID: req.TaskID, Content: req.Content} + return f.created, nil +} + +func (f *fakeMessengerTaskSvc) DeleteMessage(_ context.Context, id string) error { + f.deleted = append(f.deleted, id) + return nil +} + +func (f *fakeMessengerTaskSvc) WaitForSessionReady(_ context.Context, _ string) error { + return f.waitErr +} + +type fakeMessengerOrch struct { + queue *messagequeue.Service + startCalls int + promptCalls int + resumeCalls int + promptErr error + promptFailFirstOnly bool +} + +func (f *fakeMessengerOrch) GetMessageQueue() *messagequeue.Service { return f.queue } + +func (f *fakeMessengerOrch) StartCreatedSession(_ context.Context, _, _, _, _ string, _, _, _ bool, _ []v1.MessageAttachment, _ []v1.EntityReference) (*orchexecutor.TaskExecution, error) { + f.startCalls++ + return &orchexecutor.TaskExecution{}, nil +} + +func (f *fakeMessengerOrch) PromptTask(_ context.Context, _, _, _, _ string, _ bool, _ []v1.MessageAttachment, _ bool) (*orchestrator.PromptResult, error) { + f.promptCalls++ + retriedAfterResume := f.promptFailFirstOnly && f.promptCalls > 1 + if f.promptErr != nil && !retriedAfterResume { + return nil, f.promptErr + } + return &orchestrator.PromptResult{}, nil +} + +func (f *fakeMessengerOrch) ResumeTaskSession(_ context.Context, _, _ string) (*orchexecutor.TaskExecution, error) { + f.resumeCalls++ + return &orchexecutor.TaskExecution{}, nil +} + +func newMessengerAdapter(t *testing.T, tasks *fakeMessengerTaskSvc, orch *fakeMessengerOrch) pluginsTaskMessengerAdapter { + t.Helper() + log, err := logger.NewFromZap(zap.NewNop()) + require.NoError(t, err) + if orch.queue == nil { + orch.queue = messagequeue.NewServiceMemory(log) + } + return pluginsTaskMessengerAdapter{tasks: tasks, orch: orch, log: log} +} + +func TestPluginsMessenger_RunningSessionQueues(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateRunning}} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + res, err := a.SendMessage(context.Background(), "t1", "", "do the thing", "plugin:p") + require.NoError(t, err) + require.Equal(t, "s1", res.SessionID) + require.Equal(t, "queued", res.Status) + require.Equal(t, 1, orch.queue.GetStatus(context.Background(), "s1").Count, "message should be enqueued") + require.Nil(t, tasks.created, "queued path records via the queue, not CreateMessage") + require.Zero(t, orch.startCalls+orch.promptCalls) +} + +func TestPluginsMessenger_CreatedSessionStarts(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateCreated, AgentProfileID: "prof-1"}} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + res, err := a.SendMessage(context.Background(), "t1", "", "kick off", "plugin:p") + require.NoError(t, err) + require.Equal(t, "started", res.Status) + require.Equal(t, 1, orch.startCalls) + require.NotNil(t, tasks.created, "the user message is recorded so it's tied to the launched turn") + require.Empty(t, tasks.deleted) +} + +func TestPluginsMessenger_WaitingSessionPrompts(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + res, err := a.SendMessage(context.Background(), "t1", "", "follow up", "plugin:p") + require.NoError(t, err) + require.Equal(t, "sent", res.Status) + require.Equal(t, 1, orch.promptCalls) + require.Zero(t, orch.startCalls) + require.Empty(t, tasks.deleted) +} + +func TestPluginsMessenger_PromptResumesWhenExecutionGone(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}} + orch := &fakeMessengerOrch{promptErr: orchexecutor.ErrExecutionNotFound, promptFailFirstOnly: true} + a := newMessengerAdapter(t, tasks, orch) + + res, err := a.SendMessage(context.Background(), "t1", "", "wake up", "plugin:p") + require.NoError(t, err) + require.Equal(t, "sent", res.Status) + require.Equal(t, 1, orch.resumeCalls, "a missing execution triggers a resume") + require.Equal(t, 2, orch.promptCalls, "prompt is retried after resume") + require.Empty(t, tasks.deleted) +} + +// TestPluginsMessenger_ResumeWaitTimeoutDeletesMessage pins the explicit +// choice: when the agent execution is gone, resume succeeds, but the session +// isn't ready in time (WaitForSessionReady errors), the send fails and the +// recorded user message is deleted — so a plugin retry doesn't stack a +// duplicate prompt (queueing is not idempotent). The retry prompt is never +// reached. +func TestPluginsMessenger_ResumeWaitTimeoutDeletesMessage(t *testing.T) { + tasks := &fakeMessengerTaskSvc{ + primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}, + waitErr: errors.New("session not ready in time"), + } + orch := &fakeMessengerOrch{promptErr: orchexecutor.ErrExecutionNotFound} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.SendMessage(context.Background(), "t1", "", "wake up", "plugin:p") + require.Error(t, err) + require.Equal(t, 1, orch.resumeCalls, "resume is attempted") + require.Equal(t, 1, orch.promptCalls, "the retry prompt is not reached after a wait timeout") + require.Equal(t, []string{"msg-1"}, tasks.deleted, "the recorded message is removed so a retry can't duplicate it") +} + +func TestPluginsMessenger_PromptFailureDeletesRecordedMessage(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateWaitingForInput, AgentExecutionID: "exec-1"}} + orch := &fakeMessengerOrch{promptErr: errors.New("dispatch boom")} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.SendMessage(context.Background(), "t1", "", "nope", "plugin:p") + require.Error(t, err) + require.Equal(t, []string{"msg-1"}, tasks.deleted, "a failed dispatch must not leave an orphan message") +} + +func TestPluginsMessenger_ExplicitSessionMustBelongToTask(t *testing.T) { + tasks := &fakeMessengerTaskSvc{byID: map[string]*taskmodels.TaskSession{ + "s1": {ID: "s1", TaskID: "other-task", State: taskmodels.TaskSessionStateRunning}, + }} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.SendMessage(context.Background(), "t1", "s1", "sneaky", "plugin:p") + require.Equal(t, codes.NotFound, status.Code(err), "a session from another task must not be reachable via a mismatched pair") +} + +func TestPluginsMessenger_NoPrimarySessionIsNotFound(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primaryErr: tasksqlite.ErrNoPrimarySession} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.SendMessage(context.Background(), "t1", "", "hello", "plugin:p") + require.Equal(t, codes.NotFound, status.Code(err)) +} + +func TestPluginsMessenger_TerminalSessionRejected(t *testing.T) { + tasks := &fakeMessengerTaskSvc{primary: &taskmodels.TaskSession{ID: "s1", TaskID: "t1", State: taskmodels.TaskSessionStateFailed}} + orch := &fakeMessengerOrch{} + a := newMessengerAdapter(t, tasks, orch) + + _, err := a.SendMessage(context.Background(), "t1", "", "hello", "plugin:p") + require.Equal(t, codes.FailedPrecondition, status.Code(err)) +} diff --git a/apps/backend/internal/backendapp/adapters_plugin_starter.go b/apps/backend/internal/backendapp/adapters_plugin_starter.go new file mode 100644 index 0000000000..6c6908310f --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_plugin_starter.go @@ -0,0 +1,48 @@ +package backendapp + +import ( + "context" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/constants" + "github.com/kandev/kandev/internal/common/logger" + orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +// pluginStarterService is the narrow slice of the orchestrator the start +// adapter needs, so its fire-and-forget launch is unit-testable with a fake. +// *orchestrator.Service satisfies it. +type pluginStarterService interface { + StartTask(ctx context.Context, taskID, agentProfileID, executorID, executorProfileID, priority, prompt, workflowStepID string, planMode, autoStart bool, attachments []v1.MessageAttachment) (*orchexecutor.TaskExecution, error) +} + +// pluginsTaskStarterAdapter adapts the orchestrator to the plugins package's +// taskStarter interface (Host data API CreateTask start_agent flag, ADR 0043 +// phase 2). It launches with empty agent/executor ids so the orchestrator +// resolves the workflow-step / workspace defaults itself — the same +// default-resolving launch path the office scheduler uses. +// +// StartTask returns immediately: the launch runs in a fire-and-forget goroutine +// on a detached, time-bounded context (matching the MCP launchAutoStartTask +// pattern), so a plugin's CreateTask RPC never blocks on the orchestrator's +// substantial inline start work, and the plugin's request deadline can't abort +// a launch of a task that already exists. Best-effort — a launch error is +// logged, never surfaced. +type pluginsTaskStarterAdapter struct { + orch pluginStarterService + log *logger.Logger +} + +func (a pluginsTaskStarterAdapter) StartTask(ctx context.Context, taskID string) error { + launchCtx := context.WithoutCancel(ctx) + go func() { + startCtx, cancel := context.WithTimeout(launchCtx, constants.AgentLaunchTimeout) + defer cancel() + if _, err := a.orch.StartTask(startCtx, taskID, "", "", "", "", "", "", false, false, nil); err != nil { + a.log.Warn("plugins: best-effort start_agent failed", zap.String("task_id", taskID), zap.Error(err)) + } + }() + return nil +} diff --git a/apps/backend/internal/backendapp/adapters_plugin_starter_test.go b/apps/backend/internal/backendapp/adapters_plugin_starter_test.go new file mode 100644 index 0000000000..9dd8de1cd1 --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_plugin_starter_test.go @@ -0,0 +1,113 @@ +package backendapp + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/require" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + orchexecutor "github.com/kandev/kandev/internal/orchestrator/executor" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakePluginStarter struct { + started chan string + err error + // blockUntil, when non-nil, holds StartTask open until closed, so a test + // can prove the adapter returned before the (slow) launch finished. + blockUntil chan struct{} +} + +func (f *fakePluginStarter) StartTask(ctx context.Context, taskID, _, _, _, _, _, _ string, _, _ bool, _ []v1.MessageAttachment) (*orchexecutor.TaskExecution, error) { + if f.blockUntil != nil { + select { + case <-f.blockUntil: + case <-ctx.Done(): + return nil, ctx.Err() + } + } + select { + case f.started <- taskID: + default: + } + return &orchexecutor.TaskExecution{}, f.err +} + +func newStarterAdapter(t *testing.T, orch pluginStarterService) pluginsTaskStarterAdapter { + t.Helper() + log, err := logger.NewFromZap(zap.NewNop()) + require.NoError(t, err) + return pluginsTaskStarterAdapter{orch: orch, log: log} +} + +func TestPluginsStarter_LaunchesAsynchronously(t *testing.T) { + orch := &fakePluginStarter{started: make(chan string, 1)} + a := newStarterAdapter(t, orch) + + require.NoError(t, a.StartTask(context.Background(), "task-1")) + select { + case id := <-orch.started: + require.Equal(t, "task-1", id) + case <-time.After(2 * time.Second): + t.Fatal("orchestrator StartTask was never invoked by the fire-and-forget goroutine") + } +} + +// TestPluginsStarter_ReturnsBeforeLaunchCompletes proves StartTask does not +// block the caller on the (potentially long) orchestrator launch path. +func TestPluginsStarter_ReturnsBeforeLaunchCompletes(t *testing.T) { + block := make(chan struct{}) + // Register the release before the goroutine can block on it (and before any + // t.Fatal path), so an early failure can't strand the launch goroutine + // until AgentLaunchTimeout. sync.Once makes the explicit release below and + // this cleanup safe to both run. + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(block) }) } + t.Cleanup(release) + + orch := &fakePluginStarter{started: make(chan string, 1), blockUntil: block} + a := newStarterAdapter(t, orch) + + require.NoError(t, a.StartTask(context.Background(), "task-1"), "must return immediately even while the launch is in flight") + release() // release the launch so the goroutine can finish + select { + case <-orch.started: + case <-time.After(2 * time.Second): + t.Fatal("launch goroutine never completed after unblock") + } +} + +// TestPluginsStarter_DetachesFromCallerContext proves a cancelled caller +// context does not abort the launch — the task already exists, so the launch +// runs on a detached context. +func TestPluginsStarter_DetachesFromCallerContext(t *testing.T) { + orch := &fakePluginStarter{started: make(chan string, 1)} + a := newStarterAdapter(t, orch) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel BEFORE the call + require.NoError(t, a.StartTask(ctx, "task-1")) + select { + case id := <-orch.started: + require.Equal(t, "task-1", id, "launch proceeds despite the cancelled caller context") + case <-time.After(2 * time.Second): + t.Fatal("launch was aborted by the cancelled caller context") + } +} + +func TestPluginsStarter_LaunchErrorIsSwallowed(t *testing.T) { + orch := &fakePluginStarter{started: make(chan string, 1), err: errors.New("no executor")} + a := newStarterAdapter(t, orch) + + require.NoError(t, a.StartTask(context.Background(), "task-1"), "a launch error is best-effort, never surfaced") + select { + case <-orch.started: + case <-time.After(2 * time.Second): + t.Fatal("orchestrator StartTask was never invoked") + } +} diff --git a/apps/backend/internal/backendapp/adapters_plugin_writer_test.go b/apps/backend/internal/backendapp/adapters_plugin_writer_test.go new file mode 100644 index 0000000000..ac74e7115a --- /dev/null +++ b/apps/backend/internal/backendapp/adapters_plugin_writer_test.go @@ -0,0 +1,96 @@ +package backendapp + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + + "github.com/kandev/kandev/internal/plugins" + taskmodels "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + v1 "github.com/kandev/kandev/pkg/api/v1" +) + +type fakePluginTaskWriteService struct { + lastCreate *taskservice.CreateTaskRequest + lastUpdate *taskservice.UpdateTaskRequest + lastID string +} + +func (f *fakePluginTaskWriteService) CreateTask(_ context.Context, req *taskservice.CreateTaskRequest) (*taskmodels.Task, error) { + f.lastCreate = req + return &taskmodels.Task{ID: "task-1", WorkspaceID: req.WorkspaceID, WorkflowID: req.WorkflowID, Title: req.Title}, nil +} + +func (f *fakePluginTaskWriteService) UpdateTask(_ context.Context, id string, req *taskservice.UpdateTaskRequest) (*taskmodels.Task, error) { + f.lastID = id + f.lastUpdate = req + return &taskmodels.Task{ID: id}, nil +} + +func TestPluginsTaskWriter_CreateMapsSourceToMetadata(t *testing.T) { + svc := &fakePluginTaskWriteService{} + a := pluginsTaskWriterAdapter{svc: svc} + + _, err := a.CreateTask(context.Background(), plugins.TaskCreateInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + Title: "Investigate", Description: "details", ParentID: "parent-1", Source: "plugin:acme", + }) + require.NoError(t, err) + require.Equal(t, "ws-1", svc.lastCreate.WorkspaceID) + require.Equal(t, "wf-1", svc.lastCreate.WorkflowID) + require.Equal(t, "step-1", svc.lastCreate.WorkflowStepID) + require.Equal(t, "parent-1", svc.lastCreate.ParentID) + require.Equal(t, "plugin:acme", svc.lastCreate.Metadata["source"], "provenance is stamped into task metadata") +} + +func TestPluginsTaskWriter_CreateWithoutSourceOmitsMetadata(t *testing.T) { + svc := &fakePluginTaskWriteService{} + a := pluginsTaskWriterAdapter{svc: svc} + + _, err := a.CreateTask(context.Background(), plugins.TaskCreateInput{WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "x"}) + require.NoError(t, err) + require.Nil(t, svc.lastCreate.Metadata, "no source → no metadata map") +} + +func TestPluginsTaskWriter_UpdateMapsFieldMask(t *testing.T) { + svc := &fakePluginTaskWriteService{} + a := pluginsTaskWriterAdapter{svc: svc} + + title := "Renamed" + state := "IN_PROGRESS" + step := "step-2" + _, err := a.UpdateTask(context.Background(), plugins.TaskUpdateInput{ID: "task-1", Title: &title, State: &state, WorkflowStepID: &step}) + require.NoError(t, err) + require.Equal(t, "task-1", svc.lastID) + require.Equal(t, "Renamed", *svc.lastUpdate.Title) + require.NotNil(t, svc.lastUpdate.State) + require.Equal(t, v1.TaskStateInProgress, *svc.lastUpdate.State) + require.Equal(t, "step-2", *svc.lastUpdate.WorkflowStepID) + require.Nil(t, svc.lastUpdate.Description, "an unset field stays nil") +} + +func TestPluginsTaskWriter_UpdateRejectsUnknownState(t *testing.T) { + svc := &fakePluginTaskWriteService{} + a := pluginsTaskWriterAdapter{svc: svc} + + bad := "NOT_A_STATE" + _, err := a.UpdateTask(context.Background(), plugins.TaskUpdateInput{ID: "task-1", State: &bad}) + require.Equal(t, codes.InvalidArgument, status.Code(err), "a bogus state must be rejected before reaching the service") + require.Nil(t, svc.lastUpdate, "the service is never called with an invalid state") +} + +// TestPluginsTaskWriter_UpdateRejectsSchedulingState pins that the +// orchestrator-owned SCHEDULING transient is not plugin-settable. +func TestPluginsTaskWriter_UpdateRejectsSchedulingState(t *testing.T) { + svc := &fakePluginTaskWriteService{} + a := pluginsTaskWriterAdapter{svc: svc} + + scheduling := string(v1.TaskStateScheduling) + _, err := a.UpdateTask(context.Background(), plugins.TaskUpdateInput{ID: "task-1", State: &scheduling}) + require.Equal(t, codes.InvalidArgument, status.Code(err)) + require.Nil(t, svc.lastUpdate) +} diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 69b28b18f6..e3464b40b3 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1003,6 +1003,17 @@ func initOfficeServices( services.Task.SetBlockerRepository(repos.Office) services.Task.SetCommentRepository(repos.Office) + // Wire the Host data API's late write dependencies (ADR 0043 phase 2): the + // task-message delivery path backs SendMessage (api_write:messages), and + // the orchestrator backs CreateTask's start_agent. The orchestrator is + // constructed after StartActivePlugins spawns boot-active plugins, so the + // plugins service reads these live rather than snapshotting (see + // SetWriteDeps). + if services.Plugins != nil { + messenger := pluginsTaskMessengerAdapter{tasks: services.Task, orch: orchestratorSvc, log: log} + services.Plugins.SetWriteDeps(messenger, pluginsTaskStarterAdapter{orch: orchestratorSvc, log: log}) + } + // Build feature-package services and wire all inter-service dependencies. services.OfficeSvcs = buildOfficeFeatureServices( repos.Office, repos.Task, repos.AgentSettings, cfgLoader, cfgWriter, configBasePath, diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 05cb19244b..c113b3812e 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -41,6 +41,9 @@ import ( wfmodels "github.com/kandev/kandev/internal/workflow/models" workflowservice "github.com/kandev/kandev/internal/workflow/service" "github.com/kandev/kandev/internal/workflowsync" + v1 "github.com/kandev/kandev/pkg/api/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories, dbPool *db.Pool, eventBus bus.EventBus, agentRegistry *registry.Registry, version string) (*Services, *agentsettingscontroller.Controller, error) { @@ -126,7 +129,7 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories 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), taskSvc) + pluginsSvc.SetDataSources(taskSvc, taskSvc, workflowSvc, agentSettingsController, analyticsservice.New(repos.Analytics), taskSvc, pluginsTaskWriterAdapter{svc: taskSvc}) } shareHTTP := initShareHandlers(dbPool, repos.Task, githubSvc, log, version) @@ -704,6 +707,76 @@ func (a pluginsUtilityAgentAdapter) GetAgentByID(ctx context.Context, id string) return &plugins.UtilityAgent{Name: agent.Name, AgentID: agent.AgentID, Model: agent.Model, Enabled: agent.Enabled}, nil } +// pluginsTaskWriterAdapter adapts the task service to the plugins package's +// taskWriter interface (Host data API CreateTask/UpdateTask write RPCs, ADR +// 0043 phase 2). It translates the plugins-local TaskCreateInput/TaskUpdateInput +// — which internal/plugins can't express as service.CreateTaskRequest without +// an import cycle — into the real service requests, so writes route through the +// same task.*-event-publishing service methods the REST/MCP API uses. The +// plugin's provenance is stamped into task metadata (`source`), since the Task +// model has no dedicated source column. +// pluginTaskWriteService is the narrow slice of the task service the write +// adapter needs, so the adapter's field mapping + state validation are +// unit-testable with a fake. *taskservice.Service satisfies it. +type pluginTaskWriteService interface { + CreateTask(ctx context.Context, req *taskservice.CreateTaskRequest) (*taskmodels.Task, error) + UpdateTask(ctx context.Context, id string, req *taskservice.UpdateTaskRequest) (*taskmodels.Task, error) +} + +type pluginsTaskWriterAdapter struct { + svc pluginTaskWriteService +} + +func (a pluginsTaskWriterAdapter) CreateTask(ctx context.Context, in plugins.TaskCreateInput) (*taskmodels.Task, error) { + var metadata map[string]interface{} + if in.Source != "" { + metadata = map[string]interface{}{"source": in.Source} + } + return a.svc.CreateTask(ctx, &taskservice.CreateTaskRequest{ + WorkspaceID: in.WorkspaceID, + WorkflowID: in.WorkflowID, + WorkflowStepID: in.WorkflowStepID, + Title: in.Title, + Description: in.Description, + ParentID: in.ParentID, + Metadata: metadata, + }) +} + +func (a pluginsTaskWriterAdapter) UpdateTask(ctx context.Context, in plugins.TaskUpdateInput) (*taskmodels.Task, error) { + req := &taskservice.UpdateTaskRequest{ + Title: in.Title, + Description: in.Description, + WorkflowStepID: in.WorkflowStepID, + } + if in.State != nil { + // v1.TaskState is a string type, so the cast can't fail — validate the + // value against the known enum here (the REST/MCP path validates state + // at its HTTP handler) so a plugin typo can't forward a bogus state to + // the service and risk persisting it. + state := v1.TaskState(*in.State) + if !validPluginTaskState(state) { + return nil, status.Errorf(codes.InvalidArgument, "invalid task state %q", *in.State) + } + req.State = &state + } + return a.svc.UpdateTask(ctx, in.ID, req) +} + +// validPluginTaskState reports whether state is a state a plugin may set via +// UpdateTask. SCHEDULING is intentionally excluded — it is an orchestrator-owned +// transient state, not a value a plugin should assign directly. +func validPluginTaskState(state v1.TaskState) bool { + switch state { + case v1.TaskStateTODO, v1.TaskStateCreated, v1.TaskStateInProgress, + v1.TaskStateReview, v1.TaskStateBlocked, v1.TaskStateWaitingForInput, + v1.TaskStateCompleted, v1.TaskStateFailed, v1.TaskStateCancelled: + return true + default: + return false + } +} + // workflowProviderAdapter adapts task service to workflow service's WorkflowProvider interface. type workflowProviderAdapter struct { svc *taskservice.Service diff --git a/apps/backend/internal/plugins/host.go b/apps/backend/internal/plugins/host.go index 380489e54d..c0e7d438b9 100644 --- a/apps/backend/internal/plugins/host.go +++ b/apps/backend/internal/plugins/host.go @@ -63,6 +63,19 @@ type pluginHost struct { sessionCodeStats sessionCodeStatsSource messageData messageDataSource + // taskWriter backs the CreateTask/UpdateTask write RPCs (ADR 0043 + // phase 2, capability api_write:tasks). Wired via SetDataSources like the + // readers — the task service is available at data-source wiring time. See + // host_write.go. + taskWriter taskWriter + + // writeDeps returns the live task messenger and task starter behind the + // SendMessage RPC (api_write:messages) and CreateTask's start_agent. Read + // live (not snapshotted at hostForPlugin) because the orchestrator is + // constructed after boot-active plugins spawn — same rationale as + // utilityDeps. nil on a bare test host. See host_write.go. + writeDeps func() (taskMessenger, taskStarter) + // utilityDeps returns the live utility-agent dependencies (ADR 0048) at // call time rather than a spawn-time snapshot. hostUtilityMgr is // constructed late in boot — after StartActivePlugins has already spawned diff --git a/apps/backend/internal/plugins/host_data.go b/apps/backend/internal/plugins/host_data.go index f98548bc15..dc273c47e9 100644 --- a/apps/backend/internal/plugins/host_data.go +++ b/apps/backend/internal/plugins/host_data.go @@ -53,6 +53,12 @@ func apiReadCapability(resource string) string { return "api_read:" + resource } +// apiWriteCapability formats resource as the api_write: capability +// name permissionDenied expects for the Host data API's write RPCs (ADR 0043). +func apiWriteCapability(resource string) string { + return "api_write:" + 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 @@ -182,13 +188,12 @@ type messageDataSource interface { // this falls back to the embedded Unimplemented reader rather than a nil // pointer dereference. +// Tasks returns the task accessor. Unlike the read-only accessors below, it +// mixes read (List/Get, api_read:tasks) and write (Create/Update, +// api_write:tasks) methods whose capabilities gate independently, so the gate +// cannot live at the accessor — each method checks its own capability (reads +// here, writes in host_write.go). 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} } @@ -242,28 +247,16 @@ func (h *pluginHost) Repositories() pluginsdk.RepositoryReader { return repositoryReader{host: h} } +// Messages mixes read (List, api_read:messages) and write (Send, +// api_write:messages) with independent capabilities, so — like Tasks() — the +// gate can't live at the accessor; each method checks its own capability (List +// here, Send in host_write.go). func (h *pluginHost) Messages() pluginsdk.MessageReader { - if !h.capabilities.CanRead(resourceMessages) { - return deniedMessageReader{} - } - if h.messageData == nil { - return h.UnimplementedHostData.Messages() - } return messageReader{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) { @@ -302,12 +295,6 @@ func (deniedRepositoryReader) List(context.Context, string, pluginsdk.Page) ([]p return nil, nil, permissionDenied(apiReadCapability(resourceRepositories)) } -type deniedMessageReader struct{} - -func (deniedMessageReader) List(context.Context, pluginsdk.MessageFilter, pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) { - return nil, nil, permissionDenied(apiReadCapability(resourceMessages)) -} - // ── Real readers ──────────────────────────────────────────────────────── // // Only ever returned once the resource's capability gate has passed (see the @@ -324,6 +311,12 @@ 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) { + if !r.host.capabilities.CanRead(resourceTasks) { + return nil, nil, permissionDenied(apiReadCapability(resourceTasks)) + } + if r.host.taskData == nil { + return r.host.UnimplementedHostData.Tasks().List(ctx, filter, page) + } workspaceIDs, err := r.host.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs) if err != nil { return nil, nil, err @@ -342,6 +335,12 @@ func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page // 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) { + if !r.host.capabilities.CanRead(resourceTasks) { + return nil, permissionDenied(apiReadCapability(resourceTasks)) + } + if r.host.taskData == nil { + return r.host.UnimplementedHostData.Tasks().Get(ctx, id) + } task, err := r.host.taskData.GetTask(ctx, id) if err != nil { if errors.Is(err, repoerrors.ErrTaskNotFound) { @@ -500,6 +499,12 @@ type messageReader struct{ host *pluginHost } // messageModelToDTO (kandev-system blocks stripped) before it reaches the // plugin. func (r messageReader) List(ctx context.Context, filter pluginsdk.MessageFilter, page pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) { + if !r.host.capabilities.CanRead(resourceMessages) { + return nil, nil, permissionDenied(apiReadCapability(resourceMessages)) + } + if r.host.messageData == nil { + return r.host.UnimplementedHostData.Messages().List(ctx, filter, page) + } // Each session/task/type value becomes its own SQL bind parameter; cap // their combined count well under SQLite's host-parameter limit // (~500-999) so a large filter fails fast with a clear InvalidArgument diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go index e3d0297ac3..64b8597a91 100644 --- a/apps/backend/internal/plugins/host_data_test.go +++ b/apps/backend/internal/plugins/host_data_test.go @@ -155,6 +155,80 @@ func (f *fakeMessageDataSource) ListMessagesForPlugin( return f.messages, nil } +// fakeTaskWriter records CreateTask/UpdateTask inputs and returns canned +// tasks, standing in for the backendapp adapter over internal/task/service. +type fakeTaskWriter struct { + lastCreate TaskCreateInput + lastUpdate TaskUpdateInput + createCalls int + updateCalls int + created *taskmodels.Task + updated *taskmodels.Task + createErr error + updateErr error +} + +func (f *fakeTaskWriter) CreateTask(_ context.Context, in TaskCreateInput) (*taskmodels.Task, error) { + f.createCalls++ + f.lastCreate = in + if f.createErr != nil { + return nil, f.createErr + } + if f.created != nil { + return f.created, nil + } + return &taskmodels.Task{ID: "task-created", WorkspaceID: in.WorkspaceID, WorkflowID: in.WorkflowID, Title: in.Title}, nil +} + +func (f *fakeTaskWriter) UpdateTask(_ context.Context, in TaskUpdateInput) (*taskmodels.Task, error) { + f.updateCalls++ + f.lastUpdate = in + if f.updateErr != nil { + return nil, f.updateErr + } + if f.updated != nil { + return f.updated, nil + } + return &taskmodels.Task{ID: in.ID, Title: "updated"}, nil +} + +// fakeMessenger records SendMessage calls, standing in for the backendapp +// adapter over the task service + orchestrator delivery path. +type fakeMessenger struct { + calls int + lastTaskID string + lastSession string + lastText string + lastSource string + result PluginMessageResult + err error +} + +func (f *fakeMessenger) SendMessage(_ context.Context, taskID, sessionID, text, source string) (PluginMessageResult, error) { + f.calls++ + f.lastTaskID, f.lastSession, f.lastText, f.lastSource = taskID, sessionID, text, source + if f.err != nil { + return PluginMessageResult{}, f.err + } + if f.result == (PluginMessageResult{}) { + return PluginMessageResult{SessionID: "session-x", Status: "sent"}, nil + } + return f.result, nil +} + +// fakeTaskStarter records StartTask calls behind CreateTask's start_agent. +type fakeTaskStarter struct { + calls int + lastID string + err error +} + +func (f *fakeTaskStarter) StartTask(_ context.Context, taskID string) error { + f.calls++ + f.lastID = taskID + return f.err +} + // 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. @@ -168,6 +242,9 @@ type testDataHost struct { messages *fakeMessageDataSource utilAgents *fakeUtilityAgentSource utilRun *fakeUtilityRunner + taskWriter *fakeTaskWriter + messenger *fakeMessenger + starter *fakeTaskStarter } // newTestDataHost builds a fully-wired pluginHost (every Host data API @@ -183,6 +260,9 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { messages: &fakeMessageDataSource{}, utilAgents: &fakeUtilityAgentSource{}, utilRun: &fakeUtilityRunner{text: "ok"}, + taskWriter: &fakeTaskWriter{}, + messenger: &fakeMessenger{}, + starter: &fakeTaskStarter{}, } d.host = &pluginHost{ pluginID: "p1", @@ -193,10 +273,14 @@ func newTestDataHost(caps manifest.Capabilities) *testDataHost { agentProfiles: d.profiles, sessionCodeStats: d.codeStats, messageData: d.messages, + taskWriter: d.taskWriter, configs: &fakeConfigReader{configs: map[string]any{utilityAgentConfigKey: "utility-agent-42"}}, utilityDeps: func() (utilityAgentSource, utilityRunner) { return d.utilAgents, d.utilRun }, + writeDeps: func() (taskMessenger, taskStarter) { + return d.messenger, d.starter + }, } return d } diff --git a/apps/backend/internal/plugins/host_data_wire_test.go b/apps/backend/internal/plugins/host_data_wire_test.go index 862dc41020..60c1c84989 100644 --- a/apps/backend/internal/plugins/host_data_wire_test.go +++ b/apps/backend/internal/plugins/host_data_wire_test.go @@ -254,6 +254,68 @@ func TestPluginHostData_Wire_Messages(t *testing.T) { }) } +// TestPluginHostData_Wire_Writes proves the write RPCs (CreateTask/UpdateTask/ +// SendMessage) travel intact over the real broker transport and enforce +// api_write: host-side, exactly like the reads. A plugin declaring +// api_write:tasks (but not messages) can create/update tasks over the wire, and +// its undeclared message send is denied with the exact wire message on the +// same connection. +func TestPluginHostData_Wire_Writes(t *testing.T) { + t.Run("TasksCreateUpdateSucceed_MessageDenied", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.created = &taskmodels.Task{ID: "task-new", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate"} + d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "Renamed"} + host := dialPluginHostOverWire(t, d.host) + + created, err := host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", StartAgent: true, + }) + require.NoError(t, err) + require.Equal(t, "task-new", created.ID) + require.Equal(t, "plugin:p1", d.taskWriter.lastCreate.Source) + require.Equal(t, 1, d.starter.calls, "start_agent should reach the wired starter") + + title := "Renamed" + updated, err := host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Title: &title}) + require.NoError(t, err) + require.Equal(t, "task-1", updated.ID) + + // The undeclared message write is denied, per-resource, on the same host. + _, err = host.Messages().Send(context.Background(), "task-1", "", "hi") + 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_write:messages' not declared", st.Message()) + }) + + t.Run("SendMessageSucceeds", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"messages"}}) + d.messenger.result = PluginMessageResult{SessionID: "session-2", Status: "queued"} + host := dialPluginHostOverWire(t, d.host) + + dispatch, err := host.Messages().Send(context.Background(), "task-1", "session-2", "from the plugin") + require.NoError(t, err) + require.Equal(t, "session-2", dispatch.SessionID) + require.Equal(t, "queued", dispatch.Status) + require.Equal(t, "from the plugin", d.messenger.lastText) + require.Equal(t, "plugin:p1", d.messenger.lastSource) + }) + + t.Run("TaskCreateDeniedWithoutCapability", func(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{}) + host := dialPluginHostOverWire(t, d.host) + + _, err := host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "x", WorkspaceID: "ws-1", WorkflowID: "wf-1"}) + 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_write:tasks' not declared", st.Message()) + require.Equal(t, 0, d.taskWriter.createCalls, "gate must block before the service call") + }) +} + // TestPluginHostData_Wire_InvokeUtilityAgent proves the agent_invoke gate and // the one-shot completion round-trip over the real transport: an undeclared // manifest is PermissionDenied, and a declared one resolves the configured diff --git a/apps/backend/internal/plugins/host_write.go b/apps/backend/internal/plugins/host_write.go new file mode 100644 index 0000000000..045294f806 --- /dev/null +++ b/apps/backend/internal/plugins/host_write.go @@ -0,0 +1,272 @@ +// host_write.go implements pluginHost's Host data API WRITE surface (ADR 0043 +// phase 2): the CreateTask/UpdateTask methods on the task accessor +// (Host.Tasks()) and the Send method on the message accessor (Host.Messages()). +// Writes are gated on api_write: — undeclared → gRPC PermissionDenied, +// exactly mirroring the read gating in host_data.go. +// +// Every write routes through the same first-party layer the REST/MCP API uses, +// never a repository — that is how task.* events fire and how a message reaches +// the agent through the orchestrator's real delivery path (queue when the +// session is running, resume/start it otherwise). The service types can't be +// referenced here without an import cycle (see SetDataSources' doc), so task +// writes go through a narrow taskWriter interface and message delivery through a +// narrow taskMessenger interface, both satisfied by backendapp adapters. +package plugins + +import ( + "context" + "errors" + + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/pkg/pluginsdk" +) + +// ── Narrow write data-source interfaces ───────────────────────────────── +// +// Both are satisfied by backendapp adapters (the service request types would +// create an import cycle here): taskWriter over internal/task/service, +// taskMessenger over the task service + orchestrator delivery path. + +// TaskCreateInput is the plugins-local task-create request a taskWriter adapter +// translates into internal/task/service.CreateTaskRequest. Source is the +// "plugin:" provenance the host stamps (a plugin cannot set it). +type TaskCreateInput struct { + WorkspaceID string + WorkflowID string + WorkflowStepID string + Title string + Description string + ParentID string + Source string +} + +// TaskUpdateInput is the plugins-local task-update request a taskWriter adapter +// translates into internal/task/service.UpdateTaskRequest. A nil field means +// "leave unchanged". +type TaskUpdateInput struct { + ID string + Title *string + Description *string + State *string + WorkflowStepID *string +} + +// PluginMessageResult is the outcome of delivering a message to a task session, +// returned by a taskMessenger adapter. Status is "queued" | "sent" | "started". +type PluginMessageResult struct { + SessionID string + Status string +} + +// taskWriter is the narrow slice of the task service the CreateTask/UpdateTask +// RPCs need, adapted by backendapp to avoid an internal/task/service import +// cycle. Both methods return the persisted *taskmodels.Task so the reader can +// map it to the wire DTO. +type taskWriter interface { + CreateTask(ctx context.Context, in TaskCreateInput) (*taskmodels.Task, error) + UpdateTask(ctx context.Context, in TaskUpdateInput) (*taskmodels.Task, error) +} + +// taskMessenger delivers a prompt to a task session through the orchestrator's +// real message-delivery path (resolve session, record the user message, then +// queue/resume/start by session state — the same behavior as message_task). +// Source is the "plugin:" provenance stamped on the recorded message. An +// empty sessionID targets the task's primary session. +type taskMessenger interface { + SendMessage(ctx context.Context, taskID, sessionID, text, source string) (PluginMessageResult, error) +} + +// taskStarter is the narrow slice of the orchestrator the CreateTask RPC needs +// to honor start_agent, adapted by backendapp. Its StartTask returns promptly: +// the adapter launches the agent asynchronously (fire-and-forget on a detached, +// time-bounded context), so a plugin's CreateTask RPC never blocks on the +// orchestrator's launch path. Best-effort — a launch failure never fails task +// creation. +type taskStarter interface { + StartTask(ctx context.Context, taskID string) error +} + +// pluginSource is the "plugin:" provenance stamped on rows this plugin +// creates, so a created task/message is attributable and a plugin can never +// spoof another origin. +func (h *pluginHost) pluginSource() string { + return "plugin:" + h.pluginID +} + +// writeDependencies returns the live task messenger and task starter (wired via +// SetWriteDeps). Read live rather than snapshotted at hostForPlugin time for +// the same reason as the utility agent (ADR 0048): the orchestrator is +// constructed after StartActivePlugins spawns boot-active plugins, so a +// snapshot would strand those hosts. nil on a bare test host. +func (h *pluginHost) writeDependencies() (taskMessenger, taskStarter) { + if h.writeDeps == nil { + return nil, nil + } + return h.writeDeps() +} + +// ── Task writes (api_write:tasks) ─────────────────────────────────────── + +func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) { + if !r.host.capabilities.CanWrite(resourceTasks) { + return nil, permissionDenied(apiWriteCapability(resourceTasks)) + } + if r.host.taskWriter == nil { + return r.host.UnimplementedHostData.Tasks().Create(ctx, in) + } + if in.Title == "" { + return nil, invalidArgument("title is required") + } + workspaceID, workflowID, err := r.host.resolveCreatePlacement(ctx, in) + if err != nil { + return nil, err + } + created, err := r.host.taskWriter.CreateTask(ctx, TaskCreateInput{ + WorkspaceID: workspaceID, + WorkflowID: workflowID, + WorkflowStepID: strDeref(in.WorkflowStepID), + Title: in.Title, + Description: in.Description, + ParentID: strDeref(in.ParentID), + Source: r.host.pluginSource(), + }) + if err != nil { + return nil, err + } + if in.StartAgent { + r.host.startTaskBestEffort(ctx, created.ID) + } + dto := taskModelToDTO(created) + return &dto, nil +} + +func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) { + if !r.host.capabilities.CanWrite(resourceTasks) { + return nil, permissionDenied(apiWriteCapability(resourceTasks)) + } + if r.host.taskWriter == nil { + return r.host.UnimplementedHostData.Tasks().Update(ctx, in) + } + if in.ID == "" { + return nil, invalidArgument("id is required") + } + updated, err := r.host.taskWriter.UpdateTask(ctx, TaskUpdateInput{ + ID: in.ID, + Title: in.Title, + Description: in.Description, + State: in.State, + WorkflowStepID: in.WorkflowStepID, + }) + if err != nil { + if errors.Is(err, repoerrors.ErrTaskNotFound) { + return nil, taskNotFound(in.ID) + } + return nil, err + } + dto := taskModelToDTO(updated) + return &dto, nil +} + +// resolveCreatePlacement fills the workspace and workflow a created task lands +// in when the plugin leaves them empty, mirroring the REST/MCP "sane default" +// behavior: an empty workspace_id resolves to the single workspace (ambiguous +// when there are zero or many → InvalidArgument), and an empty workflow_id +// resolves to that workspace's first workflow. Both defaulters need the read +// data sources; if those aren't wired the plugin must pass the ids explicitly. +func (h *pluginHost) resolveCreatePlacement(ctx context.Context, in pluginsdk.CreateTaskInput) (string, string, error) { + workspaceID := in.WorkspaceID + if workspaceID == "" { + resolved, err := h.defaultWorkspaceID(ctx) + if err != nil { + return "", "", err + } + workspaceID = resolved + } + workflowID := in.WorkflowID + if workflowID == "" { + resolved, err := h.defaultWorkflowID(ctx, workspaceID) + if err != nil { + return "", "", err + } + workflowID = resolved + } + return workspaceID, workflowID, nil +} + +func (h *pluginHost) defaultWorkspaceID(ctx context.Context) (string, error) { + if h.taskData == nil { + return "", invalidArgument("workspace_id is required") + } + workspaces, err := h.taskData.ListWorkspaces(ctx) + if err != nil { + return "", err + } + if len(workspaces) != 1 { + return "", invalidArgument("workspace_id is required: cannot resolve a default among the instance's workspaces") + } + return workspaces[0].ID, nil +} + +func (h *pluginHost) defaultWorkflowID(ctx context.Context, workspaceID string) (string, error) { + if h.workflows == nil { + return "", invalidArgument("workflow_id is required") + } + workflows, err := h.workflows.ListWorkflows(ctx, workspaceID, false) + if err != nil { + return "", err + } + if len(workflows) == 0 { + return "", invalidArgument("workflow_id is required: workspace has no workflow to default to") + } + // ListWorkflows returns workflows in sort order, so the first is the + // workspace's default landing workflow. + return workflows[0].ID, nil +} + +// startTaskBestEffort asks the wired starter to launch an agent on the freshly +// created task when the plugin requested start_agent. Best-effort and +// non-fatal, matching the REST/MCP path's asynchronous auto-start: the starter +// returns promptly (it launches on a detached, time-bounded context — see the +// taskStarter doc), and a missing starter or a launch error just leaves the +// task on the board for a manual start. +func (h *pluginHost) startTaskBestEffort(ctx context.Context, taskID string) { + _, starter := h.writeDependencies() + if starter == nil { + return + } + _ = starter.StartTask(ctx, taskID) +} + +// ── Message send (api_write:messages) ─────────────────────────────────── + +func (r messageReader) Send(ctx context.Context, taskID, sessionID, text string) (*pluginsdk.MessageDispatch, error) { + if !r.host.capabilities.CanWrite(resourceMessages) { + return nil, permissionDenied(apiWriteCapability(resourceMessages)) + } + messenger, _ := r.host.writeDependencies() + if messenger == nil { + return r.host.UnimplementedHostData.Messages().Send(ctx, taskID, sessionID, text) + } + if taskID == "" { + return nil, invalidArgument("task_id is required") + } + if text == "" { + return nil, invalidArgument("text is required") + } + result, err := messenger.SendMessage(ctx, taskID, sessionID, text, r.host.pluginSource()) + if err != nil { + return nil, err + } + return &pluginsdk.MessageDispatch{SessionID: result.SessionID, Status: result.Status}, nil +} + +// strDeref returns the pointed-to string, or "" when the pointer is nil — +// translating the SDK's *string "absent" convention into the plain strings the +// task service's CreateTaskRequest uses. +func strDeref(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/apps/backend/internal/plugins/host_write_test.go b/apps/backend/internal/plugins/host_write_test.go new file mode 100644 index 0000000000..6ae4b55feb --- /dev/null +++ b/apps/backend/internal/plugins/host_write_test.go @@ -0,0 +1,255 @@ +package plugins + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/plugins/manifest" + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository/repoerrors" + "github.com/kandev/kandev/pkg/pluginsdk" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// ── Write gating: denied without api_write: ─────────────────── + +func TestPluginHost_Tasks_CreateDeniedWithoutWriteCapability(t *testing.T) { + // api_read:tasks is granted but api_write:tasks is NOT — proving the two + // capabilities gate independently: a read-only plugin cannot write. + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}}) + + _, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "x", WorkspaceID: "ws-1", WorkflowID: "wf-1"}) + assertPermissionDenied(t, err, "api_write:tasks") + + _, err = d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1"}) + assertPermissionDenied(t, err, "api_write:tasks") + + if d.taskWriter.createCalls != 0 || d.taskWriter.updateCalls != 0 { + t.Fatalf("task writer called despite missing api_write:tasks (create=%d update=%d)", d.taskWriter.createCalls, d.taskWriter.updateCalls) + } +} + +func TestPluginHost_Messages_SendDeniedWithoutWriteCapability(t *testing.T) { + // api_read:messages granted but api_write:messages is NOT — the read and + // write capabilities on the messages resource gate independently. + d := newTestDataHost(manifest.Capabilities{APIRead: []string{"messages"}}) + + _, err := d.host.Messages().Send(context.Background(), "task-1", "", "hello") + assertPermissionDenied(t, err, "api_write:messages") + if d.messenger.calls != 0 { + t.Fatalf("messenger called %d times despite missing api_write:messages", d.messenger.calls) + } +} + +// TestPluginHost_Tasks_WriteOnlyCannotRead is the mirror of the read-only case: +// a plugin with only api_write:tasks can Create but cannot List/Get — the two +// capabilities are enforced per-method on the same accessor. +func TestPluginHost_Tasks_WriteOnlyCannotRead(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + + _, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:tasks") + + _, err = d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "x", WorkspaceID: "ws-1", WorkflowID: "wf-1"}) + if err != nil { + t.Fatalf("Create() with api_write:tasks unexpected error: %v", err) + } +} + +// ── Task create ───────────────────────────────────────────────────────── + +func TestPluginHost_Tasks_CreateSucceedsAndStampsSource(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.created = &taskmodels.Task{ID: "task-9", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate"} + + task, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", Description: "details", + }) + if err != nil { + t.Fatalf("Create() unexpected error: %v", err) + } + if task == nil || task.ID != "task-9" { + t.Fatalf("Create() = %+v, want task-9", task) + } + if d.taskWriter.createCalls != 1 { + t.Fatalf("task writer create calls = %d, want 1", d.taskWriter.createCalls) + } + if d.taskWriter.lastCreate.Source != "plugin:p1" { + t.Fatalf("create source = %q, want plugin:p1 (server-stamped provenance)", d.taskWriter.lastCreate.Source) + } + if d.starter.calls != 0 { + t.Fatalf("start_agent not requested but starter called %d times", d.starter.calls) + } +} + +func TestPluginHost_Tasks_CreateStartsAgentWhenRequested(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.created = &taskmodels.Task{ID: "task-9", WorkspaceID: "ws-1", WorkflowID: "wf-1"} + + _, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", StartAgent: true, + }) + if err != nil { + t.Fatalf("Create() unexpected error: %v", err) + } + if d.starter.calls != 1 || d.starter.lastID != "task-9" { + t.Fatalf("starter calls=%d lastID=%q, want 1 call for task-9", d.starter.calls, d.starter.lastID) + } +} + +// TestPluginHost_Tasks_CreateStartAgentFailureIsNonFatal proves start_agent is +// best-effort: a launch error does not fail task creation (the task is already +// created and its event has fired). +func TestPluginHost_Tasks_CreateStartAgentFailureIsNonFatal(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.created = &taskmodels.Task{ID: "task-9"} + d.starter.err = errors.New("no executor available") + + task, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", StartAgent: true, + }) + if err != nil { + t.Fatalf("Create() should not fail on a best-effort start error: %v", err) + } + if task == nil || task.ID != "task-9" { + t.Fatalf("Create() = %+v, want the created task returned despite start failure", task) + } +} + +func TestPluginHost_Tasks_CreateRequiresTitle(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + _, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{WorkspaceID: "ws-1", WorkflowID: "wf-1"}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Create() without title error = %v, want InvalidArgument", err) + } + if d.taskWriter.createCalls != 0 { + t.Fatalf("task writer called despite empty title") + } +} + +// TestPluginHost_Tasks_CreateResolvesDefaults proves an omitted workspace_id / +// workflow_id resolves to the single workspace and that workspace's first +// workflow, mirroring REST/MCP default placement. +func TestPluginHost_Tasks_CreateResolvesDefaults(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-only"}} + d.workflows.workflows = map[string][]*taskmodels.Workflow{ + "ws-only": {{ID: "wf-first", WorkspaceID: "ws-only"}, {ID: "wf-second", WorkspaceID: "ws-only"}}, + } + + _, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "Investigate"}) + if err != nil { + t.Fatalf("Create() unexpected error: %v", err) + } + if d.taskWriter.lastCreate.WorkspaceID != "ws-only" || d.taskWriter.lastCreate.WorkflowID != "wf-first" { + t.Fatalf("resolved placement = ws=%q wf=%q, want ws-only/wf-first", d.taskWriter.lastCreate.WorkspaceID, d.taskWriter.lastCreate.WorkflowID) + } +} + +func TestPluginHost_Tasks_CreateAmbiguousWorkspaceIsInvalidArgument(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}, {ID: "ws-2"}} + + _, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{Title: "Investigate"}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Create() with ambiguous workspace error = %v, want InvalidArgument", err) + } + if d.taskWriter.createCalls != 0 { + t.Fatalf("task writer called despite unresolvable workspace default") + } +} + +// ── Task update ───────────────────────────────────────────────────────── + +func TestPluginHost_Tasks_UpdateSucceedsWithMask(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "Renamed"} + + title := "Renamed" + state := "IN_PROGRESS" + _, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Title: &title, State: &state}) + if err != nil { + t.Fatalf("Update() unexpected error: %v", err) + } + if d.taskWriter.lastUpdate.ID != "task-1" { + t.Fatalf("update id = %q, want task-1", d.taskWriter.lastUpdate.ID) + } + if d.taskWriter.lastUpdate.Title == nil || *d.taskWriter.lastUpdate.Title != "Renamed" { + t.Fatalf("update title = %v, want Renamed", d.taskWriter.lastUpdate.Title) + } + if d.taskWriter.lastUpdate.Description != nil { + t.Fatalf("unset description must stay nil, got %v", d.taskWriter.lastUpdate.Description) + } +} + +func TestPluginHost_Tasks_UpdateRequiresID(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + _, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{}) + if status.Code(err) != codes.InvalidArgument { + t.Fatalf("Update() without id error = %v, want InvalidArgument", err) + } +} + +func TestPluginHost_Tasks_UpdateNotFoundMapsToGRPCNotFound(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}}) + d.taskWriter.updateErr = repoerrors.ErrTaskNotFound + + _, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "no-such-task"}) + if status.Code(err) != codes.NotFound { + t.Fatalf("Update() of missing task error = %v, want NotFound", err) + } +} + +// ── Message send ──────────────────────────────────────────────────────── + +func TestPluginHost_Messages_SendSucceedsAndStampsSource(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"messages"}}) + d.messenger.result = PluginMessageResult{SessionID: "session-9", Status: "queued"} + + dispatch, err := d.host.Messages().Send(context.Background(), "task-1", "session-9", "please rerun the tests") + if err != nil { + t.Fatalf("Send() unexpected error: %v", err) + } + if dispatch == nil || dispatch.SessionID != "session-9" || dispatch.Status != "queued" { + t.Fatalf("Send() = %+v, want session-9/queued", dispatch) + } + if d.messenger.calls != 1 { + t.Fatalf("messenger calls = %d, want 1 (routes through the orchestrator delivery path)", d.messenger.calls) + } + if d.messenger.lastTaskID != "task-1" || d.messenger.lastSession != "session-9" || d.messenger.lastText != "please rerun the tests" { + t.Fatalf("messenger recorded taskID=%q session=%q text=%q", d.messenger.lastTaskID, d.messenger.lastSession, d.messenger.lastText) + } + if d.messenger.lastSource != "plugin:p1" { + t.Fatalf("message source = %q, want plugin:p1 (server-stamped provenance)", d.messenger.lastSource) + } +} + +// TestPluginHost_Messages_ReadAndWriteGateIndependently proves List +// (api_read:messages) and Send (api_write:messages) are enforced separately on +// the shared Messages() accessor: a write-only manifest can Send but not List. +func TestPluginHost_Messages_ReadAndWriteGateIndependently(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"messages"}}) + + _, _, err := d.host.Messages().List(context.Background(), pluginsdk.MessageFilter{}, pluginsdk.Page{}) + assertPermissionDenied(t, err, "api_read:messages") + + if _, err := d.host.Messages().Send(context.Background(), "task-1", "", "hi"); err != nil { + t.Fatalf("Send() with api_write:messages unexpected error: %v", err) + } +} + +func TestPluginHost_Messages_SendRequiresTaskAndText(t *testing.T) { + d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"messages"}}) + + if _, err := d.host.Messages().Send(context.Background(), "", "", "body"); status.Code(err) != codes.InvalidArgument { + t.Fatalf("Send() without task_id error = %v, want InvalidArgument", err) + } + if _, err := d.host.Messages().Send(context.Background(), "task-1", "", ""); status.Code(err) != codes.InvalidArgument { + t.Fatalf("Send() without text error = %v, want InvalidArgument", err) + } + if d.messenger.calls != 0 { + t.Fatalf("messenger called %d times despite invalid input", d.messenger.calls) + } +} diff --git a/apps/backend/internal/plugins/runtime/manager_test.go b/apps/backend/internal/plugins/runtime/manager_test.go index a40ee746fd..6cd7eaabc9 100644 --- a/apps/backend/internal/plugins/runtime/manager_test.go +++ b/apps/backend/internal/plugins/runtime/manager_test.go @@ -30,10 +30,62 @@ type fakeHost struct { mu sync.Mutex states map[string]map[string]any setCh chan struct{} + + // Host data API write round-trip recording (ADR 0043 phase 2). writeCh + // signals a CreateTask arrived so tests can synchronize without a sleep; + // createdTitle/lastSendText capture what the plugin sent over the real + // subprocess transport. + writeCh chan struct{} + createdTitle string + lastSendTask string + lastSendText string } func newFakeHost() *fakeHost { - return &fakeHost{states: map[string]map[string]any{}, setCh: make(chan struct{}, 16)} + return &fakeHost{states: map[string]map[string]any{}, setCh: make(chan struct{}, 16), writeCh: make(chan struct{}, 16)} +} + +// Tasks/Messages override UnimplementedHostData so the fixture's write probe +// (CreateTask + SendMessage) round-trips to this fake host over the real +// subprocess gRPC transport. This fake does not enforce capabilities — that is +// covered against a real *pluginHost in internal/plugins' wire tests; here we +// only prove the write RPC reaches the host and its result returns. +func (h *fakeHost) Tasks() pluginsdk.TaskReader { return fakeHostTaskReader{h} } +func (h *fakeHost) Messages() pluginsdk.MessageReader { return fakeHostMessageReader{h} } + +type fakeHostTaskReader struct{ h *fakeHost } + +func (fakeHostTaskReader) List(context.Context, pluginsdk.TaskFilter, pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) { + return nil, nil, nil +} +func (fakeHostTaskReader) Get(context.Context, string) (*pluginsdk.Task, error) { return nil, nil } +func (fakeHostTaskReader) Update(context.Context, pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) { + return nil, nil +} + +func (r fakeHostTaskReader) Create(_ context.Context, in pluginsdk.CreateTaskInput) (*pluginsdk.Task, error) { + r.h.mu.Lock() + r.h.createdTitle = in.Title + r.h.mu.Unlock() + select { + case r.h.writeCh <- struct{}{}: + default: + } + return &pluginsdk.Task{ID: "task-from-host", Title: in.Title, WorkspaceID: in.WorkspaceID, WorkflowID: in.WorkflowID}, nil +} + +type fakeHostMessageReader struct{ h *fakeHost } + +func (fakeHostMessageReader) List(context.Context, pluginsdk.MessageFilter, pluginsdk.Page) ([]pluginsdk.Message, *pluginsdk.PageInfo, error) { + return nil, nil, nil +} + +func (r fakeHostMessageReader) Send(_ context.Context, taskID, sessionID, text string) (*pluginsdk.MessageDispatch, error) { + r.h.mu.Lock() + r.h.lastSendTask = taskID + r.h.lastSendText = text + r.h.mu.Unlock() + return &pluginsdk.MessageDispatch{SessionID: "session-from-host", Status: "queued"}, nil } func (h *fakeHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { @@ -69,6 +121,12 @@ func (h *fakeHost) get(scope, scopeID, key string) (map[string]any, bool) { return v, ok } +func (h *fakeHost) writeProbe() (title, sendTask, sendText string) { + h.mu.Lock() + defer h.mu.Unlock() + return h.createdTitle, h.lastSendTask, h.lastSendText +} + // buildFixtureRecord copies the real fixture plugin binary into a fresh // InstallPath and returns a *store.Record ready for Manager.Start, // skipping the test if -short suppressed the fixture build. @@ -169,6 +227,30 @@ func TestManager_StartDeliverEventWebhookStop(t *testing.T) { } }) + t.Run("HandleWebhook write probe round-trips CreateTask/SendMessage to Host", func(t *testing.T) { + resp, err := remote.HandleWebhook(ctx, &pluginsdk.WebhookRequest{WebhookKey: "write"}) + if err != nil { + t.Fatalf("HandleWebhook(write) unexpected error: %v", err) + } + if string(resp.Body) != "ok" { + t.Fatalf("HandleWebhook(write).Body = %q, want ok", resp.Body) + } + select { + case <-host.writeCh: + case <-time.After(5 * time.Second): + t.Fatal("plugin never called Host.Tasks().Create over the subprocess transport") + } + title, sendTask, sendText := host.writeProbe() + if title != "fixture write probe" { + t.Fatalf("recorded created task title = %q, want %q", title, "fixture write probe") + } + // The message send targets the task id the host returned from Create, + // proving the two write RPCs chain over the real transport. + if sendTask != "task-from-host" || sendText != "fixture probe message" { + t.Fatalf("recorded message = (task %q, text %q), want (task-from-host, fixture probe message)", sendTask, sendText) + } + }) + t.Run("Ping succeeds against the live subprocess", func(t *testing.T) { if err := m.Ping(rec.ID); err != nil { t.Fatalf("Ping() unexpected error: %v", err) diff --git a/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go b/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go index a15484243a..648aacc0ee 100644 --- a/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go +++ b/apps/backend/internal/plugins/runtime/testdata/fixtureplugin/main.go @@ -20,7 +20,10 @@ import ( // through its own fake Host implementation. // - HandleWebhook echoes the request body back as the response body, // except webhook key "crash" which exits the process immediately (to -// exercise crash detection + restart). +// exercise crash detection + restart), and webhook key "write" which +// drives the Host data API write RPCs (CreateTask then SendMessage to the +// returned task) so a test can observe them arrive at the Host over the +// real subprocess transport. type fixturePlugin struct { pluginsdk.UnimplementedPlugin } @@ -36,13 +39,39 @@ func (p *fixturePlugin) OnEvent(ctx context.Context, e *pluginsdk.Event) error { }) } -func (p *fixturePlugin) HandleWebhook(_ context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) { +func (p *fixturePlugin) HandleWebhook(ctx context.Context, req *pluginsdk.WebhookRequest) (*pluginsdk.WebhookResponse, error) { if req.WebhookKey == "crash" { os.Exit(1) } + if req.WebhookKey == "write" { + return p.handleWrite(ctx) + } return &pluginsdk.WebhookResponse{Status: 200, Body: req.Body}, nil } +// handleWrite exercises the Host data API write RPCs end to end: CreateTask, +// then SendMessage to the task the Host returns. It responds "ok" on success so +// the test can assert the round-trip completed; a Host error is surfaced as a +// 500 with the error text. +func (p *fixturePlugin) handleWrite(ctx context.Context) (*pluginsdk.WebhookResponse, error) { + host := p.Host() + if host == nil { + return &pluginsdk.WebhookResponse{Status: 500, Body: []byte("no host")}, nil + } + task, err := host.Tasks().Create(ctx, pluginsdk.CreateTaskInput{ + WorkspaceID: "ws-probe", + WorkflowID: "wf-probe", + Title: "fixture write probe", + }) + if err != nil { + return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil + } + if _, err := host.Messages().Send(ctx, task.ID, "", "fixture probe message"); err != nil { + return &pluginsdk.WebhookResponse{Status: 500, Body: []byte(err.Error())}, nil + } + return &pluginsdk.WebhookResponse{Status: 200, Body: []byte("ok")}, nil +} + func main() { pluginsdk.Serve(&fixturePlugin{}) } diff --git a/apps/backend/internal/plugins/service.go b/apps/backend/internal/plugins/service.go index 5e8a58efcf..bf07f81df4 100644 --- a/apps/backend/internal/plugins/service.go +++ b/apps/backend/internal/plugins/service.go @@ -94,11 +94,19 @@ type Service struct { agentProfiles agentProfileDataSource sessionCodeStats sessionCodeStatsSource messageData messageDataSource + taskWriter taskWriter // Utility agent invocation (ADR 0048), wired via SetUtilityAgent. utilityAgents utilityAgentSource utilityRunner utilityRunner + // Host data API write dependencies wired late via SetWriteDeps (ADR + // 0043): the task-message delivery path and the orchestrator task-starter, + // both constructed after boot-active plugins spawn (see writeDeps on + // pluginHost). Mutex-guarded against the concurrent hostForPlugin reads. + messenger taskMessenger + taskStarter taskStarter + // authLogin establishes an authenticated browser session for an external // identity an auth-capable plugin asserts via its webhook response // (OIDC/SAML SSO), wired via SetAuthLoginBridge. nil until backendapp @@ -222,6 +230,7 @@ func (s *Service) SetDataSources( agentProfiles agentProfileDataSource, sessionCodeStats sessionCodeStatsSource, messages messageDataSource, + taskWrites taskWriter, ) { s.taskData = tasks s.workflows = workflows @@ -229,6 +238,33 @@ func (s *Service) SetDataSources( s.agentProfiles = agentProfiles s.sessionCodeStats = sessionCodeStats s.messageData = messages + s.taskWriter = taskWrites +} + +// SetWriteDeps wires the Host data API's late write dependencies (ADR 0043 +// phase 2): the task-message delivery path behind the SendMessage RPC +// (api_write:messages) and the orchestrator task-starter behind CreateTask's +// start_agent flag. Wired LATE (not in SetDataSources) because the orchestrator +// is constructed after StartActivePlugins has spawned boot-active plugins, so +// hosts read these live via writeDependencies rather than snapshotting them — +// the write here is mutex-guarded against those concurrent reads. Either +// argument may be nil (feature-gated off), in which case the corresponding +// write path returns Unimplemented / is a best-effort no-op. +func (s *Service) SetWriteDeps(messenger taskMessenger, starter taskStarter) { + s.mu.Lock() + defer s.mu.Unlock() + s.messenger = messenger + s.taskStarter = starter +} + +// writeDependencies returns the currently-wired task messenger and task +// starter. Read live (not snapshotted at hostForPlugin time) so a plugin +// spawned before SetWriteDeps still resolves them once it is called. Guarded by +// s.mu against the SetWriteDeps write. +func (s *Service) writeDependencies() (taskMessenger, taskStarter) { + s.mu.Lock() + defer s.mu.Unlock() + return s.messenger, s.taskStarter } // SetUtilityAgent wires the dependencies behind Host.InvokeUtilityAgent @@ -373,7 +409,9 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host { agentProfiles: s.agentProfiles, sessionCodeStats: s.sessionCodeStats, messageData: s.messageData, + taskWriter: s.taskWriter, utilityDeps: s.utilityAgentDeps, + writeDeps: s.writeDependencies, } } diff --git a/apps/backend/pkg/pluginsdk/data_types.go b/apps/backend/pkg/pluginsdk/data_types.go index f67d8802e7..67e825e436 100644 --- a/apps/backend/pkg/pluginsdk/data_types.go +++ b/apps/backend/pkg/pluginsdk/data_types.go @@ -774,3 +774,113 @@ func messageFilterFromProto(p *pluginv1.MessageFilter) MessageFilter { Types: p.GetTypes(), } } + +// ── Write inputs (ADR 0043 phase 2: api_write) ──────────────────────────── +// +// These mirror kandev.plugin.v1.CreateTaskRequest/UpdateTaskRequest/ +// CreateCommentRequest. Optional/nullable fields use *string so "leave +// unchanged" (nil) is distinguishable from "set to empty" ("") — matching the +// proto `optional` on the wire. The server stamps write provenance +// (source = "plugin:") itself; there is no field for a plugin to set it. + +// CreateTaskInput is the Go-native mirror of +// kandev.plugin.v1.CreateTaskRequest. WorkspaceID/WorkflowID may be left empty +// to accept the host's defaults (the single workspace, and that workspace's +// first workflow); the host returns a gRPC InvalidArgument when a default +// cannot be resolved unambiguously. +type CreateTaskInput struct { + WorkspaceID string + WorkflowID string + WorkflowStepID *string + Title string + Description string + ParentID *string + // StartAgent asks the host to auto-launch an agent on the created task, + // mirroring the REST/MCP create_task start_agent flag. Best-effort: a + // launch failure does not fail task creation. + StartAgent bool +} + +func (in CreateTaskInput) toProto() *pluginv1.CreateTaskRequest { + return &pluginv1.CreateTaskRequest{ + WorkspaceId: in.WorkspaceID, + WorkflowId: in.WorkflowID, + WorkflowStepId: in.WorkflowStepID, + Title: in.Title, + Description: in.Description, + ParentId: in.ParentID, + StartAgent: in.StartAgent, + } +} + +func createTaskInputFromProto(p *pluginv1.CreateTaskRequest) CreateTaskInput { + if p == nil { + return CreateTaskInput{} + } + return CreateTaskInput{ + WorkspaceID: p.GetWorkspaceId(), + WorkflowID: p.GetWorkflowId(), + WorkflowStepID: p.WorkflowStepId, + Title: p.GetTitle(), + Description: p.GetDescription(), + ParentID: p.ParentId, + StartAgent: p.GetStartAgent(), + } +} + +// UpdateTaskInput is the Go-native mirror of +// kandev.plugin.v1.UpdateTaskRequest. Every field except ID is optional: a nil +// pointer leaves that field untouched, a non-nil pointer overwrites it. The +// conservative field surface (title/description/state/workflow_step_id) is the +// documented plugin-writable mask. +type UpdateTaskInput struct { + ID string + Title *string + Description *string + State *string + WorkflowStepID *string +} + +func (in UpdateTaskInput) toProto() *pluginv1.UpdateTaskRequest { + return &pluginv1.UpdateTaskRequest{ + Id: in.ID, + Title: in.Title, + Description: in.Description, + State: in.State, + WorkflowStepId: in.WorkflowStepID, + } +} + +func updateTaskInputFromProto(p *pluginv1.UpdateTaskRequest) UpdateTaskInput { + if p == nil { + return UpdateTaskInput{} + } + return UpdateTaskInput{ + ID: p.GetId(), + Title: p.Title, + Description: p.Description, + State: p.State, + WorkflowStepID: p.WorkflowStepId, + } +} + +// MessageDispatch is the Go-native mirror of +// kandev.plugin.v1.SendMessageResponse — the result of delivering a prompt to a +// task session. Status is "queued" (the session was running and the message +// was queued for its next turn), "sent" (delivered to an idle/resumed session), +// or "started" (the session was launched with it as the first prompt). +type MessageDispatch struct { + SessionID string + Status string +} + +func (d MessageDispatch) toProto() *pluginv1.SendMessageResponse { + return &pluginv1.SendMessageResponse{SessionId: d.SessionID, Status: d.Status} +} + +func messageDispatchFromProto(p *pluginv1.SendMessageResponse) *MessageDispatch { + if p == nil { + return nil + } + return &MessageDispatch{SessionID: p.GetSessionId(), Status: p.GetStatus()} +} diff --git a/apps/backend/pkg/pluginsdk/host.go b/apps/backend/pkg/pluginsdk/host.go index f242077432..b7a2a0dd63 100644 --- a/apps/backend/pkg/pluginsdk/host.go +++ b/apps/backend/pkg/pluginsdk/host.go @@ -105,9 +105,10 @@ type Host interface { // RPCs (capability api_read:repositories). Repositories() RepositoryReader - // Messages returns the reader for the Host data API's message RPC - // (capability api_read:messages). It reads historical user/agent - // conversation content; kandev-injected system blocks are stripped. + // Messages returns the accessor for the Host data API's message RPCs. + // List (capability api_read:messages) reads historical user/agent + // conversation content; Send (capability api_write:messages) delivers a + // prompt to a task session. Messages() MessageReader // InvokeUtilityAgent runs a one-shot, non-interactive completion using @@ -118,16 +119,28 @@ type Host interface { InvokeUtilityAgent(ctx context.Context, prompt string) (string, error) } -// 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. +// TaskReader is the accessor behind Host.Tasks(), mirroring the Host data +// API's ListTasks/GetTask/CreateTask/UpdateTask RPCs (ADR 0043). Reads +// (List/Get) require api_read:tasks; writes (Create/Update) require +// api_write:tasks — the two capabilities gate independently, so a plugin may +// declare one without the other. (The name is kept for source stability with +// already-shipped read-only plugins; the interface now also writes.) 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) + + // Create creates a task through kandev's task service (so task.* events + // fire and WS clients update) and returns the created task. Requires + // api_write:tasks. + Create(ctx context.Context, in CreateTaskInput) (*Task, error) + + // Update mutates a conservative field surface of an existing task + // (title/description/state/workflow_step_id) and returns the updated task. + // Requires api_write:tasks. + Update(ctx context.Context, in UpdateTaskInput) (*Task, error) } // SessionReader is the read-only accessor behind Host.Sessions(), mirroring @@ -171,12 +184,22 @@ type RepositoryReader interface { List(ctx context.Context, workspaceID string, page Page) ([]Repository, *PageInfo, error) } -// MessageReader is the read-only accessor behind Host.Messages(), mirroring -// the Host data API's ListMessages RPC. It reads historical conversation -// content filtered by session, task, and/or time range. +// MessageReader is the accessor behind Host.Messages(), mirroring the Host +// data API's ListMessages/SendMessage RPCs. List (api_read:messages) reads +// historical conversation content filtered by session, task, and/or time +// range; Send (api_write:messages) delivers a prompt to a task session. The +// two capabilities gate independently. (The name is kept for source stability +// with already-shipped read-only plugins; the interface now also writes.) type MessageReader interface { // List returns messages matching filter, oldest first within a page. List(ctx context.Context, filter MessageFilter, page Page) ([]Message, *PageInfo, error) + + // Send delivers text as a prompt to a task session through kandev's + // orchestrator (the same delivery path message_task uses), recording a + // user message stamped "plugin:". sessionID may be empty to target the + // task's primary session. Returns the target session and a dispatch status + // ("queued" | "sent" | "started"). Requires api_write:messages. + Send(ctx context.Context, taskID, sessionID, text string) (*MessageDispatch, error) } // newHostClient wraps a *grpc.ClientConn (dialed over the go-plugin broker) @@ -337,6 +360,30 @@ func (r grpcTaskReader) Get(ctx context.Context, id string) (*Task, error) { return &task, nil } +func (r grpcTaskReader) Create(ctx context.Context, in CreateTaskInput) (*Task, error) { + resp, err := r.client.CreateTask(ctx, in.toProto()) + if err != nil { + return nil, err + } + task, err := taskFromProto(resp.GetTask()) + if err != nil { + return nil, err + } + return &task, nil +} + +func (r grpcTaskReader) Update(ctx context.Context, in UpdateTaskInput) (*Task, error) { + resp, err := r.client.UpdateTask(ctx, in.toProto()) + 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 @@ -431,6 +478,14 @@ func (r grpcMessageReader) List(ctx context.Context, filter MessageFilter, page return messagesFromProto(resp.GetMessages()), pageInfoFromProto(resp.GetPageInfo()), nil } +func (r grpcMessageReader) Send(ctx context.Context, taskID, sessionID, text string) (*MessageDispatch, error) { + resp, err := r.client.SendMessage(ctx, &pluginv1.SendMessageRequest{TaskId: taskID, SessionId: sessionID, Text: text}) + if err != nil { + return nil, err + } + return messageDispatchFromProto(resp), 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. @@ -674,6 +729,57 @@ func (s *grpcHostServer) ListMessages(ctx context.Context, req *pluginv1.ListMes return &pluginv1.ListMessagesResponse{Messages: messagesToProto(messages), PageInfo: pageInfo.toProto()}, nil } +// ── Host data API writes (ADR 0043) ───────────────────────────────────── +// +// Each dispatches to the injected impl's writer accessor (impl.Tasks() for +// Create/Update, impl.Messages() for SendMessage) and converts native<->proto +// at the boundary. Capability gating (api_write:) and the real +// service-layer calls live in the kandev-side impl, exactly like the reads +// above. The nil checks are defense-in-depth for the trust boundary: the +// in-tree Host never returns (nil, nil) on success, but a custom or test Host +// might, and a plugin should get a gRPC error rather than a server panic. + +func (s *grpcHostServer) CreateTask(ctx context.Context, req *pluginv1.CreateTaskRequest) (*pluginv1.CreateTaskResponse, error) { + task, err := s.impl.Tasks().Create(ctx, createTaskInputFromProto(req)) + if err != nil { + return nil, err + } + if task == nil { + return nil, status.Error(codes.Internal, "CreateTask returned nil task") + } + protoTask, err := task.toProto() + if err != nil { + return nil, err + } + return &pluginv1.CreateTaskResponse{Task: protoTask}, nil +} + +func (s *grpcHostServer) UpdateTask(ctx context.Context, req *pluginv1.UpdateTaskRequest) (*pluginv1.UpdateTaskResponse, error) { + task, err := s.impl.Tasks().Update(ctx, updateTaskInputFromProto(req)) + if err != nil { + return nil, err + } + if task == nil { + return nil, status.Error(codes.Internal, "UpdateTask returned nil task") + } + protoTask, err := task.toProto() + if err != nil { + return nil, err + } + return &pluginv1.UpdateTaskResponse{Task: protoTask}, nil +} + +func (s *grpcHostServer) SendMessage(ctx context.Context, req *pluginv1.SendMessageRequest) (*pluginv1.SendMessageResponse, error) { + dispatch, err := s.impl.Messages().Send(ctx, req.GetTaskId(), req.GetSessionId(), req.GetText()) + if err != nil { + return nil, err + } + if dispatch == nil { + return nil, status.Error(codes.Internal, "SendMessage returned nil dispatch") + } + return dispatch.toProto(), nil +} + var _ pluginv1.HostServer = (*grpcHostServer)(nil) // UnimplementedHostData is an embeddable default for the Host data API @@ -722,6 +828,14 @@ func (unimplementedTaskReader) Get(context.Context, string) (*Task, error) { return nil, errUnimplementedHostData("tasks") } +func (unimplementedTaskReader) Create(context.Context, CreateTaskInput) (*Task, error) { + return nil, errUnimplementedHostData("tasks") +} + +func (unimplementedTaskReader) Update(context.Context, UpdateTaskInput) (*Task, error) { + return nil, errUnimplementedHostData("tasks") +} + type unimplementedSessionReader struct{} func (unimplementedSessionReader) List(context.Context, SessionFilter, Page) ([]Session, *PageInfo, error) { @@ -765,3 +879,7 @@ type unimplementedMessageReader struct{} func (unimplementedMessageReader) List(context.Context, MessageFilter, Page) ([]Message, *PageInfo, error) { return nil, nil, errUnimplementedHostData("messages") } + +func (unimplementedMessageReader) Send(context.Context, string, string, string) (*MessageDispatch, error) { + return nil, errUnimplementedHostData("messages") +} diff --git a/apps/backend/pkg/pluginsdk/host_data_test.go b/apps/backend/pkg/pluginsdk/host_data_test.go index 901d09ee25..16b4c07201 100644 --- a/apps/backend/pkg/pluginsdk/host_data_test.go +++ b/apps/backend/pkg/pluginsdk/host_data_test.go @@ -39,6 +39,15 @@ type dataRecordingHost struct { lastWorkspaceID string lastWorkflowID string lastUtilityPrompt string + + createdTask Task + lastCreateInput CreateTaskInput + updatedTask Task + lastUpdateInput UpdateTaskInput + messageDispatch MessageDispatch + lastSendTask string + lastSendSession string + lastSendText string } func (h *dataRecordingHost) GetState(context.Context, string, string, string) (map[string]any, bool, error) { @@ -93,6 +102,18 @@ func (r dataRecordingTaskReader) Get(_ context.Context, id string) (*Task, error return &task, nil } +func (r dataRecordingTaskReader) Create(_ context.Context, in CreateTaskInput) (*Task, error) { + r.h.lastCreateInput = in + task := r.h.createdTask + return &task, nil +} + +func (r dataRecordingTaskReader) Update(_ context.Context, in UpdateTaskInput) (*Task, error) { + r.h.lastUpdateInput = in + task := r.h.updatedTask + return &task, nil +} + type dataRecordingSessionReader struct{ h *dataRecordingHost } func (r dataRecordingSessionReader) List(_ context.Context, filter SessionFilter, _ Page) ([]Session, *PageInfo, error) { @@ -143,6 +164,14 @@ func (r dataRecordingMessageReader) List(_ context.Context, filter MessageFilter return r.h.messages, r.h.messagePage, nil } +func (r dataRecordingMessageReader) Send(_ context.Context, taskID, sessionID, text string) (*MessageDispatch, error) { + r.h.lastSendTask = taskID + r.h.lastSendSession = sessionID + r.h.lastSendText = text + dispatch := r.h.messageDispatch + return &dispatch, nil +} + func TestHostData_TasksListAndGet(t *testing.T) { impl := &dataRecordingHost{ tasks: map[string]Task{ @@ -168,6 +197,49 @@ func TestHostData_TasksListAndGet(t *testing.T) { require.Equal(t, codes.NotFound, status.Code(err)) } +// TestHostData_TaskWritesAndMessage proves the write RPCs (CreateTask, +// UpdateTask, SendMessage) round-trip inputs and results through +// grpcHostClient -> proto -> grpcHostServer, including the optional-pointer +// fields on the update mask. +func TestHostData_TaskWritesAndMessage(t *testing.T) { + impl := &dataRecordingHost{ + createdTask: Task{ID: "task-new", Title: "Investigate crash", State: "TODO"}, + updatedTask: Task{ID: "task-1", Title: "Renamed", State: "IN_PROGRESS"}, + messageDispatch: MessageDispatch{SessionID: "session-1", Status: "queued"}, + } + host := dialHostOverBufconn(t, impl) + + stepID := "step-2" + created, err := host.Tasks().Create(context.Background(), CreateTaskInput{ + WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: &stepID, + Title: "Investigate crash", Description: "stack trace attached", StartAgent: true, + }) + require.NoError(t, err) + require.Equal(t, "task-new", created.ID) + require.Equal(t, "ws-1", impl.lastCreateInput.WorkspaceID) + require.NotNil(t, impl.lastCreateInput.WorkflowStepID) + require.Equal(t, "step-2", *impl.lastCreateInput.WorkflowStepID) + require.True(t, impl.lastCreateInput.StartAgent) + + title := "Renamed" + state := "IN_PROGRESS" + updated, err := host.Tasks().Update(context.Background(), UpdateTaskInput{ID: "task-1", Title: &title, State: &state}) + require.NoError(t, err) + require.Equal(t, "IN_PROGRESS", updated.State) + require.Equal(t, "task-1", impl.lastUpdateInput.ID) + require.NotNil(t, impl.lastUpdateInput.Title) + require.Equal(t, "Renamed", *impl.lastUpdateInput.Title) + require.Nil(t, impl.lastUpdateInput.Description, "an unset field must stay nil across the wire") + + dispatch, err := host.Messages().Send(context.Background(), "task-1", "session-1", "rerun the tests") + require.NoError(t, err) + require.Equal(t, "session-1", dispatch.SessionID) + require.Equal(t, "queued", dispatch.Status) + require.Equal(t, "task-1", impl.lastSendTask) + require.Equal(t, "session-1", impl.lastSendSession) + require.Equal(t, "rerun the tests", impl.lastSendText) +} + func TestHostData_SessionsListAndCodeStats(t *testing.T) { impl := &dataRecordingHost{ sessions: []Session{{ID: "session-1", TaskID: "task-1", State: "running"}}, @@ -284,4 +356,16 @@ func TestHostData_UnimplementedHostData_ReturnsUnimplemented(t *testing.T) { _, _, err = host.Sessions().CodeStats(context.Background(), SessionFilter{}, Page{}) require.Error(t, err) require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, err = host.Tasks().Create(context.Background(), CreateTaskInput{Title: "x"}) + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, err = host.Tasks().Update(context.Background(), UpdateTaskInput{ID: "task-1"}) + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) + + _, err = host.Messages().Send(context.Background(), "task-1", "", "body") + require.Error(t, err) + require.Equal(t, codes.Unimplemented, status.Code(err)) } diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index ec575a7e02..58b6b6f118 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -3643,7 +3643,7 @@ func (x *InvokeUtilityAgentResponse) GetText() string { return "" } -// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +// ── Writes (api_write) ─────────────────────────────────────────────────────── type CreateTaskRequest struct { state protoimpl.MessageState `protogen:"open.v1"` WorkspaceId string `protobuf:"bytes,1,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"` @@ -3900,31 +3900,35 @@ func (x *UpdateTaskResponse) GetTask() *Task { return nil } -type Comment struct { +// SendMessage delivers a prompt to a task session (api_write:messages). The +// server records a user message stamped source = "plugin:" and dispatches +// it through the orchestrator, resolving session_id (empty → the task's primary +// session) and handling session state: a running session queues the message; +// an idle/completed session is resumed and prompted; a never-started session is +// launched with it as the first prompt. +type SendMessageRequest struct { state protoimpl.MessageState `protogen:"open.v1"` - Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` - TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Body string `protobuf:"bytes,3,opt,name=body,proto3" json:"body,omitempty"` - Source string `protobuf:"bytes,4,opt,name=source,proto3" json:"source,omitempty"` // "plugin:" - CreatedAt string `protobuf:"bytes,5,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + SessionId string `protobuf:"bytes,2,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // empty → the task's primary session + Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *Comment) Reset() { - *x = Comment{} +func (x *SendMessageRequest) Reset() { + *x = SendMessageRequest{} mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *Comment) String() string { +func (x *SendMessageRequest) String() string { return protoimpl.X.MessageStringOf(x) } -func (*Comment) ProtoMessage() {} +func (*SendMessageRequest) ProtoMessage() {} -func (x *Comment) ProtoReflect() protoreflect.Message { +func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[66] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -3936,68 +3940,54 @@ func (x *Comment) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use Comment.ProtoReflect.Descriptor instead. -func (*Comment) Descriptor() ([]byte, []int) { +// Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. +func (*SendMessageRequest) Descriptor() ([]byte, []int) { return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{66} } -func (x *Comment) GetId() string { - if x != nil { - return x.Id - } - return "" -} - -func (x *Comment) GetTaskId() string { +func (x *SendMessageRequest) GetTaskId() string { if x != nil { return x.TaskId } return "" } -func (x *Comment) GetBody() string { +func (x *SendMessageRequest) GetSessionId() string { if x != nil { - return x.Body + return x.SessionId } return "" } -func (x *Comment) GetSource() string { +func (x *SendMessageRequest) GetText() string { if x != nil { - return x.Source + return x.Text } return "" } -func (x *Comment) GetCreatedAt() string { - if x != nil { - return x.CreatedAt - } - return "" -} - -type CreateCommentRequest struct { +type SendMessageResponse struct { state protoimpl.MessageState `protogen:"open.v1"` - TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` - Body string `protobuf:"bytes,2,opt,name=body,proto3" json:"body,omitempty"` + SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // the session the message was delivered to + Status string `protobuf:"bytes,2,opt,name=status,proto3" json:"status,omitempty"` // "queued" | "sent" | "started" unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } -func (x *CreateCommentRequest) Reset() { - *x = CreateCommentRequest{} +func (x *SendMessageResponse) Reset() { + *x = SendMessageResponse{} mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *CreateCommentRequest) String() string { +func (x *SendMessageResponse) String() string { return protoimpl.X.MessageStringOf(x) } -func (*CreateCommentRequest) ProtoMessage() {} +func (*SendMessageResponse) ProtoMessage() {} -func (x *CreateCommentRequest) ProtoReflect() protoreflect.Message { +func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[67] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -4009,69 +3999,25 @@ func (x *CreateCommentRequest) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use CreateCommentRequest.ProtoReflect.Descriptor instead. -func (*CreateCommentRequest) Descriptor() ([]byte, []int) { +// Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. +func (*SendMessageResponse) Descriptor() ([]byte, []int) { return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{67} } -func (x *CreateCommentRequest) GetTaskId() string { +func (x *SendMessageResponse) GetSessionId() string { if x != nil { - return x.TaskId + return x.SessionId } return "" } -func (x *CreateCommentRequest) GetBody() string { +func (x *SendMessageResponse) GetStatus() string { if x != nil { - return x.Body + return x.Status } return "" } -type CreateCommentResponse struct { - state protoimpl.MessageState `protogen:"open.v1"` - Comment *Comment `protobuf:"bytes,1,opt,name=comment,proto3" json:"comment,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache -} - -func (x *CreateCommentResponse) Reset() { - *x = CreateCommentResponse{} - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[68] - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - ms.StoreMessageInfo(mi) -} - -func (x *CreateCommentResponse) String() string { - return protoimpl.X.MessageStringOf(x) -} - -func (*CreateCommentResponse) ProtoMessage() {} - -func (x *CreateCommentResponse) ProtoReflect() protoreflect.Message { - mi := &file_kandev_plugin_v1_plugin_proto_msgTypes[68] - if x != nil { - ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) - if ms.LoadMessageInfo() == nil { - ms.StoreMessageInfo(mi) - } - return ms - } - return mi.MessageOf(x) -} - -// Deprecated: Use CreateCommentResponse.ProtoReflect.Descriptor instead. -func (*CreateCommentResponse) Descriptor() ([]byte, []int) { - return file_kandev_plugin_v1_plugin_proto_rawDescGZIP(), []int{68} -} - -func (x *CreateCommentResponse) GetComment() *Comment { - if x != nil { - return x.Comment - } - return nil -} - var File_kandev_plugin_v1_plugin_proto protoreflect.FileDescriptor const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + @@ -4391,22 +4337,19 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\x06_stateB\x13\n" + "\x11_workflow_step_id\"@\n" + "\x12UpdateTaskResponse\x12*\n" + - "\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"}\n" + - "\aComment\x12\x0e\n" + - "\x02id\x18\x01 \x01(\tR\x02id\x12\x17\n" + - "\atask_id\x18\x02 \x01(\tR\x06taskId\x12\x12\n" + - "\x04body\x18\x03 \x01(\tR\x04body\x12\x16\n" + - "\x06source\x18\x04 \x01(\tR\x06source\x12\x1d\n" + + "\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"`\n" + + "\x12SendMessageRequest\x12\x17\n" + + "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x1d\n" + "\n" + - "created_at\x18\x05 \x01(\tR\tcreatedAt\"C\n" + - "\x14CreateCommentRequest\x12\x17\n" + - "\atask_id\x18\x01 \x01(\tR\x06taskId\x12\x12\n" + - "\x04body\x18\x02 \x01(\tR\x04body\"L\n" + - "\x15CreateCommentResponse\x123\n" + - "\acomment\x18\x01 \x01(\v2\x19.kandev.plugin.v1.CommentR\acomment2\xa3\x01\n" + + "session_id\x18\x02 \x01(\tR\tsessionId\x12\x12\n" + + "\x04text\x18\x03 \x01(\tR\x04text\"L\n" + + "\x13SendMessageResponse\x12\x1d\n" + + "\n" + + "session_id\x18\x01 \x01(\tR\tsessionId\x12\x16\n" + + "\x06status\x18\x02 \x01(\tR\x06status2\xa3\x01\n" + "\x06Plugin\x12C\n" + "\fDeliverEvent\x12\x17.kandev.plugin.v1.Event\x1a\x1a.kandev.plugin.v1.EventAck\x12T\n" + - "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\xe2\x11\n" + + "\rHandleWebhook\x12 .kandev.plugin.v1.WebhookRequest\x1a!.kandev.plugin.v1.WebhookResponse2\xdc\x11\n" + "\x04Host\x12Q\n" + "\bGetState\x12!.kandev.plugin.v1.GetStateRequest\x1a\".kandev.plugin.v1.GetStateResponse\x12Q\n" + "\bSetState\x12!.kandev.plugin.v1.SetStateRequest\x1a\".kandev.plugin.v1.SetStateResponse\x12Z\n" + @@ -4432,8 +4375,8 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" + "\n" + "CreateTask\x12#.kandev.plugin.v1.CreateTaskRequest\x1a$.kandev.plugin.v1.CreateTaskResponse\x12W\n" + "\n" + - "UpdateTask\x12#.kandev.plugin.v1.UpdateTaskRequest\x1a$.kandev.plugin.v1.UpdateTaskResponse\x12`\n" + - "\rCreateComment\x12&.kandev.plugin.v1.CreateCommentRequest\x1a'.kandev.plugin.v1.CreateCommentResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" + "UpdateTask\x12#.kandev.plugin.v1.UpdateTaskRequest\x1a$.kandev.plugin.v1.UpdateTaskResponse\x12Z\n" + + "\vSendMessage\x12$.kandev.plugin.v1.SendMessageRequest\x1a%.kandev.plugin.v1.SendMessageResponseB:Z8github.com/kandev/kandev/proto/kandev/plugin/v1;pluginv1b\x06proto3" var ( file_kandev_plugin_v1_plugin_proto_rawDescOnce sync.Once @@ -4447,7 +4390,7 @@ func file_kandev_plugin_v1_plugin_proto_rawDescGZIP() []byte { return file_kandev_plugin_v1_plugin_proto_rawDescData } -var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 71) +var file_kandev_plugin_v1_plugin_proto_msgTypes = make([]protoimpl.MessageInfo, 70) var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*Event)(nil), // 0: kandev.plugin.v1.Event (*EventAck)(nil), // 1: kandev.plugin.v1.EventAck @@ -4515,25 +4458,24 @@ var file_kandev_plugin_v1_plugin_proto_goTypes = []any{ (*CreateTaskResponse)(nil), // 63: kandev.plugin.v1.CreateTaskResponse (*UpdateTaskRequest)(nil), // 64: kandev.plugin.v1.UpdateTaskRequest (*UpdateTaskResponse)(nil), // 65: kandev.plugin.v1.UpdateTaskResponse - (*Comment)(nil), // 66: kandev.plugin.v1.Comment - (*CreateCommentRequest)(nil), // 67: kandev.plugin.v1.CreateCommentRequest - (*CreateCommentResponse)(nil), // 68: kandev.plugin.v1.CreateCommentResponse - nil, // 69: kandev.plugin.v1.WebhookRequest.HeadersEntry - nil, // 70: kandev.plugin.v1.WebhookResponse.HeadersEntry - (*structpb.Struct)(nil), // 71: google.protobuf.Struct + (*SendMessageRequest)(nil), // 66: kandev.plugin.v1.SendMessageRequest + (*SendMessageResponse)(nil), // 67: kandev.plugin.v1.SendMessageResponse + nil, // 68: kandev.plugin.v1.WebhookRequest.HeadersEntry + nil, // 69: kandev.plugin.v1.WebhookResponse.HeadersEntry + (*structpb.Struct)(nil), // 70: google.protobuf.Struct } var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ - 71, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct - 69, // 1: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry - 70, // 2: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry - 71, // 3: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct - 71, // 4: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct + 70, // 0: kandev.plugin.v1.Event.payload:type_name -> google.protobuf.Struct + 68, // 1: kandev.plugin.v1.WebhookRequest.headers:type_name -> kandev.plugin.v1.WebhookRequest.HeadersEntry + 69, // 2: kandev.plugin.v1.WebhookResponse.headers:type_name -> kandev.plugin.v1.WebhookResponse.HeadersEntry + 70, // 3: kandev.plugin.v1.GetStateResponse.value:type_name -> google.protobuf.Struct + 70, // 4: kandev.plugin.v1.SetStateRequest.value:type_name -> google.protobuf.Struct 12, // 5: kandev.plugin.v1.ListStateResponse.entries:type_name -> kandev.plugin.v1.StateEntry - 71, // 6: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct - 71, // 7: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct - 71, // 8: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct + 70, // 6: kandev.plugin.v1.StateEntry.value:type_name -> google.protobuf.Struct + 70, // 7: kandev.plugin.v1.GetConfigResponse.config:type_name -> google.protobuf.Struct + 70, // 8: kandev.plugin.v1.EmitEventRequest.payload:type_name -> google.protobuf.Struct 28, // 9: kandev.plugin.v1.Task.repositories:type_name -> kandev.plugin.v1.TaskRepository - 71, // 10: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct + 70, // 10: kandev.plugin.v1.Task.metadata:type_name -> google.protobuf.Struct 29, // 11: kandev.plugin.v1.ListTasksRequest.filter:type_name -> kandev.plugin.v1.TaskFilter 25, // 12: kandev.plugin.v1.ListTasksRequest.page:type_name -> kandev.plugin.v1.Page 27, // 13: kandev.plugin.v1.ListTasksResponse.tasks:type_name -> kandev.plugin.v1.Task @@ -4566,64 +4508,63 @@ var file_kandev_plugin_v1_plugin_proto_depIdxs = []int32{ 26, // 40: kandev.plugin.v1.ListMessagesResponse.page_info:type_name -> kandev.plugin.v1.PageInfo 27, // 41: kandev.plugin.v1.CreateTaskResponse.task:type_name -> kandev.plugin.v1.Task 27, // 42: kandev.plugin.v1.UpdateTaskResponse.task:type_name -> kandev.plugin.v1.Task - 66, // 43: kandev.plugin.v1.CreateCommentResponse.comment:type_name -> kandev.plugin.v1.Comment - 0, // 44: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event - 2, // 45: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest - 4, // 46: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest - 6, // 47: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest - 8, // 48: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest - 10, // 49: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest - 21, // 50: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest - 23, // 51: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest - 15, // 52: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest - 17, // 53: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest - 19, // 54: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest - 13, // 55: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest - 30, // 56: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest - 32, // 57: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest - 35, // 58: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest - 38, // 59: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest - 41, // 60: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest - 44, // 61: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest - 47, // 62: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest - 51, // 63: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest - 54, // 64: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest - 58, // 65: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest - 60, // 66: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest - 62, // 67: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest - 64, // 68: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest - 67, // 69: kandev.plugin.v1.Host.CreateComment:input_type -> kandev.plugin.v1.CreateCommentRequest - 1, // 70: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck - 3, // 71: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse - 5, // 72: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse - 7, // 73: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse - 9, // 74: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse - 11, // 75: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse - 22, // 76: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse - 24, // 77: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse - 16, // 78: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse - 18, // 79: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse - 20, // 80: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse - 14, // 81: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse - 31, // 82: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse - 33, // 83: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse - 36, // 84: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse - 39, // 85: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse - 42, // 86: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse - 45, // 87: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse - 48, // 88: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse - 52, // 89: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse - 55, // 90: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse - 59, // 91: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse - 61, // 92: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse - 63, // 93: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse - 65, // 94: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse - 68, // 95: kandev.plugin.v1.Host.CreateComment:output_type -> kandev.plugin.v1.CreateCommentResponse - 70, // [70:96] is the sub-list for method output_type - 44, // [44:70] is the sub-list for method input_type - 44, // [44:44] is the sub-list for extension type_name - 44, // [44:44] is the sub-list for extension extendee - 0, // [0:44] is the sub-list for field type_name + 0, // 43: kandev.plugin.v1.Plugin.DeliverEvent:input_type -> kandev.plugin.v1.Event + 2, // 44: kandev.plugin.v1.Plugin.HandleWebhook:input_type -> kandev.plugin.v1.WebhookRequest + 4, // 45: kandev.plugin.v1.Host.GetState:input_type -> kandev.plugin.v1.GetStateRequest + 6, // 46: kandev.plugin.v1.Host.SetState:input_type -> kandev.plugin.v1.SetStateRequest + 8, // 47: kandev.plugin.v1.Host.DeleteState:input_type -> kandev.plugin.v1.DeleteStateRequest + 10, // 48: kandev.plugin.v1.Host.ListState:input_type -> kandev.plugin.v1.ListStateRequest + 21, // 49: kandev.plugin.v1.Host.RevealSecret:input_type -> kandev.plugin.v1.RevealSecretRequest + 23, // 50: kandev.plugin.v1.Host.EmitEvent:input_type -> kandev.plugin.v1.EmitEventRequest + 15, // 51: kandev.plugin.v1.Host.GetSecret:input_type -> kandev.plugin.v1.GetSecretRequest + 17, // 52: kandev.plugin.v1.Host.SetSecret:input_type -> kandev.plugin.v1.SetSecretRequest + 19, // 53: kandev.plugin.v1.Host.DeleteSecret:input_type -> kandev.plugin.v1.DeleteSecretRequest + 13, // 54: kandev.plugin.v1.Host.GetConfig:input_type -> kandev.plugin.v1.GetConfigRequest + 30, // 55: kandev.plugin.v1.Host.ListTasks:input_type -> kandev.plugin.v1.ListTasksRequest + 32, // 56: kandev.plugin.v1.Host.GetTask:input_type -> kandev.plugin.v1.GetTaskRequest + 35, // 57: kandev.plugin.v1.Host.ListWorkspaces:input_type -> kandev.plugin.v1.ListWorkspacesRequest + 38, // 58: kandev.plugin.v1.Host.ListWorkflows:input_type -> kandev.plugin.v1.ListWorkflowsRequest + 41, // 59: kandev.plugin.v1.Host.ListWorkflowSteps:input_type -> kandev.plugin.v1.ListWorkflowStepsRequest + 44, // 60: kandev.plugin.v1.Host.ListAgentProfiles:input_type -> kandev.plugin.v1.ListAgentProfilesRequest + 47, // 61: kandev.plugin.v1.Host.ListRepositories:input_type -> kandev.plugin.v1.ListRepositoriesRequest + 51, // 62: kandev.plugin.v1.Host.ListSessions:input_type -> kandev.plugin.v1.ListSessionsRequest + 54, // 63: kandev.plugin.v1.Host.ListSessionCodeStats:input_type -> kandev.plugin.v1.ListSessionCodeStatsRequest + 58, // 64: kandev.plugin.v1.Host.ListMessages:input_type -> kandev.plugin.v1.ListMessagesRequest + 60, // 65: kandev.plugin.v1.Host.InvokeUtilityAgent:input_type -> kandev.plugin.v1.InvokeUtilityAgentRequest + 62, // 66: kandev.plugin.v1.Host.CreateTask:input_type -> kandev.plugin.v1.CreateTaskRequest + 64, // 67: kandev.plugin.v1.Host.UpdateTask:input_type -> kandev.plugin.v1.UpdateTaskRequest + 66, // 68: kandev.plugin.v1.Host.SendMessage:input_type -> kandev.plugin.v1.SendMessageRequest + 1, // 69: kandev.plugin.v1.Plugin.DeliverEvent:output_type -> kandev.plugin.v1.EventAck + 3, // 70: kandev.plugin.v1.Plugin.HandleWebhook:output_type -> kandev.plugin.v1.WebhookResponse + 5, // 71: kandev.plugin.v1.Host.GetState:output_type -> kandev.plugin.v1.GetStateResponse + 7, // 72: kandev.plugin.v1.Host.SetState:output_type -> kandev.plugin.v1.SetStateResponse + 9, // 73: kandev.plugin.v1.Host.DeleteState:output_type -> kandev.plugin.v1.DeleteStateResponse + 11, // 74: kandev.plugin.v1.Host.ListState:output_type -> kandev.plugin.v1.ListStateResponse + 22, // 75: kandev.plugin.v1.Host.RevealSecret:output_type -> kandev.plugin.v1.RevealSecretResponse + 24, // 76: kandev.plugin.v1.Host.EmitEvent:output_type -> kandev.plugin.v1.EmitEventResponse + 16, // 77: kandev.plugin.v1.Host.GetSecret:output_type -> kandev.plugin.v1.GetSecretResponse + 18, // 78: kandev.plugin.v1.Host.SetSecret:output_type -> kandev.plugin.v1.SetSecretResponse + 20, // 79: kandev.plugin.v1.Host.DeleteSecret:output_type -> kandev.plugin.v1.DeleteSecretResponse + 14, // 80: kandev.plugin.v1.Host.GetConfig:output_type -> kandev.plugin.v1.GetConfigResponse + 31, // 81: kandev.plugin.v1.Host.ListTasks:output_type -> kandev.plugin.v1.ListTasksResponse + 33, // 82: kandev.plugin.v1.Host.GetTask:output_type -> kandev.plugin.v1.GetTaskResponse + 36, // 83: kandev.plugin.v1.Host.ListWorkspaces:output_type -> kandev.plugin.v1.ListWorkspacesResponse + 39, // 84: kandev.plugin.v1.Host.ListWorkflows:output_type -> kandev.plugin.v1.ListWorkflowsResponse + 42, // 85: kandev.plugin.v1.Host.ListWorkflowSteps:output_type -> kandev.plugin.v1.ListWorkflowStepsResponse + 45, // 86: kandev.plugin.v1.Host.ListAgentProfiles:output_type -> kandev.plugin.v1.ListAgentProfilesResponse + 48, // 87: kandev.plugin.v1.Host.ListRepositories:output_type -> kandev.plugin.v1.ListRepositoriesResponse + 52, // 88: kandev.plugin.v1.Host.ListSessions:output_type -> kandev.plugin.v1.ListSessionsResponse + 55, // 89: kandev.plugin.v1.Host.ListSessionCodeStats:output_type -> kandev.plugin.v1.ListSessionCodeStatsResponse + 59, // 90: kandev.plugin.v1.Host.ListMessages:output_type -> kandev.plugin.v1.ListMessagesResponse + 61, // 91: kandev.plugin.v1.Host.InvokeUtilityAgent:output_type -> kandev.plugin.v1.InvokeUtilityAgentResponse + 63, // 92: kandev.plugin.v1.Host.CreateTask:output_type -> kandev.plugin.v1.CreateTaskResponse + 65, // 93: kandev.plugin.v1.Host.UpdateTask:output_type -> kandev.plugin.v1.UpdateTaskResponse + 67, // 94: kandev.plugin.v1.Host.SendMessage:output_type -> kandev.plugin.v1.SendMessageResponse + 69, // [69:95] is the sub-list for method output_type + 43, // [43:69] is the sub-list for method input_type + 43, // [43:43] is the sub-list for extension type_name + 43, // [43:43] is the sub-list for extension extendee + 0, // [0:43] is the sub-list for field type_name } func init() { file_kandev_plugin_v1_plugin_proto_init() } @@ -4646,7 +4587,7 @@ func file_kandev_plugin_v1_plugin_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_kandev_plugin_v1_plugin_proto_rawDesc), len(file_kandev_plugin_v1_plugin_proto_rawDesc)), NumEnums: 0, - NumMessages: 71, + NumMessages: 70, NumExtensions: 0, NumServices: 2, }, diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index b3153efebc..42f01d14ef 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -13,20 +13,18 @@ service Plugin { } // Implemented by KANDEV (served back over the go-plugin broker). -// 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. +// Reads (§5) and writes 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. // // 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:`; writes require `api_write:` (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`. +// `api_read:tasks`, `api_write:comments`). An undeclared capability returns +// gRPC PermissionDenied with `capability 'api_read:tasks' not declared` (or +// `api_write:tasks`, etc.). // // DTOs below are a HAND-DEFINED public contract: the backend maps internal // models → these messages explicitly, never generates them from domain @@ -91,13 +89,17 @@ service Host { // its own API key. FailedPrecondition when no utility agent is configured. rpc InvokeUtilityAgent(InvokeUtilityAgentRequest) returns (InvokeUtilityAgentResponse); - // Writes — capability api_write:. 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. + // Writes — capability api_write:. Route through the first-party + // service layer so events fire and WS clients update — the whole reason not + // to let plugins write the DB. CreateTask/UpdateTask require api_write:tasks + // and go through the task service (task.* events; kandev stamps + // source = "plugin:" on created rows, a plugin cannot set it). SendMessage + // requires api_write:messages and delivers a prompt to a task session through + // the orchestrator — the same delivery path message_task uses (queue when the + // session is running, resume/start it otherwise). rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse); rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse); - rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse); + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); } message Event { @@ -440,7 +442,7 @@ message InvokeUtilityAgentResponse { string text = 1; } -// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +// ── Writes (api_write) ─────────────────────────────────────────────────────── message CreateTaskRequest { string workspace_id = 1; string workflow_id = 2; @@ -464,17 +466,18 @@ message UpdateTaskRequest { message UpdateTaskResponse { Task task = 1; } -message Comment { - string id = 1; - string task_id = 2; - string body = 3; - string source = 4; // "plugin:" - string created_at = 5; -} -message CreateCommentRequest { +// SendMessage delivers a prompt to a task session (api_write:messages). The +// server records a user message stamped source = "plugin:" and dispatches +// it through the orchestrator, resolving session_id (empty → the task's primary +// session) and handling session state: a running session queues the message; +// an idle/completed session is resumed and prompted; a never-started session is +// launched with it as the first prompt. +message SendMessageRequest { string task_id = 1; - string body = 2; + string session_id = 2; // empty → the task's primary session + string text = 3; } -message CreateCommentResponse { - Comment comment = 1; +message SendMessageResponse { + string session_id = 1; // the session the message was delivered to + string status = 2; // "queued" | "sent" | "started" } diff --git a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go index b21638b46a..8efbc6ec4c 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin_grpc.pb.go @@ -186,7 +186,7 @@ const ( Host_InvokeUtilityAgent_FullMethodName = "/kandev.plugin.v1.Host/InvokeUtilityAgent" Host_CreateTask_FullMethodName = "/kandev.plugin.v1.Host/CreateTask" Host_UpdateTask_FullMethodName = "/kandev.plugin.v1.Host/UpdateTask" - Host_CreateComment_FullMethodName = "/kandev.plugin.v1.Host/CreateComment" + Host_SendMessage_FullMethodName = "/kandev.plugin.v1.Host/SendMessage" ) // HostClient is the client API for Host service. @@ -194,20 +194,18 @@ 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). -// 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. +// Reads (§5) and writes 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. // // 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:`; writes require `api_write:` (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`. +// `api_read:tasks`, `api_write:comments`). An undeclared capability returns +// gRPC PermissionDenied with `capability 'api_read:tasks' not declared` (or +// `api_write:tasks`, etc.). // // DTOs below are a HAND-DEFINED public contract: the backend maps internal // models → these messages explicitly, never generates them from domain @@ -265,13 +263,17 @@ type HostClient interface { // > System), so a plugin can delegate a lightweight LLM step without holding // its own API key. FailedPrecondition when no utility agent is configured. InvokeUtilityAgent(ctx context.Context, in *InvokeUtilityAgentRequest, opts ...grpc.CallOption) (*InvokeUtilityAgentResponse, error) - // Writes — capability api_write:. 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. + // Writes — capability api_write:. Route through the first-party + // service layer so events fire and WS clients update — the whole reason not + // to let plugins write the DB. CreateTask/UpdateTask require api_write:tasks + // and go through the task service (task.* events; kandev stamps + // source = "plugin:" on created rows, a plugin cannot set it). SendMessage + // requires api_write:messages and delivers a prompt to a task session through + // the orchestrator — the same delivery path message_task uses (queue when the + // session is running, resume/start it otherwise). 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) + SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) } type hostClient struct { @@ -512,10 +514,10 @@ func (c *hostClient) UpdateTask(ctx context.Context, in *UpdateTaskRequest, opts return out, nil } -func (c *hostClient) CreateComment(ctx context.Context, in *CreateCommentRequest, opts ...grpc.CallOption) (*CreateCommentResponse, error) { +func (c *hostClient) SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) - out := new(CreateCommentResponse) - err := c.cc.Invoke(ctx, Host_CreateComment_FullMethodName, in, out, cOpts...) + out := new(SendMessageResponse) + err := c.cc.Invoke(ctx, Host_SendMessage_FullMethodName, in, out, cOpts...) if err != nil { return nil, err } @@ -527,20 +529,18 @@ func (c *hostClient) CreateComment(ctx context.Context, in *CreateCommentRequest // for forward compatibility. // // Implemented by KANDEV (served back over the go-plugin broker). -// 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. +// Reads (§5) and writes 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. // // 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:`; writes require `api_write:` (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`. +// `api_read:tasks`, `api_write:comments`). An undeclared capability returns +// gRPC PermissionDenied with `capability 'api_read:tasks' not declared` (or +// `api_write:tasks`, etc.). // // DTOs below are a HAND-DEFINED public contract: the backend maps internal // models → these messages explicitly, never generates them from domain @@ -598,13 +598,17 @@ type HostServer interface { // > System), so a plugin can delegate a lightweight LLM step without holding // its own API key. FailedPrecondition when no utility agent is configured. InvokeUtilityAgent(context.Context, *InvokeUtilityAgentRequest) (*InvokeUtilityAgentResponse, error) - // Writes — capability api_write:. 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. + // Writes — capability api_write:. Route through the first-party + // service layer so events fire and WS clients update — the whole reason not + // to let plugins write the DB. CreateTask/UpdateTask require api_write:tasks + // and go through the task service (task.* events; kandev stamps + // source = "plugin:" on created rows, a plugin cannot set it). SendMessage + // requires api_write:messages and delivers a prompt to a task session through + // the orchestrator — the same delivery path message_task uses (queue when the + // session is running, resume/start it otherwise). CreateTask(context.Context, *CreateTaskRequest) (*CreateTaskResponse, error) UpdateTask(context.Context, *UpdateTaskRequest) (*UpdateTaskResponse, error) - CreateComment(context.Context, *CreateCommentRequest) (*CreateCommentResponse, error) + SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) mustEmbedUnimplementedHostServer() } @@ -684,8 +688,8 @@ func (UnimplementedHostServer) CreateTask(context.Context, *CreateTaskRequest) ( 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) SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendMessage not implemented") } func (UnimplementedHostServer) mustEmbedUnimplementedHostServer() {} func (UnimplementedHostServer) testEmbeddedByValue() {} @@ -1122,20 +1126,20 @@ func _Host_UpdateTask_Handler(srv interface{}, ctx context.Context, dec func(int 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) +func _Host_SendMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendMessageRequest) if err := dec(in); err != nil { return nil, err } if interceptor == nil { - return srv.(HostServer).CreateComment(ctx, in) + return srv.(HostServer).SendMessage(ctx, in) } info := &grpc.UnaryServerInfo{ Server: srv, - FullMethod: Host_CreateComment_FullMethodName, + FullMethod: Host_SendMessage_FullMethodName, } handler := func(ctx context.Context, req interface{}) (interface{}, error) { - return srv.(HostServer).CreateComment(ctx, req.(*CreateCommentRequest)) + return srv.(HostServer).SendMessage(ctx, req.(*SendMessageRequest)) } return interceptor(ctx, in, info, handler) } @@ -1240,8 +1244,8 @@ var Host_ServiceDesc = grpc.ServiceDesc{ Handler: _Host_UpdateTask_Handler, }, { - MethodName: "CreateComment", - Handler: _Host_CreateComment_Handler, + MethodName: "SendMessage", + Handler: _Host_SendMessage_Handler, }, }, Streams: []grpc.StreamDesc{}, diff --git a/docs/decisions/0043-plugin-host-data-api.md b/docs/decisions/0043-plugin-host-data-api.md index 9a0edc503c..6657c44c02 100644 --- a/docs/decisions/0043-plugin-host-data-api.md +++ b/docs/decisions/0043-plugin-host-data-api.md @@ -141,6 +141,54 @@ Concretely: - 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:"`, 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:"` — 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. diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index 7bde437234..3e3758800f 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -85,13 +85,14 @@ service Host { rpc ListSessions(ListSessionsRequest) returns (ListSessionsResponse); rpc ListSessionCodeStats(ListSessionCodeStatsRequest) returns (ListSessionCodeStatsResponse); - // Host data API — writes, capability api_write:. 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. + // Host data API — writes, capability api_write:. Route through the + // first-party service layer so events fire (§3a "Writes"). api_write:tasks + // gates CreateTask/UpdateTask; api_write:messages gates SendMessage (delivers + // a prompt to a task session through the orchestrator). Undeclared → gRPC + // PermissionDenied. rpc CreateTask(CreateTaskRequest) returns (Task); rpc UpdateTask(UpdateTaskRequest) returns (Task); - rpc CreateComment(CreateCommentRequest) returns (Comment); + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); } message Event { @@ -146,13 +147,13 @@ 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 — +Read/write RPCs let plugins read and write kandev's own domain data — +tasks, sessions, workspaces, workflows, agent profiles, repositories, messages — 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 +`Repository`, `Session`, `SessionFilter`, `SessionCodeStats`, and the write +`CreateTaskRequest`/`UpdateTaskRequest`/`SendMessageRequest`/`SendMessageResponse`) 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 @@ -179,20 +180,41 @@ 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:"` on the -created row — a plugin cannot set it itself. +**Writes.** `CreateTask`, `UpdateTask`, and `SendMessage` are implemented. +`CreateTask`/`UpdateTask` are gated by `api_write:tasks` and route through +`internal/task/service` (never a repository), so `task.*` events fire and +WS-driven UI stays in sync. The server stamps `source = "plugin:"` on the +created task's metadata — a plugin cannot set it itself. `CreateTask` resolves +sane placement defaults when the plugin omits them: an empty `workspace_id` +resolves to the single workspace (ambiguous otherwise → `InvalidArgument`), an +empty `workflow_id` to that workspace's first workflow. `UpdateTask` accepts a +conservative field mask — `title`, `description`, `state`, `workflow_step_id` +(each optional/leave-unset). `start_agent` best-effort auto-launches an agent +through the orchestrator; a launch failure does not fail the create. -**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. +Write validation/error contract (so plugin authors can predict outcomes): +`CreateTask` requires a non-empty `title` (`InvalidArgument` otherwise); +`UpdateTask` requires `id` (`InvalidArgument`) and returns `NotFound` for a +task that doesn't exist; an `UpdateTask` `state` outside the known task-state +enum (or the orchestrator-owned `SCHEDULING`) is rejected with `InvalidArgument` +before it reaches the service. + +`SendMessage` is gated by `api_write:messages` and delivers a prompt to a task +session through the orchestrator's real delivery path (the same one +`message_task` uses), so the message reaches the agent and drives a turn — not +an office comment. It resolves the target session (explicit `session_id`, +verified to belong to the task, or the task's primary session), records a user +message stamped `source = "plugin:"`, and dispatches by session state: a +running session queues the prompt (`status: "queued"`); an idle/completed one is +prompted, resuming the agent if its process is gone (`"sent"`); a never-started +one is launched with the prompt as its first turn (`"started"`). A failed +dispatch deletes the recorded message so no orphan prompt is left. + +**Reads and writes 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`); each write handler calls the +event-publishing service method — so derived fields, events, and future access +rules stay centralized in one place. **DTOs are a hand-mapped, versioned contract — never internal structs.** The backend maps internal models to the proto messages above with explicit @@ -361,11 +383,12 @@ the API, never the DB. - **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:` for each Host - data API read RPC (§3a) — and returns PermissionDenied with - `capability '' not declared` on a miss. `EmitEvent` is ungated. - `api_write:` is accepted in the manifest but the write RPCs - (`CreateTask`/`UpdateTask`/`CreateComment`) aren't implemented yet, so they - return gRPC `Unimplemented` regardless of capability. + data API read RPC (§3a), `api_write:tasks` for `CreateTask`/`UpdateTask` and + `api_write:messages` for `SendMessage` — and returns PermissionDenied with + `capability '' not declared` on a miss. `EmitEvent` is ungated. Reads + and writes gate independently on the same resource, so a plugin can declare + `api_read:tasks` without `api_write:tasks` (or vice versa), and likewise for + `messages`. ## 6. Package format (`-.tar.gz`) diff --git a/docs/plans/plugins/HOST-DATA-API.proto b/docs/plans/plugins/HOST-DATA-API.proto index 8425de7672..a2a110154a 100644 --- a/docs/plans/plugins/HOST-DATA-API.proto +++ b/docs/plans/plugins/HOST-DATA-API.proto @@ -1,10 +1,13 @@ -// PROPOSED — kandev.plugin.v1 Host data API (draft for review, not wired yet). +// 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, 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. +// 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: [""]`; writes require @@ -41,7 +44,7 @@ service HostData { // plugins write the DB. rpc CreateTask(CreateTaskRequest) returns (CreateTaskResponse); rpc UpdateTask(UpdateTaskRequest) returns (UpdateTaskResponse); - rpc CreateComment(CreateCommentRequest) returns (CreateCommentResponse); + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse); } // ── Common ────────────────────────────────────────────────────────────────── @@ -191,7 +194,7 @@ message SessionCodeStats { message ListSessionCodeStatsRequest { SessionFilter filter = 1; Page page = 2; } message ListSessionCodeStatsResponse { repeated SessionCodeStats stats = 1; PageInfo page_info = 2; } -// ── Writes (phase 2; api_write) ────────────────────────────────────────────── +// ── Writes (api_write) ─────────────────────────────────────────────────────── message CreateTaskRequest { string workspace_id = 1; string workflow_id = 2; @@ -211,12 +214,17 @@ message UpdateTaskRequest { 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:" - string created_at = 5; +// 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:" 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" } -message CreateCommentRequest { string task_id = 1; string body = 2; } -message CreateCommentResponse { Comment comment = 1; } diff --git a/docs/specs/plugins/spec.md b/docs/specs/plugins/spec.md index 360283e367..bfb7629501 100644 --- a/docs/specs/plugins/spec.md +++ b/docs/specs/plugins/spec.md @@ -106,7 +106,7 @@ min_kandev_version: "0.78.0" # optional capabilities: events: ["task.created", "task.state_changed", "agent.completed"] api_read: ["tasks", "sessions"] # Host data API reads (see below); live now - api_write: ["tasks", "comments"] # Host data API writes; deferred, no effect yet + api_write: ["tasks", "messages"] # Host data API writes (CreateTask/UpdateTask/SendMessage); live now state: true secrets: true auth: true # establish a login session for an @@ -138,11 +138,10 @@ restart_count: 0 ``` `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 +RPCs (both reads and writes live now) — the vocabulary is a list of resource names: `tasks`, `sessions`, `messages`, `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". +`agent_profiles`, `repositories` for `api_read`, plus `tasks` (CreateTask/ +UpdateTask) and `messages` (SendMessage) for `api_write`. 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: @@ -151,8 +150,10 @@ corresponding Host data reads for that resource only — e.g. `api_read: `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:' -not declared`. `api_write` entries are accepted and stored but currently have no -effect (see "Write phase (deferred)"). +not declared`. Writes work the same way under `api_write` (message +`capability 'api_write:' not declared`), and reads and writes gate +independently on the same resource — a plugin may declare `api_read:tasks` +without `api_write:tasks`, or vice versa (see "Host data API writes"). **External login (`auth`).** An `auth`-capable plugin can log a visitor in against an external IdP (OIDC/SAML): its webhook (the callback / SAML ACS) @@ -351,17 +352,16 @@ service Host { Every Host RPC is capability-gated: `GetState`/`SetState`/`DeleteState`/`ListState` check `capabilities.state`, `RevealSecret` checks `capabilities.secrets`, `InvokeUtilityAgent` checks `capabilities.agent_invoke`, and each Host data API -read RPC checks `capabilities.api_read` for its resource (see "Host data API" +read RPC checks `capabilities.api_read` for its resource, and each Host data API +write RPC checks `capabilities.api_write` for its resource (see "Host data API" below) — all before the handler runs, returning gRPC status `PermissionDenied` with message `capability '' 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)"). +ungated (no boolean capability applies). ### Host data API (plugin -> kandev, gRPC) -Plugins read (and, in a later phase, write) kandev's own domain data — tasks, -sessions, workspaces, workflows, agent profiles, repositories, comments — over the +Plugins read and write kandev's own domain data — tasks, +sessions, workspaces, workflows, agent profiles, repositories, messages — 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 @@ -403,13 +403,35 @@ system context is inline markup removed at read time. Reads route through the task service's `ListMessagesForPlugin` (a single filtered session/task/time/type query), never a repository or the DB file directly. -**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:` (`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:"` on the created -row/comment — a plugin cannot set provenance itself. Declaring an `api_write` -capability has no effect until the write RPCs ship. +**Host data API writes.** `CreateTask`, `UpdateTask`, and `SendMessage` are +implemented, each gated by `api_write:` and routed through the +first-party service layer (never a repository) so the corresponding events fire +and WS clients update. The server stamps `source = "plugin:"` — a plugin +cannot set provenance itself. + +`Host.Tasks().Create`/`Update` (capability `api_write:tasks`) go through +`internal/task/service` so `task.*` events fire. `CreateTask` fills sane +placement defaults when the plugin omits them (single workspace; that +workspace's first workflow — ambiguous → `InvalidArgument`), accepts an optional +`start_agent` that best-effort auto-launches an agent, and requires a title. +`UpdateTask` accepts a conservative field mask — `title` / `description` / +`state` / `workflow_step_id`, each optional (a nil field is left unchanged); a +missing task returns gRPC `NotFound`. + +`Host.Messages().Send` (capability `api_write:messages`) delivers a prompt to a +task session through the orchestrator's real delivery path — the same one +`message_task` uses — so the message reaches the agent and drives a turn (it is +*not* an office comment). It resolves the target session (an explicit +`session_id`, verified to belong to the task, or the task's primary session), +records a user message stamped with the plugin source, and dispatches by session +state: a running session queues the prompt (`status: queued`); an idle/completed +one is prompted, resuming the agent process if it has gone (`sent`); a +never-started one is launched with the prompt as its first turn (`started`). If +dispatch fails after the user message was recorded, the recorded message is +deleted so a failed delivery leaves no durable user message (and a retry can't +stack a duplicate prompt). `task_id` and `text` are required (`InvalidArgument` +otherwise); a session that doesn't belong to the task, or a task with no active +session, returns `NotFound`; a terminal (failed/cancelled) session is rejected. **Conventions.** @@ -692,9 +714,23 @@ restart with backoff (max 5 attempts, then `error`). Next successful handshake/` plugin/slot/ordinal identity restores the saved position; the original slot remains its default side rather than overriding user order. -- **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. +- **GIVEN** a plugin whose manifest declares `api_write: ["tasks"]`, **WHEN** it + calls `CreateTask`, **THEN** kandev creates the task through the task service + (firing `task.created`), stamps `source = "plugin:"`, and returns the task; + **WHEN** a plugin without `api_write:tasks` calls `CreateTask`, **THEN** kandev + returns gRPC `PermissionDenied` with `capability 'api_write:tasks' not declared`. + +- **GIVEN** a plugin whose manifest declares `api_write: ["messages"]`, **WHEN** + it calls `SendMessage` on a task, **THEN** kandev delivers the prompt to the + task's session through the orchestrator (queueing if running, resuming/starting + otherwise), records a user message with `source = "plugin:"`, and returns + the target session and a `queued`/`sent`/`started` status; a plugin lacking + `api_write:messages` is denied. + +- **GIVEN** a `SendMessage` whose dispatch fails after the user message was + recorded, **THEN** kandev deletes the recorded message and returns an error, so + a failed delivery leaves no durable user message and a retry can't duplicate + the prompt. ## Out of scope @@ -729,9 +765,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. -- **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". +- **Broader write surface.** The Host data API writes cover task create/update + (conservative field mask) and sending a message to a task session. Deleting or + archiving tasks, writing sessions/workspaces/workflows/repositories, a wider + task-update mask, and delivery-mode/interrupt control on SendMessage are out of + scope for now. 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