5a2a063a76
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
289 lines
9.4 KiB
Go
289 lines
9.4 KiB
Go
package main
|
|
|
|
import (
|
|
"flag"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
const (
|
|
decisionApproved = "approved"
|
|
decisionRejected = "rejected"
|
|
)
|
|
|
|
func runTaskCmd(args []string) int {
|
|
if len(args) == 0 {
|
|
cliError("usage: agentctl kandev task <get|update|create|decision|handoff> [flags]")
|
|
return 1
|
|
}
|
|
switch args[0] {
|
|
case subcmdGet:
|
|
return taskGet(args[1:])
|
|
case "update":
|
|
return taskUpdate(args[1:])
|
|
case subcmdCreate:
|
|
return taskCreate(args[1:])
|
|
case "decision":
|
|
return taskDecision(args[1:])
|
|
case "handoff":
|
|
return taskHandoff(args[1:])
|
|
default:
|
|
cliError("unknown task subcommand: %s", args[0])
|
|
return 1
|
|
}
|
|
}
|
|
|
|
// taskDecision records the caller's workflow-step verdict through the signed
|
|
// Office runtime API. The runtime derives every identity field from the token.
|
|
func taskDecision(args []string) int {
|
|
fs := flag.NewFlagSet("task decision", flag.ContinueOnError)
|
|
decision := fs.String("decision", "", fmt.Sprintf("Verdict: %s or %s (required)", decisionApproved, decisionRejected))
|
|
reason := fs.String("reason", "", "Reason for the verdict (required)")
|
|
if err := fs.Parse(args); err != nil {
|
|
cliError("parse flags: %v", err)
|
|
return 1
|
|
}
|
|
if fs.NArg() > 0 {
|
|
cliError("unexpected arguments: %s", strings.Join(fs.Args(), " "))
|
|
return 1
|
|
}
|
|
switch *decision {
|
|
case decisionApproved, decisionRejected:
|
|
default:
|
|
cliError("--decision must be %q or %q", decisionApproved, decisionRejected)
|
|
return 1
|
|
}
|
|
if strings.TrimSpace(*reason) == "" {
|
|
cliError("--reason is required")
|
|
return 1
|
|
}
|
|
|
|
client, err := newKandevClient()
|
|
if err != nil {
|
|
cliError("%v", err)
|
|
return 1
|
|
}
|
|
body, status, err := client.do(http.MethodPost, "/api/v1/office/runtime/task/decision", map[string]string{
|
|
"decision": *decision,
|
|
"reason": *reason,
|
|
})
|
|
return handleResponse(body, status, err)
|
|
}
|
|
|
|
// taskGet fetches a task by ID. Defaults to $KANDEV_TASK_ID when --id is omitted.
|
|
func taskGet(args []string) int {
|
|
fs := flag.NewFlagSet("task get", flag.ContinueOnError)
|
|
id := fs.String("id", "", "Task ID (defaults to $KANDEV_TASK_ID)")
|
|
if err := fs.Parse(args); err != nil {
|
|
cliError("parse flags: %v", err)
|
|
return 1
|
|
}
|
|
|
|
client, err := newKandevClient()
|
|
if err != nil {
|
|
cliError("%v", err)
|
|
return 1
|
|
}
|
|
|
|
taskID := resolveTaskID(*id, client.taskID)
|
|
if taskID == "" {
|
|
cliError("task ID required: use --id or set KANDEV_TASK_ID")
|
|
return 1
|
|
}
|
|
|
|
body, status, err := client.do(http.MethodGet,
|
|
fmt.Sprintf("/api/v1/office/tasks/%s", taskID), nil)
|
|
return handleResponse(body, status, err)
|
|
}
|
|
|
|
// taskUpdate changes task status through the authenticated Office runtime API.
|
|
func taskUpdate(args []string) int {
|
|
fs := flag.NewFlagSet("task update", flag.ContinueOnError)
|
|
id := fs.String("id", "", "Task ID (defaults to $KANDEV_TASK_ID)")
|
|
status := fs.String("status", "", "New status")
|
|
comment := fs.String("comment", "", "Comment to add")
|
|
if err := fs.Parse(args); err != nil {
|
|
cliError("parse flags: %v", err)
|
|
return 1
|
|
}
|
|
|
|
if strings.TrimSpace(*status) == "" {
|
|
if strings.TrimSpace(*comment) != "" {
|
|
cliError("--status is required; use `kandev tasks message` for comment-only updates")
|
|
} else {
|
|
cliError("nothing to update: pass --status")
|
|
}
|
|
return 1
|
|
}
|
|
|
|
client, err := newKandevClient()
|
|
if err != nil {
|
|
cliError("%v", err)
|
|
return 1
|
|
}
|
|
|
|
taskID := resolveTaskID(*id, client.taskID)
|
|
if taskID == "" {
|
|
cliError("task ID required: use --id or set KANDEV_TASK_ID")
|
|
return 1
|
|
}
|
|
|
|
payload := map[string]string{"status": *status}
|
|
if *comment != "" {
|
|
payload["comment"] = *comment
|
|
}
|
|
|
|
body, statusCode, err := client.do(http.MethodPost,
|
|
fmt.Sprintf("/api/v1/office/runtime/tasks/%s/status", taskID), payload)
|
|
return handleResponse(body, statusCode, err)
|
|
}
|
|
|
|
// taskCreate creates a new task through the authenticated Office runtime API.
|
|
func taskCreate(args []string) int {
|
|
fs := flag.NewFlagSet("task create", flag.ContinueOnError)
|
|
title := fs.String("title", "", "Task title (required)")
|
|
description := fs.String("description", "", "Task description")
|
|
parent := fs.String("parent", "", "Parent task ID")
|
|
assignee := fs.String("assignee", "", "Assignee agent ID")
|
|
priority := fs.String("priority", "", "Priority value")
|
|
project := fs.String("project", "", "Project ID")
|
|
blockedBy := fs.String("blocked-by", "", "Comma-separated task IDs that must complete before this task")
|
|
workspaceMode := fs.String("workspace-mode", "", "Workspace mode for this task: inherit_parent, new_workspace, or shared_group")
|
|
workspaceGroupID := fs.String("workspace-group-id", "", "Workspace group ID to join (required when --workspace-mode=shared_group)")
|
|
defaultChildWorkspace := fs.String("default-child-workspace", "", "Parent-only: default workspace mode for children (inherit_parent or new_workspace)")
|
|
defaultChildOrdering := fs.String("default-child-ordering", "", "Parent-only ordering policy for children. 'sequential' records dependency edges between siblings; 'parallel' records none. Note: recording an edge does NOT by itself defer when a child starts.")
|
|
if err := fs.Parse(args); err != nil {
|
|
cliError("parse flags: %v", err)
|
|
return 1
|
|
}
|
|
|
|
normalizedTitle := strings.TrimSpace(*title)
|
|
if normalizedTitle == "" {
|
|
cliError("--title is required")
|
|
return 1
|
|
}
|
|
unsupported := []struct {
|
|
name string
|
|
value string
|
|
}{
|
|
{"priority", *priority},
|
|
{"blocked-by", *blockedBy},
|
|
{"workspace-mode", *workspaceMode},
|
|
{"workspace-group-id", *workspaceGroupID},
|
|
{"default-child-workspace", *defaultChildWorkspace},
|
|
{"default-child-ordering", *defaultChildOrdering},
|
|
}
|
|
for _, field := range unsupported {
|
|
if strings.TrimSpace(field.value) != "" {
|
|
cliError("--%s is not supported by Office runtime task create", field.name)
|
|
return 1
|
|
}
|
|
}
|
|
|
|
client, err := newKandevClient()
|
|
if err != nil {
|
|
cliError("%v", err)
|
|
return 1
|
|
}
|
|
payload := map[string]interface{}{
|
|
"title": normalizedTitle,
|
|
}
|
|
if *description != "" {
|
|
payload["description"] = *description
|
|
}
|
|
if *parent != "" {
|
|
payload["parent_id"] = *parent
|
|
}
|
|
if *assignee != "" {
|
|
payload["assignee"] = *assignee
|
|
}
|
|
if *project != "" {
|
|
payload["project_id"] = *project
|
|
}
|
|
|
|
body, status, err := client.do(http.MethodPost, "/api/v1/office/runtime/tasks", payload)
|
|
return handleResponse(body, status, err)
|
|
}
|
|
|
|
// taskHandoff creates a delivery task in a workspace other than the caller's
|
|
// own, through the authenticated Office runtime handoff route. Every flag is
|
|
// tracked through fs.Visit rather than the sibling subcommands' `if value !=
|
|
// "" { … }` idiom: a flag supplied with an empty value must still reach the
|
|
// route so it can apply AC-5a's blank-vs-absent distinction, which the idiom
|
|
// would silently swallow.
|
|
func taskHandoff(args []string) int {
|
|
fs := flag.NewFlagSet("task handoff", flag.ContinueOnError)
|
|
targetWorkspaceID := fs.String("target-workspace-id", "", "Target workspace ID (required)")
|
|
workflowID := fs.String("workflow-id", "", "Workflow ID in the target workspace (required)")
|
|
title := fs.String("title", "", "Delivery task title, 60 runes or fewer (required)")
|
|
prompt := fs.String("prompt", "", "Delivery agent's first user message (required)")
|
|
agentProfileID := fs.String("agent-profile-id", "", "Delivery agent profile ID (required)")
|
|
executorProfileID := fs.String("executor-profile-id", "", "Delivery executor profile ID (required)")
|
|
repositoryID := fs.String("repository-id", "", "Repository to attach to the delivery task")
|
|
baseBranch := fs.String("base-branch", "", "Base branch; only valid with --repository-id")
|
|
startAgent := fs.Bool("start-agent", false, "Start the delivery agent immediately (default false, diverging from same-workspace task creation's default of true: starting immediately in a workspace you do not run in is the riskiest available default)")
|
|
externalID := fs.String("external-id", "", "Create-idempotency key; omitting it makes the call non-idempotent, so a retry can create a duplicate delivery task. When a retry is possible, derive it from the deciding artefact, not from --title, which can change between attempts")
|
|
if err := fs.Parse(args); err != nil {
|
|
cliError("parse flags: %v", err)
|
|
return 1
|
|
}
|
|
if fs.NArg() > 0 {
|
|
cliError("unexpected arguments: %s", strings.Join(fs.Args(), " "))
|
|
return 1
|
|
}
|
|
|
|
visited := map[string]bool{}
|
|
fs.Visit(func(f *flag.Flag) { visited[f.Name] = true })
|
|
|
|
payload := map[string]interface{}{}
|
|
if visited["target-workspace-id"] {
|
|
payload["target_workspace_id"] = *targetWorkspaceID
|
|
}
|
|
if visited["workflow-id"] {
|
|
payload["workflow_id"] = *workflowID
|
|
}
|
|
if visited["title"] {
|
|
payload["title"] = *title
|
|
}
|
|
if visited["prompt"] {
|
|
payload["prompt"] = *prompt
|
|
}
|
|
if visited["agent-profile-id"] {
|
|
payload["agent_profile_id"] = *agentProfileID
|
|
}
|
|
if visited["executor-profile-id"] {
|
|
payload["executor_profile_id"] = *executorProfileID
|
|
}
|
|
if visited["repository-id"] {
|
|
payload["repository_id"] = *repositoryID
|
|
}
|
|
if visited["base-branch"] {
|
|
payload["base_branch"] = *baseBranch
|
|
}
|
|
if visited["start-agent"] {
|
|
payload["start_agent"] = *startAgent
|
|
}
|
|
if visited["external-id"] {
|
|
payload["external_id"] = *externalID
|
|
}
|
|
|
|
client, err := newKandevClient()
|
|
if err != nil {
|
|
cliError("%v", err)
|
|
return 1
|
|
}
|
|
|
|
body, status, err := client.do(http.MethodPost, "/api/v1/office/runtime/handoffs", payload)
|
|
return handleResponse(body, status, err)
|
|
}
|
|
|
|
// resolveTaskID returns the explicit ID if provided, otherwise falls back to
|
|
// the default (from KANDEV_TASK_ID env var).
|
|
func resolveTaskID(explicit, defaultID string) string {
|
|
if explicit != "" {
|
|
return explicit
|
|
}
|
|
return defaultID
|
|
}
|