| 1 | package taskmonitor |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | "time" |
| 9 | ) |
| 10 | |
| 11 | func TestTaskStateIsKnown(t *testing.T) { |
| 12 | for _, s := range []TaskState{ |
| 13 | TaskStateQueued, TaskStateRunning, TaskStateWaiting, |
| 14 | TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale, |
| 15 | } { |
| 16 | if !s.IsKnown() { |
| 17 | t.Errorf("expected IsKnown=true for %q", s) |
| 18 | } |
| 19 | } |
| 20 | if TaskState("bogus").IsKnown() { |
| 21 | t.Error("expected IsKnown=false for unknown state") |
| 22 | } |
| 23 | } |
| 24 | |
| 25 | func TestTaskStateTerminal(t *testing.T) { |
| 26 | for _, s := range []TaskState{ |
| 27 | TaskStateSucceeded, TaskStateFailed, TaskStateCancelled, TaskStateStale, |
| 28 | } { |
| 29 | if !s.Terminal() { |
| 30 | t.Errorf("expected Terminal=true for %q", s) |
| 31 | } |
| 32 | } |
| 33 | for _, s := range []TaskState{TaskStateQueued, TaskStateRunning, TaskStateWaiting} { |
| 34 | if s.Terminal() { |
| 35 | t.Errorf("expected Terminal=false for %q", s) |
| 36 | } |
| 37 | } |
| 38 | } |
| 39 | |
| 40 | func TestTaskStateValidTransition(t *testing.T) { |
| 41 | tests := []struct { |
| 42 | from, to TaskState |
| 43 | valid bool |
| 44 | }{ |
| 45 | // queued |
| 46 | {TaskStateQueued, TaskStateRunning, true}, |
| 47 | {TaskStateQueued, TaskStateCancelled, true}, |
| 48 | {TaskStateQueued, TaskStateStale, true}, |
| 49 | {TaskStateQueued, TaskStateSucceeded, false}, |
| 50 | {TaskStateQueued, TaskStateFailed, false}, |
| 51 | {TaskStateQueued, TaskStateQueued, false}, |
| 52 | // running |
| 53 | {TaskStateRunning, TaskStateWaiting, true}, |
| 54 | {TaskStateRunning, TaskStateSucceeded, true}, |
| 55 | {TaskStateRunning, TaskStateFailed, true}, |
| 56 | {TaskStateRunning, TaskStateCancelled, true}, |
| 57 | {TaskStateRunning, TaskStateStale, true}, |
| 58 | {TaskStateRunning, TaskStateQueued, false}, |
| 59 | // waiting |
| 60 | {TaskStateWaiting, TaskStateRunning, true}, |
| 61 | {TaskStateWaiting, TaskStateSucceeded, true}, |
| 62 | {TaskStateWaiting, TaskStateFailed, true}, |
| 63 | {TaskStateWaiting, TaskStateCancelled, true}, |
| 64 | {TaskStateWaiting, TaskStateStale, true}, |
| 65 | {TaskStateWaiting, TaskStateQueued, false}, |
| 66 | // terminal → anything (including unknown) is invalid |
| 67 | {TaskStateSucceeded, TaskStateRunning, false}, |
| 68 | {TaskStateFailed, TaskStateRunning, false}, |
| 69 | {TaskStateCancelled, TaskStateRunning, false}, |
| 70 | {TaskStateStale, TaskStateRunning, false}, |
| 71 | {TaskStateSucceeded, "future-state", false}, |
| 72 | {TaskStateFailed, "future-state", false}, |
| 73 | {TaskStateCancelled, "future-state", false}, |
| 74 | {TaskStateStale, "future-state", false}, |
| 75 | } |
| 76 | for _, tc := range tests { |
| 77 | got := tc.from.ValidTransition(tc.to) |
| 78 | if got != tc.valid { |
| 79 | t.Errorf("%s → %s: expected valid=%v, got %v", tc.from, tc.to, tc.valid, got) |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | func TestTaskStateUnknownTransition(t *testing.T) { |
| 85 | // unknown → known: allowed (forward-compat) |
| 86 | if !TaskState("future-state").ValidTransition(TaskStateRunning) { |
| 87 | t.Error("unknown state should allow transitions to known states") |
| 88 | } |
| 89 | // known non-terminal → unknown: allowed |
| 90 | if !TaskStateQueued.ValidTransition("future-state") { |
| 91 | t.Error("known non-terminal state should allow transitions to unknown states") |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | func TestTaskStateUnmarshalJSON_Unknown(t *testing.T) { |
| 96 | var s TaskState |
| 97 | if err := json.Unmarshal([]byte(`"brand-new-state"`), &s); err != nil { |
| 98 | t.Fatalf("unmarshal: %v", err) |
| 99 | } |
| 100 | if s != "brand-new-state" { |
| 101 | t.Errorf("expected 'brand-new-state', got %q", s) |
| 102 | } |
| 103 | if s.IsKnown() { |
| 104 | t.Error("unknown state should not report IsKnown") |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | func TestTaskStateUnmarshalJSON_Known(t *testing.T) { |
| 109 | var s TaskState |
| 110 | if err := json.Unmarshal([]byte(`"running"`), &s); err != nil { |
| 111 | t.Fatalf("unmarshal: %v", err) |
| 112 | } |
| 113 | if s != TaskStateRunning { |
| 114 | t.Errorf("expected running, got %q", s) |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | func TestRuntimeStateEffective_LegacyAndKnownValues(t *testing.T) { |
| 119 | if got := (RuntimeState("")).Effective(); got != RuntimeStateUnknown { |
| 120 | t.Fatalf("legacy empty runtime state = %q, want unknown", got) |
| 121 | } |
| 122 | for _, state := range []RuntimeState{RuntimeStateUnknown, RuntimeStateAlive, RuntimeStateExited} { |
| 123 | if !state.IsKnown() || state.Effective() != state { |
| 124 | t.Fatalf("runtime state %q was not preserved as known", state) |
| 125 | } |
| 126 | } |
| 127 | if RuntimeState("future-runtime").IsKnown() { |
| 128 | t.Fatal("future runtime state should remain forward-compatible but unknown") |
| 129 | } |
| 130 | } |
| 131 | |
| 132 | // --------------------------------------------------------------------------- |
| 133 | // TaskSnapshot |
| 134 | // --------------------------------------------------------------------------- |
| 135 | |
| 136 | func TestTaskSnapshotValidate_Valid(t *testing.T) { |
| 137 | ts := TaskSnapshot{ |
| 138 | SchemaVersion: 1, TaskID: "task-1", SessionID: "sess-1", |
| 139 | State: TaskStateRunning, CreatedAt: time.Now().Add(-time.Hour), UpdatedAt: time.Now(), |
| 140 | } |
| 141 | if err := ts.Validate(); err != nil { |
| 142 | t.Errorf("expected valid, got: %v", err) |
| 143 | } |
| 144 | } |
| 145 | |
| 146 | func TestTaskSnapshotValidate_MissingFields(t *testing.T) { |
| 147 | tests := []struct { |
| 148 | name string |
| 149 | snap TaskSnapshot |
| 150 | want string |
| 151 | }{ |
| 152 | {"no TaskID", TaskSnapshot{SessionID: "s", State: TaskStateQueued, SchemaVersion: 1}, "TaskID"}, |
| 153 | {"no State", TaskSnapshot{TaskID: "t", SessionID: "s", SchemaVersion: 1}, "State"}, |
| 154 | {"bad SchemaVersion", TaskSnapshot{TaskID: "t", SessionID: "s", State: TaskStateQueued, SchemaVersion: 0, CreatedAt: time.Now(), UpdatedAt: time.Now()}, "SchemaVersion"}, |
| 155 | } |
| 156 | for _, tc := range tests { |
| 157 | err := tc.snap.Validate() |
| 158 | if err == nil { |
| 159 | t.Errorf("%s: expected error, got nil", tc.name) |
| 160 | continue |
| 161 | } |
| 162 | if !strings.Contains(err.Error(), tc.want) { |
| 163 | t.Errorf("%s: expected %q in error, got %q", tc.name, tc.want, err.Error()) |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | |
| 168 | func TestTaskSnapshotValidate_UpdatedBeforeCreated(t *testing.T) { |
| 169 | ts := TaskSnapshot{ |
| 170 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 171 | State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now().Add(-time.Hour), |
| 172 | } |
| 173 | err := ts.Validate() |
| 174 | if err == nil || !strings.Contains(err.Error(), "before CreatedAt") { |
| 175 | t.Fatalf("expected 'before CreatedAt' error, got %v", err) |
| 176 | } |
| 177 | } |
| 178 | |
| 179 | func TestTaskSnapshotValidate_FieldLengthLimits(t *testing.T) { |
| 180 | long := strings.Repeat("x", maxFieldLen+1) |
| 181 | longSummary := strings.Repeat("y", maxErrorSummaryLen+1) |
| 182 | tests := []struct { |
| 183 | name string |
| 184 | snap TaskSnapshot |
| 185 | want string |
| 186 | }{ |
| 187 | {"TaskID too long", TaskSnapshot{ |
| 188 | SchemaVersion: 1, TaskID: long, SessionID: "s", |
| 189 | State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 190 | }, "TaskID exceeds"}, |
| 191 | {"JobID too long", TaskSnapshot{ |
| 192 | SchemaVersion: 1, TaskID: "t", JobID: long, SessionID: "s", |
| 193 | State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 194 | }, "JobID exceeds"}, |
| 195 | {"SessionID too long", TaskSnapshot{ |
| 196 | SchemaVersion: 1, TaskID: "t", SessionID: long, |
| 197 | State: TaskStateQueued, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 198 | }, "SessionID exceeds"}, |
| 199 | {"ErrorCode too long", TaskSnapshot{ |
| 200 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 201 | State: TaskStateFailed, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 202 | ErrorCode: long, |
| 203 | }, "ErrorCode exceeds"}, |
| 204 | {"RuntimeState too long", TaskSnapshot{ |
| 205 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 206 | State: TaskStateRunning, RuntimeState: RuntimeState(long), |
| 207 | CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 208 | }, "RuntimeState exceeds"}, |
| 209 | {"ErrorSummary too long", TaskSnapshot{ |
| 210 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 211 | State: TaskStateFailed, CreatedAt: time.Now(), UpdatedAt: time.Now(), |
| 212 | ErrorSummary: longSummary, |
| 213 | }, "ErrorSummary exceeds"}, |
| 214 | } |
| 215 | for _, tc := range tests { |
| 216 | err := tc.snap.Validate() |
| 217 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 218 | t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err) |
| 219 | } |
| 220 | } |
| 221 | } |
| 222 | |
| 223 | func TestTaskSnapshotJSON_RoundTrip(t *testing.T) { |
| 224 | now := time.Now().Truncate(time.Second) |
| 225 | ts := TaskSnapshot{ |
| 226 | SchemaVersion: 1, TaskID: "s1--t1", JobID: "t1", SessionID: "s1", |
| 227 | State: TaskStateFailed, RuntimeState: RuntimeStateExited, |
| 228 | CreatedAt: now.Add(-time.Hour), UpdatedAt: now, |
| 229 | ErrorCode: "TIMEOUT", ErrorSummary: "task exceeded deadline", |
| 230 | } |
| 231 | data, err := json.Marshal(ts) |
| 232 | if err != nil { |
| 233 | t.Fatalf("marshal: %v", err) |
| 234 | } |
| 235 | var got TaskSnapshot |
| 236 | if err := json.Unmarshal(data, &got); err != nil { |
| 237 | t.Fatalf("unmarshal: %v", err) |
| 238 | } |
| 239 | if got.TaskID != ts.TaskID || got.JobID != ts.JobID || got.State != ts.State || got.RuntimeState != ts.RuntimeState || got.ErrorCode != ts.ErrorCode { |
| 240 | t.Errorf("round-trip mismatch") |
| 241 | } |
| 242 | } |
| 243 | |
| 244 | func TestReconcileRuntimeExpiredLease(t *testing.T) { |
| 245 | now := time.Now().UTC() |
| 246 | snap := TaskSnapshot{ |
| 247 | SchemaVersion: 1, TaskID: "task-1", SessionID: "s1", State: TaskStateRunning, |
| 248 | RuntimeState: RuntimeStateAlive, RuntimeLeaseUntil: now.Add(-time.Second), |
| 249 | CreatedAt: now.Add(-time.Minute), UpdatedAt: now.Add(-time.Minute), |
| 250 | } |
| 251 | reconcileRuntime(&snap, now) |
| 252 | if snap.State != TaskStateStale || snap.RuntimeState != RuntimeStateExited { |
| 253 | t.Fatalf("reconciled snapshot = %+v", snap) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | func TestTaskSnapshotJSON_LegacyMissingRuntimeState(t *testing.T) { |
| 258 | raw := `{"schema_version":1,"task_id":"legacy","session_id":"s","state":"running","version":1,"created_at":"2025-01-01T00:00:00Z","updated_at":"2025-01-01T00:00:01Z"}` |
| 259 | var snap TaskSnapshot |
| 260 | if err := json.Unmarshal([]byte(raw), &snap); err != nil { |
| 261 | t.Fatalf("unmarshal legacy snapshot: %v", err) |
| 262 | } |
| 263 | if got := snap.RuntimeState.Effective(); got != RuntimeStateUnknown { |
| 264 | t.Fatalf("legacy runtime state = %q, want unknown", got) |
| 265 | } |
| 266 | if snap.JobID != "" || runtimeJobID(&snap) != snap.TaskID { |
| 267 | t.Fatalf("legacy job identity = %q/%q", snap.JobID, runtimeJobID(&snap)) |
| 268 | } |
| 269 | if err := snap.Validate(); err != nil { |
| 270 | t.Fatalf("legacy snapshot should remain valid: %v", err) |
| 271 | } |
| 272 | } |
| 273 | |
| 274 | // --------------------------------------------------------------------------- |
| 275 | // TaskEvent |
| 276 | // --------------------------------------------------------------------------- |
| 277 | |
| 278 | func TestTaskEventValidate_Valid(t *testing.T) { |
| 279 | ev := TaskEvent{ |
| 280 | Sequence: 1, Timestamp: time.Now(), EventType: "state_change", |
| 281 | TaskID: "t1", SessionID: "s1", State: TaskStateRunning, |
| 282 | } |
| 283 | if err := ev.Validate(); err != nil { |
| 284 | t.Errorf("expected valid, got: %v", err) |
| 285 | } |
| 286 | } |
| 287 | |
| 288 | func TestTaskEventValidate_MissingFields(t *testing.T) { |
| 289 | tests := []struct { |
| 290 | name string |
| 291 | event TaskEvent |
| 292 | want string |
| 293 | }{ |
| 294 | {"zero Sequence", TaskEvent{Timestamp: time.Now(), EventType: "e", TaskID: "t", SessionID: "s", State: TaskStateQueued}, "Sequence"}, |
| 295 | {"no TaskID", TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", SessionID: "s", State: TaskStateQueued}, "TaskID"}, |
| 296 | {"no EventType", TaskEvent{Sequence: 1, Timestamp: time.Now(), TaskID: "t", SessionID: "s", State: TaskStateQueued}, "EventType"}, |
| 297 | {"no State", TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", TaskID: "t", SessionID: "s"}, "State"}, |
| 298 | {"no Timestamp", TaskEvent{Sequence: 1, EventType: "e", TaskID: "t", SessionID: "s", State: TaskStateQueued}, "Timestamp"}, |
| 299 | } |
| 300 | for _, tc := range tests { |
| 301 | err := tc.event.Validate() |
| 302 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 303 | t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err) |
| 304 | } |
| 305 | } |
| 306 | } |
| 307 | |
| 308 | func TestTaskEventValidate_FieldLengthLimits(t *testing.T) { |
| 309 | long := strings.Repeat("x", maxFieldLen+1) |
| 310 | longSummary := strings.Repeat("y", maxErrorSummaryLen+1) |
| 311 | base := TaskEvent{ |
| 312 | Sequence: 1, Timestamp: time.Now(), EventType: "e", |
| 313 | TaskID: "t", SessionID: "s", State: TaskStateQueued, |
| 314 | } |
| 315 | tests := []struct { |
| 316 | name string |
| 317 | event TaskEvent |
| 318 | want string |
| 319 | }{ |
| 320 | {"TaskID too long", withField(base, "TaskID", long), "TaskID exceeds"}, |
| 321 | {"SessionID too long", withField(base, "SessionID", long), "SessionID exceeds"}, |
| 322 | {"EventType too long", withField(base, "EventType", long), "EventType exceeds"}, |
| 323 | {"ErrorCode too long", withField(base, "ErrorCode", long), "ErrorCode exceeds"}, |
| 324 | {"RuntimeState too long", withField(base, "RuntimeState", long), "RuntimeState exceeds"}, |
| 325 | {"ErrorSummary too long", withField(base, "ErrorSummary", longSummary), "ErrorSummary exceeds"}, |
| 326 | } |
| 327 | for _, tc := range tests { |
| 328 | err := tc.event.Validate() |
| 329 | if err == nil || !strings.Contains(err.Error(), tc.want) { |
| 330 | t.Errorf("%s: expected %q in error, got %v", tc.name, tc.want, err) |
| 331 | } |
| 332 | } |
| 333 | } |
| 334 | |
| 335 | func withField(ev TaskEvent, field, val string) TaskEvent { |
| 336 | switch field { |
| 337 | case "TaskID": |
| 338 | ev.TaskID = val |
| 339 | case "SessionID": |
| 340 | ev.SessionID = val |
| 341 | case "EventType": |
| 342 | ev.EventType = val |
| 343 | case "ErrorCode": |
| 344 | ev.ErrorCode = val |
| 345 | case "RuntimeState": |
| 346 | ev.RuntimeState = RuntimeState(val) |
| 347 | case "ErrorSummary": |
| 348 | ev.ErrorSummary = val |
| 349 | } |
| 350 | return ev |
| 351 | } |
| 352 | |
| 353 | func TestTaskEventJSON_NoSensitiveFields(t *testing.T) { |
| 354 | raw := `{ |
| 355 | "sequence": 1, "timestamp": "2025-01-01T00:00:00Z", |
| 356 | "event_type": "tool_dispatch", "task_id": "t1", "session_id": "s1", |
| 357 | "state": "running", |
| 358 | "prompt": "SECRET", "tool_args": "rm -rf /", |
| 359 | "tool_result": "sensitive", "reasoning": "private" |
| 360 | }` |
| 361 | var ev TaskEvent |
| 362 | if err := json.Unmarshal([]byte(raw), &ev); err != nil { |
| 363 | t.Fatalf("unmarshal: %v", err) |
| 364 | } |
| 365 | data, _ := json.Marshal(ev) |
| 366 | s := string(data) |
| 367 | for _, forbidden := range []string{"SECRET", "rm -rf", "sensitive", "private"} { |
| 368 | if strings.Contains(s, forbidden) { |
| 369 | t.Errorf("output contains forbidden content %q: %s", forbidden, s) |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | |
| 374 | // --------------------------------------------------------------------------- |
| 375 | // InMemoryStore |
| 376 | // --------------------------------------------------------------------------- |
| 377 | |
| 378 | func seedTime(i int) time.Time { |
| 379 | return time.Date(2025, 1, 1, 0, 0, i, 0, time.UTC) |
| 380 | } |
| 381 | |
| 382 | func TestInMemoryStore_ListTasks_Empty(t *testing.T) { |
| 383 | store := NewInMemoryStore() |
| 384 | tasks, err := store.ListTasks(context.Background(), "/proj") |
| 385 | if err != nil || len(tasks) != 0 { |
| 386 | t.Fatalf("expected empty, got %d tasks, err=%v", len(tasks), err) |
| 387 | } |
| 388 | } |
| 389 | |
| 390 | func TestInMemoryStore_ListTasks_ProjectIsolation(t *testing.T) { |
| 391 | store := NewInMemoryStore() |
| 392 | mustUpsert(t, store, "/proj-a", TaskSnapshot{ |
| 393 | SchemaVersion: 1, TaskID: "a1", SessionID: "s1", |
| 394 | State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(10), |
| 395 | }) |
| 396 | mustUpsert(t, store, "/proj-b", TaskSnapshot{ |
| 397 | SchemaVersion: 1, TaskID: "b1", SessionID: "s3", |
| 398 | State: TaskStateFailed, CreatedAt: seedTime(3), UpdatedAt: seedTime(12), |
| 399 | }) |
| 400 | aTasks, _ := store.ListTasks(context.Background(), "/proj-a") |
| 401 | if len(aTasks) != 1 || aTasks[0].TaskID != "a1" { |
| 402 | t.Fatalf("expected [a1] in /proj-a") |
| 403 | } |
| 404 | unknown, _ := store.ListTasks(context.Background(), "/no-such") |
| 405 | if len(unknown) != 0 { |
| 406 | t.Errorf("expected empty, got %d", len(unknown)) |
| 407 | } |
| 408 | } |
| 409 | |
| 410 | func TestInMemoryStore_ListTasks_AllProjects(t *testing.T) { |
| 411 | store := NewInMemoryStore() |
| 412 | mustUpsert(t, store, "/proj-a", TaskSnapshot{ |
| 413 | SchemaVersion: 1, TaskID: "a1", SessionID: "s", |
| 414 | State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(10), |
| 415 | }) |
| 416 | mustUpsert(t, store, "/proj-b", TaskSnapshot{ |
| 417 | SchemaVersion: 1, TaskID: "b1", SessionID: "s", |
| 418 | State: TaskStateFailed, CreatedAt: seedTime(2), UpdatedAt: seedTime(11), |
| 419 | }) |
| 420 | tasks, _ := store.ListTasks(context.Background(), "") |
| 421 | if len(tasks) != 2 { |
| 422 | t.Fatalf("expected 2 tasks, got %d", len(tasks)) |
| 423 | } |
| 424 | } |
| 425 | |
| 426 | func TestInMemoryStore_ListTasks_SortOrder(t *testing.T) { |
| 427 | store := NewInMemoryStore() |
| 428 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 429 | SchemaVersion: 1, TaskID: "old", SessionID: "s", State: TaskStateQueued, |
| 430 | CreatedAt: seedTime(1), UpdatedAt: seedTime(5), |
| 431 | }) |
| 432 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 433 | SchemaVersion: 1, TaskID: "new", SessionID: "s", State: TaskStateRunning, |
| 434 | CreatedAt: seedTime(2), UpdatedAt: seedTime(10), |
| 435 | }) |
| 436 | tasks, _ := store.ListTasks(context.Background(), "/p") |
| 437 | if tasks[0].TaskID != "new" || tasks[1].TaskID != "old" { |
| 438 | t.Errorf("sort order wrong: [0]=%q [1]=%q", tasks[0].TaskID, tasks[1].TaskID) |
| 439 | } |
| 440 | } |
| 441 | |
| 442 | func TestInMemoryStore_GetTask_ProjectIsolation(t *testing.T) { |
| 443 | store := NewInMemoryStore() |
| 444 | mustUpsert(t, store, "/proj-a", TaskSnapshot{ |
| 445 | SchemaVersion: 1, TaskID: "t1", SessionID: "s", |
| 446 | State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 447 | }) |
| 448 | // same task in different project — should not be visible |
| 449 | snap, err := store.GetTask(context.Background(), "/proj-b", "t1") |
| 450 | if err != nil || snap != nil { |
| 451 | t.Fatalf("expected nil in /proj-b, got snap=%v err=%v", snap, err) |
| 452 | } |
| 453 | // in /proj-a it should be found |
| 454 | snap, err = store.GetTask(context.Background(), "/proj-a", "t1") |
| 455 | if err != nil || snap == nil { |
| 456 | t.Fatalf("expected snapshot in /proj-a, got err=%v", err) |
| 457 | } |
| 458 | } |
| 459 | |
| 460 | func TestInMemoryStore_GetTask_Found(t *testing.T) { |
| 461 | store := NewInMemoryStore() |
| 462 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 463 | SchemaVersion: 1, TaskID: "t1", SessionID: "s1", |
| 464 | State: TaskStateFailed, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 465 | ErrorCode: "EXIT_42", |
| 466 | }) |
| 467 | snap, err := store.GetTask(context.Background(), "/p", "t1") |
| 468 | if err != nil || snap == nil || snap.ErrorCode != "EXIT_42" { |
| 469 | t.Fatalf("GetTask: err=%v snap=%v", err, snap) |
| 470 | } |
| 471 | // mutation safety |
| 472 | snap.ErrorCode = "MUTATED" |
| 473 | snap2, _ := store.GetTask(context.Background(), "/p", "t1") |
| 474 | if snap2.ErrorCode == "MUTATED" { |
| 475 | t.Error("GetTask must return a copy") |
| 476 | } |
| 477 | } |
| 478 | |
| 479 | func TestInMemoryStore_GetTask_NotFound(t *testing.T) { |
| 480 | store := NewInMemoryStore() |
| 481 | snap, err := store.GetTask(context.Background(), "", "ghost") |
| 482 | if err != nil || snap != nil { |
| 483 | t.Errorf("expected nil,nil, got %v,%v", snap, err) |
| 484 | } |
| 485 | } |
| 486 | |
| 487 | func TestInMemoryStore_ListEvents_Empty(t *testing.T) { |
| 488 | store := NewInMemoryStore() |
| 489 | events, _ := store.ListEvents(context.Background(), "", "no-task", 0) |
| 490 | if len(events) != 0 { |
| 491 | t.Errorf("expected empty, got %d", len(events)) |
| 492 | } |
| 493 | } |
| 494 | |
| 495 | func TestInMemoryStore_ListEvents_SequenceOrder(t *testing.T) { |
| 496 | store := NewInMemoryStore() |
| 497 | for i := 1; i <= 5; i++ { |
| 498 | mustAppend(t, store, "/p", TaskEvent{ |
| 499 | Sequence: i, Timestamp: seedTime(i), EventType: "e", |
| 500 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 501 | }) |
| 502 | } |
| 503 | events, _ := store.ListEvents(context.Background(), "/p", "t", 0) |
| 504 | if len(events) != 5 { |
| 505 | t.Fatalf("expected 5 events, got %d", len(events)) |
| 506 | } |
| 507 | for i, ev := range events { |
| 508 | if ev.Sequence != i+1 { |
| 509 | t.Errorf("event[%d].Sequence=%d, want %d", i, ev.Sequence, i+1) |
| 510 | } |
| 511 | } |
| 512 | } |
| 513 | |
| 514 | func TestInMemoryStore_ListEvents_Cursor(t *testing.T) { |
| 515 | store := NewInMemoryStore() |
| 516 | for i := 1; i <= 5; i++ { |
| 517 | mustAppend(t, store, "/p", TaskEvent{ |
| 518 | Sequence: i, Timestamp: seedTime(i), EventType: "e", |
| 519 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 520 | }) |
| 521 | } |
| 522 | events, _ := store.ListEvents(context.Background(), "/p", "t", 3) |
| 523 | if len(events) != 2 || events[0].Sequence != 4 || events[1].Sequence != 5 { |
| 524 | t.Errorf("expected events 4,5, got %v", events) |
| 525 | } |
| 526 | } |
| 527 | |
| 528 | func TestInMemoryStore_ListEvents_ProjectIsolation(t *testing.T) { |
| 529 | store := NewInMemoryStore() |
| 530 | mustAppend(t, store, "/proj-a", TaskEvent{ |
| 531 | Sequence: 1, Timestamp: seedTime(1), EventType: "e", |
| 532 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 533 | }) |
| 534 | // Query from a different project |
| 535 | events, _ := store.ListEvents(context.Background(), "/proj-b", "t", 0) |
| 536 | if len(events) != 0 { |
| 537 | t.Errorf("expected empty in /proj-b, got %d events", len(events)) |
| 538 | } |
| 539 | } |
| 540 | |
| 541 | // --------------------------------------------------------------------------- |
| 542 | // Event validation |
| 543 | // --------------------------------------------------------------------------- |
| 544 | |
| 545 | func TestInMemoryStore_AppendEvent_RejectsDuplicateSequence(t *testing.T) { |
| 546 | store := NewInMemoryStore() |
| 547 | mustAppend(t, store, "/p", TaskEvent{ |
| 548 | Sequence: 1, Timestamp: seedTime(1), EventType: "e", |
| 549 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 550 | }) |
| 551 | err := store.AppendEvent("/p", TaskEvent{ |
| 552 | Sequence: 1, Timestamp: seedTime(2), EventType: "e", |
| 553 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 554 | }) |
| 555 | if err == nil || !strings.Contains(err.Error(), "strictly greater") { |
| 556 | t.Fatalf("expected 'strictly greater' error for duplicate seq, got %v", err) |
| 557 | } |
| 558 | } |
| 559 | |
| 560 | func TestInMemoryStore_AppendEvent_RejectsRegressingSequence(t *testing.T) { |
| 561 | store := NewInMemoryStore() |
| 562 | mustAppend(t, store, "/p", TaskEvent{ |
| 563 | Sequence: 5, Timestamp: seedTime(1), EventType: "e", |
| 564 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 565 | }) |
| 566 | err := store.AppendEvent("/p", TaskEvent{ |
| 567 | Sequence: 3, Timestamp: seedTime(2), EventType: "e", |
| 568 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 569 | }) |
| 570 | if err == nil || !strings.Contains(err.Error(), "strictly greater") { |
| 571 | t.Fatalf("expected 'strictly greater' error for regressing seq, got %v", err) |
| 572 | } |
| 573 | } |
| 574 | |
| 575 | func TestInMemoryStore_AppendEvent_RejectsTerminalAppend(t *testing.T) { |
| 576 | store := NewInMemoryStore() |
| 577 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 578 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 579 | State: TaskStateSucceeded, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 580 | }) |
| 581 | err := store.AppendEvent("/p", TaskEvent{ |
| 582 | Sequence: 1, Timestamp: seedTime(3), EventType: "e", |
| 583 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 584 | }) |
| 585 | if err == nil || !strings.Contains(err.Error(), "terminal state") { |
| 586 | t.Fatalf("expected 'terminal state' error, got %v", err) |
| 587 | } |
| 588 | } |
| 589 | |
| 590 | func TestInMemoryStore_AppendEvent_RejectsSessionIDMismatch(t *testing.T) { |
| 591 | store := NewInMemoryStore() |
| 592 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 593 | SchemaVersion: 1, TaskID: "t", SessionID: "s-original", |
| 594 | State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 595 | }) |
| 596 | err := store.AppendEvent("/p", TaskEvent{ |
| 597 | Sequence: 1, Timestamp: seedTime(3), EventType: "e", |
| 598 | TaskID: "t", SessionID: "s-different", State: TaskStateRunning, |
| 599 | }) |
| 600 | if err == nil || !strings.Contains(err.Error(), "SessionID mismatch") { |
| 601 | t.Fatalf("expected 'SessionID mismatch' error, got %v", err) |
| 602 | } |
| 603 | } |
| 604 | |
| 605 | func TestInMemoryStore_AppendEvent_UpdatesSnapshot(t *testing.T) { |
| 606 | store := NewInMemoryStore() |
| 607 | mustAppend(t, store, "/p", TaskEvent{ |
| 608 | Sequence: 1, Timestamp: seedTime(1), EventType: "state_change", |
| 609 | TaskID: "t", SessionID: "s", State: TaskStateQueued, |
| 610 | }) |
| 611 | mustAppend(t, store, "/p", TaskEvent{ |
| 612 | Sequence: 2, Timestamp: seedTime(2), EventType: "state_change", |
| 613 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 614 | }) |
| 615 | mustAppend(t, store, "/p", TaskEvent{ |
| 616 | Sequence: 3, Timestamp: seedTime(3), EventType: "error", |
| 617 | TaskID: "t", SessionID: "s", State: TaskStateFailed, |
| 618 | ErrorCode: "CRASH", ErrorSummary: "unexpected panic", |
| 619 | }) |
| 620 | snap, _ := store.GetTask(context.Background(), "/p", "t") |
| 621 | if snap.State != TaskStateFailed || snap.ErrorCode != "CRASH" { |
| 622 | t.Errorf("snapshot not updated: state=%q code=%q", snap.State, snap.ErrorCode) |
| 623 | } |
| 624 | if !snap.UpdatedAt.Equal(seedTime(3)) { |
| 625 | t.Errorf("UpdatedAt not updated: %v", snap.UpdatedAt) |
| 626 | } |
| 627 | } |
| 628 | |
| 629 | func TestInMemoryStore_UpsertTask_Invalid(t *testing.T) { |
| 630 | store := NewInMemoryStore() |
| 631 | if err := store.UpsertTask("/p", TaskSnapshot{}); err == nil { |
| 632 | t.Fatal("expected error for invalid snapshot") |
| 633 | } |
| 634 | } |
| 635 | |
| 636 | func TestInMemoryStore_AppendEvent_Invalid(t *testing.T) { |
| 637 | store := NewInMemoryStore() |
| 638 | if err := store.AppendEvent("/p", TaskEvent{}); err == nil { |
| 639 | t.Fatal("expected error for invalid event") |
| 640 | } |
| 641 | } |
| 642 | |
| 643 | func TestInMemoryStore_ContextCancellation(t *testing.T) { |
| 644 | store := NewInMemoryStore() |
| 645 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 646 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 647 | State: TaskStateRunning, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 648 | }) |
| 649 | mustAppend(t, store, "/p", TaskEvent{ |
| 650 | Sequence: 1, Timestamp: seedTime(1), EventType: "e", |
| 651 | TaskID: "t", SessionID: "s", State: TaskStateRunning, |
| 652 | }) |
| 653 | |
| 654 | ctx, cancel := context.WithCancel(context.Background()) |
| 655 | cancel() |
| 656 | |
| 657 | _, err := store.ListTasks(ctx, "/p") |
| 658 | if err == nil { |
| 659 | t.Error("ListTasks should return error for cancelled context") |
| 660 | } |
| 661 | _, err = store.GetTask(ctx, "/p", "t") |
| 662 | if err == nil { |
| 663 | t.Error("GetTask should return error for cancelled context") |
| 664 | } |
| 665 | _, err = store.ListEvents(ctx, "/p", "t", 0) |
| 666 | if err == nil { |
| 667 | t.Error("ListEvents should return error for cancelled context") |
| 668 | } |
| 669 | } |
| 670 | |
| 671 | func TestStore_DoesNotLeakSensitiveViaInterface(t *testing.T) { |
| 672 | store := NewInMemoryStore() |
| 673 | mustUpsert(t, store, "/p", TaskSnapshot{ |
| 674 | SchemaVersion: 1, TaskID: "t", SessionID: "s", |
| 675 | State: TaskStateFailed, CreatedAt: seedTime(1), UpdatedAt: seedTime(2), |
| 676 | ErrorCode: "ERR", ErrorSummary: "safe summary", |
| 677 | }) |
| 678 | snap, _ := store.GetTask(context.Background(), "/p", "t") |
| 679 | data, _ := json.Marshal(snap) |
| 680 | s := string(data) |
| 681 | for _, forbidden := range []string{"prompt", "tool_args", "tool_result", "reasoning", "approval"} { |
| 682 | if strings.Contains(s, forbidden) { |
| 683 | t.Errorf("snapshot JSON contains forbidden key %q: %s", forbidden, s) |
| 684 | } |
| 685 | } |
| 686 | } |
| 687 | |
| 688 | // --------------------------------------------------------------------------- |
| 689 | // helpers |
| 690 | // --------------------------------------------------------------------------- |
| 691 | |
| 692 | func mustUpsert(t *testing.T, store *InMemoryStore, proj string, snap TaskSnapshot) { |
| 693 | t.Helper() |
| 694 | if err := store.UpsertTask(proj, snap); err != nil { |
| 695 | t.Fatalf("mustUpsert: %v", err) |
| 696 | } |
| 697 | } |
| 698 | |
| 699 | func mustAppend(t *testing.T, store *InMemoryStore, proj string, ev TaskEvent) { |
| 700 | t.Helper() |
| 701 | if err := store.AppendEvent(proj, ev); err != nil { |
| 702 | t.Fatalf("mustAppend: %v", err) |
| 703 | } |
| 704 | } |
| 705 | |
| 706 | func TestTaskSnapshotValidate_SessionIDOptional(t *testing.T) { |
| 707 | now := time.Now() |
| 708 | snap := TaskSnapshot{SchemaVersion: 1, TaskID: "t", State: TaskStateQueued, CreatedAt: now, UpdatedAt: now} |
| 709 | if err := snap.Validate(); err != nil { |
| 710 | t.Fatalf("empty SessionID should be valid, got %v", err) |
| 711 | } |
| 712 | } |
| 713 | |
| 714 | func TestTaskEventValidate_SessionIDOptional(t *testing.T) { |
| 715 | ev := TaskEvent{Sequence: 1, Timestamp: time.Now(), EventType: "e", TaskID: "t", State: TaskStateQueued} |
| 716 | if err := ev.Validate(); err != nil { |
| 717 | t.Fatalf("empty SessionID should be valid, got %v", err) |
| 718 | } |
| 719 | } |
| 720 |