| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "errors" |
| 6 | "fmt" |
| 7 | "io" |
| 8 | "net/http" |
| 9 | "strings" |
| 10 | ) |
| 11 | |
| 12 | func (a *App) forkTargetsCapable(tabID string) bool { |
| 13 | a.remoteTabMu.Lock() |
| 14 | defer a.remoteTabMu.Unlock() |
| 15 | return remoteForkTargetsSupported(a.remoteTabs[tabID]) |
| 16 | } |
| 17 | |
| 18 | func remoteForkTargetsSupported(tab *remoteTab) bool { |
| 19 | return tab != nil && tab.capabilities[serveCapabilitySessionForkTargetsV1] |
| 20 | } |
| 21 | |
| 22 | const remoteForkUnsupported = "this remote Reasonix Serve does not support " + serveCapabilitySessionForkTargetsV1 + "; upgrade it to fork a remote turn into an independent session" |
| 23 | |
| 24 | func (a *App) ForkTargetsRemoteTab(tabID string) (ForkTargetSetView, error) { |
| 25 | empty := ForkTargetSetView{Targets: []ForkTargetView{}} |
| 26 | if !a.forkTargetsCapable(tabID) { |
| 27 | return empty, nil |
| 28 | } |
| 29 | a.remoteTabMu.Lock() |
| 30 | tab := a.remoteTabs[tabID] |
| 31 | if tab == nil || tab.client == nil || tab.state != "ready" || tab.routing.currentPath == "" { |
| 32 | a.remoteTabMu.Unlock() |
| 33 | return empty, fmt.Errorf("remote tab %q is unavailable", tabID) |
| 34 | } |
| 35 | client, base, expectedPath, generation := tab.client, tab.base, tab.routing.currentPath, tab.gen |
| 36 | a.remoteTabMu.Unlock() |
| 37 | ctx, cancel := commandContext(a) |
| 38 | defer cancel() |
| 39 | resp, err := serveDoForSession(ctx, client, http.MethodGet, serveURL(base, "/fork-targets"), nil, expectedPath) |
| 40 | if err != nil { |
| 41 | return empty, err |
| 42 | } |
| 43 | defer resp.Body.Close() |
| 44 | body, _ := io.ReadAll(io.LimitReader(resp.Body, serveSnapshotMaxBytes+1)) |
| 45 | if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| 46 | return empty, &serveHTTPStatusError{url: serveURL(base, "/fork-targets"), statusCode: resp.StatusCode, message: strings.TrimSpace(string(body))} |
| 47 | } |
| 48 | var wire struct { |
| 49 | Source struct { |
| 50 | HostID string `json:"hostId"` |
| 51 | SessionID string `json:"sessionId"` |
| 52 | } `json:"source"` |
| 53 | Targets []struct { |
| 54 | TurnID string `json:"turnId"` |
| 55 | BoundarySequence uint64 `json:"boundarySequence"` |
| 56 | TurnNumber int `json:"turnNumber"` |
| 57 | Status string `json:"status"` |
| 58 | MessageID string `json:"messageId"` |
| 59 | Available bool `json:"available"` |
| 60 | Reason string `json:"reason"` |
| 61 | } `json:"targets"` |
| 62 | Verifiable bool `json:"verifiable"` |
| 63 | } |
| 64 | if err := json.Unmarshal(body, &wire); err != nil { |
| 65 | return empty, fmt.Errorf("decode remote fork targets: %w", err) |
| 66 | } |
| 67 | a.remoteTabMu.Lock() |
| 68 | current := a.remoteTabs[tabID] |
| 69 | stale := current != tab || current.client != client || current.base != base || current.gen != generation || current.routing.currentPath != expectedPath |
| 70 | a.remoteTabMu.Unlock() |
| 71 | expectedID, hasID := strings.CutPrefix(expectedPath, remoteSessionIDRoutePrefix) |
| 72 | if stale || !hasID || strings.TrimSpace(wire.Source.SessionID) == "" || wire.Source.SessionID != expectedID { |
| 73 | return empty, fmt.Errorf("stale_source") |
| 74 | } |
| 75 | view := ForkTargetSetView{SourceHostID: wire.Source.HostID, SourceSessionID: wire.Source.SessionID, |
| 76 | SessionGeneration: generation, Targets: make([]ForkTargetView, 0, len(wire.Targets)), Verifiable: wire.Verifiable} |
| 77 | for _, target := range wire.Targets { |
| 78 | view.Targets = append(view.Targets, ForkTargetView{SourceHostID: wire.Source.HostID, |
| 79 | SourceSessionID: wire.Source.SessionID, SessionGeneration: generation, TurnID: target.TurnID, |
| 80 | BoundarySequence: target.BoundarySequence, TurnNumber: target.TurnNumber, Status: target.Status, |
| 81 | MessageID: target.MessageID, Available: target.Available, Reason: target.Reason}) |
| 82 | } |
| 83 | return view, nil |
| 84 | } |
| 85 | |
| 86 | func (a *App) CreateForkRemoteTab(tabID string, anchor ForkAnchorView) (ForkCreationView, error) { |
| 87 | if strings.TrimSpace(anchor.TurnID) == "" { |
| 88 | return ForkCreationView{}, fmt.Errorf("forking a remote turn requires a turn id") |
| 89 | } |
| 90 | if !a.forkTargetsCapable(tabID) { |
| 91 | return ForkCreationView{Code: "fork_unavailable", Reason: "unsupported", Error: remoteForkUnsupported}, nil |
| 92 | } |
| 93 | a.remoteTabMu.Lock() |
| 94 | tab := a.remoteTabs[tabID] |
| 95 | currentID := "" |
| 96 | if tab != nil { |
| 97 | currentID, _ = strings.CutPrefix(tab.routing.currentPath, remoteSessionIDRoutePrefix) |
| 98 | } |
| 99 | stale := tab == nil || tab.gen != anchor.SessionGeneration || currentID != strings.TrimSpace(anchor.SourceSessionID) |
| 100 | a.remoteTabMu.Unlock() |
| 101 | if stale { |
| 102 | return ForkCreationView{Code: "fork_unavailable", Reason: "stale_source", Error: "fork source session changed"}, nil |
| 103 | } |
| 104 | operation, err := a.beginForkOperation(forkOperation{Surface: "remote", TabID: tabID, |
| 105 | SourceHostID: anchor.SourceHostID, SourceSessionID: anchor.SourceSessionID, |
| 106 | TurnID: strings.TrimSpace(anchor.TurnID), BoundarySequence: anchor.BoundarySequence}) |
| 107 | if err != nil { |
| 108 | return ForkCreationView{}, err |
| 109 | } |
| 110 | if operation.State == "completed" && operation.ChildSessionID != "" { |
| 111 | return ForkCreationView{SessionID: operation.ChildSessionID, OperationID: operation.OperationID, Opened: true}, nil |
| 112 | } |
| 113 | var view ForkCreationView |
| 114 | err = a.remoteTabPostJSON(tabID, "/fork-session", map[string]any{ |
| 115 | "sourceSessionId": operation.SourceSessionID, "turnId": operation.TurnID, |
| 116 | "boundarySequence": operation.BoundarySequence, "name": "", "operationId": operation.OperationID, |
| 117 | }, &view) |
| 118 | if err != nil { |
| 119 | var statusErr *serveHTTPStatusError |
| 120 | if errors.As(err, &statusErr) { |
| 121 | var refusal struct{ Code, Reason, Message string } |
| 122 | if json.Unmarshal([]byte(statusErr.message), &refusal) == nil && refusal.Code != "" { |
| 123 | if statusErr.statusCode >= 400 && statusErr.statusCode < 500 { |
| 124 | _ = a.discardForkOperation(operation.OperationID) |
| 125 | } |
| 126 | return ForkCreationView{Code: refusal.Code, Reason: refusal.Reason, Error: refusal.Message}, nil |
| 127 | } |
| 128 | if statusErr.statusCode >= 400 && statusErr.statusCode < 500 { |
| 129 | _ = a.discardForkOperation(operation.OperationID) |
| 130 | } |
| 131 | return ForkCreationView{Error: statusErr.message}, nil |
| 132 | } |
| 133 | return ForkCreationView{}, err |
| 134 | } |
| 135 | view.OperationID = operation.OperationID |
| 136 | if strings.TrimSpace(view.SessionID) == "" { |
| 137 | return ForkCreationView{}, fmt.Errorf("remote fork response has no child session id") |
| 138 | } |
| 139 | if err := a.completeForkOperation(operation.OperationID, view.SessionID); err != nil { |
| 140 | return ForkCreationView{}, err |
| 141 | } |
| 142 | view.Opened = true |
| 143 | return view, nil |
| 144 | } |
| 145 |