返回 DeepSeek-Reasonix
task_test.go
根目录 / internal / cli / task_test.go
1 package cli
2
3 import (
4 "encoding/json"
5 "io"
6 "os"
7 "path/filepath"
8 "strings"
9 "testing"
10 "time"
11
12 "reasonix/internal/taskmonitor"
13 )
14
15 const legacySensitiveSummary = `command "deploy --token secret" failed in /Users/alice/private`
16
17 // testStore builds an InMemoryStore with a few preloaded tasks and events.
18 func testStore(t *testing.T) *taskmonitor.InMemoryStore {
19 t.Helper()
20 s := taskmonitor.NewInMemoryStore()
21
22 seed := func(i int) time.Time { return time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC) }
23
24 // Project A: two tasks
25 mustUpsert(t, s, "/proj-a", taskmonitor.TaskSnapshot{
26 SchemaVersion: 1, TaskID: "a1", SessionID: "s1",
27 State: taskmonitor.TaskStateRunning, CreatedAt: seed(1), UpdatedAt: seed(10),
28 })
29 mustUpsert(t, s, "/proj-a", taskmonitor.TaskSnapshot{
30 SchemaVersion: 1, TaskID: "a2", SessionID: "s2",
31 State: taskmonitor.TaskStateSucceeded, CreatedAt: seed(2), UpdatedAt: seed(11),
32 })
33
34 // Events for a1
35 for i := 1; i <= 3; i++ {
36 event := taskmonitor.TaskEvent{
37 Sequence: i, Timestamp: seed(i), EventType: "state_change",
38 TaskID: "a1", SessionID: "s1", State: taskmonitor.TaskStateRunning,
39 }
40 if i == 3 {
41 event.ErrorSummary = legacySensitiveSummary
42 }
43 mustAppend(t, s, "/proj-a", event)
44 }
45
46 // Project B: one task
47 mustUpsert(t, s, "/proj-b", taskmonitor.TaskSnapshot{
48 SchemaVersion: 1, TaskID: "b1", SessionID: "s3",
49 State: taskmonitor.TaskStateFailed, CreatedAt: seed(3), UpdatedAt: seed(12),
50 ErrorCode: "EXIT_1",
51 })
52 return s
53 }
54
55 func mustUpsert(t *testing.T, s *taskmonitor.InMemoryStore, proj string, snap taskmonitor.TaskSnapshot) {
56 t.Helper()
57 if err := s.UpsertTask(proj, snap); err != nil {
58 t.Fatal(err)
59 }
60 }
61
62 func mustAppend(t *testing.T, s *taskmonitor.InMemoryStore, proj string, ev taskmonitor.TaskEvent) {
63 t.Helper()
64 if err := s.AppendEvent(proj, ev); err != nil {
65 t.Fatal(err)
66 }
67 }
68
69 // captureOut runs fn and returns (exitCode, capturedStdout).
70 func captureOut(fn func() int) (int, string) {
71 orig := taskStore
72 defer func() { taskStore = orig }()
73
74 old := os.Stdout
75 r, w, _ := os.Pipe()
76 os.Stdout = w
77 ec := fn()
78 w.Close()
79 os.Stdout = old
80 data, _ := io.ReadAll(r)
81 return ec, string(data)
82 }
83
84 // JSON schema tests
85
86 func TestTaskList_JSON_SchemaVersion(t *testing.T) {
87 s := testStore(t)
88 taskStore = s
89
90 exit, out := captureOut(func() int {
91 return taskListCmd(s, []string{"--json"})
92 })
93 if exit != 0 {
94 t.Fatalf("exit=%d", exit)
95 }
96 var v struct {
97 SchemaVersion int `json:"schema_version"`
98 }
99 if err := json.Unmarshal([]byte(out), &v); err != nil {
100 t.Fatalf("parse: %v", err)
101 }
102 if v.SchemaVersion != 1 {
103 t.Errorf("schema_version=%d, want 1", v.SchemaVersion)
104 }
105 }
106
107 func TestTaskList_JSON_Empty(t *testing.T) {
108 s := taskmonitor.NewInMemoryStore()
109 taskStore = s
110
111 exit, out := captureOut(func() int {
112 return taskListCmd(s, []string{"--json", "--dir", "/no-such"})
113 })
114 if exit != 0 {
115 t.Fatalf("exit=%d", exit)
116 }
117 var v struct {
118 Tasks []taskmonitor.TaskSnapshot `json:"tasks"`
119 }
120 if err := json.Unmarshal([]byte(out), &v); err != nil {
121 t.Fatalf("parse: %v", err)
122 }
123 if len(v.Tasks) != 0 {
124 t.Errorf("expected 0 tasks, got %d", len(v.Tasks))
125 }
126 }
127
128 func TestTaskList_JSON_FieldsPresent(t *testing.T) {
129 s := testStore(t)
130 taskStore = s
131
132 exit, out := captureOut(func() int {
133 return taskListCmd(s, []string{"--json"})
134 })
135 if exit != 0 {
136 t.Fatalf("exit=%d", exit)
137 }
138 var v struct {
139 Tasks []taskmonitor.TaskSnapshot `json:"tasks"`
140 }
141 if err := json.Unmarshal([]byte(out), &v); err != nil {
142 t.Fatalf("parse: %v", err)
143 }
144 if len(v.Tasks) < 1 {
145 t.Fatal("expected at least 1 task")
146 }
147 tsk := v.Tasks[0]
148 if tsk.SchemaVersion != 1 || tsk.TaskID == "" || tsk.SessionID == "" ||
149 tsk.State == "" || tsk.CreatedAt.IsZero() || tsk.UpdatedAt.IsZero() {
150 t.Errorf("missing required fields in %+v", tsk)
151 }
152 }
153
154 func TestTaskList_JSON_ProjectIsolation(t *testing.T) {
155 s := testStore(t)
156 taskStore = s
157
158 exit, out := captureOut(func() int {
159 return taskListCmd(s, []string{"--json", "--dir", "/proj-a"})
160 })
161 if exit != 0 {
162 t.Fatalf("exit=%d", exit)
163 }
164 var v struct {
165 Tasks []taskmonitor.TaskSnapshot `json:"tasks"`
166 }
167 json.Unmarshal([]byte(out), &v)
168 for _, tsk := range v.Tasks {
169 if tsk.TaskID == "b1" {
170 t.Error("project-b task leaked into project-a")
171 }
172 }
173 }
174
175 // status
176
177 func TestTaskStatus_JSON_Found(t *testing.T) {
178 s := testStore(t)
179 taskStore = s
180
181 exit, out := captureOut(func() int {
182 return taskStatusCmd(s, []string{"--json", "a1"})
183 })
184 if exit != 0 {
185 t.Fatalf("exit=%d", exit)
186 }
187 var v struct {
188 Task taskmonitor.TaskSnapshot `json:"task"`
189 }
190 if err := json.Unmarshal([]byte(out), &v); err != nil {
191 t.Fatalf("parse: %v", err)
192 }
193 if v.Task.TaskID != "a1" {
194 t.Errorf("expected a1, got %s", v.Task.TaskID)
195 }
196 }
197
198 func TestTaskStatus_JSON_NotFound(t *testing.T) {
199 s := testStore(t)
200 taskStore = s
201
202 exit, out := captureOut(func() int {
203 return taskStatusCmd(s, []string{"--json", "ghost"})
204 })
205 if exit != 0 {
206 t.Fatalf("exit=%d", exit)
207 }
208 var v struct {
209 Task *taskmonitor.TaskSnapshot `json:"task"`
210 }
211 if err := json.Unmarshal([]byte(out), &v); err != nil {
212 t.Fatalf("parse: %v", err)
213 }
214 if v.Task != nil {
215 t.Errorf("expected null task, got %+v", v.Task)
216 }
217 }
218
219 func TestTaskStatus_JSON_SchemaVersion(t *testing.T) {
220 s := testStore(t)
221 taskStore = s
222
223 exit, out := captureOut(func() int {
224 return taskStatusCmd(s, []string{"--json", "a1"})
225 })
226 if exit != 0 {
227 t.Fatalf("exit=%d", exit)
228 }
229 var v struct {
230 SchemaVersion int `json:"schema_version"`
231 }
232 json.Unmarshal([]byte(out), &v)
233 if v.SchemaVersion != 1 {
234 t.Errorf("schema_version=%d", v.SchemaVersion)
235 }
236 }
237
238 // events
239
240 func TestTaskEvents_JSON_SchemaVersion(t *testing.T) {
241 s := testStore(t)
242 taskStore = s
243
244 exit, out := captureOut(func() int {
245 return taskEventsCmd(s, []string{"--json", "a1"})
246 })
247 if exit != 0 {
248 t.Fatalf("exit=%d", exit)
249 }
250 var v struct {
251 SchemaVersion int `json:"schema_version"`
252 }
253 json.Unmarshal([]byte(out), &v)
254 if v.SchemaVersion != 1 {
255 t.Errorf("schema_version=%d", v.SchemaVersion)
256 }
257 }
258
259 func TestTaskEvents_JSON_FieldsPresent(t *testing.T) {
260 s := testStore(t)
261 taskStore = s
262
263 exit, out := captureOut(func() int {
264 return taskEventsCmd(s, []string{"--json", "a1"})
265 })
266 if exit != 0 {
267 t.Fatalf("exit=%d", exit)
268 }
269 var v struct {
270 TaskID string `json:"task_id"`
271 Events []taskmonitor.TaskEvent `json:"events"`
272 }
273 if err := json.Unmarshal([]byte(out), &v); err != nil {
274 t.Fatalf("parse: %v", err)
275 }
276 if v.TaskID != "a1" {
277 t.Errorf("task_id=%q", v.TaskID)
278 }
279 if len(v.Events) != 3 {
280 t.Errorf("expected 3 events, got %d", len(v.Events))
281 }
282 for _, ev := range v.Events {
283 if ev.Sequence <= 0 || ev.TaskID == "" || ev.EventType == "" || ev.State == "" || ev.Timestamp.IsZero() {
284 t.Errorf("missing required fields in event %+v", ev)
285 }
286 }
287 }
288
289 func TestTaskEvents_JSON_AfterCursor(t *testing.T) {
290 s := testStore(t)
291 taskStore = s
292
293 exit, out := captureOut(func() int {
294 return taskEventsCmd(s, []string{"--json", "--after", "1", "a1"})
295 })
296 if exit != 0 {
297 t.Fatalf("exit=%d", exit)
298 }
299 var v struct {
300 Events []taskmonitor.TaskEvent `json:"events"`
301 }
302 json.Unmarshal([]byte(out), &v)
303 if len(v.Events) != 2 {
304 t.Errorf("after seq 1: expected 2 events, got %d", len(v.Events))
305 }
306 if v.Events[0].Sequence != 2 || v.Events[1].Sequence != 3 {
307 t.Errorf("unexpected sequences: %d, %d", v.Events[0].Sequence, v.Events[1].Sequence)
308 }
309 }
310
311 func TestTaskEvents_JSONL_Format(t *testing.T) {
312 s := testStore(t)
313 taskStore = s
314
315 exit, out := captureOut(func() int {
316 return taskEventsCmd(s, []string{"--jsonl", "a1"})
317 })
318 if exit != 0 {
319 t.Fatalf("exit=%d", exit)
320 }
321 lines := strings.Split(strings.TrimSpace(out), "\n")
322 if len(lines) != 3 {
323 t.Fatalf("expected 3 JSONL lines, got %d", len(lines))
324 }
325 for _, line := range lines {
326 var ev taskmonitor.TaskEvent
327 if err := json.Unmarshal([]byte(line), &ev); err != nil {
328 t.Errorf("invalid JSONL line: %v", err)
329 }
330 }
331 }
332
333 func TestTaskEvents_JSON_NoSensitiveFields(t *testing.T) {
334 s := testStore(t)
335 taskStore = s
336
337 exit, out := captureOut(func() int {
338 return taskEventsCmd(s, []string{"--json", "a1"})
339 })
340 if exit != 0 {
341 t.Fatalf("exit=%d", exit)
342 }
343 for _, forbidden := range []string{"prompt", "tool_args", "tool_result", "reasoning", "error_summary", legacySensitiveSummary} {
344 if strings.Contains(out, forbidden) {
345 t.Errorf("output contains forbidden field %q", forbidden)
346 }
347 }
348 }
349
350 func TestTaskMonitorOutputsOmitLegacyErrorSummary(t *testing.T) {
351 s := testStore(t)
352 taskStore = s
353 commands := []struct {
354 name string
355 run func() int
356 }{
357 {name: "list", run: func() int { return taskListCmd(s, []string{"--json"}) }},
358 {name: "status", run: func() int { return taskStatusCmd(s, []string{"--json", "a1"}) }},
359 {name: "events JSON", run: func() int { return taskEventsCmd(s, []string{"--json", "a1"}) }},
360 {name: "events JSONL", run: func() int { return taskEventsCmd(s, []string{"--jsonl", "a1"}) }},
361 }
362 for _, command := range commands {
363 t.Run(command.name, func(t *testing.T) {
364 exit, out := captureOut(command.run)
365 if exit != 0 {
366 t.Fatalf("exit=%d", exit)
367 }
368 if strings.Contains(out, legacySensitiveSummary) || strings.Contains(out, `"error_summary"`) {
369 t.Fatalf("legacy error summary leaked: %s", out)
370 }
371 })
372 }
373 }
374
375 func TestTaskEvents_JSON_EmptyForUnknownTask(t *testing.T) {
376 s := testStore(t)
377 taskStore = s
378
379 exit, out := captureOut(func() int {
380 return taskEventsCmd(s, []string{"--json", "ghost"})
381 })
382 if exit != 0 {
383 t.Fatalf("exit=%d", exit)
384 }
385 var v struct {
386 Events []taskmonitor.TaskEvent `json:"events"`
387 }
388 json.Unmarshal([]byte(out), &v)
389 if len(v.Events) != 0 {
390 t.Errorf("expected empty, got %d events", len(v.Events))
391 }
392 }
393
394 func TestTaskList_NoFlagErrors(t *testing.T) {
395 s := taskmonitor.NewInMemoryStore()
396 exit, _ := captureOut(func() int {
397 return taskListCmd(s, []string{})
398 })
399 if exit == 0 {
400 t.Error("expected non-zero exit without --json")
401 }
402 }
403
404 func TestTaskStatus_MissingID(t *testing.T) {
405 s := taskmonitor.NewInMemoryStore()
406 exit, _ := captureOut(func() int {
407 return taskStatusCmd(s, []string{"--json"})
408 })
409 if exit == 0 {
410 t.Error("expected non-zero exit without ID")
411 }
412 }
413
414 func TestTaskEvents_NoFlag(t *testing.T) {
415 s := taskmonitor.NewInMemoryStore()
416 exit, _ := captureOut(func() int {
417 return taskEventsCmd(s, []string{"a1"})
418 })
419 if exit == 0 {
420 t.Error("expected non-zero exit without --json/--jsonl")
421 }
422 }
423
424 // CLI wiring
425
426 func TestTaskCommand_Dispatch(t *testing.T) {
427 s := testStore(t)
428 taskStore = s
429
430 // monitor list
431 exit, out := captureOut(func() int {
432 return taskCommand([]string{"monitor", "list", "--json"})
433 })
434 if exit != 0 || !strings.Contains(out, "task_id") {
435 t.Errorf("task monitor list failed: exit=%d out=%s", exit, out)
436 }
437
438 // monitor status
439 exit, out = captureOut(func() int {
440 return taskCommand([]string{"monitor", "status", "--json", "a1"})
441 })
442 if exit != 0 || !strings.Contains(out, "a1") {
443 t.Errorf("task monitor status failed: exit=%d out=%s", exit, out)
444 }
445
446 // monitor events
447 exit, out = captureOut(func() int {
448 return taskCommand([]string{"monitor", "events", "--json", "a1"})
449 })
450 if exit != 0 || !strings.Contains(out, "event_type") {
451 t.Errorf("task monitor events failed: exit=%d out=%s", exit, out)
452 }
453
454 // unknown subcommand
455 exit, _ = captureOut(func() int {
456 return taskCommand([]string{"unknown"})
457 })
458 if exit == 0 {
459 t.Error("expected non-zero for unknown subcommand")
460 }
461 }
462
463 func TestTaskCommand_PreservesMachineShowRoute(t *testing.T) {
464 exit, out := captureOut(func() int {
465 return taskCommand([]string{"show", "--json"})
466 })
467 if exit == 0 || !strings.Contains(out, `"command":"task.show"`) {
468 t.Fatalf("legacy task show route changed: exit=%d out=%s", exit, out)
469 }
470 }
471
472 // FileStore integration tests (real filesystem)
473
474 // writeTaskData creates a FileStore-compatible task tree in dir and resets
475 // taskStore so the CLI uses the production FileStore path.
476 func writeTaskData(t *testing.T, dir string) {
477 t.Helper()
478 taskDir := filepath.Join(dir, ".reasonix", "tasks", "task-1")
479 if err := os.MkdirAll(taskDir, 0o755); err != nil {
480 t.Fatal(err)
481 }
482 now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
483 snap := taskmonitor.TaskSnapshot{
484 SchemaVersion: 1, TaskID: "task-1", SessionID: "s1",
485 State: taskmonitor.TaskStateFailed, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
486 ErrorCode: "TIMEOUT", ErrorSummary: legacySensitiveSummary,
487 }
488 data, _ := json.Marshal(snap)
489 if err := os.WriteFile(filepath.Join(taskDir, "snapshot.json"), data, 0o644); err != nil {
490 t.Fatal(err)
491 }
492 events := `{"sequence":1,"timestamp":"2025-01-01T00:00:01Z","event_type":"state_change","task_id":"task-1","session_id":"s1","state":"queued"}
493 {"sequence":2,"timestamp":"2025-01-01T00:00:02Z","event_type":"state_change","task_id":"task-1","session_id":"s1","state":"running"}
494 {"sequence":3,"timestamp":"2025-01-01T00:00:03Z","event_type":"error","task_id":"task-1","session_id":"s1","state":"failed","error_code":"TIMEOUT","error_summary":"command deploy failed in /Users/alice/private"}
495 `
496 if err := os.WriteFile(filepath.Join(taskDir, "events.jsonl"), []byte(events), 0o644); err != nil {
497 t.Fatal(err)
498 }
499 // Use nil so CLI falls back to FileStore (production path)
500 taskStore = nil
501 }
502
503 func TestFileStoreIntegration_ListTasks(t *testing.T) {
504 dir := t.TempDir()
505 writeTaskData(t, dir)
506
507 exit, out := captureOut(func() int {
508 return taskCommand([]string{"monitor", "list", "--json", "--dir", dir})
509 })
510 if exit != 0 {
511 t.Fatalf("exit=%d", exit)
512 }
513 if !strings.Contains(out, `task-1`) {
514 t.Errorf("expected task-1 in output: %s", out)
515 }
516 if !strings.Contains(out, `"state"`) {
517 t.Errorf("expected state field: %s", out)
518 }
519 if !strings.Contains(out, `TIMEOUT`) {
520 t.Errorf("expected TIMEOUT error_code: %s", out)
521 }
522 }
523
524 func TestFileStoreIntegration_Status(t *testing.T) {
525 dir := t.TempDir()
526 writeTaskData(t, dir)
527
528 exit, out := captureOut(func() int {
529 return taskCommand([]string{"monitor", "status", "task-1", "--json", "--dir", dir})
530 })
531 if exit != 0 {
532 t.Fatalf("exit=%d", exit)
533 }
534 if !strings.Contains(out, `task-1`) {
535 t.Errorf("expected task-1: %s", out)
536 }
537 if strings.Contains(out, legacySensitiveSummary) || strings.Contains(out, `"error_summary"`) {
538 t.Errorf("status leaked legacy error_summary: %s", out)
539 }
540 }
541
542 func TestFileStoreIntegration_Status_NotFound(t *testing.T) {
543 dir := t.TempDir()
544
545 exit, out := captureOut(func() int {
546 return taskCommand([]string{"monitor", "status", "--json", "--dir", dir, "ghost"})
547 })
548 if exit != 0 {
549 t.Fatalf("exit=%d", exit)
550 }
551 if !strings.Contains(out, `null`) {
552 t.Errorf("expected null task: %s", out)
553 }
554 }
555
556 func TestFileStoreIntegration_Events_JSON(t *testing.T) {
557 dir := t.TempDir()
558 writeTaskData(t, dir)
559
560 exit, out := captureOut(func() int {
561 return taskCommand([]string{"monitor", "events", "--json", "--dir", dir, "task-1"})
562 })
563 if exit != 0 {
564 t.Fatalf("exit=%d", exit)
565 }
566 if !strings.Contains(out, `task-1`) {
567 t.Errorf("expected task_id: %s", out)
568 }
569 var v struct {
570 Events []taskmonitor.TaskEvent `json:"events"`
571 }
572 if err := json.Unmarshal([]byte(out), &v); err != nil {
573 t.Fatalf("parse: %v", err)
574 }
575 if len(v.Events) != 3 {
576 t.Errorf("expected 3 events, got %d", len(v.Events))
577 }
578 }
579
580 func TestFileStoreIntegration_Events_JSONL(t *testing.T) {
581 dir := t.TempDir()
582 writeTaskData(t, dir)
583
584 exit, out := captureOut(func() int {
585 return taskCommand([]string{"monitor", "events", "--jsonl", "--dir", dir, "task-1"})
586 })
587 if exit != 0 {
588 t.Fatalf("exit=%d", exit)
589 }
590 lines := strings.Split(strings.TrimSpace(out), "\n")
591 if len(lines) != 3 {
592 t.Fatalf("expected 3 JSONL lines, got %d: %s", len(lines), out)
593 }
594 for _, line := range lines {
595 var ev taskmonitor.TaskEvent
596 if err := json.Unmarshal([]byte(line), &ev); err != nil {
597 t.Errorf("invalid JSONL: %v — line: %s", err, line)
598 }
599 }
600 }
601
602 func TestFileStoreIntegration_Events_AfterCursor(t *testing.T) {
603 dir := t.TempDir()
604 writeTaskData(t, dir)
605
606 exit, out := captureOut(func() int {
607 return taskCommand([]string{"monitor", "events", "task-1", "--json", "--dir", dir, "--after", "1"})
608 })
609 if exit != 0 {
610 t.Fatalf("exit=%d", exit)
611 }
612 var v struct {
613 Events []taskmonitor.TaskEvent `json:"events"`
614 }
615 json.Unmarshal([]byte(out), &v)
616 if len(v.Events) != 2 || v.Events[0].Sequence != 2 {
617 t.Errorf("expected 2 events seq≥2, got %d events", len(v.Events))
618 }
619 }
620
621 func TestFileStoreIntegration_ListTasks_Empty(t *testing.T) {
622 dir := t.TempDir()
623 taskStore = nil
624
625 exit, out := captureOut(func() int {
626 return taskCommand([]string{"monitor", "list", "--json", "--dir", dir})
627 })
628 if exit != 0 {
629 t.Fatalf("exit=%d", exit)
630 }
631 if !strings.Contains(out, `"tasks"`) {
632 t.Errorf("expected tasks key: %s", out)
633 }
634 }
635
636 // CLI+JobKiller e2e tests
637
638 // mockJobKiller is a thread-safe mock for JobKiller.
639 type mockJobKiller struct {
640 called map[string]int
641 }
642
643 func newMockKiller() *mockJobKiller {
644 return &mockJobKiller{called: make(map[string]int)}
645 }
646
647 func (m *mockJobKiller) Kill(sessionID, id string) bool {
648 m.called[sessionID+"/"+id]++
649 return true
650 }
651
652 func TestCLI_StopCallsKill(t *testing.T) {
653 s := taskmonitor.NewInMemoryStore()
654 taskStore = s
655 taskJobKiller = newMockKiller()
656 defer func() { taskJobKiller = nil }()
657
658 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
659 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
660 State: taskmonitor.TaskStateRunning, Version: 1,
661 CreatedAt: time.Now(), UpdatedAt: time.Now(),
662 })
663
664 exit, out := captureOut(func() int {
665 ec := taskCommand([]string{"stop", "t1", "--json", "--expected-version", "1"})
666 return ec
667 })
668 if exit != 0 {
669 t.Fatalf("exit=%d out=%s", exit, out)
670 }
671 mk := taskJobKiller.(*mockJobKiller)
672 if mk.called["s1/t1"] != 1 {
673 t.Errorf("expected Kill(s1, t1) called once, got %v", mk.called)
674 }
675 }
676
677 func TestCLI_CancelCallsKill(t *testing.T) {
678 s := taskmonitor.NewInMemoryStore()
679 taskStore = s
680 taskJobKiller = newMockKiller()
681 defer func() { taskJobKiller = nil }()
682
683 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
684 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
685 State: taskmonitor.TaskStateRunning, Version: 1,
686 CreatedAt: time.Now(), UpdatedAt: time.Now(),
687 })
688
689 exit, out := captureOut(func() int {
690 return taskCommand([]string{"cancel", "t1", "--json", "--expected-version", "1"})
691 })
692 if exit != 0 {
693 t.Fatalf("exit=%d out=%s", exit, out)
694 }
695 mk := taskJobKiller.(*mockJobKiller)
696 if mk.called["s1/t1"] != 1 {
697 t.Errorf("expected Kill(s1, t1) called once, got %v", mk.called)
698 }
699 }
700
701 func TestCLI_RequeueReportsQueuedButExited(t *testing.T) {
702 s := taskmonitor.NewInMemoryStore()
703 taskStore = s
704 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
705 SchemaVersion: 1, TaskID: "failed", SessionID: "s1",
706 State: taskmonitor.TaskStateFailed, RuntimeState: taskmonitor.RuntimeStateExited, Version: 2,
707 CreatedAt: time.Now(), UpdatedAt: time.Now(),
708 })
709
710 exit, out := captureOut(func() int {
711 return taskCommand([]string{"requeue", "failed", "--json", "--expected-version", "2", "--dir", "/p"})
712 })
713 if exit != 0 {
714 t.Fatalf("exit=%d out=%s", exit, out)
715 }
716 var result taskmonitor.ControlResult
717 if err := json.Unmarshal([]byte(out), &result); err != nil {
718 t.Fatalf("decode output: %v\n%s", err, out)
719 }
720 if result.Command != "requeue" || result.State != taskmonitor.TaskStateQueued || result.RuntimeState != taskmonitor.RuntimeStateExited {
721 t.Fatalf("unexpected requeue result: %+v", result)
722 }
723 }
724
725 func TestCLI_OpenSessionAcceptsDocumentedIDBeforeFlags(t *testing.T) {
726 s := taskmonitor.NewInMemoryStore()
727 taskStore = s
728 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
729 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
730 State: taskmonitor.TaskStateRunning, Version: 1,
731 CreatedAt: time.Now(), UpdatedAt: time.Now(),
732 })
733
734 exit, out := captureOut(func() int {
735 return taskCommand([]string{"open-session", "t1", "--json", "--dir", "/p"})
736 })
737 if exit != 0 {
738 t.Fatalf("exit=%d out=%s", exit, out)
739 }
740 var result taskmonitor.ControlResult
741 if err := json.Unmarshal([]byte(out), &result); err != nil {
742 t.Fatalf("decode output: %v\n%s", err, out)
743 }
744 if !result.Accepted || result.TaskID != "t1" || result.SessionID != "s1" {
745 t.Fatalf("unexpected open-session result: %+v", result)
746 }
747 }
748
749 func TestCLI_ResumeIsNotATaskCommand(t *testing.T) {
750 exit, _ := captureOut(func() int { return taskCommand([]string{"resume"}) })
751 if exit != 2 {
752 t.Fatalf("legacy task resume exit=%d, want usage error 2", exit)
753 }
754 }
755
755 lines GO