| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "crypto/hmac" |
| 6 | "crypto/rand" |
| 7 | "crypto/sha256" |
| 8 | "encoding/hex" |
| 9 | "encoding/json" |
| 10 | "fmt" |
| 11 | "io" |
| 12 | "os" |
| 13 | "path/filepath" |
| 14 | "sort" |
| 15 | "strings" |
| 16 | "time" |
| 17 | |
| 18 | "reasonix/internal/agent" |
| 19 | "reasonix/internal/config" |
| 20 | "reasonix/internal/filelock" |
| 21 | "reasonix/internal/fileutil" |
| 22 | "reasonix/internal/recovery" |
| 23 | "reasonix/internal/store" |
| 24 | ) |
| 25 | |
| 26 | const ( |
| 27 | machineSchemaVersion = 1 |
| 28 | machineIdentityKeyBytes = 32 |
| 29 | machineIdentityKeyFile = "machine-id.key" |
| 30 | ) |
| 31 | |
| 32 | type machineSession struct { |
| 33 | ID string `json:"id"` |
| 34 | CreatedAt string `json:"created_at"` |
| 35 | UpdatedAt string `json:"updated_at"` |
| 36 | Scope string `json:"scope"` |
| 37 | Turns int `json:"turns"` |
| 38 | State string `json:"state"` |
| 39 | Recovered bool `json:"recovered"` |
| 40 | } |
| 41 | |
| 42 | type machineSessionList struct { |
| 43 | SchemaVersion int `json:"schema_version"` |
| 44 | Command string `json:"command"` |
| 45 | Sessions []machineSession `json:"sessions"` |
| 46 | } |
| 47 | |
| 48 | type machineSessionShow struct { |
| 49 | SchemaVersion int `json:"schema_version"` |
| 50 | Command string `json:"command"` |
| 51 | Session machineSession `json:"session"` |
| 52 | } |
| 53 | |
| 54 | type machineRecovery struct { |
| 55 | SessionID string `json:"session_id"` |
| 56 | State string `json:"state"` |
| 57 | UpdatedAt string `json:"updated_at"` |
| 58 | Tasks int `json:"tasks"` |
| 59 | Failures int `json:"failures"` |
| 60 | Pending int `json:"pending"` |
| 61 | InFlight bool `json:"in_flight"` |
| 62 | } |
| 63 | |
| 64 | type machineRecoveryList struct { |
| 65 | SchemaVersion int `json:"schema_version"` |
| 66 | Command string `json:"command"` |
| 67 | Recoveries []machineRecovery `json:"recoveries"` |
| 68 | } |
| 69 | |
| 70 | type machineError struct { |
| 71 | Code string `json:"code"` |
| 72 | Message string `json:"message"` |
| 73 | } |
| 74 | |
| 75 | type machineErrorResponse struct { |
| 76 | SchemaVersion int `json:"schema_version"` |
| 77 | Command string `json:"command"` |
| 78 | Error machineError `json:"error"` |
| 79 | } |
| 80 | |
| 81 | type sessionMachineOptions struct { |
| 82 | dir string |
| 83 | projectRoot string |
| 84 | target string |
| 85 | json bool |
| 86 | } |
| 87 | |
| 88 | func sessionCommand(args []string) int { |
| 89 | return runSessionCommand(args, os.Stdout) |
| 90 | } |
| 91 | |
| 92 | func runSessionCommand(args []string, out io.Writer) int { |
| 93 | command := "session" |
| 94 | if len(args) == 0 { |
| 95 | return writeMachineError(out, command, "invalid_argument", "a session operation is required") |
| 96 | } |
| 97 | operation := args[0] |
| 98 | command = "session." + operation |
| 99 | if operation != "list" && operation != "show" && operation != "status" && operation != "recovery" { |
| 100 | return writeMachineError(out, command, "unknown_command", "unknown session operation") |
| 101 | } |
| 102 | options, code, message := parseSessionMachineOptions(args[1:], operation) |
| 103 | if code != "" { |
| 104 | return writeMachineError(out, command, code, message) |
| 105 | } |
| 106 | if !options.json { |
| 107 | return writeMachineError(out, command, "invalid_argument", "--json is required") |
| 108 | } |
| 109 | options.dir = resolveMachineSessionDir(options.dir, options.projectRoot) |
| 110 | identityKey, err := loadMachineIdentityKey() |
| 111 | if err != nil { |
| 112 | return writeMachineError(out, command, "machine_identity_unavailable", "machine identity is unavailable") |
| 113 | } |
| 114 | if operation == "recovery" { |
| 115 | recoveries, err := machineRecoveries(options.dir, options.target, identityKey) |
| 116 | if err != nil { |
| 117 | return writeMachineError(out, command, "recovery_state_unavailable", "recovery state is unavailable") |
| 118 | } |
| 119 | return writeMachineJSON(out, machineRecoveryList{SchemaVersion: machineSchemaVersion, Command: command, Recoveries: recoveries}) |
| 120 | } |
| 121 | sessions, err := machineSessions(options.dir, identityKey) |
| 122 | if err != nil { |
| 123 | return writeMachineError(out, command, "session_dir_unavailable", "session directory is unavailable") |
| 124 | } |
| 125 | if operation == "list" { |
| 126 | return writeMachineJSON(out, machineSessionList{ |
| 127 | SchemaVersion: machineSchemaVersion, |
| 128 | Command: command, |
| 129 | Sessions: sessions, |
| 130 | }) |
| 131 | } |
| 132 | for _, session := range sessions { |
| 133 | if session.ID != options.target { |
| 134 | continue |
| 135 | } |
| 136 | return writeMachineJSON(out, machineSessionShow{ |
| 137 | SchemaVersion: machineSchemaVersion, |
| 138 | Command: command, |
| 139 | Session: session, |
| 140 | }) |
| 141 | } |
| 142 | return writeMachineError(out, command, "session_not_found", "session was not found") |
| 143 | } |
| 144 | |
| 145 | func resolveMachineSessionDir(sessionDir, projectRoot string) string { |
| 146 | if projectRoot != "" { |
| 147 | return machineProjectSessionDir(projectRoot) |
| 148 | } |
| 149 | if sessionDir != "" { |
| 150 | return sessionDir |
| 151 | } |
| 152 | return resolveCLISessionDir() |
| 153 | } |
| 154 | |
| 155 | // machineProjectSessionDir maps a project root to its per-project session store. |
| 156 | func machineProjectSessionDir(projectRoot string) string { |
| 157 | if dir := config.ProjectSessionDir(projectRoot); dir != "" { |
| 158 | return dir |
| 159 | } |
| 160 | return projectRoot |
| 161 | } |
| 162 | |
| 163 | func parseSessionMachineOptions(args []string, operation string) (sessionMachineOptions, string, string) { |
| 164 | var options sessionMachineOptions |
| 165 | for i := 0; i < len(args); i++ { |
| 166 | switch args[i] { |
| 167 | case "--json": |
| 168 | options.json = true |
| 169 | case "--dir": |
| 170 | if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" { |
| 171 | return options, "invalid_argument", "--dir requires a value" |
| 172 | } |
| 173 | i++ |
| 174 | options.dir = args[i] |
| 175 | case "--project-root": |
| 176 | if i+1 >= len(args) || strings.TrimSpace(args[i+1]) == "" { |
| 177 | return options, "invalid_argument", "--project-root requires a value" |
| 178 | } |
| 179 | i++ |
| 180 | options.projectRoot = args[i] |
| 181 | case "--help", "-h": |
| 182 | return options, "invalid_argument", "use the documented machine interface" |
| 183 | default: |
| 184 | arg := strings.TrimSpace(args[i]) |
| 185 | if strings.HasPrefix(arg, "-") { |
| 186 | return options, "invalid_argument", "unknown session option" |
| 187 | } |
| 188 | if operation == "list" || options.target != "" || strings.ContainsAny(arg, `/\\`) { |
| 189 | return options, "invalid_argument", "invalid session identifier" |
| 190 | } |
| 191 | options.target = arg |
| 192 | } |
| 193 | } |
| 194 | if options.dir != "" && options.projectRoot != "" { |
| 195 | return options, "invalid_argument", "--dir and --project-root cannot be combined" |
| 196 | } |
| 197 | if operation != "list" && operation != "recovery" && options.target == "" { |
| 198 | return options, "invalid_argument", "a session identifier is required" |
| 199 | } |
| 200 | return options, "", "" |
| 201 | } |
| 202 | |
| 203 | func machineRecoveries(dir, target string, identityKey []byte) ([]machineRecovery, error) { |
| 204 | ordered, err := agent.ListSessionOrder(dir) |
| 205 | if err != nil { |
| 206 | return nil, err |
| 207 | } |
| 208 | out := make([]machineRecovery, 0, len(ordered)) |
| 209 | for _, info := range ordered { |
| 210 | sessionID := machineSessionIDWithKey(agent.BranchID(info.Path), identityKey) |
| 211 | if target != "" && sessionID != target { |
| 212 | continue |
| 213 | } |
| 214 | meta, metaOK, _ := agent.LoadBranchMeta(info.Path) |
| 215 | snapshot, err := recovery.LoadSnapshot(info.Path) |
| 216 | if err != nil { |
| 217 | return nil, err |
| 218 | } |
| 219 | if len(snapshot.Tasks) == 0 && (meta.InFlightTurn == nil || !metaOK) { |
| 220 | continue |
| 221 | } |
| 222 | item := machineRecovery{ |
| 223 | SessionID: sessionID, |
| 224 | UpdatedAt: machineTime(info.LastActivityAt), |
| 225 | InFlight: metaOK && meta.InFlightTurn != nil, |
| 226 | } |
| 227 | for _, task := range snapshot.Tasks { |
| 228 | if task == nil { |
| 229 | continue |
| 230 | } |
| 231 | item.Tasks++ |
| 232 | if task.Failure != nil { |
| 233 | item.Failures++ |
| 234 | } |
| 235 | if task.Pending != nil { |
| 236 | item.Pending++ |
| 237 | } |
| 238 | } |
| 239 | switch { |
| 240 | case item.Pending > 0: |
| 241 | item.State = string(recovery.PhaseAwaitingDecision) |
| 242 | case item.Failures > 0: |
| 243 | item.State = "failed" |
| 244 | case item.InFlight: |
| 245 | item.State = "interrupted" |
| 246 | default: |
| 247 | item.State = string(recovery.PhaseIdle) |
| 248 | } |
| 249 | if stat, statErr := os.Stat(store.SessionRecoveryState(info.Path)); statErr == nil && stat.ModTime().After(parseMachineTime(item.UpdatedAt)) { |
| 250 | item.UpdatedAt = machineTime(stat.ModTime()) |
| 251 | } |
| 252 | out = append(out, item) |
| 253 | } |
| 254 | sort.SliceStable(out, func(i, j int) bool { |
| 255 | if out[i].UpdatedAt != out[j].UpdatedAt { |
| 256 | return out[i].UpdatedAt > out[j].UpdatedAt |
| 257 | } |
| 258 | return out[i].SessionID < out[j].SessionID |
| 259 | }) |
| 260 | if target != "" && len(out) == 0 { |
| 261 | return nil, os.ErrNotExist |
| 262 | } |
| 263 | return out, nil |
| 264 | } |
| 265 | |
| 266 | func parseMachineTime(value string) time.Time { |
| 267 | parsed, _ := time.Parse(time.RFC3339Nano, value) |
| 268 | return parsed |
| 269 | } |
| 270 | |
| 271 | func machineSessions(dir string, identityKey []byte) ([]machineSession, error) { |
| 272 | ordered, err := agent.ListSessionOrder(dir) |
| 273 | if err != nil { |
| 274 | return nil, err |
| 275 | } |
| 276 | out := make([]machineSession, 0, len(ordered)) |
| 277 | for _, info := range ordered { |
| 278 | turns := info.Turns |
| 279 | if info.SchemaVersion < agent.BranchMetaCountsVersion { |
| 280 | _, turns = agent.SessionPreview(info.Path) |
| 281 | } |
| 282 | if turns == 0 { |
| 283 | continue |
| 284 | } |
| 285 | meta, ok, _ := agent.LoadBranchMeta(info.Path) |
| 286 | state := "idle" |
| 287 | if agent.SessionLeaseHeld(info.Path) { |
| 288 | state = "active" |
| 289 | } else if ok && meta.InFlightTurn != nil { |
| 290 | state = "interrupted" |
| 291 | } else if info.Recovered { |
| 292 | state = "recovered" |
| 293 | } |
| 294 | scope := info.Scope |
| 295 | if scope == "" { |
| 296 | scope = "global" |
| 297 | } |
| 298 | out = append(out, machineSession{ |
| 299 | ID: machineSessionIDWithKey(agent.BranchID(info.Path), identityKey), |
| 300 | CreatedAt: machineTime(info.CreatedAt), |
| 301 | UpdatedAt: machineTime(info.LastActivityAt), |
| 302 | Scope: scope, |
| 303 | Turns: turns, |
| 304 | State: state, |
| 305 | Recovered: info.Recovered, |
| 306 | }) |
| 307 | } |
| 308 | sort.SliceStable(out, func(i, j int) bool { |
| 309 | if out[i].UpdatedAt == out[j].UpdatedAt { |
| 310 | return out[i].ID < out[j].ID |
| 311 | } |
| 312 | return out[i].UpdatedAt > out[j].UpdatedAt |
| 313 | }) |
| 314 | return out, nil |
| 315 | } |
| 316 | |
| 317 | func machineTime(value time.Time) string { |
| 318 | if value.IsZero() { |
| 319 | return "" |
| 320 | } |
| 321 | return value.UTC().Format(time.RFC3339Nano) |
| 322 | } |
| 323 | |
| 324 | func loadMachineIdentityKey() ([]byte, error) { |
| 325 | root := strings.TrimSpace(config.MemoryUserDir()) |
| 326 | if root == "" { |
| 327 | return nil, fmt.Errorf("machine identity: Reasonix state directory is unavailable") |
| 328 | } |
| 329 | path := filepath.Join(root, machineIdentityKeyFile) |
| 330 | key, err := readMachineIdentityKey(path) |
| 331 | if err == nil { |
| 332 | return key, nil |
| 333 | } |
| 334 | if !os.IsNotExist(err) { |
| 335 | return nil, err |
| 336 | } |
| 337 | if err := os.MkdirAll(root, 0o700); err != nil { |
| 338 | return nil, fmt.Errorf("machine identity: create state directory: %w", err) |
| 339 | } |
| 340 | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) |
| 341 | defer cancel() |
| 342 | unlock, err := filelock.Acquire(ctx, path+".lock") |
| 343 | if err != nil { |
| 344 | return nil, fmt.Errorf("machine identity: initialize key: %w", err) |
| 345 | } |
| 346 | defer unlock() |
| 347 | |
| 348 | key, err = readMachineIdentityKey(path) |
| 349 | if err == nil { |
| 350 | return key, nil |
| 351 | } |
| 352 | if !os.IsNotExist(err) { |
| 353 | return nil, err |
| 354 | } |
| 355 | key = make([]byte, machineIdentityKeyBytes) |
| 356 | if _, err := rand.Read(key); err != nil { |
| 357 | return nil, fmt.Errorf("machine identity: generate key: %w", err) |
| 358 | } |
| 359 | if err := fileutil.AtomicWriteFile(path, key, 0o600); err != nil { |
| 360 | return nil, fmt.Errorf("machine identity: persist key: %w", err) |
| 361 | } |
| 362 | return key, nil |
| 363 | } |
| 364 | |
| 365 | func readMachineIdentityKey(path string) ([]byte, error) { |
| 366 | key, err := os.ReadFile(path) |
| 367 | if err != nil { |
| 368 | return nil, err |
| 369 | } |
| 370 | if len(key) != machineIdentityKeyBytes { |
| 371 | return nil, fmt.Errorf("machine identity: invalid key length %d", len(key)) |
| 372 | } |
| 373 | return key, nil |
| 374 | } |
| 375 | |
| 376 | // machineSessionIDWithKey keeps the public machine contract stable without |
| 377 | // exposing or making offline guesses about transcript filenames, which include |
| 378 | // creation timestamps and configured model labels. |
| 379 | func machineSessionIDWithKey(branchID string, identityKey []byte) string { |
| 380 | branchID = strings.TrimSpace(branchID) |
| 381 | if branchID == "" || len(identityKey) != machineIdentityKeyBytes { |
| 382 | return "" |
| 383 | } |
| 384 | digest := hmac.New(sha256.New, identityKey) |
| 385 | _, _ = digest.Write([]byte("reasonix-machine-session-v1\x00")) |
| 386 | _, _ = digest.Write([]byte(branchID)) |
| 387 | return "session_" + hex.EncodeToString(digest.Sum(nil)[:16]) |
| 388 | } |
| 389 | |
| 390 | func writeMachineJSON(out io.Writer, value any) int { |
| 391 | encoder := json.NewEncoder(out) |
| 392 | encoder.SetEscapeHTML(false) |
| 393 | if err := encoder.Encode(value); err != nil { |
| 394 | return 1 |
| 395 | } |
| 396 | return 0 |
| 397 | } |
| 398 | |
| 399 | func writeMachineError(out io.Writer, command, code, message string) int { |
| 400 | if writeMachineJSON(out, machineErrorResponse{ |
| 401 | SchemaVersion: machineSchemaVersion, |
| 402 | Command: command, |
| 403 | Error: machineError{ |
| 404 | Code: code, |
| 405 | Message: message, |
| 406 | }, |
| 407 | }) != 0 { |
| 408 | return 1 |
| 409 | } |
| 410 | if code == "invalid_argument" || code == "unknown_command" { |
| 411 | return 2 |
| 412 | } |
| 413 | return 1 |
| 414 | } |
| 415 |