1import { normalizeProjectPathForComparison } from "@t3tools/shared/path";
2
3export type FirstRunDecision = "pending" | "app" | "wizard";
4
5export interface FirstRunGateState {
6 readonly decision: FirstRunDecision;
7 readonly stalled: boolean;
8}
9
10type FirstRunGateEvent =
11 | { readonly type: "evidence"; readonly decision: FirstRunDecision }
12 | { readonly type: "timeout" };
13
14interface 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
36interface 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
51interface HostedFirstRunDecisionInput {
52 readonly localEnvironmentDisabled?: boolean;
53 readonly hydrated: boolean;
54 readonly completed: boolean;
55 readonly catalogReady: boolean;
56 readonly environmentCount: number;
57}
58
59export 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. */
69export 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. */
89export 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. */
126export 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. */
166export 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}