返回 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 func TestTaskCommand_UnknownSubcommand(t *testing.T) {
473 exit, _ := captureOut(func() int {
474 return taskCommand([]string{"bogus"})
475 })
476 if exit != 2 {
477 t.Errorf("exit=%d, want 2", exit)
478 }
479 }
480
481 // --- FileStore integration tests (real filesystem) ---
482
483 // writeTaskData creates a FileStore-compatible task tree in dir and resets
484 // taskStore so the CLI uses the production FileStore path.
485 func writeTaskData(t *testing.T, dir string) {
486 t.Helper()
487 taskDir := filepath.Join(dir, ".reasonix", "tasks", "task-1")
488 if err := os.MkdirAll(taskDir, 0o755); err != nil {
489 t.Fatal(err)
490 }
491 now := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
492 snap := taskmonitor.TaskSnapshot{
493 SchemaVersion: 1, TaskID: "task-1", SessionID: "s1",
494 State: taskmonitor.TaskStateFailed, CreatedAt: now.Add(-time.Hour), UpdatedAt: now,
495 ErrorCode: "TIMEOUT", ErrorSummary: legacySensitiveSummary,
496 }
497 data, _ := json.Marshal(snap)
498 if err := os.WriteFile(filepath.Join(taskDir, "snapshot.json"), data, 0o644); err != nil {
499 t.Fatal(err)
500 }
501 events := `{"sequence":1,"timestamp":"2025-01-01T00:00:01Z","event_type":"state_change","task_id":"task-1","session_id":"s1","state":"queued"}
502 {"sequence":2,"timestamp":"2025-01-01T00:00:02Z","event_type":"state_change","task_id":"task-1","session_id":"s1","state":"running"}
503 {"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"}
504 `
505 if err := os.WriteFile(filepath.Join(taskDir, "events.jsonl"), []byte(events), 0o644); err != nil {
506 t.Fatal(err)
507 }
508 // Use nil so CLI falls back to FileStore (production path)
509 taskStore = nil
510 }
511
512 func TestFileStoreIntegration_ListTasks(t *testing.T) {
513 dir := t.TempDir()
514 writeTaskData(t, dir)
515
516 exit, out := captureOut(func() int {
517 return taskCommand([]string{"monitor", "list", "--json", "--dir", dir})
518 })
519 if exit != 0 {
520 t.Fatalf("exit=%d", exit)
521 }
522 if !strings.Contains(out, `task-1`) {
523 t.Errorf("expected task-1 in output: %s", out)
524 }
525 if !strings.Contains(out, `"state"`) {
526 t.Errorf("expected state field: %s", out)
527 }
528 if !strings.Contains(out, `TIMEOUT`) {
529 t.Errorf("expected TIMEOUT error_code: %s", out)
530 }
531 }
532
533 func TestFileStoreIntegration_Status(t *testing.T) {
534 dir := t.TempDir()
535 writeTaskData(t, dir)
536
537 exit, out := captureOut(func() int {
538 return taskCommand([]string{"monitor", "status", "task-1", "--json", "--dir", dir})
539 })
540 if exit != 0 {
541 t.Fatalf("exit=%d", exit)
542 }
543 if !strings.Contains(out, `task-1`) {
544 t.Errorf("expected task-1: %s", out)
545 }
546 if strings.Contains(out, legacySensitiveSummary) || strings.Contains(out, `"error_summary"`) {
547 t.Errorf("status leaked legacy error_summary: %s", out)
548 }
549 }
550
551 func TestFileStoreIntegration_Status_NotFound(t *testing.T) {
552 dir := t.TempDir()
553
554 exit, out := captureOut(func() int {
555 return taskCommand([]string{"monitor", "status", "--json", "--dir", dir, "ghost"})
556 })
557 if exit != 0 {
558 t.Fatalf("exit=%d", exit)
559 }
560 if !strings.Contains(out, `null`) {
561 t.Errorf("expected null task: %s", out)
562 }
563 }
564
565 func TestFileStoreIntegration_Events_JSON(t *testing.T) {
566 dir := t.TempDir()
567 writeTaskData(t, dir)
568
569 exit, out := captureOut(func() int {
570 return taskCommand([]string{"monitor", "events", "--json", "--dir", dir, "task-1"})
571 })
572 if exit != 0 {
573 t.Fatalf("exit=%d", exit)
574 }
575 if !strings.Contains(out, `task-1`) {
576 t.Errorf("expected task_id: %s", out)
577 }
578 var v struct {
579 Events []taskmonitor.TaskEvent `json:"events"`
580 }
581 if err := json.Unmarshal([]byte(out), &v); err != nil {
582 t.Fatalf("parse: %v", err)
583 }
584 if len(v.Events) != 3 {
585 t.Errorf("expected 3 events, got %d", len(v.Events))
586 }
587 }
588
589 func TestFileStoreIntegration_Events_JSONL(t *testing.T) {
590 dir := t.TempDir()
591 writeTaskData(t, dir)
592
593 exit, out := captureOut(func() int {
594 return taskCommand([]string{"monitor", "events", "--jsonl", "--dir", dir, "task-1"})
595 })
596 if exit != 0 {
597 t.Fatalf("exit=%d", exit)
598 }
599 lines := strings.Split(strings.TrimSpace(out), "\n")
600 if len(lines) != 3 {
601 t.Fatalf("expected 3 JSONL lines, got %d: %s", len(lines), out)
602 }
603 for _, line := range lines {
604 var ev taskmonitor.TaskEvent
605 if err := json.Unmarshal([]byte(line), &ev); err != nil {
606 t.Errorf("invalid JSONL: %v — line: %s", err, line)
607 }
608 }
609 }
610
611 func TestFileStoreIntegration_Events_AfterCursor(t *testing.T) {
612 dir := t.TempDir()
613 writeTaskData(t, dir)
614
615 exit, out := captureOut(func() int {
616 return taskCommand([]string{"monitor", "events", "task-1", "--json", "--dir", dir, "--after", "1"})
617 })
618 if exit != 0 {
619 t.Fatalf("exit=%d", exit)
620 }
621 var v struct {
622 Events []taskmonitor.TaskEvent `json:"events"`
623 }
624 json.Unmarshal([]byte(out), &v)
625 if len(v.Events) != 2 || v.Events[0].Sequence != 2 {
626 t.Errorf("expected 2 events seq≥2, got %d events", len(v.Events))
627 }
628 }
629
630 func TestFileStoreIntegration_ListTasks_Empty(t *testing.T) {
631 dir := t.TempDir()
632 taskStore = nil
633
634 exit, out := captureOut(func() int {
635 return taskCommand([]string{"monitor", "list", "--json", "--dir", dir})
636 })
637 if exit != 0 {
638 t.Fatalf("exit=%d", exit)
639 }
640 if !strings.Contains(out, `"tasks"`) {
641 t.Errorf("expected tasks key: %s", out)
642 }
643 }
644
645 // --- CLI+JobKiller e2e tests ---
646
647 // mockJobKiller is a thread-safe mock for JobKiller.
648 type mockJobKiller struct {
649 called map[string]int
650 }
651
652 func newMockKiller() *mockJobKiller {
653 return &mockJobKiller{called: make(map[string]int)}
654 }
655
656 func (m *mockJobKiller) Kill(sessionID, id string) bool {
657 m.called[sessionID+"/"+id]++
658 return true
659 }
660
661 func TestCLI_StopCallsKill(t *testing.T) {
662 s := taskmonitor.NewInMemoryStore()
663 taskStore = s
664 taskJobKiller = newMockKiller()
665 defer func() { taskJobKiller = nil }()
666
667 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
668 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
669 State: taskmonitor.TaskStateRunning, Version: 1,
670 CreatedAt: time.Now(), UpdatedAt: time.Now(),
671 })
672
673 exit, out := captureOut(func() int {
674 ec := taskCommand([]string{"stop", "t1", "--json", "--expected-version", "1"})
675 return ec
676 })
677 if exit != 0 {
678 t.Fatalf("exit=%d out=%s", exit, out)
679 }
680 mk := taskJobKiller.(*mockJobKiller)
681 if mk.called["s1/t1"] != 1 {
682 t.Errorf("expected Kill(s1, t1) called once, got %v", mk.called)
683 }
684 }
685
686 func TestCLI_CancelCallsKill(t *testing.T) {
687 s := taskmonitor.NewInMemoryStore()
688 taskStore = s
689 taskJobKiller = newMockKiller()
690 defer func() { taskJobKiller = nil }()
691
692 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
693 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
694 State: taskmonitor.TaskStateRunning, Version: 1,
695 CreatedAt: time.Now(), UpdatedAt: time.Now(),
696 })
697
698 exit, out := captureOut(func() int {
699 return taskCommand([]string{"cancel", "t1", "--json", "--expected-version", "1"})
700 })
701 if exit != 0 {
702 t.Fatalf("exit=%d out=%s", exit, out)
703 }
704 mk := taskJobKiller.(*mockJobKiller)
705 if mk.called["s1/t1"] != 1 {
706 t.Errorf("expected Kill(s1, t1) called once, got %v", mk.called)
707 }
708 }
709
710 func TestCLI_RequeueReportsQueuedButExited(t *testing.T) {
711 s := taskmonitor.NewInMemoryStore()
712 taskStore = s
713 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
714 SchemaVersion: 1, TaskID: "failed", SessionID: "s1",
715 State: taskmonitor.TaskStateFailed, RuntimeState: taskmonitor.RuntimeStateExited, Version: 2,
716 CreatedAt: time.Now(), UpdatedAt: time.Now(),
717 })
718
719 exit, out := captureOut(func() int {
720 return taskCommand([]string{"requeue", "failed", "--json", "--expected-version", "2", "--dir", "/p"})
721 })
722 if exit != 0 {
723 t.Fatalf("exit=%d out=%s", exit, out)
724 }
725 var result taskmonitor.ControlResult
726 if err := json.Unmarshal([]byte(out), &result); err != nil {
727 t.Fatalf("decode output: %v\n%s", err, out)
728 }
729 if result.Command != "requeue" || result.State != taskmonitor.TaskStateQueued || result.RuntimeState != taskmonitor.RuntimeStateExited {
730 t.Fatalf("unexpected requeue result: %+v", result)
731 }
732 }
733
734 func TestCLI_OpenSessionAcceptsDocumentedIDBeforeFlags(t *testing.T) {
735 s := taskmonitor.NewInMemoryStore()
736 taskStore = s
737 mustUpsert(t, s, "/p", taskmonitor.TaskSnapshot{
738 SchemaVersion: 1, TaskID: "t1", SessionID: "s1",
739 State: taskmonitor.TaskStateRunning, Version: 1,
740 CreatedAt: time.Now(), UpdatedAt: time.Now(),
741 })
742
743 exit, out := captureOut(func() int {
744 return taskCommand([]string{"open-session", "t1", "--json", "--dir", "/p"})
745 })
746 if exit != 0 {
747 t.Fatalf("exit=%d out=%s", exit, out)
748 }
749 var result taskmonitor.ControlResult
750 if err := json.Unmarshal([]byte(out), &result); err != nil {
751 t.Fatalf("decode output: %v\n%s", err, out)
752 }
753 if !result.Accepted || result.TaskID != "t1" || result.SessionID != "s1" {
754 t.Fatalf("unexpected open-session result: %+v", result)
755 }
756 }
757
758 func TestCLI_ResumeIsNotATaskCommand(t *testing.T) {
759 exit, _ := captureOut(func() int { return taskCommand([]string{"resume"}) })
760 if exit != 2 {
761 t.Fatalf("legacy task resume exit=%d, want usage error 2", exit)
762 }
763 }
764
764 lines GO