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