Files
multica/server/internal/handler/terminal_session_response_test.go
Jiayuan Zhang 0529d28133 feat(terminal): Phase 4 — GC hook, audit log, issue runs entry (MUL-2295)
- terminal.Manager: OnSessionStart/OnSessionStop hooks fire around the
  manager's register/deregister so callers can safely mark/unmark
  external state (GC protection, audit rows) without racing Get/Done.
- daemon: wires the hooks to markActiveEnvRoot(filepath.Dir(workDir))
  so an idle terminal on a done/cancelled issue can't have its workdir
  reclaimed mid-session, and emits structured slog audit records
  (user_id/task_id/duration; no keystrokes — RFC §Auth).
- server: persists every PTY open/close to a new terminal_sessions
  table (migration 091) as the source behind the audit log and the
  new `type=terminal` rows in `multica issue runs`. New endpoint
  GET /api/issues/{id}/terminal-sessions.
- CLI: `multica issue runs` merges the agent task runs feed with the
  terminal sessions feed, sorted by started_at; terminal rows render
  with agent="terminal" and close_reason in the ERROR column. Old
  servers without the endpoint degrade silently.
- server/handler/terminal_ws.go: pass parsed Cols/Rows query values
  to sendOpenToDaemon so the first PTY frame matches the client's
  viewport; post-open resize is now just a defensive patch (addresses
  Emacs's Phase 3 non-blocking nit).

Co-authored-by: multica-agent <github@multica.ai>
2026-05-16 18:55:20 +08:00

85 lines
3.2 KiB
Go

package handler
import (
"testing"
"time"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/util"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// TestTerminalSessionToResponse_Active validates the shape returned for a
// session whose ended_at is still NULL — the CLI's `issue runs` merge
// expects status="active" and a nil ended_at pointer (not the zero
// timestamp string, which would render as a real completion in the
// table).
func TestTerminalSessionToResponse_Active(t *testing.T) {
started := time.Date(2026, 5, 16, 10, 30, 0, 0, time.UTC)
s := db.TerminalSession{
ID: util.MustParseUUID("11111111-1111-1111-1111-111111111111"),
WorkspaceID: util.MustParseUUID("22222222-2222-2222-2222-222222222222"),
IssueID: util.MustParseUUID("33333333-3333-3333-3333-333333333333"),
TaskID: util.MustParseUUID("44444444-4444-4444-4444-444444444444"),
UserID: util.MustParseUUID("55555555-5555-5555-5555-555555555555"),
WorkDir: "/tmp/ws/task/workdir",
Shell: "/bin/bash",
StartedAt: pgtype.Timestamptz{Time: started, Valid: true},
}
resp := terminalSessionToResponse(s)
if resp.Status != "active" {
t.Errorf("Status = %q, want active", resp.Status)
}
if resp.Kind != "terminal" {
t.Errorf("Kind = %q, want terminal", resp.Kind)
}
if resp.EndedAt != nil {
t.Errorf("EndedAt = %v, want nil", *resp.EndedAt)
}
if resp.ExitCode != nil {
t.Errorf("ExitCode = %v, want nil", *resp.ExitCode)
}
if resp.StartedAt != started.Format(time.RFC3339) {
t.Errorf("StartedAt = %q, want %q", resp.StartedAt, started.Format(time.RFC3339))
}
}
// TestTerminalSessionToResponse_Closed validates the closed-session shape:
// status flips to "closed", ended_at is a populated pointer, exit_code
// surfaces the signed int. close_reason rides through verbatim so the CLI
// can display it in the ERROR column for terminal rows.
func TestTerminalSessionToResponse_Closed(t *testing.T) {
started := time.Date(2026, 5, 16, 10, 30, 0, 0, time.UTC)
ended := started.Add(15 * time.Minute)
s := db.TerminalSession{
ID: util.MustParseUUID("11111111-1111-1111-1111-111111111111"),
WorkspaceID: util.MustParseUUID("22222222-2222-2222-2222-222222222222"),
IssueID: util.MustParseUUID("33333333-3333-3333-3333-333333333333"),
TaskID: util.MustParseUUID("44444444-4444-4444-4444-444444444444"),
UserID: util.MustParseUUID("55555555-5555-5555-5555-555555555555"),
WorkDir: "/tmp/ws/task/workdir",
StartedAt: pgtype.Timestamptz{Time: started, Valid: true},
EndedAt: pgtype.Timestamptz{Time: ended, Valid: true},
ExitCode: pgtype.Int4{Int32: 130, Valid: true},
CloseReason: "idle_timeout",
}
resp := terminalSessionToResponse(s)
if resp.Status != "closed" {
t.Errorf("Status = %q, want closed", resp.Status)
}
if resp.EndedAt == nil || *resp.EndedAt != ended.Format(time.RFC3339) {
t.Errorf("EndedAt = %v, want %q", resp.EndedAt, ended.Format(time.RFC3339))
}
if resp.ExitCode == nil || *resp.ExitCode != 130 {
t.Errorf("ExitCode = %v, want 130", resp.ExitCode)
}
if resp.CloseReason != "idle_timeout" {
t.Errorf("CloseReason = %q, want idle_timeout", resp.CloseReason)
}
}