feat(remote): expose versioned session catalog
Signed-off-by: brice <b@bnema.dev> Co-authored-by: Cursor <cursoragent@cursor.com> Signed-off-by: brice <b@bnema.dev>
- commit
- 097f96e216b8df47ec488996ae6fe766f8eb11b9
- author
- brice · · 2026-07-29 21:36 UTC
- committer
- brice · · 2026-07-30 09:15 UTC
- parent
- d94ca5a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +198 -1
M internal/ports/remote.go +16 -0
| @@ -28,3 +28,19 @@ type RemoteHostStore interface { | |||
| 28 | 28 | // Remove deletes target from both pinned and learned lists atomically. | |
| 29 | 29 | Remove(target string) error | |
| 30 | 30 | } | |
| 31 | + | ||
| 32 | + | // RemoteCatalogSession is one live session in the remote discovery catalog. | |
| 33 | + | // State is an explicit string contract (running|stopped|broken), not SessionState. | |
| 34 | + | type RemoteCatalogSession struct { | |
| 35 | + | Name string `json:"name"` | |
| 36 | + | State string `json:"state"` | |
| 37 | + | Ephemeral bool `json:"ephemeral"` | |
| 38 | + | Tabs uint16 `json:"tabs"` | |
| 39 | + | Attached bool `json:"attached"` | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json. | |
| 43 | + | type RemoteCatalog struct { | |
| 44 | + | ProtocolVersion uint16 `json:"protocol_version"` | |
| 45 | + | Sessions []RemoteCatalogSession `json:"sessions"` | |
| 46 | + | } | |
| @@ -28,3 +28,19 @@ type RemoteHostStore interface { | |||||
| 28 | // Remove deletes target from both pinned and learned lists atomically. | 28 | // Remove deletes target from both pinned and learned lists atomically. | ||
| 29 | Remove(target string) error | 29 | Remove(target string) error | ||
| 30 | } | 30 | } | ||
| 31 | + | ||||
| 32 | + | // RemoteCatalogSession is one live session in the remote discovery catalog. | |||
| 33 | + | // State is an explicit string contract (running|stopped|broken), not SessionState. | |||
| 34 | + | type RemoteCatalogSession struct { | |||
| 35 | + | Name string `json:"name"` | |||
| 36 | + | State string `json:"state"` | |||
| 37 | + | Ephemeral bool `json:"ephemeral"` | |||
| 38 | + | Tabs uint16 `json:"tabs"` | |||
| 39 | + | Attached bool `json:"attached"` | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | // RemoteCatalog is the versioned JSON envelope returned by remote-catalog --json. | |||
| 43 | + | type RemoteCatalog struct { | |||
| 44 | + | ProtocolVersion uint16 `json:"protocol_version"` | |||
| 45 | + | Sessions []RemoteCatalogSession `json:"sessions"` | |||
| 46 | + | } | |||
M internal/usecase/command/command.go +1 -0
| @@ -155,6 +155,7 @@ type ControlContext interface { | |||
| 155 | 155 | ListSessions(json bool) (string, error) | |
| 156 | 156 | ListTabs(json bool) (string, error) | |
| 157 | 157 | ListPanes(json bool) (string, error) | |
| 158 | + | RemoteCatalog(json bool) (string, error) | |
| 158 | 159 | } | |
| 159 | 160 | ||
| 160 | 161 | // Command describes an executable command. | |
| @@ -155,6 +155,7 @@ type ControlContext interface { | |||||
| 155 | ListSessions(json bool) (string, error) | 155 | ListSessions(json bool) (string, error) | ||
| 156 | ListTabs(json bool) (string, error) | 156 | ListTabs(json bool) (string, error) | ||
| 157 | ListPanes(json bool) (string, error) | 157 | ListPanes(json bool) (string, error) | ||
| 158 | + | RemoteCatalog(json bool) (string, error) | |||
| 158 | } | 159 | } | ||
| 159 | 160 | ||||
| 160 | // Command describes an executable command. | 161 | // Command describes an executable command. | ||
M internal/usecase/command/registry.go +16 -0
| @@ -51,6 +51,7 @@ func Registry() []Command { | |||
| 51 | 51 | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | |
| 52 | 52 | listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }), | |
| 53 | 53 | listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }), | |
| 54 | + | remoteCatalogCommand(), | |
| 54 | 55 | sessionRecoveryCommand(), | |
| 55 | 56 | } | |
| 56 | 57 | return commands | |
| @@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command { | |||
| 139 | 140 | } | |
| 140 | 141 | } | |
| 141 | 142 | ||
| 143 | + | func remoteCatalogCommand() Command { | |
| 144 | + | return Command{ | |
| 145 | + | Slug: "remote-catalog", Name: "Remote catalog", | |
| 146 | + | Desc: "Return the versioned session catalog for remote discovery", | |
| 147 | + | Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone, | |
| 148 | + | Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | |
| 149 | + | if len(args) != 0 || !opts.JSON { | |
| 150 | + | return ControlResult{}, ErrInvalidArguments | |
| 151 | + | } | |
| 152 | + | output, err := ctx.RemoteCatalog(true) | |
| 153 | + | return ControlResult{Output: output}, err | |
| 154 | + | }, | |
| 155 | + | } | |
| 156 | + | } | |
| 157 | + | ||
| 142 | 158 | func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command { | |
| 143 | 159 | return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | |
| 144 | 160 | if len(args) != 0 { | |
| @@ -51,6 +51,7 @@ func Registry() []Command { | |||||
| 51 | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | 51 | listCommand("list-sessions", "List sessions", "List sessions with active markers", TargetNone, func(ctx ControlContext, json bool) (string, error) { return ctx.ListSessions(json) }), | ||
| 52 | listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }), | 52 | listCommand("list-tabs", "List tabs", "List tabs in the target session", TargetSession, func(ctx ControlContext, json bool) (string, error) { return ctx.ListTabs(json) }), | ||
| 53 | listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }), | 53 | listCommand("list-panes", "List panes", "List panes in the target tab", TargetTab, func(ctx ControlContext, json bool) (string, error) { return ctx.ListPanes(json) }), | ||
| 54 | + | remoteCatalogCommand(), | |||
| 54 | sessionRecoveryCommand(), | 55 | sessionRecoveryCommand(), | ||
| 55 | } | 56 | } | ||
| 56 | return commands | 57 | return commands | ||
| @@ -139,6 +140,21 @@ func sessionRecoveryCommand() Command { | |||||
| 139 | } | 140 | } | ||
| 140 | } | 141 | } | ||
| 141 | 142 | ||||
| 143 | + | func remoteCatalogCommand() Command { | |||
| 144 | + | return Command{ | |||
| 145 | + | Slug: "remote-catalog", Name: "Remote catalog", | |||
| 146 | + | Desc: "Return the versioned session catalog for remote discovery", | |||
| 147 | + | Usage: "remote-catalog --json", Scriptable: true, Target: TargetNone, | |||
| 148 | + | Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | |||
| 149 | + | if len(args) != 0 || !opts.JSON { | |||
| 150 | + | return ControlResult{}, ErrInvalidArguments | |||
| 151 | + | } | |||
| 152 | + | output, err := ctx.RemoteCatalog(true) | |||
| 153 | + | return ControlResult{Output: output}, err | |||
| 154 | + | }, | |||
| 155 | + | } | |||
| 156 | + | } | |||
| 157 | + | ||||
| 142 | func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command { | 158 | func listCommand(slug, name, desc string, target TargetKind, list func(ControlContext, bool) (string, error)) Command { | ||
| 143 | return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | 159 | return Command{Slug: slug, Name: name, Desc: desc, Usage: slug + " [--json]", Scriptable: true, Target: target, Control: func(ctx ControlContext, args []string, opts ControlOptions) (ControlResult, error) { | ||
| 144 | if len(args) != 0 { | 160 | if len(args) != 0 { | ||
M internal/usecase/command/registry_test.go +46 -1
| @@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) { | |||
| 145 | 145 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | |
| 146 | 146 | "move-pane": TargetPane, "move-tab": TargetTab, | |
| 147 | 147 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | |
| 148 | + | "remote-catalog": TargetNone, | |
| 148 | 149 | } | |
| 149 | 150 | notScriptable := []string{ | |
| 150 | 151 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | |
| @@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) { | |||
| 173 | 174 | if !cmd.PaletteVisible || cmd.Run == nil { | |
| 174 | 175 | t.Errorf("palette command is not executable and visible: %#v", cmd) | |
| 175 | 176 | } | |
| 176 | - | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" { | |
| 177 | + | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" { | |
| 177 | 178 | t.Errorf("API-only command %q is visible", cmd.Slug) | |
| 178 | 179 | } | |
| 179 | 180 | } | |
| @@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||
| 208 | 209 | } | |
| 209 | 210 | } | |
| 210 | 211 | ||
| 212 | + | func TestRemoteCatalogCommand(t *testing.T) { | |
| 213 | + | cmd, ok := BySlug("remote-catalog") | |
| 214 | + | if !ok { | |
| 215 | + | t.Fatal("BySlug(\"remote-catalog\") missing") | |
| 216 | + | } | |
| 217 | + | if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" { | |
| 218 | + | t.Fatalf("remote-catalog metadata = %#v", cmd) | |
| 219 | + | } | |
| 220 | + | if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil { | |
| 221 | + | t.Fatalf("remote-catalog visibility/target = %#v", cmd) | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | tests := []struct { | |
| 225 | + | name string | |
| 226 | + | args []string | |
| 227 | + | opts ControlOptions | |
| 228 | + | wantCall string | |
| 229 | + | wantErr error | |
| 230 | + | }{ | |
| 231 | + | {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"}, | |
| 232 | + | {name: "rejects missing json", wantErr: ErrInvalidArguments}, | |
| 233 | + | {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments}, | |
| 234 | + | {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |
| 235 | + | } | |
| 236 | + | for _, tt := range tests { | |
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |
| 238 | + | ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"} | |
| 239 | + | got, err := cmd.Control(ctx, tt.args, tt.opts) | |
| 240 | + | wantOutput := "" | |
| 241 | + | if tt.wantCall != "" { | |
| 242 | + | wantOutput = ctx.catalogOutput | |
| 243 | + | } | |
| 244 | + | if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput { | |
| 245 | + | t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr) | |
| 246 | + | } | |
| 247 | + | }) | |
| 248 | + | } | |
| 249 | + | } | |
| 250 | + | ||
| 211 | 251 | func TestControlHandlersValidateAndDelegate(t *testing.T) { | |
| 212 | 252 | tests := []struct { | |
| 213 | 253 | name string | |
| @@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||
| 257 | 297 | type controlSpy struct { | |
| 258 | 298 | call string | |
| 259 | 299 | recoveryOutput string | |
| 300 | + | catalogOutput string | |
| 260 | 301 | } | |
| 261 | 302 | ||
| 262 | 303 | func (s *controlSpy) record(call string) error { s.call = call; return nil } | |
| @@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) { | |||
| 307 | 348 | _ = s.record("list-panes:" + strconv.FormatBool(json)) | |
| 308 | 349 | return "panes", nil | |
| 309 | 350 | } | |
| 351 | + | func (s *controlSpy) RemoteCatalog(json bool) (string, error) { | |
| 352 | + | _ = s.record("remote-catalog:" + strconv.FormatBool(json)) | |
| 353 | + | return s.catalogOutput, nil | |
| 354 | + | } | |
| 310 | 355 | ||
| 311 | 356 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | |
| 312 | 357 | cmd, ok := ByCode("FLT") | |
| @@ -145,6 +145,7 @@ func TestRegistryControlMetadata(t *testing.T) { | |||||
| 145 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | 145 | "grow-pane-height": TargetPane, "shrink-pane-height": TargetPane, "equalize-panes": TargetTab, | ||
| 146 | "move-pane": TargetPane, "move-tab": TargetTab, | 146 | "move-pane": TargetPane, "move-tab": TargetTab, | ||
| 147 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | 147 | "list-sessions": TargetNone, "list-tabs": TargetSession, "list-panes": TargetTab, | ||
| 148 | + | "remote-catalog": TargetNone, | |||
| 148 | } | 149 | } | ||
| 149 | notScriptable := []string{ | 150 | notScriptable := []string{ | ||
| 150 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | 151 | "session-picker", "notifications", "visual-mode", "yank-last-notification", | ||
| @@ -173,7 +174,7 @@ func TestPaletteRegistryHidesAPIOnly(t *testing.T) { | |||||
| 173 | if !cmd.PaletteVisible || cmd.Run == nil { | 174 | if !cmd.PaletteVisible || cmd.Run == nil { | ||
| 174 | t.Errorf("palette command is not executable and visible: %#v", cmd) | 175 | t.Errorf("palette command is not executable and visible: %#v", cmd) | ||
| 175 | } | 176 | } | ||
| 176 | - | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" { | 177 | + | if cmd.Slug == "toast" || cmd.Slug == "list-sessions" || cmd.Slug == "list-tabs" || cmd.Slug == "list-panes" || cmd.Slug == "remote-catalog" { |
| 177 | t.Errorf("API-only command %q is visible", cmd.Slug) | 178 | t.Errorf("API-only command %q is visible", cmd.Slug) | ||
| 178 | } | 179 | } | ||
| 179 | } | 180 | } | ||
| @@ -208,6 +209,45 @@ func TestSessionRecoveryCommand(t *testing.T) { | |||||
| 208 | } | 209 | } | ||
| 209 | } | 210 | } | ||
| 210 | 211 | ||||
| 212 | + | func TestRemoteCatalogCommand(t *testing.T) { | |||
| 213 | + | cmd, ok := BySlug("remote-catalog") | |||
| 214 | + | if !ok { | |||
| 215 | + | t.Fatal("BySlug(\"remote-catalog\") missing") | |||
| 216 | + | } | |||
| 217 | + | if cmd.Name != "Remote catalog" || cmd.Desc == "" || cmd.Usage != "remote-catalog --json" { | |||
| 218 | + | t.Fatalf("remote-catalog metadata = %#v", cmd) | |||
| 219 | + | } | |||
| 220 | + | if !cmd.Scriptable || cmd.PaletteVisible || cmd.Target != TargetNone || cmd.Run != nil || cmd.Control == nil { | |||
| 221 | + | t.Fatalf("remote-catalog visibility/target = %#v", cmd) | |||
| 222 | + | } | |||
| 223 | + | ||||
| 224 | + | tests := []struct { | |||
| 225 | + | name string | |||
| 226 | + | args []string | |||
| 227 | + | opts ControlOptions | |||
| 228 | + | wantCall string | |||
| 229 | + | wantErr error | |||
| 230 | + | }{ | |||
| 231 | + | {name: "json delegates", opts: ControlOptions{JSON: true}, wantCall: "remote-catalog:true"}, | |||
| 232 | + | {name: "rejects missing json", wantErr: ErrInvalidArguments}, | |||
| 233 | + | {name: "rejects args", args: []string{"extra"}, opts: ControlOptions{JSON: true}, wantErr: ErrInvalidArguments}, | |||
| 234 | + | {name: "rejects args without json", args: []string{"extra"}, wantErr: ErrInvalidArguments}, | |||
| 235 | + | } | |||
| 236 | + | for _, tt := range tests { | |||
| 237 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 238 | + | ctx := &controlSpy{catalogOutput: `{"protocol_version":19,"sessions":[]}` + "\n"} | |||
| 239 | + | got, err := cmd.Control(ctx, tt.args, tt.opts) | |||
| 240 | + | wantOutput := "" | |||
| 241 | + | if tt.wantCall != "" { | |||
| 242 | + | wantOutput = ctx.catalogOutput | |||
| 243 | + | } | |||
| 244 | + | if !errors.Is(err, tt.wantErr) || ctx.call != tt.wantCall || got.Output != wantOutput { | |||
| 245 | + | t.Fatalf("Control() call/output/error = %q/%q/%v, want %q/%q/%v", ctx.call, got.Output, err, tt.wantCall, wantOutput, tt.wantErr) | |||
| 246 | + | } | |||
| 247 | + | }) | |||
| 248 | + | } | |||
| 249 | + | } | |||
| 250 | + | ||||
| 211 | func TestControlHandlersValidateAndDelegate(t *testing.T) { | 251 | func TestControlHandlersValidateAndDelegate(t *testing.T) { | ||
| 212 | tests := []struct { | 252 | tests := []struct { | ||
| 213 | name string | 253 | name string | ||
| @@ -257,6 +297,7 @@ func TestControlHandlersValidateAndDelegate(t *testing.T) { | |||||
| 257 | type controlSpy struct { | 297 | type controlSpy struct { | ||
| 258 | call string | 298 | call string | ||
| 259 | recoveryOutput string | 299 | recoveryOutput string | ||
| 300 | + | catalogOutput string | |||
| 260 | } | 301 | } | ||
| 261 | 302 | ||||
| 262 | func (s *controlSpy) record(call string) error { s.call = call; return nil } | 303 | func (s *controlSpy) record(call string) error { s.call = call; return nil } | ||
| @@ -307,6 +348,10 @@ func (s *controlSpy) ListPanes(json bool) (string, error) { | |||||
| 307 | _ = s.record("list-panes:" + strconv.FormatBool(json)) | 348 | _ = s.record("list-panes:" + strconv.FormatBool(json)) | ||
| 308 | return "panes", nil | 349 | return "panes", nil | ||
| 309 | } | 350 | } | ||
| 351 | + | func (s *controlSpy) RemoteCatalog(json bool) (string, error) { | |||
| 352 | + | _ = s.record("remote-catalog:" + strconv.FormatBool(json)) | |||
| 353 | + | return s.catalogOutput, nil | |||
| 354 | + | } | |||
| 310 | 355 | ||||
| 311 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | 356 | func TestRegistryIncludesFloatingPaneToggle(t *testing.T) { | ||
| 312 | cmd, ok := ByCode("FLT") | 357 | cmd, ok := ByCode("FLT") | ||
M internal/usecase/daemon/control.go +39 -0
| @@ -605,6 +605,45 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||
| 605 | 605 | return out.String(), nil | |
| 606 | 606 | } | |
| 607 | 607 | ||
| 608 | + | func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |
| 609 | + | if !asJSON { | |
| 610 | + | return "", command.ErrInvalidArguments | |
| 611 | + | } | |
| 612 | + | e.d.mu.Lock() | |
| 613 | + | sessions := make([]*session, 0, len(e.d.sessions)) | |
| 614 | + | for _, sess := range e.d.sessions { | |
| 615 | + | sessions = append(sessions, sess) | |
| 616 | + | } | |
| 617 | + | e.d.mu.Unlock() | |
| 618 | + | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |
| 619 | + | for _, sess := range sessions { | |
| 620 | + | snap := sess.snapshotView(viewOptions{}) | |
| 621 | + | rows = append(rows, ports.RemoteCatalogSession{ | |
| 622 | + | Name: snap.name, | |
| 623 | + | State: remoteCatalogState(ports.SessionRunning), | |
| 624 | + | Ephemeral: snap.ephemeral, | |
| 625 | + | Tabs: uint16(snap.tabCount), | |
| 626 | + | Attached: snap.attached, | |
| 627 | + | }) | |
| 628 | + | } | |
| 629 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |
| 630 | + | return marshalListing(ports.RemoteCatalog{ | |
| 631 | + | ProtocolVersion: ports.ProtocolVersion, | |
| 632 | + | Sessions: rows, | |
| 633 | + | }) | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | func remoteCatalogState(state ports.SessionState) string { | |
| 637 | + | switch state { | |
| 638 | + | case ports.SessionStopped: | |
| 639 | + | return "stopped" | |
| 640 | + | case ports.SessionBroken: | |
| 641 | + | return "broken" | |
| 642 | + | default: | |
| 643 | + | return "running" | |
| 644 | + | } | |
| 645 | + | } | |
| 646 | + | ||
| 608 | 647 | func (e controlExec) ListTabs(asJSON bool) (string, error) { | |
| 609 | 648 | type row struct { | |
| 610 | 649 | Index int `json:"index"` | |
| @@ -605,6 +605,45 @@ func (e controlExec) ListSessions(asJSON bool) (string, error) { | |||||
| 605 | return out.String(), nil | 605 | return out.String(), nil | ||
| 606 | } | 606 | } | ||
| 607 | 607 | ||||
| 608 | + | func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 609 | + | if !asJSON { | |||
| 610 | + | return "", command.ErrInvalidArguments | |||
| 611 | + | } | |||
| 612 | + | e.d.mu.Lock() | |||
| 613 | + | sessions := make([]*session, 0, len(e.d.sessions)) | |||
| 614 | + | for _, sess := range e.d.sessions { | |||
| 615 | + | sessions = append(sessions, sess) | |||
| 616 | + | } | |||
| 617 | + | e.d.mu.Unlock() | |||
| 618 | + | rows := make([]ports.RemoteCatalogSession, 0, len(sessions)) | |||
| 619 | + | for _, sess := range sessions { | |||
| 620 | + | snap := sess.snapshotView(viewOptions{}) | |||
| 621 | + | rows = append(rows, ports.RemoteCatalogSession{ | |||
| 622 | + | Name: snap.name, | |||
| 623 | + | State: remoteCatalogState(ports.SessionRunning), | |||
| 624 | + | Ephemeral: snap.ephemeral, | |||
| 625 | + | Tabs: uint16(snap.tabCount), | |||
| 626 | + | Attached: snap.attached, | |||
| 627 | + | }) | |||
| 628 | + | } | |||
| 629 | + | sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) | |||
| 630 | + | return marshalListing(ports.RemoteCatalog{ | |||
| 631 | + | ProtocolVersion: ports.ProtocolVersion, | |||
| 632 | + | Sessions: rows, | |||
| 633 | + | }) | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | func remoteCatalogState(state ports.SessionState) string { | |||
| 637 | + | switch state { | |||
| 638 | + | case ports.SessionStopped: | |||
| 639 | + | return "stopped" | |||
| 640 | + | case ports.SessionBroken: | |||
| 641 | + | return "broken" | |||
| 642 | + | default: | |||
| 643 | + | return "running" | |||
| 644 | + | } | |||
| 645 | + | } | |||
| 646 | + | ||||
| 608 | func (e controlExec) ListTabs(asJSON bool) (string, error) { | 647 | func (e controlExec) ListTabs(asJSON bool) (string, error) { | ||
| 609 | type row struct { | 648 | type row struct { | ||
| 610 | Index int `json:"index"` | 649 | Index int `json:"index"` | ||
M internal/usecase/daemon/control_test.go +80 -0
| @@ -3,6 +3,7 @@ package daemon | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | + | "strings" | |
| 6 | 7 | "sync" | |
| 7 | 8 | "testing" | |
| 8 | 9 | "time" | |
| @@ -648,6 +649,85 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 648 | 649 | require.Equal(t, true, decoded[0]["focused"]) | |
| 649 | 650 | } | |
| 650 | 651 | ||
| 652 | + | func TestRemoteCatalogJSONOutput(t *testing.T) { | |
| 653 | + | d := newTestDaemon(t, nil, stubClock{}) | |
| 654 | + | work := addControlSession(d, "work", "t_work", "p_work") | |
| 655 | + | work.ephemeral = false | |
| 656 | + | work.client = &attachedClient{} | |
| 657 | + | work.mruAt.Store(2) | |
| 658 | + | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |
| 659 | + | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |
| 660 | + | work.mu.Lock() | |
| 661 | + | work.tabs = append(work.tabs, tb) | |
| 662 | + | work.mu.Unlock() | |
| 663 | + | ||
| 664 | + | build := addControlSession(d, "build", "t_build", "p_build") | |
| 665 | + | build.ephemeral = true | |
| 666 | + | build.mruAt.Store(1) | |
| 667 | + | ||
| 668 | + | d.mu.Lock() | |
| 669 | + | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped} | |
| 670 | + | d.mu.Unlock() | |
| 671 | + | ||
| 672 | + | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| 673 | + | require.True(t, listBefore.OK, listBefore.Text) | |
| 674 | + | ||
| 675 | + | missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"}) | |
| 676 | + | require.False(t, missingJSON.OK) | |
| 677 | + | require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code) | |
| 678 | + | ||
| 679 | + | withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true}) | |
| 680 | + | require.False(t, withArgs.OK) | |
| 681 | + | require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code) | |
| 682 | + | ||
| 683 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true}) | |
| 684 | + | require.True(t, result.OK, result.Text) | |
| 685 | + | require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated") | |
| 686 | + | ||
| 687 | + | var catalog ports.RemoteCatalog | |
| 688 | + | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | |
| 689 | + | require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion) | |
| 690 | + | ||
| 691 | + | raw := map[string]json.RawMessage{} | |
| 692 | + | require.NoError(t, json.Unmarshal([]byte(result.Output), &raw)) | |
| 693 | + | require.Len(t, raw, 2) | |
| 694 | + | _, hasProtocol := raw["protocol_version"] | |
| 695 | + | _, hasSessions := raw["sessions"] | |
| 696 | + | require.True(t, hasProtocol && hasSessions) | |
| 697 | + | ||
| 698 | + | require.Equal(t, []ports.RemoteCatalogSession{ | |
| 699 | + | {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false}, | |
| 700 | + | {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true}, | |
| 701 | + | }, catalog.Sessions) | |
| 702 | + | ||
| 703 | + | for _, session := range catalog.Sessions { | |
| 704 | + | require.Equal(t, "running", session.State) | |
| 705 | + | require.NotEqual(t, "old", session.Name) | |
| 706 | + | } | |
| 707 | + | ||
| 708 | + | listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |
| 709 | + | require.True(t, listAfter.OK, listAfter.Text) | |
| 710 | + | require.Equal(t, listBefore.Output, listAfter.Output) | |
| 711 | + | require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t") | |
| 712 | + | require.NotContains(t, listAfter.Output, "old") | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | func TestRemoteCatalogStateMapping(t *testing.T) { | |
| 716 | + | tests := []struct { | |
| 717 | + | state ports.SessionState | |
| 718 | + | want string | |
| 719 | + | }{ | |
| 720 | + | {state: ports.SessionRunning, want: "running"}, | |
| 721 | + | {state: ports.SessionStopped, want: "stopped"}, | |
| 722 | + | {state: ports.SessionBroken, want: "broken"}, | |
| 723 | + | } | |
| 724 | + | for _, tt := range tests { | |
| 725 | + | t.Run(tt.want, func(t *testing.T) { | |
| 726 | + | require.Equal(t, tt.want, remoteCatalogState(tt.state)) | |
| 727 | + | }) | |
| 728 | + | } | |
| 729 | + | } | |
| 730 | + | ||
| 651 | 731 | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | |
| 652 | 732 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | |
| 653 | 733 | d := newTestDaemon(t, factory, stubClock{}) | |
| @@ -3,6 +3,7 @@ package daemon | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | + | "strings" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | "testing" | 8 | "testing" | ||
| 8 | "time" | 9 | "time" | ||
| @@ -648,6 +649,85 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||||
| 648 | require.Equal(t, true, decoded[0]["focused"]) | 649 | require.Equal(t, true, decoded[0]["focused"]) | ||
| 649 | } | 650 | } | ||
| 650 | 651 | ||||
| 652 | + | func TestRemoteCatalogJSONOutput(t *testing.T) { | |||
| 653 | + | d := newTestDaemon(t, nil, stubClock{}) | |||
| 654 | + | work := addControlSession(d, "work", "t_work", "p_work") | |||
| 655 | + | work.ephemeral = false | |||
| 656 | + | work.client = &attachedClient{} | |||
| 657 | + | work.mruAt.Store(2) | |||
| 658 | + | tb := newTabWithStableID("t_work_2", "p_work_2", newQuietPTY(), domain.Size{Cols: 80, Rows: 22}) | |||
| 659 | + | tb.ctx, tb.cancel = context.WithCancel(d.serveCtx) | |||
| 660 | + | work.mu.Lock() | |||
| 661 | + | work.tabs = append(work.tabs, tb) | |||
| 662 | + | work.mu.Unlock() | |||
| 663 | + | ||||
| 664 | + | build := addControlSession(d, "build", "t_build", "p_build") | |||
| 665 | + | build.ephemeral = true | |||
| 666 | + | build.mruAt.Store(1) | |||
| 667 | + | ||||
| 668 | + | d.mu.Lock() | |||
| 669 | + | d.stopped["old"] = stoppedSession{name: "old", cwd: "/tmp/old", createdAt: 1, state: ports.SessionStopped} | |||
| 670 | + | d.mu.Unlock() | |||
| 671 | + | ||||
| 672 | + | listBefore := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |||
| 673 | + | require.True(t, listBefore.OK, listBefore.Text) | |||
| 674 | + | ||||
| 675 | + | missingJSON := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog"}) | |||
| 676 | + | require.False(t, missingJSON.OK) | |||
| 677 | + | require.Equal(t, ports.ErrInvalidCommandArgs, missingJSON.Code) | |||
| 678 | + | ||||
| 679 | + | withArgs := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", Args: []string{"extra"}, JSON: true}) | |||
| 680 | + | require.False(t, withArgs.OK) | |||
| 681 | + | require.Equal(t, ports.ErrInvalidCommandArgs, withArgs.Code) | |||
| 682 | + | ||||
| 683 | + | result := sendCommand(t, d, ports.CommandRequest{Slug: "remote-catalog", JSON: true}) | |||
| 684 | + | require.True(t, result.OK, result.Text) | |||
| 685 | + | require.True(t, strings.HasSuffix(result.Output, "\n"), "catalog output must be newline-terminated") | |||
| 686 | + | ||||
| 687 | + | var catalog ports.RemoteCatalog | |||
| 688 | + | require.NoError(t, json.Unmarshal([]byte(result.Output), &catalog)) | |||
| 689 | + | require.Equal(t, ports.ProtocolVersion, catalog.ProtocolVersion) | |||
| 690 | + | ||||
| 691 | + | raw := map[string]json.RawMessage{} | |||
| 692 | + | require.NoError(t, json.Unmarshal([]byte(result.Output), &raw)) | |||
| 693 | + | require.Len(t, raw, 2) | |||
| 694 | + | _, hasProtocol := raw["protocol_version"] | |||
| 695 | + | _, hasSessions := raw["sessions"] | |||
| 696 | + | require.True(t, hasProtocol && hasSessions) | |||
| 697 | + | ||||
| 698 | + | require.Equal(t, []ports.RemoteCatalogSession{ | |||
| 699 | + | {Name: "build", State: "running", Ephemeral: true, Tabs: 1, Attached: false}, | |||
| 700 | + | {Name: "work", State: "running", Ephemeral: false, Tabs: 2, Attached: true}, | |||
| 701 | + | }, catalog.Sessions) | |||
| 702 | + | ||||
| 703 | + | for _, session := range catalog.Sessions { | |||
| 704 | + | require.Equal(t, "running", session.State) | |||
| 705 | + | require.NotEqual(t, "old", session.Name) | |||
| 706 | + | } | |||
| 707 | + | ||||
| 708 | + | listAfter := sendCommand(t, d, ports.CommandRequest{Slug: "list-sessions"}) | |||
| 709 | + | require.True(t, listAfter.OK, listAfter.Text) | |||
| 710 | + | require.Equal(t, listBefore.Output, listAfter.Output) | |||
| 711 | + | require.Contains(t, listAfter.Output, "work\tnamed\t2\ttrue\t") | |||
| 712 | + | require.NotContains(t, listAfter.Output, "old") | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | func TestRemoteCatalogStateMapping(t *testing.T) { | |||
| 716 | + | tests := []struct { | |||
| 717 | + | state ports.SessionState | |||
| 718 | + | want string | |||
| 719 | + | }{ | |||
| 720 | + | {state: ports.SessionRunning, want: "running"}, | |||
| 721 | + | {state: ports.SessionStopped, want: "stopped"}, | |||
| 722 | + | {state: ports.SessionBroken, want: "broken"}, | |||
| 723 | + | } | |||
| 724 | + | for _, tt := range tests { | |||
| 725 | + | t.Run(tt.want, func(t *testing.T) { | |||
| 726 | + | require.Equal(t, tt.want, remoteCatalogState(tt.state)) | |||
| 727 | + | }) | |||
| 728 | + | } | |||
| 729 | + | } | |||
| 730 | + | ||||
| 651 | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | 731 | func TestHandleCommandSerializesSelfTargetOnNonActiveTab(t *testing.T) { | ||
| 652 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | 732 | factory := &controlPTYFactory{entered: make(chan struct{}), release: make(chan struct{})} | ||
| 653 | d := newTestDaemon(t, factory, stubClock{}) | 733 | d := newTestDaemon(t, factory, stubClock{}) | ||