Skip to content

Commit cf29386

Browse files
AbirAbbasclaude
andauthored
feat(control-plane): agent-mode steering verbs, events query resource, and --json lifecycle output (#751)
* feat(control-plane): authenticated approval resolution endpoint by execution ID Approvals could previously only be resolved through the HMAC-signed webhook (POST /api/v1/webhooks/approval-response), whose secret is held by the external approval service. Extract the resolution core (idempotency, state transitions, approval bookkeeping, events, agent callback) into webhookApprovalController.applyApprovalDecision and add POST /api/v1/executions/:execution_id/approval-response under the authenticated agentAPI group, so operators and CLIs holding an API key can approve/reject/request_changes directly. Webhook behavior is unchanged; 'expired' stays webhook-only since expiry is not an operator decision. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): af agent exec steering verbs in agent-mode CLI Add 'af agent exec pause|resume|cancel|restart|approval-status|approve' as thin wrappers over the existing /api/v1/executions/:execution_id/* endpoints, emitting the standard AgentResponse envelope with non-zero exit on error. approve wires --decision approved|rejected| request_changes [--reason] to the new authenticated approval-response endpoint. proxyToServer now normalizes legacy string errors ({"error":"..."}, optionally with a sibling message) into the structured envelope error object {code,message,hint}, deriving the code from the HTTP status when the handler didn't provide one — so agents scripting against 'af agent' always get {ok:false,error:{...}} on failures. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): events resource in agentic unified query Expose persisted execution lifecycle events (workflow_execution_events) through POST /api/v1/agentic/query as resource=events so agents can poll event history from a shell loop instead of consuming SSE. Queries require filters.execution_id (direct per-execution listing) or filters.run_id (fan-out over the run's execution records); results are sorted by emitted_at ascending with sequence tie-breaking, honor since/until RFC3339 bounds, and paginate with limit/offset (total is the pre-pagination count). No new persistence layer or storage interface methods — this composes the existing QueryExecutionRecords and ListWorkflowExecutionEvents queries. CLI: 'af agent query -r events --execution-id X | --run-id Y' with a new --execution-id filter flag; help documents that this is a pollable snapshot of the SSE stream, not a live subscription. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): --json envelopes for node lifecycle commands Add a --json flag to af list, af install, af run, af stop, and af logs that emits the agent-mode {ok,data,error:{code,message,hint}} envelope on stdout with non-zero exit on error. Default human output is unchanged. - list: {nodes:[{name,version,status,port,description}], total} - install/run: envelope on the real stdout; service-layer progress prints are redirected to stderr for the duration - stop: {node, status: stopped|not_running}; progress output is suppressed via a Quiet flag on AgentNodeStopper (printf wrapper only, no changes to the signal/liveness logic) - logs: {node, log_path, lines:[...]} honoring --tail via a Go-native tail (no external tail binary); --follow with --json is rejected as invalid_flags since a stream cannot be a JSON snapshot Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(control-plane): persist recorded_at on workflow execution events The raw INSERT in storeWorkflowExecutionEventTx omitted recorded_at (the GORM model's autoCreateTime does not apply to raw SQL), leaving NULL in SQLite/Postgres. ListWorkflowExecutionEvents scanned the column into a non-nullable time.Time, so listing any stored event failed with 'unsupported Scan, storing driver.Value type <nil>'. Nothing exercised this listing path end-to-end until the agentic events query resource; the existing storage test masked the bug with a manual UPDATE of recorded_at before listing. Write recorded_at in the INSERT and scan it through sql.NullTime, falling back to emitted_at for legacy NULL rows so old databases keep listing cleanly. Found via runtime verification of 'af agent query -r events' against a local control plane. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(control-plane): in-process unit tests for proxy error normalization structuredErrorFromString and defaultCodeForStatus were exercised only through subprocess exit-path tests, which record no coverage. Add direct table-driven tests so the patch-coverage gate sees these lines. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(control-plane): list approval-response endpoint in discover catalog Register POST /api/v1/executions/:execution_id/approval-response in the agentic API catalog so 'af agent discover' surfaces the new resolution endpoint alongside the other approval routes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(storage): persist recorded_at on the execution_logs raw INSERT too storeExecutionLogEntryTx — the single writer behind both StoreExecutionLogEntry and StoreExecutionLogEntries — omitted recorded_at from its raw INSERT, so every execution-log row landed NULL and every read silently took the legacy emitted_at fallback: the server-ingestion timestamp was never actually stored. It also stamped entry.RecordedAt only AFTER the insert, mutating the caller's struct with a value that never reached the database. This is the same bug this branch already fixed for workflow_execution_events (the GORM model's autoCreateTime does not apply to raw INSERTs); the sibling path was missed. The default is now stamped before the query is built and recorded_at is bound explicitly, mirroring storeWorkflowExecutionEventTx. The regression test uses distinct emitted_at/recorded_at values so the NULL-read fallback cannot masquerade as persistence, and covers the batch path's zero-value stamping; it fails against the previous INSERT. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * test(storage): align clinch execution-log test with persisted recorded_at The recorded_at-persistence fix (raw INSERT no longer leaves the column NULL) made the legacy assertion here fail: it expected the NULL-read fallback (recorded_at -> emitted_at) for a freshly stored entry. Give the entry an explicit RecordedAt and assert it round-trips; the NULL fallback branch is still covered by the forced-NULL UPDATE above. Latent in the branch before the main merge - conflict state had kept full CI from running on the previous head. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 47b8711 commit cf29386

22 files changed

Lines changed: 2198 additions & 47 deletions

control-plane/internal/cli/agent_commands.go

Lines changed: 122 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ func NewAgentCommand() *cobra.Command {
7070
cmd.AddCommand(newAgentSearchCmd())
7171
cmd.AddCommand(newAgentQueryCmd())
7272
cmd.AddCommand(newAgentRunCmd())
73+
cmd.AddCommand(newAgentExecCmd())
7374
cmd.AddCommand(newAgentAgentSummaryCmd())
7475
cmd.AddCommand(newAgentKBCmd())
7576
cmd.AddCommand(newAgentBatchCmd())
@@ -169,6 +170,7 @@ func newAgentQueryCmd() *cobra.Command {
169170
var status string
170171
var agentID string
171172
var runID string
173+
var executionID string
172174
var since string
173175
var until string
174176
var limit int
@@ -178,10 +180,19 @@ func newAgentQueryCmd() *cobra.Command {
178180
cmd := &cobra.Command{
179181
Use: "query",
180182
Short: "Run unified resource query",
183+
Long: `Run a unified resource query against the control plane.
184+
185+
Resources: runs, executions, agents, workflows, sessions, events.
186+
187+
The events resource returns persisted execution lifecycle events
188+
(status transitions, approval waits/resolutions) sorted by emitted_at
189+
ascending — a pollable snapshot of the SSE event stream, filtered by
190+
--execution-id or fanned out across a run with --run-id (one of the two
191+
is required). It does not include live in-flight SSE-only data.`,
181192
Run: func(cmd *cobra.Command, args []string) {
182193
resource = strings.TrimSpace(resource)
183194
if resource == "" {
184-
agentError("missing_required_flag", "--resource is required", "Set --resource to one of runs, executions, agents, workflows, sessions.")
195+
agentError("missing_required_flag", "--resource is required", "Set --resource to one of runs, executions, agents, workflows, sessions, events.")
185196
}
186197

187198
filters := map[string]string{}
@@ -194,6 +205,9 @@ func newAgentQueryCmd() *cobra.Command {
194205
if v := strings.TrimSpace(runID); v != "" {
195206
filters["run_id"] = v
196207
}
208+
if v := strings.TrimSpace(executionID); v != "" {
209+
filters["execution_id"] = v
210+
}
197211
if v := strings.TrimSpace(since); v != "" {
198212
filters["since"] = v
199213
}
@@ -225,10 +239,11 @@ func newAgentQueryCmd() *cobra.Command {
225239
},
226240
}
227241

228-
cmd.Flags().StringVarP(&resource, "resource", "r", "", "Resource type: runs, executions, agents, workflows, sessions")
242+
cmd.Flags().StringVarP(&resource, "resource", "r", "", "Resource type: runs, executions, agents, workflows, sessions, events")
229243
cmd.Flags().StringVar(&status, "status", "", "Status filter")
230244
cmd.Flags().StringVar(&agentID, "agent-id", "", "Agent ID filter")
231245
cmd.Flags().StringVar(&runID, "run-id", "", "Run ID filter")
246+
cmd.Flags().StringVar(&executionID, "execution-id", "", "Execution ID filter (events resource)")
232247
cmd.Flags().StringVar(&since, "since", "", "RFC3339 lower time bound")
233248
cmd.Flags().StringVar(&until, "until", "", "RFC3339 upper time bound")
234249
cmd.Flags().IntVar(&limit, "limit", 20, "Result limit")
@@ -587,6 +602,8 @@ func proxyToServer(method, path string, body interface{}) {
587602
if _, hasHint := errMap["hint"]; !hasHint {
588603
errMap["hint"] = defaultHintForStatus(statusCode)
589604
}
605+
} else if errStr, ok := payload["error"].(string); ok {
606+
payload["error"] = structuredErrorFromString(payload, errStr, statusCode)
590607
}
591608
}
592609

@@ -633,6 +650,44 @@ func readBatchInput(file string) ([]byte, error) {
633650
return data, nil
634651
}
635652

653+
// structuredErrorFromString converts legacy {"error":"..."} responses into the
654+
// structured envelope error object {code,message,hint}. Handlers that return
655+
// {"error":"some_code","message":"detail"} keep the error string as the code;
656+
// otherwise the code is derived from the HTTP status.
657+
func structuredErrorFromString(payload map[string]interface{}, errStr string, statusCode int) map[string]interface{} {
658+
code := defaultCodeForStatus(statusCode)
659+
message := errStr
660+
if msg, ok := payload["message"].(string); ok && strings.TrimSpace(msg) != "" {
661+
code = errStr
662+
message = msg
663+
}
664+
return map[string]interface{}{
665+
"code": code,
666+
"message": message,
667+
"hint": defaultHintForStatus(statusCode),
668+
}
669+
}
670+
671+
func defaultCodeForStatus(statusCode int) string {
672+
switch statusCode {
673+
case http.StatusBadRequest:
674+
return "bad_request"
675+
case http.StatusUnauthorized:
676+
return "unauthorized"
677+
case http.StatusForbidden:
678+
return "forbidden"
679+
case http.StatusNotFound:
680+
return "not_found"
681+
case http.StatusConflict:
682+
return "conflict"
683+
default:
684+
if statusCode >= 500 {
685+
return "server_error"
686+
}
687+
return "request_failed"
688+
}
689+
}
690+
636691
func defaultHintForStatus(statusCode int) string {
637692
switch statusCode {
638693
case http.StatusUnauthorized:
@@ -707,20 +762,22 @@ func agentHelpData() map[string]interface{} {
707762
},
708763
{
709764
"name": "query",
710-
"description": "Query runs/executions/agents/workflows/sessions",
711-
"usage": "af agent query --resource runs [--status] [--agent-id] [--run-id] [--since] [--until] [--limit] [--offset] [--include]",
765+
"description": "Query runs/executions/agents/workflows/sessions/events",
766+
"usage": "af agent query --resource runs [--status] [--agent-id] [--run-id] [--execution-id] [--since] [--until] [--limit] [--offset] [--include]",
712767
"flags": []map[string]string{
713768
{"name": "resource", "short": "r", "type": "string (required)"},
714769
{"name": "status", "short": "", "type": "string"},
715770
{"name": "agent-id", "short": "", "type": "string"},
716771
{"name": "run-id", "short": "", "type": "string"},
772+
{"name": "execution-id", "short": "", "type": "string"},
717773
{"name": "since", "short": "", "type": "string(RFC3339)"},
718774
{"name": "until", "short": "", "type": "string(RFC3339)"},
719775
{"name": "limit", "short": "", "type": "int"},
720776
{"name": "offset", "short": "", "type": "int"},
721777
{"name": "include", "short": "", "type": "string(csv)"},
722778
},
723779
"example": "af agent query -r executions --agent-id analyst --status completed --limit 25",
780+
"notes": "resource=events returns persisted execution lifecycle events sorted by emitted_at ascending; requires --execution-id or --run-id. It is a pollable snapshot of the SSE stream, not a live subscription.",
724781
},
725782
{
726783
"name": "run",
@@ -736,6 +793,66 @@ func agentHelpData() map[string]interface{} {
736793
"flags": []map[string]string{{"name": "id", "short": "", "type": "string (required)"}},
737794
"example": "af agent agent-summary --id sec-analyst",
738795
},
796+
{
797+
"name": "exec pause",
798+
"description": "Pause a running execution",
799+
"usage": "af agent exec pause --id <execution_id> [--reason <text>]",
800+
"flags": []map[string]string{
801+
{"name": "id", "short": "", "type": "string (required)"},
802+
{"name": "reason", "short": "", "type": "string"},
803+
},
804+
"example": "af agent exec pause --id exec_123 --reason 'manual review'",
805+
},
806+
{
807+
"name": "exec resume",
808+
"description": "Resume a paused execution",
809+
"usage": "af agent exec resume --id <execution_id>",
810+
"flags": []map[string]string{{"name": "id", "short": "", "type": "string (required)"}},
811+
"example": "af agent exec resume --id exec_123",
812+
},
813+
{
814+
"name": "exec cancel",
815+
"description": "Cancel an execution",
816+
"usage": "af agent exec cancel --id <execution_id> [--reason <text>]",
817+
"flags": []map[string]string{
818+
{"name": "id", "short": "", "type": "string (required)"},
819+
{"name": "reason", "short": "", "type": "string"},
820+
},
821+
"example": "af agent exec cancel --id exec_123 --reason 'wrong input'",
822+
},
823+
{
824+
"name": "exec restart",
825+
"description": "Restart a workflow from an execution point",
826+
"usage": "af agent exec restart --id <execution_id> [--scope] [--reuse] [--fork] [--model] [--input] [--reason]",
827+
"flags": []map[string]string{
828+
{"name": "id", "short": "", "type": "string (required)"},
829+
{"name": "scope", "short": "", "type": "string (workflow|execution)"},
830+
{"name": "reuse", "short": "", "type": "string (succeeded-before|all-succeeded|none)"},
831+
{"name": "fork", "short": "", "type": "bool"},
832+
{"name": "model", "short": "", "type": "string"},
833+
{"name": "input", "short": "", "type": "string (JSON or @path)"},
834+
{"name": "reason", "short": "", "type": "string"},
835+
},
836+
"example": "af agent exec restart --id exec_123 --scope workflow --reuse succeeded-before",
837+
},
838+
{
839+
"name": "exec approval-status",
840+
"description": "Get the approval status for an execution",
841+
"usage": "af agent exec approval-status --id <execution_id>",
842+
"flags": []map[string]string{{"name": "id", "short": "", "type": "string (required)"}},
843+
"example": "af agent exec approval-status --id exec_123",
844+
},
845+
{
846+
"name": "exec approve",
847+
"description": "Resolve a pending approval for an execution",
848+
"usage": "af agent exec approve --id <execution_id> --decision <approved|rejected|request_changes> [--reason <text>]",
849+
"flags": []map[string]string{
850+
{"name": "id", "short": "", "type": "string (required)"},
851+
{"name": "decision", "short": "", "type": "string (required)"},
852+
{"name": "reason", "short": "", "type": "string"},
853+
},
854+
"example": "af agent exec approve --id exec_123 --decision approved",
855+
},
739856
{
740857
"name": "kb topics",
741858
"description": "List knowledge base topics",
@@ -797,7 +914,7 @@ func agentHelpData() map[string]interface{} {
797914
"auth": map[string]interface{}{
798915
"method": "Set X-API-Key header via --api-key or AGENTFIELD_API_KEY",
799916
"public_endpoints": []string{"GET /api/v1/agentic/kb/topics", "GET /api/v1/agentic/kb/articles", "GET /api/v1/agentic/kb/articles/:article_id", "GET /api/v1/agentic/kb/guide"},
800-
"requires_auth": []string{"GET /api/v1/agentic/status", "GET /api/v1/agentic/discover", "GET /api/v1/agentic/reasoners", "POST /api/v1/agentic/query", "GET /api/v1/agentic/run/:run_id", "GET /api/v1/agentic/agent/:agent_id/summary", "POST /api/v1/agentic/batch"},
917+
"requires_auth": []string{"GET /api/v1/agentic/status", "GET /api/v1/agentic/discover", "GET /api/v1/agentic/reasoners", "POST /api/v1/agentic/query", "GET /api/v1/agentic/run/:run_id", "GET /api/v1/agentic/agent/:agent_id/summary", "POST /api/v1/agentic/batch", "POST /api/v1/executions/:execution_id/pause", "POST /api/v1/executions/:execution_id/resume", "POST /api/v1/executions/:execution_id/cancel", "POST /api/v1/executions/:execution_id/restart", "GET /api/v1/executions/:execution_id/approval-status", "POST /api/v1/executions/:execution_id/approval-response"},
801918
},
802919
"response_schemas": map[string]interface{}{
803920
"success": map[string]interface{}{
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
package cli
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/url"
7+
"strings"
8+
9+
"github.com/spf13/cobra"
10+
)
11+
12+
// newAgentExecCmd groups execution steering verbs for agent mode. Each verb is
13+
// a thin wrapper over the existing /api/v1/executions/:execution_id/* REST
14+
// endpoints and emits the standard AgentResponse JSON envelope.
15+
func newAgentExecCmd() *cobra.Command {
16+
execCmd := &cobra.Command{
17+
Use: "exec",
18+
Short: "Steer executions: pause, resume, cancel, restart, approvals",
19+
Run: func(cmd *cobra.Command, args []string) {
20+
agentOutput(map[string]interface{}{
21+
"message": "Use an exec subcommand",
22+
"available": []string{
23+
"pause",
24+
"resume",
25+
"cancel",
26+
"restart",
27+
"approval-status",
28+
"approve",
29+
},
30+
})
31+
},
32+
}
33+
34+
execCmd.AddCommand(newAgentExecActionCmd("pause", "Pause a running execution", true))
35+
execCmd.AddCommand(newAgentExecActionCmd("resume", "Resume a paused execution", false))
36+
execCmd.AddCommand(newAgentExecActionCmd("cancel", "Cancel an execution", true))
37+
execCmd.AddCommand(newAgentExecRestartCmd())
38+
execCmd.AddCommand(newAgentExecApprovalStatusCmd())
39+
execCmd.AddCommand(newAgentExecApproveCmd())
40+
41+
return execCmd
42+
}
43+
44+
// requireExecutionID trims and validates the --id flag shared by exec verbs.
45+
// On failure it emits an error envelope and exits non-zero via agentError.
46+
func requireExecutionID(executionID, verb string) string {
47+
trimmed := strings.TrimSpace(executionID)
48+
if trimmed == "" {
49+
agentError(
50+
"missing_required_flag",
51+
"--id is required",
52+
fmt.Sprintf("Provide an execution ID, for example: af agent exec %s --id exec_123", verb),
53+
)
54+
}
55+
return trimmed
56+
}
57+
58+
// newAgentExecActionCmd builds pause/resume/cancel verbs. withReason controls
59+
// whether an optional --reason flag is sent in the request body.
60+
func newAgentExecActionCmd(action, short string, withReason bool) *cobra.Command {
61+
var executionID string
62+
var reason string
63+
64+
cmd := &cobra.Command{
65+
Use: action,
66+
Short: short,
67+
Run: func(cmd *cobra.Command, args []string) {
68+
id := requireExecutionID(executionID, action)
69+
70+
var body interface{}
71+
if withReason {
72+
payload := map[string]string{}
73+
if v := strings.TrimSpace(reason); v != "" {
74+
payload["reason"] = v
75+
}
76+
body = payload
77+
}
78+
79+
proxyToServer(http.MethodPost, "/api/v1/executions/"+url.PathEscape(id)+"/"+action, body)
80+
},
81+
}
82+
83+
cmd.Flags().StringVar(&executionID, "id", "", "Execution ID")
84+
if withReason {
85+
cmd.Flags().StringVar(&reason, "reason", "", "Reason for the action")
86+
}
87+
return cmd
88+
}
89+
90+
func newAgentExecRestartCmd() *cobra.Command {
91+
var executionID string
92+
opts := executionActionOptions{
93+
scope: "workflow",
94+
reuse: "succeeded-before",
95+
}
96+
97+
cmd := &cobra.Command{
98+
Use: "restart",
99+
Short: "Restart a workflow from an execution point",
100+
Run: func(cmd *cobra.Command, args []string) {
101+
id := requireExecutionID(executionID, "restart")
102+
103+
body, err := buildRestartExecutionBody(opts)
104+
if err != nil {
105+
agentError(
106+
"invalid_flag_value",
107+
err.Error(),
108+
"Pass valid JSON to --input (inline or @path) and check the restart flags.",
109+
)
110+
}
111+
112+
proxyToServer(http.MethodPost, "/api/v1/executions/"+url.PathEscape(id)+"/restart", body)
113+
},
114+
}
115+
116+
cmd.Flags().StringVar(&executionID, "id", "", "Execution ID")
117+
cmd.Flags().StringVar(&opts.scope, "scope", opts.scope, "Restart scope: workflow or execution")
118+
cmd.Flags().StringVar(&opts.reuse, "reuse", opts.reuse, "Replay reuse mode: succeeded-before, all-succeeded, or none")
119+
cmd.Flags().BoolVar(&opts.fork, "fork", false, "Mark this restart as a fork with intentional changes")
120+
cmd.Flags().StringVar(&opts.model, "model", "", "Model override to send in restart context")
121+
cmd.Flags().StringVar(&opts.input, "input", "", "JSON input override or @path to a JSON file")
122+
cmd.Flags().StringVar(&opts.reason, "reason", "", "Reason for restarting the execution")
123+
return cmd
124+
}
125+
126+
func newAgentExecApprovalStatusCmd() *cobra.Command {
127+
var executionID string
128+
129+
cmd := &cobra.Command{
130+
Use: "approval-status",
131+
Short: "Get the approval status for an execution",
132+
Run: func(cmd *cobra.Command, args []string) {
133+
id := requireExecutionID(executionID, "approval-status")
134+
proxyToServer(http.MethodGet, "/api/v1/executions/"+url.PathEscape(id)+"/approval-status", nil)
135+
},
136+
}
137+
138+
cmd.Flags().StringVar(&executionID, "id", "", "Execution ID")
139+
return cmd
140+
}
141+
142+
func newAgentExecApproveCmd() *cobra.Command {
143+
var executionID string
144+
var decision string
145+
var reason string
146+
147+
cmd := &cobra.Command{
148+
Use: "approve",
149+
Short: "Resolve a pending approval for an execution",
150+
Run: func(cmd *cobra.Command, args []string) {
151+
id := requireExecutionID(executionID, "approve --decision approved")
152+
153+
decision = strings.TrimSpace(decision)
154+
if decision == "" {
155+
agentError(
156+
"missing_required_flag",
157+
"--decision is required",
158+
"Set --decision to approved, rejected, or request_changes.",
159+
)
160+
}
161+
switch decision {
162+
case "approved", "rejected", "request_changes":
163+
default:
164+
agentError(
165+
"invalid_flag_value",
166+
fmt.Sprintf("invalid --decision %q", decision),
167+
"Set --decision to approved, rejected, or request_changes.",
168+
)
169+
}
170+
171+
payload := map[string]string{"decision": decision}
172+
if v := strings.TrimSpace(reason); v != "" {
173+
payload["reason"] = v
174+
}
175+
176+
proxyToServer(http.MethodPost, "/api/v1/executions/"+url.PathEscape(id)+"/approval-response", payload)
177+
},
178+
}
179+
180+
cmd.Flags().StringVar(&executionID, "id", "", "Execution ID")
181+
cmd.Flags().StringVar(&decision, "decision", "", "Approval decision: approved, rejected, or request_changes")
182+
cmd.Flags().StringVar(&reason, "reason", "", "Optional feedback recorded with the decision")
183+
return cmd
184+
}

0 commit comments

Comments
 (0)