返回 DeepSeek-Reasonix
inbox_target.go
根目录 / desktop / inbox_target.go
1 package main
2
3 import (
4 "encoding/json"
5 "errors"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10
11 "reasonix/internal/sessioninbox"
12 )
13
14 // inboxTransientCode is the bridge code for a transient inbox-target fence;
15 // the renderer keeps the message queued and retries it automatically.
16 const inboxTransientCode = "inbox_target_transient"
17
18 // errInboxTargetChanged is the transient identity/route fence: the tab is
19 // switching, reconnecting or re-aiming, and the same call succeeds once it
20 // settles. Boundaries classify it as inbox_target_transient.
21 var errInboxTargetChanged = errors.New("inbox target changed")
22
23 // InboxTargetView fences follow-ups across both local replacement and remote
24 // selection changes. It is process-local and never written to the inbox ledger.
25 type InboxTargetView struct {
26 TabID string `json:"tabId"`
27 SessionPath string `json:"sessionPath"`
28 Generation uint64 `json:"generation"`
29 Selection uint64 `json:"selection"`
30 Remote bool `json:"remote"`
31 HostID string `json:"hostId,omitempty"`
32 Workspace string `json:"workspace,omitempty"`
33 }
34
35 // Callers hold App.mu. Canonical tabs deliberately have no legacy file path;
36 // read their owned identity without calling back into a controller under the lock.
37 func inboxTabIdentity(tab *WorkspaceTab) string {
38 if id := strings.TrimSpace(tab.SessionID); id != "" {
39 return sessionRoute(id)
40 }
41 return tab.SessionPath
42 }
43
44 func (a *App) CaptureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) {
45 a.runtimeAdmissionMu.RLock()
46 defer a.runtimeAdmissionMu.RUnlock()
47 target, err := a.captureInboxTarget(tabID, expectedPath)
48 if errors.Is(err, errInboxTargetChanged) {
49 return InboxTargetView{}, inboxTargetTransient(err)
50 }
51 return target, err
52 }
53
54 func (a *App) captureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) {
55 a.remoteTabMu.Lock()
56 remote := a.remoteTabs[tabID]
57 if remote != nil {
58 defer a.remoteTabMu.Unlock()
59 if remote.state != "ready" || remote.client == nil || remote.routing.rehydratingPath != "" || remote.routing.currentPath == "" || remote.routing.currentPath != expectedPath {
60 return InboxTargetView{}, errInboxTargetChanged
61 }
62 return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: remote.gen, Selection: remote.selectionRevision,
63 Remote: true, HostID: remote.ref.HostID, Workspace: remote.ref.Workspace}, nil
64 }
65 a.remoteTabMu.Unlock()
66 a.mu.RLock()
67 defer a.mu.RUnlock()
68 for _, tab := range a.tabs {
69 if tab.ID == tabID && tab.Ctrl != nil && expectedPath != "" && inboxTabIdentity(tab) == expectedPath {
70 return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: tab.SessionGeneration}, nil
71 }
72 }
73 return InboxTargetView{}, errInboxTargetChanged
74 }
75
76 func (a *App) remoteInboxTarget(target InboxTargetView) (*http.Client, string, error) {
77 a.remoteTabMu.Lock()
78 defer a.remoteTabMu.Unlock()
79 tab := a.remoteTabs[target.TabID]
80 if tab == nil || tab.client == nil || tab.state != "ready" || tab.gen != target.Generation || tab.selectionRevision != target.Selection || tab.routing.currentPath != target.SessionPath || tab.routing.rehydratingPath != "" || (target.HostID != "" && (tab.ref.HostID != target.HostID || tab.ref.Workspace != target.Workspace)) {
81 return nil, "", errInboxTargetChanged
82 }
83 return tab.client, tab.base, nil
84 }
85
86 // EnqueueInboxFollowupForTarget is additive; older bindings retain their APIs.
87 func (a *App) EnqueueInboxFollowupForTarget(target InboxTargetView, display, submit string, invocations []InvocationRequest, key string) (InboxReceiptView, error) {
88 if strings.TrimSpace(key) == "" {
89 return InboxReceiptView{}, inboxNotSubmitted(fmt.Errorf("idempotency key required"))
90 }
91 if target.Remote {
92 client, base, err := a.remoteInboxTarget(target)
93 if err != nil {
94 if errors.Is(err, errInboxTargetChanged) {
95 return InboxReceiptView{}, inboxTargetTransient(err)
96 }
97 return InboxReceiptView{}, inboxNotSubmitted(err)
98 }
99 return a.enqueueRemoteFollowupAt(client, base, target.SessionPath, display, submit, invocations, key)
100 }
101 a.runtimeAdmissionMu.RLock()
102 defer a.runtimeAdmissionMu.RUnlock()
103 current, err := a.captureInboxTarget(target.TabID, target.SessionPath)
104 if err != nil || current != target {
105 if err != nil && !errors.Is(err, errInboxTargetChanged) {
106 return InboxReceiptView{}, inboxNotSubmitted(err)
107 }
108 return InboxReceiptView{}, inboxTargetTransient(errInboxTargetChanged)
109 }
110 ctrl, err := a.inboxCtrl(target.TabID)
111 if err != nil {
112 return InboxReceiptView{}, inboxNotSubmitted(err)
113 }
114 return a.enqueueInboxWithController(target.TabID, ctrl, sessioninbox.IntentFollowup, display, submit, invocations, key, false, "", target.SessionPath)
115 }
116
117 func inboxNotSubmitted(err error) error {
118 return &inboxCodedError{code: "inbox_not_submitted", cause: err}
119 }
120
121 // LookupInboxFollowupForTarget never creates an item, including on a missing
122 // or expired receipt. Position zero identifies an already removed item.
123 func (a *App) LookupInboxFollowupForTarget(target InboxTargetView, key string) (InboxReceiptView, error) {
124 if target.Remote {
125 current, err := a.remoteReceiptTarget(target)
126 if err != nil {
127 return InboxReceiptView{}, err
128 }
129 target = current
130 client, base, err := a.remoteInboxTarget(target)
131 if err != nil {
132 return InboxReceiptView{}, err
133 }
134 ctx, cancel := commandContext(a)
135 defer cancel()
136 data, err := serveGet(ctx, client, serveURL(base, "/inbox/receipt?key="+url.QueryEscape(key)+"&session="+url.QueryEscape(target.SessionPath)))
137 if err != nil {
138 return InboxReceiptView{}, err
139 }
140 var receipt InboxReceiptView
141 if err := json.Unmarshal(data, &receipt); err != nil {
142 return receipt, err
143 }
144 currentClient, currentBase, err := a.remoteInboxTarget(target)
145 if err != nil || currentClient != client || currentBase != base {
146 return InboxReceiptView{}, fmt.Errorf("inbox receipt route changed during read")
147 }
148 return receipt, nil
149 }
150 a.runtimeAdmissionMu.RLock()
151 defer a.runtimeAdmissionMu.RUnlock()
152 owner, err := a.localReceiptTarget(target.SessionPath)
153 if err != nil {
154 return InboxReceiptView{}, err
155 }
156 reader, ok := owner.ctrl.(interface {
157 LookupInboxReceiptForSession(string, string) (sessioninbox.InboxReceipt, bool, error)
158 })
159 if !ok {
160 return InboxReceiptView{}, fmt.Errorf("inbox receipt lookup unavailable")
161 }
162 receipt, found, err := reader.LookupInboxReceiptForSession(owner.path, key)
163 if err != nil {
164 return InboxReceiptView{}, err
165 }
166 if !found {
167 return InboxReceiptView{}, fmt.Errorf("inbox receipt unconfirmed")
168 }
169 if !a.localReceiptOwnerCurrent(owner) {
170 return InboxReceiptView{}, fmt.Errorf("inbox receipt owner changed during read")
171 }
172 return InboxReceiptView{ItemID: receipt.ItemID, Disposition: string(receipt.Disposition), Position: receipt.Position, Paused: receipt.Paused, Idempotent: receipt.Idempotent}, nil
173 }
174
174 lines GO