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
51export function isFirstRunWorkspaceProvenanceAuthoritative(input: {
52 readonly welcomeReceived: boolean;
53 readonly bootstrapStatus: "pending" | "complete" | null;
54}): boolean {
55 return input.welcomeReceived && input.bootstrapStatus !== "pending";
56}
57
58export function transitionFirstRunGateState(
59 state: FirstRunGateState,
60 event: FirstRunGateEvent,
61): FirstRunGateState {
62 if (event.type === "timeout") {
63 return state.decision === "pending" && !state.stalled ? { ...state, stalled: true } : state;
64 }
65
66 if (event.decision === "pending" || state.decision === event.decision) {
67 return state;
68 }
69
70 return { decision: event.decision, stalled: false };
71}
72
73export function isFreshFirstRunWorkspace(input: FirstRunWorkspaceInput): boolean {
74 if (input.projects.length > 1 || input.threads.length > 1) {
75 return false;
76 }
77
78 const bootstrapProject = input.projects[0];
79 if (bootstrapProject !== undefined) {
80 if (
81 input.bootstrapProjectCreated !== true ||
82 input.bootstrapProjectId !== bootstrapProject.id ||
83 input.serverCwd === null ||
84 bootstrapProject.environmentId !== input.primaryEnvironmentId ||
85 normalizeProjectPathForComparison(bootstrapProject.workspaceRoot) !==
86 normalizeProjectPathForComparison(input.serverCwd)
87 ) {
88 return false;
89 }
90 }
91
92 const bootstrapThread = input.threads[0];
93 if (bootstrapThread === undefined) {
94 return true;
95 }
96
97 return (
98 bootstrapProject !== undefined &&
99 input.bootstrapThreadCreated === true &&
100 input.bootstrapThreadId === bootstrapThread.id &&
101 bootstrapThread.environmentId === input.primaryEnvironmentId &&
102 bootstrapThread.projectId === bootstrapProject.id &&
103 bootstrapThread.latestRun === null &&
104 bootstrapThread.latestUserMessageAt === null &&
105 bootstrapThread.runtime === null
106 );
107}
108
109export function resolveFirstRunDecision(input: FirstRunDecisionInput): {
110 readonly decision: FirstRunDecision;
111 readonly persistCompletion: boolean;
112} {
113 if (!input.enabled || (input.hydrated && input.completed)) {
114 return { decision: "app", persistCompletion: false };
115 }
116
117 if (!input.hydrated) {
118 return { decision: "pending", persistCompletion: false };
119 }
120
121 if (input.projectCount > 1 || input.threadCount > 1) {
122 return {
123 decision: "app",
124 persistCompletion:
125 input.bootstrapped &&
126 input.authoritative &&
127 input.workspaceAuthoritative &&
128 input.catalogReady &&
129 input.serverConfigAvailable,
130 };
131 }
132
133 if (
134 !input.bootstrapped ||
135 !input.authoritative ||
136 !input.workspaceProvenanceAuthoritative ||
137 !input.catalogReady ||
138 !input.serverConfigAvailable
139 ) {
140 return { decision: "pending", persistCompletion: false };
141 }
142
143 return input.workspaceFresh
144 ? { decision: "wizard", persistCompletion: false }
145 : { decision: "app", persistCompletion: input.workspaceAuthoritative };
146}
147
148export function resolveHostedFirstRunDecision(hydrated: boolean): {
149 readonly decision: FirstRunDecision;
150 readonly persistCompletion: boolean;
151} {
152 return { decision: hydrated ? "app" : "pending", persistCompletion: false };
153}