Files
2026-09-18 14:55:51 +00:00

150 lines
5.9 KiB
Go

// 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
}