| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "runtime" |
| 9 | "slices" |
| 10 | "strings" |
| 11 | |
| 12 | "reasonix/internal/session" |
| 13 | "reasonix/internal/store" |
| 14 | ) |
| 15 | |
| 16 | type savedTabRouteCandidate struct { |
| 17 | sessionID string |
| 18 | kind string |
| 19 | } |
| 20 | |
| 21 | func savedTabRouteCandidateForPath(raw string) savedTabRouteCandidate { |
| 22 | value := strings.TrimSpace(raw) |
| 23 | if value == "" { |
| 24 | return savedTabRouteCandidate{} |
| 25 | } |
| 26 | if strings.HasPrefix(value, remoteSessionIDRoutePrefix) { |
| 27 | locator := classifySessionLocator(value) |
| 28 | if locator.kind != sessionLocatorCanonical { |
| 29 | return savedTabRouteCandidate{kind: "invalid_route"} |
| 30 | } |
| 31 | return savedTabRouteCandidate{sessionID: locator.ref.SessionID, kind: "canonical_route"} |
| 32 | } |
| 33 | if !persistedPathLooksAbsolute(value) { |
| 34 | return savedTabRouteCandidate{} |
| 35 | } |
| 36 | normalized := strings.ReplaceAll(value, `\`, "/") |
| 37 | base := normalized[strings.LastIndex(normalized, "/")+1:] |
| 38 | if !strings.HasPrefix(base, remoteSessionIDRoutePrefix) { |
| 39 | return savedTabRouteCandidate{} |
| 40 | } |
| 41 | // A real legacy transcript or sidecar can legally contain a colon on |
| 42 | // POSIX. Those names remain paths and are never upgraded by this repair. |
| 43 | if strings.HasSuffix(strings.ToLower(base), ".jsonl") || strings.HasSuffix(strings.ToLower(base), ".meta") { |
| 44 | return savedTabRouteCandidate{} |
| 45 | } |
| 46 | locator := classifySessionLocator(base) |
| 47 | if locator.kind != sessionLocatorCanonical { |
| 48 | return savedTabRouteCandidate{kind: "invalid_route"} |
| 49 | } |
| 50 | return savedTabRouteCandidate{sessionID: locator.ref.SessionID, kind: "pseudo_route_path"} |
| 51 | } |
| 52 | |
| 53 | func persistedPathLooksAbsolute(path string) bool { |
| 54 | if strings.HasPrefix(path, "/") || strings.HasPrefix(path, `\\`) || strings.HasPrefix(path, "//") { |
| 55 | return true |
| 56 | } |
| 57 | return len(path) >= 3 && ((path[0] >= 'A' && path[0] <= 'Z') || (path[0] >= 'a' && path[0] <= 'z')) && path[1] == ':' && (path[2] == '\\' || path[2] == '/') |
| 58 | } |
| 59 | |
| 60 | func (a *App) normalizeSavedTabRoute(ctx context.Context, entry desktopTabEntry, evidence savedTabReconcileEvidence) (desktopTabEntry, bool, *savedTabReconcileDecision) { |
| 61 | candidate := savedTabRouteCandidateForPath(entry.SessionPath) |
| 62 | failed := func(reason string, pending, recovery bool) (desktopTabEntry, bool, *savedTabReconcileDecision) { |
| 63 | return entry, false, &savedTabReconcileDecision{ |
| 64 | outcome: preserveError, reason: reason, identityKind: candidate.kind, |
| 65 | hadPending: pending, hadRecoveryOwner: recovery, |
| 66 | } |
| 67 | } |
| 68 | if candidate.kind == "" { |
| 69 | return entry, false, nil |
| 70 | } |
| 71 | if candidate.kind == "invalid_route" || candidate.sessionID == "" { |
| 72 | return failed("invalid_route", false, false) |
| 73 | } |
| 74 | if id := strings.TrimSpace(entry.SessionID); id != "" && id != candidate.sessionID { |
| 75 | return failed("identity_conflict", false, false) |
| 76 | } |
| 77 | if evidence.registryErr != nil || evidence.draftErr != nil { |
| 78 | return failed("persistence_state_unavailable", false, false) |
| 79 | } |
| 80 | |
| 81 | pendingID, pendingFound, pendingConflict := savedTabPendingSessionIdentity(desktopTabEntry{ |
| 82 | CreateOperationID: entry.CreateOperationID, |
| 83 | }, evidence) |
| 84 | if pendingConflict || (pendingFound && pendingID != candidate.sessionID) { |
| 85 | return failed("pending_identity_conflict", true, false) |
| 86 | } |
| 87 | if candidate.kind == "canonical_route" { |
| 88 | entry.SessionID = candidate.sessionID |
| 89 | entry.SessionPath = "" |
| 90 | return entry, true, nil |
| 91 | } |
| 92 | |
| 93 | present, artifactErr := savedTabPseudoRouteArtifactsPresent(entry.SessionPath) |
| 94 | if artifactErr != nil { |
| 95 | return failed("legacy_artifacts_unreadable", pendingFound, false) |
| 96 | } |
| 97 | if present { |
| 98 | return failed("legacy_artifacts_present", pendingFound, false) |
| 99 | } |
| 100 | confirmed, conflict, recoveryOwner := a.savedTabPseudoRouteIdentityConfirmed(ctx, entry, candidate.sessionID, evidence, pendingID, pendingFound) |
| 101 | if conflict { |
| 102 | return failed("identity_conflict", pendingFound, recoveryOwner) |
| 103 | } |
| 104 | if !confirmed { |
| 105 | return failed("identity_evidence_absent", pendingFound, recoveryOwner) |
| 106 | } |
| 107 | entry.SessionID = candidate.sessionID |
| 108 | entry.SessionPath = "" |
| 109 | return entry, true, nil |
| 110 | } |
| 111 | |
| 112 | func savedTabPseudoRouteArtifactsPresent(path string) (bool, error) { |
| 113 | return savedTabPseudoRouteArtifactsPresentWith(path, os.Lstat) |
| 114 | } |
| 115 | |
| 116 | func savedTabPseudoRouteArtifactsPresentWith(path string, lstat func(string) (os.FileInfo, error)) (bool, error) { |
| 117 | path = strings.TrimSpace(path) |
| 118 | if path == "" { |
| 119 | return false, nil |
| 120 | } |
| 121 | if runtime.GOOS == "windows" && savedTabRouteCandidateForPath(path).kind == "pseudo_route_path" { |
| 122 | // The route colon is illegal in a Windows file name, so this spelling |
| 123 | // cannot identify a real transcript or sidecar on the native filesystem. |
| 124 | return false, nil |
| 125 | } |
| 126 | // Foreign Windows paths on POSIX cannot name a local artifact. Native |
| 127 | // paths are probed without cleaning so the persisted spelling is preserved. |
| 128 | if runtime.GOOS != "windows" && !filepath.IsAbs(path) { |
| 129 | return false, nil |
| 130 | } |
| 131 | artifacts := append([]string{path}, store.SessionSidecarFiles(path)...) |
| 132 | artifacts = append(artifacts, |
| 133 | store.SessionLockFile(path), store.SessionLeaseLock(path), store.SessionLeaseInfo(path), |
| 134 | store.SessionCheckpointDir(path), store.SessionJobsDir(path), store.SessionInboxDir(path), |
| 135 | store.SessionCleanupPending(path), |
| 136 | ) |
| 137 | seen := make(map[string]bool, len(artifacts)) |
| 138 | for _, artifact := range artifacts { |
| 139 | if artifact == "" || seen[artifact] { |
| 140 | continue |
| 141 | } |
| 142 | seen[artifact] = true |
| 143 | if _, err := lstat(artifact); err == nil { |
| 144 | return true, nil |
| 145 | } else if !errors.Is(err, os.ErrNotExist) { |
| 146 | return false, err |
| 147 | } |
| 148 | } |
| 149 | return false, nil |
| 150 | } |
| 151 | |
| 152 | func (a *App) savedTabPseudoRouteIdentityConfirmed(ctx context.Context, entry desktopTabEntry, sessionID string, evidence savedTabReconcileEvidence, pendingID string, pendingFound bool) (bool, bool, bool) { |
| 153 | confirmed := pendingFound && pendingID == sessionID |
| 154 | owners := savedTabDurableRouteOwnerIDs(entry, sessionID, evidence) |
| 155 | if len(owners) > 1 || (len(owners) == 1 && !owners[sessionID]) { |
| 156 | return false, true, len(owners) > 0 |
| 157 | } |
| 158 | if owners[sessionID] { |
| 159 | confirmed = true |
| 160 | } |
| 161 | |
| 162 | info, err := a.desktopSessionService("").Query().Stat(ctx, session.SessionRef{HostID: localDesktopHostID, SessionID: sessionID}) |
| 163 | if err == nil { |
| 164 | workspace, consistent := savedTabCanonicalWorkspace(evidence.registry, info, sessionID) |
| 165 | if consistent && savedTabMatchesWorkspace(entry, workspace) { |
| 166 | confirmed = true |
| 167 | } |
| 168 | } |
| 169 | return confirmed, false, len(owners) > 0 |
| 170 | } |
| 171 | |
| 172 | func savedTabDurableRouteOwnerIDs(entry desktopTabEntry, candidateID string, evidence savedTabReconcileEvidence) map[string]bool { |
| 173 | owners := map[string]bool{} |
| 174 | operationID := strings.TrimSpace(entry.CreateOperationID) |
| 175 | rawPath := strings.TrimSpace(entry.SessionPath) |
| 176 | workspaceID := restoredWorkspaceID(entry) |
| 177 | for sessionID, pending := range evidence.registry.PendingCreates { |
| 178 | if (operationID != "" && pending.OperationID == operationID) || (sessionID == candidateID && pending.WorkspaceID == workspaceID) { |
| 179 | owners[strings.TrimSpace(sessionID)] = true |
| 180 | } |
| 181 | } |
| 182 | for _, operation := range evidence.draftOps { |
| 183 | if operationID != "" && operation.ID == operationID { |
| 184 | owners[strings.TrimSpace(operation.SessionID)] = true |
| 185 | } |
| 186 | } |
| 187 | for _, operation := range evidence.registry.PendingOperations { |
| 188 | if operationID != "" && strings.TrimSpace(operation.ID) == operationID { |
| 189 | for _, id := range operation.SessionIDs { |
| 190 | owners[strings.TrimSpace(id)] = true |
| 191 | } |
| 192 | } |
| 193 | if operation.Mapping != nil && strings.TrimSpace(operation.Mapping.Path) == rawPath { |
| 194 | owners[strings.TrimSpace(operation.Mapping.SessionID)] = true |
| 195 | } |
| 196 | if operation.WorkspaceID == workspaceID && slices.Contains(operation.SessionIDs, candidateID) { |
| 197 | owners[candidateID] = true |
| 198 | } |
| 199 | } |
| 200 | for _, mapping := range evidence.registry.SourceMappings { |
| 201 | if strings.TrimSpace(mapping.Path) == rawPath { |
| 202 | owners[strings.TrimSpace(mapping.SessionID)] = true |
| 203 | } |
| 204 | if mapping.WorkspaceID == workspaceID && mapping.SessionID == candidateID { |
| 205 | owners[candidateID] = true |
| 206 | } |
| 207 | } |
| 208 | for _, recovery := range evidence.registry.RecoveryEntries { |
| 209 | if strings.TrimSpace(recovery.Path) == rawPath { |
| 210 | owners[strings.TrimSpace(recovery.SessionID)] = true |
| 211 | } |
| 212 | if recovery.SessionID == candidateID && (recovery.WorkspaceID == workspaceID || recovery.Scope == entry.Scope && sameDesktopPath(recovery.WorkspaceRoot, entry.WorkspaceRoot)) { |
| 213 | owners[candidateID] = true |
| 214 | } |
| 215 | } |
| 216 | delete(owners, "") |
| 217 | return owners |
| 218 | } |
| 219 | |
| 220 | func blockSavedTabUnsafeRestore(file desktopTabsFile, reason string) desktopTabsFile { |
| 221 | file.Tabs = append([]desktopTabEntry(nil), file.Tabs...) |
| 222 | for index := range file.Tabs { |
| 223 | path := strings.TrimSpace(file.Tabs[index].SessionPath) |
| 224 | unsafe := savedTabRouteCandidateForPath(path).kind != "" |
| 225 | if !unsafe && path != "" { |
| 226 | _, ok, err := legacySessionPathForFileAccess(path) |
| 227 | unsafe = err != nil || !ok |
| 228 | } |
| 229 | if unsafe { |
| 230 | file.Tabs[index].restoreBlocked = true |
| 231 | file.Tabs[index].restoreBlockReason = reason |
| 232 | } |
| 233 | } |
| 234 | return file |
| 235 | } |
| 236 |