feat(plugins): expose task dependencies in plugin and canvas data API (#3794)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-18 22:55:51 +08:00
committed by GitHub
parent f92877b4be
commit 277677f330
38 changed files with 5545 additions and 656 deletions
@@ -47,9 +47,13 @@ All data responses use JSON. Collection responses use this envelope:
}
```
`next_cursor` is omitted when there is no next page. `limit` must be from 1 to
the host page limit. A task-scoped canvas is restricted to its task. A
workspace-scoped canvas is restricted to its workspace.
`next_cursor` is omitted when there is no next page. `limit` defaults to 50
and accepts any value from 1 to 200. Unlike the gRPC Host API, this surface
does not clamp an out-of-range `limit` into that window: a supplied value
outside 1..200 is rejected outright with HTTP 400 `invalid_request`, and only
an omitted `limit` falls back to the 50-row default. A task-scoped canvas is
restricted to its task. A workspace-scoped canvas is restricted to its
workspace.
| Method | Route | Permission | Use |
| --- | --- | --- | --- |
@@ -70,8 +74,45 @@ A task object contains these fields: `id`, `workspace_id`, `workflow_id`,
`is_ephemeral`, `repositories`, `metadata`, `archived_at`, `pull_requests`,
`workflow_step_id`, `position`, `assignee_agent_profile_id`, `labels`,
`autopilot`, `wip_admitted`, `queued_for_step_id`, `queued_at`, `project_id`,
and `external_id`. Repository entries contain `id`, `repository_id`,
`base_branch`, `position`, and `checkout_branch`.
`external_id`, `blocked`, `blocked_reason`, `depends_on`, `blocks`,
`depends_on_truncated`, `blocks_truncated`, and `start_when_unblocked`.
Repository entries contain `id`, `repository_id`, `base_branch`, `position`,
and `checkout_branch`.
`depends_on` and `blocks` are read-only dependency-edge lists. Each entry
contains `id`, `title`, `state`, and, on a `depends_on` entry only, `status`
(a `blocks` entry never carries `status`, since a task cannot be "pending" or
"resolved" against a task it blocks). Each list is capped at 512 entries;
`depends_on_truncated` and `blocks_truncated` report whether more edges exist
than were returned. `title` and `state` are blanked (empty string) on any
edge end the caller's canvas scope does not directly admit: a
workspace-scoped canvas admits an edge end sharing its workspace; a
repository- or session-scoped canvas admits an edge end only when it is also
returned as a directly readable task in the same response, never by
workspace equality alone; a task-scoped canvas admits none, seeing only the
edge's `id` and, for a `depends_on` entry, its `status`.
`blocked`, `blocked_reason`, and `start_when_unblocked` summarize the same
projection at the task level. When an internal read failure keeps the host
from deriving a verdict for a task a route does return, it substitutes a
withheld verdict rather than failing that route: `blocked: true`,
`blocked_reason: "unknown"`, empty `depends_on`/`blocks`, both truncation
flags `false`, and `start_when_unblocked: false`. Treat that shape as "no
answer," not as "task is actually blocked." A canvas lacking the read
capability never sees this verdict: every task route, including the `PATCH`
route, checks the read capability before returning any task and fails
outright with `403 plugin_permission_denied` when it is missing, so accessor
denial and a withheld verdict are never the same response. This is also
distinct from the fan-out limit described below, which fails the whole page
with `response_too_large` rather than substituting a withheld verdict onto
any task.
These seven fields exist on a task object only once the host's manifest
`min_kandev_version` floor is met; declare that floor for the release your
canvas expects them from, since an older host omits the fields entirely
rather than sending empty defaults for them. A host running a `dev` or other
non-release build always satisfies that floor check regardless of its actual
age, so a canvas can still receive zero-value dependency fields on such a
host even when the floor is declared correctly.
A workflow object contains `id`, `workspace_id`, `name`, `description`,
`sort_order`, `created_at`, and `updated_at`. A workflow-step object contains
@@ -166,6 +207,23 @@ Event delivery does not replace HTTP reads. Refetch after an event that can
change visible data, and stop using the iframe immediately when its host
reports a lifecycle or authority change.
No event ever carries the dependency projection (`blocked`, `blocked_reason`,
`depends_on`, `blocks`, the truncation flags, `start_when_unblocked`) in its
data payload — those fields are refetch-on-signal only. Refetch a cached
task's dependency fields when: a `task.updated` event names that task or
either end of one of its edges; a `task.dependencies_resolved` or
`task.dependency_failed` event names that task; or a `task.state_changed`
event names any task ID present in that task's cached `depends_on` or
`blocks` list, since a predecessor or dependent simply advancing state is not
itself one of the first three signals. A single task read is not a
transactional snapshot: with no surrounding lock, an edge can change while
the read is being derived, so one response can show an edge asymmetrically
(for example, a predecessor
still listed as pending after it has already resolved). Treat what a
response returns as the union of independently-read facts, and resolve
staleness by refetching on the next matching signal rather than by trusting
any single response as authoritative.
## Actions and errors
`POST ./_kandev/v1/actions/{key}` accepts a JSON body only when the manifest
@@ -188,5 +246,14 @@ Other stable error codes are:
| 413 or 500 | `response_too_large` when a host limit is exceeded |
| 503 | `runtime_unavailable` |
On the task list route, `response_too_large` also covers dependency
derivation refusing a page: deriving `depends_on`/`blocks` for every task on
a page reads a bounded number of distinct task IDs across all of that page's
edges (shared edge ends count once), and a page that would cross that bound
fails the whole request with `response_too_large` rather than returning a
partial or withheld-verdict page. Retry with a smaller `limit`. A single-task
read or write is never subject to this bound, since it can only ever derive
one task's edges.
All request bodies are bounded. Treat unknown error codes as retryable only
when the operation is a read and the canvas is still mounted.
@@ -114,29 +114,37 @@ func (r *Repository) ListTasksBlockedBy(ctx context.Context, blockerTaskID strin
return ids, nil
}
// ListBlockersForTasks returns a map of task ID → list of blocker IDs for a set of tasks.
// Uses IN query over the given task IDs.
// ListBlockersForTasks returns a map of task ID → list of blocker IDs for a set
// of tasks, ordered by edge creation time ascending and tiebroken by blocker
// task id ascending, so depends_on has a total, repeatable order. Input is
// chunked at sqliteMaxHostParams so the IN clause stays legal past the
// database's bind-parameter ceiling; every paginated flow caps at 200 task
// ids, well under the chunk size, so chunking only engages on the unpaginated
// preview flow.
func (r *Repository) ListBlockersForTasks(ctx context.Context, taskIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(taskIDs))
if len(taskIDs) == 0 {
return map[string][]string{}, nil
return result, nil
}
query, args, err := sqlx.In(
`SELECT task_id, blocker_task_id FROM task_blockers WHERE task_id IN (?)`, taskIDs)
if err != nil {
return nil, err
}
query = r.ro.Rebind(query)
type row struct {
TaskID string `db:"task_id"`
BlockerTaskID string `db:"blocker_task_id"`
}
var rows []row
if err := r.ro.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]string, len(taskIDs))
for _, rw := range rows {
result[rw.TaskID] = append(result[rw.TaskID], rw.BlockerTaskID)
for _, chunk := range chunkTaskIDs(taskIDs) {
query, args, err := sqlx.In(
`SELECT task_id, blocker_task_id FROM task_blockers
WHERE task_id IN (?) ORDER BY created_at, blocker_task_id`, chunk)
if err != nil {
return nil, err
}
query = r.ro.Rebind(query)
var rows []row
if err := r.ro.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
for _, rw := range rows {
result[rw.TaskID] = append(result[rw.TaskID], rw.BlockerTaskID)
}
}
return result, nil
}
@@ -145,28 +153,31 @@ func (r *Repository) ListBlockersForTasks(ctx context.Context, taskIDs []string)
// blocked by it, for a set of blocker task IDs. This is the batched reverse of
// ListBlockersForTasks and backs the "blocks" direction of the dependency
// payload; a per-task query would add one round trip per card to a board read.
// Chunked at sqliteMaxHostParams for the same reason as ListBlockersForTasks.
func (r *Repository) ListDependentsForTasks(ctx context.Context, blockerTaskIDs []string) (map[string][]string, error) {
result := make(map[string][]string, len(blockerTaskIDs))
if len(blockerTaskIDs) == 0 {
return map[string][]string{}, nil
return result, nil
}
query, args, err := sqlx.In(
`SELECT task_id, blocker_task_id FROM task_blockers WHERE blocker_task_id IN (?) ORDER BY created_at, task_id`,
blockerTaskIDs)
if err != nil {
return nil, err
}
query = r.ro.Rebind(query)
type row struct {
TaskID string `db:"task_id"`
BlockerTaskID string `db:"blocker_task_id"`
}
var rows []row
if err := r.ro.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
result := make(map[string][]string, len(blockerTaskIDs))
for _, rw := range rows {
result[rw.BlockerTaskID] = append(result[rw.BlockerTaskID], rw.TaskID)
for _, chunk := range chunkTaskIDs(blockerTaskIDs) {
query, args, err := sqlx.In(
`SELECT task_id, blocker_task_id FROM task_blockers WHERE blocker_task_id IN (?) ORDER BY created_at, task_id`,
chunk)
if err != nil {
return nil, err
}
query = r.ro.Rebind(query)
var rows []row
if err := r.ro.SelectContext(ctx, &rows, query, args...); err != nil {
return nil, err
}
for _, rw := range rows {
result[rw.BlockerTaskID] = append(result[rw.BlockerTaskID], rw.TaskID)
}
}
return result, nil
}
@@ -2,11 +2,13 @@ package sqlite_test
import (
"context"
"fmt"
"slices"
"testing"
"time"
"github.com/kandev/kandev/internal/office/models"
"github.com/kandev/kandev/internal/office/repository/sqlite"
)
func TestTaskBlocker_CRUD(t *testing.T) {
@@ -339,6 +341,93 @@ func TestListBlockersForTasks(t *testing.T) {
}
}
// ListBlockersForTasks must order its result by edge creation time ascending,
// tiebroken by predecessor task id ascending, so depends_on has a total,
// repeatable order. Timestamps are set directly so the tiebreak is exercised
// deterministically rather than racing on time.Now() resolution.
func TestListBlockersForTasks_OrdersByCreatedAtThenBlockerID(t *testing.T) {
repo, db := newTestRepoWithDB(t)
ctx := context.Background()
// Insertion order deliberately does not match the expected sorted order
// (task-z, task-a, task-c) below, so a query missing its ORDER BY clause
// would return rows in insertion/rowid order instead and fail this test.
for _, b := range []*models.TaskBlocker{
{TaskID: "task-1", BlockerTaskID: "task-c"}, // same time as task-a, higher id
{TaskID: "task-1", BlockerTaskID: "task-z"}, // earliest, but higher id
{TaskID: "task-1", BlockerTaskID: "task-a"}, // same time as task-c, lower id
} {
if err := repo.CreateTaskBlocker(ctx, b); err != nil {
t.Fatalf("create blocker: %v", err)
}
}
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
for blockerID, ts := range map[string]time.Time{
"task-z": base,
"task-a": base.Add(time.Second),
"task-c": base.Add(time.Second), // ties task-a: must tiebreak on blocker id
} {
if _, err := db.ExecContext(ctx,
`UPDATE task_blockers SET created_at = ? WHERE task_id = 'task-1' AND blocker_task_id = ?`,
ts, blockerID); err != nil {
t.Fatalf("stamp created_at for %s: %v", blockerID, err)
}
}
m, err := repo.ListBlockersForTasks(ctx, []string{"task-1"})
if err != nil {
t.Fatalf("ListBlockersForTasks: %v", err)
}
want := []string{"task-z", "task-a", "task-c"}
if !slices.Equal(m["task-1"], want) {
t.Errorf("task-1 blockers = %v, want %v (created_at asc, blocker id tiebreak)", m["task-1"], want)
}
}
// A batched IN (...) query must stay below the database's bind-parameter
// ceiling by chunking its input. 501 distinct task ids exceed
// workspaceGroupMaxHostParams (500), so this must split into 2 chunks; that
// split is asserted directly via sqlite.ChunkTaskIDsCount rather than relying
// on the database to reject an unchunked query, since SQLite's own compiled
// host-parameter ceiling sits far above 501 and would not reject it anyway.
func TestListBlockersAndDependentsForTasks_ChunksPastHostParamCeiling(t *testing.T) {
repo := newTestRepo(t)
ctx := context.Background()
const n = 501
taskIDs := make([]string, n)
blockerIDs := make([]string, n)
for i := 0; i < n; i++ {
taskIDs[i] = fmt.Sprintf("dependent-%d", i)
blockerIDs[i] = fmt.Sprintf("blocker-%d", i)
if err := repo.CreateTaskBlocker(ctx, &models.TaskBlocker{
TaskID: taskIDs[i], BlockerTaskID: blockerIDs[i],
}); err != nil {
t.Fatalf("create blocker %d: %v", i, err)
}
}
if got := sqlite.ChunkTaskIDsCount(taskIDs); got != 2 {
t.Fatalf("ChunkTaskIDsCount(%d ids) = %d chunks, want 2", n, got)
}
blockers, err := repo.ListBlockersForTasks(ctx, taskIDs)
if err != nil {
t.Fatalf("ListBlockersForTasks over %d ids: %v", n, err)
}
if len(blockers) != n {
t.Fatalf("ListBlockersForTasks returned %d task entries, want %d", len(blockers), n)
}
dependents, err := repo.ListDependentsForTasks(ctx, blockerIDs)
if err != nil {
t.Fatalf("ListDependentsForTasks over %d ids: %v", n, err)
}
if len(dependents) != n {
t.Fatalf("ListDependentsForTasks returned %d blocker entries, want %d", len(dependents), n)
}
}
// TestGetTaskAssigneeTx_ReflectsCurrentRunner covers the primitive
// ParentWakeReconciler.recordReceipt (scheduler_wake_reconciler.go) uses to close the
// TOCTOU race between ListStuckParents' SELECT and its own transactional run
@@ -15,3 +15,12 @@ func SetClaimWindowHook(fn func(ctx context.Context, tx *sqlx.Tx, seatID string)
claimWindowHook = fn
return func() { claimWindowHook = prev }
}
// ChunkTaskIDsCount reports how many chunks chunkTaskIDs would split ids
// into. External tests use this to assert that a batched lookup actually
// split its input rather than relying on the database's own host-parameter
// ceiling to fail the call, since that ceiling can sit far above the chunk
// size the production code chose.
func ChunkTaskIDsCount(ids []string) int {
return len(chunkTaskIDs(ids))
}
+20
View File
@@ -10,6 +10,7 @@ import (
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events/bus"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/state"
@@ -107,6 +108,18 @@ type pluginHost struct {
// and read through agentConversationsDeps (live, not snapshotted at
// hostForPlugin time, for the same late-wiring reason as writeDeps).
agentConversations func() AgentConversationService
// log receives the dependency-derivation-failure diagnostic emitted by
// attachDependencies (see host_data_dependencies.go). nil on a bare test
// host; attachDependencies skips logging rather than panicking.
log *logger.Logger
// instanceID identifies the canvas plugin instance a request is scoped
// to, set by Service.webAppHost. Empty on a gRPC plugin's host, which has
// no instance identity distinct from pluginID: every managed plugin uses
// one implicit global instance, so attachDependencies logs pluginID as
// the instance in that case.
instanceID string
}
var _ pluginsdk.Host = (*pluginHost)(nil)
@@ -133,6 +146,13 @@ func invalidArgument(msg string) error {
return status.Error(codes.InvalidArgument, msg)
}
// resourceExhausted builds the gRPC error a Host data reader returns when a
// batch read would exceed a hard response-size bound (e.g. the dependency
// projection's fan-out cap) rather than silently truncate it.
func resourceExhausted(msg string) error {
return status.Error(codes.ResourceExhausted, msg)
}
func (h *pluginHost) GetState(ctx context.Context, scope, scopeID, key string) (map[string]any, bool, error) {
if !h.capabilities.State {
return nil, false, permissionDenied("state")
+77 -22
View File
@@ -30,6 +30,7 @@ import (
githubsvc "github.com/kandev/kandev/internal/github"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
)
@@ -142,6 +143,8 @@ type taskDataSource interface {
GetExecutorRunningBySessionID(ctx context.Context, sessionID string) (*taskmodels.ExecutorRunning, error)
ListAllExecutorProfiles(ctx context.Context) ([]*taskmodels.ExecutorProfile, error)
GetExecutor(ctx context.Context, id string) (*taskmodels.Executor, error)
BuildDependencyViews(ctx context.Context, tasks []*taskmodels.Task) map[string]taskservice.DependencyView
BuildDependencyViewsBounded(ctx context.Context, tasks []*taskmodels.Task) (map[string]taskservice.DependencyView, error)
}
// workflowLister is the narrow slice of internal/task/service.Service the
@@ -342,6 +345,28 @@ const taskFetchPageSize = 1000
type taskReader struct{ host *pluginHost }
// fetchTaskPage resolves, filters, sorts, and paginates matching tasks,
// returning the raw models backing exactly the returned page (not the whole
// matching set). Both taskReader.List (gRPC ListTasks, which has no further
// narrowing) and the canvas task-list route (which narrows further by
// binding scope, and so must derive dependencies AFTER that narrowing
// rather than trust this method's caller to have already bounded the set)
// call this to fetch.
func (h *pluginHost) fetchTaskPage(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]*taskmodels.Task, *pluginsdk.PageInfo, error) {
workspaceIDs, err := h.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs)
if err != nil {
return nil, nil, err
}
tasks, err := h.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, filter.IncludeArchived)
if err != nil {
return nil, nil, err
}
tasks = filterTasks(tasks, filter)
sortTasksNewestFirst(tasks)
models, info := paginate(tasks, page)
return models, info, nil
}
func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page pluginsdk.Page) ([]pluginsdk.Task, *pluginsdk.PageInfo, error) {
if !r.host.capabilities.CanRead(resourceTasks) {
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
@@ -349,21 +374,19 @@ func (r taskReader) List(ctx context.Context, filter pluginsdk.TaskFilter, page
if r.host.taskData == nil {
return r.host.UnimplementedHostData.Tasks().List(ctx, filter, page)
}
workspaceIDs, err := r.host.resolveWorkspaceIDs(ctx, filter.WorkspaceIDs)
models, info, err := r.host.fetchTaskPage(ctx, filter, page)
if err != nil {
return nil, nil, err
}
tasks, err := r.host.fetchTasksForWorkspaces(ctx, workspaceIDs, filter.IncludeEphemeral, filter.IncludeArchived)
if err != nil {
return nil, nil, err
}
tasks = filterTasks(tasks, filter)
sortTasksNewestFirst(tasks)
items, info := paginate(tasksToDTOs(tasks), page)
// Attached AFTER pagination so the PR lookup covers one page, not the whole
// workspace: a campaign-sized read would otherwise fan out over every task
// kandev holds to fill a list the caller already bounded.
items := tasksToDTOs(models)
// Attached AFTER pagination so the PR lookup and dependency derivation
// cover one page, not the whole workspace: a campaign-sized read would
// otherwise fan out over every task kandev holds to fill a list the
// caller already bounded.
r.host.attachPullRequests(ctx, items)
if err := r.host.attachDependencies(ctx, items, models, true, "ListTasks"); err != nil {
return nil, nil, err
}
return items, info, nil
}
@@ -396,25 +419,57 @@ func (h *pluginHost) attachPullRequests(ctx context.Context, tasks []pluginsdk.T
}
}
// fetchTask resolves id to both wire and raw model forms without attaching
// pull requests or the dependency projection. model is nil whenever h has no
// task data source (dto then comes straight from UnimplementedHostData).
// Callers that only need the task for a scope-match preflight — and discard
// it immediately after — must use this (or fetchTaskForScopeCheck) rather
// than taskReader.Get, so a caller that never serializes the task never pays
// for its dependency derivation either.
func (h *pluginHost) fetchTask(ctx context.Context, id string) (dto *pluginsdk.Task, model *taskmodels.Task, err error) {
if !h.capabilities.CanRead(resourceTasks) {
return nil, nil, permissionDenied(apiReadCapability(resourceTasks))
}
if h.taskData == nil {
dto, err = h.UnimplementedHostData.Tasks().Get(ctx, id)
return dto, nil, err
}
task, err := h.taskData.GetTask(ctx, id)
if err != nil {
if errors.Is(err, repoerrors.ErrTaskNotFound) {
return nil, nil, taskNotFound(id)
}
return nil, nil, err
}
converted := taskModelToDTO(task)
return &converted, task, nil
}
// fetchTaskForScopeCheck resolves id for a scope-match preflight only: the
// caller discards the task immediately after deciding whether it may act on
// it, so this must not pay for pull-request or dependency-projection
// attachment the response will never serialize.
func (h *pluginHost) fetchTaskForScopeCheck(ctx context.Context, id string) (*pluginsdk.Task, error) {
dto, _, err := h.fetchTask(ctx, id)
return dto, err
}
// Get returns a gRPC NotFound error (not a (nil, nil) success) when id
// doesn't resolve to a task, so the in-process contract matches exactly what
// a real plugin observes over the wire via grpcHostServer.GetTask.
func (r taskReader) Get(ctx context.Context, id string) (*pluginsdk.Task, error) {
if !r.host.capabilities.CanRead(resourceTasks) {
return nil, permissionDenied(apiReadCapability(resourceTasks))
}
if r.host.taskData == nil {
return r.host.UnimplementedHostData.Tasks().Get(ctx, id)
}
task, err := r.host.taskData.GetTask(ctx, id)
dto, model, err := r.host.fetchTask(ctx, id)
if err != nil {
if errors.Is(err, repoerrors.ErrTaskNotFound) {
return nil, taskNotFound(id)
}
return nil, err
}
items := []pluginsdk.Task{taskModelToDTO(task)}
if model == nil {
return dto, nil
}
items := []pluginsdk.Task{*dto}
r.host.attachPullRequests(ctx, items)
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{model}, false, "GetTask"); err != nil {
return nil, err
}
return &items[0], nil
}
@@ -0,0 +1,149 @@
// host_data_dependencies.go attaches the dependency projection (blocked,
// blocked_reason, depends_on, blocks, depends_on_truncated,
// blocks_truncated, start_when_unblocked) onto Task DTOs, for both the gRPC
// and canvas surfaces. It reuses internal/task/service's already fail-closed
// derivation (BuildDependencyViews/BuildDependencyViewsBounded) rather than
// reimplementing any of its withheld-verdict or fan-out-bound logic.
package plugins
import (
"context"
"go.uber.org/zap"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// attachDependencies fills the seven dependency-projection fields on tasks in
// place. models[i] must be the raw model backing tasks[i] (same order, same
// length) — StartWhenUnblocked reads the stored auto-start intent directly
// off models[i]. operation identifies the calling Host RPC or canvas route
// (e.g. "ListTasks", "GetWebAppTask") for the derivation-failure diagnostic
// logDependencyDerivationFailure emits; it never carries a task id or title.
//
// A caller without api_read:tasks never reaches real dependency data through
// this path, even when it holds some other capability (e.g. api_write:tasks)
// that let it reach a Task DTO in the first place: every task is stamped with
// the withheld verdict instead of deriving anything. That is not a failed
// derivation attempt, so it is not logged.
//
// Unlike attachPullRequests, a derivation failure is not left at zero value:
// BuildDependencyViews/Bounded already substitute the withheld verdict
// (blocked: true, blocked_reason: "unknown") for every requested id on any
// read failure, so this only copies whatever the derivation returned. The
// one failure this function itself can return is the bounded fan-out
// refusal, translated to a single gRPC ResourceExhausted error so a caller
// never partially serializes a bounded batch around it — that refusal is a
// distinct sentinel, never a withheld verdict, so it is not logged here
// either.
func (h *pluginHost) attachDependencies(
ctx context.Context, tasks []pluginsdk.Task, models []*taskmodels.Task, bounded bool, operation string,
) error {
if len(tasks) == 0 {
return nil
}
if !h.capabilities.CanRead(resourceTasks) {
withholdDependencies(tasks)
return nil
}
if h.taskData == nil {
h.logDependencyDerivationFailure(operation, "source_unavailable")
withholdDependencies(tasks)
return nil
}
var views map[string]taskservice.DependencyView
if bounded {
v, err := h.taskData.BuildDependencyViewsBounded(ctx, models)
if err != nil {
return resourceExhausted(err.Error())
}
views = v
} else {
views = h.taskData.BuildDependencyViews(ctx, models)
}
derivationWithheld := false
for i := range tasks {
view := views[tasks[i].ID]
tasks[i].Blocked = view.Blocked
tasks[i].BlockedReason = view.BlockedReason
tasks[i].DependsOn = dependencyRefsToDTOs(view.DependsOn, true)
tasks[i].Blocks = dependencyRefsToDTOs(view.Blocks, false)
tasks[i].DependsOnTruncated = view.DependsOnTruncated
tasks[i].BlocksTruncated = view.BlocksTruncated
withheld := view.BlockedReason == taskservice.BlockedReasonUnknown
tasks[i].StartWhenUnblocked = !withheld && taskmodels.HasStartWhenUnblockedIntent(models[i])
if withheld {
derivationWithheld = true
}
}
if derivationWithheld {
// One record for the whole attempt, however many tasks it withheld
// for: BuildDependencyViews/Bounded only ever substitute this
// verdict batch-wide, on one of the read or edge-end-resolution
// failures it already logs internally without plugin/instance/
// operation identity.
h.logDependencyDerivationFailure(operation, "derivation_withheld")
}
return nil
}
// logDependencyDerivationFailure emits one structured record per derivation
// attempt: plugin, instance, resource type, operation, and result code,
// never a task title, task id, or edge content. No counter or metric is
// added; this log record is the whole observability contract.
func (h *pluginHost) logDependencyDerivationFailure(operation, resultCode string) {
if h.log == nil {
return
}
instanceID := h.instanceID
if instanceID == "" {
instanceID = h.pluginID
}
h.log.Warn("plugin dependency derivation withheld",
zap.String("plugin_id", h.pluginID),
zap.String("instance_id", instanceID),
zap.String("resource_type", "task"),
zap.String("operation", operation),
zap.String("result_code", resultCode),
)
}
// withholdDependencies stamps every task with the fail-closed withheld
// verdict in place, for a caller that lacks api_read:tasks. It is the same
// verdict a derivation failure produces, so a write-only caller cannot
// distinguish "withheld for lack of capability" from "withheld because
// derivation failed."
func withholdDependencies(tasks []pluginsdk.Task) {
for i := range tasks {
tasks[i].Blocked = true
tasks[i].BlockedReason = taskservice.BlockedReasonUnknown
tasks[i].DependsOn = []pluginsdk.TaskDependencyRef{}
tasks[i].Blocks = []pluginsdk.TaskDependencyRef{}
tasks[i].DependsOnTruncated = false
tasks[i].BlocksTruncated = false
tasks[i].StartWhenUnblocked = false
}
}
// dependencyRefsToDTOs converts a derived edge list to its DTO form,
// preserving order. Status carries DependencyStatusForTask's verdict for a
// predecessor (includeStatus true); a dependent's own status describes its
// own progress, not readiness to unblock this task, so it is dropped
// (includeStatus false). Never nil: depends_on/blocks always serialize as
// [], never as an absent or null field.
func dependencyRefsToDTOs(refs []taskservice.DependencyRef, includeStatus bool) []pluginsdk.TaskDependencyRef {
out := make([]pluginsdk.TaskDependencyRef, len(refs))
for i, ref := range refs {
status := ref.Status
if !includeStatus {
status = ""
}
out[i] = pluginsdk.TaskDependencyRef{
ID: ref.ID, Title: ref.Title, State: string(ref.State), Status: status,
WorkspaceID: ref.WorkspaceID,
}
}
return out
}
@@ -0,0 +1,155 @@
package plugins
import (
"context"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
"go.uber.org/zap/zaptest/observer"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/pkg/pluginsdk"
)
// newObservedLogger wires a *logger.Logger backed by a zaptest/observer
// core, so a test can assert on the fields of a specific log record.
func newObservedLogger(t *testing.T) (*logger.Logger, *observer.ObservedLogs) {
t.Helper()
core, logs := observer.New(zapcore.DebugLevel)
log, err := logger.NewFromZap(zap.New(core))
require.NoError(t, err)
return log, logs
}
// TestAttachDependencies_LogsWhenTaskDataSourceIsNil proves the
// AC-PLUGINS-TASK-DEPS-002.4 diagnostic fires for the "derivation source
// absent" failure class, carrying plugin, instance, resource type,
// operation, and result code — no task title, id, or edge content.
func TestAttachDependencies_LogsWhenTaskDataSourceIsNil(t *testing.T) {
log, logs := newObservedLogger(t)
host := &pluginHost{
pluginID: "plugin-1",
capabilities: manifest.Capabilities{APIRead: []string{"tasks"}},
log: log,
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
entries := logs.All()
require.Len(t, entries, 1, "exactly one record for this derivation attempt")
fields := entries[0].ContextMap()
require.Equal(t, "plugin-1", fields["plugin_id"])
require.Equal(t, "plugin-1", fields["instance_id"], "a gRPC host has no separate instance id, so it falls back to plugin id")
require.Equal(t, "task", fields["resource_type"])
require.Equal(t, "GetTask", fields["operation"])
require.Equal(t, "source_unavailable", fields["result_code"])
}
// TestAttachDependencies_UsesCanvasInstanceIDWhenSet proves a canvas-scoped
// host logs its real instance id rather than falling back to plugin id.
func TestAttachDependencies_UsesCanvasInstanceIDWhenSet(t *testing.T) {
log, logs := newObservedLogger(t)
host := &pluginHost{
pluginID: "plugin-1",
instanceID: "instance-9",
capabilities: manifest.Capabilities{APIRead: []string{"tasks"}},
log: log,
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetWebAppTask")
require.NoError(t, err)
require.Len(t, logs.All(), 1)
require.Equal(t, "instance-9", logs.All()[0].ContextMap()["instance_id"])
}
// TestAttachDependencies_LogsOnceWhenDerivationWithheldAcrossManyTasks
// proves the log unit is the derivation attempt, not the withheld task: a
// batch-wide failure (every task's BlockedReason coming back "unknown")
// still produces exactly one record.
func TestAttachDependencies_LogsOnceWhenDerivationWithheldAcrossManyTasks(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
"task-2": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
"task-3": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
}
tasks := []pluginsdk.Task{{ID: "task-1"}, {ID: "task-2"}, {ID: "task-3"}}
models := []*taskmodels.Task{{ID: "task-1"}, {ID: "task-2"}, {ID: "task-3"}}
err := d.host.attachDependencies(context.Background(), tasks, models, true, "ListTasks")
require.NoError(t, err)
entries := logs.All()
require.Len(t, entries, 1, "one record per derivation attempt, not one per withheld task")
require.Equal(t, "derivation_withheld", entries[0].ContextMap()["result_code"])
}
// TestAttachDependencies_NoLogOnSuccessfulDerivation proves an ordinary
// non-withheld read logs nothing.
func TestAttachDependencies_NoLogOnSuccessfulDerivation(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NoLogWhenCapabilityDenied proves a caller lacking
// api_read:tasks is not logged as a failed derivation attempt: per the
// design, withholding for lack of capability never reaches the derivation
// at all, so there is nothing to report as having failed.
func TestAttachDependencies_NoLogWhenCapabilityDenied(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{})
d.host.log = log
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
require.NoError(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NoLogOnFanOutRefusal proves the bounded fan-out
// refusal is not logged by this diagnostic: it is a distinct sentinel
// (ResourceExhausted before any task is serialized), never the withheld
// verdict AC-PLUGINS-TASK-DEPS-002.4 covers.
func TestAttachDependencies_NoLogOnFanOutRefusal(t *testing.T) {
log, logs := newObservedLogger(t)
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.host.log = log
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "ListTasks")
require.Error(t, err)
require.Empty(t, logs.All())
}
// TestAttachDependencies_NilLoggerNeverPanics proves a bare test/production
// host with no logger wired (log is nil) degrades to no-op logging instead
// of panicking, on both the source-absent and derivation-withheld paths.
func TestAttachDependencies_NilLoggerNeverPanics(t *testing.T) {
host := &pluginHost{capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}}
tasks := []pluginsdk.Task{{ID: "task-1"}}
require.NotPanics(t, func() {
_ = host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "GetTask")
})
}
@@ -0,0 +1,236 @@
package plugins
import (
"context"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/pluginsdk"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestAttachDependencies_CopiesViewOntoMatchingTask(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{{ID: "task-0", Title: "Predecessor", State: v1.TaskStateInProgress, Status: taskservice.DependencyPending}},
Blocks: []taskservice.DependencyRef{{ID: "task-9", Title: "Dependent", State: v1.TaskStateTODO, Status: taskservice.DependencyPending}},
DependsOnTruncated: true,
},
}
model := &taskmodels.Task{ID: "task-1"}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
require.NoError(t, err)
require.True(t, tasks[0].Blocked)
require.Equal(t, taskservice.BlockedReasonPending, tasks[0].BlockedReason)
require.Equal(t, []pluginsdk.TaskDependencyRef{{ID: "task-0", Title: "Predecessor", State: string(v1.TaskStateInProgress), Status: taskservice.DependencyPending}}, tasks[0].DependsOn)
require.True(t, tasks[0].DependsOnTruncated)
require.False(t, tasks[0].BlocksTruncated)
}
func TestAttachDependencies_BlocksEntriesNeverCarryStatus(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {
Blocks: []taskservice.DependencyRef{{ID: "task-9", Title: "Dependent", State: v1.TaskStateTODO, Status: taskservice.DependencyPending}},
},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
require.NoError(t, err)
require.Len(t, tasks[0].Blocks, 1)
require.Empty(t, tasks[0].Blocks[0].Status, "a blocks entry's status describes the dependent's own progress, not readiness, so the wire contract drops it")
}
func TestAttachDependencies_EmptyEdgeListsAreNeverNil(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
require.NoError(t, err)
require.NotNil(t, tasks[0].DependsOn)
require.Empty(t, tasks[0].DependsOn)
require.NotNil(t, tasks[0].Blocks)
require.Empty(t, tasks[0].Blocks)
}
func TestAttachDependencies_StartWhenUnblockedReflectsStoredIntentWhenNotWithheld(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
model := &taskmodels.Task{
ID: "task-1",
Metadata: map[string]interface{}{
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
},
},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
require.NoError(t, err)
require.True(t, tasks[0].StartWhenUnblocked)
}
func TestAttachDependencies_StartWhenUnblockedForcedFalseUnderWithheldVerdict(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
}
model := &taskmodels.Task{
ID: "task-1",
Metadata: map[string]interface{}{
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
},
},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
require.NoError(t, err)
require.False(t, tasks[0].StartWhenUnblocked, "the withheld verdict forces start_when_unblocked false regardless of the stored intent")
}
func TestAttachDependencies_BoundedFanOutRefusalBecomesResourceExhausted(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "Test")
require.Error(t, err)
require.Equal(t, codes.ResourceExhausted, status.Code(err))
}
func TestAttachDependencies_UnboundedVariantNeverConsultsBoundedSource(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
require.NoError(t, err)
require.Equal(t, 1, d.tasks.dependencyViewsCalls)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestAttachDependencies_NoopOnEmptyTasks(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
err := d.host.attachDependencies(context.Background(), nil, nil, true, "Test")
require.NoError(t, err)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestPluginHost_Tasks_GetAttachesDependencyProjectionUnbounded(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", Title: "Task 1"}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonUnknown},
}
got, err := d.host.Tasks().Get(context.Background(), "task-1")
require.NoError(t, err)
require.True(t, got.Blocked)
require.Equal(t, taskservice.BlockedReasonUnknown, got.BlockedReason)
require.Equal(t, 1, d.tasks.dependencyViewsCalls)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls, "Get is one of the single-task flows exempt from the fan-out cap")
}
func TestPluginHost_Tasks_ListAttachesDependencyProjectionBoundedOverThePageOnly(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{
"ws-1": {
{ID: "task-1", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC)},
{ID: "task-2", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)},
{ID: "task-3", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)},
},
}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
tasks, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{Limit: 1})
require.NoError(t, err)
require.Len(t, tasks, 1)
require.True(t, tasks[0].Blocked)
require.Equal(t, []string{"task-1"}, d.tasks.dependencyViewsTasks, "derivation runs over the returned page only, not the whole workspace")
require.Equal(t, 1, d.tasks.dependencyViewsBoundedCalls, "ListTasks is bound by the fan-out cap")
}
func TestPluginHost_Tasks_ListTranslatesFanOutRefusalToResourceExhausted(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {{ID: "task-1", WorkspaceID: "ws-1"}}}
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
_, _, err := d.host.Tasks().List(context.Background(), pluginsdk.TaskFilter{}, pluginsdk.Page{})
require.Error(t, err)
require.Equal(t, codes.ResourceExhausted, status.Code(err))
}
func TestAttachDependencies_WithholdsWhenTaskDataSourceNil(t *testing.T) {
host := &pluginHost{capabilities: manifest.Capabilities{APIRead: []string{"tasks"}}}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, true, "Test")
require.NoError(t, err)
require.True(t, tasks[0].Blocked, "a nil task-data source must fail closed to the withheld verdict, not report not-blocked")
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason)
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].DependsOn)
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].Blocks)
}
func TestAttachDependencies_WithholdsWhenCallerLacksReadCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: false, BlockedReason: ""},
}
model := &taskmodels.Task{
ID: "task-1",
Metadata: map[string]interface{}{
taskmodels.MetaKeyDeferredLaunch: map[string]interface{}{
taskmodels.DeferredLaunchStartWhenUnblockedKey: true,
},
},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{model}, false, "Test")
require.NoError(t, err)
require.True(t, tasks[0].Blocked, "a caller without api_read:tasks gets the fail-closed withheld verdict, not real data")
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason)
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].DependsOn)
require.Equal(t, []pluginsdk.TaskDependencyRef{}, tasks[0].Blocks)
require.False(t, tasks[0].DependsOnTruncated)
require.False(t, tasks[0].BlocksTruncated)
require.False(t, tasks[0].StartWhenUnblocked)
require.Equal(t, 0, d.tasks.dependencyViewsCalls, "withholding for lack of capability issues no derivation call")
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestAttachDependencies_WriteOnlyCapabilityWithholdsEvenWithTaskDataSource(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
tasks := []pluginsdk.Task{{ID: "task-1"}}
err := d.host.attachDependencies(context.Background(), tasks, []*taskmodels.Task{{ID: "task-1"}}, false, "Test")
require.NoError(t, err)
require.Equal(t, taskservice.BlockedReasonUnknown, tasks[0].BlockedReason, "api_write:tasks alone never unlocks real dependency data")
}
@@ -13,6 +13,7 @@ import (
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/internal/task/repository/repoerrors"
taskservice "github.com/kandev/kandev/internal/task/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
v1 "github.com/kandev/kandev/pkg/api/v1"
"github.com/kandev/kandev/pkg/pluginsdk"
@@ -49,6 +50,20 @@ type fakeTaskDataSource struct {
// can prove a workspace with more tasks than a single page issues
// multiple calls instead of returning a truncated first page.
listTasksByWorkspaceCalls int
// dependencyViews, keyed by task ID, is returned by both
// BuildDependencyViews and BuildDependencyViewsBounded. A nil map
// yields an empty map (every task unblocked), matching the zero value a
// test that never sets it should observe. dependencyViewsErr, when set,
// is returned only by the bounded variant, simulating a fan-out refusal.
dependencyViews map[string]taskservice.DependencyView
dependencyViewsErr error
dependencyViewsCalls int
dependencyViewsBoundedCalls int
// dependencyViewsTasks records the task IDs passed to the most recent
// BuildDependencyViews/Bounded call, so tests can prove attachment
// derives over the right (e.g. post-filter) slice.
dependencyViewsTasks []string
}
func (f *fakeTaskDataSource) ListWorkspaces(context.Context) ([]*taskmodels.Workspace, error) {
@@ -114,6 +129,35 @@ func (f *fakeTaskDataSource) GetExecutor(_ context.Context, id string) (*taskmod
return f.executors[id], nil
}
func (f *fakeTaskDataSource) recordDependencyViewsTasks(tasks []*taskmodels.Task) {
ids := make([]string, len(tasks))
for i, t := range tasks {
ids[i] = t.ID
}
f.dependencyViewsTasks = ids
}
func (f *fakeTaskDataSource) BuildDependencyViews(_ context.Context, tasks []*taskmodels.Task) map[string]taskservice.DependencyView {
f.dependencyViewsCalls++
f.recordDependencyViewsTasks(tasks)
if f.dependencyViews == nil {
return map[string]taskservice.DependencyView{}
}
return f.dependencyViews
}
func (f *fakeTaskDataSource) BuildDependencyViewsBounded(_ context.Context, tasks []*taskmodels.Task) (map[string]taskservice.DependencyView, error) {
f.dependencyViewsBoundedCalls++
f.recordDependencyViewsTasks(tasks)
if f.dependencyViewsErr != nil {
return nil, f.dependencyViewsErr
}
if f.dependencyViews == nil {
return map[string]taskservice.DependencyView{}, nil
}
return f.dependencyViews, nil
}
type fakeWorkflowLister struct {
workflows map[string][]*taskmodels.Workflow
}
+35 -7
View File
@@ -222,16 +222,27 @@ func (r taskReader) Create(ctx context.Context, in pluginsdk.CreateTaskInput) (*
if in.StartAgent {
r.host.startTaskBestEffort(ctx, created.ID, launch)
}
dto := taskModelToDTO(created)
return &dto, nil
items := []pluginsdk.Task{taskModelToDTO(created)}
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{created}, false, "CreateTask"); err != nil {
return nil, err
}
return &items[0], nil
}
func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) {
if !r.host.capabilities.CanWrite(resourceTasks) {
// writeTaskUpdate performs UpdateTask's validation and mutation without
// attaching pull requests or the dependency projection, returning the raw
// model. A caller that folds this write into a larger response alongside
// another write (the canvas PATCH route's Update+Move body) must derive once
// on whichever result is actually serialized, not once per write it makes;
// taskReader.Update wraps this for the ordinary single-write callers (gRPC,
// and every other webapp route), attaching immediately after.
func (h *pluginHost) writeTaskUpdate(ctx context.Context, in pluginsdk.UpdateTaskInput) (*taskmodels.Task, error) {
if !h.capabilities.CanWrite(resourceTasks) {
return nil, permissionDenied(apiWriteCapability(resourceTasks))
}
if r.host.taskWriter == nil {
return r.host.UnimplementedHostData.Tasks().Update(ctx, in)
if h.taskWriter == nil {
_, err := h.UnimplementedHostData.Tasks().Update(ctx, in)
return nil, err
}
if in.ID == "" {
return nil, invalidArgument("id is required")
@@ -244,7 +255,7 @@ func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*
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{
updated, err := h.taskWriter.UpdateTask(ctx, TaskUpdateInput{
ID: in.ID,
Title: in.Title,
Description: in.Description,
@@ -258,8 +269,19 @@ func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*
}
return nil, err
}
return updated, nil
}
func (r taskReader) Update(ctx context.Context, in pluginsdk.UpdateTaskInput) (*pluginsdk.Task, error) {
updated, err := r.host.writeTaskUpdate(ctx, in)
if err != nil {
return nil, err
}
items := []pluginsdk.Task{taskModelToDTO(updated)}
r.host.attachPullRequests(ctx, items)
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{updated}, false, "UpdateTask"); err != nil {
return nil, err
}
return &items[0], nil
}
@@ -302,6 +324,9 @@ func (r taskReader) Move(ctx context.Context, in pluginsdk.MoveTaskInput) (*plug
}
items := []pluginsdk.Task{taskModelToDTO(result.Task)}
r.host.attachPullRequests(ctx, items)
if err := r.host.attachDependencies(ctx, items, []*taskmodels.Task{result.Task}, false, "MoveTask"); err != nil {
return nil, err
}
return &pluginsdk.MoveTaskOutcome{
Task: &items[0],
Transitioned: result.Transitioned,
@@ -541,6 +566,9 @@ func (m pluginOwnedTaskTreeManager) Preview(ctx context.Context, rootTaskID stri
}
dtos := tasksToDTOs(tasks)
m.host.attachPullRequests(ctx, dtos)
if err := m.host.attachDependencies(ctx, dtos, tasks, true, "PreviewPluginOwnedTaskTree"); err != nil {
return nil, err
}
return dtos, nil
}
@@ -0,0 +1,147 @@
package plugins
import (
"context"
"testing"
"github.com/kandev/kandev/internal/plugins/manifest"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
"github.com/kandev/kandev/pkg/pluginsdk"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
func TestPluginHost_Tasks_CreateAttachesDependencyProjection(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}, APIRead: []string{"tasks"}})
d.taskWriter.created = &taskmodels.Task{ID: "task-9", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate"}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-9": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
task, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate",
})
require.NoError(t, err)
require.True(t, task.Blocked)
require.Equal(t, taskservice.BlockedReasonPending, task.BlockedReason)
require.Equal(t, 1, d.tasks.dependencyViewsCalls, "Create derives via the unbounded variant")
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestPluginHost_Tasks_UpdateAttachesDependencyProjection(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}, APIRead: []string{"tasks"}})
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "updated"}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: false, BlockedReason: ""},
}
got, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1"})
require.NoError(t, err)
require.False(t, got.Blocked)
require.Equal(t, 1, d.tasks.dependencyViewsCalls)
}
func TestPluginHost_Tasks_MoveAttachesDependencyProjectionOnTheResultingTask(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}, APIRead: []string{"tasks"}})
moved := &taskmodels.Task{ID: "task-1", WorkflowStepID: "step-2"}
d.taskWriter.moveResult = &TaskMoveResult{Task: moved, Transitioned: true}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonFailed},
}
outcome, err := d.host.Tasks().Move(context.Background(), pluginsdk.MoveTaskInput{TaskID: "task-1", WorkflowStepID: "step-2"})
require.NoError(t, err)
require.True(t, outcome.Task.Blocked)
require.Equal(t, taskservice.BlockedReasonFailed, outcome.Task.BlockedReason)
require.Equal(t, 1, d.tasks.dependencyViewsCalls, "Move derives via the unbounded variant, once, on the object that was serialized")
}
func TestPluginHost_PluginOwnedTaskTreePreviewAttachesDependencyProjectionBounded(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}, APIRead: []string{"tasks"}})
root := &taskmodels.Task{ID: "root", WorkspaceID: "ws-1", Metadata: map[string]any{taskSourceMetadataKey: "plugin:p1"}}
d.tasks.tasksByID = map[string]*taskmodels.Task{"root": root}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {root}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"root": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
preview, err := d.host.PluginOwnedTaskTrees().Preview(context.Background(), "root")
require.NoError(t, err)
require.Len(t, preview, 1)
require.True(t, preview[0].Blocked)
require.Equal(t, 1, d.tasks.dependencyViewsBoundedCalls, "Preview is one of the flows F46 binds to the fan-out cap")
require.Equal(t, 0, d.tasks.dependencyViewsCalls)
}
func TestPluginHost_PluginOwnedTaskTreePreviewTranslatesFanOutRefusal(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}, APIRead: []string{"tasks"}})
root := &taskmodels.Task{ID: "root", WorkspaceID: "ws-1", Metadata: map[string]any{taskSourceMetadataKey: "plugin:p1"}}
d.tasks.tasksByID = map[string]*taskmodels.Task{"root": root}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {root}}
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
_, err := d.host.PluginOwnedTaskTrees().Preview(context.Background(), "root")
require.Error(t, err)
require.Equal(t, codes.ResourceExhausted, status.Code(err))
}
// ── write-only capability (no api_read:tasks) never sees real dependency
// data through a write RPC, per AC-PLUGINS-TASK-DEPS-001.11 ─────────────
func TestPluginHost_Tasks_CreateWithholdsDependencyProjectionWithoutReadCapability(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.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-9": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
task, err := d.host.Tasks().Create(context.Background(), pluginsdk.CreateTaskInput{
WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Investigate",
})
require.NoError(t, err)
require.Equal(t, taskservice.BlockedReasonUnknown, task.BlockedReason, "a write-only caller must not read real dependency data through Create")
require.Equal(t, 0, d.tasks.dependencyViewsCalls)
}
func TestPluginHost_Tasks_UpdateWithholdsDependencyProjectionWithoutReadCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "updated"}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
got, err := d.host.Tasks().Update(context.Background(), pluginsdk.UpdateTaskInput{ID: "task-1"})
require.NoError(t, err)
require.Equal(t, taskservice.BlockedReasonUnknown, got.BlockedReason, "a write-only caller must not read real dependency data through Update")
}
func TestPluginHost_Tasks_MoveWithholdsDependencyProjectionWithoutReadCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
moved := &taskmodels.Task{ID: "task-1", WorkflowStepID: "step-2"}
d.taskWriter.moveResult = &TaskMoveResult{Task: moved, Transitioned: true}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {Blocked: true, BlockedReason: taskservice.BlockedReasonFailed},
}
outcome, err := d.host.Tasks().Move(context.Background(), pluginsdk.MoveTaskInput{TaskID: "task-1", WorkflowStepID: "step-2"})
require.NoError(t, err)
require.Equal(t, taskservice.BlockedReasonUnknown, outcome.Task.BlockedReason, "a write-only caller must not read real dependency data through Move")
}
func TestPluginHost_PluginOwnedTaskTreePreviewWithholdsDependencyProjectionWithoutReadCapability(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIWrite: []string{"tasks"}})
root := &taskmodels.Task{ID: "root", WorkspaceID: "ws-1", Metadata: map[string]any{taskSourceMetadataKey: "plugin:p1"}}
d.tasks.tasksByID = map[string]*taskmodels.Task{"root": root}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {root}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"root": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
preview, err := d.host.PluginOwnedTaskTrees().Preview(context.Background(), "root")
require.NoError(t, err)
require.Len(t, preview, 1)
require.Equal(t, taskservice.BlockedReasonUnknown, preview[0].BlockedReason, "a write-only caller must not read real dependency data through Preview")
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
+1
View File
@@ -817,6 +817,7 @@ func (s *Service) hostForPlugin(pluginID string) pluginsdk.Host {
writeDeps: s.writeDependencies,
interactionDeps: s.interactionResponderDep,
agentConversations: s.agentConversationDeps,
log: s.log,
}
}
@@ -37,3 +37,99 @@ func TestProjectWebAppEventDropsInternalPayloadFields(t *testing.T) {
t.Fatalf("projected scope = %+v", projected.Scope)
}
}
// TestProjectWebAppEventDropsDependencyProjectionFields covers the refresh
// contract's test-strategy commitment (task-dependency-refresh.md): the
// canvas event projection for task.updated, task.dependencies_resolved and
// task.dependency_failed carries no dependency projection field, no edge
// list, and no edge end's title or state. Asserted directly against a
// payload that carries every projection field, not inferred from the field
// allowlist, so a future allowlist edit that accidentally widens it fails
// this test instead of leaking silently.
func TestProjectWebAppEventDropsDependencyProjectionFields(t *testing.T) {
dependencyFields := map[string]any{
"blocked": true,
"blocked_reason": "unknown",
"depends_on": []map[string]any{{"id": "task-a", "title": "Blocker A", "state": "in_progress", "status": "pending"}},
"blocks": []map[string]any{{"id": "task-b", "title": "Dependent B", "state": "backlog"}},
"depends_on_truncated": true,
"blocks_truncated": false,
"start_when_unblocked": true,
}
forbidden := []string{
"blocked", "blocked_reason", "depends_on", "blocks",
"depends_on_truncated", "blocks_truncated", "start_when_unblocked",
"Blocker A", "Dependent B",
}
cases := []struct {
name string
event string
payload map[string]any
}{
{
name: "task.updated",
event: events.TaskUpdated,
payload: map[string]any{
"task_id": "task-1",
"workspace_id": "workspace-1",
"title": "Task one",
"state": "in_progress",
},
},
{
name: "task.dependencies_resolved",
event: events.TaskDependenciesResolved,
payload: map[string]any{
"task_id": "task-1",
"resolved_by_task_id": "task-a",
},
},
{
name: "task.dependency_failed",
event: events.TaskDependencyFailed,
payload: map[string]any{
"task_id": "task-1",
"failed_task_id": "task-a",
"failed_state": "cancelled",
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := map[string]any{}
for k, v := range tc.payload {
payload[k] = v
}
for k, v := range dependencyFields {
payload[k] = v
}
busEvent := bus.NewEvent(tc.event, "task-service", payload)
projected, ok := projectWebAppEvent(busEvent)
if !ok {
t.Fatalf("projectWebAppEvent() rejected %s", tc.event)
}
raw, err := json.Marshal(projected.Data)
if err != nil {
t.Fatalf("marshal projected data: %v", err)
}
encoded := string(raw)
for _, field := range forbidden {
if strings.Contains(encoded, field) {
t.Fatalf("%s projection leaked dependency data %q: %s", tc.event, field, encoded)
}
}
data, ok := projected.Data.(map[string]any)
if !ok {
t.Fatalf("%s projected data is %T, want map[string]any", tc.event, projected.Data)
}
for _, key := range []string{"blocked", "blocked_reason", "depends_on", "blocks", "depends_on_truncated", "blocks_truncated", "start_when_unblocked"} {
if _, exists := data[key]; exists {
t.Fatalf("%s projection kept dependency key %q: %+v", tc.event, key, data)
}
}
})
}
}
@@ -119,9 +119,11 @@ func (s *Service) webAppHost(binding webapp.CapabilityBinding) *pluginHost {
taskWriter: s.taskWriter,
taskPRsDep: s.taskPRSourceDep,
writeDeps: s.writeDependencies,
log: s.log,
}
}
host.pluginID = binding.PluginID
host.instanceID = binding.InstanceID
host.capabilities = webAppCapabilities(binding.Permissions)
return host
}
@@ -338,6 +340,8 @@ func webAppProtocolStatus(err error) int {
return http.StatusConflict
case codes.Unimplemented:
return http.StatusNotImplemented
case codes.ResourceExhausted:
return http.StatusInternalServerError
default:
if errors.Is(err, context.DeadlineExceeded) {
return http.StatusGatewayTimeout
@@ -12,6 +12,7 @@ import (
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/state"
"github.com/kandev/kandev/internal/plugins/webapp"
taskmodels "github.com/kandev/kandev/internal/task/models"
"github.com/kandev/kandev/pkg/pluginsdk"
)
@@ -40,31 +41,76 @@ func (s *Service) listWebAppTasks(ctx context.Context, w http.ResponseWriter, r
IncludeEphemeral: true,
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
if err != nil || task == nil {
// Resolved via the non-attaching fetchTask, and dependencies attached
// only below, after the discard checks: matching the attachment rule
// the non-task-scope branch below already documents for itself.
dto, model, err := host.fetchTask(ctx, binding.TaskID)
if err != nil || dto == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if !webAppTaskMatches(ctx, host, binding, *task) || !webAppTaskMatchesFilter(*task, filter) || (!includeArchived && task.ArchivedAt != nil) {
if !webAppTaskMatches(ctx, host, binding, *dto) || !webAppTaskMatchesFilter(*dto, filter) || (!includeArchived && dto.ArchivedAt != nil) {
writeWebAppJSON(w, r, http.StatusOK, webAppPage[webAppTask]{Items: []webAppTask{}, PageInfo: webAppPageInfo{}})
return
}
items, info := paginate([]webAppTask{webAppTaskFromSDK(*task)}, page)
fetched := []pluginsdk.Task{*dto}
host.attachPullRequests(ctx, fetched)
var models []*taskmodels.Task
if model != nil {
models = []*taskmodels.Task{model}
}
if err := host.attachDependencies(ctx, fetched, models, false, "ListWebAppTasks"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
scope := dependencyRedactionScopeFromBinding(binding, nil)
items, info := paginate([]webAppTask{webAppTaskFromSDK(fetched[0], scope)}, page)
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(items, info))
return
}
items, info, err := host.Tasks().List(ctx, filter, page)
// Fetches without deriving dependencies: the binding scope (repository or
// session) narrows the fetched page further below, and deriving before
// that narrowing would both waste work on tasks the caller never sees and
// risk tripping the fan-out cap on a page the actual response never
// approaches.
if !host.capabilities.CanRead(resourceTasks) {
writeWebAppError(w, http.StatusForbidden, "plugin_permission_denied")
return
}
if host.taskData == nil {
_, _, err := host.UnimplementedHostData.Tasks().List(ctx, filter, page)
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
models, info, err := host.fetchTaskPage(ctx, filter, page)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
filtered := make([]webAppTask, 0, len(items))
for _, item := range items {
if webAppTaskMatches(ctx, host, binding, item) {
filtered = append(filtered, webAppTaskFromSDK(item))
dtos := tasksToDTOs(models)
host.attachPullRequests(ctx, dtos)
survivingModels := make([]*taskmodels.Task, 0, len(dtos))
survivingDTOs := make([]pluginsdk.Task, 0, len(dtos))
for i, dto := range dtos {
if webAppTaskMatches(ctx, host, binding, dto) {
survivingDTOs = append(survivingDTOs, dto)
survivingModels = append(survivingModels, models[i])
}
}
if err := host.attachDependencies(ctx, survivingDTOs, survivingModels, true, "ListWebAppTasks"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
directlyReadableIDs := make([]string, len(survivingDTOs))
for i, dto := range survivingDTOs {
directlyReadableIDs[i] = dto.ID
}
scope := dependencyRedactionScopeFromBinding(binding, directlyReadableIDs)
filtered := make([]webAppTask, len(survivingDTOs))
for i, dto := range survivingDTOs {
filtered[i] = webAppTaskFromSDK(dto, scope)
}
writeWebAppJSON(w, r, http.StatusOK, webAppPageFromSDK(filtered, info))
}
@@ -73,16 +119,29 @@ func (s *Service) getWebAppTask(ctx context.Context, w http.ResponseWriter, r *h
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
if err != nil || task == nil {
// Resolved via the non-attaching fetchTask, and dependencies attached only
// below, after the scope check that can discard the result and 404.
dto, model, err := host.fetchTask(ctx, taskID)
if err != nil || dto == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if !webAppTaskMatches(ctx, host, binding, *task) {
if !webAppTaskMatches(ctx, host, binding, *dto) {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*task))
fetched := []pluginsdk.Task{*dto}
host.attachPullRequests(ctx, fetched)
var models []*taskmodels.Task
if model != nil {
models = []*taskmodels.Task{model}
}
if err := host.attachDependencies(ctx, fetched, models, false, "GetWebAppTask"); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
scope := dependencyRedactionScopeFromBinding(binding, []string{fetched[0].ID})
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(fetched[0], scope))
}
type webAppTaskPatch struct {
@@ -105,7 +164,7 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
task, err := host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -121,12 +180,25 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
}
var updated *pluginsdk.Task
if patch.hasTaskFields() {
updated, err = host.Tasks().Update(ctx, pluginsdk.UpdateTaskInput{
updateInput := pluginsdk.UpdateTaskInput{
ID: taskID, Title: patch.Title, Description: patch.Description, State: patch.State,
})
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
if patch.WorkflowStepID != nil {
// A body naming both a task field and workflow_step_id takes both
// this branch and the Move branch below, but only Move's result is
// serialized -- so this write must not attach dependencies on a
// result nobody sees. The one derivation the response makes runs
// on Move's result, below.
if _, err = host.writeTaskUpdate(ctx, updateInput); err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
} else {
updated, err = host.Tasks().Update(ctx, updateInput)
if err != nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
}
}
}
if patch.WorkflowStepID != nil {
@@ -150,7 +222,8 @@ func (s *Service) updateWebAppTask(ctx context.Context, w http.ResponseWriter, r
writeWebAppError(w, http.StatusBadRequest, "invalid_request")
return
}
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated))
scope := dependencyRedactionScopeFromBinding(binding, []string{updated.ID})
writeWebAppJSON(w, r, http.StatusOK, webAppTaskFromSDK(*updated, scope))
}
type webAppMessageRequest struct {
@@ -163,7 +236,7 @@ func (s *Service) sendWebAppMessage(ctx context.Context, w http.ResponseWriter,
writeWebAppError(w, http.StatusNotFound, "not_found")
return
}
task, err := host.Tasks().Get(ctx, taskID)
task, err := host.fetchTaskForScopeCheck(ctx, taskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -206,7 +279,7 @@ func (s *Service) listWebAppWorkflows(ctx context.Context, w http.ResponseWriter
workflows = append(workflows, items...)
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil {
writeWebAppError(w, webAppProtocolStatus(err), webAppErrorCode(err))
return
@@ -246,7 +319,7 @@ func (s *Service) listWebAppWorkflowSteps(ctx context.Context, w http.ResponseWr
}
}
if binding.ScopeKind == instances.ScopeTask {
task, err := host.Tasks().Get(ctx, binding.TaskID)
task, err := host.fetchTaskForScopeCheck(ctx, binding.TaskID)
if err != nil || task == nil || task.WorkflowID != workflowID {
writeWebAppError(w, http.StatusNotFound, "not_found")
return
@@ -403,6 +476,8 @@ func webAppErrorCode(err error) string {
return "plugin_state_conflict"
case codes.Unimplemented:
return webAppRuntimeUnavailable
case codes.ResourceExhausted:
return "response_too_large"
default:
return webAppRuntimeUnavailable
}
@@ -0,0 +1,494 @@
package plugins
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/manifest"
"github.com/kandev/kandev/internal/plugins/webapp"
taskmodels "github.com/kandev/kandev/internal/task/models"
taskservice "github.com/kandev/kandev/internal/task/service"
wfmodels "github.com/kandev/kandev/internal/workflow/models"
"github.com/kandev/kandev/pkg/pluginsdk"
"github.com/stretchr/testify/require"
)
func TestWebAppTaskFromSDK_MapsDependencyFields(t *testing.T) {
task := pluginsdk.Task{
ID: "task-1", Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []pluginsdk.TaskDependencyRef{{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-1"}},
Blocks: []pluginsdk.TaskDependencyRef{{ID: "task-9", Title: "Dependent", State: "TODO", WorkspaceID: "ws-1"}},
DependsOnTruncated: true,
StartWhenUnblocked: true,
}
wire := webAppTaskFromSDK(task, dependencyRedactionScope{kind: instances.ScopeWorkspace, workspaceID: "ws-1"})
require.True(t, wire.Blocked)
require.Equal(t, taskservice.BlockedReasonPending, wire.BlockedReason)
require.Equal(t, []webAppTaskDependencyRef{{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending}}, wire.DependsOn)
require.Equal(t, []webAppTaskDependencyRef{{ID: "task-9", Title: "Dependent", State: "TODO"}}, wire.Blocks)
require.True(t, wire.DependsOnTruncated)
require.False(t, wire.BlocksTruncated)
require.True(t, wire.StartWhenUnblocked)
}
// TestWebAppTaskFromSDK_BlockedReasonKeyAlwaysPresentEvenWhenEmpty guards the
// presence rule for the not-blocked case, which none of the other dependency
// fixtures exercises (they all set Blocked: true). blocked_reason is "" on an
// unblocked task, so an omitempty tag would silently drop the key from the
// wire response for every unblocked task -- the majority case.
func TestWebAppTaskFromSDK_BlockedReasonKeyAlwaysPresentEvenWhenEmpty(t *testing.T) {
task := pluginsdk.Task{ID: "task-1", Blocked: false, BlockedReason: ""}
wire := webAppTaskFromSDK(task, dependencyRedactionScope{kind: instances.ScopeInstance})
raw, err := json.Marshal(wire)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
value, present := decoded["blocked_reason"]
require.True(t, present, "blocked_reason key must always be present, never omitted, even when empty")
require.Equal(t, "", value)
}
// TestWebAppTaskDependencyRefFromSDK_RedactedTitleAndStateKeysAlwaysPresent
// guards Review round 2's F-B: a redacted entry blanks title/state to "",
// which an omitempty tag would silently drop instead of serializing.
func TestWebAppTaskDependencyRefFromSDK_RedactedTitleAndStateKeysAlwaysPresent(t *testing.T) {
ref := pluginsdk.TaskDependencyRef{ID: "task-0", Title: "Predecessor", State: "TODO", WorkspaceID: "ws-other"}
out := webAppTaskDependencyRefFromSDK(ref, dependencyRedactionScope{kind: instances.ScopeWorkspace, workspaceID: "ws-1"})
raw, err := json.Marshal(out)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
title, titlePresent := decoded["title"]
require.True(t, titlePresent, "title key must always be present, never omitted, even when redacted to empty")
require.Equal(t, "", title)
state, statePresent := decoded["state"]
require.True(t, statePresent, "state key must always be present, never omitted, even when redacted to empty")
require.Equal(t, "", state)
}
func TestWebAppTaskDependencyRefFromSDK_RedactsOutOfScopeEdge(t *testing.T) {
ref := pluginsdk.TaskDependencyRef{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-other"}
out := webAppTaskDependencyRefFromSDK(ref, dependencyRedactionScope{kind: instances.ScopeWorkspace, workspaceID: "ws-1"})
require.Equal(t, "task-0", out.ID)
require.Empty(t, out.Title, "title is redacted for an edge end outside the caller's scoped workspace")
require.Empty(t, out.State, "state is redacted for an edge end outside the caller's scoped workspace")
require.Equal(t, taskservice.DependencyPending, out.Status, "status is not scope-sensitive and is never redacted")
}
func TestWebAppTaskDependencyRefFromSDK_InstanceScopeCallerNeverRedacts(t *testing.T) {
ref := pluginsdk.TaskDependencyRef{ID: "task-0", Title: "Predecessor", State: "TODO", WorkspaceID: "ws-other"}
out := webAppTaskDependencyRefFromSDK(ref, dependencyRedactionScope{kind: instances.ScopeInstance})
require.Equal(t, "Predecessor", out.Title)
require.Equal(t, "TODO", out.State)
}
func TestWebAppTaskDependencyRefsFromSDK_EmptyIsNeverNil(t *testing.T) {
out := webAppTaskDependencyRefsFromSDK(nil, dependencyRedactionScope{kind: instances.ScopeWorkspace, workspaceID: "ws-1"})
require.NotNil(t, out)
require.Empty(t, out)
}
func TestListWebAppTasks_DerivesDependenciesAfterScopeNarrowing(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
inScope := &taskmodels.Task{
ID: "task-in", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-1", RepositoryID: "repo-1"}},
}
outOfScope := &taskmodels.Task{
ID: "task-out", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-2", RepositoryID: "repo-2"}},
}
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {inScope, outOfScope}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-in": {Blocked: true, BlockedReason: taskservice.BlockedReasonPending},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeRepository, WorkspaceID: "ws-1", RepositoryID: "repo-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"task-in"}, d.tasks.dependencyViewsTasks, "dependency derivation runs only over tasks surviving the repository scope filter")
require.Contains(t, recorder.Body.String(), `"blocked":true`)
}
func TestListWebAppTasks_FanOutRefusalBecomesResponseTooLarge(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {task}}
d.tasks.dependencyViewsErr = taskservice.ErrDependencyFanOutExceeded
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.Contains(t, recorder.Body.String(), "response_too_large")
}
// TestGetWebAppTask_TaskScopeRedactsEveryEdgeEnd covers
// AC-PLUGINS-TASK-DEPS-003.8's task-scope rule: task scope admits no end,
// because an end is never the bound task itself. A task-scoped canvas may
// see that its task is blocked, but never the title of what blocks it --
// even when the end's workspace equals the bound one.
func TestGetWebAppTask_TaskScopeRedactsEveryEdgeEnd(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-1"}},
},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.NotContains(t, recorder.Body.String(), `"title":"Predecessor"`, "task scope admits no edge end, even one sharing the bound workspace")
require.Contains(t, recorder.Body.String(), `"id":"task-0"`, "the entry is kept, only its title and state are redacted")
}
// TestGetWebAppTask_WorkspaceScopeAdmitsEdgeEndInSameWorkspace covers
// AC-PLUGINS-TASK-DEPS-003.8's workspace-scope rule: workspace scope admits
// an end whose workspace is known equal to the bound workspace.
func TestGetWebAppTask_WorkspaceScopeAdmitsEdgeEndInSameWorkspace(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-1"}},
},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), `"title":"Predecessor"`, "an edge end sharing the bound workspace keeps its title")
}
// TestGetWebAppTask_WorkspaceScopeFailsClosedOnUnknownEdgeWorkspace covers
// AC-PLUGINS-TASK-DEPS-003.3: an end whose predicate inputs are unavailable
// (here, an edge end with no recorded workspace) is treated as not admitted.
func TestGetWebAppTask_WorkspaceScopeFailsClosedOnUnknownEdgeWorkspace(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
task := &taskmodels.Task{ID: "task-1", WorkspaceID: "ws-1"}
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": task}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-1": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{{ID: "task-0", Title: "Predecessor", State: "TODO", Status: taskservice.DependencyPending}},
},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.NotContains(t, recorder.Body.String(), `"title":"Predecessor"`, "an edge end with no recorded workspace fails closed, not open")
}
// TestListWebAppTasks_RepositoryScopeRedactsEdgeEndNotReturnedInSameResponse
// covers AC-PLUGINS-TASK-DEPS-003.8's repository/session rule: such scopes do
// not admit by workspace equality alone -- an end filtered out of this
// response by the repository scope stays redacted even though it shares the
// bound workspace.
func TestListWebAppTasks_RepositoryScopeRedactsEdgeEndNotReturnedInSameResponse(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
inScope := &taskmodels.Task{
ID: "task-in", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-1", RepositoryID: "repo-1"}},
}
outOfScope := &taskmodels.Task{
ID: "task-out", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-2", RepositoryID: "repo-2"}},
}
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {inScope, outOfScope}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-in": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{
{ID: "task-out", Title: "Same workspace, different repository", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-1"},
},
},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeRepository, WorkspaceID: "ws-1", RepositoryID: "repo-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.NotContains(t, recorder.Body.String(), `"title":"Same workspace, different repository"`,
"repository scope does not admit by workspace equality; task-out was filtered out of this response by the repository scope, so it is not a directly readable task here")
}
// TestListWebAppTasks_RepositoryScopeAdmitsEdgeEndReturnedInSameResponse
// covers the positive side of the same rule: when the edge's far end DOES
// survive the repository scope filter and is returned in the same response,
// it is a directly readable task and keeps its title/state.
func TestListWebAppTasks_RepositoryScopeAdmitsEdgeEndReturnedInSameResponse(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
dependent := &taskmodels.Task{
ID: "task-dependent", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-1", RepositoryID: "repo-1"}},
}
predecessor := &taskmodels.Task{
ID: "task-predecessor", WorkspaceID: "ws-1", CreatedAt: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC),
Repositories: []*taskmodels.TaskRepository{{ID: "tr-1", RepositoryID: "repo-1"}},
}
d.tasks.workspaces = []*taskmodels.Workspace{{ID: "ws-1"}}
d.tasks.tasksByWorkspace = map[string][]*taskmodels.Task{"ws-1": {dependent, predecessor}}
d.tasks.dependencyViews = map[string]taskservice.DependencyView{
"task-dependent": {
Blocked: true, BlockedReason: taskservice.BlockedReasonPending,
DependsOn: []taskservice.DependencyRef{
{ID: "task-predecessor", Title: "Same repository, in this response", State: "TODO", Status: taskservice.DependencyPending, WorkspaceID: "ws-1"},
},
},
}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeRepository, WorkspaceID: "ws-1", RepositoryID: "repo-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), `"title":"Same repository, in this response"`,
"task-predecessor survived the repository scope filter and is a directly readable task in this response, so its title is admitted")
}
// ── F1: a scope-check preflight that discards the task must never pay for
// dependency derivation, because it never serializes the task it fetched ──
func TestUpdateWebAppTask_ScopeCheckPreflightDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}, APIWrite: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1"}}
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "new title"}
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks", "api_write:tasks"},
}
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"title":"new title"}`))
recorder := httptest.NewRecorder()
svc.updateWebAppTask(context.Background(), recorder, req, d.host, binding, "task-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, 1, d.tasks.dependencyViewsCalls,
"derivation runs once, for the updated task actually serialized -- not a second time for the discarded scope-check preflight")
}
// TestUpdateWebAppTask_CombinedTitleAndStepPatchDerivesOnce covers the design's
// Placement section ("the PATCH route derives once ... one response, one
// derivation, on the object serialized") for a body that takes both the
// Update and the Move write branch: only Move's result is ever serialized, so
// the Update branch's write must not derive on the intermediate result nobody
// sees.
func TestUpdateWebAppTask_CombinedTitleAndStepPatchDerivesOnce(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}, APIWrite: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1"}}
d.taskWriter.updated = &taskmodels.Task{ID: "task-1", Title: "new title", WorkspaceID: "ws-1"}
d.taskWriter.moveResult = &TaskMoveResult{
Task: &taskmodels.Task{ID: "task-1", WorkflowStepID: "step-2", WorkspaceID: "ws-1"}, Transitioned: true,
}
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks", "api_write:tasks"},
}
req := httptest.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"title":"new title","workflow_step_id":"step-2"}`))
recorder := httptest.NewRecorder()
svc.updateWebAppTask(context.Background(), recorder, req, d.host, binding, "task-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, 1, d.taskWriter.updateCalls, "the update branch still writes the task fields")
require.Equal(t, 1, d.taskWriter.moveCalls, "the move branch still writes the step transition")
require.Equal(t, 1, d.tasks.dependencyViewsCalls,
"a body naming both a title and a workflow step takes both write branches, but only Move's result is serialized -- derivation must run exactly once, not once per write")
}
func TestSendWebAppMessage_ScopeCheckPreflightDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}, APIWrite: []string{"messages"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1"}}
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks", "api_write:messages"},
}
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{"text":"hello"}`))
recorder := httptest.NewRecorder()
svc.sendWebAppMessage(context.Background(), recorder, req, d.host, binding, "task-1")
require.Equal(t, http.StatusAccepted, recorder.Code, recorder.Body.String())
require.Equal(t, 0, d.tasks.dependencyViewsCalls)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestListWebAppWorkflows_TaskScopeCheckPreflightDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks", "workflows"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1"}}
d.workflows.workflows = map[string][]*taskmodels.Workflow{"ws-1": {{ID: "wf-1", WorkspaceID: "ws-1"}}}
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks", "api_read:workflows"},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
svc.listWebAppWorkflows(context.Background(), recorder, req, d.host, binding)
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, 0, d.tasks.dependencyViewsCalls)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestListWebAppWorkflowSteps_TaskScopeCheckPreflightDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks", "workflows"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1"}}
d.workflows.workflows = map[string][]*taskmodels.Workflow{"ws-1": {{ID: "wf-1", WorkspaceID: "ws-1"}}}
d.steps.steps = map[string][]*wfmodels.WorkflowStep{"wf-1": {{ID: "step-1", WorkflowID: "wf-1"}}}
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks", "api_read:workflows"},
}
req := httptest.NewRequest(http.MethodGet, "/", nil)
recorder := httptest.NewRecorder()
svc.listWebAppWorkflowSteps(context.Background(), recorder, req, d.host, binding, "wf-1")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, 0, d.tasks.dependencyViewsCalls)
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
// ── Review round 2, Finding #1/#2: a scope check that can discard the task
// must run before dependency derivation on the canvas single-task read and
// the list route's task-scope branch, matching the rule the non-task-scope
// branch already documents for itself ───────────────────────────────────
func TestGetWebAppTask_ScopeCheckDiscardDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-other"}}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks/task-1")
require.Equal(t, http.StatusNotFound, recorder.Code, recorder.Body.String())
require.Equal(t, 0, d.tasks.dependencyViewsCalls,
"a task the scope check discards is never serialized, so it must not pay for dependency derivation")
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
func TestListWebAppTasks_TaskScopeDiscardDoesNotDeriveDependencies(t *testing.T) {
d := newTestDataHost(manifest.Capabilities{APIRead: []string{"tasks"}})
d.tasks.tasksByID = map[string]*taskmodels.Task{"task-1": {ID: "task-1", WorkspaceID: "ws-other"}}
svc := &Service{taskData: d.tasks}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeTask, WorkspaceID: "ws-1", TaskID: "task-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), `"items":[]`)
require.Equal(t, 0, d.tasks.dependencyViewsCalls,
"a task the scope check discards is never serialized, so the list route's task-scope branch must not pay for dependency derivation")
require.Equal(t, 0, d.tasks.dependencyViewsBoundedCalls)
}
// ── listWebAppTasks's non-task-scope branch must guard a nil task data
// source the same way the gRPC taskReader.List path already does, rather
// than panicking inside resolveWorkspaceIDs ─────────────────────────────
func TestListWebAppTasks_NonTaskScopeReturnsUnimplementedWhenTaskDataSourceIsNil(t *testing.T) {
svc := &Service{}
binding := webapp.CapabilityBinding{
ScopeKind: instances.ScopeWorkspace, WorkspaceID: "ws-1",
Permissions: []string{"api_read:tasks"},
}
recorder := httptest.NewRecorder()
svc.handleWebAppProtocol(recorder, httptest.NewRequest(http.MethodGet, "/", nil), "", binding, "v1/data/tasks")
require.Equal(t, http.StatusNotImplemented, recorder.Code, recorder.Body.String())
}
@@ -4,6 +4,8 @@ import (
"encoding/json"
"net/http"
"github.com/kandev/kandev/internal/plugins/instances"
"github.com/kandev/kandev/internal/plugins/webapp"
"github.com/kandev/kandev/pkg/pluginsdk"
)
@@ -85,6 +87,86 @@ type webAppPullRequest struct {
AuthorLogin string `json:"author_login"`
}
// Title and State are never omitempty: a redacted entry blanks them to ""
// rather than dropping the key, per AC-PLUGINS-TASK-DEPS-003.2. Status stays
// omitempty because a blocks entry legitimately carries none.
type webAppTaskDependencyRef struct {
ID string `json:"id"`
Title string `json:"title"`
State string `json:"state"`
Status string `json:"status,omitempty"`
}
// dependencyRedactionScope carries what AC-PLUGINS-TASK-DEPS-003.8's
// admission test needs to decide, for one canvas response, whether an edge
// end may be disclosed as a directly readable task. It is built once per
// response from the canvas binding plus the set of task IDs that response
// already serializes as directly readable tasks (directlyReadableIDs) --
// never from an additional read.
type dependencyRedactionScope struct {
kind string
workspaceID string
directlyReadableIDs map[string]struct{}
}
// dependencyRedactionScopeFromBinding builds the scope for one canvas
// response. directlyReadableIDs must list every task ID that response
// serializes as a directly readable task (e.g. the single task a GET
// returns, or the page a list route returns after its own scope narrowing);
// it is ignored for scope kinds whose admission test doesn't consult it.
func dependencyRedactionScopeFromBinding(binding webapp.CapabilityBinding, directlyReadableIDs []string) dependencyRedactionScope {
ids := make(map[string]struct{}, len(directlyReadableIDs))
for _, id := range directlyReadableIDs {
ids[id] = struct{}{}
}
return dependencyRedactionScope{kind: binding.ScopeKind, workspaceID: binding.WorkspaceID, directlyReadableIDs: ids}
}
// admits reports whether ref may be disclosed as a directly readable task
// under s, resolving by scope kind exactly as AC-PLUGINS-TASK-DEPS-003.8
// requires: instance scope admits every end; workspace scope admits an end
// whose workspace is known equal to the bound workspace; task scope admits
// no end; repository and session scope admit an end only when it is also
// returned in the same response as a directly readable task. An end whose
// predicate inputs are unavailable (e.g. an empty workspace on either side)
// is never admitted, per AC-PLUGINS-TASK-DEPS-003.3's fail-closed rule.
func (s dependencyRedactionScope) admits(ref pluginsdk.TaskDependencyRef) bool {
switch s.kind {
case instances.ScopeInstance:
return true
case instances.ScopeWorkspace:
return s.workspaceID != "" && ref.WorkspaceID != "" && ref.WorkspaceID == s.workspaceID
case instances.ScopeRepository, instances.ScopeSession:
_, ok := s.directlyReadableIDs[ref.ID]
return ok
default:
// instances.ScopeTask, and any unrecognized scope kind: admit nothing.
return false
}
}
// webAppTaskDependencyRefFromSDK redacts Title and State when scope does not
// admit ref (REQ-PLUGINS-TASK-DEPS-003).
func webAppTaskDependencyRefFromSDK(ref pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) webAppTaskDependencyRef {
out := webAppTaskDependencyRef{ID: ref.ID, Title: ref.Title, State: ref.State, Status: ref.Status}
if !scope.admits(ref) {
out.Title = ""
out.State = ""
}
return out
}
// webAppTaskDependencyRefsFromSDK never returns nil, even for an empty or
// nil input: depends_on/blocks always serialize as [], never as null or an
// absent field.
func webAppTaskDependencyRefsFromSDK(refs []pluginsdk.TaskDependencyRef, scope dependencyRedactionScope) []webAppTaskDependencyRef {
out := make([]webAppTaskDependencyRef, len(refs))
for i, ref := range refs {
out[i] = webAppTaskDependencyRefFromSDK(ref, scope)
}
return out
}
type webAppTask struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
@@ -115,9 +197,25 @@ type webAppTask struct {
QueuedAt *string `json:"queued_at,omitempty"`
ProjectID string `json:"project_id,omitempty"`
ExternalID string `json:"external_id,omitempty"`
Blocked bool `json:"blocked"`
// BlockedReason never carries omitempty: it is "" on an unblocked task, and
// the key must stay present so absence is never a third state alongside the
// other seven projection fields.
BlockedReason string `json:"blocked_reason"`
// DependsOn/Blocks never carry omitempty: an empty dependency graph still
// reports [], not an absent or null field.
DependsOn []webAppTaskDependencyRef `json:"depends_on"`
Blocks []webAppTaskDependencyRef `json:"blocks"`
DependsOnTruncated bool `json:"depends_on_truncated"`
BlocksTruncated bool `json:"blocks_truncated"`
StartWhenUnblocked bool `json:"start_when_unblocked"`
}
func webAppTaskFromSDK(task pluginsdk.Task) webAppTask {
// webAppTaskFromSDK converts task to its canvas wire form. scope is the
// requesting canvas response's dependency redaction scope, used only to
// redact out-of-scope dependency edge ends.
func webAppTaskFromSDK(task pluginsdk.Task, scope dependencyRedactionScope) webAppTask {
result := webAppTask{
ID: task.ID,
WorkspaceID: task.WorkspaceID,
@@ -146,6 +244,14 @@ func webAppTaskFromSDK(task pluginsdk.Task) webAppTask {
QueuedAt: task.QueuedAt,
ProjectID: task.ProjectID,
ExternalID: task.ExternalID,
Blocked: task.Blocked,
BlockedReason: task.BlockedReason,
DependsOn: webAppTaskDependencyRefsFromSDK(task.DependsOn, scope),
Blocks: webAppTaskDependencyRefsFromSDK(task.Blocks, scope),
DependsOnTruncated: task.DependsOnTruncated,
BlocksTruncated: task.BlocksTruncated,
StartWhenUnblocked: task.StartWhenUnblocked,
}
if len(task.Repositories) > 0 {
result.Repositories = make([]webAppTaskRepository, len(task.Repositories))
@@ -3796,20 +3796,23 @@ func (r *Repository) GetTasksByIDs(ctx context.Context, ids []string) ([]*models
if len(ids) == 0 {
return nil, nil
}
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
var tasks []*models.Task
for _, chunk := range chunkIDs(ids, sqliteMaxHostParams) {
placeholders, args := buildInPlaceholders(chunk)
query := fmt.Sprintf(`SELECT %s FROM tasks t WHERE t.id IN (%s)`,
taskSelectColumns("t"), placeholders)
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
chunkTasks, err := r.scanTasks(rows)
_ = rows.Close()
if err != nil {
return nil, err
}
tasks = append(tasks, chunkTasks...)
}
query := fmt.Sprintf(`SELECT %s FROM tasks t WHERE t.id IN (%s)`,
taskSelectColumns("t"), strings.Join(placeholders, ","))
rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
return r.scanTasks(rows)
return tasks, nil
}
// ArchiveTask sets the archived_at timestamp on a task
@@ -8,6 +8,7 @@ package sqlite
import (
"context"
"errors"
"fmt"
"testing"
"time"
@@ -358,3 +359,27 @@ func TestGetTasksByIDsFetchesRequestedRowsAndSkipsUnknown(t *testing.T) {
t.Errorf("GetTasksByIDs(nil) = %v, want empty", taskIDs(empty))
}
}
func TestGetTasksByIDsChunksAcrossHostParamLimit(t *testing.T) {
repo := seedHierarchy(t)
ctx := context.Background()
// SQLite's compiled-in SQLITE_MAX_VARIABLE_NUMBER on this build is
// 32766; pad well past it (and past sqliteMaxHostParams's much lower
// safety margin) so an unchunked IN-clause query would error, with the
// real id placed last so it lands in the final chunk.
const padCount = 33000
ids := make([]string, 0, padCount+1)
for i := 0; i < padCount; i++ {
ids = append(ids, fmt.Sprintf("padding-task-%d", i))
}
ids = append(ids, "task-child-a")
got, err := repo.GetTasksByIDs(ctx, ids)
if err != nil {
t.Fatalf("GetTasksByIDs with %d ids: %v", len(ids), err)
}
if len(got) != 1 || got[0].ID != "task-child-a" {
t.Fatalf("GetTasksByIDs = %v, want exactly [task-child-a]", taskIDs(got))
}
}
@@ -64,18 +64,21 @@ const (
// DependencyRef is one end of a dependency edge, carrying enough detail for the
// dependency chip to render without fetching each related task.
type DependencyRef struct {
ID string `json:"id"`
Title string `json:"title"`
State v1.TaskState `json:"state"`
Status string `json:"status,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
State v1.TaskState `json:"state"`
Status string `json:"status,omitempty"`
WorkspaceID string `json:"-"`
}
// DependencyView is the derived dependency state for one task.
type DependencyView struct {
Blocked bool
BlockedReason string
DependsOn []DependencyRef
Blocks []DependencyRef
Blocked bool
BlockedReason string
DependsOn []DependencyRef
Blocks []DependencyRef
DependsOnTruncated bool
BlocksTruncated bool
}
// CycleError is returned when a proposed edge would close a cycle. Path lists
@@ -448,14 +451,48 @@ func buildCyclePath(parent map[string]string, taskID, dependsOnTaskID string) []
return path
}
// maxDependencyFanOut is the maximum number of distinct edge ends one
// derivation batch may resolve. It bounds a paginated or preview flow's query
// cost independently of the per-task display limit: many small predecessor or
// dependent lists across a page can still name thousands of distinct tasks.
const maxDependencyFanOut = 4096
// ErrDependencyFanOutExceeded is returned by BuildDependencyViewsBounded when
// a batch's distinct edge-end count exceeds maxDependencyFanOut. This is a
// refusal, not a derivation failure: it must never produce the withheld
// verdict, and callers report it as an oversized response
// (response_too_large / ResourceExhausted) before serializing any task, not
// as "blocked: true, blocked_reason: unknown".
var ErrDependencyFanOutExceeded = errors.New("dependency fan-out exceeds maximum")
// BuildDependencyViews returns derived dependency state for a batch of tasks.
//
// Batched deliberately: the Kanban board reads a whole workflow at once, so a
// per-task query would add one round trip per card to every board load.
func (s *Service) BuildDependencyViews(ctx context.Context, tasks []*models.Task) map[string]DependencyView {
// The fan-out maximum is never enforced here: this entry point backs flows
// that return exactly one task and the Kanban board, which paginates tasks
// rather than distinct edge ends. BuildDependencyViewsBounded is for
// callers that must enforce it.
views, _ := s.buildDependencyViews(ctx, tasks, false)
return views
}
// BuildDependencyViewsBounded is BuildDependencyViews for a paginated or
// preview flow that can return more than one task: it refuses with
// ErrDependencyFanOutExceeded when the batch's distinct edge-end count would
// exceed maxDependencyFanOut, checked before the expensive edge-end
// resolution read and before any task is serialized.
func (s *Service) BuildDependencyViewsBounded(ctx context.Context, tasks []*models.Task) (map[string]DependencyView, error) {
return s.buildDependencyViews(ctx, tasks, true)
}
func (s *Service) buildDependencyViews(
ctx context.Context, tasks []*models.Task, enforceFanOut bool,
) (map[string]DependencyView, error) {
out := make(map[string]DependencyView, len(tasks))
if s.blockers == nil || len(tasks) == 0 {
return out
if len(tasks) == 0 {
return out, nil
}
ids := make([]string, 0, len(tasks))
for _, t := range tasks {
@@ -463,33 +500,94 @@ func (s *Service) BuildDependencyViews(ctx context.Context, tasks []*models.Task
ids = append(ids, t.ID)
}
}
if s.blockers == nil {
// No dependency store wired: unlike DependencyGate (where this means no
// edges can exist), the projection reports the withheld verdict here,
// because an omitted map entry reads as the zero-value DependencyView
// (Blocked: false) — an indistinguishable, silent "not blocked".
return withheldDependencyViews(ids), nil
}
predecessors, err := s.blockers.ListBlockersForTasks(ctx, ids)
if err != nil {
s.logger.Warn("failed to load task dependencies", zap.Error(err))
// Fail closed: a task whose edges cannot be read reports blocked with
// an unknown reason rather than silently reporting "not blocked".
for _, id := range ids {
out[id] = DependencyView{Blocked: true, BlockedReason: BlockedReasonUnknown}
}
return out
return withheldDependencyViews(ids), nil
}
dependents, err := s.blockers.ListDependentsForTasks(ctx, ids)
if err != nil {
s.logger.Warn("failed to load task dependents", zap.Error(err))
dependents = map[string][]string{}
return withheldDependencyViews(ids), nil
}
// Cut the dependent direction to the display limit BEFORE resolution: no
// verdict reads a dependent's far row (dependent entries carry no
// resolution status), and the edge order is already decidable from the
// edge rows alone, so there is no reason to resolve title/state for an
// end that would only be truncated away afterward.
dependents, blocksTruncated := cutDependentsForResolution(dependents)
if enforceFanOut && distinctEdgeEndCount(predecessors, dependents) > maxDependencyFanOut {
return nil, ErrDependencyFanOutExceeded
}
refs, err := s.resolveDependencyRefs(ctx, predecessors, dependents)
if err != nil {
s.logger.Warn("failed to resolve dependency edge ends", zap.Error(err))
return withheldDependencyViews(ids), nil
}
refs := s.resolveDependencyRefs(ctx, predecessors, dependents)
for _, id := range ids {
out[id] = buildDependencyView(refs, predecessors[id], dependents[id])
out[id] = buildDependencyView(refs, predecessors[id], dependents[id], blocksTruncated[id])
}
return out, nil
}
// distinctEdgeEndCount returns the number of distinct task ids named on
// either side of any edge in the batch, mirroring the union resolveDependencyRefs
// resolves in one query.
func distinctEdgeEndCount(predecessors, dependents map[string][]string) int {
seen := map[string]struct{}{}
for _, group := range []map[string][]string{predecessors, dependents} {
for _, ids := range group {
for _, id := range ids {
seen[id] = struct{}{}
}
}
}
return len(seen)
}
// cutDependentsForResolution caps each task's dependent id list to
// maxTaskDependencyCount, preserving edge order, and reports which tasks were
// cut so the truncation flag survives to the derived view.
func cutDependentsForResolution(dependents map[string][]string) (map[string][]string, map[string]bool) {
truncated := make(map[string]bool, len(dependents))
cut := make(map[string][]string, len(dependents))
for id, depIDs := range dependents {
if len(depIDs) > maxTaskDependencyCount {
cut[id] = depIDs[:maxTaskDependencyCount]
truncated[id] = true
} else {
cut[id] = depIDs
}
}
return cut, truncated
}
// withheldDependencyViews reports the fail-closed verdict for every id: a
// derivation step could not be completed, so every task in the batch is
// blocked with an unknown reason rather than silently reporting "not
// blocked" for the ones an empty map would otherwise omit.
func withheldDependencyViews(ids []string) map[string]DependencyView {
out := make(map[string]DependencyView, len(ids))
for _, id := range ids {
out[id] = DependencyView{Blocked: true, BlockedReason: BlockedReasonUnknown}
}
return out
}
// resolveDependencyRefs loads title/state for every task named on either side
// of any edge in the batch, in one query.
// of any edge in the batch, in one query. A read failure here fails the whole
// batch closed: a derivation step that cannot complete blocks every task in
// the batch, not just the tasks whose edges could not resolve.
func (s *Service) resolveDependencyRefs(
ctx context.Context, predecessors, dependents map[string][]string,
) map[string]DependencyRef {
) (map[string]DependencyRef, error) {
seen := map[string]struct{}{}
for _, group := range []map[string][]string{predecessors, dependents} {
for _, ids := range group {
@@ -505,7 +603,10 @@ func (s *Service) resolveDependencyRefs(
}
// One batched read: a board payload references every edge on every card, so
// a per-edge query turned one board load into N round trips.
found, batchErr := s.tasks.GetTasksByIDs(ctx, ids)
found, err := s.tasks.GetTasksByIDs(ctx, ids)
if err != nil {
return nil, err
}
byID := make(map[string]*models.Task, len(found))
for _, task := range found {
if task != nil {
@@ -515,33 +616,33 @@ func (s *Service) resolveDependencyRefs(
for _, id := range ids {
task := byID[id]
if task == nil {
// Absent row (deleted predecessor, dangling edge) versus a failed
// read: the former must not block forever, the latter must not open
// the gate. Mark them differently so buildDependencyView can tell.
status := dependencyMissing
if batchErr != nil {
status = DependencyPending
}
refs[id] = DependencyRef{ID: id, Status: status}
// Absent row: a deleted predecessor/dependent, i.e. a dangling edge
// left behind by a failed cleanup. It must not block forever.
refs[id] = DependencyRef{ID: id, Status: dependencyMissing}
continue
}
refs[id] = DependencyRef{
ID: id,
Title: task.Title,
State: task.State,
Status: DependencyStatusForTask(task),
ID: id,
Title: task.Title,
State: task.State,
Status: DependencyStatusForTask(task),
WorkspaceID: task.WorkspaceID,
}
}
return refs
return refs, nil
}
// buildDependencyView derives blocked/reason plus both edge lists for one task.
// buildDependencyView derives blocked/reason plus both edge lists for one
// task. dependentsTruncated carries forward the pre-resolution cut already
// applied to dependentIDs, so the flag survives even when the dangling-edge
// drop below leaves the displayed list shorter than the limit.
func buildDependencyView(
refs map[string]DependencyRef, predecessorIDs, dependentIDs []string,
refs map[string]DependencyRef, predecessorIDs, dependentIDs []string, dependentsTruncated bool,
) DependencyView {
view := DependencyView{
DependsOn: make([]DependencyRef, 0, len(predecessorIDs)),
Blocks: make([]DependencyRef, 0, len(dependentIDs)),
DependsOn: make([]DependencyRef, 0, len(predecessorIDs)),
Blocks: make([]DependencyRef, 0, len(dependentIDs)),
BlocksTruncated: dependentsTruncated,
}
tally := dependencyTally{}
for _, id := range predecessorIDs {
@@ -554,7 +655,15 @@ func buildDependencyView(
// failed cleanup cannot block a dependent forever.
continue
}
view.DependsOn = append(view.DependsOn, ref)
// The predecessor direction is resolved in full so the verdict can
// account for every predecessor, but the displayed list is cut to the
// same limit as the dependent direction, keeping the first entries in
// edge order.
if len(view.DependsOn) < maxTaskDependencyCount {
view.DependsOn = append(view.DependsOn, ref)
} else {
view.DependsOnTruncated = true
}
tally.add(ref.Status)
}
for _, id := range dependentIDs {
@@ -562,6 +671,12 @@ func buildDependencyView(
if ref.ID == "" {
ref = DependencyRef{ID: id, Status: DependencyPending}
}
if ref.Status == dependencyMissing {
// Dangling edge to a deleted task: dropped from both directions, not
// just the predecessor side, so a removed dependent cannot leave a
// stale entry in the board's blocks list.
continue
}
view.Blocks = append(view.Blocks, ref)
}
view.Blocked, view.BlockedReason = tally.verdict()
@@ -3,6 +3,7 @@ package service
import (
"context"
"errors"
"fmt"
"strings"
"sync"
"testing"
@@ -19,7 +20,8 @@ import (
// asserting the happy path.
type errBlockerRepo struct {
*mockBlockerRepo
failList bool
failList bool
failDependents bool
}
func (e *errBlockerRepo) ListTaskBlockers(ctx context.Context, taskID string) ([]*orchmodels.TaskBlocker, error) {
@@ -36,6 +38,24 @@ func (e *errBlockerRepo) ListBlockersForTasks(ctx context.Context, ids []string)
return e.mockBlockerRepo.ListBlockersForTasks(ctx, ids)
}
func (e *errBlockerRepo) ListDependentsForTasks(ctx context.Context, ids []string) (map[string][]string, error) {
if e.failDependents {
return nil, errors.New("boom")
}
return e.mockBlockerRepo.ListDependentsForTasks(ctx, ids)
}
// errTaskRepoOnGetByIDs wraps a working task repository and fails only the
// edge-end resolution read, so the derivation's fail-closed handling of that
// path can be proven without breaking any other repository method it needs.
type errTaskRepoOnGetByIDs struct {
taskrepo.TaskRepository
}
func (r *errTaskRepoOnGetByIDs) GetTasksByIDs(ctx context.Context, ids []string) ([]*models.Task, error) {
return nil, errors.New("boom")
}
func setDependencyState(t *testing.T, svc *Service, taskID string, state v1.TaskState) {
t.Helper()
task, err := svc.tasks.GetTask(context.Background(), taskID)
@@ -161,6 +181,14 @@ func TestBuildDependencyViews_ReportsBothDirections(t *testing.T) {
if !view.Blocked || view.BlockedReason != BlockedReasonPending {
t.Errorf("blocked=%v reason=%q; want true/pending", view.Blocked, view.BlockedReason)
}
// The far end's workspace id must survive resolution: canvas scope
// redaction decides admission from it without any extra read.
if view.DependsOn[0].WorkspaceID != a.WorkspaceID {
t.Errorf("depends_on[0].WorkspaceID = %q, want %q", view.DependsOn[0].WorkspaceID, a.WorkspaceID)
}
if view.Blocks[0].WorkspaceID != c.WorkspaceID {
t.Errorf("blocks[0].WorkspaceID = %q, want %q", view.Blocks[0].WorkspaceID, c.WorkspaceID)
}
}
// BuildDependencyViews must fail closed too: the board reads dependency state
@@ -177,6 +205,63 @@ func TestBuildDependencyViews_FailsClosedOnReadError(t *testing.T) {
}
}
// A nil blocker repository must fail closed too. Today's empty-map return
// means a caller reading a missing key sees the zero-value DependencyView
// (Blocked: false) — indistinguishable from "no edges" — which is exactly the
// silent "not blocked" the fail-closed contract exists to rule out.
func TestBuildDependencyViews_FailsClosedWhenRepositoryUnconfigured(t *testing.T) {
svc, _ := setupOfficeTest(t)
task := mustSeedTask(t, svc, "A")
views := svc.BuildDependencyViews(context.Background(), []*models.Task{task})
view, ok := views[task.ID]
if !ok {
t.Fatalf("BuildDependencyViews omitted %s entirely; a missing key reads as unblocked", task.ID)
}
if !view.Blocked || view.BlockedReason != BlockedReasonUnknown {
t.Errorf("no repository configured: blocked=%v reason=%q; want true/unknown", view.Blocked, view.BlockedReason)
}
}
// A dependent-direction read failure must fail closed exactly like the
// predecessor-direction failure already covered above.
func TestBuildDependencyViews_FailsClosedOnDependentReadError(t *testing.T) {
svc, _ := setupOfficeTest(t)
task := mustSeedTask(t, svc, "A")
svc.SetBlockerRepository(&errBlockerRepo{mockBlockerRepo: &mockBlockerRepo{}, failDependents: true})
views := svc.BuildDependencyViews(context.Background(), []*models.Task{task})
view := views[task.ID]
if !view.Blocked || view.BlockedReason != BlockedReasonUnknown {
t.Errorf("dependent read failure: blocked=%v reason=%q; want true/unknown", view.Blocked, view.BlockedReason)
}
}
// A failure resolving edge-end refs (title/state lookup) must fail the whole
// batch closed too: it fails the derivation for every task in the batch, not
// just the tasks with edges.
func TestBuildDependencyViews_FailsClosedOnEdgeEndResolutionError(t *testing.T) {
svc, _ := setupOfficeTest(t)
repo := &mockBlockerRepo{}
svc.SetBlockerRepository(repo)
ctx := context.Background()
a := mustSeedTask(t, svc, "A")
b := mustSeedTask(t, svc, "B")
if err := svc.AddDependency(ctx, b.ID, a.ID); err != nil {
t.Fatalf("AddDependency: %v", err)
}
svc.tasks = &errTaskRepoOnGetByIDs{TaskRepository: svc.tasks}
views := svc.BuildDependencyViews(ctx, []*models.Task{b})
view := views[b.ID]
if !view.Blocked || view.BlockedReason != BlockedReasonUnknown {
t.Errorf("edge-end resolution failure: blocked=%v reason=%q; want true/unknown", view.Blocked, view.BlockedReason)
}
if len(view.DependsOn) != 0 || len(view.Blocks) != 0 {
t.Errorf("edge-end resolution failure: depends_on=%+v blocks=%+v; want both empty", view.DependsOn, view.Blocks)
}
}
func TestAddDependency_RejectsSelfEdge(t *testing.T) {
svc, _ := setupOfficeTest(t)
svc.SetBlockerRepository(&mockBlockerRepo{})
@@ -365,7 +450,9 @@ func TestDependencyGateIgnoresDanglingEdgeButFailsClosedOnError(t *testing.T) {
}
// The derived projection must drop a dangling edge too, so the board does not
// render a blocked badge for a predecessor that no longer exists.
// render a blocked badge for a predecessor that no longer exists. Coverage
// spans both directions: a removed predecessor must vanish from depends_on,
// and a removed dependent must vanish from blocks.
func TestBuildDependencyViewsDropsDanglingEdges(t *testing.T) {
svc, _ := setupOfficeTest(t)
repo := &mockBlockerRepo{}
@@ -377,6 +464,13 @@ func TestBuildDependencyViewsDropsDanglingEdges(t *testing.T) {
}); err != nil {
t.Fatalf("seed dangling edge: %v", err)
}
// The reverse direction: some other task that no longer exists once
// depended on "dependent", so "dependent" would list it under Blocks.
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
TaskID: "deleted-dependent-id", BlockerTaskID: dependent.ID,
}); err != nil {
t.Fatalf("seed dangling dependent edge: %v", err)
}
view := svc.BuildDependencyViews(ctx, []*models.Task{dependent})[dependent.ID]
if view.Blocked {
@@ -385,6 +479,188 @@ func TestBuildDependencyViewsDropsDanglingEdges(t *testing.T) {
if len(view.DependsOn) != 0 {
t.Errorf("depends_on = %+v; a dangling edge must not be reported", view.DependsOn)
}
if len(view.Blocks) != 0 {
t.Errorf("blocks = %+v; a dangling dependent edge must not be reported", view.Blocks)
}
}
// seedIndexedTask creates a lightweight task directly through the repository,
// bypassing the full CreateTask flow so a large fixture stays fast.
func seedIndexedTask(t *testing.T, svc *Service, id string) {
t.Helper()
if err := svc.tasks.CreateTask(context.Background(), &models.Task{
ID: id, WorkspaceID: "ws-1", Title: id, State: v1.TaskStateCompleted,
}); err != nil {
t.Fatalf("seed task %s: %v", id, err)
}
}
// The predecessor direction is resolved in full so the verdict can account for
// every predecessor, but the displayed list is cut to 512 with the truncation
// flag set, keeping the first entries in edge order.
func TestBuildDependencyViews_DependsOnListTruncatesAt512(t *testing.T) {
svc, _ := setupOfficeTest(t)
repo := &mockBlockerRepo{}
svc.SetBlockerRepository(repo)
ctx := context.Background()
dependent := mustSeedTask(t, svc, "Dependent")
const total = maxTaskDependencyCount + 1
for i := 0; i < total; i++ {
id := fmt.Sprintf("pred-%04d", i)
seedIndexedTask(t, svc, id)
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
TaskID: dependent.ID, BlockerTaskID: id,
}); err != nil {
t.Fatalf("seed predecessor edge %s: %v", id, err)
}
}
view := svc.BuildDependencyViews(ctx, []*models.Task{dependent})[dependent.ID]
if len(view.DependsOn) != maxTaskDependencyCount {
t.Fatalf("depends_on length = %d, want %d", len(view.DependsOn), maxTaskDependencyCount)
}
if !view.DependsOnTruncated {
t.Error("depends_on_truncated = false, want true")
}
if view.BlocksTruncated {
t.Error("blocks_truncated = true, want false (no dependent edges seeded)")
}
for i, ref := range view.DependsOn {
want := fmt.Sprintf("pred-%04d", i)
if ref.ID != want {
t.Fatalf("depends_on[%d] = %s, want %s (first entries in edge order)", i, ref.ID, want)
}
}
// Every predecessor resolves (all completed), so the extra, un-shown
// predecessor still counts toward the verdict: none of them is pending or
// failed, so the task should not be blocked despite the truncation.
if view.Blocked {
t.Errorf("blocked=%v reason=%q; every predecessor (shown or not) resolved", view.Blocked, view.BlockedReason)
}
// The predecessor beyond the displayed limit still participates in the
// verdict. Changing only that omitted predecessor must make the task block,
// while the displayed list stays capped and unchanged.
omittedID := fmt.Sprintf("pred-%04d", total-1)
for _, tc := range []struct {
name string
state v1.TaskState
reason string
}{
{name: "pending", state: v1.TaskStateInProgress, reason: BlockedReasonPending},
{name: "failed", state: v1.TaskStateFailed, reason: BlockedReasonFailed},
} {
t.Run(tc.name, func(t *testing.T) {
setDependencyState(t, svc, omittedID, tc.state)
got := svc.BuildDependencyViews(ctx, []*models.Task{dependent})[dependent.ID]
if len(got.DependsOn) != maxTaskDependencyCount {
t.Fatalf("depends_on length = %d, want %d", len(got.DependsOn), maxTaskDependencyCount)
}
if !got.DependsOnTruncated {
t.Error("depends_on_truncated = false, want true")
}
for i, ref := range got.DependsOn {
if ref.ID != view.DependsOn[i].ID {
t.Errorf("depends_on[%d] = %s, want %s", i, ref.ID, view.DependsOn[i].ID)
}
}
if !got.Blocked || got.BlockedReason != tc.reason {
t.Errorf("blocked=%v reason=%q; omitted %s predecessor should block with %q", got.Blocked, got.BlockedReason, tc.state, tc.reason)
}
})
}
}
// The dependent direction is cut to 512 BEFORE resolution: only the first 512
// dependent ids, in edge order, are even looked up.
func TestBuildDependencyViews_BlocksListTruncatesAt512(t *testing.T) {
svc, _ := setupOfficeTest(t)
repo := &mockBlockerRepo{}
svc.SetBlockerRepository(repo)
ctx := context.Background()
predecessor := mustSeedTask(t, svc, "Predecessor")
const total = maxTaskDependencyCount + 1
for i := 0; i < total; i++ {
id := fmt.Sprintf("dep-%04d", i)
seedIndexedTask(t, svc, id)
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
TaskID: id, BlockerTaskID: predecessor.ID,
}); err != nil {
t.Fatalf("seed dependent edge %s: %v", id, err)
}
}
view := svc.BuildDependencyViews(ctx, []*models.Task{predecessor})[predecessor.ID]
if len(view.Blocks) != maxTaskDependencyCount {
t.Fatalf("blocks length = %d, want %d", len(view.Blocks), maxTaskDependencyCount)
}
if !view.BlocksTruncated {
t.Error("blocks_truncated = false, want true")
}
if view.DependsOnTruncated {
t.Error("depends_on_truncated = true, want false (no predecessor edges seeded)")
}
for i, ref := range view.Blocks {
want := fmt.Sprintf("dep-%04d", i)
if ref.ID != want {
t.Fatalf("blocks[%d] = %s, want %s (first entries in edge order)", i, ref.ID, want)
}
}
}
// The bounded entry point refuses before the expensive edge-end resolution
// read when a batch's distinct edge-end count would exceed the maximum,
// rather than degrading to the withheld verdict.
func TestBuildDependencyViewsBounded_RefusesAboveFanOutMaximum(t *testing.T) {
svc, _ := setupOfficeTest(t)
repo := &mockBlockerRepo{}
svc.SetBlockerRepository(repo)
ctx := context.Background()
dependent := mustSeedTask(t, svc, "Dependent")
const total = maxDependencyFanOut + 1
for i := 0; i < total; i++ {
id := fmt.Sprintf("pred-fanout-%05d", i)
if err := repo.CreateTaskBlocker(ctx, &orchmodels.TaskBlocker{
TaskID: dependent.ID, BlockerTaskID: id,
}); err != nil {
t.Fatalf("seed predecessor edge %s: %v", id, err)
}
}
// Fail resolution if it is ever reached, proving the refusal happens
// before edge-end resolution and before any task is serialized.
svc.tasks = &errTaskRepoOnGetByIDs{TaskRepository: svc.tasks}
views, err := svc.BuildDependencyViewsBounded(ctx, []*models.Task{dependent})
if !errors.Is(err, ErrDependencyFanOutExceeded) {
t.Fatalf("err = %v, want ErrDependencyFanOutExceeded", err)
}
if views != nil {
t.Errorf("views = %+v, want nil on refusal", views)
}
}
// Under the fan-out maximum, the bounded entry point derives normally.
func TestBuildDependencyViewsBounded_AllowsUnderFanOutMaximum(t *testing.T) {
svc, _ := setupOfficeTest(t)
svc.SetBlockerRepository(&mockBlockerRepo{})
ctx := context.Background()
a := mustSeedTask(t, svc, "A")
b := mustSeedTask(t, svc, "B")
if err := svc.AddDependency(ctx, b.ID, a.ID); err != nil {
t.Fatalf("AddDependency: %v", err)
}
views, err := svc.BuildDependencyViewsBounded(ctx, []*models.Task{b})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
view := views[b.ID]
if len(view.DependsOn) != 1 || view.DependsOn[0].ID != a.ID {
t.Errorf("depends_on = %+v; want [%s]", view.DependsOn, a.ID)
}
}
// An edge mutation must publish the recomputed projection, not a bare
+83
View File
@@ -132,6 +132,36 @@ type Task struct {
QueuedAt *string
ProjectID string
ExternalID string
// Dependency projection derived from task_blockers. Blocked and
// BlockedReason report the task's own gate; DependsOn is what it is
// waiting on and Blocks is what is waiting on it. A read that cannot
// resolve the dependency graph reports Blocked=true,
// BlockedReason="unknown" and empty lists rather than a false
// "unblocked".
Blocked bool
BlockedReason string // "pending" | "failed" | "unknown" | ""
DependsOn []TaskDependencyRef
Blocks []TaskDependencyRef
DependsOnTruncated bool
BlocksTruncated bool
// StartWhenUnblocked is always false when BlockedReason is "unknown",
// regardless of the underlying stored configuration.
StartWhenUnblocked bool
}
// TaskDependencyRef is one edge end in a task's dependency projection: a
// predecessor entry in DependsOn, or a dependent entry in Blocks.
type TaskDependencyRef struct {
ID string
Title string // redacted to "" on a canvas surface outside the caller's scope
State string // redacted to "" alongside Title
// Status is DependencyStatusForTask's verdict ("resolved" | "failed" |
// "pending"). Always "" on a Blocks entry.
Status string
// WorkspaceID is never sent over the wire (not mapped by toProto/proto
// decode). It exists only so a canvas surface can decide, without a
// further read, whether this ref falls outside the caller's scope.
WorkspaceID string
}
// TaskPullRequest is one change opened for a task. Provider-neutral by design:
@@ -187,6 +217,43 @@ func taskPullRequestFromProto(p *pluginv1.TaskPullRequest) TaskPullRequest {
}
}
func (r TaskDependencyRef) toProto() *pluginv1.TaskDependencyRef {
return &pluginv1.TaskDependencyRef{
Id: r.ID, Title: r.Title, State: r.State, Status: r.Status,
}
}
func taskDependencyRefFromProto(p *pluginv1.TaskDependencyRef) TaskDependencyRef {
if p == nil {
return TaskDependencyRef{}
}
return TaskDependencyRef{
ID: p.GetId(), Title: p.GetTitle(), State: p.GetState(), Status: p.GetStatus(),
}
}
func taskDependencyRefsToProto(in []TaskDependencyRef) []*pluginv1.TaskDependencyRef {
if len(in) == 0 {
return nil
}
out := make([]*pluginv1.TaskDependencyRef, len(in))
for i := range in {
out[i] = in[i].toProto()
}
return out
}
func taskDependencyRefsFromProto(in []*pluginv1.TaskDependencyRef) []TaskDependencyRef {
if len(in) == 0 {
return nil
}
out := make([]TaskDependencyRef, len(in))
for i, r := range in {
out[i] = taskDependencyRefFromProto(r)
}
return out
}
func (t Task) toProto() (*pluginv1.Task, error) {
metadata, err := mapToStruct(t.Metadata)
if err != nil {
@@ -230,6 +297,14 @@ func (t Task) toProto() (*pluginv1.Task, error) {
QueuedAt: t.QueuedAt,
ProjectId: t.ProjectID,
ExternalId: t.ExternalID,
Blocked: t.Blocked,
BlockedReason: t.BlockedReason,
DependsOn: taskDependencyRefsToProto(t.DependsOn),
Blocks: taskDependencyRefsToProto(t.Blocks),
DependsOnTruncated: t.DependsOnTruncated,
BlocksTruncated: t.BlocksTruncated,
StartWhenUnblocked: t.StartWhenUnblocked,
}, nil
}
@@ -301,6 +376,14 @@ func taskFromProto(p *pluginv1.Task) (Task, error) {
QueuedAt: p.QueuedAt,
ProjectID: p.GetProjectId(),
ExternalID: p.GetExternalId(),
Blocked: p.GetBlocked(),
BlockedReason: p.GetBlockedReason(),
DependsOn: taskDependencyRefsFromProto(p.GetDependsOn()),
Blocks: taskDependencyRefsFromProto(p.GetBlocks()),
DependsOnTruncated: p.GetDependsOnTruncated(),
BlocksTruncated: p.GetBlocksTruncated(),
StartWhenUnblocked: p.GetStartWhenUnblocked(),
}, nil
}
@@ -72,6 +72,15 @@ func TestTaskProtoRoundTrip(t *testing.T) {
Autopilot: true, WIPAdmitted: true,
QueuedForStepID: "step-build", QueuedAt: strPtr("2026-07-16T10:00:00Z"),
ProjectID: "project-1", ExternalID: "external-1",
Blocked: true, BlockedReason: "pending",
DependsOn: []TaskDependencyRef{
{ID: "task-0", Title: "Blocking task", State: "in_progress", Status: "pending"},
},
Blocks: []TaskDependencyRef{
{ID: "task-9", Title: "Dependent task", State: "not_started", Status: ""},
},
DependsOnTruncated: true, BlocksTruncated: false, StartWhenUnblocked: false,
}
proto, err := task.toProto()
@@ -86,12 +95,44 @@ func TestTaskProtoRoundTrip(t *testing.T) {
require.True(t, proto.GetAutopilot())
require.Len(t, proto.GetPullRequests(), 1)
require.Equal(t, int64(42), proto.GetPullRequests()[0].GetNumber())
require.True(t, proto.GetBlocked())
require.Equal(t, "pending", proto.GetBlockedReason())
require.Len(t, proto.GetDependsOn(), 1)
require.Equal(t, "task-0", proto.GetDependsOn()[0].GetId())
require.Equal(t, "pending", proto.GetDependsOn()[0].GetStatus())
require.Len(t, proto.GetBlocks(), 1)
require.Empty(t, proto.GetBlocks()[0].GetStatus(), "blocks entries never carry a status")
require.True(t, proto.GetDependsOnTruncated())
require.False(t, proto.GetBlocksTruncated())
back, err := taskFromProto(proto)
require.NoError(t, err)
require.Equal(t, task, back)
}
func TestTaskDependencyRefProtoRoundTrip(t *testing.T) {
ref := TaskDependencyRef{ID: "task-5", Title: "Dependency", State: "in_progress", Status: "resolved"}
proto := ref.toProto()
require.Equal(t, ref, taskDependencyRefFromProto(proto))
}
func TestTaskDependencyRefWorkspaceIDNeverReachesTheWire(t *testing.T) {
ref := TaskDependencyRef{ID: "task-5", Title: "Dependency", WorkspaceID: "ws-secret"}
proto := ref.toProto()
require.NotContains(t, proto.String(), "ws-secret", "WorkspaceID must never be serialized onto the wire")
back := taskDependencyRefFromProto(proto)
require.Empty(t, back.WorkspaceID, "a decoded ref never recovers a WorkspaceID; the proto has no field for it")
}
func TestTaskDependencyRefsSliceProtoRoundTrip_EmptyIsNil(t *testing.T) {
require.Nil(t, taskDependencyRefsToProto(nil))
require.Nil(t, taskDependencyRefsToProto([]TaskDependencyRef{}))
require.Nil(t, taskDependencyRefsFromProto(nil))
}
func TestTaskProtoRoundTrip_NilOptionalsAndEmptyMetadata(t *testing.T) {
task := Task{ID: "task-2", Title: "Bare task"}
File diff suppressed because it is too large Load Diff
@@ -426,6 +426,23 @@ message Task {
string project_id = 28;
// Caller-supplied create-idempotency key, empty when the task holds none.
string external_id = 29;
// Dependency projection derived from task_blockers, matching what the
// kanban board already shows. A read that cannot resolve the dependency
// graph reports blocked=true, blocked_reason="unknown", and empty edge
// lists rather than a false "unblocked".
bool blocked = 30;
string blocked_reason = 31; // "pending" | "failed" | "unknown" | ""
// Predecessors this task waits on, and dependents waiting on this task.
// Each list is capped per task; the matching *_truncated flag reports
// whether entries beyond the cap were omitted.
repeated TaskDependencyRef depends_on = 32;
repeated TaskDependencyRef blocks = 33;
bool depends_on_truncated = 34;
bool blocks_truncated = 35;
// Whether this task is configured to auto-start once every predecessor in
// depends_on resolves. Always false when blocked_reason is "unknown",
// regardless of the underlying stored configuration.
bool start_when_unblocked = 36;
}
message TaskRepository {
string id = 1;
@@ -461,6 +478,20 @@ message TaskPullRequest {
string author_login = 18;
optional string closed_at = 19; // RFC3339
}
// TaskDependencyRef is one edge end in a task's dependency projection: a
// predecessor entry in depends_on, or a dependent entry in blocks.
message TaskDependencyRef {
string id = 1;
// Redacted to "" on a canvas surface when the referenced task falls
// outside the caller's scope; id and status still identify and describe
// the edge without exposing the other task's content.
string title = 2;
string state = 3; // TaskState enum value as a string, stable; "" when redacted
// DependencyStatusForTask's verdict: "resolved" | "failed" | "pending".
// Always "" on a blocks entry, since status describes a predecessor's
// progress toward unblocking this task, not this task's own state.
string status = 4;
}
message TaskFilter {
repeated string workspace_ids = 1; // empty → all the plugin may see