iop/apps/agent/internal/command/root.go
toki 45d4bd98fd fix(agent): task-loop 검증 경계와 상태 검사를 보강한다
리뷰에서 확인된 Milestone 식별자와 컴파일 바이너리 상태 검증의 빈틈을 보완하고, 활성 agent-task에서는 dispatcher만 실행 경로로 사용하도록 고정한다.
2026-07-31 18:40:40 +09:00

369 lines
12 KiB
Go

// Package command owns the narrow Cobra surface that exposes the agent runtime
// to a headless operator. This file constructs the root command, registers
// flags, and wires every subcommand to a CommandService.
//
// The root command accepts --repo-config, --local-config, and --provider-catalog
// flags. Every subcommand builds a request DTO, dispatches it through the
// Service port, and prints the response DTO in text or JSON form.
package command
import (
"fmt"
"io"
"github.com/spf13/cobra"
)
// NewRoot constructs the Cobra root command with all subcommands and flags.
// It accepts a Service implementation so the command tree can be driven by
// either a live runtime adapter or a recording fake in tests.
func NewRoot(svc Service, stdout, stderr io.Writer) *cobra.Command {
root := &cobra.Command{
Use: "iop-agent",
Short: "IOP agent headless runtime CLI",
Long: "Headless CLI for the IOP agent runtime. Provides validate, list, preview, serve, start, stop, resume, and status commands over narrow host ports.",
SilenceErrors: true,
SilenceUsage: true,
TraverseChildren: true,
}
var (
repoConfig string
localConfig string
providerCat string
outputFormat string
)
root.PersistentFlags().StringVar(&repoConfig, "repo-config", "", "path to the repo-global runtime config (required for mutating commands)")
root.PersistentFlags().StringVar(&localConfig, "local-config", "", "path to the user-local runtime config (required for mutating commands)")
root.PersistentFlags().StringVar(&providerCat, "provider-catalog", "", "path to the provider catalog YAML (required for mutating commands)")
root.PersistentFlags().StringVar(&outputFormat, "output", "text", "output format: text or json")
root.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
return validateOutputFormat(outputFormat)
}
root.SetOut(stdout)
root.SetErr(stderr)
// validate
root.AddCommand(newValidateCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// provider group
providerCmd := &cobra.Command{
Use: "provider",
Short: "Provider catalog commands",
}
providerCmd.AddCommand(newProviderListCmd(svc, &providerCat, &outputFormat))
root.AddCommand(providerCmd)
// project group
projectCmd := &cobra.Command{
Use: "project",
Short: "Project commands",
}
projectCmd.AddCommand(newProjectListCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
root.AddCommand(projectCmd)
// milestone group
milestoneCmd := &cobra.Command{
Use: "milestone",
Short: "Milestone commands",
}
milestoneCmd.AddCommand(newMilestoneListCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
milestoneCmd.AddCommand(newMilestoneSelectCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
root.AddCommand(milestoneCmd)
// preview
root.AddCommand(newPreviewCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// serve
root.AddCommand(newServeCmd(svc, &repoConfig, &localConfig, &providerCat))
// start
root.AddCommand(newStartCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// stop
root.AddCommand(newStopCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// resume
root.AddCommand(newResumeCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// status
root.AddCommand(newStatusCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
// task-loop
root.AddCommand(newTaskLoopCmd(svc, &repoConfig, &localConfig, &providerCat, &outputFormat))
return root
}
func validateOutputFormat(format string) error {
if format != "text" && format != "json" {
return fmt.Errorf("unsupported output format: %s", format)
}
return nil
}
func newValidateCmd(svc Service, repoConfig, localConfig, providerCat, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "validate",
Short: "Validate configuration paths",
Long: "Load and validate the repo-global, user-local, and provider catalog configurations. Does not mutate any state.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
req := ValidateRequest{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
ProviderCatalog: *providerCat,
}
resp, err := svc.Validate(cmd.Context(), req)
if err != nil {
return fmt.Errorf("validate: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newProviderListCmd(svc Service, providerCat, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List providers from the catalog",
Long: "Enumerate providers declared in the provider catalog. Does not mutate any state.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
req := ProviderListRequest{
CatalogPath: *providerCat,
}
resp, err := svc.ProviderList(cmd.Context(), req)
if err != nil {
return fmt.Errorf("provider list: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newProjectListCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "List registered projects",
Long: "Enumerate registered projects and their Milestone selection state from the loaded runtime snapshot.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
req := ProjectListRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
},
}
resp, err := svc.ProjectList(cmd.Context(), req)
if err != nil {
return fmt.Errorf("project list: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newMilestoneListCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "list [project]",
Short: "List milestones for a project",
Long: "List selectable milestones for the supplied project.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := MilestoneListRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
},
Project: args[0],
}
resp, err := svc.MilestoneList(cmd.Context(), req)
if err != nil {
return fmt.Errorf("milestone list: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newMilestoneSelectCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "select [project] [milestone]",
Short: "Select a Milestone for a project",
Long: "Record the operator's explicit Milestone choice for the supplied project. Start rejects a project without an explicitly selected Milestone.",
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
req := MilestoneSelectRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
},
Project: args[0],
MilestoneID: args[1],
}
resp, err := svc.MilestoneSelect(cmd.Context(), req)
if err != nil {
return fmt.Errorf("milestone select: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newPreviewCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "preview [project]",
Short: "Preview selection and dependency verdict",
Long: "Run the same selection and dependency logic as start but do not mutate durable state, start processes, or acquire leases. Does not call any mutation method.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := PreviewRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
},
Project: args[0],
}
resp, err := svc.Preview(cmd.Context(), req)
if err != nil {
return fmt.Errorf("preview: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newServeCmd(svc Service, repoConfig, localConfig, providerCat *string) *cobra.Command {
return &cobra.Command{
Use: "serve",
Short: "Run the agent runtime lifecycle",
Long: "Block until interrupted and drive the runtime lifecycle. This is the only command that performs sustained mutation.",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
req := ServeRequest{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
ProviderCatalog: *providerCat,
}
return svc.Serve(cmd.Context(), req)
},
}
}
func newStartCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "start [project]",
Short: "Start a project manually",
Long: "Record a manual start intent for the supplied project. Rejects projects whose selected milestone is empty.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := StartRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
ProviderCatalog: *providerCatalog,
},
Project: args[0],
}
resp, err := svc.Start(cmd.Context(), req)
if err != nil {
return fmt.Errorf("start: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newStopCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "stop [project]",
Short: "Stop a project manually",
Long: "Record a manual stop intent for the supplied project.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := StopRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
ProviderCatalog: *providerCatalog,
},
Project: args[0],
}
resp, err := svc.Stop(cmd.Context(), req)
if err != nil {
return fmt.Errorf("stop: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newResumeCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "resume [project]",
Short: "Resume a stopped project",
Long: "Record a manual resume intent for the supplied project.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := ResumeRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
ProviderCatalog: *providerCatalog,
},
Project: args[0],
}
resp, err := svc.Resume(cmd.Context(), req)
if err != nil {
return fmt.Errorf("resume: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
func newStatusCmd(svc Service, repoConfig, localConfig, providerCatalog, outputFormat *string) *cobra.Command {
return &cobra.Command{
Use: "status [project]",
Short: "Show project status",
Long: "Show the live project status including ordered per-work state, dispatch ordinals, overlay/integration state, and blockers.",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
req := StatusRequest{
Config: RuntimeConfigPaths{
RepoGlobalPath: *repoConfig,
UserLocalPath: *localConfig,
},
Project: args[0],
}
resp, err := svc.Status(cmd.Context(), req)
if err != nil {
return fmt.Errorf("status: %w", err)
}
return printOutput(cmd, resp, *outputFormat)
},
}
}
// printOutput dispatches the response DTO to stdout in the requested format.
func printOutput(cmd *cobra.Command, resp interface{}, format string) error {
if err := validateOutputFormat(format); err != nil {
return err
}
out := cmd.OutOrStdout()
if format == "json" {
if j, ok := resp.(interface{ FormatJSON() string }); ok {
_, err := fmt.Fprintln(out, j.FormatJSON())
return err
}
}
if v, ok := resp.(interface{ FormatText() string }); ok {
_, err := fmt.Fprint(out, v.FormatText())
return err
}
return fmt.Errorf("unsupported response type: %T", resp)
}