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
|
||||
|
||||
Reference in New Issue
Block a user