okami.mom/albedo

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

Aly Raffauffix(stream): restore unfinished responses on session attachmenta229862

main
5.1 KiB151 linesraw
1package daemon
2
3import (
4	"context"
5	"encoding/json"
6	"errors"
7	"io"
8	"maps"
9	"net/http"
10
11	"albedo/cli/internal/daemon/protocol"
12)
13
14const ProtocolVersion = 3
15
16type ServerInfo struct {
17	State        string
18	Protocol     int
19	InstanceID   string
20	Capabilities map[string]int64
21	Build        string
22	Digest       string
23	Notices      []ServerNotice
24}
25type ServerNotice struct{ ID, Kind, Message string }
26
27func decodeServer(body []byte) (ServerInfo, error) {
28	var server protocol.Server
29	if err := decodeRequired(body, &server); err != nil {
30		return ServerInfo{}, &ProtocolError{Code: "invalid_server", Operation: "read server", Cause: err}
31
32	}
33	if server.InstanceID == "" || server.Protocol <= 0 || server.Capabilities == nil || (server.State != "ready" && server.State != "draining") {
34		return ServerInfo{}, &ProtocolError{Code: "invalid_server", Operation: "read server", Cause: errors.New("missing daemon identity, protocol, state, or capabilities")}
35	}
36	return ServerInfo{State: server.State, Protocol: int(server.Protocol), InstanceID: server.InstanceID, Capabilities: server.Capabilities, Build: value(server.Build), Digest: value(server.Digest), Notices: noticeValues(server.Notices)}, nil
37
38}
39
40func ProbeServer(ctx context.Context, conn *Connection) (ServerInfo, error) {
41	if conn == nil {
42		return ServerInfo{}, errors.New("not connected to Albedo")
43	}
44	if ctx == nil {
45		ctx = context.Background()
46	}
47	body, err := requestBytes(ctx, conn, operation{Name: "read server", BuildRequest: func(base string, body io.Reader) (*http.Request, error) {
48		return protocol.NewGetServerRequest(base, nil)
49	}, Policy: noRecovery}, responseLimits{successStatus: 200, bodyBytes: 1048576, errorBytes: 65536})
50	if err != nil {
51		return ServerInfo{}, err
52	}
53	return decodeServer(body)
54}
55
56func CheckCompatible(server ServerInfo) error {
57	failure := &CompatibilityError{Version: server.Protocol}
58	for _, name := range []string{"durable_inputs", "session_replay", "collection_invalidation", "tool_progress"} {
59		minimum := int64(1)
60		if name == "session_replay" {
61			minimum = 2
62		}
63		if server.Capabilities[name] < minimum {
64			failure.MissingCapabilities = append(failure.MissingCapabilities, name)
65		}
66	}
67	if server.Protocol != ProtocolVersion || len(failure.MissingCapabilities) > 0 {
68		return failure
69	}
70	if server.State != "ready" {
71		return &APIError{StatusCode: 503, Code: "server_draining", Message: "the daemon is draining"}
72	}
73	return nil
74}
75
76func Attach(ctx context.Context, snapshot ConnectionSnapshot, rediscover Rediscovery) (*Connection, error) {
77	if snapshot.Port < 1 || snapshot.Port > 65535 || snapshot.Token == "" {
78		return nil, &ProtocolError{Code: "invalid_endpoint", Operation: "attach daemon", Cause: errors.New("daemon endpoint requires a valid port and token")}
79	}
80	conn := NewConnection(snapshot, rediscover)
81	server, err := ProbeServer(ctx, conn)
82	if err == nil {
83		err = CheckCompatible(server)
84	}
85	if err != nil {
86		conn.HTTPClient().CloseIdleConnections()
87		return nil, err
88	}
89	snapshot.Version, snapshot.Build, snapshot.Digest, snapshot.InstanceID = server.Protocol, server.Build, server.Digest, server.InstanceID
90	conn.state.Store(&connectionState{endpoint: snapshot, capabilities: maps.Clone(server.Capabilities)})
91
92	return conn, nil
93}
94
95func StopDaemon(ctx context.Context, conn *Connection) error {
96	instanceID := conn.Snapshot().InstanceID
97	if instanceID == "" {
98		server, err := ProbeServer(ctx, conn)
99		if err != nil {
100			return err
101		}
102		instanceID = server.InstanceID
103	}
104	return executeMutation(ctx, conn, operation{Name: "stop daemon", BuildRequest: func(base string, body io.Reader) (*http.Request, error) {
105		return protocol.NewShutdownServerRequestWithBody(base, "application/json", body)
106	}, Body: protocol.ShutdownRequest{InstanceID: instanceID}, Policy: noRecovery}, []int{202}, func(data []byte, _ int) error {
107		var reply protocol.Shutdown
108		if err := json.Unmarshal(data, &reply); err != nil {
109			return err
110		}
111		if reply.InstanceID != instanceID || reply.State != "draining" {
112			return fieldError("shutdown")
113		}
114		return nil
115	})
116}
117
118func noticeValues(rows []struct {
119	ID      string `json:"id"`
120	Kind    string `json:"kind"`
121	Message string `json:"message"`
122}) []ServerNotice {
123	result := []ServerNotice{}
124	for _, row := range rows {
125		result = append(result, ServerNotice{ID: row.ID, Kind: row.Kind, Message: row.Message})
126	}
127	return result
128}
129func DismissServerNotices(ctx context.Context, conn *Connection, etag string, ids []string) error {
130	var change protocol.UISettingsChange
131	err := patchSettingsGroup(ctx, conn, "ui", etag, map[string]any{"dismissed_notices": ids}, &change)
132	return err
133}
134
135// BuildIdentity identifies daemon code by its label and content digest.
136type BuildIdentity struct {
137	Build  string
138	Digest string
139}
140
141// BuildMismatch offers a restart unless matching digests, or matching labels
142// when digests are unavailable, establish that the code is the same.
143func BuildMismatch(running, selected BuildIdentity) bool {
144	if running.Digest != "" && selected.Digest != "" {
145		return running.Digest != selected.Digest
146	}
147	if running.Build != "" && selected.Build != "" {
148		return running.Build != selected.Build
149	}
150	return true
151}