From 5f5b1f0f319e688c7aa4f161c30e0dd024d3f1eb Mon Sep 17 00:00:00 2001 From: Alassane <79718216+yattdev@users.noreply.github.com> Date: Sun, 6 Sep 2026 06:30:02 -0400 Subject: [PATCH] feat(plugins): expand task fields for integrations --- .../backendapp/adapters_plugin_writer_test.go | 7 +- apps/backend/internal/backendapp/services.go | 2 + .../internal/plugins/host_data_mappers.go | 11 +-- .../plugins/host_data_task_fields_test.go | 15 +--- .../internal/plugins/host_data_test.go | 45 +++++++--- apps/backend/internal/plugins/host_write.go | 22 ++++- .../internal/plugins/host_write_test.go | 56 +++++++++++- .../internal/task/models/task_priority.go | 23 +++++ .../internal/task/service/service_tasks.go | 21 ++++- .../internal/task/service/service_test.go | 32 +++++++ apps/backend/pkg/pluginsdk/data_types.go | 41 +++++---- apps/backend/pkg/pluginsdk/data_types_test.go | 36 +++++++- apps/backend/pkg/pluginsdk/protocol_test.go | 38 +++++++- .../proto/kandev/plugin/v1/plugin.pb.go | 87 ++++++++++++------- .../proto/kandev/plugin/v1/plugin.proto | 11 ++- docs/decisions/0043-plugin-host-data-api.md | 13 ++- docs/plans/plugins/GRPC-CONTRACT.md | 11 ++- docs/plans/plugins/HOST-DATA-API.proto | 3 + docs/plans/plugins/host-data-api/plan.md | 17 ++-- .../host-data-api/task-01-proto-contract.md | 5 +- .../task-03-sdk-data-accessors.md | 5 +- .../host-data-api/task-04-host-data-impl.md | 5 +- .../plugins/host-data-api/task-06-tests.md | 2 +- .../plugins/host-data-api/task-07-docs.md | 21 ++--- docs/public/plugins-authoring.md | 13 ++- .../plugin-initiated-step-transitions.md | 15 ++-- .../specs/plugins/system-design/plugins-02.md | 12 ++- 27 files changed, 430 insertions(+), 139 deletions(-) create mode 100644 apps/backend/internal/task/models/task_priority.go diff --git a/apps/backend/internal/backendapp/adapters_plugin_writer_test.go b/apps/backend/internal/backendapp/adapters_plugin_writer_test.go index 4df8a6b50b..987987e7f3 100644 --- a/apps/backend/internal/backendapp/adapters_plugin_writer_test.go +++ b/apps/backend/internal/backendapp/adapters_plugin_writer_test.go @@ -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") } diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index ea9cbcedcc..1bb55810fb 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -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 diff --git a/apps/backend/internal/plugins/host_data_mappers.go b/apps/backend/internal/plugins/host_data_mappers.go index 111db1820e..ac90e647eb 100644 --- a/apps/backend/internal/plugins/host_data_mappers.go +++ b/apps/backend/internal/plugins/host_data_mappers.go @@ -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 diff --git a/apps/backend/internal/plugins/host_data_task_fields_test.go b/apps/backend/internal/plugins/host_data_task_fields_test.go index dfc88ebcc5..453ebe9573 100644 --- a/apps/backend/internal/plugins/host_data_task_fields_test.go +++ b/apps/backend/internal/plugins/host_data_task_fields_test.go @@ -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. diff --git a/apps/backend/internal/plugins/host_data_test.go b/apps/backend/internal/plugins/host_data_test.go index 7a3ee25a4c..cb218f424e 100644 --- a/apps/backend/internal/plugins/host_data_test.go +++ b/apps/backend/internal/plugins/host_data_test.go @@ -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) { diff --git a/apps/backend/internal/plugins/host_write.go b/apps/backend/internal/plugins/host_write.go index 3bd2d4f3c3..1eba28daa5 100644 --- a/apps/backend/internal/plugins/host_write.go +++ b/apps/backend/internal/plugins/host_write.go @@ -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) { diff --git a/apps/backend/internal/plugins/host_write_test.go b/apps/backend/internal/plugins/host_write_test.go index 6ff1098b33..4674dddc8c 100644 --- a/apps/backend/internal/plugins/host_write_test.go +++ b/apps/backend/internal/plugins/host_write_test.go @@ -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) { diff --git a/apps/backend/internal/task/models/task_priority.go b/apps/backend/internal/task/models/task_priority.go new file mode 100644 index 0000000000..756348c87f --- /dev/null +++ b/apps/backend/internal/task/models/task_priority.go @@ -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) + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 868e41f92d..c6266c1caf 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -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 diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 3c989222dd..3f0a4e52fb 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -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() diff --git a/apps/backend/pkg/pluginsdk/data_types.go b/apps/backend/pkg/pluginsdk/data_types.go index c38c53740b..2399719b68 100644 --- a/apps/backend/pkg/pluginsdk/data_types.go +++ b/apps/backend/pkg/pluginsdk/data_types.go @@ -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, } } diff --git a/apps/backend/pkg/pluginsdk/data_types_test.go b/apps/backend/pkg/pluginsdk/data_types_test.go index 7201315686..3887a248f3 100644 --- a/apps/backend/pkg/pluginsdk/data_types_test.go +++ b/apps/backend/pkg/pluginsdk/data_types_test.go @@ -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", diff --git a/apps/backend/pkg/pluginsdk/protocol_test.go b/apps/backend/pkg/pluginsdk/protocol_test.go index 71d66715d2..0f96db98e2 100644 --- a/apps/backend/pkg/pluginsdk/protocol_test.go +++ b/apps/backend/pkg/pluginsdk/protocol_test.go @@ -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) + } +} diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go index 59294962f8..917809f361 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.pb.go +++ b/apps/backend/proto/kandev/plugin/v1/plugin.pb.go @@ -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:" 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" + diff --git a/apps/backend/proto/kandev/plugin/v1/plugin.proto b/apps/backend/proto/kandev/plugin/v1/plugin.proto index 6f69ccfcc3..1f195d5c2c 100644 --- a/apps/backend/proto/kandev/plugin/v1/plugin.proto +++ b/apps/backend/proto/kandev/plugin/v1/plugin.proto @@ -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; diff --git a/docs/decisions/0043-plugin-host-data-api.md b/docs/decisions/0043-plugin-host-data-api.md index c37a7309bb..0dab7448aa 100644 --- a/docs/decisions/0043-plugin-host-data-api.md +++ b/docs/decisions/0043-plugin-host-data-api.md @@ -102,6 +102,11 @@ Concretely: empty. - **Write provenance:** the server stamps `source = "plugin:"` 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 diff --git a/docs/plans/plugins/GRPC-CONTRACT.md b/docs/plans/plugins/GRPC-CONTRACT.md index 9c01f03109..1588e98278 100644 --- a/docs/plans/plugins/GRPC-CONTRACT.md +++ b/docs/plans/plugins/GRPC-CONTRACT.md @@ -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:` 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): diff --git a/docs/plans/plugins/HOST-DATA-API.proto b/docs/plans/plugins/HOST-DATA-API.proto index 020d78cf21..003aa173a3 100644 --- a/docs/plans/plugins/HOST-DATA-API.proto +++ b/docs/plans/plugins/HOST-DATA-API.proto @@ -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 diff --git a/docs/plans/plugins/host-data-api/plan.md b/docs/plans/plugins/host-data-api/plan.md index 7e9b902f02..cc2acecc7c 100644 --- a/docs/plans/plugins/host-data-api/plan.md +++ b/docs/plans/plugins/host-data-api/plan.md @@ -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:' 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). diff --git a/docs/plans/plugins/host-data-api/task-01-proto-contract.md b/docs/plans/plugins/host-data-api/task-01-proto-contract.md index c6666f1dd8..c2827c2ae1 100644 --- a/docs/plans/plugins/host-data-api/task-01-proto-contract.md +++ b/docs/plans/plugins/host-data-api/task-01-proto-contract.md @@ -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. diff --git a/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md b/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md index 9550ea4ed4..ea8e17754e 100644 --- a/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md +++ b/docs/plans/plugins/host-data-api/task-03-sdk-data-accessors.md @@ -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 diff --git a/docs/plans/plugins/host-data-api/task-04-host-data-impl.md b/docs/plans/plugins/host-data-api/task-04-host-data-impl.md index 520ce11f8e..89842feacf 100644 --- a/docs/plans/plugins/host-data-api/task-04-host-data-impl.md +++ b/docs/plans/plugins/host-data-api/task-04-host-data-impl.md @@ -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. diff --git a/docs/plans/plugins/host-data-api/task-06-tests.md b/docs/plans/plugins/host-data-api/task-06-tests.md index 9aa43295b0..c67eb7a6ee 100644 --- a/docs/plans/plugins/host-data-api/task-06-tests.md +++ b/docs/plans/plugins/host-data-api/task-06-tests.md @@ -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:' 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`. diff --git a/docs/plans/plugins/host-data-api/task-07-docs.md b/docs/plans/plugins/host-data-api/task-07-docs.md index c7aa110e1a..dc746a19e1 100644 --- a/docs/plans/plugins/host-data-api/task-07-docs.md +++ b/docs/plans/plugins/host-data-api/task-07-docs.md @@ -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:` / `api_write:` (§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 diff --git a/docs/public/plugins-authoring.md b/docs/public/plugins-authoring.md index 9a71fefd35..5f7189e119 100644 --- a/docs/public/plugins-authoring.md +++ b/docs/public/plugins-authoring.md @@ -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:` 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), diff --git a/docs/specs/plugins/requirements/plugin-initiated-step-transitions.md b/docs/specs/plugins/requirements/plugin-initiated-step-transitions.md index 689249fd58..5b932a671d 100644 --- a/docs/specs/plugins/requirements/plugin-initiated-step-transitions.md +++ b/docs/specs/plugins/requirements/plugin-initiated-step-transitions.md @@ -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 diff --git a/docs/specs/plugins/system-design/plugins-02.md b/docs/specs/plugins/system-design/plugins-02.md index 19d0b0aedd..6341ab3b7a 100644 --- a/docs/specs/plugins/system-design/plugins-02.md +++ b/docs/specs/plugins/system-design/plugins-02.md @@ -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