| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "crypto/rand" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "fmt" |
| 8 | "net/http" |
| 9 | |
| 10 | "reasonix/internal/control" |
| 11 | ) |
| 12 | |
| 13 | // Once a source-bound Serve supports the enhanced protocol, it owns refresh |
| 14 | // and receipt lookup in one admission transaction. Desktop must not reject a |
| 15 | // retry before Serve can return an already accepted receipt. |
| 16 | func (a *App) remoteModelApplicationReady(tabID string) bool { |
| 17 | a.remoteTabMu.Lock() |
| 18 | defer a.remoteTabMu.Unlock() |
| 19 | t := a.remoteTabs[tabID] |
| 20 | return t != nil && t.capabilities["model-application-v1"] && t.settings.revision != "" && t.settings.generation == t.gen && t.settings.sessionPath == t.routing.currentPath |
| 21 | } |
| 22 | |
| 23 | func (a *App) remoteModelApplicationDetails(tabID string) *ModelApplicationDetails { |
| 24 | a.remoteTabMu.Lock() |
| 25 | tab := a.remoteTabs[tabID] |
| 26 | if tab == nil || !tab.capabilities["model-application-v1"] { |
| 27 | a.remoteTabMu.Unlock() |
| 28 | return nil |
| 29 | } |
| 30 | generation := tab.gen |
| 31 | a.remoteTabMu.Unlock() |
| 32 | client, base, path, err := a.remoteTabCommandTarget(tabID) |
| 33 | if err != nil { |
| 34 | return nil |
| 35 | } |
| 36 | ctx, cancel := commandContext(a) |
| 37 | defer cancel() |
| 38 | status, err := remoteModelSettingsRequest(ctx, client, base, path, nil) |
| 39 | if err != nil { |
| 40 | return nil |
| 41 | } |
| 42 | a.remoteTabMu.Lock() |
| 43 | defer a.remoteTabMu.Unlock() |
| 44 | if current := a.remoteTabs[tabID]; current != tab || current.gen != generation || current.routing.currentPath != path { |
| 45 | return nil |
| 46 | } |
| 47 | return bindRemoteModelConfirmation(tab, generation, path, status.Application) |
| 48 | } |
| 49 | |
| 50 | // A host token prevents a confirmation from being revived by a later status |
| 51 | // refresh after reconnect, even if Serve still has the same model runtime. |
| 52 | func bindRemoteModelConfirmation(tab *remoteTab, generation uint64, path string, detail *ModelApplicationDetails) *ModelApplicationDetails { |
| 53 | if detail == nil { |
| 54 | return nil |
| 55 | } |
| 56 | copy := *detail |
| 57 | copy.ConfirmationToken = "" // Only this Desktop connection can issue confirmations. |
| 58 | previous := tab.settings.details |
| 59 | if previous != nil && tab.settings.detailsGeneration == generation && tab.settings.detailsPath == path && previous.RuntimeIdentity == copy.RuntimeIdentity && previous.AppliedRevision == copy.AppliedRevision && previous.DesiredRevision == copy.DesiredRevision { |
| 60 | copy.ConfirmationToken = previous.ConfirmationToken |
| 61 | } |
| 62 | if copy.ConfirmationToken == "" { |
| 63 | copy.ConfirmationToken = rand.Text() |
| 64 | } |
| 65 | tab.settings.details = © |
| 66 | tab.settings.detailsGeneration, tab.settings.detailsPath = generation, path |
| 67 | return © |
| 68 | } |
| 69 | |
| 70 | func (a *App) validateRemoteModelConfirmation(tabID string, choice control.ModelApplicationChoice) error { |
| 71 | a.remoteTabMu.Lock() |
| 72 | defer a.remoteTabMu.Unlock() |
| 73 | tab := a.remoteTabs[tabID] |
| 74 | if tab == nil || !tab.capabilities["model-application-v1"] || tab.settings.detailsGeneration != tab.gen || tab.settings.detailsPath != tab.routing.currentPath { |
| 75 | return &submissionNotAcceptedError{cause: control.ErrModelChoiceStale} |
| 76 | } |
| 77 | d := tab.settings.details |
| 78 | if d == nil || choice.ConfirmationToken == "" || choice.ConfirmationToken != d.ConfirmationToken || choice.ExpectedRuntimeIdentity != d.RuntimeIdentity || choice.ExpectedAppliedRevision != d.AppliedRevision || choice.ExpectedDesiredRevision != d.DesiredRevision { |
| 79 | return &submissionNotAcceptedError{cause: control.ErrModelChoiceStale} |
| 80 | } |
| 81 | return nil |
| 82 | } |
| 83 | |
| 84 | func (a *App) SubmitRemoteTabWithModelApplication(tabID, text, submissionID string, choice control.ModelApplicationChoice) error { |
| 85 | if err := a.requireRemoteExecutionProtocol(tabID); err != nil { |
| 86 | return err |
| 87 | } |
| 88 | if err := a.requireRemotePermissionPresets(tabID); err != nil { |
| 89 | return err |
| 90 | } |
| 91 | a.remoteTabMu.Lock() |
| 92 | tab := a.remoteTabs[tabID] |
| 93 | supported := tab != nil && tab.capabilities["model-application-v1"] |
| 94 | var generation uint64 |
| 95 | if tab != nil { |
| 96 | generation = tab.gen |
| 97 | } |
| 98 | a.remoteTabMu.Unlock() |
| 99 | if !supported { |
| 100 | return &submissionNotAcceptedError{cause: fmt.Errorf("upgrade Serve to use model application recovery")} |
| 101 | } |
| 102 | _, _, path, err := a.remoteTabCommandTarget(tabID) |
| 103 | if err != nil { |
| 104 | return err |
| 105 | } |
| 106 | if !a.remoteTabAdmissionCurrent(tabID, generation) { |
| 107 | return &submissionNotAcceptedError{cause: control.ErrModelChoiceStale} |
| 108 | } |
| 109 | if choice.Mode == "applied_once" { |
| 110 | if err := a.validateRemoteModelConfirmation(tabID, choice); err != nil { |
| 111 | // Serve must still be allowed to acknowledge an existing receipt. |
| 112 | // Empty identity can never authorize new execution; a missing |
| 113 | // receipt therefore returns its structured stale-choice rejection. |
| 114 | choice.ExpectedRuntimeIdentity = "" |
| 115 | } |
| 116 | } |
| 117 | body, err := json.Marshal(map[string]any{"input": text, "submissionId": submissionID, "modelApplication": choice}) |
| 118 | if err != nil { |
| 119 | return err |
| 120 | } |
| 121 | err = a.submitWithRouteRetry(tabID, func(client *http.Client, base, expectedPath string) error { |
| 122 | ctx, cancel := commandContext(a) |
| 123 | defer cancel() |
| 124 | return servePostForSession(ctx, client, serveURL(base, "/submit"), body, expectedPath) |
| 125 | }) |
| 126 | var detailed interface{ RPCErrorData() map[string]any } |
| 127 | if errors.As(err, &detailed) { |
| 128 | if raw, marshalErr := json.Marshal(detailed.RPCErrorData()["modelApplication"]); marshalErr == nil { |
| 129 | var detail *ModelApplicationDetails |
| 130 | if json.Unmarshal(raw, &detail) == nil && detail != nil { |
| 131 | a.remoteTabMu.Lock() |
| 132 | if current := a.remoteTabs[tabID]; current == tab && current.gen == generation && current.routing.currentPath == path { |
| 133 | detail = bindRemoteModelConfirmation(current, generation, path, detail) |
| 134 | var httpError *serveHTTPStatusError |
| 135 | if errors.As(err, &httpError) { |
| 136 | httpError.data["modelApplication"] = detail |
| 137 | } |
| 138 | } |
| 139 | a.remoteTabMu.Unlock() |
| 140 | } |
| 141 | } |
| 142 | } |
| 143 | if remoteBusySubmitError(err) { |
| 144 | // Busy submissions become durable follow-ups. The queue does not carry |
| 145 | // the per-submit model application choice; the message remains queued. |
| 146 | if queued, queueErr := a.queueBusyFollowup(tabID, text, submissionID); queueErr == nil { |
| 147 | return queued |
| 148 | } |
| 149 | } |
| 150 | return err |
| 151 | } |
| 152 |