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