| 1 | import { normalizeProjectPathForComparison } from "@t3tools/shared/path"; |
| 2 | |
| 3 | export type FirstRunDecision = "pending" | "app" | "wizard"; |
| 4 | |
| 5 | export interface FirstRunGateState { |
| 6 | readonly decision: FirstRunDecision; |
| 7 | readonly stalled: boolean; |
| 8 | } |
| 9 | |
| 10 | type FirstRunGateEvent = |
| 11 | | { readonly type: "evidence"; readonly decision: FirstRunDecision } |
| 12 | | { readonly type: "timeout" }; |
| 13 | |
| 14 | interface FirstRunWorkspaceInput { |
| 15 | readonly primaryEnvironmentId: string | null; |
| 16 | readonly serverCwd: string | null; |
| 17 | readonly bootstrapProjectId?: string | undefined; |
| 18 | readonly bootstrapThreadId?: string | undefined; |
| 19 | readonly bootstrapProjectCreated?: boolean | undefined; |
| 20 | readonly bootstrapThreadCreated?: boolean | undefined; |
| 21 | readonly projects: ReadonlyArray<{ |
| 22 | readonly id: string; |
| 23 | readonly environmentId: string; |
| 24 | readonly workspaceRoot: string; |
| 25 | }>; |
| 26 | readonly threads: ReadonlyArray<{ |
| 27 | readonly id: string; |
| 28 | readonly projectId: string; |
| 29 | readonly environmentId: string; |
| 30 | readonly latestRun: unknown; |
| 31 | readonly latestUserMessageAt: string | null; |
| 32 | readonly runtime: unknown; |
| 33 | }>; |
| 34 | } |
| 35 | |
| 36 | interface FirstRunDecisionInput { |
| 37 | readonly enabled: boolean; |
| 38 | readonly hydrated: boolean; |
| 39 | readonly completed: boolean; |
| 40 | readonly bootstrapped: boolean; |
| 41 | readonly authoritative: boolean; |
| 42 | readonly workspaceAuthoritative: boolean; |
| 43 | readonly workspaceProvenanceAuthoritative: boolean; |
| 44 | readonly catalogReady: boolean; |
| 45 | readonly serverConfigAvailable: boolean; |
| 46 | readonly workspaceFresh: boolean; |
| 47 | readonly projectCount: number; |
| 48 | readonly threadCount: number; |
| 49 | } |
| 50 | |
| 51 | interface HostedFirstRunDecisionInput { |
| 52 | readonly localEnvironmentDisabled?: boolean; |
| 53 | readonly hydrated: boolean; |
| 54 | readonly completed: boolean; |
| 55 | readonly catalogReady: boolean; |
| 56 | readonly environmentCount: number; |
| 57 | } |
| 58 | |
| 59 | export function isFirstRunWorkspaceProvenanceAuthoritative(input: { |
| 60 | readonly welcomeReceived: boolean; |
| 61 | readonly bootstrapStatus: "pending" | "complete" | null; |
| 62 | }): boolean { |
| 63 | // An empty catalog is not final while cwd auto-bootstrap is pending. Older |
| 64 | // servers omit bootstrapStatus, so a received welcome with null stays valid. |
| 65 | return input.welcomeReceived && input.bootstrapStatus !== "pending"; |
| 66 | } |
| 67 | |
| 68 | /** Keeps the authenticated app unmounted until workspace evidence settles. */ |
| 69 | export function transitionFirstRunGateState( |
| 70 | state: FirstRunGateState, |
| 71 | event: FirstRunGateEvent, |
| 72 | ): FirstRunGateState { |
| 73 | if (event.type === "timeout") { |
| 74 | return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state; |
| 75 | } |
| 76 | |
| 77 | if ( |
| 78 | state.decision === "wizard" || |
| 79 | event.decision === "pending" || |
| 80 | (state.decision === "app" && event.decision !== "wizard") |
| 81 | ) { |
| 82 | return state; |
| 83 | } |
| 84 | |
| 85 | return { decision: event.decision, stalled: false }; |
| 86 | } |
| 87 | |
| 88 | /** Only a project and thread created by this startup count as a fresh nonempty workspace. */ |
| 89 | export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean { |
| 90 | if (input.projects.length > 1 || input.threads.length > 1) { |
| 91 | return false; |
| 92 | } |
| 93 | |
| 94 | const bootstrapProject = input.projects[0]; |
| 95 | if (bootstrapProject !== undefined) { |
| 96 | if ( |
| 97 | input.bootstrapProjectCreated !== true || |
| 98 | input.bootstrapProjectId !== bootstrapProject.id || |
| 99 | input.serverCwd === null || |
| 100 | bootstrapProject.environmentId !== input.primaryEnvironmentId || |
| 101 | normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !== |
| 102 | normalizeProjectPathForComparison(input.serverCwd) |
| 103 | ) { |
| 104 | return false; |
| 105 | } |
| 106 | } |
| 107 | |
| 108 | const bootstrapThread = input.threads[0]; |
| 109 | if (bootstrapThread === undefined) { |
| 110 | return true; |
| 111 | } |
| 112 | |
| 113 | return ( |
| 114 | bootstrapProject !== undefined && |
| 115 | input.bootstrapThreadCreated === true && |
| 116 | input.bootstrapThreadId === bootstrapThread.id && |
| 117 | bootstrapThread.environmentId === input.primaryEnvironmentId && |
| 118 | bootstrapThread.projectId === bootstrapProject.id && |
| 119 | bootstrapThread.latestRun === null && |
| 120 | bootstrapThread.latestUserMessageAt === null && |
| 121 | bootstrapThread.runtime === null |
| 122 | ); |
| 123 | } |
| 124 | |
| 125 | /** Cached projects may open the app, but only live workspace data may complete onboarding. */ |
| 126 | export function resolveFirstRunDecision(input: FirstRunDecisionInput): { |
| 127 | readonly decision: FirstRunDecision; |
| 128 | readonly persistCompletion: boolean; |
| 129 | } { |
| 130 | if (!input.enabled || (input.hydrated && input.completed)) { |
| 131 | return { decision: "app", persistCompletion: false }; |
| 132 | } |
| 133 | |
| 134 | if (!input.hydrated) { |
| 135 | return { decision: "pending", persistCompletion: false }; |
| 136 | } |
| 137 | |
| 138 | if (input.projectCount > 1 || input.threadCount > 1) { |
| 139 | return { |
| 140 | decision: "app", |
| 141 | persistCompletion: |
| 142 | input.bootstrapped && |
| 143 | input.authoritative && |
| 144 | input.workspaceAuthoritative && |
| 145 | input.catalogReady && |
| 146 | input.serverConfigAvailable, |
| 147 | }; |
| 148 | } |
| 149 | |
| 150 | if ( |
| 151 | !input.bootstrapped || |
| 152 | !input.authoritative || |
| 153 | !input.workspaceProvenanceAuthoritative || |
| 154 | !input.catalogReady || |
| 155 | !input.serverConfigAvailable |
| 156 | ) { |
| 157 | return { decision: "pending", persistCompletion: false }; |
| 158 | } |
| 159 | |
| 160 | return input.workspaceFresh |
| 161 | ? { decision: "wizard", persistCompletion: false } |
| 162 | : { decision: "app", persistCompletion: input.workspaceAuthoritative }; |
| 163 | } |
| 164 | |
| 165 | /** Hosted onboarding depends on saved environments because there is no primary server. */ |
| 166 | export function resolveHostedFirstRunDecision(input: HostedFirstRunDecisionInput): { |
| 167 | readonly decision: FirstRunDecision; |
| 168 | readonly persistCompletion: boolean; |
| 169 | } { |
| 170 | if (!input.hydrated) { |
| 171 | return { decision: "pending", persistCompletion: false }; |
| 172 | } |
| 173 | |
| 174 | if (input.completed) { |
| 175 | return { decision: "app", persistCompletion: false }; |
| 176 | } |
| 177 | |
| 178 | if (!input.catalogReady) { |
| 179 | return { decision: "pending", persistCompletion: false }; |
| 180 | } |
| 181 | |
| 182 | // An existing desktop may have disabled its server before onboarding existed. |
| 183 | // Keep Connections accessible so it can turn local execution back on. |
| 184 | return input.environmentCount === 0 && !input.localEnvironmentDisabled |
| 185 | ? { decision: "wizard", persistCompletion: false } |
| 186 | : { decision: "app", persistCompletion: true }; |
| 187 | } |