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:
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
---
|
||||
created: 2026-09-17
|
||||
status: implemented
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-001
|
||||
- REQ-PLUGINS-TASK-DEPS-002
|
||||
- REQ-PLUGINS-TASK-DEPS-003
|
||||
- REQ-PLUGINS-TASK-DEPS-004
|
||||
- REQ-PLUGINS-TASK-DEPS-005
|
||||
- REQ-PLUGINS-TASK-DEPS-006
|
||||
system_design:
|
||||
- ../../specs/plugins/system-design/task-dependency-projection.md
|
||||
- ../../specs/plugins/system-design/task-dependency-refresh.md
|
||||
- ../../specs/plugins/system-design/task-dependency-edge-ends.md
|
||||
- ../../specs/plugins/system-design/task-dependency-response-bounds.md
|
||||
legacy_specs: []
|
||||
---
|
||||
|
||||
# Implementation Plan: Expose task dependencies in the plugin and canvas data API
|
||||
|
||||
## Overview
|
||||
|
||||
A workspace canvas or gRPC plugin reading the Host Data API's `Task` read model
|
||||
could not see a single dependency edge, even though the kanban board already
|
||||
derives and ships a complete view (`blocked`, `blocked_reason`, `depends_on`,
|
||||
`blocks`, `start_when_unblocked`) via `BuildDependencyViews`. This plan adds the
|
||||
same seven-field, read-only projection to every plugin task read model across
|
||||
the gRPC surface (proto, SDK, `grpcHostServer`), the canvas JSON surface
|
||||
(`webapp_protocol_json.go`), and the shared derivation the two reuse
|
||||
(`internal/task/service/service_dependencies.go`), landing it as a field on the
|
||||
existing `api_read:tasks` capability with no new grant. It builds on the
|
||||
plugin Host Data API's existing task read implementation (ADR 0043,
|
||||
`docs/plans/plugins/host-data-api/`).
|
||||
|
||||
## Scope
|
||||
|
||||
See `task-01-dependency-projection.md` for the full field set, attachment
|
||||
rule, withheld-verdict semantics, fan-out bound, edge-end redaction, and the
|
||||
shared-derivation fixes this plan lands.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test ./internal/plugins/... ./internal/task/service/... ./internal/task/repository/sqlite/... ./internal/office/repository/sqlite/... ./pkg/pluginsdk/...`
|
||||
- `golangci-lint run ./...` (backend)
|
||||
- `gofmt -l` on touched files
|
||||
|
||||
## Work orders
|
||||
|
||||
- [x] [Task 01: Expose task dependencies in the plugin and canvas data API](task-01-dependency-projection.md)
|
||||
@@ -0,0 +1,199 @@
|
||||
---
|
||||
id: "01-dependency-projection"
|
||||
title: "Expose task dependencies in the plugin and canvas data API"
|
||||
status: done
|
||||
wave: 1
|
||||
depends_on: []
|
||||
plan: "plan.md"
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-001
|
||||
- REQ-PLUGINS-TASK-DEPS-002
|
||||
- REQ-PLUGINS-TASK-DEPS-003
|
||||
- REQ-PLUGINS-TASK-DEPS-004
|
||||
- REQ-PLUGINS-TASK-DEPS-005
|
||||
- REQ-PLUGINS-TASK-DEPS-006
|
||||
acceptance_criteria:
|
||||
- AC-PLUGINS-TASK-DEPS-001.1
|
||||
- AC-PLUGINS-TASK-DEPS-001.2
|
||||
- AC-PLUGINS-TASK-DEPS-001.3
|
||||
- AC-PLUGINS-TASK-DEPS-001.4
|
||||
- AC-PLUGINS-TASK-DEPS-001.5
|
||||
- AC-PLUGINS-TASK-DEPS-001.6
|
||||
- AC-PLUGINS-TASK-DEPS-001.7
|
||||
- AC-PLUGINS-TASK-DEPS-001.8
|
||||
- AC-PLUGINS-TASK-DEPS-001.9
|
||||
- AC-PLUGINS-TASK-DEPS-001.10
|
||||
- AC-PLUGINS-TASK-DEPS-001.11
|
||||
- AC-PLUGINS-TASK-DEPS-001.12
|
||||
- AC-PLUGINS-TASK-DEPS-001.13
|
||||
- AC-PLUGINS-TASK-DEPS-001.14
|
||||
- AC-PLUGINS-TASK-DEPS-001.15
|
||||
- AC-PLUGINS-TASK-DEPS-001.16
|
||||
- AC-PLUGINS-TASK-DEPS-001.17
|
||||
- AC-PLUGINS-TASK-DEPS-001.18
|
||||
- AC-PLUGINS-TASK-DEPS-002.1
|
||||
- AC-PLUGINS-TASK-DEPS-002.2
|
||||
- AC-PLUGINS-TASK-DEPS-002.3
|
||||
- AC-PLUGINS-TASK-DEPS-002.4
|
||||
- AC-PLUGINS-TASK-DEPS-002.5
|
||||
- AC-PLUGINS-TASK-DEPS-003.1
|
||||
- AC-PLUGINS-TASK-DEPS-003.2
|
||||
- AC-PLUGINS-TASK-DEPS-003.3
|
||||
- AC-PLUGINS-TASK-DEPS-003.4
|
||||
- AC-PLUGINS-TASK-DEPS-003.5
|
||||
- AC-PLUGINS-TASK-DEPS-003.6
|
||||
- AC-PLUGINS-TASK-DEPS-003.7
|
||||
- AC-PLUGINS-TASK-DEPS-003.8
|
||||
- AC-PLUGINS-TASK-DEPS-004.1
|
||||
- AC-PLUGINS-TASK-DEPS-004.2
|
||||
- AC-PLUGINS-TASK-DEPS-004.3
|
||||
- AC-PLUGINS-TASK-DEPS-004.4
|
||||
- AC-PLUGINS-TASK-DEPS-004.5
|
||||
- AC-PLUGINS-TASK-DEPS-004.6
|
||||
- AC-PLUGINS-TASK-DEPS-004.7
|
||||
- AC-PLUGINS-TASK-DEPS-004.8
|
||||
- AC-PLUGINS-TASK-DEPS-004.9
|
||||
- AC-PLUGINS-TASK-DEPS-004.10
|
||||
- AC-PLUGINS-TASK-DEPS-004.11
|
||||
- AC-PLUGINS-TASK-DEPS-004.12
|
||||
- AC-PLUGINS-TASK-DEPS-004.13
|
||||
- AC-PLUGINS-TASK-DEPS-004.14
|
||||
- AC-PLUGINS-TASK-DEPS-004.15
|
||||
- AC-PLUGINS-TASK-DEPS-005.1
|
||||
- AC-PLUGINS-TASK-DEPS-005.2
|
||||
- AC-PLUGINS-TASK-DEPS-005.3
|
||||
- AC-PLUGINS-TASK-DEPS-005.4
|
||||
- AC-PLUGINS-TASK-DEPS-005.5
|
||||
- AC-PLUGINS-TASK-DEPS-005.6
|
||||
- AC-PLUGINS-TASK-DEPS-005.7
|
||||
- AC-PLUGINS-TASK-DEPS-005.8
|
||||
- AC-PLUGINS-TASK-DEPS-006.1
|
||||
- AC-PLUGINS-TASK-DEPS-006.2
|
||||
- AC-PLUGINS-TASK-DEPS-006.3
|
||||
- AC-PLUGINS-TASK-DEPS-006.4
|
||||
- AC-PLUGINS-TASK-DEPS-006.5
|
||||
system_design:
|
||||
- ../../specs/plugins/system-design/task-dependency-projection.md
|
||||
- ../../specs/plugins/system-design/task-dependency-refresh.md
|
||||
- ../../specs/plugins/system-design/task-dependency-edge-ends.md
|
||||
- ../../specs/plugins/system-design/task-dependency-response-bounds.md
|
||||
---
|
||||
|
||||
# Task 01: Expose task dependencies in the plugin and canvas data API
|
||||
|
||||
## Summary
|
||||
|
||||
A workspace canvas or gRPC plugin reading the Host Data API's `Task` read model
|
||||
could not see a single dependency edge, even though the kanban board already
|
||||
derives and ships a complete view (`blocked`, `blocked_reason`, `depends_on`,
|
||||
`blocks`, `start_when_unblocked`) via `BuildDependencyViews`. This task adds
|
||||
the same seven-field, read-only projection to every plugin task read model
|
||||
across the gRPC surface (proto, SDK, `grpcHostServer`), the canvas JSON
|
||||
surface (`webapp_protocol_json.go`), and the shared derivation the two reuse
|
||||
(`internal/task/service/service_dependencies.go`), landing it as a field on
|
||||
the existing `api_read:tasks` capability with no new grant.
|
||||
|
||||
## Scope
|
||||
|
||||
- **Field set** (all seven, always present within one host version — absence
|
||||
is never a third state): `blocked`, `blocked_reason`
|
||||
(`pending|failed|unknown`), `depends_on[]`, `blocks[]`,
|
||||
`depends_on_truncated`, `blocks_truncated`, `start_when_unblocked`.
|
||||
- **Attachment rule**: the projection is derived and attached on every plugin
|
||||
task read model object a response actually serializes, and on no discarded
|
||||
or intermediate read — enforced via non-attaching (`fetchTask`,
|
||||
`fetchTaskForScopeCheck`, `writeTaskUpdate`) vs. attaching
|
||||
(`taskReader.Get/Update/Move`, `pluginOwnedTaskTreeManager.Preview`) helper
|
||||
pairs across nine call sites (6 gRPC RPCs, 3 canvas routes).
|
||||
- **Withheld verdict**: `blocked=true, blocked_reason="unknown"`, empty lists,
|
||||
both truncation flags false, `start_when_unblocked=false` — the single
|
||||
fail-closed sentinel for all four derivation-failure paths and for a caller
|
||||
lacking `api_read:tasks` (indistinguishable from each other by design).
|
||||
- **Fan-out bound**: `maxDependencyFanOut = 4096` distinct edge ends on
|
||||
multi-task flows (`ListTasks`, canvas task list, preview RPC), returned as
|
||||
gRPC `ResourceExhausted`; single-task reads and write RPCs are exempt and
|
||||
use the unbounded derivation.
|
||||
- **Edge-end redaction**: canvas surfaces redact `title`/`state` on an edge
|
||||
end their scope does not admit as directly readable (5-way switch over
|
||||
scope kind), decided without an additional read; gRPC redacts nothing.
|
||||
- **Shared-derivation fixes** (Kanban-visible, no migration): ordering
|
||||
(`ListBlockersForTasks` gains `ORDER BY created_at, blocker_task_id`),
|
||||
dangling-edge drop extended to the `Blocks` direction, fail-closed on all
|
||||
four derivation-failure paths, edge-end workspace id carried on the ref,
|
||||
and chunking both batched id-query families
|
||||
(`sqliteMaxHostParams = 500`) past SQLite's host-parameter ceiling on the
|
||||
unpaginated preview flow.
|
||||
- **Events**: deliberately unchanged — dependency fields are not added to the
|
||||
canvas SSE allowlist (`webapp_events.go`'s `commonFields`); the contract is
|
||||
refetch-on-signal off the existing `task.updated` /
|
||||
`task.dependencies_resolved` / `task.dependency_failed` events, including a
|
||||
`task.state_changed` refetch rule for any task named in a cached task's
|
||||
edge lists.
|
||||
- **No new capability**: the projection is a field set on `api_read:tasks`;
|
||||
version skew is gated by the manifest's existing `min_kandev_version` floor.
|
||||
|
||||
## Acceptance
|
||||
|
||||
- `GET ./_kandev/v1/data/tasks` on a workspace canvas and gRPC
|
||||
`ListTasks`/`GetTask` return the same seven dependency fields, redaction
|
||||
aside, for the same tasks.
|
||||
- Listing N tasks issues a constant number of dependency queries, not one per
|
||||
task (batched `BuildDependencyViews`/`BuildDependencyViewsBounded`).
|
||||
- A derivation failure or a caller without `api_read:tasks` yields the
|
||||
withheld verdict, never a fail-open empty/false projection.
|
||||
- Mapper round-trip (proto ↔ SDK), canvas JSON projection, the redaction-scope
|
||||
table, the withheld-verdict path, and the fan-out/chunking bound are each
|
||||
covered by tests in the relevant package.
|
||||
|
||||
## Verification
|
||||
|
||||
- `go test ./internal/plugins/... ./internal/task/service/... ./internal/task/repository/sqlite/... ./internal/office/repository/sqlite/... ./pkg/pluginsdk/...` — all green.
|
||||
- `golangci-lint run ./...` (backend) — 0 issues.
|
||||
- `gofmt -l` on touched files — clean.
|
||||
- Three independent review legs (code-reviewer, security-reviewer,
|
||||
test-supervisor) plus a mandatory cross-vendor `codex` pass ran against the
|
||||
final diff; every finding was independently re-verified against source.
|
||||
codex's one finding and the security-reviewer's SEC-001 finding were both
|
||||
confirmed to predate this branch and fall outside its diff (see the task
|
||||
plan's Review round 3 entry for the full trace); no production defect
|
||||
survived review. The residual is test-rigor coverage gaps, tracked in the
|
||||
task plan rather than blocking this PR.
|
||||
|
||||
## Files touched
|
||||
|
||||
- `apps/backend/proto/kandev/plugin/v1/plugin.proto`,
|
||||
`apps/backend/pkg/pluginsdk/data_types.go` (+ tests) — `Task` field
|
||||
additions, `TaskDependencyRef`, proto/SDK conversions.
|
||||
- `apps/backend/internal/plugins/host.go`, `host_data.go`,
|
||||
`host_data_dependencies.go` (+ tests), `host_write.go` (+ tests),
|
||||
`service.go`, `webapp_protocol.go`, `webapp_protocol_data.go`,
|
||||
`webapp_protocol_json.go` (+ tests), `webapp_events.go` (test only) — gRPC
|
||||
and canvas attachment, redaction, withheld-verdict, and logging.
|
||||
- `apps/backend/internal/task/service/service_dependencies.go` (+ tests) —
|
||||
shared derivation: ordering, dangling-edge drop, fail-closed paths,
|
||||
workspace id on refs, fan-out bound.
|
||||
- `apps/backend/internal/office/repository/sqlite/blockers.go` (+ tests),
|
||||
`apps/backend/internal/task/repository/sqlite/task.go` (+ tests) — ordering
|
||||
and chunking on the batched id queries.
|
||||
- `docs/public/canvases.md`, `docs/public/plugins-authoring.md`,
|
||||
`apps/backend/internal/mcp/canvasskill/files/references/browser-api.md` —
|
||||
consumer-facing contract docs.
|
||||
|
||||
## Output contract
|
||||
|
||||
Summary, files changed, and status update here + in `plan.md`.
|
||||
|
||||
## Output
|
||||
|
||||
**Status: done.** Landed across 9 commits on
|
||||
`feature/expose-task-dependen-61269a`
|
||||
(`5d359841a`..`9a5b0b761`), covering the frozen spec's `-001` through `-006`
|
||||
requirements in full. Spec Review ran 8 rounds to freeze the contract; Build
|
||||
ran 7 rounds (2 operator-authorized beyond the standing 4-round cap) fixing
|
||||
findings from Testing and Review; Testing round 7 and Review round 3 both
|
||||
found no production defect. See the task plan for the full round-by-round
|
||||
record, including the two review findings adjudicated as pre-existing and
|
||||
out of this branch's scope (a codex finding about the internal event bus,
|
||||
and a security-reviewer finding about gRPC plugin event-delivery capability
|
||||
gating — both confirmed unmodified by this branch and filed as follow-up
|
||||
candidates rather than blockers).
|
||||
@@ -152,4 +152,12 @@ separate plugin repo, backend tests, public docs) and parallelize.
|
||||
new method on the task repository's `git_snapshots.go`. Recommendation:
|
||||
analytics repository, consistent with `GetGitStats`/`GetRepositoryStats`.
|
||||
Settle in task-02.
|
||||
|
||||
## Related follow-on work
|
||||
|
||||
Exposing task dependencies on this same read model is tracked as its own plan,
|
||||
[`docs/plans/plugin-task-dependency-projection/`](../plugin-task-dependency-projection/plan.md),
|
||||
kept separate because this plan's directory nests an extra path segment
|
||||
(`plugins/host-data-api`) that later tooling expects a single-segment
|
||||
initiative directory to not have.
|
||||
</content>
|
||||
|
||||
@@ -57,6 +57,8 @@ A task canvas belongs to one task. A workspace canvas appears in workspace navig
|
||||
|
||||
The app uses relative requests such as `./_kandev/v1/data/tasks` and `./_kandev/v1/state`. Kandev remains the source of truth for task, workflow, and message data. Canvas state stores app-specific shared state. It does not replace Kandev domain data.
|
||||
|
||||
A task read includes a read-only summary of that task's dependencies: whether it is blocked, and which tasks it depends on or blocks. Events never carry this summary, so a canvas that displays it refetches the task rather than reading the summary out of the event stream: on a `task.updated`, `task.dependencies_resolved`, or `task.dependency_failed` event for that task or one of its edges, or on a `task.state_changed` event for any task in its cached dependency lists (a predecessor or dependent simply advancing state is not one of the first three events). Kandev refuses an oversized read rather than truncating it silently; the authoring reference has the exact fields and limits.
|
||||
|
||||
The host shows canvas controls outside the app frame. The app runs in a sandboxed iframe with an opaque browser origin:
|
||||
|
||||
- The frame allows packaged scripts and forms.
|
||||
|
||||
@@ -921,6 +921,99 @@ next page. See `pkg/pluginsdk/data_types.go` for the full `Task`,
|
||||
`Workspace`, `Workflow`, `WorkflowStep`, `AgentProfile`, `Repository`,
|
||||
`Session`, `Message`, and filter/page types.
|
||||
|
||||
#### Task dependencies
|
||||
|
||||
Every `Task` returned by `host.Tasks().Get`/`.List` carries a read-only
|
||||
dependency projection: `Blocked`, `BlockedReason`, `DependsOn`, `Blocks`,
|
||||
`DependsOnTruncated`, `BlocksTruncated`, and `StartWhenUnblocked`. `DependsOn`
|
||||
and `Blocks` are `[]TaskDependencyRef` (`ID`, `Title`, `State`, `Status`;
|
||||
`Status` is only ever set on a `DependsOn` entry, since a task cannot be
|
||||
"pending" or "resolved" against a task it blocks). Each list is capped at 512
|
||||
entries; the matching `*Truncated` flag reports whether more edges exist than
|
||||
were returned. A gRPC plugin sees no redaction: every edge end's `Title` and
|
||||
`State` are populated regardless of which workspace it belongs to, unlike the
|
||||
canvas surface described in [`canvases.md`](canvases.md), which blanks both
|
||||
fields for an edge end the caller's canvas scope does not directly admit: a
|
||||
workspace-scoped canvas admits an end sharing its workspace, a repository- or
|
||||
session-scoped canvas admits an end only when it is also returned as a
|
||||
directly readable task in the same response, and a task-scoped canvas admits
|
||||
none.
|
||||
|
||||
If dependency derivation cannot produce a verdict for a task that a call
|
||||
does return, the host substitutes the withheld verdict rather than failing
|
||||
that call: `Blocked: true`, `BlockedReason: "unknown"`, empty
|
||||
`DependsOn`/`Blocks`, both truncation flags `false`, and
|
||||
`StartWhenUnblocked: false`. Treat this shape as "no answer," not as "task is
|
||||
actually blocked." Two distinct causes reach it: an internal read failure
|
||||
during derivation, or a task reached through `CreateTask`/`UpdateTask`/
|
||||
`MoveTask` by a caller holding `api_write:tasks` but not `api_read:tasks` (an
|
||||
independent capability those RPCs gate on writing, not reading). `List` and
|
||||
`Get` themselves never produce this verdict for a missing read capability:
|
||||
each fails the call outright with `PermissionDenied` before any task is
|
||||
returned, so accessor denial and a withheld verdict on a returned task are
|
||||
never the same signal. This is also distinct from the fan-out limit below:
|
||||
that refuses the whole call with `ResourceExhausted` rather than substituting
|
||||
a withheld verdict onto any task.
|
||||
|
||||
Canvas event payloads do not carry the dependency projection: `Blocked`,
|
||||
`BlockedReason`, `DependsOn`, `Blocks`, the truncation flags, and
|
||||
`StartWhenUnblocked` are refetch-on-signal fields for the canvas surface.
|
||||
Native plugin `OnEvent` deliveries can carry the four dependency fields
|
||||
(`blocked`, `blocked_reason`, `depends_on`, and `blocks`) on dependency-related
|
||||
`task.updated` events. The truncation flags and `start_when_unblocked` still
|
||||
come from `host.Tasks().Get`/`.List`, so an event is never a complete
|
||||
replacement for a task read. A plugin that caches a task's dependency fields
|
||||
refetches them 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 `DependsOn` or `Blocks` list, since a predecessor or dependent
|
||||
simply advancing state is not itself one of the first three signals. A single
|
||||
`Get`/`List` response 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 trusting any single response as
|
||||
authoritative.
|
||||
|
||||
Reading dependencies adds no extra query per task; the host derives them for
|
||||
the whole page in one batched pass. That batch is bounded by a fixed limit on
|
||||
the number of distinct task IDs it will read across all edges on the page:
|
||||
whether an edge is expressed once or shared by many tasks, each distinct ID
|
||||
only counts once toward the limit. `ListTasks` and the plugin-owned
|
||||
task-tree preview RPC (`PreviewPluginOwnedTaskTree`) are both bound by this
|
||||
limit and fail the call with a `ResourceExhausted` error when a page (or, for
|
||||
the preview RPC, the tree itself) would cross it; the preview RPC accepts no
|
||||
page `limit` at all, so the only remedy is asking for a smaller tree. Flows
|
||||
that always return exactly one task, `GetTask` and the task-write RPCs
|
||||
(`CreateTask`, `UpdateTask`, `MoveTask`), can never exceed the limit and are
|
||||
exempt. A `ResourceExhausted` response is about the cost of deriving the
|
||||
answer, not about the size of the reply; it is never folded into the withheld
|
||||
verdict.
|
||||
|
||||
`ListTasks`'s `Page.Limit` clamps only its upper bound: a limit above the
|
||||
host's page ceiling is lowered to that ceiling, and a limit at or below zero
|
||||
(including an unset, proto3-default zero) falls back to the host's default
|
||||
page size. That default is the largest page the endpoint will return on its
|
||||
own, so retrying a rejected call with a smaller explicit `Limit` (not with a
|
||||
zero or negative one) is the way to shrink a page that tripped the fan-out
|
||||
limit above.
|
||||
|
||||
Declare a `min_kandev_version` manifest floor for the first Kandev release
|
||||
your plugin expects to carry these seven fields; a host older than that floor
|
||||
omits them from the wire message entirely (the SDK reports them as their zero
|
||||
values, indistinguishable from "not blocked, no edges"). This floor is a
|
||||
single host-wide capability check performed once at install time: it does
|
||||
not name `task-dependencies` or any other capability, unlike the
|
||||
capability-keyed `min_kandev_version` requirement on `api_read:messages`
|
||||
described above, which the host re-validates per declared capability. A dev
|
||||
or otherwise non-release build of the host always satisfies the floor check
|
||||
regardless of its actual age, so a plugin installed on such a build can still
|
||||
receive the same ambiguous zero-value bytes; do not rely on the floor check
|
||||
alone as proof the fields are populated when running against a non-release
|
||||
host.
|
||||
|
||||
`host.Messages().List(ctx, MessageFilter{...}, Page{...})` reads historical
|
||||
conversation content (capability `api_read:messages`). Filter by `SessionIDs`,
|
||||
`TaskIDs`, a `Since`/`Until` `created_at` window (RFC3339; `Since` inclusive,
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
---
|
||||
id: plugins-task-dependency-edge-ends
|
||||
title: Dependency edge ends on the plugin data API
|
||||
status: draft
|
||||
system: plugins
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Dependency edge ends on the plugin data API Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The [task dependency projection](task-dependency-projection.md) adds each task's
|
||||
direct predecessors and dependents to the plugin and canvas task read model. Each
|
||||
entry in those lists names *another* task, and carries that task's title and
|
||||
state. Those entries are where the projection stops describing the task the
|
||||
caller asked for and starts describing one it did not, so they raise two
|
||||
questions the projection itself does not answer.
|
||||
|
||||
**Who may read an end.** On a canvas surface an entry can describe a task the
|
||||
caller is refused outright: a task-scoped canvas may read exactly one task, yet an
|
||||
unredacted end would hand it the titles of every task blocking that one.
|
||||
`REQ-PLUGINS-TASK-DEPS-003` bounds that disclosure.
|
||||
|
||||
**What an end says when the response does not contain it.** A graph assembled
|
||||
from one page names ends that are archived, on another page, or gone from storage
|
||||
entirely. `REQ-PLUGINS-TASK-DEPS-006` fixes what each of those reports, so a
|
||||
consumer never renders a stale or invented node.
|
||||
|
||||
This document is separated from the projection requirements because the two are
|
||||
reviewed against different risks: the projection decides what the graph says, and
|
||||
this decides what each end of it is allowed to say. The projection's criteria
|
||||
defer here wherever an end's `title`, `state`, or existence is at stake. A third
|
||||
sibling, [response bounds](task-dependency-response-bounds.md), owns
|
||||
`REQ-PLUGINS-TASK-DEPS-004`; the query bound that
|
||||
`AC-PLUGINS-TASK-DEPS-003.3` keeps the admission test inside lives there.
|
||||
|
||||
Identities continue the `TASK-DEPS` token, so `REQ-PLUGINS-TASK-DEPS-003` and
|
||||
`REQ-PLUGINS-TASK-DEPS-006` keep the identities they were reviewed under.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Dependency edge:** A directed peer-to-peer relation "this task depends on
|
||||
that task", distinct from the parent/child subtask hierarchy. Its two ends are
|
||||
the **predecessor** and the **dependent**, listed in `depends_on` and in
|
||||
`blocks` respectively; **edge end** means either.
|
||||
- **Canvas scope:** The scope kind and trusted resource identity bound to a
|
||||
canvas instance (instance, workspace, task, repository, or session), which
|
||||
already decides which tasks that canvas may read directly.
|
||||
- **Redaction:** Emptying an edge end's `title` and `state` while keeping the
|
||||
entry, its `id`, and (for a predecessor) its `status`.
|
||||
- **Plugin task read:** As defined in
|
||||
[task dependency projection](task-dependency-projection.md) — any surface
|
||||
returning the plugin task read model, on either the gRPC or the canvas surface.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-003: Scope-bounded edge ends
|
||||
|
||||
**Intent:** An edge end must not become a way to read a task the caller is
|
||||
refused directly. A task-scoped canvas sees that its task is blocked; it must not
|
||||
learn the titles of the tasks blocking it.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-003.1:** When a plugin task read on a canvas surface
|
||||
returns an edge end the canvas scope would not admit as a directly readable task,
|
||||
that entry shall carry only the end's `id` and, for a `depends_on` entry, its
|
||||
`status`. This shall hold on the list route, the single-task route, and the task
|
||||
object returned by the update route alike.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.2:** A redacted entry shall report `title` and
|
||||
`state` as empty rather than omitting the entry.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.3:** The admission test for an edge end shall be the
|
||||
same scope predicate that decides direct readability, evaluated only over data
|
||||
the response has already loaded. It shall issue no additional read per edge end,
|
||||
so it cannot conflict with `AC-PLUGINS-TASK-DEPS-004.1`. An end whose predicate
|
||||
inputs are unavailable shall be treated as not admitted.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.4:** The cross-workspace write validator shall not
|
||||
be relied on as a blanket exemption from the workspace test in
|
||||
`AC-PLUGINS-TASK-DEPS-003.8`, because it does not reject a pair in which either
|
||||
end carries no workspace.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.5:** A gRPC plugin read shall redact no edge end, on
|
||||
the grounds of disclosure parity rather than of a workspace filter: that surface
|
||||
has no canvas scope, and the caller may already read any such end as a task
|
||||
through the single-task read, which applies no workspace narrowing. The list
|
||||
read's narrowing shall not be cited, because it does not apply on every gRPC
|
||||
task read.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.6:** Redaction shall not change `blocked`,
|
||||
`blocked_reason`, `start_when_unblocked`, the number of entries in either
|
||||
list, or their order.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.7:** No field shall be added to report that an entry
|
||||
was redacted, and consumers shall be documented as rendering an empty `title`
|
||||
as an unlabelled node. The contract shall not claim a redacted entry is
|
||||
indistinguishable from one whose task genuinely has an empty title: it is not,
|
||||
because such a task still reports a non-empty `state`.
|
||||
- **AC-PLUGINS-TASK-DEPS-003.8:** The admission test shall resolve by scope kind as
|
||||
follows and admit no other end: instance scope admits every end; workspace scope
|
||||
admits an end whose workspace is known equal to the bound workspace; task scope
|
||||
admits no end, because an end is never the bound task itself; repository and
|
||||
session scope admit an end only when it is also returned in the response as a
|
||||
directly readable task.
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-006: Archived and removed edge ends
|
||||
|
||||
**Intent:** A graph assembled from one page has ends the page does not contain.
|
||||
The consumer must render them without being told a stale story about them.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-006.1:** An archived predecessor shall appear in
|
||||
`depends_on` with `status: pending`, and shall never report `resolved`.
|
||||
- **AC-PLUGINS-TASK-DEPS-006.2:** An edge end shall appear in its list whether or
|
||||
not the same response contains that end as a task, so a default
|
||||
`include_archived=false` list still names its archived ends.
|
||||
- **AC-PLUGINS-TASK-DEPS-006.3:** An entry not redacted under
|
||||
`REQ-PLUGINS-TASK-DEPS-003` shall carry enough to render a node without reading
|
||||
the end as a task; a consumer needing the full task reads it by id or lists with
|
||||
`include_archived=true`.
|
||||
- **AC-PLUGINS-TASK-DEPS-006.4:** An edge whose end no longer exists as a task
|
||||
shall be omitted from `depends_on` and `blocks`, and shall not contribute to
|
||||
`blocked` or `blocked_reason`. This differs from the redaction in
|
||||
`AC-PLUGINS-TASK-DEPS-003.2`, which keeps an entry for a task that exists but is
|
||||
outside the caller's scope.
|
||||
- **AC-PLUGINS-TASK-DEPS-006.5:** No entry of `depends_on` or `blocks` shall
|
||||
report a *non-empty* status value outside the closed vocabulary in
|
||||
`AC-PLUGINS-TASK-DEPS-001.6`, including for an end whose task row is absent. The
|
||||
derivation's internal `missing` marker shall never reach either list. An empty
|
||||
status is required on a `blocks` entry by `AC-PLUGINS-TASK-DEPS-001.7` and is
|
||||
not a vocabulary violation there; on a `depends_on` entry it is one, because
|
||||
`AC-PLUGINS-TASK-DEPS-001.6` requires exactly one of the three values.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **A flag reporting that an entry was redacted.** Excluded by
|
||||
`AC-PLUGINS-TASK-DEPS-003.7`: a false value on such a flag invites reading as an
|
||||
authority statement, and what it carries is diagnostic only.
|
||||
- **Admitting an end by reading it.** Excluded by
|
||||
`AC-PLUGINS-TASK-DEPS-003.3`, which bounds the admission test to data the
|
||||
response already holds so it cannot conflict with the query bound in
|
||||
`AC-PLUGINS-TASK-DEPS-004.1`. The cost is accepted under
|
||||
`AC-PLUGINS-TASK-DEPS-003.8`: repository and session scope under-admit, and an
|
||||
end the canvas could fetch directly may still be redacted.
|
||||
- **Page-stable redaction.** Under `AC-PLUGINS-TASK-DEPS-003.8` a repository- or
|
||||
session-scoped canvas admits an end only when the same response returns it, so
|
||||
the same edge end may carry a title at one page size and not another. The rule
|
||||
fully determines each response; no criterion requires the outcome to be stable
|
||||
across requests.
|
||||
- **Redaction on the event stream.** The canvas event projection has no per-entry
|
||||
scope evaluation, so the dependency projection is kept off events entirely by
|
||||
`AC-PLUGINS-TASK-DEPS-005.1` rather than redacted there.
|
||||
@@ -0,0 +1,305 @@
|
||||
---
|
||||
id: plugins-task-dependency-projection
|
||||
title: Task dependency projection on the plugin data API
|
||||
status: draft
|
||||
system: plugins
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Task dependency projection on the plugin data API Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The plugin task read model exposes `parent_id` and nothing else about how one
|
||||
task relates to another. Dependency edges ("task B cannot start until task A
|
||||
succeeds") are derived for every Kandev board read, but the Plugin Host data API
|
||||
and the isolated-canvas data protocol both drop them, so a gRPC plugin or a
|
||||
canvas draws the parent/child tree ("part of") and never the dependency graph
|
||||
("not until") from tasks it has already read. This capability adds the edges, the
|
||||
derived blocked verdict, and the deferred-launch intent to that read model as a
|
||||
read-only projection, on both surfaces.
|
||||
|
||||
Plugins owns it because the extended contract is the plugin and canvas read
|
||||
model ([plugins README](../README.md)). Dependency semantics stay with Tasks
|
||||
([task dependencies](../../tasks/system-design/task-dependencies.md)), which this
|
||||
capability re-projects, never redefines. Identities use the `TASK-DEPS` token,
|
||||
distinct from `REQ-TASKS-TASK-DEPENDENCIES-001`.
|
||||
|
||||
Two sibling documents own the questions this one does not. What an edge *end*
|
||||
may say lives in [dependency edge ends](task-dependency-edge-ends.md), which owns
|
||||
`REQ-PLUGINS-TASK-DEPS-003` (which ends a canvas may see described, and what is
|
||||
left of one it may not) and `REQ-PLUGINS-TASK-DEPS-006` (ends the response does
|
||||
not contain: archived, out of page, or removed); it defines *canvas scope* and
|
||||
*redaction*. What one response may cost and how large it may be lives in
|
||||
[response bounds](task-dependency-response-bounds.md), which owns
|
||||
`REQ-PLUGINS-TASK-DEPS-004` (query count, list cut, edge-end fan-out, and the
|
||||
response-size bound on each surface).
|
||||
|
||||
This document owns the projection itself: the field set, the derivation, the
|
||||
order, and the refresh rule. Criteria here defer to the edge-ends sibling
|
||||
wherever an end's `title`, `state`, or existence is at stake, and to the
|
||||
response-bounds sibling wherever a query count, a list length, or a response size
|
||||
is.
|
||||
|
||||
## Prior art
|
||||
|
||||
**Leg 1: Kandev's own prior reasoning (wiki). Receipt: did not run.** No
|
||||
`wiki-query` in either skill directory or on `PATH`, no `~/.obsidian-wiki/`, no
|
||||
`OBSIDIAN_VAULT_PATH`; no vault path or QMD collection can be reported.
|
||||
Tool-unavailable, not an empty result.
|
||||
|
||||
In its place the in-repository record was searched (`scripts/list-docs.py
|
||||
decisions`, `docs/specs/{plugins,tasks}/**`). Three positions found there are
|
||||
adopted rather than re-derived: **ADR 0043** (DTO fields are additive-only;
|
||||
hand-mapped DTOs read through the service layer);
|
||||
**[isolated web-app contributions](../system-design/isolated-web-app-contributions.md)**
|
||||
(canvas routes reuse the Host data API DTOs, so a field added for one adapter is
|
||||
returned by both with the same nullability and authorization, as
|
||||
`workflow_step_id` established); and
|
||||
**[task dependencies](../../tasks/system-design/task-dependencies.md)** (blocked
|
||||
state is derived on every read, never persisted, because the auto-start gate
|
||||
would read a stale copy).
|
||||
|
||||
**Leg 2: what other products shipped (saas-kb). Receipt: did not run.** No
|
||||
`search_fsm_docs` tool and no `saas-kb` MCP server in this session's tool
|
||||
surface, and no tool-discovery tool to find one. No query issued.
|
||||
|
||||
**What we are doing differently.** Two departures, both normative below. The
|
||||
board treats an **absent** projection as "unknown" and an empty list as "no
|
||||
edges"; this contract computes it on every read, so within one host absence never
|
||||
occurs (`AC-PLUGINS-TASK-DEPS-001.2`) and an underivable projection reports the
|
||||
withheld verdict (`REQ-PLUGINS-TASK-DEPS-002`). And `Task.pull_requests` degrades
|
||||
to an empty list on a failed lookup; dependencies must not, because an empty edge
|
||||
list is indistinguishable from a real answer.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Dependency edge:** A directed peer-to-peer relation "this task depends on
|
||||
that task", distinct from the parent/child subtask hierarchy. Its two ends are
|
||||
the **predecessor** and the **dependent**, listed in `depends_on` and in
|
||||
`blocks` respectively; **edge end** means either.
|
||||
- **Projection:** The derived, never-persisted dependency field set on one task,
|
||||
enumerated in `AC-PLUGINS-TASK-DEPS-001.1`.
|
||||
- **Plugin task read:** Any surface returning the plugin task read model: the
|
||||
gRPC `ListTasks`, `GetTask`, task-write and task-tree-preview RPCs, and the
|
||||
canvas `GET`/`PATCH ./_kandev/v1/data/tasks[/{task_id}]` routes.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-001: Dependency projection on the plugin task read model
|
||||
|
||||
**Intent:** A canvas author reading tasks draws the dependency graph from the
|
||||
task read model alone, each task naming its direct predecessors and dependents
|
||||
with the same edges and verdict the board shows, without a second data source.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-001.1:** Every plugin task read shall return
|
||||
`blocked`, `blocked_reason`, `depends_on`, `blocks`, `depends_on_truncated`,
|
||||
`blocks_truncated`, and `start_when_unblocked` for each task in the response.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.2:** Every one of those fields shall be present on
|
||||
every task returned by a host implementing this capability, including a task
|
||||
with no edges, so a consumer never treats absence as a third state. Scoped by
|
||||
`AC-PLUGINS-TASK-DEPS-001.15`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.3:** When `blocked_reason` is not `unknown`, an
|
||||
empty `depends_on` or `blocks` shall mean "no edge in that direction". When it
|
||||
is `unknown`, both lists shall be non-authoritative and shall not be read as
|
||||
"no edges"; `blocked_reason` shall be the only discriminator.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.4:** `blocked_reason` shall be exactly one of
|
||||
`pending`, `failed`, `unknown`, or the empty value, and shall be the empty
|
||||
value if and only if `blocked` is false.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.5:** `depends_on` shall list only direct
|
||||
predecessors and `blocks` only direct dependents. Neither shall be transitive
|
||||
and neither shall contain the task itself.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.6:** Each `depends_on` entry shall carry the
|
||||
predecessor's `id`, `title`, `state`, and a `status` that is exactly one of
|
||||
`resolved`, `failed`, or `pending`, subject to `REQ-PLUGINS-TASK-DEPS-003`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.7:** Each `blocks` entry shall carry the
|
||||
dependent's `id`, `title`, and `state`, and no resolution status, subject to
|
||||
`REQ-PLUGINS-TASK-DEPS-003`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.8:** `depends_on` shall be ordered by edge creation
|
||||
time ascending, tiebroken by predecessor task id ascending; `blocks` likewise,
|
||||
tiebroken by dependent task id ascending. Both orders shall be total and
|
||||
repeatable for unchanged data.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.9:** For the same task and edges, the gRPC read and
|
||||
the canvas read shall report the same values for every projection field,
|
||||
allowing only the scope redaction in `REQ-PLUGINS-TASK-DEPS-003`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.10:** `start_when_unblocked` shall report the stored
|
||||
deferred-launch intent as recorded, not a judgement about whether it can still
|
||||
fire: a task whose last edge was removed keeps reporting true, because removing
|
||||
an edge does not clear the intent. It shall be read-only on every plugin
|
||||
surface, and false under the withheld verdict of `AC-PLUGINS-TASK-DEPS-001.14`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.11:** A plugin task read shall require no capability
|
||||
beyond `api_read:tasks`. A surface whose own gate requires it shall keep
|
||||
rejecting a caller without it outright, never turning that rejection into a
|
||||
successful response. On a surface gated by something else, including the gRPC
|
||||
task-write RPCs whose write capability gates independently, a caller without
|
||||
`api_read:tasks` shall receive the withheld verdict of
|
||||
`AC-PLUGINS-TASK-DEPS-001.14` and never an edge list, an edge end, or a
|
||||
`blocked` value derived from real data.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.12:** Reading a task shall not change any task,
|
||||
edge, or launch intent, and repeating a read shall only report the current
|
||||
derivation.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.13:** A task object returned by a plugin or canvas
|
||||
write shall carry the projection derived after that write commits, matching what
|
||||
a read would report at the instant of derivation, subject to
|
||||
`AC-PLUGINS-TASK-DEPS-001.11`. Because that derivation takes no lock
|
||||
(`AC-PLUGINS-TASK-DEPS-005.6`), an edge mutated between commit and derivation may
|
||||
appear; a consumer needing a later state refetches under
|
||||
`REQ-PLUGINS-TASK-DEPS-005`.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.14:** The withheld verdict shall be `blocked: true`,
|
||||
`blocked_reason: unknown`, empty `depends_on`, empty `blocks`, both truncation
|
||||
flags false, and `start_when_unblocked` false. It shall be reported on any
|
||||
response that returns a task object but cannot carry a real projection, so no
|
||||
response asserts "not blocked" without authority for that claim. It shall never
|
||||
be the body of a response that should have been rejected instead;
|
||||
`AC-PLUGINS-TASK-DEPS-001.11` governs which callers reach it.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.15:** The presence guarantee in
|
||||
`AC-PLUGINS-TASK-DEPS-001.2` shall hold within one host version only. The wire
|
||||
form carries no field presence, so a host predating this capability yields the
|
||||
bytes of `blocked: false` with empty lists, indistinguishable from a real "not
|
||||
blocked, no edges" answer.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.16:** The mechanism separating the two shall be the
|
||||
manifest's `min_kandev_version` floor, declared by the consumer as the first
|
||||
host release carrying this capability and enforced at install time: an older
|
||||
host rejects the install outright, with no partial registration. The concrete
|
||||
version shall be pinned at release and never guessed ahead of one. It is a
|
||||
load-time gate, not a runtime check. No runtime capability or version signal
|
||||
shall be added, because the projection adds no capability -- it is a field set
|
||||
on the already-approved `api_read:tasks` resource, the gRPC handshake version is
|
||||
fixed, and the canvas protocol version does not change.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.17:** The floor shall not be a capability-keyed
|
||||
minimum on `api_read:tasks`, which would reject every installed plugin that
|
||||
reads tasks without this capability. It is therefore declared, not validated,
|
||||
and the public documentation shall state the floor, the effect of omitting it,
|
||||
and that a sideloaded plugin bypassing the install path gets the ambiguous
|
||||
bytes with no signal.
|
||||
- **AC-PLUGINS-TASK-DEPS-001.18:** In the canvas JSON an empty `depends_on` or
|
||||
`blocks` shall be encoded as an empty array, never `null` and never omitted: a
|
||||
null is the third state `AC-PLUGINS-TASK-DEPS-001.2` forbids and is the default
|
||||
encoding of an unallocated list, so this binds the encoder, not the derivation.
|
||||
The withheld verdict shall be encoded the same way.
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-002: Fail-closed derivation
|
||||
|
||||
**Intent:** A read that cannot be answered must say so; an empty edge list is
|
||||
indistinguishable from a real answer and renders a confidently wrong graph.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-002.1:** When the dependency derivation for a task
|
||||
cannot be completed, the read shall report the withheld verdict of
|
||||
`AC-PLUGINS-TASK-DEPS-001.14` for that task.
|
||||
- **AC-PLUGINS-TASK-DEPS-002.2:** A failed dependency derivation shall not fail
|
||||
the surrounding task read, shall not remove any other field of the task, and
|
||||
shall not omit any projection field.
|
||||
- **AC-PLUGINS-TASK-DEPS-002.3:** Failure granularity shall follow the derivation's
|
||||
batch boundaries. A batch-wide failure shall produce the withheld verdict for
|
||||
every task in that batch; a failure confined to one task's edge-end resolution
|
||||
shall produce it for that task alone. Per-task isolation of a batch-wide failure
|
||||
shall not be required, because `AC-PLUGINS-TASK-DEPS-004.1` forbids the per-task
|
||||
queries needed to detect it.
|
||||
- **AC-PLUGINS-TASK-DEPS-002.4:** A failed dependency derivation shall emit one
|
||||
structured log record per derivation attempt, not one per withheld task. The
|
||||
record shall carry plugin, instance, resource type, operation, and result code,
|
||||
and shall not record task titles, task identifiers, or edge contents. This
|
||||
capability shall add no counter and no metric namespace; the log record is its
|
||||
whole observability contract.
|
||||
- **AC-PLUGINS-TASK-DEPS-002.5:** Each of the following shall count as a
|
||||
derivation that cannot be completed and shall produce the withheld verdict, not
|
||||
a partial or degraded result: a failed read of the predecessor direction; a
|
||||
failed read of the dependent direction; a failed resolution of edge-end detail;
|
||||
and a derivation source absent or not wired to the read at all. None shall be
|
||||
reported as an empty list carrying a `blocked_reason` other than `unknown`.
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-005: Refresh contract for dependency changes
|
||||
|
||||
**Intent:** A consumer must know how it learns the graph changed, without the
|
||||
event stream becoming a second source of truth for edges.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-005.1:** Canvas events shall not carry the dependency
|
||||
projection, any edge list, or any edge end's title or state.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.2:** Adding or removing a dependency edge shall
|
||||
publish a `task.updated` event for both ends of that edge. Delivery to a canvas
|
||||
shall follow the existing event scope matching, which for repository and session
|
||||
scopes matches on a single identity carried by the event rather than the full
|
||||
set the direct-read predicate accepts. The contract shall therefore not promise
|
||||
delivery to every canvas whose scope admits an end; the consumer's guarantee is
|
||||
`AC-PLUGINS-TASK-DEPS-005.3`.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.3:** The documented refresh rule shall be that a
|
||||
consumer re-reads the affected tasks after `task.updated`,
|
||||
`task.dependencies_resolved`, or `task.dependency_failed`, because none of
|
||||
those events identifies which fields changed.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.4:** One response shall not be a guaranteed
|
||||
transactional snapshot: a consumer shall be documented as unable to assume that a
|
||||
task appearing in another task's `blocks` also lists that task in its own
|
||||
`depends_on` within the same response.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.5:** The refresh rule and the non-snapshot rule shall
|
||||
be stated in the public canvas and plugin authoring documentation.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.6:** Deriving the projection shall acquire no
|
||||
dependency mutation lock, so a read shall neither delay nor be delayed by a
|
||||
concurrent edge add or remove, and concurrent reads of the same task shall not
|
||||
serialize against each other.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.7:** The rule shall also cover a predecessor's
|
||||
ordinary state change, which the three events above miss: a predecessor moving
|
||||
state while another predecessor is still pending publishes neither
|
||||
`task.dependencies_resolved` nor `task.dependency_failed` for the dependent,
|
||||
only `task.state_changed` for itself, so the `state` on that dependent's
|
||||
`depends_on` entry would otherwise stay stale indefinitely. The documented rule
|
||||
shall require a consumer holding a cached task to re-read it on
|
||||
`task.state_changed` for any task named in its `depends_on` or `blocks`.
|
||||
- **AC-PLUGINS-TASK-DEPS-005.8:** No dependent-side event shall be added for a
|
||||
predecessor state change: the event contract is owned elsewhere, the fan-out is
|
||||
unbounded in the dependent count, and `AC-PLUGINS-TASK-DEPS-005.2` already
|
||||
declines to promise scope-complete delivery. A consumer rule over an
|
||||
already-published event is the guarantee instead.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Dependency writes.** Neither gRPC `UpdateTask` nor the canvas `PATCH` route
|
||||
gains an edge field; a consumer mutates edges through the Kandev task API or UI.
|
||||
A write contract needs cycle rejection, cross-workspace rejection, and Tasks'
|
||||
serialized validate-then-insert path.
|
||||
- **A blocked filter on the task list.** The filter keeps workspace, workflow,
|
||||
state, and parent only; a consumer pages and filters on `blocked` itself. A
|
||||
server-side filter would evaluate a derived, unindexed value: a Tasks decision.
|
||||
- **Session reads for canvases.** The canvas data surface exposes only tasks and
|
||||
workflows, so a canvas colours a node by task `state` but cannot tell a running
|
||||
session from a waiting one. `data/sessions` is a separate extension.
|
||||
- **Dependency fields in the event payload.** Excluded by
|
||||
`AC-PLUGINS-TASK-DEPS-005.1`: the event projection has no per-entry scope
|
||||
evaluation, so edge ends there would bypass `REQ-PLUGINS-TASK-DEPS-003`.
|
||||
- **Event routing enrichment.** `AC-PLUGINS-TASK-DEPS-005.2` accepts that a
|
||||
repository- or session-scoped canvas may miss a `task.updated` for a task it
|
||||
could read directly. Widening routing changes the event contract, owned
|
||||
elsewhere; `AC-PLUGINS-TASK-DEPS-005.3` is the guarantee instead.
|
||||
- **Transitive closure, path finding, and cycle reporting.** Both lists are
|
||||
direct edges only; a consumer composes reachability from what it read.
|
||||
- **A transactional snapshot across one response.** Excluded by
|
||||
`AC-PLUGINS-TASK-DEPS-005.4`; symmetry would need one transaction across the
|
||||
whole batched derivation.
|
||||
- **A new capability id.** The projection is a field set on the already-approved
|
||||
`api_read:tasks` resource; adding fields changes neither a plugin manifest nor
|
||||
its digest, so no re-approval is triggered.
|
||||
- **A runtime host-version signal.** Excluded by `AC-PLUGINS-TASK-DEPS-001.16`
|
||||
in favour of the install-time `min_kandev_version` floor.
|
||||
- **Further fields on the projection or on an edge end.** Considered in Spec and
|
||||
deferred as a set: true `depends_on_count` and `blocks_count` beside the
|
||||
truncation flags, an end's `archived_at`, `identifier`, or `parent_id`, and the
|
||||
edge's own `created_at`. Every one of them is already loaded and then discarded
|
||||
by the derivation, so this is a scope decision rather than a cost one. Two
|
||||
things make them a later capability instead of a late addition here: each
|
||||
edge-end field must be placed on one side of
|
||||
`AC-PLUGINS-TASK-DEPS-003.8`'s redaction line, which reopens that whole table,
|
||||
and any new field reopens `AC-PLUGINS-TASK-DEPS-001.1` and
|
||||
`AC-PLUGINS-TASK-DEPS-001.2`. ADR 0043's additive-only rule means each can ship
|
||||
later without breaking a consumer; the carrying cost is that
|
||||
`AC-PLUGINS-TASK-DEPS-001.15`'s "within one host version" presence guarantee
|
||||
makes every later addition repeat the version-skew ambiguity this contract
|
||||
prices once in `AC-PLUGINS-TASK-DEPS-001.16` and
|
||||
`AC-PLUGINS-TASK-DEPS-001.17`.
|
||||
@@ -0,0 +1,281 @@
|
||||
---
|
||||
id: plugins-task-dependency-response-bounds
|
||||
title: Response bounds for the task dependency projection
|
||||
status: draft
|
||||
system: plugins
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Response bounds for the task dependency projection Requirements
|
||||
|
||||
## Overview
|
||||
|
||||
The [task dependency projection](task-dependency-projection.md) adds seven fields
|
||||
to the plugin and canvas task read model, two of them lists that name other
|
||||
tasks. That turns a task read into a read whose cost and whose payload both grow
|
||||
with data the caller did not ask for: the edges of every task on the page, and
|
||||
the far task rows behind those edges.
|
||||
|
||||
This document owns the third question the capability raises — **what one response
|
||||
may cost, and what happens when it cannot be delivered.** It is separated from
|
||||
the projection requirements because the two are reviewed against different risks.
|
||||
The projection decides what the graph says; this decides what happens when the
|
||||
graph is too expensive to compute or too large to send, and it is the part that
|
||||
must stay honest when a workspace is larger than the one the capability was
|
||||
demonstrated on.
|
||||
|
||||
`REQ-PLUGINS-TASK-DEPS-004` keeps the identity it was reviewed under; nothing is
|
||||
renumbered. The projection's criteria defer here wherever a query count, a list
|
||||
length, or a response size is at stake, and the sibling
|
||||
[dependency edge ends](task-dependency-edge-ends.md) defers here for the query
|
||||
bound its query-free admission test is written against.
|
||||
|
||||
## Terminology
|
||||
|
||||
- **Plugin task read:** As defined in
|
||||
[task dependency projection](task-dependency-projection.md) — any surface
|
||||
returning the plugin task read model, on either the gRPC or the canvas surface.
|
||||
- **Edge-end resolution:** The derivation step that loads the far task row behind
|
||||
each edge, in order to give an entry its `title` and `state`. Its input is the
|
||||
set of distinct edge ends across the whole response, deduplicated, not one
|
||||
task's list, and narrowed before the step runs by
|
||||
`AC-PLUGINS-TASK-DEPS-004.13`.
|
||||
- **Distinct edge ends:** The deduplicated set that edge-end resolution reads.
|
||||
**Serialized entries** are a different quantity: the entries a response writes
|
||||
across all its tasks, which repeat wherever two tasks share an end. The two are
|
||||
bounded separately and by different criteria; see
|
||||
`AC-PLUGINS-TASK-DEPS-004.14`.
|
||||
- **Host-parameter ceiling:** The database's maximum number of bind parameters in
|
||||
one statement, below which a batched `IN (...)` query must stay.
|
||||
- **Paginated flow:** A plugin task read accepting a page `limit`. The list reads
|
||||
are paginated; the single-task reads, the task-write RPCs, and the
|
||||
plugin-owned task-tree preview RPC are not.
|
||||
|
||||
## Requirements
|
||||
|
||||
### REQ-PLUGINS-TASK-DEPS-004: Bounded cost and bounded payload
|
||||
|
||||
**Intent:** Reading a page of tasks must not cost one dependency query per task;
|
||||
no task may become unreadable because too many tasks depend on it; and a response
|
||||
that cannot be delivered must fail in a way that says so, rather than arriving
|
||||
complete-looking and wrong.
|
||||
|
||||
#### Acceptance criteria
|
||||
|
||||
- **AC-PLUGINS-TASK-DEPS-004.1:** No dependency read shall be issued per task in
|
||||
the response. The derivation's own structural steps shall be a fixed number of
|
||||
reads that does not change with the number of tasks in the response: a response
|
||||
of many tasks carrying no edges shall cost the same reads as a response of one.
|
||||
The only read count permitted to vary is the batch count edge-end resolution
|
||||
requires under `AC-PLUGINS-TASK-DEPS-004.11`, which varies with the number of
|
||||
distinct edge ends and not with the number of tasks, and which on a paginated
|
||||
flow is therefore bounded by the maximum in `AC-PLUGINS-TASK-DEPS-004.10`
|
||||
divided by the host-parameter ceiling. Two responses resolving the same number
|
||||
of distinct edge ends shall cost the same reads whether they carry one task or
|
||||
many, with the single exception that a response carrying more tasks than the
|
||||
host-parameter ceiling allows in one statement chunks its task-keyed edge
|
||||
queries under `AC-PLUGINS-TASK-DEPS-004.11` — reachable only on the unpaginated
|
||||
preview flow, since every paginated flow is capped at 200 tasks. No criterion
|
||||
shall be read as requiring a single fixed read count across responses of
|
||||
differing edge volume or differing task count past that ceiling, which chunking
|
||||
makes impossible to hold.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.2:** The dependency derivation for a task list
|
||||
shall cover only the tasks in the returned page, not every task the filter
|
||||
matched before pagination.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.3:** `depends_on` and `blocks` shall each carry at
|
||||
most 512 entries for one task.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.4:** When a list is cut to that limit, the read
|
||||
shall report that list's truncation flag true and keep the first entries in the
|
||||
order defined by `AC-PLUGINS-TASK-DEPS-001.8`. Both flags shall be present on
|
||||
every task and false when no cut occurred.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.5:** A truncated list shall not change `blocked` or
|
||||
`blocked_reason`, which shall still account for every predecessor including the
|
||||
omitted ones.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.6:** When a canvas response carrying the projection
|
||||
exceeds the runtime response limit, the runtime shall return its existing
|
||||
bounded `response_too_large` error. That limit shall be stated as applying to
|
||||
every canvas response rather than to the list routes alone, because the runtime
|
||||
applies it to each response it writes. A smaller page `limit` is the documented
|
||||
remedy on the paginated flows only; `AC-PLUGINS-TASK-DEPS-004.9` governs the
|
||||
flows that accept no `limit`.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.7:** The gRPC surface's bound shall be stated, not
|
||||
assumed absent. A Host response over the receiving end's maximum receive size
|
||||
shall fail the whole RPC with `ResourceExhausted`, never a truncated or
|
||||
partially populated list, so a consumer never reads a short page as the end of
|
||||
the data. The remedy is the same smaller `limit` on the paginated flows only,
|
||||
and the documentation shall say the size belongs to the plugin's own client
|
||||
connection, not to this capability.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.8:** Neither bound shall be met by dropping,
|
||||
truncating, or redacting the projection to fit. `AC-PLUGINS-TASK-DEPS-004.3` is
|
||||
this capability's only size reduction; an undeliverable response fails rather
|
||||
than arriving silently incomplete.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.9:** The remedy in `AC-PLUGINS-TASK-DEPS-004.6` and
|
||||
`AC-PLUGINS-TASK-DEPS-004.7` shall not be claimed for a flow that accepts no
|
||||
page `limit`, and those flows shall be named rather than left to inference: the
|
||||
gRPC single-task read, the three gRPC task-write RPCs, the canvas single-task
|
||||
and update routes, and the plugin-owned task-tree preview RPC. On every one of
|
||||
them that returns exactly one task, the projection's contribution to the
|
||||
response shall be at most the two lists bounded by
|
||||
`AC-PLUGINS-TASK-DEPS-004.3`. That is a bound on **serialized entries**, and it
|
||||
shall be grounded on `AC-PLUGINS-TASK-DEPS-004.3` alone rather than on the
|
||||
distinct-end maximum of `AC-PLUGINS-TASK-DEPS-004.10`, from which those flows
|
||||
are exempt under that criterion. The contribution shall be stated as a byte
|
||||
figure in the documentation, and what it places on the gRPC surface shall be
|
||||
stated as a floor on the receiving end rather than as a guarantee this
|
||||
capability can enforce: a plugin whose client receive maximum is at or above the
|
||||
gRPC library default shall not be able to trip `AC-PLUGINS-TASK-DEPS-004.7`
|
||||
through the projection on such a flow, and a plugin that lowers that maximum
|
||||
below the stated figure may — a consequence of its own connection setting, which
|
||||
`AC-PLUGINS-TASK-DEPS-004.7` already assigns to it. No criterion shall claim the
|
||||
bound holds against a receive maximum the host does not set. The preview RPC
|
||||
shall be the one exception, because it returns an unpaginated task set: its
|
||||
response size and its edge-end total shall be bounded only by the size of the
|
||||
plugin-owned tree, and
|
||||
the documentation shall state that reducing that tree is the only lever on it.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.10:** The number of distinct edge ends one
|
||||
derivation resolves shall be bounded by a declared maximum **on every paginated
|
||||
flow**, and that maximum shall exceed twice the per-task limit in
|
||||
`AC-PLUGINS-TASK-DEPS-004.3`.
|
||||
A response whose task set would exceed it shall fail under
|
||||
`AC-PLUGINS-TASK-DEPS-004.6` or `AC-PLUGINS-TASK-DEPS-004.7`. Exceeding it
|
||||
shall not count as a derivation that cannot be completed under
|
||||
`AC-PLUGINS-TASK-DEPS-002.5`, and shall therefore never produce the withheld
|
||||
verdict of `AC-PLUGINS-TASK-DEPS-001.14`: such a response is undeliverable, not
|
||||
unknown, and a consumer reducing its page shall receive a real graph rather than
|
||||
a page uniformly reporting `blocked_reason: unknown`. The refusal shall reuse
|
||||
each surface's existing oversized-response error rather than introduce a code:
|
||||
`response_too_large` on the canvas surface and `ResourceExhausted` on the gRPC
|
||||
surface, returned before any task object is serialized, so a caller cannot
|
||||
receive a partial page alongside it.
|
||||
The flows named in `AC-PLUGINS-TASK-DEPS-004.9` **that return exactly one
|
||||
task** shall be exempt from this maximum. They accept no page `limit`, so the
|
||||
remedy the refusal assumes does not exist on them, and refusing there would make
|
||||
a task unreadable exactly because too many tasks name it — the outcome this
|
||||
requirement's Intent forbids. On an exempt flow the derivation shall resolve
|
||||
whatever `AC-PLUGINS-TASK-DEPS-004.13` leaves it, in batches under
|
||||
`AC-PLUGINS-TASK-DEPS-004.11`, and shall not refuse on end count; the response
|
||||
shall remain subject to the size bounds of `AC-PLUGINS-TASK-DEPS-004.6` and
|
||||
`AC-PLUGINS-TASK-DEPS-004.7`, which act on serialized entries under
|
||||
`AC-PLUGINS-TASK-DEPS-004.14` and which `AC-PLUGINS-TASK-DEPS-004.3` already
|
||||
bounds for one task. The cost this concedes is named in `## Out of scope`.
|
||||
The preview RPC, which `AC-PLUGINS-TASK-DEPS-004.9` names in the same list of
|
||||
unpaginated flows, shall **not** be exempt: it returns many tasks, its
|
||||
documented remedy of reducing the plugin-owned tree is a real lever, and
|
||||
refusing it leaves every task in that tree individually readable through an
|
||||
exempt flow, so no task becomes unreadable.
|
||||
The maximum's floor of twice `AC-PLUGINS-TASK-DEPS-004.3` is what keeps the
|
||||
paginated and exempt regimes agreeing in the ordinary case: after
|
||||
`AC-PLUGINS-TASK-DEPS-004.13`'s cut, a task holding no more predecessors than
|
||||
that limit contributes at most twice it, so a paginated page of one such task
|
||||
stays under the maximum and reads the same as the exempt single-task flow. The
|
||||
two can still disagree for a task that passed the predecessor limit on the
|
||||
incremental write path, which a paginated read at the minimum `limit` of 1 would
|
||||
refuse with no smaller `limit` left to retry. That residue shall not be left to
|
||||
inference: the documented remedy there is the single-task read, which is exempt
|
||||
and returns the same projection.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.11:** No statement in the derivation shall carry a
|
||||
bind-parameter count that grows without bound with its input. This binds **two**
|
||||
families of statement, not one, and naming only the first is the gap this
|
||||
criterion closes. Edge-end resolution shall split its distinct ends into batches
|
||||
at or below the host-parameter ceiling. The task-keyed edge queries — the
|
||||
batched predecessor and dependent reads, whose parameters are the response's
|
||||
task ids — shall be split the same way; that bound is unreachable on a paginated
|
||||
flow, where `AC-PLUGINS-TASK-DEPS-004.15`'s maximum of 200 tasks keeps them to
|
||||
one statement, and is reachable only on the unpaginated preview flow. Both shall
|
||||
reuse the chunking convention the repository already applies to its other
|
||||
batched id queries rather than introducing a second one. No derivation
|
||||
shall fail because a generated statement exceeded that ceiling, so none of
|
||||
`AC-PLUGINS-TASK-DEPS-002.5`'s failure paths shall be reachable by that route.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.12:** Splitting edge-end resolution into batches
|
||||
shall change neither the failure granularity nor the ordering of the result.
|
||||
The batches shall remain one logical step, so a failure in any one of them shall
|
||||
be the single edge-end-resolution failure of `AC-PLUGINS-TASK-DEPS-002.5` over
|
||||
the whole derivation and shall not become a narrower per-batch boundary under
|
||||
`AC-PLUGINS-TASK-DEPS-002.3`. Entry order shall continue to come from the edge
|
||||
queries of `AC-PLUGINS-TASK-DEPS-001.8` and shall not depend on which batch
|
||||
resolved an end. Because the batches are not one statement, the derivation's
|
||||
observation point shall be an interval rather than an instant; this is already
|
||||
permitted by `AC-PLUGINS-TASK-DEPS-005.4` and
|
||||
`AC-PLUGINS-TASK-DEPS-005.6`, and shall not be read as a stronger guarantee
|
||||
than either.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.13:** Edge-end resolution shall read only the ends
|
||||
whose detail the response can carry, except where another criterion requires a
|
||||
wider read. The **dependent** direction shall be ordered and cut to the limit in
|
||||
`AC-PLUGINS-TASK-DEPS-004.3` *before* resolution, because no criterion reads a
|
||||
dependent's far row to compute a verdict — `AC-PLUGINS-TASK-DEPS-001.7` gives
|
||||
dependent entries no resolution status — and because
|
||||
`AC-PLUGINS-TASK-DEPS-001.8`'s order and its tiebreak are both decidable from
|
||||
the edge rows alone, so the cut does not need the detail it is deciding about.
|
||||
The **predecessor** direction shall be resolved in full, because
|
||||
`AC-PLUGINS-TASK-DEPS-004.5` requires the verdict to account for every
|
||||
predecessor and a predecessor's `status` is read from its far row. One task's
|
||||
contribution to a derivation's distinct-end count shall therefore be its full
|
||||
predecessor count plus at most the limit in `AC-PLUGINS-TASK-DEPS-004.3`, not
|
||||
its full edge count.
|
||||
Because that cut precedes resolution, an end kept by it and then dropped as
|
||||
dangling under `AC-PLUGINS-TASK-DEPS-006.4` shall leave the list **shorter than
|
||||
the limit with its truncation flag still true**, and the entries the cut
|
||||
discarded shall not be back-filled in its place. A shorter-than-limit truncated
|
||||
list is therefore correct rather than a defect, and no criterion shall be read
|
||||
as requiring a truncated list to hold exactly the limit.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.14:** The quantity the response-size bounds of
|
||||
`AC-PLUGINS-TASK-DEPS-004.6` and `AC-PLUGINS-TASK-DEPS-004.7` act on shall be
|
||||
the number of entries a response **serializes**, which is not the distinct-end
|
||||
count bounded by `AC-PLUGINS-TASK-DEPS-004.10` and shall not be documented as
|
||||
though it were. Entries repeat across tasks that share an end, so one response
|
||||
may serialize as many as the page limit times twice the per-task limit of
|
||||
`AC-PLUGINS-TASK-DEPS-004.3` while resolving far fewer distinct ends. A response
|
||||
may therefore exceed a surface's size limit while staying under the distinct-end
|
||||
maximum, and shall then fail under `AC-PLUGINS-TASK-DEPS-004.6` or
|
||||
`AC-PLUGINS-TASK-DEPS-004.7` with the smaller-`limit` remedy. No criterion and
|
||||
no design prose shall state or imply that `AC-PLUGINS-TASK-DEPS-004.10` bounds
|
||||
response size, or that the two bounds engage at comparable payloads.
|
||||
- **AC-PLUGINS-TASK-DEPS-004.15:** The page `limit` that the remedy names shall
|
||||
have its accepted range, its default, and its out-of-range behaviour documented
|
||||
for each surface, because the two surfaces already differ and this capability
|
||||
changes neither. The canvas surface shall continue to **reject** a `limit`
|
||||
**supplied** outside 1 to 200, or non-numeric, with its existing
|
||||
invalid-request error, while the host's own page normalization shall continue to
|
||||
**clamp** a supplied out-of-range value into that range. The divergence shall be
|
||||
documented as applying to a supplied value only: an **absent** `limit` is not an
|
||||
error on either surface and shall continue to reach the host's default of 50. A
|
||||
consumer retrying with a smaller `limit` shall be able to predict which of the
|
||||
two behaviours it will get, and no criterion shall describe the remedy as though
|
||||
they were the same.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Paginating the preview RPC.** `AC-PLUGINS-TASK-DEPS-004.9` names it as the
|
||||
one flow with no applicable remedy rather than adding a page parameter to it.
|
||||
That RPC's request and response shape is owned by the plugin-owned task-tree
|
||||
contract, and changing it is a wire change this capability does not need: the
|
||||
projection inherits the tree's existing unboundedness rather than introducing
|
||||
it, and the same is already true of the linked pull requests attached there.
|
||||
- **A cost bound on a single-task read's own edges.** The exemption in
|
||||
`AC-PLUGINS-TASK-DEPS-004.10` concedes that a non-paginated single-task read of
|
||||
a task with an extreme predecessor count resolves that many ends, in
|
||||
`AC-PLUGINS-TASK-DEPS-004.11` batches, rather than refusing. This is deliberate,
|
||||
and it is the narrower of the two costs on offer: the alternative is precisely
|
||||
the unreadable task this requirement's Intent forbids, and the caller named that
|
||||
one task by id rather than asking for a page. The dependent direction — the one
|
||||
an arbitrary third party can grow without the task's consent, and the one the
|
||||
Intent is worded against — is cut before resolution by
|
||||
`AC-PLUGINS-TASK-DEPS-004.13`, so the residue is confined to a task's own
|
||||
declared predecessors, bounded at 512 on the full-set replacement write path and
|
||||
reachable past it only on the incremental add path.
|
||||
- **A time or memory budget per derivation.** The bounds here are counts —
|
||||
reads, entries, distinct ends, and bytes — because those are the values the
|
||||
contract can state and a test can assert. A latency budget would be an
|
||||
operational target, not a contract a consumer can rely on.
|
||||
- **Chunking as a substitute for the maximum.** `AC-PLUGINS-TASK-DEPS-004.11`
|
||||
keeps a statement legal; `AC-PLUGINS-TASK-DEPS-004.10` keeps the response
|
||||
affordable. Chunking alone would turn an oversized page into an unbounded number
|
||||
of small queries, which satisfies no bound and is the fault
|
||||
`AC-PLUGINS-TASK-DEPS-004.1` exists to prevent.
|
||||
- **A server-side cap that silently shrinks a page.** Excluded by
|
||||
`AC-PLUGINS-TASK-DEPS-004.8`. Returning fewer tasks than asked for, without an
|
||||
error, is the failure mode `AC-PLUGINS-TASK-DEPS-004.7` rejects for the gRPC
|
||||
surface, and it is no better on the canvas surface.
|
||||
@@ -0,0 +1,218 @@
|
||||
---
|
||||
id: plugins-task-dependency-edge-ends-design
|
||||
title: Dependency edge ends on the plugin data API
|
||||
status: draft
|
||||
system: plugins
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-003
|
||||
- REQ-PLUGINS-TASK-DEPS-006
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Dependency edge ends on the plugin data API System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns what an edge end is allowed to say: which ends a plugin task
|
||||
read may describe (`REQ-PLUGINS-TASK-DEPS-003`) and what an end reports when the
|
||||
response does not contain it (`REQ-PLUGINS-TASK-DEPS-006`). It is separated from
|
||||
the [task dependency projection](task-dependency-projection.md) because the two
|
||||
are reviewed against different risks, and because these rules are the part that
|
||||
must not be traded away when the projection's shape changes.
|
||||
|
||||
It does not own the projection's field set, its derivation, its ordering, its
|
||||
bounding rules, or its event behaviour. Those are the projection design's, and
|
||||
this design changes none of them: redaction empties two fields on an entry, and
|
||||
the dangling-edge drop removes an entry whose task no longer exists. Neither
|
||||
changes the verdict.
|
||||
|
||||
Adjacent contracts used but not owned:
|
||||
|
||||
- [Task dependency projection](task-dependency-projection.md) — the field set,
|
||||
the edge-end ref type, and the rule fixing which responses attach the
|
||||
projection.
|
||||
- [Response bounds](task-dependency-response-bounds.md) — the query bound this
|
||||
rule's query-free admission test is written against.
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md) — the
|
||||
canvas scope binding this rule evaluates against.
|
||||
- [Capability approval](capability-approval.md) — the effective-authority
|
||||
intersection behind `api_read:tasks`.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-PLUGINS-TASK-DEPS-003` | [Redaction rule](#redaction-rule) |
|
||||
| `REQ-PLUGINS-TASK-DEPS-006` | [Edge ends outside the response](#edge-ends-outside-the-response) |
|
||||
|
||||
## Redaction rule
|
||||
|
||||
The projection adds no capability. It is a field set on the already-approved
|
||||
`api_read:tasks` resource; adding fields changes neither a plugin manifest nor
|
||||
its recorded digest, so no re-approval is triggered and the effective-authority
|
||||
intersection is unchanged.
|
||||
|
||||
The one new authority question is the edge end. An end carries another task's
|
||||
title and state, and a task-scoped canvas is refused that task directly, so an
|
||||
unredacted ref would hand it resource content the scope denies. Redaction
|
||||
resolves it:
|
||||
|
||||
- The admission test for an end is the same scope predicate that decides direct
|
||||
readability, so there is one rule and no second notion of visibility to keep in
|
||||
step. It is evaluated **only over data the response has already loaded** -- a
|
||||
read per edge end would be a per-task query and would break the bound in
|
||||
`AC-PLUGINS-TASK-DEPS-004.1`. An end whose predicate inputs are not in hand is
|
||||
treated as not admitted, so the query bound and the scope rule fail in the same
|
||||
direction rather than trading against each other.
|
||||
- An end the scope would not admit keeps `id` and, on a predecessor, `status`.
|
||||
`title` and `state` are emptied.
|
||||
- `status` is retained deliberately. It is the three-value resolution verdict,
|
||||
and the aggregate it feeds, `blocked_reason`, is already about the bound task
|
||||
and already visible. `state` is dropped because it is the richer raw task
|
||||
state, which is a genuine addition about a task the scope denies.
|
||||
- The entry is emptied rather than dropped, so the edge's existence and the list
|
||||
length stay truthful. A canvas can already infer that an edge exists from
|
||||
`blocked`, so hiding the entry would make the count wrong without hiding
|
||||
anything.
|
||||
- No flag is added to tell a redacted entry apart, and consumers are documented
|
||||
as rendering an empty `title` as an unlabelled node. The design does **not**
|
||||
claim the two are indistinguishable: a task that genuinely has an empty title
|
||||
still reports a non-empty `state`, and a redacted entry reports neither. The
|
||||
flag is omitted for vocabulary, not concealment -- its false value invites
|
||||
reading as an authority statement, and what it carries is diagnostic only.
|
||||
|
||||
By scope kind, the query-free rule resolves as follows, and admits no other end
|
||||
(`AC-PLUGINS-TASK-DEPS-003.8`):
|
||||
|
||||
| Scope | Admits | Why it is decidable without a read |
|
||||
| --- | --- | --- |
|
||||
| Instance | Every end | The predicate admits every task; nothing to check. |
|
||||
| Workspace | An end whose workspace is known equal to the bound workspace | The ref carries the end's workspace; see below. |
|
||||
| Task | No end | An end is never the bound task itself. |
|
||||
| Repository | An end also returned in the response as a directly readable task | The predicate needs the task's repository set, which is a per-task read. |
|
||||
| Session | An end also returned in the response as a directly readable task | The predicate needs the task's session list, which is a per-task read. |
|
||||
|
||||
**Where the end's workspace comes from.** The admission test may issue no read,
|
||||
so the workspace must arrive with the derivation or not at all. It does: resolving
|
||||
edge-end detail already loads the full task row for every end in the batch in one
|
||||
query, and that row carries the workspace id, which the derivation discards today
|
||||
when it collapses the row into the slim ref. Carrying it costs no extra query, and
|
||||
is change 4 of the five listed under
|
||||
[Edge order](task-dependency-projection.md#edge-order).
|
||||
|
||||
**Which type carries it, and where it is dropped.** The carrier is the edge-end
|
||||
ref type itself, at every in-process layer: the shared derivation's ref struct
|
||||
gains a workspace-id field, and the mirrored SDK ref type declares the same field.
|
||||
It is dropped at exactly two points, both of them encoders:
|
||||
|
||||
1. The ref's `toProto` mapping, which assigns no proto field for it, because the
|
||||
ref message declares none.
|
||||
2. The canvas JSON adapter's field set, which emits `id`, `title`, `state` and
|
||||
`status` for an entry and nothing else.
|
||||
|
||||
So "never encoded on either wire" means dropped on the way *out* of the SDK type,
|
||||
not dropped *from* it. The canvas adapter sits downstream of the SDK mapping and
|
||||
reads the workspace off the SDK ref it already holds, which is what makes
|
||||
`AC-PLUGINS-TASK-DEPS-003.8`'s workspace row decidable at the one layer that holds
|
||||
the canvas binding; it then drops the field while building the JSON entry. A
|
||||
design that dropped the field at the SDK boundary instead would leave the adapter
|
||||
with no predicate input at all.
|
||||
|
||||
Naming the carrier matters because the alternative is not a slower implementation
|
||||
but a quietly different contract: without it,
|
||||
`AC-PLUGINS-TASK-DEPS-003.3`'s "predicate inputs unavailable, so not admitted"
|
||||
becomes the only legal outcome, workspace scope collapses into the repository and
|
||||
session rule, and every out-of-page end is redacted while every acceptance
|
||||
criterion still passes as literally written.
|
||||
|
||||
Publishing the field instead of dropping it is rejected for the reason redaction
|
||||
exists: it would hand every canvas the workspace of tasks it cannot read.
|
||||
|
||||
The repository and session rows are the load-bearing ones. Their predicates
|
||||
resolve a task's repositories and sessions with a read *per task*, which the
|
||||
admission test may not issue, so the only end they can admit for free is one the
|
||||
response already proved readable by including it. This under-admits: an end the
|
||||
canvas could fetch directly may still be redacted. That is the correct direction
|
||||
for a scope rule to err, and the cost is a title, not an edge -- the entry, the
|
||||
count, and the verdict are unaffected.
|
||||
|
||||
The workspace row previously read "redacts nothing, since an edge cannot cross a
|
||||
workspace". That justification does not hold. The cross-workspace check lives in
|
||||
the *write* validator, and it does not reject a pair in which either end carries
|
||||
no workspace, so a stored edge can name an end whose workspace is empty or
|
||||
different. The validator is therefore not a blanket exemption
|
||||
(`AC-PLUGINS-TASK-DEPS-003.4`), and workspace scope admits on known equality
|
||||
rather than on assumption.
|
||||
|
||||
The gRPC surface redacts nothing (`AC-PLUGINS-TASK-DEPS-003.5`). It has no canvas
|
||||
scope to redact against and no narrower binding to enforce. The reason is *not* a
|
||||
workspace filter: `Tasks().Get` applies none at all -- it resolves a task by id
|
||||
and returns it -- and the write and preview RPCs reach tasks by id too, so only
|
||||
`Tasks().List` narrows by workspace. The correct ground is disclosure parity. A
|
||||
caller holding `api_read:tasks` can already read any of those tasks directly by
|
||||
id, so an edge end carrying that task's title and state reveals nothing the
|
||||
surface withholds; redacting there would cost a title and buy no
|
||||
confidentiality.
|
||||
|
||||
## Edge ends outside the response
|
||||
|
||||
A graph built from one page has ends the page does not contain. Three cases, all
|
||||
of which resolve without a second contract:
|
||||
|
||||
- **Archived end.** An archived predecessor is `pending`, never `resolved`,
|
||||
because archival is neither success nor failure. The default task list hides
|
||||
archived tasks, so such an end is named by the ref but absent from `items`. A
|
||||
non-redacted ref carries id, title, and state, which is enough to render the
|
||||
node; a consumer needing the whole task reads it by id or lists with
|
||||
`include_archived=true`. A read by id is not filtered by archived state, so
|
||||
that path works.
|
||||
- **Out-of-page end.** Same shape as the archived case, for an end that simply
|
||||
fell on another page. No special handling.
|
||||
- **Removed end.** An edge whose predecessor row is gone is dropped from
|
||||
`depends_on` and does not contribute to the verdict, so a failed cleanup cannot
|
||||
block a dependent forever.
|
||||
|
||||
The dependent direction is not symmetric today, and this is change 2 of the
|
||||
four the [projection design](task-dependency-projection.md#edge-order)
|
||||
enumerates in the shared derivation. The upstream dangling-edge drop is
|
||||
applied to the predecessor list only -- its regression test asserts on that
|
||||
list alone -- so an edge whose *dependent* row is gone still yields an entry,
|
||||
carrying the derivation's internal `missing` marker as its status. Emptying
|
||||
`status` on `blocks` entries at the mapping layer keeps that marker off the
|
||||
wire and closes the public vocabulary, but it leaves a ref with a dead id in
|
||||
the list, which `AC-PLUGINS-TASK-DEPS-006.4` forbids. The drop must therefore
|
||||
be applied to both directions in the shared derivation, and its regression test
|
||||
extended to assert on `Blocks` as well.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Scope redaction: per scope kind against the table above -- instance admitting
|
||||
every end, workspace admitting only a known-equal workspace (including an end
|
||||
whose workspace is empty, which is redacted), task scope redacting every end,
|
||||
repository and session admitting only an end the same response returns as a
|
||||
task. Redaction leaves the verdict, count, and order unchanged, and issues no
|
||||
additional read, asserted with the query-count test's counting fake.
|
||||
- The workspace carrier survives to the adapter: a canvas-surface read on a
|
||||
workspace-scoped canvas admits an out-of-page end in the bound workspace and
|
||||
redacts one outside it, which fails if either encoder's drop point is moved
|
||||
upstream of the adapter.
|
||||
- Neither wire carries the workspace of an edge end: the proto ref message has no
|
||||
such field, and the canvas JSON entry has exactly the four documented keys.
|
||||
- The gRPC surface redacts nothing: the same task read over gRPC returns every
|
||||
end's `title` and `state`, on the single-task read as well as the list read.
|
||||
- Dangling ends in both directions: an edge whose predecessor row is gone and one
|
||||
whose dependent row is gone are both omitted, and no entry carries the internal
|
||||
`missing` marker.
|
||||
- Vocabulary: no entry reports a non-empty status outside the closed set,
|
||||
including for an absent end; `blocks` entries report an empty one.
|
||||
- Archived predecessor reports `pending`, appears in the ref, and is absent from
|
||||
a default list.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0043 — Plugin host data API](../../../decisions/0043-plugin-host-data-api.md)
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md)
|
||||
- [Task dependencies](../../tasks/system-design/task-dependencies.md)
|
||||
@@ -0,0 +1,518 @@
|
||||
---
|
||||
id: plugins-task-dependency-projection-design
|
||||
title: Task dependency projection on the plugin data API
|
||||
status: draft
|
||||
system: plugins
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-001
|
||||
- REQ-PLUGINS-TASK-DEPS-002
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Task dependency projection on the plugin data API System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns how the plugin task read model carries task dependency state:
|
||||
the wire contract on the gRPC plugin surface and the canvas JSON protocol, the
|
||||
batched derivation step, the ordering, where the projection is attached, and the
|
||||
refresh rule.
|
||||
|
||||
It does not own what an edge *end* may say. Scope redaction
|
||||
(`REQ-PLUGINS-TASK-DEPS-003`) and ends the response does not contain
|
||||
(`REQ-PLUGINS-TASK-DEPS-006`) are owned by
|
||||
[dependency edge ends](task-dependency-edge-ends.md), which this design's field
|
||||
set and derivation feed.
|
||||
|
||||
It does not own what a response may cost or how large it may be. The query
|
||||
bound, the edge-end fan-out, the per-task list cut, and the response-size bound
|
||||
(`REQ-PLUGINS-TASK-DEPS-004`) are owned by
|
||||
[response bounds](task-dependency-response-bounds.md). This design fixes the set
|
||||
of objects a response serializes; that one counts derivations and bytes over it.
|
||||
|
||||
It does not own the refresh contract. When a consumer re-reads, what the events
|
||||
carry, and the no-lock and no-snapshot rules (`REQ-PLUGINS-TASK-DEPS-005`) are
|
||||
owned by [refresh contract](task-dependency-refresh.md), which this design's
|
||||
field set and derivation feed.
|
||||
|
||||
It does not own dependency semantics. The derivation of `blocked`,
|
||||
`blocked_reason`, and the two edge lists, the resolution vocabulary, the
|
||||
fail-closed rule, and the deferred-launch intent are owned by
|
||||
[task dependencies](../../tasks/system-design/task-dependencies.md). This design
|
||||
consumes that derivation and adds no second definition of it.
|
||||
|
||||
Adjacent contracts used but not owned:
|
||||
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md) — the
|
||||
canvas browser data protocol, its scope binding, response limits, and event
|
||||
stream.
|
||||
- [Capability approval](capability-approval.md) — the effective-authority
|
||||
intersection behind `api_read:tasks`.
|
||||
- [Task completion](../../tasks/system-design/task-completion.md) — what makes a
|
||||
predecessor resolved.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-PLUGINS-TASK-DEPS-001` | [Data and contracts](#data-and-contracts), [Control flow](#control-flow) |
|
||||
| `REQ-PLUGINS-TASK-DEPS-002` | [Failure and recovery](#failure-and-recovery) |
|
||||
|
||||
## Components and responsibilities
|
||||
|
||||
The projection crosses four existing layers. Each keeps its current
|
||||
responsibility; none gains a dependency query of its own.
|
||||
|
||||
| Component | Responsibility |
|
||||
| --- | --- |
|
||||
| Task service dependency derivation | Sole producer of the derived view for a batch of tasks. Already batched for the Kanban board. |
|
||||
| Plugin host task reader | Requests one batched derivation per response, after pagination, and stamps it onto the SDK task DTOs. Mirrors how linked pull requests are attached. |
|
||||
| Plugin SDK task type and proto mapping | Carries the projection as additive, hand-mapped fields with a stable wire form in both directions. |
|
||||
| Canvas JSON adapter | Projects the SDK task into the browser JSON shape and applies scope redaction, because it is the only layer that holds the canvas binding. |
|
||||
|
||||
There is no single funnel today, and the design does not pretend otherwise. It
|
||||
states the attachment point **once**, as a property of the response rather than
|
||||
of a call site:
|
||||
|
||||
> A response carries the projection on every plugin task read model object it
|
||||
> serializes, and on no other. A task read whose object never reaches a wire
|
||||
> encoder does not derive it.
|
||||
|
||||
The rule is stated on the response because the call sites are not in one-to-one
|
||||
correspondence with responses: one route can read three task objects and return
|
||||
one, and one host method serves both kinds of caller.
|
||||
|
||||
The test a builder applies is mechanical, because "serializes" has exactly three
|
||||
implementations. A task object derives the projection if and only if it reaches
|
||||
`toProto` or `tasksToProto` on the gRPC side (`pkg/pluginsdk/host.go`), or
|
||||
`webAppTaskFromSDK` on the canvas side
|
||||
(`internal/plugins/webapp_protocol_json.go:120`). Everything else read on the way
|
||||
is an intermediate.
|
||||
|
||||
Applied today, the rule resolves to nine routes: the six gRPC handlers on
|
||||
`grpcHostServer` -- `ListTasks`, `GetTask`, `CreateTask`, `UpdateTask`,
|
||||
`MoveTask` and `PreviewPluginOwnedTaskTree` -- and three canvas routes, the task
|
||||
list, the single-task `GET`, and the `PATCH`. The canvas list route reaches the
|
||||
encoder from **two** sources: its task-scope branch serializes a single task
|
||||
obtained from `Tasks().Get` and never calls `Tasks().List` at all
|
||||
(`internal/plugins/webapp_protocol_data.go:43`), which a checklist written from
|
||||
host methods misses entirely.
|
||||
|
||||
That list is *derived from* the rule and does not replace it. If a later call
|
||||
site makes the list stale, the rule governs and the list is what is wrong. This
|
||||
enumeration has already been incomplete twice, so the contract may not depend on
|
||||
it being complete a third time.
|
||||
|
||||
## Data and contracts
|
||||
|
||||
### Task read model additions
|
||||
|
||||
Five fields plus two bounding flags, on the gRPC `Task` message, the SDK `Task`
|
||||
type, and the canvas task JSON, with one name per field across all three:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `blocked` | bool | Derived blocked verdict for this task. |
|
||||
| `blocked_reason` | string | `pending`, `failed`, `unknown`, or empty. Empty if and only if `blocked` is false. |
|
||||
| `depends_on` | repeated ref | Direct predecessors. |
|
||||
| `blocks` | repeated ref | Direct dependents. |
|
||||
| `depends_on_truncated` | bool | The predecessor list was cut to the per-task limit. |
|
||||
| `blocks_truncated` | bool | The dependent list was cut to the per-task limit. |
|
||||
| `start_when_unblocked` | bool | The task's stored chain-launch intent, reported raw. |
|
||||
|
||||
An edge-end ref carries `id`, `title`, `state`, and `status`:
|
||||
|
||||
- `status` on a `depends_on` entry is exactly `resolved`, `failed`, or
|
||||
`pending`.
|
||||
- `status` on a `blocks` entry is always empty. Resolution is a property of a
|
||||
predecessor, so a dependent has none. This diverges from the Kanban board wire
|
||||
shape, which copies a status onto `blocks` entries and can emit the derivation's
|
||||
internal `missing` marker there; emptying it closes the public vocabulary and
|
||||
removes that leak.
|
||||
|
||||
The ref type is a new named message on the proto surface so both lists share one
|
||||
shape, and a mirrored SDK type with explicit `toProto` and `fromProto`
|
||||
functions, per ADR 0043's hand-mapped-DTO rule.
|
||||
|
||||
### Presence and the absent-versus-empty rule
|
||||
|
||||
All seven fields are always populated on every returned task, including a task
|
||||
with no edges. This is the one intentional departure from the Kanban and
|
||||
WebSocket task payload, where an absent projection means "not computed" and an
|
||||
empty list means "no edges".
|
||||
|
||||
The departure is safe only because the plugin surface always computes the
|
||||
projection, so absence never occurs within one host version. It follows the
|
||||
in-repo precedent for derived task-DTO booleans always serialized rather than
|
||||
omitted when false -- `is_ephemeral`, `autopilot`, `wip_admitted` -- for the same
|
||||
reason: a missing key is indistinguishable from false.
|
||||
|
||||
On the proto surface, `blocked`, the two flags, and `start_when_unblocked` are
|
||||
plain `bool` and `blocked_reason` is a plain `string`; the always-computed rule
|
||||
is what makes their zero values meaningful rather than ambiguous. In the canvas
|
||||
JSON, none of the seven keys is omitted when empty, for the same reason.
|
||||
|
||||
The two lists need one more thing said about them, because "not omitted" is not
|
||||
enough on that surface: an empty `depends_on` or `blocks` is encoded as `[]`,
|
||||
never `null` (`AC-PLUGINS-TASK-DEPS-001.18`). A null would be exactly the third
|
||||
state the presence rule exists to forbid, and it is the *default* encoding of an
|
||||
unallocated list, so the adapter must allocate an empty one rather than pass
|
||||
through whatever the derivation left. The neighbouring task fields on this
|
||||
surface are declared `omitempty`, which makes the omission trap live too; both
|
||||
are avoided by the same explicit construction. This binds the encoder, not the
|
||||
derivation, and the withheld verdict is encoded the same way.
|
||||
|
||||
**What `start_when_unblocked` reads.** `models.HasStartWhenUnblockedIntent`, which
|
||||
returns the `start_when_unblocked` key of the task's `deferred_launch` metadata
|
||||
record. That key separates a dependency-chain intent from a WIP-overflow one,
|
||||
which share the record, so the projection calls the accessor rather than testing
|
||||
the record's presence. `RemoveDependency` does not clear the record, which is why
|
||||
`AC-PLUGINS-TASK-DEPS-001.10` reports the stored value rather than re-judging it
|
||||
against the current edges.
|
||||
|
||||
### Wire compatibility
|
||||
|
||||
Field additions only, per ADR 0043's additive-only rule. New proto field numbers
|
||||
continue past the highest number currently assigned on the `Task` message; no
|
||||
existing number, name, or type changes, and no field is removed or renumbered. A
|
||||
plugin built against the previous contract ignores the new fields.
|
||||
|
||||
A plugin built against the new contract reading an older host is the case that
|
||||
needs care, and it is not benign. proto3 gives these scalars no field presence,
|
||||
so the older host's response decodes as `blocked: false`, empty lists, both
|
||||
flags false -- a well-formed "not blocked, no edges" answer, and the exact
|
||||
opposite of the withheld verdict. The client cannot tell it from a real one by
|
||||
looking at the fields, so the presence guarantee holds within one host version
|
||||
only (`AC-PLUGINS-TASK-DEPS-001.15`).
|
||||
|
||||
**What separates them is the install-time floor, not a runtime check**
|
||||
(`AC-PLUGINS-TASK-DEPS-001.16`). There is no runtime signal to consult and none
|
||||
is added: the gRPC handshake's protocol version is a fixed constant, the canvas
|
||||
context's protocol version is likewise fixed and does not change here, and the
|
||||
capability set is unchanged because the projection is a field set on the
|
||||
already-approved `api_read:tasks` resource. Nothing on either surface reports the
|
||||
host's feature set. The mechanism that does exist is the manifest's
|
||||
`min_kandev_version`, compared against the running host during install and
|
||||
rejecting an older one outright with no partial registration
|
||||
(`CheckMinimumKandevVersion`,
|
||||
`internal/plugins/manifest/semver.go:12-28`, called from
|
||||
`service_install.go:259`). That check is capability-**agnostic**: it reads one
|
||||
declared floor and compares it, and knows nothing about which capabilities the
|
||||
manifest requests. A consumer that must not misread the ambiguous bytes declares
|
||||
the first host release carrying this capability there.
|
||||
|
||||
The `api_read:messages` floor is **not** that mechanism and must not be copied as
|
||||
though it were. It is capability-**keyed**, runs at `Manifest.Validate()` time
|
||||
against a hardcoded constant, and never consults the running host
|
||||
(`RequiresMessagesCapabilityMinimum` / `validateCapabilityMinimumVersions`,
|
||||
`internal/plugins/manifest/min_version_policy.go:11-28`). Building that shape for
|
||||
`api_read:tasks` is precisely what the next paragraph forbids.
|
||||
|
||||
That floor is declared by the author, not enforced per capability
|
||||
(`AC-PLUGINS-TASK-DEPS-001.17`): a capability-keyed minimum on `api_read:tasks`
|
||||
would reject every already-installed plugin that reads tasks and wants nothing to
|
||||
do with dependencies. The residue is honest and documented -- a plugin that omits
|
||||
the floor, or is sideloaded past the install path, receives the ambiguous bytes
|
||||
with no signal. Adding a wrapper message to manufacture field presence was
|
||||
rejected separately: it would change the field shape for every consumer to serve
|
||||
a check the install gate already answers.
|
||||
|
||||
The canvas protocol version does not change: the browser data protocol already
|
||||
documents its task object as a field list, and this is an addition to that list.
|
||||
A canvas ships inside a plugin package, so the same manifest floor covers it.
|
||||
|
||||
### Edge order
|
||||
|
||||
`depends_on` is ordered by edge creation time ascending, tiebroken by
|
||||
predecessor task id ascending. `blocks` is ordered by edge creation time
|
||||
ascending, tiebroken by dependent task id ascending. The tiebreak column is the
|
||||
far end's task id, which is unique within one list because an edge is keyed by
|
||||
the pair, so both orders are total.
|
||||
|
||||
In storage those are `task_blockers.created_at` for both lists, tiebroken by
|
||||
`task_blockers.blocker_task_id` for `depends_on` and by `task_blockers.task_id`
|
||||
for `blocks`. The batched predecessor query selects neither column today, so
|
||||
establishing the order means adding the `ORDER BY`, not sorting after the read.
|
||||
|
||||
The `blocks` direction already has exactly this order in its batched query. The
|
||||
`depends_on` direction does not: the batched predecessor query has no `ORDER BY`
|
||||
at all, so today's order is whatever the storage engine returns, even though the
|
||||
Tasks system design states predecessors are listed in creation order. Satisfying
|
||||
`AC-PLUGINS-TASK-DEPS-001.8` therefore requires that ordering
|
||||
to be established in the shared derivation.
|
||||
|
||||
That is a change to a shared path and also affects the Kanban board's order.
|
||||
It is a correction toward an already-frozen Tasks contract rather than a new
|
||||
contract, so it belongs in the shared derivation and not in a plugin-only
|
||||
re-sort: a plugin-only sort would leave the two surfaces disagreeing about an
|
||||
order the Tasks design has already fixed. Implementation must treat the shared
|
||||
derivation as the ordering authority and flag the board-visible effect.
|
||||
|
||||
This is one of **five** changes this design requires in the shared derivation.
|
||||
They are enumerated here because Build plans its work orders off this list, and an
|
||||
undercount silently under-scopes the Tasks-owned half of the capability:
|
||||
|
||||
1. **Edge order**, above: an `ORDER BY` on the batched predecessor query.
|
||||
2. **Dangling-edge drop in the dependent direction**, in the sibling's
|
||||
[Edge ends outside the response](task-dependency-edge-ends.md#edge-ends-outside-the-response).
|
||||
3. **Fail closed on every derivation-failure path**, in
|
||||
[Failure and recovery](#failure-and-recovery). Three of the four do not today.
|
||||
4. **Carry the far end's workspace id on the edge-end ref**, in the sibling's
|
||||
[Redaction rule](task-dependency-edge-ends.md#redaction-rule), so scope
|
||||
admission stays query-free.
|
||||
5. **Chunk the derivation's batched id queries** -- edge-end resolution, and the
|
||||
task-keyed predecessor and dependent queries on the unpaginated preview flow --
|
||||
in the sibling's
|
||||
[Edge-end fan-out](task-dependency-response-bounds.md#edge-end-fan-out), so
|
||||
neither family can exceed the database's bind-parameter ceiling.
|
||||
|
||||
All five are corrections toward, or additive reads on, the existing Tasks
|
||||
contract; all five are board-visible; none needs a migration; and all five must be
|
||||
planned as work on the Tasks-owned derivation rather than as plugin-local code.
|
||||
|
||||
## Control flow
|
||||
|
||||
For a list read:
|
||||
|
||||
1. The host reader resolves scope, fetches, filters, and sorts tasks as today.
|
||||
2. The page is cut to the requested limit.
|
||||
3. On the canvas list route, the scope filter runs here, and the steps below see
|
||||
only its survivors.
|
||||
4. The remaining tasks are mapped to SDK DTOs, and one batched dependency
|
||||
derivation runs for exactly their task ids.
|
||||
5. The derived view is stamped onto each DTO, with per-list bounding applied.
|
||||
6. For a canvas request, the JSON adapter projects each DTO and redacts
|
||||
out-of-scope edge ends.
|
||||
|
||||
For a single-task read, and for the task object returned by a write, steps 4 to
|
||||
6 run for that one task. For the plugin-owned task-tree preview they run once
|
||||
over the whole returned tree: one derivation, not one per task, and the tree is
|
||||
not paginated, so [response bounds](task-dependency-response-bounds.md#response-bounds-by-flow)
|
||||
states what bounds that response and why the smaller-`limit` remedy is the one
|
||||
remedy that does not apply to it. The derivation for a write response runs after
|
||||
the write commits, so the response reflects the post-write state.
|
||||
|
||||
It is not, however, a snapshot taken atomically with the write. The derivation
|
||||
takes no dependency mutation lock (see
|
||||
[refresh contract](task-dependency-refresh.md#events-and-refresh)),
|
||||
so the observation point is the derivation instant, and an edge mutated between
|
||||
commit and derivation is permitted to appear in the response. This is the
|
||||
`AC-PLUGINS-TASK-DEPS-001.13` contract: the write response matches what a read
|
||||
would report at that instant, not what the write itself established. A consumer
|
||||
needing a later state refetches on signal.
|
||||
|
||||
Ordering of steps 2 and 4 is load-bearing. Deriving before pagination would fan
|
||||
the derivation out over every task the filter matched, which is the fault the
|
||||
pull-request attachment step already avoids by attaching after pagination.
|
||||
|
||||
The canvas list route applies its own scope filter after the host has paginated,
|
||||
and **the derivation runs after that filter**. That is what makes the attachment
|
||||
rule's second sentence true here: a task `webAppTaskMatches` drops never reaches
|
||||
`webAppTaskFromSDK`, so it derives nothing. Implementation collects the surviving
|
||||
tasks first and derives over that slice, rather than deriving over the host's
|
||||
page inside the existing filter-and-encode loop
|
||||
(`internal/plugins/webapp_protocol_data.go:56-68`). Filtering first costs
|
||||
`AC-PLUGINS-TASK-DEPS-004.1` nothing: the predicate is query-free in instance,
|
||||
workspace, task and repository scope, and in session scope it already issues a
|
||||
per-task session read this capability neither adds nor removes, which is not a
|
||||
dependency read. Deriving over the host's page would also satisfy
|
||||
`AC-PLUGINS-TASK-DEPS-004.2`, but it contradicts the rule and spends the
|
||||
derivation on tasks the canvas is about to drop.
|
||||
|
||||
### Reads whose task object is discarded
|
||||
|
||||
`Tasks().Get` is the method that makes the response-level rule necessary rather
|
||||
than merely tidy. It has six callers, and only two of them serialize the task
|
||||
they read:
|
||||
|
||||
| Caller | Uses the task for | Serializes it |
|
||||
| --- | --- | --- |
|
||||
| `listWebAppTasks`, task-scope branch (`:43`) | the response itself | Yes |
|
||||
| `getWebAppTask` (`:76`) | the response itself | Yes |
|
||||
| `updateWebAppTask` (`:108`) | binding preflight, then writes and returns the written object | No |
|
||||
| `sendWebAppMessage` (`:166`) | binding preflight; returns no task | No |
|
||||
| `listWebAppWorkflows` (`:209`) | reads `WorkflowID` to filter workflows | No |
|
||||
| `listWebAppWorkflowSteps` (`:249`) | reads `WorkflowID` to authorize | No |
|
||||
|
||||
All six are in `internal/plugins/webapp_protocol_data.go`. An earlier draft named
|
||||
only two of the four that discard -- which is why the contract is the rule above
|
||||
and not a list, since a seventh caller would make any replacement list wrong
|
||||
again. Under the rule the four need no enumeration, because none reaches an
|
||||
encoder.
|
||||
|
||||
**The consequence for placement.** The projection therefore may **not** be
|
||||
attached unconditionally inside `Tasks().Get`: four of its six callers would
|
||||
derive for a response that carries no projection, and the `PATCH` route would
|
||||
derive twice. Two shapes satisfy the rule and either is acceptable -- attach in
|
||||
the responders that serialize, or keep a non-attaching lookup for the preflights
|
||||
and attach in `Tasks().Get` for the rest. What the design forbids is the third
|
||||
shape: the helper inside `Tasks().Get`, with the preflight callers expected not
|
||||
to mind.
|
||||
|
||||
**The `PATCH` route derives once.** Its body declares `title`, `description`,
|
||||
`state` and `workflow_step_id` as independently optional with no mutual
|
||||
exclusivity, so one valid body naming both a title and a step takes **both**
|
||||
write branches -- `Tasks().Update` (`:124`) then `Tasks().Move` (`:135`) -- and
|
||||
returns one object while discarding the other (`:147`). One response, one
|
||||
derivation, on the object serialized.
|
||||
|
||||
## Failure and recovery
|
||||
|
||||
One verdict covers every path on which the projection is unavailable to the
|
||||
caller. The **withheld verdict** is `blocked: true`, `blocked_reason: unknown`,
|
||||
both lists empty, both truncation flags false, and `start_when_unblocked` false
|
||||
(`AC-PLUGINS-TASK-DEPS-001.14`). It reuses the derivation's existing `unknown`
|
||||
vocabulary rather than adding a value, so no consumer needs a new branch.
|
||||
|
||||
The upstream derivation already fails closed this way on a predecessor read
|
||||
error. The plugin surface preserves that verdict verbatim, and extends it to the
|
||||
paths the derivation never sees:
|
||||
|
||||
- A failed derivation does not fail the task read. The task is returned with the
|
||||
withheld verdict and every other field intact.
|
||||
- A failed derivation is not silently degraded to empty. This is the deliberate
|
||||
difference from the pull-request attachment step, which returns an empty list
|
||||
on a failed lookup: an absent pull request is a plausible true answer, while an
|
||||
empty edge list is indistinguishable from a real answer.
|
||||
|
||||
Four paths produce it, enumerated so none is discovered later as a degraded
|
||||
empty list: a failed read of the predecessor direction,
|
||||
a failed read of the dependent direction, a failed resolution of edge-end
|
||||
detail, and a derivation source absent or not wired to the plugin host at all.
|
||||
There is no configuration in which tasks silently read as unblocked.
|
||||
|
||||
**Only the first of the four fails closed today**, which is why this is change 3
|
||||
of the five listed under [Edge order](#edge-order). The dependent-direction read
|
||||
error is swallowed into an empty map, and the derivation then returns a real,
|
||||
non-`unknown` verdict computed from the predecessor tally alone; an edge-end batch
|
||||
error marks every unresolved end `pending`, which reads as a live predecessor
|
||||
rather than an unknown one; and an absent derivation source returns an empty map,
|
||||
which reads as `blocked: false` on every task. Each is exactly the "degraded to
|
||||
empty" this section forbids.
|
||||
|
||||
The withheld verdict is **whole-task and indivisible**: a failure on any of the
|
||||
four paths yields every field of `AC-PLUGINS-TASK-DEPS-001.14` together. A
|
||||
dependent-direction failure does not empty `blocks` while keeping a real
|
||||
`blocked` -- that publishes a verdict derived from half a read, the
|
||||
confidently-wrong answer the requirement exists to prevent.
|
||||
|
||||
A fifth path is authority rather than failure, and applies only where a response
|
||||
is returned at all. A surface gated on `api_read:tasks` -- the read RPCs and the
|
||||
canvas data routes -- keeps rejecting a caller without it outright; the withheld
|
||||
verdict never becomes the body of a call that should have been refused
|
||||
(`AC-PLUGINS-TASK-DEPS-001.11`). It applies where the read model is returned
|
||||
behind a *different* gate: the gRPC task-write RPCs gate on the write capability,
|
||||
granted independently of the read one, so a caller holding only `api_write:tasks`
|
||||
would otherwise read the graph through the write response. The projection is
|
||||
therefore stamped behind the read-capability check on every surface returning the
|
||||
read model, not only the read RPCs. The shape is uniform on purpose: a distinct
|
||||
"forbidden" projection would tell a caller there was something to withhold.
|
||||
|
||||
Failure granularity follows the derivation's batch boundaries, not the task. A
|
||||
failure that affects the batched read yields the withheld verdict for every task
|
||||
in that batch; a failure confined to one task's edge-end resolution yields it for
|
||||
that task alone. Per-task isolation of a batch-wide failure is deliberately not
|
||||
promised, because detecting it would need the per-task queries that
|
||||
`AC-PLUGINS-TASK-DEPS-004.1` forbids.
|
||||
|
||||
|
||||
## Persistence
|
||||
|
||||
None. No table, column, migration, or cache is added. The projection is derived
|
||||
on every read, which is the invariant the Tasks design exists to protect: a
|
||||
stored blocked flag would be read by the auto-start gate, and a stale value
|
||||
there would launch work whose predecessor never ran.
|
||||
|
||||
|
||||
## Observability
|
||||
|
||||
One structured log record per failed dependency derivation, carrying plugin,
|
||||
instance, resource type, operation, and result code. No task title, task id, or
|
||||
edge content is logged, matching the isolated-web-app diagnostics rule that keeps
|
||||
data content out of logs and metrics.
|
||||
|
||||
The unit is the **derivation attempt, not the withheld task**: one batched
|
||||
derivation that fails emits one record however many tasks it withholds for, and a
|
||||
failure confined to one task's edge-end resolution likewise emits one. Records
|
||||
therefore do not scale with page size, which is what stops a failing dependency
|
||||
store turning a 200-task read into 200 log lines; since no field identifies a
|
||||
task, per-task records would be indistinguishable anyway.
|
||||
|
||||
That log record is the whole of the observability contract
|
||||
(`AC-PLUGINS-TASK-DEPS-002.4`). No counter and no metric namespace is added, and
|
||||
the design does not claim a withheld verdict is visible on an existing
|
||||
data-request counter -- it is not, because a failed derivation does not fail the
|
||||
surrounding request, so the request is counted as a success. An operator who
|
||||
needs the rate of withheld verdicts reads the log records. Promoting that to a
|
||||
counter is a deliberate non-goal: it would be the capability's only metric, and
|
||||
the isolated-web-app diagnostics surface is where one would belong.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Proto and SDK round trip for the ref type and all seven fields, including the
|
||||
empty-list and empty-status cases.
|
||||
- Canvas JSON projection: every field present on a task with no edges, and no
|
||||
key omitted when empty.
|
||||
- Parity: the same task yields the same projection values on the gRPC and canvas
|
||||
surfaces, redaction aside.
|
||||
- Order: predecessors and dependents come back in the defined order with the
|
||||
named tiebreak, repeatably.
|
||||
- Withheld verdict: each of the four failure paths -- predecessor read,
|
||||
dependent read, edge-end resolution, and an unwired derivation source -- yields
|
||||
`blocked: true`, reason `unknown`, empty lists, both flags false, without
|
||||
failing the read and without omitting a field.
|
||||
- Failure granularity: a batch-wide failure withholds for every task in the
|
||||
batch; a failure confined to one task's edge-end resolution leaves the other
|
||||
tasks' real verdicts intact.
|
||||
- Capability: a caller holding only the write capability receives the withheld
|
||||
verdict from a task-write RPC's task object, byte-identical to the derivation
|
||||
failure case, and never an edge end; a caller without `api_read:tasks` on a
|
||||
surface gated on it is rejected outright, with no task object returned.
|
||||
- `start_when_unblocked` reports the stored intent: true for a task whose last
|
||||
edge was removed, false for a WIP-overflow-only deferred launch.
|
||||
- Write response carries the post-write projection, asserted on each of the three
|
||||
write RPCs and on the preview RPC, not only on the read path.
|
||||
- Empty lists encode as `[]` in the canvas JSON: asserted on the raw response
|
||||
body for a task with no edges and for the withheld verdict, so a `null` or an
|
||||
omitted key fails rather than being normalized away by a typed decoder.
|
||||
- Derivation count per response, asserted with a counting fake rather than on
|
||||
the response body: the canvas `PATCH` route derives exactly once for a body
|
||||
naming both a title and a workflow step, which takes both the update and the
|
||||
move branch; and each of the four `Tasks().Get` callers that discards its task
|
||||
-- the `PATCH` preflight, the message route, the workflow list, and the
|
||||
workflow-step list -- derives not at all. The last two fail if the projection
|
||||
is attached unconditionally inside `Tasks().Get`.
|
||||
- Every serializing route carries the projection, asserted per route rather than
|
||||
once, over the nine routes enumerated in
|
||||
[Components and responsibilities](#components-and-responsibilities) -- the
|
||||
canvas list route's task-scope branch included.
|
||||
- The canvas list route derives only for the tasks it serializes: on a page where
|
||||
the scope filter drops tasks, the derived task-id set is exactly the survivors
|
||||
and no dropped task is derived for. Asserted on the id set handed to the
|
||||
derivation, because the response body cannot distinguish the two orders.
|
||||
|
||||
There is no new Kandev UI, so the web E2E suites are not a coverage source. The
|
||||
consumer-facing proof is a canvas that renders a workspace dependency graph from
|
||||
the data API alone.
|
||||
|
||||
## Documentation
|
||||
|
||||
Seven things across this capability are consumer contract rather than wire shape,
|
||||
so they must be stated in the public documentation rather than inferred. One is
|
||||
this design's: the `min_kandev_version` floor and the consequence of omitting it.
|
||||
Two are the [refresh contract](task-dependency-refresh.md#documentation)'s, and
|
||||
four are [response bounds](task-dependency-response-bounds.md#documentation)'.
|
||||
All seven go in `docs/public/canvases.md`,
|
||||
`docs/public/plugins-authoring.md`, and the canvas authoring browser-API
|
||||
reference, whose task-object field list and event section both need updating.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0043 — Plugin host data API](../../../decisions/0043-plugin-host-data-api.md)
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md)
|
||||
- [Task dependencies](../../tasks/system-design/task-dependencies.md)
|
||||
- [Dependency edge ends](task-dependency-edge-ends.md)
|
||||
- [Response bounds](task-dependency-response-bounds.md)
|
||||
@@ -0,0 +1,131 @@
|
||||
---
|
||||
id: plugins-task-dependency-refresh-design
|
||||
title: Refresh contract for the task dependency projection
|
||||
status: draft
|
||||
system: plugins
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-005
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Refresh contract for the task dependency projection System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns the fourth question the capability raises -- **when a consumer
|
||||
learns its cached graph is stale, and what it is promised about that.** It covers
|
||||
what canvas events carry, the refetch-on-signal rule and the `task.state_changed`
|
||||
clause, the publication-is-not-delivery gap, and the no-lock and no-snapshot
|
||||
rules (`REQ-PLUGINS-TASK-DEPS-005`).
|
||||
|
||||
It is separated from [task dependency projection](task-dependency-projection.md)
|
||||
by question, not by size, on the same principle as the other splits: that design
|
||||
settles what a response says and where it is attached, and this one settles what
|
||||
happens after it is read. `REQ-PLUGINS-TASK-DEPS-005` keeps the identity it was
|
||||
reviewed under, its criteria stay in the projection requirements document, and
|
||||
nothing is renumbered.
|
||||
|
||||
Adjacent contracts used but not owned:
|
||||
|
||||
- [Task dependency projection](task-dependency-projection.md) -- the field set,
|
||||
the derivation and the attachment rule whose staleness this governs.
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md) -- the
|
||||
canvas event stream, its field allowlist and its scope routing.
|
||||
- [Task dependencies](../../tasks/system-design/task-dependencies.md) -- the
|
||||
events edge mutation already publishes.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-PLUGINS-TASK-DEPS-005` | [Events and refresh](#events-and-refresh) |
|
||||
|
||||
## Events and refresh
|
||||
|
||||
Canvas events carry no dependency projection. The event projection is a flat
|
||||
field allowlist with no per-entry scope evaluation, so an edge list on an event
|
||||
would carry other tasks' titles past the redaction rule that the data path
|
||||
enforces. The allowlist stays as it is.
|
||||
|
||||
Edge mutation already publishes a task-updated event for both ends, and the two
|
||||
dependency lifecycle events are published too. What the consumer receives is a
|
||||
change signal without a payload, and the contract is explicitly
|
||||
refetch-on-signal: the signal does not identify which fields changed, because
|
||||
the changed-field list is not on the event allowlist either, so the rule is to
|
||||
re-read the affected tasks on any of the three event types.
|
||||
|
||||
Those three cover edge mutation and resolution, and they miss the commonest
|
||||
change of all: a predecessor simply moving state. A `depends_on` entry carries
|
||||
the predecessor's `state`, and the dependency handler publishes a
|
||||
dependent-facing event only when the dependent's own blocked verdict resolves or
|
||||
fails -- a predecessor advancing while another predecessor is still pending
|
||||
publishes nothing for the dependent. The only signal is `task.state_changed` for
|
||||
the predecessor itself, which a consumer holding a cached dependent has no reason
|
||||
to connect to that dependent. Left there, the rendered edge state is stale
|
||||
indefinitely, which is the one staleness this contract would not have declared.
|
||||
|
||||
The fix is a consumer rule over an event that is already published, not a new
|
||||
event (`AC-PLUGINS-TASK-DEPS-005.7`, `AC-PLUGINS-TASK-DEPS-005.8`): a consumer
|
||||
re-reads a cached task on `task.state_changed` for any task named in its
|
||||
`depends_on` or `blocks`. A dependent-facing event was rejected because the
|
||||
event contract is owned elsewhere, the fan-out is unbounded in the dependent
|
||||
count, and `AC-PLUGINS-TASK-DEPS-005.2` already declines to promise
|
||||
scope-complete delivery, so a new event would inherit the same routing gap it was
|
||||
added to close.
|
||||
|
||||
Publication is not delivery, and this design does not promise the gap away.
|
||||
The canvas event gateway matches an event against the instance's scope using the
|
||||
identities the event itself carries, and for repository and session scopes that
|
||||
is a *single* identity, while the direct-read predicate admits a task on any of
|
||||
its repositories or sessions. A repository- or session-scoped canvas can
|
||||
therefore miss a `task.updated` for a task it could read directly. Widening
|
||||
event routing to the full set would change the event contract, which this design
|
||||
does not own, so the honest guarantee is the one in
|
||||
`AC-PLUGINS-TASK-DEPS-005.3`: publication for both ends, plus a documented
|
||||
refetch rule that a consumer can also drive from its own refresh. Closing the
|
||||
routing gap is listed as out of scope in the requirements rather than assumed
|
||||
here.
|
||||
|
||||
One response is not a transactional snapshot. The derivation issues its reads
|
||||
sequentially without a surrounding transaction, so an edge mutated mid-response
|
||||
can appear on one end and not the other. Guaranteeing symmetry would mean
|
||||
holding a transaction across the derivation that every board read also uses.
|
||||
The consumer rule is therefore: treat the union of what it read, and refetch on
|
||||
signal.
|
||||
|
||||
Reads take no dependency mutation lock. The process-wide lock that serializes
|
||||
validate-then-insert on the write path is not acquired for a derivation, so a
|
||||
read never delays an edge mutation and concurrent reads never serialize against
|
||||
each other. Acquiring it for reads would put every plugin and board read behind
|
||||
dependency writes to buy a snapshot the contract does not promise.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Events carry no projection: the canvas event projection for `task.updated`,
|
||||
`task.dependencies_resolved` and `task.dependency_failed` carries no projection
|
||||
field, no edge list, and no edge end's title or state, asserted directly rather
|
||||
than inferred from the field allowlist. The internal event *does* carry the
|
||||
recomputed projection, so this holds only because the allowlist omits those
|
||||
keys; the test is what makes a later widening fail here rather than silently
|
||||
publish every edge end's title to every canvas.
|
||||
- Reads take no mutation lock: a derivation driven through a blocker source that
|
||||
fails if the mutation lock is held completes normally, and concurrent reads of
|
||||
one task do not serialize.
|
||||
|
||||
## Documentation
|
||||
|
||||
Two of the consumer-contract items listed in the
|
||||
[projection design](task-dependency-projection.md#documentation) are this
|
||||
design's: the refresh rule including its `task.state_changed` clause, and the
|
||||
non-snapshot rule. They belong in `docs/public/canvases.md`,
|
||||
`docs/public/plugins-authoring.md`, and the canvas authoring browser-API
|
||||
reference.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0043 -- Plugin host data API](../../../decisions/0043-plugin-host-data-api.md)
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md)
|
||||
- [Task dependency projection](task-dependency-projection.md)
|
||||
@@ -0,0 +1,414 @@
|
||||
---
|
||||
id: plugins-task-dependency-response-bounds-design
|
||||
title: Response bounds for the task dependency projection
|
||||
status: draft
|
||||
system: plugins
|
||||
requirements:
|
||||
- REQ-PLUGINS-TASK-DEPS-004
|
||||
owners:
|
||||
- kandev
|
||||
created: 2026-09-17
|
||||
last_updated: 2026-09-17
|
||||
---
|
||||
|
||||
# Response bounds for the task dependency projection System Design
|
||||
|
||||
## Purpose and boundaries
|
||||
|
||||
This design owns what one response carrying the dependency projection may cost
|
||||
and how large it may be: the query bound, the edge-end fan-out, the per-task list
|
||||
cut, and the response-size bound on each surface
|
||||
(`REQ-PLUGINS-TASK-DEPS-004`). It is separated from the
|
||||
[task dependency projection](task-dependency-projection.md) because the two are
|
||||
reviewed against different risks, and because these are the rules that decide
|
||||
whether the capability still behaves on a workspace larger than the one it was
|
||||
demonstrated on.
|
||||
|
||||
It does not own the field set, the derivation, the ordering, the refresh rule, or
|
||||
**where** the projection is attached. Those are the projection design's. In
|
||||
particular, *which* responses carry the projection is settled by
|
||||
[Components and responsibilities](task-dependency-projection.md#components-and-responsibilities);
|
||||
this design counts derivations over whatever set that rule produces, and does not
|
||||
define a second one.
|
||||
|
||||
Adjacent contracts used but not owned:
|
||||
|
||||
- [Task dependency projection](task-dependency-projection.md) — the field set,
|
||||
the derivation, and the attachment rule these bounds are counted against.
|
||||
- [Dependency edge ends](task-dependency-edge-ends.md) — the redaction rule,
|
||||
whose admission test is deliberately written to issue no read so that it cannot
|
||||
spend the budget in `AC-PLUGINS-TASK-DEPS-004.1`.
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md) — the
|
||||
canvas runtime's response limit.
|
||||
|
||||
## Requirement mapping
|
||||
|
||||
| Requirement | Design section |
|
||||
| --- | --- |
|
||||
| `REQ-PLUGINS-TASK-DEPS-004` | [Bounding rules](#bounding-rules), [Edge-end fan-out](#edge-end-fan-out), [Response bounds by flow](#response-bounds-by-flow) |
|
||||
|
||||
## Bounding rules
|
||||
|
||||
Four independent bounds, none of which subsumes another. The first two bound
|
||||
cost, the last two bound payload.
|
||||
|
||||
1. **Query count.** At most one batched derivation per response, counted on the
|
||||
task set the response serializes — a read whose task object is discarded runs
|
||||
none. The set is fixed by the projection design's attachment rule, not
|
||||
restated here. The derivation's own read count is fixed by its steps and does
|
||||
not grow with the number of tasks in the page — except that a task set larger
|
||||
than the host-parameter ceiling chunks its task-keyed edge queries too, which
|
||||
only the unpaginated preview flow can reach
|
||||
(`AC-PLUGINS-TASK-DEPS-004.11`).
|
||||
2. **Edge-end fan-out.** The one part of the derivation whose cost is driven by
|
||||
data rather than by its own structure, because its input is the set of
|
||||
distinct edge ends across the whole response. It is narrowed before it runs:
|
||||
the dependent direction is ordered and cut to 512 *first*, since no verdict
|
||||
reads a dependent's far row, while the predecessor direction is resolved whole
|
||||
because the verdict does read those (`AC-PLUGINS-TASK-DEPS-004.13`). What is
|
||||
left is bounded twice — by a chunk size that keeps each statement legal, and,
|
||||
on paginated flows, by a maximum that keeps the read count affordable.
|
||||
[Edge-end fan-out](#edge-end-fan-out) below. This bounds **cost**, not bytes.
|
||||
3. **Per-task list size.** Each list is cut at 512 entries, reusing the existing
|
||||
per-task dependency maximum rather than introducing a second number. The cut
|
||||
keeps the first entries in the defined order and sets the matching truncation
|
||||
flag. `blocked` and `blocked_reason` are computed before the cut, so a
|
||||
truncated list never changes the verdict. This is the bound that holds the
|
||||
**payload** side down on a single-task read, and it is the one
|
||||
`AC-PLUGINS-TASK-DEPS-004.9`'s single-task claim rests on — rule 2's maximum
|
||||
does not, and never did.
|
||||
|
||||
Both directions need the bound, and both flags are reachable. The predecessor
|
||||
maximum is enforced on the full-set replacement path only; the incremental
|
||||
add path does not check it, so a task can accumulate more than 512
|
||||
predecessors. The dependent direction has no write-side bound at all, because
|
||||
any number of tasks may depend on one task.
|
||||
4. **Response size.** The existing per-surface limits still apply, and this
|
||||
design adds none. [Response bounds by flow](#response-bounds-by-flow) below
|
||||
states which flows can reach them and what the remedy is on each.
|
||||
|
||||
## Edge-end fan-out
|
||||
|
||||
Rule 3 bounds one task's list at 512 and the page limit bounds a list read at
|
||||
200 tasks. **Two different quantities grow out of that, and conflating them is
|
||||
the specific mistake this section exists to prevent.**
|
||||
|
||||
- **Distinct edge ends** are what edge-end resolution reads: the deduplicated
|
||||
union of every end across the page, after the dependent-direction cut of
|
||||
`AC-PLUGINS-TASK-DEPS-004.13`. Deduplication makes this set far smaller than
|
||||
the product whenever tasks share edges -- a page of 200 tasks all waiting on
|
||||
the same five blockers resolves five ends, not a thousand.
|
||||
- **Serialized entries** are what the response writes: as many as 200 x 2 x 512 =
|
||||
204,800, because an end shared by forty tasks is written forty times. This is
|
||||
the quantity the per-surface response limits act on
|
||||
(`AC-PLUGINS-TASK-DEPS-004.14`).
|
||||
|
||||
Neither bounds the other in either direction. A page can resolve a thousand ends
|
||||
and serialize two hundred thousand entries, or resolve four thousand ends and
|
||||
serialize four thousand. So each needs its own rule, and a bound written over one
|
||||
must not be described as protecting the other. The paragraphs below bound the
|
||||
first. The second is bounded only by the per-surface response limit already in
|
||||
`AC-PLUGINS-TASK-DEPS-004.6` and `.7` -- a real bound, since the runtime refuses
|
||||
an oversized response rather than truncating it, but a bound on the response
|
||||
rather than one this capability places on the projection.
|
||||
|
||||
That matters because the resolution query is a batched `IN (...)` with one bind
|
||||
parameter per id and no splitting (`GetTasksByIDs`,
|
||||
`internal/task/repository/sqlite/task.go:3791`). Past the database's
|
||||
bind-parameter ceiling — 999 on older SQLite builds, 32766 on newer ones — such a
|
||||
statement does not return a short answer; it fails to execute.
|
||||
|
||||
Left unaddressed, that failure has a specific and bad shape. It surfaces as an
|
||||
error from the edge-end resolution step, which is exactly
|
||||
`AC-PLUGINS-TASK-DEPS-002.5`'s third failure path, so the derivation fails closed
|
||||
and every task on the page returns the withheld verdict. The result is
|
||||
well-formed, satisfies every criterion as written, and reports a uniformly
|
||||
unknown graph for a workspace whose only fault is being large. A small
|
||||
acceptance workspace passes while a dense one silently degrades. The two bounds
|
||||
below exist to make that unreachable rather than to handle it.
|
||||
|
||||
**Chunking keeps the statements legal** (`AC-PLUGINS-TASK-DEPS-004.11`). Two
|
||||
families of statement need it, for the same reason and with the same fix. The
|
||||
edge-end resolution query is the one this section is named for. The task-keyed
|
||||
edge queries — `ListBlockersForTasks` and `ListDependentsForTasks`, which bind
|
||||
one parameter per task id in the response — carry the identical hazard, but only
|
||||
on the preview flow: every paginated flow is capped at 200 task ids, comfortably
|
||||
inside the ceiling, while preview walks an uncapped subtree. A builder who
|
||||
chunks only the first leaves a large plugin-owned tree failing at the database
|
||||
with the derivation's own error, which fails closed and reports a uniformly
|
||||
unknown graph — the precise shape the next paragraph rejects.
|
||||
|
||||
The
|
||||
repository already owns this convention in the same package as the offending
|
||||
query: `sqliteMaxHostParams = 500` and a `chunkIDs` helper in
|
||||
`internal/task/repository/sqlite/base_queries.go`, whose sibling
|
||||
`buildInPlaceholders` documents that the caller is responsible for splitting
|
||||
oversized input, and which `task_status_summary.go`, `message.go` and
|
||||
`session.go` already use. So this is not a new mechanism; it is the existing one
|
||||
applied to a query that skipped it. This is **change 5** of the changes the
|
||||
projection design enumerates in the shared derivation, and like the other four it
|
||||
is a correction toward existing convention, is board-visible, and needs no
|
||||
migration.
|
||||
|
||||
Splitting the query does not split the step. A failure in any batch is the one
|
||||
edge-end-resolution failure of `AC-PLUGINS-TASK-DEPS-002.5`, over the whole
|
||||
derivation, and does not create a narrower failure boundary than the one
|
||||
`AC-PLUGINS-TASK-DEPS-002.3` already describes. Order is unaffected for a
|
||||
different reason: entry order comes from the edge queries, and this step only
|
||||
resolves detail for ends those queries already returned, so which batch resolved
|
||||
an end cannot be observed. What the split does change is that the derivation
|
||||
observes over an interval rather than an instant -- already permitted, and
|
||||
already stated, by the no-lock and no-snapshot rules.
|
||||
|
||||
Chunking alone is not enough, which is why it is not the only rule. It converts
|
||||
one illegal statement into an unbounded number of legal ones, so an oversized
|
||||
page would still cost reads in proportion to its edge volume — the fault
|
||||
`AC-PLUGINS-TASK-DEPS-004.1` exists to prevent.
|
||||
|
||||
**A maximum keeps the response affordable** (`AC-PLUGINS-TASK-DEPS-004.10`). The
|
||||
number of distinct ends one derivation resolves is capped, and a task set
|
||||
exceeding the cap fails as an oversized response rather than as a derivation
|
||||
failure. Two properties of the number are load-bearing:
|
||||
|
||||
- It binds **paginated flows only**. The single-task flows are exempt
|
||||
(`AC-PLUGINS-TASK-DEPS-004.10`), because the refusal's whole premise is that
|
||||
the caller can retry with a smaller `limit`, and those flows have no `limit` to
|
||||
reduce. Refusing there would make a task permanently unreadable for the single
|
||||
reason that too many tasks name it -- the outcome
|
||||
`REQ-PLUGINS-TASK-DEPS-004`'s own Intent forbids. An earlier draft instead
|
||||
claimed one task could never reach the maximum, on the grounds that its two
|
||||
lists hold at most 1024 entries. That reasoning was wrong, and the way it was
|
||||
wrong is worth keeping: the list cut is a **presentation** cut applied at
|
||||
stamping, while resolution runs upstream over every end the task has, so a task
|
||||
with ten thousand predecessors resolves ten thousand ends and serializes 512.
|
||||
- It is **reachable on a list read**, because 200 tasks each naming hundreds of
|
||||
distinct ends exceed any affordable figure. The bound is real rather than
|
||||
decorative, and the smaller-`limit` remedy is the consumer's lever.
|
||||
|
||||
The number is **4096**, declared as a named constant beside the derivation rather
|
||||
than left for implementation to choose, because an unpinned bound is one every
|
||||
call site picks differently. Three checks place it, all three on the cost side,
|
||||
because this number bounds reads and rows and does not bound bytes. At the
|
||||
repository's chunk size of 500 it is at most nine resolution queries
|
||||
(ceil(4096/500) = 9), the same order as the derivation's fixed steps, so rule 1
|
||||
still holds at the maximum rather than only in the average case. A full page of
|
||||
200 tasks reaches it at an average of 4096 / 200 = 20.48 distinct ends per task,
|
||||
which is a graph a real workspace can have, so the bound engages before a dense
|
||||
page turns into hundreds of queries -- a breakeven test should be anchored at 20
|
||||
ends per task and just under the cap, or at 21 and just over it, rather than on
|
||||
21 as the breakeven figure. And it leaves four times the headroom over the 1024
|
||||
entries one task can serialize. That headroom is not decoration: a *paginated*
|
||||
read at `limit=1` is still a paginated flow and still bound by the maximum, so
|
||||
the floor is what makes a one-task page of an ordinary task agree with the exempt
|
||||
single-task read of the same task. The two regimes part company only for a task
|
||||
that passed the 512 predecessor limit on the incremental write path, where a
|
||||
`limit=1` page has no smaller `limit` to retry with; the documented remedy there
|
||||
is the single-task read (`AC-PLUGINS-TASK-DEPS-004.10`).
|
||||
|
||||
**What this number does not do is bound the response size**, and the design says
|
||||
so rather than implying otherwise (`AC-PLUGINS-TASK-DEPS-004.14`). 4096 entries
|
||||
of roughly 150 bytes would be about 600 KB, which is 58.6% of the canvas 1 MiB
|
||||
limit -- but 4096 counts *distinct ends*, not serialized entries, and a page that
|
||||
resolves 4096 distinct ends may serialize up to 204,800 entries, some 30 MB. The
|
||||
two bounds therefore do **not** trip at comparable sizes, and the byte side is
|
||||
held by the per-surface response limit alone. A dense page can pass this maximum
|
||||
and still be refused as oversized; that is expected, and both refusals carry the
|
||||
same smaller-`limit` remedy.
|
||||
|
||||
Routing this to the withheld verdict instead is explicitly rejected. The two
|
||||
conditions are different: a derivation failure means the graph is *unknown*, and
|
||||
an over-budget page means the graph is *known but undeliverable at this page
|
||||
size*. Reporting the second as the first would tell a consumer its workspace has
|
||||
no readable dependency state, when halving its page size would return the whole
|
||||
graph. The fail-closed rule is for the first case only.
|
||||
|
||||
The failure mode also matches how this package already treats the same hazard.
|
||||
`maxMessageFilterValues = 400` exists so batched message filters stay
|
||||
"comfortably under SQLite's `SQLITE_MAX_VARIABLE_NUMBER`"
|
||||
(`internal/plugins/host_data.go:66-76`), and the message reader rejects an
|
||||
over-limit request outright rather than letting it reach the database as "a
|
||||
cryptic 'too many SQL variables'" (`:605-612`). The difference here is only that
|
||||
the caller did not choose the edge count, so the error is an oversized-response
|
||||
error on the response rather than an invalid-argument error on the request.
|
||||
|
||||
## Response bounds by flow
|
||||
|
||||
`AC-PLUGINS-TASK-DEPS-004.6` and `.7` both name a smaller page `limit` as the
|
||||
remedy. Most in-scope flows accept no `limit`, so the remedy has to be stated per
|
||||
flow rather than once.
|
||||
|
||||
The canvas limit is not a list-route limit: the runtime applies its response
|
||||
ceiling to **every** canvas response it writes
|
||||
(`writeWebAppJSON`, `internal/plugins/webapp_protocol_json.go:34`), so a
|
||||
single-task or update response is subject to it too. On the gRPC side the Host
|
||||
server sets no maximum send size and the plugin is the client, so the effective
|
||||
bound is the plugin's own client-side maximum receive size, and a response over
|
||||
it fails the whole RPC with `ResourceExhausted`.
|
||||
|
||||
| Flow | Paginated | Can the projection reach a bound? | Remedy |
|
||||
| --- | --- | --- | --- |
|
||||
| gRPC `ListTasks` | Yes | Yes, via the page's edge-end total and size | Smaller `limit` |
|
||||
| Canvas task list | Yes | Yes, same | Smaller `limit` |
|
||||
| gRPC `GetTask` | No | Not through the projection — at most 1024 serialized entries; exempt from the fan-out maximum | None needed |
|
||||
| Canvas single-task and update routes | No | Not through the projection — same | None needed |
|
||||
| gRPC `CreateTask` / `UpdateTask` / `MoveTask` | No | Not through the projection — same | None needed |
|
||||
| Plugin-owned task-tree preview | No | **Yes, unbounded** — and not exempt from the fan-out maximum | Reduce the tree |
|
||||
|
||||
The "none needed" rows are a claim about the projection, not a promise that such
|
||||
a response always fits. A single task already carries a description and metadata
|
||||
this capability neither adds nor bounds, so a single-task response can exceed a
|
||||
limit on its own; what the table states is that the projection is unlikely to be
|
||||
the reason, because two lists of at most 512 entries are roughly 150 KB at the
|
||||
design's 150 B/entry estimate.
|
||||
|
||||
Two qualifications keep that row honest. On the **canvas** side 150 KB sits well
|
||||
under the 1 MiB ceiling, so the claim holds outright. On the **gRPC** side the
|
||||
ceiling belongs to the plugin's own client connection, not to the host, so the
|
||||
claim is conditional: it holds for any plugin at or above the gRPC library
|
||||
default receive maximum of 4 MiB, and a plugin that deliberately lowers its
|
||||
`MaxCallRecvMsgSize` below roughly 150 KB plus the task object can trip
|
||||
`ResourceExhausted` on a single-task read. `AC-PLUGINS-TASK-DEPS-004.9` states
|
||||
that as a floor on the receiving end rather than as a guarantee, and the
|
||||
documentation carries the byte figure so a plugin author can size the connection
|
||||
against it.
|
||||
|
||||
These rows are also where the exemption lands. Because rule 2's maximum does not
|
||||
bind them, a single-task read of a task with an extreme predecessor count
|
||||
*succeeds*, resolving those ends in chunks rather than refusing. That is the
|
||||
`REQ-PLUGINS-TASK-DEPS-004` Intent honoured literally: the task stays readable.
|
||||
The dependent direction, which is the one a third party can inflate without the
|
||||
task's consent, never reaches resolution beyond 512 at all
|
||||
(`AC-PLUGINS-TASK-DEPS-004.13`), so the conceded cost is confined to a task's own
|
||||
declared predecessors.
|
||||
|
||||
**The remedy's boundary semantics differ by surface, and both are inherited
|
||||
rather than set here** (`AC-PLUGINS-TASK-DEPS-004.15`). A consumer told to
|
||||
"retry with a smaller `limit`" meets two different contracts. The canvas surface
|
||||
**rejects** a supplied `limit` outside 1 to 200 with `invalid_request`
|
||||
(`webAppRequestPage`, `internal/plugins/webapp_protocol.go:277-288`), so a
|
||||
consumer that halves its way down to 0 gets an error rather than a page, while
|
||||
the host's own page normalization **clamps** a supplied out-of-range value into
|
||||
the same range (`normalizePageLimit`, `internal/plugins/host_data.go:66-88`) --
|
||||
so a gRPC caller asking for 500 silently receives 200 where a canvas caller
|
||||
asking for 500 receives an error. The divergence is confined to a supplied
|
||||
value: `webAppRequestPage` validates only a non-empty `limit` parameter, so an
|
||||
absent one is an error on neither surface and reaches the host's default of 50
|
||||
either way. This capability changes none of it; it documents it, because the
|
||||
remedy is stated in two criteria and a builder reading only those would have to
|
||||
guess which behaviour applies where.
|
||||
|
||||
Preview is the real exception and is stated as one rather than smoothed over. It
|
||||
returns every plugin-owned task in a subtree, walked recursively with no cap
|
||||
(`ownedTaskSubtree`, `internal/plugins/host_write.go:630`), and accepts no page
|
||||
parameter, so both its response size and its edge-end total are bounded only by
|
||||
how large that tree is. Adding pagination to it is out of scope: its request and
|
||||
response shape belongs to the plugin-owned task-tree contract, and the same
|
||||
unboundedness already exists there today, since `Preview` attaches linked pull
|
||||
requests over exactly the same unpaginated set (`:541`). The projection inherits
|
||||
that exposure rather than creating it. What this design requires is that the
|
||||
documentation stop implying a `limit` the RPC does not have, and say plainly that
|
||||
the only lever is the size of the plugin-owned tree.
|
||||
|
||||
## Test strategy
|
||||
|
||||
- Query count, per `AC-PLUGINS-TASK-DEPS-004.1`, in two parts that must not be
|
||||
collapsed into one. **Structural steps:** a one-task page and a many-task page
|
||||
*carrying the same number of distinct edge ends* cost the same reads, measured
|
||||
with a counting fake, including the degenerate pair where neither page carries
|
||||
any edges. **No per-task read:** read count is flat as the task count grows at
|
||||
constant edge volume, which is the N+1 regression this guards. Neither bullet
|
||||
asserts a fixed read count across pages of *differing* edge volume — chunking
|
||||
makes that false by construction, and an earlier draft of this section asserted
|
||||
it alongside the chunking test below, which no implementation could satisfy.
|
||||
- Chunking: a derivation over more distinct edge ends than the chunk size
|
||||
completes and returns a correct graph, and issues more than one resolution
|
||||
query — the assertion is on correctness plus a bounded query count, so a
|
||||
regression that removes the chunking fails here rather than at a database
|
||||
error.
|
||||
- The bind-parameter ceiling is never reached, asserted on **both** statement
|
||||
families: a derivation over an end count that would exceed the lower ceiling of
|
||||
999 in a single statement succeeds, and a preview over a task count that would
|
||||
do the same succeeds. The second fails if a builder chunks edge-end resolution
|
||||
and leaves the task-keyed edge queries unsplit.
|
||||
- The fan-out maximum is not routed to the withheld verdict: a task set past the
|
||||
maximum returns the surface's oversized-response error, and no task in it
|
||||
reports `blocked_reason: unknown`. This is the test that fails if a builder
|
||||
wires the condition into `AC-PLUGINS-TASK-DEPS-002.5`'s third path.
|
||||
- The paginated and exempt regimes agree where they should: a task holding the
|
||||
full 512 entries in both lists returns an identical projection from a
|
||||
`limit=1` list read and from each single-task flow, and a task past the
|
||||
predecessor limit returns it from the single-task flows while the `limit=1`
|
||||
list read refuses with the oversized-response error. This pins the one place
|
||||
the two regimes deliberately differ, so a builder cannot collapse them.
|
||||
- The single-task exemption holds, per `AC-PLUGINS-TASK-DEPS-004.10`: a task
|
||||
whose *predecessor* count alone exceeds the fan-out maximum still returns a
|
||||
complete projection on each non-paginated single-task flow — no
|
||||
`response_too_large`, no `ResourceExhausted`, no withheld verdict — with both
|
||||
lists cut to 512, both truncation flags true, and a verdict computed from every
|
||||
predecessor including the omitted ones. This is the test that fails if a builder
|
||||
applies the maximum uniformly across flows, which would make the task
|
||||
unreadable.
|
||||
- The dependent direction is cut before resolution, per
|
||||
`AC-PLUGINS-TASK-DEPS-004.13`: a task with far more than 512 dependents resolves
|
||||
at most 512 dependent ends, asserted on the id set passed to edge-end
|
||||
resolution, while a task with more than 512 predecessors resolves all of them.
|
||||
A regression that resolves first and cuts second passes every output assertion
|
||||
and fails only here.
|
||||
- Per-task cut: a list past 512 is cut with the flag set, in the defined order,
|
||||
and the verdict is computed before the cut and left unchanged.
|
||||
- Response size: a canvas response over the runtime limit returns the existing
|
||||
bounded error on a single-task route as well as a list route, which fails if
|
||||
the limit is implemented as list-only.
|
||||
- No bound is met by shrinking the projection: none of the above paths returns a
|
||||
task whose projection was dropped, emptied, or redacted to fit.
|
||||
- Chunking is not a failure boundary: a resolution failure confined to one batch
|
||||
withholds for the whole derivation, not for the subset of tasks whose ends fell
|
||||
in that batch, and the entry order of an unaffected run is identical whether the
|
||||
ends resolved in one batch or several.
|
||||
- Distinct ends do not bound bytes, per `AC-PLUGINS-TASK-DEPS-004.14`: a page
|
||||
whose tasks share their predecessors — so that its distinct-end count stays well
|
||||
under the fan-out maximum — but which serializes enough repeated entries to pass
|
||||
the canvas response limit returns `response_too_large`. This fails if a builder
|
||||
treats the fan-out maximum as the payload guard and skips the size check.
|
||||
- The documented remedy behaves as documented on each surface, per
|
||||
`AC-PLUGINS-TASK-DEPS-004.15`: the canvas list route rejects `limit=0` and
|
||||
`limit=201` with `invalid_request`, and the host's normalization clamps the same
|
||||
inputs and defaults an absent limit to 50. These assert inherited behaviour
|
||||
rather than new behaviour, and exist so the remedy's two contracts cannot drift
|
||||
apart unnoticed while two criteria still name one remedy.
|
||||
- The oversized-response refusal uses the existing codes and precedes
|
||||
serialization: an over-maximum page returns `response_too_large` on the canvas
|
||||
surface and `ResourceExhausted` on the gRPC surface, with no task object in the
|
||||
response.
|
||||
|
||||
## Documentation
|
||||
|
||||
Four of the seven consumer-contract items listed in the
|
||||
[projection design](task-dependency-projection.md#documentation) are this
|
||||
design's, all of them about what a response costs or weighs:
|
||||
|
||||
- The `ResourceExhausted` failure mode of an oversized gRPC response, and that
|
||||
the receive maximum belongs to the plugin's own client connection rather than
|
||||
to the host (`AC-PLUGINS-TASK-DEPS-004.7`).
|
||||
- That the plugin-owned task-tree preview RPC accepts no page `limit`, so
|
||||
reducing the tree is the only lever on its size
|
||||
(`AC-PLUGINS-TASK-DEPS-004.9`).
|
||||
- The projection's maximum contribution to a single-task response as a byte
|
||||
figure -- two lists of at most 512 entries, roughly 150 KB -- so a plugin author
|
||||
can size a lowered `MaxCallRecvMsgSize` against it
|
||||
(`AC-PLUGINS-TASK-DEPS-004.9`).
|
||||
- The page `limit`'s accepted range, default and out-of-range behaviour on each
|
||||
surface, because the canvas rejects an out-of-range limit while the host's
|
||||
normalization clamps it and defaults it to 50
|
||||
(`AC-PLUGINS-TASK-DEPS-004.15`). Without this a consumer following the
|
||||
smaller-`limit` remedy cannot predict what it will get.
|
||||
|
||||
They belong in `docs/public/canvases.md`, `docs/public/plugins-authoring.md`, and
|
||||
the canvas authoring browser-API reference.
|
||||
|
||||
## Related decisions
|
||||
|
||||
- [ADR 0043 — Plugin host data API](../../../decisions/0043-plugin-host-data-api.md)
|
||||
- [Isolated web-app contributions](isolated-web-app-contributions.md)
|
||||
- [Task dependency projection](task-dependency-projection.md)
|
||||
- [Dependency edge ends](task-dependency-edge-ends.md)
|
||||
Reference in New Issue
Block a user