okami.mom/albedo

git clone https://git.nkp.pet/okami.mom/albedo

Aly Raffauffix(cli): resume child sessions by full ID4434577

main
6.6 KiB239 linesraw
1// Package app owns daemon-backed workflows without terminal interaction or rendering.
2package app
3
4import (
5	"context"
6	"errors"
7	"fmt"
8	"net/http"
9	"path/filepath"
10	"regexp"
11	"slices"
12	"strings"
13
14	"albedo/cli/internal/daemon"
15)
16
17// Service obtains connections only when an operation needs one.
18type Service struct {
19	Connect  func(context.Context) (*daemon.Connection, error)
20	Existing func(context.Context) (*daemon.Connection, error)
21}
22type OpenOptions struct {
23	SessionID, Workspace string
24	Fresh, Terminal      bool
25}
26type PreparedOpen struct {
27	Connection    *daemon.Connection
28	Settings      daemon.Settings
29	Selected      *daemon.Session
30	Workspace     string
31	Sessions      []daemon.Session
32	LoginRequired bool
33}
34
35// absoluteWorkspace resolves a local workspace against the current
36// directory; a location on another host is the daemon's to read.
37func absoluteWorkspace(workspace string) string {
38	if host, _ := daemon.SplitLocation(workspace); host != "" {
39		return workspace
40	}
41	if abs, err := filepath.Abs(workspace); err == nil {
42		return abs
43	}
44	return workspace
45}
46
47func (s *Service) PrepareOpen(ctx context.Context, options OpenOptions) (PreparedOpen, error) {
48	id, workspace, fresh := options.SessionID, options.Workspace, options.Fresh
49	absWorkspace := absoluteWorkspace(workspace)
50
51	conn, err := s.Connect(ctx)
52	if err != nil {
53		return PreparedOpen{}, err
54	}
55
56	sessions, err := daemon.ListSessions(ctx, conn)
57	if err != nil {
58		return PreparedOpen{}, err
59	}
60
61	selectedID := ""
62	if id != "" {
63		resolved, matchErr := resolveSessionID(id, sessions)
64		if matchErr != nil {
65			return PreparedOpen{}, matchErr
66		}
67		selectedID = resolved
68	}
69
70	settings, err := daemon.GetSettings(ctx, conn)
71	if err != nil {
72		return PreparedOpen{}, err
73	}
74	configured := settings.Profiles.Active != ""
75
76	if !configured && !options.Terminal {
77		return PreparedOpen{}, errors.New("no model provider is configured; run albedo login in a terminal to set one up")
78	}
79
80	var initial *daemon.Session
81	if selectedID != "" {
82		captured, err := daemon.GetSession(ctx, conn, selectedID)
83		if err != nil {
84			return PreparedOpen{}, sessionLookupError(id, err)
85		}
86		initial = &captured
87	} else if configured && (fresh || len(sessions) == 0) {
88		created, createErr := daemon.CreateSession(ctx, conn, daemon.CreateSessionRequest{Workspace: absWorkspace})
89		if createErr != nil {
90			return PreparedOpen{}, createErr
91		}
92		initial = &created
93	}
94
95	if initial != nil {
96		if i := slices.IndexFunc(sessions, func(s daemon.Session) bool { return s.ID == initial.ID }); i >= 0 {
97			sessions[i] = *initial
98		} else {
99			sessions = append([]daemon.Session{*initial}, sessions...)
100		}
101	}
102	return PreparedOpen{Connection: conn, Settings: settings, Sessions: sessions, Selected: initial, Workspace: absWorkspace, LoginRequired: !configured}, nil
103}
104
105func (s *Service) Sessions(ctx context.Context) ([]daemon.Session, error) {
106	conn, err := s.Connect(ctx)
107	if err != nil {
108		return nil, err
109	}
110	sessions, err := daemon.ListSessions(ctx, conn)
111	if err != nil {
112		return nil, err
113	}
114	return slices.DeleteFunc(sessions, func(session daemon.Session) bool { return session.Archived }), nil
115}
116
117// resolveSessionID addresses full IDs directly and expands prefixes against roots.
118func resolveSessionID(id string, sessions []daemon.Session) (string, error) {
119	if fullSessionID.MatchString(id) {
120		return id, nil
121	}
122	selectedIndex, matchCount := -1, 0
123	for i := range sessions {
124		if sessions[i].ID == id {
125			return sessions[i].ID, nil
126		}
127		if strings.HasPrefix(sessions[i].ID, id) {
128			selectedIndex = i
129			matchCount++
130		}
131	}
132	switch matchCount {
133	case 0:
134		return "", noSessionMatch(id)
135	case 1:
136		return sessions[selectedIndex].ID, nil
137	default:
138		return "", fmt.Errorf("more than one session ID starts with %q; use a longer ID or run albedo sessions to find it", id)
139	}
140}
141
142// The UUIDv7 shape in the daemon's operation validator and OpenAPI schema.
143var fullSessionID = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`)
144
145func noSessionMatch(id string) error {
146	return fmt.Errorf("no session matches %q; run albedo sessions to see the available sessions", id)
147}
148
149func sessionLookupError(id string, err error) error {
150	if problem, ok := errors.AsType[*daemon.APIError](err); ok && problem.StatusCode == http.StatusNotFound && fullSessionID.MatchString(id) {
151		return noSessionMatch(id)
152	}
153	return err
154}
155
156// resolveSession validates full IDs directly and lists roots only for prefixes.
157func resolveSession(ctx context.Context, conn *daemon.Connection, id string) (string, error) {
158	var sessions []daemon.Session
159	if !fullSessionID.MatchString(id) {
160		var err error
161		sessions, err = daemon.ListSessions(ctx, conn)
162		if err != nil {
163			return "", err
164		}
165	}
166	resolved, err := resolveSessionID(id, sessions)
167	if err != nil {
168		return "", err
169	}
170	if fullSessionID.MatchString(id) {
171		configuration, err := daemon.GetSessionConfiguration(ctx, conn, resolved)
172		return configuration.Value.ID, sessionLookupError(id, err)
173	}
174	return resolved, nil
175}
176
177func (s *Service) Send(ctx context.Context, id, prompt string) (daemon.SendResult, error) {
178	conn, err := s.Connect(ctx)
179	if err != nil {
180		return daemon.SendResult{}, err
181	}
182	id, err = resolveSession(ctx, conn, id)
183	if err != nil {
184		return daemon.SendResult{}, err
185	}
186	return daemon.Submit(ctx, conn, id, daemon.SubmissionRequest{Content: &prompt})
187}
188
189type InterruptionResult struct {
190	Interrupted bool `json:"interrupted"`
191}
192
193func (s *Service) Stop(ctx context.Context, id string) (InterruptionResult, error) {
194	conn, err := s.Connect(ctx)
195	if err != nil {
196		return InterruptionResult{}, err
197	}
198	id, err = resolveSession(ctx, conn, id)
199	if err != nil {
200		return InterruptionResult{}, err
201	}
202	interrupted, err := daemon.InterruptSession(ctx, conn, id)
203	return InterruptionResult{Interrupted: interrupted}, err
204}
205func (s *Service) DeleteSessions(ctx context.Context, ids []string) error {
206	conn, err := s.Connect(ctx)
207	if err != nil {
208		return err
209	}
210	for _, id := range ids {
211		configuration, err := daemon.GetSessionConfiguration(ctx, conn, id)
212		if err != nil {
213			return err
214		}
215		result, err := daemon.DeleteSession(ctx, conn, id, false, daemon.SessionCondition{ETag: configuration.ETag})
216		if err != nil {
217			return err
218		}
219		if !result.OK {
220			return errors.New(result.Message)
221		}
222	}
223	return nil
224}
225func (s *Service) StopDaemon(ctx context.Context) error {
226	conn, err := s.Existing(ctx)
227	if err != nil || conn == nil {
228		return err
229	}
230	return daemon.StopDaemon(ctx, conn)
231}
232func (s *Service) PrepareLogin(ctx context.Context) (PreparedOpen, error) {
233	conn, err := s.Connect(ctx)
234	if err != nil {
235		return PreparedOpen{}, err
236	}
237	settings, err := daemon.GetSettings(ctx, conn)
238	return PreparedOpen{Connection: conn, Settings: settings}, err
239}