| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "fmt" |
| 5 | "io" |
| 6 | "os" |
| 7 | "sort" |
| 8 | "strings" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/agent" |
| 12 | "reasonix/internal/jobs" |
| 13 | ) |
| 14 | |
| 15 | type machineTask struct { |
| 16 | ID string `json:"id"` |
| 17 | SessionID string `json:"session_id"` |
| 18 | Kind string `json:"kind"` |
| 19 | Status string `json:"status"` |
| 20 | StartedAt string `json:"started_at"` |
| 21 | FinishedAt string `json:"finished_at,omitempty"` |
| 22 | ArtifactComplete bool `json:"artifact_complete"` |
| 23 | } |
| 24 | |
| 25 | type machineTaskList struct { |
| 26 | SchemaVersion int `json:"schema_version"` |
| 27 | Command string `json:"command"` |
| 28 | Tasks []machineTask `json:"tasks"` |
| 29 | } |
| 30 | |
| 31 | type machineTaskShow struct { |
| 32 | SchemaVersion int `json:"schema_version"` |
| 33 | Command string `json:"command"` |
| 34 | Task machineTask `json:"task"` |
| 35 | } |
| 36 | |
| 37 | type taskMachineOptions struct { |
| 38 | dir string |
| 39 | projectRoot string |
| 40 | sessionID string |
| 41 | target string |
| 42 | json bool |
| 43 | } |
| 44 | |
| 45 | func runTaskCommand(args []string, out io.Writer) int { |
| 46 | command := "task" |
| 47 | if len(args) == 0 { |
| 48 | return writeMachineError(out, command, "invalid_argument", "a task operation is required") |
| 49 | } |
| 50 | operation := args[0] |
| 51 | command = "task." + operation |
| 52 | if operation != "list" && operation != "show" { |
| 53 | return writeMachineError(out, command, "unknown_command", fmt.Sprintf("unknown task operation %q; expected list or show", operation)) |
| 54 | } |
| 55 | options, code, message := parseTaskMachineOptions(args[1:], operation) |
| 56 | if code != "" { |
| 57 | return writeMachineError(out, command, code, message) |
| 58 | } |
| 59 | if !options.json { |
| 60 | return writeMachineError(out, command, "invalid_argument", "--json is required") |
| 61 | } |
| 62 | options.dir = resolveMachineSessionDir(options.dir, options.projectRoot) |
| 63 | identityKey, err := loadMachineIdentityKey() |
| 64 | if err != nil { |
| 65 | return writeMachineError(out, command, "machine_identity_unavailable", "machine identity is unavailable") |
| 66 | } |
| 67 | tasks, err := machineTasks(options.dir, options.sessionID, identityKey) |
| 68 | if err != nil { |
| 69 | return writeMachineError(out, command, "task_state_unavailable", "task state is unavailable") |
| 70 | } |
| 71 | if operation == "list" { |
| 72 | return writeMachineJSON(out, machineTaskList{SchemaVersion: machineSchemaVersion, Command: command, Tasks: tasks}) |
| 73 | } |
| 74 | var found *machineTask |
| 75 | for i := range tasks { |
| 76 | if tasks[i].ID != options.target { |
| 77 | continue |
| 78 | } |
| 79 | if found != nil { |
| 80 | return writeMachineError(out, command, "task_ambiguous", "task identifier is ambiguous") |
| 81 | } |
| 82 | found = &tasks[i] |
| 83 | } |
| 84 | if found == nil { |
| 85 | return writeMachineError(out, command, "task_not_found", "task was not found") |
| 86 | } |
| 87 | return writeMachineJSON(out, machineTaskShow{SchemaVersion: machineSchemaVersion, Command: command, Task: *found}) |
| 88 | } |
| 89 | |
| 90 | func parseTaskMachineOptions(args []string, operation string) (taskMachineOptions, string, string) { |
| 91 | var options taskMachineOptions |
| 92 | for i := 0; i < len(args); i++ { |
| 93 | switch args[i] { |
| 94 | case "--json": |
| 95 | options.json = true |
| 96 | case "--dir": |
| 97 | if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" { |
| 98 | return options, "invalid_argument", "--dir requires a value" |
| 99 | } |
| 100 | i++ |
| 101 | options.dir = args[i] |
| 102 | case "--project-root": |
| 103 | if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" { |
| 104 | return options, "invalid_argument", "--project-root requires a value" |
| 105 | } |
| 106 | i++ |
| 107 | options.projectRoot = args[i] |
| 108 | case "--session": |
| 109 | if i+1 >= len(args) || !validMachineID(args[i+1]) { |
| 110 | return options, "invalid_argument", "--session requires a valid identifier" |
| 111 | } |
| 112 | i++ |
| 113 | options.sessionID = args[i] |
| 114 | case "--help", "-h": |
| 115 | return options, "invalid_argument", "use the documented machine interface" |
| 116 | default: |
| 117 | arg := strings.TrimSpace(args[i]) |
| 118 | if strings.HasPrefix(arg, "-") { |
| 119 | return options, "invalid_argument", "unknown task option" |
| 120 | } |
| 121 | if operation == "list" || options.target != "" || !validMachineID(arg) { |
| 122 | return options, "invalid_argument", "invalid task identifier" |
| 123 | } |
| 124 | options.target = arg |
| 125 | } |
| 126 | } |
| 127 | if options.dir != "" && options.projectRoot != "" { |
| 128 | return options, "invalid_argument", "--dir and --project-root cannot be combined" |
| 129 | } |
| 130 | if operation == "show" && options.target == "" { |
| 131 | return options, "invalid_argument", "a task identifier is required" |
| 132 | } |
| 133 | return options, "", "" |
| 134 | } |
| 135 | |
| 136 | func machineTasks(dir, sessionFilter string, identityKey []byte) ([]machineTask, error) { |
| 137 | ordered, err := agent.ListSessionOrder(dir) |
| 138 | if err != nil { |
| 139 | return nil, err |
| 140 | } |
| 141 | out := make([]machineTask, 0) |
| 142 | for _, session := range ordered { |
| 143 | rawSessionID := agent.BranchID(session.Path) |
| 144 | sessionID := machineSessionIDWithKey(rawSessionID, identityKey) |
| 145 | if sessionFilter != "" && sessionID != sessionFilter { |
| 146 | continue |
| 147 | } |
| 148 | sessionActive := agent.SessionLeaseHeld(session.Path) |
| 149 | views, err := jobs.ListArtifactViews(session.Path) |
| 150 | if err != nil { |
| 151 | return nil, err |
| 152 | } |
| 153 | for _, view := range views { |
| 154 | if view.Kind != "task" { |
| 155 | continue |
| 156 | } |
| 157 | status := view.Status |
| 158 | finishedAt := machineUnixMillis(view.FinishedAt) |
| 159 | artifactComplete := view.ArtifactComplete |
| 160 | if status == jobs.Running && !sessionActive { |
| 161 | status = jobs.Interrupted |
| 162 | finishedAt = "" |
| 163 | artifactComplete = false |
| 164 | } |
| 165 | out = append(out, machineTask{ |
| 166 | ID: view.ID, |
| 167 | SessionID: sessionID, |
| 168 | Kind: "background", |
| 169 | Status: string(status), |
| 170 | StartedAt: machineUnixMillis(view.StartedAt), |
| 171 | FinishedAt: finishedAt, |
| 172 | ArtifactComplete: artifactComplete, |
| 173 | }) |
| 174 | } |
| 175 | artifacts, err := agent.ListSubagentsByParent(dir, rawSessionID) |
| 176 | if err != nil { |
| 177 | return nil, err |
| 178 | } |
| 179 | for _, artifact := range artifacts { |
| 180 | if artifact.Meta.Kind != "task" { |
| 181 | continue |
| 182 | } |
| 183 | status := artifact.Meta.Status |
| 184 | finishedAt := "" |
| 185 | artifactComplete := false |
| 186 | if status == agent.SubagentRunning { |
| 187 | if !sessionActive { |
| 188 | status = agent.SubagentInterrupted |
| 189 | } |
| 190 | } else { |
| 191 | finishedAt = machineTime(artifact.Meta.UpdatedAt) |
| 192 | artifactComplete = machineArtifactComplete(artifact.SessionPath) |
| 193 | } |
| 194 | out = append(out, machineTask{ |
| 195 | ID: artifact.Ref, |
| 196 | SessionID: sessionID, |
| 197 | Kind: "subagent", |
| 198 | Status: string(status), |
| 199 | StartedAt: machineTime(artifact.Meta.CreatedAt), |
| 200 | FinishedAt: finishedAt, |
| 201 | ArtifactComplete: artifactComplete, |
| 202 | }) |
| 203 | } |
| 204 | } |
| 205 | sort.SliceStable(out, func(i, j int) bool { |
| 206 | if out[i].StartedAt != out[j].StartedAt { |
| 207 | return out[i].StartedAt > out[j].StartedAt |
| 208 | } |
| 209 | if out[i].SessionID != out[j].SessionID { |
| 210 | return out[i].SessionID < out[j].SessionID |
| 211 | } |
| 212 | return out[i].ID < out[j].ID |
| 213 | }) |
| 214 | return out, nil |
| 215 | } |
| 216 | |
| 217 | func machineArtifactComplete(path string) bool { |
| 218 | info, err := os.Stat(path) |
| 219 | return err == nil && info.Mode().IsRegular() && info.Size() > 0 |
| 220 | } |
| 221 | |
| 222 | func validMachineID(value string) bool { |
| 223 | value = strings.TrimSpace(value) |
| 224 | return value != "" && !strings.ContainsAny(value, `/\\`) |
| 225 | } |
| 226 | |
| 227 | func machineUnixMillis(value int64) string { |
| 228 | if value <= 0 { |
| 229 | return "" |
| 230 | } |
| 231 | return machineTime(time.UnixMilli(value)) |
| 232 | } |
| 233 |