| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "net/http" |
| 7 | "net/url" |
| 8 | "strings" |
| 9 | |
| 10 | "reasonix/internal/sessioninbox" |
| 11 | ) |
| 12 | |
| 13 | // InboxTargetView fences follow-ups across both local replacement and remote |
| 14 | // selection changes. It is process-local and never written to the inbox ledger. |
| 15 | type InboxTargetView struct { |
| 16 | TabID string `json:"tabId"` |
| 17 | SessionPath string `json:"sessionPath"` |
| 18 | Generation uint64 `json:"generation"` |
| 19 | Selection uint64 `json:"selection"` |
| 20 | Remote bool `json:"remote"` |
| 21 | HostID string `json:"hostId,omitempty"` |
| 22 | Workspace string `json:"workspace,omitempty"` |
| 23 | } |
| 24 | |
| 25 | func (a *App) CaptureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) { |
| 26 | a.runtimeAdmissionMu.RLock() |
| 27 | defer a.runtimeAdmissionMu.RUnlock() |
| 28 | return a.captureInboxTarget(tabID, expectedPath) |
| 29 | } |
| 30 | |
| 31 | func (a *App) captureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) { |
| 32 | a.remoteTabMu.Lock() |
| 33 | remote := a.remoteTabs[tabID] |
| 34 | if remote != nil { |
| 35 | defer a.remoteTabMu.Unlock() |
| 36 | if remote.state != "ready" || remote.client == nil || remote.routing.rehydratingPath != "" || remote.routing.currentPath == "" || remote.routing.currentPath != expectedPath { |
| 37 | return InboxTargetView{}, fmt.Errorf("inbox target changed") |
| 38 | } |
| 39 | return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: remote.gen, Selection: remote.selectionRevision, |
| 40 | Remote: true, HostID: remote.ref.HostID, Workspace: remote.ref.Workspace}, nil |
| 41 | } |
| 42 | a.remoteTabMu.Unlock() |
| 43 | a.mu.RLock() |
| 44 | defer a.mu.RUnlock() |
| 45 | for _, tab := range a.tabs { |
| 46 | if tab.ID == tabID && tab.Ctrl != nil && tab.SessionPath != "" && tab.SessionPath == expectedPath { |
| 47 | return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: tab.SessionGeneration}, nil |
| 48 | } |
| 49 | } |
| 50 | return InboxTargetView{}, fmt.Errorf("inbox target changed") |
| 51 | } |
| 52 | |
| 53 | func (a *App) remoteInboxTarget(target InboxTargetView) (*http.Client, string, error) { |
| 54 | a.remoteTabMu.Lock() |
| 55 | defer a.remoteTabMu.Unlock() |
| 56 | tab := a.remoteTabs[target.TabID] |
| 57 | 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)) { |
| 58 | return nil, "", fmt.Errorf("inbox target changed") |
| 59 | } |
| 60 | return tab.client, tab.base, nil |
| 61 | } |
| 62 | |
| 63 | // EnqueueInboxFollowupForTarget is additive; older bindings retain their APIs. |
| 64 | func (a *App) EnqueueInboxFollowupForTarget(target InboxTargetView, display, submit string, invocations []InvocationRequest, key string) (InboxReceiptView, error) { |
| 65 | if strings.TrimSpace(key) == "" { |
| 66 | return InboxReceiptView{}, inboxNotSubmitted(fmt.Errorf("idempotency key required")) |
| 67 | } |
| 68 | if target.Remote { |
| 69 | client, base, err := a.remoteInboxTarget(target) |
| 70 | if err != nil { |
| 71 | return InboxReceiptView{}, inboxNotSubmitted(err) |
| 72 | } |
| 73 | return a.enqueueRemoteFollowupAt(client, base, target.SessionPath, display, submit, invocations, key) |
| 74 | } |
| 75 | a.runtimeAdmissionMu.RLock() |
| 76 | defer a.runtimeAdmissionMu.RUnlock() |
| 77 | current, err := a.captureInboxTarget(target.TabID, target.SessionPath) |
| 78 | if err != nil || current != target { |
| 79 | return InboxReceiptView{}, inboxNotSubmitted(fmt.Errorf("inbox target changed")) |
| 80 | } |
| 81 | ctrl, err := a.inboxCtrl(target.TabID) |
| 82 | if err != nil { |
| 83 | return InboxReceiptView{}, inboxNotSubmitted(err) |
| 84 | } |
| 85 | return a.enqueueInboxWithController(target.TabID, ctrl, sessioninbox.IntentFollowup, display, submit, invocations, key, false, "", target.SessionPath) |
| 86 | } |
| 87 | |
| 88 | func inboxNotSubmitted(err error) error { |
| 89 | return &inboxCodedError{code: "inbox_not_submitted", cause: err} |
| 90 | } |
| 91 | |
| 92 | // LookupInboxFollowupForTarget never creates an item, including on a missing |
| 93 | // or expired receipt. Position zero identifies an already removed item. |
| 94 | func (a *App) LookupInboxFollowupForTarget(target InboxTargetView, key string) (InboxReceiptView, error) { |
| 95 | if target.Remote { |
| 96 | current, err := a.remoteReceiptTarget(target) |
| 97 | if err != nil { |
| 98 | return InboxReceiptView{}, err |
| 99 | } |
| 100 | target = current |
| 101 | client, base, err := a.remoteInboxTarget(target) |
| 102 | if err != nil { |
| 103 | return InboxReceiptView{}, err |
| 104 | } |
| 105 | ctx, cancel := commandContext(a) |
| 106 | defer cancel() |
| 107 | data, err := serveGet(ctx, client, serveURL(base, "/inbox/receipt?key="+url.QueryEscape(key)+"&session="+url.QueryEscape(target.SessionPath))) |
| 108 | if err != nil { |
| 109 | return InboxReceiptView{}, err |
| 110 | } |
| 111 | var receipt InboxReceiptView |
| 112 | if err := json.Unmarshal(data, &receipt); err != nil { |
| 113 | return receipt, err |
| 114 | } |
| 115 | currentClient, currentBase, err := a.remoteInboxTarget(target) |
| 116 | if err != nil || currentClient != client || currentBase != base { |
| 117 | return InboxReceiptView{}, fmt.Errorf("inbox receipt route changed during read") |
| 118 | } |
| 119 | return receipt, nil |
| 120 | } |
| 121 | a.runtimeAdmissionMu.RLock() |
| 122 | defer a.runtimeAdmissionMu.RUnlock() |
| 123 | owner, err := a.localReceiptTarget(target.SessionPath) |
| 124 | if err != nil { |
| 125 | return InboxReceiptView{}, err |
| 126 | } |
| 127 | reader, ok := owner.ctrl.(interface { |
| 128 | LookupInboxReceiptForSession(string, string) (sessioninbox.InboxReceipt, bool, error) |
| 129 | }) |
| 130 | if !ok { |
| 131 | return InboxReceiptView{}, fmt.Errorf("inbox receipt lookup unavailable") |
| 132 | } |
| 133 | receipt, found, err := reader.LookupInboxReceiptForSession(owner.path, key) |
| 134 | if err != nil { |
| 135 | return InboxReceiptView{}, err |
| 136 | } |
| 137 | if !found { |
| 138 | return InboxReceiptView{}, fmt.Errorf("inbox receipt unconfirmed") |
| 139 | } |
| 140 | if !a.localReceiptOwnerCurrent(owner) { |
| 141 | return InboxReceiptView{}, fmt.Errorf("inbox receipt owner changed during read") |
| 142 | } |
| 143 | return InboxReceiptView{ItemID: receipt.ItemID, Disposition: string(receipt.Disposition), Position: receipt.Position, Paused: receipt.Paused, Idempotent: receipt.Idempotent}, nil |
| 144 | } |
| 145 |