返回 DeepSeek-Reasonix
session_machine.go
根目录 / internal / cli / session_machine.go
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
415 lines GO