返回 DeepSeek-Reasonix
session_identity_test.go
根目录 / internal / serve / session_identity_test.go
1 package serve
2
3 import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "net/http"
8 "net/http/httptest"
9 "os"
10 "path/filepath"
11 "strings"
12 "testing"
13 "time"
14
15 "reasonix/internal/agent"
16 "reasonix/internal/config"
17 "reasonix/internal/control"
18 "reasonix/internal/event"
19 "reasonix/internal/session"
20 "reasonix/internal/store"
21 )
22
23 func TestSessionsDeduplicatesMigratedLegacySource(t *testing.T) {
24 legacyDir := t.TempDir()
25 v4Root := filepath.Join(t.TempDir(), "sessions-v4")
26 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
27 if err != nil {
28 t.Fatal(err)
29 }
30 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
31 ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
32 if _, err := ctrl.BindFreshSession(t.Context(), "current"); err != nil {
33 t.Fatal(err)
34 }
35 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"})
36 if err != nil {
37 t.Fatal(err)
38 }
39 if err := service.Close(t.Context(), target.Ref()); err != nil {
40 t.Fatal(err)
41 }
42 legacy := filepath.Join(legacyDir, "old.jsonl")
43 if err := os.WriteFile(legacy, []byte(`{"role":"user","content":"old"}`+"\n"), 0o600); err != nil {
44 t.Fatal(err)
45 }
46 mapping := session.MigrationMapping{
47 SchemaVersion: session.SchemaVersion,
48 Entries: []session.MigrationEntry{{SourcePath: agent.CanonicalSessionPath(legacy), TargetID: "canonical-target"}},
49 }
50 data, err := json.Marshal(mapping)
51 if err != nil {
52 t.Fatal(err)
53 }
54 if err := os.WriteFile(filepath.Join(v4Root, "migration-map.json"), data, 0o600); err != nil {
55 t.Fatal(err)
56 }
57 t.Cleanup(ctrl.Close)
58 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
59 // Service-backed controllers park their runtime in the idle cache after
60 // close, so the lifecycle fixture's writer-retire wait does not apply;
61 // these listing tests only need the HTTP surface.
62 srv := New(ctrl, NewBroadcaster(), config.ServeConfig{})
63 t.Cleanup(srv.Close)
64 recorder := httptest.NewRecorder()
65 srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil))
66 var rows []sessionListEntry
67 if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil {
68 t.Fatal(err)
69 }
70 for _, row := range rows {
71 if row.Name == "old" || agent.CanonicalSessionPath(row.Path) == agent.CanonicalSessionPath(legacy) {
72 t.Fatalf("migrated legacy row was not deduplicated: %+v", rows)
73 }
74 }
75 found := false
76 for _, row := range rows {
77 if row.SessionID == "canonical-target" {
78 found = true
79 }
80 }
81 if !found {
82 t.Fatalf("canonical target missing from sessions: %+v", rows)
83 }
84 }
85
86 func TestDeleteSessionDeletesCanonicalIdentity(t *testing.T) {
87 legacyDir := t.TempDir()
88 v4Root := filepath.Join(t.TempDir(), "sessions-v4")
89 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
90 if err != nil {
91 t.Fatal(err)
92 }
93 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
94 ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
95 current, err := ctrl.BindFreshSession(t.Context(), "current")
96 if err != nil {
97 t.Fatal(err)
98 }
99 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "canonical-target"})
100 if err != nil {
101 t.Fatal(err)
102 }
103 if err := service.Close(t.Context(), target.Ref()); err != nil {
104 t.Fatal(err)
105 }
106 t.Cleanup(ctrl.Close)
107 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
108 srv := httptest.NewServer(operatorHandler(newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{})))
109 defer srv.Close()
110 post := func(body string) int {
111 resp, err := http.Post(srv.URL+"/delete-session", "application/json", strings.NewReader(body))
112 if err != nil {
113 t.Fatal(err)
114 }
115 defer resp.Body.Close()
116 return resp.StatusCode
117 }
118 if got := post(`{"name":"` + current.SessionID + `","sessionId":"` + current.SessionID + `"}`); got != http.StatusConflict {
119 t.Fatalf("active canonical delete status = %d, want 409", got)
120 }
121 if got := post(`{"name":"canonical-target","sessionId":"canonical-target"}`); got != http.StatusNoContent {
122 t.Fatalf("canonical delete status = %d, want 204", got)
123 }
124 if _, err := os.Stat(filepath.Join(v4Root, "canonical-target")); !os.IsNotExist(err) {
125 t.Fatalf("canonical session still exists or stat failed unexpectedly: %v", err)
126 }
127 }
128
129 func newExclusiveSessionServe(t *testing.T) (*Server, *control.Controller, *session.Service, session.SessionRef) {
130 t.Helper()
131 return newExclusiveSessionServeWithOptions(t, nil)
132 }
133
134 func TestExclusiveSessionPermissionPresetDoesNotFollowTheForegroundController(t *testing.T) {
135 srv, ctrl, service, first := newExclusiveSessionServe(t)
136 httpServer := httptest.NewServer(operatorHandler(srv))
137 defer httpServer.Close()
138 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"})
139 if err != nil {
140 t.Fatal(err)
141 }
142 if _, err := target.Session().Flush(t.Context()); err != nil {
143 t.Fatal(err)
144 }
145 if err := service.Close(t.Context(), target.Ref()); err != nil {
146 t.Fatal(err)
147 }
148 set := func(preset string) {
149 t.Helper()
150 resp, err := http.Post(httpServer.URL+"/composer-profile", "application/json", strings.NewReader(`{"collaborationMode":"normal","toolApprovalMode":"`+preset+`","goal":""}`))
151 if err != nil {
152 t.Fatal(err)
153 }
154 defer resp.Body.Close()
155 if resp.StatusCode != http.StatusOK {
156 t.Fatalf("set %s: %d", preset, resp.StatusCode)
157 }
158 }
159 open := func(id, want string) {
160 t.Helper()
161 resp, err := http.Post(httpServer.URL+"/resume", "application/json", strings.NewReader(`{"hostId":"serve-test","sessionId":"`+id+`"}`))
162 if err != nil {
163 t.Fatal(err)
164 }
165 defer resp.Body.Close()
166 if resp.StatusCode != http.StatusNoContent {
167 t.Fatalf("resume %s: %d", id, resp.StatusCode)
168 }
169 if got := ctrl.PermissionSnapshot(); got.SessionID != id || got.Preset != want {
170 t.Fatalf("resume %s: snapshot = %+v, want preset %s", id, got, want)
171 }
172 }
173 set(control.ToolApprovalDangerFullAccess)
174 open("target", control.ToolApprovalWorkspaceWrite)
175 set(control.ToolApprovalReadOnly)
176 open(first.SessionID, control.ToolApprovalDangerFullAccess)
177 open("target", control.ToolApprovalReadOnly)
178 }
179
180 func TestExclusiveSessionPermissionPresetRoutesRememberTheSelectedSession(t *testing.T) {
181 routes := []struct {
182 name string
183 path string
184 body string
185 status int
186 serve func(*Server, http.ResponseWriter, *http.Request)
187 }{
188 {"preset", "/permission/preset", `{"preset":"workspace-write","expectedRevision":2}`, http.StatusOK, func(s *Server, w http.ResponseWriter, r *http.Request) {
189 s.foregroundMutation(s.permissionPreset)(w, r)
190 }},
191 {"legacy mode", "/tool-approval-mode", `{"mode":"workspace-write"}`, http.StatusNoContent, func(s *Server, w http.ResponseWriter, r *http.Request) {
192 s.foregroundMutation(s.toolApprovalMode)(w, r)
193 }},
194 {"legacy auto", "/auto-approve-tools", `{"on":false}`, http.StatusNoContent, func(s *Server, w http.ResponseWriter, r *http.Request) {
195 s.foregroundMutation(s.autoApproveTools)(w, r)
196 }},
197 }
198 for _, route := range routes {
199 t.Run(route.name, func(t *testing.T) {
200 srv, ctrl, service, first := newExclusiveSessionServe(t)
201 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"})
202 if err != nil {
203 t.Fatal(err)
204 }
205 if _, err := target.Session().Flush(t.Context()); err != nil {
206 t.Fatal(err)
207 }
208 if err := service.Close(t.Context(), target.Ref()); err != nil {
209 t.Fatal(err)
210 }
211 set := httptest.NewRecorder()
212 srv.composerProfile(set, httptest.NewRequest(http.MethodPost, "/composer-profile", strings.NewReader(`{"collaborationMode":"normal","toolApprovalMode":"danger-full-access","goal":""}`)))
213 if set.Code != http.StatusOK {
214 t.Fatalf("set first: %d %s", set.Code, set.Body.String())
215 }
216 resume := func(id string) {
217 t.Helper()
218 w := httptest.NewRecorder()
219 srv.resume(w, httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"hostId":"serve-test","sessionId":"`+id+`"}`)))
220 if w.Code != http.StatusNoContent {
221 t.Fatalf("resume %s: %d %s", id, w.Code, w.Body.String())
222 }
223 }
224 resume("target")
225 before := ctrl.PermissionSnapshot()
226 body := route.body
227 if route.name == "preset" {
228 body = `{"preset":"workspace-write","expectedRevision":` + fmt.Sprint(ctrl.PermissionSnapshot().Revision) + `}`
229 }
230 req := httptest.NewRequest(http.MethodPost, route.path, strings.NewReader(body))
231 req.Header.Set(expectedSessionIDHeader, "target")
232 w := httptest.NewRecorder()
233 route.serve(srv, w, req)
234 if w.Code != route.status {
235 t.Fatalf("set target: %d %s", w.Code, w.Body.String())
236 }
237 if route.name == "preset" && ctrl.PermissionSnapshot().Revision <= before.Revision {
238 t.Fatal("an explicit same-value preset choice did not invalidate the prior revision")
239 }
240 resume(first.SessionID)
241 if got := ctrl.PermissionSnapshot().Preset; got != control.ToolApprovalDangerFullAccess {
242 t.Fatalf("first preset = %q", got)
243 }
244 if route.name == "preset" {
245 stale := httptest.NewRequest(http.MethodPost, route.path, strings.NewReader(`{"preset":"danger-full-access","expectedRevision":`+fmt.Sprint(ctrl.PermissionSnapshot().Revision)+`}`))
246 stale.Header.Set(expectedSessionIDHeader, "target")
247 refused := httptest.NewRecorder()
248 route.serve(srv, refused, stale)
249 if refused.Code != http.StatusConflict {
250 t.Fatalf("stale target update status = %d, want conflict", refused.Code)
251 }
252 if got := ctrl.PermissionSnapshot().Preset; got != control.ToolApprovalDangerFullAccess {
253 t.Fatalf("stale target update changed first preset to %q", got)
254 }
255 }
256 resume("target")
257 if got := ctrl.PermissionSnapshot().Preset; got != control.ToolApprovalWorkspaceWrite {
258 t.Fatalf("target preset = %q", got)
259 }
260 })
261 }
262 }
263
264 // newExclusiveSessionServeWithOptions lets a test shape the foreground
265 // controller (for example install a blocking Runner so a turn can be held open)
266 // before the exclusive identity is bound.
267 func newExclusiveSessionServeWithOptions(t *testing.T, configure func(*control.Options)) (*Server, *control.Controller, *session.Service, session.SessionRef) {
268 t.Helper()
269 root := filepath.Join(t.TempDir(), "sessions-v4")
270 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(root))
271 if err != nil {
272 t.Fatal(err)
273 }
274 t.Cleanup(func() {
275 if err := service.Shutdown(context.Background()); err != nil {
276 t.Errorf("shutdown session service: %v", err)
277 }
278 })
279 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
280 opts := control.Options{
281 Executor: exec, SessionDir: t.TempDir(), SessionService: service, ExclusiveSession: true,
282 }
283 if configure != nil {
284 configure(&opts)
285 }
286 ctrl := control.New(opts)
287 ref, err := ctrl.BindFreshSession(t.Context(), "current")
288 if err != nil {
289 t.Fatal(err)
290 }
291 t.Cleanup(ctrl.Close)
292 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
293 bc := NewBroadcaster()
294 srv := New(ctrl, bc, config.ServeConfig{})
295 // Production serve hosts register a frame tag per controller; the takeover
296 // tests assert its identity, so wire the sink the CLI server would.
297 tag := newSessionTagSink(bc)
298 if id, bound := ctrl.SessionRef(); bound {
299 tag.SetIdentity("", id.SessionID)
300 }
301 srv.RegisterSessionTag(ctrl, tag)
302 return srv, ctrl, service, ref
303 }
304
305 func TestExclusiveV3SessionsAndResumeUseImmutableIdentity(t *testing.T) {
306 srv, ctrl, service, current := newExclusiveSessionServe(t)
307 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "target"})
308 if err != nil {
309 t.Fatal(err)
310 }
311 if _, err := target.Session().Flush(t.Context()); err != nil {
312 t.Fatal(err)
313 }
314 if err := service.Close(t.Context(), target.Ref()); err != nil {
315 t.Fatal(err)
316 }
317
318 list := httptest.NewRecorder()
319 srv.sessions(list, httptest.NewRequest(http.MethodGet, "/sessions", nil))
320 var rows []sessionListEntry
321 if err := json.Unmarshal(list.Body.Bytes(), &rows); err != nil {
322 t.Fatal(err)
323 }
324 foundCurrent, foundTarget := false, false
325 for _, row := range rows {
326 if row.SessionID == current.SessionID && row.HostID == current.HostID && row.Current && row.Path == "" {
327 foundCurrent = true
328 }
329 if row.SessionID == "target" && row.HostID == current.HostID && row.Path == "" {
330 foundTarget = true
331 }
332 }
333 if !foundCurrent || !foundTarget {
334 t.Fatalf("v3 rows = %+v", rows)
335 }
336
337 resume := httptest.NewRecorder()
338 req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"hostId":"serve-test","sessionId":"target"}`))
339 srv.resume(resume, req)
340 if resume.Code != http.StatusNoContent {
341 t.Fatalf("resume status = %d: %s", resume.Code, resume.Body.String())
342 }
343 if got := resume.Header().Get(sessionIDHeader); got != "target" {
344 t.Fatalf("resume session id = %q", got)
345 }
346 if got, ok := ctrl.SessionRef(); !ok || got.SessionID != "target" || ctrl.SessionPath() != "" {
347 t.Fatalf("controller identity = %+v, bound=%v path=%q", got, ok, ctrl.SessionPath())
348 }
349 }
350
351 func TestSessionsReportsFinalizingExclusiveRuntimeAsRunning(t *testing.T) {
352 srv, ctrl, service, ref := newExclusiveSessionServe(t)
353 runtime, ok := service.Runtime(ref)
354 if !ok {
355 t.Fatal("current runtime is not published")
356 }
357 generation := ctrl.ExecutionGeneration()
358 runtime.NoteExecution(generation, session.RuntimeFinalizing, "terminal_commit")
359 defer runtime.NoteExecution(generation, session.RuntimeIdle, "")
360
361 list := httptest.NewRecorder()
362 srv.sessions(list, httptest.NewRequest(http.MethodGet, "/sessions", nil))
363 var rows []sessionListEntry
364 if err := json.Unmarshal(list.Body.Bytes(), &rows); err != nil {
365 t.Fatal(err)
366 }
367 for _, row := range rows {
368 if row.SessionID == ref.SessionID {
369 if !row.Running {
370 t.Fatalf("finalizing session row = %+v, want running", row)
371 }
372 return
373 }
374 }
375 t.Fatalf("session %q missing from rows %+v", ref.SessionID, rows)
376 }
377
378 func TestExclusiveV3MissingResumeDoesNotCreateOrReplaceCurrent(t *testing.T) {
379 srv, ctrl, service, current := newExclusiveSessionServe(t)
380 resume := httptest.NewRecorder()
381 req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"hostId":"serve-test","sessionId":"missing"}`))
382 srv.resume(resume, req)
383 if resume.Code != http.StatusConflict {
384 t.Fatalf("resume status = %d, want 409", resume.Code)
385 }
386 if got, ok := ctrl.SessionRef(); !ok || got != current {
387 t.Fatalf("current identity changed to %+v, bound=%v", got, ok)
388 }
389 if _, err := service.Query().Snapshot(t.Context(), session.SessionRef{HostID: "serve-test", SessionID: "missing"}); err == nil {
390 t.Fatal("missing Open created a session")
391 }
392 }
393
394 func TestExclusiveV3ResumeNameFallbackUsesCanonicalIdentity(t *testing.T) {
395 srv, ctrl, service, current := newExclusiveSessionServe(t)
396 target, err := service.Create(t.Context(), session.CreateOptions{SessionID: "named-target"})
397 if err != nil {
398 t.Fatal(err)
399 }
400 if err := service.Close(t.Context(), target.Ref()); err != nil {
401 t.Fatal(err)
402 }
403
404 resume := httptest.NewRecorder()
405 req := httptest.NewRequest(http.MethodPost, "/resume", strings.NewReader(`{"name":"named-target"}`))
406 srv.resume(resume, req)
407 if resume.Code != http.StatusNoContent {
408 t.Fatalf("name fallback status = %d: %s", resume.Code, resume.Body.String())
409 }
410 if got := resume.Header().Get(sessionIDHeader); got != "named-target" {
411 t.Fatalf("name fallback session id = %q", got)
412 }
413 if got, ok := ctrl.SessionRef(); !ok || got.HostID != current.HostID || got.SessionID != "named-target" {
414 t.Fatalf("controller identity after name fallback = %+v, bound=%v", got, ok)
415 }
416 }
417
418 func TestExclusiveV3RotationAndMutationFenceReturnSessionID(t *testing.T) {
419 srv, ctrl, _, current := newExclusiveSessionServe(t)
420 stale := httptest.NewRequest(http.MethodPost, "/cancel", nil)
421 stale.Header.Set(expectedSessionIDHeader, "stale")
422 if err := srv.expectedSessionErrorLocked(stale); err == nil {
423 t.Fatal("stale immutable identity passed mutation fence")
424 }
425 matching := httptest.NewRequest(http.MethodPost, "/cancel", nil)
426 matching.Header.Set(expectedSessionIDHeader, current.SessionID)
427 if err := srv.expectedSessionErrorLocked(matching); err != nil {
428 t.Fatalf("matching identity rejected: %v", err)
429 }
430
431 rotate := httptest.NewRecorder()
432 srv.newSession(rotate, httptest.NewRequest(http.MethodPost, "/new", nil))
433 if rotate.Code != http.StatusNoContent {
434 t.Fatalf("new status = %d: %s", rotate.Code, rotate.Body.String())
435 }
436 ref, ok := ctrl.SessionRef()
437 if !ok || ref.SessionID == current.SessionID || ref.SessionID == "" {
438 t.Fatalf("rotated identity = %+v, bound=%v", ref, ok)
439 }
440 if got := rotate.Header().Get(sessionIDHeader); got != ref.SessionID {
441 t.Fatalf("new response session id = %q, want %q", got, ref.SessionID)
442 }
443 if got := rotate.Header().Get(sessionPathHeader); got != "" {
444 t.Fatalf("exclusive rotation exposed legacy path %q", got)
445 }
446 }
447
448 // The persistence list caps one page at 100 rows ordered by the random
449 // session id, so a workspace with more canonical sessions hid an arbitrary
450 // subset — including a session another runtime had just taken over. The
451 // handler must follow NextCursor and surface every row in one response.
452 func TestSessionsListsBeyondFirstHundredCanonicalSessions(t *testing.T) {
453 v4Root := filepath.Join(robustTempDir(t), "sessions-v4")
454 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
455 if err != nil {
456 t.Fatal(err)
457 }
458 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
459 ctrl := control.New(control.Options{Executor: exec, SessionDir: robustTempDir(t), SessionService: service, ExclusiveSession: true})
460 const total = 103
461 for i := range total {
462 created, err := service.Create(t.Context(), session.CreateOptions{SessionID: fmt.Sprintf("s%03d", i)})
463 if err != nil {
464 t.Fatal(err)
465 }
466 if err := service.Close(t.Context(), created.Ref()); err != nil {
467 t.Fatal(err)
468 }
469 }
470 // A catalog full of pending metadata keeps the query's background rebuild
471 // queue busy, which races the lifecycle fixture's writer-retire check.
472 // Drive the queue to quiescence before the handler under test runs.
473 deadline := time.Now().Add(30 * time.Second)
474 for time.Now().Before(deadline) {
475 page, err := service.Query().List(t.Context(), "", 100)
476 if err != nil {
477 t.Fatal(err)
478 }
479 pending := 0
480 for _, info := range page.Sessions {
481 if info.MetadataStatus != session.MetadataReady {
482 pending++
483 }
484 }
485 if pending == 0 {
486 break
487 }
488 time.Sleep(50 * time.Millisecond)
489 }
490 t.Cleanup(ctrl.Close)
491 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
492 // Service-backed controllers park their runtime in the idle cache after
493 // close, so the lifecycle fixture's writer-retire wait does not apply;
494 // these listing tests only need the HTTP surface.
495 srv := New(ctrl, NewBroadcaster(), config.ServeConfig{})
496 t.Cleanup(srv.Close)
497 recorder := httptest.NewRecorder()
498 srv.sessions(recorder, httptest.NewRequest(http.MethodGet, "/sessions", nil))
499 var rows []sessionListEntry
500 if err := json.Unmarshal(recorder.Body.Bytes(), &rows); err != nil {
501 t.Fatal(err)
502 }
503 seen := make(map[string]bool, len(rows))
504 for _, row := range rows {
505 seen[row.SessionID] = true
506 }
507 for i := range total {
508 if !seen[fmt.Sprintf("s%03d", i)] {
509 t.Fatalf("session s%03d missing from the list (%d rows returned)", i, len(rows))
510 }
511 }
512 }
513
514 // migratedDeleteFixture hosts an exclusive foreground beside a legacy session
515 // dir and a v4 root so tests can stage migration maps and drive
516 // /delete-session against canonical rows.
517 type migratedDeleteFixture struct {
518 t *testing.T
519 srv *Server
520 url string
521 legacyDir string
522 v4Root string
523 service *session.Service
524 }
525
526 func newMigratedDeleteFixture(t *testing.T, canonicalIDs ...string) *migratedDeleteFixture {
527 t.Helper()
528 legacyDir := t.TempDir()
529 v4Root := filepath.Join(t.TempDir(), "sessions-v4")
530 service, err := session.NewService("serve-test", session.NewFilesystemPersistence(v4Root))
531 if err != nil {
532 t.Fatal(err)
533 }
534 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
535 ctrl := control.New(control.Options{Executor: exec, SessionDir: legacyDir, SessionService: service, ExclusiveSession: true})
536 if _, err := ctrl.BindFreshSession(t.Context(), "current"); err != nil {
537 t.Fatal(err)
538 }
539 for _, id := range canonicalIDs {
540 created, err := service.Create(t.Context(), session.CreateOptions{SessionID: id})
541 if err != nil {
542 t.Fatal(err)
543 }
544 if err := service.Close(t.Context(), created.Ref()); err != nil {
545 t.Fatal(err)
546 }
547 }
548 t.Cleanup(ctrl.Close)
549 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
550 srv := newLifecycleTestServer(t, ctrl, NewBroadcaster(), config.ServeConfig{})
551 ts := httptest.NewServer(operatorHandler(srv))
552 t.Cleanup(ts.Close)
553 return &migratedDeleteFixture{t: t, srv: srv, url: ts.URL, legacyDir: legacyDir, v4Root: v4Root, service: service}
554 }
555
556 // writeLegacy stages a legacy transcript plus one sidecar so a removal can be
557 // checked against the whole legacy file set, not just the .jsonl.
558 func (f *migratedDeleteFixture) writeLegacy(dir, name string) string {
559 f.t.Helper()
560 path := filepath.Join(dir, name+".jsonl")
561 if err := os.WriteFile(path, []byte(`{"role":"user","content":"old"}`+"\n"), 0o600); err != nil {
562 f.t.Fatal(err)
563 }
564 if err := os.WriteFile(store.SessionMeta(path), []byte(`{}`), 0o600); err != nil {
565 f.t.Fatal(err)
566 }
567 return path
568 }
569
570 func (f *migratedDeleteFixture) writeMigrationMap(entries ...session.MigrationEntry) {
571 f.t.Helper()
572 data, err := json.Marshal(session.MigrationMapping{SchemaVersion: session.SchemaVersion, Entries: entries})
573 if err != nil {
574 f.t.Fatal(err)
575 }
576 if err := os.WriteFile(filepath.Join(f.v4Root, "migration-map.json"), data, 0o600); err != nil {
577 f.t.Fatal(err)
578 }
579 }
580
581 func (f *migratedDeleteFixture) deleteCanonical(id string) int {
582 f.t.Helper()
583 resp, err := http.Post(f.url+"/delete-session", "application/json", strings.NewReader(`{"name":"`+id+`","sessionId":"`+id+`"}`))
584 if err != nil {
585 f.t.Fatal(err)
586 }
587 defer resp.Body.Close()
588 return resp.StatusCode
589 }
590
591 func (f *migratedDeleteFixture) canonicalExists(id string) bool {
592 _, err := os.Stat(filepath.Join(f.v4Root, id))
593 return err == nil
594 }
595
596 func fileExists(t *testing.T, path string) bool {
597 t.Helper()
598 _, err := os.Stat(path)
599 if err != nil && !os.IsNotExist(err) {
600 t.Fatalf("stat %s: %v", path, err)
601 }
602 return err == nil
603 }
604
605 // One legacy head can migrate into several canonical sessions; the listing
606 // keeps such a source visible as its own row. Deleting one of those targets
607 // must therefore leave the source (and the sibling target) untouched, or the
608 // delete removes a transcript the user still sees as a distinct session.
609 func TestDeleteCanonicalSessionKeepsSharedMigrationSource(t *testing.T) {
610 f := newMigratedDeleteFixture(t, "target-a", "target-b")
611 source := f.writeLegacy(f.legacyDir, "shared")
612 f.writeMigrationMap(
613 session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"},
614 session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-b"},
615 )
616 if got := f.deleteCanonical("target-a"); got != http.StatusNoContent {
617 t.Fatalf("delete status = %d, want 204", got)
618 }
619 if f.canonicalExists("target-a") {
620 t.Fatal("canonical target-a survived its delete")
621 }
622 if !f.canonicalExists("target-b") {
623 t.Fatal("sibling canonical target-b was removed")
624 }
625 if !fileExists(t, source) || !fileExists(t, store.SessionMeta(source)) {
626 t.Fatal("shared legacy source was deleted while target-b still references it")
627 }
628 }
629
630 // Once the deleted row was the source's only live target the source would
631 // resurface in /sessions as a fresh legacy row; the whole legacy file set goes
632 // with the row.
633 func TestDeleteCanonicalSessionRemovesSoleMigrationSource(t *testing.T) {
634 f := newMigratedDeleteFixture(t, "target-a")
635 source := f.writeLegacy(f.legacyDir, "sole")
636 f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"})
637 if got := f.deleteCanonical("target-a"); got != http.StatusNoContent {
638 t.Fatalf("delete status = %d, want 204", got)
639 }
640 if f.canonicalExists("target-a") {
641 t.Fatal("canonical target-a survived its delete")
642 }
643 if fileExists(t, source) || fileExists(t, store.SessionMeta(source)) {
644 t.Fatal("sole-target legacy source (or its sidecar) survived the canonical delete")
645 }
646 }
647
648 // A migration map is data, not authority: a recorded source outside the
649 // session dir is never removed, while the canonical delete itself still lands.
650 func TestDeleteCanonicalSessionLeavesMigrationSourceOutsideSessionDir(t *testing.T) {
651 f := newMigratedDeleteFixture(t, "target-a")
652 outside := f.writeLegacy(t.TempDir(), "elsewhere")
653 f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(outside), TargetID: "target-a"})
654 if got := f.deleteCanonical("target-a"); got != http.StatusNoContent {
655 t.Fatalf("delete status = %d, want 204", got)
656 }
657 if f.canonicalExists("target-a") {
658 t.Fatal("canonical target-a survived its delete")
659 }
660 if !fileExists(t, outside) || !fileExists(t, store.SessionMeta(outside)) {
661 t.Fatal("transcript outside the session dir was removed on the strength of a migration map")
662 }
663 }
664
665 // The source obeys the same ownership gate as a named legacy delete. A source a
666 // local window is writing refuses the request before anything is removed, so
667 // the row is not deleted only to resurrect its source.
668 func TestDeleteCanonicalSessionRefusesMirroredMigrationSource(t *testing.T) {
669 f := newMigratedDeleteFixture(t, "target-a")
670 source := f.writeLegacy(f.legacyDir, "mirrored")
671 f.writeMigrationMap(session.MigrationEntry{SourcePath: agent.CanonicalSessionPath(source), TargetID: "target-a"})
672 f.srv.markMirrored(mirroredSession{path: source, mirrorID: "local-writer", phase: mirrorPhaseExternal})
673 if got := f.deleteCanonical("target-a"); got != http.StatusConflict {
674 t.Fatalf("delete status = %d, want 409", got)
675 }
676 if !f.canonicalExists("target-a") {
677 t.Fatal("canonical row was deleted although its mirrored source refused")
678 }
679 if !fileExists(t, source) {
680 t.Fatal("mirrored legacy source was removed")
681 }
682 }
683
683 lines GO