feat(plugins): expand task fields for integrations
This commit is contained in:
@@ -91,7 +91,7 @@ func TestPluginsTaskWriter_CreateMapsSourceToMetadata(t *testing.T) {
|
||||
|
||||
_, 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",
|
||||
Title: "Investigate", Description: "details", ParentID: "parent-1", Source: "plugin:acme", Priority: "critical",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "ws-1", svc.lastCreate.WorkspaceID)
|
||||
@@ -99,6 +99,7 @@ func TestPluginsTaskWriter_CreateMapsSourceToMetadata(t *testing.T) {
|
||||
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")
|
||||
require.Equal(t, "critical", svc.lastCreate.Priority)
|
||||
}
|
||||
|
||||
func TestPluginsTaskWriter_CreateWithoutSourceOmitsMetadata(t *testing.T) {
|
||||
@@ -154,12 +155,14 @@ func TestPluginsTaskWriter_UpdateMapsFieldMask(t *testing.T) {
|
||||
|
||||
title := "Renamed"
|
||||
state := "IN_PROGRESS"
|
||||
_, err := a.UpdateTask(context.Background(), plugins.TaskUpdateInput{ID: "task-1", Title: &title, State: &state})
|
||||
priority := "low"
|
||||
_, err := a.UpdateTask(context.Background(), plugins.TaskUpdateInput{ID: "task-1", Title: &title, State: &state, Priority: &priority})
|
||||
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, "low", *svc.lastUpdate.Priority)
|
||||
require.Nil(t, svc.lastUpdate.Description, "an unset field stays nil")
|
||||
}
|
||||
|
||||
|
||||
@@ -1262,6 +1262,7 @@ func (a pluginsTaskWriterAdapter) CreateTask(ctx context.Context, in plugins.Tas
|
||||
Metadata: metadata,
|
||||
Repositories: repositories,
|
||||
PlanMode: in.PlanMode,
|
||||
Priority: in.Priority,
|
||||
StartAgent: in.StartAgent,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1384,6 +1385,7 @@ func (a pluginsTaskWriterAdapter) UpdateTask(ctx context.Context, in plugins.Tas
|
||||
req := &taskservice.UpdateTaskRequest{
|
||||
Title: in.Title,
|
||||
Description: in.Description,
|
||||
Priority: in.Priority,
|
||||
}
|
||||
if in.State != nil {
|
||||
// v1.TaskState is a string type, so the cast can't fail — validate the
|
||||
|
||||
@@ -147,21 +147,16 @@ func timePtrToRFC3339(t *time.Time) *string {
|
||||
return &s
|
||||
}
|
||||
|
||||
// decodeTaskLabels turns the model's JSON-array-string into a real slice.
|
||||
//
|
||||
// A MALFORMED VALUE YIELDS NO LABELS, NOT AN ERROR. Labels are decoration on a
|
||||
// read that must not fail because one row holds something unparseable; the
|
||||
// alternative is a whole task list refused over a cosmetic field.
|
||||
func decodeTaskLabels(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
if err := json.Unmarshal([]byte(raw), &out); err != nil {
|
||||
var labels []string
|
||||
if err := json.Unmarshal([]byte(raw), &labels); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
return labels
|
||||
}
|
||||
|
||||
// taskPRsToDTOs maps kandev's own PR rows onto the plugin contract. Review and
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The step a task stands on, its labels, and whether it is archived were all
|
||||
// The step a task stands on and whether it is archived were all
|
||||
// unreadable from a plugin before this. A plugin could see a task's State
|
||||
// (created / in progress / review / completed) but not WHERE on the board it
|
||||
// was, so it could not render or group by a workflow at all.
|
||||
@@ -25,7 +25,6 @@ func TestTaskModelToDTOCarriesBoardAndPlanningFields(t *testing.T) {
|
||||
WorkflowStepID: "step-pr-review",
|
||||
Position: 3,
|
||||
AssigneeAgentProfileID: "agent-opus",
|
||||
Labels: `["office","unblock"]`,
|
||||
Autopilot: true,
|
||||
WIPAdmitted: true,
|
||||
QueuedForStepID: "step-build",
|
||||
@@ -40,7 +39,6 @@ func TestTaskModelToDTOCarriesBoardAndPlanningFields(t *testing.T) {
|
||||
require.Equal(t, "step-pr-review", dto.WorkflowStepID)
|
||||
require.Equal(t, int32(3), dto.Position)
|
||||
require.Equal(t, "agent-opus", dto.AssigneeAgentProfileID)
|
||||
require.Equal(t, []string{"office", "unblock"}, dto.Labels)
|
||||
require.True(t, dto.Autopilot)
|
||||
require.True(t, dto.WIPAdmitted)
|
||||
require.Equal(t, "step-build", dto.QueuedForStepID)
|
||||
@@ -60,17 +58,6 @@ func TestTaskModelToDTOLeavesArchivedAtNilForALiveTask(t *testing.T) {
|
||||
require.Nil(t, dto.QueuedAt)
|
||||
}
|
||||
|
||||
// Labels are a JSON-array string on the model. A row holding something
|
||||
// unparseable must cost that row its labels, never the whole task read.
|
||||
func TestDecodeTaskLabelsToleratesEmptyAndMalformedValues(t *testing.T) {
|
||||
require.Equal(t, []string{"a", "b"}, decodeTaskLabels(`["a","b"]`))
|
||||
require.Nil(t, decodeTaskLabels(""))
|
||||
require.Nil(t, decodeTaskLabels("[]"))
|
||||
require.Nil(t, decodeTaskLabels(" "))
|
||||
require.Nil(t, decodeTaskLabels("not json"))
|
||||
require.Nil(t, decodeTaskLabels(`{"a":1}`))
|
||||
}
|
||||
|
||||
// Review and check state ride along because kandev's PR watcher already syncs
|
||||
// them; a plugin asking "what can merge" would otherwise re-query the forge
|
||||
// once per pull request.
|
||||
|
||||
@@ -948,22 +948,24 @@ func TestSortSessionsNewestFirst_TiesBrokenByID(t *testing.T) {
|
||||
func TestTaskModelToDTO_MapsFields(t *testing.T) {
|
||||
created := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
task := &taskmodels.Task{
|
||||
ID: "task-1",
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
Title: "Fix bug",
|
||||
Description: "details",
|
||||
State: v1.TaskStateInProgress,
|
||||
Priority: "high",
|
||||
Origin: "agent_created",
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
ParentID: "parent-1",
|
||||
Identifier: "KAN-1",
|
||||
IsEphemeral: false,
|
||||
ID: "task-1",
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
WorkflowStepID: "step-7f3a9c2b-0001-4f42-a5d1-9c0e8b7d6a5f",
|
||||
Title: "Fix bug",
|
||||
Description: "details",
|
||||
State: v1.TaskStateInProgress,
|
||||
Priority: "high",
|
||||
Origin: "agent_created",
|
||||
CreatedAt: created,
|
||||
UpdatedAt: created,
|
||||
ParentID: "parent-1",
|
||||
Identifier: "KAN-1",
|
||||
IsEphemeral: false,
|
||||
Repositories: []*taskmodels.TaskRepository{
|
||||
{ID: "tr-1", RepositoryID: "repo-1", BaseBranch: "main", Position: 0, CheckoutBranch: "feature/fix"},
|
||||
},
|
||||
Labels: `["bug","customer"]`,
|
||||
Metadata: map[string]any{"k": "v"},
|
||||
}
|
||||
|
||||
@@ -972,6 +974,9 @@ func TestTaskModelToDTO_MapsFields(t *testing.T) {
|
||||
if dto.ID != "task-1" || dto.State != "IN_PROGRESS" || dto.CreatedBy != "agent_created" {
|
||||
t.Fatalf("taskModelToDTO() = %+v, unexpected core fields", dto)
|
||||
}
|
||||
if dto.Priority != "high" {
|
||||
t.Errorf("Priority = %q, want high", dto.Priority)
|
||||
}
|
||||
if dto.CreatedAt != created.Format(time.RFC3339) {
|
||||
t.Errorf("CreatedAt = %q, want RFC3339 %q", dto.CreatedAt, created.Format(time.RFC3339))
|
||||
}
|
||||
@@ -984,6 +989,20 @@ func TestTaskModelToDTO_MapsFields(t *testing.T) {
|
||||
if dto.Metadata["k"] != "v" {
|
||||
t.Errorf("Metadata = %+v, want k=v", dto.Metadata)
|
||||
}
|
||||
if dto.WorkflowStepID != "step-7f3a9c2b-0001-4f42-a5d1-9c0e8b7d6a5f" {
|
||||
t.Errorf("WorkflowStepID = %q, want %q", dto.WorkflowStepID, "step-7f3a9c2b-0001-4f42-a5d1-9c0e8b7d6a5f")
|
||||
}
|
||||
if got, want := dto.Labels, []string{"bug", "customer"}; !reflect.DeepEqual(got, want) { //nolint:staticcheck // verifies deprecated API v1 compatibility
|
||||
t.Errorf("Labels = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskModelToDTO_MalformedLabelsFallbackToEmpty(t *testing.T) {
|
||||
dto := taskModelToDTO(&taskmodels.Task{ID: "task-1", Labels: `not-json`})
|
||||
labels := dto.Labels //nolint:staticcheck // verifies deprecated API v1 compatibility
|
||||
if len(labels) != 0 {
|
||||
t.Errorf("Labels = %#v, want empty fallback for malformed stored JSON", labels)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskModelToDTO_EmptyParentIDIsNil(t *testing.T) {
|
||||
|
||||
@@ -7,10 +7,9 @@
|
||||
// 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.
|
||||
// session is running, resume/start it otherwise). Task writes and message
|
||||
// delivery go through narrow taskWriter and taskMessenger interfaces,
|
||||
// satisfied by backendapp adapters.
|
||||
package plugins
|
||||
|
||||
import (
|
||||
@@ -49,6 +48,7 @@ type TaskCreateInput struct {
|
||||
Launch TaskLaunchInput
|
||||
Metadata map[string]any
|
||||
PlanMode bool
|
||||
Priority string
|
||||
// StartAgent reports that the caller will launch an agent for this task
|
||||
// right after creation. The adapter maps it onto CreateTaskRequest so step
|
||||
// resolution sends the task to the first step that runs agents rather than
|
||||
@@ -75,6 +75,7 @@ type TaskUpdateInput struct {
|
||||
Description *string
|
||||
State *string
|
||||
WorkflowStepID *string
|
||||
Priority *string
|
||||
}
|
||||
|
||||
// TaskMoveInput is the plugins-local task-move request a taskWriter adapter
|
||||
@@ -177,6 +178,9 @@ func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*
|
||||
if in.Title == "" {
|
||||
return nil, invalidArgument("title is required")
|
||||
}
|
||||
if in.Priority != "" && taskmodels.ValidateTaskPriority(in.Priority) != nil {
|
||||
return nil, invalidArgument(fmt.Sprintf("invalid task priority %q", in.Priority))
|
||||
}
|
||||
metadata, err := r.host.pluginTaskMetadata(in.Metadata)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -209,6 +213,7 @@ func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*
|
||||
Launch: launch,
|
||||
Metadata: metadata,
|
||||
PlanMode: launch.PlanMode,
|
||||
Priority: in.Priority,
|
||||
StartAgent: in.StartAgent,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -231,12 +236,21 @@ func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*
|
||||
if in.ID == "" {
|
||||
return nil, invalidArgument("id is required")
|
||||
}
|
||||
if in.WorkflowStepID != nil {
|
||||
return nil, invalidArgument(
|
||||
"workflow_step_id cannot be set via UpdateTask: use MoveTask to transition a task between workflow steps",
|
||||
)
|
||||
}
|
||||
if in.Priority != nil && taskmodels.ValidateTaskPriority(*in.Priority) != nil {
|
||||
return nil, invalidArgument(fmt.Sprintf("invalid task priority %q", *in.Priority))
|
||||
}
|
||||
updated, err := r.host.taskWriter.UpdateTask(ctx, TaskUpdateInput{
|
||||
ID: in.ID,
|
||||
Title: in.Title,
|
||||
Description: in.Description,
|
||||
State: in.State,
|
||||
WorkflowStepID: in.WorkflowStepID,
|
||||
Priority: in.Priority,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, repoerrors.ErrTaskNotFound) {
|
||||
|
||||
@@ -82,7 +82,7 @@ func TestPluginHost_Tasks_WriteOnlyCannotRead(t *testing.T) {
|
||||
|
||||
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"}
|
||||
d.taskWriter.created = &taskmodels.Task{ID: "task-9", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", Priority: "high"}
|
||||
|
||||
task, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{
|
||||
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate", Description: "details",
|
||||
@@ -93,6 +93,9 @@ func TestPluginHost_Tasks_CreateSucceedsAndStampsSource(t *testing.T) {
|
||||
if task == nil || task.ID != "task-9" {
|
||||
t.Fatalf("Create() = %+v, want task-9", task)
|
||||
}
|
||||
if task.Priority != "high" {
|
||||
t.Fatalf("Create() priority = %q, want high", task.Priority)
|
||||
}
|
||||
if d.taskWriter.createCalls != 1 {
|
||||
t.Fatalf("task writer create calls = %d, want 1", d.taskWriter.createCalls)
|
||||
}
|
||||
@@ -450,11 +453,12 @@ func TestPluginHost_Tasks_CreateAmbiguousWorkspaceIsInvalidArgument(t *testing.T
|
||||
|
||||
func TestPluginHost_Tasks_UpdateSucceedsWithMask(t *testing.T) {
|
||||
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
|
||||
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "Renamed"}
|
||||
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "Renamed", Priority: "high"}
|
||||
|
||||
title := "Renamed"
|
||||
state := "IN_PROGRESS"
|
||||
_, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Title: &title, State: &state})
|
||||
priority := "high"
|
||||
task, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Title: &title, State: &state, Priority: &priority})
|
||||
if err != nil {
|
||||
t.Fatalf("Update() unexpected error: %v", err)
|
||||
}
|
||||
@@ -467,6 +471,52 @@ func TestPluginHost_Tasks_UpdateSucceedsWithMask(t *testing.T) {
|
||||
if d.taskWriter.lastUpdate.Description != nil {
|
||||
t.Fatalf("unset description must stay nil, got %v", d.taskWriter.lastUpdate.Description)
|
||||
}
|
||||
if d.taskWriter.lastUpdate.Priority == nil || *d.taskWriter.lastUpdate.Priority != "high" {
|
||||
t.Fatalf("priority = %v, want high", d.taskWriter.lastUpdate.Priority)
|
||||
}
|
||||
if task.Priority != "high" {
|
||||
t.Fatalf("Update() priority = %q, want high", task.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHost_Tasks_RejectsInvalidPriority(t *testing.T) {
|
||||
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
|
||||
bad := "urgent"
|
||||
|
||||
_, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "x", Priority: bad})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("Create() invalid priority error = %v, want InvalidArgument", err)
|
||||
}
|
||||
_, err = d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1", Priority: &bad})
|
||||
if status.Code(err) != codes.InvalidArgument {
|
||||
t.Fatalf("Update() invalid priority error = %v, want InvalidArgument", err)
|
||||
}
|
||||
if d.taskWriter.createCalls != 0 || d.taskWriter.updateCalls != 0 {
|
||||
t.Fatal("task writer called with an invalid priority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHost_Tasks_UpdateRejectsWorkflowStepIDBeforeInvalidPriority(t *testing.T) {
|
||||
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
|
||||
step := "step-2"
|
||||
badPriority := "urgent"
|
||||
|
||||
_, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{
|
||||
ID: "task-1",
|
||||
WorkflowStepID: &step,
|
||||
Priority: &badPriority,
|
||||
})
|
||||
|
||||
if got := status.Code(err); got != codes.InvalidArgument {
|
||||
t.Fatalf("Update() error code = %v, want InvalidArgument", got)
|
||||
}
|
||||
const wantMessage = "workflow_step_id cannot be set via UpdateTask: use MoveTask to transition a task between workflow steps"
|
||||
if got := status.Convert(err).Message(); got != wantMessage {
|
||||
t.Fatalf("Update() error message = %q, want %q", got, wantMessage)
|
||||
}
|
||||
if d.taskWriter.updateCalls != 0 {
|
||||
t.Fatalf("task writer called %d times for a rejected move", d.taskWriter.updateCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginHost_Tasks_UpdateRequiresID(t *testing.T) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package models
|
||||
|
||||
import "fmt"
|
||||
|
||||
// Canonical task priority enum values, shared by the task service and any
|
||||
// lower-tier caller (such as the plugin host) that must validate a priority
|
||||
// without importing internal/task/service.
|
||||
const (
|
||||
TaskPriorityCritical = "critical"
|
||||
TaskPriorityHigh = "high"
|
||||
TaskPriorityMedium = "medium"
|
||||
TaskPriorityLow = "low"
|
||||
)
|
||||
|
||||
// ValidateTaskPriority checks the canonical priority enum.
|
||||
func ValidateTaskPriority(priority string) error {
|
||||
switch priority {
|
||||
case TaskPriorityCritical, TaskPriorityHigh, TaskPriorityMedium, TaskPriorityLow:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid task priority %q", priority)
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,16 @@ import (
|
||||
|
||||
// defaultPriority is the default value for the task priority column.
|
||||
// Used when a caller omits priority so the DB CHECK constraint is satisfied.
|
||||
const defaultPriority = "medium"
|
||||
const defaultPriority = models.TaskPriorityMedium
|
||||
|
||||
// ValidateTaskPriority checks the canonical priority enum. Creation callers
|
||||
// may omit priority and receive the default; updates must always name a value.
|
||||
// It delegates to models.ValidateTaskPriority so internal/plugins (which
|
||||
// cannot import internal/task/service without an import cycle, per ADR 0043)
|
||||
// can validate against the same enum via the lower-level models package.
|
||||
func ValidateTaskPriority(priority string) error {
|
||||
return models.ValidateTaskPriority(priority)
|
||||
}
|
||||
|
||||
const (
|
||||
providerAzureDevOps = "azure_devops"
|
||||
@@ -752,6 +761,11 @@ func (s *Service) validateCreateTaskRequest(req *CreateTaskRequest) error {
|
||||
if err := validateTaskTitle(req.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if req.Priority != "" {
|
||||
if err := ValidateTaskPriority(req.Priority); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
isOffice := isOfficeRequest(req)
|
||||
// Automation runs never land on a board, so they need no workflow — the
|
||||
// trigger is the start signal, not a column. They are still ordinary,
|
||||
@@ -1908,6 +1922,11 @@ func (s *Service) UpdateTask(ctx context.Context, id string, req *UpdateTaskRequ
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if req.Priority != nil {
|
||||
if err := ValidateTaskPriority(*req.Priority); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
task, err := s.tasks.GetTask(ctx, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -654,6 +654,38 @@ func TestService_CreateTask_DefaultsPriorityWhenEmpty(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_TaskPriorityRejectsUnknownValues(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-priority", Name: "Workspace"}); err != nil {
|
||||
t.Fatalf("CreateWorkspace: %v", err)
|
||||
}
|
||||
if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-priority", WorkspaceID: "ws-priority", Name: "Workflow"}); err != nil {
|
||||
t.Fatalf("CreateWorkflow: %v", err)
|
||||
}
|
||||
if _, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-priority", WorkflowID: "wf-priority", Title: "Bad priority", Priority: "urgent"}); err == nil {
|
||||
t.Fatal("CreateTask accepted an unknown priority")
|
||||
}
|
||||
if err := repo.CreateTask(ctx, &models.Task{ID: "task-priority", WorkspaceID: "ws-priority", WorkflowID: "wf-priority", Title: "Priority", Priority: "medium"}); err != nil {
|
||||
t.Fatalf("CreateTask: %v", err)
|
||||
}
|
||||
priority := "urgent"
|
||||
if _, err := svc.UpdateTask(ctx, "task-priority", &UpdateTaskRequest{Priority: &priority}); err == nil {
|
||||
t.Fatal("UpdateTask accepted an unknown priority")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTaskPriority(t *testing.T) {
|
||||
for _, priority := range []string{"critical", "high", "medium", "low"} {
|
||||
if err := ValidateTaskPriority(priority); err != nil {
|
||||
t.Errorf("ValidateTaskPriority(%q) = %v, want nil", priority, err)
|
||||
}
|
||||
}
|
||||
if err := ValidateTaskPriority("urgent"); err == nil {
|
||||
t.Error("ValidateTaskPriority(urgent) = nil, want error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_CreateTask_RejectsDuplicateRepositories(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -93,12 +93,13 @@ func taskRepositoryFromProto(p *pluginv1.TaskRepository) TaskRepository {
|
||||
|
||||
// Task is the Go-native mirror of kandev.plugin.v1.Task.
|
||||
type Task struct {
|
||||
ID string
|
||||
WorkspaceID string
|
||||
WorkflowID string
|
||||
Title string
|
||||
Description string
|
||||
State string
|
||||
ID string
|
||||
WorkspaceID string
|
||||
WorkflowID string
|
||||
Title string
|
||||
Description string
|
||||
State string
|
||||
// Priority is one of critical, high, medium, or low.
|
||||
Priority string
|
||||
CreatedBy string
|
||||
CreatedAt string
|
||||
@@ -122,13 +123,15 @@ type Task struct {
|
||||
WorkflowStepID string
|
||||
Position int32
|
||||
AssigneeAgentProfileID string
|
||||
Labels []string
|
||||
Autopilot bool
|
||||
WIPAdmitted bool
|
||||
QueuedForStepID string
|
||||
QueuedAt *string
|
||||
ProjectID string
|
||||
ExternalID string
|
||||
// Deprecated: provider-specific labels belong in plugin-owned task state
|
||||
// and UI slot data. Retained for API v1 source and wire compatibility.
|
||||
Labels []string
|
||||
Autopilot bool
|
||||
WIPAdmitted bool
|
||||
QueuedForStepID string
|
||||
QueuedAt *string
|
||||
ProjectID string
|
||||
ExternalID string
|
||||
}
|
||||
|
||||
// TaskPullRequest is one change opened for a task. Provider-neutral by design:
|
||||
@@ -1041,7 +1044,9 @@ type CreateTaskInput struct {
|
||||
// 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
|
||||
StartAgent bool
|
||||
// Priority accepts critical, high, medium, or low. Empty defaults to medium.
|
||||
Priority string
|
||||
Repositories []PluginTaskRepository
|
||||
Launch *PluginTaskLaunchOptions
|
||||
Metadata map[string]any
|
||||
@@ -1063,6 +1068,7 @@ func (in CreateTaskInput) toProto() (*pluginv1.CreateTaskRequest, error) {
|
||||
Repositories: pluginTaskRepositoriesToProto(in.Repositories),
|
||||
Launch: in.Launch.toProto(),
|
||||
Metadata: metadata,
|
||||
Priority: in.Priority,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1085,6 +1091,7 @@ func createTaskInputFromProto(p *pluginv1.CreateTaskRequest) (CreateTaskInput, e
|
||||
Repositories: pluginTaskRepositoriesFromProto(p.GetRepositories()),
|
||||
Launch: pluginTaskLaunchOptionsFromProto(p.GetLaunch()),
|
||||
Metadata: metadata,
|
||||
Priority: p.GetPriority(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -1195,7 +1202,7 @@ func pluginTaskLaunchOptionsFromProto(p *pluginv1.PluginTaskLaunchOptions) *Plug
|
||||
// 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) is the documented
|
||||
// conservative field surface (title/description/state/priority) is the documented
|
||||
// plugin-writable mask. WorkflowStepID is rejected when non-nil — it exists
|
||||
// on this struct only for wire-shape symmetry with UpdateTaskRequest; use
|
||||
// TaskReader.Move (see host.go) to transition a task between workflow steps,
|
||||
@@ -1208,6 +1215,8 @@ type UpdateTaskInput struct {
|
||||
Description *string
|
||||
State *string
|
||||
WorkflowStepID *string
|
||||
// Priority, when set, must be critical, high, medium, or low.
|
||||
Priority *string
|
||||
}
|
||||
|
||||
func (in UpdateTaskInput) toProto() *pluginv1.UpdateTaskRequest {
|
||||
@@ -1217,6 +1226,7 @@ func (in UpdateTaskInput) toProto() *pluginv1.UpdateTaskRequest {
|
||||
Description: in.Description,
|
||||
State: in.State,
|
||||
WorkflowStepId: in.WorkflowStepID,
|
||||
Priority: in.Priority,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1230,6 +1240,7 @@ func updateTaskInputFromProto(p *pluginv1.UpdateTaskRequest) UpdateTaskInput {
|
||||
Description: p.Description,
|
||||
State: p.State,
|
||||
WorkflowStepID: p.WorkflowStepId,
|
||||
Priority: p.Priority,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package pluginsdk
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -8,6 +9,12 @@ import (
|
||||
|
||||
func strPtr(s string) *string { return &s }
|
||||
|
||||
func TestTaskRetainsReleasedLabelsSourceField(t *testing.T) {
|
||||
field, ok := reflect.TypeOf(Task{}).FieldByName("Labels")
|
||||
require.True(t, ok, "Task.Labels shipped in v0.93.0 and must remain source-compatible in API v1")
|
||||
require.Equal(t, reflect.TypeOf([]string{}), field.Type)
|
||||
}
|
||||
|
||||
func TestPageProtoRoundTrip(t *testing.T) {
|
||||
p := Page{Limit: 25, Cursor: "cursor-1"}
|
||||
proto := p.toProto()
|
||||
@@ -61,7 +68,8 @@ func TestTaskProtoRoundTrip(t *testing.T) {
|
||||
ChecksTotal: 5, ChecksPassing: 5, Additions: 12, Deletions: 3, AuthorLogin: "nova28",
|
||||
}},
|
||||
WorkflowStepID: "step-review", Position: 4, AssigneeAgentProfileID: "agent-1",
|
||||
Labels: []string{"plugin", "ready"}, Autopilot: true, WIPAdmitted: true,
|
||||
Labels: []string{"bug", "customer"},
|
||||
Autopilot: true, WIPAdmitted: true,
|
||||
QueuedForStepID: "step-build", QueuedAt: strPtr("2026-07-16T10:00:00Z"),
|
||||
ProjectID: "project-1", ExternalID: "external-1",
|
||||
}
|
||||
@@ -74,6 +82,7 @@ func TestTaskProtoRoundTrip(t *testing.T) {
|
||||
require.Equal(t, "feature/fix", proto.GetRepositories()[0].GetCheckoutBranch())
|
||||
require.Empty(t, proto.GetRepositories()[1].GetCheckoutBranch(), "empty checkout branches remain wire-compatible")
|
||||
require.Equal(t, "step-review", proto.GetWorkflowStepId())
|
||||
require.Equal(t, []string{"bug", "customer"}, proto.GetLabels()) //nolint:staticcheck // verifies deprecated API v1 compatibility
|
||||
require.True(t, proto.GetAutopilot())
|
||||
require.Len(t, proto.GetPullRequests(), 1)
|
||||
require.Equal(t, int64(42), proto.GetPullRequests()[0].GetNumber())
|
||||
@@ -218,6 +227,7 @@ func TestCreateTaskInputRichProtoRoundTrip(t *testing.T) {
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
Title: "Plugin-created task",
|
||||
Priority: "high",
|
||||
Repositories: []PluginTaskRepository{{
|
||||
Remote: &RemoteRepositoryDescriptor{
|
||||
ProviderID: "example", ProviderHost: "code.example.test", OwnerOrProject: "team",
|
||||
@@ -237,6 +247,30 @@ func TestCreateTaskInputRichProtoRoundTrip(t *testing.T) {
|
||||
require.Equal(t, input, back)
|
||||
}
|
||||
|
||||
func TestCreateTaskInputProtoRoundTripPreservesPriority(t *testing.T) {
|
||||
input := CreateTaskInput{
|
||||
WorkspaceID: "ws-1",
|
||||
WorkflowID: "wf-1",
|
||||
Title: "plugin task",
|
||||
Priority: "critical",
|
||||
}
|
||||
proto, err := input.toProto()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "critical", proto.GetPriority())
|
||||
|
||||
back, err := createTaskInputFromProto(proto)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, input, back)
|
||||
}
|
||||
|
||||
func TestUpdateTaskInputProtoRoundTripPreservesPriority(t *testing.T) {
|
||||
priority := "low"
|
||||
input := UpdateTaskInput{ID: "task-1", Priority: &priority}
|
||||
|
||||
proto := input.toProto()
|
||||
require.Equal(t, input, updateTaskInputFromProto(proto))
|
||||
}
|
||||
|
||||
func TestRepositoryProtoRoundTrip(t *testing.T) {
|
||||
repo := Repository{
|
||||
ID: "repo-1",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
pluginv1 "github.com/kandev/kandev/proto/kandev/plugin/v1"
|
||||
"google.golang.org/protobuf/reflect/protoreflect"
|
||||
"google.golang.org/protobuf/types/descriptorpb"
|
||||
)
|
||||
|
||||
func TestProtocolDeclaresAdditivePluginAndHostMethods(t *testing.T) {
|
||||
@@ -22,11 +23,24 @@ func TestProtocolDeclaresAdditivePluginAndHostMethods(t *testing.T) {
|
||||
assertMessageFields(t, "TaskRepository", "id", "repository_id", "base_branch", "position", "checkout_branch")
|
||||
assertMessageFields(t, "Repository", "id", "workspace_id", "name", "default_branch", "source_type", "provider_id", "provider_repository_id", "provider_host", "owner_or_project", "provider_name", "remote_url", "provider_scope")
|
||||
assertMessageOmitsFields(t, "Repository", "local_path", "setup_script", "cleanup_script", "dev_script", "copy_files")
|
||||
assertMessageFields(t, "CreateTaskRequest", "repositories", "launch", "metadata")
|
||||
assertMessageFields(t, "Task", "priority", "labels")
|
||||
assertDeprecatedRepeatedMessageField(t, "Task", "labels", 23)
|
||||
assertMessageFields(t, "CreateTaskRequest", "repositories", "launch", "metadata", "priority")
|
||||
assertMessageFields(t, "UpdateTaskRequest", "priority")
|
||||
assertMessageOmitsFields(t, "CreateTaskRequest", "labels")
|
||||
assertMessageOmitsFields(t, "UpdateTaskRequest", "labels")
|
||||
assertMessageFields(t, "RemoteRepositoryDescriptor", "provider_id", "provider_host", "owner_or_project", "provider_repository_id", "name", "clone_url", "provider_scope")
|
||||
assertMessageFields(t, "DeletePluginOwnedTaskTreeProgress", "deleted_task_ids")
|
||||
}
|
||||
|
||||
func TestReleasedTaskLabelsGeneratedSourceCompatibility(t *testing.T) {
|
||||
generated := &pluginv1.Task{Labels: []string{"legacy"}}
|
||||
native := Task{Labels: generated.GetLabels()} //nolint:staticcheck // verifies the released API v1 source surface
|
||||
if len(native.Labels) != 1 || native.Labels[0] != "legacy" {
|
||||
t.Fatalf("released Task.Labels source surface did not round-trip: %+v", native.Labels)
|
||||
}
|
||||
}
|
||||
|
||||
func assertServiceMethods(t *testing.T, service protoreflect.ServiceDescriptor, methods ...protoreflect.Name) {
|
||||
t.Helper()
|
||||
if service == nil {
|
||||
@@ -64,3 +78,25 @@ func assertMessageOmitsFields(t *testing.T, messageName string, fields ...protor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertDeprecatedRepeatedMessageField(t *testing.T, messageName, fieldName string, number protoreflect.FieldNumber) {
|
||||
t.Helper()
|
||||
message := pluginv1.File_kandev_plugin_v1_plugin_proto.Messages().ByName(protoreflect.Name(messageName))
|
||||
if message == nil {
|
||||
t.Fatalf("required message %s is missing", messageName)
|
||||
}
|
||||
field := message.Fields().ByName(protoreflect.Name(fieldName))
|
||||
if field == nil {
|
||||
t.Fatalf("%s is missing field %s", message.FullName(), fieldName)
|
||||
}
|
||||
if field.Number() != number {
|
||||
t.Fatalf("%s.%s field number = %d, want %d", message.FullName(), fieldName, field.Number(), number)
|
||||
}
|
||||
if field.Cardinality() != protoreflect.Repeated {
|
||||
t.Fatalf("%s.%s cardinality = %s, want repeated", message.FullName(), fieldName, field.Cardinality())
|
||||
}
|
||||
options, ok := field.Options().(*descriptorpb.FieldOptions)
|
||||
if !ok || !options.GetDeprecated() {
|
||||
t.Fatalf("%s.%s must remain deprecated", message.FullName(), fieldName)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2366,24 +2366,26 @@ func (x *PageInfo) GetHasMore() bool {
|
||||
|
||||
// ── Task ────────────────────────────────────────────────────────────────────
|
||||
type Task struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
WorkspaceId string `protobuf:"bytes,2,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"`
|
||||
WorkflowId string `protobuf:"bytes,3,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"`
|
||||
Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"`
|
||||
State string `protobuf:"bytes,6,opt,name=state,proto3" json:"state,omitempty"` // TaskState enum value as a string, stable
|
||||
Priority string `protobuf:"bytes,7,opt,name=priority,proto3" json:"priority,omitempty"`
|
||||
CreatedBy string `protobuf:"bytes,8,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"`
|
||||
CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339
|
||||
UpdatedAt string `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
|
||||
StartedAt *string `protobuf:"bytes,11,opt,name=started_at,json=startedAt,proto3,oneof" json:"started_at,omitempty"`
|
||||
CompletedAt *string `protobuf:"bytes,12,opt,name=completed_at,json=completedAt,proto3,oneof" json:"completed_at,omitempty"`
|
||||
ParentId *string `protobuf:"bytes,13,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"`
|
||||
Identifier string `protobuf:"bytes,14,opt,name=identifier,proto3" json:"identifier,omitempty"`
|
||||
IsEphemeral bool `protobuf:"varint,15,opt,name=is_ephemeral,json=isEphemeral,proto3" json:"is_ephemeral,omitempty"`
|
||||
Repositories []*TaskRepository `protobuf:"bytes,16,rep,name=repositories,proto3" json:"repositories,omitempty"`
|
||||
Metadata *structpb.Struct `protobuf:"bytes,17,opt,name=metadata,proto3" json:"metadata,omitempty"` // free-form; plugin-tolerant
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
|
||||
WorkspaceId string `protobuf:"bytes,2,opt,name=workspace_id,json=workspaceId,proto3" json:"workspace_id,omitempty"`
|
||||
WorkflowId string `protobuf:"bytes,3,opt,name=workflow_id,json=workflowId,proto3" json:"workflow_id,omitempty"`
|
||||
Title string `protobuf:"bytes,4,opt,name=title,proto3" json:"title,omitempty"`
|
||||
Description string `protobuf:"bytes,5,opt,name=description,proto3" json:"description,omitempty"`
|
||||
State string `protobuf:"bytes,6,opt,name=state,proto3" json:"state,omitempty"` // TaskState enum value as a string, stable
|
||||
// One of critical, high, medium, or low. Create defaults to medium when
|
||||
// omitted; updates validate the supplied value.
|
||||
Priority string `protobuf:"bytes,7,opt,name=priority,proto3" json:"priority,omitempty"`
|
||||
CreatedBy string `protobuf:"bytes,8,opt,name=created_by,json=createdBy,proto3" json:"created_by,omitempty"`
|
||||
CreatedAt string `protobuf:"bytes,9,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // RFC3339
|
||||
UpdatedAt string `protobuf:"bytes,10,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"`
|
||||
StartedAt *string `protobuf:"bytes,11,opt,name=started_at,json=startedAt,proto3,oneof" json:"started_at,omitempty"`
|
||||
CompletedAt *string `protobuf:"bytes,12,opt,name=completed_at,json=completedAt,proto3,oneof" json:"completed_at,omitempty"`
|
||||
ParentId *string `protobuf:"bytes,13,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"`
|
||||
Identifier string `protobuf:"bytes,14,opt,name=identifier,proto3" json:"identifier,omitempty"`
|
||||
IsEphemeral bool `protobuf:"varint,15,opt,name=is_ephemeral,json=isEphemeral,proto3" json:"is_ephemeral,omitempty"`
|
||||
Repositories []*TaskRepository `protobuf:"bytes,16,rep,name=repositories,proto3" json:"repositories,omitempty"`
|
||||
Metadata *structpb.Struct `protobuf:"bytes,17,opt,name=metadata,proto3" json:"metadata,omitempty"` // free-form; plugin-tolerant
|
||||
// RFC3339, set only when the task is archived. Present so a plugin reading
|
||||
// with include_archived can tell a live task from a retired one; absent is
|
||||
// the normal case, not an unknown.
|
||||
@@ -2403,8 +2405,10 @@ type Task struct {
|
||||
// `runner` row in workflow_step_participants (ADR 0005 Wave F); this is the
|
||||
// same read-time projection kandev's own callers consume.
|
||||
AssigneeAgentProfileId string `protobuf:"bytes,22,opt,name=assignee_agent_profile_id,json=assigneeAgentProfileId,proto3" json:"assignee_agent_profile_id,omitempty"`
|
||||
// Task labels. Stored as a JSON array string on the model; carried here as a
|
||||
// real repeated field so a plugin does not have to re-parse it.
|
||||
// Deprecated compatibility field shipped in API v1. New plugins should keep
|
||||
// provider-specific labels in plugin-owned task state and UI slot data.
|
||||
//
|
||||
// Deprecated: Marked as deprecated in kandev/plugin/v1/plugin.proto.
|
||||
Labels []string `protobuf:"bytes,23,rep,name=labels,proto3" json:"labels,omitempty"`
|
||||
Autopilot bool `protobuf:"varint,24,opt,name=autopilot,proto3" json:"autopilot,omitempty"`
|
||||
// Whether the task consumes a slot in its current step's WIP capacity. A
|
||||
@@ -2605,6 +2609,7 @@ func (x *Task) GetAssigneeAgentProfileId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Deprecated: Marked as deprecated in kandev/plugin/v1/plugin.proto.
|
||||
func (x *Task) GetLabels() []string {
|
||||
if x != nil {
|
||||
return x.Labels
|
||||
@@ -6197,9 +6202,11 @@ type CreateTaskRequest struct {
|
||||
ParentId *string `protobuf:"bytes,6,opt,name=parent_id,json=parentId,proto3,oneof" json:"parent_id,omitempty"`
|
||||
StartAgent bool `protobuf:"varint,7,opt,name=start_agent,json=startAgent,proto3" json:"start_agent,omitempty"`
|
||||
// kandev stamps source = "plugin:<id>" server-side; not settable here.
|
||||
Repositories []*PluginTaskRepository `protobuf:"bytes,8,rep,name=repositories,proto3" json:"repositories,omitempty"`
|
||||
Launch *PluginTaskLaunchOptions `protobuf:"bytes,9,opt,name=launch,proto3,oneof" json:"launch,omitempty"`
|
||||
Metadata *structpb.Struct `protobuf:"bytes,10,opt,name=metadata,proto3" json:"metadata,omitempty"` // plugin namespace only; host reserves source.
|
||||
Repositories []*PluginTaskRepository `protobuf:"bytes,8,rep,name=repositories,proto3" json:"repositories,omitempty"`
|
||||
Launch *PluginTaskLaunchOptions `protobuf:"bytes,9,opt,name=launch,proto3,oneof" json:"launch,omitempty"`
|
||||
Metadata *structpb.Struct `protobuf:"bytes,10,opt,name=metadata,proto3" json:"metadata,omitempty"` // plugin namespace only; host reserves source.
|
||||
// One of critical, high, medium, or low; empty defaults to medium.
|
||||
Priority string `protobuf:"bytes,11,opt,name=priority,proto3" json:"priority,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -6304,6 +6311,13 @@ func (x *CreateTaskRequest) GetMetadata() *structpb.Struct {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (x *CreateTaskRequest) GetPriority() string {
|
||||
if x != nil {
|
||||
return x.Priority
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type PluginTaskRepository struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
RepositoryId string `protobuf:"bytes,1,opt,name=repository_id,json=repositoryId,proto3" json:"repository_id,omitempty"` // existing Kandev repository, or empty with remote below
|
||||
@@ -6625,6 +6639,7 @@ type UpdateTaskRequest struct {
|
||||
Description *string `protobuf:"bytes,3,opt,name=description,proto3,oneof" json:"description,omitempty"`
|
||||
State *string `protobuf:"bytes,4,opt,name=state,proto3,oneof" json:"state,omitempty"`
|
||||
WorkflowStepId *string `protobuf:"bytes,5,opt,name=workflow_step_id,json=workflowStepId,proto3,oneof" json:"workflow_step_id,omitempty"`
|
||||
Priority *string `protobuf:"bytes,6,opt,name=priority,proto3,oneof" json:"priority,omitempty"`
|
||||
unknownFields protoimpl.UnknownFields
|
||||
sizeCache protoimpl.SizeCache
|
||||
}
|
||||
@@ -6694,6 +6709,13 @@ func (x *UpdateTaskRequest) GetWorkflowStepId() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (x *UpdateTaskRequest) GetPriority() string {
|
||||
if x != nil && x.Priority != nil {
|
||||
return *x.Priority
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
type UpdateTaskResponse struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
Task *Task `protobuf:"bytes,1,opt,name=task,proto3" json:"task,omitempty"`
|
||||
@@ -7404,7 +7426,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" +
|
||||
"\bPageInfo\x12\x1f\n" +
|
||||
"\vnext_cursor\x18\x01 \x01(\tR\n" +
|
||||
"nextCursor\x12\x19\n" +
|
||||
"\bhas_more\x18\x02 \x01(\bR\ahasMore\"\xf0\b\n" +
|
||||
"\bhas_more\x18\x02 \x01(\bR\ahasMore\"\xf4\b\n" +
|
||||
"\x04Task\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12!\n" +
|
||||
"\fworkspace_id\x18\x02 \x01(\tR\vworkspaceId\x12\x1f\n" +
|
||||
@@ -7436,8 +7458,8 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" +
|
||||
"\rpull_requests\x18\x13 \x03(\v2!.kandev.plugin.v1.TaskPullRequestR\fpullRequests\x12(\n" +
|
||||
"\x10workflow_step_id\x18\x14 \x01(\tR\x0eworkflowStepId\x12\x1a\n" +
|
||||
"\bposition\x18\x15 \x01(\x05R\bposition\x129\n" +
|
||||
"\x19assignee_agent_profile_id\x18\x16 \x01(\tR\x16assigneeAgentProfileId\x12\x16\n" +
|
||||
"\x06labels\x18\x17 \x03(\tR\x06labels\x12\x1c\n" +
|
||||
"\x19assignee_agent_profile_id\x18\x16 \x01(\tR\x16assigneeAgentProfileId\x12\x1a\n" +
|
||||
"\x06labels\x18\x17 \x03(\tB\x02\x18\x01R\x06labels\x12\x1c\n" +
|
||||
"\tautopilot\x18\x18 \x01(\bR\tautopilot\x12!\n" +
|
||||
"\fwip_admitted\x18\x19 \x01(\bR\vwipAdmitted\x12+\n" +
|
||||
"\x12queued_for_step_id\x18\x1a \x01(\tR\x0fqueuedForStepId\x12 \n" +
|
||||
@@ -7748,7 +7770,7 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" +
|
||||
"\x19InvokeUtilityAgentRequest\x12\x16\n" +
|
||||
"\x06prompt\x18\x01 \x01(\tR\x06prompt\"0\n" +
|
||||
"\x1aInvokeUtilityAgentResponse\x12\x12\n" +
|
||||
"\x04text\x18\x01 \x01(\tR\x04text\"\xf8\x03\n" +
|
||||
"\x04text\x18\x01 \x01(\tR\x04text\"\x94\x04\n" +
|
||||
"\x11CreateTaskRequest\x12!\n" +
|
||||
"\fworkspace_id\x18\x01 \x01(\tR\vworkspaceId\x12\x1f\n" +
|
||||
"\vworkflow_id\x18\x02 \x01(\tR\n" +
|
||||
@@ -7762,7 +7784,8 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" +
|
||||
"\frepositories\x18\b \x03(\v2&.kandev.plugin.v1.PluginTaskRepositoryR\frepositories\x12F\n" +
|
||||
"\x06launch\x18\t \x01(\v2).kandev.plugin.v1.PluginTaskLaunchOptionsH\x02R\x06launch\x88\x01\x01\x123\n" +
|
||||
"\bmetadata\x18\n" +
|
||||
" \x01(\v2\x17.google.protobuf.StructR\bmetadataB\x13\n" +
|
||||
" \x01(\v2\x17.google.protobuf.StructR\bmetadata\x12\x1a\n" +
|
||||
"\bpriority\x18\v \x01(\tR\bpriorityB\x13\n" +
|
||||
"\x11_workflow_step_idB\f\n" +
|
||||
"\n" +
|
||||
"_parent_idB\t\n" +
|
||||
@@ -7809,17 +7832,19 @@ const file_kandev_plugin_v1_plugin_proto_rawDesc = "" +
|
||||
"\n" +
|
||||
"_plan_mode\"@\n" +
|
||||
"\x12CreateTaskResponse\x12*\n" +
|
||||
"\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"\xe8\x01\n" +
|
||||
"\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"\x96\x02\n" +
|
||||
"\x11UpdateTaskRequest\x12\x0e\n" +
|
||||
"\x02id\x18\x01 \x01(\tR\x02id\x12\x19\n" +
|
||||
"\x05title\x18\x02 \x01(\tH\x00R\x05title\x88\x01\x01\x12%\n" +
|
||||
"\vdescription\x18\x03 \x01(\tH\x01R\vdescription\x88\x01\x01\x12\x19\n" +
|
||||
"\x05state\x18\x04 \x01(\tH\x02R\x05state\x88\x01\x01\x12-\n" +
|
||||
"\x10workflow_step_id\x18\x05 \x01(\tH\x03R\x0eworkflowStepId\x88\x01\x01B\b\n" +
|
||||
"\x10workflow_step_id\x18\x05 \x01(\tH\x03R\x0eworkflowStepId\x88\x01\x01\x12\x1f\n" +
|
||||
"\bpriority\x18\x06 \x01(\tH\x04R\bpriority\x88\x01\x01B\b\n" +
|
||||
"\x06_titleB\x0e\n" +
|
||||
"\f_descriptionB\b\n" +
|
||||
"\x06_stateB\x13\n" +
|
||||
"\x11_workflow_step_id\"@\n" +
|
||||
"\x11_workflow_step_idB\v\n" +
|
||||
"\t_priority\"@\n" +
|
||||
"\x12UpdateTaskResponse\x12*\n" +
|
||||
"\x04task\x18\x01 \x01(\v2\x16.kandev.plugin.v1.TaskR\x04task\"\xa6\x01\n" +
|
||||
"\x0fMoveTaskRequest\x12\x17\n" +
|
||||
|
||||
@@ -364,6 +364,8 @@ message Task {
|
||||
string title = 4;
|
||||
string description = 5;
|
||||
string state = 6; // TaskState enum value as a string, stable
|
||||
// One of critical, high, medium, or low. Create defaults to medium when
|
||||
// omitted; updates validate the supplied value.
|
||||
string priority = 7;
|
||||
string created_by = 8;
|
||||
string created_at = 9; // RFC3339
|
||||
@@ -394,9 +396,9 @@ message Task {
|
||||
// `runner` row in workflow_step_participants (ADR 0005 Wave F); this is the
|
||||
// same read-time projection kandev's own callers consume.
|
||||
string assignee_agent_profile_id = 22;
|
||||
// Task labels. Stored as a JSON array string on the model; carried here as a
|
||||
// real repeated field so a plugin does not have to re-parse it.
|
||||
repeated string labels = 23;
|
||||
// Deprecated compatibility field shipped in API v1. New plugins should keep
|
||||
// provider-specific labels in plugin-owned task state and UI slot data.
|
||||
repeated string labels = 23 [deprecated = true];
|
||||
bool autopilot = 24;
|
||||
// Whether the task consumes a slot in its current step's WIP capacity. A
|
||||
// queued task stays visible but admits nothing, so a capacity report that
|
||||
@@ -822,6 +824,8 @@ message CreateTaskRequest {
|
||||
repeated PluginTaskRepository repositories = 8;
|
||||
optional PluginTaskLaunchOptions launch = 9;
|
||||
google.protobuf.Struct metadata = 10; // plugin namespace only; host reserves source.
|
||||
// One of critical, high, medium, or low; empty defaults to medium.
|
||||
string priority = 11;
|
||||
}
|
||||
message PluginTaskRepository {
|
||||
string repository_id = 1; // existing Kandev repository, or empty with remote below
|
||||
@@ -860,6 +864,7 @@ message UpdateTaskRequest {
|
||||
optional string description = 3;
|
||||
optional string state = 4;
|
||||
optional string workflow_step_id = 5;
|
||||
optional string priority = 6;
|
||||
}
|
||||
message UpdateTaskResponse {
|
||||
Task task = 1;
|
||||
|
||||
@@ -102,6 +102,11 @@ Concretely:
|
||||
empty.
|
||||
- **Write provenance:** the server stamps `source = "plugin:<id>"` on created
|
||||
rows/comments; a plugin cannot set it.
|
||||
- **API v1 compatibility:** DTO fields are additive-only. The read-side
|
||||
`Task.labels` field shipped in v0.93.0 and remains at field 23 as deprecated
|
||||
source/wire compatibility. New provider integrations keep their annotations
|
||||
in plugin-owned task state and UI slot data; task label writes are not part
|
||||
of the plugin write contract.
|
||||
|
||||
## Open decisions (recorded, with recommendation)
|
||||
|
||||
@@ -128,6 +133,9 @@ Concretely:
|
||||
carrot is sufficient.
|
||||
- The proto is a public contract: DTO fields are additive-only thereafter;
|
||||
removing or renaming a field is a breaking change requiring a new api_version.
|
||||
- Deprecated fields remain generated in API v1. In particular, `Task.labels`
|
||||
stays readable for clients compiled against v0.93.0 even though new plugins
|
||||
should not use it for provider-specific annotations.
|
||||
- A **new read-only per-session LOC aggregation must be added** at the service/
|
||||
repository layer. `internal/analytics/repository/sqlite/stats.go` aggregates git
|
||||
stats at workspace and per-repository granularity only; nothing computes
|
||||
@@ -158,8 +166,9 @@ verbatim. Concretely:
|
||||
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.
|
||||
`priority`, each optional — deliberately smaller than the full REST
|
||||
update surface. `workflow_step_id` remains present for wire compatibility but
|
||||
is rejected by Update; plugins must use `MoveTask` for workflow transitions.
|
||||
- **`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
|
||||
|
||||
@@ -213,6 +213,11 @@ duplicated here; this section covers the RPC list (added to `service Host` above
|
||||
capability gating, and cross-cutting conventions. See ADR 0043
|
||||
(`docs/decisions/0043-plugin-host-data-api.md`) for the design rationale.
|
||||
|
||||
API v1 DTO fields are additive-only. `Task.labels` field 23, shipped in v0.93.0,
|
||||
remains generated and readable as a deprecated compatibility field. New plugins
|
||||
store provider-specific annotations in plugin-owned task state and render them
|
||||
through plugin UI slots; CreateTask and UpdateTask do not expose label writes.
|
||||
|
||||
**Readable resources.** Each read RPC requires `api_read:<resource>` in the
|
||||
plugin's manifest:
|
||||
|
||||
@@ -245,8 +250,10 @@ 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
|
||||
conservative field mask — `title`, `description`, `state`, and `priority` (each
|
||||
optional/leave-unset). `workflow_step_id` remains present for
|
||||
wire compatibility but is rejected; plugins use `MoveTask` for transitions.
|
||||
`start_agent` best-effort auto-launches an agent
|
||||
through the orchestrator; a launch failure does not fail the create.
|
||||
|
||||
Write validation/error contract (so plugin authors can predict outcomes):
|
||||
|
||||
@@ -95,6 +95,7 @@ message Task {
|
||||
bool is_ephemeral = 15;
|
||||
repeated TaskRepository repositories = 16;
|
||||
google.protobuf.Struct metadata = 17; // free-form; plugin-tolerant
|
||||
repeated string labels = 23 [deprecated = true]; // API v1 compatibility only.
|
||||
}
|
||||
message TaskRepository {
|
||||
string id = 1;
|
||||
@@ -246,6 +247,7 @@ message CreateTaskRequest {
|
||||
repeated PluginTaskRepository repositories = 8;
|
||||
optional PluginTaskLaunchOptions launch = 9;
|
||||
google.protobuf.Struct metadata = 10; // plugin namespace only; host reserves source.
|
||||
string priority = 11; // empty defaults to medium
|
||||
}
|
||||
message PluginTaskRepository {
|
||||
string repository_id = 1; // existing Kandev repository, or empty with remote below
|
||||
@@ -280,6 +282,7 @@ message UpdateTaskRequest {
|
||||
optional string description = 3;
|
||||
optional string state = 4;
|
||||
optional string workflow_step_id = 5;
|
||||
optional string priority = 6;
|
||||
}
|
||||
message UpdateTaskResponse { Task task = 1; }
|
||||
// SendMessage delivers a prompt to a task session (api_write:messages) through
|
||||
|
||||
@@ -9,13 +9,13 @@ status: implemented
|
||||
|
||||
## Overview
|
||||
|
||||
Give plugins a typed, capability-gated path to read (phase 1) kandev domain data
|
||||
Give plugins a typed, capability-gated path to read and write kandev domain data
|
||||
over the existing `kandev.plugin.v1` Host gRPC channel, replacing direct SQLite
|
||||
access. Land the contract first (proto + regen) and the one net-new aggregation
|
||||
(per-session LOC), then the SDK's Go-native accessors, then kandev's server-side
|
||||
implementation, and finally the proof (rewrite the agent-stats plugin) plus tests
|
||||
and docs. Write RPCs (`CreateTask`/`UpdateTask`/`CreateComment`) are specified in
|
||||
the proto but deferred; handlers return `Unimplemented` this phase.
|
||||
and docs. The implemented write surface is `CreateTask`, `UpdateTask`, and
|
||||
`SendMessage`; workflow transitions use `MoveTask`.
|
||||
|
||||
The draft contract at `docs/plans/plugins/HOST-DATA-API.proto` is largely settled
|
||||
— refine field-level details against the real DTOs (`apps/backend/pkg/api/v1/`),
|
||||
@@ -26,7 +26,7 @@ do not redesign.
|
||||
## Backend
|
||||
|
||||
### Proto contract (`apps/backend/proto/kandev/plugin/v1/plugin.proto`)
|
||||
Fold the read + (deferred) write RPCs and DTOs from
|
||||
Fold the read and write RPCs and DTOs from
|
||||
`docs/plans/plugins/HOST-DATA-API.proto` into the frozen plugin proto. Decide
|
||||
placement: add the RPCs to the existing `service Host` (simplest — reuses the
|
||||
single Host broker connection and the existing capability interceptor) rather
|
||||
@@ -60,8 +60,9 @@ new Host data interface methods. Each read handler:
|
||||
the new aggregation for code stats) — never a repository directly;
|
||||
3. maps internal models → the Go-native DTOs with explicit code.
|
||||
Wire the new service dependencies into `plugins.Service` /
|
||||
`plugins.NewService` / `Provide`, and thread them into `hostForPlugin`. Write
|
||||
handlers return `codes.Unimplemented`.
|
||||
`plugins.NewService` / `Provide`, and thread them into `hostForPlugin`. The
|
||||
implemented writes route through the task service and orchestrator delivery
|
||||
path.
|
||||
|
||||
---
|
||||
|
||||
@@ -93,8 +94,8 @@ Extend the Go-native Host surface authors and kandev share:
|
||||
DTO for each resource, including nullable/optional and timestamp formatting.
|
||||
- **Capability gating (`internal/plugins/host_test.go`):** each read RPC returns
|
||||
`PermissionDenied` with `capability 'api_read:<resource>' not declared` when the
|
||||
manifest omits the resource, and succeeds when it declares it. Write RPC returns
|
||||
`Unimplemented`.
|
||||
manifest omits the resource, and succeeds when it declares it. Write RPCs
|
||||
enforce their `api_write` capabilities and return their task/message results.
|
||||
- **Per-session LOC aggregation (`internal/analytics/repository/sqlite/*_test.go`):**
|
||||
real SQLite fixture with commits + snapshots; assert committed sums and the
|
||||
peak-pending `MAX` semantics (peak, not latest; not double-counted).
|
||||
|
||||
@@ -19,8 +19,7 @@ later task compiles against.
|
||||
- Read RPCs: `ListTasks`, `GetTask`, `ListWorkspaces`, `ListWorkflows`,
|
||||
`ListWorkflowSteps`, `ListAgentProfiles`, `ListRepositories`, `ListSessions`,
|
||||
`ListSessionCodeStats`.
|
||||
- Deferred write RPCs (declared in proto, not implemented later this phase):
|
||||
`CreateTask`, `UpdateTask`, `CreateComment`.
|
||||
- Implemented write RPCs: `CreateTask`, `UpdateTask`, and `SendMessage`.
|
||||
- DTOs + `Page`/`PageInfo`/filter messages from the draft.
|
||||
- Decide placement: add RPCs to the existing `service Host` (recommended — reuses
|
||||
the single broker connection and the capability interceptor) rather than a new
|
||||
@@ -30,7 +29,7 @@ later task compiles against.
|
||||
string timestamps and `optional` nullables per ADR 0043.
|
||||
|
||||
## Acceptance
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin.proto` contains the read + deferred
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin.proto` contains the read + write
|
||||
write RPCs and DTOs; existing `Host`/`Plugin` RPCs are unchanged.
|
||||
- `make -C apps/backend proto` regenerates `plugin.pb.go` / `plugin_grpc.pb.go`
|
||||
with no manual edits, and the tree builds.
|
||||
|
||||
@@ -25,8 +25,9 @@ state/secrets pattern in `host.go`/`types.go`.
|
||||
`host.Sessions()`, `host.Workspaces()`, `host.Workflows()`,
|
||||
`host.AgentProfiles()`, `host.Repositories()` — each returning a small typed
|
||||
accessor (e.g. `TasksAPI.List(ctx, filter, page)`, `TasksAPI.Get(ctx, id)`,
|
||||
`SessionsAPI.List(...)`, `SessionsAPI.CodeStats(...)`). Keep write methods off
|
||||
the surface this phase (deferred).
|
||||
`SessionsAPI.List(...)`, `SessionsAPI.CodeStats(...)`). The task and message
|
||||
accessors also expose the implemented write methods, including
|
||||
`Tasks().Create`, `Tasks().Update`, `Tasks().Move`, and `Messages().Send`.
|
||||
- **Client + server conversions**: `grpcHostClient` (plugin side) calls the
|
||||
generated data RPCs and converts responses to Go-native; `grpcHostServer`
|
||||
(kandev side) dispatches to the Go-native interface. Follow the existing
|
||||
|
||||
@@ -32,7 +32,8 @@ gating, and wire the new service dependencies through `plugins.Service`.
|
||||
- Apply the v1 scoping rule: global reads, filters narrow results, ephemeral
|
||||
tasks excluded unless requested. Leave a single scoping hook per ADR 0043(a).
|
||||
- Implement opaque-cursor pagination (server-defined cursor encoding + max cap).
|
||||
- Write RPC handlers return `codes.Unimplemented`.
|
||||
- Implemented task and message writes route through the task service and
|
||||
orchestrator delivery path.
|
||||
- **Wiring:** add the new service deps to `plugins.Service`, `plugins.NewService`,
|
||||
and `Provide`; thread them into `hostForPlugin` so each spawned `pluginHost`
|
||||
can serve data RPCs. Confirm the new RPCs are served over the existing Host
|
||||
@@ -43,7 +44,7 @@ gating, and wire the new service dependencies through `plugins.Service`.
|
||||
`ListSessionCodeStats` results via the Host channel; one without it gets
|
||||
`PermissionDenied` naming `api_read:sessions`.
|
||||
- All read RPCs map internal models to DTOs with no proto/domain-struct leakage;
|
||||
write RPCs return `Unimplemented`.
|
||||
write RPCs enforce capabilities and return their task/message results.
|
||||
- `make -C apps/backend proto` not required here (done in task-01); package builds
|
||||
and tests pass.
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ the host RPC behavior and the end-to-end plugin path.)
|
||||
- **Capability gating (`internal/plugins/host_test.go`):** for each read RPC,
|
||||
assert `PermissionDenied` with `capability 'api_read:<resource>' not declared`
|
||||
when the manifest omits the resource, and success when it declares it. Assert
|
||||
write RPCs return `Unimplemented`.
|
||||
write RPCs enforce their capabilities and return their task/message results.
|
||||
- **DTO mapping:** table-driven internal-model → DTO assertions per resource,
|
||||
covering optional/NULL fields and RFC3339 timestamp formatting; assert the
|
||||
Session DTO carries `acp_session_id`.
|
||||
|
||||
@@ -18,7 +18,8 @@ Document the Host data API for plugin authors: which resources are readable, the
|
||||
- **Wire reference (`docs/plans/plugins/GRPC-CONTRACT.md`):** add the Host data
|
||||
RPCs to the `service Host` block (§3) and note capability gating uses
|
||||
`api_read:<resource>` / `api_write:<resource>` (§5), replacing the "reserved"
|
||||
language. Note write RPCs are deferred (`Unimplemented`).
|
||||
language. Document the implemented task and message writes, including the
|
||||
`UpdateTask` field mask and `MoveTask` workflow-transition path.
|
||||
- **SDK reference (`docs/plans/plugins/GRPC-CONTRACT.md` §4 and/or
|
||||
`PLUGIN-API.md`):** document the author accessors (`host.Tasks().List(...)`,
|
||||
`host.Sessions().List(...)`, `host.Sessions().CodeStats(...)`, etc.) and that
|
||||
@@ -36,9 +37,9 @@ Document the Host data API for plugin authors: which resources are readable, the
|
||||
## Acceptance
|
||||
- `GRPC-CONTRACT.md` lists the Host data RPCs and the per-resource capability
|
||||
gating; the "reserved / not implemented" wording for `api_read`/`api_write` is
|
||||
removed or scoped to the deferred write RPCs.
|
||||
- The manifest capability vocabulary is documented with the readable-resource
|
||||
list and an example.
|
||||
removed or scoped to capabilities that are not yet implemented.
|
||||
- The manifest capability vocabulary is documented with the readable and
|
||||
writable-resource list and an example.
|
||||
|
||||
## Verification
|
||||
- Markdown-only; no build. `cd apps && pnpm --filter @kandev/web lint` only if a
|
||||
@@ -65,9 +66,9 @@ Summary, files changed, and status update here + in `plan.md`.
|
||||
reference and reconciled the spec section against the shipped code.
|
||||
|
||||
**Files changed:**
|
||||
- `docs/plans/plugins/GRPC-CONTRACT.md` — added the 9 read RPCs + 3 deferred
|
||||
write RPCs to the `service Host` block (§3); added a new "§3a. Host data API
|
||||
(ADR 0043)" subsection covering the resource/capability table, deferred
|
||||
- `docs/plans/plugins/GRPC-CONTRACT.md` — added the Host data read and
|
||||
implemented write RPCs to the `service Host` block (§3); added a new "§3a.
|
||||
Host data API (ADR 0043)" subsection covering the resource/capability table,
|
||||
writes, service-layer/DTO conventions, pagination/timestamp/nullable/scoping
|
||||
conventions; added the `Host` interface's 6 data accessors plus the 6 reader
|
||||
interfaces (`TaskReader`, `SessionReader`, `WorkspaceReader`,
|
||||
@@ -75,7 +76,7 @@ reference and reconciled the spec section against the shipped code.
|
||||
signatures, and an authoring example (manifest snippet + `Sessions().CodeStats`
|
||||
call) to §4; rewrote the §5 "Capability gating" bullet to remove the stale
|
||||
"api_read/api_write reserved" wording and describe the real per-RPC gating
|
||||
plus the write RPCs' current `Unimplemented` behavior.
|
||||
plus the write RPCs' current behavior.
|
||||
- `docs/specs/plugins/requirements/plugins.md` — fixed capability-vocabulary drift against code
|
||||
(see below) and added a "Declaring data access" note under the manifest
|
||||
capabilities section; reworded the interceptor description to match the
|
||||
@@ -97,8 +98,8 @@ truth, left unchanged):**
|
||||
- The spec described `api_read`/`api_write` as "reserved for future Host RPCs"
|
||||
in the manifest example comments and said the capability-gating interceptor
|
||||
covers "state, secrets; api_read/api_write reserved" — both predate this
|
||||
ADR shipping; `api_read` is live now (write RPCs remain deferred/
|
||||
`Unimplemented`). Reworded both.
|
||||
ADR shipping; `api_read` and the implemented write capabilities are live now.
|
||||
Reworded both.
|
||||
- The spec attributed capability gating to "a unary server interceptor on
|
||||
Host"; there is no `grpc.UnaryServerInterceptor` in `internal/plugins` or
|
||||
`pkg/pluginsdk` — each Host/Host-data method checks its own capability
|
||||
|
||||
@@ -582,7 +582,7 @@ subscription vocabulary and wildcard rules are in the
|
||||
| Config | GetConfig | None | Reads this plugin's validated config_schema; secret fields are cleartext in the subprocess; config updates restart active plugins |
|
||||
| Secrets | RevealSecret, GetSecret, SetSecret, DeleteSecret | secrets: true | Encrypted vault; plugin-owned keys are namespaced; never log values |
|
||||
| Tasks | Tasks().List, Tasks().Get | api_read: tasks | Typed DTOs and opaque pagination cursor |
|
||||
| Tasks writes | Tasks().Create, Tasks().Update, Tasks().Move | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire. Update rejects a workflow step change; Move is the only path that moves a task between steps |
|
||||
| Tasks writes | Tasks().Create, Tasks().Update, Tasks().Move | api_write: tasks | Implemented; routed through Kandev services so events/WS updates fire. Create and Update support priority. Update rejects a workflow step change; Move is the only path that moves a task between steps |
|
||||
| Sessions | Sessions().List, Sessions().CodeStats | api_read: sessions | Typed session and computed code-stat records |
|
||||
| Workspaces | Workspaces().List | api_read: workspaces | Instance-visible workspaces |
|
||||
| Workflows | Workflows().List, Workflows().ListSteps | api_read: workflows | List steps by workflow id |
|
||||
@@ -916,7 +916,16 @@ session or resumes/starts it when appropriate, returning `queued`, `sent`, or
|
||||
Task writes use Kandev's first-party service layer, so normal task events and
|
||||
browser updates occur. Kandev stamps the source as `plugin:<id>` and reserves
|
||||
the `metadata.source` key; plugin metadata is stored under that source. `.Update`
|
||||
writes only title, description, and state: it rejects a workflow step change.
|
||||
writes title, description, state, and priority: it rejects a workflow step
|
||||
change. Priority accepts only `critical`, `high`, `medium`, or `low`; omitted
|
||||
priority on `.Create` defaults to `medium`.
|
||||
|
||||
The read-side `Task.Labels` field remains available only as deprecated API v1
|
||||
compatibility for plugins built against Kandev v0.93.0. New plugins should keep
|
||||
provider-specific labels or tracker annotations in plugin-owned task state and
|
||||
render them through task UI slots. `.Create` and `.Update` do not write Kandev
|
||||
task labels.
|
||||
|
||||
Moving a task between workflow steps goes through `.Move` instead, which routes
|
||||
through the same path the board's own drag-and-drop move uses (validation, WIP
|
||||
admission, `task.moved` publication, auto-start gates, queue reconciliation),
|
||||
|
||||
@@ -13,14 +13,13 @@ owners:
|
||||
|
||||
## Overview
|
||||
|
||||
A plugin can already relocate a card on the board, and doing so starts nothing.
|
||||
`Host.UpdateTask` accepts `workflow_step_id` and the adapter forwards it to
|
||||
`taskservice.UpdateTask`, which assigns `task.WorkflowStepID` directly
|
||||
(`service_tasks.go:1593`) and never calls `publishTaskMovedEvent`, whose only
|
||||
call sites are the shared move and queue-promotion paths. Without that event
|
||||
`autoStartTaskForStep` never runs, so no `on_enter` action fires and no agent
|
||||
launches. A plugin "Dispatch" button built on `UpdateTask` moves cards and
|
||||
starts no work.
|
||||
A plugin can relocate a card through the dedicated `Host.MoveTask` path, which
|
||||
uses the board move implementation and can run step-entry actions. The
|
||||
plugin-facing `Host.UpdateTask` contract deliberately rejects
|
||||
`workflow_step_id`; using the generic update path would assign
|
||||
`task.WorkflowStepID` directly without calling `publishTaskMovedEvent`, so no
|
||||
`on_enter` action would fire and no agent would launch. A plugin "Dispatch"
|
||||
button must therefore use `MoveTask` rather than `UpdateTask`.
|
||||
|
||||
This system owns the contract because the durable decision is *what a plugin
|
||||
may do to an operator's board and under whose identity*, not how a move is
|
||||
|
||||
@@ -249,7 +249,7 @@ domain structs. See [ADR 0043](../../../decisions/0043-plugin-host-data-api.md)
|
||||
|
||||
| RPC | Capability | Returns |
|
||||
| ----------------------- | ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ListTasks` / `GetTask` | `api_read:tasks` | Tasks (id, workspace, workflow, title, description, state, priority, timestamps, parent, identifier, repositories, metadata) |
|
||||
| `ListTasks` / `GetTask` | `api_read:tasks` | Tasks (id, workspace, workflow, workflow-step ID, title, description, state, priority, timestamps, parent, identifier, repositories, metadata, and deprecated API v1 labels compatibility) |
|
||||
| `ListWorkspaces` | `api_read:workspaces` | Workspaces (id, name, owner, defaults, timestamps) |
|
||||
| `ListWorkflows` | `api_read:workflows` | Workflows for a workspace |
|
||||
| `ListWorkflowSteps` | `api_read:workflows` | Steps for a workflow (`id`, `workflow_id`, `name`, `position`, `stage_type`, `color`, `is_start_step`, `wip_limit`, `agent_profile_id`, `on_enter_action_types`) |
|
||||
@@ -267,6 +267,11 @@ aggregate the agent-stats plugin previously re-derived by hand from
|
||||
`task_session_commits` and `task_session_git_snapshots` — so plugins never touch
|
||||
those raw rows.
|
||||
|
||||
`Task.labels` field 23 shipped in v0.93.0 and remains generated/readable because
|
||||
API v1 DTO evolution is additive-only. It is deprecated: new provider plugins
|
||||
keep tracker annotations in plugin-owned task state and UI slot data, and the
|
||||
plugin task write contract does not expose label mutation.
|
||||
|
||||
`on_enter_action_types` on a step lists the on-enter action *types* configured
|
||||
for that step (e.g. `auto_start_agent`, `queue_run`), never the action
|
||||
*config* — target task ids, payloads, and profile ids stay server-side. A type
|
||||
@@ -299,8 +304,9 @@ 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`.
|
||||
`state` / `priority`, each optional (a nil field is left unchanged);
|
||||
a present `workflow_step_id` is rejected and workflow transitions use
|
||||
`MoveTask`. 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
|
||||
|
||||
Reference in New Issue
Block a user