| 1 | package cli |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "time" |
| 9 | |
| 10 | "reasonix/internal/control" |
| 11 | "reasonix/internal/i18n" |
| 12 | "reasonix/internal/session" |
| 13 | ) |
| 14 | |
| 15 | // cliCanonicalRoutePrefix marks a takeover/resume target as a final-format |
| 16 | // identity instead of a legacy transcript path. It matches the serve and |
| 17 | // desktop routing prefix for exclusive identity sessions. |
| 18 | const cliCanonicalRoutePrefix = "session-id:" |
| 19 | |
| 20 | func isCLICanonicalRoute(target string) bool { |
| 21 | return strings.HasPrefix(strings.TrimSpace(target), cliCanonicalRoutePrefix) |
| 22 | } |
| 23 | |
| 24 | func cliCanonicalRouteID(target string) (string, bool) { |
| 25 | id, ok := strings.CutPrefix(strings.TrimSpace(target), cliCanonicalRoutePrefix) |
| 26 | return id, ok && id != "" |
| 27 | } |
| 28 | |
| 29 | func cliCanonicalRoute(sessionID string) string { |
| 30 | return cliCanonicalRoutePrefix + strings.TrimSpace(sessionID) |
| 31 | } |
| 32 | |
| 33 | // sessionWriterHeldNotice is the friendly refusal for a final-format session |
| 34 | // whose writer another runtime owns. |
| 35 | func sessionWriterHeldNotice() string { |
| 36 | return "this session is written by another Reasonix runtime on this machine; run /takeover to take it over" |
| 37 | } |
| 38 | |
| 39 | // cliServeUnreachableError marks a takeover attempt that never obtained a |
| 40 | // serve's verdict: the dial, the token exchange, or the request itself failed |
| 41 | // at the transport. Only this kind of failure justifies a re-discovery pass. |
| 42 | // An HTTP verdict — a refusal, "still running; retry with mode=interrupt", an |
| 43 | // invalid grant — is the serve's answer and repeating the round would only |
| 44 | // repeat the same bounded wait. |
| 45 | type cliServeUnreachableError struct{ err error } |
| 46 | |
| 47 | func (e *cliServeUnreachableError) Error() string { return e.err.Error() } |
| 48 | func (e *cliServeUnreachableError) Unwrap() error { return e.err } |
| 49 | |
| 50 | func cliServeUnreachable(err error) bool { |
| 51 | var unreachable *cliServeUnreachableError |
| 52 | return errors.As(err, &unreachable) |
| 53 | } |
| 54 | |
| 55 | // cliTakeoverIdentityHeldSession asks every resident serve to hand the |
| 56 | // final-format identity over. The writer lock carries no PID, so discovery is |
| 57 | // exhaustive rather than PID-matched; the serve that actually holds the |
| 58 | // identity grants, the others refuse. |
| 59 | func cliTakeoverIdentityHeldSession(route string, manager *cliTakeoverManager) (*cliTakeoverBinding, error) { |
| 60 | if manager != nil && manager.Reclaiming() { |
| 61 | return nil, fmt.Errorf("the remote side is reclaiming the current session") |
| 62 | } |
| 63 | // One re-discovery pass, and only when no serve could be reached: a |
| 64 | // desktop reconnect respawns the serve and rewrites its state file, so an |
| 65 | // all-unreachable round may simply have raced the restart. PIDs of dead |
| 66 | // serves are pruned during discovery. |
| 67 | var lastErr error |
| 68 | for range 2 { |
| 69 | records := discoverCLIServesForTakeover() |
| 70 | if len(records) == 0 { |
| 71 | break |
| 72 | } |
| 73 | answered := false |
| 74 | lastErr = nil |
| 75 | for i := range records { |
| 76 | binding, err := cliTakeoverIdentityFromServe(route, &records[i]) |
| 77 | if err == nil { |
| 78 | return binding, nil |
| 79 | } |
| 80 | if !cliServeUnreachable(err) { |
| 81 | // A serve that answered is the one worth reporting. |
| 82 | answered = true |
| 83 | lastErr = err |
| 84 | } else if lastErr == nil { |
| 85 | lastErr = err |
| 86 | } |
| 87 | } |
| 88 | if answered { |
| 89 | break |
| 90 | } |
| 91 | } |
| 92 | if lastErr == nil { |
| 93 | return nil, fmt.Errorf("no resident serve on this machine holds this session; check that the desktop is connected and retry") |
| 94 | } |
| 95 | return nil, lastErr |
| 96 | } |
| 97 | |
| 98 | // cliTakeoverIdentityFromServe performs one serve's identity handoff. The |
| 99 | // grant must name the exact route this process asked for. |
| 100 | func cliTakeoverIdentityFromServe(route string, record *cliServeRecord) (*cliTakeoverBinding, error) { |
| 101 | ctx, cancel := context.WithTimeout(context.Background(), cliTakeoverTimeout+15*time.Second) |
| 102 | defer cancel() |
| 103 | client, err := cliServeClient(ctx, *record) |
| 104 | if err != nil { |
| 105 | return nil, fmt.Errorf("takeover from local serve: %w", err) |
| 106 | } |
| 107 | grant, err := postCLITakeoverHandoff(ctx, client, record.base, route, "takeover from local serve") |
| 108 | if err != nil { |
| 109 | return nil, err |
| 110 | } |
| 111 | if strings.TrimSpace(grant.SessionPath) != route { |
| 112 | return nil, fmt.Errorf("invalid handoff grant") |
| 113 | } |
| 114 | return &cliTakeoverBinding{path: route, canonical: true, record: *record, client: client, grant: grant}, nil |
| 115 | } |
| 116 | |
| 117 | // ctrlOpenCanonicalSession attaches the controller to a final-format identity. |
| 118 | func ctrlOpenCanonicalSession(ctrl control.SessionAPI, ref session.SessionRef) error { |
| 119 | identity, ok := ctrl.(control.IdentityLifecycle) |
| 120 | if !ok || !identity.UsesExclusiveSession() { |
| 121 | return errors.New("final-format session resume requires the session engine") |
| 122 | } |
| 123 | if _, err := identity.OpenSession(context.Background(), ref); err != nil { |
| 124 | return err |
| 125 | } |
| 126 | return nil |
| 127 | } |
| 128 | |
| 129 | // cliCanonicalRouteRef resolves the identity a route names against the |
| 130 | // workspace's session service, so the takeover opens the same identity the |
| 131 | // serve released. |
| 132 | func cliCanonicalRouteRef(sessionDir, route string) (session.SessionRef, error) { |
| 133 | id, ok := cliCanonicalRouteID(route) |
| 134 | if !ok { |
| 135 | return session.SessionRef{}, errors.New("invalid session identity") |
| 136 | } |
| 137 | service := cliSessionService(sessionDir) |
| 138 | if service == nil { |
| 139 | return session.SessionRef{}, errors.New("session service unavailable") |
| 140 | } |
| 141 | ref := session.SessionRef{HostID: service.HostID(), SessionID: id} |
| 142 | if _, err := service.SessionDir(context.Background(), ref); err != nil { |
| 143 | return session.SessionRef{}, fmt.Errorf("unknown session: %w", err) |
| 144 | } |
| 145 | return ref, nil |
| 146 | } |
| 147 | |
| 148 | // runCanonicalTakeoverCommand handles "/takeover" for a final-format identity: |
| 149 | // every resident serve is asked to hand the writer over; on grant the |
| 150 | // controller attaches through OpenSession and the mirror manager forwards |
| 151 | // frames so the remote tab keeps rendering read-only. When no runtime holds |
| 152 | // the identity any more there is nothing to hand over and it is resumed |
| 153 | // directly, which is what the reclaim notice promises after the desktop has |
| 154 | // closed the session it took back. |
| 155 | func (m *chatTUI) runCanonicalTakeoverCommand(route string) { |
| 156 | if m.ctrl.Running() { |
| 157 | m.notice(i18n.M.ResumeBusy) |
| 158 | return |
| 159 | } |
| 160 | ref, err := cliCanonicalRouteRef(m.ctrl.SessionDir(), route) |
| 161 | if err != nil { |
| 162 | m.notice("takeover: " + err.Error()) |
| 163 | return |
| 164 | } |
| 165 | if resumeEntryIsActive(m.ctrl, resumeEntry{target: cliResumeTarget{ref: ref}}) { |
| 166 | m.notice(i18n.M.ResumeAlreadyActive) |
| 167 | return |
| 168 | } |
| 169 | detached := m.sessionDetached() |
| 170 | if !detached { |
| 171 | if err := m.ctrl.Snapshot(); err != nil { |
| 172 | m.notice("takeover: snapshot current session: " + err.Error()) |
| 173 | return |
| 174 | } |
| 175 | m.followSessionLease() |
| 176 | } |
| 177 | binding, takeoverErr := cliTakeoverIdentityHeldSession(route, m.takeover) |
| 178 | if takeoverErr != nil { |
| 179 | m.resumeUnheldCanonicalSession(ref, takeoverErr, detached) |
| 180 | return |
| 181 | } |
| 182 | if err := m.commitCanonicalSessionSwitch(ref); err != nil { |
| 183 | cliEndFailedHandoff(binding) |
| 184 | if !detached { |
| 185 | m.restoreSessionLease() |
| 186 | } |
| 187 | m.notice("takeover: " + err.Error()) |
| 188 | return |
| 189 | } |
| 190 | m.pendingTakeoverPath = "" |
| 191 | if m.takeover != nil { |
| 192 | m.takeover.AttachController(m.ctrl) |
| 193 | m.takeover.Activate(binding) |
| 194 | } |
| 195 | m.resumeAfterReclaim() |
| 196 | m.replayActiveBranch(i18n.M.ResumedTitle) |
| 197 | m.notice("session taken over; the remote side is now read-only and can take it back") |
| 198 | } |
| 199 | |
| 200 | // resumeUnheldCanonicalSession runs after no serve granted the identity. The |
| 201 | // writer lock is the authority on whether the refusal mattered: a session |
| 202 | // nobody holds (the desktop closed it after reclaiming, or the holder exited) |
| 203 | // is simply resumed, while a still-held one reports why the handoff failed |
| 204 | // rather than the generic ownership error. |
| 205 | func (m *chatTUI) resumeUnheldCanonicalSession(ref session.SessionRef, takeoverErr error, detached bool) { |
| 206 | if err := m.commitCanonicalSessionSwitch(ref); err != nil { |
| 207 | if !detached { |
| 208 | m.restoreSessionLease() |
| 209 | } |
| 210 | if errors.Is(err, session.ErrWriterOwned) { |
| 211 | m.notice("takeover: " + takeoverErr.Error()) |
| 212 | return |
| 213 | } |
| 214 | m.notice("takeover: " + err.Error()) |
| 215 | return |
| 216 | } |
| 217 | m.pendingTakeoverPath = "" |
| 218 | m.resumeAfterReclaim() |
| 219 | m.replayActiveBranch(i18n.M.ResumedTitle) |
| 220 | m.notice("session resumed; no other runtime holds it") |
| 221 | } |
| 222 | |
| 223 | // cliStartupCanonicalTakeover is the startup counterpart: after a confirmed |
| 224 | // prompt (or --takeover), the resident serve releases the identity and this |
| 225 | // process attaches as the new writer. |
| 226 | func cliStartupCanonicalTakeover(ctrl control.SessionAPI, manager *cliTakeoverManager, target cliResumeTarget) error { |
| 227 | route := cliCanonicalRoute(target.ref.SessionID) |
| 228 | binding, err := cliTakeoverIdentityHeldSession(route, manager) |
| 229 | if err != nil { |
| 230 | return err |
| 231 | } |
| 232 | if err := ctrlOpenCanonicalSession(ctrl, target.ref); err != nil { |
| 233 | cliEndFailedHandoff(binding) |
| 234 | return err |
| 235 | } |
| 236 | if manager != nil { |
| 237 | manager.AttachController(ctrl) |
| 238 | manager.Activate(binding) |
| 239 | } |
| 240 | return nil |
| 241 | } |
| 242 |