Files
kandev/apps/backend/internal/workflow/engine/phase2_callbacks.go
T
Carlos Florêncio 2d73a3d1db fix: keep completed office comments editable (#1548)
* fix: keep completed office comments editable

Keep the office task detail composer editable after completed sessions load,

including projectless office tasks.

Update verification tests for SHA-pinned GitHub actions. Extract the

dockview main area to satisfy the existing max-lines lint rule.

* fix: preserve upload artifact version guard

* fix: clarify upload artifact pin check

* fix: wake completed office sessions on comments

* fix: queue completed office comment runs

* fix: preserve dispatcher session lookup errors

* fix: resolve office comment wake review issues

* fix: stabilize comment wake idempotency

* fix: salt custom comment wake keys

* fix: address comment wake review cleanup

* fix: avoid comment payload allocation overflow pattern

* fix: salt comment wake action variants

* fix: resolve cross-task primary by target step

* fix: resolve cross-task participant roles

* fix: surface cross-task comment run status

* fix: use target steps for cross-task queue runs

* fix: avoid comment run lookup scan regression

* fix: cover cross-task queue run edge cases

* fix: index comment payload run lookup

* fix: support postgres comment run index

* fix: tighten queue run target test coverage

* fix: route source comment run updates

* fix: preserve cross-task comment wakes

* fix: tighten comment wake identity

* fix: align comment wake routing

* fix: remove stale primary resolver

* fix: dedupe comment run status

* fix: tighten legacy comment wake dedupe

* fix: remove unreachable comment wake guard

* fix: skip duplicate comment reactivity wakes

* fix: route comment wakes synchronously

* fix: share comment engine dispatch contracts

* fix: preserve comment wake fallbacks

* fix: preserve done comment runner

* fix: guard empty task runner fallback

* fix: handle empty task runner fallback

* fix: stabilize completed comment wakes

* fix: index comment runner fallback

---------

Co-authored-by: root <root@kandev.ts.net>
2026-06-30 13:10:40 +01:00

427 lines
15 KiB
Go

package engine
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/kandev/kandev/internal/runs/commentkeys"
)
// ErrActionNotYetWired is the sentinel returned by Phase 2 callbacks when a
// required engine dependency (RunQueueAdapter, ParticipantStore, …) has not
// been wired. It is intentionally exported so the orchestrator can detect
// "kanban-only" engines vs office-wired ones in tests, and so callers see a
// loud, distinctive error rather than a silent no-op.
var ErrActionNotYetWired = errors.New("workflow action not yet wired")
// Target prefixes / sentinels recognised by QueueRunCallback.
const (
TargetPrimary = "primary"
TargetParticipant = "participant_role:"
TargetAgentProfile = "agent_profile_id:"
TargetWorkspaceCEO = "workspace.ceo_agent"
TaskIDThis = "this"
defaultQueueReasonR = "queue_run"
)
// PrimaryAgentResolver resolves the task's "primary" agent profile id. The
// engine asks via this interface when a queue_run target is "primary". The
// answer is task-aware so office steps can prefer the task's current runner
// participant while kanban-style steps still fall back to the step primary.
// The indirection keeps the engine package free of model imports.
type PrimaryAgentResolver interface {
PrimaryAgentProfileID(ctx context.Context, stepID, taskID string) (string, error)
}
// TargetTaskStepResolver resolves a task's current workflow step for cross-task
// queue_run actions. The engine only has the triggering task's StepSpec, so
// adapters that read task storage provide this for target task lookups.
type TargetTaskStepResolver interface {
WorkflowStepIDForTask(ctx context.Context, taskID string) (string, error)
}
// QueueRunCallback executes the queue_run action by resolving Target/TaskID
// then enqueuing a run via RunQueueAdapter.
type QueueRunCallback struct {
Adapter RunQueueAdapter
Participants ParticipantStore
CEOResolver CEOAgentResolver
Primary PrimaryAgentResolver
TaskSteps TargetTaskStepResolver
}
// Execute satisfies ActionCallback.
func (c QueueRunCallback) Execute(ctx context.Context, in ActionInput) (ActionResult, error) {
if c.Adapter == nil {
return ActionResult{}, fmt.Errorf("%w: queue_run requires RunQueueAdapter", ErrActionNotYetWired)
}
if in.Action.QueueRun == nil {
return ActionResult{}, fmt.Errorf("queue_run action missing QueueRun config")
}
taskID := resolveTaskID(in.Action.QueueRun.TaskID, in.State.TaskID)
agentIDs, workflowStepID, err := c.resolveTarget(ctx, in, taskID)
if err != nil {
return ActionResult{}, err
}
for _, agentID := range agentIDs {
req := QueueRunRequest{
AgentProfileID: agentID,
TaskID: taskID,
WorkflowStepID: workflowStepID,
Reason: queueRunReason(in),
IdempotencyKey: idempotencyKey(in, agentID, taskID),
Payload: queueRunPayload(in, in.Action.QueueRun.Payload, taskID),
}
if err := c.Adapter.QueueRun(ctx, req); err != nil {
return ActionResult{}, fmt.Errorf("queue_run for agent %s: %w", agentID, err)
}
}
return ActionResult{}, nil
}
func (c QueueRunCallback) resolveTarget(
ctx context.Context, in ActionInput, taskID string,
) ([]string, string, error) {
target := strings.TrimSpace(in.Action.QueueRun.Target)
switch {
case target == "" || target == TargetPrimary:
stepID, err := c.resolveTargetStepID(ctx, in, taskID, pickStepResolver(c.Primary, c.TaskSteps))
if err != nil {
return nil, "", err
}
agentIDs, err := c.resolvePrimary(ctx, taskID, stepID)
return agentIDs, stepID, err
case strings.HasPrefix(target, TargetParticipant):
role := strings.TrimPrefix(target, TargetParticipant)
stepID, err := c.resolveTargetStepID(ctx, in, taskID, pickStepResolver(c.Participants, c.TaskSteps))
if err != nil {
return nil, "", err
}
agentIDs, err := c.resolveParticipantRole(ctx, stepID, taskID, role)
return agentIDs, stepID, err
case strings.HasPrefix(target, TargetAgentProfile):
id := strings.TrimPrefix(target, TargetAgentProfile)
if id == "" {
return nil, "", fmt.Errorf("queue_run agent_profile_id target is empty")
}
stepID, err := c.resolveTargetStepID(ctx, in, taskID, c.TaskSteps)
if err != nil {
return nil, "", err
}
return []string{id}, stepID, nil
case target == TargetWorkspaceCEO:
stepID, err := c.resolveTargetStepID(ctx, in, taskID, c.TaskSteps)
if err != nil {
return nil, "", err
}
agentIDs, err := c.resolveCEO(ctx, taskID)
return agentIDs, stepID, err
default:
return nil, "", fmt.Errorf("queue_run: unsupported target %q", target)
}
}
func (c QueueRunCallback) resolveTargetStepID(
ctx context.Context, in ActionInput, taskID string, resolver TargetTaskStepResolver,
) (string, error) {
if taskID == in.State.TaskID {
return in.Step.ID, nil
}
if resolver == nil {
return "", fmt.Errorf("%w: queue_run cross-task target requires TargetTaskStepResolver", ErrActionNotYetWired)
}
stepID, err := resolver.WorkflowStepIDForTask(ctx, taskID)
if err != nil {
return "", fmt.Errorf("queue_run resolve target task step: %w", err)
}
if stepID == "" {
return "", fmt.Errorf("queue_run: task %s has no workflow step", taskID)
}
return stepID, nil
}
func pickStepResolver(v any, fallback TargetTaskStepResolver) TargetTaskStepResolver {
if resolver, ok := v.(TargetTaskStepResolver); ok {
return resolver
}
return fallback
}
func (c QueueRunCallback) resolvePrimary(ctx context.Context, taskID, stepID string) ([]string, error) {
if c.Primary == nil {
return nil, fmt.Errorf("%w: queue_run target=primary requires PrimaryAgentResolver", ErrActionNotYetWired)
}
id, err := c.Primary.PrimaryAgentProfileID(ctx, stepID, taskID)
if err != nil {
return nil, fmt.Errorf("queue_run resolve primary: %w", err)
}
if id == "" {
return nil, fmt.Errorf("queue_run: step %s has no primary agent profile", stepID)
}
return []string{id}, nil
}
func (c QueueRunCallback) resolveParticipantRole(ctx context.Context, stepID, taskID, role string) ([]string, error) {
if c.Participants == nil {
return nil, fmt.Errorf("%w: queue_run target=participant_role requires ParticipantStore", ErrActionNotYetWired)
}
all, err := c.Participants.ListStepParticipants(ctx, stepID, taskID)
if err != nil {
return nil, fmt.Errorf("queue_run list participants: %w", err)
}
ids := make([]string, 0, len(all))
for _, p := range all {
if p.Role == role {
ids = append(ids, p.AgentProfileID)
}
}
if len(ids) == 0 {
return nil, fmt.Errorf("queue_run: no participants with role %q on step %s", role, stepID)
}
return ids, nil
}
func (c QueueRunCallback) resolveCEO(ctx context.Context, taskID string) ([]string, error) {
if c.CEOResolver == nil {
return nil, fmt.Errorf("%w: queue_run target=workspace.ceo_agent requires CEOAgentResolver", ErrActionNotYetWired)
}
id, err := c.CEOResolver.ResolveCEOAgentProfileID(ctx, taskID)
if err != nil {
return nil, fmt.Errorf("queue_run resolve workspace.ceo_agent: %w", err)
}
if id == "" {
return nil, fmt.Errorf("queue_run: workspace has no CEO agent profile for task %s", taskID)
}
return []string{id}, nil
}
// resolveTaskID maps the action's TaskID string into a concrete id, honouring
// the "this" sentinel and the default-empty-means-this convention.
func resolveTaskID(target, currentTaskID string) string {
t := strings.TrimSpace(target)
if t == "" || t == TaskIDThis {
return currentTaskID
}
return t
}
// queueRunReason picks the action-supplied reason, falling back to the
// trigger type so logs and telemetry get a meaningful default.
func queueRunReason(in ActionInput) string {
if in.Action.QueueRun != nil && in.Action.QueueRun.Reason != "" {
return in.Action.QueueRun.Reason
}
if in.Trigger != "" {
return string(in.Trigger)
}
return defaultQueueReasonR
}
// idempotencyKey synthesises a deterministic key from the engine's
// operation id (already idempotent across retries) plus action-specific
// salt. Comment status lookups map runs back through payload.comment_id, so
// even same-task primary comment wakes keep agent/task/action salt. That lets
// a later wake for the same comment reach a newly resolved runner instead of
// being suppressed by a bare task_comment:<comment_id> key.
// When OperationID is empty, the adapter sees an empty key and is expected to
// dedupe via its own mechanism (or accept the duplicate).
func idempotencyKey(in ActionInput, agentID, taskID string) string {
if in.OperationID == "" {
return ""
}
return fmt.Sprintf("%s:%s:%s:%s:%s",
in.OperationID, in.Step.ID, taskID, agentID, queueActionDigest(in))
}
func queueActionDigest(in ActionInput) string {
key := struct {
Kind ActionKind `json:"kind"`
Target string `json:"target,omitempty"`
TaskID string `json:"task_id,omitempty"`
Role string `json:"role,omitempty"`
Reason string `json:"reason"`
Payload map[string]any `json:"payload,omitempty"`
}{
Kind: in.Action.Kind,
}
switch in.Action.Kind {
case ActionQueueRun:
if in.Action.QueueRun != nil {
key.Target = strings.TrimSpace(in.Action.QueueRun.Target)
key.TaskID = strings.TrimSpace(in.Action.QueueRun.TaskID)
key.Reason = queueRunReason(in)
key.Payload = in.Action.QueueRun.Payload
}
case ActionQueueRunForEachParticipant:
if in.Action.QueueRunForEachParticipant != nil {
cfg := in.Action.QueueRunForEachParticipant
key.Role = strings.TrimSpace(cfg.Role)
key.Reason = queueRunForEachParticipantReason(in)
key.Payload = cfg.Payload
}
}
b, err := json.Marshal(key)
if err != nil {
b = []byte(string(key.Kind) + "\x00" + key.Target + "\x00" +
key.TaskID + "\x00" + key.Role + "\x00" + key.Reason)
}
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:8])
}
func queueRunPayload(in ActionInput, actionPayload map[string]any, targetTaskID string) map[string]any {
out := make(map[string]any, len(actionPayload))
comment, ok := commentPayload(in.Payload)
if ok {
if comment.CommentID != "" {
out["comment_id"] = comment.CommentID
}
if comment.AuthorID != "" {
out["author_id"] = comment.AuthorID
}
}
// Workflow-authored payload fields are explicit overrides. The trigger's
// comment_id/author_id only provide defaults for ordinary comment wakes.
for k, v := range actionPayload {
out[k] = v
}
if targetTaskID != "" &&
in.State.TaskID != "" &&
targetTaskID != in.State.TaskID &&
(ok || commentkeys.HasTaskCommentPrefix(in.OperationID)) {
out["source_task_id"] = in.State.TaskID
}
if len(out) == 0 {
return nil
}
return out
}
func commentPayload(payload any) (OnCommentPayload, bool) {
switch p := payload.(type) {
case OnCommentPayload:
return p, true
case *OnCommentPayload:
if p != nil {
return *p, true
}
}
return OnCommentPayload{}, false
}
// ClearDecisionsCallback executes the clear_decisions action by deleting all
// recorded decisions for the trigger's (task, step) pair.
type ClearDecisionsCallback struct {
Decisions DecisionStore
}
// Execute satisfies ActionCallback.
func (c ClearDecisionsCallback) Execute(ctx context.Context, in ActionInput) (ActionResult, error) {
if c.Decisions == nil {
return ActionResult{}, fmt.Errorf("%w: clear_decisions requires DecisionStore", ErrActionNotYetWired)
}
if _, err := c.Decisions.ClearStepDecisions(ctx, in.State.TaskID, in.Step.ID); err != nil {
return ActionResult{}, fmt.Errorf("clear_decisions: %w", err)
}
return ActionResult{}, nil
}
// QueueRunForEachParticipantCallback fans out queue_run over every participant
// on the step matching the configured role.
type QueueRunForEachParticipantCallback struct {
Adapter RunQueueAdapter
Participants ParticipantStore
}
// Execute satisfies ActionCallback.
func (c QueueRunForEachParticipantCallback) Execute(ctx context.Context, in ActionInput) (ActionResult, error) {
if c.Adapter == nil {
return ActionResult{}, fmt.Errorf("%w: queue_run_for_each_participant requires RunQueueAdapter", ErrActionNotYetWired)
}
if c.Participants == nil {
return ActionResult{}, fmt.Errorf("%w: queue_run_for_each_participant requires ParticipantStore", ErrActionNotYetWired)
}
cfg := in.Action.QueueRunForEachParticipant
if cfg == nil || cfg.Role == "" {
return ActionResult{}, fmt.Errorf("queue_run_for_each_participant missing role")
}
taskID := in.State.TaskID
all, err := c.Participants.ListStepParticipants(ctx, in.Step.ID, taskID)
if err != nil {
return ActionResult{}, fmt.Errorf("queue_run_for_each_participant list participants: %w", err)
}
reason := queueRunForEachParticipantReason(in)
for _, p := range all {
if p.Role != cfg.Role {
continue
}
req := QueueRunRequest{
AgentProfileID: p.AgentProfileID,
TaskID: taskID,
WorkflowStepID: in.Step.ID,
Reason: reason,
IdempotencyKey: idempotencyKey(in, p.AgentProfileID, taskID),
Payload: queueRunPayload(in, cfg.Payload, taskID),
}
if err := c.Adapter.QueueRun(ctx, req); err != nil {
return ActionResult{}, fmt.Errorf("queue_run for participant %s: %w", p.ID, err)
}
}
return ActionResult{}, nil
}
func queueRunForEachParticipantReason(in ActionInput) string {
if in.Action.QueueRunForEachParticipant != nil && in.Action.QueueRunForEachParticipant.Reason != "" {
return in.Action.QueueRunForEachParticipant.Reason
}
return string(in.Trigger)
}
// PlaceholderQueueRunCallback is preserved as a typed alias for backward
// compatibility with the Phase 2 prep slice. New code should construct
// QueueRunCallback directly.
//
// Deprecated: Use QueueRunCallback.
type PlaceholderQueueRunCallback struct{}
// Execute returns ErrActionNotYetWired so accidental use is loud.
func (PlaceholderQueueRunCallback) Execute(_ context.Context, _ ActionInput) (ActionResult, error) {
return ActionResult{}, ErrActionNotYetWired
}
// PlaceholderClearDecisionsCallback is preserved for backward compatibility.
//
// Deprecated: Use ClearDecisionsCallback.
type PlaceholderClearDecisionsCallback struct{}
// Execute returns ErrActionNotYetWired so accidental use is loud.
func (PlaceholderClearDecisionsCallback) Execute(_ context.Context, _ ActionInput) (ActionResult, error) {
return ActionResult{}, ErrActionNotYetWired
}
// PlaceholderQueueRunForEachParticipantCallback is preserved for backward
// compatibility.
//
// Deprecated: Use QueueRunForEachParticipantCallback.
type PlaceholderQueueRunForEachParticipantCallback struct{}
// Execute returns ErrActionNotYetWired so accidental use is loud.
func (PlaceholderQueueRunForEachParticipantCallback) Execute(_ context.Context, _ ActionInput) (ActionResult, error) {
return ActionResult{}, ErrActionNotYetWired
}
// Compile-time interface assertions.
var (
_ ActionCallback = QueueRunCallback{}
_ ActionCallback = ClearDecisionsCallback{}
_ ActionCallback = QueueRunForEachParticipantCallback{}
_ ActionCallback = PlaceholderQueueRunCallback{}
_ ActionCallback = PlaceholderClearDecisionsCallback{}
_ ActionCallback = PlaceholderQueueRunForEachParticipantCallback{}
)