| 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 |