History

Merge origin/main into codething/50e6e9da

Julius Marminge committed 7 months ago

Commit 037a7712 parents 07252e5 f297e30599 files · +71384 −18403Browse files

Unified · Split

599 files
  1. .devcontainer/devcontainer.json +22 −0
  2. .docs/remote-architecture.md +302 −0
  3. .github/workflows/ci.yml +9 −4
  4. .github/workflows/release.yml +181 −47
  5. .gitignore +1 −0
  6. .oxfmtrc.json +3 −1
  7. .plans/18-server-auth-model.md +823 −0
  8. CLAUDE.md +1 −1
  9. KEYBINDINGS.md +2 −0
  10. README.md +8 −0
  11. REMOTE.md +63 −44
  12. apps/desktop/package.json +5 −4
  13. apps/desktop/scripts/dev-electron.mjs +17 −10
  14. apps/desktop/scripts/electron-launcher.mjs +75 −39
  15. apps/desktop/scripts/smoke-test.mjs +1 −1
  16. apps/desktop/scripts/start-electron.mjs +1 −1
  17. apps/desktop/src/appBranding.test.ts +47 −0
  18. apps/desktop/src/appBranding.ts +28 −0
  19. apps/desktop/src/backendPort.test.ts +109 −0
  20. apps/desktop/src/backendPort.ts +83 −0
  21. apps/desktop/src/backendReadiness.test.ts +114 −0
  22. apps/desktop/src/backendReadiness.ts +107 −0
  23. apps/desktop/src/clientPersistence.test.ts +238 −0
  24. apps/desktop/src/clientPersistence.ts +229 −0
  25. apps/desktop/src/confirmDialog.test.ts +1 −1
  26. apps/desktop/src/desktopSettings.test.ts +147 −0
  27. apps/desktop/src/desktopSettings.ts +92 −0
  28. apps/desktop/src/main.ts +802 −101
  29. apps/desktop/src/preload.ts +40 −5
  30. apps/desktop/src/runtimeArch.test.ts +1 −1
  31. apps/desktop/src/serverExposure.test.ts +139 −0
  32. apps/desktop/src/serverExposure.ts +80 −0
  33. apps/desktop/src/serverListeningDetector.test.ts +31 −0
  34. apps/desktop/src/serverListeningDetector.ts +56 −0
  35. apps/desktop/src/syncShellEnvironment.test.ts +191 −7
  36. apps/desktop/src/syncShellEnvironment.ts +85 −12
  37. apps/desktop/src/updateChannels.test.ts +41 −0
  38. apps/desktop/src/updateChannels.ts +18 −0
  39. apps/desktop/src/updateMachine.test.ts +8 −7
  40. apps/desktop/src/updateMachine.ts +8 −2
  41. apps/desktop/src/updateState.test.ts +31 −1
  42. apps/desktop/src/updateState.ts +4 −0
  43. apps/desktop/tsconfig.json +1 −1
  44. apps/desktop/tsdown.config.ts +1 −1
  45. apps/marketing/src/pages/download.astro +5 −4
  46. apps/server/integration/OrchestrationEngineHarness.integration.ts +23 −4
  47. apps/server/integration/TestProviderAdapter.integration.ts +3 −3
  48. apps/server/integration/fixtures/providerRuntime.ts +2 −2
  49. apps/server/integration/orchestrationEngine.integration.test.ts +18 −19
  50. apps/server/integration/providerService.integration.test.ts +29 −41
  51. apps/server/package.json +6 −4
  52. apps/server/scripts/cli.ts +33 −16
  53. apps/server/src/auth/Layers/AuthControlPlane.test.ts +111 −0
  54. apps/server/src/auth/Layers/AuthControlPlane.ts +178 −0
  55. apps/server/src/auth/Layers/BootstrapCredentialService.test.ts +151 −0
  56. apps/server/src/auth/Layers/BootstrapCredentialService.ts +296 −0
  57. apps/server/src/auth/Layers/ServerAuth.test.ts +182 −0
  58. apps/server/src/auth/Layers/ServerAuth.ts +395 −0
  59. apps/server/src/auth/Layers/ServerAuthPolicy.test.ts +114 −0
  60. apps/server/src/auth/Layers/ServerAuthPolicy.ts +44 −0
  61. apps/server/src/auth/Layers/ServerSecretStore.test.ts +263 −0
  62. apps/server/src/auth/Layers/ServerSecretStore.ts +148 −0
  63. apps/server/src/auth/Layers/SessionCredentialService.test.ts +191 −0
  64. apps/server/src/auth/Layers/SessionCredentialService.ts +500 −0
  65. apps/server/src/auth/Services/AuthControlPlane.ts +69 −0
  66. apps/server/src/auth/Services/BootstrapCredentialService.ts +57 −0
  67. apps/server/src/auth/Services/ServerAuth.ts +84 −0
  68. apps/server/src/auth/Services/ServerAuthPolicy.ts +11 −0
  69. apps/server/src/auth/Services/ServerSecretStore.ts +21 −0
  70. apps/server/src/auth/Services/SessionCredentialService.ts +87 −0
  71. apps/server/src/auth/http.ts +254 −0
  72. apps/server/src/auth/utils.test.ts +26 −0
  73. apps/server/src/auth/utils.ts +132 −0
  74. apps/server/src/bin.ts +3 −3
  75. apps/server/src/bootstrap.test.ts +1 −1
  76. apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts +14 −4
  77. apps/server/src/checkpointing/Layers/CheckpointStore.test.ts +1 −1
  78. apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts +2 −2
  79. apps/server/src/checkpointing/Services/CheckpointStore.ts +3 −3
  80. apps/server/src/checkpointing/Utils.ts +1 −1
  81. apps/server/src/cli-config.test.ts +134 −17
  82. apps/server/src/cli.test.ts +326 −7
  83. apps/server/src/cli.ts +854 −89
  84. apps/server/src/cliAuthFormat.test.ts +88 −0
  85. apps/server/src/cliAuthFormat.ts +190 −0
  86. apps/server/src/codexAppServerManager.test.ts +154 −6
  87. apps/server/src/codexAppServerManager.ts +75 −35
  88. apps/server/src/config.ts +20 −4
  89. apps/server/src/environment/Layers/ServerEnvironment.test.ts +125 −0
  90. apps/server/src/environment/Layers/ServerEnvironment.ts +92 −0
  91. apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts +150 −0
  92. apps/server/src/environment/Layers/ServerEnvironmentLabel.ts +106 −0
  93. apps/server/src/environment/Services/ServerEnvironment.ts +12 −0
  94. apps/server/src/git/Layers/CodexTextGeneration.ts +1 −0
  95. apps/server/src/git/Layers/GitCore.test.ts +183 −63
  96. apps/server/src/git/Layers/GitCore.ts +109 −36
  97. apps/server/src/git/Layers/GitHubCli.test.ts +100 −1
  98. apps/server/src/git/Layers/GitHubCli.ts +41 −81
  99. apps/server/src/git/Layers/GitManager.test.ts +188 −3
  100. apps/server/src/git/Layers/GitManager.ts +136 −119
  101. apps/server/src/git/Layers/GitStatusBroadcaster.test.ts +312 −0
  102. apps/server/src/git/Layers/GitStatusBroadcaster.ts +311 −0
  103. apps/server/src/git/Layers/RoutingTextGeneration.ts +3 −3
  104. apps/server/src/git/Services/GitCore.ts +14 −7
  105. apps/server/src/git/Services/GitHubCli.ts +3 −3
  106. apps/server/src/git/Services/GitManager.ts +33 −2
  107. apps/server/src/git/Services/GitStatusBroadcaster.ts +27 −0
  108. apps/server/src/git/Services/TextGeneration.ts +2 −2
  109. apps/server/src/git/githubPullRequests.ts +128 −0
  110. apps/server/src/http.test.ts +27 −0
  111. apps/server/src/http.ts +127 −12
  112. apps/server/src/keybindings.test.ts +20 −3
  113. apps/server/src/keybindings.ts +7 −6
  114. apps/server/src/observability/Layers/Observability.ts +23 −3
  115. apps/server/src/observability/LocalFileTracer.test.ts +3 −3
  116. apps/server/src/observability/LocalFileTracer.ts +17 −9
  117. apps/server/src/observability/Services/BrowserTraceCollector.ts +13 −0
  118. apps/server/src/observability/TraceRecord.ts +202 −15
  119. apps/server/src/open.test.ts +91 −2
  120. apps/server/src/open.ts +50 −124
  121. apps/server/src/orchestration/Layers/CheckpointReactor.test.ts +164 −119
  122. apps/server/src/orchestration/Layers/CheckpointReactor.ts +31 −8
  123. apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +77 −62
  124. apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts +627 −302
  125. apps/server/src/orchestration/Layers/ProjectionPipeline.ts +173 −2
  126. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts +410 −13
  127. apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +860 −245
  128. apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts +232 −152
  129. apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +10 −11
  130. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts +45 −49
  131. apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts +9 −10
  132. apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts +20 −2
  133. apps/server/src/orchestration/Normalizer.ts +26 −5
  134. apps/server/src/orchestration/Services/CheckpointReactor.ts +4 −5
  135. apps/server/src/orchestration/Services/OrchestrationEngine.ts +3 −3
  136. apps/server/src/orchestration/Services/OrchestrationReactor.ts +2 −2
  137. apps/server/src/orchestration/Services/ProjectionPipeline.ts +2 −2
  138. apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts +38 −2
  139. apps/server/src/orchestration/Services/ProviderCommandReactor.ts +2 −2
  140. apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts +2 −2
  141. apps/server/src/orchestration/Services/RuntimeReceiptBus.ts +21 −6
  142. apps/server/src/orchestration/commandInvariants.test.ts +24 −26
  143. apps/server/src/orchestration/decider.projectScripts.test.ts +34 −34
  144. apps/server/src/orchestration/http.ts +93 −0
  145. apps/server/src/orchestration/projector.test.ts +4 −4
  146. apps/server/src/orchestration/runtimeLayer.ts +27 −0
  147. apps/server/src/os-jank.test.ts +150 −4
  148. apps/server/src/os-jank.ts +65 −10
  149. apps/server/src/persistence/Errors.ts +2 −0
  150. apps/server/src/persistence/Layers/AuthPairingLinks.ts +209 −0
  151. apps/server/src/persistence/Layers/AuthSessions.ts +279 −0
  152. apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts +8 −8
  153. apps/server/src/persistence/Layers/ProjectionRepositories.test.ts +9 −5
  154. apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts +4 −4
  155. apps/server/src/persistence/Layers/ProjectionThreads.ts +20 −0
  156. apps/server/src/persistence/Migrations.ts +12 −0
  157. apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts +42 −0
  158. apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts +62 −0
  159. apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts +17 −0
  160. apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts +26 −0
  161. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts +218 −0
  162. apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts +277 −0
  163. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts +196 −0
  164. apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts +27 −0
  165. apps/server/src/persistence/NodeSqliteClient.ts +10 −10
  166. apps/server/src/persistence/Services/AuthPairingLinks.ts +76 −0
  167. apps/server/src/persistence/Services/AuthSessions.ts +93 −0
  168. apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts +2 −2
  169. apps/server/src/persistence/Services/OrchestrationEventStore.ts +3 −3
  170. apps/server/src/persistence/Services/ProjectionCheckpoints.ts +2 −2
  171. apps/server/src/persistence/Services/ProjectionPendingApprovals.ts +2 −2
  172. apps/server/src/persistence/Services/ProjectionProjects.ts +2 −2
  173. apps/server/src/persistence/Services/ProjectionState.ts +2 −2
  174. apps/server/src/persistence/Services/ProjectionThreadActivities.ts +2 −2
  175. apps/server/src/persistence/Services/ProjectionThreadMessages.ts +2 −2
  176. apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts +2 −2
  177. apps/server/src/persistence/Services/ProjectionThreadSessions.ts +2 −2
  178. apps/server/src/persistence/Services/ProjectionThreads.ts +7 −2
  179. apps/server/src/persistence/Services/ProjectionTurns.ts +2 −2
  180. apps/server/src/persistence/Services/ProviderSessionRuntime.ts +2 −2
  181. apps/server/src/process/Layers/TerminalProcessInspector.test.ts +2 −2
  182. apps/server/src/process/Layers/TerminalProcessInspector.ts +6 −6
  183. apps/server/src/process/Layers/WebPortInspector.test.ts +2 −2
  184. apps/server/src/process/Layers/WebPortInspector.ts +3 −3
  185. apps/server/src/process/Services/TerminalProcessInspector.ts +2 −2
  186. apps/server/src/process/Services/WebPortInspector.ts +2 −2
  187. apps/server/src/process/posix.test.ts +1 −1
  188. apps/server/src/process/posix.ts +2 −2
  189. apps/server/src/process/win32.test.ts +1 −1
  190. apps/server/src/process/win32.ts +2 −2
  191. apps/server/src/processRunner.test.ts +1 −1
  192. apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts +223 −0
  193. apps/server/src/project/Layers/RepositoryIdentityResolver.ts +151 −0
  194. apps/server/src/project/Services/ProjectFaviconResolver.ts +2 −2
  195. apps/server/src/project/Services/ProjectSetupScriptRunner.ts +2 −2
  196. apps/server/src/project/Services/RepositoryIdentityResolver.ts +12 −0
  197. apps/server/src/provider/Layers/ClaudeAdapter.test.ts +419 −69
  198. apps/server/src/provider/Layers/ClaudeAdapter.ts +243 −92
  199. apps/server/src/provider/Layers/ClaudeProvider.ts +225 −74
  200. apps/server/src/provider/Layers/CodexAdapter.test.ts +11 −15
  201. apps/server/src/provider/Layers/CodexAdapter.ts +18 −21
  202. apps/server/src/provider/Layers/CodexProvider.ts +108 −24
  203. apps/server/src/provider/Layers/EventNdjsonLogger.test.ts +46 −3
  204. apps/server/src/provider/Layers/EventNdjsonLogger.ts +65 −27
  205. apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts +4 −2
  206. apps/server/src/provider/Layers/ProviderRegistry.test.ts +263 −7
  207. apps/server/src/provider/Layers/ProviderRegistry.ts +138 −38
  208. apps/server/src/provider/Layers/ProviderService.test.ts +46 −7
  209. apps/server/src/provider/Layers/ProviderService.ts +40 −10
  210. apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts +77 −5
  211. apps/server/src/provider/Layers/ProviderSessionDirectory.ts +38 −12
  212. apps/server/src/provider/Layers/ProviderSessionReaper.test.ts +522 −0
  213. apps/server/src/provider/Layers/ProviderSessionReaper.ts +133 −0
  214. apps/server/src/provider/Services/ClaudeAdapter.ts +3 −3
  215. apps/server/src/provider/Services/ClaudeProvider.ts +3 −3
  216. apps/server/src/provider/Services/CodexAdapter.ts +3 −3
  217. apps/server/src/provider/Services/CodexProvider.ts +3 −3
  218. apps/server/src/provider/Services/ProviderAdapterRegistry.ts +2 −2
  219. apps/server/src/provider/Services/ProviderRegistry.ts +2 −2
  220. apps/server/src/provider/Services/ProviderService.ts +3 −3
  221. apps/server/src/provider/Services/ProviderSessionDirectory.ts +11 −2
  222. apps/server/src/provider/Services/ProviderSessionReaper.ts +14 −0
  223. apps/server/src/provider/cliVersion.test.ts +17 −0
  224. apps/server/src/provider/cliVersion.ts +123 −0
  225. apps/server/src/provider/codexAppServer.ts +96 −9
  226. apps/server/src/provider/codexCliVersion.ts +4 −120
  227. apps/server/src/provider/makeManagedServerProvider.ts +3 −2
  228. apps/server/src/provider/providerSnapshot.ts +11 −4
  229. apps/server/src/provider/providerStatusCache.test.ts +136 −0
  230. apps/server/src/provider/providerStatusCache.ts +105 −0
  231. apps/server/src/server.test.ts +2827 −696
  232. apps/server/src/server.ts +157 −87
  233. apps/server/src/serverLifecycleEvents.test.ts +10 −0
  234. apps/server/src/serverLifecycleEvents.ts +2 −2
  235. apps/server/src/serverLogger.ts +1 −1
  236. apps/server/src/serverRuntimeStartup.test.ts +129 −2
  237. apps/server/src/serverRuntimeStartup.ts +143 −58
  238. apps/server/src/serverRuntimeState.ts +77 −0
  239. apps/server/src/serverSettings.test.ts +37 −2
  240. apps/server/src/serverSettings.ts +21 −5
  241. apps/server/src/startupAccess.test.ts +79 −0
  242. apps/server/src/startupAccess.ts +148 −0
  243. apps/server/src/telemetry/Identify.ts +1 −1
  244. apps/server/src/telemetry/Layers/AnalyticsService.ts +2 −2
  245. apps/server/src/telemetry/Services/AnalyticsService.ts +2 −2
  246. apps/server/src/terminal/Layers/BunPTY.ts +5 −2
  247. apps/server/src/terminal/Layers/Manager.test.ts +83 −12
  248. apps/server/src/terminal/Layers/Manager.ts +96 −48
  249. apps/server/src/terminal/Layers/NodePTY.test.ts +1 −1
  250. apps/server/src/terminal/Layers/NodePTY.ts +27 −8
  251. apps/server/src/terminal/Services/Manager.ts +3 −3
  252. apps/server/src/terminal/Services/PTY.ts +2 −2
  253. apps/server/src/workspace/Layers/WorkspaceEntries.test.ts +98 −0
  254. apps/server/src/workspace/Layers/WorkspaceEntries.ts +133 −129
  255. apps/server/src/workspace/Layers/WorkspacePaths.test.ts +18 −0
  256. apps/server/src/workspace/Layers/WorkspacePaths.ts +17 −2
  257. apps/server/src/workspace/Services/WorkspaceEntries.ts +26 −3
  258. apps/server/src/workspace/Services/WorkspaceFileSystem.ts +2 −2
  259. apps/server/src/workspace/Services/WorkspacePaths.ts +20 −3
  260. apps/server/src/ws.ts +1006 −660
  261. apps/server/tsconfig.json +1 −3
  262. apps/server/vitest.config.ts +1 −1
  263. apps/web/index.html +83 −2
  264. apps/web/package.json +3 −2
  265. apps/web/src/authBootstrap.test.ts +495 −0
  266. apps/web/src/branding.test.ts +37 −0
  267. apps/web/src/branding.ts +17 −3
  268. apps/web/src/chat-scroll.test.ts +0 −62
  269. apps/web/src/chat-scroll.ts +0 −24
  270. apps/web/src/clientPersistenceStorage.test.ts +80 −0
  271. apps/web/src/clientPersistenceStorage.ts +194 −0
  272. apps/web/src/commandPaletteStore.ts +32 −0
  273. apps/web/src/components/BranchToolbar.logic.test.ts +93 −1
  274. apps/web/src/components/BranchToolbar.logic.ts +54 −4
  275. apps/web/src/components/BranchToolbar.tsx +83 −137
  276. apps/web/src/components/BranchToolbarBranchSelector.tsx +222 −148
  277. apps/web/src/components/BranchToolbarEnvModeSelector.tsx +98 −0
  278. apps/web/src/components/BranchToolbarEnvironmentSelector.tsx +88 −0
  279. apps/web/src/components/ChatMarkdown.browser.tsx +141 −0
  280. apps/web/src/components/ChatMarkdown.tsx +294 −20
  281. apps/web/src/components/ChatView.browser.tsx +3469 −837
  282. apps/web/src/components/ChatView.logic.test.ts +384 −121
  283. apps/web/src/components/ChatView.logic.ts +77 −24
  284. apps/web/src/components/ChatView.tsx +1118 −2187
  285. apps/web/src/components/CommandPalette.logic.test.ts +166 −0
  286. apps/web/src/components/CommandPalette.logic.ts +359 −0
  287. apps/web/src/components/CommandPalette.tsx +1115 −0
  288. apps/web/src/components/CommandPaletteResults.tsx +116 −0
  289. apps/web/src/components/ComposerPromptEditor.tsx +610 −71
  290. apps/web/src/components/DiffPanel.tsx +35 −22
  291. apps/web/src/components/DiffPanelShell.tsx +4 −2
  292. apps/web/src/components/GitActionsControl.browser.tsx +294 −70
  293. apps/web/src/components/GitActionsControl.logic.test.ts +9 −0
  294. apps/web/src/components/GitActionsControl.logic.ts +10 −0
  295. apps/web/src/components/GitActionsControl.tsx +138 −38
  296. apps/web/src/components/Icons.tsx +139 −0
  297. apps/web/src/components/KeybindingsToast.browser.tsx +193 −28
  298. apps/web/src/components/NoActiveThreadState.tsx +45 −0
  299. apps/web/src/components/PlanSidebar.tsx +20 −6
  300. apps/web/src/components/ProjectFavicon.tsx +14 −7
  301. apps/web/src/components/PullRequestThreadDialog.tsx +7 −3
  302. apps/web/src/components/RightPanelSheet.tsx +30 −0
  303. apps/web/src/components/Sidebar.logic.test.ts +134 −230
  304. apps/web/src/components/Sidebar.logic.ts +26 −62
  305. apps/web/src/components/Sidebar.tsx +2681 −1542
  306. apps/web/src/components/SplashScreen.tsx +9 −0
  307. apps/web/src/components/ThreadStatusIndicators.tsx +241 −0
  308. apps/web/src/components/ThreadTerminalDrawer.browser.tsx +318 −0
  309. apps/web/src/components/ThreadTerminalDrawer.tsx +90 −33
  310. apps/web/src/components/WebSocketConnectionSurface.logic.test.ts +113 −0
  311. apps/web/src/components/WebSocketConnectionSurface.tsx +390 −0
  312. apps/web/src/components/auth/PairingRouteSurface.tsx +195 −0
  313. apps/web/src/components/chat/ChangedFilesTree.test.tsx +2 −2
  314. apps/web/src/components/chat/ChatComposer.tsx +1999 −0
  315. apps/web/src/components/chat/ChatHeader.tsx +14 −1
  316. apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx +38 −29
  317. apps/web/src/components/chat/CompactComposerControlsMenu.tsx +7 −3
  318. apps/web/src/components/chat/ComposerCommandMenu.tsx +161 −27
  319. apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx +1 −1
  320. apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +39 −32
  321. apps/web/src/components/chat/ComposerPrimaryActions.test.ts +93 −0
  322. apps/web/src/components/chat/ComposerPrimaryActions.tsx +8 −3
  323. apps/web/src/components/chat/ExpandedImageDialog.tsx +120 −0
  324. apps/web/src/components/chat/MessageCopyButton.tsx +74 −12
  325. apps/web/src/components/chat/MessagesTimeline.browser.tsx +160 −0
  326. apps/web/src/components/chat/MessagesTimeline.logic.test.ts +291 −1
  327. apps/web/src/components/chat/MessagesTimeline.logic.ts +102 −61
  328. apps/web/src/components/chat/MessagesTimeline.test.tsx +93 −47
  329. apps/web/src/components/chat/MessagesTimeline.tsx +551 −421
  330. apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx +0 −1037
  331. apps/web/src/components/chat/OpenInPicker.tsx +13 −5
  332. apps/web/src/components/chat/ProposedPlanCard.tsx +5 −2
  333. apps/web/src/components/chat/ProviderModelPicker.browser.tsx +6 −0
  334. apps/web/src/components/chat/TraitsPicker.browser.tsx +35 −25
  335. apps/web/src/components/chat/TraitsPicker.tsx +12 −5
  336. apps/web/src/components/chat/composerMenuHighlight.test.ts +51 −0
  337. apps/web/src/components/chat/composerMenuHighlight.ts +20 −0
  338. apps/web/src/components/chat/composerProviderRegistry.test.tsx +33 −1
  339. apps/web/src/components/chat/composerProviderRegistry.tsx +93 −53
  340. apps/web/src/components/chat/composerSlashCommandSearch.test.ts +68 −0
  341. apps/web/src/components/chat/composerSlashCommandSearch.ts +83 −0
  342. apps/web/src/components/composerFooterLayout.test.ts +0 −99
  343. apps/web/src/components/composerFooterLayout.ts +0 −45
  344. apps/web/src/components/composerInlineChip.ts +3 −0
  345. apps/web/src/components/desktopUpdate.logic.test.ts +1 −0
  346. apps/web/src/components/settings/ConnectionsSettings.tsx +1432 −0
  347. apps/web/src/components/settings/SettingsPanels.browser.tsx +625 −12
  348. apps/web/src/components/settings/SettingsPanels.tsx +200 −158
  349. apps/web/src/components/settings/SettingsSidebarNav.tsx +9 −5
  350. apps/web/src/components/settings/settingsLayout.tsx +122 −0
  351. apps/web/src/components/timelineHeight.test.ts +0 −138
  352. apps/web/src/components/timelineHeight.ts +0 −107
  353. apps/web/src/components/ui/combobox.tsx +15 −0
  354. apps/web/src/components/ui/command.tsx +6 −3
  355. apps/web/src/components/ui/qr-code.test.tsx +27 −0
  356. apps/web/src/components/ui/qr-code.tsx +81 −0
  357. apps/web/src/components/ui/select.tsx +1 −1
  358. apps/web/src/components/ui/toast.logic.test.ts +52 −1
  359. apps/web/src/components/ui/toast.logic.ts +27 −0
  360. apps/web/src/components/ui/toast.tsx +48 −32
  361. apps/web/src/composer-editor-mentions.test.ts +88 −1
  362. apps/web/src/composer-editor-mentions.ts +185 −27
  363. apps/web/src/composer-logic.test.ts +53 −0
  364. apps/web/src/composer-logic.ts +50 −14
  365. apps/web/src/composerDraftStore.test.ts +360 −202
  366. apps/web/src/composerDraftStore.ts +1652 −984
  367. apps/web/src/composerHandleContext.ts +10 −0
  368. apps/web/src/contextMenuFallback.test.ts +221 −0
  369. apps/web/src/contextMenuFallback.ts +115 −47
  370. apps/web/src/diffRouteSearch.ts +1 −1
  371. apps/web/src/editorPreferences.ts +2 −2
  372. apps/web/src/environmentApi.ts +86 −0
  373. apps/web/src/environmentGrouping.test.ts +614 −0
  374. apps/web/src/environments/primary/auth.ts +373 −0
  375. apps/web/src/environments/primary/bootstrap.test.ts +170 −0
  376. apps/web/src/environments/primary/context.ts +120 −0
  377. apps/web/src/environments/primary/index.ts +35 −0
  378. apps/web/src/environments/primary/target.ts +158 −0
  379. apps/web/src/environments/remote/api.test.ts +235 −0
  380. apps/web/src/environments/remote/api.ts +143 −0
  381. apps/web/src/environments/remote/target.ts +86 −0
  382. apps/web/src/environments/runtime/catalog.test.ts +141 −0
  383. apps/web/src/environments/runtime/catalog.ts +341 −0
  384. apps/web/src/environments/runtime/connection.test.ts +249 −0
  385. apps/web/src/environments/runtime/connection.ts +173 −0
  386. apps/web/src/environments/runtime/index.ts +29 −0
  387. apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts +105 −0
  388. apps/web/src/environments/runtime/service.test.ts +41 −0
  389. apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +298 −0
  390. apps/web/src/environments/runtime/service.ts +1081 −0
  391. apps/web/src/filePathDisplay.test.ts +41 −0
  392. apps/web/src/filePathDisplay.ts +57 −0
  393. apps/web/src/historyBootstrap.test.ts +1 −1
  394. apps/web/src/hooks/useHandleNewThread.ts +112 −52
  395. apps/web/src/hooks/useSettings.test.ts +0 −16
  396. apps/web/src/hooks/useSettings.ts +101 −177
  397. apps/web/src/hooks/useTheme.ts +78 −5
  398. apps/web/src/hooks/useThreadActions.ts +138 −67
  399. apps/web/src/index.css +73 −6
  400. apps/web/src/keybindings.test.ts +55 −0
  401. apps/web/src/keybindings.ts +23 −0
  402. apps/web/src/lib/chatThreadActions.test.ts +107 −0
  403. apps/web/src/lib/chatThreadActions.ts +98 −0
  404. apps/web/src/lib/contextWindow.test.ts +2 −2
  405. apps/web/src/lib/desktopUpdateReactQuery.test.ts +1 −0
  406. apps/web/src/lib/gitReactQuery.test.ts +30 −42
  407. apps/web/src/lib/gitReactQuery.ts +119 −107
  408. apps/web/src/lib/gitStatusState.test.ts +325 −0
  409. apps/web/src/lib/gitStatusState.ts +287 −0
  410. apps/web/src/lib/projectPaths.test.ts +117 −0
  411. apps/web/src/lib/projectPaths.ts +259 −0
  412. apps/web/src/lib/projectReactQuery.ts +17 −8
  413. apps/web/src/lib/providerReactQuery.test.ts +12 −5
  414. apps/web/src/lib/providerReactQuery.ts +11 −4
  415. apps/web/src/lib/terminalContext.test.ts +1 −1
  416. apps/web/src/lib/terminalStateCleanup.test.ts +21 −14
  417. apps/web/src/lib/terminalStateCleanup.ts +9 −11
  418. apps/web/src/lib/threadSort.test.ts +187 −0
  419. apps/web/src/lib/threadSort.ts +73 −0
  420. apps/web/src/lib/utils.test.ts +1 −2
  421. apps/web/src/lib/utils.ts +6 −42
  422. apps/web/src/lib/windowControlsOverlay.ts +40 −0
  423. apps/web/src/localApi.test.ts +638 −0
  424. apps/web/src/localApi.ts +154 −0
  425. apps/web/src/logicalProject.ts +157 −0
  426. apps/web/src/main.tsx +5 −0
  427. apps/web/src/markdown-links.test.ts +42 −1
  428. apps/web/src/markdown-links.ts +57 −8
  429. apps/web/src/nativeApi.ts +0 −31
  430. apps/web/src/observability/clientTracing.ts +144 −0
  431. apps/web/src/orchestrationEventEffects.test.ts +15 −15
  432. apps/web/src/orchestrationEventEffects.ts +4 −4
  433. apps/web/src/orchestrationRecovery.ts +5 −1
  434. apps/web/src/pairingUrl.ts +33 −0
  435. apps/web/src/pendingUserInput.test.ts +113 −55
  436. apps/web/src/pendingUserInput.ts +63 −15
  437. apps/web/src/projectScripts.ts +1 −1
  438. apps/web/src/providerSkillPresentation.test.ts +57 −0
  439. apps/web/src/providerSkillPresentation.ts +52 −0
  440. apps/web/src/providerSkillSearch.test.ts +59 −0
  441. apps/web/src/providerSkillSearch.ts +105 −0
  442. apps/web/src/pullRequestReference.test.ts +2 −2
  443. apps/web/src/pullRequestReference.ts +1 −1
  444. apps/web/src/rightPanelLayout.ts +2 −0
  445. apps/web/src/routeTree.gen.ts +81 −17
  446. apps/web/src/routes/__root.tsx +116 −341
  447. apps/web/src/routes/_chat.$environmentId.$threadId.tsx +90 −62
  448. apps/web/src/routes/_chat.draft.$draftId.tsx +86 −0
  449. apps/web/src/routes/_chat.index.tsx +2 −26
  450. apps/web/src/routes/_chat.tsx +41 −25
  451. apps/web/src/routes/pair.tsx +40 −0
  452. apps/web/src/routes/settings.connections.tsx +7 −0
  453. apps/web/src/routes/settings.tsx +6 −2
  454. apps/web/src/rpc/client.test.ts +0 −231
  455. apps/web/src/rpc/client.ts +0 −33
  456. apps/web/src/rpc/protocol.ts +163 −13
  457. apps/web/src/rpc/requestLatencyState.test.ts +65 −0
  458. apps/web/src/rpc/requestLatencyState.ts +133 −0
  459. apps/web/src/rpc/serverState.test.ts +32 −6
  460. apps/web/src/rpc/serverState.ts +1 −1
  461. apps/web/src/rpc/transportError.test.ts +24 −0
  462. apps/web/src/rpc/transportError.ts +23 −0
  463. apps/web/src/rpc/wsConnectionState.test.ts +75 −0
  464. apps/web/src/rpc/wsConnectionState.ts +199 −0
  465. apps/web/src/rpc/wsRpcClient.test.ts +104 −0
  466. apps/web/src/rpc/wsRpcClient.ts +90 −41
  467. apps/web/src/rpc/wsTransport.test.ts +1105 −0
  468. apps/web/src/rpc/wsTransport.ts +287 −0
  469. apps/web/src/session-logic.test.ts +210 −45
  470. apps/web/src/session-logic.ts +216 −28
  471. apps/web/src/sidebarProjectGrouping.ts +118 −0
  472. apps/web/src/store.test.ts +550 −349
  473. apps/web/src/store.ts +1192 −388
  474. apps/web/src/storeSelectors.ts +62 −20
  475. apps/web/src/terminal-links.test.ts +104 −0
  476. apps/web/src/terminal-links.ts +112 −1
  477. apps/web/src/terminalStateStore.test.ts +127 −78
  478. apps/web/src/terminalStateStore.ts +165 −127
  479. apps/web/src/threadDerivation.ts +152 −0
  480. apps/web/src/threadRoutes.test.ts +67 −0
  481. apps/web/src/threadRoutes.ts +59 −0
  482. apps/web/src/threadSelectionStore.test.ts +59 −59
  483. apps/web/src/threadSelectionStore.ts +51 −53
  484. apps/web/src/timestampFormat.test.ts +86 −2
  485. apps/web/src/timestampFormat.ts +88 −2
  486. apps/web/src/types.ts +28 −0
  487. apps/web/src/uiStateStore.test.ts +185 −29
  488. apps/web/src/uiStateStore.ts +197 −49
  489. apps/web/src/vite-env.d.ts +2 −2
  490. apps/web/src/worktreeCleanup.test.ts +15 −12
  491. apps/web/src/wsNativeApi.test.ts +0 −358
  492. apps/web/src/wsNativeApi.ts +0 −107
  493. apps/web/src/wsTransport.test.ts +0 −443
  494. apps/web/src/wsTransport.ts +0 −131
  495. apps/web/test/authHttpHandlers.ts +41 −0
  496. apps/web/test/wsRpcHarness.ts +13 −4
  497. apps/web/tsconfig.json +4 −0
  498. apps/web/vite.config.ts +48 −2
  499. apps/web/vitest.browser.config.ts +8 −0
  500. assets/dev/blueprint-icon-composer.icon/Assets/T3.svg +3 −0
  501. assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg +0 −0
  502. assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png +0 −0
  503. assets/dev/blueprint-icon-composer.icon/icon.json +47 −0
  504. assets/dev/blueprint-ios-1024.png +0 −0
  505. assets/dev/blueprint-macos-1024.png +0 −0
  506. assets/dev/blueprint-universal-1024.png +0 −0
  507. assets/dev/blueprint-web-apple-touch-180.png +0 −0
  508. assets/dev/blueprint-web-favicon-16x16.png +0 −0
  509. assets/dev/blueprint-web-favicon-32x32.png +0 −0
  510. assets/dev/blueprint-web-favicon.ico +0 −0
  511. assets/dev/blueprint-windows.ico +0 −0
  512. assets/nightly/blueprint-ios-1024.png +0 −0
  513. assets/nightly/blueprint-macos-1024.png +0 −0
  514. assets/nightly/blueprint-universal-1024.png +0 −0
  515. assets/nightly/blueprint-web-apple-touch-180.png +0 −0
  516. assets/nightly/blueprint-web-favicon-16x16.png +0 −0
  517. assets/nightly/blueprint-web-favicon-32x32.png +0 −0
  518. assets/nightly/blueprint-web-favicon.ico +0 −0
  519. assets/nightly/blueprint-windows.ico +0 −0
  520. bun.lock +208 −27
  521. docs/observability.md +4 −4
  522. docs/release.md +37 −12
  523. package.json +21 −11
  524. packages/client-runtime/package.json +25 −0
  525. packages/client-runtime/src/index.ts +2 −0
  526. packages/client-runtime/src/knownEnvironment.test.ts +92 −0
  527. packages/client-runtime/src/knownEnvironment.ts +53 −0
  528. packages/client-runtime/src/scoped.ts +64 −0
  529. packages/client-runtime/tsconfig.json +4 −0
  530. packages/contracts/package.json +1 −1
  531. packages/contracts/src/auth.ts +266 −0
  532. packages/contracts/src/baseSchemas.ts +7 −2
  533. packages/contracts/src/editor.ts +13 −11
  534. packages/contracts/src/environment.ts +78 −0
  535. packages/contracts/src/filesystem.ts +30 −0
  536. packages/contracts/src/git.test.ts +1 −1
  537. packages/contracts/src/git.ts +50 −2
  538. packages/contracts/src/index.ts +17 −14
  539. packages/contracts/src/ipc.ts +145 −38
  540. packages/contracts/src/keybindings.test.ts +7 −1
  541. packages/contracts/src/keybindings.ts +2 −1
  542. packages/contracts/src/model.ts +20 −10
  543. packages/contracts/src/orchestration.test.ts +18 −1
  544. packages/contracts/src/orchestration.ts +140 −28
  545. packages/contracts/src/project.ts +1 −1
  546. packages/contracts/src/provider.test.ts +1 −1
  547. packages/contracts/src/provider.ts +3 −3
  548. packages/contracts/src/providerRuntime.test.ts +1 −1
  549. packages/contracts/src/providerRuntime.ts +4 −4
  550. packages/contracts/src/rpc.ts +64 −27
  551. packages/contracts/src/server.test.ts +26 −0
  552. packages/contracts/src/server.ts +40 −7
  553. packages/contracts/src/settings.ts +50 −27
  554. packages/contracts/src/terminal.test.ts +1 −1
  555. packages/contracts/src/terminal.ts +3 −3
  556. packages/shared/package.json +16 −0
  557. packages/shared/src/DrainableWorker.test.ts +1 −1
  558. packages/shared/src/KeyedCoalescingWorker.test.ts +1 −1
  559. packages/shared/src/Net.test.ts +1 −1
  560. packages/shared/src/Net.ts +2 −2
  561. packages/shared/src/String.test.ts +1 −1
  562. packages/shared/src/cliArgs.test.ts +134 −0
  563. packages/shared/src/cliArgs.ts +76 −0
  564. packages/shared/src/git.test.ts +135 −0
  565. packages/shared/src/git.ts +202 −1
  566. packages/shared/src/model.test.ts +2 −1
  567. packages/shared/src/model.ts +2 −2
  568. packages/shared/src/path.test.ts +34 −0
  569. packages/shared/src/path.ts +22 −0
  570. packages/shared/src/qrCode.ts +985 −0
  571. packages/shared/src/searchRanking.test.ts +95 −0
  572. packages/shared/src/searchRanking.ts +192 −0
  573. packages/shared/src/serverSettings.test.ts +60 −1
  574. packages/shared/src/serverSettings.ts +34 −2
  575. packages/shared/src/shell.test.ts +331 −1
  576. packages/shared/src/shell.ts +384 −20
  577. scripts/build-desktop-artifact.test.ts +101 −0
  578. scripts/build-desktop-artifact.ts +225 −97
  579. scripts/dev-runner.test.ts +83 −31
  580. scripts/dev-runner.ts +40 −74
  581. scripts/lib/brand-assets.ts +6 −0
  582. scripts/lib/build-target-arch.test.ts +61 −0
  583. scripts/lib/build-target-arch.ts +50 −0
  584. scripts/lib/resolve-catalog.ts +3 −3
  585. scripts/lib/update-manifest.ts +58 −68
  586. scripts/merge-mac-update-manifests.test.ts +0 −108
  587. scripts/merge-update-manifests.test.ts +306 −0
  588. scripts/merge-update-manifests.ts +108 −0
  589. scripts/mock-update-server.test.ts +104 −0
  590. scripts/mock-update-server.ts +147 −37
  591. scripts/release-smoke.ts +247 −3
  592. scripts/resolve-nightly-release.test.ts +32 −0
  593. scripts/resolve-nightly-release.ts +137 −0
  594. scripts/resolve-previous-release-tag.ts +205 −0
  595. scripts/tsconfig.json +2 −4
  596. scripts/update-release-package-versions.test.ts +213 −0
  597. scripts/update-release-package-versions.ts +68 −86
  598. tsconfig.base.json +8 −3
  599. turbo.json +1 −1
.devcontainer/devcontainer.json+22−0
@@ -0,0 +1,22 @@
1{
2 "name": "T3 Code Dev",
3 "image": "debian:bookworm",
4 "features": {
5 "ghcr.io/devcontainers-extra/features/bun:1": {},
6 "ghcr.io/devcontainers/features/node:1": {
7 "version": "24",
8 "nodeGypDependencies": true
9 },
10 "ghcr.io/devcontainers/features/python:1": {
11 "version": "3.12"
12 }
13 },
14 "postCreateCommand": {
15 "bun-install": "bun install --backend=copyfile --frozen-lockfile"
16 },
17 "customizations": {
18 "vscode": {
19 "extensions": ["oxc.oxc-vscode"]
20 }
21 }
22}
.docs/remote-architecture.md+302−0
@@ -0,0 +1,302 @@
1# Remote Architecture
2
3This document describes the target architecture for first-class remote environments in T3 Code.
4
5It is intentionally architecture-first. It does not define a complete implementation plan or user-facing rollout checklist. The goal is to establish the core model so remote support can be added without another broad rewrite.
6
7## Goals
8
9- Treat remote environments as first-class product primitives, not special cases.
10- Support multiple ways to reach the same environment.
11- Keep the T3 server as the execution boundary.
12- Let desktop, mobile, and web all share the same conceptual model.
13- Avoid introducing a local control plane unless product pressure proves it is necessary.
14
15## Non-goals
16
17- Replacing the existing WebSocket server boundary with a custom transport protocol.
18- Making SSH the only remote story.
19- Syncing provider auth across machines.
20- Shipping every access method in the first iteration.
21
22## High-level architecture
23
24T3 already has a clean runtime boundary: the client talks to a T3 server over HTTP/WebSocket, and the server owns orchestration, providers, terminals, git, and filesystem operations.
25
26Remote support should preserve that boundary.
27
28```text
29┌──────────────────────────────────────────────┐
30│ Client (desktop / mobile / web) │
31│ │
32│ - known environments │
33│ - connection manager │
34│ - environment-aware routing │
35└───────────────┬──────────────────────────────┘
36 │
37 │ resolves one access endpoint
38 │
39┌───────────────▼──────────────────────────────┐
40│ Access method │
41│ │
42│ - direct ws / wss │
43│ - tunneled ws / wss │
44│ - desktop-managed ssh bootstrap + forward │
45└───────────────┬──────────────────────────────┘
46 │
47 │ connects to one T3 server
48 │
49┌───────────────▼──────────────────────────────┐
50│ Execution environment = one T3 server │
51│ │
52│ - environment identity │
53│ - provider state │
54│ - projects / threads / terminals │
55│ - git / filesystem / process runtime │
56└──────────────────────────────────────────────┘
57```
58
59The important decision is that remoteness is expressed at the environment connection layer, not by splitting the T3 runtime itself.
60
61## Domain model
62
63### ExecutionEnvironment
64
65An `ExecutionEnvironment` is one running T3 server instance.
66
67It is the unit that owns:
68
69- provider availability and auth state
70- model availability
71- projects and threads
72- terminal processes
73- filesystem access
74- git operations
75- server settings
76
77It is identified by a stable `environmentId`.
78
79This is the shared cross-client primitive. Desktop, mobile, and web should all reason about the same concept here.
80
81### KnownEnvironment
82
83A `KnownEnvironment` is a client-side saved entry for an environment the client knows how to reach.
84
85It is not server-authored. It is local to a device or client profile.
86
87Examples:
88
89- a saved LAN URL
90- a saved public `wss://` endpoint
91- a desktop-managed SSH host entry
92- a saved tunneled environment
93
94A known environment may or may not know the target `environmentId` before first successful connect.
95
96### AccessEndpoint
97
98An `AccessEndpoint` is one concrete way to reach a known environment.
99
100This is the key abstraction that keeps SSH from taking over the model.
101
102A single environment may have many endpoints:
103
104- `wss://t3.example.com`
105- `ws://10.0.0.25:3773`
106- a tunneled relay URL
107- a desktop-managed SSH tunnel that resolves to a local forwarded WebSocket URL
108
109The environment stays the same. Only the access path changes.
110
111### RepositoryIdentity
112
113`RepositoryIdentity` remains a best-effort logical repo grouping mechanism across environments.
114
115It is not used for routing. It is only used for UI grouping and correlation between local and remote clones of the same repository.
116
117### Workspace / Project
118
119The current `Project` model remains environment-local.
120
121That means:
122
123- a local clone and a remote clone are different projects
124- they may share a `RepositoryIdentity`
125- threads still bind to one project in one environment
126
127## Access methods
128
129Access methods answer one question:
130
131How does the client speak WebSocket to a T3 server?
132
133They do not answer:
134
135- how the server got started
136- who manages the server process
137- whether the environment is local or remote
138
139### 1. Direct WebSocket access
140
141Examples:
142
143- `ws://10.0.0.15:3773`
144- `wss://t3.example.com`
145
146This is the base model and should be the first-class default.
147
148Benefits:
149
150- works for desktop, mobile, and web
151- no client-specific process management required
152- best fit for hosted or self-managed remote T3 deployments
153
154### 2. Tunneled WebSocket access
155
156Examples:
157
158- public relay URLs
159- private network relay URLs
160- local tunnel products such as pipenet
161
162This is still direct WebSocket access from the client's perspective. The difference is that the route is mediated by a tunnel or relay.
163
164For T3, tunnels are best modeled as another `AccessEndpoint`, not as a different kind of environment.
165
166This is especially useful when:
167
168- the host is behind NAT
169- inbound ports are unavailable
170- mobile must reach a desktop-hosted environment
171- a machine should be reachable without exposing raw LAN or public ports
172
173### 3. Desktop-managed SSH access
174
175SSH is an access and launch helper, not a separate environment type.
176
177The desktop main process can use SSH to:
178
179- reach a machine
180- probe it
181- launch or reuse a remote T3 server
182- establish a local port forward
183
184After that, the renderer should still connect using an ordinary WebSocket URL against the forwarded local port.
185
186This keeps the renderer transport model consistent with every other access method.
187
188## Launch methods
189
190Launch methods answer a different question:
191
192How does a T3 server come to exist on the target machine?
193
194Launch and access should stay separate in the design.
195
196### 1. Pre-existing server
197
198The simplest launch method is no launch at all.
199
200The user or operator already runs T3 on the target machine, and the client connects through a direct or tunneled WebSocket endpoint.
201
202This should be the first remote mode shipped because it validates the environment model with minimal extra machinery.
203
204### 2. Desktop-managed remote launch over SSH
205
206This is the main place where Zed is a useful reference.
207
208Useful ideas to borrow from Zed:
209
210- remote probing
211- platform detection
212- session directories with pid/log metadata
213- reconnect-friendly launcher behavior
214- desktop-owned connection UX
215
216What should be different in T3:
217
218- no custom stdio/socket proxy protocol between renderer and remote runtime
219- no attempt to make the remote runtime look like an editor transport
220- keep the final client-to-server connection as WebSocket
221
222The recommended T3 flow is:
223
2241. Desktop connects over SSH.
2252. Desktop probes the remote machine and verifies T3 availability.
2263. Desktop launches or reuses a remote T3 server.
2274. Desktop establishes local port forwarding.
2285. Renderer connects to the forwarded WebSocket endpoint as a normal environment.
229
230### 3. Client-managed local publish
231
232This is the inverse of remote launch: a local T3 server is already running, and the client publishes it through a tunnel.
233
234This is useful for:
235
236- exposing a desktop-hosted environment to mobile
237- temporary remote access without changing router or firewall settings
238
239This is still a launch concern, not a new environment kind.
240
241## Why access and launch must stay separate
242
243These concerns are easy to conflate, but separating them prevents architectural drift.
244
245Examples:
246
247- A manually hosted T3 server might be reached through direct `wss`.
248- The same server might also be reachable through a tunnel.
249- An SSH-managed server might be launched over SSH but then reached through forwarded WebSocket.
250- A local desktop server might be published through a tunnel for mobile.
251
252In all of those cases, the `ExecutionEnvironment` is the same kind of thing.
253
254Only the launch and access paths differ.
255
256## Security model
257
258Remote support must assume that some environments will be reachable over untrusted networks.
259
260That means:
261
262- remote-capable environments should require explicit authentication
263- tunnel exposure should not rely on obscurity
264- client-saved endpoints should carry enough auth metadata to reconnect safely
265
266T3 already supports a WebSocket auth token on the server. That should become a first-class part of environment access rather than remaining an incidental query parameter convention.
267
268For publicly reachable environments, authenticated access should be treated as required.
269
270## Relationship to Zed
271
272Zed is a useful reference implementation for managed remote launch and reconnect behavior.
273
274The relevant lessons are:
275
276- remote bootstrap should be explicit
277- reconnect should be first-class
278- connection UX belongs in the client shell
279- runtime ownership should stay clearly on the remote host
280
281The important mismatch is transport shape.
282
283Zed needs a custom proxy/server protocol because its remote boundary sits below the editor and project runtime.
284
285T3 should not copy that part.
286
287T3 already has the right runtime boundary:
288
289- one T3 server per environment
290- ordinary HTTP/WebSocket between client and environment
291
292So T3 should borrow Zed's launch discipline, not its transport protocol.
293
294## Recommended rollout
295
2961. First-class known environments and access endpoints.
2972. Direct `ws` / `wss` remote environments.
2983. Authenticated tunnel-backed environments.
2994. Desktop-managed SSH launch and forwarding.
3005. Multi-environment UI improvements after the base runtime path is proven.
301
302This ordering keeps the architecture network-first and transport-agnostic while still leaving room for richer managed remote flows.
.github/workflows/ci.yml+9−4
@@ -9,7 +9,8 @@ on: · 8 unmodified lines
9jobs:9jobs:
10 quality:10 quality:
11 name: Format, Lint, Typecheck, Test, Browser Test, Build11 name: Format, Lint, Typecheck, Test, Browser Test, Build
12 runs-on: blacksmith-4vcpu-ubuntu-240412 runs-on: blacksmith-8vcpu-ubuntu-2404
13 timeout-minutes: 10
13 steps:14 steps:
14 - name: Checkout15 - name: Checkout
15 uses: actions/checkout@v616 uses: actions/checkout@v6
@@ -70,12 +71,13 @@ jobs: · 54 unmodified lines
70 71
71 - name: Verify preload bundle output72 - name: Verify preload bundle output
72 run: |73 run: |
73 test -f apps/desktop/dist-electron/preload.js74 test -f apps/desktop/dist-electron/preload.cjs
74 grep -nE "desktopBridge|getWsUrl|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.js75 grep -nE "desktopBridge|getLocalEnvironmentBootstrap|PICK_FOLDER_CHANNEL|wsUrl" apps/desktop/dist-electron/preload.cjs
75 76
76 release_smoke:77 release_smoke:
77 name: Release Smoke78 name: Release Smoke
78 runs-on: ubuntu-24.0479 runs-on: blacksmith-8vcpu-ubuntu-2404
80 timeout-minutes: 10
79 steps:81 steps:
80 - name: Checkout82 - name: Checkout
81 uses: actions/checkout@v683 uses: actions/checkout@v6
@@ -90,5 +92,8 @@ jobs: · 8 unmodified lines
90 with:92 with:
91 node-version-file: package.json93 node-version-file: package.json
92 94
95 - name: Install dependencies
96 run: bun install --frozen-lockfile
97
93 - name: Exercise release-only workflow steps98 - name: Exercise release-only workflow steps
94 run: node scripts/release-smoke.ts99 run: node scripts/release-smoke.ts
.github/workflows/release.yml+181−47
@@ -1,14 +1,24 @@
1name: Release Desktop1name: Release
2 2
3on:3on:
4 push:4 push:
5 tags:5 tags:
6 - "v*.*.*"6 - "v*.*.*"
7 schedule:
8 - cron: "0 9 * * *"
7 workflow_dispatch:9 workflow_dispatch:
8 inputs:10 inputs:
11 channel:
12 description: "Release channel"
13 required: false
14 default: stable
15 type: choice
16 options:
17 - stable
18 - nightly
9 version:19 version:
10 description: "Release version (for example 1.2.3 or v1.2.3)"20 description: "Release version (for example 1.2.3 or v1.2.3)"
11 required: true21 required: false
12 type: string22 type: string
13 23
14permissions:24permissions:
@@ -18,42 +28,24 @@ permissions: · 3 unmodified lines
18jobs:28jobs:
19 preflight:29 preflight:
20 name: Preflight30 name: Preflight
21 runs-on: ubuntu-24.0431 runs-on: blacksmith-8vcpu-ubuntu-2404
32 timeout-minutes: 10
22 outputs:33 outputs:
34 release_channel: ${{ steps.release_meta.outputs.release_channel }}
23 version: ${{ steps.release_meta.outputs.version }}35 version: ${{ steps.release_meta.outputs.version }}
24 tag: ${{ steps.release_meta.outputs.tag }}36 tag: ${{ steps.release_meta.outputs.tag }}
37 release_name: ${{ steps.release_meta.outputs.name }}
38 short_sha: ${{ steps.release_meta.outputs.short_sha }}
39 previous_tag: ${{ steps.previous_tag.outputs.previous_tag }}
40 cli_dist_tag: ${{ steps.release_meta.outputs.cli_dist_tag }}
25 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}41 is_prerelease: ${{ steps.release_meta.outputs.is_prerelease }}
26 make_latest: ${{ steps.release_meta.outputs.make_latest }}42 make_latest: ${{ steps.release_meta.outputs.make_latest }}
27 ref: ${{ github.sha }}43 ref: ${{ github.sha }}
28 steps:44 steps:
29 - name: Checkout45 - name: Checkout
30 uses: actions/checkout@v646 uses: actions/checkout@v6
31 47 with:
32 - id: release_meta48 fetch-depth: 0
33 name: Resolve release version
34 shell: bash
35 run: |
36 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
37 raw="${{ github.event.inputs.version }}"
38 else
39 raw="${GITHUB_REF_NAME}"
40 fi
41
42 version="${raw#v}"
43 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
44 echo "Invalid release version: $raw" >&2
45 exit 1
46 fi
47
48 echo "version=$version" >> "$GITHUB_OUTPUT"
49 echo "tag=v$version" >> "$GITHUB_OUTPUT"
50 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
51 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
52 echo "make_latest=true" >> "$GITHUB_OUTPUT"
53 else
54 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
55 echo "make_latest=false" >> "$GITHUB_OUTPUT"
56 fi
57 49
58 - name: Setup Bun50 - name: Setup Bun
59 uses: oven-sh/setup-bun@v251 uses: oven-sh/setup-bun@v2
@@ -68,6 +60,60 @@ jobs: · 8 unmodified lines
68 - name: Install dependencies60 - name: Install dependencies
69 run: bun install --frozen-lockfile61 run: bun install --frozen-lockfile
70 62
63 - id: release_meta
64 name: Resolve release version
65 shell: bash
66 env:
67 DISPATCH_CHANNEL: ${{ github.event.inputs.channel }}
68 DISPATCH_VERSION: ${{ github.event.inputs.version }}
69 NIGHTLY_DATE: ${{ github.run_started_at }}
70 NIGHTLY_SHA: ${{ github.sha }}
71 NIGHTLY_RUN_NUMBER: ${{ github.run_number }}
72 run: |
73 if [[ "${GITHUB_EVENT_NAME}" == "schedule" || ( "${GITHUB_EVENT_NAME}" == "workflow_dispatch" && "${DISPATCH_CHANNEL:-stable}" == "nightly" ) ]]; then
74 nightly_date="$(date -u -d "$NIGHTLY_DATE" +%Y%m%d)"
75
76 node scripts/resolve-nightly-release.ts \
77 --date "$nightly_date" \
78 --run-number "$NIGHTLY_RUN_NUMBER" \
79 --sha "$NIGHTLY_SHA" \
80 --github-output
81
82 echo "release_channel=nightly" >> "$GITHUB_OUTPUT"
83 echo "cli_dist_tag=nightly" >> "$GITHUB_OUTPUT"
84 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
85 echo "make_latest=false" >> "$GITHUB_OUTPUT"
86 else
87 if [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then
88 raw="${DISPATCH_VERSION}"
89 if [[ -z "$raw" ]]; then
90 echo "workflow_dispatch stable releases require the version input." >&2
91 exit 1
92 fi
93 else
94 raw="${GITHUB_REF_NAME}"
95 fi
96
97 version="${raw#v}"
98 if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+([.-][0-9A-Za-z.-]+)?$ ]]; then
99 echo "Invalid release version: $raw" >&2
100 exit 1
101 fi
102
103 echo "release_channel=stable" >> "$GITHUB_OUTPUT"
104 echo "version=$version" >> "$GITHUB_OUTPUT"
105 echo "tag=v$version" >> "$GITHUB_OUTPUT"
106 echo "name=T3 Code v$version" >> "$GITHUB_OUTPUT"
107 echo "cli_dist_tag=latest" >> "$GITHUB_OUTPUT"
108 if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
109 echo "is_prerelease=false" >> "$GITHUB_OUTPUT"
110 echo "make_latest=true" >> "$GITHUB_OUTPUT"
111 else
112 echo "is_prerelease=true" >> "$GITHUB_OUTPUT"
113 echo "make_latest=false" >> "$GITHUB_OUTPUT"
114 fi
115 fi
116
71 - name: Lint117 - name: Lint
72 run: bun run lint118 run: bun run lint
73 119
@@ -77,34 +123,48 @@ jobs: · 3 unmodified lines
77 - name: Test123 - name: Test
78 run: bun run test124 run: bun run test
79 125
126 - id: previous_tag
127 name: Resolve previous release tag
128 run: |
129 node scripts/resolve-previous-release-tag.ts \
130 --channel "${{ steps.release_meta.outputs.release_channel }}" \
131 --current-tag "${{ steps.release_meta.outputs.tag }}" \
132 --github-output
133
80 build:134 build:
81 name: Build ${{ matrix.label }}135 name: Build ${{ matrix.label }}
82 needs: preflight136 needs: preflight
83 runs-on: ${{ matrix.runner }}137 runs-on: ${{ matrix.runner }}
138 timeout-minutes: 30
84 strategy:139 strategy:
85 fail-fast: false140 fail-fast: false
86 matrix:141 matrix:
87 include:142 include:
88 - label: macOS arm64143 - label: macOS arm64
89 runner: macos-14144 runner: blacksmith-12vcpu-macos-26
90 platform: mac145 platform: mac
91 target: dmg146 target: dmg
92 arch: arm64147 arch: arm64
93 - label: macOS x64148 - label: macOS x64
94 runner: macos-15-intel149 runner: blacksmith-12vcpu-macos-26
95 platform: mac150 platform: mac
96 target: dmg151 target: dmg
97 arch: x64152 arch: x64
98 - label: Linux x64153 - label: Linux x64
99 runner: ubuntu-24.04154 runner: blacksmith-32vcpu-ubuntu-2404
100 platform: linux155 platform: linux
101 target: AppImage156 target: AppImage
102 arch: x64157 arch: x64
103 - label: Windows x64158 - label: Windows x64
104 runner: windows-2022159 runner: windows-2022 # blacksmith-32vcpu-windows-2025
105 platform: win160 platform: win
106 target: nsis161 target: nsis
107 arch: x64162 arch: x64
163 # - label: Windows arm64
164 # runner: windows-11-arm
165 # platform: win
166 # target: nsis
167 # arch: arm64
108 steps:168 steps:
109 - name: Checkout169 - name: Checkout
110 uses: actions/checkout@v6170 uses: actions/checkout@v6
@@ -204,18 +264,31 @@ jobs: · 93 unmodified lines
204 "release/*.AppImage" \264 "release/*.AppImage" \
205 "release/*.exe" \265 "release/*.exe" \
206 "release/*.blockmap" \266 "release/*.blockmap" \
207 "release/latest*.yml"; do267 "release/*.yml"; do
208 for file in $pattern; do268 for file in $pattern; do
209 cp "$file" release-publish/269 cp "$file" release-publish/
210 done270 done
211 done271 done
212 272
213 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then273 if [[ "${{ matrix.platform }}" == "mac" && "${{ matrix.arch }}" != "arm64" ]]; then
214 if [[ -f release-publish/latest-mac.yml ]]; then274 shopt -s nullglob
215 mv release-publish/latest-mac.yml "release-publish/latest-mac-${{ matrix.arch }}.yml"275 for manifest in release-publish/*-mac.yml; do
216 fi276 mv "$manifest" "${manifest%.yml}-${{ matrix.arch }}.yml"
277 done
217 fi278 fi
218 279
280 # Enable if Windows arm64 builds are enabled.
281 # Windows updater metadata is channel-specific (for example
282 # "latest.yml" or "nightly.yml"). Suffix each per-arch copy so the
283 # release job can merge matching arm64/x64 manifests back into one
284 # canonical manifest per channel.
285 # if [[ "${{ matrix.platform }}" == "win" ]]; then
286 # shopt -s nullglob
287 # for manifest in release-publish/*.yml; do
288 # mv "$manifest" "${manifest%.yml}-win-${{ matrix.arch }}.yml"
289 # done
290 # fi
291
219 - name: Upload build artifacts292 - name: Upload build artifacts
220 uses: actions/upload-artifact@v7293 uses: actions/upload-artifact@v7
221 with:294 with:
@@ -226,7 +299,8 @@ jobs: · 4 unmodified lines
226 publish_cli:299 publish_cli:
227 name: Publish CLI to npm300 name: Publish CLI to npm
228 needs: [preflight, build]301 needs: [preflight, build]
229 runs-on: ubuntu-24.04302 runs-on: ubuntu-24.04 # blacksmith-8vcpu-ubuntu-2404
303 timeout-minutes: 10
230 steps:304 steps:
231 - name: Checkout305 - name: Checkout
232 uses: actions/checkout@v6306 uses: actions/checkout@v6
@@ -254,12 +328,13 @@ jobs: · 21 unmodified lines
254 run: bun run build --filter=@t3tools/web --filter=t3328 run: bun run build --filter=@t3tools/web --filter=t3
255 329
256 - name: Publish CLI package330 - name: Publish CLI package
257 run: node apps/server/scripts/cli.ts publish --tag latest --app-version "${{ needs.preflight.outputs.version }}" --verbose331 run: node apps/server/scripts/cli.ts publish --tag "${{ needs.preflight.outputs.cli_dist_tag }}" --app-version "${{ needs.preflight.outputs.version }}" --verbose
258 332
259 release:333 release:
260 name: Publish GitHub Release334 name: Publish GitHub Release
261 needs: [preflight, build, publish_cli]335 needs: [preflight, build, publish_cli]
262 runs-on: ubuntu-24.04336 runs-on: blacksmith-8vcpu-ubuntu-2404
337 timeout-minutes: 10
263 steps:338 steps:
264 - name: Checkout339 - name: Checkout
265 uses: actions/checkout@v6340 uses: actions/checkout@v6
@@ -280,18 +355,53 @@ jobs: · 14 unmodified lines
280 355
281 - name: Merge macOS updater manifests356 - name: Merge macOS updater manifests
282 run: |357 run: |
283 node scripts/merge-mac-update-manifests.ts \358 shopt -s nullglob
284 release-assets/latest-mac.yml \359 for x64_manifest in release-assets/*-mac-x64.yml; do
285 release-assets/latest-mac-x64.yml360 arm64_manifest="${x64_manifest%-x64.yml}.yml"
286 rm -f release-assets/latest-mac-x64.yml361 if [[ -f "$arm64_manifest" ]]; then
362 node scripts/merge-update-manifests.ts --platform mac "$arm64_manifest" "$x64_manifest"
363 rm -f "$x64_manifest"
364 fi
365 done
366
367 # - name: Merge Windows updater manifests
368 # run: |
369 # shopt -s nullglob
370 # found_windows_manifest=false
371 # for x64_manifest in release-assets/*-win-x64.yml; do
372 # if [[ "$(basename "$x64_manifest")" == builder-debug-* ]]; then
373 # continue
374 # fi
375
376 # arm64_manifest="${x64_manifest/-x64.yml/-arm64.yml}"
377 # output_manifest="${x64_manifest/-win-x64.yml/.yml}"
378 # if [[ ! -f "$arm64_manifest" ]]; then
379 # echo "Missing matching arm64 Windows manifest for $x64_manifest" >&2
380 # exit 1
381 # fi
382
383 # found_windows_manifest=true
384 # node scripts/merge-update-manifests.ts --platform win \
385 # "$arm64_manifest" \
386 # "$x64_manifest" \
387 # "$output_manifest"
388 # rm -f "$arm64_manifest" "$x64_manifest"
389 # done
390
391 # if [[ "$found_windows_manifest" != true ]]; then
392 # echo "No Windows updater manifests found to merge." >&2
393 # exit 1
394 # fi
287 395
288 - name: Publish release396 - name: Publish release
397 if: needs.preflight.outputs.previous_tag != ''
289 uses: softprops/action-gh-release@v2398 uses: softprops/action-gh-release@v2
290 with:399 with:
291 tag_name: ${{ needs.preflight.outputs.tag }}400 tag_name: ${{ needs.preflight.outputs.tag }}
292 target_commitish: ${{ needs.preflight.outputs.ref }}401 target_commitish: ${{ needs.preflight.outputs.ref }}
293 name: T3 Code v${{ needs.preflight.outputs.version }}402 name: ${{ needs.preflight.outputs.release_name }}
294 generate_release_notes: true403 generate_release_notes: true
404 previous_tag: ${{ needs.preflight.outputs.previous_tag }}
295 prerelease: ${{ needs.preflight.outputs.is_prerelease }}405 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
296 make_latest: ${{ needs.preflight.outputs.make_latest }}406 make_latest: ${{ needs.preflight.outputs.make_latest }}
297 files: |407 files: |
@@ -300,13 +410,34 @@ jobs: · 2 unmodified lines
300 release-assets/*.AppImage410 release-assets/*.AppImage
301 release-assets/*.exe411 release-assets/*.exe
302 release-assets/*.blockmap412 release-assets/*.blockmap
303 release-assets/latest*.yml413 release-assets/*.yml
414 fail_on_unmatched_files: true
415
416 - name: Publish first release
417 if: needs.preflight.outputs.previous_tag == ''
418 uses: softprops/action-gh-release@v2
419 with:
420 tag_name: ${{ needs.preflight.outputs.tag }}
421 target_commitish: ${{ needs.preflight.outputs.ref }}
422 name: ${{ needs.preflight.outputs.release_name }}
423 generate_release_notes: true
424 prerelease: ${{ needs.preflight.outputs.is_prerelease }}
425 make_latest: ${{ needs.preflight.outputs.make_latest }}
426 files: |
427 release-assets/*.dmg
428 release-assets/*.zip
429 release-assets/*.AppImage
430 release-assets/*.exe
431 release-assets/*.blockmap
432 release-assets/*.yml
304 fail_on_unmatched_files: true433 fail_on_unmatched_files: true
305 434
306 finalize:435 finalize:
307 name: Finalize release436 name: Finalize release
437 if: needs.preflight.outputs.release_channel == 'stable'
308 needs: [preflight, release]438 needs: [preflight, release]
309 runs-on: ubuntu-24.04439 runs-on: blacksmith-8vcpu-ubuntu-2404
440 timeout-minutes: 10
310 steps:441 steps:
311 - id: app_token442 - id: app_token
312 name: Mint release app token443 name: Mint release app token
@@ -344,6 +475,9 @@ jobs: · 31 unmodified lines
344 with:475 with:
345 node-version-file: package.json476 node-version-file: package.json
346 477
478 - name: Install dependencies
479 run: bun install --frozen-lockfile
480
347 - id: update_versions481 - id: update_versions
348 name: Update version strings482 name: Update version strings
349 env:483 env:
.gitignore+1−0
@@ -21,3 +21,4 @@ apps/web/src/components/__screenshots__ · 20 unmodified lines
21.vitest-*21.vitest-*
22__screenshots__/22__screenshots__/
23.tanstack23.tanstack
24squashfs-root/
.oxfmtrc.json+3−1
@@ -9,7 +9,9 @@ · 8 unmodified lines
9 "bun.lock",9 "bun.lock",
10 "*.tsbuildinfo",10 "*.tsbuildinfo",
11 "**/routeTree.gen.ts",11 "**/routeTree.gen.ts",
12 "apps/web/public/mockServiceWorker.js"12 "apps/web/public/mockServiceWorker.js",
13 "apps/web/src/lib/vendor/qrcodegen.ts",
14 "*.icon/**"
13 ],15 ],
14 "sortPackageJson": {}16 "sortPackageJson": {}
15}17}
.plans/18-server-auth-model.md+823−0
@@ -0,0 +1,823 @@
1# Server Auth Model Plan
2
3## Purpose
4
5Define the long-term server auth architecture for T3 Code before first-class remote environments ship.
6
7This plan is deliberately broader than the current WebSocket token check and narrower than a complete remote collaboration system. The goal is to make the server secure by default, keep local desktop UX frictionless, and leave clean integration points for future remote access methods.
8
9This document is written in terms of Effect-native services and layers because auth needs to be a core runtime concern, not route-local glue code.
10
11## Primary goals
12
13- Make auth server-wide, not WebSocket-only.
14- Make insecure exposure hard to do accidentally.
15- Preserve zero-login local desktop UX for desktop-managed environments.
16- Support browser-native pairing and session auth.
17- Leave room for native/mobile credentials later without rewriting the server boundary.
18- Keep auth separate from transport and launch method.
19
20## Non-goals
21
22- Full multi-user authorization and RBAC.
23- OAuth / SSO / enterprise identity.
24- Passkeys or biometric UX in v1.
25- Syncing auth state across environments.
26- Designing the full remote environment product in this document.
27
28## Core decisions
29
30### 1. Auth is a server concern
31
32Every privileged surface of the T3 server must go through the same auth policy engine:
33
34- HTTP routes
35- WebSocket upgrades
36- RPC methods reached through WebSocket
37
38The current split where [`/ws`](../apps/server/src/ws.ts) checks `authToken` but routes in [`http.ts`](../apps/server/src/http.ts) do not is not sufficient for a remote-capable product.
39
40### 2. Pairing and session are different things
41
42The system should distinguish:
43
44- bootstrap credentials
45- session credentials
46
47Bootstrap credentials are short-lived and high-trust. They allow a client to become authenticated.
48
49Session credentials are the durable credentials used after pairing.
50
51Bootstrap should never become the long-lived request credential.
52
53### 3. Auth and transport are separate
54
55Auth must not be defined by how the client reached the server.
56
57Examples:
58
59- local desktop-managed server
60- LAN `ws://`
61- public `wss://`
62- tunneled `wss://`
63- SSH-forwarded `ws://127.0.0.1:<port>`
64
65All of these should feed into the same auth model.
66
67### 4. Exposure level changes defaults
68
69The more exposed an environment is, the narrower the safe default should be.
70
71Safe default expectations:
72
73- local desktop-managed: auto-pair allowed
74- loopback browser access: explicit bootstrap allowed
75- non-loopback bind: auth required
76- tunnel/public endpoint: auth required, explicit enablement required
77
78### 5. Browser and native clients may use different session credentials
79
80The auth model should support more than one session credential type even if only one ships first.
81
82Examples:
83
84- browser session cookie
85- native bearer/device token
86
87This should be represented in the model now, even if browser cookies are the first implementation.
88
89## Target auth domain
90
91### Route classes
92
93Every route or transport entrypoint should be classified as one of:
94
951. `public`
962. `bootstrap`
973. `authenticated`
98
99#### `public`
100
101Unauthenticated by definition.
102
103Should be extremely small. Examples:
104
105- static shell needed to render the pairing/login UI
106- favicon/assets required for the pairing screen
107- a minimal server health/version endpoint if needed
108
109#### `bootstrap`
110
111Used only to exchange a bootstrap credential for a session.
112
113Examples:
114
115- Initial bootstrap envelope over file descriptor at startup
116- `POST /api/auth/bootstrap`
117- `GET /api/auth/session` if unauthenticated checks are part of bootstrap UX
118
119#### `authenticated`
120
121Everything that reveals machine state or mutates it.
122
123Examples:
124
125- WebSocket upgrade
126- orchestration snapshot and events
127- terminal open/write/close
128- project search and file writes
129- git routes
130- attachments
131- project favicon lookup
132- server settings
133
134The default stance should be: if it touches the machine, it is authenticated.
135
136## Credential model
137
138### Bootstrap credentials
139
140Initial credential types to model:
141
142- `desktop-bootstrap`
143- `one-time-token`
144
145Possible future credential types:
146
147- `device-code`
148- `passkey-assertion`
149- `external-identity`
150
151#### `desktop-bootstrap`
152
153Used when the desktop shell manages the server and should be the only default pairing method for desktop-local environments.
154
155Properties:
156
157- launcher-provided
158- short-lived
159- one-time or bounded-use
160- never shown to the user as a reusable password
161
162#### `one-time-token`
163
164Used for explicit browser/mobile pairing flows.
165
166Properties:
167
168- short TTL
169- one-time use
170- safe to embed in a pairing URL fragment
171- exchanged for a session credential
172
173### Session credentials
174
175Initial credential types to model:
176
177- `browser-session-cookie`
178- `bearer-session-token`
179
180#### `browser-session-cookie`
181
182Primary browser credential.
183
184Properties:
185
186- signed
187- `HttpOnly`
188- bounded lifetime
189- revocable by server key rotation or session invalidation
190
191#### `bearer-session-token`
192
193Reserved for native/mobile or non-browser clients.
194
195Properties:
196
197- opaque token, not a bootstrap secret
198- long enough lifetime to survive reconnects
199- stored in secure client storage when available
200
201## Auth policy model
202
203Auth behavior should be driven by an explicit environment auth policy, not route-local heuristics.
204
205### Policy examples
206
207#### `DesktopManagedLocalPolicy`
208
209Default for desktop-managed local server.
210
211Allowed bootstrap methods:
212
213- `desktop-bootstrap`
214
215Allowed session methods:
216
217- `browser-session-cookie`
218
219Disabled by default:
220
221- `one-time-token`
222- `bearer-session-token`
223- password login
224- public pairing
225
226#### `LoopbackBrowserPolicy`
227
228Used for browser access on localhost without desktop-managed bootstrap.
229
230Allowed bootstrap methods:
231
232- `one-time-token`
233
234Allowed session methods:
235
236- `browser-session-cookie`
237
238#### `RemoteReachablePolicy`
239
240Used when binding non-loopback or using an explicit remote/tunnel workflow.
241
242Allowed bootstrap methods:
243
244- `one-time-token`
245- possibly `desktop-bootstrap` when a desktop shell is brokering access
246
247Allowed session methods:
248
249- `browser-session-cookie`
250- `bearer-session-token`
251
252#### `UnsafeNoAuthPolicy`
253
254Should exist only as an explicit escape hatch.
255
256Requirements:
257
258- explicit opt-in flag
259- loud startup warnings
260- never defaulted automatically
261
262## Effect-native service model
263
264### `ServerAuth`
265
266The main auth facade used by HTTP routes and WebSocket upgrade handling.
267
268Responsibilities:
269
270- classify requests
271- authenticate requests
272- authorize bootstrap attempts
273- create sessions from bootstrap credentials
274- enforce policy by environment mode
275
276Sketch:
277
278```ts
279export interface ServerAuthShape {
280 readonly getCapabilities: Effect.Effect<AuthCapabilities>;
281 readonly authenticateHttpRequest: (
282 request: HttpServerRequest.HttpServerRequest,
283 routeClass: RouteAuthClass,
284 ) => Effect.Effect<AuthContext, AuthError>;
285 readonly authenticateWebSocketUpgrade: (
286 request: HttpServerRequest.HttpServerRequest,
287 ) => Effect.Effect<AuthContext, AuthError>;
288 readonly exchangeBootstrapCredential: (
289 input: BootstrapExchangeInput,
290 ) => Effect.Effect<SessionGrant, AuthError>;
291}
292
293export class ServerAuth extends ServiceMap.Service<ServerAuth, ServerAuthShape>()(
294 "t3/ServerAuth",
295) {}
296```
297
298### `BootstrapCredentialService`
299
300Owns issuance, storage, validation, and consumption of bootstrap credentials.
301
302Responsibilities:
303
304- issue desktop bootstrap grants
305- issue one-time pairing tokens
306- validate TTL and single-use semantics
307- consume bootstrap grants atomically
308
309Sketch:
310
311```ts
312export interface BootstrapCredentialServiceShape {
313 readonly issueDesktopBootstrap: (
314 input: IssueDesktopBootstrapInput,
315 ) => Effect.Effect<BootstrapCredential>;
316 readonly issueOneTimeToken: (
317 input: IssueOneTimeTokenInput,
318 ) => Effect.Effect<BootstrapCredential>;
319 readonly consume: (
320 presented: PresentedBootstrapCredential,
321 ) => Effect.Effect<ConsumedBootstrapCredential, BootstrapCredentialError>;
322}
323```
324
325### `SessionCredentialService`
326
327Owns creation and validation of authenticated sessions.
328
329Responsibilities:
330
331- mint cookie sessions
332- mint bearer sessions
333- validate active session credentials
334- revoke sessions if needed later
335
336Sketch:
337
338```ts
339export interface SessionCredentialServiceShape {
340 readonly createBrowserSession: (
341 input: CreateSessionFromBootstrapInput,
342 ) => Effect.Effect<BrowserSessionGrant, SessionCredentialError>;
343 readonly createBearerSession: (
344 input: CreateSessionFromBootstrapInput,
345 ) => Effect.Effect<BearerSessionGrant, SessionCredentialError>;
346 readonly authenticateCookie: (
347 request: HttpServerRequest.HttpServerRequest,
348 ) => Effect.Effect<AuthContext, SessionCredentialError>;
349 readonly authenticateBearer: (
350 request: HttpServerRequest.HttpServerRequest,
351 ) => Effect.Effect<AuthContext, SessionCredentialError>;
352}
353```
354
355### `ServerAuthPolicy`
356
357Pure policy/config service that decides which credential types are allowed.
358
359Responsibilities:
360
361- map runtime mode and bind/exposure settings to allowed auth methods
362- answer whether a route can be public
363- answer whether remote exposure requires auth
364
365This should stay mostly pure and cheap to test.
366
367### `ServerSecretStore`
368
369Owns long-lived server signing keys and secrets.
370
371Responsibilities:
372
373- get or create signing key
374- rotate signing key
375- abstract secure OS-backed storage vs filesystem fallback
376
377Important:
378
379- prefer platform secure storage when available
380- support hardened filesystem fallback for headless/server-only environments
381
382### `BrowserSessionCookieCodec`
383
384Focused utility service for cookie encode/decode/signing behavior.
385
386This should not own policy. It should only own the cookie format.
387
388### `AuthRouteGuards`
389
390Thin helper layer used by routes to enforce auth consistently.
391
392Responsibilities:
393
394- require auth for HTTP route handlers
395- classify route auth mode
396- convert auth failures into `401` / `403`
397
398This prevents every route from re-implementing the same pattern.
399
400Integrates with `HttpRouter.middleware` to enforce auth consistently.
401
402## Suggested layer graph
403
404```text
405ServerSecretStore
406 ├─> BootstrapCredentialService
407 ├─> BrowserSessionCookieCodec
408 └─> SessionCredentialService
409
410ServerAuthPolicy
411 ├─> BootstrapCredentialService
412 ├─> SessionCredentialService
413 └─> ServerAuth
414
415ServerAuth
416 └─> AuthRouteGuards
417```
418
419Layer naming should follow existing repo style:
420
421- `ServerSecretStoreLive`
422- `BootstrapCredentialServiceLive`
423- `SessionCredentialServiceLive`
424- `ServerAuthPolicyLive`
425- `ServerAuthLive`
426- `AuthRouteGuardsLive`
427
428## High-level implementation examples
429
430### Example: WebSocket upgrade auth
431
432Current state:
433
434- `authToken` query param is checked in [`ws.ts`](../apps/server/src/ws.ts)
435
436Target shape:
437
438```ts
439const websocketUpgradeAuth = HttpMiddleware.make((httpApp) =>
440 Effect.gen(function* () {
441 const request = yield* HttpServerRequest.HttpServerRequest;
442 const serverAuth = yield* ServerAuth;
443 yield* serverAuth.authenticateWebSocketUpgrade(request);
444 return yield* httpApp;
445 }),
446);
447```
448
449Then the `/ws` route becomes:
450
451```ts
452export const websocketRpcRouteLayer = HttpRouter.add(
453 "GET",
454 "/ws",
455 rpcWebSocketHttpEffect.pipe(
456 websocketUpgradeAuth,
457 Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)),
458 ),
459);
460```
461
462This keeps the route itself declarative and makes auth compose like normal HTTP middleware.
463
464### Example: authenticated HTTP route
465
466For routes like attachments or project favicon:
467
468```ts
469const authenticatedRoute = (routeClass: RouteAuthClass) =>
470 HttpMiddleware.make((httpApp) =>
471 Effect.gen(function* () {
472 const request = yield* HttpServerRequest.HttpServerRequest;
473 const serverAuth = yield* ServerAuth;
474 yield* serverAuth.authenticateHttpRequest(request, routeClass);
475 return yield* httpApp;
476 }),
477 );
478```
479
480Then:
481
482```ts
483export const attachmentsRouteLayer = HttpRouter.add(
484 "GET",
485 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
486 serveAttachment.pipe(
487 authenticatedRoute(RouteAuthClass.Authenticated),
488 Effect.catchTag("AuthError", (error) => toUnauthorizedResponse(error)),
489 ),
490);
491```
492
493### Example: desktop bootstrap exchange
494
495The desktop shell launches the local server and gets a short-lived bootstrap grant through a trusted side channel.
496
497That grant is then exchanged for a browser cookie session when the renderer loads.
498
499Sketch:
500
501```ts
502const pairDesktopRenderer = Effect.gen(function* () {
503 const bootstrapService = yield* BootstrapCredentialService;
504 const credential = yield* bootstrapService.issueDesktopBootstrap({
505 audience: "desktop-renderer",
506 ttlMs: 30_000,
507 });
508 return credential;
509});
510```
511
512The renderer then calls a bootstrap endpoint and receives a cookie session. The bootstrap credential is consumed and becomes invalid.
513
514### Example: one-time pairing URL
515
516For browser-driven pairing:
517
518```ts
519const createPairingToken = Effect.gen(function* () {
520 const bootstrapService = yield* BootstrapCredentialService;
521 return yield* bootstrapService.issueOneTimeToken({
522 ttlMs: 5 * 60_000,
523 audience: "browser",
524 });
525});
526```
527
528The server can emit a pairing URL where the token lives in the URL fragment so it is not automatically sent to the server before the client explicitly exchanges it.
529
530## Sequence diagrams
531
532These flows are meant to anchor the auth model in concrete user journeys.
533
534The important invariant across all of them is:
535
536- access method is not the auth method
537- launch method is not the auth method
538- bootstrap credential is not the session credential
539
540### Normal desktop user
541
542This is the default desktop-managed local flow.
543
544The desktop shell is trusted to bootstrap the local renderer, but the renderer should still exchange that one-time bootstrap grant for a normal browser session cookie.
545
546```text
547Participants:
548 DesktopMain = Electron main
549 SecretStore = secure local secret backend
550 T3Server = local backend child process
551 Frontend = desktop renderer
552
553DesktopMain -> SecretStore : getOrCreate("server-signing-key")
554SecretStore --> DesktopMain : signing key available
555
556DesktopMain -> T3Server : spawn server (--bootstrap-fd ...)
557DesktopMain -> T3Server : send desktop bootstrap envelope
558note over T3Server : policy = DesktopManagedLocalPolicy
559note over T3Server : allowed pairing = desktop-bootstrap only
560
561Frontend -> DesktopMain : request local bootstrap grant
562DesktopMain --> Frontend : short-lived desktop bootstrap grant
563
564Frontend -> T3Server : POST /api/auth/bootstrap
565T3Server -> T3Server : validate desktop bootstrap grant
566T3Server -> T3Server : create browser session
567T3Server --> Frontend : Set-Cookie: session=...
568
569Frontend -> T3Server : GET /ws + authenticated cookie
570T3Server -> T3Server : validate cookie session
571T3Server --> Frontend : websocket accepted
572```
573
574### `npx t3` user
575
576This is the standalone local server flow.
577
578There is no trusted desktop shell here, so pairing should be explicit.
579
580```text
581Participants:
582 UserShell = npx t3 launcher
583 T3Server = standalone local server
584 Browser = browser tab
585
586UserShell -> T3Server : start server
587T3Server -> T3Server : getOrCreate("server-signing-key")
588note over T3Server : policy = LoopbackBrowserPolicy
589
590UserShell -> T3Server : issue one-time pairing token
591T3Server --> UserShell : pairing URL or pairing token
592
593UserShell --> Browser : open /pair?token=...
594
595Browser -> T3Server : GET /pair?token=...
596T3Server -> T3Server : validate one-time token
597T3Server -> T3Server : create browser session
598T3Server --> Browser : Set-Cookie: session=...
599T3Server --> Browser : redirect to app
600
601Browser -> T3Server : GET /ws + authenticated cookie
602T3Server --> Browser : websocket accepted
603```
604
605### Phone user with tunneled host
606
607This is the explicit remote access flow for a browser on another device.
608
609The tunnel only provides reachability. It must not imply trust.
610
611Recommended UX:
612
613- desktop shows a QR code
614- desktop also shows a copyable pairing URL
615- if the phone opens the host URL without a valid token, the server should render a login or pairing screen rather than granting access
616
617```text
618Participants:
619 DesktopUser = user at the host machine
620 DesktopMain = desktop app
621 Tunnel = tunnel provider
622 T3Server = T3 server
623 PhoneBrowser = mobile browser
624
625DesktopUser -> DesktopMain : enable remote access via tunnel
626DesktopMain -> T3Server : switch policy to RemoteReachablePolicy
627DesktopMain -> Tunnel : publish local T3 endpoint
628Tunnel --> DesktopMain : public https/wss URL
629
630DesktopMain -> T3Server : issue one-time pairing token
631T3Server --> DesktopMain : pairing token
632DesktopMain -> DesktopUser : show QR code / shareable URL
633
634DesktopUser -> PhoneBrowser : scan QR / open URL
635PhoneBrowser -> Tunnel : GET https://public-host/pair?token=...
636Tunnel -> T3Server : forward request
637T3Server -> T3Server : validate one-time token
638T3Server -> T3Server : create mobile browser session
639T3Server --> PhoneBrowser : Set-Cookie: session=...
640T3Server --> PhoneBrowser : redirect to app
641
642PhoneBrowser -> Tunnel : GET /ws + authenticated cookie
643Tunnel -> T3Server : forward websocket upgrade
644T3Server --> PhoneBrowser : websocket accepted
645```
646
647### Phone user with private network
648
649This is operationally similar to the tunnel flow, but the access endpoint is on a private network such as Tailscale.
650
651The auth flow should stay the same.
652
653```text
654Participants:
655 DesktopUser = user at the host machine
656 T3Server = T3 server
657 PrivateNet = tailscale / private LAN
658 PhoneBrowser = mobile browser
659
660DesktopUser -> T3Server : enable private-network access
661T3Server -> T3Server : switch policy to RemoteReachablePolicy
662DesktopUser -> T3Server : issue one-time pairing token
663T3Server --> DesktopUser : pairing URL / QR
664
665DesktopUser -> PhoneBrowser : open private-network URL
666PhoneBrowser -> PrivateNet : GET http(s)://private-host/pair?token=...
667PrivateNet -> T3Server : route request
668T3Server -> T3Server : validate one-time token
669T3Server -> T3Server : create mobile browser session
670T3Server --> PhoneBrowser : Set-Cookie: session=...
671T3Server --> PhoneBrowser : redirect to app
672
673PhoneBrowser -> PrivateNet : GET /ws + authenticated cookie
674PrivateNet -> T3Server : websocket upgrade
675T3Server --> PhoneBrowser : websocket accepted
676```
677
678### Desktop user adding new SSH hosts
679
680SSH should be treated as launch and reachability plumbing, not as the long-term auth model.
681
682The desktop app uses SSH to start or reach the remote server, then the renderer pairs against that server using the same bootstrap/session split as every other environment.
683
684```text
685Participants:
686 DesktopUser = local desktop user
687 DesktopMain = desktop app
688 SSH = ssh transport/session
689 RemoteHost = remote machine
690 RemoteT3 = remote T3 server
691 Frontend = desktop renderer
692
693DesktopUser -> DesktopMain : add SSH host
694DesktopMain -> SSH : connect to remote host
695SSH -> RemoteHost : probe environment / verify t3 availability
696DesktopMain -> SSH : run remote launch command
697SSH -> RemoteHost : t3 remote launch --json
698RemoteHost -> RemoteT3 : start or reuse server
699RemoteT3 --> RemoteHost : port + environment metadata
700RemoteHost --> SSH : launch result JSON
701SSH --> DesktopMain : remote server details
702
703DesktopMain -> SSH : establish local port forward
704SSH --> DesktopMain : localhost:FORWARDED_PORT ready
705
706note over RemoteT3 : policy = RemoteReachablePolicy
707note over DesktopMain,RemoteT3 : desktop may use a trusted bootstrap flow here
708
709Frontend -> DesktopMain : request bootstrap for selected environment
710DesktopMain --> Frontend : short-lived bootstrap grant
711
712Frontend -> RemoteT3 : POST /api/auth/bootstrap via forwarded port
713RemoteT3 -> RemoteT3 : validate bootstrap grant
714RemoteT3 -> RemoteT3 : create browser session
715RemoteT3 --> Frontend : Set-Cookie: session=...
716
717Frontend -> RemoteT3 : GET /ws + authenticated cookie
718RemoteT3 --> Frontend : websocket accepted
719```
720
721## Storage decisions
722
723### Server secrets
724
725Use a `ServerSecretStore` abstraction.
726
727Preferred order (use a layer for each, resolve on startup):
728
7291. OS secure storage if available
7302. hardened filesystem fallback if not
731
732The filesystem fallback should store only opaque signing material with strict file permissions. It should not store user passwords or reusable third-party credentials.
733
734### Client credentials
735
736Client-side credential persistence should prefer secure storage when available:
737
738- desktop: OS keychain / secure store
739- mobile: platform secure storage
740- browser: cookie session for browser auth
741
742This concern should stay in the client shell/runtime layer, not the server auth layer.
743
744## What to build now
745
746These are the parts worth building before remote environments ship:
747
7481. `ServerAuth` service boundary.
7492. route classification and route guards.
7503. `ServerSecretStore` abstraction.
7514. bootstrap vs session credential split.
7525. browser session cookie codec as one session method.
7536. explicit auth capabilities/config surfaced in contracts.
754
755Even if only one pairing flow is used initially, these seams will keep future remote and mobile work contained.
756
757## What to add as part of first remote-capable auth
758
7591. Browser pairing flow using one-time bootstrap token and cookie session.
7602. Desktop-managed auto-bootstrap for the local desktop-managed environment.
7613. Auth-required defaults for any non-loopback or explicitly published server.
7624. Explicit environment auth policy selection instead of scattered `if (host !== localhost)` checks.
763
764## What to defer
765
766- passkeys / WebAuthn
767- iCloud Keychain / Face ID-specific UX
768- multi-user permissions
769- collaboration roles
770- OAuth / SSO
771- polished session management UI
772- complex device approval flows
773
774These can all sit on top of the same bootstrap/session/service split.
775
776## Relationship to future remote environments
777
778Remote access is one reason this auth model matters, but the auth model should not be remote-shaped.
779
780Keep the design focused on:
781
782- one T3 server
783- one auth policy
784- multiple credential types
785- multiple future access methods
786
787That keeps the server auth model stable even as access methods expand later.
788
789## Recommended implementation order
790
791### Phase 1
792
793- Introduce route auth classes.
794- Add `ServerAuth` and `AuthRouteGuards`.
795- Move existing `authToken` check behind `ServerAuth`.
796- Require auth for all privileged HTTP routes as well as WebSocket.
797
798### Phase 2
799
800- Add `ServerSecretStore` service with platform-specific layer implementations.
801 - `layerOSXKeychain`, `layer
802- Add bootstrap/session split.
803- Add browser session cookie support.
804- Add one-time bootstrap exchange endpoint.
805
806### Phase 3
807
808- Add desktop bootstrap flow on top of the same services.
809- Make desktop-managed local environments default to bootstrap-only pairing.
810- Surface auth capabilities in shared contracts and renderer bootstrap.
811
812### Phase 4
813
814- Add non-browser bearer session support if mobile/native needs it.
815- Add richer policy modes for remote-reachable environments.
816
817## Acceptance criteria
818
819- No privileged HTTP or WebSocket path bypasses auth policy.
820- Local desktop-managed flows still avoid a visible login screen.
821- Non-loopback or published environments require explicit authenticated pairing by default.
822- Bootstrap and session credentials are distinct in code and in behavior.
823- Auth logic is centralized in Effect services/layers rather than route-local branching.
CLAUDE.md+1−1
@@ -1 +1 @@
1AGENTS.md1AGENTS.md
KEYBINDINGS.md+2−0
@@ -23,6 +23,7 @@ See the full schema for more details: [`packages/contracts/src/keybindings.ts`]( · 22 unmodified lines
23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },23 { "key": "mod+d", "command": "terminal.split", "when": "terminalFocus" },
24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },24 { "key": "mod+n", "command": "terminal.new", "when": "terminalFocus" },
25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },25 { "key": "mod+w", "command": "terminal.close", "when": "terminalFocus" },
26 { "key": "mod+k", "command": "commandPalette.toggle", "when": "!terminalFocus" },
26 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },27 { "key": "mod+n", "command": "chat.new", "when": "!terminalFocus" },
27 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },28 { "key": "mod+shift+o", "command": "chat.new", "when": "!terminalFocus" },
28 { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },29 { "key": "mod+shift+n", "command": "chat.newLocal", "when": "!terminalFocus" },
@@ -50,6 +51,7 @@ Invalid rules are ignored. Invalid config files are ignored. Warnings are logged · 21 unmodified lines
50- `terminal.split`: split terminal (in focused terminal context by default)51- `terminal.split`: split terminal (in focused terminal context by default)
51- `terminal.new`: create new terminal (in focused terminal context by default)52- `terminal.new`: create new terminal (in focused terminal context by default)
52- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)53- `terminal.close`: close/kill the focused terminal (in focused terminal context by default)
54- `commandPalette.toggle`: open or close the global command palette
53- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state55- `chat.new`: create a new chat thread preserving the active thread's branch/worktree state
54- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))56- `chat.newLocal`: create a new chat thread for the active project in a new environment (local/worktree determined by app settings (default `local`))
55- `editor.openFavorite`: open current project/worktree in the last-used editor57- `editor.openFavorite`: open current project/worktree in the last-used editor
README.md+8−0
@@ -49,6 +49,14 @@ Observability guide: [docs/observability.md](./docs/observability.md) · 48 unmodified lines
49 49
50## If you REALLY want to contribute still.... read this first50## If you REALLY want to contribute still.... read this first
51 51
52Before local development, prepare the environment and install dependencies:
53
54```bash
55# Optional: only needed if you use mise for dev tool management.
56mise install
57bun install .
58```
59
52Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.60Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
53 61
54Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).62Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
REMOTE.md+63−44
@@ -1,68 +1,87 @@
1# Remote Access Setup1# Remote Access
2 2
3Use this when you want to open T3 Code from another device (phone, tablet, another laptop).3Use this when you want to connect to a T3 Code server from another device such as a phone, tablet, or separate desktop app.
4 4
5## CLI ↔ Env option map5## Recommended Setup
6 6
7The T3 Code CLI accepts the following configuration options, available either as CLI flags or environment variables:7Use a trusted private network that meshes your devices together, such as a tailnet.
8 8
9| CLI flag | Env var | Notes |9That gives you:
10| ----------------------- | --------------------- | ------------------------------------------------------------------------------------ |
11| `--mode <web\|desktop>` | `T3CODE_MODE` | Runtime mode. |
12| `--port <number>` | `T3CODE_PORT` | HTTP/WebSocket port. |
13| `--host <address>` | `T3CODE_HOST` | Bind interface/address. |
14| `--base-dir <path>` | `T3CODE_HOME` | Base directory. |
15| `--dev-url <url>` | `VITE_DEV_SERVER_URL` | Dev web URL redirect/proxy target. |
16| `--no-browser` | `T3CODE_NO_BROWSER` | Disable auto-open browser. |
17| `--auth-token <token>` | `T3CODE_AUTH_TOKEN` | WebSocket auth token. Use this for standard CLI and remote-server flows. |
18| `--bootstrap-fd <fd>` | `T3CODE_BOOTSTRAP_FD` | Read a one-shot bootstrap envelope from an inherited file descriptor during startup. |
19 10
20> TIP: Use the `--help` flag to see all available options and their descriptions.11- a stable address to connect to
12- transport security at the network layer
13- less exposure than opening the server to the public internet
21 14
22## Security First15## Enabling Network Access
23 16
24- Always set `--auth-token` before exposing the server outside localhost.17There are two ways to expose your server for remote connections: from the desktop app or from the CLI.
25 - When you control the process launcher, prefer sending the auth token in a JSON envelope via `--bootstrap-fd <fd>`.
26 With `--bootstrap-fd <fd>`, the launcher starts the server first, then sends a one-shot JSON envelope over the inherited file descriptor. This allows the auth token to be delivered without putting it in process environment or command line arguments.
27- Treat the token like a password.
28- Prefer binding to trusted interfaces (LAN IP or Tailnet IP) instead of opening all interfaces unless needed.
29 18
30## 1) Build + run server for remote access19### Option 1: Desktop App
31 20
32Remote access should use the built web app (not local Vite redirect mode).21If you are already running the desktop app and want to make it reachable from other devices:
22
231. Open **Settings** → **Connections**.
242. Under **Manage Local Backend**, toggle **Network access** on. This will restart the app and run the backend on all network interfaces.
253. The settings panel will show the address the server is reachable at (e.g. `http://192.168.x.y:3773`).
264. Use **Create Link** to generate a pairing link you can share with another device.
27
28### Option 2: Headless Server (CLI)
29
30Use this when you want to run the server without a GUI, for example on a remote machine over SSH.
31
32Run the server with `t3 serve`.
33 33
34```bash34```bash
35bun run build35npx t3 serve --host "$(tailscale ip -4)"
36TOKEN="$(openssl rand -hex 24)"
37bun run --cwd apps/server start -- --host 0.0.0.0 --port 3773 --auth-token "$TOKEN" --no-browser
38```36```
39 37
40Then open on your phone:38`t3 serve` starts the server without opening a browser and prints:
41 39
42`http://<your-machine-ip>:3773`40- a connection string
41- a pairing token
42- a pairing URL
43- a QR code for the pairing URL
43 44
44Example:45From there, connect from another device in either of these ways:
45 46
46`http://192.168.1.42:3773`47- scan the QR code on your phone
48- in the desktop app, enter the full pairing URL
49- in the desktop app, enter the host and token separately
47 50
48Notes:51Use `t3 serve --help` for the full flag reference. It supports the same general startup options as the normal server command, including an optional `cwd` argument.
49 52
50- `--host 0.0.0.0` listens on all IPv4 interfaces.53> Note
51- `--no-browser` prevents local auto-open, which is usually better for headless/remote sessions.54> The GUIs do not currently support adding projects on remote environments.
52- Ensure your OS firewall allows inbound TCP on the selected port.55> For now, use `t3 project ...` on the server machine instead.
56> Full GUI support for remote project management is coming soon.
53 57
54## 2) Tailnet / Tailscale access58## How Pairing Works
55 59
56If you use Tailscale, you can bind directly to your Tailnet address.60The remote device does not need a long-lived secret up front.
57 61
58```bash62Instead:
59TAILNET_IP="$(tailscale ip -4)"63
60TOKEN="$(openssl rand -hex 24)"641. `t3 serve` issues a one-time owner pairing token.
61bun run --cwd apps/server start -- --host "$(tailscale ip -4)" --port 3773 --auth-token "$TOKEN" --no-browser652. The remote device exchanges that token with the server.
62```663. The server creates an authenticated session for that device.
67
68After pairing, future access is session-based. You do not need to keep reusing the original token unless you are pairing a new device.
69
70## Managing Access Later
71
72Use `t3 auth` to manage access after the initial pairing flow.
73
74Typical uses:
75
76- issue additional pairing credentials
77- inspect active sessions
78- revoke old pairing links or sessions
63 79
64Open from any device in your tailnet:80Use `t3 auth --help` and the nested subcommand help pages for the full reference.
65 81
66`http://<tailnet-ip>:3773`82## Security Notes
67 83
68You can also bind `--host 0.0.0.0` and connect through the Tailnet IP, but binding directly to the Tailnet IP limits exposure.84- Treat pairing URLs and pairing tokens like passwords.
85- Prefer binding `--host` to a trusted private address, such as a Tailnet IP, instead of exposing the server broadly.
86- Anyone with a valid pairing credential can create a session until that credential expires or is revoked.
87- Use `t3 auth` to revoke credentials or sessions you no longer trust.
apps/desktop/package.json+5−4
@@ -1,14 +1,15 @@
1{1{
2 "name": "@t3tools/desktop",2 "name": "@t3tools/desktop",
3 "version": "0.0.15",3 "version": "0.0.20",
4 "private": true,4 "private": true,
5 "main": "dist-electron/main.js",5 "type": "module",
6 "main": "dist-electron/main.cjs",
6 "scripts": {7 "scripts": {
7 "dev": "bun run --parallel dev:bundle dev:electron",8 "dev": "bun run --parallel dev:bundle dev:electron",
8 "dev:bundle": "tsdown --watch",9 "dev:bundle": "tsdown --watch",
9 "dev:electron": "bun run scripts/dev-electron.mjs",10 "dev:electron": "node scripts/dev-electron.mjs",
10 "build": "tsdown",11 "build": "tsdown",
11 "start": "bun run scripts/start-electron.mjs",12 "start": "node scripts/start-electron.mjs",
12 "typecheck": "tsc --noEmit",13 "typecheck": "tsc --noEmit",
13 "test": "vitest run --passWithNoTests",14 "test": "vitest run --passWithNoTests",
14 "smoke-test": "node scripts/smoke-test.mjs"15 "smoke-test": "node scripts/smoke-test.mjs"
apps/desktop/scripts/dev-electron.mjs+17−10
@@ -5,15 +5,24 @@ import { join } from "node:path"; · 4 unmodified lines
5import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";5import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs";
6import { waitForResources } from "./wait-for-resources.mjs";6import { waitForResources } from "./wait-for-resources.mjs";
7 7
8const port = Number(process.env.ELECTRON_RENDERER_PORT ?? 5733);8const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
9const devServerUrl = `http://localhost:${port}`;9if (!devServerUrl) {
10 throw new Error("VITE_DEV_SERVER_URL is required for desktop development.");
11}
12
13const devServer = new URL(devServerUrl);
14const port = Number.parseInt(devServer.port, 10);
15if (!Number.isInteger(port) || port <= 0) {
16 throw new Error(`VITE_DEV_SERVER_URL must include an explicit port: ${devServerUrl}`);
17}
18
10const requiredFiles = [19const requiredFiles = [
11 "dist-electron/main.js",20 "dist-electron/main.cjs",
12 "dist-electron/preload.js",21 "dist-electron/preload.cjs",
13 "../server/dist/bin.mjs",22 "../server/dist/bin.mjs",
14];23];
15const watchedDirectories = [24const watchedDirectories = [
16 { directory: "dist-electron", files: new Set(["main.js", "preload.js"]) },25 { directory: "dist-electron", files: new Set(["main.cjs", "preload.cjs"]) },
17 { directory: "../server/dist", files: new Set(["bin.mjs"]) },26 { directory: "../server/dist", files: new Set(["bin.mjs"]) },
18];27];
19const forcedShutdownTimeoutMs = 1_500;28const forcedShutdownTimeoutMs = 1_500;
@@ -23,6 +32,7 @@ const childTreeGracePeriodMs = 1_200; · 3 unmodified lines
23await waitForResources({32await waitForResources({
24 baseDir: desktopDir,33 baseDir: desktopDir,
25 files: requiredFiles,34 files: requiredFiles,
35 tcpHost: devServer.hostname,
26 tcpPort: port,36 tcpPort: port,
27});37});
28 38
@@ -59,13 +69,10 @@ function startApp() { · 30 unmodified lines
59 69
60 const app = spawn(70 const app = spawn(
61 resolveElectronPath(),71 resolveElectronPath(),
62 [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.js"],72 [`--t3code-dev-root=${desktopDir}`, "dist-electron/main.cjs"],
63 {73 {
64 cwd: desktopDir,74 cwd: desktopDir,
65 env: {75 env: childEnv,
66 ...childEnv,
67 VITE_DEV_SERVER_URL: devServerUrl,
68 },
69 stdio: "inherit",76 stdio: "inherit",
70 },77 },
71 );78 );
apps/desktop/scripts/electron-launcher.mjs+75−39
@@ -6,8 +6,8 @@ import { · 5 unmodified lines
6 cpSync,6 cpSync,
7 existsSync,7 existsSync,
8 mkdirSync,8 mkdirSync,
9 mkdtempSync,
9 readFileSync,10 readFileSync,
10 readdirSync,
11 rmSync,11 rmSync,
12 statSync,12 statSync,
13 writeFileSync,13 writeFileSync,
@@ -18,11 +18,14 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
18 18
19const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);19const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
20const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";20const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";
21const APP_BUNDLE_ID = "com.t3tools.t3code";21const APP_BUNDLE_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
22const LAUNCHER_VERSION = 1;22const LAUNCHER_VERSION = 2;
23 23
24const __dirname = dirname(fileURLToPath(import.meta.url));24const __dirname = dirname(fileURLToPath(import.meta.url));
25export const desktopDir = resolve(__dirname, "..");25export const desktopDir = resolve(__dirname, "..");
26const repoRoot = resolve(desktopDir, "..", "..");
27const defaultIconPath = join(desktopDir, "resources", "icon.icns");
28const developmentMacIconPngPath = join(repoRoot, "assets", "dev", "blueprint-macos-1024.png");
26 29
27function setPlistString(plistPath, key, value) {30function setPlistString(plistPath, key, value) {
28 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {31 const replaceResult = spawnSync("plutil", ["-replace", key, "-string", value, plistPath], {
@@ -43,6 +46,68 @@ function setPlistString(plistPath, key, value) { · 14 unmodified lines
43 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());46 throw new Error(`Failed to update plist key "${key}" at ${plistPath}: ${details}`.trim());
44}47}
45 48
49function runChecked(command, args) {
50 const result = spawnSync(command, args, { encoding: "utf8" });
51 if (result.status === 0) {
52 return;
53 }
54
55 const details = [result.stdout, result.stderr].filter(Boolean).join("\n");
56 throw new Error(`Failed to run ${command} ${args.join(" ")}: ${details}`.trim());
57}
58
59function ensureDevelopmentIconIcns(runtimeDir) {
60 const generatedIconPath = join(runtimeDir, "icon-dev.icns");
61 mkdirSync(runtimeDir, { recursive: true });
62
63 if (!existsSync(developmentMacIconPngPath)) {
64 return defaultIconPath;
65 }
66
67 const sourceMtimeMs = statSync(developmentMacIconPngPath).mtimeMs;
68 if (existsSync(generatedIconPath) && statSync(generatedIconPath).mtimeMs >= sourceMtimeMs) {
69 return generatedIconPath;
70 }
71
72 const iconsetRoot = mkdtempSync(join(runtimeDir, "dev-iconset-"));
73 const iconsetDir = join(iconsetRoot, "icon.iconset");
74 mkdirSync(iconsetDir, { recursive: true });
75
76 try {
77 for (const size of [16, 32, 128, 256, 512]) {
78 runChecked("sips", [
79 "-z",
80 String(size),
81 String(size),
82 developmentMacIconPngPath,
83 "--out",
84 join(iconsetDir, `icon_${size}x${size}.png`),
85 ]);
86
87 const retinaSize = size * 2;
88 runChecked("sips", [
89 "-z",
90 String(retinaSize),
91 String(retinaSize),
92 developmentMacIconPngPath,
93 "--out",
94 join(iconsetDir, `icon_${size}x${size}@2x.png`),
95 ]);
96 }
97
98 runChecked("iconutil", ["-c", "icns", iconsetDir, "-o", generatedIconPath]);
99 return generatedIconPath;
100 } catch (error) {
101 console.warn(
102 "[desktop-launcher] Failed to generate dev macOS icon, falling back to default icon.",
103 error,
104 );
105 return defaultIconPath;
106 } finally {
107 rmSync(iconsetRoot, { recursive: true, force: true });
108 }
109}
110
46function patchMainBundleInfoPlist(appBundlePath, iconPath) {111function patchMainBundleInfoPlist(appBundlePath, iconPath) {
47 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");112 const infoPlistPath = join(appBundlePath, "Contents", "Info.plist");
48 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);113 setPlistString(infoPlistPath, "CFBundleDisplayName", APP_DISPLAY_NAME);
@@ -55,40 +120,6 @@ function patchMainBundleInfoPlist(appBundlePath, iconPath) { · 6 unmodified lines
55 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));120 copyFileSync(iconPath, join(resourcesDir, "electron.icns"));
56}121}
57 122
58function patchHelperBundleInfoPlists(appBundlePath) {
59 const frameworksDir = join(appBundlePath, "Contents", "Frameworks");
60 if (!existsSync(frameworksDir)) {
61 return;
62 }
63
64 for (const entry of readdirSync(frameworksDir, { withFileTypes: true })) {
65 if (!entry.isDirectory() || !entry.name.endsWith(".app")) {
66 continue;
67 }
68 if (!entry.name.startsWith("Electron Helper")) {
69 continue;
70 }
71
72 const helperPlistPath = join(frameworksDir, entry.name, "Contents", "Info.plist");
73 if (!existsSync(helperPlistPath)) {
74 continue;
75 }
76
77 const suffix = entry.name.replace("Electron Helper", "").replace(".app", "").trim();
78 const helperName = suffix
79 ? `${APP_DISPLAY_NAME} Helper ${suffix}`
80 : `${APP_DISPLAY_NAME} Helper`;
81 const helperIdSuffix = suffix.replace(/[()]/g, "").trim().toLowerCase().replace(/\s+/g, "-");
82 const helperBundleId = helperIdSuffix
83 ? `${APP_BUNDLE_ID}.helper.${helperIdSuffix}`
84 : `${APP_BUNDLE_ID}.helper`;
85
86 setPlistString(helperPlistPath, "CFBundleDisplayName", helperName);
87 setPlistString(helperPlistPath, "CFBundleName", helperName);
88 setPlistString(helperPlistPath, "CFBundleIdentifier", helperBundleId);
89 }
90}
91
92function readJson(path) {123function readJson(path) {
93 try {124 try {
94 return JSON.parse(readFileSync(path, "utf8"));125 return JSON.parse(readFileSync(path, "utf8"));
@@ -102,7 +133,7 @@ function buildMacLauncher(electronBinaryPath) { · 7 unmodified lines
102 const runtimeDir = join(desktopDir, ".electron-runtime");133 const runtimeDir = join(desktopDir, ".electron-runtime");
103 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);134 const targetAppBundlePath = join(runtimeDir, `${APP_DISPLAY_NAME}.app`);
104 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");135 const targetBinaryPath = join(targetAppBundlePath, "Contents", "MacOS", "Electron");
105 const iconPath = join(desktopDir, "resources", "icon.icns");136 const iconPath = isDevelopment ? ensureDevelopmentIconIcns(runtimeDir) : defaultIconPath;
106 const metadataPath = join(runtimeDir, "metadata.json");137 const metadataPath = join(runtimeDir, "metadata.json");
107 138
108 mkdirSync(runtimeDir, { recursive: true });139 mkdirSync(runtimeDir, { recursive: true });
@@ -126,7 +157,6 @@ function buildMacLauncher(electronBinaryPath) { · 17 unmodified lines
126 rmSync(targetAppBundlePath, { recursive: true, force: true });157 rmSync(targetAppBundlePath, { recursive: true, force: true });
127 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });158 cpSync(sourceAppBundlePath, targetAppBundlePath, { recursive: true });
128 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);159 patchMainBundleInfoPlist(targetAppBundlePath, iconPath);
129 patchHelperBundleInfoPlists(targetAppBundlePath);
130 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);160 writeFileSync(metadataPath, `${JSON.stringify(expectedMetadata, null, 2)}\n`);
131 161
132 return targetBinaryPath;162 return targetBinaryPath;
@@ -140,5 +170,11 @@ export function resolveElectronPath() { · 7 unmodified lines
140 return electronBinaryPath;170 return electronBinaryPath;
141 }171 }
142 172
173 // Dev launches do not need a renamed app bundle badly enough to risk breaking
174 // Electron helper resource lookup on macOS.
175 if (isDevelopment) {
176 return electronBinaryPath;
177 }
178
143 return buildMacLauncher(electronBinaryPath);179 return buildMacLauncher(electronBinaryPath);
144}180}
apps/desktop/scripts/smoke-test.mjs+1−1
@@ -5,7 +5,7 @@ import { fileURLToPath } from "node:url"; · 4 unmodified lines
5const __dirname = dirname(fileURLToPath(import.meta.url));5const __dirname = dirname(fileURLToPath(import.meta.url));
6const desktopDir = resolve(__dirname, "..");6const desktopDir = resolve(__dirname, "..");
7const electronBin = resolve(desktopDir, "node_modules/.bin/electron");7const electronBin = resolve(desktopDir, "node_modules/.bin/electron");
8const mainJs = resolve(desktopDir, "dist-electron/main.js");8const mainJs = resolve(desktopDir, "dist-electron/main.cjs");
9 9
10console.log("\nLaunching Electron smoke test...");10console.log("\nLaunching Electron smoke test...");
11 11
apps/desktop/scripts/start-electron.mjs+1−1
@@ -5,7 +5,7 @@ import { desktopDir, resolveElectronPath } from "./electron-launcher.mjs"; · 4 unmodified lines
5const childEnv = { ...process.env };5const childEnv = { ...process.env };
6delete childEnv.ELECTRON_RUN_AS_NODE;6delete childEnv.ELECTRON_RUN_AS_NODE;
7 7
8const child = spawn(resolveElectronPath(), ["dist-electron/main.js"], {8const child = spawn(resolveElectronPath(), ["dist-electron/main.cjs"], {
9 stdio: "inherit",9 stdio: "inherit",
10 cwd: desktopDir,10 cwd: desktopDir,
11 env: childEnv,11 env: childEnv,
apps/desktop/src/appBranding.test.ts+47−0
@@ -0,0 +1,47 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopAppBranding, resolveDesktopAppStageLabel } from "./appBranding.ts";
4
5describe("resolveDesktopAppStageLabel", () => {
6 it("uses Dev in desktop development", () => {
7 expect(
8 resolveDesktopAppStageLabel({
9 isDevelopment: true,
10 appVersion: "0.0.17-nightly.20260414.1",
11 }),
12 ).toBe("Dev");
13 });
14
15 it("uses Nightly for packaged nightly builds", () => {
16 expect(
17 resolveDesktopAppStageLabel({
18 isDevelopment: false,
19 appVersion: "0.0.17-nightly.20260414.1",
20 }),
21 ).toBe("Nightly");
22 });
23
24 it("uses Alpha for packaged stable builds", () => {
25 expect(
26 resolveDesktopAppStageLabel({
27 isDevelopment: false,
28 appVersion: "0.0.17",
29 }),
30 ).toBe("Alpha");
31 });
32});
33
34describe("resolveDesktopAppBranding", () => {
35 it("returns a complete desktop branding payload", () => {
36 expect(
37 resolveDesktopAppBranding({
38 isDevelopment: false,
39 appVersion: "0.0.17-nightly.20260414.1",
40 }),
41 ).toEqual({
42 baseName: "T3 Code",
43 stageLabel: "Nightly",
44 displayName: "T3 Code (Nightly)",
45 });
46 });
47});
apps/desktop/src/appBranding.ts+28−0
@@ -0,0 +1,28 @@
1import type { DesktopAppBranding, DesktopAppStageLabel } from "@t3tools/contracts";
2
3import { isNightlyDesktopVersion } from "./updateChannels.ts";
4
5const APP_BASE_NAME = "T3 Code";
6
7export function resolveDesktopAppStageLabel(input: {
8 readonly isDevelopment: boolean;
9 readonly appVersion: string;
10}): DesktopAppStageLabel {
11 if (input.isDevelopment) {
12 return "Dev";
13 }
14
15 return isNightlyDesktopVersion(input.appVersion) ? "Nightly" : "Alpha";
16}
17
18export function resolveDesktopAppBranding(input: {
19 readonly isDevelopment: boolean;
20 readonly appVersion: string;
21}): DesktopAppBranding {
22 const stageLabel = resolveDesktopAppStageLabel(input);
23 return {
24 baseName: APP_BASE_NAME,
25 stageLabel,
26 displayName: `${APP_BASE_NAME} (${stageLabel})`,
27 };
28}
apps/desktop/src/backendPort.test.ts+109−0
@@ -0,0 +1,109 @@
1import { describe, expect, it, vi } from "vitest";
2
3import { resolveDesktopBackendPort } from "./backendPort.ts";
4
5describe("resolveDesktopBackendPort", () => {
6 it("returns the starting port when it is available", async () => {
7 const canListenOnHost = vi.fn(async (port: number) => port === 3773);
8
9 await expect(
10 resolveDesktopBackendPort({
11 host: "127.0.0.1",
12 startPort: 3773,
13 canListenOnHost,
14 }),
15 ).resolves.toBe(3773);
16
17 expect(canListenOnHost).toHaveBeenCalledTimes(1);
18 expect(canListenOnHost).toHaveBeenCalledWith(3773, "127.0.0.1");
19 });
20
21 it("increments sequentially until it finds an available port", async () => {
22 const canListenOnHost = vi.fn(async (port: number) => port === 3775);
23
24 await expect(
25 resolveDesktopBackendPort({
26 host: "127.0.0.1",
27 startPort: 3773,
28 canListenOnHost,
29 }),
30 ).resolves.toBe(3775);
31
32 expect(canListenOnHost.mock.calls).toEqual([
33 [3773, "127.0.0.1"],
34 [3774, "127.0.0.1"],
35 [3775, "127.0.0.1"],
36 ]);
37 });
38
39 it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => {
40 const canListenOnHost = vi.fn(async (port: number, host: string) => {
41 if (port === 3773 && host === "127.0.0.1") return true;
42 if (port === 3773 && host === "0.0.0.0") return false;
43 return port === 3774;
44 });
45
46 await expect(
47 resolveDesktopBackendPort({
48 host: "127.0.0.1",
49 requiredHosts: ["0.0.0.0"],
50 startPort: 3773,
51 canListenOnHost,
52 }),
53 ).resolves.toBe(3774);
54
55 expect(canListenOnHost.mock.calls).toEqual([
56 [3773, "127.0.0.1"],
57 [3773, "0.0.0.0"],
58 [3774, "127.0.0.1"],
59 [3774, "0.0.0.0"],
60 ]);
61 });
62
63 it("checks overlapping hosts sequentially to avoid self-interference", async () => {
64 let inFlightCount = 0;
65 const canListenOnHost = vi.fn(async (_port: number, _host: string) => {
66 inFlightCount += 1;
67 const overlapped = inFlightCount > 1;
68 await Promise.resolve();
69 inFlightCount -= 1;
70 return !overlapped;
71 });
72
73 await expect(
74 resolveDesktopBackendPort({
75 host: "127.0.0.1",
76 requiredHosts: ["0.0.0.0", "::"],
77 startPort: 3773,
78 maxPort: 3773,
79 canListenOnHost,
80 }),
81 ).resolves.toBe(3773);
82
83 expect(canListenOnHost.mock.calls).toEqual([
84 [3773, "127.0.0.1"],
85 [3773, "0.0.0.0"],
86 [3773, "::"],
87 ]);
88 });
89
90 it("fails when the scan range is exhausted", async () => {
91 const canListenOnHost = vi.fn(async () => false);
92
93 await expect(
94 resolveDesktopBackendPort({
95 host: "127.0.0.1",
96 startPort: 65534,
97 maxPort: 65535,
98 canListenOnHost,
99 }),
100 ).rejects.toThrow(
101 "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535",
102 );
103
104 expect(canListenOnHost.mock.calls).toEqual([
105 [65534, "127.0.0.1"],
106 [65535, "127.0.0.1"],
107 ]);
108 });
109});
apps/desktop/src/backendPort.ts+83−0
@@ -0,0 +1,83 @@
1import * as Effect from "effect/Effect";
2import { NetService } from "@t3tools/shared/Net";
3
4export const DEFAULT_DESKTOP_BACKEND_PORT = 3773;
5const MAX_TCP_PORT = 65_535;
6
7export interface ResolveDesktopBackendPortOptions {
8 readonly host: string;
9 readonly startPort?: number;
10 readonly maxPort?: number;
11 readonly requiredHosts?: ReadonlyArray<string>;
12 readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>;
13}
14
15const defaultCanListenOnHost = async (port: number, host: string): Promise<boolean> =>
16 Effect.service(NetService).pipe(
17 Effect.flatMap((net) => net.canListenOnHost(port, host)),
18 Effect.provide(NetService.layer),
19 Effect.runPromise,
20 );
21
22const isValidPort = (port: number): boolean =>
23 Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT;
24
25const normalizeHosts = (
26 host: string,
27 requiredHosts: ReadonlyArray<string>,
28): ReadonlyArray<string> =>
29 Array.from(
30 new Set(
31 [host, ...requiredHosts]
32 .map((candidate) => candidate.trim())
33 .filter((candidate) => candidate.length > 0),
34 ),
35 );
36
37async function canListenOnAllHosts(
38 port: number,
39 hosts: ReadonlyArray<string>,
40 canListenOnHost: (port: number, host: string) => Promise<boolean>,
41): Promise<boolean> {
42 for (const candidateHost of hosts) {
43 if (!(await canListenOnHost(port, candidateHost))) {
44 return false;
45 }
46 }
47
48 return true;
49}
50
51export async function resolveDesktopBackendPort({
52 host,
53 startPort = DEFAULT_DESKTOP_BACKEND_PORT,
54 maxPort = MAX_TCP_PORT,
55 requiredHosts = [],
56 canListenOnHost = defaultCanListenOnHost,
57}: ResolveDesktopBackendPortOptions): Promise<number> {
58 if (!isValidPort(startPort)) {
59 throw new Error(`Invalid desktop backend start port: ${startPort}`);
60 }
61
62 if (!isValidPort(maxPort)) {
63 throw new Error(`Invalid desktop backend max port: ${maxPort}`);
64 }
65
66 if (maxPort < startPort) {
67 throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`);
68 }
69
70 const hostsToCheck = normalizeHosts(host, requiredHosts);
71
72 // Keep desktop startup predictable across app restarts by probing upward from
73 // the same preferred port instead of picking a fresh ephemeral port.
74 for (let port = startPort; port <= maxPort; port += 1) {
75 if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) {
76 return port;
77 }
78 }
79
80 throw new Error(
81 `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`,
82 );
83}
apps/desktop/src/backendReadiness.test.ts+114−0
@@ -0,0 +1,114 @@
1import { describe, expect, it, vi } from "vitest";
2
3import {
4 BackendReadinessAbortedError,
5 isBackendReadinessAborted,
6 waitForHttpReady,
7} from "./backendReadiness.ts";
8
9describe("waitForHttpReady", () => {
10 it("returns once the backend serves the requested readiness path", async () => {
11 const fetchImpl = vi
12 .fn<typeof fetch>()
13 .mockResolvedValueOnce(new Response(null, { status: 503 }))
14 .mockResolvedValueOnce(new Response(null, { status: 200 }));
15
16 await waitForHttpReady("http://127.0.0.1:3773", {
17 fetchImpl,
18 timeoutMs: 1_000,
19 intervalMs: 0,
20 });
21
22 expect(fetchImpl).toHaveBeenCalledTimes(2);
23 expect(fetchImpl).toHaveBeenNthCalledWith(
24 1,
25 "http://127.0.0.1:3773/",
26 expect.objectContaining({ redirect: "manual" }),
27 );
28 });
29
30 it("retries after a readiness request stalls past the per-request timeout", async () => {
31 const fetchImpl = vi
32 .fn<typeof fetch>()
33 .mockImplementationOnce(
34 (_input, init) =>
35 new Promise((_resolve, reject) => {
36 init?.signal?.addEventListener(
37 "abort",
38 () => {
39 reject(new Error("request timed out"));
40 },
41 { once: true },
42 );
43 }) as ReturnType<typeof fetch>,
44 )
45 .mockResolvedValueOnce(new Response(null, { status: 200 }));
46
47 await waitForHttpReady("http://127.0.0.1:3773", {
48 fetchImpl,
49 timeoutMs: 100,
50 intervalMs: 0,
51 requestTimeoutMs: 1,
52 });
53
54 expect(fetchImpl).toHaveBeenCalledTimes(2);
55 });
56
57 it("aborts an in-flight readiness wait", async () => {
58 const controller = new AbortController();
59 const fetchImpl = vi.fn<typeof fetch>().mockImplementation(
60 () =>
61 new Promise((_resolve, reject) => {
62 controller.signal.addEventListener(
63 "abort",
64 () => {
65 reject(new BackendReadinessAbortedError());
66 },
67 { once: true },
68 );
69 }) as ReturnType<typeof fetch>,
70 );
71
72 const waitPromise = waitForHttpReady("http://127.0.0.1:3773", {
73 fetchImpl,
74 timeoutMs: 1_000,
75 intervalMs: 0,
76 signal: controller.signal,
77 });
78
79 controller.abort();
80
81 await expect(waitPromise).rejects.toBeInstanceOf(BackendReadinessAbortedError);
82 });
83
84 it("recognizes aborted readiness errors", () => {
85 expect(isBackendReadinessAborted(new BackendReadinessAbortedError())).toBe(true);
86 expect(isBackendReadinessAborted(new Error("nope"))).toBe(false);
87 });
88
89 it("supports custom readiness predicates", async () => {
90 const fetchImpl = vi
91 .fn<typeof fetch>()
92 .mockResolvedValueOnce(new Response(null, { status: 200 }))
93 .mockResolvedValueOnce(new Response(null, { status: 204 }));
94
95 await waitForHttpReady("http://127.0.0.1:3773", {
96 fetchImpl,
97 timeoutMs: 1_000,
98 intervalMs: 0,
99 path: "/api/healthz",
100 isReady: (response) => response.status === 204,
101 });
102
103 expect(fetchImpl).toHaveBeenNthCalledWith(
104 1,
105 "http://127.0.0.1:3773/api/healthz",
106 expect.objectContaining({ redirect: "manual" }),
107 );
108 expect(fetchImpl).toHaveBeenNthCalledWith(
109 2,
110 "http://127.0.0.1:3773/api/healthz",
111 expect.objectContaining({ redirect: "manual" }),
112 );
113 });
114});
apps/desktop/src/backendReadiness.ts+107−0
@@ -0,0 +1,107 @@
1export interface WaitForHttpReadyOptions {
2 readonly timeoutMs?: number;
3 readonly intervalMs?: number;
4 readonly requestTimeoutMs?: number;
5 readonly fetchImpl?: typeof fetch;
6 readonly signal?: AbortSignal;
7 readonly path?: string;
8 readonly isReady?: (response: Response) => boolean;
9}
10
11const DEFAULT_TIMEOUT_MS = 30_000;
12const DEFAULT_INTERVAL_MS = 100;
13const DEFAULT_REQUEST_TIMEOUT_MS = 1_000;
14
15export class BackendReadinessAbortedError extends Error {
16 constructor() {
17 super("Backend readiness wait was aborted.");
18 this.name = "BackendReadinessAbortedError";
19 }
20}
21
22function delay(ms: number, signal: AbortSignal | undefined): Promise<void> {
23 return new Promise<void>((resolve, reject) => {
24 const timer = setTimeout(() => {
25 cleanup();
26 resolve();
27 }, ms);
28
29 const onAbort = () => {
30 cleanup();
31 reject(new BackendReadinessAbortedError());
32 };
33
34 const cleanup = () => {
35 clearTimeout(timer);
36 signal?.removeEventListener("abort", onAbort);
37 };
38
39 if (signal?.aborted) {
40 cleanup();
41 reject(new BackendReadinessAbortedError());
42 return;
43 }
44
45 signal?.addEventListener("abort", onAbort, { once: true });
46 });
47}
48
49export function isBackendReadinessAborted(error: unknown): error is BackendReadinessAbortedError {
50 return error instanceof BackendReadinessAbortedError;
51}
52
53export async function waitForHttpReady(
54 baseUrl: string,
55 options?: WaitForHttpReadyOptions,
56): Promise<void> {
57 const fetchImpl = options?.fetchImpl ?? fetch;
58 const signal = options?.signal;
59 const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
60 const intervalMs = options?.intervalMs ?? DEFAULT_INTERVAL_MS;
61 const requestTimeoutMs = options?.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
62 const readinessPath = options?.path ?? "/";
63 const isReady = options?.isReady ?? ((response: Response) => response.ok);
64 const deadline = Date.now() + timeoutMs;
65
66 for (;;) {
67 if (signal?.aborted) {
68 throw new BackendReadinessAbortedError();
69 }
70
71 const requestController = new AbortController();
72 const requestTimeout = setTimeout(() => {
73 requestController.abort();
74 }, requestTimeoutMs);
75 const abortRequest = () => {
76 requestController.abort();
77 };
78 signal?.addEventListener("abort", abortRequest, { once: true });
79
80 try {
81 const response = await fetchImpl(new URL(readinessPath, baseUrl).toString(), {
82 redirect: "manual",
83 signal: requestController.signal,
84 });
85 if (isReady(response)) {
86 return;
87 }
88 } catch (error) {
89 if (isBackendReadinessAborted(error)) {
90 throw error;
91 }
92 if (signal?.aborted) {
93 throw new BackendReadinessAbortedError();
94 }
95 // Retry until the backend becomes reachable or the deadline expires.
96 } finally {
97 clearTimeout(requestTimeout);
98 signal?.removeEventListener("abort", abortRequest);
99 }
100
101 if (Date.now() >= deadline) {
102 throw new Error(`Timed out waiting for backend readiness at ${baseUrl}.`);
103 }
104
105 await delay(intervalMs, signal);
106 }
107}
apps/desktop/src/clientPersistence.test.ts+238−0
@@ -0,0 +1,238 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import {
6 EnvironmentId,
7 type ClientSettings,
8 type PersistedSavedEnvironmentRecord,
9} from "@t3tools/contracts";
10import { afterEach, describe, expect, it } from "vitest";
11
12import {
13 readClientSettings,
14 readSavedEnvironmentRegistry,
15 readSavedEnvironmentSecret,
16 removeSavedEnvironmentSecret,
17 writeClientSettings,
18 writeSavedEnvironmentRegistry,
19 writeSavedEnvironmentSecret,
20 type DesktopSecretStorage,
21} from "./clientPersistence.ts";
22
23const tempDirectories: string[] = [];
24
25afterEach(() => {
26 for (const directory of tempDirectories.splice(0)) {
27 fs.rmSync(directory, { recursive: true, force: true });
28 }
29});
30
31function makeTempPath(fileName: string): string {
32 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-client-persistence-test-"));
33 tempDirectories.push(directory);
34 return path.join(directory, fileName);
35}
36
37function makeSecretStorage(available: boolean): DesktopSecretStorage {
38 return {
39 isEncryptionAvailable: () => available,
40 encryptString: (value) => Buffer.from(`enc:${value}`, "utf8"),
41 decryptString: (value) => {
42 const decoded = value.toString("utf8");
43 if (!decoded.startsWith("enc:")) {
44 throw new Error("invalid secret");
45 }
46 return decoded.slice("enc:".length);
47 },
48 };
49}
50
51const clientSettings: ClientSettings = {
52 confirmThreadArchive: true,
53 confirmThreadDelete: false,
54 diffWordWrap: true,
55 sidebarProjectGroupingMode: "repository_path",
56 sidebarProjectGroupingOverrides: {
57 "environment-1:/tmp/project-a": "separate",
58 },
59 sidebarProjectSortOrder: "manual",
60 sidebarThreadSortOrder: "created_at",
61 timestampFormat: "24-hour",
62};
63
64const savedRegistryRecord: PersistedSavedEnvironmentRecord = {
65 environmentId: EnvironmentId.make("environment-1"),
66 label: "Remote environment",
67 httpBaseUrl: "https://remote.example.com/",
68 wsBaseUrl: "wss://remote.example.com/",
69 createdAt: "2026-04-09T00:00:00.000Z",
70 lastConnectedAt: "2026-04-09T01:00:00.000Z",
71};
72
73describe("clientPersistence", () => {
74 it("persists and reloads client settings", () => {
75 const settingsPath = makeTempPath("client-settings.json");
76
77 writeClientSettings(settingsPath, clientSettings);
78
79 expect(readClientSettings(settingsPath)).toEqual(clientSettings);
80 });
81
82 it("persists and reloads saved environment metadata", () => {
83 const registryPath = makeTempPath("saved-environments.json");
84
85 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
86
87 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
88 });
89
90 it("persists encrypted saved environment secrets when encryption is available", () => {
91 const registryPath = makeTempPath("saved-environments.json");
92 const secretStorage = makeSecretStorage(true);
93
94 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
95
96 expect(
97 writeSavedEnvironmentSecret({
98 registryPath,
99 environmentId: savedRegistryRecord.environmentId,
100 secret: "bearer-token",
101 secretStorage,
102 }),
103 ).toBe(true);
104
105 expect(
106 readSavedEnvironmentSecret({
107 registryPath,
108 environmentId: savedRegistryRecord.environmentId,
109 secretStorage,
110 }),
111 ).toBe("bearer-token");
112
113 expect(JSON.parse(fs.readFileSync(registryPath, "utf8"))).toEqual({
114 records: [
115 {
116 ...savedRegistryRecord,
117 encryptedBearerToken: Buffer.from("enc:bearer-token", "utf8").toString("base64"),
118 },
119 ],
120 });
121 });
122
123 it("preserves existing secrets when encryption is unavailable", () => {
124 const registryPath = makeTempPath("saved-environments.json");
125 const availableSecretStorage = makeSecretStorage(true);
126
127 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
128
129 writeSavedEnvironmentSecret({
130 registryPath,
131 environmentId: savedRegistryRecord.environmentId,
132 secret: "bearer-token",
133 secretStorage: availableSecretStorage,
134 });
135
136 expect(
137 writeSavedEnvironmentSecret({
138 registryPath,
139 environmentId: savedRegistryRecord.environmentId,
140 secret: "next-token",
141 secretStorage: makeSecretStorage(false),
142 }),
143 ).toBe(false);
144
145 expect(
146 readSavedEnvironmentSecret({
147 registryPath,
148 environmentId: savedRegistryRecord.environmentId,
149 secretStorage: availableSecretStorage,
150 }),
151 ).toBe("bearer-token");
152 });
153
154 it("removes saved environment secrets", () => {
155 const registryPath = makeTempPath("saved-environments.json");
156 const secretStorage = makeSecretStorage(true);
157
158 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
159
160 writeSavedEnvironmentSecret({
161 registryPath,
162 environmentId: savedRegistryRecord.environmentId,
163 secret: "bearer-token",
164 secretStorage,
165 });
166
167 removeSavedEnvironmentSecret({
168 registryPath,
169 environmentId: savedRegistryRecord.environmentId,
170 });
171
172 expect(
173 readSavedEnvironmentSecret({
174 registryPath,
175 environmentId: savedRegistryRecord.environmentId,
176 secretStorage,
177 }),
178 ).toBeNull();
179 });
180
181 it("treats malformed secrets documents as empty", () => {
182 const registryPath = makeTempPath("saved-environments.json");
183 fs.writeFileSync(registryPath, "{}\n", "utf8");
184
185 expect(
186 readSavedEnvironmentSecret({
187 registryPath,
188 environmentId: savedRegistryRecord.environmentId,
189 secretStorage: makeSecretStorage(true),
190 }),
191 ).toBeNull();
192
193 expect(() =>
194 removeSavedEnvironmentSecret({
195 registryPath,
196 environmentId: savedRegistryRecord.environmentId,
197 }),
198 ).not.toThrow();
199 });
200
201 it("returns false when writing a secret without metadata", () => {
202 const registryPath = makeTempPath("saved-environments.json");
203
204 expect(
205 writeSavedEnvironmentSecret({
206 registryPath,
207 environmentId: savedRegistryRecord.environmentId,
208 secret: "bearer-token",
209 secretStorage: makeSecretStorage(true),
210 }),
211 ).toBe(false);
212 });
213
214 it("preserves encrypted secrets when metadata is rewritten", () => {
215 const registryPath = makeTempPath("saved-environments.json");
216 const secretStorage = makeSecretStorage(true);
217
218 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
219
220 writeSavedEnvironmentSecret({
221 registryPath,
222 environmentId: savedRegistryRecord.environmentId,
223 secret: "bearer-token",
224 secretStorage,
225 });
226
227 writeSavedEnvironmentRegistry(registryPath, [savedRegistryRecord]);
228
229 expect(readSavedEnvironmentRegistry(registryPath)).toEqual([savedRegistryRecord]);
230 expect(
231 readSavedEnvironmentSecret({
232 registryPath,
233 environmentId: savedRegistryRecord.environmentId,
234 secretStorage,
235 }),
236 ).toBe("bearer-token");
237 });
238});
apps/desktop/src/clientPersistence.ts+229−0
@@ -0,0 +1,229 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3
4import {
5 ClientSettingsSchema,
6 type ClientSettings,
7 type PersistedSavedEnvironmentRecord,
8} from "@t3tools/contracts";
9import { Predicate } from "effect";
10import * as Schema from "effect/Schema";
11
12interface ClientSettingsDocument {
13 readonly settings: ClientSettings;
14}
15
16interface PersistedSavedEnvironmentStorageRecord extends PersistedSavedEnvironmentRecord {
17 readonly encryptedBearerToken?: string;
18}
19
20interface SavedEnvironmentRegistryDocument {
21 readonly records: readonly PersistedSavedEnvironmentStorageRecord[];
22}
23
24export interface DesktopSecretStorage {
25 readonly isEncryptionAvailable: () => boolean;
26 readonly encryptString: (value: string) => Buffer;
27 readonly decryptString: (value: Buffer) => string;
28}
29
30function readJsonFile<T>(filePath: string): T | null {
31 try {
32 if (!FS.existsSync(filePath)) {
33 return null;
34 }
35 return JSON.parse(FS.readFileSync(filePath, "utf8")) as T;
36 } catch {
37 return null;
38 }
39}
40
41function writeJsonFile(filePath: string, value: unknown): void {
42 const directory = Path.dirname(filePath);
43 const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
44 FS.mkdirSync(directory, { recursive: true });
45 FS.writeFileSync(tempPath, `${JSON.stringify(value, null, 2)}\n`, "utf8");
46 FS.renameSync(tempPath, filePath);
47}
48
49function isPersistedSavedEnvironmentStorageRecord(
50 value: unknown,
51): value is PersistedSavedEnvironmentStorageRecord {
52 return (
53 Predicate.isObject(value) &&
54 typeof value.environmentId === "string" &&
55 typeof value.label === "string" &&
56 typeof value.httpBaseUrl === "string" &&
57 typeof value.wsBaseUrl === "string" &&
58 typeof value.createdAt === "string" &&
59 (value.lastConnectedAt === null || typeof value.lastConnectedAt === "string") &&
60 (value.encryptedBearerToken === undefined || typeof value.encryptedBearerToken === "string")
61 );
62}
63
64function readSavedEnvironmentRegistryDocument(filePath: string): SavedEnvironmentRegistryDocument {
65 const parsed = readJsonFile<SavedEnvironmentRegistryDocument>(filePath);
66 if (!Predicate.isObject(parsed)) {
67 return { records: [] };
68 }
69
70 return {
71 records: Array.isArray(parsed.records)
72 ? parsed.records.filter(isPersistedSavedEnvironmentStorageRecord)
73 : [],
74 };
75}
76
77function toPersistedSavedEnvironmentRecord(
78 record: PersistedSavedEnvironmentStorageRecord,
79): PersistedSavedEnvironmentRecord {
80 return {
81 environmentId: record.environmentId,
82 label: record.label,
83 httpBaseUrl: record.httpBaseUrl,
84 wsBaseUrl: record.wsBaseUrl,
85 createdAt: record.createdAt,
86 lastConnectedAt: record.lastConnectedAt,
87 };
88}
89
90export function readClientSettings(settingsPath: string): ClientSettings | null {
91 const raw = readJsonFile<ClientSettingsDocument>(settingsPath)?.settings;
92 if (!raw) {
93 return null;
94 }
95 try {
96 return Schema.decodeUnknownSync(ClientSettingsSchema)(raw);
97 } catch {
98 return null;
99 }
100}
101
102export function writeClientSettings(settingsPath: string, settings: ClientSettings): void {
103 writeJsonFile(settingsPath, { settings } satisfies ClientSettingsDocument);
104}
105
106export function readSavedEnvironmentRegistry(
107 registryPath: string,
108): readonly PersistedSavedEnvironmentRecord[] {
109 return readSavedEnvironmentRegistryDocument(registryPath).records.map((record) =>
110 toPersistedSavedEnvironmentRecord(record),
111 );
112}
113
114export function writeSavedEnvironmentRegistry(
115 registryPath: string,
116 records: readonly PersistedSavedEnvironmentRecord[],
117): void {
118 const currentDocument = readSavedEnvironmentRegistryDocument(registryPath);
119 const encryptedBearerTokenById = new Map(
120 currentDocument.records.flatMap((record) =>
121 record.encryptedBearerToken
122 ? [[record.environmentId, record.encryptedBearerToken] as const]
123 : [],
124 ),
125 );
126 writeJsonFile(registryPath, {
127 records: records.map((record) => {
128 const encryptedBearerToken = encryptedBearerTokenById.get(record.environmentId);
129 return encryptedBearerToken
130 ? {
131 environmentId: record.environmentId,
132 label: record.label,
133 httpBaseUrl: record.httpBaseUrl,
134 wsBaseUrl: record.wsBaseUrl,
135 createdAt: record.createdAt,
136 lastConnectedAt: record.lastConnectedAt,
137 encryptedBearerToken,
138 }
139 : record;
140 }),
141 } satisfies SavedEnvironmentRegistryDocument);
142}
143
144export function readSavedEnvironmentSecret(input: {
145 readonly registryPath: string;
146 readonly environmentId: string;
147 readonly secretStorage: DesktopSecretStorage;
148}): string | null {
149 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
150 const encoded = document.records.find(
151 (record) => record.environmentId === input.environmentId,
152 )?.encryptedBearerToken;
153 if (!encoded) {
154 return null;
155 }
156
157 if (!input.secretStorage.isEncryptionAvailable()) {
158 return null;
159 }
160
161 try {
162 return input.secretStorage.decryptString(Buffer.from(encoded, "base64"));
163 } catch {
164 return null;
165 }
166}
167
168export function writeSavedEnvironmentSecret(input: {
169 readonly registryPath: string;
170 readonly environmentId: string;
171 readonly secret: string;
172 readonly secretStorage: DesktopSecretStorage;
173}): boolean {
174 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
175
176 if (!input.secretStorage.isEncryptionAvailable()) {
177 return false;
178 }
179
180 let found = false;
181
182 writeJsonFile(input.registryPath, {
183 records: document.records.map((record) => {
184 if (record.environmentId !== input.environmentId) {
185 return record;
186 }
187
188 found = true;
189 const encryptedBearerToken = input.secretStorage
190 .encryptString(input.secret)
191 .toString("base64");
192 return {
193 environmentId: record.environmentId,
194 label: record.label,
195 httpBaseUrl: record.httpBaseUrl,
196 wsBaseUrl: record.wsBaseUrl,
197 createdAt: record.createdAt,
198 lastConnectedAt: record.lastConnectedAt,
199 encryptedBearerToken,
200 } satisfies PersistedSavedEnvironmentStorageRecord;
201 }),
202 } satisfies SavedEnvironmentRegistryDocument);
203 return found;
204}
205
206export function removeSavedEnvironmentSecret(input: {
207 readonly registryPath: string;
208 readonly environmentId: string;
209}): void {
210 const document = readSavedEnvironmentRegistryDocument(input.registryPath);
211 if (
212 !document.records.some(
213 (record) =>
214 record.environmentId === input.environmentId && record.encryptedBearerToken !== undefined,
215 )
216 ) {
217 return;
218 }
219
220 writeJsonFile(input.registryPath, {
221 records: document.records.map((record) => {
222 if (record.environmentId !== input.environmentId) {
223 return record;
224 }
225
226 return toPersistedSavedEnvironmentRecord(record);
227 }),
228 } satisfies SavedEnvironmentRegistryDocument);
229}
apps/desktop/src/confirmDialog.test.ts+1−1
@@ -11,7 +11,7 @@ vi.mock("electron", () => ({ · 10 unmodified lines
11 },11 },
12}));12}));
13 13
14import { showDesktopConfirmDialog } from "./confirmDialog";14import { showDesktopConfirmDialog } from "./confirmDialog.ts";
15 15
16describe("showDesktopConfirmDialog", () => {16describe("showDesktopConfirmDialog", () => {
17 beforeEach(() => {17 beforeEach(() => {
apps/desktop/src/desktopSettings.test.ts+147−0
@@ -0,0 +1,147 @@
1import * as fs from "node:fs";
2import * as os from "node:os";
3import * as path from "node:path";
4
5import { afterEach, describe, expect, it } from "vitest";
6
7import {
8 DEFAULT_DESKTOP_SETTINGS,
9 readDesktopSettings,
10 resolveDefaultDesktopSettings,
11 setDesktopServerExposurePreference,
12 setDesktopUpdateChannelPreference,
13 writeDesktopSettings,
14} from "./desktopSettings.ts";
15
16const tempDirectories: string[] = [];
17
18afterEach(() => {
19 for (const directory of tempDirectories.splice(0)) {
20 fs.rmSync(directory, { recursive: true, force: true });
21 }
22});
23
24function makeSettingsPath() {
25 const directory = fs.mkdtempSync(path.join(os.tmpdir(), "t3-desktop-settings-test-"));
26 tempDirectories.push(directory);
27 return path.join(directory, "desktop-settings.json");
28}
29
30describe("desktopSettings", () => {
31 it("returns defaults when no settings file exists", () => {
32 expect(readDesktopSettings(makeSettingsPath(), "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
33 });
34
35 it("defaults packaged nightly builds to the nightly update channel", () => {
36 expect(resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1")).toEqual({
37 serverExposureMode: "local-only",
38 updateChannel: "nightly",
39 updateChannelConfiguredByUser: false,
40 });
41 });
42
43 it("persists and reloads the configured server exposure mode", () => {
44 const settingsPath = makeSettingsPath();
45
46 writeDesktopSettings(settingsPath, {
47 serverExposureMode: "network-accessible",
48 updateChannel: "latest",
49 updateChannelConfiguredByUser: true,
50 });
51
52 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual({
53 serverExposureMode: "network-accessible",
54 updateChannel: "latest",
55 updateChannelConfiguredByUser: true,
56 });
57 });
58
59 it("preserves the requested network-accessible preference across temporary fallback", () => {
60 expect(
61 setDesktopServerExposurePreference(
62 {
63 serverExposureMode: "local-only",
64 updateChannel: "latest",
65 updateChannelConfiguredByUser: false,
66 },
67 "network-accessible",
68 ),
69 ).toEqual({
70 serverExposureMode: "network-accessible",
71 updateChannel: "latest",
72 updateChannelConfiguredByUser: false,
73 });
74 });
75
76 it("persists the requested nightly update channel", () => {
77 expect(
78 setDesktopUpdateChannelPreference(
79 {
80 serverExposureMode: "local-only",
81 updateChannel: "latest",
82 updateChannelConfiguredByUser: false,
83 },
84 "nightly",
85 ),
86 ).toEqual({
87 serverExposureMode: "local-only",
88 updateChannel: "nightly",
89 updateChannelConfiguredByUser: true,
90 });
91 });
92
93 it("falls back to defaults when the settings file is malformed", () => {
94 const settingsPath = makeSettingsPath();
95 fs.writeFileSync(settingsPath, "{not-json", "utf8");
96
97 expect(readDesktopSettings(settingsPath, "0.0.17")).toEqual(DEFAULT_DESKTOP_SETTINGS);
98 });
99
100 it("falls back to the nightly channel for legacy nightly settings without an update track", () => {
101 const settingsPath = makeSettingsPath();
102 fs.writeFileSync(settingsPath, JSON.stringify({ serverExposureMode: "local-only" }), "utf8");
103
104 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
105 serverExposureMode: "local-only",
106 updateChannel: "nightly",
107 updateChannelConfiguredByUser: false,
108 });
109 });
110
111 it("migrates legacy implicit stable settings to nightly when running a nightly build", () => {
112 const settingsPath = makeSettingsPath();
113 fs.writeFileSync(
114 settingsPath,
115 JSON.stringify({
116 serverExposureMode: "local-only",
117 updateChannel: "latest",
118 }),
119 "utf8",
120 );
121
122 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
123 serverExposureMode: "local-only",
124 updateChannel: "nightly",
125 updateChannelConfiguredByUser: false,
126 });
127 });
128
129 it("preserves an explicit stable choice on nightly builds", () => {
130 const settingsPath = makeSettingsPath();
131 fs.writeFileSync(
132 settingsPath,
133 JSON.stringify({
134 serverExposureMode: "local-only",
135 updateChannel: "latest",
136 updateChannelConfiguredByUser: true,
137 }),
138 "utf8",
139 );
140
141 expect(readDesktopSettings(settingsPath, "0.0.17-nightly.20260415.1")).toEqual({
142 serverExposureMode: "local-only",
143 updateChannel: "latest",
144 updateChannelConfiguredByUser: true,
145 });
146 });
147});
apps/desktop/src/desktopSettings.ts+92−0
@@ -0,0 +1,92 @@
1import * as FS from "node:fs";
2import * as Path from "node:path";
3import type { DesktopServerExposureMode, DesktopUpdateChannel } from "@t3tools/contracts";
4
5import { resolveDefaultDesktopUpdateChannel } from "./updateChannels.ts";
6
7export interface DesktopSettings {
8 readonly serverExposureMode: DesktopServerExposureMode;
9 readonly updateChannel: DesktopUpdateChannel;
10 readonly updateChannelConfiguredByUser: boolean;
11}
12
13export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = {
14 serverExposureMode: "local-only",
15 updateChannel: "latest",
16 updateChannelConfiguredByUser: false,
17};
18
19export function resolveDefaultDesktopSettings(appVersion: string): DesktopSettings {
20 return {
21 ...DEFAULT_DESKTOP_SETTINGS,
22 updateChannel: resolveDefaultDesktopUpdateChannel(appVersion),
23 };
24}
25
26export function setDesktopServerExposurePreference(
27 settings: DesktopSettings,
28 requestedMode: DesktopServerExposureMode,
29): DesktopSettings {
30 return settings.serverExposureMode === requestedMode
31 ? settings
32 : {
33 ...settings,
34 serverExposureMode: requestedMode,
35 };
36}
37
38export function setDesktopUpdateChannelPreference(
39 settings: DesktopSettings,
40 requestedChannel: DesktopUpdateChannel,
41): DesktopSettings {
42 return {
43 ...settings,
44 updateChannel: requestedChannel,
45 updateChannelConfiguredByUser: true,
46 };
47}
48
49export function readDesktopSettings(settingsPath: string, appVersion: string): DesktopSettings {
50 const defaultSettings = resolveDefaultDesktopSettings(appVersion);
51
52 try {
53 if (!FS.existsSync(settingsPath)) {
54 return defaultSettings;
55 }
56
57 const raw = FS.readFileSync(settingsPath, "utf8");
58 const parsed = JSON.parse(raw) as {
59 readonly serverExposureMode?: unknown;
60 readonly updateChannel?: unknown;
61 readonly updateChannelConfiguredByUser?: unknown;
62 };
63 const parsedUpdateChannel =
64 parsed.updateChannel === "nightly" || parsed.updateChannel === "latest"
65 ? parsed.updateChannel
66 : null;
67 const isLegacySettings = parsed.updateChannelConfiguredByUser === undefined;
68 const updateChannelConfiguredByUser =
69 parsed.updateChannelConfiguredByUser === true ||
70 (isLegacySettings && parsedUpdateChannel === "nightly");
71
72 return {
73 serverExposureMode:
74 parsed.serverExposureMode === "network-accessible" ? "network-accessible" : "local-only",
75 updateChannel:
76 updateChannelConfiguredByUser && parsedUpdateChannel !== null
77 ? parsedUpdateChannel
78 : defaultSettings.updateChannel,
79 updateChannelConfiguredByUser,
80 };
81 } catch {
82 return defaultSettings;
83 }
84}
85
86export function writeDesktopSettings(settingsPath: string, settings: DesktopSettings): void {
87 const directory = Path.dirname(settingsPath);
88 const tempPath = `${settingsPath}.${process.pid}.${Date.now()}.tmp`;
89 FS.mkdirSync(directory, { recursive: true });
90 FS.writeFileSync(tempPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
91 FS.renameSync(tempPath, settingsPath);
92}
apps/desktop/src/main.ts+802−101
@@ -7,18 +7,26 @@ import * as Path from "node:path"; · 6 unmodified lines
7import {7import {
8 app,8 app,
9 BrowserWindow,9 BrowserWindow,
10 type BrowserWindowConstructorOptions,
11 clipboard,
10 dialog,12 dialog,
11 ipcMain,13 ipcMain,
12 Menu,14 Menu,
13 nativeImage,15 nativeImage,
14 nativeTheme,16 nativeTheme,
15 protocol,17 protocol,
18 safeStorage,
16 shell,19 shell,
17} from "electron";20} from "electron";
18import type { MenuItemConstructorOptions } from "electron";21import type { MenuItemConstructorOptions, OpenDialogOptions } from "electron";
19import * as Effect from "effect/Effect";
20import type {22import type {
23 ClientSettings,
21 DesktopTheme,24 DesktopTheme,
25 DesktopAppBranding,
26 DesktopServerExposureMode,
27 DesktopServerExposureState,
28 DesktopUpdateChannel,
29 PersistedSavedEnvironmentRecord,
22 DesktopUpdateActionResult,30 DesktopUpdateActionResult,
23 DesktopUpdateCheckResult,31 DesktopUpdateCheckResult,
24 DesktopUpdateState,32 DesktopUpdateState,
@@ -26,12 +34,32 @@ import type { · 1 unmodified line
26import { autoUpdater } from "electron-updater";34import { autoUpdater } from "electron-updater";
27 35
28import type { ContextMenuItem } from "@t3tools/contracts";36import type { ContextMenuItem } from "@t3tools/contracts";
29import { NetService } from "@t3tools/shared/Net";
30import { RotatingFileSink } from "@t3tools/shared/logging";37import { RotatingFileSink } from "@t3tools/shared/logging";
31import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";38import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
32import { showDesktopConfirmDialog } from "./confirmDialog";39import { DEFAULT_DESKTOP_BACKEND_PORT, resolveDesktopBackendPort } from "./backendPort.ts";
33import { syncShellEnvironment } from "./syncShellEnvironment";40import {
34import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState";41 DEFAULT_DESKTOP_SETTINGS,
42 readDesktopSettings,
43 setDesktopServerExposurePreference,
44 setDesktopUpdateChannelPreference,
45 writeDesktopSettings,
46} from "./desktopSettings.ts";
47import {
48 readClientSettings,
49 readSavedEnvironmentRegistry,
50 readSavedEnvironmentSecret,
51 removeSavedEnvironmentSecret,
52 writeClientSettings,
53 writeSavedEnvironmentRegistry,
54 writeSavedEnvironmentSecret,
55} from "./clientPersistence.ts";
56import { isBackendReadinessAborted, waitForHttpReady } from "./backendReadiness.ts";
57import { showDesktopConfirmDialog } from "./confirmDialog.ts";
58import { resolveDesktopServerExposure } from "./serverExposure.ts";
59import { syncShellEnvironment } from "./syncShellEnvironment.ts";
60import { getAutoUpdateDisabledReason, shouldBroadcastDownloadProgress } from "./updateState.ts";
61import { doesVersionMatchDesktopUpdateChannel } from "./updateChannels.ts";
62import { ServerListeningDetector } from "./serverListeningDetector.ts";
35import {63import {
36 createInitialDesktopUpdateState,64 createInitialDesktopUpdateState,
37 reduceDesktopUpdateStateOnCheckFailure,65 reduceDesktopUpdateStateOnCheckFailure,
@@ -43,8 +71,9 @@ import { · 5 unmodified lines
43 reduceDesktopUpdateStateOnInstallFailure,71 reduceDesktopUpdateStateOnInstallFailure,
44 reduceDesktopUpdateStateOnNoUpdate,72 reduceDesktopUpdateStateOnNoUpdate,
45 reduceDesktopUpdateStateOnUpdateAvailable,73 reduceDesktopUpdateStateOnUpdateAvailable,
46} from "./updateMachine";74} from "./updateMachine.ts";
47import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";75import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
76import { resolveDesktopAppBranding } from "./appBranding.ts";
48 77
49syncShellEnvironment();78syncShellEnvironment();
50 79
@@ -56,17 +85,35 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 5 unmodified lines
56const MENU_ACTION_CHANNEL = "desktop:menu-action";85const MENU_ACTION_CHANNEL = "desktop:menu-action";
57const UPDATE_STATE_CHANNEL = "desktop:update-state";86const UPDATE_STATE_CHANNEL = "desktop:update-state";
58const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";87const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
88const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
59const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";89const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
60const UPDATE_INSTALL_CHANNEL = "desktop:update-install";90const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
61const UPDATE_CHECK_CHANNEL = "desktop:update-check";91const UPDATE_CHECK_CHANNEL = "desktop:update-check";
62const GET_WS_URL_CHANNEL = "desktop:get-ws-url";92const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
93const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
94const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
95const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
96const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
97const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
98const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
99const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
100const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
101const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
102const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
63const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");103const BASE_DIR = process.env.T3CODE_HOME?.trim() || Path.join(OS.homedir(), ".t3");
64const STATE_DIR = Path.join(BASE_DIR, "userdata");104const STATE_DIR = Path.join(BASE_DIR, "userdata");
105const DESKTOP_SETTINGS_PATH = Path.join(STATE_DIR, "desktop-settings.json");
106const CLIENT_SETTINGS_PATH = Path.join(STATE_DIR, "client-settings.json");
107const SAVED_ENVIRONMENT_REGISTRY_PATH = Path.join(STATE_DIR, "saved-environments.json");
65const DESKTOP_SCHEME = "t3";108const DESKTOP_SCHEME = "t3";
66const ROOT_DIR = Path.resolve(__dirname, "../../..");109const ROOT_DIR = Path.resolve(__dirname, "../../..");
67const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);110const isDevelopment = Boolean(process.env.VITE_DEV_SERVER_URL);
68const APP_DISPLAY_NAME = isDevelopment ? "T3 Code (Dev)" : "T3 Code (Alpha)";111const desktopAppBranding: DesktopAppBranding = resolveDesktopAppBranding({
69const APP_USER_MODEL_ID = "com.t3tools.t3code";112 isDevelopment,
113 appVersion: app.getVersion(),
114});
115const APP_DISPLAY_NAME = desktopAppBranding.displayName;
116const APP_USER_MODEL_ID = isDevelopment ? "com.t3tools.t3code.dev" : "com.t3tools.t3code";
70const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";117const LINUX_DESKTOP_ENTRY_NAME = isDevelopment ? "t3code-dev.desktop" : "t3code.desktop";
71const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";118const LINUX_WM_CLASS = isDevelopment ? "t3code-dev" : "t3code";
72const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";119const USER_DATA_DIR_NAME = isDevelopment ? "t3code-dev" : "t3code";
@@ -80,8 +127,72 @@ const APP_RUN_ID = Crypto.randomBytes(6).toString("hex"); · 7 unmodified lines
80const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");127const SERVER_SETTINGS_PATH = Path.join(STATE_DIR, "settings.json");
81const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;128const AUTO_UPDATE_STARTUP_DELAY_MS = 15_000;
82const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;129const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000;
83const DESKTOP_UPDATE_CHANNEL = "latest";130
84const DESKTOP_UPDATE_ALLOW_PRERELEASE = false;131function resolvePickFolderDefaultPath(rawOptions: unknown): string | undefined {
132 if (typeof rawOptions !== "object" || rawOptions === null) {
133 return undefined;
134 }
135
136 const { initialPath } = rawOptions as { initialPath?: unknown };
137 if (typeof initialPath !== "string") {
138 return undefined;
139 }
140
141 const trimmedPath = initialPath.trim();
142 if (trimmedPath.length === 0) {
143 return undefined;
144 }
145
146 if (trimmedPath === "~") {
147 return OS.homedir();
148 }
149
150 if (trimmedPath.startsWith("~/") || trimmedPath.startsWith("~\\")) {
151 return Path.join(OS.homedir(), trimmedPath.slice(2));
152 }
153
154 return Path.resolve(trimmedPath);
155}
156const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
157const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const;
158const TITLEBAR_HEIGHT = 40;
159const TITLEBAR_COLOR = "#01000000"; // #00000000 does not work correctly on Linux
160const TITLEBAR_LIGHT_SYMBOL_COLOR = "#1f2937";
161const TITLEBAR_DARK_SYMBOL_COLOR = "#f8fafc";
162
163function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextMenuItem[] {
164 const normalizedItems: ContextMenuItem[] = [];
165
166 for (const sourceItem of source) {
167 if (typeof sourceItem.id !== "string" || typeof sourceItem.label !== "string") {
168 continue;
169 }
170
171 const normalizedItem: ContextMenuItem = {
172 id: sourceItem.id,
173 label: sourceItem.label,
174 destructive: sourceItem.destructive === true,
175 disabled: sourceItem.disabled === true,
176 };
177
178 if (sourceItem.children) {
179 const normalizedChildren = normalizeContextMenuItems(sourceItem.children);
180 if (normalizedChildren.length === 0) {
181 continue;
182 }
183 normalizedItem.children = normalizedChildren;
184 }
185
186 normalizedItems.push(normalizedItem);
187 }
188
189 return normalizedItems;
190}
191
192type WindowTitleBarOptions = Pick<
193 BrowserWindowConstructorOptions,
194 "titleBarOverlay" | "titleBarStyle" | "trafficLightPosition"
195>;
85 196
86type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];197type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"];
87type LinuxDesktopNamedApp = Electron.App & {198type LinuxDesktopNamedApp = Electron.App & {
@@ -91,8 +202,15 @@ type LinuxDesktopNamedApp = Electron.App & { · 3 unmodified lines
91let mainWindow: BrowserWindow | null = null;202let mainWindow: BrowserWindow | null = null;
92let backendProcess: ChildProcess.ChildProcess | null = null;203let backendProcess: ChildProcess.ChildProcess | null = null;
93let backendPort = 0;204let backendPort = 0;
94let backendAuthToken = "";205let backendBindHost = DESKTOP_LOOPBACK_HOST;
206let backendBootstrapToken = "";
207let backendHttpUrl = "";
95let backendWsUrl = "";208let backendWsUrl = "";
209let backendEndpointUrl: string | null = null;
210let backendAdvertisedHost: string | null = null;
211let backendReadinessAbortController: AbortController | null = null;
212let backendInitialWindowOpenInFlight: Promise<void> | null = null;
213let backendListeningDetector: ServerListeningDetector | null = null;
96let restartAttempt = 0;214let restartAttempt = 0;
97let restartTimer: ReturnType<typeof setTimeout> | null = null;215let restartTimer: ReturnType<typeof setTimeout> | null = null;
98let isQuitting = false;216let isQuitting = false;
@@ -102,6 +220,8 @@ let desktopLogSink: RotatingFileSink | null = null; · 3 unmodified lines
102let backendLogSink: RotatingFileSink | null = null;220let backendLogSink: RotatingFileSink | null = null;
103let restoreStdIoCapture: (() => void) | null = null;221let restoreStdIoCapture: (() => void) | null = null;
104let backendObservabilitySettings = readPersistedBackendObservabilitySettings();222let backendObservabilitySettings = readPersistedBackendObservabilitySettings();
223let desktopSettings = readDesktopSettings(DESKTOP_SETTINGS_PATH, app.getVersion());
224let desktopServerExposureMode: DesktopServerExposureMode = desktopSettings.serverExposureMode;
105 225
106let destructiveMenuIconCache: Electron.NativeImage | null | undefined;226let destructiveMenuIconCache: Electron.NativeImage | null | undefined;
107const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();227const expectedBackendExitChildren = new WeakSet<ChildProcess.ChildProcess>();
@@ -111,7 +231,11 @@ const desktopRuntimeInfo = resolveDesktopRuntimeInfo({ · 3 unmodified lines
111 runningUnderArm64Translation: app.runningUnderARM64Translation === true,231 runningUnderArm64Translation: app.runningUnderARM64Translation === true,
112});232});
113const initialUpdateState = (): DesktopUpdateState =>233const initialUpdateState = (): DesktopUpdateState =>
114 createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo);234 createInitialDesktopUpdateState(
235 app.getVersion(),
236 desktopRuntimeInfo,
237 desktopSettings.updateChannel,
238 );
115 239
116function logTimestamp(): string {240function logTimestamp(): string {
117 return new Date().toISOString();241 return new Date().toISOString();
@@ -140,17 +264,128 @@ function readPersistedBackendObservabilitySettings(): { · 22 unmodified lines
140 }264 }
141}265}
142 266
267function resolveConfiguredDesktopBackendPort(rawPort: string | undefined): number | undefined {
268 if (!rawPort) {
269 return undefined;
270 }
271
272 const parsedPort = Number.parseInt(rawPort, 10);
273 if (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65_535) {
274 return undefined;
275 }
276
277 return parsedPort;
278}
279
280function resolveDesktopDevServerUrl(): string {
281 const devServerUrl = process.env.VITE_DEV_SERVER_URL?.trim();
282 if (!devServerUrl) {
283 throw new Error("VITE_DEV_SERVER_URL is required in desktop development.");
284 }
285
286 return devServerUrl;
287}
288
143function backendChildEnv(): NodeJS.ProcessEnv {289function backendChildEnv(): NodeJS.ProcessEnv {
144 const env = { ...process.env };290 const env = { ...process.env };
145 delete env.T3CODE_PORT;291 delete env.T3CODE_PORT;
146 delete env.T3CODE_AUTH_TOKEN;
147 delete env.T3CODE_MODE;292 delete env.T3CODE_MODE;
148 delete env.T3CODE_NO_BROWSER;293 delete env.T3CODE_NO_BROWSER;
149 delete env.T3CODE_HOST;294 delete env.T3CODE_HOST;
150 delete env.T3CODE_DESKTOP_WS_URL;295 delete env.T3CODE_DESKTOP_WS_URL;
296 delete env.T3CODE_DESKTOP_LAN_ACCESS;
297 delete env.T3CODE_DESKTOP_LAN_HOST;
151 return env;298 return env;
152}299}
153 300
301function getDesktopServerExposureState(): DesktopServerExposureState {
302 return {
303 mode: desktopServerExposureMode,
304 endpointUrl: backendEndpointUrl,
305 advertisedHost: backendAdvertisedHost,
306 };
307}
308
309function getDesktopSecretStorage() {
310 return {
311 isEncryptionAvailable: () => safeStorage.isEncryptionAvailable(),
312 encryptString: (value: string) => safeStorage.encryptString(value),
313 decryptString: (value: Buffer) => safeStorage.decryptString(value),
314 } as const;
315}
316
317function resolveAdvertisedHostOverride(): string | undefined {
318 const override = process.env.T3CODE_DESKTOP_LAN_HOST?.trim();
319 return override && override.length > 0 ? override : undefined;
320}
321
322async function applyDesktopServerExposureMode(
323 mode: DesktopServerExposureMode,
324 options?: { readonly persist?: boolean; readonly rejectIfUnavailable?: boolean },
325): Promise<DesktopServerExposureState> {
326 const advertisedHostOverride = resolveAdvertisedHostOverride();
327 const requestedMode = mode;
328 let exposure = resolveDesktopServerExposure({
329 mode,
330 port: backendPort,
331 networkInterfaces: OS.networkInterfaces(),
332 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
333 });
334
335 if (requestedMode === "network-accessible" && exposure.endpointUrl === null) {
336 if (options?.rejectIfUnavailable) {
337 throw new Error("No reachable network address is available for this desktop right now.");
338 }
339 exposure = resolveDesktopServerExposure({
340 mode: "local-only",
341 port: backendPort,
342 networkInterfaces: OS.networkInterfaces(),
343 ...(advertisedHostOverride ? { advertisedHostOverride } : {}),
344 });
345 }
346
347 desktopServerExposureMode = exposure.mode;
348 desktopSettings = setDesktopServerExposurePreference(desktopSettings, requestedMode);
349 backendBindHost = exposure.bindHost;
350 backendHttpUrl = exposure.localHttpUrl;
351 backendWsUrl = exposure.localWsUrl;
352 backendEndpointUrl = exposure.endpointUrl;
353 backendAdvertisedHost = exposure.advertisedHost;
354
355 if (options?.persist) {
356 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
357 }
358
359 return getDesktopServerExposureState();
360}
361
362function relaunchDesktopApp(reason: string): void {
363 writeDesktopLogHeader(`desktop relaunch requested reason=${reason}`);
364 setImmediate(() => {
365 isQuitting = true;
366 clearUpdatePollTimer();
367 cancelBackendReadinessWait();
368 void stopBackendAndWaitForExit()
369 .catch((error) => {
370 writeDesktopLogHeader(
371 `desktop relaunch backend shutdown warning message=${formatErrorMessage(error)}`,
372 );
373 })
374 .finally(() => {
375 restoreStdIoCapture?.();
376 if (isDevelopment) {
377 app.exit(75);
378 return;
379 }
380 app.relaunch({
381 execPath: process.execPath,
382 args: process.argv.slice(1),
383 });
384 app.exit(0);
385 });
386 });
387}
388
154function writeDesktopLogHeader(message: string): void {389function writeDesktopLogHeader(message: string): void {
155 if (!desktopLogSink) return;390 if (!desktopLogSink) return;
156 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);391 desktopLogSink.write(`[${logTimestamp()}] [${logScope("desktop")}] ${message}\n`);
@@ -198,6 +433,113 @@ function getSafeTheme(rawTheme: unknown): DesktopTheme | null { · 41 unmodified lines
198 return null;433 return null;
199}434}
200 435
436async function waitForBackendHttpReady(
437 baseUrl: string,
438 options?: Parameters<typeof waitForHttpReady>[1],
439): Promise<void> {
440 cancelBackendReadinessWait();
441 const controller = new AbortController();
442 backendReadinessAbortController = controller;
443
444 try {
445 await waitForHttpReady(baseUrl, {
446 ...options,
447 signal: controller.signal,
448 });
449 } finally {
450 if (backendReadinessAbortController === controller) {
451 backendReadinessAbortController = null;
452 }
453 }
454}
455
456function cancelBackendReadinessWait(): void {
457 backendReadinessAbortController?.abort();
458 backendReadinessAbortController = null;
459}
460
461async function waitForBackendWindowReady(baseUrl: string): Promise<"listening" | "http"> {
462 const httpReadyPromise = waitForBackendHttpReady(baseUrl, {
463 timeoutMs: 60_000,
464 });
465 const listeningPromise = backendListeningDetector?.promise;
466
467 if (!listeningPromise) {
468 await httpReadyPromise;
469 return "http";
470 }
471
472 return await new Promise<"listening" | "http">((resolve, reject) => {
473 let settled = false;
474
475 const settleResolve = (source: "listening" | "http") => {
476 if (settled) {
477 return;
478 }
479 settled = true;
480 if (source === "listening") {
481 cancelBackendReadinessWait();
482 }
483 resolve(source);
484 };
485
486 const settleReject = (error: unknown) => {
487 if (settled) {
488 return;
489 }
490 settled = true;
491 reject(error);
492 };
493
494 listeningPromise.then(
495 () => settleResolve("listening"),
496 (error) => settleReject(error),
497 );
498 httpReadyPromise.then(
499 () => settleResolve("http"),
500 (error) => {
501 if (settled && isBackendReadinessAborted(error)) {
502 return;
503 }
504 settleReject(error);
505 },
506 );
507 });
508}
509
510function ensureInitialBackendWindowOpen(): void {
511 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0] ?? null;
512 if (isDevelopment || existingWindow !== null || backendInitialWindowOpenInFlight !== null) {
513 return;
514 }
515
516 const nextOpen = waitForBackendWindowReady(backendHttpUrl)
517 .then((source) => {
518 writeDesktopLogHeader(`bootstrap backend ready source=${source}`);
519 if (mainWindow ?? BrowserWindow.getAllWindows()[0]) {
520 return;
521 }
522 mainWindow = createWindow();
523 writeDesktopLogHeader("bootstrap main window created");
524 })
525 .catch((error) => {
526 if (isBackendReadinessAborted(error)) {
527 return;
528 }
529 writeDesktopLogHeader(
530 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
531 );
532 console.warn("[desktop] backend readiness check timed out during packaged bootstrap", error);
533 })
534 .finally(() => {
535 if (backendInitialWindowOpenInFlight === nextOpen) {
536 backendInitialWindowOpenInFlight = null;
537 }
538 });
539
540 backendInitialWindowOpenInFlight = nextOpen;
541}
542
201function writeDesktopStreamChunk(543function writeDesktopStreamChunk(
202 streamName: "stdout" | "stderr",544 streamName: "stdout" | "stderr",
203 chunk: unknown,545 chunk: unknown,
@@ -275,14 +617,16 @@ function initializePackagedLogging(): void { · 71 unmodified lines
275}617}
276 618
277function captureBackendOutput(child: ChildProcess.ChildProcess): void {619function captureBackendOutput(child: ChildProcess.ChildProcess): void {
278 if (!app.isPackaged || backendLogSink === null) return;620 const attachStream = (stream: NodeJS.ReadableStream | null | undefined): void => {
279 const writeChunk = (chunk: unknown): void => {621 stream?.on("data", (chunk: unknown) => {
280 if (!backendLogSink) return;622 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");
281 const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk), "utf8");623 backendLogSink?.write(buffer);
282 backendLogSink.write(buffer);624 backendListeningDetector?.push(buffer);
625 });
283 };626 };
284 child.stdout?.on("data", writeChunk);627
285 child.stderr?.on("data", writeChunk);628 attachStream(child.stdout);
629 attachStream(child.stderr);
286}630}
287 631
288initializePackagedLogging();632initializePackagedLogging();
@@ -542,10 +886,7 @@ function dispatchMenuAction(action: string): void { · 253 unmodified lines
542 const send = () => {886 const send = () => {
543 if (targetWindow.isDestroyed()) return;887 if (targetWindow.isDestroyed()) return;
544 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);888 targetWindow.webContents.send(MENU_ACTION_CHANNEL, action);
545 if (!targetWindow.isVisible()) {889 revealWindow(targetWindow);
546 targetWindow.show();
547 }
548 targetWindow.focus();
549 };890 };
550 891
551 if (targetWindow.webContents.isLoadingMainFrame()) {892 if (targetWindow.webContents.isLoadingMainFrame()) {
@@ -557,12 +898,15 @@ function dispatchMenuAction(action: string): void { · 5 unmodified lines
557}898}
558 899
559function handleCheckForUpdatesMenuClick(): void {900function handleCheckForUpdatesMenuClick(): void {
901 const hasUpdateFeedConfig =
902 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
560 const disabledReason = getAutoUpdateDisabledReason({903 const disabledReason = getAutoUpdateDisabledReason({
561 isDevelopment,904 isDevelopment,
562 isPackaged: app.isPackaged,905 isPackaged: app.isPackaged,
563 platform: process.platform,906 platform: process.platform,
564 appImage: process.env.APPIMAGE,907 appImage: process.env.APPIMAGE,
565 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",908 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
909 hasUpdateFeedConfig,
566 });910 });
567 if (disabledReason) {911 if (disabledReason) {
568 console.info("[desktop-updater] Manual update check requested, but updates are disabled.");912 console.info("[desktop-updater] Manual update check requested, but updates are disabled.");
@@ -699,6 +1043,18 @@ function resolveResourcePath(fileName: string): string | null { · 130 unmodified lines
699}1043}
700 1044
701function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {1045function resolveIconPath(ext: "ico" | "icns" | "png"): string | null {
1046 if (isDevelopment && process.platform === "darwin" && ext === "png") {
1047 const developmentDockIconPath = Path.join(
1048 ROOT_DIR,
1049 "assets",
1050 "dev",
1051 "blueprint-macos-1024.png",
1052 );
1053 if (FS.existsSync(developmentDockIconPath)) {
1054 return developmentDockIconPath;
1055 }
1056 }
1057
702 return resolveResourcePath(`icon.${ext}`);1058 return resolveResourcePath(`icon.${ext}`);
703}1059}
704 1060
@@ -766,6 +1122,26 @@ function clearUpdatePollTimer(): void { · 61 unmodified lines
766 }1122 }
767}1123}
768 1124
1125function revealWindow(window: BrowserWindow): void {
1126 if (window.isDestroyed()) {
1127 return;
1128 }
1129
1130 if (window.isMinimized()) {
1131 window.restore();
1132 }
1133
1134 if (!window.isVisible()) {
1135 window.show();
1136 }
1137
1138 if (process.platform === "darwin") {
1139 app.focus({ steal: true });
1140 }
1141
1142 window.focus();
1143}
1144
769function emitUpdateState(): void {1145function emitUpdateState(): void {
770 for (const window of BrowserWindow.getAllWindows()) {1146 for (const window of BrowserWindow.getAllWindows()) {
771 if (window.isDestroyed()) continue;1147 if (window.isDestroyed()) continue;
@@ -778,7 +1154,29 @@ function setUpdateState(patch: Partial<DesktopUpdateState>): void { · 6 unmodified lines
778 emitUpdateState();1154 emitUpdateState();
779}1155}
780 1156
1157function createBaseUpdateState(
1158 channel: DesktopUpdateChannel,
1159 enabled: boolean,
1160): DesktopUpdateState {
1161 return {
1162 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo, channel),
1163 enabled,
1164 status: enabled ? "idle" : "disabled",
1165 };
1166}
1167
1168function applyAutoUpdaterChannel(channel: DesktopUpdateChannel): void {
1169 autoUpdater.channel = channel;
1170 autoUpdater.allowPrerelease = channel === "nightly";
1171 autoUpdater.allowDowngrade = channel === "nightly";
1172 console.info(
1173 `[desktop-updater] Using update channel '${channel}' (allowPrerelease=${channel === "nightly"}, allowDowngrade=${channel === "nightly"}).`,
1174 );
1175}
1176
781function shouldEnableAutoUpdates(): boolean {1177function shouldEnableAutoUpdates(): boolean {
1178 const hasUpdateFeedConfig =
1179 readAppUpdateYml() !== null || Boolean(process.env.T3CODE_DESKTOP_MOCK_UPDATES);
782 return (1180 return (
783 getAutoUpdateDisabledReason({1181 getAutoUpdateDisabledReason({
784 isDevelopment,1182 isDevelopment,
@@ -786,6 +1184,7 @@ function shouldEnableAutoUpdates(): boolean { · 1 unmodified line
786 platform: process.platform,1184 platform: process.platform,
787 appImage: process.env.APPIMAGE,1185 appImage: process.env.APPIMAGE,
788 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",1186 disabledByEnv: process.env.T3CODE_DISABLE_AUTO_UPDATE === "1",
1187 hasUpdateFeedConfig,
789 }) === null1188 }) === null
790 );1189 );
791}1190}
@@ -869,17 +1268,6 @@ async function installDownloadedUpdate(): Promise<{ accepted: boolean; completed · 77 unmodified lines
869}1268}
870 1269
871function configureAutoUpdater(): void {1270function configureAutoUpdater(): void {
872 const enabled = shouldEnableAutoUpdates();
873 setUpdateState({
874 ...createInitialDesktopUpdateState(app.getVersion(), desktopRuntimeInfo),
875 enabled,
876 status: enabled ? "idle" : "disabled",
877 });
878 if (!enabled) {
879 return;
880 }
881 updaterConfigured = true;
882
883 const githubToken =1271 const githubToken =
884 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";1272 process.env.T3CODE_DESKTOP_UPDATE_GITHUB_TOKEN?.trim() || process.env.GH_TOKEN?.trim() || "";
885 if (githubToken) {1273 if (githubToken) {
@@ -904,12 +1292,16 @@ function configureAutoUpdater(): void { · 18 unmodified lines
904 });1292 });
905 }1293 }
906 1294
1295 const enabled = shouldEnableAutoUpdates();
1296 setUpdateState(createBaseUpdateState(desktopSettings.updateChannel, enabled));
1297 if (!enabled) {
1298 return;
1299 }
1300 updaterConfigured = true;
1301
907 autoUpdater.autoDownload = false;1302 autoUpdater.autoDownload = false;
908 autoUpdater.autoInstallOnAppQuit = false;1303 autoUpdater.autoInstallOnAppQuit = false;
909 // Keep alpha branding, but force all installs onto the stable update track.1304 applyAutoUpdaterChannel(desktopSettings.updateChannel);
910 autoUpdater.channel = DESKTOP_UPDATE_CHANNEL;
911 autoUpdater.allowPrerelease = DESKTOP_UPDATE_ALLOW_PRERELEASE;
912 autoUpdater.allowDowngrade = false;
913 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);1305 autoUpdater.disableDifferentialDownload = isArm64HostRunningIntelBuild(desktopRuntimeInfo);
914 let lastLoggedDownloadMilestone = -1;1306 let lastLoggedDownloadMilestone = -1;
915 1307
@@ -923,6 +1315,15 @@ function configureAutoUpdater(): void { · 7 unmodified lines
923 console.info("[desktop-updater] Looking for updates...");1315 console.info("[desktop-updater] Looking for updates...");
924 });1316 });
925 autoUpdater.on("update-available", (info) => {1317 autoUpdater.on("update-available", (info) => {
1318 if (!doesVersionMatchDesktopUpdateChannel(info.version, updateState.channel)) {
1319 console.info(
1320 `[desktop-updater] Ignoring ${info.version} because it does not match the selected '${updateState.channel}' channel.`,
1321 );
1322 setUpdateState(reduceDesktopUpdateStateOnNoUpdate(updateState, new Date().toISOString()));
1323 lastLoggedDownloadMilestone = -1;
1324 return;
1325 }
1326
926 setUpdateState(1327 setUpdateState(
927 reduceDesktopUpdateStateOnUpdateAvailable(1328 reduceDesktopUpdateStateOnUpdateAvailable(
928 updateState,1329 updateState,
@@ -1014,7 +1415,7 @@ function startBackend(): void { · 85 unmodified lines
1014 return;1415 return;
1015 }1416 }
1016 1417
1017 const captureBackendLogs = app.isPackaged && backendLogSink !== null;1418 const captureBackendLogs = !isDevelopment;
1018 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {1419 const child = ChildProcess.spawn(process.execPath, [backendEntry, "--bootstrap-fd", "3"], {
1019 cwd: resolveBackendCwd(),1420 cwd: resolveBackendCwd(),
1020 // In Electron main, process.execPath points to the Electron binary.1421 // In Electron main, process.execPath points to the Electron binary.
@@ -1035,7 +1436,8 @@ function startBackend(): void { · 14 unmodified lines
1035 noBrowser: true,1436 noBrowser: true,
1036 port: backendPort,1437 port: backendPort,
1037 t3Home: BASE_DIR,1438 t3Home: BASE_DIR,
1038 authToken: backendAuthToken,1439 host: backendBindHost,
1440 desktopBootstrapToken: backendBootstrapToken,
1039 ...(backendObservabilitySettings.otlpTracesUrl1441 ...(backendObservabilitySettings.otlpTracesUrl
1040 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }1442 ? { otlpTracesUrl: backendObservabilitySettings.otlpTracesUrl }
1041 : {}),1443 : {}),
@@ -1050,6 +1452,8 @@ function startBackend(): void { · 8 unmodified lines
1050 scheduleBackendRestart("missing desktop bootstrap pipe");1452 scheduleBackendRestart("missing desktop bootstrap pipe");
1051 return;1453 return;
1052 }1454 }
1455 const listeningDetector = new ServerListeningDetector();
1456 backendListeningDetector = listeningDetector;
1053 backendProcess = child;1457 backendProcess = child;
1054 let backendSessionClosed = false;1458 let backendSessionClosed = false;
1055 const closeBackendSession = (details: string) => {1459 const closeBackendSession = (details: string) => {
@@ -1068,6 +1472,10 @@ function startBackend(): void { · 12 unmodified lines
1068 });1472 });
1069 1473
1070 child.on("error", (error) => {1474 child.on("error", (error) => {
1475 if (backendListeningDetector === listeningDetector) {
1476 listeningDetector.fail(error);
1477 backendListeningDetector = null;
1478 }
1071 const wasExpected = expectedBackendExitChildren.has(child);1479 const wasExpected = expectedBackendExitChildren.has(child);
1072 if (backendProcess === child) {1480 if (backendProcess === child) {
1073 backendProcess = null;1481 backendProcess = null;
@@ -1080,6 +1488,14 @@ function startBackend(): void { · 6 unmodified lines
1080 });1488 });
1081 1489
1082 child.on("exit", (code, signal) => {1490 child.on("exit", (code, signal) => {
1491 if (backendListeningDetector === listeningDetector) {
1492 listeningDetector.fail(
1493 new Error(
1494 `backend exited before logging readiness (code=${code ?? "null"} signal=${signal ?? "null"})`,
1495 ),
1496 );
1497 backendListeningDetector = null;
1498 }
1083 const wasExpected = expectedBackendExitChildren.has(child);1499 const wasExpected = expectedBackendExitChildren.has(child);
1084 if (backendProcess === child) {1500 if (backendProcess === child) {
1085 backendProcess = null;1501 backendProcess = null;
@@ -1091,9 +1507,13 @@ function startBackend(): void { · 5 unmodified lines
1091 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;1507 const reason = `code=${code ?? "null"} signal=${signal ?? "null"}`;
1092 scheduleBackendRestart(reason);1508 scheduleBackendRestart(reason);
1093 });1509 });
1510
1511 ensureInitialBackendWindowOpen();
1094}1512}
1095 1513
1096function stopBackend(): void {1514function stopBackend(): void {
1515 cancelBackendReadinessWait();
1516 backendListeningDetector = null;
1097 if (restartTimer) {1517 if (restartTimer) {
1098 clearTimeout(restartTimer);1518 clearTimeout(restartTimer);
1099 restartTimer = null;1519 restartTimer = null;
@@ -1115,6 +1535,7 @@ function stopBackend(): void { · 15 unmodified lines
1115}1535}
1116 1536
1117async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {1537async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> {
1538 cancelBackendReadinessWait();
1118 if (restartTimer) {1539 if (restartTimer) {
1119 clearTimeout(restartTimer);1540 clearTimeout(restartTimer);
1120 restartTimer = null;1541 restartTimer = null;
@@ -1167,21 +1588,134 @@ async function stopBackendAndWaitForExit(timeoutMs = 5_000): Promise<void> { · 46 unmodified lines
1167}1588}
1168 1589
1169function registerIpcHandlers(): void {1590function registerIpcHandlers(): void {
1170 ipcMain.removeAllListeners(GET_WS_URL_CHANNEL);1591 ipcMain.removeAllListeners(GET_APP_BRANDING_CHANNEL);
1171 ipcMain.on(GET_WS_URL_CHANNEL, (event) => {1592 ipcMain.on(GET_APP_BRANDING_CHANNEL, (event) => {
1172 event.returnValue = backendWsUrl;1593 event.returnValue = desktopAppBranding;
1594 });
1595
1596 ipcMain.removeAllListeners(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
1597 ipcMain.on(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL, (event) => {
1598 event.returnValue = {
1599 label: "Local environment",
1600 httpBaseUrl: backendHttpUrl || null,
1601 wsBaseUrl: backendWsUrl || null,
1602 bootstrapToken: backendBootstrapToken || undefined,
1603 } as const;
1604 });
1605
1606 ipcMain.removeHandler(GET_CLIENT_SETTINGS_CHANNEL);
1607 ipcMain.handle(GET_CLIENT_SETTINGS_CHANNEL, async () => readClientSettings(CLIENT_SETTINGS_PATH));
1608
1609 ipcMain.removeHandler(SET_CLIENT_SETTINGS_CHANNEL);
1610 ipcMain.handle(SET_CLIENT_SETTINGS_CHANNEL, async (_event, rawSettings: unknown) => {
1611 if (typeof rawSettings !== "object" || rawSettings === null) {
1612 throw new Error("Invalid client settings payload.");
1613 }
1614
1615 writeClientSettings(CLIENT_SETTINGS_PATH, rawSettings as ClientSettings);
1616 });
1617
1618 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1619 ipcMain.handle(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async () =>
1620 readSavedEnvironmentRegistry(SAVED_ENVIRONMENT_REGISTRY_PATH),
1621 );
1622
1623 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL);
1624 ipcMain.handle(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, async (_event, rawRecords: unknown) => {
1625 if (!Array.isArray(rawRecords)) {
1626 throw new Error("Invalid saved environment registry payload.");
1627 }
1628
1629 writeSavedEnvironmentRegistry(
1630 SAVED_ENVIRONMENT_REGISTRY_PATH,
1631 rawRecords as readonly PersistedSavedEnvironmentRecord[],
1632 );
1633 });
1634
1635 ipcMain.removeHandler(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1636 ipcMain.handle(
1637 GET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1638 async (_event, rawEnvironmentId: unknown) => {
1639 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1640 return null;
1641 }
1642
1643 return readSavedEnvironmentSecret({
1644 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1645 environmentId: rawEnvironmentId,
1646 secretStorage: getDesktopSecretStorage(),
1647 });
1648 },
1649 );
1650
1651 ipcMain.removeHandler(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1652 ipcMain.handle(
1653 SET_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1654 async (_event, rawEnvironmentId: unknown, rawSecret: unknown) => {
1655 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1656 throw new Error("Invalid saved environment id.");
1657 }
1658 if (typeof rawSecret !== "string" || rawSecret.trim().length === 0) {
1659 throw new Error("Invalid saved environment secret.");
1660 }
1661
1662 return writeSavedEnvironmentSecret({
1663 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1664 environmentId: rawEnvironmentId,
1665 secret: rawSecret,
1666 secretStorage: getDesktopSecretStorage(),
1667 });
1668 },
1669 );
1670
1671 ipcMain.removeHandler(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL);
1672 ipcMain.handle(
1673 REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL,
1674 async (_event, rawEnvironmentId: unknown) => {
1675 if (typeof rawEnvironmentId !== "string" || rawEnvironmentId.trim().length === 0) {
1676 return;
1677 }
1678
1679 removeSavedEnvironmentSecret({
1680 registryPath: SAVED_ENVIRONMENT_REGISTRY_PATH,
1681 environmentId: rawEnvironmentId,
1682 });
1683 },
1684 );
1685
1686 ipcMain.removeHandler(GET_SERVER_EXPOSURE_STATE_CHANNEL);
1687 ipcMain.handle(GET_SERVER_EXPOSURE_STATE_CHANNEL, async () => getDesktopServerExposureState());
1688
1689 ipcMain.removeHandler(SET_SERVER_EXPOSURE_MODE_CHANNEL);
1690 ipcMain.handle(SET_SERVER_EXPOSURE_MODE_CHANNEL, async (_event, rawMode: unknown) => {
1691 if (rawMode !== "local-only" && rawMode !== "network-accessible") {
1692 throw new Error("Invalid desktop server exposure input.");
1693 }
1694
1695 const nextMode = rawMode as DesktopServerExposureMode;
1696 if (nextMode === desktopServerExposureMode) {
1697 return getDesktopServerExposureState();
1698 }
1699
1700 const nextState = await applyDesktopServerExposureMode(nextMode, {
1701 persist: true,
1702 rejectIfUnavailable: true,
1703 });
1704 relaunchDesktopApp(`serverExposureMode=${nextMode}`);
1705 return nextState;
1173 });1706 });
1174 1707
1175 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);1708 ipcMain.removeHandler(PICK_FOLDER_CHANNEL);
1176 ipcMain.handle(PICK_FOLDER_CHANNEL, async () => {1709 ipcMain.handle(PICK_FOLDER_CHANNEL, async (_event, rawOptions: unknown) => {
1177 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;1710 const owner = BrowserWindow.getFocusedWindow() ?? mainWindow;
1711 const defaultPath = resolvePickFolderDefaultPath(rawOptions);
1712 const openDialogOptions: OpenDialogOptions = {
1713 properties: ["openDirectory", "createDirectory"],
1714 ...(defaultPath ? { defaultPath } : {}),
1715 };
1178 const result = owner1716 const result = owner
1179 ? await dialog.showOpenDialog(owner, {1717 ? await dialog.showOpenDialog(owner, openDialogOptions)
1180 properties: ["openDirectory", "createDirectory"],1718 : await dialog.showOpenDialog(openDialogOptions);
1181 })
1182 : await dialog.showOpenDialog({
1183 properties: ["openDirectory", "createDirectory"],
1184 });
1185 if (result.canceled) return null;1719 if (result.canceled) return null;
1186 return result.filePaths[0] ?? null;1720 return result.filePaths[0] ?? null;
1187 });1721 });
@@ -1210,14 +1744,7 @@ function registerIpcHandlers(): void { · 22 unmodified lines
1210 ipcMain.handle(1744 ipcMain.handle(
1211 CONTEXT_MENU_CHANNEL,1745 CONTEXT_MENU_CHANNEL,
1212 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {1746 async (_event, items: ContextMenuItem[], position?: { x: number; y: number }) => {
1213 const normalizedItems = items1747 const normalizedItems = normalizeContextMenuItems(items);
1214 .filter((item) => typeof item.id === "string" && typeof item.label === "string")
1215 .map((item) => ({
1216 id: item.id,
1217 label: item.label,
1218 destructive: item.destructive === true,
1219 disabled: item.disabled === true,
1220 }));
1221 if (normalizedItems.length === 0) {1748 if (normalizedItems.length === 0) {
1222 return null;1749 return null;
1223 }1750 }
@@ -1238,28 +1765,37 @@ function registerIpcHandlers(): void { · 14 unmodified lines
1238 if (!window) return null;1765 if (!window) return null;
1239 1766
1240 return new Promise<string | null>((resolve) => {1767 return new Promise<string | null>((resolve) => {
1241 const template: MenuItemConstructorOptions[] = [];1768 const buildTemplate = (
1242 let hasInsertedDestructiveSeparator = false;1769 entries: readonly ContextMenuItem[],
1243 for (const item of normalizedItems) {1770 ): MenuItemConstructorOptions[] => {
1244 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {1771 const template: MenuItemConstructorOptions[] = [];
1245 template.push({ type: "separator" });1772 let hasInsertedDestructiveSeparator = false;
1246 hasInsertedDestructiveSeparator = true;1773 for (const item of entries) {
1247 }1774 if (item.destructive && !hasInsertedDestructiveSeparator && template.length > 0) {
1248 const itemOption: MenuItemConstructorOptions = {1775 template.push({ type: "separator" });
1249 label: item.label,1776 hasInsertedDestructiveSeparator = true;
1250 enabled: !item.disabled,1777 }
1251 click: () => resolve(item.id),1778 const itemOption: MenuItemConstructorOptions = {
1252 };1779 label: item.label,
1253 if (item.destructive) {1780 enabled: !item.disabled,
1254 const destructiveIcon = getDestructiveMenuIcon();1781 };
1255 if (destructiveIcon) {1782 if (item.children && item.children.length > 0) {
1256 itemOption.icon = destructiveIcon;1783 itemOption.submenu = buildTemplate(item.children);
1784 } else {
1785 itemOption.click = () => resolve(item.id);
1786 }
1787 if (item.destructive && (!item.children || item.children.length === 0)) {
1788 const destructiveIcon = getDestructiveMenuIcon();
1789 if (destructiveIcon) {
1790 itemOption.icon = destructiveIcon;
1791 }
1257 }1792 }
1793 template.push(itemOption);
1258 }1794 }
1259 template.push(itemOption);1795 return template;
1260 }1796 };
1261 1797
1262 const menu = Menu.buildFromTemplate(template);1798 const menu = Menu.buildFromTemplate(buildTemplate(normalizedItems));
1263 menu.popup({1799 menu.popup({
1264 window,1800 window,
1265 ...popupPosition,1801 ...popupPosition,
@@ -1287,6 +1823,43 @@ function registerIpcHandlers(): void { · 21 unmodified lines
1287 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);1823 ipcMain.removeHandler(UPDATE_GET_STATE_CHANNEL);
1288 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);1824 ipcMain.handle(UPDATE_GET_STATE_CHANNEL, async () => updateState);
1289 1825
1826 ipcMain.removeHandler(UPDATE_SET_CHANNEL_CHANNEL);
1827 ipcMain.handle(UPDATE_SET_CHANNEL_CHANNEL, async (_event, rawChannel: unknown) => {
1828 if (rawChannel !== "latest" && rawChannel !== "nightly") {
1829 throw new Error("Invalid desktop update channel input.");
1830 }
1831 if (updateCheckInFlight || updateDownloadInFlight || updateInstallInFlight) {
1832 throw new Error("Cannot change update tracks while an update action is in progress.");
1833 }
1834
1835 const nextChannel = rawChannel as DesktopUpdateChannel;
1836
1837 desktopSettings = setDesktopUpdateChannelPreference(desktopSettings, nextChannel);
1838 writeDesktopSettings(DESKTOP_SETTINGS_PATH, desktopSettings);
1839
1840 if (nextChannel === updateState.channel) {
1841 return updateState;
1842 }
1843
1844 const enabled = shouldEnableAutoUpdates();
1845 setUpdateState(createBaseUpdateState(nextChannel, enabled));
1846
1847 if (!enabled || !updaterConfigured) {
1848 return updateState;
1849 }
1850
1851 applyAutoUpdaterChannel(nextChannel);
1852 const allowDowngrade = autoUpdater.allowDowngrade;
1853 // An explicit channel switch should allow the immediate nightly->stable rollback path.
1854 autoUpdater.allowDowngrade = true;
1855 try {
1856 await checkForUpdates("channel-change");
1857 } finally {
1858 autoUpdater.allowDowngrade = allowDowngrade;
1859 }
1860 return updateState;
1861 });
1862
1290 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);1863 ipcMain.removeHandler(UPDATE_DOWNLOAD_CHANNEL);
1291 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {1864 ipcMain.handle(UPDATE_DOWNLOAD_CHANNEL, async () => {
1292 const result = await downloadAvailableUpdate();1865 const result = await downloadAvailableUpdate();
@@ -1337,6 +1910,50 @@ function getIconOption(): { icon: string } | Record<string, never> { · 44 unmodified lines
1337 return iconPath ? { icon: iconPath } : {};1910 return iconPath ? { icon: iconPath } : {};
1338}1911}
1339 1912
1913function getInitialWindowBackgroundColor(): string {
1914 return nativeTheme.shouldUseDarkColors ? "#0a0a0a" : "#ffffff";
1915}
1916
1917function getWindowTitleBarOptions(): WindowTitleBarOptions {
1918 if (process.platform === "darwin") {
1919 return {
1920 titleBarStyle: "hiddenInset",
1921 trafficLightPosition: { x: 16, y: 18 },
1922 };
1923 }
1924
1925 return {
1926 titleBarStyle: "hidden",
1927 titleBarOverlay: {
1928 color: TITLEBAR_COLOR,
1929 height: TITLEBAR_HEIGHT,
1930 symbolColor: nativeTheme.shouldUseDarkColors
1931 ? TITLEBAR_DARK_SYMBOL_COLOR
1932 : TITLEBAR_LIGHT_SYMBOL_COLOR,
1933 },
1934 };
1935}
1936
1937function syncWindowAppearance(window: BrowserWindow): void {
1938 if (window.isDestroyed()) {
1939 return;
1940 }
1941
1942 window.setBackgroundColor(getInitialWindowBackgroundColor());
1943 const { titleBarOverlay } = getWindowTitleBarOptions();
1944 if (typeof titleBarOverlay === "object") {
1945 window.setTitleBarOverlay(titleBarOverlay);
1946 }
1947}
1948
1949function syncAllWindowAppearance(): void {
1950 for (const window of BrowserWindow.getAllWindows()) {
1951 syncWindowAppearance(window);
1952 }
1953}
1954
1955nativeTheme.on("updated", syncAllWindowAppearance);
1956
1340function createWindow(): BrowserWindow {1957function createWindow(): BrowserWindow {
1341 const window = new BrowserWindow({1958 const window = new BrowserWindow({
1342 width: 1100,1959 width: 1100,
@@ -1345,12 +1962,12 @@ function createWindow(): BrowserWindow { · 2 unmodified lines
1345 minHeight: 620,1962 minHeight: 620,
1346 show: false,1963 show: false,
1347 autoHideMenuBar: true,1964 autoHideMenuBar: true,
1965 backgroundColor: getInitialWindowBackgroundColor(),
1348 ...getIconOption(),1966 ...getIconOption(),
1349 title: APP_DISPLAY_NAME,1967 title: APP_DISPLAY_NAME,
1350 titleBarStyle: "hiddenInset",1968 ...getWindowTitleBarOptions(),
1351 trafficLightPosition: { x: 16, y: 18 },
1352 webPreferences: {1969 webPreferences: {
1353 preload: Path.join(__dirname, "preload.js"),1970 preload: Path.join(__dirname, "preload.cjs"),
1354 contextIsolation: true,1971 contextIsolation: true,
1355 nodeIntegration: false,1972 nodeIntegration: false,
1356 sandbox: true,1973 sandbox: true,
@@ -1375,6 +1992,22 @@ function createWindow(): BrowserWindow { · 18 unmodified lines
1375 menuTemplate.push({ type: "separator" });1992 menuTemplate.push({ type: "separator" });
1376 }1993 }
1377 1994
1995 const externalUrl = getSafeExternalUrl(params.linkURL);
1996 if (externalUrl) {
1997 menuTemplate.push(
1998 { label: "Copy Link", click: () => clipboard.writeText(params.linkURL) },
1999 { type: "separator" },
2000 );
2001 }
2002
2003 if (params.mediaType === "image") {
2004 menuTemplate.push({
2005 label: "Copy Image",
2006 click: () => window.webContents.copyImageAt(params.x, params.y),
2007 });
2008 menuTemplate.push({ type: "separator" });
2009 }
2010
1378 menuTemplate.push(2011 menuTemplate.push(
1379 { role: "cut", enabled: params.editFlags.canCut },2012 { role: "cut", enabled: params.editFlags.canCut },
1380 { role: "copy", enabled: params.editFlags.canCopy },2013 { role: "copy", enabled: params.editFlags.canCopy },
@@ -1401,15 +2034,23 @@ function createWindow(): BrowserWindow { · 20 unmodified lines
1401 window.setTitle(APP_DISPLAY_NAME);2034 window.setTitle(APP_DISPLAY_NAME);
1402 emitUpdateState();2035 emitUpdateState();
1403 });2036 });
1404 window.once("ready-to-show", () => {2037
1405 window.show();2038 let initialRevealScheduled = false;
1406 });2039 const revealInitialWindow = () => {
2040 if (initialRevealScheduled) {
2041 return;
2042 }
2043 initialRevealScheduled = true;
2044 revealWindow(window);
2045 };
2046
2047 window.once("ready-to-show", revealInitialWindow);
1407 2048
1408 if (isDevelopment) {2049 if (isDevelopment) {
1409 void window.loadURL(process.env.VITE_DEV_SERVER_URL as string);2050 void window.loadURL(resolveDesktopDevServerUrl());
1410 window.webContents.openDevTools({ mode: "detach" });2051 window.webContents.openDevTools({ mode: "detach" });
1411 } else {2052 } else {
1412 void window.loadURL(`${DESKTOP_SCHEME}://app/index.html`);2053 void window.loadURL(backendHttpUrl);
1413 }2054 }
1414 2055
1415 window.on("closed", () => {2056 window.on("closed", () => {
@@ -1430,23 +2071,71 @@ configureAppIdentity(); · 14 unmodified lines
1430 2071
1431async function bootstrap(): Promise<void> {2072async function bootstrap(): Promise<void> {
1432 writeDesktopLogHeader("bootstrap start");2073 writeDesktopLogHeader("bootstrap start");
1433 backendPort = await Effect.service(NetService).pipe(2074 const configuredBackendPort = resolveConfiguredDesktopBackendPort(process.env.T3CODE_PORT);
1434 Effect.flatMap((net) => net.reserveLoopbackPort()),2075 if (isDevelopment && configuredBackendPort === undefined) {
1435 Effect.provide(NetService.layer),2076 throw new Error("T3CODE_PORT is required in desktop development.");
1436 Effect.runPromise,2077 }
2078
2079 backendPort =
2080 configuredBackendPort ??
2081 (await resolveDesktopBackendPort({
2082 host: DESKTOP_LOOPBACK_HOST,
2083 startPort: DEFAULT_DESKTOP_BACKEND_PORT,
2084 requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS,
2085 }));
2086 writeDesktopLogHeader(
2087 configuredBackendPort === undefined
2088 ? `selected backend port via sequential scan startPort=${DEFAULT_DESKTOP_BACKEND_PORT} port=${backendPort}`
2089 : `using configured backend port port=${backendPort}`,
2090 );
2091 backendBootstrapToken = Crypto.randomBytes(24).toString("hex");
2092 if (desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode) {
2093 writeDesktopLogHeader(
2094 `bootstrap restoring persisted server exposure mode mode=${desktopSettings.serverExposureMode}`,
2095 );
2096 }
2097 const serverExposureState = await applyDesktopServerExposureMode(
2098 desktopSettings.serverExposureMode,
2099 {
2100 persist: desktopSettings.serverExposureMode !== DEFAULT_DESKTOP_SETTINGS.serverExposureMode,
2101 },
1437 );2102 );
1438 writeDesktopLogHeader(`reserved backend port via NetService port=${backendPort}`);2103 writeDesktopLogHeader(`bootstrap resolved backend endpoint baseUrl=${backendHttpUrl}`);
1439 backendAuthToken = Crypto.randomBytes(24).toString("hex");2104 if (serverExposureState.endpointUrl) {
1440 const baseUrl = `ws://127.0.0.1:${backendPort}`;2105 writeDesktopLogHeader(
1441 backendWsUrl = `${baseUrl}/?token=${encodeURIComponent(backendAuthToken)}`;2106 `bootstrap enabled network access endpointUrl=${serverExposureState.endpointUrl}`,
1442 writeDesktopLogHeader(`bootstrap resolved websocket endpoint baseUrl=${baseUrl}`);2107 );
2108 } else if (desktopSettings.serverExposureMode === "network-accessible") {
2109 writeDesktopLogHeader(
2110 "bootstrap fell back to local-only because no advertised network host was available",
2111 );
2112 }
1443 2113
1444 registerIpcHandlers();2114 registerIpcHandlers();
1445 writeDesktopLogHeader("bootstrap ipc handlers registered");2115 writeDesktopLogHeader("bootstrap ipc handlers registered");
1446 startBackend();2116 startBackend();
1447 writeDesktopLogHeader("bootstrap backend start requested");2117 writeDesktopLogHeader("bootstrap backend start requested");
1448 mainWindow = createWindow();2118
1449 writeDesktopLogHeader("bootstrap main window created");2119 if (isDevelopment) {
2120 mainWindow = createWindow();
2121 writeDesktopLogHeader("bootstrap main window created");
2122 void waitForBackendHttpReady(backendHttpUrl)
2123 .then(() => {
2124 writeDesktopLogHeader("bootstrap backend ready");
2125 })
2126 .catch((error) => {
2127 if (isBackendReadinessAborted(error)) {
2128 return;
2129 }
2130 writeDesktopLogHeader(
2131 `bootstrap backend readiness warning message=${formatErrorMessage(error)}`,
2132 );
2133 console.warn("[desktop] backend readiness check timed out during dev bootstrap", error);
2134 });
2135 return;
2136 }
2137
2138 ensureInitialBackendWindowOpen();
1450}2139}
1451 2140
1452app.on("before-quit", () => {2141app.on("before-quit", () => {
@@ -1454,6 +2143,7 @@ app.on("before-quit", () => { · 1 unmodified line
1454 updateInstallInFlight = false;2143 updateInstallInFlight = false;
1455 writeDesktopLogHeader("before-quit received");2144 writeDesktopLogHeader("before-quit received");
1456 clearUpdatePollTimer();2145 clearUpdatePollTimer();
2146 cancelBackendReadinessWait();
1457 stopBackend();2147 stopBackend();
1458 restoreStdIoCapture?.();2148 restoreStdIoCapture?.();
1459});2149});
@@ -1467,13 +2157,23 @@ app · 7 unmodified lines
1467 registerDesktopProtocol();2157 registerDesktopProtocol();
1468 configureAutoUpdater();2158 configureAutoUpdater();
1469 void bootstrap().catch((error) => {2159 void bootstrap().catch((error) => {
2160 if (isBackendReadinessAborted(error) && isQuitting) {
2161 return;
2162 }
1470 handleFatalStartupError("bootstrap", error);2163 handleFatalStartupError("bootstrap", error);
1471 });2164 });
1472 2165
1473 app.on("activate", () => {2166 app.on("activate", () => {
1474 if (BrowserWindow.getAllWindows().length === 0) {2167 const existingWindow = mainWindow ?? BrowserWindow.getAllWindows()[0];
2168 if (existingWindow) {
2169 revealWindow(existingWindow);
2170 return;
2171 }
2172 if (isDevelopment) {
1475 mainWindow = createWindow();2173 mainWindow = createWindow();
2174 return;
1476 }2175 }
2176 ensureInitialBackendWindowOpen();
1477 });2177 });
1478 })2178 })
1479 .catch((error) => {2179 .catch((error) => {
@@ -1492,6 +2192,7 @@ if (process.platform !== "win32") { · 12 unmodified lines
1492 isQuitting = true;2192 isQuitting = true;
1493 writeDesktopLogHeader("SIGINT received");2193 writeDesktopLogHeader("SIGINT received");
1494 clearUpdatePollTimer();2194 clearUpdatePollTimer();
2195 cancelBackendReadinessWait();
1495 stopBackend();2196 stopBackend();
1496 restoreStdIoCapture?.();2197 restoreStdIoCapture?.();
1497 app.quit();2198 app.quit();
apps/desktop/src/preload.ts+40−5
@@ -9,17 +9,51 @@ const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; · 8 unmodified lines
9const MENU_ACTION_CHANNEL = "desktop:menu-action";9const MENU_ACTION_CHANNEL = "desktop:menu-action";
10const UPDATE_STATE_CHANNEL = "desktop:update-state";10const UPDATE_STATE_CHANNEL = "desktop:update-state";
11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";11const UPDATE_GET_STATE_CHANNEL = "desktop:update-get-state";
12const UPDATE_SET_CHANNEL_CHANNEL = "desktop:update-set-channel";
12const UPDATE_CHECK_CHANNEL = "desktop:update-check";13const UPDATE_CHECK_CHANNEL = "desktop:update-check";
13const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";14const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download";
14const UPDATE_INSTALL_CHANNEL = "desktop:update-install";15const UPDATE_INSTALL_CHANNEL = "desktop:update-install";
15const GET_WS_URL_CHANNEL = "desktop:get-ws-url";16const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding";
17const GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL = "desktop:get-local-environment-bootstrap";
18const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings";
19const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings";
20const GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:get-saved-environment-registry";
21const SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL = "desktop:set-saved-environment-registry";
22const GET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:get-saved-environment-secret";
23const SET_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:set-saved-environment-secret";
24const REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL = "desktop:remove-saved-environment-secret";
25const GET_SERVER_EXPOSURE_STATE_CHANNEL = "desktop:get-server-exposure-state";
26const SET_SERVER_EXPOSURE_MODE_CHANNEL = "desktop:set-server-exposure-mode";
16 27
17contextBridge.exposeInMainWorld("desktopBridge", {28contextBridge.exposeInMainWorld("desktopBridge", {
18 getWsUrl: () => {29 getAppBranding: () => {
19 const result = ipcRenderer.sendSync(GET_WS_URL_CHANNEL);30 const result = ipcRenderer.sendSync(GET_APP_BRANDING_CHANNEL);
20 return typeof result === "string" ? result : null;31 if (typeof result !== "object" || result === null) {
32 return null;
33 }
34 return result as ReturnType<DesktopBridge["getAppBranding"]>;
21 },35 },
22 pickFolder: () => ipcRenderer.invoke(PICK_FOLDER_CHANNEL),36 getLocalEnvironmentBootstrap: () => {
37 const result = ipcRenderer.sendSync(GET_LOCAL_ENVIRONMENT_BOOTSTRAP_CHANNEL);
38 if (typeof result !== "object" || result === null) {
39 return null;
40 }
41 return result as ReturnType<DesktopBridge["getLocalEnvironmentBootstrap"]>;
42 },
43 getClientSettings: () => ipcRenderer.invoke(GET_CLIENT_SETTINGS_CHANNEL),
44 setClientSettings: (settings) => ipcRenderer.invoke(SET_CLIENT_SETTINGS_CHANNEL, settings),
45 getSavedEnvironmentRegistry: () => ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL),
46 setSavedEnvironmentRegistry: (records) =>
47 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_REGISTRY_CHANNEL, records),
48 getSavedEnvironmentSecret: (environmentId) =>
49 ipcRenderer.invoke(GET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
50 setSavedEnvironmentSecret: (environmentId, secret) =>
51 ipcRenderer.invoke(SET_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId, secret),
52 removeSavedEnvironmentSecret: (environmentId) =>
53 ipcRenderer.invoke(REMOVE_SAVED_ENVIRONMENT_SECRET_CHANNEL, environmentId),
54 getServerExposureState: () => ipcRenderer.invoke(GET_SERVER_EXPOSURE_STATE_CHANNEL),
55 setServerExposureMode: (mode) => ipcRenderer.invoke(SET_SERVER_EXPOSURE_MODE_CHANNEL, mode),
56 pickFolder: (options) => ipcRenderer.invoke(PICK_FOLDER_CHANNEL, options),
23 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),57 confirm: (message) => ipcRenderer.invoke(CONFIRM_CHANNEL, message),
24 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),58 setTheme: (theme) => ipcRenderer.invoke(SET_THEME_CHANNEL, theme),
25 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),59 showContextMenu: (items, position) => ipcRenderer.invoke(CONTEXT_MENU_CHANNEL, items, position),
@@ -36,6 +70,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 10 unmodified lines
36 };70 };
37 },71 },
38 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),72 getUpdateState: () => ipcRenderer.invoke(UPDATE_GET_STATE_CHANNEL),
73 setUpdateChannel: (channel) => ipcRenderer.invoke(UPDATE_SET_CHANNEL_CHANNEL, channel),
39 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),74 checkForUpdate: () => ipcRenderer.invoke(UPDATE_CHECK_CHANNEL),
40 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),75 downloadUpdate: () => ipcRenderer.invoke(UPDATE_DOWNLOAD_CHANNEL),
41 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),76 installUpdate: () => ipcRenderer.invoke(UPDATE_INSTALL_CHANNEL),
apps/desktop/src/runtimeArch.test.ts+1−1
@@ -1,6 +1,6 @@
1import { describe, expect, it } from "vitest";1import { describe, expect, it } from "vitest";
2 2
3import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch";3import { isArm64HostRunningIntelBuild, resolveDesktopRuntimeInfo } from "./runtimeArch.ts";
4 4
5describe("resolveDesktopRuntimeInfo", () => {5describe("resolveDesktopRuntimeInfo", () => {
6 it("detects Rosetta-translated Intel builds on Apple Silicon", () => {6 it("detects Rosetta-translated Intel builds on Apple Silicon", () => {
apps/desktop/src/serverExposure.test.ts+139−0
@@ -0,0 +1,139 @@
1import { describe, expect, it } from "vitest";
2
3import { resolveDesktopServerExposure, resolveLanAdvertisedHost } from "./serverExposure.ts";
4
5describe("resolveLanAdvertisedHost", () => {
6 it("prefers an explicit host override", () => {
7 expect(
8 resolveLanAdvertisedHost(
9 {
10 en0: [
11 {
12 address: "192.168.1.44",
13 family: "IPv4",
14 internal: false,
15 netmask: "255.255.255.0",
16 cidr: "192.168.1.44/24",
17 mac: "00:00:00:00:00:00",
18 },
19 ],
20 },
21 "10.0.0.9",
22 ),
23 ).toBe("10.0.0.9");
24 });
25
26 it("returns the first usable non-internal IPv4 address", () => {
27 expect(
28 resolveLanAdvertisedHost(
29 {
30 lo0: [
31 {
32 address: "127.0.0.1",
33 family: "IPv4",
34 internal: true,
35 netmask: "255.0.0.0",
36 cidr: "127.0.0.1/8",
37 mac: "00:00:00:00:00:00",
38 },
39 ],
40 en0: [
41 {
42 address: "192.168.1.44",
43 family: "IPv4",
44 internal: false,
45 netmask: "255.255.255.0",
46 cidr: "192.168.1.44/24",
47 mac: "00:00:00:00:00:00",
48 },
49 ],
50 },
51 undefined,
52 ),
53 ).toBe("192.168.1.44");
54 });
55
56 it("returns null when no usable network address is available", () => {
57 expect(
58 resolveLanAdvertisedHost(
59 {
60 lo0: [
61 {
62 address: "127.0.0.1",
63 family: "IPv4",
64 internal: true,
65 netmask: "255.0.0.0",
66 cidr: "127.0.0.1/8",
67 mac: "00:00:00:00:00:00",
68 },
69 ],
70 },
71 undefined,
72 ),
73 ).toBeNull();
74 });
75});
76
77describe("resolveDesktopServerExposure", () => {
78 it("keeps the desktop server loopback-only when local-only mode is selected", () => {
79 expect(
80 resolveDesktopServerExposure({
81 mode: "local-only",
82 port: 3773,
83 networkInterfaces: {},
84 }),
85 ).toEqual({
86 mode: "local-only",
87 bindHost: "127.0.0.1",
88 localHttpUrl: "http://127.0.0.1:3773",
89 localWsUrl: "ws://127.0.0.1:3773",
90 endpointUrl: null,
91 advertisedHost: null,
92 });
93 });
94
95 it("binds to all interfaces in network-accessible mode", () => {
96 expect(
97 resolveDesktopServerExposure({
98 mode: "network-accessible",
99 port: 3773,
100 networkInterfaces: {
101 en0: [
102 {
103 address: "192.168.1.44",
104 family: "IPv4",
105 internal: false,
106 netmask: "255.255.255.0",
107 cidr: "192.168.1.44/24",
108 mac: "00:00:00:00:00:00",
109 },
110 ],
111 },
112 }),
113 ).toEqual({
114 mode: "network-accessible",
115 bindHost: "0.0.0.0",
116 localHttpUrl: "http://127.0.0.1:3773",
117 localWsUrl: "ws://127.0.0.1:3773",
118 endpointUrl: "http://192.168.1.44:3773",
119 advertisedHost: "192.168.1.44",
120 });
121 });
122
123 it("stays network-accessible even when no LAN address is currently detectable", () => {
124 expect(
125 resolveDesktopServerExposure({
126 mode: "network-accessible",
127 port: 3773,
128 networkInterfaces: {},
129 }),
130 ).toEqual({
131 mode: "network-accessible",
132 bindHost: "0.0.0.0",
133 localHttpUrl: "http://127.0.0.1:3773",
134 localWsUrl: "ws://127.0.0.1:3773",
135 endpointUrl: null,
136 advertisedHost: null,
137 });
138 });
139});
apps/desktop/src/serverExposure.ts+80−0
@@ -0,0 +1,80 @@
1import type { NetworkInterfaceInfo } from "node:os";
2import type { DesktopServerExposureMode } from "@t3tools/contracts";
3
4const DESKTOP_LOOPBACK_HOST = "127.0.0.1";
5const DESKTOP_LAN_BIND_HOST = "0.0.0.0";
6
7export interface DesktopServerExposure {
8 readonly mode: DesktopServerExposureMode;
9 readonly bindHost: string;
10 readonly localHttpUrl: string;
11 readonly localWsUrl: string;
12 readonly endpointUrl: string | null;
13 readonly advertisedHost: string | null;
14}
15
16const normalizeOptionalHost = (value: string | undefined): string | undefined => {
17 const normalized = value?.trim();
18 return normalized && normalized.length > 0 ? normalized : undefined;
19};
20
21const isUsableLanIpv4Address = (address: string): boolean =>
22 !address.startsWith("127.") && !address.startsWith("169.254.");
23
24export function resolveLanAdvertisedHost(
25 networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>,
26 explicitHost: string | undefined,
27): string | null {
28 const normalizedExplicitHost = normalizeOptionalHost(explicitHost);
29 if (normalizedExplicitHost) {
30 return normalizedExplicitHost;
31 }
32
33 for (const interfaceAddresses of Object.values(networkInterfaces)) {
34 if (!interfaceAddresses) continue;
35
36 for (const address of interfaceAddresses) {
37 if (address.internal) continue;
38 if (address.family !== "IPv4") continue;
39 if (!isUsableLanIpv4Address(address.address)) continue;
40 return address.address;
41 }
42 }
43
44 return null;
45}
46
47export function resolveDesktopServerExposure(input: {
48 readonly mode: DesktopServerExposureMode;
49 readonly port: number;
50 readonly networkInterfaces: NodeJS.Dict<NetworkInterfaceInfo[]>;
51 readonly advertisedHostOverride?: string;
52}): DesktopServerExposure {
53 const localHttpUrl = `http://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
54 const localWsUrl = `ws://${DESKTOP_LOOPBACK_HOST}:${input.port}`;
55
56 if (input.mode === "local-only") {
57 return {
58 mode: input.mode,
59 bindHost: DESKTOP_LOOPBACK_HOST,
60 localHttpUrl,
61 localWsUrl,
62 endpointUrl: null,
63 advertisedHost: null,
64 };
65 }
66
67 const advertisedHost = resolveLanAdvertisedHost(
68 input.networkInterfaces,
69 input.advertisedHostOverride,
70 );
71
72 return {
73 mode: input.mode,
74 bindHost: DESKTOP_LAN_BIND_HOST,
75 localHttpUrl,
76 localWsUrl,
77 endpointUrl: advertisedHost ? `http://${advertisedHost}:${input.port}` : null,
78 advertisedHost,
79 };
80}
apps/desktop/src/serverListeningDetector.test.ts+31−0
@@ -0,0 +1,31 @@
1import { describe, expect, it } from "vitest";
2
3import { ServerListeningDetector } from "./serverListeningDetector.ts";
4
5describe("ServerListeningDetector", () => {
6 it("resolves when the server logs the listening line", async () => {
7 const detector = new ServerListeningDetector();
8
9 detector.push("[01:23:30.571] INFO (#148): Listening on http://0.0.0.0:7011\n");
10
11 await expect(detector.promise).resolves.toBeUndefined();
12 });
13
14 it("resolves when the listening line arrives across multiple chunks", async () => {
15 const detector = new ServerListeningDetector();
16
17 detector.push("[01:23:30.571] INFO (#148): Listen");
18 detector.push("ing on http://0.0.0.0:7011\n");
19
20 await expect(detector.promise).resolves.toBeUndefined();
21 });
22
23 it("rejects when the server exits before logging readiness", async () => {
24 const detector = new ServerListeningDetector();
25 const error = new Error("server exited");
26
27 detector.fail(error);
28
29 await expect(detector.promise).rejects.toBe(error);
30 });
31});
apps/desktop/src/serverListeningDetector.ts+56−0
@@ -0,0 +1,56 @@
1const LISTENING_LOG_FRAGMENT = "Listening on http://";
2const MAX_BUFFER_CHARS = 8_192;
3
4export class ServerListeningDetector {
5 private buffer = "";
6 private settled = false;
7 private readonly resolvePromise: () => void;
8 private readonly rejectPromise: (error: unknown) => void;
9 readonly promise: Promise<void>;
10
11 constructor() {
12 let resolvePromise: (() => void) | null = null;
13 let rejectPromise: ((error: unknown) => void) | null = null;
14
15 this.promise = new Promise<void>((resolve, reject) => {
16 resolvePromise = resolve;
17 rejectPromise = reject;
18 });
19
20 this.resolvePromise = () => {
21 if (this.settled) {
22 return;
23 }
24 this.settled = true;
25 resolvePromise?.();
26 };
27 this.rejectPromise = (error) => {
28 if (this.settled) {
29 return;
30 }
31 this.settled = true;
32 rejectPromise?.(error);
33 };
34 }
35
36 push(chunk: unknown): void {
37 if (this.settled) {
38 return;
39 }
40
41 const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
42 this.buffer = `${this.buffer}${text.replace(/\r/g, "")}`;
43 if (this.buffer.includes(LISTENING_LOG_FRAGMENT)) {
44 this.resolvePromise();
45 return;
46 }
47
48 if (this.buffer.length > MAX_BUFFER_CHARS) {
49 this.buffer = this.buffer.slice(-MAX_BUFFER_CHARS);
50 }
51 }
52
53 fail(error: unknown): void {
54 this.rejectPromise(error);
55 }
56}
apps/desktop/src/syncShellEnvironment.test.ts+191−7
@@ -1,16 +1,17 @@
1import { describe, expect, it, vi } from "vitest";1import { describe, expect, it, vi } from "vitest";
2 2
3import { syncShellEnvironment } from "./syncShellEnvironment";3import { syncShellEnvironment } from "./syncShellEnvironment.ts";
4 4
5describe("syncShellEnvironment", () => {5describe("syncShellEnvironment", () => {
6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {6 it("hydrates PATH and missing SSH_AUTH_SOCK from the login shell on macOS", () => {
7 const env: NodeJS.ProcessEnv = {7 const env: NodeJS.ProcessEnv = {
8 SHELL: "/bin/zsh",8 SHELL: "/bin/zsh",
9 PATH: "/usr/bin",9 PATH: "/Users/test/.local/bin:/usr/bin",
10 };10 };
11 const readEnvironment = vi.fn(() => ({11 const readEnvironment = vi.fn(() => ({
12 PATH: "/opt/homebrew/bin:/usr/bin",12 PATH: "/opt/homebrew/bin:/usr/bin",
13 SSH_AUTH_SOCK: "/tmp/secretive.sock",13 SSH_AUTH_SOCK: "/tmp/secretive.sock",
14 HOMEBREW_PREFIX: "/opt/homebrew",
14 }));15 }));
15 16
16 syncShellEnvironment(env, {17 syncShellEnvironment(env, {
@@ -18,9 +19,18 @@ describe("syncShellEnvironment", () => { · 1 unmodified line
18 readEnvironment,19 readEnvironment,
19 });20 });
20 21
21 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);22 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
22 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");23 "PATH",
24 "SSH_AUTH_SOCK",
25 "HOMEBREW_PREFIX",
26 "HOMEBREW_CELLAR",
27 "HOMEBREW_REPOSITORY",
28 "XDG_CONFIG_HOME",
29 "XDG_DATA_HOME",
30 ]);
31 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin:/Users/test/.local/bin");
23 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");32 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
33 expect(env.HOMEBREW_PREFIX).toBe("/opt/homebrew");
24 });34 });
25 35
26 it("preserves an inherited SSH_AUTH_SOCK value", () => {36 it("preserves an inherited SSH_AUTH_SOCK value", () => {
@@ -77,12 +87,68 @@ describe("syncShellEnvironment", () => { · 50 unmodified lines
77 readEnvironment,87 readEnvironment,
78 });88 });
79 89
80 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", ["PATH", "SSH_AUTH_SOCK"]);90 expect(readEnvironment).toHaveBeenCalledWith("/bin/zsh", [
91 "PATH",
92 "SSH_AUTH_SOCK",
93 "HOMEBREW_PREFIX",
94 "HOMEBREW_CELLAR",
95 "HOMEBREW_REPOSITORY",
96 "XDG_CONFIG_HOME",
97 "XDG_DATA_HOME",
98 ]);
81 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");99 expect(env.PATH).toBe("/home/linuxbrew/.linuxbrew/bin:/usr/bin");
82 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");100 expect(env.SSH_AUTH_SOCK).toBe("/tmp/secretive.sock");
83 });101 });
84 102
85 it("does nothing outside macOS and linux", () => {103 it("falls back to launchctl PATH on macOS when shell probing does not return one", () => {
104 const env: NodeJS.ProcessEnv = {
105 SHELL: "/opt/homebrew/bin/nu",
106 PATH: "/usr/bin",
107 };
108 const readEnvironment = vi
109 .fn()
110 .mockImplementationOnce(() => {
111 throw new Error("unknown flag");
112 })
113 .mockImplementationOnce(() => ({}));
114 const readLaunchctlPath = vi.fn(() => "/opt/homebrew/bin:/usr/bin");
115 const logWarning = vi.fn();
116
117 syncShellEnvironment(env, {
118 platform: "darwin",
119 readEnvironment,
120 readLaunchctlPath,
121 userShell: "/bin/zsh",
122 logWarning,
123 });
124
125 expect(readEnvironment).toHaveBeenNthCalledWith(1, "/opt/homebrew/bin/nu", [
126 "PATH",
127 "SSH_AUTH_SOCK",
128 "HOMEBREW_PREFIX",
129 "HOMEBREW_CELLAR",
130 "HOMEBREW_REPOSITORY",
131 "XDG_CONFIG_HOME",
132 "XDG_DATA_HOME",
133 ]);
134 expect(readEnvironment).toHaveBeenNthCalledWith(2, "/bin/zsh", [
135 "PATH",
136 "SSH_AUTH_SOCK",
137 "HOMEBREW_PREFIX",
138 "HOMEBREW_CELLAR",
139 "HOMEBREW_REPOSITORY",
140 "XDG_CONFIG_HOME",
141 "XDG_DATA_HOME",
142 ]);
143 expect(readLaunchctlPath).toHaveBeenCalledTimes(1);
144 expect(logWarning).toHaveBeenCalledWith(
145 "Failed to read login shell environment from /opt/homebrew/bin/nu.",
146 expect.any(Error),
147 );
148 expect(env.PATH).toBe("/opt/homebrew/bin:/usr/bin");
149 });
150
151 it("does nothing on unsupported platforms", () => {
86 const env: NodeJS.ProcessEnv = {152 const env: NodeJS.ProcessEnv = {
87 SHELL: "C:/Program Files/Git/bin/bash.exe",153 SHELL: "C:/Program Files/Git/bin/bash.exe",
88 PATH: "C:\\Windows\\System32",154 PATH: "C:\\Windows\\System32",
@@ -94,7 +160,7 @@ describe("syncShellEnvironment", () => { · 5 unmodified lines
94 }));160 }));
95 161
96 syncShellEnvironment(env, {162 syncShellEnvironment(env, {
97 platform: "win32",163 platform: "freebsd",
98 readEnvironment,164 readEnvironment,
99 });165 });
100 166
@@ -102,4 +168,122 @@ describe("syncShellEnvironment", () => { · 1 unmodified line
102 expect(env.PATH).toBe("C:\\Windows\\System32");168 expect(env.PATH).toBe("C:\\Windows\\System32");
103 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");169 expect(env.SSH_AUTH_SOCK).toBe("/tmp/inherited.sock");
104 });170 });
171
172 it("hydrates PATH on Windows by merging PowerShell PATH with inherited PATH", () => {
173 const env: NodeJS.ProcessEnv = {
174 PATH: "C:\\Windows\\System32",
175 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
176 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
177 USERPROFILE: "C:\\Users\\testuser",
178 };
179 const readWindowsEnvironment = vi.fn(() => ({
180 PATH: "C:\\Custom\\Bin;C:\\Windows\\System32",
181 }));
182 const isWindowsCommandAvailable = vi.fn(() => true);
183
184 syncShellEnvironment(env, {
185 platform: "win32",
186 readWindowsEnvironment,
187 isWindowsCommandAvailable,
188 });
189
190 expect(readWindowsEnvironment).toHaveBeenCalledWith(["PATH"], { loadProfile: false });
191 expect(env.PATH).toBe(
192 [
193 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
194 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
195 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
196 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
197 "C:\\Users\\testuser\\.bun\\bin",
198 "C:\\Users\\testuser\\scoop\\shims",
199 "C:\\Custom\\Bin",
200 "C:\\Windows\\System32",
201 ].join(";"),
202 );
203 expect(isWindowsCommandAvailable).toHaveBeenCalledTimes(1);
204 });
205
206 it("loads the PowerShell profile on Windows when node is not available", () => {
207 const env: NodeJS.ProcessEnv = {
208 PATH: "C:\\Windows\\System32",
209 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
210 LOCALAPPDATA: "C:\\Users\\testuser\\AppData\\Local",
211 USERPROFILE: "C:\\Users\\testuser",
212 };
213 const readWindowsEnvironment = vi.fn(
214 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) =>
215 options?.loadProfile
216 ? {
217 PATH: "C:\\Profile\\Node;C:\\Windows\\System32",
218 FNM_DIR: "C:\\Users\\testuser\\AppData\\Roaming\\fnm",
219 FNM_MULTISHELL_PATH: "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
220 }
221 : { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" },
222 );
223 const isWindowsCommandAvailable = vi.fn().mockReturnValueOnce(false).mockReturnValueOnce(true);
224
225 syncShellEnvironment(env, {
226 platform: "win32",
227 readWindowsEnvironment,
228 isWindowsCommandAvailable,
229 });
230
231 expect(env.PATH).toBe(
232 [
233 "C:\\Profile\\Node",
234 "C:\\Windows\\System32",
235 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
236 "C:\\Users\\testuser\\AppData\\Local\\Programs\\nodejs",
237 "C:\\Users\\testuser\\AppData\\Local\\Volta\\bin",
238 "C:\\Users\\testuser\\AppData\\Local\\pnpm",
239 "C:\\Users\\testuser\\.bun\\bin",
240 "C:\\Users\\testuser\\scoop\\shims",
241 "C:\\Custom\\Bin",
242 ].join(";"),
243 );
244 expect(env.FNM_DIR).toBe("C:\\Users\\testuser\\AppData\\Roaming\\fnm");
245 expect(env.FNM_MULTISHELL_PATH).toBe(
246 "C:\\Users\\testuser\\AppData\\Local\\fnm_multishells\\123",
247 );
248 expect(readWindowsEnvironment).toHaveBeenNthCalledWith(1, ["PATH"], { loadProfile: false });
249 expect(readWindowsEnvironment).toHaveBeenNthCalledWith(
250 2,
251 ["PATH", "FNM_DIR", "FNM_MULTISHELL_PATH"],
252 { loadProfile: true },
253 );
254 });
255
256 it("preserves baseline Windows env when the profile probe fails", () => {
257 const env: NodeJS.ProcessEnv = {
258 PATH: "C:\\Windows\\System32",
259 APPDATA: "C:\\Users\\testuser\\AppData\\Roaming",
260 USERPROFILE: "C:\\Users\\testuser",
261 };
262 const readWindowsEnvironment = vi.fn(
263 (_names: ReadonlyArray<string>, options?: { loadProfile?: boolean }) => {
264 if (options?.loadProfile) {
265 throw new Error("profile load failed");
266 }
267 return { PATH: "C:\\Custom\\Bin;C:\\Windows\\System32" };
268 },
269 );
270 const isWindowsCommandAvailable = vi.fn(() => false);
271
272 syncShellEnvironment(env, {
273 platform: "win32",
274 readWindowsEnvironment,
275 isWindowsCommandAvailable,
276 });
277
278 expect(env.PATH).toBe(
279 [
280 "C:\\Users\\testuser\\AppData\\Roaming\\npm",
281 "C:\\Users\\testuser\\.bun\\bin",
282 "C:\\Users\\testuser\\scoop\\shims",
283 "C:\\Custom\\Bin",
284 "C:\\Windows\\System32",
285 ].join(";"),
286 );
287 expect(env.SSH_AUTH_SOCK).toBeUndefined();
288 });
105});289});
apps/desktop/src/syncShellEnvironment.ts+85−12
@@ -1,36 +1,109 @@
1import {1import {
2 listLoginShellCandidates,
3 mergePathEntries,
4 readPathFromLaunchctl,
2 readEnvironmentFromLoginShell,5 readEnvironmentFromLoginShell,
3 resolveLoginShell,6 resolveWindowsEnvironment,
7} from "@t3tools/shared/shell";
8import type {
9 CommandAvailabilityOptions,
4 ShellEnvironmentReader,10 ShellEnvironmentReader,
11 WindowsShellEnvironmentReader,
5} from "@t3tools/shared/shell";12} from "@t3tools/shared/shell";
6 13
14type WindowsCommandAvailabilityChecker = (
15 command: string,
16 options?: CommandAvailabilityOptions,
17) => boolean;
18
19const LOGIN_SHELL_ENV_NAMES = [
20 "PATH",
21 "SSH_AUTH_SOCK",
22 "HOMEBREW_PREFIX",
23 "HOMEBREW_CELLAR",
24 "HOMEBREW_REPOSITORY",
25 "XDG_CONFIG_HOME",
26 "XDG_DATA_HOME",
27] as const;
28
29function logShellEnvironmentWarning(message: string, error?: unknown): void {
30 console.warn(`[desktop] ${message}`, error instanceof Error ? error.message : (error ?? ""));
31}
32
7export function syncShellEnvironment(33export function syncShellEnvironment(
8 env: NodeJS.ProcessEnv = process.env,34 env: NodeJS.ProcessEnv = process.env,
9 options: {35 options: {
10 platform?: NodeJS.Platform;36 platform?: NodeJS.Platform;
11 readEnvironment?: ShellEnvironmentReader;37 readEnvironment?: ShellEnvironmentReader;
38 readWindowsEnvironment?: WindowsShellEnvironmentReader;
39 isWindowsCommandAvailable?: WindowsCommandAvailabilityChecker;
40 readLaunchctlPath?: typeof readPathFromLaunchctl;
41 userShell?: string;
42 logWarning?: (message: string, error?: unknown) => void;
12 } = {},43 } = {},
13): void {44): void {
14 const platform = options.platform ?? process.platform;45 const platform = options.platform ?? process.platform;
15 if (platform !== "darwin" && platform !== "linux") return;46
47 const logWarning = options.logWarning ?? logShellEnvironmentWarning;
48 const readEnvironment = options.readEnvironment ?? readEnvironmentFromLoginShell;
49 const shellEnvironment: Partial<Record<string, string>> = {};
16 50
17 try {51 try {
18 const shell = resolveLoginShell(platform, env.SHELL);52 if (platform === "win32") {
19 if (!shell) return;53 const repairedEnvironment = resolveWindowsEnvironment(env, {
54 ...(options.readWindowsEnvironment
55 ? { readEnvironment: options.readWindowsEnvironment }
56 : {}),
57 ...(options.isWindowsCommandAvailable
58 ? { commandAvailable: options.isWindowsCommandAvailable }
59 : {}),
60 });
61 for (const [key, value] of Object.entries(repairedEnvironment)) {
62 if (value !== undefined) {
63 env[key] = value;
64 }
65 }
66 return;
67 }
68
69 if (platform !== "darwin" && platform !== "linux") return;
20 70
21 const shellEnvironment = (options.readEnvironment ?? readEnvironmentFromLoginShell)(shell, [71 for (const shell of listLoginShellCandidates(platform, env.SHELL, options.userShell)) {
22 "PATH",72 try {
23 "SSH_AUTH_SOCK",73 Object.assign(shellEnvironment, readEnvironment(shell, LOGIN_SHELL_ENV_NAMES));
24 ]);74 if (shellEnvironment.PATH) {
75 break;
76 }
77 } catch (error) {
78 logWarning(`Failed to read login shell environment from ${shell}.`, error);
79 }
80 }
25 81
26 if (shellEnvironment.PATH) {82 const launchctlPath =
27 env.PATH = shellEnvironment.PATH;83 platform === "darwin" && !shellEnvironment.PATH
84 ? (options.readLaunchctlPath ?? readPathFromLaunchctl)()
85 : undefined;
86 const mergedPath = mergePathEntries(shellEnvironment.PATH ?? launchctlPath, env.PATH, platform);
87 if (mergedPath) {
88 env.PATH = mergedPath;
28 }89 }
29 90
30 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {91 if (!env.SSH_AUTH_SOCK && shellEnvironment.SSH_AUTH_SOCK) {
31 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;92 env.SSH_AUTH_SOCK = shellEnvironment.SSH_AUTH_SOCK;
32 }93 }
33 } catch {94
34 // Keep inherited environment if shell lookup fails.95 for (const name of [
96 "HOMEBREW_PREFIX",
97 "HOMEBREW_CELLAR",
98 "HOMEBREW_REPOSITORY",
99 "XDG_CONFIG_HOME",
100 "XDG_DATA_HOME",
101 ] as const) {
102 if (!env[name] && shellEnvironment[name]) {
103 env[name] = shellEnvironment[name];
104 }
105 }
106 } catch (error) {
107 logWarning("Failed to synchronize the desktop shell environment.", error);
35 }108 }
36}109}
apps/desktop/src/updateChannels.test.ts+41−0
@@ -0,0 +1,41 @@
1import { describe, expect, it } from "vitest";
2
3import {
4 doesVersionMatchDesktopUpdateChannel,
5 isNightlyDesktopVersion,
6 resolveDefaultDesktopUpdateChannel,
7} from "./updateChannels.ts";
8
9describe("isNightlyDesktopVersion", () => {
10 it("detects packaged nightly versions", () => {
11 expect(isNightlyDesktopVersion("0.0.17-nightly.20260415.1")).toBe(true);
12 });
13
14 it("does not flag stable versions as nightly", () => {
15 expect(isNightlyDesktopVersion("0.0.17")).toBe(false);
16 });
17});
18
19describe("resolveDefaultDesktopUpdateChannel", () => {
20 it("defaults stable builds to latest", () => {
21 expect(resolveDefaultDesktopUpdateChannel("0.0.17")).toBe("latest");
22 });
23
24 it("defaults nightly builds to nightly", () => {
25 expect(resolveDefaultDesktopUpdateChannel("0.0.17-nightly.20260415.1")).toBe("nightly");
26 });
27});
28
29describe("doesVersionMatchDesktopUpdateChannel", () => {
30 it("accepts nightly releases on the nightly channel", () => {
31 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "nightly")).toBe(true);
32 });
33
34 it("rejects stable releases on the nightly channel", () => {
35 expect(doesVersionMatchDesktopUpdateChannel("0.0.17", "nightly")).toBe(false);
36 });
37
38 it("rejects nightly releases on the stable channel", () => {
39 expect(doesVersionMatchDesktopUpdateChannel("0.0.17-nightly.20260416.1", "latest")).toBe(false);
40 });
41});
apps/desktop/src/updateChannels.ts+18−0
@@ -0,0 +1,18 @@
1import type { DesktopUpdateChannel } from "@t3tools/contracts";
2
3const NIGHTLY_VERSION_PATTERN = /-nightly\.\d{8}\.\d+$/;
4
5export function isNightlyDesktopVersion(version: string): boolean {
6 return NIGHTLY_VERSION_PATTERN.test(version);
7}
8
9export function resolveDefaultDesktopUpdateChannel(appVersion: string): DesktopUpdateChannel {
10 return isNightlyDesktopVersion(appVersion) ? "nightly" : "latest";
11}
12
13export function doesVersionMatchDesktopUpdateChannel(
14 version: string,
15 channel: DesktopUpdateChannel,
16): boolean {
17 return resolveDefaultDesktopUpdateChannel(version) === channel;
18}
apps/desktop/src/updateMachine.test.ts+8−7
@@ -11,7 +11,7 @@ import { · 10 unmodified lines
11 reduceDesktopUpdateStateOnInstallFailure,11 reduceDesktopUpdateStateOnInstallFailure,
12 reduceDesktopUpdateStateOnNoUpdate,12 reduceDesktopUpdateStateOnNoUpdate,
13 reduceDesktopUpdateStateOnUpdateAvailable,13 reduceDesktopUpdateStateOnUpdateAvailable,
14} from "./updateMachine";14} from "./updateMachine.ts";
15 15
16const runtimeInfo = {16const runtimeInfo = {
17 hostArch: "x64",17 hostArch: "x64",
@@ -23,7 +23,7 @@ describe("updateMachine", () => { · 5 unmodified lines
23 it("clears transient errors when a check starts", () => {23 it("clears transient errors when a check starts", () => {
24 const state = reduceDesktopUpdateStateOnCheckStart(24 const state = reduceDesktopUpdateStateOnCheckStart(
25 {25 {
26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),26 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
27 enabled: true,27 enabled: true,
28 status: "error",28 status: "error",
29 message: "network",29 message: "network",
@@ -42,7 +42,7 @@ describe("updateMachine", () => { · 12 unmodified lines
42 it("records a check failure without exposing an action", () => {42 it("records a check failure without exposing an action", () => {
43 const state = reduceDesktopUpdateStateOnCheckFailure(43 const state = reduceDesktopUpdateStateOnCheckFailure(
44 {44 {
45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),45 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
46 enabled: true,46 enabled: true,
47 status: "checking",47 status: "checking",
48 },48 },
@@ -58,7 +58,7 @@ describe("updateMachine", () => { · 9 unmodified lines
58 it("preserves available version on download failure for retry", () => {58 it("preserves available version on download failure for retry", () => {
59 const state = reduceDesktopUpdateStateOnDownloadFailure(59 const state = reduceDesktopUpdateStateOnDownloadFailure(
60 {60 {
61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),61 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
62 enabled: true,62 enabled: true,
63 status: "downloading",63 status: "downloading",
64 availableVersion: "1.1.0",64 availableVersion: "1.1.0",
@@ -76,7 +76,7 @@ describe("updateMachine", () => { · 11 unmodified lines
76 it("transitions to downloaded and then preserves install retry state", () => {76 it("transitions to downloaded and then preserves install retry state", () => {
77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(77 const downloaded = reduceDesktopUpdateStateOnDownloadComplete(
78 {78 {
79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),79 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
80 enabled: true,80 enabled: true,
81 status: "downloading",81 status: "downloading",
82 availableVersion: "1.1.0",82 availableVersion: "1.1.0",
@@ -98,7 +98,7 @@ describe("updateMachine", () => { · 15 unmodified lines
98 it("clears stale download state when no update is available", () => {98 it("clears stale download state when no update is available", () => {
99 const state = reduceDesktopUpdateStateOnNoUpdate(99 const state = reduceDesktopUpdateStateOnNoUpdate(
100 {100 {
101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),101 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
102 enabled: true,102 enabled: true,
103 status: "error",103 status: "error",
104 availableVersion: "1.1.0",104 availableVersion: "1.1.0",
@@ -120,7 +120,7 @@ describe("updateMachine", () => { · 15 unmodified lines
120 it("tracks available, download start, and progress cleanly", () => {120 it("tracks available, download start, and progress cleanly", () => {
121 const available = reduceDesktopUpdateStateOnUpdateAvailable(121 const available = reduceDesktopUpdateStateOnUpdateAvailable(
122 {122 {
123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo),123 ...createInitialDesktopUpdateState("1.0.0", runtimeInfo, "latest"),
124 enabled: true,124 enabled: true,
125 status: "checking",125 status: "checking",
126 },126 },
@@ -131,6 +131,7 @@ describe("updateMachine", () => { · 4 unmodified lines
131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);131 const progress = reduceDesktopUpdateStateOnDownloadProgress(downloading, 55.5);
132 132
133 expect(available.status).toBe("available");133 expect(available.status).toBe("available");
134 expect(available.channel).toBe("latest");
134 expect(downloading.status).toBe("downloading");135 expect(downloading.status).toBe("downloading");
135 expect(downloading.downloadPercent).toBe(0);136 expect(downloading.downloadPercent).toBe(0);
136 expect(progress.downloadPercent).toBe(55.5);137 expect(progress.downloadPercent).toBe(55.5);
apps/desktop/src/updateMachine.ts+8−2
@@ -1,14 +1,20 @@
1import type { DesktopRuntimeInfo, DesktopUpdateState } from "@t3tools/contracts";1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateChannel,
4 DesktopUpdateState,
5} from "@t3tools/contracts";
2 6
3import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState";7import { getCanRetryAfterDownloadFailure, nextStatusAfterDownloadFailure } from "./updateState.ts";
4 8
5export function createInitialDesktopUpdateState(9export function createInitialDesktopUpdateState(
6 currentVersion: string,10 currentVersion: string,
7 runtimeInfo: DesktopRuntimeInfo,11 runtimeInfo: DesktopRuntimeInfo,
12 channel: DesktopUpdateChannel,
8): DesktopUpdateState {13): DesktopUpdateState {
9 return {14 return {
10 enabled: false,15 enabled: false,
11 status: "disabled",16 status: "disabled",
17 channel,
12 currentVersion,18 currentVersion,
13 hostArch: runtimeInfo.hostArch,19 hostArch: runtimeInfo.hostArch,
14 appArch: runtimeInfo.appArch,20 appArch: runtimeInfo.appArch,
apps/desktop/src/updateState.test.ts+31−1
@@ -6,11 +6,12 @@ import { · 5 unmodified lines
6 getAutoUpdateDisabledReason,6 getAutoUpdateDisabledReason,
7 nextStatusAfterDownloadFailure,7 nextStatusAfterDownloadFailure,
8 shouldBroadcastDownloadProgress,8 shouldBroadcastDownloadProgress,
9} from "./updateState";9} from "./updateState.ts";
10 10
11const baseState: DesktopUpdateState = {11const baseState: DesktopUpdateState = {
12 enabled: true,12 enabled: true,
13 status: "idle",13 status: "idle",
14 channel: "latest",
14 currentVersion: "1.0.0",15 currentVersion: "1.0.0",
15 hostArch: "x64",16 hostArch: "x64",
16 appArch: "x64",17 appArch: "x64",
@@ -71,10 +72,37 @@ describe("getAutoUpdateDisabledReason", () => { · 54 unmodified lines
71 platform: "darwin",72 platform: "darwin",
72 appImage: undefined,73 appImage: undefined,
73 disabledByEnv: false,74 disabledByEnv: false,
75 hasUpdateFeedConfig: true,
74 }),76 }),
75 ).toContain("packaged production builds");77 ).toContain("packaged production builds");
76 });78 });
77 79
80 it("reports packaged local builds without an update feed as disabled", () => {
81 expect(
82 getAutoUpdateDisabledReason({
83 isDevelopment: false,
84 isPackaged: true,
85 platform: "darwin",
86 appImage: undefined,
87 disabledByEnv: false,
88 hasUpdateFeedConfig: false,
89 }),
90 ).toContain("no update feed");
91 });
92
93 it("allows packaged builds with an update feed", () => {
94 expect(
95 getAutoUpdateDisabledReason({
96 isDevelopment: false,
97 isPackaged: true,
98 platform: "darwin",
99 appImage: undefined,
100 disabledByEnv: false,
101 hasUpdateFeedConfig: true,
102 }),
103 ).toBeNull();
104 });
105
78 it("reports env-disabled auto updates", () => {106 it("reports env-disabled auto updates", () => {
79 expect(107 expect(
80 getAutoUpdateDisabledReason({108 getAutoUpdateDisabledReason({
@@ -83,6 +111,7 @@ describe("getAutoUpdateDisabledReason", () => { · 2 unmodified lines
83 platform: "darwin",111 platform: "darwin",
84 appImage: undefined,112 appImage: undefined,
85 disabledByEnv: true,113 disabledByEnv: true,
114 hasUpdateFeedConfig: true,
86 }),115 }),
87 ).toContain("T3CODE_DISABLE_AUTO_UPDATE");116 ).toContain("T3CODE_DISABLE_AUTO_UPDATE");
88 });117 });
@@ -95,6 +124,7 @@ describe("getAutoUpdateDisabledReason", () => { · 6 unmodified lines
95 platform: "linux",124 platform: "linux",
96 appImage: undefined,125 appImage: undefined,
97 disabledByEnv: false,126 disabledByEnv: false,
127 hasUpdateFeedConfig: true,
98 }),128 }),
99 ).toContain("AppImage");129 ).toContain("AppImage");
100 });130 });
apps/desktop/src/updateState.ts+4−0
@@ -34,7 +34,11 @@ export function getAutoUpdateDisabledReason(args: { · 33 unmodified lines
34 platform: NodeJS.Platform;34 platform: NodeJS.Platform;
35 appImage?: string | undefined;35 appImage?: string | undefined;
36 disabledByEnv: boolean;36 disabledByEnv: boolean;
37 hasUpdateFeedConfig: boolean;
37}): string | null {38}): string | null {
39 if (!args.hasUpdateFeedConfig) {
40 return "Automatic updates are not available because no update feed is configured.";
41 }
38 if (args.isDevelopment || !args.isPackaged) {42 if (args.isDevelopment || !args.isPackaged) {
39 return "Automatic updates are only available in packaged production builds.";43 return "Automatic updates are only available in packaged production builds.";
40 }44 }
apps/desktop/tsconfig.json+1−1
@@ -3,7 +3,7 @@ · 2 unmodified lines
3 "compilerOptions": {3 "compilerOptions": {
4 "composite": true,4 "composite": true,
5 "types": ["node", "electron"],5 "types": ["node", "electron"],
6 "lib": ["ES2023", "DOM", "esnext.disposable"]6 "lib": ["ESNext", "DOM", "esnext.disposable"]
7 },7 },
8 "include": ["src", "tsdown.config.ts"]8 "include": ["src", "tsdown.config.ts"]
9}9}
apps/desktop/tsdown.config.ts+1−1
@@ -4,7 +4,7 @@ const shared = { · 3 unmodified lines
4 format: "cjs" as const,4 format: "cjs" as const,
5 outDir: "dist-electron",5 outDir: "dist-electron",
6 sourcemap: true,6 sourcemap: true,
7 outExtensions: () => ({ js: ".js" }),7 outExtensions: () => ({ js: ".cjs" }),
8};8};
9 9
10export default defineConfig([10export default defineConfig([
apps/marketing/src/pages/download.astro+5−4
@@ -1,5 +1,6 @@
1---1---
2import Layout from "../layouts/Layout.astro";2import Layout from "../layouts/Layout.astro";
3import { RELEASES_URL } from "../lib/releases";
3---4---
4 5
5<Layout title="Download — T3 Code" description="Download T3 Code for macOS, Windows, or Linux.">6<Layout title="Download — T3 Code" description="Download T3 Code for macOS, Windows, or Linux.">
@@ -17,11 +18,11 @@ import Layout from "../layouts/Layout.astro"; · 11 unmodified lines
17 <h2 class="platform-name">macOS</h2>18 <h2 class="platform-name">macOS</h2>
18 </div>19 </div>
19 <div class="download-cards">20 <div class="download-cards">
20 <a class="download-card" data-asset="arm64.dmg" href="#">21 <a class="download-card" data-asset="arm64.dmg" href={RELEASES_URL}>
21 <span class="card-arch">Apple Silicon (arm64)</span>22 <span class="card-arch">Apple Silicon (arm64)</span>
22 <span class="card-format">.dmg</span>23 <span class="card-format">.dmg</span>
23 </a>24 </a>
24 <a class="download-card" data-asset="x64.dmg" href="#">25 <a class="download-card" data-asset="x64.dmg" href={RELEASES_URL}>
25 <span class="card-arch">Intel (x64)</span>26 <span class="card-arch">Intel (x64)</span>
26 <span class="card-format">.dmg</span>27 <span class="card-format">.dmg</span>
27 </a>28 </a>
@@ -35,7 +36,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines
35 <h2 class="platform-name">Windows</h2>36 <h2 class="platform-name">Windows</h2>
36 </div>37 </div>
37 <div class="download-cards">38 <div class="download-cards">
38 <a class="download-card" data-asset="x64.exe" href="#">39 <a class="download-card" data-asset="x64.exe" href={RELEASES_URL}>
39 <span class="card-arch">Windows 10, 11</span>40 <span class="card-arch">Windows 10, 11</span>
40 <span class="card-format">.exe</span>41 <span class="card-format">.exe</span>
41 </a>42 </a>
@@ -49,7 +50,7 @@ import Layout from "../layouts/Layout.astro"; · 7 unmodified lines
49 <h2 class="platform-name">Linux</h2>50 <h2 class="platform-name">Linux</h2>
50 </div>51 </div>
51 <div class="download-cards">52 <div class="download-cards">
52 <a class="download-card" data-asset="x86_64.AppImage" href="#">53 <a class="download-card" data-asset="x86_64.AppImage" href={RELEASES_URL}>
53 <span class="card-arch">x86_64</span>54 <span class="card-arch">x86_64</span>
54 <span class="card-format">AppImage</span>55 <span class="card-format">AppImage</span>
55 </a>56 </a>
apps/server/integration/OrchestrationEngineHarness.integration.ts+23−4
@@ -26,6 +26,7 @@ import { CheckpointStoreLive } from "../src/checkpointing/Layers/CheckpointStore · 25 unmodified lines
26import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";26import { CheckpointStore } from "../src/checkpointing/Services/CheckpointStore.ts";
27import { GitCoreLive } from "../src/git/Layers/GitCore.ts";27import { GitCoreLive } from "../src/git/Layers/GitCore.ts";
28import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";28import { GitCore, type GitCoreShape } from "../src/git/Services/GitCore.ts";
29import { GitStatusBroadcaster } from "../src/git/Services/GitStatusBroadcaster.ts";
29import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";30import { TextGeneration, type TextGenerationShape } from "../src/git/Services/TextGeneration.ts";
30import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";31import { OrchestrationCommandReceiptRepositoryLive } from "../src/persistence/Layers/OrchestrationCommandReceipts.ts";
31import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";32import { OrchestrationEventStoreLive } from "../src/persistence/Layers/OrchestrationEventStore.ts";
@@ -45,10 +46,11 @@ import { CodexAdapter } from "../src/provider/Services/CodexAdapter.ts"; · 13 unmodified lines
45import { ProviderService } from "../src/provider/Services/ProviderService.ts";46import { ProviderService } from "../src/provider/Services/ProviderService.ts";
46import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";47import { AnalyticsService } from "../src/telemetry/Services/AnalyticsService.ts";
47import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";48import { CheckpointReactorLive } from "../src/orchestration/Layers/CheckpointReactor.ts";
49import { RepositoryIdentityResolverLive } from "../src/project/Layers/RepositoryIdentityResolver.ts";
48import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";50import { OrchestrationEngineLive } from "../src/orchestration/Layers/OrchestrationEngine.ts";
49import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";51import { OrchestrationProjectionPipelineLive } from "../src/orchestration/Layers/ProjectionPipeline.ts";
50import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";52import { OrchestrationProjectionSnapshotQueryLive } from "../src/orchestration/Layers/ProjectionSnapshotQuery.ts";
51import { RuntimeReceiptBusLive } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";53import { RuntimeReceiptBusTest } from "../src/orchestration/Layers/RuntimeReceiptBus.ts";
52import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";54import { OrchestrationReactorLive } from "../src/orchestration/Layers/OrchestrationReactor.ts";
53import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";55import { ProviderCommandReactorLive } from "../src/orchestration/Layers/ProviderCommandReactor.ts";
54import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";56import { ProviderRuntimeIngestionLive } from "../src/orchestration/Layers/ProviderRuntimeIngestion.ts";
@@ -297,7 +299,7 @@ export const makeOrchestrationIntegrationHarness = ( · 242 unmodified lines
297 ProjectionPendingApprovalRepositoryLive,299 ProjectionPendingApprovalRepositoryLive,
298 checkpointStoreLayer,300 checkpointStoreLayer,
299 providerLayer,301 providerLayer,
300 RuntimeReceiptBusLive,302 RuntimeReceiptBusTest,
301 );303 );
302 const serverSettingsLayer = ServerSettingsService.layerTest();304 const serverSettingsLayer = ServerSettingsService.layerTest();
303 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(305 const runtimeIngestionLayer = ProviderRuntimeIngestionLive.pipe(
@@ -320,6 +322,22 @@ export const makeOrchestrationIntegrationHarness = ( · 16 unmodified lines
320 );322 );
321 const checkpointReactorLayer = CheckpointReactorLive.pipe(323 const checkpointReactorLayer = CheckpointReactorLive.pipe(
322 Layer.provideMerge(runtimeServicesLayer),324 Layer.provideMerge(runtimeServicesLayer),
325 Layer.provideMerge(
326 Layer.succeed(GitStatusBroadcaster, {
327 getStatus: () => Effect.die("getStatus should not be called in this test"),
328 refreshLocalStatus: () =>
329 Effect.succeed({
330 isRepo: true,
331 hasOriginRemote: false,
332 isDefaultBranch: true,
333 branch: "main",
334 hasWorkingTreeChanges: false,
335 workingTree: { files: [], insertions: 0, deletions: 0 },
336 }),
337 refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
338 streamStatus: () => Stream.empty,
339 }),
340 ),
323 Layer.provideMerge(341 Layer.provideMerge(
324 WorkspaceEntriesLive.pipe(342 WorkspaceEntriesLive.pipe(
325 Layer.provide(WorkspacePathsLive),343 Layer.provide(WorkspacePathsLive),
@@ -338,6 +356,7 @@ export const makeOrchestrationIntegrationHarness = ( · 12 unmodified lines
338 Layer.provideMerge(runtimeServicesLayer),356 Layer.provideMerge(runtimeServicesLayer),
339 Layer.provideMerge(orchestrationReactorLayer),357 Layer.provideMerge(orchestrationReactorLayer),
340 Layer.provide(persistenceLayer),358 Layer.provide(persistenceLayer),
359 Layer.provideMerge(RepositoryIdentityResolverLive),
341 Layer.provideMerge(ServerSettingsService.layerTest()),360 Layer.provideMerge(ServerSettingsService.layerTest()),
342 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),361 Layer.provideMerge(ServerConfig.layerTest(workspaceDir, rootDir)),
343 Layer.provideMerge(NodeServices.layer),362 Layer.provideMerge(NodeServices.layer),
@@ -376,7 +395,7 @@ export const makeOrchestrationIntegrationHarness = ( · 32 unmodified lines
376 runtime.runPromise(reactor.start().pipe(Scope.provide(scope))),395 runtime.runPromise(reactor.start().pipe(Scope.provide(scope))),
377 ).pipe(Effect.orDie);396 ).pipe(Effect.orDie);
378 const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]);397 const receiptHistory = yield* Ref.make<ReadonlyArray<OrchestrationRuntimeReceipt>>([]);
379 yield* Stream.runForEach(runtimeReceiptBus.stream, (receipt) =>398 yield* Stream.runForEach(runtimeReceiptBus.streamEventsForTest, (receipt) =>
380 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),399 Ref.update(receiptHistory, (history) => [...history, receipt]).pipe(Effect.asVoid),
381 ).pipe(Effect.forkIn(scope));400 ).pipe(Effect.forkIn(scope));
382 yield* Effect.sleep(10);401 yield* Effect.sleep(10);
@@ -419,7 +438,7 @@ export const makeOrchestrationIntegrationHarness = ( · 36 unmodified lines
419 ) =>438 ) =>
420 waitFor(439 waitFor(
421 pendingApprovalRepository440 pendingApprovalRepository
422 .getByRequestId({ requestId: ApprovalRequestId.makeUnsafe(requestId) })441 .getByRequestId({ requestId: ApprovalRequestId.make(requestId) })
423 .pipe(442 .pipe(
424 Effect.map((row) =>443 Effect.map((row) =>
425 Option.match(row, {444 Option.match(row, {
apps/server/integration/TestProviderAdapter.integration.ts+3−3
@@ -289,7 +289,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 288 unmodified lines
289 289
290 state.turnCount += 1;290 state.turnCount += 1;
291 const turnCount = state.turnCount;291 const turnCount = state.turnCount;
292 const turnId = TurnId.makeUnsafe(`turn-${turnCount}`);292 const turnId = TurnId.make(`turn-${turnCount}`);
293 293
294 const response = state.queuedResponses.shift();294 const response = state.queuedResponses.shift();
295 if (!response) {295 if (!response) {
@@ -307,7 +307,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 11 unmodified lines
307 ...(fixtureEvent as Record<string, unknown>),307 ...(fixtureEvent as Record<string, unknown>),
308 eventId: randomUUID(),308 eventId: randomUUID(),
309 provider,309 provider,
310 sessionId: RuntimeSessionId.makeUnsafe(String(input.threadId)),310 sessionId: RuntimeSessionId.make(String(input.threadId)),
311 createdAt: nowIso(),311 createdAt: nowIso(),
312 };312 };
313 rawEvent.threadId = state.snapshot.threadId;313 rawEvent.threadId = state.snapshot.threadId;
@@ -363,7 +363,7 @@ export const makeTestProviderAdapterHarness = (options?: MakeTestProviderAdapter · 49 unmodified lines
363 if (deferredTurnCompletedEvents.length === 0) {363 if (deferredTurnCompletedEvents.length === 0) {
364 yield* emit({364 yield* emit({
365 type: "turn.completed",365 type: "turn.completed",
366 eventId: EventId.makeUnsafe(randomUUID()),366 eventId: EventId.make(randomUUID()),
367 provider,367 provider,
368 createdAt: nowIso(),368 createdAt: nowIso(),
369 threadId: state.snapshot.threadId,369 threadId: state.snapshot.threadId,
apps/server/integration/fixtures/providerRuntime.ts+2−2
@@ -5,14 +5,14 @@ const PROVIDER = "codex" as const; · 4 unmodified lines
5const SESSION_ID = "fixture-session";5const SESSION_ID = "fixture-session";
6const THREAD_ID = "fixture-thread";6const THREAD_ID = "fixture-thread";
7const TURN_ID = "fixture-turn";7const TURN_ID = "fixture-turn";
8const REQUEST_ID = RuntimeRequestId.makeUnsafe("req-1");8const REQUEST_ID = RuntimeRequestId.make("req-1");
9 9
10function baseEvent(10function baseEvent(
11 eventId: string,11 eventId: string,
12 createdAt: string,12 createdAt: string,
13): Pick<LegacyProviderRuntimeEvent, "eventId" | "provider" | "sessionId" | "createdAt"> {13): Pick<LegacyProviderRuntimeEvent, "eventId" | "provider" | "sessionId" | "createdAt"> {
14 return {14 return {
15 eventId: EventId.makeUnsafe(eventId),15 eventId: EventId.make(eventId),
16 provider: PROVIDER,16 provider: PROVIDER,
17 sessionId: SESSION_ID,17 sessionId: SESSION_ID,
18 createdAt,18 createdAt,
apps/server/integration/orchestrationEngine.integration.test.ts+18−19
@@ -30,14 +30,13 @@ import type { · 29 unmodified lines
30} from "../src/orchestration/Services/RuntimeReceiptBus.ts";30} from "../src/orchestration/Services/RuntimeReceiptBus.ts";
31import * as NodeServices from "@effect/platform-node/NodeServices";31import * as NodeServices from "@effect/platform-node/NodeServices";
32 32
33const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);33const asMessageId = (value: string): MessageId => MessageId.make(value);
34const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);34const asProjectId = (value: string): ProjectId => ProjectId.make(value);
35const asEventId = (value: string): EventId => EventId.makeUnsafe(value);35const asEventId = (value: string): EventId => EventId.make(value);
36const asApprovalRequestId = (value: string): ApprovalRequestId =>36const asApprovalRequestId = (value: string): ApprovalRequestId => ApprovalRequestId.make(value);
37 ApprovalRequestId.makeUnsafe(value);
38 37
39const PROJECT_ID = asProjectId("project-1");38const PROJECT_ID = asProjectId("project-1");
40const THREAD_ID = ThreadId.makeUnsafe("thread-1");39const THREAD_ID = ThreadId.make("thread-1");
41const FIXTURE_TURN_ID = "fixture-turn";40const FIXTURE_TURN_ID = "fixture-turn";
42const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");41const APPROVAL_REQUEST_ID = asApprovalRequestId("req-approval-1");
43type IntegrationProvider = ProviderKind;42type IntegrationProvider = ProviderKind;
@@ -112,7 +111,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 68 unmodified lines
112 111
113 yield* harness.engine.dispatch({112 yield* harness.engine.dispatch({
114 type: "project.create",113 type: "project.create",
115 commandId: CommandId.makeUnsafe("cmd-project-create"),114 commandId: CommandId.make("cmd-project-create"),
116 projectId: PROJECT_ID,115 projectId: PROJECT_ID,
117 title: "Integration Project",116 title: "Integration Project",
118 workspaceRoot: harness.workspaceDir,117 workspaceRoot: harness.workspaceDir,
@@ -125,7 +124,7 @@ const seedProjectAndThread = (harness: OrchestrationIntegrationHarness) => · 6 unmodified lines
125 124
126 yield* harness.engine.dispatch({125 yield* harness.engine.dispatch({
127 type: "thread.create",126 type: "thread.create",
128 commandId: CommandId.makeUnsafe("cmd-thread-create"),127 commandId: CommandId.make("cmd-thread-create"),
129 threadId: THREAD_ID,128 threadId: THREAD_ID,
130 projectId: PROJECT_ID,129 projectId: PROJECT_ID,
131 title: "Integration Thread",130 title: "Integration Thread",
@@ -150,7 +149,7 @@ const startTurn = (input: { · 18 unmodified lines
150}) =>149}) =>
151 input.harness.engine.dispatch({150 input.harness.engine.dispatch({
152 type: "thread.turn.start",151 type: "thread.turn.start",
153 commandId: CommandId.makeUnsafe(input.commandId),152 commandId: CommandId.make(input.commandId),
154 threadId: THREAD_ID,153 threadId: THREAD_ID,
155 message: {154 message: {
156 messageId: asMessageId(input.messageId),155 messageId: asMessageId(input.messageId),
@@ -261,7 +260,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 104 unmodified lines
261 260
262 yield* harness.engine.dispatch({261 yield* harness.engine.dispatch({
263 type: "project.create",262 type: "project.create",
264 commandId: CommandId.makeUnsafe("cmd-project-create-real-codex"),263 commandId: CommandId.make("cmd-project-create-real-codex"),
265 projectId: PROJECT_ID,264 projectId: PROJECT_ID,
266 title: "Integration Project",265 title: "Integration Project",
267 workspaceRoot: harness.workspaceDir,266 workspaceRoot: harness.workspaceDir,
@@ -274,7 +273,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 6 unmodified lines
274 273
275 yield* harness.engine.dispatch({274 yield* harness.engine.dispatch({
276 type: "thread.create",275 type: "thread.create",
277 commandId: CommandId.makeUnsafe("cmd-thread-create-real-codex"),276 commandId: CommandId.make("cmd-thread-create-real-codex"),
278 threadId: THREAD_ID,277 threadId: THREAD_ID,
279 projectId: PROJECT_ID,278 projectId: PROJECT_ID,
280 title: "Integration Thread",279 title: "Integration Thread",
@@ -291,7 +290,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 10 unmodified lines
291 290
292 yield* harness.engine.dispatch({291 yield* harness.engine.dispatch({
293 type: "thread.turn.start",292 type: "thread.turn.start",
294 commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-1"),293 commandId: CommandId.make("cmd-turn-start-real-codex-1"),
295 threadId: THREAD_ID,294 threadId: THREAD_ID,
296 message: {295 message: {
297 messageId: asMessageId("msg-real-codex-1"),296 messageId: asMessageId("msg-real-codex-1"),
@@ -318,7 +317,7 @@ it.live.skipIf(!process.env.CODEX_BINARY_PATH)( · 20 unmodified lines
318 317
319 yield* harness.engine.dispatch({318 yield* harness.engine.dispatch({
320 type: "thread.turn.start",319 type: "thread.turn.start",
321 commandId: CommandId.makeUnsafe("cmd-turn-start-real-codex-2"),320 commandId: CommandId.make("cmd-turn-start-real-codex-2"),
322 threadId: THREAD_ID,321 threadId: THREAD_ID,
323 message: {322 message: {
324 messageId: asMessageId("msg-real-codex-2"),323 messageId: asMessageId("msg-real-codex-2"),
@@ -583,7 +582,7 @@ it.live("tracks approval requests and resolves pending approvals on user respons · 258 unmodified lines
583 582
584 yield* harness.engine.dispatch({583 yield* harness.engine.dispatch({
585 type: "thread.approval.respond",584 type: "thread.approval.respond",
586 commandId: CommandId.makeUnsafe("cmd-approval-respond"),585 commandId: CommandId.make("cmd-approval-respond"),
587 threadId: THREAD_ID,586 threadId: THREAD_ID,
588 requestId: APPROVAL_REQUEST_ID,587 requestId: APPROVAL_REQUEST_ID,
589 decision: "accept",588 decision: "accept",
@@ -815,7 +814,7 @@ it.live("reverts to an earlier checkpoint and trims checkpoint projections + git · 225 unmodified lines
815 814
816 yield* harness.engine.dispatch({815 yield* harness.engine.dispatch({
817 type: "thread.checkpoint.revert",816 type: "thread.checkpoint.revert",
818 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert"),817 commandId: CommandId.make("cmd-checkpoint-revert"),
819 threadId: THREAD_ID,818 threadId: THREAD_ID,
820 turnCount: 1,819 turnCount: 1,
821 createdAt: nowIso(),820 createdAt: nowIso(),
@@ -875,7 +874,7 @@ it.live( · 53 unmodified lines
875 874
876 yield* harness.engine.dispatch({875 yield* harness.engine.dispatch({
877 type: "thread.checkpoint.revert",876 type: "thread.checkpoint.revert",
878 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-no-session"),877 commandId: CommandId.make("cmd-checkpoint-revert-no-session"),
879 threadId: THREAD_ID,878 threadId: THREAD_ID,
880 turnCount: 0,879 turnCount: 0,
881 createdAt: nowIso(),880 createdAt: nowIso(),
@@ -1119,7 +1118,7 @@ it.live("forwards claudeAgent approval responses to the provider session", () => · 237 unmodified lines
1119 1118
1120 yield* harness.engine.dispatch({1119 yield* harness.engine.dispatch({
1121 type: "thread.approval.respond",1120 type: "thread.approval.respond",
1122 commandId: CommandId.makeUnsafe("cmd-claude-approval-respond"),1121 commandId: CommandId.make("cmd-claude-approval-respond"),
1123 threadId: THREAD_ID,1122 threadId: THREAD_ID,
1124 requestId: APPROVAL_REQUEST_ID,1123 requestId: APPROVAL_REQUEST_ID,
1125 decision: "accept",1124 decision: "accept",
@@ -1192,7 +1191,7 @@ it.live("forwards thread.turn.interrupt to claudeAgent provider sessions", () => · 66 unmodified lines
1192 1191
1193 yield* harness.engine.dispatch({1192 yield* harness.engine.dispatch({
1194 type: "thread.turn.interrupt",1193 type: "thread.turn.interrupt",
1195 commandId: CommandId.makeUnsafe("cmd-turn-interrupt-claude"),1194 commandId: CommandId.make("cmd-turn-interrupt-claude"),
1196 threadId: THREAD_ID,1195 threadId: THREAD_ID,
1197 createdAt: nowIso(),1196 createdAt: nowIso(),
1198 });1197 });
@@ -1309,7 +1308,7 @@ it.live("reverts claudeAgent turns and rolls back provider conversation state", · 110 unmodified lines
1309 1308
1310 yield* harness.engine.dispatch({1309 yield* harness.engine.dispatch({
1311 type: "thread.checkpoint.revert",1310 type: "thread.checkpoint.revert",
1312 commandId: CommandId.makeUnsafe("cmd-checkpoint-revert-claude"),1311 commandId: CommandId.make("cmd-checkpoint-revert-claude"),
1313 threadId: THREAD_ID,1312 threadId: THREAD_ID,
1314 turnCount: 1,1313 turnCount: 1,
1315 createdAt: nowIso(),1314 createdAt: nowIso(),
apps/server/integration/providerService.integration.test.ts+29−41
@@ -86,6 +86,7 @@ const collectEventsDuring = <A, E, R>( · 85 unmodified lines
86 Effect.forkScoped,86 Effect.forkScoped,
87 );87 );
88 88
89 yield* Effect.sleep("50 millis");
89 yield* action;90 yield* action;
90 91
91 return yield* Effect.forEach(92 return yield* Effect.forEach(
@@ -104,7 +105,6 @@ const runTurn = (input: { · 12 unmodified lines
104}) =>105}) =>
105 Effect.gen(function* () {106 Effect.gen(function* () {
106 yield* input.harness.queueTurnResponse(input.threadId, input.response);107 yield* input.harness.queueTurnResponse(input.threadId, input.response);
107
108 return yield* collectEventsDuring(108 return yield* collectEventsDuring(
109 input.provider.streamEvents,109 input.provider.streamEvents,
110 input.response.events.length,110 input.response.events.length,
@@ -116,21 +116,18 @@ const runTurn = (input: { · 5 unmodified lines
116 );116 );
117 });117 });
118 118
119it.effect("replays typed runtime fixture events", () =>119it.live("replays typed runtime fixture events", () =>
120 Effect.gen(function* () {120 Effect.gen(function* () {
121 const fixture = yield* makeIntegrationFixture;121 const fixture = yield* makeIntegrationFixture;
122 122
123 yield* Effect.gen(function* () {123 yield* Effect.gen(function* () {
124 const provider = yield* ProviderService;124 const provider = yield* ProviderService;
125 const session = yield* provider.startSession(125 const session = yield* provider.startSession(ThreadId.make("thread-integration-typed"), {
126 ThreadId.makeUnsafe("thread-integration-typed"),126 threadId: ThreadId.make("thread-integration-typed"),
127 {127 provider: "codex",
128 threadId: ThreadId.makeUnsafe("thread-integration-typed"),128 cwd: fixture.cwd,
129 provider: "codex",129 runtimeMode: "full-access",
130 cwd: fixture.cwd,130 });
131 runtimeMode: "full-access",
132 },
133 );
134 assert.equal((session.threadId ?? "").length > 0, true);131 assert.equal((session.threadId ?? "").length > 0, true);
135 132
136 const observedEvents = yield* runTurn({133 const observedEvents = yield* runTurn({
@@ -149,7 +146,7 @@ it.effect("replays typed runtime fixture events", () => · 12 unmodified lines
149 }).pipe(Effect.provide(NodeServices.layer)),146 }).pipe(Effect.provide(NodeServices.layer)),
150);147);
151 148
152it.effect("replays file-changing fixture turn events", () =>149it.live("replays file-changing fixture turn events", () =>
153 Effect.gen(function* () {150 Effect.gen(function* () {
154 const fixture = yield* makeIntegrationFixture;151 const fixture = yield* makeIntegrationFixture;
155 const { join } = yield* Path.Path;152 const { join } = yield* Path.Path;
@@ -157,15 +154,12 @@ it.effect("replays file-changing fixture turn events", () => · 1 unmodified line
157 154
158 yield* Effect.gen(function* () {155 yield* Effect.gen(function* () {
159 const provider = yield* ProviderService;156 const provider = yield* ProviderService;
160 const session = yield* provider.startSession(157 const session = yield* provider.startSession(ThreadId.make("thread-integration-tools"), {
161 ThreadId.makeUnsafe("thread-integration-tools"),158 threadId: ThreadId.make("thread-integration-tools"),
162 {159 provider: "codex",
163 threadId: ThreadId.makeUnsafe("thread-integration-tools"),160 cwd: fixture.cwd,
164 provider: "codex",161 runtimeMode: "full-access",
165 cwd: fixture.cwd,162 });
166 runtimeMode: "full-access",
167 },
168 );
169 assert.equal((session.threadId ?? "").length > 0, true);163 assert.equal((session.threadId ?? "").length > 0, true);
170 164
171 const observedEvents = yield* runTurn({165 const observedEvents = yield* runTurn({
@@ -188,7 +182,7 @@ it.effect("replays file-changing fixture turn events", () => · 16 unmodified lines
188 }).pipe(Effect.provide(NodeServices.layer)),182 }).pipe(Effect.provide(NodeServices.layer)),
189);183);
190 184
191it.effect("runs multi-turn tool/approval flow", () =>185it.live("runs multi-turn tool/approval flow", () =>
192 Effect.gen(function* () {186 Effect.gen(function* () {
193 const fixture = yield* makeIntegrationFixture;187 const fixture = yield* makeIntegrationFixture;
194 const { join } = yield* Path.Path;188 const { join } = yield* Path.Path;
@@ -196,15 +190,12 @@ it.effect("runs multi-turn tool/approval flow", () => · 1 unmodified line
196 190
197 yield* Effect.gen(function* () {191 yield* Effect.gen(function* () {
198 const provider = yield* ProviderService;192 const provider = yield* ProviderService;
199 const session = yield* provider.startSession(193 const session = yield* provider.startSession(ThreadId.make("thread-integration-multi"), {
200 ThreadId.makeUnsafe("thread-integration-multi"),194 threadId: ThreadId.make("thread-integration-multi"),
201 {195 provider: "codex",
202 threadId: ThreadId.makeUnsafe("thread-integration-multi"),196 cwd: fixture.cwd,
203 provider: "codex",197 runtimeMode: "full-access",
204 cwd: fixture.cwd,198 });
205 runtimeMode: "full-access",
206 },
207 );
208 assert.equal((session.threadId ?? "").length > 0, true);199 assert.equal((session.threadId ?? "").length > 0, true);
209 200
210 const firstTurnEvents = yield* runTurn({201 const firstTurnEvents = yield* runTurn({
@@ -242,7 +233,7 @@ it.effect("runs multi-turn tool/approval flow", () => · 31 unmodified lines
242 }).pipe(Effect.provide(NodeServices.layer)),233 }).pipe(Effect.provide(NodeServices.layer)),
243);234);
244 235
245it.effect("rolls back provider conversation state only", () =>236it.live("rolls back provider conversation state only", () =>
246 Effect.gen(function* () {237 Effect.gen(function* () {
247 const fixture = yield* makeIntegrationFixture;238 const fixture = yield* makeIntegrationFixture;
248 const { join } = yield* Path.Path;239 const { join } = yield* Path.Path;
@@ -250,15 +241,12 @@ it.effect("rolls back provider conversation state only", () => · 1 unmodified line
250 241
251 yield* Effect.gen(function* () {242 yield* Effect.gen(function* () {
252 const provider = yield* ProviderService;243 const provider = yield* ProviderService;
253 const session = yield* provider.startSession(244 const session = yield* provider.startSession(ThreadId.make("thread-integration-rollback"), {
254 ThreadId.makeUnsafe("thread-integration-rollback"),245 threadId: ThreadId.make("thread-integration-rollback"),
255 {246 provider: "codex",
256 threadId: ThreadId.makeUnsafe("thread-integration-rollback"),247 cwd: fixture.cwd,
257 provider: "codex",248 runtimeMode: "full-access",
258 cwd: fixture.cwd,249 });
259 runtimeMode: "full-access",
260 },
261 );
262 assert.equal((session.threadId ?? "").length > 0, true);250 assert.equal((session.threadId ?? "").length > 0, true);
263 251
264 yield* runTurn({252 yield* runTurn({
apps/server/package.json+6−4
@@ -1,6 +1,6 @@
1{1{
2 "name": "t3",2 "name": "t3",
3 "version": "0.0.15",3 "version": "0.0.20",
4 "license": "MIT",4 "license": "MIT",
5 "repository": {5 "repository": {
6 "type": "git",6 "type": "git",
@@ -15,15 +15,17 @@ · 8 unmodified lines
15 ],15 ],
16 "type": "module",16 "type": "module",
17 "scripts": {17 "scripts": {
18 "dev": "bun run src/bin.ts",18 "dev": "node --watch src/bin.ts",
19 "build": "node scripts/cli.ts build",19 "build": "node scripts/cli.ts build",
20 "build:bundle": "tsdown",
20 "start": "node dist/bin.mjs",21 "start": "node dist/bin.mjs",
21 "prepare": "effect-language-service patch",22 "prepare": "effect-language-service patch",
22 "typecheck": "tsc --noEmit",23 "typecheck": "tsc --noEmit",
23 "test": "vitest run"24 "test": "vitest run",
25 "test:process-reaper": "vitest run src/server.test.ts src/provider/Layers/ClaudeAdapter.test.ts src/provider/Layers/ProviderSessionDirectory.test.ts src/provider/Layers/ProviderSessionReaper.test.ts src/provider/Layers/CodexAdapter.test.ts"
24 },26 },
25 "dependencies": {27 "dependencies": {
26 "@anthropic-ai/claude-agent-sdk": "^0.2.77",28 "@anthropic-ai/claude-agent-sdk": "^0.2.111",
27 "@effect/platform-bun": "catalog:",29 "@effect/platform-bun": "catalog:",
28 "@effect/platform-node": "catalog:",30 "@effect/platform-node": "catalog:",
29 "@effect/sql-sqlite-bun": "catalog:",31 "@effect/sql-sqlite-bun": "catalog:",
apps/server/scripts/cli.ts+33−16
@@ -14,6 +14,22 @@ import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog · 13 unmodified lines
14import rootPackageJson from "../../../package.json" with { type: "json" };14import rootPackageJson from "../../../package.json" with { type: "json" };
15import serverPackageJson from "../package.json" with { type: "json" };15import serverPackageJson from "../package.json" with { type: "json" };
16 16
17interface PackageJson {
18 name: string;
19 repository: {
20 type: string;
21 url: string;
22 directory: string;
23 };
24 bin: Record<string, string>;
25 type: string;
26 version: string;
27 engines: Record<string, string>;
28 files: string[];
29 dependencies: Record<string, string>;
30 overrides: Record<string, string>;
31}
32
17class CliError extends Data.TaggedError("CliError")<{33class CliError extends Data.TaggedError("CliError")<{
18 readonly message: string;34 readonly message: string;
19 readonly cause?: unknown;35 readonly cause?: unknown;
@@ -131,13 +147,13 @@ const buildCmd = Command.make( · 111 unmodified lines
131 147
132 yield* Effect.log("[cli] Running tsdown...");148 yield* Effect.log("[cli] Running tsdown...");
133 yield* runCommand(149 yield* runCommand(
134 ChildProcess.make({150 ChildProcess.make(process.execPath, ["--run", "build:bundle"], {
135 cwd: serverDir,151 cwd: serverDir,
136 stdout: config.verbose ? "inherit" : "ignore",152 stdout: config.verbose ? "inherit" : "ignore",
137 stderr: "inherit",153 stderr: "inherit",
138 // Windows needs shell mode to resolve .cmd shims (e.g. bun.cmd).154 // Windows needs shell mode to resolve `.cmd` shims on PATH.
139 shell: process.platform === "win32",155 shell: process.platform === "win32",
140 })`bun tsdown`,156 }),
141 );157 );
142 158
143 const webDist = path.join(repoRoot, "apps/web/dist");159 const webDist = path.join(repoRoot, "apps/web/dist");
@@ -177,7 +193,7 @@ const publishCmd = Command.make( · 33 unmodified lines
177 const backupPath = `${packageJsonPath}.bak`;193 const backupPath = `${packageJsonPath}.bak`;
178 194
179 // Assert build assets exist195 // Assert build assets exist
180 for (const relPath of ["dist/index.mjs", "dist/client/index.html"]) {196 for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) {
181 const abs = path.join(serverDir, relPath);197 const abs = path.join(serverDir, relPath);
182 if (!(yield* fs.exists(abs))) {198 if (!(yield* fs.exists(abs))) {
183 return yield* new CliError({199 return yield* new CliError({
@@ -187,12 +203,10 @@ const publishCmd = Command.make( · 3 unmodified lines
187 }203 }
188 204
189 yield* Effect.acquireUseRelease(205 yield* Effect.acquireUseRelease(
190 // Acquire: backup package.json, resolve catalog: deps, strip devDependencies/scripts206 // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts
191 Effect.gen(function* () {207 Effect.gen(function* () {
192 // Resolve catalog dependencies before any file mutations. If this throws,
193 // acquire fails and no release hook runs, so filesystem must still be untouched.
194 const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);208 const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version);
195 const pkg = {209 const pkg: PackageJson = {
196 name: serverPackageJson.name,210 name: serverPackageJson.name,
197 repository: serverPackageJson.repository,211 repository: serverPackageJson.repository,
198 bin: serverPackageJson.bin,212 bin: serverPackageJson.bin,
@@ -200,19 +214,22 @@ const publishCmd = Command.make( · 1 unmodified line
200 version,214 version,
201 engines: serverPackageJson.engines,215 engines: serverPackageJson.engines,
202 files: serverPackageJson.files,216 files: serverPackageJson.files,
203 dependencies: serverPackageJson.dependencies as Record<string, unknown>,217 dependencies: resolveCatalogDependencies(
218 serverPackageJson.dependencies,
219 rootPackageJson.workspaces.catalog,
220 "apps/server",
221 ),
222 overrides: resolveCatalogDependencies(
223 rootPackageJson.overrides,
224 rootPackageJson.workspaces.catalog,
225 "apps/server",
226 ),
204 };227 };
205 228
206 pkg.dependencies = resolveCatalogDependencies(
207 pkg.dependencies,
208 rootPackageJson.workspaces.catalog,
209 "apps/server dependencies",
210 );
211
212 const original = yield* fs.readFileString(packageJsonPath);229 const original = yield* fs.readFileString(packageJsonPath);
213 yield* fs.writeFileString(backupPath, original);230 yield* fs.writeFileString(backupPath, original);
214 yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);231 yield* fs.writeFileString(packageJsonPath, `${JSON.stringify(pkg, null, 2)}\n`);
215 yield* Effect.log("[cli] Resolved package.json for publish");232 yield* Effect.log("[cli] Prepared package.json for publish");
216 233
217 const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir);234 const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir);
218 return { iconBackups };235 return { iconBackups };
apps/server/src/auth/Layers/AuthControlPlane.test.ts+111−0
@@ -0,0 +1,111 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import type { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
8import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
10import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
11import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
12import { makeAuthControlPlane } from "./AuthControlPlane.ts";
13import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
14
15const makeServerConfigLayer = (
16 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
17) =>
18 Layer.effect(
19 ServerConfig,
20 Effect.gen(function* () {
21 const config = yield* ServerConfig;
22 return {
23 ...config,
24 ...overrides,
25 } satisfies ServerConfigShape;
26 }),
27 ).pipe(
28 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-control-plane-test-" })),
29 );
30
31const makeAuthControlPlaneLayer = (
32 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
33) =>
34 Layer.effect(AuthControlPlane, makeAuthControlPlane).pipe(
35 Layer.provideMerge(BootstrapCredentialServiceLive),
36 Layer.provideMerge(SessionCredentialServiceLive),
37 Layer.provideMerge(ServerSecretStoreLive),
38 Layer.provideMerge(SqlitePersistenceMemory),
39 Layer.provide(makeServerConfigLayer(overrides)),
40 );
41
42it.layer(NodeServices.layer)("AuthControlPlane", (it) => {
43 it.effect("creates, lists, and revokes client pairing links", () =>
44 Effect.gen(function* () {
45 const authControlPlane = yield* AuthControlPlane;
46
47 const created = yield* authControlPlane.createPairingLink({
48 role: "client",
49 subject: "one-time-token",
50 label: "CI phone",
51 });
52 const listedBeforeRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
53 const revoked = yield* authControlPlane.revokePairingLink(created.id);
54 const listedAfterRevoke = yield* authControlPlane.listPairingLinks({ role: "client" });
55
56 expect(created.role).toBe("client");
57 expect(created.credential.length).toBeGreaterThan(0);
58 expect(listedBeforeRevoke).toHaveLength(1);
59 expect(listedBeforeRevoke[0]?.id).toBe(created.id);
60 expect(listedBeforeRevoke[0]?.label).toBe("CI phone");
61 expect(listedBeforeRevoke[0]?.credential).toBe(created.credential);
62 expect(revoked).toBe(true);
63 expect(listedAfterRevoke).toHaveLength(0);
64 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
65 );
66
67 it.effect("issues bearer sessions and lists them without exposing raw tokens", () =>
68 Effect.gen(function* () {
69 const authControlPlane = yield* AuthControlPlane;
70 const sessionCredentials = yield* SessionCredentialService;
71
72 const issued = yield* authControlPlane.issueSession({
73 label: "deploy-bot",
74 });
75 const verified = yield* sessionCredentials.verify(issued.token);
76 const listedBeforeRevoke = yield* authControlPlane.listSessions();
77 const revoked = yield* authControlPlane.revokeSession(issued.sessionId);
78 const listedAfterRevoke = yield* authControlPlane.listSessions();
79
80 expect(issued.method).toBe("bearer-session-token");
81 expect(issued.role).toBe("owner");
82 expect(issued.client.deviceType).toBe("bot");
83 expect(issued.client.label).toBe("deploy-bot");
84 expect(verified.sessionId).toBe(issued.sessionId);
85 expect(verified.role).toBe("owner");
86 expect(verified.method).toBe("bearer-session-token");
87 expect(listedBeforeRevoke).toHaveLength(1);
88 expect(listedBeforeRevoke[0]?.sessionId).toBe(issued.sessionId);
89 expect("token" in (listedBeforeRevoke[0] ?? {})).toBe(false);
90 expect(revoked).toBe(true);
91 expect(listedAfterRevoke).toHaveLength(0);
92 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
93 );
94
95 it.effect("surfaces lastConnectedAt through the listed session view", () =>
96 Effect.gen(function* () {
97 const authControlPlane = yield* AuthControlPlane;
98 const sessionCredentials = yield* SessionCredentialService;
99
100 const issued = yield* authControlPlane.issueSession({
101 label: "remote-ipad",
102 });
103 const beforeConnect = yield* authControlPlane.listSessions();
104 yield* sessionCredentials.markConnected(issued.sessionId);
105 const afterConnect = yield* authControlPlane.listSessions();
106
107 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
108 expect(afterConnect[0]?.lastConnectedAt).not.toBeNull();
109 }).pipe(Effect.provide(makeAuthControlPlaneLayer())),
110 );
111});
apps/server/src/auth/Layers/AuthControlPlane.ts+178−0
@@ -0,0 +1,178 @@
1import type { AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import { DateTime, Effect, Layer } from "effect";
3
4import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
5import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
6import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
7import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
8import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
9import { layerConfig as SqlitePersistenceLayerLive } from "../../persistence/Layers/Sqlite.ts";
10import {
11 AuthControlPlane,
12 AuthControlPlaneError,
13 DEFAULT_SESSION_SUBJECT,
14} from "../Services/AuthControlPlane.ts";
15import type {
16 AuthControlPlaneShape,
17 IssuedBearerSession,
18 IssuedPairingLink,
19} from "../Services/AuthControlPlane.ts";
20
21const bySessionPriority = (left: AuthClientSession, right: AuthClientSession) => {
22 if (left.role !== right.role) {
23 return left.role === "owner" ? -1 : 1;
24 }
25 if (left.connected !== right.connected) {
26 return left.connected ? -1 : 1;
27 }
28 return right.issuedAt.epochMilliseconds - left.issuedAt.epochMilliseconds;
29};
30
31const toAuthControlPlaneError =
32 (message: string) =>
33 (cause: unknown): AuthControlPlaneError =>
34 new AuthControlPlaneError({
35 message,
36 cause,
37 });
38
39export const makeAuthControlPlane = Effect.gen(function* () {
40 const bootstrapCredentials = yield* BootstrapCredentialService;
41 const sessions = yield* SessionCredentialService;
42
43 const createPairingLink: AuthControlPlaneShape["createPairingLink"] = (input) =>
44 Effect.gen(function* () {
45 const createdAt = yield* DateTime.now;
46 const issued = yield* bootstrapCredentials.issueOneTimeToken({
47 role: input?.role ?? "client",
48 subject: input?.subject ?? "one-time-token",
49 ...(input?.ttl ? { ttl: input.ttl } : {}),
50 ...(input?.label ? { label: input.label } : {}),
51 });
52 return {
53 id: issued.id,
54 credential: issued.credential,
55 role: input?.role ?? "client",
56 subject: input?.subject ?? "one-time-token",
57 ...(issued.label ? { label: issued.label } : {}),
58 createdAt: DateTime.toUtc(createdAt),
59 expiresAt: DateTime.toUtc(issued.expiresAt),
60 } satisfies IssuedPairingLink;
61 }).pipe(Effect.mapError(toAuthControlPlaneError("Failed to create pairing link.")));
62
63 const listPairingLinks: AuthControlPlaneShape["listPairingLinks"] = (input) =>
64 bootstrapCredentials.listActive().pipe(
65 Effect.map((pairingLinks) =>
66 pairingLinks
67 .filter((pairingLink) => (input?.role ? pairingLink.role === input.role : true))
68 .filter((pairingLink) => !input?.excludeSubjects?.includes(pairingLink.subject))
69 .map((pairingLink) =>
70 pairingLink.label
71 ? ({
72 id: pairingLink.id,
73 credential: pairingLink.credential,
74 role: pairingLink.role,
75 subject: pairingLink.subject,
76 label: pairingLink.label,
77 createdAt: pairingLink.createdAt,
78 expiresAt: pairingLink.expiresAt,
79 } satisfies AuthPairingLink)
80 : ({
81 id: pairingLink.id,
82 credential: pairingLink.credential,
83 role: pairingLink.role,
84 subject: pairingLink.subject,
85 createdAt: pairingLink.createdAt,
86 expiresAt: pairingLink.expiresAt,
87 } satisfies AuthPairingLink),
88 )
89 .toSorted(
90 (left, right) => right.createdAt.epochMilliseconds - left.createdAt.epochMilliseconds,
91 ),
92 ),
93 Effect.mapError(toAuthControlPlaneError("Failed to list pairing links.")),
94 );
95
96 const revokePairingLink: AuthControlPlaneShape["revokePairingLink"] = (id) =>
97 bootstrapCredentials
98 .revoke(id)
99 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke pairing link.")));
100
101 const issueSession: AuthControlPlaneShape["issueSession"] = (input) =>
102 sessions
103 .issue({
104 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
105 method: "bearer-session-token",
106 role: input?.role ?? "owner",
107 client: {
108 ...(input?.label ? { label: input.label } : {}),
109 deviceType: "bot",
110 },
111 ...(input?.ttl ? { ttl: input.ttl } : {}),
112 })
113 .pipe(
114 Effect.flatMap((issued) => {
115 if (issued.method !== "bearer-session-token") {
116 return Effect.fail(
117 new AuthControlPlaneError({
118 message: "CLI session issuance produced an unexpected session method.",
119 }),
120 );
121 }
122
123 return Effect.succeed({
124 sessionId: issued.sessionId,
125 token: issued.token,
126 method: "bearer-session-token" as const,
127 role: issued.role,
128 subject: input?.subject ?? DEFAULT_SESSION_SUBJECT,
129 client: issued.client,
130 expiresAt: DateTime.toUtc(issued.expiresAt),
131 } satisfies IssuedBearerSession);
132 }),
133 Effect.mapError(toAuthControlPlaneError("Failed to issue session token.")),
134 );
135
136 const listSessions: AuthControlPlaneShape["listSessions"] = () =>
137 sessions.listActive().pipe(
138 Effect.map((activeSessions) => activeSessions.toSorted(bySessionPriority)),
139 Effect.mapError(toAuthControlPlaneError("Failed to list sessions.")),
140 );
141
142 const revokeSession: AuthControlPlaneShape["revokeSession"] = (sessionId) =>
143 sessions
144 .revoke(sessionId)
145 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke session.")));
146
147 const revokeOtherSessionsExcept: AuthControlPlaneShape["revokeOtherSessionsExcept"] = (
148 sessionId,
149 ) =>
150 sessions
151 .revokeAllExcept(sessionId)
152 .pipe(Effect.mapError(toAuthControlPlaneError("Failed to revoke other sessions.")));
153
154 return {
155 createPairingLink,
156 listPairingLinks,
157 revokePairingLink,
158 issueSession,
159 listSessions,
160 revokeSession,
161 revokeOtherSessionsExcept,
162 } satisfies AuthControlPlaneShape;
163});
164
165export const AuthCoreLive = Layer.mergeAll(
166 BootstrapCredentialServiceLive,
167 SessionCredentialServiceLive,
168);
169
170export const AuthStorageLive = Layer.mergeAll(ServerSecretStoreLive, SqlitePersistenceLayerLive);
171
172export const AuthRuntimeLive = AuthCoreLive.pipe(Layer.provideMerge(AuthStorageLive));
173
174export const AuthControlPlaneLive = Layer.effect(AuthControlPlane, makeAuthControlPlane);
175
176export const AuthControlPlaneRuntimeLive = AuthControlPlaneLive.pipe(
177 Layer.provideMerge(AuthRuntimeLive),
178);
apps/server/src/auth/Layers/BootstrapCredentialService.test.ts+151−0
@@ -0,0 +1,151 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";
4import { TestClock } from "effect/testing";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
10import { BootstrapCredentialServiceLive } from "./BootstrapCredentialService.ts";
11
12const makeServerConfigLayer = (
13 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
14) =>
15 Layer.effect(
16 ServerConfig,
17 Effect.gen(function* () {
18 const config = yield* ServerConfig;
19 return {
20 ...config,
21 ...overrides,
22 } satisfies ServerConfigShape;
23 }),
24 ).pipe(
25 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-bootstrap-test-" })),
26 );
27
28const makeBootstrapCredentialLayer = (
29 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
30) =>
31 BootstrapCredentialServiceLive.pipe(
32 Layer.provide(SqlitePersistenceMemory),
33 Layer.provide(makeServerConfigLayer(overrides)),
34 );
35
36it.layer(NodeServices.layer)("BootstrapCredentialServiceLive", (it) => {
37 it.effect("issues pairing tokens in a short manual-entry format", () =>
38 Effect.gen(function* () {
39 const bootstrapCredentials = yield* BootstrapCredentialService;
40 const issued = yield* bootstrapCredentials.issueOneTimeToken();
41
42 expect(issued.credential).toMatch(/^[23456789ABCDEFGHJKLMNPQRSTUVWXYZ]{12}$/);
43 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
44 );
45
46 it.effect("issues one-time bootstrap tokens that can only be consumed once", () =>
47 Effect.gen(function* () {
48 const bootstrapCredentials = yield* BootstrapCredentialService;
49 const issued = yield* bootstrapCredentials.issueOneTimeToken({ label: "Julius iPhone" });
50 const first = yield* bootstrapCredentials.consume(issued.credential);
51 const second = yield* Effect.flip(bootstrapCredentials.consume(issued.credential));
52
53 expect(first.method).toBe("one-time-token");
54 expect(first.role).toBe("client");
55 expect(first.subject).toBe("one-time-token");
56 expect(first.label).toBe("Julius iPhone");
57 expect(issued.label).toBe("Julius iPhone");
58 expect(second._tag).toBe("BootstrapCredentialError");
59 expect(second.message).toContain("Unknown bootstrap credential");
60 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
61 );
62
63 it.effect("atomically consumes a one-time token when multiple requests race", () =>
64 Effect.gen(function* () {
65 const bootstrapCredentials = yield* BootstrapCredentialService;
66 const token = yield* bootstrapCredentials.issueOneTimeToken();
67 const results = yield* Effect.all(
68 Array.from({ length: 8 }, () =>
69 Effect.result(bootstrapCredentials.consume(token.credential)),
70 ),
71 {
72 concurrency: "unbounded",
73 },
74 );
75
76 const successes = results.filter((result) => result._tag === "Success");
77 const failures = results.filter((result) => result._tag === "Failure");
78
79 expect(successes).toHaveLength(1);
80 expect(failures).toHaveLength(7);
81 for (const failure of failures) {
82 expect(failure.failure._tag).toBe("BootstrapCredentialError");
83 expect(failure.failure.message).toContain("Unknown bootstrap credential");
84 }
85 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
86 );
87
88 it.effect("seeds the desktop bootstrap credential as a one-time grant", () =>
89 Effect.gen(function* () {
90 const bootstrapCredentials = yield* BootstrapCredentialService;
91 const first = yield* bootstrapCredentials.consume("desktop-bootstrap-token");
92 const second = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
93
94 expect(first.method).toBe("desktop-bootstrap");
95 expect(first.role).toBe("owner");
96 expect(first.subject).toBe("desktop-bootstrap");
97 expect(second._tag).toBe("BootstrapCredentialError");
98 expect(second.status).toBe(401);
99 }).pipe(
100 Effect.provide(
101 makeBootstrapCredentialLayer({
102 desktopBootstrapToken: "desktop-bootstrap-token",
103 }),
104 ),
105 ),
106 );
107
108 it.effect("reports seeded desktop bootstrap credentials as expired after their ttl", () =>
109 Effect.gen(function* () {
110 const bootstrapCredentials = yield* BootstrapCredentialService;
111
112 yield* TestClock.adjust(Duration.minutes(6));
113 const expired = yield* Effect.flip(bootstrapCredentials.consume("desktop-bootstrap-token"));
114
115 expect(expired._tag).toBe("BootstrapCredentialError");
116 expect(expired.status).toBe(401);
117 expect(expired.message).toContain("Bootstrap credential expired");
118 }).pipe(
119 Effect.provide(
120 Layer.merge(
121 makeBootstrapCredentialLayer({
122 desktopBootstrapToken: "desktop-bootstrap-token",
123 }),
124 TestClock.layer(),
125 ),
126 ),
127 ),
128 );
129
130 it.effect("lists and revokes active pairing links", () =>
131 Effect.gen(function* () {
132 const bootstrapCredentials = yield* BootstrapCredentialService;
133 const first = yield* bootstrapCredentials.issueOneTimeToken();
134 const second = yield* bootstrapCredentials.issueOneTimeToken({ role: "owner" });
135
136 const activeBeforeRevoke = yield* bootstrapCredentials.listActive();
137 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(first.id);
138 expect(activeBeforeRevoke.map((entry) => entry.id)).toContain(second.id);
139
140 const revoked = yield* bootstrapCredentials.revoke(first.id);
141 const activeAfterRevoke = yield* bootstrapCredentials.listActive();
142 const revokedConsume = yield* Effect.flip(bootstrapCredentials.consume(first.credential));
143
144 expect(revoked).toBe(true);
145 expect(activeAfterRevoke.map((entry) => entry.id)).not.toContain(first.id);
146 expect(activeAfterRevoke.map((entry) => entry.id)).toContain(second.id);
147 expect(revokedConsume.message).toContain("no longer available");
148 expect(revokedConsume.status).toBe(401);
149 }).pipe(Effect.provide(makeBootstrapCredentialLayer())),
150 );
151});
apps/server/src/auth/Layers/BootstrapCredentialService.ts+296−0
@@ -0,0 +1,296 @@
1import type { AuthPairingLink } from "@t3tools/contracts";
2import { DateTime, Duration, Effect, Layer, PubSub, Ref, Stream } from "effect";
3import { Option } from "effect";
4
5import { ServerConfig } from "../../config.ts";
6import { AuthPairingLinkRepositoryLive } from "../../persistence/Layers/AuthPairingLinks.ts";
7import { AuthPairingLinkRepository } from "../../persistence/Services/AuthPairingLinks.ts";
8import {
9 BootstrapCredentialError,
10 BootstrapCredentialService,
11 type BootstrapCredentialChange,
12 type BootstrapCredentialServiceShape,
13 type BootstrapGrant,
14 type IssuedBootstrapCredential,
15} from "../Services/BootstrapCredentialService.ts";
16
17interface StoredBootstrapGrant extends BootstrapGrant {
18 readonly remainingUses: number | "unbounded";
19}
20
21type ConsumeResult =
22 | {
23 readonly _tag: "error";
24 readonly reason: "not-found" | "expired";
25 readonly error: BootstrapCredentialError;
26 }
27 | {
28 readonly _tag: "success";
29 readonly grant: BootstrapGrant;
30 };
31
32const DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES = Duration.minutes(5);
33const PAIRING_TOKEN_ALPHABET = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ";
34const PAIRING_TOKEN_LENGTH = 12;
35
36const generatePairingToken = (): string => {
37 const randomBytes = crypto.getRandomValues(new Uint8Array(PAIRING_TOKEN_LENGTH));
38
39 return Array.from(randomBytes, (value) => PAIRING_TOKEN_ALPHABET[value & 31]).join("");
40};
41
42export const makeBootstrapCredentialService = Effect.gen(function* () {
43 const config = yield* ServerConfig;
44 const pairingLinks = yield* AuthPairingLinkRepository;
45 const seededGrantsRef = yield* Ref.make(new Map<string, StoredBootstrapGrant>());
46 const changesPubSub = yield* PubSub.unbounded<BootstrapCredentialChange>();
47
48 const invalidBootstrapCredentialError = (message: string) =>
49 new BootstrapCredentialError({
50 message,
51 status: 401,
52 });
53
54 const internalBootstrapCredentialError = (message: string, cause: unknown) =>
55 new BootstrapCredentialError({
56 message,
57 status: 500,
58 cause,
59 });
60
61 const seedGrant = (credential: string, grant: StoredBootstrapGrant) =>
62 Ref.update(seededGrantsRef, (current) => {
63 const next = new Map(current);
64 next.set(credential, grant);
65 return next;
66 });
67
68 const emitUpsert = (pairingLink: AuthPairingLink) =>
69 PubSub.publish(changesPubSub, {
70 type: "pairingLinkUpserted",
71 pairingLink,
72 }).pipe(Effect.asVoid);
73
74 const emitRemoved = (id: string) =>
75 PubSub.publish(changesPubSub, {
76 type: "pairingLinkRemoved",
77 id,
78 }).pipe(Effect.asVoid);
79
80 if (config.desktopBootstrapToken) {
81 const now = yield* DateTime.now;
82 yield* seedGrant(config.desktopBootstrapToken, {
83 method: "desktop-bootstrap",
84 role: "owner",
85 subject: "desktop-bootstrap",
86 expiresAt: DateTime.add(now, {
87 milliseconds: Duration.toMillis(DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES),
88 }),
89 remainingUses: 1,
90 });
91 }
92
93 const toBootstrapCredentialError = (message: string) => (cause: unknown) =>
94 internalBootstrapCredentialError(message, cause);
95
96 const listActive: BootstrapCredentialServiceShape["listActive"] = () =>
97 Effect.gen(function* () {
98 const now = yield* DateTime.now;
99 const rows = yield* pairingLinks.listActive({ now });
100
101 return rows.map((row) =>
102 row.label
103 ? ({
104 id: row.id,
105 credential: row.credential,
106 role: row.role,
107 subject: row.subject,
108 label: row.label,
109 createdAt: row.createdAt,
110 expiresAt: row.expiresAt,
111 } satisfies AuthPairingLink)
112 : ({
113 id: row.id,
114 credential: row.credential,
115 role: row.role,
116 subject: row.subject,
117 createdAt: row.createdAt,
118 expiresAt: row.expiresAt,
119 } satisfies AuthPairingLink),
120 );
121 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to load active pairing links.")));
122
123 const revoke: BootstrapCredentialServiceShape["revoke"] = (id) =>
124 Effect.gen(function* () {
125 const revokedAt = yield* DateTime.now;
126 const revoked = yield* pairingLinks.revoke({
127 id,
128 revokedAt,
129 });
130 if (revoked) {
131 yield* emitRemoved(id);
132 }
133 return revoked;
134 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to revoke pairing link.")));
135
136 const issueOneTimeToken: BootstrapCredentialServiceShape["issueOneTimeToken"] = (input) =>
137 Effect.gen(function* () {
138 const id = crypto.randomUUID();
139 const credential = generatePairingToken();
140 const ttl = input?.ttl ?? DEFAULT_ONE_TIME_TOKEN_TTL_MINUTES;
141 const now = yield* DateTime.now;
142 const expiresAt = DateTime.add(now, { milliseconds: Duration.toMillis(ttl) });
143 const issued: IssuedBootstrapCredential = {
144 id,
145 credential,
146 ...(input?.label ? { label: input.label } : {}),
147 expiresAt,
148 };
149 yield* pairingLinks.create({
150 id,
151 credential,
152 method: "one-time-token",
153 role: input?.role ?? "client",
154 subject: input?.subject ?? "one-time-token",
155 label: input?.label ?? null,
156 createdAt: now,
157 expiresAt: expiresAt,
158 });
159 yield* emitUpsert({
160 id,
161 credential,
162 role: input?.role ?? "client",
163 subject: input?.subject ?? "one-time-token",
164 ...(input?.label ? { label: input.label } : {}),
165 createdAt: now,
166 expiresAt,
167 });
168 return issued;
169 }).pipe(Effect.mapError(toBootstrapCredentialError("Failed to issue pairing credential.")));
170
171 const consume: BootstrapCredentialServiceShape["consume"] = (credential) =>
172 Effect.gen(function* () {
173 const now = yield* DateTime.now;
174 const seededResult: ConsumeResult = yield* Ref.modify(
175 seededGrantsRef,
176 (current): readonly [ConsumeResult, Map<string, StoredBootstrapGrant>] => {
177 const grant = current.get(credential);
178 if (!grant) {
179 return [
180 {
181 _tag: "error",
182 reason: "not-found",
183 error: invalidBootstrapCredentialError("Unknown bootstrap credential."),
184 },
185 current,
186 ];
187 }
188
189 const next = new Map(current);
190 if (DateTime.isGreaterThanOrEqualTo(now, grant.expiresAt)) {
191 next.delete(credential);
192 return [
193 {
194 _tag: "error",
195 reason: "expired",
196 error: invalidBootstrapCredentialError("Bootstrap credential expired."),
197 },
198 next,
199 ];
200 }
201
202 const remainingUses = grant.remainingUses;
203 if (typeof remainingUses === "number") {
204 if (remainingUses <= 1) {
205 next.delete(credential);
206 } else {
207 next.set(credential, {
208 ...grant,
209 remainingUses: remainingUses - 1,
210 });
211 }
212 }
213
214 return [
215 {
216 _tag: "success",
217 grant: {
218 method: grant.method,
219 role: grant.role,
220 subject: grant.subject,
221 ...(grant.label ? { label: grant.label } : {}),
222 expiresAt: grant.expiresAt,
223 } satisfies BootstrapGrant,
224 },
225 next,
226 ];
227 },
228 );
229
230 if (seededResult._tag === "success") {
231 return seededResult.grant;
232 }
233 if (seededResult.reason !== "not-found") {
234 return yield* seededResult.error;
235 }
236
237 const consumed = yield* pairingLinks.consumeAvailable({
238 credential,
239 consumedAt: now,
240 now,
241 });
242
243 if (Option.isSome(consumed)) {
244 yield* emitRemoved(consumed.value.id);
245 return {
246 method: consumed.value.method,
247 role: consumed.value.role,
248 subject: consumed.value.subject,
249 ...(consumed.value.label ? { label: consumed.value.label } : {}),
250 expiresAt: consumed.value.expiresAt,
251 } satisfies BootstrapGrant;
252 }
253
254 const matching = yield* pairingLinks.getByCredential({ credential });
255 if (Option.isNone(matching)) {
256 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
257 }
258
259 if (matching.value.revokedAt !== null) {
260 return yield* invalidBootstrapCredentialError(
261 "Bootstrap credential is no longer available.",
262 );
263 }
264
265 if (matching.value.consumedAt !== null) {
266 return yield* invalidBootstrapCredentialError("Unknown bootstrap credential.");
267 }
268
269 if (DateTime.isGreaterThanOrEqualTo(now, matching.value.expiresAt)) {
270 return yield* invalidBootstrapCredentialError("Bootstrap credential expired.");
271 }
272
273 return yield* invalidBootstrapCredentialError("Bootstrap credential is no longer available.");
274 }).pipe(
275 Effect.mapError((cause) =>
276 cause instanceof BootstrapCredentialError
277 ? cause
278 : internalBootstrapCredentialError("Failed to consume bootstrap credential.", cause),
279 ),
280 );
281
282 return {
283 issueOneTimeToken,
284 listActive,
285 get streamChanges() {
286 return Stream.fromPubSub(changesPubSub);
287 },
288 revoke,
289 consume,
290 } satisfies BootstrapCredentialServiceShape;
291});
292
293export const BootstrapCredentialServiceLive = Layer.effect(
294 BootstrapCredentialService,
295 makeBootstrapCredentialService,
296).pipe(Layer.provideMerge(AuthPairingLinkRepositoryLive));
apps/server/src/auth/Layers/ServerAuth.test.ts+182−0
@@ -0,0 +1,182 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import type { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
8import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
9import { ServerAuth, type ServerAuthShape } from "../Services/ServerAuth.ts";
10import { ServerAuthLive, toBootstrapExchangeAuthError } from "./ServerAuth.ts";
11import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
12
13const makeServerConfigLayer = (overrides?: Partial<ServerConfigShape>) =>
14 Layer.effect(
15 ServerConfig,
16 Effect.gen(function* () {
17 const config = yield* ServerConfig;
18 return {
19 ...config,
20 ...overrides,
21 } satisfies ServerConfigShape;
22 }),
23 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-server-test-" })));
24
25const makeServerAuthLayer = (overrides?: Partial<ServerConfigShape>) =>
26 ServerAuthLive.pipe(
27 Layer.provide(SqlitePersistenceMemory),
28 Layer.provide(ServerSecretStoreLive),
29 Layer.provide(makeServerConfigLayer(overrides)),
30 );
31
32const makeCookieRequest = (
33 sessionToken: string,
34): Parameters<ServerAuthShape["authenticateHttpRequest"]>[0] =>
35 ({
36 cookies: {
37 t3_session: sessionToken,
38 },
39 headers: {},
40 }) as unknown as Parameters<ServerAuthShape["authenticateHttpRequest"]>[0];
41
42const requestMetadata = {
43 deviceType: "desktop" as const,
44 os: "macOS",
45 browser: "Chrome",
46 ipAddress: "192.168.1.23",
47};
48
49it.layer(NodeServices.layer)("ServerAuthLive", (it) => {
50 it.effect("maps invalid bootstrap credential failures to 401", () =>
51 Effect.sync(() => {
52 const error = toBootstrapExchangeAuthError(
53 new BootstrapCredentialError({
54 message: "Unknown bootstrap credential.",
55 status: 401,
56 }),
57 );
58
59 expect(error.status).toBe(401);
60 expect(error.message).toBe("Invalid bootstrap credential.");
61 }),
62 );
63
64 it.effect("maps unexpected bootstrap failures to 500", () =>
65 Effect.sync(() => {
66 const error = toBootstrapExchangeAuthError(
67 new BootstrapCredentialError({
68 message: "Failed to consume bootstrap credential.",
69 status: 500,
70 cause: new Error("sqlite is unavailable"),
71 }),
72 );
73
74 expect(error.status).toBe(500);
75 expect(error.message).toBe("Failed to validate bootstrap credential.");
76 }),
77 );
78
79 it.effect("issues client pairing credentials by default", () =>
80 Effect.gen(function* () {
81 const serverAuth = yield* ServerAuth;
82
83 const pairingCredential = yield* serverAuth.issuePairingCredential();
84 const exchanged = yield* serverAuth.exchangeBootstrapCredential(
85 pairingCredential.credential,
86 requestMetadata,
87 );
88 const verified = yield* serverAuth.authenticateHttpRequest(
89 makeCookieRequest(exchanged.sessionToken),
90 );
91
92 expect(verified.sessionId.length).toBeGreaterThan(0);
93 expect(verified.role).toBe("client");
94 expect(verified.subject).toBe("one-time-token");
95 }).pipe(Effect.provide(makeServerAuthLayer())),
96 );
97
98 it.effect("issues startup pairing URLs that bootstrap owner sessions", () =>
99 Effect.gen(function* () {
100 const serverAuth = yield* ServerAuth;
101
102 const pairingUrl = yield* serverAuth.issueStartupPairingUrl("http://127.0.0.1:3773");
103 const token = new URLSearchParams(new URL(pairingUrl).hash.slice(1)).get("token");
104 const listedPairingLinks = yield* serverAuth.listPairingLinks();
105 expect(token).toBeTruthy();
106 expect(
107 listedPairingLinks.some((pairingLink) => pairingLink.subject === "owner-bootstrap"),
108 ).toBe(false);
109
110 const exchanged = yield* serverAuth.exchangeBootstrapCredential(token ?? "", requestMetadata);
111 const verified = yield* serverAuth.authenticateHttpRequest(
112 makeCookieRequest(exchanged.sessionToken),
113 );
114
115 expect(verified.role).toBe("owner");
116 expect(verified.subject).toBe("owner-bootstrap");
117 }).pipe(Effect.provide(makeServerAuthLayer())),
118 );
119
120 it.effect("lists pairing links and revokes other client sessions while keeping the owner", () =>
121 Effect.gen(function* () {
122 const serverAuth = yield* ServerAuth;
123
124 const ownerExchange = yield* serverAuth.exchangeBootstrapCredential(
125 "desktop-bootstrap-token",
126 requestMetadata,
127 );
128 const ownerSession = yield* serverAuth.authenticateHttpRequest(
129 makeCookieRequest(ownerExchange.sessionToken),
130 );
131 const pairingCredential = yield* serverAuth.issuePairingCredential({
132 label: "Julius iPhone",
133 });
134 const listedPairingLinks = yield* serverAuth.listPairingLinks();
135 const clientExchange = yield* serverAuth.exchangeBootstrapCredential(
136 pairingCredential.credential,
137 {
138 ...requestMetadata,
139 deviceType: "mobile",
140 os: "iOS",
141 browser: "Safari",
142 ipAddress: "192.168.1.88",
143 },
144 );
145 const clientSession = yield* serverAuth.authenticateHttpRequest(
146 makeCookieRequest(clientExchange.sessionToken),
147 );
148 const clientsBeforeRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
149 const revokedCount = yield* serverAuth.revokeOtherClientSessions(ownerSession.sessionId);
150 const clientsAfterRevoke = yield* serverAuth.listClientSessions(ownerSession.sessionId);
151
152 expect(listedPairingLinks.map((entry) => entry.id)).toContain(pairingCredential.id);
153 expect(listedPairingLinks.find((entry) => entry.id === pairingCredential.id)?.label).toBe(
154 "Julius iPhone",
155 );
156 expect(clientsBeforeRevoke).toHaveLength(2);
157 expect(
158 clientsBeforeRevoke.find((entry) => entry.sessionId === ownerSession.sessionId)?.current,
159 ).toBe(true);
160 expect(
161 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.current,
162 ).toBe(false);
163 expect(
164 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
165 .label,
166 ).toBe("Julius iPhone");
167 expect(
168 clientsBeforeRevoke.find((entry) => entry.sessionId === clientSession.sessionId)?.client
169 .deviceType,
170 ).toBe("mobile");
171 expect(revokedCount).toBe(1);
172 expect(clientsAfterRevoke).toHaveLength(1);
173 expect(clientsAfterRevoke[0]?.sessionId).toBe(ownerSession.sessionId);
174 }).pipe(
175 Effect.provide(
176 makeServerAuthLayer({
177 desktopBootstrapToken: "desktop-bootstrap-token",
178 }),
179 ),
180 ),
181 );
182});
apps/server/src/auth/Layers/ServerAuth.ts+395−0
@@ -0,0 +1,395 @@
1import {
2 type AuthBearerBootstrapResult,
3 type AuthClientSession,
4 type AuthBootstrapResult,
5 type AuthPairingCredentialResult,
6 type AuthSessionState,
7 type AuthWebSocketTokenResult,
8} from "@t3tools/contracts";
9import { DateTime, Effect, Layer, Option } from "effect";
10import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
11
12import { AuthControlPlane } from "../Services/AuthControlPlane.ts";
13import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
14import { BootstrapCredentialService } from "../Services/BootstrapCredentialService.ts";
15import { BootstrapCredentialError } from "../Services/BootstrapCredentialService.ts";
16import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
17import {
18 ServerAuth,
19 type AuthenticatedSession,
20 AuthError,
21 type ServerAuthShape,
22} from "../Services/ServerAuth.ts";
23import {
24 SessionCredentialError,
25 SessionCredentialService,
26} from "../Services/SessionCredentialService.ts";
27import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts";
28
29type BootstrapExchangeResult = {
30 readonly response: AuthBootstrapResult;
31 readonly sessionToken: string;
32};
33
34const AUTHORIZATION_PREFIX = "Bearer ";
35const WEBSOCKET_TOKEN_QUERY_PARAM = "wsToken";
36
37export function toBootstrapExchangeAuthError(cause: BootstrapCredentialError): AuthError {
38 if (cause.status === 500) {
39 return new AuthError({
40 message: "Failed to validate bootstrap credential.",
41 status: 500,
42 cause,
43 });
44 }
45
46 return new AuthError({
47 message: "Invalid bootstrap credential.",
48 status: 401,
49 cause,
50 });
51}
52
53function parseBearerToken(request: HttpServerRequest.HttpServerRequest): string | null {
54 const header = request.headers["authorization"];
55 if (typeof header !== "string" || !header.startsWith(AUTHORIZATION_PREFIX)) {
56 return null;
57 }
58 const token = header.slice(AUTHORIZATION_PREFIX.length).trim();
59 return token.length > 0 ? token : null;
60}
61
62export const makeServerAuth = Effect.gen(function* () {
63 const policy = yield* ServerAuthPolicy;
64 const bootstrapCredentials = yield* BootstrapCredentialService;
65 const authControlPlane = yield* AuthControlPlane;
66 const sessions = yield* SessionCredentialService;
67 const descriptor = yield* policy.getDescriptor();
68
69 const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> =>
70 sessions.verify(token).pipe(
71 Effect.tapError((cause: SessionCredentialError) =>
72 Effect.logWarning("Rejected authenticated session credential.").pipe(
73 Effect.annotateLogs({
74 reason: cause.message,
75 }),
76 ),
77 ),
78 Effect.map((session) => ({
79 sessionId: session.sessionId,
80 subject: session.subject,
81 method: session.method,
82 role: session.role,
83 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
84 })),
85 Effect.mapError(
86 (cause) =>
87 new AuthError({
88 message: "Unauthorized request.",
89 status: 401,
90 cause,
91 }),
92 ),
93 );
94
95 const authenticateRequest = (request: HttpServerRequest.HttpServerRequest) => {
96 const cookieToken = request.cookies[sessions.cookieName];
97 const bearerToken = parseBearerToken(request);
98 const credential = cookieToken ?? bearerToken;
99 if (!credential) {
100 return Effect.fail(
101 new AuthError({
102 message: "Authentication required.",
103 status: 401,
104 }),
105 );
106 }
107 return authenticateToken(credential);
108 };
109
110 const getSessionState: ServerAuthShape["getSessionState"] = (request) =>
111 authenticateRequest(request).pipe(
112 Effect.map(
113 (session) =>
114 ({
115 authenticated: true,
116 auth: descriptor,
117 role: session.role,
118 sessionMethod: session.method,
119 ...(session.expiresAt ? { expiresAt: DateTime.toUtc(session.expiresAt) } : {}),
120 }) satisfies AuthSessionState,
121 ),
122 Effect.catchTag("AuthError", () =>
123 Effect.succeed({
124 authenticated: false,
125 auth: descriptor,
126 } satisfies AuthSessionState),
127 ),
128 );
129
130 const exchangeBootstrapCredential: ServerAuthShape["exchangeBootstrapCredential"] = (
131 credential,
132 requestMetadata,
133 ) =>
134 bootstrapCredentials.consume(credential).pipe(
135 Effect.mapError(toBootstrapExchangeAuthError),
136 Effect.flatMap((grant) =>
137 sessions
138 .issue({
139 method: "browser-session-cookie",
140 subject: grant.subject,
141 role: grant.role,
142 client: {
143 ...requestMetadata,
144 ...(grant.label ? { label: grant.label } : {}),
145 },
146 })
147 .pipe(
148 Effect.mapError(
149 (cause) =>
150 new AuthError({
151 message: "Failed to issue authenticated session.",
152 cause,
153 }),
154 ),
155 ),
156 ),
157 Effect.map(
158 (session) =>
159 ({
160 response: {
161 authenticated: true,
162 role: session.role,
163 sessionMethod: session.method,
164 expiresAt: DateTime.toUtc(session.expiresAt),
165 } satisfies AuthBootstrapResult,
166 sessionToken: session.token,
167 }) satisfies BootstrapExchangeResult,
168 ),
169 );
170
171 const exchangeBootstrapCredentialForBearerSession: ServerAuthShape["exchangeBootstrapCredentialForBearerSession"] =
172 (credential, requestMetadata) =>
173 bootstrapCredentials.consume(credential).pipe(
174 Effect.mapError(toBootstrapExchangeAuthError),
175 Effect.flatMap((grant) =>
176 sessions
177 .issue({
178 method: "bearer-session-token",
179 subject: grant.subject,
180 role: grant.role,
181 client: {
182 ...requestMetadata,
183 ...(grant.label ? { label: grant.label } : {}),
184 },
185 })
186 .pipe(
187 Effect.mapError(
188 (cause) =>
189 new AuthError({
190 message: "Failed to issue authenticated session.",
191 cause,
192 }),
193 ),
194 ),
195 ),
196 Effect.map(
197 (session) =>
198 ({
199 authenticated: true,
200 role: session.role,
201 sessionMethod: "bearer-session-token",
202 expiresAt: DateTime.toUtc(session.expiresAt),
203 sessionToken: session.token,
204 }) satisfies AuthBearerBootstrapResult,
205 ),
206 );
207
208 const issuePairingCredential: ServerAuthShape["issuePairingCredential"] = (input) =>
209 authControlPlane
210 .createPairingLink({
211 role: input?.role ?? "client",
212 subject: input?.role === "owner" ? "owner-bootstrap" : "one-time-token",
213 ...(input?.label ? { label: input.label } : {}),
214 })
215 .pipe(
216 Effect.mapError(
217 (cause) =>
218 new AuthError({
219 message: "Failed to issue pairing credential.",
220 cause,
221 }),
222 ),
223 Effect.map(
224 (issued) =>
225 ({
226 id: issued.id,
227 credential: issued.credential,
228 ...(issued.label ? { label: issued.label } : {}),
229 expiresAt: issued.expiresAt,
230 }) satisfies AuthPairingCredentialResult,
231 ),
232 );
233
234 const listPairingLinks: ServerAuthShape["listPairingLinks"] = () =>
235 authControlPlane
236 .listPairingLinks({
237 role: "client",
238 excludeSubjects: ["owner-bootstrap"],
239 })
240 .pipe(
241 Effect.mapError(
242 (cause) =>
243 new AuthError({
244 message: "Failed to load pairing links.",
245 cause,
246 }),
247 ),
248 );
249
250 const revokePairingLink: ServerAuthShape["revokePairingLink"] = (id) =>
251 authControlPlane.revokePairingLink(id).pipe(
252 Effect.mapError(
253 (cause) =>
254 new AuthError({
255 message: "Failed to revoke pairing link.",
256 cause,
257 }),
258 ),
259 );
260
261 const listClientSessions: ServerAuthShape["listClientSessions"] = (currentSessionId) =>
262 authControlPlane.listSessions().pipe(
263 Effect.mapError(
264 (cause) =>
265 new AuthError({
266 message: "Failed to load paired clients.",
267 cause,
268 }),
269 ),
270 Effect.map((clientSessions) =>
271 clientSessions.map(
272 (clientSession): AuthClientSession => ({
273 ...clientSession,
274 current: clientSession.sessionId === currentSessionId,
275 }),
276 ),
277 ),
278 );
279
280 const revokeClientSession: ServerAuthShape["revokeClientSession"] = (
281 currentSessionId,
282 targetSessionId,
283 ) =>
284 Effect.gen(function* () {
285 if (currentSessionId === targetSessionId) {
286 return yield* new AuthError({
287 message: "Use revoke other clients to keep the current owner session active.",
288 status: 403,
289 });
290 }
291 return yield* authControlPlane.revokeSession(targetSessionId).pipe(
292 Effect.mapError(
293 (cause) =>
294 new AuthError({
295 message: "Failed to revoke client session.",
296 cause,
297 }),
298 ),
299 );
300 });
301
302 const revokeOtherClientSessions: ServerAuthShape["revokeOtherClientSessions"] = (
303 currentSessionId,
304 ) =>
305 authControlPlane.revokeOtherSessionsExcept(currentSessionId).pipe(
306 Effect.mapError(
307 (cause) =>
308 new AuthError({
309 message: "Failed to revoke other client sessions.",
310 cause,
311 }),
312 ),
313 );
314
315 const issueStartupPairingUrl: ServerAuthShape["issueStartupPairingUrl"] = (baseUrl) =>
316 issuePairingCredential({ role: "owner" }).pipe(
317 Effect.map((issued) => {
318 const url = new URL(baseUrl);
319 url.pathname = "/pair";
320 url.searchParams.delete("token");
321 url.hash = new URLSearchParams([["token", issued.credential]]).toString();
322 return url.toString();
323 }),
324 );
325
326 const issueWebSocketToken: ServerAuthShape["issueWebSocketToken"] = (session) =>
327 sessions.issueWebSocketToken(session.sessionId).pipe(
328 Effect.mapError(
329 (cause) =>
330 new AuthError({
331 message: "Failed to issue websocket token.",
332 cause,
333 }),
334 ),
335 Effect.map(
336 (issued) =>
337 ({
338 token: issued.token,
339 expiresAt: DateTime.toUtc(issued.expiresAt),
340 }) satisfies AuthWebSocketTokenResult,
341 ),
342 );
343
344 const authenticateWebSocketUpgrade: ServerAuthShape["authenticateWebSocketUpgrade"] = (request) =>
345 Effect.gen(function* () {
346 const requestUrl = HttpServerRequest.toURL(request);
347 if (Option.isSome(requestUrl)) {
348 const websocketToken = requestUrl.value.searchParams.get(WEBSOCKET_TOKEN_QUERY_PARAM);
349 if (websocketToken && websocketToken.trim().length > 0) {
350 return yield* sessions.verifyWebSocketToken(websocketToken).pipe(
351 Effect.map((session) => ({
352 sessionId: session.sessionId,
353 subject: session.subject,
354 method: session.method,
355 role: session.role,
356 ...(session.expiresAt ? { expiresAt: session.expiresAt } : {}),
357 })),
358 Effect.mapError(
359 (cause) =>
360 new AuthError({
361 message: "Unauthorized request.",
362 status: 401,
363 cause,
364 }),
365 ),
366 );
367 }
368 }
369
370 return yield* authenticateRequest(request);
371 });
372
373 return {
374 getDescriptor: () => Effect.succeed(descriptor),
375 getSessionState,
376 exchangeBootstrapCredential,
377 exchangeBootstrapCredentialForBearerSession,
378 issuePairingCredential,
379 listPairingLinks,
380 revokePairingLink,
381 listClientSessions,
382 revokeClientSession,
383 revokeOtherClientSessions,
384 authenticateHttpRequest: authenticateRequest,
385 authenticateWebSocketUpgrade,
386 issueWebSocketToken,
387 issueStartupPairingUrl,
388 } satisfies ServerAuthShape;
389});
390
391export const ServerAuthLive = Layer.effect(ServerAuth, makeServerAuth).pipe(
392 Layer.provideMerge(AuthControlPlaneLive),
393 Layer.provideMerge(AuthCoreLive),
394 Layer.provideMerge(ServerAuthPolicyLive),
395);
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts+114−0
@@ -0,0 +1,114 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Effect, Layer } from "effect";
4
5import type { ServerConfigShape } from "../../config.ts";
6import { ServerConfig } from "../../config.ts";
7import { ServerAuthPolicy } from "../Services/ServerAuthPolicy.ts";
8import { ServerAuthPolicyLive } from "./ServerAuthPolicy.ts";
9
10const makeServerAuthPolicyLayer = (overrides?: Partial<ServerConfigShape>) =>
11 ServerAuthPolicyLive.pipe(
12 Layer.provide(
13 Layer.effect(
14 ServerConfig,
15 Effect.gen(function* () {
16 const config = yield* ServerConfig;
17 return {
18 ...config,
19 ...overrides,
20 } satisfies ServerConfigShape;
21 }),
22 ).pipe(
23 Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-policy-test-" })),
24 ),
25 ),
26 );
27
28it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => {
29 it.effect("uses desktop-managed-local policy for desktop mode", () =>
30 Effect.gen(function* () {
31 const policy = yield* ServerAuthPolicy;
32 const descriptor = yield* policy.getDescriptor();
33
34 expect(descriptor.policy).toBe("desktop-managed-local");
35 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]);
36 expect(descriptor.sessionCookieName).toBe("t3_session_3773");
37 }).pipe(
38 Effect.provide(
39 makeServerAuthPolicyLayer({
40 mode: "desktop",
41 port: 3773,
42 }),
43 ),
44 ),
45 );
46
47 it.effect("uses remote-reachable policy for desktop mode when bound beyond loopback", () =>
48 Effect.gen(function* () {
49 const policy = yield* ServerAuthPolicy;
50 const descriptor = yield* policy.getDescriptor();
51
52 expect(descriptor.policy).toBe("remote-reachable");
53 expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap", "one-time-token"]);
54 }).pipe(
55 Effect.provide(
56 makeServerAuthPolicyLayer({
57 mode: "desktop",
58 host: "0.0.0.0",
59 }),
60 ),
61 ),
62 );
63
64 it.effect("uses loopback-browser policy for loopback web hosts", () =>
65 Effect.gen(function* () {
66 const policy = yield* ServerAuthPolicy;
67 const descriptor = yield* policy.getDescriptor();
68
69 expect(descriptor.policy).toBe("loopback-browser");
70 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
71 expect(descriptor.sessionCookieName).toBe("t3_session");
72 }).pipe(
73 Effect.provide(
74 makeServerAuthPolicyLayer({
75 mode: "web",
76 host: "127.0.0.1",
77 }),
78 ),
79 ),
80 );
81
82 it.effect("uses remote-reachable policy for wildcard web hosts", () =>
83 Effect.gen(function* () {
84 const policy = yield* ServerAuthPolicy;
85 const descriptor = yield* policy.getDescriptor();
86
87 expect(descriptor.policy).toBe("remote-reachable");
88 expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]);
89 }).pipe(
90 Effect.provide(
91 makeServerAuthPolicyLayer({
92 mode: "web",
93 host: "0.0.0.0",
94 }),
95 ),
96 ),
97 );
98
99 it.effect("uses remote-reachable policy for non-loopback web hosts", () =>
100 Effect.gen(function* () {
101 const policy = yield* ServerAuthPolicy;
102 const descriptor = yield* policy.getDescriptor();
103
104 expect(descriptor.policy).toBe("remote-reachable");
105 }).pipe(
106 Effect.provide(
107 makeServerAuthPolicyLayer({
108 mode: "web",
109 host: "192.168.1.50",
110 }),
111 ),
112 ),
113 );
114});
apps/server/src/auth/Layers/ServerAuthPolicy.ts+44−0
@@ -0,0 +1,44 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import { Effect, Layer } from "effect";
3
4import { ServerConfig } from "../../config.ts";
5import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts";
6import { resolveSessionCookieName } from "../utils.ts";
7import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts";
8
9export const makeServerAuthPolicy = Effect.gen(function* () {
10 const config = yield* ServerConfig;
11 const isRemoteReachable = isWildcardHost(config.host) || !isLoopbackHost(config.host);
12
13 const policy =
14 config.mode === "desktop"
15 ? isRemoteReachable
16 ? "remote-reachable"
17 : "desktop-managed-local"
18 : isRemoteReachable
19 ? "remote-reachable"
20 : "loopback-browser";
21
22 const bootstrapMethods: ServerAuthDescriptor["bootstrapMethods"] =
23 policy === "desktop-managed-local"
24 ? ["desktop-bootstrap"]
25 : config.mode === "desktop" && policy === "remote-reachable"
26 ? ["desktop-bootstrap", "one-time-token"]
27 : ["one-time-token"];
28
29 const descriptor: ServerAuthDescriptor = {
30 policy,
31 bootstrapMethods,
32 sessionMethods: ["browser-session-cookie", "bearer-session-token"],
33 sessionCookieName: resolveSessionCookieName({
34 mode: config.mode,
35 port: config.port,
36 }),
37 };
38
39 return {
40 getDescriptor: () => Effect.succeed(descriptor),
41 } satisfies ServerAuthPolicyShape;
42});
43
44export const ServerAuthPolicyLive = Layer.effect(ServerAuthPolicy, makeServerAuthPolicy);
apps/server/src/auth/Layers/ServerSecretStore.test.ts+263−0
@@ -0,0 +1,263 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Cause, Deferred, Effect, FileSystem, Layer, Ref } from "effect";
4import * as PlatformError from "effect/PlatformError";
5
6import { ServerConfig } from "../../config.ts";
7import { SecretStoreError, ServerSecretStore } from "../Services/ServerSecretStore.ts";
8import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
9
10const makeServerConfigLayer = () =>
11 ServerConfig.layerTest(process.cwd(), { prefix: "t3-secret-store-test-" });
12
13const makeServerSecretStoreLayer = () =>
14 Layer.provide(ServerSecretStoreLive, makeServerConfigLayer());
15
16const PermissionDeniedFileSystemLayer = Layer.effect(
17 FileSystem.FileSystem,
18 Effect.gen(function* () {
19 const fileSystem = yield* FileSystem.FileSystem;
20
21 return {
22 ...fileSystem,
23 readFile: (path) =>
24 Effect.fail(
25 PlatformError.systemError({
26 _tag: "PermissionDenied",
27 module: "FileSystem",
28 method: "readFile",
29 pathOrDescriptor: path,
30 description: "Permission denied while reading secret file.",
31 }),
32 ),
33 } satisfies FileSystem.FileSystem;
34 }),
35).pipe(Layer.provide(NodeServices.layer));
36
37const makePermissionDeniedSecretStoreLayer = () =>
38 ServerSecretStoreLive.pipe(
39 Layer.provide(makeServerConfigLayer()),
40 Layer.provideMerge(PermissionDeniedFileSystemLayer),
41 );
42
43const RenameFailureFileSystemLayer = Layer.effect(
44 FileSystem.FileSystem,
45 Effect.gen(function* () {
46 const fileSystem = yield* FileSystem.FileSystem;
47
48 return {
49 ...fileSystem,
50 rename: (from, to) =>
51 Effect.fail(
52 PlatformError.systemError({
53 _tag: "PermissionDenied",
54 module: "FileSystem",
55 method: "rename",
56 pathOrDescriptor: `${String(from)} -> ${String(to)}`,
57 description: "Permission denied while persisting secret file.",
58 }),
59 ),
60 } satisfies FileSystem.FileSystem;
61 }),
62).pipe(Layer.provide(NodeServices.layer));
63
64const makeRenameFailureSecretStoreLayer = () =>
65 ServerSecretStoreLive.pipe(
66 Layer.provide(makeServerConfigLayer()),
67 Layer.provideMerge(RenameFailureFileSystemLayer),
68 );
69
70const RemoveFailureFileSystemLayer = Layer.effect(
71 FileSystem.FileSystem,
72 Effect.gen(function* () {
73 const fileSystem = yield* FileSystem.FileSystem;
74
75 return {
76 ...fileSystem,
77 remove: (path, options) =>
78 Effect.fail(
79 PlatformError.systemError({
80 _tag: "PermissionDenied",
81 module: "FileSystem",
82 method: "remove",
83 pathOrDescriptor: String(path),
84 description: `Permission denied while removing secret file.${options ? " options-set" : ""}`,
85 }),
86 ),
87 } satisfies FileSystem.FileSystem;
88 }),
89).pipe(Layer.provide(NodeServices.layer));
90
91const makeRemoveFailureSecretStoreLayer = () =>
92 ServerSecretStoreLive.pipe(
93 Layer.provide(makeServerConfigLayer()),
94 Layer.provideMerge(RemoveFailureFileSystemLayer),
95 );
96
97const ConcurrentReadMissFileSystemLayer = Layer.effect(
98 FileSystem.FileSystem,
99 Effect.gen(function* () {
100 const fileSystem = yield* FileSystem.FileSystem;
101 const readCountRef = yield* Ref.make(0);
102 const readBarrier = yield* Deferred.make<void>();
103
104 return {
105 ...fileSystem,
106 readFile: (path) =>
107 String(path).endsWith("/session-signing-key.bin")
108 ? Ref.updateAndGet(readCountRef, (count) => count + 1).pipe(
109 Effect.flatMap((count) => {
110 if (count > 2) {
111 return fileSystem.readFile(path);
112 }
113 return Effect.gen(function* () {
114 if (count === 2) {
115 yield* Deferred.succeed(readBarrier, void 0);
116 }
117 yield* Deferred.await(readBarrier);
118 return yield* Effect.failCause(
119 Cause.fail(
120 PlatformError.systemError({
121 _tag: "NotFound",
122 module: "FileSystem",
123 method: "readFile",
124 pathOrDescriptor: String(path),
125 description: "Secret file does not exist yet.",
126 }),
127 ),
128 );
129 });
130 }),
131 )
132 : fileSystem.readFile(path),
133 } satisfies FileSystem.FileSystem;
134 }),
135).pipe(Layer.provide(NodeServices.layer));
136
137const makeConcurrentCreateSecretStoreLayer = () =>
138 ServerSecretStoreLive.pipe(
139 Layer.provide(makeServerConfigLayer()),
140 Layer.provideMerge(ConcurrentReadMissFileSystemLayer),
141 );
142
143it.layer(NodeServices.layer)("ServerSecretStoreLive", (it) => {
144 it.effect("returns null when a secret file does not exist", () =>
145 Effect.gen(function* () {
146 const secretStore = yield* ServerSecretStore;
147
148 const secret = yield* secretStore.get("missing-secret");
149
150 expect(secret).toBeNull();
151 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
152 );
153
154 it.effect("reuses an existing secret instead of regenerating it", () =>
155 Effect.gen(function* () {
156 const secretStore = yield* ServerSecretStore;
157
158 const first = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
159 const second = yield* secretStore.getOrCreateRandom("session-signing-key", 32);
160
161 expect(Array.from(second)).toEqual(Array.from(first));
162 }).pipe(Effect.provide(makeServerSecretStoreLayer())),
163 );
164
165 it.effect("returns the persisted secret when concurrent creators race", () =>
166 Effect.gen(function* () {
167 const secretStore = yield* ServerSecretStore;
168
169 const [first, second] = yield* Effect.all(
170 [
171 secretStore.getOrCreateRandom("session-signing-key", 32),
172 secretStore.getOrCreateRandom("session-signing-key", 32),
173 ],
174 { concurrency: "unbounded" },
175 );
176 const persisted = yield* secretStore.get("session-signing-key");
177
178 expect(persisted).not.toBeNull();
179 expect(Array.from(first)).toEqual(Array.from(persisted ?? new Uint8Array()));
180 expect(Array.from(second)).toEqual(Array.from(persisted ?? new Uint8Array()));
181 }).pipe(Effect.provide(makeConcurrentCreateSecretStoreLayer())),
182 );
183
184 it.effect("uses restrictive permissions for the secret directory and files", () =>
185 Effect.gen(function* () {
186 const chmodCalls: Array<{ readonly path: string; readonly mode: number }> = [];
187 const recordingFileSystemLayer = Layer.effect(
188 FileSystem.FileSystem,
189 Effect.gen(function* () {
190 const fileSystem = yield* FileSystem.FileSystem;
191
192 return {
193 ...fileSystem,
194 makeDirectory: () => Effect.void,
195 writeFile: () => Effect.void,
196 rename: () => Effect.void,
197 chmod: (path, mode) =>
198 Effect.sync(() => {
199 chmodCalls.push({ path: String(path), mode });
200 }),
201 } satisfies FileSystem.FileSystem;
202 }),
203 ).pipe(Layer.provide(NodeServices.layer));
204
205 const secretStore = yield* Effect.service(ServerSecretStore).pipe(
206 Effect.provide(
207 ServerSecretStoreLive.pipe(
208 Layer.provide(makeServerConfigLayer()),
209 Layer.provideMerge(recordingFileSystemLayer),
210 ),
211 ),
212 );
213
214 yield* secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3]));
215
216 expect(chmodCalls.some((call) => call.mode === 0o700 && call.path.endsWith("/secrets"))).toBe(
217 true,
218 );
219 expect(chmodCalls.filter((call) => call.mode === 0o600).length).toBeGreaterThanOrEqual(2);
220 }).pipe(Effect.provide(NodeServices.layer)),
221 );
222
223 it.effect("propagates read failures other than missing-file errors", () =>
224 Effect.gen(function* () {
225 const secretStore = yield* ServerSecretStore;
226
227 const error = yield* Effect.flip(secretStore.getOrCreateRandom("session-signing-key", 32));
228
229 expect(error).toBeInstanceOf(SecretStoreError);
230 expect(error.message).toContain("Failed to read secret session-signing-key.");
231 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
232 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
233 }).pipe(Effect.provide(makePermissionDeniedSecretStoreLayer())),
234 );
235
236 it.effect("propagates write failures instead of treating them as success", () =>
237 Effect.gen(function* () {
238 const secretStore = yield* ServerSecretStore;
239
240 const error = yield* Effect.flip(
241 secretStore.set("session-signing-key", Uint8Array.from([1, 2, 3])),
242 );
243
244 expect(error).toBeInstanceOf(SecretStoreError);
245 expect(error.message).toContain("Failed to persist secret session-signing-key.");
246 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
247 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
248 }).pipe(Effect.provide(makeRenameFailureSecretStoreLayer())),
249 );
250
251 it.effect("propagates remove failures other than missing-file errors", () =>
252 Effect.gen(function* () {
253 const secretStore = yield* ServerSecretStore;
254
255 const error = yield* Effect.flip(secretStore.remove("session-signing-key"));
256
257 expect(error).toBeInstanceOf(SecretStoreError);
258 expect(error.message).toContain("Failed to remove secret session-signing-key.");
259 expect(error.cause).toBeInstanceOf(PlatformError.PlatformError);
260 expect((error.cause as PlatformError.PlatformError).reason._tag).toBe("PermissionDenied");
261 }).pipe(Effect.provide(makeRemoveFailureSecretStoreLayer())),
262 );
263});
apps/server/src/auth/Layers/ServerSecretStore.ts+148−0
@@ -0,0 +1,148 @@
1import * as Crypto from "node:crypto";
2
3import { Effect, FileSystem, Layer, Path, Predicate } from "effect";
4import * as PlatformError from "effect/PlatformError";
5
6import { ServerConfig } from "../../config.ts";
7import {
8 SecretStoreError,
9 ServerSecretStore,
10 type ServerSecretStoreShape,
11} from "../Services/ServerSecretStore.ts";
12
13export const makeServerSecretStore = Effect.gen(function* () {
14 const fileSystem = yield* FileSystem.FileSystem;
15 const path = yield* Path.Path;
16 const serverConfig = yield* ServerConfig;
17
18 yield* fileSystem.makeDirectory(serverConfig.secretsDir, { recursive: true });
19 yield* fileSystem.chmod(serverConfig.secretsDir, 0o700).pipe(
20 Effect.mapError(
21 (cause) =>
22 new SecretStoreError({
23 message: `Failed to secure secrets directory ${serverConfig.secretsDir}.`,
24 cause,
25 }),
26 ),
27 );
28
29 const resolveSecretPath = (name: string) => path.join(serverConfig.secretsDir, `${name}.bin`);
30
31 const isPlatformError = (u: unknown): u is PlatformError.PlatformError =>
32 Predicate.isTagged(u, "PlatformError");
33
34 const get: ServerSecretStoreShape["get"] = (name) =>
35 fileSystem.readFile(resolveSecretPath(name)).pipe(
36 Effect.map((bytes) => Uint8Array.from(bytes)),
37 Effect.catch((cause) =>
38 cause.reason._tag === "NotFound"
39 ? Effect.succeed(null)
40 : Effect.fail(
41 new SecretStoreError({
42 message: `Failed to read secret ${name}.`,
43 cause,
44 }),
45 ),
46 ),
47 );
48
49 const set: ServerSecretStoreShape["set"] = (name, value) => {
50 const secretPath = resolveSecretPath(name);
51 const tempPath = `${secretPath}.${Crypto.randomUUID()}.tmp`;
52 return Effect.gen(function* () {
53 yield* fileSystem.writeFile(tempPath, value);
54 yield* fileSystem.chmod(tempPath, 0o600);
55 yield* fileSystem.rename(tempPath, secretPath);
56 yield* fileSystem.chmod(secretPath, 0o600);
57 }).pipe(
58 Effect.catch((cause) =>
59 fileSystem.remove(tempPath).pipe(
60 Effect.ignore,
61 Effect.flatMap(() =>
62 Effect.fail(
63 new SecretStoreError({
64 message: `Failed to persist secret ${name}.`,
65 cause,
66 }),
67 ),
68 ),
69 ),
70 ),
71 );
72 };
73
74 const create: ServerSecretStoreShape["set"] = (name, value) => {
75 const secretPath = resolveSecretPath(name);
76 return Effect.scoped(
77 Effect.gen(function* () {
78 const file = yield* fileSystem.open(secretPath, {
79 flag: "wx",
80 mode: 0o600,
81 });
82 yield* file.writeAll(value);
83 yield* file.sync;
84 yield* fileSystem.chmod(secretPath, 0o600);
85 }),
86 ).pipe(
87 Effect.mapError(
88 (cause) =>
89 new SecretStoreError({
90 message: `Failed to persist secret ${name}.`,
91 cause,
92 }),
93 ),
94 );
95 };
96
97 const getOrCreateRandom: ServerSecretStoreShape["getOrCreateRandom"] = (name, bytes) =>
98 get(name).pipe(
99 Effect.flatMap((existing) => {
100 if (existing) {
101 return Effect.succeed(existing);
102 }
103
104 const generated = Crypto.randomBytes(bytes);
105 return create(name, generated).pipe(
106 Effect.as(Uint8Array.from(generated)),
107 Effect.catchTag("SecretStoreError", (error) =>
108 isPlatformError(error.cause) && error.cause.reason._tag === "AlreadyExists"
109 ? get(name).pipe(
110 Effect.flatMap((created) =>
111 created !== null
112 ? Effect.succeed(created)
113 : Effect.fail(
114 new SecretStoreError({
115 message: `Failed to read secret ${name} after concurrent creation.`,
116 }),
117 ),
118 ),
119 )
120 : Effect.fail(error),
121 ),
122 );
123 }),
124 );
125
126 const remove: ServerSecretStoreShape["remove"] = (name) =>
127 fileSystem.remove(resolveSecretPath(name)).pipe(
128 Effect.catch((cause) =>
129 cause.reason._tag === "NotFound"
130 ? Effect.void
131 : Effect.fail(
132 new SecretStoreError({
133 message: `Failed to remove secret ${name}.`,
134 cause,
135 }),
136 ),
137 ),
138 );
139
140 return {
141 get,
142 set,
143 getOrCreateRandom,
144 remove,
145 } satisfies ServerSecretStoreShape;
146});
147
148export const ServerSecretStoreLive = Layer.effect(ServerSecretStore, makeServerSecretStore);
apps/server/src/auth/Layers/SessionCredentialService.test.ts+191−0
@@ -0,0 +1,191 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { Duration, Effect, Layer } from "effect";
4import { TestClock } from "effect/testing";
5
6import type { ServerConfigShape } from "../../config.ts";
7import { ServerConfig } from "../../config.ts";
8import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
9import { SessionCredentialService } from "../Services/SessionCredentialService.ts";
10import { ServerSecretStoreLive } from "./ServerSecretStore.ts";
11import { SessionCredentialServiceLive } from "./SessionCredentialService.ts";
12
13const makeServerConfigLayer = (
14 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
15) =>
16 Layer.effect(
17 ServerConfig,
18 Effect.gen(function* () {
19 const config = yield* ServerConfig;
20 return {
21 ...config,
22 ...overrides,
23 } satisfies ServerConfigShape;
24 }),
25 ).pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), { prefix: "t3-auth-session-test-" })));
26
27const makeSessionCredentialLayer = (
28 overrides?: Partial<Pick<ServerConfigShape, "desktopBootstrapToken">>,
29) =>
30 SessionCredentialServiceLive.pipe(
31 Layer.provide(SqlitePersistenceMemory),
32 Layer.provide(ServerSecretStoreLive),
33 Layer.provide(makeServerConfigLayer(overrides)),
34 );
35
36it.layer(NodeServices.layer)("SessionCredentialServiceLive", (it) => {
37 it.effect("issues and verifies signed browser session tokens", () =>
38 Effect.gen(function* () {
39 const sessions = yield* SessionCredentialService;
40 const issued = yield* sessions.issue({
41 subject: "desktop-bootstrap",
42 role: "owner",
43 client: {
44 label: "Desktop app",
45 deviceType: "desktop",
46 os: "macOS",
47 browser: "Electron",
48 ipAddress: "127.0.0.1",
49 },
50 });
51 const verified = yield* sessions.verify(issued.token);
52
53 expect(verified.method).toBe("browser-session-cookie");
54 expect(verified.subject).toBe("desktop-bootstrap");
55 expect(verified.role).toBe("owner");
56 expect(verified.client.label).toBe("Desktop app");
57 expect(verified.client.browser).toBe("Electron");
58 expect(verified.expiresAt?.toString()).toBe(issued.expiresAt.toString());
59 }).pipe(Effect.provide(makeSessionCredentialLayer())),
60 );
61 it.effect("rejects malformed session tokens", () =>
62 Effect.gen(function* () {
63 const sessions = yield* SessionCredentialService;
64 const error = yield* Effect.flip(sessions.verify("not-a-session-token"));
65
66 expect(error._tag).toBe("SessionCredentialError");
67 expect(error.message).toContain("Malformed session token");
68 }).pipe(Effect.provide(makeSessionCredentialLayer())),
69 );
70 it.effect("verifies session tokens against the Effect clock", () =>
71 Effect.gen(function* () {
72 const sessions = yield* SessionCredentialService;
73 const issued = yield* sessions.issue({
74 method: "bearer-session-token",
75 subject: "test-clock",
76 });
77 const verified = yield* sessions.verify(issued.token);
78
79 expect(verified.method).toBe("bearer-session-token");
80 expect(verified.subject).toBe("test-clock");
81 expect(verified.role).toBe("client");
82 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
83 );
84
85 it.effect("rejects websocket tokens once the parent session has expired", () =>
86 Effect.gen(function* () {
87 const sessions = yield* SessionCredentialService;
88 const issued = yield* sessions.issue({
89 method: "bearer-session-token",
90 subject: "short-lived",
91 ttl: Duration.seconds(1),
92 });
93 const websocket = yield* sessions.issueWebSocketToken(issued.sessionId);
94
95 yield* TestClock.adjust(Duration.seconds(2));
96
97 const error = yield* Effect.flip(sessions.verifyWebSocketToken(websocket.token));
98 expect(error.message).toContain("expired");
99 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
100 );
101
102 it.effect("lists active sessions, tracks connectivity, and revokes other sessions", () =>
103 Effect.gen(function* () {
104 const sessions = yield* SessionCredentialService;
105 const owner = yield* sessions.issue({
106 subject: "desktop-bootstrap",
107 role: "owner",
108 client: {
109 label: "Desktop app",
110 deviceType: "desktop",
111 os: "macOS",
112 browser: "Electron",
113 },
114 });
115 const client = yield* sessions.issue({
116 subject: "one-time-token",
117 role: "client",
118 client: {
119 label: "Julius iPhone",
120 deviceType: "mobile",
121 os: "iOS",
122 browser: "Safari",
123 ipAddress: "192.168.1.88",
124 },
125 });
126
127 yield* sessions.markConnected(client.sessionId);
128 const beforeRevoke = yield* sessions.listActive();
129 const revokedCount = yield* sessions.revokeAllExcept(owner.sessionId);
130 const afterRevoke = yield* sessions.listActive();
131 const revokedClient = yield* Effect.flip(sessions.verify(client.token));
132
133 expect(beforeRevoke).toHaveLength(2);
134 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.connected).toBe(
135 true,
136 );
137 expect(beforeRevoke.find((entry) => entry.sessionId === client.sessionId)?.client.label).toBe(
138 "Julius iPhone",
139 );
140 expect(
141 beforeRevoke.find((entry) => entry.sessionId === owner.sessionId)?.client.deviceType,
142 ).toBe("desktop");
143 expect(revokedCount).toBe(1);
144 expect(afterRevoke).toHaveLength(1);
145 expect(afterRevoke[0]?.sessionId).toBe(owner.sessionId);
146 expect(revokedClient.message).toContain("revoked");
147 }).pipe(Effect.provide(makeSessionCredentialLayer())),
148 );
149
150 it.effect("persists lastConnectedAt on first connect and updates it after reconnect", () =>
151 Effect.gen(function* () {
152 const sessions = yield* SessionCredentialService;
153 const issued = yield* sessions.issue({
154 subject: "reconnect-test",
155 method: "bearer-session-token",
156 });
157
158 const beforeConnect = yield* sessions.listActive();
159 expect(beforeConnect[0]?.lastConnectedAt).toBeNull();
160
161 yield* TestClock.adjust(Duration.seconds(1));
162 yield* sessions.markConnected(issued.sessionId);
163 const firstConnect = yield* sessions.listActive();
164 const firstConnectedAt = firstConnect[0]?.lastConnectedAt;
165
166 expect(firstConnect[0]?.connected).toBe(true);
167 expect(firstConnectedAt).not.toBeNull();
168
169 yield* TestClock.adjust(Duration.seconds(1));
170 yield* sessions.markConnected(issued.sessionId);
171 const stillConnected = yield* sessions.listActive();
172
173 expect(stillConnected[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
174
175 yield* sessions.markDisconnected(issued.sessionId);
176 yield* sessions.markDisconnected(issued.sessionId);
177 const afterDisconnect = yield* sessions.listActive();
178
179 expect(afterDisconnect[0]?.connected).toBe(false);
180 expect(afterDisconnect[0]?.lastConnectedAt?.toString()).toBe(firstConnectedAt?.toString());
181
182 yield* TestClock.adjust(Duration.seconds(1));
183 yield* sessions.markConnected(issued.sessionId);
184 const afterReconnect = yield* sessions.listActive();
185
186 expect(afterReconnect[0]?.connected).toBe(true);
187 expect(afterReconnect[0]?.lastConnectedAt).not.toBeNull();
188 expect(afterReconnect[0]?.lastConnectedAt?.toString()).not.toBe(firstConnectedAt?.toString());
189 }).pipe(Effect.provide(Layer.merge(makeSessionCredentialLayer(), TestClock.layer()))),
190 );
191});
apps/server/src/auth/Layers/SessionCredentialService.ts+500−0
@@ -0,0 +1,500 @@
1import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from "@t3tools/contracts";
2import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect";
3import { Option } from "effect";
4
5import { ServerConfig } from "../../config.ts";
6import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts";
7import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts";
8import { ServerSecretStore } from "../Services/ServerSecretStore.ts";
9import {
10 SessionCredentialError,
11 SessionCredentialService,
12 type IssuedSession,
13 type SessionCredentialChange,
14 type SessionCredentialServiceShape,
15 type VerifiedSession,
16} from "../Services/SessionCredentialService.ts";
17import {
18 base64UrlDecodeUtf8,
19 base64UrlEncode,
20 resolveSessionCookieName,
21 signPayload,
22 timingSafeEqualBase64Url,
23} from "../utils.ts";
24
25const SIGNING_SECRET_NAME = "server-signing-key";
26const DEFAULT_SESSION_TTL = Duration.days(30);
27const DEFAULT_WEBSOCKET_TOKEN_TTL = Duration.minutes(5);
28
29const SessionClaims = Schema.Struct({
30 v: Schema.Literal(1),
31 kind: Schema.Literal("session"),
32 sid: AuthSessionId,
33 sub: Schema.String,
34 role: Schema.Literals(["owner", "client"]),
35 method: Schema.Literals(["browser-session-cookie", "bearer-session-token"]),
36 iat: Schema.Number,
37 exp: Schema.Number,
38});
39type SessionClaims = typeof SessionClaims.Type;
40
41const WebSocketClaims = Schema.Struct({
42 v: Schema.Literal(1),
43 kind: Schema.Literal("websocket"),
44 sid: AuthSessionId,
45 iat: Schema.Number,
46 exp: Schema.Number,
47});
48type WebSocketClaims = typeof WebSocketClaims.Type;
49
50const decodeSessionClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(SessionClaims));
51const decodeWebSocketClaims = Schema.decodeUnknownEffect(Schema.fromJsonString(WebSocketClaims));
52
53function createDefaultClientMetadata(): AuthClientMetadata {
54 return {
55 deviceType: "unknown",
56 };
57}
58
59function toClientMetadata(record: {
60 readonly label: string | null;
61 readonly ipAddress: string | null;
62 readonly userAgent: string | null;
63 readonly deviceType: AuthClientMetadata["deviceType"];
64 readonly os: string | null;
65 readonly browser: string | null;
66}): AuthClientMetadata {
67 return {
68 ...(record.label ? { label: record.label } : {}),
69 ...(record.ipAddress ? { ipAddress: record.ipAddress } : {}),
70 ...(record.userAgent ? { userAgent: record.userAgent } : {}),
71 deviceType: record.deviceType,
72 ...(record.os ? { os: record.os } : {}),
73 ...(record.browser ? { browser: record.browser } : {}),
74 };
75}
76
77function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthClientSession {
78 return {
79 ...input,
80 current: false,
81 };
82}
83
84export const makeSessionCredentialService = Effect.gen(function* () {
85 const serverConfig = yield* ServerConfig;
86 const secretStore = yield* ServerSecretStore;
87 const authSessions = yield* AuthSessionRepository;
88 const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32);
89 const connectedSessionsRef = yield* Ref.make(new Map<string, number>());
90 const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>();
91 const cookieName = resolveSessionCookieName({
92 mode: serverConfig.mode,
93 port: serverConfig.port,
94 });
95
96 const toSessionCredentialError = (message: string) => (cause: unknown) =>
97 new SessionCredentialError({
98 message,
99 cause,
100 });
101
102 const emitUpsert = (clientSession: AuthClientSession) =>
103 PubSub.publish(changesPubSub, {
104 type: "clientUpserted",
105 clientSession,
106 }).pipe(Effect.asVoid);
107
108 const emitRemoved = (sessionId: AuthSessionId) =>
109 PubSub.publish(changesPubSub, {
110 type: "clientRemoved",
111 sessionId,
112 }).pipe(Effect.asVoid);
113
114 const loadActiveSession = (sessionId: AuthSessionId) =>
115 Effect.gen(function* () {
116 const row = yield* authSessions.getById({ sessionId });
117 if (Option.isNone(row) || row.value.revokedAt !== null) {
118 return Option.none<AuthClientSession>();
119 }
120
121 const connectedSessions = yield* Ref.get(connectedSessionsRef);
122 return Option.some(
123 toAuthClientSession({
124 sessionId: row.value.sessionId,
125 subject: row.value.subject,
126 role: row.value.role,
127 method: row.value.method,
128 client: toClientMetadata(row.value.client),
129 issuedAt: row.value.issuedAt,
130 expiresAt: row.value.expiresAt,
131 lastConnectedAt: row.value.lastConnectedAt,
132 connected: connectedSessions.has(row.value.sessionId),
133 }),
134 );
135 });
136
137 const markConnected: SessionCredentialServiceShape["markConnected"] = (sessionId) =>
138 Ref.modify(connectedSessionsRef, (current) => {
139 const next = new Map(current);
140 const wasDisconnected = !next.has(sessionId);
141 next.set(sessionId, (next.get(sessionId) ?? 0) + 1);
142 return [wasDisconnected, next] as const;
143 }).pipe(
144 Effect.flatMap((wasDisconnected) =>
145 wasDisconnected
146 ? DateTime.now.pipe(
147 Effect.flatMap((lastConnectedAt) =>
148 authSessions.setLastConnectedAt({
149 sessionId,
150 lastConnectedAt,
151 }),
152 ),
153 )
154 : Effect.void,
155 ),
156 Effect.flatMap(() => loadActiveSession(sessionId)),
157 Effect.flatMap((session) =>
158 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
159 ),
160 Effect.catchCause((cause) =>
161 Effect.logError("Failed to publish connected-session auth update.").pipe(
162 Effect.annotateLogs({
163 sessionId,
164 cause,
165 }),
166 ),
167 ),
168 );
169
170 const markDisconnected: SessionCredentialServiceShape["markDisconnected"] = (sessionId) =>
171 Ref.update(connectedSessionsRef, (current) => {
172 const next = new Map(current);
173 const remaining = (next.get(sessionId) ?? 0) - 1;
174 if (remaining > 0) {
175 next.set(sessionId, remaining);
176 } else {
177 next.delete(sessionId);
178 }
179 return next;
180 }).pipe(
181 Effect.flatMap(() => loadActiveSession(sessionId)),
182 Effect.flatMap((session) =>
183 Option.isSome(session) ? emitUpsert(session.value) : Effect.void,
184 ),
185 Effect.catchCause((cause) =>
186 Effect.logError("Failed to publish disconnected-session auth update.").pipe(
187 Effect.annotateLogs({
188 sessionId,
189 cause,
190 }),
191 ),
192 ),
193 );
194
195 const issue: SessionCredentialServiceShape["issue"] = (input) =>
196 Effect.gen(function* () {
197 const sessionId = AuthSessionId.make(crypto.randomUUID());
198 const issuedAt = yield* DateTime.now;
199 const expiresAt = DateTime.add(issuedAt, {
200 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_SESSION_TTL),
201 });
202 const claims: SessionClaims = {
203 v: 1,
204 kind: "session",
205 sid: sessionId,
206 sub: input?.subject ?? "browser",
207 role: input?.role ?? "client",
208 method: input?.method ?? "browser-session-cookie",
209 iat: issuedAt.epochMilliseconds,
210 exp: expiresAt.epochMilliseconds,
211 };
212 const encodedPayload = base64UrlEncode(JSON.stringify(claims));
213 const signature = signPayload(encodedPayload, signingSecret);
214 const client = input?.client ?? createDefaultClientMetadata();
215 yield* authSessions.create({
216 sessionId,
217 subject: claims.sub,
218 role: claims.role,
219 method: claims.method,
220 client: {
221 label: client.label ?? null,
222 ipAddress: client.ipAddress ?? null,
223 userAgent: client.userAgent ?? null,
224 deviceType: client.deviceType,
225 os: client.os ?? null,
226 browser: client.browser ?? null,
227 },
228 issuedAt,
229 expiresAt,
230 });
231 yield* emitUpsert(
232 toAuthClientSession({
233 sessionId,
234 subject: claims.sub,
235 role: claims.role,
236 method: claims.method,
237 client,
238 issuedAt,
239 expiresAt,
240 lastConnectedAt: null,
241 connected: false,
242 }),
243 );
244
245 return {
246 sessionId,
247 token: `${encodedPayload}.${signature}`,
248 method: claims.method,
249 client,
250 expiresAt: expiresAt,
251 role: claims.role,
252 } satisfies IssuedSession;
253 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue session credential.")));
254
255 const verify: SessionCredentialServiceShape["verify"] = (token) =>
256 Effect.gen(function* () {
257 const [encodedPayload, signature] = token.split(".");
258 if (!encodedPayload || !signature) {
259 return yield* new SessionCredentialError({
260 message: "Malformed session token.",
261 });
262 }
263
264 const expectedSignature = signPayload(encodedPayload, signingSecret);
265 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
266 return yield* new SessionCredentialError({
267 message: "Invalid session token signature.",
268 });
269 }
270
271 const claims = yield* decodeSessionClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
272 Effect.mapError(
273 (cause) =>
274 new SessionCredentialError({
275 message: "Invalid session token payload.",
276 cause,
277 }),
278 ),
279 );
280
281 const now = yield* Clock.currentTimeMillis;
282 if (claims.exp <= now) {
283 return yield* new SessionCredentialError({
284 message: "Session token expired.",
285 });
286 }
287
288 const row = yield* authSessions.getById({ sessionId: claims.sid });
289 if (Option.isNone(row)) {
290 return yield* new SessionCredentialError({
291 message: "Unknown session token.",
292 });
293 }
294 if (row.value.revokedAt !== null) {
295 return yield* new SessionCredentialError({
296 message: "Session token revoked.",
297 });
298 }
299
300 return {
301 sessionId: claims.sid,
302 token,
303 method: claims.method,
304 client: toClientMetadata(row.value.client),
305 expiresAt: DateTime.makeUnsafe(claims.exp),
306 subject: claims.sub,
307 role: claims.role,
308 } satisfies VerifiedSession;
309 }).pipe(
310 Effect.mapError((cause) =>
311 cause instanceof SessionCredentialError
312 ? cause
313 : new SessionCredentialError({
314 message: "Failed to verify session credential.",
315 cause,
316 }),
317 ),
318 );
319
320 const issueWebSocketToken: SessionCredentialServiceShape["issueWebSocketToken"] = (
321 sessionId,
322 input,
323 ) =>
324 Effect.gen(function* () {
325 const issuedAt = yield* DateTime.now;
326 const expiresAt = DateTime.add(issuedAt, {
327 milliseconds: Duration.toMillis(input?.ttl ?? DEFAULT_WEBSOCKET_TOKEN_TTL),
328 });
329 const claims: WebSocketClaims = {
330 v: 1,
331 kind: "websocket",
332 sid: sessionId,
333 iat: issuedAt.epochMilliseconds,
334 exp: expiresAt.epochMilliseconds,
335 };
336 const encodedPayload = base64UrlEncode(JSON.stringify(claims));
337 const signature = signPayload(encodedPayload, signingSecret);
338 return {
339 token: `${encodedPayload}.${signature}`,
340 expiresAt,
341 };
342 }).pipe(Effect.mapError(toSessionCredentialError("Failed to issue websocket token.")));
343
344 const verifyWebSocketToken: SessionCredentialServiceShape["verifyWebSocketToken"] = (token) =>
345 Effect.gen(function* () {
346 const [encodedPayload, signature] = token.split(".");
347 if (!encodedPayload || !signature) {
348 return yield* new SessionCredentialError({
349 message: "Malformed websocket token.",
350 });
351 }
352
353 const expectedSignature = signPayload(encodedPayload, signingSecret);
354 if (!timingSafeEqualBase64Url(signature, expectedSignature)) {
355 return yield* new SessionCredentialError({
356 message: "Invalid websocket token signature.",
357 });
358 }
359
360 const claims = yield* decodeWebSocketClaims(base64UrlDecodeUtf8(encodedPayload)).pipe(
361 Effect.mapError(
362 (cause) =>
363 new SessionCredentialError({
364 message: "Invalid websocket token payload.",
365 cause,
366 }),
367 ),
368 );
369
370 const now = yield* Clock.currentTimeMillis;
371 if (claims.exp <= now) {
372 return yield* new SessionCredentialError({
373 message: "Websocket token expired.",
374 });
375 }
376
377 const row = yield* authSessions.getById({ sessionId: claims.sid });
378 if (Option.isNone(row)) {
379 return yield* new SessionCredentialError({
380 message: "Unknown websocket session.",
381 });
382 }
383 if (row.value.expiresAt.epochMilliseconds <= now) {
384 return yield* new SessionCredentialError({
385 message: "Websocket session expired.",
386 });
387 }
388 if (row.value.revokedAt !== null) {
389 return yield* new SessionCredentialError({
390 message: "Websocket session revoked.",
391 });
392 }
393
394 return {
395 sessionId: row.value.sessionId,
396 token,
397 method: row.value.method,
398 client: toClientMetadata(row.value.client),
399 expiresAt: row.value.expiresAt,
400 subject: row.value.subject,
401 role: row.value.role,
402 } satisfies VerifiedSession;
403 }).pipe(
404 Effect.mapError((cause) =>
405 cause instanceof SessionCredentialError
406 ? cause
407 : new SessionCredentialError({
408 message: "Failed to verify websocket token.",
409 cause,
410 }),
411 ),
412 );
413
414 const listActive: SessionCredentialServiceShape["listActive"] = () =>
415 Effect.gen(function* () {
416 const now = yield* DateTime.now;
417 const connectedSessions = yield* Ref.get(connectedSessionsRef);
418 const rows = yield* authSessions.listActive({ now });
419
420 return rows.map((row) =>
421 toAuthClientSession({
422 sessionId: row.sessionId,
423 subject: row.subject,
424 role: row.role,
425 method: row.method,
426 client: toClientMetadata(row.client),
427 issuedAt: row.issuedAt,
428 expiresAt: row.expiresAt,
429 lastConnectedAt: row.lastConnectedAt,
430 connected: connectedSessions.has(row.sessionId),
431 }),
432 );
433 }).pipe(Effect.mapError(toSessionCredentialError("Failed to list active sessions.")));
434
435 const revoke: SessionCredentialServiceShape["revoke"] = (sessionId) =>
436 Effect.gen(function* () {
437 const revokedAt = yield* DateTime.now;
438 const revoked = yield* authSessions.revoke({
439 sessionId,
440 revokedAt,
441 });
442 if (revoked) {
443 yield* Ref.update(connectedSessionsRef, (current) => {
444 const next = new Map(current);
445 next.delete(sessionId);
446 return next;
447 });
448 yield* emitRemoved(sessionId);
449 }
450 return revoked;
451 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke session.")));
452
453 const revokeAllExcept: SessionCredentialServiceShape["revokeAllExcept"] = (sessionId) =>
454 Effect.gen(function* () {
455 const revokedAt = yield* DateTime.now;
456 const revokedSessionIds = yield* authSessions.revokeAllExcept({
457 currentSessionId: sessionId,
458 revokedAt,
459 });
460 if (revokedSessionIds.length > 0) {
461 yield* Ref.update(connectedSessionsRef, (current) => {
462 const next = new Map(current);
463 for (const revokedSessionId of revokedSessionIds) {
464 next.delete(revokedSessionId);
465 }
466 return next;
467 });
468 yield* Effect.forEach(
469 revokedSessionIds,
470 (revokedSessionId) => emitRemoved(revokedSessionId),
471 {
472 concurrency: "unbounded",
473 discard: true,
474 },
475 );
476 }
477 return revokedSessionIds.length;
478 }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions.")));
479
480 return {
481 cookieName,
482 issue,
483 verify,
484 issueWebSocketToken,
485 verifyWebSocketToken,
486 listActive,
487 get streamChanges() {
488 return Stream.fromPubSub(changesPubSub);
489 },
490 revoke,
491 revokeAllExcept,
492 markConnected,
493 markDisconnected,
494 } satisfies SessionCredentialServiceShape;
495});
496
497export const SessionCredentialServiceLive = Layer.effect(
498 SessionCredentialService,
499 makeSessionCredentialService,
500).pipe(Layer.provideMerge(AuthSessionRepositoryLive));
apps/server/src/auth/Services/AuthControlPlane.ts+69−0
@@ -0,0 +1,69 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthPairingLink,
5 AuthSessionId,
6} from "@t3tools/contracts";
7import { Data, DateTime, Duration, Effect, Context } from "effect";
8import type { SessionRole } from "./SessionCredentialService.ts";
9
10export const DEFAULT_SESSION_SUBJECT = "cli-issued-session";
11
12export interface IssuedPairingLink {
13 readonly id: string;
14 readonly credential: string;
15 readonly role: SessionRole;
16 readonly subject: string;
17 readonly label?: string;
18 readonly createdAt: DateTime.Utc;
19 readonly expiresAt: DateTime.Utc;
20}
21
22export interface IssuedBearerSession {
23 readonly sessionId: AuthSessionId;
24 readonly token: string;
25 readonly method: "bearer-session-token";
26 readonly role: SessionRole;
27 readonly subject: string;
28 readonly client: AuthClientMetadata;
29 readonly expiresAt: DateTime.Utc;
30}
31
32export class AuthControlPlaneError extends Data.TaggedError("AuthControlPlaneError")<{
33 readonly message: string;
34 readonly cause?: unknown;
35}> {}
36
37export interface AuthControlPlaneShape {
38 readonly createPairingLink: (input?: {
39 readonly ttl?: Duration.Duration;
40 readonly label?: string;
41 readonly role?: SessionRole;
42 readonly subject?: string;
43 }) => Effect.Effect<IssuedPairingLink, AuthControlPlaneError>;
44 readonly listPairingLinks: (input?: {
45 readonly role?: SessionRole;
46 readonly excludeSubjects?: ReadonlyArray<string>;
47 }) => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthControlPlaneError>;
48 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthControlPlaneError>;
49 readonly issueSession: (input?: {
50 readonly ttl?: Duration.Duration;
51 readonly subject?: string;
52 readonly role?: SessionRole;
53 readonly label?: string;
54 }) => Effect.Effect<IssuedBearerSession, AuthControlPlaneError>;
55 readonly listSessions: () => Effect.Effect<
56 ReadonlyArray<AuthClientSession>,
57 AuthControlPlaneError
58 >;
59 readonly revokeSession: (
60 sessionId: AuthSessionId,
61 ) => Effect.Effect<boolean, AuthControlPlaneError>;
62 readonly revokeOtherSessionsExcept: (
63 sessionId: AuthSessionId,
64 ) => Effect.Effect<number, AuthControlPlaneError>;
65}
66
67export class AuthControlPlane extends Context.Service<AuthControlPlane, AuthControlPlaneShape>()(
68 "t3/AuthControlPlane",
69) {}
apps/server/src/auth/Services/BootstrapCredentialService.ts+57−0
@@ -0,0 +1,57 @@
1import type { AuthPairingLink, ServerAuthBootstrapMethod } from "@t3tools/contracts";
2import { Data, DateTime, Duration, Context } from "effect";
3import type { Effect, Stream } from "effect";
4
5export type BootstrapCredentialRole = "owner" | "client";
6
7export interface BootstrapGrant {
8 readonly method: ServerAuthBootstrapMethod;
9 readonly role: BootstrapCredentialRole;
10 readonly subject: string;
11 readonly label?: string;
12 readonly expiresAt: DateTime.DateTime;
13}
14
15export class BootstrapCredentialError extends Data.TaggedError("BootstrapCredentialError")<{
16 readonly message: string;
17 readonly status?: 401 | 500;
18 readonly cause?: unknown;
19}> {}
20
21export interface IssuedBootstrapCredential {
22 readonly id: string;
23 readonly credential: string;
24 readonly label?: string;
25 readonly expiresAt: DateTime.Utc;
26}
27
28export type BootstrapCredentialChange =
29 | {
30 readonly type: "pairingLinkUpserted";
31 readonly pairingLink: AuthPairingLink;
32 }
33 | {
34 readonly type: "pairingLinkRemoved";
35 readonly id: string;
36 };
37
38export interface BootstrapCredentialServiceShape {
39 readonly issueOneTimeToken: (input?: {
40 readonly ttl?: Duration.Duration;
41 readonly role?: BootstrapCredentialRole;
42 readonly subject?: string;
43 readonly label?: string;
44 }) => Effect.Effect<IssuedBootstrapCredential, BootstrapCredentialError>;
45 readonly listActive: () => Effect.Effect<
46 ReadonlyArray<AuthPairingLink>,
47 BootstrapCredentialError
48 >;
49 readonly streamChanges: Stream.Stream<BootstrapCredentialChange>;
50 readonly revoke: (id: string) => Effect.Effect<boolean, BootstrapCredentialError>;
51 readonly consume: (credential: string) => Effect.Effect<BootstrapGrant, BootstrapCredentialError>;
52}
53
54export class BootstrapCredentialService extends Context.Service<
55 BootstrapCredentialService,
56 BootstrapCredentialServiceShape
57>()("t3/auth/Services/BootstrapCredentialService") {}
apps/server/src/auth/Services/ServerAuth.ts+84−0
@@ -0,0 +1,84 @@
1import type {
2 AuthBearerBootstrapResult,
3 AuthBootstrapResult,
4 AuthClientMetadata,
5 AuthClientSession,
6 AuthCreatePairingCredentialInput,
7 AuthPairingLink,
8 AuthPairingCredentialResult,
9 AuthSessionId,
10 AuthSessionState,
11 ServerAuthDescriptor,
12 ServerAuthSessionMethod,
13 AuthWebSocketTokenResult,
14} from "@t3tools/contracts";
15import { Data, DateTime, Context } from "effect";
16import type { Effect } from "effect";
17import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
18import type { SessionRole } from "./SessionCredentialService.ts";
19
20export interface AuthenticatedSession {
21 readonly sessionId: AuthSessionId;
22 readonly subject: string;
23 readonly method: ServerAuthSessionMethod;
24 readonly role: SessionRole;
25 readonly expiresAt?: DateTime.DateTime;
26}
27
28export class AuthError extends Data.TaggedError("AuthError")<{
29 readonly message: string;
30 readonly status?: 400 | 401 | 403 | 500;
31 readonly cause?: unknown;
32}> {}
33
34export interface ServerAuthShape {
35 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
36 readonly getSessionState: (
37 request: HttpServerRequest.HttpServerRequest,
38 ) => Effect.Effect<AuthSessionState, never>;
39 readonly exchangeBootstrapCredential: (
40 credential: string,
41 requestMetadata: AuthClientMetadata,
42 ) => Effect.Effect<
43 {
44 readonly response: AuthBootstrapResult;
45 readonly sessionToken: string;
46 },
47 AuthError
48 >;
49 readonly exchangeBootstrapCredentialForBearerSession: (
50 credential: string,
51 requestMetadata: AuthClientMetadata,
52 ) => Effect.Effect<AuthBearerBootstrapResult, AuthError>;
53 readonly issuePairingCredential: (
54 input?: AuthCreatePairingCredentialInput & {
55 readonly role?: SessionRole;
56 },
57 ) => Effect.Effect<AuthPairingCredentialResult, AuthError>;
58 readonly listPairingLinks: () => Effect.Effect<ReadonlyArray<AuthPairingLink>, AuthError>;
59 readonly revokePairingLink: (id: string) => Effect.Effect<boolean, AuthError>;
60 readonly listClientSessions: (
61 currentSessionId: AuthSessionId,
62 ) => Effect.Effect<ReadonlyArray<AuthClientSession>, AuthError>;
63 readonly revokeClientSession: (
64 currentSessionId: AuthSessionId,
65 targetSessionId: AuthSessionId,
66 ) => Effect.Effect<boolean, AuthError>;
67 readonly revokeOtherClientSessions: (
68 currentSessionId: AuthSessionId,
69 ) => Effect.Effect<number, AuthError>;
70 readonly authenticateHttpRequest: (
71 request: HttpServerRequest.HttpServerRequest,
72 ) => Effect.Effect<AuthenticatedSession, AuthError>;
73 readonly authenticateWebSocketUpgrade: (
74 request: HttpServerRequest.HttpServerRequest,
75 ) => Effect.Effect<AuthenticatedSession, AuthError>;
76 readonly issueWebSocketToken: (
77 session: AuthenticatedSession,
78 ) => Effect.Effect<AuthWebSocketTokenResult, AuthError>;
79 readonly issueStartupPairingUrl: (baseUrl: string) => Effect.Effect<string, AuthError>;
80}
81
82export class ServerAuth extends Context.Service<ServerAuth, ServerAuthShape>()(
83 "t3/auth/Services/ServerAuth",
84) {}
apps/server/src/auth/Services/ServerAuthPolicy.ts+11−0
@@ -0,0 +1,11 @@
1import type { ServerAuthDescriptor } from "@t3tools/contracts";
2import { Context } from "effect";
3import type { Effect } from "effect";
4
5export interface ServerAuthPolicyShape {
6 readonly getDescriptor: () => Effect.Effect<ServerAuthDescriptor>;
7}
8
9export class ServerAuthPolicy extends Context.Service<ServerAuthPolicy, ServerAuthPolicyShape>()(
10 "t3/auth/Services/ServerAuthPolicy",
11) {}
apps/server/src/auth/Services/ServerSecretStore.ts+21−0
@@ -0,0 +1,21 @@
1import { Data, Context } from "effect";
2import type { Effect } from "effect";
3
4export class SecretStoreError extends Data.TaggedError("SecretStoreError")<{
5 readonly message: string;
6 readonly cause?: unknown;
7}> {}
8
9export interface ServerSecretStoreShape {
10 readonly get: (name: string) => Effect.Effect<Uint8Array | null, SecretStoreError>;
11 readonly set: (name: string, value: Uint8Array) => Effect.Effect<void, SecretStoreError>;
12 readonly getOrCreateRandom: (
13 name: string,
14 bytes: number,
15 ) => Effect.Effect<Uint8Array, SecretStoreError>;
16 readonly remove: (name: string) => Effect.Effect<void, SecretStoreError>;
17}
18
19export class ServerSecretStore extends Context.Service<ServerSecretStore, ServerSecretStoreShape>()(
20 "t3/auth/Services/ServerSecretStore",
21) {}
apps/server/src/auth/Services/SessionCredentialService.ts+87−0
@@ -0,0 +1,87 @@
1import type {
2 AuthClientMetadata,
3 AuthClientSession,
4 AuthSessionId,
5 ServerAuthSessionMethod,
6} from "@t3tools/contracts";
7import { Data, DateTime, Duration, Context } from "effect";
8import type { Effect, Stream } from "effect";
9
10export type SessionRole = "owner" | "client";
11
12export interface IssuedSession {
13 readonly sessionId: AuthSessionId;
14 readonly token: string;
15 readonly method: ServerAuthSessionMethod;
16 readonly client: AuthClientMetadata;
17 readonly expiresAt: DateTime.DateTime;
18 readonly role: SessionRole;
19}
20
21export interface VerifiedSession {
22 readonly sessionId: AuthSessionId;
23 readonly token: string;
24 readonly method: ServerAuthSessionMethod;
25 readonly client: AuthClientMetadata;
26 readonly expiresAt?: DateTime.DateTime;
27 readonly subject: string;
28 readonly role: SessionRole;
29}
30
31export type SessionCredentialChange =
32 | {
33 readonly type: "clientUpserted";
34 readonly clientSession: AuthClientSession;
35 }
36 | {
37 readonly type: "clientRemoved";
38 readonly sessionId: AuthSessionId;
39 };
40
41export class SessionCredentialError extends Data.TaggedError("SessionCredentialError")<{
42 readonly message: string;
43 readonly cause?: unknown;
44}> {}
45
46export interface SessionCredentialServiceShape {
47 readonly cookieName: string;
48 readonly issue: (input?: {
49 readonly ttl?: Duration.Duration;
50 readonly subject?: string;
51 readonly method?: ServerAuthSessionMethod;
52 readonly role?: SessionRole;
53 readonly client?: AuthClientMetadata;
54 }) => Effect.Effect<IssuedSession, SessionCredentialError>;
55 readonly verify: (token: string) => Effect.Effect<VerifiedSession, SessionCredentialError>;
56 readonly issueWebSocketToken: (
57 sessionId: AuthSessionId,
58 input?: {
59 readonly ttl?: Duration.Duration;
60 },
61 ) => Effect.Effect<
62 {
63 readonly token: string;
64 readonly expiresAt: DateTime.DateTime;
65 },
66 SessionCredentialError
67 >;
68 readonly verifyWebSocketToken: (
69 token: string,
70 ) => Effect.Effect<VerifiedSession, SessionCredentialError>;
71 readonly listActive: () => Effect.Effect<
72 ReadonlyArray<AuthClientSession>,
73 SessionCredentialError
74 >;
75 readonly streamChanges: Stream.Stream<SessionCredentialChange>;
76 readonly revoke: (sessionId: AuthSessionId) => Effect.Effect<boolean, SessionCredentialError>;
77 readonly revokeAllExcept: (
78 sessionId: AuthSessionId,
79 ) => Effect.Effect<number, SessionCredentialError>;
80 readonly markConnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
81 readonly markDisconnected: (sessionId: AuthSessionId) => Effect.Effect<void, never>;
82}
83
84export class SessionCredentialService extends Context.Service<
85 SessionCredentialService,
86 SessionCredentialServiceShape
87>()("t3/auth/Services/SessionCredentialService") {}
apps/server/src/auth/http.ts+254−0
@@ -0,0 +1,254 @@
1import {
2 type AuthBearerBootstrapResult,
3 AuthBootstrapInput,
4 AuthCreatePairingCredentialInput,
5 AuthRevokeClientSessionInput,
6 AuthRevokePairingLinkInput,
7 type AuthWebSocketTokenResult,
8} from "@t3tools/contracts";
9import { DateTime, Effect, Schema } from "effect";
10import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
11
12import { AuthError, ServerAuth } from "./Services/ServerAuth.ts";
13import { SessionCredentialService } from "./Services/SessionCredentialService.ts";
14import { deriveAuthClientMetadata } from "./utils.ts";
15
16export const respondToAuthError = (error: AuthError) =>
17 Effect.gen(function* () {
18 if ((error.status ?? 500) >= 500) {
19 yield* Effect.logError("auth route failed", {
20 message: error.message,
21 cause: error.cause,
22 });
23 }
24 return HttpServerResponse.jsonUnsafe(
25 {
26 error: error.message,
27 },
28 { status: error.status ?? 500 },
29 );
30 });
31
32export const authSessionRouteLayer = HttpRouter.add(
33 "GET",
34 "/api/auth/session",
35 Effect.gen(function* () {
36 const request = yield* HttpServerRequest.HttpServerRequest;
37 const serverAuth = yield* ServerAuth;
38 const session = yield* serverAuth.getSessionState(request);
39 return HttpServerResponse.jsonUnsafe(session, { status: 200 });
40 }),
41);
42
43const PairingCredentialRequestHeaders = Schema.Struct({
44 "content-length": Schema.optionalKey(Schema.String),
45 "content-type": Schema.optionalKey(Schema.String),
46 "transfer-encoding": Schema.optionalKey(Schema.String),
47});
48
49function hasRequestBody(headers: typeof PairingCredentialRequestHeaders.Type) {
50 const contentLengthHeader = headers["content-length"];
51 if (typeof contentLengthHeader === "string") {
52 const contentLength = Number.parseInt(contentLengthHeader, 10);
53 if (Number.isFinite(contentLength)) {
54 return contentLength > 0;
55 }
56 }
57 return typeof headers["transfer-encoding"] === "string";
58}
59
60export const authBootstrapRouteLayer = HttpRouter.add(
61 "POST",
62 "/api/auth/bootstrap",
63 Effect.gen(function* () {
64 const request = yield* HttpServerRequest.HttpServerRequest;
65 const serverAuth = yield* ServerAuth;
66 const sessions = yield* SessionCredentialService;
67 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
68 Effect.mapError(
69 (cause) =>
70 new AuthError({
71 message: "Invalid bootstrap payload.",
72 status: 400,
73 cause,
74 }),
75 ),
76 );
77 const result = yield* serverAuth.exchangeBootstrapCredential(
78 payload.credential,
79 deriveAuthClientMetadata({ request }),
80 );
81
82 return yield* HttpServerResponse.jsonUnsafe(result.response, { status: 200 }).pipe(
83 HttpServerResponse.setCookie(sessions.cookieName, result.sessionToken, {
84 expires: DateTime.toDate(result.response.expiresAt),
85 httpOnly: true,
86 path: "/",
87 sameSite: "lax",
88 }),
89 );
90 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
91);
92
93export const authBearerBootstrapRouteLayer = HttpRouter.add(
94 "POST",
95 "/api/auth/bootstrap/bearer",
96 Effect.gen(function* () {
97 const request = yield* HttpServerRequest.HttpServerRequest;
98 const serverAuth = yield* ServerAuth;
99 const payload = yield* HttpServerRequest.schemaBodyJson(AuthBootstrapInput).pipe(
100 Effect.mapError(
101 (cause) =>
102 new AuthError({
103 message: "Invalid bootstrap payload.",
104 status: 400,
105 cause,
106 }),
107 ),
108 );
109 const result = yield* serverAuth.exchangeBootstrapCredentialForBearerSession(
110 payload.credential,
111 deriveAuthClientMetadata({ request }),
112 );
113 return HttpServerResponse.jsonUnsafe(result satisfies AuthBearerBootstrapResult, {
114 status: 200,
115 });
116 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
117);
118
119export const authWebSocketTokenRouteLayer = HttpRouter.add(
120 "POST",
121 "/api/auth/ws-token",
122 Effect.gen(function* () {
123 const request = yield* HttpServerRequest.HttpServerRequest;
124 const serverAuth = yield* ServerAuth;
125 const session = yield* serverAuth.authenticateHttpRequest(request);
126 const result = yield* serverAuth.issueWebSocketToken(session);
127 return HttpServerResponse.jsonUnsafe(result satisfies AuthWebSocketTokenResult, {
128 status: 200,
129 });
130 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
131);
132
133export const authPairingCredentialRouteLayer = HttpRouter.add(
134 "POST",
135 "/api/auth/pairing-token",
136 Effect.gen(function* () {
137 const serverAuth = yield* ServerAuth;
138 const request = yield* HttpServerRequest.HttpServerRequest;
139 const session = yield* serverAuth.authenticateHttpRequest(request);
140 if (session.role !== "owner") {
141 return yield* new AuthError({
142 message: "Only owner sessions can create pairing credentials.",
143 status: 403,
144 });
145 }
146 const headers = yield* HttpServerRequest.schemaHeaders(PairingCredentialRequestHeaders).pipe(
147 Effect.mapError(
148 (cause) =>
149 new AuthError({
150 message: "Invalid pairing credential request headers.",
151 status: 400,
152 cause,
153 }),
154 ),
155 );
156 const payload = hasRequestBody(headers)
157 ? yield* HttpServerRequest.schemaBodyJson(AuthCreatePairingCredentialInput).pipe(
158 Effect.mapError(
159 (cause) =>
160 new AuthError({
161 message: "Invalid pairing credential payload.",
162 status: 400,
163 cause,
164 }),
165 ),
166 )
167 : {};
168 const result = yield* serverAuth.issuePairingCredential(payload);
169 return HttpServerResponse.jsonUnsafe(result, { status: 200 });
170 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
171);
172
173const authenticateOwnerSession = Effect.gen(function* () {
174 const request = yield* HttpServerRequest.HttpServerRequest;
175 const serverAuth = yield* ServerAuth;
176 const session = yield* serverAuth.authenticateHttpRequest(request);
177 if (session.role !== "owner") {
178 return yield* new AuthError({
179 message: "Only owner sessions can manage network access.",
180 status: 403,
181 });
182 }
183 return { serverAuth, session } as const;
184});
185
186export const authPairingLinksRouteLayer = HttpRouter.add(
187 "GET",
188 "/api/auth/pairing-links",
189 Effect.gen(function* () {
190 const { serverAuth } = yield* authenticateOwnerSession;
191 const pairingLinks = yield* serverAuth.listPairingLinks();
192 return HttpServerResponse.jsonUnsafe(pairingLinks, { status: 200 });
193 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
194);
195
196export const authPairingLinksRevokeRouteLayer = HttpRouter.add(
197 "POST",
198 "/api/auth/pairing-links/revoke",
199 Effect.gen(function* () {
200 const { serverAuth } = yield* authenticateOwnerSession;
201 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokePairingLinkInput).pipe(
202 Effect.mapError(
203 (cause) =>
204 new AuthError({
205 message: "Invalid revoke pairing link payload.",
206 status: 400,
207 cause,
208 }),
209 ),
210 );
211 const revoked = yield* serverAuth.revokePairingLink(payload.id);
212 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
213 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
214);
215
216export const authClientsRouteLayer = HttpRouter.add(
217 "GET",
218 "/api/auth/clients",
219 Effect.gen(function* () {
220 const { serverAuth, session } = yield* authenticateOwnerSession;
221 const clients = yield* serverAuth.listClientSessions(session.sessionId);
222 return HttpServerResponse.jsonUnsafe(clients, { status: 200 });
223 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
224);
225
226export const authClientsRevokeRouteLayer = HttpRouter.add(
227 "POST",
228 "/api/auth/clients/revoke",
229 Effect.gen(function* () {
230 const { serverAuth, session } = yield* authenticateOwnerSession;
231 const payload = yield* HttpServerRequest.schemaBodyJson(AuthRevokeClientSessionInput).pipe(
232 Effect.mapError(
233 (cause) =>
234 new AuthError({
235 message: "Invalid revoke client payload.",
236 status: 400,
237 cause,
238 }),
239 ),
240 );
241 const revoked = yield* serverAuth.revokeClientSession(session.sessionId, payload.sessionId);
242 return HttpServerResponse.jsonUnsafe({ revoked }, { status: 200 });
243 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
244);
245
246export const authClientsRevokeOthersRouteLayer = HttpRouter.add(
247 "POST",
248 "/api/auth/clients/revoke-others",
249 Effect.gen(function* () {
250 const { serverAuth, session } = yield* authenticateOwnerSession;
251 const revokedCount = yield* serverAuth.revokeOtherClientSessions(session.sessionId);
252 return HttpServerResponse.jsonUnsafe({ revokedCount }, { status: 200 });
253 }).pipe(Effect.catchTag("AuthError", (error) => respondToAuthError(error))),
254);
apps/server/src/auth/utils.test.ts+26−0
@@ -0,0 +1,26 @@
1import { describe, expect, it } from "vitest";
2
3import { deriveAuthClientMetadata } from "./utils.ts";
4
5describe("deriveAuthClientMetadata", () => {
6 it("labels Electron user agents as Electron instead of Chrome", () => {
7 const metadata = deriveAuthClientMetadata({
8 request: {
9 headers: {
10 "user-agent":
11 "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) t3code/0.0.15 Chrome/136.0.7103.93 Electron/36.3.2 Safari/537.36",
12 },
13 source: {
14 remoteAddress: "::ffff:127.0.0.1",
15 },
16 } as never,
17 });
18
19 expect(metadata).toMatchObject({
20 browser: "Electron",
21 deviceType: "desktop",
22 ipAddress: "127.0.0.1",
23 os: "macOS",
24 });
25 });
26});
apps/server/src/auth/utils.ts+132−0
@@ -0,0 +1,132 @@
1import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/contracts";
2import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
3import * as Crypto from "node:crypto";
4
5const SESSION_COOKIE_NAME = "t3_session";
6
7export function resolveSessionCookieName(input: {
8 readonly mode: "web" | "desktop";
9 readonly port: number;
10}): string {
11 if (input.mode !== "desktop") {
12 return SESSION_COOKIE_NAME;
13 }
14
15 return `${SESSION_COOKIE_NAME}_${input.port}`;
16}
17
18export function base64UrlEncode(input: string | Uint8Array): string {
19 const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input);
20 return buffer.toString("base64url");
21}
22
23export function base64UrlDecodeUtf8(input: string): string {
24 return Buffer.from(input, "base64url").toString("utf8");
25}
26
27export function signPayload(payload: string, secret: Uint8Array): string {
28 return Crypto.createHmac("sha256", Buffer.from(secret)).update(payload).digest("base64url");
29}
30
31export function timingSafeEqualBase64Url(left: string, right: string): boolean {
32 const leftBuffer = Buffer.from(left, "base64url");
33 const rightBuffer = Buffer.from(right, "base64url");
34 if (leftBuffer.length !== rightBuffer.length) {
35 return false;
36 }
37 return Crypto.timingSafeEqual(leftBuffer, rightBuffer);
38}
39
40function normalizeNonEmptyString(value: string | null | undefined): string | undefined {
41 if (typeof value !== "string") {
42 return undefined;
43 }
44 const trimmed = value.trim();
45 return trimmed.length > 0 ? trimmed : undefined;
46}
47
48function normalizeIpAddress(value: string | null | undefined): string | undefined {
49 const normalized = normalizeNonEmptyString(value);
50 if (!normalized) {
51 return undefined;
52 }
53 return normalized.startsWith("::ffff:") ? normalized.slice("::ffff:".length) : normalized;
54}
55
56function inferDeviceType(userAgent: string | undefined): AuthClientMetadataDeviceType {
57 if (!userAgent) {
58 return "unknown";
59 }
60
61 const normalized = userAgent.toLowerCase();
62 if (/bot|crawler|spider|slurp|curl|wget/.test(normalized)) {
63 return "bot";
64 }
65 if (/ipad|tablet/.test(normalized)) {
66 return "tablet";
67 }
68 if (/iphone|android.+mobile|mobile/.test(normalized)) {
69 return "mobile";
70 }
71 return "desktop";
72}
73
74function inferBrowser(userAgent: string | undefined): string | undefined {
75 if (!userAgent) {
76 return undefined;
77 }
78 const normalized = userAgent.toLowerCase();
79 if (/edg\//.test(normalized)) return "Edge";
80 if (/opr\//.test(normalized)) return "Opera";
81 if (/firefox\//.test(normalized)) return "Firefox";
82 if (/electron\//.test(normalized)) return "Electron";
83 if (/chrome\//.test(normalized) || /crios\//.test(normalized)) return "Chrome";
84 if (/safari\//.test(normalized) && !/chrome\//.test(normalized)) return "Safari";
85 return undefined;
86}
87
88function inferOs(userAgent: string | undefined): string | undefined {
89 if (!userAgent) {
90 return undefined;
91 }
92 const normalized = userAgent.toLowerCase();
93 if (/iphone|ipad|ipod/.test(normalized)) return "iOS";
94 if (/android/.test(normalized)) return "Android";
95 if (/mac os x|macintosh/.test(normalized)) return "macOS";
96 if (/windows nt/.test(normalized)) return "Windows";
97 if (/linux/.test(normalized)) return "Linux";
98 return undefined;
99}
100
101function readRemoteAddressFromSource(source: unknown): string | undefined {
102 if (!source || typeof source !== "object") {
103 return undefined;
104 }
105
106 const candidate = source as {
107 readonly remoteAddress?: string | null;
108 readonly socket?: {
109 readonly remoteAddress?: string | null;
110 };
111 };
112
113 return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress);
114}
115
116export function deriveAuthClientMetadata(input: {
117 readonly request: HttpServerRequest.HttpServerRequest;
118 readonly label?: string;
119}): AuthClientMetadata {
120 const userAgent = normalizeNonEmptyString(input.request.headers["user-agent"]);
121 const ipAddress = readRemoteAddressFromSource(input.request.source);
122 const os = inferOs(userAgent);
123 const browser = inferBrowser(userAgent);
124 return {
125 ...(input.label ? { label: input.label } : {}),
126 ...(ipAddress ? { ipAddress } : {}),
127 ...(userAgent ? { userAgent } : {}),
128 deviceType: inferDeviceType(userAgent),
129 ...(os ? { os } : {}),
130 ...(browser ? { browser } : {}),
131 };
132}
apps/server/src/bin.ts+3−3
@@ -5,12 +5,12 @@ import * as Layer from "effect/Layer"; · 4 unmodified lines
5import { Command } from "effect/unstable/cli";5import { Command } from "effect/unstable/cli";
6 6
7import { NetService } from "@t3tools/shared/Net";7import { NetService } from "@t3tools/shared/Net";
8import { cli } from "./cli";8import { cli } from "./cli.ts";
9import { version } from "../package.json" with { type: "json" };9import packageJson from "../package.json" with { type: "json" };
10 10
11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12 12
13Command.run(cli, { version }).pipe(13Command.run(cli, { version: packageJson.version }).pipe(
14 Effect.scoped,14 Effect.scoped,
15 Effect.provide(CliRuntimeLayer),15 Effect.provide(CliRuntimeLayer),
16 NodeRuntime.runMain,16 NodeRuntime.runMain,
apps/server/src/bootstrap.test.ts+1−1
@@ -10,7 +10,7 @@ import * as Fiber from "effect/Fiber"; · 9 unmodified lines
10import { TestClock } from "effect/testing";10import { TestClock } from "effect/testing";
11import { vi } from "vitest";11import { vi } from "vitest";
12 12
13import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap";13import { readBootstrapEnvelope, resolveFdPath } from "./bootstrap.ts";
14import { assertNone, assertSome } from "@effect/vitest/utils";14import { assertNone, assertSome } from "@effect/vitest/utils";
15 15
16const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));16const openSyncInterceptor = vi.hoisted(() => ({ failPath: null as string | null }));
apps/server/src/checkpointing/Layers/CheckpointDiffQuery.test.ts+14−4
@@ -26,7 +26,7 @@ function makeThreadCheckpointContext(input: { · 25 unmodified lines
26 worktreePath: input.worktreePath,26 worktreePath: input.worktreePath,
27 checkpoints: [27 checkpoints: [
28 {28 {
29 turnId: TurnId.makeUnsafe("turn-1"),29 turnId: TurnId.make("turn-1"),
30 checkpointTurnCount: input.checkpointTurnCount,30 checkpointTurnCount: input.checkpointTurnCount,
31 checkpointRef: input.checkpointRef,31 checkpointRef: input.checkpointRef,
32 status: "ready",32 status: "ready",
@@ -40,8 +40,8 @@ function makeThreadCheckpointContext(input: { · 7 unmodified lines
40 40
41describe("CheckpointDiffQueryLive", () => {41describe("CheckpointDiffQueryLive", () => {
42 it("computes diffs using canonical turn-0 checkpoint refs", async () => {42 it("computes diffs using canonical turn-0 checkpoint refs", async () => {
43 const projectId = ProjectId.makeUnsafe("project-1");43 const projectId = ProjectId.make("project-1");
44 const threadId = ThreadId.makeUnsafe("thread-1");44 const threadId = ThreadId.make("thread-1");
45 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);45 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
46 const hasCheckpointRefCalls: Array<CheckpointRef> = [];46 const hasCheckpointRefCalls: Array<CheckpointRef> = [];
47 const diffCheckpointsCalls: Array<{47 const diffCheckpointsCalls: Array<{
@@ -82,10 +82,15 @@ describe("CheckpointDiffQueryLive", () => { · 34 unmodified lines
82 Layer.succeed(ProjectionSnapshotQuery, {82 Layer.succeed(ProjectionSnapshotQuery, {
83 getSnapshot: () =>83 getSnapshot: () =>
84 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),84 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
85 getShellSnapshot: () =>
86 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
85 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),87 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
86 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),88 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
89 getProjectShellById: () => Effect.succeed(Option.none()),
87 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),90 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
88 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),91 getThreadCheckpointContext: () => Effect.succeed(Option.some(threadCheckpointContext)),
92 getThreadShellById: () => Effect.succeed(Option.none()),
93 getThreadDetailById: () => Effect.succeed(Option.none()),
89 }),94 }),
90 ),95 ),
91 );96 );
@@ -119,7 +124,7 @@ describe("CheckpointDiffQueryLive", () => { · 27 unmodified lines
119 });124 });
120 125
121 it("fails when the thread is missing from the snapshot", async () => {126 it("fails when the thread is missing from the snapshot", async () => {
122 const threadId = ThreadId.makeUnsafe("thread-missing");127 const threadId = ThreadId.make("thread-missing");
123 128
124 const checkpointStore: CheckpointStoreShape = {129 const checkpointStore: CheckpointStoreShape = {
125 isGitRepository: () => Effect.succeed(true),130 isGitRepository: () => Effect.succeed(true),
@@ -136,10 +141,15 @@ describe("CheckpointDiffQueryLive", () => { · 10 unmodified lines
136 Layer.succeed(ProjectionSnapshotQuery, {141 Layer.succeed(ProjectionSnapshotQuery, {
137 getSnapshot: () =>142 getSnapshot: () =>
138 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),143 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
144 getShellSnapshot: () =>
145 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
139 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),146 getCounts: () => Effect.succeed({ projectCount: 0, threadCount: 0 }),
140 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),147 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
148 getProjectShellById: () => Effect.succeed(Option.none()),
141 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),149 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
142 getThreadCheckpointContext: () => Effect.succeed(Option.none()),150 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
151 getThreadShellById: () => Effect.succeed(Option.none()),
152 getThreadDetailById: () => Effect.succeed(Option.none()),
143 }),153 }),
144 ),154 ),
145 );155 );
apps/server/src/checkpointing/Layers/CheckpointStore.test.ts+1−1
@@ -93,7 +93,7 @@ it.layer(TestLayer)("CheckpointStoreLive", (it) => { · 92 unmodified lines
93 const tmp = yield* makeTmpDir();93 const tmp = yield* makeTmpDir();
94 yield* initRepoWithCommit(tmp);94 yield* initRepoWithCommit(tmp);
95 const checkpointStore = yield* CheckpointStore;95 const checkpointStore = yield* CheckpointStore;
96 const threadId = ThreadId.makeUnsafe("thread-checkpoint-store");96 const threadId = ThreadId.make("thread-checkpoint-store");
97 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);97 const fromCheckpointRef = checkpointRefForThreadTurn(threadId, 0);
98 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);98 const toCheckpointRef = checkpointRefForThreadTurn(threadId, 1);
99 99
apps/server/src/checkpointing/Services/CheckpointDiffQuery.ts+2−2
@@ -12,7 +12,7 @@ import type { · 11 unmodified lines
12 OrchestrationGetTurnDiffInput,12 OrchestrationGetTurnDiffInput,
13 OrchestrationGetTurnDiffResult,13 OrchestrationGetTurnDiffResult,
14} from "@t3tools/contracts";14} from "@t3tools/contracts";
15import { ServiceMap } from "effect";15import { Context } from "effect";
16import type { Effect } from "effect";16import type { Effect } from "effect";
17 17
18import type { CheckpointServiceError } from "../Errors.ts";18import type { CheckpointServiceError } from "../Errors.ts";
@@ -43,7 +43,7 @@ export interface CheckpointDiffQueryShape { · 24 unmodified lines
43/**43/**
44 * CheckpointDiffQuery - Service tag for checkpoint diff queries.44 * CheckpointDiffQuery - Service tag for checkpoint diff queries.
45 */45 */
46export class CheckpointDiffQuery extends ServiceMap.Service<46export class CheckpointDiffQuery extends Context.Service<
47 CheckpointDiffQuery,47 CheckpointDiffQuery,
48 CheckpointDiffQueryShape48 CheckpointDiffQueryShape
49>()("t3/checkpointing/Services/CheckpointDiffQuery") {}49>()("t3/checkpointing/Services/CheckpointDiffQuery") {}
apps/server/src/checkpointing/Services/CheckpointStore.ts+3−3
@@ -5,12 +5,12 @@ · 4 unmodified lines
5 * workspace thread timeline. It does not store user-facing checkpoint metadata5 * workspace thread timeline. It does not store user-facing checkpoint metadata
6 * and does not coordinate provider conversation rollback.6 * and does not coordinate provider conversation rollback.
7 *7 *
8 * Uses Effect `ServiceMap.Service` for dependency injection and exposes typed8 * Uses Effect `Context.Service` for dependency injection and exposes typed
9 * domain errors for checkpoint storage operations.9 * domain errors for checkpoint storage operations.
10 *10 *
11 * @module CheckpointStore11 * @module CheckpointStore
12 */12 */
13import { ServiceMap } from "effect";13import { Context } from "effect";
14import type { Effect } from "effect";14import type { Effect } from "effect";
15 15
16import type { CheckpointStoreError } from "../Errors.ts";16import type { CheckpointStoreError } from "../Errors.ts";
@@ -95,6 +95,6 @@ export interface CheckpointStoreShape { · 78 unmodified lines
95/**95/**
96 * CheckpointStore - Service tag for checkpoint persistence and restore operations.96 * CheckpointStore - Service tag for checkpoint persistence and restore operations.
97 */97 */
98export class CheckpointStore extends ServiceMap.Service<CheckpointStore, CheckpointStoreShape>()(98export class CheckpointStore extends Context.Service<CheckpointStore, CheckpointStoreShape>()(
99 "t3/checkpointing/Services/CheckpointStore",99 "t3/checkpointing/Services/CheckpointStore",
100) {}100) {}
apps/server/src/checkpointing/Utils.ts+1−1
@@ -4,7 +4,7 @@ import { CheckpointRef, ProjectId, type ThreadId } from "@t3tools/contracts"; · 3 unmodified lines
4export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";4export const CHECKPOINT_REFS_PREFIX = "refs/t3/checkpoints";
5 5
6export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {6export function checkpointRefForThreadTurn(threadId: ThreadId, turnCount: number): CheckpointRef {
7 return CheckpointRef.makeUnsafe(7 return CheckpointRef.make(
8 `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`,8 `${CHECKPOINT_REFS_PREFIX}/${Encoding.encodeBase64Url(threadId)}/turn/${turnCount}`,
9 );9 );
10}10}
apps/server/src/cli-config.test.ts+134−17
@@ -5,8 +5,8 @@ import { ConfigProvider, Effect, FileSystem, Layer, Option, Path } from "effect" · 4 unmodified lines
5 5
6import { NetService } from "@t3tools/shared/Net";6import { NetService } from "@t3tools/shared/Net";
7import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
8import { deriveServerPaths } from "./config";8import { deriveServerPaths } from "./config.ts";
9import { resolveServerConfig } from "./cli";9import { resolveServerConfig } from "./cli.ts";
10 10
11it.layer(NodeServices.layer)("cli config resolution", (it) => {11it.layer(NodeServices.layer)("cli config resolution", (it) => {
12 const defaultObservabilityConfig = {12 const defaultObservabilityConfig = {
@@ -43,7 +43,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 30 unmodified lines
43 cwd: Option.none(),43 cwd: Option.none(),
44 devUrl: Option.none(),44 devUrl: Option.none(),
45 noBrowser: Option.none(),45 noBrowser: Option.none(),
46 authToken: Option.none(),
47 bootstrapFd: Option.none(),46 bootstrapFd: Option.none(),
48 autoBootstrapProjectFromCwd: Option.none(),47 autoBootstrapProjectFromCwd: Option.none(),
49 logWebSocketEvents: Option.none(),48 logWebSocketEvents: Option.none(),
@@ -62,7 +61,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines
62 T3CODE_HOME: baseDir,61 T3CODE_HOME: baseDir,
63 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",62 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
64 T3CODE_NO_BROWSER: "true",63 T3CODE_NO_BROWSER: "true",
65 T3CODE_AUTH_TOKEN: "env-token",
66 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",64 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
67 T3CODE_LOG_WS_EVENTS: "true",65 T3CODE_LOG_WS_EVENTS: "true",
68 },66 },
@@ -85,7 +83,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines
85 staticDir: undefined,83 staticDir: undefined,
86 devUrl: new URL("http://127.0.0.1:5173"),84 devUrl: new URL("http://127.0.0.1:5173"),
87 noBrowser: true,85 noBrowser: true,
88 authToken: "env-token",86 startupPresentation: "browser",
87 desktopBootstrapToken: undefined,
89 autoBootstrapProjectFromCwd: false,88 autoBootstrapProjectFromCwd: false,
90 logWebSocketEvents: true,89 logWebSocketEvents: true,
91 });90 });
@@ -106,7 +105,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 14 unmodified lines
106 cwd: Option.none(),105 cwd: Option.none(),
107 devUrl: Option.some(new URL("http://127.0.0.1:4173")),106 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
108 noBrowser: Option.some(true),107 noBrowser: Option.some(true),
109 authToken: Option.some("flag-token"),
110 bootstrapFd: Option.none(),108 bootstrapFd: Option.none(),
111 autoBootstrapProjectFromCwd: Option.some(true),109 autoBootstrapProjectFromCwd: Option.some(true),
112 logWebSocketEvents: Option.some(true),110 logWebSocketEvents: Option.some(true),
@@ -125,7 +123,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 12 unmodified lines
125 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),123 T3CODE_HOME: join(os.tmpdir(), "ignored-base"),
126 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",124 VITE_DEV_SERVER_URL: "http://127.0.0.1:5173",
127 T3CODE_NO_BROWSER: "false",125 T3CODE_NO_BROWSER: "false",
128 T3CODE_AUTH_TOKEN: "ignored-token",
129 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",126 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "false",
130 T3CODE_LOG_WS_EVENTS: "false",127 T3CODE_LOG_WS_EVENTS: "false",
131 },128 },
@@ -148,13 +145,77 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 16 unmodified lines
148 staticDir: undefined,145 staticDir: undefined,
149 devUrl: new URL("http://127.0.0.1:4173"),146 devUrl: new URL("http://127.0.0.1:4173"),
150 noBrowser: true,147 noBrowser: true,
151 authToken: "flag-token",148 startupPresentation: "browser",
149 desktopBootstrapToken: undefined,
152 autoBootstrapProjectFromCwd: true,150 autoBootstrapProjectFromCwd: true,
153 logWebSocketEvents: true,151 logWebSocketEvents: true,
154 });152 });
155 }),153 }),
156 );154 );
157 155
156 it.effect("preserves explicit false CLI boolean flags over env and bootstrap values", () =>
157 Effect.gen(function* () {
158 const { join } = yield* Path.Path;
159 const baseDir = join(os.tmpdir(), "t3-cli-config-false-flags");
160 const fd = yield* openBootstrapFd({
161 noBrowser: true,
162 autoBootstrapProjectFromCwd: true,
163 logWebSocketEvents: true,
164 });
165 const derivedPaths = yield* deriveServerPaths(baseDir, new URL("http://127.0.0.1:4173"));
166
167 const resolved = yield* resolveServerConfig(
168 {
169 mode: Option.some("web"),
170 port: Option.some(8788),
171 host: Option.some("127.0.0.1"),
172 baseDir: Option.some(baseDir),
173 cwd: Option.none(),
174 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
175 noBrowser: Option.some(false),
176 bootstrapFd: Option.none(),
177 autoBootstrapProjectFromCwd: Option.some(false),
178 logWebSocketEvents: Option.some(false),
179 },
180 Option.none(),
181 ).pipe(
182 Effect.provide(
183 Layer.mergeAll(
184 ConfigProvider.layer(
185 ConfigProvider.fromEnv({
186 env: {
187 T3CODE_BOOTSTRAP_FD: String(fd),
188 T3CODE_NO_BROWSER: "true",
189 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
190 T3CODE_LOG_WS_EVENTS: "true",
191 },
192 }),
193 ),
194 NetService.layer,
195 ),
196 ),
197 );
198
199 expect(resolved).toEqual({
200 logLevel: "Info",
201 ...defaultObservabilityConfig,
202 mode: "web",
203 port: 8788,
204 cwd: process.cwd(),
205 baseDir,
206 ...derivedPaths,
207 host: "127.0.0.1",
208 staticDir: undefined,
209 devUrl: new URL("http://127.0.0.1:4173"),
210 noBrowser: false,
211 startupPresentation: "browser",
212 desktopBootstrapToken: undefined,
213 autoBootstrapProjectFromCwd: false,
214 logWebSocketEvents: false,
215 });
216 }),
217 );
218
158 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>219 it.effect("uses bootstrap envelope values as fallbacks when flags and env are absent", () =>
159 Effect.gen(function* () {220 Effect.gen(function* () {
160 const { join } = yield* Path.Path;221 const { join } = yield* Path.Path;
@@ -166,7 +227,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 5 unmodified lines
166 t3Home: baseDir,227 t3Home: baseDir,
167 devUrl: "http://127.0.0.1:5173",228 devUrl: "http://127.0.0.1:5173",
168 noBrowser: true,229 noBrowser: true,
169 authToken: "bootstrap-token",
170 autoBootstrapProjectFromCwd: false,230 autoBootstrapProjectFromCwd: false,
171 logWebSocketEvents: true,231 logWebSocketEvents: true,
172 otlpTracesUrl: "http://localhost:4318/v1/traces",232 otlpTracesUrl: "http://localhost:4318/v1/traces",
@@ -183,7 +243,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 10 unmodified lines
183 cwd: Option.none(),243 cwd: Option.none(),
184 devUrl: Option.none(),244 devUrl: Option.none(),
185 noBrowser: Option.none(),245 noBrowser: Option.none(),
186 authToken: Option.none(),
187 bootstrapFd: Option.none(),246 bootstrapFd: Option.none(),
188 autoBootstrapProjectFromCwd: Option.none(),247 autoBootstrapProjectFromCwd: Option.none(),
189 logWebSocketEvents: Option.none(),248 logWebSocketEvents: Option.none(),
@@ -218,7 +277,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 28 unmodified lines
218 staticDir: undefined,277 staticDir: undefined,
219 devUrl: new URL("http://127.0.0.1:5173"),278 devUrl: new URL("http://127.0.0.1:5173"),
220 noBrowser: true,279 noBrowser: true,
221 authToken: "bootstrap-token",280 startupPresentation: "browser",
281 desktopBootstrapToken: undefined,
222 autoBootstrapProjectFromCwd: false,282 autoBootstrapProjectFromCwd: false,
223 logWebSocketEvents: true,283 logWebSocketEvents: true,
224 });284 });
@@ -242,7 +302,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 17 unmodified lines
242 cwd: Option.some(customCwd),302 cwd: Option.some(customCwd),
243 devUrl: Option.some(new URL("http://127.0.0.1:5173")),303 devUrl: Option.some(new URL("http://127.0.0.1:5173")),
244 noBrowser: Option.none(),304 noBrowser: Option.none(),
245 authToken: Option.none(),
246 bootstrapFd: Option.none(),305 bootstrapFd: Option.none(),
247 autoBootstrapProjectFromCwd: Option.none(),306 autoBootstrapProjectFromCwd: Option.none(),
248 logWebSocketEvents: Option.none(),307 logWebSocketEvents: Option.none(),
@@ -285,7 +344,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 36 unmodified lines
285 t3Home: "/tmp/t3-bootstrap-home",344 t3Home: "/tmp/t3-bootstrap-home",
286 devUrl: "http://127.0.0.1:5173",345 devUrl: "http://127.0.0.1:5173",
287 noBrowser: false,346 noBrowser: false,
288 authToken: "bootstrap-token",
289 autoBootstrapProjectFromCwd: false,347 autoBootstrapProjectFromCwd: false,
290 logWebSocketEvents: false,348 logWebSocketEvents: false,
291 });349 });
@@ -300,7 +358,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 8 unmodified lines
300 cwd: Option.none(),358 cwd: Option.none(),
301 devUrl: Option.some(new URL("http://127.0.0.1:4173")),359 devUrl: Option.some(new URL("http://127.0.0.1:4173")),
302 noBrowser: Option.none(),360 noBrowser: Option.none(),
303 authToken: Option.some("flag-token"),
304 bootstrapFd: Option.none(),361 bootstrapFd: Option.none(),
305 autoBootstrapProjectFromCwd: Option.none(),362 autoBootstrapProjectFromCwd: Option.none(),
306 logWebSocketEvents: Option.none(),363 logWebSocketEvents: Option.none(),
@@ -338,7 +395,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 31 unmodified lines
338 staticDir: undefined,395 staticDir: undefined,
339 devUrl: new URL("http://127.0.0.1:4173"),396 devUrl: new URL("http://127.0.0.1:4173"),
340 noBrowser: true,397 noBrowser: true,
341 authToken: "flag-token",398 startupPresentation: "browser",
399 desktopBootstrapToken: undefined,
342 autoBootstrapProjectFromCwd: true,400 autoBootstrapProjectFromCwd: true,
343 logWebSocketEvents: true,401 logWebSocketEvents: true,
344 });402 });
@@ -371,7 +429,6 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 26 unmodified lines
371 cwd: Option.none(),429 cwd: Option.none(),
372 devUrl: Option.none(),430 devUrl: Option.none(),
373 noBrowser: Option.none(),431 noBrowser: Option.none(),
374 authToken: Option.none(),
375 bootstrapFd: Option.none(),432 bootstrapFd: Option.none(),
376 autoBootstrapProjectFromCwd: Option.none(),433 autoBootstrapProjectFromCwd: Option.none(),
377 logWebSocketEvents: Option.none(),434 logWebSocketEvents: Option.none(),
@@ -402,7 +459,67 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { · 24 unmodified lines
402 staticDir: resolved.staticDir,459 staticDir: resolved.staticDir,
403 devUrl: undefined,460 devUrl: undefined,
404 noBrowser: true,461 noBrowser: true,
405 authToken: undefined,462 startupPresentation: "browser",
463 desktopBootstrapToken: undefined,
464 autoBootstrapProjectFromCwd: false,
465 logWebSocketEvents: false,
466 });
467 }),
468 );
469
470 it.effect("forces noBrowser and disables auto-bootstrap for headless startup presentation", () =>
471 Effect.gen(function* () {
472 const { join } = yield* Path.Path;
473 const baseDir = join(os.tmpdir(), "t3-cli-config-headless-base");
474 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
475
476 const resolved = yield* resolveServerConfig(
477 {
478 mode: Option.some("web"),
479 port: Option.some(3773),
480 host: Option.none(),
481 baseDir: Option.some(baseDir),
482 cwd: Option.none(),
483 devUrl: Option.none(),
484 noBrowser: Option.none(),
485 bootstrapFd: Option.none(),
486 autoBootstrapProjectFromCwd: Option.none(),
487 logWebSocketEvents: Option.none(),
488 },
489 Option.none(),
490 {
491 startupPresentation: "headless",
492 },
493 ).pipe(
494 Effect.provide(
495 Layer.mergeAll(
496 ConfigProvider.layer(
497 ConfigProvider.fromEnv({
498 env: {
499 T3CODE_NO_BROWSER: "false",
500 T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD: "true",
501 },
502 }),
503 ),
504 NetService.layer,
505 ),
506 ),
507 );
508
509 expect(resolved).toEqual({
510 logLevel: "Info",
511 ...defaultObservabilityConfig,
512 mode: "web",
513 port: 3773,
514 cwd: process.cwd(),
515 baseDir,
516 ...derivedPaths,
517 host: undefined,
518 staticDir: resolved.staticDir,
519 devUrl: undefined,
520 noBrowser: true,
521 startupPresentation: "headless",
522 desktopBootstrapToken: undefined,
406 autoBootstrapProjectFromCwd: false,523 autoBootstrapProjectFromCwd: false,
407 logWebSocketEvents: false,524 logWebSocketEvents: false,
408 });525 });
apps/server/src/cli.test.ts+326−7
@@ -1,28 +1,164 @@
1import * as NodeHttp from "node:http";
2import { mkdtempSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5
6import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
1import * as NodeServices from "@effect/platform-node/NodeServices";7import * as NodeServices from "@effect/platform-node/NodeServices";
2import { NetService } from "@t3tools/shared/Net";8import { NetService } from "@t3tools/shared/Net";
3import { assert, it } from "@effect/vitest";9import { assert, it } from "@effect/vitest";
4import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";11import * as Layer from "effect/Layer";
12import * as HttpRouter from "effect/unstable/http/HttpRouter";
13import * as HttpServer from "effect/unstable/http/HttpServer";
6import * as CliError from "effect/unstable/cli/CliError";14import * as CliError from "effect/unstable/cli/CliError";
15import * as TestConsole from "effect/testing/TestConsole";
7import { Command } from "effect/unstable/cli";16import { Command } from "effect/unstable/cli";
8 17
9import { cli } from "./cli.ts";18import { cli } from "./cli.ts";
19import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "./config.ts";
20import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
21import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
22import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
23import {
24 orchestrationDispatchRouteLayer,
25 orchestrationSnapshotRouteLayer,
26} from "./orchestration/http.ts";
27import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
28import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
29import {
30 makePersistedServerRuntimeState,
31 persistServerRuntimeState,
32} from "./serverRuntimeState.ts";
33import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
34import { ServerSecretStoreLive } from "./auth/Layers/ServerSecretStore.ts";
35import { ServerAuthLive } from "./auth/Layers/ServerAuth.ts";
10 36
11const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);37const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer);
12 38
39const runCli = (args: ReadonlyArray<string>) => Command.runWith(cli, { version: "0.0.0" })(args);
40const runCliWithRuntime = (args: ReadonlyArray<string>) =>
41 runCli(args).pipe(Effect.provide(CliRuntimeLayer));
42
43const captureStdout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
44 Effect.gen(function* () {
45 const result = yield* effect;
46 const output =
47 (yield* TestConsole.logLines).findLast((line): line is string => typeof line === "string") ??
48 "";
49 return { result, output };
50 }).pipe(Effect.provide(Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)));
51
52const makeCliTestServerConfig = (baseDir: string) =>
53 Effect.gen(function* () {
54 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
55 return {
56 logLevel: "Info",
57 traceMinLevel: "Info",
58 traceTimingEnabled: true,
59 traceBatchWindowMs: 200,
60 traceMaxBytes: 10 * 1024 * 1024,
61 traceMaxFiles: 10,
62 otlpTracesUrl: undefined,
63 otlpMetricsUrl: undefined,
64 otlpExportIntervalMs: 10_000,
65 otlpServiceName: "t3-server",
66 mode: "web",
67 port: 0,
68 host: "127.0.0.1",
69 cwd: process.cwd(),
70 baseDir,
71 ...derivedPaths,
72 staticDir: undefined,
73 devUrl: undefined,
74 noBrowser: true,
75 startupPresentation: "browser",
76 desktopBootstrapToken: undefined,
77 autoBootstrapProjectFromCwd: false,
78 logWebSocketEvents: false,
79 } satisfies ServerConfigShape;
80 });
81
82const makeProjectPersistenceLayer = (config: ServerConfigShape) =>
83 Layer.mergeAll(
84 OrchestrationLayerLive.pipe(
85 Layer.provideMerge(RepositoryIdentityResolverLive),
86 Layer.provideMerge(SqlitePersistenceLayerLive),
87 ),
88 WorkspacePathsLive,
89 ).pipe(
90 Layer.provideMerge(NodeServices.layer),
91 Layer.provide(Layer.succeed(ServerConfig, config)),
92 );
93
94const readPersistedSnapshot = (baseDir: string) =>
95 Effect.gen(function* () {
96 const config = yield* makeCliTestServerConfig(baseDir);
97 return yield* Effect.gen(function* () {
98 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
99 return yield* projectionSnapshotQuery.getSnapshot();
100 }).pipe(Effect.provide(makeProjectPersistenceLayer(config)));
101 });
102
103const withLiveProjectCliServer = <A, E, R>(baseDir: string, run: () => Effect.Effect<A, E, R>) =>
104 Effect.gen(function* () {
105 const config = yield* makeCliTestServerConfig(baseDir);
106 const routesLayer = Layer.mergeAll(
107 orchestrationSnapshotRouteLayer,
108 orchestrationDispatchRouteLayer,
109 );
110 const appLayer = HttpRouter.serve(routesLayer, {
111 disableListenLog: true,
112 disableLogger: true,
113 }).pipe(
114 Layer.provideMerge(
115 ServerAuthLive.pipe(
116 Layer.provideMerge(SqlitePersistenceLayerLive),
117 Layer.provide(ServerSecretStoreLive),
118 ),
119 ),
120 Layer.provideMerge(makeProjectPersistenceLayer(config)),
121 Layer.provideMerge(
122 NodeHttpServer.layer(NodeHttp.createServer, {
123 host: "127.0.0.1",
124 port: 0,
125 }),
126 ),
127 Layer.provideMerge(NodeServices.layer),
128 Layer.provide(Layer.succeed(ServerConfig, config)),
129 );
130
131 return yield* Effect.scoped(
132 Effect.gen(function* () {
133 const server = yield* HttpServer.HttpServer;
134 const address = server.address;
135 if (typeof address === "string" || !("port" in address)) {
136 assert.fail(`Expected TCP address, got ${address}`);
137 }
138 yield* persistServerRuntimeState({
139 path: config.serverRuntimeStatePath,
140 state: makePersistedServerRuntimeState({
141 config,
142 port: address.port,
143 }),
144 });
145 return yield* run();
146 }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))),
147 );
148 });
149
13it.layer(NodeServices.layer)("cli log-level parsing", (it) => {150it.layer(NodeServices.layer)("cli log-level parsing", (it) => {
14 it.effect("accepts the built-in lowercase log-level flag values", () =>151 it.effect("accepts the built-in lowercase log-level flag values", () =>
15 Command.runWith(cli, { version: "0.0.0" })(["--log-level", "debug", "--version"]).pipe(152 runCliWithRuntime(["--log-level", "debug", "--version"]),
16 Effect.provide(CliRuntimeLayer),153 );
17 ),154
155 it.effect("accepts canonical --no-<flag> boolean negation", () =>
156 runCliWithRuntime(["--no-log-websocket-events", "--version"]),
18 );157 );
19 158
20 it.effect("rejects invalid log-level casing before launching the server", () =>159 it.effect("rejects invalid log-level casing before launching the server", () =>
21 Effect.gen(function* () {160 Effect.gen(function* () {
22 const error = yield* Command.runWith(cli, { version: "0.0.0" })([161 const error = yield* runCliWithRuntime(["--log-level", "Debug"]).pipe(Effect.flip);
23 "--log-level",
24 "Debug",
25 ]).pipe(Effect.provide(CliRuntimeLayer), Effect.flip);
26 162
27 if (!CliError.isCliError(error)) {163 if (!CliError.isCliError(error)) {
28 assert.fail(`Expected CliError, got ${String(error)}`);164 assert.fail(`Expected CliError, got ${String(error)}`);
@@ -34,4 +170,187 @@ it.layer(NodeServices.layer)("cli log-level parsing", (it) => { · 5 unmodified lines
34 assert.equal(error.value, "Debug");170 assert.equal(error.value, "Debug");
35 }),171 }),
36 );172 );
173
174 it.effect("executes auth pairing subcommands and redacts secrets from list output", () =>
175 Effect.gen(function* () {
176 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-pairing-test-"));
177
178 const createdOutput = yield* captureStdout(
179 runCli(["auth", "pairing", "create", "--base-dir", baseDir, "--json"]),
180 );
181 const created = JSON.parse(createdOutput.output) as {
182 readonly id: string;
183 readonly credential: string;
184 };
185 const listedOutput = yield* captureStdout(
186 runCli(["auth", "pairing", "list", "--base-dir", baseDir, "--json"]),
187 );
188 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
189 readonly id: string;
190 readonly credential?: string;
191 }>;
192
193 assert.equal(typeof created.id, "string");
194 assert.equal(typeof created.credential, "string");
195 assert.equal(created.credential.length > 0, true);
196 assert.equal(listed.length, 1);
197 assert.equal(listed[0]?.id, created.id);
198 assert.equal("credential" in (listed[0] ?? {}), false);
199 }),
200 );
201
202 it.effect("executes auth session subcommands and redacts secrets from list output", () =>
203 Effect.gen(function* () {
204 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-auth-session-test-"));
205
206 const issuedOutput = yield* captureStdout(
207 runCli(["auth", "session", "issue", "--base-dir", baseDir, "--json"]),
208 );
209 const issued = JSON.parse(issuedOutput.output) as {
210 readonly sessionId: string;
211 readonly token: string;
212 readonly role: string;
213 };
214 const listedOutput = yield* captureStdout(
215 runCli(["auth", "session", "list", "--base-dir", baseDir, "--json"]),
216 );
217 const listed = JSON.parse(listedOutput.output) as ReadonlyArray<{
218 readonly sessionId: string;
219 readonly token?: string;
220 readonly role: string;
221 }>;
222
223 assert.equal(typeof issued.sessionId, "string");
224 assert.equal(typeof issued.token, "string");
225 assert.equal(issued.role, "owner");
226 assert.equal(listed.length, 1);
227 assert.equal(listed[0]?.sessionId, issued.sessionId);
228 assert.equal(listed[0]?.role, "owner");
229 assert.equal("token" in (listed[0] ?? {}), false);
230 }),
231 );
232
233 it.effect("rejects invalid ttl values before running auth commands", () =>
234 Effect.gen(function* () {
235 const error = yield* runCliWithRuntime(["auth", "pairing", "create", "--ttl", "soon"]).pipe(
236 Effect.flip,
237 );
238
239 if (!CliError.isCliError(error)) {
240 assert.fail(`Expected CliError, got ${String(error)}`);
241 }
242 if (error._tag !== "ShowHelp") {
243 assert.fail(`Expected ShowHelp, got ${error._tag}`);
244 }
245 assert.deepEqual(error.commandPath, ["t3", "auth", "pairing", "create"]);
246 const ttlError = error.errors[0] as CliError.CliError | undefined;
247 if (!ttlError || ttlError._tag !== "InvalidValue") {
248 assert.fail(`Expected InvalidValue, got ${String(ttlError?._tag)}`);
249 }
250 assert.equal(ttlError.option, "ttl");
251 assert.equal(ttlError.value, "soon");
252 assert.isTrue(ttlError.message.includes("Invalid duration"));
253 assert.isTrue(ttlError.message.includes("5m, 1h, 30d, or 15 minutes"));
254 }),
255 );
256
257 it.effect("adds, renames, and removes projects offline through the orchestration engine", () =>
258 Effect.gen(function* () {
259 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-offline-test-"));
260 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-workspace-"));
261
262 yield* runCliWithRuntime([
263 "project",
264 "add",
265 workspaceRoot,
266 "--title",
267 "Alpha",
268 "--base-dir",
269 baseDir,
270 ]);
271 const afterAdd = yield* readPersistedSnapshot(baseDir);
272 const addedProject = afterAdd.projects.find(
273 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
274 );
275 assert.isTrue(addedProject !== undefined);
276 assert.equal(addedProject?.title, "Alpha");
277
278 yield* runCliWithRuntime(["project", "rename", workspaceRoot, "Beta", "--base-dir", baseDir]);
279 const afterRename = yield* readPersistedSnapshot(baseDir);
280 const renamedProject = afterRename.projects.find(
281 (project) => project.id === addedProject?.id,
282 );
283 assert.equal(renamedProject?.title, "Beta");
284 assert.equal(renamedProject?.deletedAt, null);
285
286 yield* runCliWithRuntime([
287 "project",
288 "remove",
289 addedProject?.id ?? "",
290 "--base-dir",
291 baseDir,
292 ]);
293 const afterRemove = yield* readPersistedSnapshot(baseDir);
294 const removedProject = afterRemove.projects.find(
295 (project) => project.id === addedProject?.id,
296 );
297 assert.isTrue((removedProject?.deletedAt ?? null) !== null);
298 }),
299 );
300
301 it.effect("routes project commands through a running server when runtime state is present", () =>
302 Effect.gen(function* () {
303 const baseDir = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-test-"));
304 const workspaceRoot = mkdtempSync(join(tmpdir(), "t3-cli-projects-live-workspace-"));
305
306 yield* withLiveProjectCliServer(baseDir, () =>
307 Effect.gen(function* () {
308 yield* runCliWithRuntime([
309 "project",
310 "add",
311 workspaceRoot,
312 "--title",
313 "Live Project",
314 "--base-dir",
315 baseDir,
316 ]);
317 const orchestrationEngine = yield* OrchestrationEngineService;
318 const readModel = yield* orchestrationEngine.getReadModel();
319 const addedProject = readModel.projects.find(
320 (project) => project.workspaceRoot === workspaceRoot && project.deletedAt === null,
321 );
322 assert.isTrue(addedProject !== undefined);
323 assert.equal(addedProject?.title, "Live Project");
324 }),
325 );
326 }),
327 );
328
329 it.effect("rejects dev-url on project commands", () =>
330 Effect.gen(function* () {
331 const workspaceRoot = mkdtempSync(
332 join(tmpdir(), "t3-cli-projects-unknown-option-workspace-"),
333 );
334 const error = yield* runCliWithRuntime([
335 "project",
336 "add",
337 workspaceRoot,
338 "--dev-url",
339 "http://127.0.0.1:5173",
340 ]).pipe(Effect.flip);
341
342 if (!CliError.isCliError(error)) {
343 assert.fail(`Expected CliError, got ${String(error)}`);
344 }
345 if (error._tag !== "ShowHelp") {
346 assert.fail(`Expected ShowHelp, got ${error._tag}`);
347 }
348 assert.deepEqual(error.commandPath, ["t3", "project", "add"]);
349 const optionError = error.errors[0] as CliError.CliError | undefined;
350 if (!optionError || optionError._tag !== "UnrecognizedOption") {
351 assert.fail(`Expected UnrecognizedOption, got ${String(optionError?._tag)}`);
352 }
353 assert.equal(optionError.option, "--dev-url");
354 }),
355 );
37});356});
apps/server/src/cli.ts+854−89
@@ -1,7 +1,35 @@
1import { NetService } from "@t3tools/shared/Net";1import { NetService } from "@t3tools/shared/Net";
2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";2import { parsePersistedServerObservabilitySettings } from "@t3tools/shared/serverSettings";
3import { Config, Effect, FileSystem, LogLevel, Option, Path, Schema } from "effect";3import {
4 AuthSessionId,
5 CommandId,
6 OrchestrationReadModel,
7 ProjectId,
8 type ClientOrchestrationCommand,
9} from "@t3tools/contracts";
10import {
11 Config,
12 Console,
13 Duration,
14 Effect,
15 Exit,
16 FileSystem,
17 Layer,
18 LogLevel,
19 Option,
20 Path,
21 References,
22 Schema,
23 SchemaIssue,
24 SchemaTransformation,
25} from "effect";
4import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";26import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli";
27import {
28 FetchHttpClient,
29 HttpClient,
30 HttpClientRequest,
31 HttpClientResponse,
32} from "effect/unstable/http";
5 33
6import {34import {
7 DEFAULT_PORT,35 DEFAULT_PORT,
@@ -11,10 +39,32 @@ import { · 3 unmodified lines
11 ServerConfig,39 ServerConfig,
12 RuntimeMode,40 RuntimeMode,
13 type ServerConfigShape,41 type ServerConfigShape,
14} from "./config";42 type StartupPresentation,
15import { readBootstrapEnvelope } from "./bootstrap";43} from "./config.ts";
16import { expandHomePath, resolveBaseDir } from "./os-jank";44import { readBootstrapEnvelope } from "./bootstrap.ts";
17import { runServer } from "./server";45import { expandHomePath, resolveBaseDir } from "./os-jank.ts";
46import { runServer } from "./server.ts";
47import { AuthControlPlaneRuntimeLive } from "./auth/Layers/AuthControlPlane.ts";
48import {
49 formatIssuedPairingCredential,
50 formatIssuedSession,
51 formatPairingCredentialList,
52 formatSessionList,
53} from "./cliAuthFormat.ts";
54import { AuthControlPlane } from "./auth/Services/AuthControlPlane.ts";
55import type { AuthControlPlaneShape } from "./auth/Services/AuthControlPlane.ts";
56import { OrchestrationEngineService } from "./orchestration/Services/OrchestrationEngine.ts";
57import { ProjectionSnapshotQuery } from "./orchestration/Services/ProjectionSnapshotQuery.ts";
58import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts";
59import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts";
60import { RepositoryIdentityResolverLive } from "./project/Layers/RepositoryIdentityResolver.ts";
61import { getAutoBootstrapDefaultModelSelection } from "./serverRuntimeStartup.ts";
62import {
63 clearPersistedServerRuntimeState,
64 readPersistedServerRuntimeState,
65} from "./serverRuntimeState.ts";
66import { WorkspacePaths } from "./workspace/Services/WorkspacePaths.ts";
67import { WorkspacePathsLive } from "./workspace/Layers/WorkspacePaths.ts";
18 68
19const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));69const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }));
20 70
@@ -25,7 +75,7 @@ const BootstrapEnvelopeSchema = Schema.Struct({ · 4 unmodified lines
25 t3Home: Schema.optional(Schema.String),75 t3Home: Schema.optional(Schema.String),
26 devUrl: Schema.optional(Schema.URLFromString),76 devUrl: Schema.optional(Schema.URLFromString),
27 noBrowser: Schema.optional(Schema.Boolean),77 noBrowser: Schema.optional(Schema.Boolean),
28 authToken: Schema.optional(Schema.String),78 desktopBootstrapToken: Schema.optional(Schema.String),
29 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),79 autoBootstrapProjectFromCwd: Schema.optional(Schema.Boolean),
30 logWebSocketEvents: Schema.optional(Schema.Boolean),80 logWebSocketEvents: Schema.optional(Schema.Boolean),
31 otlpTracesUrl: Schema.optional(Schema.String),81 otlpTracesUrl: Schema.optional(Schema.String),
@@ -58,11 +108,6 @@ const noBrowserFlag = Flag.boolean("no-browser").pipe( · 26 unmodified lines
58 Flag.withDescription("Disable automatic browser opening."),108 Flag.withDescription("Disable automatic browser opening."),
59 Flag.optional,109 Flag.optional,
60);110);
61const authTokenFlag = Flag.string("auth-token").pipe(
62 Flag.withDescription("Auth token required for WebSocket connections."),
63 Flag.withAlias("token"),
64 Flag.optional,
65);
66const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(111const bootstrapFdFlag = Flag.integer("bootstrap-fd").pipe(
67 Flag.withSchema(Schema.Int),112 Flag.withSchema(Schema.Int),
68 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),113 Flag.withDescription("Read one-time bootstrap secrets from the given file descriptor."),
@@ -117,10 +162,6 @@ const EnvServerConfig = Config.all({ · 48 unmodified lines
117 Config.option,162 Config.option,
118 Config.map(Option.getOrUndefined),163 Config.map(Option.getOrUndefined),
119 ),164 ),
120 authToken: Config.string("T3CODE_AUTH_TOKEN").pipe(
121 Config.option,
122 Config.map(Option.getOrUndefined),
123 ),
124 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(165 bootstrapFd: Config.int("T3CODE_BOOTSTRAP_FD").pipe(
125 Config.option,166 Config.option,
126 Config.map(Option.getOrUndefined),167 Config.map(Option.getOrUndefined),
@@ -143,14 +184,15 @@ interface CliServerFlags { · 16 unmodified lines
143 readonly cwd: Option.Option<string>;184 readonly cwd: Option.Option<string>;
144 readonly devUrl: Option.Option<URL>;185 readonly devUrl: Option.Option<URL>;
145 readonly noBrowser: Option.Option<boolean>;186 readonly noBrowser: Option.Option<boolean>;
146 readonly authToken: Option.Option<string>;
147 readonly bootstrapFd: Option.Option<number>;187 readonly bootstrapFd: Option.Option<number>;
148 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;188 readonly autoBootstrapProjectFromCwd: Option.Option<boolean>;
149 readonly logWebSocketEvents: Option.Option<boolean>;189 readonly logWebSocketEvents: Option.Option<boolean>;
150}190}
151 191
152const resolveBooleanFlag = (flag: Option.Option<boolean>, envValue: boolean) =>192interface CliAuthLocationFlags {
153 Option.getOrElse(Option.filter(flag, Boolean), () => envValue);193 readonly baseDir: Option.Option<string>;
194 readonly devUrl?: Option.Option<URL>;
195}
154 196
155const resolveOptionPrecedence = <Value>(197const resolveOptionPrecedence = <Value>(
156 ...values: ReadonlyArray<Option.Option<Value>>198 ...values: ReadonlyArray<Option.Option<Value>>
@@ -170,32 +212,49 @@ const loadPersistedObservabilitySettings = Effect.fn(function* (settingsPath: st · 13 unmodified lines
170export const resolveServerConfig = (212export const resolveServerConfig = (
171 flags: CliServerFlags,213 flags: CliServerFlags,
172 cliLogLevel: Option.Option<LogLevel.LogLevel>,214 cliLogLevel: Option.Option<LogLevel.LogLevel>,
215 options?: {
216 readonly startupPresentation?: StartupPresentation;
217 readonly forceAutoBootstrapProjectFromCwd?: boolean;
218 },
173) =>219) =>
174 Effect.gen(function* () {220 Effect.gen(function* () {
175 const { findAvailablePort } = yield* NetService;221 const { findAvailablePort } = yield* NetService;
176 const path = yield* Path.Path;222 const path = yield* Path.Path;
177 const fs = yield* FileSystem.FileSystem;223 const fs = yield* FileSystem.FileSystem;
178 const env = yield* EnvServerConfig;224 const env = yield* EnvServerConfig;
179 const bootstrapFd = Option.getOrUndefined(flags.bootstrapFd) ?? env.bootstrapFd;225 const normalizedFlags = {
226 mode: flags.mode ?? Option.none(),
227 port: flags.port ?? Option.none(),
228 host: flags.host ?? Option.none(),
229 baseDir: flags.baseDir ?? Option.none(),
230 cwd: flags.cwd ?? Option.none(),
231 devUrl: flags.devUrl ?? Option.none(),
232 noBrowser: flags.noBrowser ?? Option.none(),
233 bootstrapFd: flags.bootstrapFd ?? Option.none(),
234 autoBootstrapProjectFromCwd: flags.autoBootstrapProjectFromCwd ?? Option.none(),
235 logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(),
236 } satisfies CliServerFlags;
237 const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd;
180 const bootstrapEnvelope =238 const bootstrapEnvelope =
181 bootstrapFd !== undefined239 bootstrapFd !== undefined
182 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)240 ? yield* readBootstrapEnvelope(BootstrapEnvelopeSchema, bootstrapFd)
183 : Option.none();241 : Option.none();
242 const bootstrap = Option.getOrUndefined(bootstrapEnvelope);
184 243
185 const mode: RuntimeMode = Option.getOrElse(244 const mode: RuntimeMode = Option.getOrElse(
186 resolveOptionPrecedence(245 resolveOptionPrecedence(
187 flags.mode,246 normalizedFlags.mode,
188 Option.fromUndefinedOr(env.mode),247 Option.fromUndefinedOr(env.mode),
189 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.mode)),248 Option.fromUndefinedOr(bootstrap?.mode),
190 ),249 ),
191 () => "web",250 () => "web",
192 );251 );
193 252
194 const port = yield* Option.match(253 const port = yield* Option.match(
195 resolveOptionPrecedence(254 resolveOptionPrecedence(
196 flags.port,255 normalizedFlags.port,
197 Option.fromUndefinedOr(env.port),256 Option.fromUndefinedOr(env.port),
198 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.port)),257 Option.fromUndefinedOr(bootstrap?.port),
199 ),258 ),
200 {259 {
201 onSome: (value) => Effect.succeed(value),260 onSome: (value) => Effect.succeed(value),
@@ -209,24 +268,22 @@ export const resolveServerConfig = ( · 7 unmodified lines
209 );268 );
210 const devUrl = Option.getOrElse(269 const devUrl = Option.getOrElse(
211 resolveOptionPrecedence(270 resolveOptionPrecedence(
212 flags.devUrl,271 normalizedFlags.devUrl,
213 Option.fromUndefinedOr(env.devUrl),272 Option.fromUndefinedOr(env.devUrl),
214 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.devUrl)),273 Option.fromUndefinedOr(bootstrap?.devUrl),
215 ),274 ),
216 () => undefined,275 () => undefined,
217 );276 );
218 const baseDir = yield* resolveBaseDir(277 const baseDir = yield* resolveBaseDir(
219 Option.getOrUndefined(278 Option.getOrUndefined(
220 resolveOptionPrecedence(279 resolveOptionPrecedence(
221 flags.baseDir,280 normalizedFlags.baseDir,
222 Option.fromUndefinedOr(env.t3Home),281 Option.fromUndefinedOr(env.t3Home),
223 Option.flatMap(bootstrapEnvelope, (bootstrap) =>282 Option.fromUndefinedOr(bootstrap?.t3Home),
224 Option.fromUndefinedOr(bootstrap.t3Home),
225 ),
226 ),283 ),
227 ),284 ),
228 );285 );
229 const rawCwd = Option.getOrElse(flags.cwd, () => process.cwd());286 const rawCwd = Option.getOrElse(normalizedFlags.cwd, () => process.cwd());
230 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));287 const cwd = path.resolve(yield* expandHomePath(rawCwd.trim()));
231 yield* fs.makeDirectory(cwd, { recursive: true });288 yield* fs.makeDirectory(cwd, { recursive: true });
232 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);289 const derivedPaths = yield* deriveServerPaths(baseDir, devUrl);
@@ -236,57 +293,42 @@ export const resolveServerConfig = ( · 3 unmodified lines
236 );293 );
237 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;294 const serverTracePath = env.traceFile ?? derivedPaths.serverTracePath;
238 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });295 yield* fs.makeDirectory(path.dirname(serverTracePath), { recursive: true });
239 const noBrowser = resolveBooleanFlag(296 const startupPresentation = options?.startupPresentation ?? "browser";
240 flags.noBrowser,297 const isHeadlessStartup = startupPresentation === "headless";
241 Option.getOrElse(298 const noBrowser = Option.getOrElse(
242 resolveOptionPrecedence(
243 Option.fromUndefinedOr(env.noBrowser),
244 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
245 Option.fromUndefinedOr(bootstrap.noBrowser),
246 ),
247 ),
248 () => mode === "desktop",
249 ),
250 );
251 const authToken = Option.getOrUndefined(
252 resolveOptionPrecedence(299 resolveOptionPrecedence(
253 flags.authToken,300 isHeadlessStartup ? Option.some(true) : Option.none(),
254 Option.fromUndefinedOr(env.authToken),301 normalizedFlags.noBrowser,
255 Option.flatMap(bootstrapEnvelope, (bootstrap) =>302 Option.fromUndefinedOr(env.noBrowser),
256 Option.fromUndefinedOr(bootstrap.authToken),303 Option.fromUndefinedOr(bootstrap?.noBrowser),
257 ),
258 ),304 ),
305 () => mode === "desktop",
259 );306 );
260 const autoBootstrapProjectFromCwd = resolveBooleanFlag(307 const desktopBootstrapToken = bootstrap?.desktopBootstrapToken;
261 flags.autoBootstrapProjectFromCwd,308 const autoBootstrapProjectFromCwd = Option.getOrElse(
262 Option.getOrElse(309 resolveOptionPrecedence(
263 resolveOptionPrecedence(310 Option.fromUndefinedOr(options?.forceAutoBootstrapProjectFromCwd),
264 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),311 isHeadlessStartup ? Option.some(false) : Option.none(),
265 Option.flatMap(bootstrapEnvelope, (bootstrap) =>312 normalizedFlags.autoBootstrapProjectFromCwd,
266 Option.fromUndefinedOr(bootstrap.autoBootstrapProjectFromCwd),313 Option.fromUndefinedOr(env.autoBootstrapProjectFromCwd),
267 ),314 Option.fromUndefinedOr(bootstrap?.autoBootstrapProjectFromCwd),
268 ),
269 () => mode === "web",
270 ),315 ),
316 () => mode === "web",
271 );317 );
272 const logWebSocketEvents = resolveBooleanFlag(318 const logWebSocketEvents = Option.getOrElse(
273 flags.logWebSocketEvents,319 resolveOptionPrecedence(
274 Option.getOrElse(320 normalizedFlags.logWebSocketEvents,
275 resolveOptionPrecedence(321 Option.fromUndefinedOr(env.logWebSocketEvents),
276 Option.fromUndefinedOr(env.logWebSocketEvents),322 Option.fromUndefinedOr(bootstrap?.logWebSocketEvents),
277 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
278 Option.fromUndefinedOr(bootstrap.logWebSocketEvents),
279 ),
280 ),
281 () => Boolean(devUrl),
282 ),323 ),
324 () => Boolean(devUrl),
283 );325 );
284 const staticDir = devUrl ? undefined : yield* resolveStaticDir();326 const staticDir = devUrl ? undefined : yield* resolveStaticDir();
285 const host = Option.getOrElse(327 const host = Option.getOrElse(
286 resolveOptionPrecedence(328 resolveOptionPrecedence(
287 flags.host,329 normalizedFlags.host,
288 Option.fromUndefinedOr(env.host),330 Option.fromUndefinedOr(env.host),
289 Option.flatMap(bootstrapEnvelope, (bootstrap) => Option.fromUndefinedOr(bootstrap.host)),331 Option.fromUndefinedOr(bootstrap?.host),
290 ),332 ),
291 () => (mode === "desktop" ? "127.0.0.1" : undefined),333 () => (mode === "desktop" ? "127.0.0.1" : undefined),
292 );334 );
@@ -301,19 +343,11 @@ export const resolveServerConfig = ( · 8 unmodified lines
301 traceMaxFiles: env.traceMaxFiles,343 traceMaxFiles: env.traceMaxFiles,
302 otlpTracesUrl:344 otlpTracesUrl:
303 env.otlpTracesUrl ??345 env.otlpTracesUrl ??
304 Option.getOrUndefined(346 bootstrap?.otlpTracesUrl ??
305 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
306 Option.fromUndefinedOr(bootstrap.otlpTracesUrl),
307 ),
308 ) ??
309 persistedObservabilitySettings.otlpTracesUrl,347 persistedObservabilitySettings.otlpTracesUrl,
310 otlpMetricsUrl:348 otlpMetricsUrl:
311 env.otlpMetricsUrl ??349 env.otlpMetricsUrl ??
312 Option.getOrUndefined(350 bootstrap?.otlpMetricsUrl ??
313 Option.flatMap(bootstrapEnvelope, (bootstrap) =>
314 Option.fromUndefinedOr(bootstrap.otlpMetricsUrl),
315 ),
316 ) ??
317 persistedObservabilitySettings.otlpMetricsUrl,351 persistedObservabilitySettings.otlpMetricsUrl,
318 otlpExportIntervalMs: env.otlpExportIntervalMs,352 otlpExportIntervalMs: env.otlpExportIntervalMs,
319 otlpServiceName: env.otlpServiceName,353 otlpServiceName: env.otlpServiceName,
@@ -327,7 +361,8 @@ export const resolveServerConfig = ( · 7 unmodified lines
327 staticDir,361 staticDir,
328 devUrl,362 devUrl,
329 noBrowser,363 noBrowser,
330 authToken,364 startupPresentation,
365 desktopBootstrapToken,
331 autoBootstrapProjectFromCwd,366 autoBootstrapProjectFromCwd,
332 logWebSocketEvents,367 logWebSocketEvents,
333 };368 };
@@ -335,7 +370,387 @@ export const resolveServerConfig = ( · 1 unmodified line
335 return config;370 return config;
336 });371 });
337 372
338const commandFlags = {373const resolveCliAuthConfig = (
374 flags: CliAuthLocationFlags,
375 cliLogLevel: Option.Option<LogLevel.LogLevel>,
376) =>
377 resolveServerConfig(
378 {
379 mode: Option.none(),
380 port: Option.none(),
381 host: Option.none(),
382 baseDir: flags.baseDir,
383 cwd: Option.none(),
384 devUrl: flags.devUrl ?? Option.none(),
385 noBrowser: Option.none(),
386 bootstrapFd: Option.none(),
387 autoBootstrapProjectFromCwd: Option.none(),
388 logWebSocketEvents: Option.none(),
389 },
390 cliLogLevel,
391 );
392
393const DurationShorthandPattern = /^(?<value>\d+)(?<unit>ms|s|m|h|d|w)$/i;
394
395const parseDurationInput = (value: string): Duration.Duration | null => {
396 const trimmed = value.trim();
397 if (trimmed.length === 0) return null;
398
399 const shorthand = DurationShorthandPattern.exec(trimmed);
400 const normalizedInput = shorthand?.groups
401 ? (() => {
402 const amountText = shorthand.groups.value;
403 const unitText = shorthand.groups.unit;
404 if (typeof amountText !== "string" || typeof unitText !== "string") {
405 return null;
406 }
407
408 const amount = Number.parseInt(amountText, 10);
409 if (!Number.isFinite(amount)) return null;
410
411 switch (unitText.toLowerCase()) {
412 case "ms":
413 return `${amount} millis`;
414 case "s":
415 return `${amount} seconds`;
416 case "m":
417 return `${amount} minutes`;
418 case "h":
419 return `${amount} hours`;
420 case "d":
421 return `${amount} days`;
422 case "w":
423 return `${amount} weeks`;
424 default:
425 return null;
426 }
427 })()
428 : (trimmed as Duration.Input);
429
430 if (normalizedInput === null) return null;
431
432 const decoded = Duration.fromInput(normalizedInput as Duration.Input);
433 return Option.isSome(decoded) ? decoded.value : null;
434};
435
436const DurationFromString = Schema.String.pipe(
437 Schema.decodeTo(
438 Schema.Duration,
439 SchemaTransformation.transformOrFail({
440 decode: (value) => {
441 const duration = parseDurationInput(value);
442 if (duration !== null) {
443 return Effect.succeed(duration);
444 }
445 return Effect.fail(
446 new SchemaIssue.InvalidValue(Option.some(value), {
447 message: "Invalid duration. Use values like 5m, 1h, 30d, or 15 minutes.",
448 }),
449 );
450 },
451 encode: (duration) => Effect.succeed(Duration.format(duration)),
452 }),
453 ),
454);
455
456const runWithAuthControlPlane = <A, E>(
457 flags: CliAuthLocationFlags,
458 run: (authControlPlane: AuthControlPlaneShape) => Effect.Effect<A, E>,
459 options?: {
460 readonly quietLogs?: boolean;
461 },
462) =>
463 Effect.gen(function* () {
464 const logLevel = yield* GlobalFlag.LogLevel;
465 const config = yield* resolveCliAuthConfig(flags, logLevel);
466 const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
467 return yield* Effect.gen(function* () {
468 const authControlPlane = yield* AuthControlPlane;
469 return yield* run(authControlPlane);
470 }).pipe(
471 Effect.provide(
472 Layer.mergeAll(AuthControlPlaneRuntimeLive).pipe(
473 Layer.provide(Layer.succeed(ServerConfig, config)),
474 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
475 ),
476 ),
477 );
478 });
479
480type ProjectMutationTarget = {
481 readonly id: ProjectId;
482 readonly title: string;
483 readonly workspaceRoot: string;
484};
485
486type ProjectCommandExecutionMode = "live" | "offline";
487type ProjectCliDispatchCommand = Extract<
488 ClientOrchestrationCommand,
489 { type: "project.create" | "project.meta.update" | "project.delete" }
490>;
491
492const ProjectCliRuntimeLive = Layer.mergeAll(
493 WorkspacePathsLive,
494 OrchestrationLayerLive.pipe(
495 Layer.provideMerge(RepositoryIdentityResolverLive),
496 Layer.provideMerge(SqlitePersistenceLayerLive),
497 ),
498);
499
500const PROJECT_CLI_LIVE_SERVER_TIMEOUT = Duration.seconds(1);
501const OrchestrationHttpErrorResponse = Schema.Struct({
502 error: Schema.String,
503});
504
505const withProjectCliSessionToken = <A, E, R>(
506 authControlPlane: AuthControlPlaneShape,
507 run: (token: string) => Effect.Effect<A, E, R>,
508) =>
509 Effect.acquireUseRelease(
510 authControlPlane.issueSession({
511 role: "owner",
512 label: "t3 project cli",
513 }),
514 (issued) => run(issued.token),
515 (issued) => authControlPlane.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })),
516 );
517
518const withProjectCliLiveServerTimeout = <A, E, R>(effect: Effect.Effect<A, E, R>) =>
519 effect.pipe(Effect.timeout(PROJECT_CLI_LIVE_SERVER_TIMEOUT));
520
521const runLiveServerRequest = <A, E extends Error, R>(
522 request: HttpClientRequest.HttpClientRequest,
523 handle: (response: HttpClientResponse.HttpClientResponse) => Effect.Effect<A, E, R>,
524) =>
525 Effect.gen(function* () {
526 const httpClient = yield* HttpClient.HttpClient;
527 const response = yield* httpClient.execute(request);
528 return yield* handle(response);
529 }).pipe(withProjectCliLiveServerTimeout);
530
531const decodeOrchestrationReadModelResponse = (response: HttpClientResponse.HttpClientResponse) =>
532 HttpClientResponse.schemaBodyJson(OrchestrationReadModel)(response);
533
534const readErrorMessageFromResponse = (response: HttpClientResponse.HttpClientResponse) =>
535 HttpClientResponse.schemaBodyJson(OrchestrationHttpErrorResponse)(response).pipe(
536 Effect.map((body) => body.error),
537 Effect.catch(() => Effect.succeed(null)),
538 Effect.map((body) => {
539 if (typeof body === "string" && body.trim().length > 0) {
540 return body;
541 }
542 return `Server request failed with status ${response.status}.`;
543 }),
544 );
545
546const normalizeWorkspaceRootForProjectCommand = Effect.fn(
547 "normalizeWorkspaceRootForProjectCommand",
548)(function* (workspaceRoot: string) {
549 const workspacePaths = yield* WorkspacePaths;
550 return yield* workspacePaths.normalizeWorkspaceRoot(workspaceRoot);
551});
552
553const resolveProjectTitle = Effect.fn("resolveProjectTitle")(function* (
554 workspaceRoot: string,
555 explicitTitle?: string,
556) {
557 if (explicitTitle !== undefined) {
558 const trimmed = explicitTitle.trim();
559 if (trimmed.length > 0) {
560 return trimmed;
561 }
562 return yield* Effect.fail(new Error("Project title cannot be empty."));
563 }
564
565 const path = yield* Path.Path;
566 const basename = path.basename(workspaceRoot).trim();
567 return basename.length > 0 ? basename : "project";
568});
569
570const findActiveProjectTarget = Effect.fn("findActiveProjectTarget")(function* (input: {
571 readonly snapshot: OrchestrationReadModel;
572 readonly identifier: string;
573}) {
574 const trimmedIdentifier = input.identifier.trim();
575 if (trimmedIdentifier.length === 0) {
576 return yield* Effect.fail(new Error("Project identifier cannot be empty."));
577 }
578
579 const activeProjects = input.snapshot.projects.filter((project) => project.deletedAt === null);
580 const exactIdMatch = activeProjects.find((project) => project.id === trimmedIdentifier);
581 if (exactIdMatch) {
582 return {
583 id: exactIdMatch.id,
584 title: exactIdMatch.title,
585 workspaceRoot: exactIdMatch.workspaceRoot,
586 } satisfies ProjectMutationTarget;
587 }
588
589 const normalizedWorkspaceRootResult = yield* Effect.exit(
590 normalizeWorkspaceRootForProjectCommand(trimmedIdentifier),
591 );
592 const normalizedWorkspaceRoot = Exit.isSuccess(normalizedWorkspaceRootResult)
593 ? normalizedWorkspaceRootResult.value
594 : null;
595
596 const exactWorkspaceMatch =
597 normalizedWorkspaceRoot === null
598 ? undefined
599 : activeProjects.find((project) => project.workspaceRoot === normalizedWorkspaceRoot);
600
601 const resolved = exactWorkspaceMatch;
602 if (!resolved) {
603 return yield* Effect.fail(new Error(`No active project found for '${trimmedIdentifier}'.`));
604 }
605
606 return {
607 id: resolved.id,
608 title: resolved.title,
609 workspaceRoot: resolved.workspaceRoot,
610 } satisfies ProjectMutationTarget;
611});
612
613const fetchLiveOrchestrationSnapshot = (origin: string, bearerToken: string) =>
614 runLiveServerRequest(
615 HttpClientRequest.get(`${origin}/api/orchestration/snapshot`).pipe(
616 HttpClientRequest.acceptJson,
617 HttpClientRequest.bearerToken(bearerToken),
618 ),
619 HttpClientResponse.matchStatus({
620 "2xx": decodeOrchestrationReadModelResponse,
621 orElse: (response) =>
622 readErrorMessageFromResponse(response).pipe(
623 Effect.flatMap((message) => Effect.fail(new Error(message))),
624 ),
625 }),
626 );
627
628const dispatchLiveOrchestrationCommand = (
629 origin: string,
630 bearerToken: string,
631 command: ProjectCliDispatchCommand,
632) =>
633 HttpClientRequest.post(`${origin}/api/orchestration/dispatch`).pipe(
634 HttpClientRequest.acceptJson,
635 HttpClientRequest.bearerToken(bearerToken),
636 HttpClientRequest.bodyJson(command),
637 Effect.flatMap((request) =>
638 runLiveServerRequest(
639 request,
640 HttpClientResponse.matchStatus({
641 "2xx": () => Effect.void,
642 orElse: (response) =>
643 readErrorMessageFromResponse(response).pipe(
644 Effect.flatMap((message) => Effect.fail(new Error(message))),
645 ),
646 }),
647 ),
648 ),
649 );
650
651const getOfflineSnapshot = Effect.fn("getOfflineSnapshot")(function* () {
652 const projectionSnapshotQuery = yield* ProjectionSnapshotQuery;
653 return yield* projectionSnapshotQuery.getSnapshot();
654});
655
656const tryResolveLiveProjectExecutionMode = Effect.fn("tryResolveLiveProjectExecutionMode")(
657 function* (authControlPlane: AuthControlPlaneShape, config: ServerConfigShape) {
658 const runtimeState = yield* readPersistedServerRuntimeState(config.serverRuntimeStatePath);
659 if (Option.isNone(runtimeState)) {
660 return Option.none<{ readonly origin: string }>();
661 }
662
663 const attempt = withProjectCliSessionToken(authControlPlane, (token) =>
664 fetchLiveOrchestrationSnapshot(runtimeState.value.origin, token).pipe(
665 Effect.as({
666 origin: runtimeState.value.origin,
667 }),
668 ),
669 );
670
671 const attempted = yield* Effect.exit(attempt);
672 if (Exit.isSuccess(attempted)) {
673 return Option.some(attempted.value);
674 }
675
676 yield* clearPersistedServerRuntimeState(config.serverRuntimeStatePath);
677 return Option.none<{ readonly origin: string }>();
678 },
679);
680
681const runProjectMutation = Effect.fn("runProjectMutation")(function* (
682 flags: CliAuthLocationFlags,
683 run: (input: {
684 readonly snapshot: OrchestrationReadModel;
685 readonly dispatch: (
686 command: ProjectCliDispatchCommand,
687 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
688 readonly mode: ProjectCommandExecutionMode;
689 }) => Effect.Effect<
690 string,
691 Error,
692 FileSystem.FileSystem | HttpClient.HttpClient | Path.Path | WorkspacePaths
693 >,
694) {
695 const logLevel = yield* GlobalFlag.LogLevel;
696 const config = yield* resolveCliAuthConfig(flags, logLevel);
697 const minimumLogLevel = config.logLevel;
698
699 return yield* Effect.gen(function* () {
700 const authControlPlane = yield* AuthControlPlane;
701 const liveMode = yield* tryResolveLiveProjectExecutionMode(authControlPlane, config);
702
703 if (Option.isSome(liveMode)) {
704 return yield* withProjectCliSessionToken(authControlPlane, (token) =>
705 Effect.gen(function* () {
706 const snapshot = yield* fetchLiveOrchestrationSnapshot(liveMode.value.origin, token);
707 const output = yield* run({
708 snapshot,
709 dispatch: (command) =>
710 dispatchLiveOrchestrationCommand(liveMode.value.origin, token, command),
711 mode: "live",
712 });
713 yield* Console.log(output);
714 }),
715 );
716 }
717
718 const offlineRuntimeLayer = ProjectCliRuntimeLive.pipe(
719 Layer.provide(Layer.succeed(ServerConfig, config)),
720 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
721 );
722
723 return yield* Effect.gen(function* () {
724 const snapshot = yield* getOfflineSnapshot();
725 const orchestrationEngine = yield* OrchestrationEngineService;
726 const output = yield* run({
727 snapshot,
728 dispatch: (command) => orchestrationEngine.dispatch(command),
729 mode: "offline",
730 });
731 yield* Console.log(output);
732 }).pipe(Effect.provide(offlineRuntimeLayer));
733 }).pipe(
734 Effect.provide(
735 Layer.mergeAll(AuthControlPlaneRuntimeLive, WorkspacePathsLive).pipe(
736 Layer.provideMerge(FetchHttpClient.layer),
737 Layer.provide(Layer.succeed(ServerConfig, config)),
738 Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
739 ),
740 ),
741 );
742});
743
744const sharedServerLocationFlags = {
745 baseDir: baseDirFlag,
746 devUrl: devUrlFlag,
747} as const;
748
749const projectLocationFlags = {
750 baseDir: baseDirFlag,
751} as const;
752
753const sharedServerCommandFlags = {
339 mode: modeFlag,754 mode: modeFlag,
340 port: portFlag,755 port: portFlag,
341 host: hostFlag,756 host: hostFlag,
@@ -348,21 +763,371 @@ const commandFlags = { · 6 unmodified lines
348 ),763 ),
349 devUrl: devUrlFlag,764 devUrl: devUrlFlag,
350 noBrowser: noBrowserFlag,765 noBrowser: noBrowserFlag,
351 authToken: authTokenFlag,
352 bootstrapFd: bootstrapFdFlag,766 bootstrapFd: bootstrapFdFlag,
353 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,767 autoBootstrapProjectFromCwd: autoBootstrapProjectFromCwdFlag,
354 logWebSocketEvents: logWebSocketEventsFlag,768 logWebSocketEvents: logWebSocketEventsFlag,
355} as const;769} as const;
356 770
357const rootCommand = Command.make("t3", commandFlags).pipe(771const authLocationFlags = sharedServerLocationFlags;
772
773const ttlFlag = Flag.string("ttl").pipe(
774 Flag.withSchema(DurationFromString),
775 Flag.withDescription("TTL, for example `5m`, `1h`, `30d`, or `15 minutes`."),
776 Flag.optional,
777);
778
779const jsonFlag = Flag.boolean("json").pipe(
780 Flag.withDescription("Emit JSON instead of human-readable output."),
781 Flag.withDefault(false),
782);
783
784const sessionRoleFlag = Flag.choice("role", ["owner", "client"]).pipe(
785 Flag.withDescription("Role for the issued bearer session."),
786 Flag.withDefault("owner"),
787);
788
789const labelFlag = Flag.string("label").pipe(
790 Flag.withDescription("Optional human-readable label."),
791 Flag.optional,
792);
793
794const subjectFlag = Flag.string("subject").pipe(
795 Flag.withDescription("Optional session subject."),
796 Flag.optional,
797);
798
799const baseUrlFlag = Flag.string("base-url").pipe(
800 Flag.withDescription("Optional public base URL used to print a ready `/pair#token=...` link."),
801 Flag.optional,
802);
803
804const tokenOnlyFlag = Flag.boolean("token-only").pipe(
805 Flag.withDescription("Print only the issued bearer token."),
806 Flag.withDefault(false),
807);
808
809const pairingCreateCommand = Command.make("create", {
810 ...authLocationFlags,
811 ttl: ttlFlag,
812 label: labelFlag,
813 baseUrl: baseUrlFlag,
814 json: jsonFlag,
815}).pipe(
816 Command.withDescription("Issue a new client pairing token."),
817 Command.withHandler((flags) =>
818 runWithAuthControlPlane(
819 flags,
820 (authControlPlane) =>
821 Effect.gen(function* () {
822 const issued = yield* authControlPlane.createPairingLink({
823 role: "client",
824 subject: "one-time-token",
825 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
826 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
827 });
828 const output = formatIssuedPairingCredential(issued, {
829 json: flags.json,
830 ...(Option.isSome(flags.baseUrl) ? { baseUrl: flags.baseUrl.value } : {}),
831 });
832 yield* Console.log(output);
833 }),
834 {
835 quietLogs: flags.json,
836 },
837 ),
838 ),
839);
840
841const pairingListCommand = Command.make("list", {
842 ...authLocationFlags,
843 json: jsonFlag,
844}).pipe(
845 Command.withDescription("List active client pairing tokens without revealing their secrets."),
846 Command.withHandler((flags) =>
847 runWithAuthControlPlane(
848 flags,
849 (authControlPlane) =>
850 Effect.gen(function* () {
851 const pairingLinks = yield* authControlPlane.listPairingLinks({ role: "client" });
852 yield* Console.log(formatPairingCredentialList(pairingLinks, { json: flags.json }));
853 }),
854 {
855 quietLogs: flags.json,
856 },
857 ),
858 ),
859);
860
861const pairingRevokeCommand = Command.make("revoke", {
862 ...authLocationFlags,
863 id: Argument.string("id").pipe(Argument.withDescription("Pairing credential id to revoke.")),
864}).pipe(
865 Command.withDescription("Revoke an active client pairing token."),
866 Command.withHandler((flags) =>
867 runWithAuthControlPlane(flags, (authControlPlane) =>
868 Effect.gen(function* () {
869 const revoked = yield* authControlPlane.revokePairingLink(flags.id);
870 yield* Console.log(
871 revoked
872 ? `Revoked pairing credential ${flags.id}.\n`
873 : `No active pairing credential found for ${flags.id}.\n`,
874 );
875 }),
876 ),
877 ),
878);
879
880const pairingCommand = Command.make("pairing").pipe(
881 Command.withDescription("Manage one-time client pairing tokens."),
882 Command.withSubcommands([pairingCreateCommand, pairingListCommand, pairingRevokeCommand]),
883);
884
885const sessionIssueCommand = Command.make("issue", {
886 ...authLocationFlags,
887 ttl: ttlFlag,
888 role: sessionRoleFlag,
889 label: labelFlag,
890 subject: subjectFlag,
891 tokenOnly: tokenOnlyFlag,
892 json: jsonFlag,
893}).pipe(
894 Command.withDescription("Issue a bearer session token for headless or remote clients."),
895 Command.withHandler((flags) =>
896 runWithAuthControlPlane(
897 flags,
898 (authControlPlane) =>
899 Effect.gen(function* () {
900 const issued = yield* authControlPlane.issueSession({
901 role: flags.role,
902 ...(Option.isSome(flags.ttl) ? { ttl: flags.ttl.value } : {}),
903 ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}),
904 ...(Option.isSome(flags.subject) ? { subject: flags.subject.value } : {}),
905 });
906 yield* Console.log(
907 formatIssuedSession(issued, {
908 json: flags.json,
909 tokenOnly: flags.tokenOnly,
910 }),
911 );
912 }),
913 {
914 quietLogs: flags.json || flags.tokenOnly,
915 },
916 ),
917 ),
918);
919
920const sessionListCommand = Command.make("list", {
921 ...authLocationFlags,
922 json: jsonFlag,
923}).pipe(
924 Command.withDescription("List active sessions without revealing bearer tokens."),
925 Command.withHandler((flags) =>
926 runWithAuthControlPlane(
927 flags,
928 (authControlPlane) =>
929 Effect.gen(function* () {
930 const sessions = yield* authControlPlane.listSessions();
931 yield* Console.log(formatSessionList(sessions, { json: flags.json }));
932 }),
933 {
934 quietLogs: flags.json,
935 },
936 ),
937 ),
938);
939
940const sessionRevokeCommand = Command.make("revoke", {
941 ...authLocationFlags,
942 sessionId: Argument.string("session-id").pipe(
943 Argument.withDescription("Session id to revoke."),
944 Argument.withSchema(AuthSessionId),
945 ),
946}).pipe(
947 Command.withDescription("Revoke an active session."),
948 Command.withHandler((flags) =>
949 runWithAuthControlPlane(flags, (authControlPlane) =>
950 Effect.gen(function* () {
951 const revoked = yield* authControlPlane.revokeSession(flags.sessionId);
952 yield* Console.log(
953 revoked
954 ? `Revoked session ${flags.sessionId}.\n`
955 : `No active session found for ${flags.sessionId}.\n`,
956 );
957 }),
958 ),
959 ),
960);
961
962const sessionCommand = Command.make("session").pipe(
963 Command.withDescription("Manage bearer sessions."),
964 Command.withSubcommands([sessionIssueCommand, sessionListCommand, sessionRevokeCommand]),
965);
966
967const authCommand = Command.make("auth").pipe(
968 Command.withDescription("Manage the local auth control plane for headless deployments."),
969 Command.withSubcommands([pairingCommand, sessionCommand]),
970);
971
972const projectAddCommand = Command.make("add", {
973 ...projectLocationFlags,
974 workspaceRoot: Argument.string("path").pipe(
975 Argument.withDescription("Workspace root to add as a project."),
976 ),
977 title: Flag.string("title").pipe(Flag.withDescription("Optional project title."), Flag.optional),
978}).pipe(
979 Command.withDescription("Add a project."),
980 Command.withHandler((flags) =>
981 runProjectMutation(
982 flags,
983 Effect.fn("projectAddMutation")(function* ({
984 snapshot,
985 dispatch,
986 }: {
987 readonly snapshot: OrchestrationReadModel;
988 readonly dispatch: (
989 command: ProjectCliDispatchCommand,
990 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
991 }) {
992 const workspaceRoot = yield* normalizeWorkspaceRootForProjectCommand(flags.workspaceRoot);
993 const existingProject = snapshot.projects.find(
994 (project) => project.deletedAt === null && project.workspaceRoot === workspaceRoot,
995 );
996 if (existingProject) {
997 return yield* Effect.fail(
998 new Error(`An active project already exists for '${workspaceRoot}'.`),
999 );
1000 }
1001
1002 const title = yield* resolveProjectTitle(workspaceRoot, Option.getOrUndefined(flags.title));
1003 const projectId = ProjectId.make(crypto.randomUUID());
1004 yield* dispatch({
1005 type: "project.create",
1006 commandId: CommandId.make(crypto.randomUUID()),
1007 projectId,
1008 title,
1009 workspaceRoot,
1010 defaultModelSelection: getAutoBootstrapDefaultModelSelection(),
1011 createdAt: new Date().toISOString(),
1012 });
1013 return `Added project ${projectId} (${title}) at ${workspaceRoot}.`;
1014 }),
1015 ),
1016 ),
1017);
1018
1019const projectRemoveCommand = Command.make("remove", {
1020 ...projectLocationFlags,
1021 project: Argument.string("project").pipe(
1022 Argument.withDescription("Project id or workspace root to remove."),
1023 ),
1024}).pipe(
1025 Command.withDescription("Remove a project."),
1026 Command.withHandler((flags) =>
1027 runProjectMutation(
1028 flags,
1029 Effect.fn("projectRemoveMutation")(function* ({
1030 snapshot,
1031 dispatch,
1032 }: {
1033 readonly snapshot: OrchestrationReadModel;
1034 readonly dispatch: (
1035 command: ProjectCliDispatchCommand,
1036 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1037 }) {
1038 const project = yield* findActiveProjectTarget({
1039 snapshot,
1040 identifier: flags.project,
1041 });
1042 yield* dispatch({
1043 type: "project.delete",
1044 commandId: CommandId.make(crypto.randomUUID()),
1045 projectId: project.id,
1046 });
1047 return `Removed project ${project.id} (${project.title}).`;
1048 }),
1049 ),
1050 ),
1051);
1052
1053const projectRenameCommand = Command.make("rename", {
1054 ...projectLocationFlags,
1055 project: Argument.string("project").pipe(
1056 Argument.withDescription("Project id or workspace root to rename."),
1057 ),
1058 title: Argument.string("title").pipe(Argument.withDescription("New project title.")),
1059}).pipe(
1060 Command.withDescription("Rename a project."),
1061 Command.withHandler((flags) =>
1062 runProjectMutation(
1063 flags,
1064 Effect.fn("projectRenameMutation")(function* ({
1065 snapshot,
1066 dispatch,
1067 }: {
1068 readonly snapshot: OrchestrationReadModel;
1069 readonly dispatch: (
1070 command: ProjectCliDispatchCommand,
1071 ) => Effect.Effect<void, Error, FileSystem.FileSystem | HttpClient.HttpClient | Path.Path>;
1072 }) {
1073 const project = yield* findActiveProjectTarget({
1074 snapshot,
1075 identifier: flags.project,
1076 });
1077 const nextTitle = yield* resolveProjectTitle(project.workspaceRoot, flags.title);
1078 if (nextTitle === project.title) {
1079 return `Project ${project.id} is already named ${nextTitle}.`;
1080 }
1081
1082 yield* dispatch({
1083 type: "project.meta.update",
1084 commandId: CommandId.make(crypto.randomUUID()),
1085 projectId: project.id,
1086 title: nextTitle,
1087 });
1088 return `Renamed project ${project.id} to ${nextTitle}.`;
1089 }),
1090 ),
1091 ),
1092);
1093
1094const projectCommand = Command.make("project").pipe(
1095 Command.withDescription("Manage projects."),
1096 Command.withSubcommands([projectAddCommand, projectRemoveCommand, projectRenameCommand]),
1097);
1098
1099const runServerCommand = (
1100 flags: CliServerFlags,
1101 options?: {
1102 readonly startupPresentation?: StartupPresentation;
1103 readonly forceAutoBootstrapProjectFromCwd?: boolean;
1104 },
1105) =>
1106 Effect.gen(function* () {
1107 const logLevel = yield* GlobalFlag.LogLevel;
1108 const config = yield* resolveServerConfig(flags, logLevel, options);
1109 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
1110 });
1111
1112const startCommand = Command.make("start", { ...sharedServerCommandFlags }).pipe(
358 Command.withDescription("Run the T3 Code server."),1113 Command.withDescription("Run the T3 Code server."),
1114 Command.withHandler((flags) => runServerCommand(flags)),
1115);
1116
1117const serveCommand = Command.make("serve", { ...sharedServerCommandFlags }).pipe(
1118 Command.withDescription(
1119 "Run the T3 Code server without opening a browser and print headless pairing details.",
1120 ),
359 Command.withHandler((flags) =>1121 Command.withHandler((flags) =>
360 Effect.gen(function* () {1122 runServerCommand(flags, {
361 const logLevel = yield* GlobalFlag.LogLevel;1123 startupPresentation: "headless",
362 const config = yield* resolveServerConfig(flags, logLevel);1124 forceAutoBootstrapProjectFromCwd: false,
363 return yield* runServer.pipe(Effect.provideService(ServerConfig, config));
364 }),1125 }),
365 ),1126 ),
366);1127);
367 1128
368export const cli = rootCommand;1129export const cli = Command.make("t3", { ...sharedServerCommandFlags }).pipe(
1130 Command.withDescription("Run the T3 Code server."),
1131 Command.withHandler((flags) => runServerCommand(flags)),
1132 Command.withSubcommands([startCommand, serveCommand, authCommand, projectCommand]),
1133);
apps/server/src/cliAuthFormat.test.ts+88−0
@@ -0,0 +1,88 @@
1import { expect, it } from "@effect/vitest";
2import { DateTime } from "effect";
3
4import {
5 formatIssuedPairingCredential,
6 formatIssuedSession,
7 formatPairingCredentialList,
8 formatSessionList,
9} from "./cliAuthFormat.ts";
10
11it("formats issued pairing credentials with the secret and optional pair URL", () => {
12 const output = formatIssuedPairingCredential(
13 {
14 id: "pairing-1",
15 credential: "secret-pairing-token",
16 role: "client",
17 subject: "one-time-token",
18 createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
19 expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
20 },
21 { baseUrl: "https://example.com", json: false },
22 );
23
24 expect(output).toContain("secret-pairing-token");
25 expect(output).toContain("https://example.com/pair#token=secret-pairing-token");
26});
27
28it("formats pairing listings without exposing the secret token", () => {
29 const output = formatPairingCredentialList(
30 [
31 {
32 id: "pairing-1",
33 credential: "secret-pairing-token",
34 subject: "one-time-token",
35 label: "Phone",
36 role: "client",
37 createdAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
38 expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
39 },
40 ],
41 { json: false },
42 );
43
44 expect(output).toContain("pairing-1");
45 expect(output).not.toContain("secret-pairing-token");
46});
47
48it("formats issued sessions with the bearer token but omits tokens from listings", () => {
49 const issuedOutput = formatIssuedSession(
50 {
51 sessionId: "session-1" as never,
52 token: "secret-session-token",
53 method: "bearer-session-token",
54 role: "owner",
55 subject: "cli-issued-session",
56 client: {
57 label: "deploy-bot",
58 deviceType: "bot",
59 },
60 expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
61 },
62 { json: false },
63 );
64
65 const listedOutput = formatSessionList(
66 [
67 {
68 sessionId: "session-1" as never,
69 method: "bearer-session-token",
70 role: "owner",
71 subject: "cli-issued-session",
72 client: {
73 label: "deploy-bot",
74 deviceType: "bot",
75 },
76 connected: false,
77 current: false,
78 issuedAt: DateTime.fromDateUnsafe(new Date("2026-04-08T09:00:00.000Z")),
79 expiresAt: DateTime.fromDateUnsafe(new Date("2026-04-08T10:00:00.000Z")),
80 lastConnectedAt: null,
81 },
82 ],
83 { json: false },
84 );
85
86 expect(issuedOutput).toContain("secret-session-token");
87 expect(listedOutput).not.toContain("secret-session-token");
88});
apps/server/src/cliAuthFormat.ts+190−0
@@ -0,0 +1,190 @@
1import type { AuthClientMetadata, AuthClientSession, AuthPairingLink } from "@t3tools/contracts";
2import { DateTime } from "effect";
3
4import type { IssuedBearerSession, IssuedPairingLink } from "./auth/Services/AuthControlPlane.ts";
5
6const newline = "\n";
7
8function serializeOptionalFields(values: ReadonlyArray<string | null | undefined>) {
9 return values.filter((value): value is string => typeof value === "string" && value.length > 0);
10}
11
12function formatClientMetadata(metadata: AuthClientMetadata): string {
13 const details = serializeOptionalFields([
14 metadata.label,
15 metadata.deviceType !== "unknown" ? metadata.deviceType : undefined,
16 metadata.os,
17 metadata.browser,
18 metadata.ipAddress,
19 ]);
20 return details.length > 0 ? details.join(" | ") : "unlabeled client";
21}
22
23function toIsoString(value: DateTime.DateTime | DateTime.Utc): string {
24 return DateTime.formatIso(DateTime.toUtc(value));
25}
26
27export function formatIssuedPairingCredential(
28 credential: IssuedPairingLink,
29 options?: {
30 readonly json?: boolean;
31 readonly baseUrl?: string;
32 },
33): string {
34 const pairUrl =
35 options?.baseUrl != null && options.baseUrl.length > 0
36 ? (() => {
37 const url = new URL("/pair", options.baseUrl);
38 url.searchParams.delete("token");
39 url.hash = new URLSearchParams([["token", credential.credential]]).toString();
40 return url.toString();
41 })()
42 : undefined;
43
44 if (options?.json) {
45 return `${JSON.stringify(
46 {
47 id: credential.id,
48 credential: credential.credential,
49 ...(credential.label ? { label: credential.label } : {}),
50 role: credential.role,
51 expiresAt: toIsoString(credential.expiresAt),
52 ...(pairUrl ? { pairUrl } : {}),
53 },
54 null,
55 2,
56 )}${newline}`;
57 }
58
59 return (
60 [
61 `Issued client pairing token ${credential.id}.`,
62 `Token: ${credential.credential}`,
63 ...(pairUrl ? [`Pair URL: ${pairUrl}`] : []),
64 `Expires at: ${credential.expiresAt}`,
65 ].join(newline) + newline
66 );
67}
68
69export function formatPairingCredentialList(
70 credentials: ReadonlyArray<AuthPairingLink>,
71 options?: {
72 readonly json?: boolean;
73 },
74): string {
75 if (options?.json) {
76 return `${JSON.stringify(
77 credentials.map((credential) => ({
78 id: credential.id,
79 ...(credential.label ? { label: credential.label } : {}),
80 role: credential.role,
81 createdAt: toIsoString(credential.createdAt),
82 expiresAt: toIsoString(credential.expiresAt),
83 })),
84 null,
85 2,
86 )}${newline}`;
87 }
88
89 if (credentials.length === 0) {
90 return `No active pairing credentials.${newline}`;
91 }
92
93 return (
94 credentials
95 .map((credential) =>
96 [
97 `${credential.id}${credential.label ? ` (${credential.label})` : ""}`,
98 ` role: ${credential.role}`,
99 ` created: ${toIsoString(credential.createdAt)}`,
100 ` expires: ${toIsoString(credential.expiresAt)}`,
101 ].join(newline),
102 )
103 .join(`${newline}${newline}`) + newline
104 );
105}
106
107export function formatIssuedSession(
108 session: IssuedBearerSession,
109 options?: {
110 readonly json?: boolean;
111 readonly tokenOnly?: boolean;
112 },
113): string {
114 if (options?.tokenOnly) {
115 return `${session.token}${newline}`;
116 }
117
118 if (options?.json) {
119 return `${JSON.stringify(
120 {
121 sessionId: session.sessionId,
122 token: session.token,
123 method: session.method,
124 role: session.role,
125 subject: session.subject,
126 client: session.client,
127 expiresAt: toIsoString(session.expiresAt),
128 },
129 null,
130 2,
131 )}${newline}`;
132 }
133
134 return (
135 [
136 `Issued ${session.role} bearer session ${session.sessionId}.`,
137 `Token: ${session.token}`,
138 `Subject: ${session.subject}`,
139 `Client: ${formatClientMetadata(session.client)}`,
140 `Expires at: ${toIsoString(session.expiresAt)}`,
141 ].join(newline) + newline
142 );
143}
144
145export function formatSessionList(
146 sessions: ReadonlyArray<AuthClientSession>,
147 options?: {
148 readonly json?: boolean;
149 },
150): string {
151 if (options?.json) {
152 return `${JSON.stringify(
153 sessions.map((session) => ({
154 sessionId: session.sessionId,
155 method: session.method,
156 role: session.role,
157 subject: session.subject,
158 client: session.client,
159 connected: session.connected,
160 issuedAt: toIsoString(session.issuedAt),
161 expiresAt: toIsoString(session.expiresAt),
162 lastConnectedAt: session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : null,
163 })),
164 null,
165 2,
166 )}${newline}`;
167 }
168
169 if (sessions.length === 0) {
170 return `No active sessions.${newline}`;
171 }
172
173 return (
174 sessions
175 .map((session) =>
176 [
177 `${session.sessionId} [${session.role}]${session.connected ? " connected" : ""}`,
178 ` method: ${session.method}`,
179 ` subject: ${session.subject}`,
180 ` client: ${formatClientMetadata(session.client)}`,
181 ` issued: ${toIsoString(session.issuedAt)}`,
182 ` last connected: ${
183 session.lastConnectedAt ? toIsoString(session.lastConnectedAt) : "never"
184 }`,
185 ` expires: ${toIsoString(session.expiresAt)}`,
186 ].join(newline),
187 )
188 .join(`${newline}${newline}`) + newline
189 );
190}
apps/server/src/codexAppServerManager.test.ts+154−6
@@ -15,9 +15,9 @@ import { · 14 unmodified lines
15 normalizeCodexModelSlug,15 normalizeCodexModelSlug,
16 readCodexAccountSnapshot,16 readCodexAccountSnapshot,
17 resolveCodexModelForAccount,17 resolveCodexModelForAccount,
18} from "./codexAppServerManager";18} from "./codexAppServerManager.ts";
19 19
20const asThreadId = (value: string): ThreadId => ThreadId.makeUnsafe(value);20const asThreadId = (value: string): ThreadId => ThreadId.make(value);
21 21
22function createSendTurnHarness() {22function createSendTurnHarness() {
23 const manager = new CodexAppServerManager();23 const manager = new CodexAppServerManager();
@@ -111,9 +111,9 @@ function createPendingUserInputHarness() { · 87 unmodified lines
111 },111 },
112 pendingUserInputs: new Map([112 pendingUserInputs: new Map([
113 [113 [
114 ApprovalRequestId.makeUnsafe("req-user-input-1"),114 ApprovalRequestId.make("req-user-input-1"),
115 {115 {
116 requestId: ApprovalRequestId.makeUnsafe("req-user-input-1"),116 requestId: ApprovalRequestId.make("req-user-input-1"),
117 jsonRpcId: 42,117 jsonRpcId: 42,
118 threadId: asThreadId("thread_1"),118 threadId: asThreadId("thread_1"),
119 },119 },
@@ -470,6 +470,154 @@ describe("startSession", () => { · 350 unmodified lines
470 manager.stopAll();470 manager.stopAll();
471 }471 }
472 });472 });
473
474 it("disposes an existing session before starting a replacement for the same thread", async () => {
475 const manager = new CodexAppServerManager();
476 const existingContext = {
477 session: {
478 provider: "codex",
479 status: "ready",
480 threadId: asThreadId("thread-1"),
481 runtimeMode: "full-access",
482 createdAt: "2026-02-10T00:00:00.000Z",
483 updatedAt: "2026-02-10T00:00:00.000Z",
484 },
485 };
486
487 (
488 manager as unknown as {
489 sessions: Map<ThreadId, typeof existingContext>;
490 }
491 ).sessions.set(asThreadId("thread-1"), existingContext);
492
493 const disposeSession = vi
494 .spyOn(
495 manager as unknown as {
496 disposeSession: (
497 context: typeof existingContext,
498 options?: { readonly emitLifecycleEvent?: boolean },
499 ) => void;
500 },
501 "disposeSession",
502 )
503 .mockImplementation(() => {});
504 const assertSupportedCodexCliVersion = vi
505 .spyOn(
506 manager as unknown as {
507 assertSupportedCodexCliVersion: (input: {
508 binaryPath: string;
509 cwd: string;
510 homePath?: string;
511 }) => void;
512 },
513 "assertSupportedCodexCliVersion",
514 )
515 .mockImplementation(() => {});
516 const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
517 throw new Error("cwd missing");
518 });
519
520 try {
521 await expect(
522 manager.startSession({
523 threadId: asThreadId("thread-1"),
524 provider: "codex",
525 binaryPath: "codex",
526 runtimeMode: "full-access",
527 }),
528 ).rejects.toThrow("cwd missing");
529
530 expect(disposeSession).toHaveBeenCalledWith(existingContext, {
531 emitLifecycleEvent: false,
532 });
533 expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled();
534 } finally {
535 disposeSession.mockRestore();
536 assertSupportedCodexCliVersion.mockRestore();
537 processCwd.mockRestore();
538 (
539 manager as unknown as {
540 sessions: Map<ThreadId, typeof existingContext>;
541 }
542 ).sessions.clear();
543 manager.stopAll();
544 }
545 });
546
547 it("continues replacement start when existing session disposal fails", async () => {
548 const manager = new CodexAppServerManager();
549 const existingContext = {
550 session: {
551 provider: "codex",
552 status: "ready",
553 threadId: asThreadId("thread-1"),
554 runtimeMode: "full-access",
555 createdAt: "2026-02-10T00:00:00.000Z",
556 updatedAt: "2026-02-10T00:00:00.000Z",
557 },
558 };
559
560 (
561 manager as unknown as {
562 sessions: Map<ThreadId, typeof existingContext>;
563 }
564 ).sessions.set(asThreadId("thread-1"), existingContext);
565
566 const disposeSession = vi
567 .spyOn(
568 manager as unknown as {
569 disposeSession: (
570 context: typeof existingContext,
571 options?: { readonly emitLifecycleEvent?: boolean },
572 ) => void;
573 },
574 "disposeSession",
575 )
576 .mockImplementation(() => {
577 throw new Error("dispose failed");
578 });
579 const assertSupportedCodexCliVersion = vi
580 .spyOn(
581 manager as unknown as {
582 assertSupportedCodexCliVersion: (input: {
583 binaryPath: string;
584 cwd: string;
585 homePath?: string;
586 }) => void;
587 },
588 "assertSupportedCodexCliVersion",
589 )
590 .mockImplementation(() => {});
591 const processCwd = vi.spyOn(process, "cwd").mockImplementation(() => {
592 throw new Error("cwd missing");
593 });
594
595 try {
596 await expect(
597 manager.startSession({
598 threadId: asThreadId("thread-1"),
599 provider: "codex",
600 binaryPath: "codex",
601 runtimeMode: "full-access",
602 }),
603 ).rejects.toThrow("cwd missing");
604
605 expect(disposeSession).toHaveBeenCalledWith(existingContext, {
606 emitLifecycleEvent: false,
607 });
608 expect(assertSupportedCodexCliVersion).not.toHaveBeenCalled();
609 } finally {
610 disposeSession.mockRestore();
611 assertSupportedCodexCliVersion.mockRestore();
612 processCwd.mockRestore();
613 (
614 manager as unknown as {
615 sessions: Map<ThreadId, typeof existingContext>;
616 }
617 ).sessions.clear();
618 manager.stopAll();
619 }
620 });
473});621});
474 622
475describe("sendTurn", () => {623describe("sendTurn", () => {
@@ -743,7 +891,7 @@ describe("respondToUserInput", () => { · 267 unmodified lines
743 891
744 await manager.respondToUserInput(892 await manager.respondToUserInput(
745 asThreadId("thread_1"),893 asThreadId("thread_1"),
746 ApprovalRequestId.makeUnsafe("req-user-input-1"),894 ApprovalRequestId.make("req-user-input-1"),
747 {895 {
748 scope: "All request methods",896 scope: "All request methods",
749 compat: "Keep current envelope",897 compat: "Keep current envelope",
@@ -780,7 +928,7 @@ describe("respondToUserInput", () => { · 30 unmodified lines
780 928
781 await manager.respondToUserInput(929 await manager.respondToUserInput(
782 asThreadId("thread_1"),930 asThreadId("thread_1"),
783 ApprovalRequestId.makeUnsafe("req-user-input-1"),931 ApprovalRequestId.make("req-user-input-1"),
784 {932 {
785 scope: [],933 scope: [],
786 },934 },
apps/server/src/codexAppServerManager.ts+75−35
@@ -19,22 +19,22 @@ import { · 18 unmodified lines
19 ProviderInteractionMode,19 ProviderInteractionMode,
20} from "@t3tools/contracts";20} from "@t3tools/contracts";
21import { normalizeModelSlug } from "@t3tools/shared/model";21import { normalizeModelSlug } from "@t3tools/shared/model";
22import { Effect, ServiceMap } from "effect";22import { Effect, Context } from "effect";
23 23
24import {24import {
25 formatCodexCliUpgradeMessage,25 formatCodexCliUpgradeMessage,
26 isCodexCliVersionSupported,26 isCodexCliVersionSupported,
27 parseCodexCliVersion,27 parseCodexCliVersion,
28} from "./provider/codexCliVersion";28} from "./provider/codexCliVersion.ts";
29import {29import {
30 readCodexAccountSnapshot,30 readCodexAccountSnapshot,
31 resolveCodexModelForAccount,31 resolveCodexModelForAccount,
32 type CodexAccountSnapshot,32 type CodexAccountSnapshot,
33} from "./provider/codexAccount";33} from "./provider/codexAccount.ts";
34import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer";34import { buildCodexInitializeParams, killCodexChildProcess } from "./provider/codexAppServer.ts";
35 35
36export { buildCodexInitializeParams } from "./provider/codexAppServer";36export { buildCodexInitializeParams } from "./provider/codexAppServer.ts";
37export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount";37export { readCodexAccountSnapshot, resolveCodexModelForAccount } from "./provider/codexAccount.ts";
38 38
39type PendingRequestKey = string;39type PendingRequestKey = string;
40 40
@@ -290,20 +290,26 @@ In Default mode, strongly prefer making reasonable assumptions and executing the · 249 unmodified lines
290</collaboration_mode>`;290</collaboration_mode>`;
291 291
292function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {292function mapCodexRuntimeMode(runtimeMode: RuntimeMode): {
293 readonly approvalPolicy: "on-request" | "never";293 readonly approvalPolicy: "untrusted" | "on-request" | "never";
294 readonly sandbox: "workspace-write" | "danger-full-access";294 readonly sandbox: "read-only" | "workspace-write" | "danger-full-access";
295} {295} {
296 if (runtimeMode === "approval-required") {296 switch (runtimeMode) {
297 return {297 case "approval-required":
298 approvalPolicy: "on-request",298 return {
299 sandbox: "workspace-write",299 approvalPolicy: "untrusted",
300 };300 sandbox: "read-only",
301 };
302 case "auto-accept-edits":
303 return {
304 approvalPolicy: "on-request",
305 sandbox: "workspace-write",
306 };
307 case "full-access":
308 return {
309 approvalPolicy: "never",
310 sandbox: "danger-full-access",
311 };
301 }312 }
302
303 return {
304 approvalPolicy: "never",
305 sandbox: "danger-full-access",
306 };
307}313}
308 314
309/**315/**
@@ -433,7 +439,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 123 unmodified lines
433 private readonly sessions = new Map<ThreadId, CodexSessionContext>();439 private readonly sessions = new Map<ThreadId, CodexSessionContext>();
434 440
435 private runPromise: (effect: Effect.Effect<unknown, never>) => Promise<unknown>;441 private runPromise: (effect: Effect.Effect<unknown, never>) => Promise<unknown>;
436 constructor(services?: ServiceMap.ServiceMap<never>) {442 constructor(services?: Context.Context<never>) {
437 super();443 super();
438 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;444 this.runPromise = services ? Effect.runPromiseWith(services) : Effect.runPromise;
439 }445 }
@@ -444,6 +450,25 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines
444 let context: CodexSessionContext | undefined;450 let context: CodexSessionContext | undefined;
445 451
446 try {452 try {
453 const existingContext = this.sessions.get(threadId);
454 if (existingContext) {
455 await Effect.logWarning("codex app-server replacing existing session", {
456 threadId,
457 existingStatus: existingContext.session.status,
458 }).pipe(this.runPromise);
459 try {
460 this.disposeSession(existingContext, {
461 emitLifecycleEvent: false,
462 });
463 } catch (error) {
464 await Effect.logWarning("codex app-server failed to dispose existing session", {
465 threadId,
466 existingStatus: existingContext.session.status,
467 cause: error instanceof Error ? error.message : String(error),
468 }).pipe(this.runPromise);
469 }
470 }
471
447 const resolvedCwd = input.cwd ?? process.cwd();472 const resolvedCwd = input.cwd ?? process.cwd();
448 473
449 const session: ProviderSession = {474 const session: ProviderSession = {
@@ -634,7 +659,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 184 unmodified lines
634 this.stopSession(threadId);659 this.stopSession(threadId);
635 } else {660 } else {
636 this.emitEvent({661 this.emitEvent({
637 id: EventId.makeUnsafe(randomUUID()),662 id: EventId.make(randomUUID()),
638 kind: "error",663 kind: "error",
639 provider: "codex",664 provider: "codex",
640 threadId,665 threadId,
@@ -733,7 +758,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 92 unmodified lines
733 if (!turnIdRaw) {758 if (!turnIdRaw) {
734 throw new Error("turn/start response did not include a turn id.");759 throw new Error("turn/start response did not include a turn id.");
735 }760 }
736 const turnId = TurnId.makeUnsafe(turnIdRaw);761 const turnId = TurnId.make(turnIdRaw);
737 762
738 this.updateSession(context, {763 this.updateSession(context, {
739 status: "running",764 status: "running",
@@ -834,7 +859,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 94 unmodified lines
834 });859 });
835 860
836 this.emitEvent({861 this.emitEvent({
837 id: EventId.makeUnsafe(randomUUID()),862 id: EventId.make(randomUUID()),
838 kind: "notification",863 kind: "notification",
839 provider: "codex",864 provider: "codex",
840 threadId: context.session.threadId,865 threadId: context.session.threadId,
@@ -873,7 +898,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 32 unmodified lines
873 });898 });
874 899
875 this.emitEvent({900 this.emitEvent({
876 id: EventId.makeUnsafe(randomUUID()),901 id: EventId.make(randomUUID()),
877 kind: "notification",902 kind: "notification",
878 provider: "codex",903 provider: "codex",
879 threadId: context.session.threadId,904 threadId: context.session.threadId,
@@ -895,6 +920,19 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 15 unmodified lines
895 return;920 return;
896 }921 }
897 922
923 this.disposeSession(context, {
924 emitLifecycleEvent: true,
925 });
926 }
927
928 private disposeSession(
929 context: CodexSessionContext,
930 options?: { readonly emitLifecycleEvent?: boolean },
931 ): void {
932 if (context.stopping) {
933 return;
934 }
935
898 context.stopping = true;936 context.stopping = true;
899 937
900 for (const pending of context.pending.values()) {938 for (const pending of context.pending.values()) {
@@ -915,8 +953,10 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 14 unmodified lines
915 status: "closed",953 status: "closed",
916 activeTurnId: undefined,954 activeTurnId: undefined,
917 });955 });
918 this.emitLifecycleEvent(context, "session/closed", "Session stopped");956 if (options?.emitLifecycleEvent !== false) {
919 this.sessions.delete(threadId);957 this.emitLifecycleEvent(context, "session/closed", "Session stopped");
958 }
959 this.sessions.delete(context.session.threadId);
920 }960 }
921 961
922 listSessions(): ProviderSession[] {962 listSessions(): ProviderSession[] {
@@ -1055,7 +1095,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 132 unmodified lines
1055 : undefined;1095 : undefined;
1056 1096
1057 this.emitEvent({1097 this.emitEvent({
1058 id: EventId.makeUnsafe(randomUUID()),1098 id: EventId.make(randomUUID()),
1059 kind: "notification",1099 kind: "notification",
1060 provider: "codex",1100 provider: "codex",
1061 threadId: context.session.threadId,1101 threadId: context.session.threadId,
@@ -1128,7 +1168,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 66 unmodified lines
1128 const requestKind = this.requestKindForMethod(request.method);1168 const requestKind = this.requestKindForMethod(request.method);
1129 let requestId: ApprovalRequestId | undefined;1169 let requestId: ApprovalRequestId | undefined;
1130 if (requestKind) {1170 if (requestKind) {
1131 requestId = ApprovalRequestId.makeUnsafe(randomUUID());1171 requestId = ApprovalRequestId.make(randomUUID());
1132 const pendingRequest: PendingApprovalRequest = {1172 const pendingRequest: PendingApprovalRequest = {
1133 requestId,1173 requestId,
1134 jsonRpcId: request.id,1174 jsonRpcId: request.id,
@@ -1147,7 +1187,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 12 unmodified lines
1147 }1187 }
1148 1188
1149 if (request.method === "item/tool/requestUserInput") {1189 if (request.method === "item/tool/requestUserInput") {
1150 requestId = ApprovalRequestId.makeUnsafe(randomUUID());1190 requestId = ApprovalRequestId.make(randomUUID());
1151 context.pendingUserInputs.set(requestId, {1191 context.pendingUserInputs.set(requestId, {
1152 requestId,1192 requestId,
1153 jsonRpcId: request.id,1193 jsonRpcId: request.id,
@@ -1158,7 +1198,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 4 unmodified lines
1158 }1198 }
1159 1199
1160 this.emitEvent({1200 this.emitEvent({
1161 id: EventId.makeUnsafe(randomUUID()),1201 id: EventId.make(randomUUID()),
1162 kind: "request",1202 kind: "request",
1163 provider: "codex",1203 provider: "codex",
1164 threadId: context.session.threadId,1204 threadId: context.session.threadId,
@@ -1248,7 +1288,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 83 unmodified lines
1248 1288
1249 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {1289 private emitLifecycleEvent(context: CodexSessionContext, method: string, message: string): void {
1250 this.emitEvent({1290 this.emitEvent({
1251 id: EventId.makeUnsafe(randomUUID()),1291 id: EventId.make(randomUUID()),
1252 kind: "session",1292 kind: "session",
1253 provider: "codex",1293 provider: "codex",
1254 threadId: context.session.threadId,1294 threadId: context.session.threadId,
@@ -1260,7 +1300,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 5 unmodified lines
1260 1300
1261 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {1301 private emitErrorEvent(context: CodexSessionContext, method: string, message: string): void {
1262 this.emitEvent({1302 this.emitEvent({
1263 id: EventId.makeUnsafe(randomUUID()),1303 id: EventId.make(randomUUID()),
1264 kind: "error",1304 kind: "error",
1265 provider: "codex",1305 provider: "codex",
1266 threadId: context.session.threadId,1306 threadId: context.session.threadId,
@@ -1276,7 +1316,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 9 unmodified lines
1276 message: string,1316 message: string,
1277 ): void {1317 ): void {
1278 this.emitEvent({1318 this.emitEvent({
1279 id: EventId.makeUnsafe(randomUUID()),1319 id: EventId.make(randomUUID()),
1280 kind: "notification",1320 kind: "notification",
1281 provider: "codex",1321 provider: "codex",
1282 threadId: context.session.threadId,1322 threadId: context.session.threadId,
@@ -1335,7 +1375,7 @@ export class CodexAppServerManager extends EventEmitter<CodexAppServerManagerEve · 52 unmodified lines
1335 const turns = turnsRaw.map((turnValue, index) => {1375 const turns = turnsRaw.map((turnValue, index) => {
1336 const turn = this.readObject(turnValue);1376 const turn = this.readObject(turnValue);
1337 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;1377 const turnIdRaw = this.readString(turn, "id") ?? `${threadIdRaw}:turn:${index + 1}`;
1338 const turnId = TurnId.makeUnsafe(turnIdRaw);1378 const turnId = TurnId.make(turnIdRaw);
1339 const items = this.readArray(turn, "items") ?? [];1379 const items = this.readArray(turn, "items") ?? [];
1340 return {1380 return {
1341 id: turnId,1381 id: turnId,
@@ -1584,9 +1624,9 @@ function readResumeThreadId(input: { · 242 unmodified lines
1584}1624}
1585 1625
1586function toTurnId(value: string | undefined): TurnId | undefined {1626function toTurnId(value: string | undefined): TurnId | undefined {
1587 return brandIfNonEmpty(value, TurnId.makeUnsafe);1627 return brandIfNonEmpty(value, TurnId.make);
1588}1628}
1589 1629
1590function toProviderItemId(value: string | undefined): ProviderItemId | undefined {1630function toProviderItemId(value: string | undefined): ProviderItemId | undefined {
1591 return brandIfNonEmpty(value, ProviderItemId.makeUnsafe);1631 return brandIfNonEmpty(value, ProviderItemId.make);
1592}1632}
apps/server/src/config.ts+20−4
@@ -6,13 +6,16 @@ · 5 unmodified lines
6 *6 *
7 * @module ServerConfig7 * @module ServerConfig
8 */8 */
9import { Effect, FileSystem, Layer, LogLevel, Path, Schema, ServiceMap } from "effect";9import { Effect, FileSystem, Layer, LogLevel, Path, Schema, Context } from "effect";
10 10
11export const DEFAULT_PORT = 3773;11export const DEFAULT_PORT = 3773;
12 12
13export const RuntimeMode = Schema.Literals(["web", "desktop"]);13export const RuntimeMode = Schema.Literals(["web", "desktop"]);
14export type RuntimeMode = typeof RuntimeMode.Type;14export type RuntimeMode = typeof RuntimeMode.Type;
15 15
16export const StartupPresentation = Schema.Literals(["browser", "headless"]);
17export type StartupPresentation = typeof StartupPresentation.Type;
18
16/**19/**
17 * ServerDerivedPaths - Derived paths from the base directory.20 * ServerDerivedPaths - Derived paths from the base directory.
18 */21 */
@@ -21,6 +24,7 @@ export interface ServerDerivedPaths { · 2 unmodified lines
21 readonly dbPath: string;24 readonly dbPath: string;
22 readonly keybindingsConfigPath: string;25 readonly keybindingsConfigPath: string;
23 readonly settingsPath: string;26 readonly settingsPath: string;
27 readonly providerStatusCacheDir: string;
24 readonly worktreesDir: string;28 readonly worktreesDir: string;
25 readonly attachmentsDir: string;29 readonly attachmentsDir: string;
26 readonly logsDir: string;30 readonly logsDir: string;
@@ -30,6 +34,9 @@ export interface ServerDerivedPaths { · 3 unmodified lines
30 readonly providerEventLogPath: string;34 readonly providerEventLogPath: string;
31 readonly terminalLogsDir: string;35 readonly terminalLogsDir: string;
32 readonly anonymousIdPath: string;36 readonly anonymousIdPath: string;
37 readonly environmentIdPath: string;
38 readonly serverRuntimeStatePath: string;
39 readonly secretsDir: string;
33}40}
34 41
35/**42/**
@@ -54,7 +61,8 @@ export interface ServerConfigShape extends ServerDerivedPaths { · 18 unmodified lines
54 readonly staticDir: string | undefined;61 readonly staticDir: string | undefined;
55 readonly devUrl: URL | undefined;62 readonly devUrl: URL | undefined;
56 readonly noBrowser: boolean;63 readonly noBrowser: boolean;
57 readonly authToken: string | undefined;64 readonly startupPresentation: StartupPresentation;
65 readonly desktopBootstrapToken: string | undefined;
58 readonly autoBootstrapProjectFromCwd: boolean;66 readonly autoBootstrapProjectFromCwd: boolean;
59 readonly logWebSocketEvents: boolean;67 readonly logWebSocketEvents: boolean;
60}68}
@@ -69,11 +77,13 @@ export const deriveServerPaths = Effect.fn(function* ( · 8 unmodified lines
69 const attachmentsDir = join(stateDir, "attachments");77 const attachmentsDir = join(stateDir, "attachments");
70 const logsDir = join(stateDir, "logs");78 const logsDir = join(stateDir, "logs");
71 const providerLogsDir = join(logsDir, "provider");79 const providerLogsDir = join(logsDir, "provider");
80 const providerStatusCacheDir = join(baseDir, "caches");
72 return {81 return {
73 stateDir,82 stateDir,
74 dbPath,83 dbPath,
75 keybindingsConfigPath: join(stateDir, "keybindings.json"),84 keybindingsConfigPath: join(stateDir, "keybindings.json"),
76 settingsPath: join(stateDir, "settings.json"),85 settingsPath: join(stateDir, "settings.json"),
86 providerStatusCacheDir,
77 worktreesDir: join(baseDir, "worktrees"),87 worktreesDir: join(baseDir, "worktrees"),
78 attachmentsDir,88 attachmentsDir,
79 logsDir,89 logsDir,
@@ -83,6 +93,9 @@ export const deriveServerPaths = Effect.fn(function* ( · 3 unmodified lines
83 providerEventLogPath: join(providerLogsDir, "events.log"),93 providerEventLogPath: join(providerLogsDir, "events.log"),
84 terminalLogsDir: join(logsDir, "terminals"),94 terminalLogsDir: join(logsDir, "terminals"),
85 anonymousIdPath: join(stateDir, "anonymous-id"),95 anonymousIdPath: join(stateDir, "anonymous-id"),
96 environmentIdPath: join(stateDir, "environment-id"),
97 serverRuntimeStatePath: join(stateDir, "server-runtime.json"),
98 secretsDir: join(stateDir, "secrets"),
86 };99 };
87});100});
88 101
@@ -100,7 +113,9 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 11 unmodified lines
100 fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),113 fs.makeDirectory(derivedPaths.worktreesDir, { recursive: true }),
101 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),114 fs.makeDirectory(path.dirname(derivedPaths.keybindingsConfigPath), { recursive: true }),
102 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),115 fs.makeDirectory(path.dirname(derivedPaths.settingsPath), { recursive: true }),
116 fs.makeDirectory(derivedPaths.providerStatusCacheDir, { recursive: true }),
103 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),117 fs.makeDirectory(path.dirname(derivedPaths.anonymousIdPath), { recursive: true }),
118 fs.makeDirectory(path.dirname(derivedPaths.serverRuntimeStatePath), { recursive: true }),
104 ],119 ],
105 { concurrency: "unbounded" },120 { concurrency: "unbounded" },
106 );121 );
@@ -109,7 +124,7 @@ export const ensureServerDirectories = Effect.fn(function* (derivedPaths: Server · 2 unmodified lines
109/**124/**
110 * ServerConfig - Service tag for server runtime configuration.125 * ServerConfig - Service tag for server runtime configuration.
111 */126 */
112export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigShape>()(127export class ServerConfig extends Context.Service<ServerConfig, ServerConfigShape>()(
113 "t3/config/ServerConfig",128 "t3/config/ServerConfig",
114) {129) {
115 static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) =>130 static readonly layerTest = (cwd: string, baseDirOrPrefix: string | { prefix: string }) =>
@@ -145,10 +160,11 @@ export class ServerConfig extends ServiceMap.Service<ServerConfig, ServerConfigS · 29 unmodified lines
145 logWebSocketEvents: false,160 logWebSocketEvents: false,
146 port: 0,161 port: 0,
147 host: undefined,162 host: undefined,
148 authToken: undefined,163 desktopBootstrapToken: undefined,
149 staticDir: undefined,164 staticDir: undefined,
150 devUrl,165 devUrl,
151 noBrowser: false,166 noBrowser: false,
167 startupPresentation: "browser",
152 } satisfies ServerConfigShape;168 } satisfies ServerConfigShape;
153 }),169 }),
154 );170 );
apps/server/src/environment/Layers/ServerEnvironment.test.ts+125−0
@@ -0,0 +1,125 @@
1import * as nodePath from "node:path";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { expect, it } from "@effect/vitest";
4import { Effect, Exit, FileSystem, Layer, PlatformError } from "effect";
5
6import { deriveServerPaths, ServerConfig, type ServerConfigShape } from "../../config.ts";
7import { ServerEnvironment } from "../Services/ServerEnvironment.ts";
8import { ServerEnvironmentLive } from "./ServerEnvironment.ts";
9
10const makeServerEnvironmentLayer = (baseDir: string) =>
11 ServerEnvironmentLive.pipe(Layer.provide(ServerConfig.layerTest(process.cwd(), baseDir)));
12
13const makeServerConfig = Effect.fn(function* (baseDir: string) {
14 const derivedPaths = yield* deriveServerPaths(baseDir, undefined);
15
16 return {
17 ...derivedPaths,
18 logLevel: "Error",
19 traceMinLevel: "Info",
20 traceTimingEnabled: true,
21 traceBatchWindowMs: 200,
22 traceMaxBytes: 10 * 1024 * 1024,
23 traceMaxFiles: 10,
24 otlpTracesUrl: undefined,
25 otlpMetricsUrl: undefined,
26 otlpExportIntervalMs: 10_000,
27 otlpServiceName: "t3-server",
28 cwd: process.cwd(),
29 baseDir,
30 mode: "web",
31 autoBootstrapProjectFromCwd: false,
32 logWebSocketEvents: false,
33 port: 0,
34 host: undefined,
35 desktopBootstrapToken: undefined,
36 staticDir: undefined,
37 devUrl: undefined,
38 noBrowser: false,
39 startupPresentation: "browser",
40 } satisfies ServerConfigShape;
41});
42
43it.layer(NodeServices.layer)("ServerEnvironmentLive", (it) => {
44 it.effect("persists the environment id across service restarts", () =>
45 Effect.gen(function* () {
46 const fileSystem = yield* FileSystem.FileSystem;
47 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
48 prefix: "t3-server-environment-test-",
49 });
50
51 const first = yield* Effect.gen(function* () {
52 const serverEnvironment = yield* ServerEnvironment;
53 return yield* serverEnvironment.getDescriptor;
54 }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
55 const second = yield* Effect.gen(function* () {
56 const serverEnvironment = yield* ServerEnvironment;
57 return yield* serverEnvironment.getDescriptor;
58 }).pipe(Effect.provide(makeServerEnvironmentLayer(baseDir)));
59
60 expect(first.environmentId).toBe(second.environmentId);
61 expect(second.capabilities.repositoryIdentity).toBe(true);
62 }),
63 );
64
65 it.effect("fails instead of overwriting a persisted id when reading the file errors", () =>
66 Effect.gen(function* () {
67 const fileSystem = yield* FileSystem.FileSystem;
68 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
69 prefix: "t3-server-environment-read-error-test-",
70 });
71 const serverConfig = yield* makeServerConfig(baseDir);
72 const environmentIdPath = serverConfig.environmentIdPath;
73 yield* fileSystem.makeDirectory(nodePath.dirname(environmentIdPath), { recursive: true });
74 yield* fileSystem.writeFileString(environmentIdPath, "persisted-environment-id\n");
75 const writeAttempts: string[] = [];
76 const failingFileSystemLayer = FileSystem.layerNoop({
77 exists: (path) => Effect.succeed(path === environmentIdPath),
78 readFileString: (path) =>
79 path === environmentIdPath
80 ? Effect.fail(
81 PlatformError.systemError({
82 _tag: "PermissionDenied",
83 module: "FileSystem",
84 method: "readFileString",
85 description: "permission denied",
86 pathOrDescriptor: path,
87 }),
88 )
89 : Effect.fail(
90 PlatformError.systemError({
91 _tag: "NotFound",
92 module: "FileSystem",
93 method: "readFileString",
94 description: "not found",
95 pathOrDescriptor: path,
96 }),
97 ),
98 writeFileString: (path) => {
99 writeAttempts.push(path);
100 return Effect.void;
101 },
102 });
103
104 const exit = yield* Effect.gen(function* () {
105 const serverEnvironment = yield* ServerEnvironment;
106 return yield* serverEnvironment.getDescriptor;
107 }).pipe(
108 Effect.provide(
109 ServerEnvironmentLive.pipe(
110 Layer.provide(
111 Layer.merge(Layer.succeed(ServerConfig, serverConfig), failingFileSystemLayer),
112 ),
113 ),
114 ),
115 Effect.exit,
116 );
117
118 expect(Exit.isFailure(exit)).toBe(true);
119 expect(writeAttempts).toEqual([]);
120 expect(yield* fileSystem.readFileString(environmentIdPath)).toBe(
121 "persisted-environment-id\n",
122 );
123 }),
124 );
125});
apps/server/src/environment/Layers/ServerEnvironment.ts+92−0
@@ -0,0 +1,92 @@
1import { EnvironmentId, type ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { Effect, FileSystem, Layer, Path, Random } from "effect";
3
4import { ServerConfig } from "../../config.ts";
5import { ServerEnvironment, type ServerEnvironmentShape } from "../Services/ServerEnvironment.ts";
6import packageJson from "../../../package.json" with { type: "json" };
7import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
8
9function platformOs(): ExecutionEnvironmentDescriptor["platform"]["os"] {
10 switch (process.platform) {
11 case "darwin":
12 return "darwin";
13 case "linux":
14 return "linux";
15 case "win32":
16 return "windows";
17 default:
18 return "unknown";
19 }
20}
21
22function platformArch(): ExecutionEnvironmentDescriptor["platform"]["arch"] {
23 switch (process.arch) {
24 case "arm64":
25 return "arm64";
26 case "x64":
27 return "x64";
28 default:
29 return "other";
30 }
31}
32
33export const makeServerEnvironment = Effect.fn("makeServerEnvironment")(function* () {
34 const fileSystem = yield* FileSystem.FileSystem;
35 const path = yield* Path.Path;
36 const serverConfig = yield* ServerConfig;
37
38 const readPersistedEnvironmentId = Effect.gen(function* () {
39 const exists = yield* fileSystem
40 .exists(serverConfig.environmentIdPath)
41 .pipe(Effect.orElseSucceed(() => false));
42 if (!exists) {
43 return null;
44 }
45
46 const raw = yield* fileSystem
47 .readFileString(serverConfig.environmentIdPath)
48 .pipe(Effect.map((value) => value.trim()));
49
50 return raw.length > 0 ? raw : null;
51 });
52
53 const persistEnvironmentId = (value: string) =>
54 fileSystem.writeFileString(serverConfig.environmentIdPath, `${value}\n`);
55
56 const environmentIdRaw = yield* Effect.gen(function* () {
57 const persisted = yield* readPersistedEnvironmentId;
58 if (persisted) {
59 return persisted;
60 }
61
62 const generated = yield* Random.nextUUIDv4;
63 yield* persistEnvironmentId(generated);
64 return generated;
65 });
66
67 const environmentId = EnvironmentId.make(environmentIdRaw);
68 const cwdBaseName = path.basename(serverConfig.cwd).trim();
69 const label = yield* resolveServerEnvironmentLabel({
70 cwdBaseName,
71 });
72
73 const descriptor: ExecutionEnvironmentDescriptor = {
74 environmentId,
75 label,
76 platform: {
77 os: platformOs(),
78 arch: platformArch(),
79 },
80 serverVersion: packageJson.version,
81 capabilities: {
82 repositoryIdentity: true,
83 },
84 };
85
86 return {
87 getEnvironmentId: Effect.succeed(environmentId),
88 getDescriptor: Effect.succeed(descriptor),
89 } satisfies ServerEnvironmentShape;
90});
91
92export const ServerEnvironmentLive = Layer.effect(ServerEnvironment, makeServerEnvironment());
apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+150−0
@@ -0,0 +1,150 @@
1import { afterEach, describe, expect, it } from "@effect/vitest";
2import { Effect, FileSystem } from "effect";
3import { vi } from "vitest";
4
5vi.mock("../../processRunner.ts", () => ({
6 runProcess: vi.fn(),
7}));
8
9import { runProcess } from "../../processRunner.ts";
10import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
11
12const mockedRunProcess = vi.mocked(runProcess);
13const NoopFileSystemLayer = FileSystem.layerNoop({});
14
15afterEach(() => {
16 mockedRunProcess.mockReset();
17});
18
19describe("resolveServerEnvironmentLabel", () => {
20 it.effect("uses hostname fallback regardless of launch mode", () =>
21 Effect.gen(function* () {
22 const result = yield* resolveServerEnvironmentLabel({
23 cwdBaseName: "t3code",
24 platform: "win32",
25 hostname: "macbook-pro",
26 }).pipe(Effect.provide(NoopFileSystemLayer));
27
28 expect(result).toBe("macbook-pro");
29 }),
30 );
31
32 it.effect("prefers the macOS ComputerName", () =>
33 Effect.gen(function* () {
34 mockedRunProcess.mockResolvedValueOnce({
35 stdout: " Julius's MacBook Pro \n",
36 stderr: "",
37 code: 0,
38 signal: null,
39 timedOut: false,
40 });
41
42 const result = yield* resolveServerEnvironmentLabel({
43 cwdBaseName: "t3code",
44 platform: "darwin",
45 hostname: "macbook-pro",
46 }).pipe(Effect.provide(NoopFileSystemLayer));
47
48 expect(result).toBe("Julius's MacBook Pro");
49 expect(mockedRunProcess).toHaveBeenCalledWith(
50 "scutil",
51 ["--get", "ComputerName"],
52 expect.objectContaining({ allowNonZeroExit: true }),
53 );
54 }),
55 );
56
57 it.effect("prefers Linux PRETTY_HOSTNAME from machine-info", () =>
58 Effect.gen(function* () {
59 const result = yield* resolveServerEnvironmentLabel({
60 cwdBaseName: "t3code",
61 platform: "linux",
62 hostname: "buildbox",
63 }).pipe(
64 Effect.provide(
65 FileSystem.layerNoop({
66 exists: (path) => Effect.succeed(path === "/etc/machine-info"),
67 readFileString: (path) =>
68 path === "/etc/machine-info"
69 ? Effect.succeed('PRETTY_HOSTNAME="Build Agent 01"\nICON_NAME="computer-vm"\n')
70 : Effect.succeed(""),
71 }),
72 ),
73 );
74
75 expect(result).toBe("Build Agent 01");
76 expect(mockedRunProcess).not.toHaveBeenCalled();
77 }),
78 );
79
80 it.effect("falls back to hostnamectl pretty hostname on Linux", () =>
81 Effect.gen(function* () {
82 mockedRunProcess.mockResolvedValueOnce({
83 stdout: "CI Runner\n",
84 stderr: "",
85 code: 0,
86 signal: null,
87 timedOut: false,
88 });
89
90 const result = yield* resolveServerEnvironmentLabel({
91 cwdBaseName: "t3code",
92 platform: "linux",
93 hostname: "runner-01",
94 }).pipe(Effect.provide(NoopFileSystemLayer));
95
96 expect(result).toBe("CI Runner");
97 expect(mockedRunProcess).toHaveBeenCalledWith(
98 "hostnamectl",
99 ["--pretty"],
100 expect.objectContaining({ allowNonZeroExit: true }),
101 );
102 }),
103 );
104
105 it.effect("falls back to the hostname when friendly labels are unavailable", () =>
106 Effect.gen(function* () {
107 const result = yield* resolveServerEnvironmentLabel({
108 cwdBaseName: "t3code",
109 platform: "win32",
110 hostname: "JULIUS-LAPTOP",
111 }).pipe(Effect.provide(NoopFileSystemLayer));
112
113 expect(result).toBe("JULIUS-LAPTOP");
114 }),
115 );
116
117 it.effect("falls back to the hostname when the friendly-label command is missing", () =>
118 Effect.gen(function* () {
119 mockedRunProcess.mockRejectedValueOnce(new Error("spawn scutil ENOENT"));
120
121 const result = yield* resolveServerEnvironmentLabel({
122 cwdBaseName: "t3code",
123 platform: "darwin",
124 hostname: "macbook-pro",
125 }).pipe(Effect.provide(NoopFileSystemLayer));
126
127 expect(result).toBe("macbook-pro");
128 }),
129 );
130
131 it.effect("falls back to the cwd basename when the hostname is blank", () =>
132 Effect.gen(function* () {
133 mockedRunProcess.mockResolvedValueOnce({
134 stdout: " ",
135 stderr: "",
136 code: 0,
137 signal: null,
138 timedOut: false,
139 });
140
141 const result = yield* resolveServerEnvironmentLabel({
142 cwdBaseName: "t3code",
143 platform: "linux",
144 hostname: " ",
145 }).pipe(Effect.provide(NoopFileSystemLayer));
146
147 expect(result).toBe("t3code");
148 }),
149 );
150});
apps/server/src/environment/Layers/ServerEnvironmentLabel.ts+106−0
@@ -0,0 +1,106 @@
1import * as OS from "node:os";
2
3import { Effect, FileSystem } from "effect";
4
5import { runProcess } from "../../processRunner.ts";
6
7interface ResolveServerEnvironmentLabelInput {
8 readonly cwdBaseName: string;
9 readonly platform?: NodeJS.Platform;
10 readonly hostname?: string | null;
11}
12
13function normalizeLabel(value: string | null | undefined): string | null {
14 const trimmed = value?.trim();
15 return trimmed && trimmed.length > 0 ? trimmed : null;
16}
17
18function parseMachineInfoValue(raw: string, key: string): string | null {
19 for (const line of raw.split(/\r?\n/g)) {
20 const trimmed = line.trim();
21 if (trimmed.length === 0 || trimmed.startsWith("#") || !trimmed.startsWith(`${key}=`)) {
22 continue;
23 }
24 const value = trimmed.slice(key.length + 1).trim();
25 if (
26 (value.startsWith('"') && value.endsWith('"')) ||
27 (value.startsWith("'") && value.endsWith("'"))
28 ) {
29 return normalizeLabel(value.slice(1, -1));
30 }
31 return normalizeLabel(value);
32 }
33 return null;
34}
35
36const readLinuxMachineInfo = Effect.fn("readLinuxMachineInfo")(function* () {
37 const fileSystem = yield* FileSystem.FileSystem;
38 const exists = yield* fileSystem
39 .exists("/etc/machine-info")
40 .pipe(Effect.orElseSucceed(() => false));
41 if (!exists) {
42 return null;
43 }
44
45 return yield* fileSystem
46 .readFileString("/etc/machine-info")
47 .pipe(Effect.orElseSucceed(() => null));
48});
49
50const runFriendlyLabelCommand = Effect.fn("runFriendlyLabelCommand")(function* (
51 command: string,
52 args: readonly string[],
53) {
54 const result = yield* Effect.tryPromise({
55 try: () =>
56 runProcess(command, args, {
57 allowNonZeroExit: true,
58 }),
59 catch: () => null,
60 }).pipe(Effect.orElseSucceed(() => null));
61
62 if (!result || result.code !== 0) {
63 return null;
64 }
65
66 return normalizeLabel(result.stdout);
67});
68
69const resolveFriendlyHostLabel = Effect.fn("resolveFriendlyHostLabel")(function* (
70 platform: NodeJS.Platform,
71) {
72 if (platform === "darwin") {
73 return yield* runFriendlyLabelCommand("scutil", ["--get", "ComputerName"]);
74 }
75
76 if (platform === "linux") {
77 const machineInfo = normalizeLabel(yield* readLinuxMachineInfo());
78 if (machineInfo) {
79 const prettyHostname = parseMachineInfoValue(machineInfo, "PRETTY_HOSTNAME");
80 if (prettyHostname) {
81 return prettyHostname;
82 }
83 }
84
85 return yield* runFriendlyLabelCommand("hostnamectl", ["--pretty"]);
86 }
87
88 return null;
89});
90
91export const resolveServerEnvironmentLabel = Effect.fn("resolveServerEnvironmentLabel")(function* (
92 input: ResolveServerEnvironmentLabelInput,
93) {
94 const platform = input.platform ?? process.platform;
95 const friendlyHostLabel = yield* resolveFriendlyHostLabel(platform);
96 if (friendlyHostLabel) {
97 return friendlyHostLabel;
98 }
99
100 const hostname = normalizeLabel(input.hostname ?? OS.hostname());
101 if (hostname) {
102 return hostname;
103 }
104
105 return normalizeLabel(input.cwdBaseName) ?? "T3 environment";
106});
apps/server/src/environment/Services/ServerEnvironment.ts+12−0
@@ -0,0 +1,12 @@
1import type { EnvironmentId, ExecutionEnvironmentDescriptor } from "@t3tools/contracts";
2import { Context } from "effect";
3import type { Effect } from "effect";
4
5export interface ServerEnvironmentShape {
6 readonly getEnvironmentId: Effect.Effect<EnvironmentId>;
7 readonly getDescriptor: Effect.Effect<ExecutionEnvironmentDescriptor>;
8}
9
10export class ServerEnvironment extends Context.Service<ServerEnvironment, ServerEnvironmentShape>()(
11 "t3/environment/Services/ServerEnvironment",
12) {}
apps/server/src/git/Layers/CodexTextGeneration.ts+1−0
@@ -166,6 +166,7 @@ const makeCodexTextGeneration = Effect.gen(function* () { · 165 unmodified lines
166 [166 [
167 "exec",167 "exec",
168 "--ephemeral",168 "--ephemeral",
169 "--skip-git-repo-check",
169 "-s",170 "-s",
170 "read-only",171 "read-only",
171 "--model",172 "--model",
apps/server/src/git/Layers/GitCore.test.ts+183−63
@@ -40,6 +40,24 @@ function writeTextFile( · 39 unmodified lines
40 });40 });
41}41}
42 42
43function removePath(
44 targetPath: string,
45): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
46 return Effect.gen(function* () {
47 const fileSystem = yield* FileSystem.FileSystem;
48 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
49 });
50}
51
52function makeDirectory(
53 dirPath: string,
54): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
55 return Effect.gen(function* () {
56 const fileSystem = yield* FileSystem.FileSystem;
57 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
58 });
59}
60
43/** Run a raw git command for test setup (not under test). */61/** Run a raw git command for test setup (not under test). */
44function git(62function git(
45 cwd: string,63 cwd: string,
@@ -219,7 +237,10 @@ it.layer(TestLayer)("git integration", (it) => { · 173 unmodified lines
219 237
220 const seenChunks: string[][] = [];238 const seenChunks: string[][] = [];
221 const core = yield* makeIsolatedGitCore((input) => {239 const core = yield* makeIsolatedGitCore((input) => {
222 if (input.args.join(" ") !== "check-ignore --no-index -z --stdin") {240 if (
241 input.args.join(" ") !==
242 "-c core.fsmonitor=false -c core.untrackedCache=false check-ignore --no-index -z --stdin"
243 ) {
223 return Effect.fail(244 return Effect.fail(
224 new GitCommandError({245 new GitCommandError({
225 operation: input.operation,246 operation: input.operation,
@@ -252,6 +273,35 @@ it.layer(TestLayer)("git integration", (it) => { · 26 unmodified lines
252 expect(result).toEqual(expectedPaths);273 expect(result).toEqual(expectedPaths);
253 }),274 }),
254 );275 );
276
277 it.effect("listWorkspaceFiles disables fsmonitor and untracked cache helpers", () =>
278 Effect.gen(function* () {
279 const core = yield* makeIsolatedGitCore((input) => {
280 expect(input.args).toEqual([
281 "-c",
282 "core.fsmonitor=false",
283 "-c",
284 "core.untrackedCache=false",
285 "ls-files",
286 "--cached",
287 "--others",
288 "--exclude-standard",
289 "-z",
290 ]);
291 return Effect.succeed({
292 code: 0,
293 stdout: "src/index.ts\0README.md\0",
294 stderr: "",
295 stdoutTruncated: false,
296 stderrTruncated: false,
297 });
298 });
299
300 const result = yield* core.listWorkspaceFiles("/virtual/repo");
301 expect(result.paths).toEqual(["src/index.ts", "README.md"]);
302 expect(result.truncated).toBe(false);
303 }),
304 );
255 });305 });
256 306
257 // ── listGitBranches ──307 // ── listGitBranches ──
@@ -267,6 +317,21 @@ it.layer(TestLayer)("git integration", (it) => { · 9 unmodified lines
267 }),317 }),
268 );318 );
269 319
320 it.effect("returns isRepo: false for deleted directories", () =>
321 Effect.gen(function* () {
322 const tmp = yield* makeTmpDir();
323 const deletedDir = path.join(tmp, "deleted-repo");
324 yield* makeDirectory(deletedDir);
325 yield* removePath(deletedDir);
326
327 const result = yield* (yield* GitCore).listBranches({ cwd: deletedDir });
328
329 expect(result.isRepo).toBe(false);
330 expect(result.hasOriginRemote).toBe(false);
331 expect(result.branches).toEqual([]);
332 }),
333 );
334
270 it.effect("returns the current branch with current: true", () =>335 it.effect("returns the current branch with current: true", () =>
271 Effect.gen(function* () {336 Effect.gen(function* () {
272 const tmp = yield* makeTmpDir();337 const tmp = yield* makeTmpDir();
@@ -617,8 +682,8 @@ it.layer(TestLayer)("git integration", (it) => { · 344 unmodified lines
617 682
618 it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>683 it.effect("refreshes upstream behind count after checkout when remote branch advanced", () =>
619 Effect.gen(function* () {684 Effect.gen(function* () {
620 const services = yield* Effect.services();685 const context = yield* Effect.context<never>();
621 const runPromise = Effect.runPromiseWith(services);686 const runPromise = Effect.runPromiseWith(context);
622 687
623 const remote = yield* makeTmpDir();688 const remote = yield* makeTmpDir();
624 const source = yield* makeTmpDir();689 const source = yield* makeTmpDir();
@@ -761,7 +826,7 @@ it.layer(TestLayer)("git integration", (it) => { · 136 unmodified lines
761 }),826 }),
762 );827 );
763 828
764 it.effect("shares upstream refreshes across worktrees that use the same git common dir", () =>829 it.effect("coalesces upstream refreshes across sibling worktrees on the same remote", () =>
765 Effect.gen(function* () {830 Effect.gen(function* () {
766 const ok = (stdout = "") =>831 const ok = (stdout = "") =>
767 Effect.succeed({832 Effect.succeed({
@@ -780,7 +845,9 @@ it.layer(TestLayer)("git integration", (it) => { · 12 unmodified lines
780 input.args[2] === "--symbolic-full-name" &&845 input.args[2] === "--symbolic-full-name" &&
781 input.args[3] === "@{upstream}"846 input.args[3] === "@{upstream}"
782 ) {847 ) {
783 return ok("origin/main\n");848 return ok(
849 input.cwd === "/repo/worktrees/pr-123" ? "origin/feature/pr-123\n" : "origin/main\n",
850 );
784 }851 }
785 if (input.args[0] === "remote") {852 if (input.args[0] === "remote") {
786 return ok("origin\n");853 return ok("origin\n");
@@ -791,10 +858,22 @@ it.layer(TestLayer)("git integration", (it) => { · 4 unmodified lines
791 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {858 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
792 fetchCount += 1;859 fetchCount += 1;
793 expect(input.cwd).toBe("/repo");860 expect(input.cwd).toBe("/repo");
861 expect(input.args).toEqual([
862 "--git-dir",
863 "/repo/.git",
864 "fetch",
865 "--quiet",
866 "--no-tags",
867 "origin",
868 ]);
794 return ok();869 return ok();
795 }870 }
796 if (input.operation === "GitCore.statusDetails.status") {871 if (input.operation === "GitCore.statusDetails.status") {
797 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");872 return ok(
873 input.cwd === "/repo/worktrees/pr-123"
874 ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
875 : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
876 );
798 }877 }
799 if (878 if (
800 input.operation === "GitCore.statusDetails.unstagedNumstat" ||879 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
@@ -821,70 +900,80 @@ it.layer(TestLayer)("git integration", (it) => { · 20 unmodified lines
821 }),900 }),
822 );901 );
823 902
824 it.effect("briefly backs off failed upstream refreshes across sibling worktrees", () =>903 it.effect(
825 Effect.gen(function* () {904 "briefly backs off failed upstream refreshes across sibling worktrees on one remote",
826 const ok = (stdout = "") =>905 () =>
827 Effect.succeed({906 Effect.gen(function* () {
828 code: 0,907 const ok = (stdout = "") =>
829 stdout,908 Effect.succeed({
830 stderr: "",909 code: 0,
831 stdoutTruncated: false,910 stdout,
832 stderrTruncated: false,911 stderr: "",
833 });912 stdoutTruncated: false,
913 stderrTruncated: false,
914 });
834 915
835 let fetchCount = 0;916 let fetchCount = 0;
836 const core = yield* makeIsolatedGitCore((input) => {917 const core = yield* makeIsolatedGitCore((input) => {
837 if (918 if (
838 input.args[0] === "rev-parse" &&919 input.args[0] === "rev-parse" &&
839 input.args[1] === "--abbrev-ref" &&920 input.args[1] === "--abbrev-ref" &&
840 input.args[2] === "--symbolic-full-name" &&921 input.args[2] === "--symbolic-full-name" &&
841 input.args[3] === "@{upstream}"922 input.args[3] === "@{upstream}"
842 ) {923 ) {
843 return ok("origin/main\n");924 return ok(
844 }925 input.cwd === "/repo/worktrees/pr-123"
845 if (input.args[0] === "remote") {926 ? "origin/feature/pr-123\n"
846 return ok("origin\n");927 : "origin/main\n",
847 }928 );
848 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {929 }
849 return ok("/repo/.git\n");930 if (input.args[0] === "remote") {
850 }931 return ok("origin\n");
851 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {932 }
852 fetchCount += 1;933 if (input.args[0] === "rev-parse" && input.args[1] === "--git-common-dir") {
934 return ok("/repo/.git\n");
935 }
936 if (input.args[0] === "--git-dir" && input.args[2] === "fetch") {
937 fetchCount += 1;
938 return Effect.fail(
939 new GitCommandError({
940 operation: input.operation,
941 command: `git ${input.args.join(" ")}`,
942 cwd: input.cwd,
943 detail: "simulated fetch timeout",
944 }),
945 );
946 }
947 if (input.operation === "GitCore.statusDetails.status") {
948 return ok(
949 input.cwd === "/repo/worktrees/pr-123"
950 ? "# branch.head feature/pr-123\n# branch.upstream origin/feature/pr-123\n# branch.ab +0 -0\n"
951 : "# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n",
952 );
953 }
954 if (
955 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
956 input.operation === "GitCore.statusDetails.stagedNumstat"
957 ) {
958 return ok();
959 }
960 if (input.operation === "GitCore.statusDetails.defaultRef") {
961 return ok("refs/remotes/origin/main\n");
962 }
853 return Effect.fail(963 return Effect.fail(
854 new GitCommandError({964 new GitCommandError({
855 operation: input.operation,965 operation: input.operation,
856 command: `git ${input.args.join(" ")}`,966 command: `git ${input.args.join(" ")}`,
857 cwd: input.cwd,967 cwd: input.cwd,
858 detail: "simulated fetch timeout",968 detail: "Unexpected git command in refresh failure cooldown test.",
859 }),969 }),
860 );970 );
861 }971 });
862 if (input.operation === "GitCore.statusDetails.status") {
863 return ok("# branch.head main\n# branch.upstream origin/main\n# branch.ab +0 -0\n");
864 }
865 if (
866 input.operation === "GitCore.statusDetails.unstagedNumstat" ||
867 input.operation === "GitCore.statusDetails.stagedNumstat"
868 ) {
869 return ok();
870 }
871 if (input.operation === "GitCore.statusDetails.defaultRef") {
872 return ok("refs/remotes/origin/main\n");
873 }
874 return Effect.fail(
875 new GitCommandError({
876 operation: input.operation,
877 command: `git ${input.args.join(" ")}`,
878 cwd: input.cwd,
879 detail: "Unexpected git command in refresh failure cooldown test.",
880 }),
881 );
882 });
883 972
884 yield* core.statusDetails("/repo/worktrees/main");973 yield* core.statusDetails("/repo/worktrees/main");
885 yield* core.statusDetails("/repo/worktrees/pr-123");974 yield* core.statusDetails("/repo/worktrees/pr-123");
886 expect(fetchCount).toBe(1);975 expect(fetchCount).toBe(1);
887 }),976 }),
888 );977 );
889 978
890 it.effect("throws when branch does not exist", () =>979 it.effect("throws when branch does not exist", () =>
@@ -949,11 +1038,12 @@ it.layer(TestLayer)("git integration", (it) => { · 58 unmodified lines
949 yield* git(source, ["checkout", defaultBranch]);1038 yield* git(source, ["checkout", defaultBranch]);
950 yield* git(source, ["branch", "-D", featureBranch]);1039 yield* git(source, ["branch", "-D", featureBranch]);
951 1040
952 yield* (yield* GitCore).checkoutBranch({1041 const checkoutResult = yield* (yield* GitCore).checkoutBranch({
953 cwd: source,1042 cwd: source,
954 branch: `${remoteName}/${featureBranch}`,1043 branch: `${remoteName}/${featureBranch}`,
955 });1044 });
956 1045
1046 expect(checkoutResult.branch).toBe("upstream/feature");
957 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");1047 expect(yield* git(source, ["branch", "--show-current"])).toBe("upstream/feature");
958 const realGitCore = yield* GitCore;1048 const realGitCore = yield* GitCore;
959 let fetchArgs: readonly string[] | null = null;1049 let fetchArgs: readonly string[] | null = null;
@@ -981,7 +1071,6 @@ it.layer(TestLayer)("git integration", (it) => { · 21 unmodified lines
981 "--quiet",1071 "--quiet",
982 "--no-tags",1072 "--no-tags",
983 remoteName,1073 remoteName,
984 `+refs/heads/${featureBranch}:refs/remotes/${remoteName}/${featureBranch}`,
985 ]);1074 ]);
986 }),1075 }),
987 );1076 );
@@ -1593,6 +1682,37 @@ it.layer(TestLayer)("git integration", (it) => { · 605 unmodified lines
1593 }),1682 }),
1594 );1683 );
1595 1684
1685 it.effect("returns a non-repo status for deleted directories", () =>
1686 Effect.gen(function* () {
1687 const tmp = yield* makeTmpDir();
1688 const deletedDir = path.join(tmp, "deleted-repo");
1689 yield* makeDirectory(deletedDir);
1690 yield* removePath(deletedDir);
1691 const core = yield* GitCore;
1692
1693 const status = yield* core.statusDetails(deletedDir);
1694 const localStatus = yield* core.statusDetailsLocal(deletedDir);
1695
1696 expect(status).toEqual({
1697 isRepo: false,
1698 hasOriginRemote: false,
1699 isDefaultBranch: false,
1700 branch: null,
1701 upstreamRef: null,
1702 hasWorkingTreeChanges: false,
1703 workingTree: {
1704 files: [],
1705 insertions: 0,
1706 deletions: 0,
1707 },
1708 hasUpstream: false,
1709 aheadCount: 0,
1710 behindCount: 0,
1711 });
1712 expect(localStatus).toEqual(status);
1713 }),
1714 );
1715
1596 it.effect("computes ahead count against base branch when no upstream is configured", () =>1716 it.effect("computes ahead count against base branch when no upstream is configured", () =>
1597 Effect.gen(function* () {1717 Effect.gen(function* () {
1598 const tmp = yield* makeTmpDir();1718 const tmp = yield* makeTmpDir();
apps/server/src/git/Layers/GitCore.ts+109−36
@@ -27,6 +27,7 @@ import { · 26 unmodified lines
27 type ExecuteGitProgress,27 type ExecuteGitProgress,
28 type GitCommitOptions,28 type GitCommitOptions,
29 type GitCoreShape,29 type GitCoreShape,
30 type GitStatusDetails,
30 type ExecuteGitInput,31 type ExecuteGitInput,
31 type ExecuteGitResult,32 type ExecuteGitResult,
32} from "../Services/GitCore.ts";33} from "../Services/GitCore.ts";
@@ -47,23 +48,39 @@ const RANGE_DIFF_SUMMARY_MAX_OUTPUT_BYTES = 19_000; · 14 unmodified lines
47const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;48const RANGE_DIFF_PATCH_MAX_OUTPUT_BYTES = 59_000;
48const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;49const WORKSPACE_FILES_MAX_OUTPUT_BYTES = 16 * 1024 * 1024;
49const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;50const GIT_CHECK_IGNORE_MAX_STDIN_BYTES = 256 * 1024;
51const WORKSPACE_GIT_HARDENED_CONFIG_ARGS = [
52 "-c",
53 "core.fsmonitor=false",
54 "-c",
55 "core.untrackedCache=false",
56] as const;
50const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);57const STATUS_UPSTREAM_REFRESH_INTERVAL = Duration.seconds(15);
51const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);58const STATUS_UPSTREAM_REFRESH_TIMEOUT = Duration.seconds(5);
52const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);59const STATUS_UPSTREAM_REFRESH_FAILURE_COOLDOWN = Duration.seconds(5);
53const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;60const STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY = 2_048;
54const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;61const DEFAULT_BASE_BRANCH_CANDIDATES = ["main", "master"] as const;
55const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;62const GIT_LIST_BRANCHES_DEFAULT_LIMIT = 100;
63const NON_REPOSITORY_STATUS_DETAILS = Object.freeze<GitStatusDetails>({
64 isRepo: false,
65 hasOriginRemote: false,
66 isDefaultBranch: false,
67 branch: null,
68 upstreamRef: null,
69 hasWorkingTreeChanges: false,
70 workingTree: { files: [], insertions: 0, deletions: 0 },
71 hasUpstream: false,
72 aheadCount: 0,
73 behindCount: 0,
74});
56 75
57type TraceTailState = {76type TraceTailState = {
58 processedChars: number;77 processedChars: number;
59 remainder: string;78 remainder: string;
60};79};
61 80
62class StatusUpstreamRefreshCacheKey extends Data.Class<{81class StatusRemoteRefreshCacheKey extends Data.Class<{
63 gitCommonDir: string;82 gitCommonDir: string;
64 upstreamRef: string;
65 remoteName: string;83 remoteName: string;
66 upstreamBranch: string;
67}> {}84}> {}
68 85
69interface ExecuteGitOptions {86interface ExecuteGitOptions {
@@ -353,6 +370,16 @@ function quoteGitCommand(args: ReadonlyArray<string>): string { · 283 unmodified lines
353 return `git ${args.join(" ")}`;370 return `git ${args.join(" ")}`;
354}371}
355 372
373function isMissingGitCwdError(error: GitCommandError): boolean {
374 const normalized = `${error.detail}\n${error.message}`.toLowerCase();
375 return (
376 normalized.includes("no such file or directory") ||
377 normalized.includes("notfound: filesystem.access") ||
378 normalized.includes("enoent") ||
379 normalized.includes("not a directory")
380 );
381}
382
356function toGitCommandError(383function toGitCommandError(
357 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,384 input: Pick<ExecuteGitInput, "operation" | "cwd" | "args">,
358 detail: string,385 detail: string,
@@ -890,17 +917,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 531 unmodified lines
890 );917 );
891 });918 });
892 919
893 const fetchUpstreamRefForStatus = (920 const fetchRemoteForStatus = (
894 gitCommonDir: string,921 gitCommonDir: string,
895 upstream: { upstreamRef: string; remoteName: string; upstreamBranch: string },922 remoteName: string,
896 ): Effect.Effect<void, GitCommandError> => {923 ): Effect.Effect<void, GitCommandError> => {
897 const refspec = `+refs/heads/${upstream.upstreamBranch}:refs/remotes/${upstream.upstreamRef}`;
898 const fetchCwd =924 const fetchCwd =
899 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;925 path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir;
900 return executeGit(926 return executeGit(
901 "GitCore.fetchUpstreamRefForStatus",927 "GitCore.fetchRemoteForStatus",
902 fetchCwd,928 fetchCwd,
903 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", upstream.remoteName, refspec],929 ["--git-dir", gitCommonDir, "fetch", "--quiet", "--no-tags", remoteName],
904 {930 {
905 allowNonZeroExit: true,931 allowNonZeroExit: true,
906 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),932 timeoutMs: Duration.toMillis(STATUS_UPSTREAM_REFRESH_TIMEOUT),
@@ -916,20 +942,15 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 9 unmodified lines
916 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);942 return path.isAbsolute(gitCommonDir) ? gitCommonDir : path.resolve(cwd, gitCommonDir);
917 });943 });
918 944
919 const refreshStatusUpstreamCacheEntry = Effect.fn("refreshStatusUpstreamCacheEntry")(function* (945 const refreshStatusRemoteCacheEntry = Effect.fn("refreshStatusRemoteCacheEntry")(function* (
920 cacheKey: StatusUpstreamRefreshCacheKey,946 cacheKey: StatusRemoteRefreshCacheKey,
921 ) {947 ) {
922 yield* fetchUpstreamRefForStatus(cacheKey.gitCommonDir, {948 yield* fetchRemoteForStatus(cacheKey.gitCommonDir, cacheKey.remoteName);
923 upstreamRef: cacheKey.upstreamRef,
924 remoteName: cacheKey.remoteName,
925 upstreamBranch: cacheKey.upstreamBranch,
926 });
927 return true as const;949 return true as const;
928 });950 });
929 951
930 const statusUpstreamRefreshCache = yield* Cache.makeWith({952 const statusRemoteRefreshCache = yield* Cache.makeWith(refreshStatusRemoteCacheEntry, {
931 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,953 capacity: STATUS_UPSTREAM_REFRESH_CACHE_CAPACITY,
932 lookup: refreshStatusUpstreamCacheEntry,
933 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.954 // Keep successful refreshes warm and briefly back off failed refreshes to avoid retry storms.
934 timeToLive: (exit) =>955 timeToLive: (exit) =>
935 Exit.isSuccess(exit)956 Exit.isSuccess(exit)
@@ -944,12 +965,10 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 8 unmodified lines
944 if (!upstream) return;965 if (!upstream) return;
945 const gitCommonDir = yield* resolveGitCommonDir(cwd);966 const gitCommonDir = yield* resolveGitCommonDir(cwd);
946 yield* Cache.get(967 yield* Cache.get(
947 statusUpstreamRefreshCache,968 statusRemoteRefreshCache,
948 new StatusUpstreamRefreshCacheKey({969 new StatusRemoteRefreshCacheKey({
949 gitCommonDir,970 gitCommonDir,
950 upstreamRef: upstream.upstreamRef,
951 remoteName: upstream.remoteName,971 remoteName: upstream.remoteName,
952 upstreamBranch: upstream.upstreamBranch,
953 }),972 }),
954 );973 );
955 });974 });
@@ -1177,9 +1196,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 221 unmodified lines
1177 return branchLastCommit;1196 return branchLastCommit;
1178 });1197 });
1179 1198
1180 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {1199 const readStatusDetailsLocal = Effect.fn("readStatusDetailsLocal")(function* (cwd: string) {
1181 yield* refreshStatusUpstreamIfStale(cwd).pipe(Effect.ignoreCause({ log: true }));
1182
1183 const statusResult = yield* executeGit(1200 const statusResult = yield* executeGit(
1184 "GitCore.statusDetails.status",1201 "GitCore.statusDetails.status",
1185 cwd,1202 cwd,
@@ -1187,7 +1204,11 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 1 unmodified line
1187 {1204 {
1188 allowNonZeroExit: true,1205 allowNonZeroExit: true,
1189 },1206 },
1190 );1207 ).pipe(Effect.catchIf(isMissingGitCwdError, () => Effect.succeed(null)));
1208
1209 if (statusResult === null) {
1210 return NON_REPOSITORY_STATUS_DETAILS;
1211 }
1191 1212
1192 if (statusResult.code !== 0) {1213 if (statusResult.code !== 0) {
1193 const stderr = statusResult.stderr.trim();1214 const stderr = statusResult.stderr.trim();
@@ -1312,6 +1333,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 118 unmodified lines
1312 };1333 };
1313 });1334 });
1314 1335
1336 const statusDetailsLocal: GitCoreShape["statusDetailsLocal"] = Effect.fn("statusDetailsLocal")(
1337 function* (cwd) {
1338 return yield* readStatusDetailsLocal(cwd);
1339 },
1340 );
1341
1342 const statusDetails: GitCoreShape["statusDetails"] = Effect.fn("statusDetails")(function* (cwd) {
1343 yield* refreshStatusUpstreamIfStale(cwd).pipe(
1344 Effect.catchIf(isMissingGitCwdError, () => Effect.void),
1345 Effect.ignoreCause({ log: true }),
1346 );
1347 return yield* readStatusDetailsLocal(cwd);
1348 });
1349
1315 const status: GitCoreShape["status"] = (input) =>1350 const status: GitCoreShape["status"] = (input) =>
1316 statusDetails(input.cwd).pipe(1351 statusDetails(input.cwd).pipe(
1317 Effect.map((details) => ({1352 Effect.map((details) => ({
@@ -1610,7 +1645,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 292 unmodified lines
1610 executeGit(1645 executeGit(
1611 "GitCore.listWorkspaceFiles",1646 "GitCore.listWorkspaceFiles",
1612 cwd,1647 cwd,
1613 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],1648 [
1649 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1650 "ls-files",
1651 "--cached",
1652 "--others",
1653 "--exclude-standard",
1654 "-z",
1655 ],
1614 {1656 {
1615 allowNonZeroExit: true,1657 allowNonZeroExit: true,
1616 timeoutMs: 20_000,1658 timeoutMs: 20_000,
@@ -1628,7 +1670,14 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 11 unmodified lines
1628 createGitCommandError(1670 createGitCommandError(
1629 "GitCore.listWorkspaceFiles",1671 "GitCore.listWorkspaceFiles",
1630 cwd,1672 cwd,
1631 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],1673 [
1674 ...WORKSPACE_GIT_HARDENED_CONFIG_ARGS,
1675 "ls-files",
1676 "--cached",
1677 "--others",
1678 "--exclude-standard",
1679 "-z",
1680 ],
1632 result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed",1681 result.stderr.trim().length > 0 ? result.stderr.trim() : "git ls-files failed",
1633 ),1682 ),
1634 ),1683 ),
@@ -1648,7 +1697,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 13 unmodified lines
1648 const result = yield* executeGit(1697 const result = yield* executeGit(
1649 "GitCore.filterIgnoredPaths",1698 "GitCore.filterIgnoredPaths",
1650 cwd,1699 cwd,
1651 ["check-ignore", "--no-index", "-z", "--stdin"],1700 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
1652 {1701 {
1653 stdin: `${chunk.join("\0")}\0`,1702 stdin: `${chunk.join("\0")}\0`,
1654 allowNonZeroExit: true,1703 allowNonZeroExit: true,
@@ -1662,7 +1711,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 7 unmodified lines
1662 return yield* createGitCommandError(1711 return yield* createGitCommandError(
1663 "GitCore.filterIgnoredPaths",1712 "GitCore.filterIgnoredPaths",
1664 cwd,1713 cwd,
1665 ["check-ignore", "--no-index", "-z", "--stdin"],1714 [...WORKSPACE_GIT_HARDENED_CONFIG_ARGS, "check-ignore", "--no-index", "-z", "--stdin"],
1666 result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed",1715 result.stderr.trim().length > 0 ? result.stderr.trim() : "git check-ignore failed",
1667 );1716 );
1668 }1717 }
@@ -1691,6 +1740,16 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 22 unmodified lines
1691 timeoutMs: 10_000,1740 timeoutMs: 10_000,
1692 allowNonZeroExit: true,1741 allowNonZeroExit: true,
1693 },1742 },
1743 ).pipe(
1744 Effect.catchIf(isMissingGitCwdError, () =>
1745 Effect.succeed({
1746 code: 128,
1747 stdout: "",
1748 stderr: "fatal: not a git repository",
1749 stdoutTruncated: false,
1750 stderrTruncated: false,
1751 }),
1752 ),
1694 );1753 );
1695 1754
1696 if (localBranchResult.code !== 0) {1755 if (localBranchResult.code !== 0) {
@@ -1973,7 +2032,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 276 unmodified lines
1973 "GitCore.removeWorktree",2032 "GitCore.removeWorktree",
1974 input.cwd,2033 input.cwd,
1975 args,2034 args,
1976 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error instanceof Error ? error.message : String(error)}`,2035 `${commandLabel(args)} failed (cwd: ${input.cwd}): ${error.message}`,
1977 error,2036 error,
1978 ),2037 ),
1979 ),2038 ),
@@ -2000,12 +2059,6 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 20 unmodified lines
2000 return { branch: targetBranch };2059 return { branch: targetBranch };
2001 });2060 });
2002 2061
2003 const createBranch: GitCoreShape["createBranch"] = (input) =>
2004 executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2005 timeoutMs: 10_000,
2006 fallbackErrorMessage: "git branch create failed",
2007 }).pipe(Effect.asVoid);
2008
2009 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(2062 const checkoutBranch: GitCoreShape["checkoutBranch"] = Effect.fn("checkoutBranch")(
2010 function* (input) {2063 function* (input) {
2011 const [localInputExists, remoteExists] = yield* Effect.all(2064 const [localInputExists, remoteExists] = yield* Effect.all(
@@ -2078,9 +2131,28 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 66 unmodified lines
2078 timeoutMs: 10_000,2131 timeoutMs: 10_000,
2079 fallbackErrorMessage: "git checkout failed",2132 fallbackErrorMessage: "git checkout failed",
2080 });2133 });
2134
2135 const branch = yield* runGitStdout("GitCore.checkoutBranch.currentBranch", input.cwd, [
2136 "branch",
2137 "--show-current",
2138 ]).pipe(Effect.map((stdout) => stdout.trim() || null));
2139
2140 return { branch };
2081 },2141 },
2082 );2142 );
2083 2143
2144 const createBranch: GitCoreShape["createBranch"] = Effect.fn("createBranch")(function* (input) {
2145 yield* executeGit("GitCore.createBranch", input.cwd, ["branch", input.branch], {
2146 timeoutMs: 10_000,
2147 fallbackErrorMessage: "git branch create failed",
2148 });
2149 if (input.checkout) {
2150 yield* checkoutBranch({ cwd: input.cwd, branch: input.branch });
2151 }
2152
2153 return { branch: input.branch };
2154 });
2155
2084 const initRepo: GitCoreShape["initRepo"] = (input) =>2156 const initRepo: GitCoreShape["initRepo"] = (input) =>
2085 executeGit("GitCore.initRepo", input.cwd, ["init"], {2157 executeGit("GitCore.initRepo", input.cwd, ["init"], {
2086 timeoutMs: 10_000,2158 timeoutMs: 10_000,
@@ -2106,6 +2178,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { · 19 unmodified lines
2106 execute,2178 execute,
2107 status,2179 status,
2108 statusDetails,2180 statusDetails,
2181 statusDetailsLocal,
2109 prepareCommitContext,2182 prepareCommitContext,
2110 commit,2183 commit,
2111 pushCurrentBranch,2184 pushCurrentBranch,
apps/server/src/git/Layers/GitHubCli.test.ts+100−1
@@ -6,7 +6,7 @@ vi.mock("../../processRunner", () => ({ · 5 unmodified lines
6 runProcess: vi.fn(),6 runProcess: vi.fn(),
7}));7}));
8 8
9import { runProcess } from "../../processRunner";9import { runProcess } from "../../processRunner.ts";
10import { GitHubCli } from "../Services/GitHubCli.ts";10import { GitHubCli } from "../Services/GitHubCli.ts";
11import { GitHubCliLive } from "./GitHubCli.ts";11import { GitHubCliLive } from "./GitHubCli.ts";
12 12
@@ -76,6 +76,105 @@ layer("GitHubCliLive", (it) => { · 63 unmodified lines
76 }),76 }),
77 );77 );
78 78
79 it.effect("trims pull request fields decoded from gh json", () =>
80 Effect.gen(function* () {
81 mockedRunProcess.mockResolvedValueOnce({
82 stdout: JSON.stringify({
83 number: 42,
84 title: " Add PR thread creation \n",
85 url: " https://github.com/pingdotgg/codething-mvp/pull/42 ",
86 baseRefName: " main ",
87 headRefName: "\tfeature/pr-threads\t",
88 state: "OPEN",
89 mergedAt: null,
90 isCrossRepository: true,
91 headRepository: {
92 nameWithOwner: " octocat/codething-mvp ",
93 },
94 headRepositoryOwner: {
95 login: " octocat ",
96 },
97 }),
98 stderr: "",
99 code: 0,
100 signal: null,
101 timedOut: false,
102 });
103
104 const result = yield* Effect.gen(function* () {
105 const gh = yield* GitHubCli;
106 return yield* gh.getPullRequest({
107 cwd: "/repo",
108 reference: "#42",
109 });
110 });
111
112 assert.deepStrictEqual(result, {
113 number: 42,
114 title: "Add PR thread creation",
115 url: "https://github.com/pingdotgg/codething-mvp/pull/42",
116 baseRefName: "main",
117 headRefName: "feature/pr-threads",
118 state: "open",
119 isCrossRepository: true,
120 headRepositoryNameWithOwner: "octocat/codething-mvp",
121 headRepositoryOwnerLogin: "octocat",
122 });
123 }),
124 );
125
126 it.effect("skips invalid entries when parsing pr lists", () =>
127 Effect.gen(function* () {
128 mockedRunProcess.mockResolvedValueOnce({
129 stdout: JSON.stringify([
130 {
131 number: 0,
132 title: "invalid",
133 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
134 baseRefName: "main",
135 headRefName: "feature/invalid",
136 },
137 {
138 number: 43,
139 title: " Valid PR ",
140 url: " https://github.com/pingdotgg/codething-mvp/pull/43 ",
141 baseRefName: " main ",
142 headRefName: " feature/pr-list ",
143 headRepository: {
144 nameWithOwner: " ",
145 },
146 headRepositoryOwner: {
147 login: " ",
148 },
149 },
150 ]),
151 stderr: "",
152 code: 0,
153 signal: null,
154 timedOut: false,
155 });
156
157 const result = yield* Effect.gen(function* () {
158 const gh = yield* GitHubCli;
159 return yield* gh.listOpenPullRequests({
160 cwd: "/repo",
161 headSelector: "feature/pr-list",
162 });
163 });
164
165 assert.deepStrictEqual(result, [
166 {
167 number: 43,
168 title: "Valid PR",
169 url: "https://github.com/pingdotgg/codething-mvp/pull/43",
170 baseRefName: "main",
171 headRefName: "feature/pr-list",
172 state: "open",
173 },
174 ]);
175 }),
176 );
177
79 it.effect("reads repository clone URLs", () =>178 it.effect("reads repository clone URLs", () =>
80 Effect.gen(function* () {179 Effect.gen(function* () {
81 mockedRunProcess.mockResolvedValueOnce({180 mockedRunProcess.mockResolvedValueOnce({
apps/server/src/git/Layers/GitHubCli.ts+41−81
@@ -1,14 +1,18 @@
1import { Effect, Layer, Schema } from "effect";1import { Effect, Layer, Result, Schema, SchemaIssue } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";2import { TrimmedNonEmptyString } from "@t3tools/contracts";
3 3
4import { runProcess } from "../../processRunner";4import { runProcess } from "../../processRunner.ts";
5import { GitHubCliError } from "@t3tools/contracts";5import { GitHubCliError } from "@t3tools/contracts";
6import {6import {
7 GitHubCli,7 GitHubCli,
8 type GitHubRepositoryCloneUrls,8 type GitHubRepositoryCloneUrls,
9 type GitHubCliShape,9 type GitHubCliShape,
10 type GitHubPullRequestSummary,
11} from "../Services/GitHubCli.ts";10} from "../Services/GitHubCli.ts";
11import {
12 decodeGitHubPullRequestJson,
13 decodeGitHubPullRequestListJson,
14 formatGitHubJsonDecodeError,
15} from "../githubPullRequests.ts";
12 16
13const DEFAULT_TIMEOUT_MS = 30_000;17const DEFAULT_TIMEOUT_MS = 30_000;
14 18
@@ -63,76 +67,12 @@ function normalizeGitHubCliError(operation: "execute" | "stdout", error: unknown · 48 unmodified lines
63 });67 });
64}68}
65 69
66function normalizePullRequestState(input: {
67 state?: string | null | undefined;
68 mergedAt?: string | null | undefined;
69}): "open" | "closed" | "merged" {
70 const mergedAt = input.mergedAt;
71 const state = input.state;
72 if ((typeof mergedAt === "string" && mergedAt.trim().length > 0) || state === "MERGED") {
73 return "merged";
74 }
75 if (state === "CLOSED") {
76 return "closed";
77 }
78 return "open";
79}
80
81const RawGitHubPullRequestSchema = Schema.Struct({
82 number: PositiveInt,
83 title: TrimmedNonEmptyString,
84 url: TrimmedNonEmptyString,
85 baseRefName: TrimmedNonEmptyString,
86 headRefName: TrimmedNonEmptyString,
87 state: Schema.optional(Schema.NullOr(Schema.String)),
88 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
89 isCrossRepository: Schema.optional(Schema.Boolean),
90 headRepository: Schema.optional(
91 Schema.NullOr(
92 Schema.Struct({
93 nameWithOwner: Schema.String,
94 }),
95 ),
96 ),
97 headRepositoryOwner: Schema.optional(
98 Schema.NullOr(
99 Schema.Struct({
100 login: Schema.String,
101 }),
102 ),
103 ),
104});
105
106const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({70const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
107 nameWithOwner: TrimmedNonEmptyString,71 nameWithOwner: TrimmedNonEmptyString,
108 url: TrimmedNonEmptyString,72 url: TrimmedNonEmptyString,
109 sshUrl: TrimmedNonEmptyString,73 sshUrl: TrimmedNonEmptyString,
110});74});
111 75
112function normalizePullRequestSummary(
113 raw: Schema.Schema.Type<typeof RawGitHubPullRequestSchema>,
114): GitHubPullRequestSummary {
115 const headRepositoryNameWithOwner = raw.headRepository?.nameWithOwner ?? null;
116 const headRepositoryOwnerLogin =
117 raw.headRepositoryOwner?.login ??
118 (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
119 ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
120 : null);
121 return {
122 number: raw.number,
123 title: raw.title,
124 url: raw.url,
125 baseRefName: raw.baseRefName,
126 headRefName: raw.headRefName,
127 state: normalizePullRequestState(raw),
128 ...(typeof raw.isCrossRepository === "boolean"
129 ? { isCrossRepository: raw.isCrossRepository }
130 : {}),
131 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
132 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
133 };
134}
135
136function normalizeRepositoryCloneUrls(76function normalizeRepositoryCloneUrls(
137 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,77 raw: Schema.Schema.Type<typeof RawGitHubRepositoryCloneUrlsSchema>,
138): GitHubRepositoryCloneUrls {78): GitHubRepositoryCloneUrls {
@@ -154,7 +94,7 @@ function decodeGitHubJson<S extends Schema.Top>( · 15 unmodified lines
154 (error) =>94 (error) =>
155 new GitHubCliError({95 new GitHubCliError({
156 operation,96 operation,
157 detail: error instanceof Error ? `${invalidDetail}: ${error.message}` : invalidDetail,97 detail: `${invalidDetail}: ${SchemaIssue.makeFormatterDefault()(error.issue)}`,
158 cause: error,98 cause: error,
159 }),99 }),
160 ),100 ),
@@ -194,14 +134,24 @@ const makeGitHubCli = Effect.sync(() => { · 33 unmodified lines
194 Effect.flatMap((raw) =>134 Effect.flatMap((raw) =>
195 raw.length === 0135 raw.length === 0
196 ? Effect.succeed([])136 ? Effect.succeed([])
197 : decodeGitHubJson(137 : Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
198 raw,138 Effect.flatMap((decoded) => {
199 Schema.Array(RawGitHubPullRequestSchema),139 if (!Result.isSuccess(decoded)) {
200 "listOpenPullRequests",140 return Effect.fail(
201 "GitHub CLI returned invalid PR list JSON.",141 new GitHubCliError({
142 operation: "listOpenPullRequests",
143 detail: `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
144 cause: decoded.failure,
145 }),
146 );
147 }
148
149 return Effect.succeed(
150 decoded.success.map(({ updatedAt: _updatedAt, ...summary }) => summary),
151 );
152 }),
202 ),153 ),
203 ),154 ),
204 Effect.map((pullRequests) => pullRequests.map(normalizePullRequestSummary)),
205 ),155 ),
206 getPullRequest: (input) =>156 getPullRequest: (input) =>
207 execute({157 execute({
@@ -216,14 +166,24 @@ const makeGitHubCli = Effect.sync(() => { · 8 unmodified lines
216 }).pipe(166 }).pipe(
217 Effect.map((result) => result.stdout.trim()),167 Effect.map((result) => result.stdout.trim()),
218 Effect.flatMap((raw) =>168 Effect.flatMap((raw) =>
219 decodeGitHubJson(169 Effect.sync(() => decodeGitHubPullRequestJson(raw)).pipe(
220 raw,170 Effect.flatMap((decoded) => {
221 RawGitHubPullRequestSchema,171 if (!Result.isSuccess(decoded)) {
222 "getPullRequest",172 return Effect.fail(
223 "GitHub CLI returned invalid pull request JSON.",173 new GitHubCliError({
174 operation: "getPullRequest",
175 detail: `GitHub CLI returned invalid pull request JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
176 cause: decoded.failure,
177 }),
178 );
179 }
180
181 return Effect.succeed(
182 (({ updatedAt: _updatedAt, ...summary }) => summary)(decoded.success),
183 );
184 }),
224 ),185 ),
225 ),186 ),
226 Effect.map(normalizePullRequestSummary),
227 ),187 ),
228 getRepositoryCloneUrls: (input) =>188 getRepositoryCloneUrls: (input) =>
229 execute({189 execute({
apps/server/src/git/Layers/GitManager.test.ts+188−3
@@ -186,6 +186,24 @@ function makeTempDir( · 185 unmodified lines
186 });186 });
187}187}
188 188
189function removePath(
190 targetPath: string,
191): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
192 return Effect.gen(function* () {
193 const fileSystem = yield* FileSystem.FileSystem;
194 yield* fileSystem.remove(targetPath, { recursive: true, force: true });
195 });
196}
197
198function makeDirectory(
199 dirPath: string,
200): Effect.Effect<void, PlatformError.PlatformError, FileSystem.FileSystem> {
201 return Effect.gen(function* () {
202 const fileSystem = yield* FileSystem.FileSystem;
203 yield* fileSystem.makeDirectory(dirPath, { recursive: true });
204 });
205}
206
189function runGit(207function runGit(
190 cwd: string,208 cwd: string,
191 args: readonly string[],209 args: readonly string[],
@@ -694,6 +712,144 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 502 unmodified lines
694 }),712 }),
695 );713 );
696 714
715 it.effect("status trims PR metadata returned by gh before publishing it", () =>
716 Effect.gen(function* () {
717 const repoDir = yield* makeTempDir("t3code-git-manager-");
718 yield* initRepo(repoDir);
719 yield* runGit(repoDir, ["checkout", "-b", "feature/status-trimmed-pr"]);
720 const remoteDir = yield* createBareRemote();
721 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
722 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-trimmed-pr"]);
723
724 const { manager } = yield* makeManager({
725 ghScenario: {
726 prListSequence: [
727 JSON.stringify([
728 {
729 number: 14,
730 title: " Existing PR title \n",
731 url: " https://github.com/pingdotgg/codething-mvp/pull/14 ",
732 baseRefName: " main ",
733 headRefName: "\tfeature/status-trimmed-pr\t",
734 },
735 ]),
736 ],
737 },
738 });
739
740 const status = yield* manager.status({ cwd: repoDir });
741
742 expect(status.pr).toEqual({
743 number: 14,
744 title: "Existing PR title",
745 url: "https://github.com/pingdotgg/codething-mvp/pull/14",
746 baseBranch: "main",
747 headBranch: "feature/status-trimmed-pr",
748 state: "open",
749 });
750 }),
751 );
752
753 it.effect("status ignores invalid gh pr list entries and keeps valid ones", () =>
754 Effect.gen(function* () {
755 const repoDir = yield* makeTempDir("t3code-git-manager-");
756 yield* initRepo(repoDir);
757 yield* runGit(repoDir, ["checkout", "-b", "feature/status-valid-pr-entry"]);
758 const remoteDir = yield* createBareRemote();
759 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
760 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-valid-pr-entry"]);
761
762 const { manager } = yield* makeManager({
763 ghScenario: {
764 prListSequence: [
765 JSON.stringify([
766 {
767 number: 0,
768 title: "invalid",
769 url: "https://github.com/pingdotgg/codething-mvp/pull/0",
770 baseRefName: "main",
771 headRefName: "feature/invalid",
772 },
773 {
774 number: 15,
775 title: " Valid PR title ",
776 url: " https://github.com/pingdotgg/codething-mvp/pull/15 ",
777 baseRefName: " main ",
778 headRefName: "\tfeature/status-valid-pr-entry\t",
779 headRepository: {
780 nameWithOwner: " ",
781 },
782 headRepositoryOwner: {
783 login: " ",
784 },
785 },
786 ]),
787 ],
788 },
789 });
790
791 const status = yield* manager.status({ cwd: repoDir });
792
793 expect(status.pr).toEqual({
794 number: 15,
795 title: "Valid PR title",
796 url: "https://github.com/pingdotgg/codething-mvp/pull/15",
797 baseBranch: "main",
798 headBranch: "feature/status-valid-pr-entry",
799 state: "open",
800 });
801 }),
802 );
803
804 it.effect("status preserves lowercase merged and closed PR states from gh json", () =>
805 Effect.gen(function* () {
806 const repoDir = yield* makeTempDir("t3code-git-manager-");
807 yield* initRepo(repoDir);
808 yield* runGit(repoDir, ["checkout", "-b", "feature/status-lowercase-state"]);
809 const remoteDir = yield* createBareRemote();
810 yield* runGit(repoDir, ["remote", "add", "origin", remoteDir]);
811 yield* runGit(repoDir, ["push", "-u", "origin", "feature/status-lowercase-state"]);
812
813 const { manager } = yield* makeManager({
814 ghScenario: {
815 prListSequence: [
816 JSON.stringify([
817 {
818 number: 16,
819 title: "Closed PR",
820 url: "https://github.com/pingdotgg/codething-mvp/pull/16",
821 baseRefName: "main",
822 headRefName: "feature/status-lowercase-state",
823 state: "closed",
824 updatedAt: "2026-01-01T00:00:00.000Z",
825 },
826 {
827 number: 17,
828 title: "Merged PR",
829 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
830 baseRefName: "main",
831 headRefName: "feature/status-lowercase-state",
832 state: "merged",
833 updatedAt: "2026-01-02T00:00:00.000Z",
834 },
835 ]),
836 ],
837 },
838 });
839
840 const status = yield* manager.status({ cwd: repoDir });
841
842 expect(status.pr).toEqual({
843 number: 17,
844 title: "Merged PR",
845 url: "https://github.com/pingdotgg/codething-mvp/pull/17",
846 baseBranch: "main",
847 headBranch: "feature/status-lowercase-state",
848 state: "merged",
849 });
850 }),
851 );
852
697 it.effect("status returns an explicit non-repo result for non-git directories", () =>853 it.effect("status returns an explicit non-repo result for non-git directories", () =>
698 Effect.gen(function* () {854 Effect.gen(function* () {
699 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");855 const cwd = yield* makeTempDir("t3code-git-manager-non-repo-");
@@ -720,6 +876,35 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 20 unmodified lines
720 }),876 }),
721 );877 );
722 878
879 it.effect("status returns an explicit non-repo result for deleted directories", () =>
880 Effect.gen(function* () {
881 const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-");
882 const cwd = path.join(rootDir, "deleted-repo");
883 yield* makeDirectory(cwd);
884 yield* removePath(cwd);
885 const { manager } = yield* makeManager();
886
887 const status = yield* manager.status({ cwd });
888
889 expect(status).toEqual({
890 isRepo: false,
891 hasOriginRemote: false,
892 isDefaultBranch: false,
893 branch: null,
894 hasWorkingTreeChanges: false,
895 workingTree: {
896 files: [],
897 insertions: 0,
898 deletions: 0,
899 },
900 hasUpstream: false,
901 aheadCount: 0,
902 behindCount: 0,
903 pr: null,
904 });
905 }),
906 );
907
723 it.effect("status briefly caches repeated lookups for the same cwd", () =>908 it.effect("status briefly caches repeated lookups for the same cwd", () =>
724 Effect.gen(function* () {909 Effect.gen(function* () {
725 const repoDir = yield* makeTempDir("t3code-git-manager-");910 const repoDir = yield* makeTempDir("t3code-git-manager-");
@@ -854,7 +1039,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 128 unmodified lines
854 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",1039 "pr list --head jasonLaster:statemachine --state all --limit 20 --json number,title,url,baseRefName,headRefName,state,mergedAt,updatedAt,isCrossRepository,headRepository,headRepositoryOwner",
855 );1040 );
856 }),1041 }),
857 12_000,1042 20_000,
858 );1043 );
859 1044
860 it.effect(1045 it.effect(
@@ -962,7 +1147,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 101 unmodified lines
962 ),1147 ),
963 ).toBe(false);1148 ).toBe(false);
964 }),1149 }),
965 12_000,1150 20_000,
966 );1151 );
967 1152
968 it.effect("status returns merged PR state when latest PR was merged", () =>1153 it.effect("status returns merged PR state when latest PR was merged", () =>
@@ -1685,7 +1870,7 @@ it.layer(GitManagerTestLayer)("GitManager", (it) => { · 716 unmodified lines
1685 false,1870 false,
1686 );1871 );
1687 }),1872 }),
1688 12_000,1873 20_000,
1689 );1874 );
1690 1875
1691 it.effect(1876 it.effect(
apps/server/src/git/Layers/GitManager.ts+136−119
@@ -1,16 +1,31 @@
1import { randomUUID } from "node:crypto";1import { randomUUID } from "node:crypto";
2import { realpathSync } from "node:fs";2import { realpathSync } from "node:fs";
3 3
4import { Cache, Duration, Effect, Exit, FileSystem, Layer, Option, Path, Ref } from "effect";4import {
5 Cache,
6 Duration,
7 Effect,
8 Exit,
9 FileSystem,
10 Layer,
11 Option,
12 Path,
13 Ref,
14 Result,
15} from "effect";
5import {16import {
6 GitActionProgressEvent,17 GitActionProgressEvent,
7 GitActionProgressPhase,18 GitActionProgressPhase,
8 GitCommandError,19 GitCommandError,
9 GitRunStackedActionResult,20 GitRunStackedActionResult,
10 GitStackedAction,21 GitStackedAction,
22 type GitStatusLocalResult,
23 type GitStatusRemoteResult,
11 ModelSelection,24 ModelSelection,
12} from "@t3tools/contracts";25} from "@t3tools/contracts";
13import {26import {
27 detectGitHostingProviderFromRemoteUrl,
28 mergeGitStatusParts,
14 resolveAutoFeatureBranchName,29 resolveAutoFeatureBranchName,
15 sanitizeBranchFragment,30 sanitizeBranchFragment,
16 sanitizeFeatureBranchName,31 sanitizeFeatureBranchName,
@@ -23,13 +38,18 @@ import { · 6 unmodified lines
23 type GitManagerShape,38 type GitManagerShape,
24 type GitRunStackedActionOptions,39 type GitRunStackedActionOptions,
25} from "../Services/GitManager.ts";40} from "../Services/GitManager.ts";
26import { GitCore, GitStatusDetails } from "../Services/GitCore.ts";41import { GitCore } from "../Services/GitCore.ts";
42import type { GitStatusDetails } from "../Services/GitCore.ts";
27import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";43import { GitHubCli, type GitHubPullRequestSummary } from "../Services/GitHubCli.ts";
28import { TextGeneration } from "../Services/TextGeneration.ts";44import { TextGeneration } from "../Services/TextGeneration.ts";
29import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts";45import { ProjectSetupScriptRunner } from "../../project/Services/ProjectSetupScriptRunner.ts";
30import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";46import { extractBranchNameFromRemoteRef } from "../remoteRefs.ts";
31import { ServerSettingsService } from "../../serverSettings.ts";47import { ServerSettingsService } from "../../serverSettings.ts";
32import type { GitManagerServiceError } from "@t3tools/contracts";48import type { GitManagerServiceError } from "@t3tools/contracts";
49import {
50 decodeGitHubPullRequestListJson,
51 formatGitHubJsonDecodeError,
52} from "../githubPullRequests.ts";
33 53
34const COMMIT_TIMEOUT_MS = 10 * 60_000;54const COMMIT_TIMEOUT_MS = 10 * 60_000;
35const MAX_PROGRESS_TEXT_LENGTH = 500;55const MAX_PROGRESS_TEXT_LENGTH = 500;
@@ -236,85 +256,6 @@ function matchesBranchHeadContext( · 200 unmodified lines
236 return true;256 return true;
237}257}
238 258
239function parsePullRequestList(raw: unknown): PullRequestInfo[] {
240 if (!Array.isArray(raw)) return [];
241
242 const parsed: PullRequestInfo[] = [];
243 for (const entry of raw) {
244 if (!entry || typeof entry !== "object") continue;
245 const record = entry as Record<string, unknown>;
246 const number = record.number;
247 const title = record.title;
248 const url = record.url;
249 const baseRefName = record.baseRefName;
250 const headRefName = record.headRefName;
251 const state = record.state;
252 const mergedAt = record.mergedAt;
253 const updatedAt = record.updatedAt;
254 const isCrossRepository = record.isCrossRepository;
255 const headRepositoryRecord =
256 typeof record.headRepository === "object" && record.headRepository !== null
257 ? (record.headRepository as Record<string, unknown>)
258 : null;
259 const headRepositoryOwnerRecord =
260 typeof record.headRepositoryOwner === "object" && record.headRepositoryOwner !== null
261 ? (record.headRepositoryOwner as Record<string, unknown>)
262 : null;
263 const headRepositoryNameWithOwner =
264 typeof record.headRepositoryNameWithOwner === "string"
265 ? record.headRepositoryNameWithOwner
266 : typeof headRepositoryRecord?.nameWithOwner === "string"
267 ? headRepositoryRecord.nameWithOwner
268 : null;
269 const headRepositoryOwnerLogin =
270 typeof record.headRepositoryOwnerLogin === "string"
271 ? record.headRepositoryOwnerLogin
272 : typeof headRepositoryOwnerRecord?.login === "string"
273 ? headRepositoryOwnerRecord.login
274 : null;
275 if (typeof number !== "number" || !Number.isInteger(number) || number <= 0) {
276 continue;
277 }
278 if (
279 typeof title !== "string" ||
280 typeof url !== "string" ||
281 typeof baseRefName !== "string" ||
282 typeof headRefName !== "string"
283 ) {
284 continue;
285 }
286
287 let normalizedState: "open" | "closed" | "merged";
288 if (
289 (typeof mergedAt === "string" && mergedAt.trim().length > 0) ||
290 state === "MERGED" ||
291 state === "merged"
292 ) {
293 normalizedState = "merged";
294 } else if (state === "OPEN" || state === "open" || state === undefined || state === null) {
295 normalizedState = "open";
296 } else if (state === "CLOSED" || state === "closed") {
297 normalizedState = "closed";
298 } else {
299 continue;
300 }
301
302 parsed.push({
303 number,
304 title,
305 url,
306 baseRefName,
307 headRefName,
308 state: normalizedState,
309 updatedAt: typeof updatedAt === "string" && updatedAt.trim().length > 0 ? updatedAt : null,
310 ...(typeof isCrossRepository === "boolean" ? { isCrossRepository } : {}),
311 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
312 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
313 });
314 }
315 return parsed;
316}
317
318function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {259function toPullRequestInfo(summary: GitHubPullRequestSummary): PullRequestInfo {
319 return {260 return {
320 number: summary.number,261 number: summary.number,
@@ -695,26 +636,54 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 374 unmodified lines
695 636
696 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";637 const tempDir = process.env.TMPDIR ?? process.env.TEMP ?? process.env.TMP ?? "/tmp";
697 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);638 const normalizeStatusCacheKey = (cwd: string) => canonicalizeExistingPath(cwd);
698 const readStatus = Effect.fn("readStatus")(function* (cwd: string) {639 const nonRepositoryStatusDetails = {
699 const details = yield* gitCore.statusDetails(cwd).pipe(640 isRepo: false,
700 Effect.catchIf(isNotGitRepositoryError, () =>641 hasOriginRemote: false,
701 Effect.succeed({642 isDefaultBranch: false,
702 isRepo: false,643 branch: null,
703 hasOriginRemote: false,644 upstreamRef: null,
704 isDefaultBranch: false,645 hasWorkingTreeChanges: false,
705 branch: null,646 workingTree: { files: [], insertions: 0, deletions: 0 },
706 upstreamRef: null,647 hasUpstream: false,
707 hasWorkingTreeChanges: false,648 aheadCount: 0,
708 workingTree: { files: [], insertions: 0, deletions: 0 },649 behindCount: 0,
709 hasUpstream: false,650 } satisfies GitStatusDetails;
710 aheadCount: 0,651 const readLocalStatus = Effect.fn("readLocalStatus")(function* (cwd: string) {
711 behindCount: 0,652 const details = yield* gitCore
712 } satisfies GitStatusDetails),653 .statusDetailsLocal(cwd)
713 ),654 .pipe(
714 );655 Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)),
656 );
657 const hostingProvider = details.isRepo
658 ? yield* resolveHostingProvider(cwd, details.branch)
659 : null;
660
661 return {
662 isRepo: details.isRepo,
663 ...(hostingProvider ? { hostingProvider } : {}),
664 hasOriginRemote: details.hasOriginRemote,
665 isDefaultBranch: details.isDefaultBranch,
666 branch: details.branch,
667 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
668 workingTree: details.workingTree,
669 } satisfies GitStatusLocalResult;
670 });
671 const localStatusResultCache = yield* Cache.makeWith(readLocalStatus, {
672 capacity: STATUS_RESULT_CACHE_CAPACITY,
673 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
674 });
675 const invalidateLocalStatusResultCache = (cwd: string) =>
676 Cache.invalidate(localStatusResultCache, normalizeStatusCacheKey(cwd));
677 const readRemoteStatus = Effect.fn("readRemoteStatus")(function* (cwd: string) {
678 const details = yield* gitCore
679 .statusDetails(cwd)
680 .pipe(Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(null)));
681 if (details === null || !details.isRepo) {
682 return null;
683 }
715 684
716 const pr =685 const pr =
717 details.isRepo && details.branch !== null686 details.branch !== null
718 ? yield* findLatestPr(cwd, {687 ? yield* findLatestPr(cwd, {
719 branch: details.branch,688 branch: details.branch,
720 upstreamRef: details.upstreamRef,689 upstreamRef: details.upstreamRef,
@@ -725,29 +694,37 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 4 unmodified lines
725 : null;694 : null;
726 695
727 return {696 return {
728 isRepo: details.isRepo,
729 hasOriginRemote: details.hasOriginRemote,
730 isDefaultBranch: details.isDefaultBranch,
731 branch: details.branch,
732 hasWorkingTreeChanges: details.hasWorkingTreeChanges,
733 workingTree: details.workingTree,
734 hasUpstream: details.hasUpstream,697 hasUpstream: details.hasUpstream,
735 aheadCount: details.aheadCount,698 aheadCount: details.aheadCount,
736 behindCount: details.behindCount,699 behindCount: details.behindCount,
737 pr,700 pr,
738 };701 } satisfies GitStatusRemoteResult;
739 });702 });
740 const statusResultCache = yield* Cache.makeWith({703 const remoteStatusResultCache = yield* Cache.makeWith(readRemoteStatus, {
741 capacity: STATUS_RESULT_CACHE_CAPACITY,704 capacity: STATUS_RESULT_CACHE_CAPACITY,
742 lookup: readStatus,
743 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),705 timeToLive: (exit) => (Exit.isSuccess(exit) ? STATUS_RESULT_CACHE_TTL : Duration.zero),
744 });706 });
745 const invalidateStatusResultCache = (cwd: string) =>707 const invalidateRemoteStatusResultCache = (cwd: string) =>
746 Cache.invalidate(statusResultCache, normalizeStatusCacheKey(cwd));708 Cache.invalidate(remoteStatusResultCache, normalizeStatusCacheKey(cwd));
747 709
748 const readConfigValueNullable = (cwd: string, key: string) =>710 const readConfigValueNullable = (cwd: string, key: string) =>
749 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));711 gitCore.readConfigValue(cwd, key).pipe(Effect.catch(() => Effect.succeed(null)));
750 712
713 const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
714 cwd: string,
715 branch: string | null,
716 ) {
717 const preferredRemoteName =
718 branch === null
719 ? "origin"
720 : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
721 const remoteUrl =
722 (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
723 (yield* readConfigValueNullable(cwd, "remote.origin.url"));
724
725 return remoteUrl ? detectGitHostingProviderFromRemoteUrl(remoteUrl) : null;
726 });
727
751 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (728 const resolveRemoteRepositoryContext = Effect.fn("resolveRemoteRepositoryContext")(function* (
752 cwd: string,729 cwd: string,
753 remoteName: string | null,730 remoteName: string | null,
@@ -907,13 +884,23 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 153 unmodified lines
907 continue;884 continue;
908 }885 }
909 886
910 const parsedJson = yield* Effect.try({887 const pullRequests = yield* Effect.sync(() => decodeGitHubPullRequestListJson(raw)).pipe(
911 try: () => JSON.parse(raw) as unknown,888 Effect.flatMap((decoded) => {
912 catch: (cause) =>889 if (!Result.isSuccess(decoded)) {
913 gitManagerError("findLatestPr", "GitHub CLI returned invalid PR list JSON.", cause),890 return Effect.fail(
914 });891 gitManagerError(
892 "findLatestPr",
893 `GitHub CLI returned invalid PR list JSON: ${formatGitHubJsonDecodeError(decoded.failure)}`,
894 decoded.failure,
895 ),
896 );
897 }
915 898
916 for (const pr of parsePullRequestList(parsedJson)) {899 return Effect.succeed(decoded.success);
900 }),
901 );
902
903 for (const pr of pullRequests) {
917 if (!matchesBranchHeadContext(pr, headContext)) {904 if (!matchesBranchHeadContext(pr, headContext)) {
918 continue;905 continue;
919 }906 }
@@ -1311,9 +1298,34 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 391 unmodified lines
1311 };1298 };
1312 });1299 });
1313 1300
1301 const localStatus: GitManagerShape["localStatus"] = Effect.fn("localStatus")(function* (input) {
1302 return yield* Cache.get(localStatusResultCache, normalizeStatusCacheKey(input.cwd));
1303 });
1304 const remoteStatus: GitManagerShape["remoteStatus"] = Effect.fn("remoteStatus")(
1305 function* (input) {
1306 return yield* Cache.get(remoteStatusResultCache, normalizeStatusCacheKey(input.cwd));
1307 },
1308 );
1314 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {1309 const status: GitManagerShape["status"] = Effect.fn("status")(function* (input) {
1315 return yield* Cache.get(statusResultCache, normalizeStatusCacheKey(input.cwd));1310 const [local, remote] = yield* Effect.all([localStatus(input), remoteStatus(input)]);
1311 return mergeGitStatusParts(local, remote);
1312 });
1313 const invalidateLocalStatus: GitManagerShape["invalidateLocalStatus"] = Effect.fn(
1314 "invalidateLocalStatus",
1315 )(function* (cwd) {
1316 yield* invalidateLocalStatusResultCache(cwd);
1316 });1317 });
1318 const invalidateRemoteStatus: GitManagerShape["invalidateRemoteStatus"] = Effect.fn(
1319 "invalidateRemoteStatus",
1320 )(function* (cwd) {
1321 yield* invalidateRemoteStatusResultCache(cwd);
1322 });
1323 const invalidateStatus: GitManagerShape["invalidateStatus"] = Effect.fn("invalidateStatus")(
1324 function* (cwd) {
1325 yield* invalidateLocalStatusResultCache(cwd);
1326 yield* invalidateRemoteStatusResultCache(cwd);
1327 },
1328 );
1317 1329
1318 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(1330 const resolvePullRequest: GitManagerShape["resolvePullRequest"] = Effect.fn("resolvePullRequest")(
1319 function* (input) {1331 function* (input) {
@@ -1488,7 +1500,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 168 unmodified lines
1488 branch: worktree.worktree.branch,1500 branch: worktree.worktree.branch,
1489 worktreePath: worktree.worktree.path,1501 worktreePath: worktree.worktree.path,
1490 };1502 };
1491 }).pipe(Effect.ensuring(invalidateStatusResultCache(input.cwd)));1503 }).pipe(Effect.ensuring(invalidateStatus(input.cwd)));
1492 });1504 });
1493 1505
1494 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (1506 const runFeatureBranchStep = Effect.fn("runFeatureBranchStep")(function* (
@@ -1692,7 +1704,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 197 unmodified lines
1692 });1704 });
1693 1705
1694 return yield* runAction().pipe(1706 return yield* runAction().pipe(
1695 Effect.ensuring(invalidateStatusResultCache(input.cwd)),1707 Effect.ensuring(invalidateStatus(input.cwd)),
1696 Effect.tapError((error) =>1708 Effect.tapError((error) =>
1697 Effect.flatMap(Ref.get(currentPhase), (phase) =>1709 Effect.flatMap(Ref.get(currentPhase), (phase) =>
1698 progress.emit({1710 progress.emit({
@@ -1707,7 +1719,12 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { · 8 unmodified lines
1707 );1719 );
1708 1720
1709 return {1721 return {
1722 localStatus,
1723 remoteStatus,
1710 status,1724 status,
1725 invalidateLocalStatus,
1726 invalidateRemoteStatus,
1727 invalidateStatus,
1711 resolvePullRequest,1728 resolvePullRequest,
1712 preparePullRequestThread,1729 preparePullRequestThread,
1713 runStackedAction,1730 runStackedAction,
apps/server/src/git/Layers/GitStatusBroadcaster.test.ts+312−0
@@ -0,0 +1,312 @@
1import { assert, it } from "@effect/vitest";
2import { Deferred, Effect, Exit, Layer, Option, Scope, Stream } from "effect";
3import type {
4 GitStatusLocalResult,
5 GitStatusRemoteResult,
6 GitStatusResult,
7 GitStatusStreamEvent,
8} from "@t3tools/contracts";
9import { describe } from "vitest";
10
11import { GitStatusBroadcaster } from "../Services/GitStatusBroadcaster.ts";
12import { GitStatusBroadcasterLive } from "./GitStatusBroadcaster.ts";
13import { type GitManagerShape, GitManager } from "../Services/GitManager.ts";
14
15const baseLocalStatus: GitStatusLocalResult = {
16 isRepo: true,
17 hostingProvider: {
18 kind: "github",
19 name: "GitHub",
20 baseUrl: "https://github.com",
21 },
22 hasOriginRemote: true,
23 isDefaultBranch: false,
24 branch: "feature/status-broadcast",
25 hasWorkingTreeChanges: false,
26 workingTree: { files: [], insertions: 0, deletions: 0 },
27};
28
29const baseRemoteStatus: GitStatusRemoteResult = {
30 hasUpstream: true,
31 aheadCount: 0,
32 behindCount: 0,
33 pr: null,
34};
35
36const baseStatus: GitStatusResult = {
37 ...baseLocalStatus,
38 ...baseRemoteStatus,
39};
40
41function makeTestLayer(state: {
42 currentLocalStatus: GitStatusLocalResult;
43 currentRemoteStatus: GitStatusRemoteResult | null;
44 localStatusCalls: number;
45 remoteStatusCalls: number;
46 localInvalidationCalls: number;
47 remoteInvalidationCalls: number;
48}) {
49 const gitManager: GitManagerShape = {
50 localStatus: () =>
51 Effect.sync(() => {
52 state.localStatusCalls += 1;
53 return state.currentLocalStatus;
54 }),
55 remoteStatus: () =>
56 Effect.sync(() => {
57 state.remoteStatusCalls += 1;
58 return state.currentRemoteStatus;
59 }),
60 status: () => Effect.die("status should not be called in this test"),
61 invalidateLocalStatus: () =>
62 Effect.sync(() => {
63 state.localInvalidationCalls += 1;
64 }),
65 invalidateRemoteStatus: () =>
66 Effect.sync(() => {
67 state.remoteInvalidationCalls += 1;
68 }),
69 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
70 resolvePullRequest: () => Effect.die("resolvePullRequest should not be called in this test"),
71 preparePullRequestThread: () =>
72 Effect.die("preparePullRequestThread should not be called in this test"),
73 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
74 };
75
76 return GitStatusBroadcasterLive.pipe(Layer.provide(Layer.succeed(GitManager, gitManager)));
77}
78
79describe("GitStatusBroadcasterLive", () => {
80 it.effect("reuses the cached git status across repeated reads", () => {
81 const state = {
82 currentLocalStatus: baseLocalStatus,
83 currentRemoteStatus: baseRemoteStatus,
84 localStatusCalls: 0,
85 remoteStatusCalls: 0,
86 localInvalidationCalls: 0,
87 remoteInvalidationCalls: 0,
88 };
89
90 return Effect.gen(function* () {
91 const broadcaster = yield* GitStatusBroadcaster;
92
93 const first = yield* broadcaster.getStatus({ cwd: "/repo" });
94 const second = yield* broadcaster.getStatus({ cwd: "/repo" });
95
96 assert.deepStrictEqual(first, baseStatus);
97 assert.deepStrictEqual(second, baseStatus);
98 assert.equal(state.localStatusCalls, 1);
99 assert.equal(state.remoteStatusCalls, 1);
100 assert.equal(state.localInvalidationCalls, 0);
101 assert.equal(state.remoteInvalidationCalls, 0);
102 }).pipe(Effect.provide(makeTestLayer(state)));
103 });
104
105 it.effect("refreshes the cached snapshot after explicit invalidation", () => {
106 const state = {
107 currentLocalStatus: baseLocalStatus,
108 currentRemoteStatus: baseRemoteStatus,
109 localStatusCalls: 0,
110 remoteStatusCalls: 0,
111 localInvalidationCalls: 0,
112 remoteInvalidationCalls: 0,
113 };
114
115 return Effect.gen(function* () {
116 const broadcaster = yield* GitStatusBroadcaster;
117 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
118
119 state.currentLocalStatus = {
120 ...baseLocalStatus,
121 branch: "feature/updated-status",
122 };
123 state.currentRemoteStatus = {
124 ...baseRemoteStatus,
125 aheadCount: 2,
126 };
127 const refreshed = yield* broadcaster.refreshStatus("/repo");
128 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
129
130 assert.deepStrictEqual(initial, baseStatus);
131 assert.deepStrictEqual(refreshed, {
132 ...state.currentLocalStatus,
133 ...state.currentRemoteStatus,
134 });
135 assert.deepStrictEqual(cached, {
136 ...state.currentLocalStatus,
137 ...state.currentRemoteStatus,
138 });
139 assert.equal(state.localStatusCalls, 2);
140 assert.equal(state.remoteStatusCalls, 2);
141 assert.equal(state.localInvalidationCalls, 1);
142 assert.equal(state.remoteInvalidationCalls, 1);
143 }).pipe(Effect.provide(makeTestLayer(state)));
144 });
145
146 it.effect("refreshes only the cached local snapshot when requested", () => {
147 const state = {
148 currentLocalStatus: baseLocalStatus,
149 currentRemoteStatus: baseRemoteStatus,
150 localStatusCalls: 0,
151 remoteStatusCalls: 0,
152 localInvalidationCalls: 0,
153 remoteInvalidationCalls: 0,
154 };
155
156 return Effect.gen(function* () {
157 const broadcaster = yield* GitStatusBroadcaster;
158 const initial = yield* broadcaster.getStatus({ cwd: "/repo" });
159
160 state.currentLocalStatus = {
161 ...baseLocalStatus,
162 branch: "feature/local-only-refresh",
163 hasWorkingTreeChanges: true,
164 };
165
166 const refreshedLocal = yield* broadcaster.refreshLocalStatus("/repo");
167 const cached = yield* broadcaster.getStatus({ cwd: "/repo" });
168
169 assert.deepStrictEqual(initial, baseStatus);
170 assert.deepStrictEqual(refreshedLocal, state.currentLocalStatus);
171 assert.deepStrictEqual(cached, {
172 ...state.currentLocalStatus,
173 ...baseRemoteStatus,
174 });
175 assert.equal(state.localStatusCalls, 2);
176 assert.equal(state.remoteStatusCalls, 1);
177 assert.equal(state.localInvalidationCalls, 1);
178 assert.equal(state.remoteInvalidationCalls, 0);
179 }).pipe(Effect.provide(makeTestLayer(state)));
180 });
181
182 it.effect("streams a local snapshot first and remote updates later", () => {
183 const state = {
184 currentLocalStatus: baseLocalStatus,
185 currentRemoteStatus: baseRemoteStatus,
186 localStatusCalls: 0,
187 remoteStatusCalls: 0,
188 localInvalidationCalls: 0,
189 remoteInvalidationCalls: 0,
190 };
191
192 return Effect.gen(function* () {
193 const broadcaster = yield* GitStatusBroadcaster;
194 const snapshotDeferred = yield* Deferred.make<GitStatusStreamEvent>();
195 const remoteUpdatedDeferred = yield* Deferred.make<GitStatusStreamEvent>();
196 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) => {
197 if (event._tag === "snapshot") {
198 return Deferred.succeed(snapshotDeferred, event).pipe(Effect.ignore);
199 }
200 if (event._tag === "remoteUpdated") {
201 return Deferred.succeed(remoteUpdatedDeferred, event).pipe(Effect.ignore);
202 }
203 return Effect.void;
204 }).pipe(Effect.forkScoped);
205
206 const snapshot = yield* Deferred.await(snapshotDeferred);
207 yield* broadcaster.refreshStatus("/repo");
208 const remoteUpdated = yield* Deferred.await(remoteUpdatedDeferred);
209
210 assert.deepStrictEqual(snapshot, {
211 _tag: "snapshot",
212 local: baseLocalStatus,
213 remote: null,
214 } satisfies GitStatusStreamEvent);
215 assert.deepStrictEqual(remoteUpdated, {
216 _tag: "remoteUpdated",
217 remote: baseRemoteStatus,
218 } satisfies GitStatusStreamEvent);
219 }).pipe(Effect.provide(makeTestLayer(state)));
220 });
221
222 it.effect("stops the remote poller after the last stream subscriber disconnects", () => {
223 const state = {
224 currentLocalStatus: baseLocalStatus,
225 currentRemoteStatus: baseRemoteStatus,
226 localStatusCalls: 0,
227 remoteStatusCalls: 0,
228 localInvalidationCalls: 0,
229 remoteInvalidationCalls: 0,
230 };
231 let remoteInterruptedDeferred: Deferred.Deferred<void, never> | null = null;
232 let remoteStartedDeferred: Deferred.Deferred<void, never> | null = null;
233 const testLayer = GitStatusBroadcasterLive.pipe(
234 Layer.provide(
235 Layer.succeed(GitManager, {
236 localStatus: () =>
237 Effect.sync(() => {
238 state.localStatusCalls += 1;
239 return state.currentLocalStatus;
240 }),
241 remoteStatus: () =>
242 Effect.sync(() => {
243 state.remoteStatusCalls += 1;
244 }).pipe(
245 Effect.andThen(
246 remoteStartedDeferred
247 ? Deferred.succeed(remoteStartedDeferred, undefined).pipe(Effect.ignore)
248 : Effect.void,
249 ),
250 Effect.andThen(Effect.never as Effect.Effect<GitStatusRemoteResult | null, never>),
251 Effect.onInterrupt(() =>
252 remoteInterruptedDeferred
253 ? Deferred.succeed(remoteInterruptedDeferred, undefined).pipe(Effect.ignore)
254 : Effect.void,
255 ),
256 ),
257 status: () => Effect.die("status should not be called in this test"),
258 invalidateLocalStatus: () =>
259 Effect.sync(() => {
260 state.localInvalidationCalls += 1;
261 }),
262 invalidateRemoteStatus: () =>
263 Effect.sync(() => {
264 state.remoteInvalidationCalls += 1;
265 }),
266 invalidateStatus: () => Effect.die("invalidateStatus should not be called in this test"),
267 resolvePullRequest: () =>
268 Effect.die("resolvePullRequest should not be called in this test"),
269 preparePullRequestThread: () =>
270 Effect.die("preparePullRequestThread should not be called in this test"),
271 runStackedAction: () => Effect.die("runStackedAction should not be called in this test"),
272 } satisfies GitManagerShape),
273 ),
274 );
275
276 return Effect.gen(function* () {
277 const remoteInterrupted = yield* Deferred.make<void>();
278 const remoteStarted = yield* Deferred.make<void>();
279 remoteInterruptedDeferred = remoteInterrupted;
280 remoteStartedDeferred = remoteStarted;
281
282 const broadcaster = yield* GitStatusBroadcaster;
283 const firstSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
284 const secondSnapshot = yield* Deferred.make<GitStatusStreamEvent>();
285 const firstScope = yield* Scope.make();
286 const secondScope = yield* Scope.make();
287 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
288 event._tag === "snapshot"
289 ? Deferred.succeed(firstSnapshot, event).pipe(Effect.ignore)
290 : Effect.void,
291 ).pipe(Effect.forkIn(firstScope));
292 yield* Stream.runForEach(broadcaster.streamStatus({ cwd: "/repo" }), (event) =>
293 event._tag === "snapshot"
294 ? Deferred.succeed(secondSnapshot, event).pipe(Effect.ignore)
295 : Effect.void,
296 ).pipe(Effect.forkIn(secondScope));
297
298 yield* Deferred.await(firstSnapshot);
299 yield* Deferred.await(secondSnapshot);
300 yield* Deferred.await(remoteStarted);
301
302 assert.equal(state.remoteStatusCalls, 1);
303
304 yield* Scope.close(firstScope, Exit.void);
305 assert.equal(Option.isNone(yield* Deferred.poll(remoteInterrupted)), true);
306
307 yield* Scope.close(secondScope, Exit.void).pipe(Effect.forkScoped);
308 yield* Deferred.await(remoteInterrupted);
309 assert.equal(Option.isSome(yield* Deferred.poll(remoteInterrupted)), true);
310 }).pipe(Effect.provide(testLayer));
311 });
312});
apps/server/src/git/Layers/GitStatusBroadcaster.ts+311−0
@@ -0,0 +1,311 @@
1import { realpathSync } from "node:fs";
2
3import {
4 Duration,
5 Effect,
6 Exit,
7 Fiber,
8 Layer,
9 PubSub,
10 Ref,
11 Scope,
12 Stream,
13 SynchronizedRef,
14} from "effect";
15import type {
16 GitStatusInput,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
19 GitStatusStreamEvent,
20} from "@t3tools/contracts";
21import { mergeGitStatusParts } from "@t3tools/shared/git";
22
23import {
24 GitStatusBroadcaster,
25 type GitStatusBroadcasterShape,
26} from "../Services/GitStatusBroadcaster.ts";
27import { GitManager } from "../Services/GitManager.ts";
28
29const GIT_STATUS_REFRESH_INTERVAL = Duration.seconds(30);
30
31interface GitStatusChange {
32 readonly cwd: string;
33 readonly event: GitStatusStreamEvent;
34}
35
36interface CachedValue<T> {
37 readonly fingerprint: string;
38 readonly value: T;
39}
40
41interface CachedGitStatus {
42 readonly local: CachedValue<GitStatusLocalResult> | null;
43 readonly remote: CachedValue<GitStatusRemoteResult | null> | null;
44}
45
46interface ActiveRemotePoller {
47 readonly fiber: Fiber.Fiber<void, never>;
48 readonly subscriberCount: number;
49}
50
51function normalizeCwd(cwd: string): string {
52 try {
53 return realpathSync.native(cwd);
54 } catch {
55 return cwd;
56 }
57}
58
59function fingerprintStatusPart(status: unknown): string {
60 return JSON.stringify(status);
61}
62
63export const GitStatusBroadcasterLive = Layer.effect(
64 GitStatusBroadcaster,
65 Effect.gen(function* () {
66 const gitManager = yield* GitManager;
67 const changesPubSub = yield* Effect.acquireRelease(
68 PubSub.unbounded<GitStatusChange>(),
69 (pubsub) => PubSub.shutdown(pubsub),
70 );
71 const broadcasterScope = yield* Effect.acquireRelease(Scope.make(), (scope) =>
72 Scope.close(scope, Exit.void),
73 );
74 const cacheRef = yield* Ref.make(new Map<string, CachedGitStatus>());
75 const pollersRef = yield* SynchronizedRef.make(new Map<string, ActiveRemotePoller>());
76
77 const getCachedStatus = Effect.fn("getCachedStatus")(function* (cwd: string) {
78 return yield* Ref.get(cacheRef).pipe(Effect.map((cache) => cache.get(cwd) ?? null));
79 });
80
81 const updateCachedLocalStatus = Effect.fn("updateCachedLocalStatus")(function* (
82 cwd: string,
83 local: GitStatusLocalResult,
84 options?: { publish?: boolean },
85 ) {
86 const nextLocal = {
87 fingerprint: fingerprintStatusPart(local),
88 value: local,
89 } satisfies CachedValue<GitStatusLocalResult>;
90 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
91 const previous = cache.get(cwd) ?? { local: null, remote: null };
92 const nextCache = new Map(cache);
93 nextCache.set(cwd, {
94 ...previous,
95 local: nextLocal,
96 });
97 return [previous.local?.fingerprint !== nextLocal.fingerprint, nextCache] as const;
98 });
99
100 if (options?.publish && shouldPublish) {
101 yield* PubSub.publish(changesPubSub, {
102 cwd,
103 event: {
104 _tag: "localUpdated",
105 local,
106 },
107 });
108 }
109
110 return local;
111 });
112
113 const updateCachedRemoteStatus = Effect.fn("updateCachedRemoteStatus")(function* (
114 cwd: string,
115 remote: GitStatusRemoteResult | null,
116 options?: { publish?: boolean },
117 ) {
118 const nextRemote = {
119 fingerprint: fingerprintStatusPart(remote),
120 value: remote,
121 } satisfies CachedValue<GitStatusRemoteResult | null>;
122 const shouldPublish = yield* Ref.modify(cacheRef, (cache) => {
123 const previous = cache.get(cwd) ?? { local: null, remote: null };
124 const nextCache = new Map(cache);
125 nextCache.set(cwd, {
126 ...previous,
127 remote: nextRemote,
128 });
129 return [previous.remote?.fingerprint !== nextRemote.fingerprint, nextCache] as const;
130 });
131
132 if (options?.publish && shouldPublish) {
133 yield* PubSub.publish(changesPubSub, {
134 cwd,
135 event: {
136 _tag: "remoteUpdated",
137 remote,
138 },
139 });
140 }
141
142 return remote;
143 });
144
145 const loadLocalStatus = Effect.fn("loadLocalStatus")(function* (cwd: string) {
146 const local = yield* gitManager.localStatus({ cwd });
147 return yield* updateCachedLocalStatus(cwd, local);
148 });
149
150 const loadRemoteStatus = Effect.fn("loadRemoteStatus")(function* (cwd: string) {
151 const remote = yield* gitManager.remoteStatus({ cwd });
152 return yield* updateCachedRemoteStatus(cwd, remote);
153 });
154
155 const getOrLoadLocalStatus = Effect.fn("getOrLoadLocalStatus")(function* (cwd: string) {
156 const cached = yield* getCachedStatus(cwd);
157 if (cached?.local) {
158 return cached.local.value;
159 }
160 return yield* loadLocalStatus(cwd);
161 });
162
163 const getOrLoadRemoteStatus = Effect.fn("getOrLoadRemoteStatus")(function* (cwd: string) {
164 const cached = yield* getCachedStatus(cwd);
165 if (cached?.remote) {
166 return cached.remote.value;
167 }
168 return yield* loadRemoteStatus(cwd);
169 });
170
171 const getStatus: GitStatusBroadcasterShape["getStatus"] = Effect.fn("getStatus")(function* (
172 input: GitStatusInput,
173 ) {
174 const normalizedCwd = normalizeCwd(input.cwd);
175 const [local, remote] = yield* Effect.all([
176 getOrLoadLocalStatus(normalizedCwd),
177 getOrLoadRemoteStatus(normalizedCwd),
178 ]);
179 return mergeGitStatusParts(local, remote);
180 });
181
182 const refreshLocalStatus: GitStatusBroadcasterShape["refreshLocalStatus"] = Effect.fn(
183 "refreshLocalStatus",
184 )(function* (cwd) {
185 const normalizedCwd = normalizeCwd(cwd);
186 yield* gitManager.invalidateLocalStatus(normalizedCwd);
187 const local = yield* gitManager.localStatus({ cwd: normalizedCwd });
188 return yield* updateCachedLocalStatus(normalizedCwd, local, { publish: true });
189 });
190
191 const refreshRemoteStatus = Effect.fn("refreshRemoteStatus")(function* (cwd: string) {
192 yield* gitManager.invalidateRemoteStatus(cwd);
193 const remote = yield* gitManager.remoteStatus({ cwd });
194 return yield* updateCachedRemoteStatus(cwd, remote, { publish: true });
195 });
196
197 const refreshStatus: GitStatusBroadcasterShape["refreshStatus"] = Effect.fn("refreshStatus")(
198 function* (cwd) {
199 const normalizedCwd = normalizeCwd(cwd);
200 const [local, remote] = yield* Effect.all([
201 refreshLocalStatus(normalizedCwd),
202 refreshRemoteStatus(normalizedCwd),
203 ]);
204 return mergeGitStatusParts(local, remote);
205 },
206 );
207
208 const makeRemoteRefreshLoop = (cwd: string) => {
209 const logRefreshFailure = (error: Error) =>
210 Effect.logWarning("git remote status refresh failed", {
211 cwd,
212 detail: error.message,
213 });
214
215 return refreshRemoteStatus(cwd).pipe(
216 Effect.catch(logRefreshFailure),
217 Effect.andThen(
218 Effect.forever(
219 Effect.sleep(GIT_STATUS_REFRESH_INTERVAL).pipe(
220 Effect.andThen(refreshRemoteStatus(cwd).pipe(Effect.catch(logRefreshFailure))),
221 ),
222 ),
223 ),
224 );
225 };
226
227 const retainRemotePoller = Effect.fn("retainRemotePoller")(function* (cwd: string) {
228 yield* SynchronizedRef.modifyEffect(pollersRef, (activePollers) => {
229 const existing = activePollers.get(cwd);
230 if (existing) {
231 const nextPollers = new Map(activePollers);
232 nextPollers.set(cwd, {
233 ...existing,
234 subscriberCount: existing.subscriberCount + 1,
235 });
236 return Effect.succeed([undefined, nextPollers] as const);
237 }
238
239 return makeRemoteRefreshLoop(cwd).pipe(
240 Effect.forkIn(broadcasterScope),
241 Effect.map((fiber) => {
242 const nextPollers = new Map(activePollers);
243 nextPollers.set(cwd, {
244 fiber,
245 subscriberCount: 1,
246 });
247 return [undefined, nextPollers] as const;
248 }),
249 );
250 });
251 });
252
253 const releaseRemotePoller = Effect.fn("releaseRemotePoller")(function* (cwd: string) {
254 const pollerToInterrupt = yield* SynchronizedRef.modify(pollersRef, (activePollers) => {
255 const existing = activePollers.get(cwd);
256 if (!existing) {
257 return [null, activePollers] as const;
258 }
259
260 if (existing.subscriberCount > 1) {
261 const nextPollers = new Map(activePollers);
262 nextPollers.set(cwd, {
263 ...existing,
264 subscriberCount: existing.subscriberCount - 1,
265 });
266 return [null, nextPollers] as const;
267 }
268
269 const nextPollers = new Map(activePollers);
270 nextPollers.delete(cwd);
271 return [existing.fiber, nextPollers] as const;
272 });
273
274 if (pollerToInterrupt) {
275 yield* Fiber.interrupt(pollerToInterrupt).pipe(Effect.ignore);
276 }
277 });
278
279 const streamStatus: GitStatusBroadcasterShape["streamStatus"] = (input) =>
280 Stream.unwrap(
281 Effect.gen(function* () {
282 const normalizedCwd = normalizeCwd(input.cwd);
283 const subscription = yield* PubSub.subscribe(changesPubSub);
284 const initialLocal = yield* getOrLoadLocalStatus(normalizedCwd);
285 const initialRemote = (yield* getCachedStatus(normalizedCwd))?.remote?.value ?? null;
286 yield* retainRemotePoller(normalizedCwd);
287
288 const release = releaseRemotePoller(normalizedCwd).pipe(Effect.ignore, Effect.asVoid);
289
290 return Stream.concat(
291 Stream.make({
292 _tag: "snapshot" as const,
293 local: initialLocal,
294 remote: initialRemote,
295 }),
296 Stream.fromSubscription(subscription).pipe(
297 Stream.filter((event) => event.cwd === normalizedCwd),
298 Stream.map((event) => event.event),
299 ),
300 ).pipe(Stream.ensuring(release));
301 }),
302 );
303
304 return {
305 getStatus,
306 refreshLocalStatus,
307 refreshStatus,
308 streamStatus,
309 } satisfies GitStatusBroadcasterShape;
310 }),
311);
apps/server/src/git/Layers/RoutingTextGeneration.ts+3−3
@@ -9,7 +9,7 @@ · 8 unmodified lines
9 *9 *
10 * @module RoutingTextGeneration10 * @module RoutingTextGeneration
11 */11 */
12import { Effect, Layer, ServiceMap } from "effect";12import { Effect, Layer, Context } from "effect";
13 13
14import {14import {
15 TextGeneration,15 TextGeneration,
@@ -23,11 +23,11 @@ import { ClaudeTextGenerationLive } from "./ClaudeTextGeneration.ts"; · 7 unmodified lines
23// Internal service tags so both concrete layers can coexist.23// Internal service tags so both concrete layers can coexist.
24// ---------------------------------------------------------------------------24// ---------------------------------------------------------------------------
25 25
26class CodexTextGen extends ServiceMap.Service<CodexTextGen, TextGenerationShape>()(26class CodexTextGen extends Context.Service<CodexTextGen, TextGenerationShape>()(
27 "t3/git/Layers/RoutingTextGeneration/CodexTextGen",27 "t3/git/Layers/RoutingTextGeneration/CodexTextGen",
28) {}28) {}
29 29
30class ClaudeTextGen extends ServiceMap.Service<ClaudeTextGen, TextGenerationShape>()(30class ClaudeTextGen extends Context.Service<ClaudeTextGen, TextGenerationShape>()(
31 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",31 "t3/git/Layers/RoutingTextGeneration/ClaudeTextGen",
32) {}32) {}
33 33
apps/server/src/git/Services/GitCore.ts+14−7
@@ -6,11 +6,13 @@ · 5 unmodified lines
6 *6 *
7 * @module GitCore7 * @module GitCore
8 */8 */
9import { ServiceMap } from "effect";9import { Context } from "effect";
10import type { Effect, Scope } from "effect";10import type { Effect } from "effect";
11import type {11import type {
12 GitCheckoutInput,12 GitCheckoutInput,
13 GitCheckoutResult,
13 GitCreateBranchInput,14 GitCreateBranchInput,
15 GitCreateBranchResult,
14 GitCreateWorktreeInput,16 GitCreateWorktreeInput,
15 GitCreateWorktreeResult,17 GitCreateWorktreeResult,
16 GitInitInput,18 GitInitInput,
@@ -156,6 +158,11 @@ export interface GitCoreShape { · 139 unmodified lines
156 */158 */
157 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;159 readonly statusDetails: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
158 160
161 /**
162 * Read detailed working tree / branch status without refreshing remote tracking refs.
163 */
164 readonly statusDetailsLocal: (cwd: string) => Effect.Effect<GitStatusDetails, GitCommandError>;
165
159 /**166 /**
160 * Build staged change context for commit generation.167 * Build staged change context for commit generation.
161 */168 */
@@ -278,14 +285,16 @@ export interface GitCoreShape { · 116 unmodified lines
278 /**285 /**
279 * Create a local branch.286 * Create a local branch.
280 */287 */
281 readonly createBranch: (input: GitCreateBranchInput) => Effect.Effect<void, GitCommandError>;288 readonly createBranch: (
289 input: GitCreateBranchInput,
290 ) => Effect.Effect<GitCreateBranchResult, GitCommandError>;
282 291
283 /**292 /**
284 * Checkout an existing branch and refresh its upstream metadata in background.293 * Checkout an existing branch and refresh its upstream metadata in background.
285 */294 */
286 readonly checkoutBranch: (295 readonly checkoutBranch: (
287 input: GitCheckoutInput,296 input: GitCheckoutInput,
288 ) => Effect.Effect<void, GitCommandError, Scope.Scope>;297 ) => Effect.Effect<GitCheckoutResult, GitCommandError>;
289 298
290 /**299 /**
291 * Initialize a repository in the provided directory.300 * Initialize a repository in the provided directory.
@@ -301,6 +310,4 @@ export interface GitCoreShape { · 9 unmodified lines
301/**310/**
302 * GitCore - Service tag for low-level Git repository operations.311 * GitCore - Service tag for low-level Git repository operations.
303 */312 */
304export class GitCore extends ServiceMap.Service<GitCore, GitCoreShape>()(313export class GitCore extends Context.Service<GitCore, GitCoreShape>()("t3/git/Services/GitCore") {}
305 "t3/git/Services/GitCore",
306) {}
apps/server/src/git/Services/GitHubCli.ts+3−3
@@ -5,10 +5,10 @@ · 4 unmodified lines
5 *5 *
6 * @module GitHubCli6 * @module GitHubCli
7 */7 */
8import { ServiceMap } from "effect";8import { Context } from "effect";
9import type { Effect } from "effect";9import type { Effect } from "effect";
10 10
11import type { ProcessRunResult } from "../../processRunner";11import type { ProcessRunResult } from "../../processRunner.ts";
12import type { GitHubCliError } from "@t3tools/contracts";12import type { GitHubCliError } from "@t3tools/contracts";
13 13
14export interface GitHubPullRequestSummary {14export interface GitHubPullRequestSummary {
@@ -98,6 +98,6 @@ export interface GitHubCliShape { · 83 unmodified lines
98/**98/**
99 * GitHubCli - Service tag for GitHub CLI process execution.99 * GitHubCli - Service tag for GitHub CLI process execution.
100 */100 */
101export class GitHubCli extends ServiceMap.Service<GitHubCli, GitHubCliShape>()(101export class GitHubCli extends Context.Service<GitHubCli, GitHubCliShape>()(
102 "t3/git/Services/GitHubCli",102 "t3/git/Services/GitHubCli",
103) {}103) {}
apps/server/src/git/Services/GitManager.ts+33−2
@@ -14,10 +14,12 @@ import { · 13 unmodified lines
14 GitResolvePullRequestResult,14 GitResolvePullRequestResult,
15 GitRunStackedActionInput,15 GitRunStackedActionInput,
16 GitRunStackedActionResult,16 GitRunStackedActionResult,
17 GitStatusLocalResult,
18 GitStatusRemoteResult,
17 GitStatusInput,19 GitStatusInput,
18 GitStatusResult,20 GitStatusResult,
19} from "@t3tools/contracts";21} from "@t3tools/contracts";
20import { ServiceMap } from "effect";22import { Context } from "effect";
21import type { Effect } from "effect";23import type { Effect } from "effect";
22import type { GitManagerServiceError } from "@t3tools/contracts";24import type { GitManagerServiceError } from "@t3tools/contracts";
23 25
@@ -41,6 +43,35 @@ export interface GitManagerShape { · 17 unmodified lines
41 input: GitStatusInput,43 input: GitStatusInput,
42 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;44 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
43 45
46 /**
47 * Read local repository status without remote hosting enrichment.
48 */
49 readonly localStatus: (
50 input: GitStatusInput,
51 ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
52
53 /**
54 * Read remote tracking / PR status for a repository.
55 */
56 readonly remoteStatus: (
57 input: GitStatusInput,
58 ) => Effect.Effect<GitStatusRemoteResult | null, GitManagerServiceError>;
59
60 /**
61 * Clear any cached local status snapshot for a repository.
62 */
63 readonly invalidateLocalStatus: (cwd: string) => Effect.Effect<void, never>;
64
65 /**
66 * Clear any cached remote status snapshot for a repository.
67 */
68 readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect<void, never>;
69
70 /**
71 * Clear any cached status snapshot for a repository so the next read is fresh.
72 */
73 readonly invalidateStatus: (cwd: string) => Effect.Effect<void, never>;
74
44 /**75 /**
45 * Resolve a pull request by URL/number against the current repository.76 * Resolve a pull request by URL/number against the current repository.
46 */77 */
@@ -68,6 +99,6 @@ export interface GitManagerShape { · 21 unmodified lines
68/**99/**
69 * GitManager - Service tag for stacked Git workflow orchestration.100 * GitManager - Service tag for stacked Git workflow orchestration.
70 */101 */
71export class GitManager extends ServiceMap.Service<GitManager, GitManagerShape>()(102export class GitManager extends Context.Service<GitManager, GitManagerShape>()(
72 "t3/git/Services/GitManager",103 "t3/git/Services/GitManager",
73) {}104) {}
apps/server/src/git/Services/GitStatusBroadcaster.ts+27−0
@@ -0,0 +1,27 @@
1import { Context } from "effect";
2import type { Effect, Stream } from "effect";
3import type {
4 GitManagerServiceError,
5 GitStatusInput,
6 GitStatusLocalResult,
7 GitStatusResult,
8 GitStatusStreamEvent,
9} from "@t3tools/contracts";
10
11export interface GitStatusBroadcasterShape {
12 readonly getStatus: (
13 input: GitStatusInput,
14 ) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
15 readonly refreshLocalStatus: (
16 cwd: string,
17 ) => Effect.Effect<GitStatusLocalResult, GitManagerServiceError>;
18 readonly refreshStatus: (cwd: string) => Effect.Effect<GitStatusResult, GitManagerServiceError>;
19 readonly streamStatus: (
20 input: GitStatusInput,
21 ) => Stream.Stream<GitStatusStreamEvent, GitManagerServiceError>;
22}
23
24export class GitStatusBroadcaster extends Context.Service<
25 GitStatusBroadcaster,
26 GitStatusBroadcasterShape
27>()("t3/git/Services/GitStatusBroadcaster") {}
apps/server/src/git/Services/TextGeneration.ts+2−2
@@ -6,7 +6,7 @@ · 5 unmodified lines
6 *6 *
7 * @module TextGeneration7 * @module TextGeneration
8 */8 */
9import { ServiceMap } from "effect";9import { Context } from "effect";
10import type { Effect } from "effect";10import type { Effect } from "effect";
11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";11import type { ChatAttachment, ModelSelection } from "@t3tools/contracts";
12 12
@@ -118,6 +118,6 @@ export interface TextGenerationShape { · 105 unmodified lines
118/**118/**
119 * TextGeneration - Service tag for commit and PR text generation.119 * TextGeneration - Service tag for commit and PR text generation.
120 */120 */
121export class TextGeneration extends ServiceMap.Service<TextGeneration, TextGenerationShape>()(121export class TextGeneration extends Context.Service<TextGeneration, TextGenerationShape>()(
122 "t3/git/Services/TextGeneration",122 "t3/git/Services/TextGeneration",
123) {}123) {}
apps/server/src/git/githubPullRequests.ts+128−0
@@ -0,0 +1,128 @@
1import { Cause, Exit, Result, Schema } from "effect";
2import { PositiveInt, TrimmedNonEmptyString } from "@t3tools/contracts";
3import { decodeJsonResult, formatSchemaError } from "@t3tools/shared/schemaJson";
4
5export interface NormalizedGitHubPullRequestRecord {
6 readonly number: number;
7 readonly title: string;
8 readonly url: string;
9 readonly baseRefName: string;
10 readonly headRefName: string;
11 readonly state: "open" | "closed" | "merged";
12 readonly updatedAt: string | null;
13 readonly isCrossRepository?: boolean;
14 readonly headRepositoryNameWithOwner?: string | null;
15 readonly headRepositoryOwnerLogin?: string | null;
16}
17
18const GitHubPullRequestSchema = Schema.Struct({
19 number: PositiveInt,
20 title: TrimmedNonEmptyString,
21 url: TrimmedNonEmptyString,
22 baseRefName: TrimmedNonEmptyString,
23 headRefName: TrimmedNonEmptyString,
24 state: Schema.optional(Schema.NullOr(Schema.String)),
25 mergedAt: Schema.optional(Schema.NullOr(Schema.String)),
26 updatedAt: Schema.optional(Schema.NullOr(Schema.String)),
27 isCrossRepository: Schema.optional(Schema.Boolean),
28 headRepository: Schema.optional(
29 Schema.NullOr(
30 Schema.Struct({
31 nameWithOwner: Schema.String,
32 }),
33 ),
34 ),
35 headRepositoryOwner: Schema.optional(
36 Schema.NullOr(
37 Schema.Struct({
38 login: Schema.String,
39 }),
40 ),
41 ),
42});
43
44function trimOptionalString(value: string | null | undefined): string | null {
45 const trimmed = value?.trim() ?? "";
46 return trimmed.length > 0 ? trimmed : null;
47}
48
49function normalizeGitHubPullRequestState(input: {
50 state?: string | null | undefined;
51 mergedAt?: string | null | undefined;
52}): "open" | "closed" | "merged" {
53 const normalizedState = input.state?.trim().toUpperCase();
54 if (
55 (typeof input.mergedAt === "string" && input.mergedAt.trim().length > 0) ||
56 normalizedState === "MERGED"
57 ) {
58 return "merged";
59 }
60 if (normalizedState === "CLOSED") {
61 return "closed";
62 }
63 return "open";
64}
65
66function normalizeGitHubPullRequestRecord(
67 raw: Schema.Schema.Type<typeof GitHubPullRequestSchema>,
68): NormalizedGitHubPullRequestRecord {
69 const headRepositoryNameWithOwner = trimOptionalString(raw.headRepository?.nameWithOwner);
70 const headRepositoryOwnerLogin =
71 trimOptionalString(raw.headRepositoryOwner?.login) ??
72 (typeof headRepositoryNameWithOwner === "string" && headRepositoryNameWithOwner.includes("/")
73 ? (headRepositoryNameWithOwner.split("/")[0] ?? null)
74 : null);
75
76 return {
77 number: raw.number,
78 title: raw.title,
79 url: raw.url,
80 baseRefName: raw.baseRefName,
81 headRefName: raw.headRefName,
82 state: normalizeGitHubPullRequestState(raw),
83 updatedAt:
84 typeof raw.updatedAt === "string" && raw.updatedAt.trim().length > 0 ? raw.updatedAt : null,
85 ...(typeof raw.isCrossRepository === "boolean"
86 ? { isCrossRepository: raw.isCrossRepository }
87 : {}),
88 ...(headRepositoryNameWithOwner ? { headRepositoryNameWithOwner } : {}),
89 ...(headRepositoryOwnerLogin ? { headRepositoryOwnerLogin } : {}),
90 };
91}
92
93const decodeGitHubPullRequestList = decodeJsonResult(Schema.Array(Schema.Unknown));
94const decodeGitHubPullRequest = decodeJsonResult(GitHubPullRequestSchema);
95const decodeGitHubPullRequestEntry = Schema.decodeUnknownExit(GitHubPullRequestSchema);
96
97export const formatGitHubJsonDecodeError = formatSchemaError;
98
99export function decodeGitHubPullRequestListJson(
100 raw: string,
101): Result.Result<
102 ReadonlyArray<NormalizedGitHubPullRequestRecord>,
103 Cause.Cause<Schema.SchemaError>
104> {
105 const result = decodeGitHubPullRequestList(raw);
106 if (Result.isSuccess(result)) {
107 const pullRequests: NormalizedGitHubPullRequestRecord[] = [];
108 for (const entry of result.success) {
109 const decodedEntry = decodeGitHubPullRequestEntry(entry);
110 if (Exit.isFailure(decodedEntry)) {
111 continue;
112 }
113 pullRequests.push(normalizeGitHubPullRequestRecord(decodedEntry.value));
114 }
115 return Result.succeed(pullRequests);
116 }
117 return Result.fail(result.failure);
118}
119
120export function decodeGitHubPullRequestJson(
121 raw: string,
122): Result.Result<NormalizedGitHubPullRequestRecord, Cause.Cause<Schema.SchemaError>> {
123 const result = decodeGitHubPullRequest(raw);
124 if (Result.isSuccess(result)) {
125 return Result.succeed(normalizeGitHubPullRequestRecord(result.success));
126 }
127 return Result.fail(result.failure);
128}
apps/server/src/http.test.ts+27−0
@@ -0,0 +1,27 @@
1import { describe, expect, it } from "vitest";
2
3import { isLoopbackHostname, resolveDevRedirectUrl } from "./http.ts";
4
5describe("http dev routing", () => {
6 it("treats localhost and loopback addresses as local", () => {
7 expect(isLoopbackHostname("127.0.0.1")).toBe(true);
8 expect(isLoopbackHostname("localhost")).toBe(true);
9 expect(isLoopbackHostname("::1")).toBe(true);
10 expect(isLoopbackHostname("[::1]")).toBe(true);
11 });
12
13 it("does not treat LAN addresses as local", () => {
14 expect(isLoopbackHostname("192.168.86.35")).toBe(false);
15 expect(isLoopbackHostname("10.0.0.24")).toBe(false);
16 expect(isLoopbackHostname("example.local")).toBe(false);
17 });
18
19 it("preserves path and query when redirecting to the dev server", () => {
20 const devUrl = new URL("http://127.0.0.1:5173/");
21 const requestUrl = new URL("http://127.0.0.1:3774/pair?token=test-token");
22
23 expect(resolveDevRedirectUrl(devUrl, requestUrl)).toBe(
24 "http://127.0.0.1:5173/pair?token=test-token",
25 );
26 });
27});
apps/server/src/http.ts+127−12
@@ -1,23 +1,133 @@
1import Mime from "@effect/platform-node/Mime";1import Mime from "@effect/platform-node/Mime";
2import { Effect, FileSystem, Option, Path } from "effect";2import { Data, Effect, FileSystem, Option, Path } from "effect";
3import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";3import { cast } from "effect/Function";
4import {
5 HttpBody,
6 HttpClient,
7 HttpClientResponse,
8 HttpRouter,
9 HttpServerResponse,
10 HttpServerRequest,
11} from "effect/unstable/http";
12import { OtlpTracer } from "effect/unstable/observability";
4 13
5import {14import {
6 ATTACHMENTS_ROUTE_PREFIX,15 ATTACHMENTS_ROUTE_PREFIX,
7 normalizeAttachmentRelativePath,16 normalizeAttachmentRelativePath,
8 resolveAttachmentRelativePath,17 resolveAttachmentRelativePath,
9} from "./attachmentPaths";18} from "./attachmentPaths.ts";
10import { resolveAttachmentPathById } from "./attachmentStore";19import { resolveAttachmentPathById } from "./attachmentStore.ts";
11import { ServerConfig } from "./config";20import { resolveStaticDir, ServerConfig } from "./config.ts";
12import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver";21import { decodeOtlpTraceRecords } from "./observability/TraceRecord.ts";
22import { BrowserTraceCollector } from "./observability/Services/BrowserTraceCollector.ts";
23import { ProjectFaviconResolver } from "./project/Services/ProjectFaviconResolver.ts";
24import { ServerAuth } from "./auth/Services/ServerAuth.ts";
25import { respondToAuthError } from "./auth/http.ts";
26import { ServerEnvironment } from "./environment/Services/ServerEnvironment.ts";
13 27
14const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";28const PROJECT_FAVICON_CACHE_CONTROL = "public, max-age=3600";
15const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;29const FALLBACK_PROJECT_FAVICON_SVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="#6b728080" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" data-fallback="project-favicon"><path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2Z"/></svg>`;
30const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
31const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
32
33export const browserApiCorsLayer = HttpRouter.cors({
34 allowedMethods: ["GET", "POST", "OPTIONS"],
35 allowedHeaders: ["authorization", "b3", "traceparent", "content-type"],
36 maxAge: 600,
37});
38
39export function isLoopbackHostname(hostname: string): boolean {
40 const normalizedHostname = hostname
41 .trim()
42 .toLowerCase()
43 .replace(/^\[(.*)\]$/, "$1");
44 return LOOPBACK_HOSTNAMES.has(normalizedHostname);
45}
46
47export function resolveDevRedirectUrl(devUrl: URL, requestUrl: URL): string {
48 const redirectUrl = new URL(devUrl.toString());
49 redirectUrl.pathname = requestUrl.pathname;
50 redirectUrl.search = requestUrl.search;
51 redirectUrl.hash = requestUrl.hash;
52 return redirectUrl.toString();
53}
54
55const requireAuthenticatedRequest = Effect.gen(function* () {
56 const request = yield* HttpServerRequest.HttpServerRequest;
57 const serverAuth = yield* ServerAuth;
58 yield* serverAuth.authenticateHttpRequest(request);
59});
60
61export const serverEnvironmentRouteLayer = HttpRouter.add(
62 "GET",
63 "/.well-known/t3/environment",
64 Effect.gen(function* () {
65 const descriptor = yield* Effect.service(ServerEnvironment).pipe(
66 Effect.flatMap((serverEnvironment) => serverEnvironment.getDescriptor),
67 );
68 return HttpServerResponse.jsonUnsafe(descriptor, { status: 200 });
69 }),
70);
71
72class DecodeOtlpTraceRecordsError extends Data.TaggedError("DecodeOtlpTraceRecordsError")<{
73 readonly cause: unknown;
74 readonly bodyJson: OtlpTracer.TraceData;
75}> {}
76
77export const otlpTracesProxyRouteLayer = HttpRouter.add(
78 "POST",
79 OTLP_TRACES_PROXY_PATH,
80 Effect.gen(function* () {
81 yield* requireAuthenticatedRequest;
82 const request = yield* HttpServerRequest.HttpServerRequest;
83 const config = yield* ServerConfig;
84 const otlpTracesUrl = config.otlpTracesUrl;
85 const browserTraceCollector = yield* BrowserTraceCollector;
86 const httpClient = yield* HttpClient.HttpClient;
87 const bodyJson = cast<unknown, OtlpTracer.TraceData>(yield* request.json);
88
89 yield* Effect.try({
90 try: () => decodeOtlpTraceRecords(bodyJson),
91 catch: (cause) => new DecodeOtlpTraceRecordsError({ cause, bodyJson }),
92 }).pipe(
93 Effect.flatMap((records) => browserTraceCollector.record(records)),
94 Effect.catch((cause) =>
95 Effect.logWarning("Failed to decode browser OTLP traces", {
96 cause,
97 bodyJson,
98 }),
99 ),
100 );
101
102 if (otlpTracesUrl === undefined) {
103 return HttpServerResponse.empty({ status: 204 });
104 }
105
106 return yield* httpClient
107 .post(otlpTracesUrl, {
108 body: HttpBody.jsonUnsafe(bodyJson),
109 })
110 .pipe(
111 Effect.flatMap(HttpClientResponse.filterStatusOk),
112 Effect.as(HttpServerResponse.empty({ status: 204 })),
113 Effect.tapError((cause) =>
114 Effect.logWarning("Failed to export browser OTLP traces", {
115 cause,
116 otlpTracesUrl,
117 }),
118 ),
119 Effect.catch(() =>
120 Effect.succeed(HttpServerResponse.text("Trace export failed.", { status: 502 })),
121 ),
122 );
123 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
124);
16 125
17export const attachmentsRouteLayer = HttpRouter.add(126export const attachmentsRouteLayer = HttpRouter.add(
18 "GET",127 "GET",
19 `${ATTACHMENTS_ROUTE_PREFIX}/*`,128 `${ATTACHMENTS_ROUTE_PREFIX}/*`,
20 Effect.gen(function* () {129 Effect.gen(function* () {
130 yield* requireAuthenticatedRequest;
21 const request = yield* HttpServerRequest.HttpServerRequest;131 const request = yield* HttpServerRequest.HttpServerRequest;
22 const url = HttpServerRequest.toURL(request);132 const url = HttpServerRequest.toURL(request);
23 if (Option.isNone(url)) {133 if (Option.isNone(url)) {
@@ -66,13 +176,14 @@ export const attachmentsRouteLayer = HttpRouter.add( · 42 unmodified lines
66 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),176 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
67 ),177 ),
68 );178 );
69 }),179 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
70);180);
71 181
72export const projectFaviconRouteLayer = HttpRouter.add(182export const projectFaviconRouteLayer = HttpRouter.add(
73 "GET",183 "GET",
74 "/api/project-favicon",184 "/api/project-favicon",
75 Effect.gen(function* () {185 Effect.gen(function* () {
186 yield* requireAuthenticatedRequest;
76 const request = yield* HttpServerRequest.HttpServerRequest;187 const request = yield* HttpServerRequest.HttpServerRequest;
77 const url = HttpServerRequest.toURL(request);188 const url = HttpServerRequest.toURL(request);
78 if (Option.isNone(url)) {189 if (Option.isNone(url)) {
@@ -106,7 +217,7 @@ export const projectFaviconRouteLayer = HttpRouter.add( · 27 unmodified lines
106 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),217 Effect.succeed(HttpServerResponse.text("Internal Server Error", { status: 500 })),
107 ),218 ),
108 );219 );
109 }),220 }).pipe(Effect.catchTag("AuthError", respondToAuthError)),
110);221);
111 222
112export const staticAndDevRouteLayer = HttpRouter.add(223export const staticAndDevRouteLayer = HttpRouter.add(
@@ -115,16 +226,20 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 2 unmodified lines
115 Effect.gen(function* () {226 Effect.gen(function* () {
116 const request = yield* HttpServerRequest.HttpServerRequest;227 const request = yield* HttpServerRequest.HttpServerRequest;
117 const url = HttpServerRequest.toURL(request);228 const url = HttpServerRequest.toURL(request);
229
118 if (Option.isNone(url)) {230 if (Option.isNone(url)) {
119 return HttpServerResponse.text("Bad Request", { status: 400 });231 return HttpServerResponse.text("Bad Request", { status: 400 });
120 }232 }
121 233
122 const config = yield* ServerConfig;234 const config = yield* ServerConfig;
123 if (config.devUrl) {235 if (config.devUrl && isLoopbackHostname(url.value.hostname)) {
124 return HttpServerResponse.redirect(config.devUrl.href, { status: 302 });236 return HttpServerResponse.redirect(resolveDevRedirectUrl(config.devUrl, url.value), {
237 status: 302,
238 });
125 }239 }
126 240
127 if (!config.staticDir) {241 const staticDir = config.staticDir ?? (config.devUrl ? yield* resolveStaticDir() : undefined);
242 if (!staticDir) {
128 return HttpServerResponse.text("No static directory configured and no dev URL set.", {243 return HttpServerResponse.text("No static directory configured and no dev URL set.", {
129 status: 503,244 status: 503,
130 });245 });
@@ -132,7 +247,7 @@ export const staticAndDevRouteLayer = HttpRouter.add( · 1 unmodified line
132 247
133 const fileSystem = yield* FileSystem.FileSystem;248 const fileSystem = yield* FileSystem.FileSystem;
134 const path = yield* Path.Path;249 const path = yield* Path.Path;
135 const staticRoot = path.resolve(config.staticDir);250 const staticRoot = path.resolve(staticDir);
136 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;251 const staticRequestPath = url.value.pathname === "/" ? "/index.html" : url.value.pathname;
137 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");252 const rawStaticRelativePath = staticRequestPath.replace(/^[/\\]+/, "");
138 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");253 const hasRawLeadingParentSegment = rawStaticRelativePath.startsWith("..");
apps/server/src/keybindings.test.ts+20−3
@@ -2,8 +2,8 @@ import { KeybindingCommand, KeybindingRule, KeybindingsConfig } from "@t3tools/c · 1 unmodified line
2import * as NodeServices from "@effect/platform-node/NodeServices";2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { assert, it } from "@effect/vitest";3import { assert, it } from "@effect/vitest";
4import { assertFailure } from "@effect/vitest/utils";4import { assertFailure } from "@effect/vitest/utils";
5import { Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";5import { Cause, Effect, FileSystem, Layer, Logger, Path, Schema } from "effect";
6import { ServerConfig } from "./config";6import { ServerConfig } from "./config.ts";
7 7
8import {8import {
9 DEFAULT_KEYBINDINGS,9 DEFAULT_KEYBINDINGS,
@@ -13,7 +13,7 @@ import { · 3 unmodified lines
13 compileResolvedKeybindingRule,13 compileResolvedKeybindingRule,
14 compileResolvedKeybindingsConfig,14 compileResolvedKeybindingsConfig,
15 parseKeybindingShortcut,15 parseKeybindingShortcut,
16} from "./keybindings";16} from "./keybindings.ts";
17import { KeybindingsConfigError } from "@t3tools/contracts";17import { KeybindingsConfigError } from "@t3tools/contracts";
18 18
19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);19const KeybindingsConfigJson = Schema.fromJsonString(KeybindingsConfig);
@@ -149,6 +149,23 @@ it.layer(NodeServices.layer)("keybindings", (it) => { · 129 unmodified lines
149 }),149 }),
150 );150 );
151 151
152 it.effect("formats invalid resolved keybinding rules with the custom message", () =>
153 Effect.sync(() => {
154 const result = Schema.decodeUnknownExit(ResolvedKeybindingFromConfig)({
155 key: "mod+shift+d+o",
156 command: "terminal.new",
157 });
158
159 if (result._tag !== "Failure") {
160 assert.fail("Expected invalid keybinding decode to fail");
161 }
162
163 const detail = Cause.pretty(result.cause);
164 assert.isTrue(detail.includes("Invalid keybinding rule"));
165 assert.isFalse(detail.includes("Invalid data"));
166 }),
167 );
168
152 it.effect("bootstraps default keybindings when config file is missing", () =>169 it.effect("bootstraps default keybindings when config file is missing", () =>
153 Effect.gen(function* () {170 Effect.gen(function* () {
154 const fs = yield* FileSystem.FileSystem;171 const fs = yield* FileSystem.FileSystem;
apps/server/src/keybindings.ts+7−6
@@ -19,7 +19,7 @@ import { · 18 unmodified lines
19 THREAD_JUMP_KEYBINDING_COMMANDS,19 THREAD_JUMP_KEYBINDING_COMMANDS,
20 type ServerConfigIssue,20 type ServerConfigIssue,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { Mutable } from "effect/Types";22import type { Mutable } from "effect/Types";
23import {23import {
24 Array,24 Array,
25 Cache,25 Cache,
@@ -39,12 +39,12 @@ import { · 13 unmodified lines
39 SchemaIssue,39 SchemaIssue,
40 SchemaTransformation,40 SchemaTransformation,
41 Ref,41 Ref,
42 ServiceMap,42 Context,
43 Scope,43 Scope,
44 Stream,44 Stream,
45} from "effect";45} from "effect";
46import * as Semaphore from "effect/Semaphore";46import * as Semaphore from "effect/Semaphore";
47import { ServerConfig } from "./config";47import { ServerConfig } from "./config.ts";
48import { fromLenientJson } from "@t3tools/shared/schemaJson";48import { fromLenientJson } from "@t3tools/shared/schemaJson";
49 49
50type WhenToken =50type WhenToken =
@@ -61,6 +61,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [ · 10 unmodified lines
61 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },61 { key: "mod+n", command: "terminal.new", when: "terminalFocus" },
62 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },62 { key: "mod+w", command: "terminal.close", when: "terminalFocus" },
63 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },63 { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" },
64 { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" },
64 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },65 { key: "mod+n", command: "chat.new", when: "!terminalFocus" },
65 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },66 { key: "mod+shift+o", command: "chat.new", when: "!terminalFocus" },
66 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },67 { key: "mod+shift+n", command: "chat.newLocal", when: "!terminalFocus" },
@@ -322,7 +323,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 255 unmodified lines
322 Predicate.isNotNull,323 Predicate.isNotNull,
323 () =>324 () =>
324 new SchemaIssue.InvalidValue(Option.some(rule), {325 new SchemaIssue.InvalidValue(Option.some(rule), {
325 title: "Invalid keybinding rule",326 message: "Invalid keybinding rule",
326 }),327 }),
327 ),328 ),
328 Effect.map((resolved) => resolved),329 Effect.map((resolved) => resolved),
@@ -334,7 +335,7 @@ export const ResolvedKeybindingFromConfig = KeybindingRule.pipe( · 5 unmodified lines
334 if (!key) {335 if (!key) {
335 return yield* Effect.fail(336 return yield* Effect.fail(
336 new SchemaIssue.InvalidValue(Option.some(resolved), {337 new SchemaIssue.InvalidValue(Option.some(resolved), {
337 title: "Resolved shortcut cannot be encoded to key string",338 message: "Resolved shortcut cannot be encoded to key string",
338 }),339 }),
339 );340 );
340 }341 }
@@ -521,7 +522,7 @@ export interface KeybindingsShape { · 180 unmodified lines
521/**522/**
522 * Keybindings - Service tag for keybinding configuration operations.523 * Keybindings - Service tag for keybinding configuration operations.
523 */524 */
524export class Keybindings extends ServiceMap.Service<Keybindings, KeybindingsShape>()(525export class Keybindings extends Context.Service<Keybindings, KeybindingsShape>()(
525 "t3/keybindings",526 "t3/keybindings",
526) {}527) {}
527 528
apps/server/src/observability/Layers/Observability.ts+23−3
@@ -4,6 +4,8 @@ import { OtlpMetrics, OtlpSerialization, OtlpTracer } from "effect/unstable/obse · 3 unmodified lines
4import { ServerConfig } from "../../config.ts";4import { ServerConfig } from "../../config.ts";
5import { ServerLoggerLive } from "../../serverLogger.ts";5import { ServerLoggerLive } from "../../serverLogger.ts";
6import { makeLocalFileTracer } from "../LocalFileTracer.ts";6import { makeLocalFileTracer } from "../LocalFileTracer.ts";
7import { BrowserTraceCollector } from "../Services/BrowserTraceCollector.ts";
8import { makeTraceSink } from "../TraceSink.ts";
7 9
8const otlpSerializationLayer = OtlpSerialization.layerJson;10const otlpSerializationLayer = OtlpSerialization.layerJson;
9 11
@@ -16,9 +18,14 @@ export const ObservabilityLive = Layer.unwrap( · 6 unmodified lines
16 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),18 Layer.succeed(References.TracerTimingEnabled, config.traceTimingEnabled),
17 );19 );
18 20
19 const tracerLayer = Layer.effect(21 const tracerLayer = Layer.unwrap(
20 Tracer.Tracer,
21 Effect.gen(function* () {22 Effect.gen(function* () {
23 const sink = yield* makeTraceSink({
24 filePath: config.serverTracePath,
25 maxBytes: config.traceMaxBytes,
26 maxFiles: config.traceMaxFiles,
27 batchWindowMs: config.traceBatchWindowMs,
28 });
22 const delegate =29 const delegate =
23 config.otlpTracesUrl === undefined30 config.otlpTracesUrl === undefined
24 ? undefined31 ? undefined
@@ -34,13 +41,26 @@ export const ObservabilityLive = Layer.unwrap( · 9 unmodified lines
34 },41 },
35 });42 });
36 43
37 return yield* makeLocalFileTracer({44 const tracer = yield* makeLocalFileTracer({
38 filePath: config.serverTracePath,45 filePath: config.serverTracePath,
39 maxBytes: config.traceMaxBytes,46 maxBytes: config.traceMaxBytes,
40 maxFiles: config.traceMaxFiles,47 maxFiles: config.traceMaxFiles,
41 batchWindowMs: config.traceBatchWindowMs,48 batchWindowMs: config.traceBatchWindowMs,
49 sink,
42 ...(delegate ? { delegate } : {}),50 ...(delegate ? { delegate } : {}),
43 });51 });
52
53 return Layer.mergeAll(
54 Layer.succeed(Tracer.Tracer, tracer),
55 Layer.succeed(BrowserTraceCollector, {
56 record: (records) =>
57 Effect.sync(() => {
58 for (const record of records) {
59 sink.push(record);
60 }
61 }),
62 }),
63 );
44 }),64 }),
45 ).pipe(Layer.provideMerge(otlpSerializationLayer));65 ).pipe(Layer.provideMerge(otlpSerializationLayer));
46 66
apps/server/src/observability/LocalFileTracer.test.ts+3−3
@@ -5,7 +5,7 @@ import path from "node:path"; · 4 unmodified lines
5import { assert, describe, it } from "@effect/vitest";5import { assert, describe, it } from "@effect/vitest";
6import { Effect, Layer, Logger, References, Tracer } from "effect";6import { Effect, Layer, Logger, References, Tracer } from "effect";
7 7
8import type { TraceRecord } from "./TraceRecord.ts";8import type { EffectTraceRecord } from "./TraceRecord.ts";
9import { makeLocalFileTracer } from "./LocalFileTracer.ts";9import { makeLocalFileTracer } from "./LocalFileTracer.ts";
10 10
11const makeTestLayer = (tracePath: string) =>11const makeTestLayer = (tracePath: string) =>
@@ -23,13 +23,13 @@ const makeTestLayer = (tracePath: string) => · 11 unmodified lines
23 Layer.succeed(References.MinimumLogLevel, "Info"),23 Layer.succeed(References.MinimumLogLevel, "Info"),
24 );24 );
25 25
26const readTraceRecords = (tracePath: string): Array<TraceRecord> =>26const readTraceRecords = (tracePath: string): Array<EffectTraceRecord> =>
27 fs27 fs
28 .readFileSync(tracePath, "utf8")28 .readFileSync(tracePath, "utf8")
29 .trim()29 .trim()
30 .split("\n")30 .split("\n")
31 .filter((line) => line.length > 0)31 .filter((line) => line.length > 0)
32 .map((line) => JSON.parse(line) as TraceRecord);32 .map((line) => JSON.parse(line) as EffectTraceRecord);
33 33
34describe("LocalFileTracer", () => {34describe("LocalFileTracer", () => {
35 it.effect("writes nested spans to disk and captures log messages as span events", () =>35 it.effect("writes nested spans to disk and captures log messages as span events", () =>
apps/server/src/observability/LocalFileTracer.ts+17−9
@@ -2,7 +2,8 @@ import type * as Exit from "effect/Exit"; · 1 unmodified line
2import { Effect, Option, Tracer } from "effect";2import { Effect, Option, Tracer } from "effect";
3 3
4import { spanToTraceRecord } from "./TraceRecord.ts";4import { spanToTraceRecord } from "./TraceRecord.ts";
5import { makeTraceSink } from "./TraceSink.ts";5import type { EffectTraceRecord } from "./TraceRecord.ts";
6import { makeTraceSink, type TraceSink } from "./TraceSink.ts";
6 7
7export interface LocalFileTracerOptions {8export interface LocalFileTracerOptions {
8 readonly filePath: string;9 readonly filePath: string;
@@ -10,6 +11,7 @@ export interface LocalFileTracerOptions { · 1 unmodified line
10 readonly maxFiles: number;11 readonly maxFiles: number;
11 readonly batchWindowMs: number;12 readonly batchWindowMs: number;
12 readonly delegate?: Tracer.Tracer;13 readonly delegate?: Tracer.Tracer;
14 readonly sink?: TraceSink;
13}15}
14 16
15class LocalFileSpan implements Tracer.Span {17class LocalFileSpan implements Tracer.Span {
@@ -26,12 +28,16 @@ class LocalFileSpan implements Tracer.Span { · 10 unmodified lines
26 status: Tracer.SpanStatus;28 status: Tracer.SpanStatus;
27 attributes: Map<string, unknown>;29 attributes: Map<string, unknown>;
28 events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;30 events: Array<[name: string, startTime: bigint, attributes: Record<string, unknown>]>;
31 private readonly delegate: Tracer.Span;
32 private readonly push: (record: EffectTraceRecord) => void;
29 33
30 constructor(34 constructor(
31 options: Parameters<Tracer.Tracer["span"]>[0],35 options: Parameters<Tracer.Tracer["span"]>[0],
32 private readonly delegate: Tracer.Span,36 delegate: Tracer.Span,
33 private readonly push: (record: ReturnType<typeof spanToTraceRecord>) => void,37 push: (record: EffectTraceRecord) => void,
34 ) {38 ) {
39 this.delegate = delegate;
40 this.push = push;
35 this.name = delegate.name;41 this.name = delegate.name;
36 this.spanId = delegate.spanId;42 this.spanId = delegate.spanId;
37 this.traceId = delegate.traceId;43 this.traceId = delegate.traceId;
@@ -82,12 +88,14 @@ class LocalFileSpan implements Tracer.Span { · 44 unmodified lines
82export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (88export const makeLocalFileTracer = Effect.fn("makeLocalFileTracer")(function* (
83 options: LocalFileTracerOptions,89 options: LocalFileTracerOptions,
84) {90) {
85 const sink = yield* makeTraceSink({91 const sink =
86 filePath: options.filePath,92 options.sink ??
87 maxBytes: options.maxBytes,93 (yield* makeTraceSink({
88 maxFiles: options.maxFiles,94 filePath: options.filePath,
89 batchWindowMs: options.batchWindowMs,95 maxBytes: options.maxBytes,
90 });96 maxFiles: options.maxFiles,
97 batchWindowMs: options.batchWindowMs,
98 }));
91 99
92 const delegate =100 const delegate =
93 options.delegate ??101 options.delegate ??
apps/server/src/observability/Services/BrowserTraceCollector.ts+13−0
@@ -0,0 +1,13 @@
1import { Context } from "effect";
2import type { Effect } from "effect";
3
4import type { TraceRecord } from "../TraceRecord.ts";
5
6export interface BrowserTraceCollectorShape {
7 readonly record: (records: ReadonlyArray<TraceRecord>) => Effect.Effect<void>;
8}
9
10export class BrowserTraceCollector extends Context.Service<
11 BrowserTraceCollector,
12 BrowserTraceCollectorShape
13>()("t3/observability/Services/BrowserTraceCollector") {}
apps/server/src/observability/TraceRecord.ts+202−15
@@ -1,29 +1,37 @@
1import { Cause, Exit, Option, Tracer } from "effect";1import { Cause, Exit, Option, Tracer } from "effect";
2 2
3import { compactTraceAttributes } from "./Attributes.ts";3import { compactTraceAttributes } from "./Attributes.ts";
4import { OtlpResource, OtlpTracer } from "effect/unstable/observability";
4 5
5export interface TraceRecord {6interface TraceRecordEvent {
6 readonly type: "effect-span";7 readonly name: string;
8 readonly timeUnixNano: string;
9 readonly attributes: Readonly<Record<string, unknown>>;
10}
11
12interface TraceRecordLink {
13 readonly traceId: string;
14 readonly spanId: string;
15 readonly attributes: Readonly<Record<string, unknown>>;
16}
17
18interface BaseTraceRecord {
7 readonly name: string;19 readonly name: string;
20 readonly kind: string;
8 readonly traceId: string;21 readonly traceId: string;
9 readonly spanId: string;22 readonly spanId: string;
10 readonly parentSpanId?: string;23 readonly parentSpanId?: string;
11 readonly sampled: boolean;24 readonly sampled: boolean;
12 readonly kind: Tracer.SpanKind;
13 readonly startTimeUnixNano: string;25 readonly startTimeUnixNano: string;
14 readonly endTimeUnixNano: string;26 readonly endTimeUnixNano: string;
15 readonly durationMs: number;27 readonly durationMs: number;
16 readonly attributes: Readonly<Record<string, unknown>>;28 readonly attributes: Readonly<Record<string, unknown>>;
17 readonly events: ReadonlyArray<{29 readonly events: ReadonlyArray<TraceRecordEvent>;
18 readonly name: string;30 readonly links: ReadonlyArray<TraceRecordLink>;
19 readonly timeUnixNano: string;31}
20 readonly attributes: Readonly<Record<string, unknown>>;32
21 }>;33export interface EffectTraceRecord extends BaseTraceRecord {
22 readonly links: ReadonlyArray<{34 readonly type: "effect-span";
23 readonly traceId: string;
24 readonly spanId: string;
25 readonly attributes: Readonly<Record<string, unknown>>;
26 }>;
27 readonly exit:35 readonly exit:
28 | {36 | {
29 readonly _tag: "Success";37 readonly _tag: "Success";
@@ -38,6 +46,29 @@ export interface TraceRecord { · 8 unmodified lines
38 };46 };
39}47}
40 48
49interface OtlpTraceRecord extends BaseTraceRecord {
50 readonly type: "otlp-span";
51 readonly resourceAttributes: Readonly<Record<string, unknown>>;
52 readonly scope: Readonly<{
53 readonly name?: string;
54 readonly version?: string;
55 readonly attributes: Readonly<Record<string, unknown>>;
56 }>;
57 readonly status?:
58 | {
59 readonly code?: string;
60 readonly message?: string;
61 }
62 | undefined;
63}
64
65export type TraceRecord = EffectTraceRecord | OtlpTraceRecord;
66
67type OtlpSpan = OtlpTracer.ScopeSpan["spans"][number];
68type OtlpSpanEvent = OtlpSpan["events"][number];
69type OtlpSpanLink = OtlpSpan["links"][number];
70type OtlpSpanStatus = OtlpSpan["status"];
71
41interface SerializableSpan {72interface SerializableSpan {
42 readonly name: string;73 readonly name: string;
43 readonly traceId: string;74 readonly traceId: string;
@@ -53,7 +84,7 @@ interface SerializableSpan { · 9 unmodified lines
53 >;84 >;
54}85}
55 86
56function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] {87function formatTraceExit(exit: Exit.Exit<unknown, unknown>): EffectTraceRecord["exit"] {
57 if (Exit.isSuccess(exit)) {88 if (Exit.isSuccess(exit)) {
58 return { _tag: "Success" };89 return { _tag: "Success" };
59 }90 }
@@ -69,7 +100,7 @@ function formatTraceExit(exit: Exit.Exit<unknown, unknown>): TraceRecord["exit"] · 9 unmodified lines
69 };100 };
70}101}
71 102
72export function spanToTraceRecord(span: SerializableSpan): TraceRecord {103export function spanToTraceRecord(span: SerializableSpan): EffectTraceRecord {
73 const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>;104 const status = span.status as Extract<Tracer.SpanStatus, { _tag: "Ended" }>;
74 const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;105 const parentSpanId = Option.getOrUndefined(span.parent)?.spanId;
75 106
@@ -98,3 +129,159 @@ export function spanToTraceRecord(span: SerializableSpan): TraceRecord { · 22 unmodified lines
98 exit: formatTraceExit(status.exit),129 exit: formatTraceExit(status.exit),
99 };130 };
100}131}
132
133const SPAN_KIND_MAP: Record<number, OtlpTraceRecord["kind"]> = {
134 1: "internal",
135 2: "server",
136 3: "client",
137 4: "producer",
138 5: "consumer",
139};
140
141export function decodeOtlpTraceRecords(
142 payload: OtlpTracer.TraceData,
143): ReadonlyArray<OtlpTraceRecord> {
144 const records: Array<OtlpTraceRecord> = [];
145
146 for (const resourceSpan of payload.resourceSpans) {
147 const resourceAttributes = decodeAttributes(resourceSpan.resource?.attributes ?? []);
148
149 for (const scopeSpan of resourceSpan.scopeSpans) {
150 for (const span of scopeSpan.spans) {
151 records.push(
152 otlpSpanToTraceRecord({
153 resourceAttributes,
154 scopeAttributes: decodeAttributes(
155 "attributes" in scopeSpan.scope && Array.isArray(scopeSpan.scope.attributes)
156 ? scopeSpan.scope.attributes
157 : [],
158 ),
159 scopeName: scopeSpan.scope.name,
160 scopeVersion:
161 "version" in scopeSpan.scope && typeof scopeSpan.scope.version === "string"
162 ? scopeSpan.scope.version
163 : undefined,
164 span,
165 }),
166 );
167 }
168 }
169 }
170
171 return records;
172}
173
174function otlpSpanToTraceRecord(input: {
175 readonly resourceAttributes: Readonly<Record<string, unknown>>;
176 readonly scopeAttributes: Readonly<Record<string, unknown>>;
177 readonly scopeName: string | undefined;
178 readonly scopeVersion: string | undefined;
179 readonly span: OtlpSpan;
180}): OtlpTraceRecord {
181 return {
182 type: "otlp-span",
183 name: input.span.name,
184 traceId: input.span.traceId,
185 spanId: input.span.spanId,
186 ...(input.span.parentSpanId ? { parentSpanId: input.span.parentSpanId } : {}),
187 sampled: true,
188 kind: normalizeSpanKind(input.span.kind),
189 startTimeUnixNano: input.span.startTimeUnixNano,
190 endTimeUnixNano: input.span.endTimeUnixNano,
191 durationMs:
192 Number(parseBigInt(input.span.endTimeUnixNano) - parseBigInt(input.span.startTimeUnixNano)) /
193 1_000_000,
194 attributes: decodeAttributes(input.span.attributes),
195 resourceAttributes: input.resourceAttributes,
196 scope: {
197 ...(input.scopeName ? { name: input.scopeName } : {}),
198 ...(input.scopeVersion ? { version: input.scopeVersion } : {}),
199 attributes: input.scopeAttributes,
200 },
201 events: decodeEvents(input.span.events),
202 links: decodeLinks(input.span.links),
203 status: decodeStatus(input.span.status),
204 };
205}
206
207function decodeStatus(input: OtlpSpanStatus): OtlpTraceRecord["status"] {
208 const code = String(input.code);
209 const message = input.message;
210
211 return {
212 code,
213 ...(message ? { message } : {}),
214 };
215}
216
217function decodeEvents(input: ReadonlyArray<OtlpSpanEvent>): ReadonlyArray<TraceRecordEvent> {
218 return input.map((current) => ({
219 name: current.name,
220 timeUnixNano: current.timeUnixNano,
221 attributes: decodeAttributes(current.attributes),
222 }));
223}
224
225function decodeLinks(input: ReadonlyArray<OtlpSpanLink>): ReadonlyArray<TraceRecordLink> {
226 return input.flatMap((current) => {
227 const traceId = current.traceId;
228 const spanId = current.spanId;
229 return {
230 traceId,
231 spanId,
232 attributes: decodeAttributes(current.attributes),
233 };
234 });
235}
236
237function decodeAttributes(
238 input: ReadonlyArray<OtlpResource.KeyValue>,
239): Readonly<Record<string, unknown>> {
240 const entries: Record<string, unknown> = {};
241
242 for (const attribute of input) {
243 entries[attribute.key] = decodeValue(attribute.value);
244 }
245
246 return compactTraceAttributes(entries);
247}
248
249function decodeValue(input: OtlpResource.AnyValue | null | undefined): unknown {
250 if (input == null) {
251 return null;
252 }
253 if ("stringValue" in input) {
254 return input.stringValue;
255 }
256 if ("boolValue" in input) {
257 return input.boolValue;
258 }
259 if ("intValue" in input) {
260 return input.intValue;
261 }
262 if ("doubleValue" in input) {
263 return input.doubleValue;
264 }
265 if ("bytesValue" in input) {
266 return input.bytesValue;
267 }
268 if (input.arrayValue) {
269 return input.arrayValue.values.map((entry) => decodeValue(entry));
270 }
271 if (input.kvlistValue) {
272 return decodeAttributes(input.kvlistValue.values);
273 }
274 return null;
275}
276
277function normalizeSpanKind(input: number): OtlpTraceRecord["kind"] {
278 return SPAN_KIND_MAP[input] || "internal";
279}
280
281function parseBigInt(input: string): bigint {
282 try {
283 return BigInt(input);
284 } catch {
285 return 0n;
286 }
287}
apps/server/src/open.test.ts+91−2
@@ -8,7 +8,7 @@ import { · 7 unmodified lines
8 launchDetached,8 launchDetached,
9 resolveAvailableEditors,9 resolveAvailableEditors,
10 resolveEditorLaunch,10 resolveEditorLaunch,
11} from "./open";11} from "./open.ts";
12 12
13it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {13it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => {
14 it.effect("returns commands for command-based editors", () =>14 it.effect("returns commands for command-based editors", () =>
@@ -16,6 +16,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 1 unmodified line
16 const antigravityLaunch = yield* resolveEditorLaunch(16 const antigravityLaunch = yield* resolveEditorLaunch(
17 { cwd: "/tmp/workspace", editor: "antigravity" },17 { cwd: "/tmp/workspace", editor: "antigravity" },
18 "darwin",18 "darwin",
19 { PATH: "" },
19 );20 );
20 assert.deepEqual(antigravityLaunch, {21 assert.deepEqual(antigravityLaunch, {
21 command: "agy",22 command: "agy",
@@ -25,6 +26,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
25 const cursorLaunch = yield* resolveEditorLaunch(26 const cursorLaunch = yield* resolveEditorLaunch(
26 { cwd: "/tmp/workspace", editor: "cursor" },27 { cwd: "/tmp/workspace", editor: "cursor" },
27 "darwin",28 "darwin",
29 { PATH: "" },
28 );30 );
29 assert.deepEqual(cursorLaunch, {31 assert.deepEqual(cursorLaunch, {
30 command: "cursor",32 command: "cursor",
@@ -40,9 +42,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines
40 args: ["/tmp/workspace"],42 args: ["/tmp/workspace"],
41 });43 });
42 44
45 const kiroLaunch = yield* resolveEditorLaunch(
46 { cwd: "/tmp/workspace", editor: "kiro" },
47 "darwin",
48 { PATH: "" },
49 );
50 assert.deepEqual(kiroLaunch, {
51 command: "kiro",
52 args: ["ide", "/tmp/workspace"],
53 });
54
43 const vscodeLaunch = yield* resolveEditorLaunch(55 const vscodeLaunch = yield* resolveEditorLaunch(
44 { cwd: "/tmp/workspace", editor: "vscode" },56 { cwd: "/tmp/workspace", editor: "vscode" },
45 "darwin",57 "darwin",
58 { PATH: "" },
46 );59 );
47 assert.deepEqual(vscodeLaunch, {60 assert.deepEqual(vscodeLaunch, {
48 command: "code",61 command: "code",
@@ -70,6 +83,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines
70 const zedLaunch = yield* resolveEditorLaunch(83 const zedLaunch = yield* resolveEditorLaunch(
71 { cwd: "/tmp/workspace", editor: "zed" },84 { cwd: "/tmp/workspace", editor: "zed" },
72 "darwin",85 "darwin",
86 { PATH: "" },
73 );87 );
74 assert.deepEqual(zedLaunch, {88 assert.deepEqual(zedLaunch, {
75 command: "zed",89 command: "zed",
@@ -92,6 +106,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 16 unmodified lines
92 const lineOnly = yield* resolveEditorLaunch(106 const lineOnly = yield* resolveEditorLaunch(
93 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },107 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "cursor" },
94 "darwin",108 "darwin",
109 { PATH: "" },
95 );110 );
96 assert.deepEqual(lineOnly, {111 assert.deepEqual(lineOnly, {
97 command: "cursor",112 command: "cursor",
@@ -101,6 +116,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
101 const lineAndColumn = yield* resolveEditorLaunch(116 const lineAndColumn = yield* resolveEditorLaunch(
102 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },117 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "cursor" },
103 "darwin",118 "darwin",
119 { PATH: "" },
104 );120 );
105 assert.deepEqual(lineAndColumn, {121 assert.deepEqual(lineAndColumn, {
106 command: "cursor",122 command: "cursor",
@@ -116,9 +132,20 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 9 unmodified lines
116 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],132 args: ["--goto", "/tmp/workspace/src/open.ts:71:5"],
117 });133 });
118 134
135 const kiroLineAndColumn = yield* resolveEditorLaunch(
136 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "kiro" },
137 "darwin",
138 { PATH: "" },
139 );
140 assert.deepEqual(kiroLineAndColumn, {
141 command: "kiro",
142 args: ["ide", "--goto", "/tmp/workspace/src/open.ts:71:5"],
143 });
144
119 const vscodeLineAndColumn = yield* resolveEditorLaunch(145 const vscodeLineAndColumn = yield* resolveEditorLaunch(
120 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },146 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "vscode" },
121 "darwin",147 "darwin",
148 { PATH: "" },
122 );149 );
123 assert.deepEqual(vscodeLineAndColumn, {150 assert.deepEqual(vscodeLineAndColumn, {
124 command: "code",151 command: "code",
@@ -146,6 +173,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 21 unmodified lines
146 const zedLineAndColumn = yield* resolveEditorLaunch(173 const zedLineAndColumn = yield* resolveEditorLaunch(
147 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },174 { cwd: "/tmp/workspace/src/open.ts:71:5", editor: "zed" },
148 "darwin",175 "darwin",
176 { PATH: "" },
149 );177 );
150 assert.deepEqual(zedLineAndColumn, {178 assert.deepEqual(zedLineAndColumn, {
151 command: "zed",179 command: "zed",
@@ -155,6 +183,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
155 const zedLineOnly = yield* resolveEditorLaunch(183 const zedLineOnly = yield* resolveEditorLaunch(
156 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },184 { cwd: "/tmp/workspace/AGENTS.md:48", editor: "zed" },
157 "darwin",185 "darwin",
186 { PATH: "" },
158 );187 );
159 assert.deepEqual(zedLineOnly, {188 assert.deepEqual(zedLineOnly, {
160 command: "zed",189 command: "zed",
@@ -181,11 +210,43 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 20 unmodified lines
181 }),210 }),
182 );211 );
183 212
213 it.effect("falls back to zeditor when zed is not installed", () =>
214 Effect.gen(function* () {
215 const fs = yield* FileSystem.FileSystem;
216 const path = yield* Path.Path;
217 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-open-test-" });
218 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
219 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
220
221 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
222 PATH: dir,
223 });
224
225 assert.deepEqual(result, {
226 command: "zeditor",
227 args: ["/tmp/workspace"],
228 });
229 }),
230 );
231
232 it.effect("falls back to the primary command when no alias is installed", () =>
233 Effect.gen(function* () {
234 const result = yield* resolveEditorLaunch({ cwd: "/tmp/workspace", editor: "zed" }, "linux", {
235 PATH: "",
236 });
237 assert.deepEqual(result, {
238 command: "zed",
239 args: ["/tmp/workspace"],
240 });
241 }),
242 );
243
184 it.effect("maps file-manager editor to OS open commands", () =>244 it.effect("maps file-manager editor to OS open commands", () =>
185 Effect.gen(function* () {245 Effect.gen(function* () {
186 const launch1 = yield* resolveEditorLaunch(246 const launch1 = yield* resolveEditorLaunch(
187 { cwd: "/tmp/workspace", editor: "file-manager" },247 { cwd: "/tmp/workspace", editor: "file-manager" },
188 "darwin",248 "darwin",
249 { PATH: "" },
189 );250 );
190 assert.deepEqual(launch1, {251 assert.deepEqual(launch1, {
191 command: "open",252 command: "open",
@@ -195,6 +256,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
195 const launch2 = yield* resolveEditorLaunch(256 const launch2 = yield* resolveEditorLaunch(
196 { cwd: "C:\\workspace", editor: "file-manager" },257 { cwd: "C:\\workspace", editor: "file-manager" },
197 "win32",258 "win32",
259 { PATH: "" },
198 );260 );
199 assert.deepEqual(launch2, {261 assert.deepEqual(launch2, {
200 command: "explorer",262 command: "explorer",
@@ -204,6 +266,7 @@ it.layer(NodeServices.layer)("resolveEditorLaunch", (it) => { · 3 unmodified lines
204 const launch3 = yield* resolveEditorLaunch(266 const launch3 = yield* resolveEditorLaunch(
205 { cwd: "/tmp/workspace", editor: "file-manager" },267 { cwd: "/tmp/workspace", editor: "file-manager" },
206 "linux",268 "linux",
269 { PATH: "" },
207 );270 );
208 assert.deepEqual(launch3, {271 assert.deepEqual(launch3, {
209 command: "xdg-open",272 command: "xdg-open",
@@ -311,6 +374,7 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 101 unmodified lines
311 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });374 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
312 375
313 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");376 yield* fs.writeFileString(path.join(dir, "trae.CMD"), "@echo off\r\n");
377 yield* fs.writeFileString(path.join(dir, "kiro.CMD"), "@echo off\r\n");
314 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");378 yield* fs.writeFileString(path.join(dir, "code-insiders.CMD"), "@echo off\r\n");
315 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");379 yield* fs.writeFileString(path.join(dir, "codium.CMD"), "@echo off\r\n");
316 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");380 yield* fs.writeFileString(path.join(dir, "explorer.CMD"), "MZ");
@@ -318,7 +382,32 @@ it.layer(NodeServices.layer)("resolveAvailableEditors", (it) => { · 1 unmodified line
318 PATH: dir,382 PATH: dir,
319 PATHEXT: ".COM;.EXE;.BAT;.CMD",383 PATHEXT: ".COM;.EXE;.BAT;.CMD",
320 });384 });
321 assert.deepEqual(editors, ["trae", "vscode-insiders", "vscodium", "file-manager"]);385 assert.deepEqual(editors, ["trae", "kiro", "vscode-insiders", "vscodium", "file-manager"]);
322 }),386 }),
323 );387 );
388
389 it.effect("includes zed when only the zeditor command is installed", () =>
390 Effect.gen(function* () {
391 const fs = yield* FileSystem.FileSystem;
392 const path = yield* Path.Path;
393 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-editors-" });
394
395 yield* fs.writeFileString(path.join(dir, "zeditor"), "#!/bin/sh\nexit 0\n");
396 yield* fs.writeFileString(path.join(dir, "xdg-open"), "#!/bin/sh\nexit 0\n");
397 yield* fs.chmod(path.join(dir, "zeditor"), 0o755);
398 yield* fs.chmod(path.join(dir, "xdg-open"), 0o755);
399
400 const editors = resolveAvailableEditors("linux", {
401 PATH: dir,
402 });
403 assert.deepEqual(editors, ["zed", "file-manager"]);
404 }),
405 );
406
407 it("omits file-manager when the platform opener is unavailable", () => {
408 const editors = resolveAvailableEditors("linux", {
409 PATH: "",
410 });
411 assert.deepEqual(editors, []);
412 });
324});413});
apps/server/src/open.ts+50−124
@@ -7,17 +7,17 @@ · 6 unmodified lines
7 * @module Open7 * @module Open
8 */8 */
9import { spawn } from "node:child_process";9import { spawn } from "node:child_process";
10import { accessSync, constants, statSync } from "node:fs";
11import { extname, join } from "node:path";
12 10
13import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";11import { EDITORS, OpenError, type EditorId } from "@t3tools/contracts";
14import { ServiceMap, Effect, Layer } from "effect";12import { isCommandAvailable, type CommandAvailabilityOptions } from "@t3tools/shared/shell";
13import { Context, Effect, Layer } from "effect";
15 14
16// ==============================15// ==============================
17// Definitions16// Definitions
18// ==============================17// ==============================
19 18
20export { OpenError };19export { OpenError };
20export { isCommandAvailable } from "@t3tools/shared/shell";
21 21
22export interface OpenInEditorInput {22export interface OpenInEditorInput {
23 readonly cwd: string;23 readonly cwd: string;
@@ -29,11 +29,6 @@ interface EditorLaunch { · 5 unmodified lines
29 readonly args: ReadonlyArray<string>;29 readonly args: ReadonlyArray<string>;
30}30}
31 31
32interface CommandAvailabilityOptions {
33 readonly platform?: NodeJS.Platform;
34 readonly env?: NodeJS.ProcessEnv;
35}
36
37const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;32const TARGET_WITH_POSITION_PATTERN = /^(.*?):(\d+)(?::(\d+))?$/;
38 33
39function parseTargetPathAndPosition(target: string): {34function parseTargetPathAndPosition(target: string): {
@@ -75,6 +70,26 @@ function resolveCommandEditorArgs( · 35 unmodified lines
75 }70 }
76}71}
77 72
73function resolveEditorArgs(
74 editor: (typeof EDITORS)[number],
75 target: string,
76): ReadonlyArray<string> {
77 const baseArgs = "baseArgs" in editor ? editor.baseArgs : [];
78 return [...baseArgs, ...resolveCommandEditorArgs(editor, target)];
79}
80
81function resolveAvailableCommand(
82 commands: ReadonlyArray<string>,
83 options: CommandAvailabilityOptions = {},
84): string | null {
85 for (const command of commands) {
86 if (isCommandAvailable(command, options)) {
87 return command;
88 }
89 }
90 return null;
91}
92
78function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {93function fileManagerCommandForPlatform(platform: NodeJS.Platform): string {
79 switch (platform) {94 switch (platform) {
80 case "darwin":95 case "darwin":
@@ -86,111 +101,6 @@ function fileManagerCommandForPlatform(platform: NodeJS.Platform): string { · 5 unmodified lines
86 }101 }
87}102}
88 103
89function stripWrappingQuotes(value: string): string {
90 return value.replace(/^"+|"+$/g, "");
91}
92
93function resolvePathEnvironmentVariable(env: NodeJS.ProcessEnv): string {
94 return env.PATH ?? env.Path ?? env.path ?? "";
95}
96
97function resolveWindowsPathExtensions(env: NodeJS.ProcessEnv): ReadonlyArray<string> {
98 const rawValue = env.PATHEXT;
99 const fallback = [".COM", ".EXE", ".BAT", ".CMD"];
100 if (!rawValue) return fallback;
101
102 const parsed = rawValue
103 .split(";")
104 .map((entry) => entry.trim())
105 .filter((entry) => entry.length > 0)
106 .map((entry) => (entry.startsWith(".") ? entry.toUpperCase() : `.${entry.toUpperCase()}`));
107 return parsed.length > 0 ? Array.from(new Set(parsed)) : fallback;
108}
109
110function resolveCommandCandidates(
111 command: string,
112 platform: NodeJS.Platform,
113 windowsPathExtensions: ReadonlyArray<string>,
114): ReadonlyArray<string> {
115 if (platform !== "win32") return [command];
116 const extension = extname(command);
117 const normalizedExtension = extension.toUpperCase();
118
119 if (extension.length > 0 && windowsPathExtensions.includes(normalizedExtension)) {
120 const commandWithoutExtension = command.slice(0, -extension.length);
121 return Array.from(
122 new Set([
123 command,
124 `${commandWithoutExtension}${normalizedExtension}`,
125 `${commandWithoutExtension}${normalizedExtension.toLowerCase()}`,
126 ]),
127 );
128 }
129
130 const candidates: string[] = [];
131 for (const extension of windowsPathExtensions) {
132 candidates.push(`${command}${extension}`);
133 candidates.push(`${command}${extension.toLowerCase()}`);
134 }
135 return Array.from(new Set(candidates));
136}
137
138function isExecutableFile(
139 filePath: string,
140 platform: NodeJS.Platform,
141 windowsPathExtensions: ReadonlyArray<string>,
142): boolean {
143 try {
144 const stat = statSync(filePath);
145 if (!stat.isFile()) return false;
146 if (platform === "win32") {
147 const extension = extname(filePath);
148 if (extension.length === 0) return false;
149 return windowsPathExtensions.includes(extension.toUpperCase());
150 }
151 accessSync(filePath, constants.X_OK);
152 return true;
153 } catch {
154 return false;
155 }
156}
157
158function resolvePathDelimiter(platform: NodeJS.Platform): string {
159 return platform === "win32" ? ";" : ":";
160}
161
162export function isCommandAvailable(
163 command: string,
164 options: CommandAvailabilityOptions = {},
165): boolean {
166 const platform = options.platform ?? process.platform;
167 const env = options.env ?? process.env;
168 const windowsPathExtensions = platform === "win32" ? resolveWindowsPathExtensions(env) : [];
169 const commandCandidates = resolveCommandCandidates(command, platform, windowsPathExtensions);
170
171 if (command.includes("/") || command.includes("\\")) {
172 return commandCandidates.some((candidate) =>
173 isExecutableFile(candidate, platform, windowsPathExtensions),
174 );
175 }
176
177 const pathValue = resolvePathEnvironmentVariable(env);
178 if (pathValue.length === 0) return false;
179 const pathEntries = pathValue
180 .split(resolvePathDelimiter(platform))
181 .map((entry) => stripWrappingQuotes(entry.trim()))
182 .filter((entry) => entry.length > 0);
183
184 for (const pathEntry of pathEntries) {
185 for (const candidate of commandCandidates) {
186 if (isExecutableFile(join(pathEntry, candidate), platform, windowsPathExtensions)) {
187 return true;
188 }
189 }
190 }
191 return false;
192}
193
194export function resolveAvailableEditors(104export function resolveAvailableEditors(
195 platform: NodeJS.Platform = process.platform,105 platform: NodeJS.Platform = process.platform,
196 env: NodeJS.ProcessEnv = process.env,106 env: NodeJS.ProcessEnv = process.env,
@@ -198,8 +108,16 @@ export function resolveAvailableEditors( · 1 unmodified line
198 const available: EditorId[] = [];108 const available: EditorId[] = [];
199 109
200 for (const editor of EDITORS) {110 for (const editor of EDITORS) {
201 const command = editor.command ?? fileManagerCommandForPlatform(platform);111 if (editor.commands === null) {
202 if (isCommandAvailable(command, { platform, env })) {112 const command = fileManagerCommandForPlatform(platform);
113 if (isCommandAvailable(command, { platform, env })) {
114 available.push(editor.id);
115 }
116 continue;
117 }
118
119 const command = resolveAvailableCommand(editor.commands, { platform, env });
120 if (command !== null) {
203 available.push(editor.id);121 available.push(editor.id);
204 }122 }
205 }123 }
@@ -227,7 +145,7 @@ export interface OpenShape { · 21 unmodified lines
227/**145/**
228 * Open - Service tag for browser/editor launch operations.146 * Open - Service tag for browser/editor launch operations.
229 */147 */
230export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {}148export class Open extends Context.Service<Open, OpenShape>()("t3/open") {}
231 149
232// ==============================150// ==============================
233// Implementations151// Implementations
@@ -236,6 +154,7 @@ export class Open extends ServiceMap.Service<Open, OpenShape>()("t3/open") {} · 2 unmodified lines
236export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (154export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* (
237 input: OpenInEditorInput,155 input: OpenInEditorInput,
238 platform: NodeJS.Platform = process.platform,156 platform: NodeJS.Platform = process.platform,
157 env: NodeJS.ProcessEnv = process.env,
239): Effect.fn.Return<EditorLaunch, OpenError> {158): Effect.fn.Return<EditorLaunch, OpenError> {
240 yield* Effect.annotateCurrentSpan({159 yield* Effect.annotateCurrentSpan({
241 "open.editor": input.editor,160 "open.editor": input.editor,
@@ -247,10 +166,12 @@ export const resolveEditorLaunch = Effect.fn("resolveEditorLaunch")(function* ( · 5 unmodified lines
247 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });166 return yield* new OpenError({ message: `Unknown editor: ${input.editor}` });
248 }167 }
249 168
250 if (editorDef.command) {169 if (editorDef.commands) {
170 const command =
171 resolveAvailableCommand(editorDef.commands, { platform, env }) ?? editorDef.commands[0];
251 return {172 return {
252 command: editorDef.command,173 command,
253 args: resolveCommandEditorArgs(editorDef, input.cwd),174 args: resolveEditorArgs(editorDef, input.cwd),
254 };175 };
255 }176 }
256 177
@@ -270,11 +191,16 @@ export const launchDetached = (launch: EditorLaunch) => · 13 unmodified lines
270 yield* Effect.callback<void, OpenError>((resume) => {191 yield* Effect.callback<void, OpenError>((resume) => {
271 let child;192 let child;
272 try {193 try {
273 child = spawn(launch.command, [...launch.args], {194 const isWin32 = process.platform === "win32";
274 detached: true,195 child = spawn(
275 stdio: "ignore",196 launch.command,
276 shell: process.platform === "win32",197 isWin32 ? launch.args.map((a) => `"${a}"`) : [...launch.args],
277 });198 {
199 detached: true,
200 stdio: "ignore",
201 shell: isWin32,
202 },
203 );
278 } catch (error) {204 } catch (error) {
279 return resume(205 return resume(
280 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),206 Effect.fail(new OpenError({ message: "failed to spawn detached process", cause: error })),
apps/server/src/orchestration/Layers/CheckpointReactor.test.ts+164−119
@@ -20,6 +20,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; · 19 unmodified lines
20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";20import { CheckpointStoreLive } from "../../checkpointing/Layers/CheckpointStore.ts";
21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";21import { CheckpointStore } from "../../checkpointing/Services/CheckpointStore.ts";
22import { GitCoreLive } from "../../git/Layers/GitCore.ts";22import { GitCoreLive } from "../../git/Layers/GitCore.ts";
23import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
24import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
23import { CheckpointReactorLive } from "./CheckpointReactor.ts";25import { CheckpointReactorLive } from "./CheckpointReactor.ts";
24import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";26import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
25import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";27import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
@@ -42,8 +44,8 @@ import { ServerConfig } from "../../config.ts"; · 16 unmodified lines
42import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";44import { WorkspaceEntriesLive } from "../../workspace/Layers/WorkspaceEntries.ts";
43import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";45import { WorkspacePathsLive } from "../../workspace/Layers/WorkspacePaths.ts";
44 46
45const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);47const asProjectId = (value: string): ProjectId => ProjectId.make(value);
46const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);48const asTurnId = (value: string): TurnId => TurnId.make(value);
47 49
48type LegacyProviderRuntimeEvent = {50type LegacyProviderRuntimeEvent = {
49 readonly type: string;51 readonly type: string;
@@ -79,7 +81,7 @@ function createProviderServiceHarness( · 29 unmodified lines
79 provider: providerName,81 provider: providerName,
80 status: "ready",82 status: "ready",
81 runtimeMode: "full-access",83 runtimeMode: "full-access",
82 threadId: ThreadId.makeUnsafe("thread-1"),84 threadId: ThreadId.make("thread-1"),
83 cwd: sessionCwd,85 cwd: sessionCwd,
84 createdAt: now,86 createdAt: now,
85 updatedAt: now,87 updatedAt: now,
@@ -96,7 +98,9 @@ function createProviderServiceHarness( · 10 unmodified lines
96 listSessions,98 listSessions,
97 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),99 getCapabilities: () => Effect.succeed({ sessionModelSwitch: "in-session" }),
98 rollbackConversation,100 rollbackConversation,
99 streamEvents: Stream.fromPubSub(runtimeEventPubSub),101 get streamEvents() {
102 return Stream.fromPubSub(runtimeEventPubSub);
103 },
100 };104 };
101 105
102 const emit = (event: LegacyProviderRuntimeEvent): void => {106 const emit = (event: LegacyProviderRuntimeEvent): void => {
@@ -126,7 +130,7 @@ async function waitForThread( · 23 unmodified lines
126 activities: ReadonlyArray<{ kind: string }>;130 activities: ReadonlyArray<{ kind: string }>;
127 }> => {131 }> => {
128 const readModel = await Effect.runPromise(engine.getReadModel());132 const readModel = await Effect.runPromise(engine.getReadModel());
129 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));133 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
130 if (thread && predicate(thread)) {134 if (thread && predicate(thread)) {
131 return thread;135 return thread;
132 }136 }
@@ -240,6 +244,7 @@ describe("CheckpointReactor", () => { · 107 unmodified lines
240 readonly threadWorktreePath?: string | null;244 readonly threadWorktreePath?: string | null;
241 readonly providerSessionCwd?: string;245 readonly providerSessionCwd?: string;
242 readonly providerName?: ProviderKind;246 readonly providerName?: ProviderKind;
247 readonly gitStatusRefreshCalls?: Array<string>;
243 }) {248 }) {
244 const cwd = createGitRepository();249 const cwd = createGitRepository();
245 tempDirs.push(cwd);250 tempDirs.push(cwd);
@@ -254,17 +259,37 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
254 Layer.provide(OrchestrationProjectionPipelineLive),259 Layer.provide(OrchestrationProjectionPipelineLive),
255 Layer.provide(OrchestrationEventStoreLive),260 Layer.provide(OrchestrationEventStoreLive),
256 Layer.provide(OrchestrationCommandReceiptRepositoryLive),261 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
262 Layer.provide(RepositoryIdentityResolverLive),
257 Layer.provide(SqlitePersistenceMemory),263 Layer.provide(SqlitePersistenceMemory),
258 );264 );
259 265
260 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {266 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
261 prefix: "t3-checkpoint-reactor-test-",267 prefix: "t3-checkpoint-reactor-test-",
262 });268 });
269 const gitStatusBroadcasterLayer = Layer.succeed(GitStatusBroadcaster, {
270 getStatus: () => Effect.die("getStatus should not be called in this test"),
271 refreshLocalStatus: (cwd: string) =>
272 Effect.sync(() => {
273 options?.gitStatusRefreshCalls?.push(cwd);
274 }).pipe(
275 Effect.as({
276 isRepo: true,
277 hasOriginRemote: false,
278 isDefaultBranch: true,
279 branch: "main",
280 hasWorkingTreeChanges: false,
281 workingTree: { files: [], insertions: 0, deletions: 0 },
282 }),
283 ),
284 refreshStatus: () => Effect.die("refreshStatus should not be called in this test"),
285 streamStatus: () => Stream.empty,
286 });
263 287
264 const layer = CheckpointReactorLive.pipe(288 const layer = CheckpointReactorLive.pipe(
265 Layer.provideMerge(orchestrationLayer),289 Layer.provideMerge(orchestrationLayer),
266 Layer.provideMerge(RuntimeReceiptBusLive),290 Layer.provideMerge(RuntimeReceiptBusLive),
267 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),291 Layer.provideMerge(Layer.succeed(ProviderService, provider.service)),
292 Layer.provideMerge(gitStatusBroadcasterLayer),
268 Layer.provideMerge(CheckpointStoreLive),293 Layer.provideMerge(CheckpointStoreLive),
269 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),294 Layer.provideMerge(WorkspaceEntriesLive.pipe(Layer.provide(WorkspacePathsLive))),
270 Layer.provideMerge(WorkspacePathsLive),295 Layer.provideMerge(WorkspacePathsLive),
@@ -285,7 +310,7 @@ describe("CheckpointReactor", () => { · 14 unmodified lines
285 await Effect.runPromise(310 await Effect.runPromise(
286 engine.dispatch({311 engine.dispatch({
287 type: "project.create",312 type: "project.create",
288 commandId: CommandId.makeUnsafe("cmd-project-create"),313 commandId: CommandId.make("cmd-project-create"),
289 projectId: asProjectId("project-1"),314 projectId: asProjectId("project-1"),
290 title: "Test Project",315 title: "Test Project",
291 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,316 workspaceRoot: options?.projectWorkspaceRoot ?? cwd,
@@ -299,8 +324,8 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
299 await Effect.runPromise(324 await Effect.runPromise(
300 engine.dispatch({325 engine.dispatch({
301 type: "thread.create",326 type: "thread.create",
302 commandId: CommandId.makeUnsafe("cmd-thread-create"),327 commandId: CommandId.make("cmd-thread-create"),
303 threadId: ThreadId.makeUnsafe("thread-1"),328 threadId: ThreadId.make("thread-1"),
304 projectId: asProjectId("project-1"),329 projectId: asProjectId("project-1"),
305 title: "Thread",330 title: "Thread",
306 modelSelection: {331 modelSelection: {
@@ -319,21 +344,21 @@ describe("CheckpointReactor", () => { · 12 unmodified lines
319 await runtime.runPromise(344 await runtime.runPromise(
320 checkpointStore.captureCheckpoint({345 checkpointStore.captureCheckpoint({
321 cwd,346 cwd,
322 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),347 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
323 }),348 }),
324 );349 );
325 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");350 fs.writeFileSync(path.join(cwd, "README.md"), "v2\n", "utf8");
326 await runtime.runPromise(351 await runtime.runPromise(
327 checkpointStore.captureCheckpoint({352 checkpointStore.captureCheckpoint({
328 cwd,353 cwd,
329 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),354 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
330 }),355 }),
331 );356 );
332 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");357 fs.writeFileSync(path.join(cwd, "README.md"), "v3\n", "utf8");
333 await runtime.runPromise(358 await runtime.runPromise(
334 checkpointStore.captureCheckpoint({359 checkpointStore.captureCheckpoint({
335 cwd,360 cwd,
336 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),361 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
337 }),362 }),
338 );363 );
339 }364 }
@@ -353,10 +378,10 @@ describe("CheckpointReactor", () => { · 13 unmodified lines
353 await Effect.runPromise(378 await Effect.runPromise(
354 harness.engine.dispatch({379 harness.engine.dispatch({
355 type: "thread.session.set",380 type: "thread.session.set",
356 commandId: CommandId.makeUnsafe("cmd-session-set-capture"),381 commandId: CommandId.make("cmd-session-set-capture"),
357 threadId: ThreadId.makeUnsafe("thread-1"),382 threadId: ThreadId.make("thread-1"),
358 session: {383 session: {
359 threadId: ThreadId.makeUnsafe("thread-1"),384 threadId: ThreadId.make("thread-1"),
360 status: "ready",385 status: "ready",
361 providerName: "codex",386 providerName: "codex",
362 runtimeMode: "approval-required",387 runtimeMode: "approval-required",
@@ -370,26 +395,26 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
370 395
371 harness.provider.emit({396 harness.provider.emit({
372 type: "turn.started",397 type: "turn.started",
373 eventId: EventId.makeUnsafe("evt-turn-started-1"),398 eventId: EventId.make("evt-turn-started-1"),
374 provider: "codex",399 provider: "codex",
375 400
376 createdAt: new Date().toISOString(),401 createdAt: new Date().toISOString(),
377 threadId: ThreadId.makeUnsafe("thread-1"),402 threadId: ThreadId.make("thread-1"),
378 turnId: asTurnId("turn-1"),403 turnId: asTurnId("turn-1"),
379 });404 });
380 await waitForGitRefExists(405 await waitForGitRefExists(
381 harness.cwd,406 harness.cwd,
382 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),407 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
383 );408 );
384 409
385 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");410 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
386 harness.provider.emit({411 harness.provider.emit({
387 type: "turn.completed",412 type: "turn.completed",
388 eventId: EventId.makeUnsafe("evt-turn-completed-1"),413 eventId: EventId.make("evt-turn-completed-1"),
389 provider: "codex",414 provider: "codex",
390 415
391 createdAt: new Date().toISOString(),416 createdAt: new Date().toISOString(),
392 threadId: ThreadId.makeUnsafe("thread-1"),417 threadId: ThreadId.make("thread-1"),
393 turnId: asTurnId("turn-1"),418 turnId: asTurnId("turn-1"),
394 payload: { state: "completed" },419 payload: { state: "completed" },
395 });420 });
@@ -401,27 +426,49 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
401 );426 );
402 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);427 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
403 expect(428 expect(
404 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)),429 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
405 ).toBe(true);430 ).toBe(true);
406 expect(431 expect(
407 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),432 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
408 ).toBe(true);433 ).toBe(true);
409 expect(434 expect(
410 gitShowFileAtRef(435 gitShowFileAtRef(
411 harness.cwd,436 harness.cwd,
412 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),437 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
413 "README.md",438 "README.md",
414 ),439 ),
415 ).toBe("v1\n");440 ).toBe("v1\n");
416 expect(441 expect(
417 gitShowFileAtRef(442 gitShowFileAtRef(
418 harness.cwd,443 harness.cwd,
419 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),444 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
420 "README.md",445 "README.md",
421 ),446 ),
422 ).toBe("v2\n");447 ).toBe("v2\n");
423 });448 });
424 449
450 it("refreshes local git status state on turn completion using the session cwd", async () => {
451 const gitStatusRefreshCalls: string[] = [];
452 const harness = await createHarness({
453 seedFilesystemCheckpoints: false,
454 gitStatusRefreshCalls,
455 });
456
457 harness.provider.emit({
458 type: "turn.completed",
459 eventId: EventId.make("evt-turn-completed-refresh-local-status"),
460 provider: "codex",
461 createdAt: new Date().toISOString(),
462 threadId: ThreadId.make("thread-1"),
463 turnId: asTurnId("turn-refresh-local-status"),
464 payload: { state: "completed" },
465 });
466
467 await harness.drain();
468
469 expect(gitStatusRefreshCalls).toEqual([harness.cwd]);
470 });
471
425 it("ignores auxiliary thread turn completion while primary turn is active", async () => {472 it("ignores auxiliary thread turn completion while primary turn is active", async () => {
426 const harness = await createHarness({ seedFilesystemCheckpoints: false });473 const harness = await createHarness({ seedFilesystemCheckpoints: false });
427 const createdAt = new Date().toISOString();474 const createdAt = new Date().toISOString();
@@ -429,10 +476,10 @@ describe("CheckpointReactor", () => { · 1 unmodified line
429 await Effect.runPromise(476 await Effect.runPromise(
430 harness.engine.dispatch({477 harness.engine.dispatch({
431 type: "thread.session.set",478 type: "thread.session.set",
432 commandId: CommandId.makeUnsafe("cmd-session-set-primary-running"),479 commandId: CommandId.make("cmd-session-set-primary-running"),
433 threadId: ThreadId.makeUnsafe("thread-1"),480 threadId: ThreadId.make("thread-1"),
434 session: {481 session: {
435 threadId: ThreadId.makeUnsafe("thread-1"),482 threadId: ThreadId.make("thread-1"),
436 status: "running",483 status: "running",
437 providerName: "codex",484 providerName: "codex",
438 runtimeMode: "approval-required",485 runtimeMode: "approval-required",
@@ -446,45 +493,43 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
446 493
447 harness.provider.emit({494 harness.provider.emit({
448 type: "turn.started",495 type: "turn.started",
449 eventId: EventId.makeUnsafe("evt-turn-started-main"),496 eventId: EventId.make("evt-turn-started-main"),
450 provider: "codex",497 provider: "codex",
451 498
452 createdAt: new Date().toISOString(),499 createdAt: new Date().toISOString(),
453 threadId: ThreadId.makeUnsafe("thread-1"),500 threadId: ThreadId.make("thread-1"),
454 turnId: asTurnId("turn-main"),501 turnId: asTurnId("turn-main"),
455 });502 });
456 await waitForGitRefExists(503 await waitForGitRefExists(
457 harness.cwd,504 harness.cwd,
458 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),505 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
459 );506 );
460 507
461 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");508 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
462 509
463 harness.provider.emit({510 harness.provider.emit({
464 type: "turn.completed",511 type: "turn.completed",
465 eventId: EventId.makeUnsafe("evt-turn-completed-aux"),512 eventId: EventId.make("evt-turn-completed-aux"),
466 provider: "codex",513 provider: "codex",
467 514
468 createdAt: new Date().toISOString(),515 createdAt: new Date().toISOString(),
469 threadId: ThreadId.makeUnsafe("thread-1"),516 threadId: ThreadId.make("thread-1"),
470 turnId: asTurnId("turn-aux"),517 turnId: asTurnId("turn-aux"),
471 payload: { state: "completed" },518 payload: { state: "completed" },
472 });519 });
473 520
474 await harness.drain();521 await harness.drain();
475 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());522 const midReadModel = await Effect.runPromise(harness.engine.getReadModel());
476 const midThread = midReadModel.threads.find(523 const midThread = midReadModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
477 (entry) => entry.id === ThreadId.makeUnsafe("thread-1"),
478 );
479 expect(midThread?.checkpoints).toHaveLength(0);524 expect(midThread?.checkpoints).toHaveLength(0);
480 525
481 harness.provider.emit({526 harness.provider.emit({
482 type: "turn.completed",527 type: "turn.completed",
483 eventId: EventId.makeUnsafe("evt-turn-completed-main"),528 eventId: EventId.make("evt-turn-completed-main"),
484 provider: "codex",529 provider: "codex",
485 530
486 createdAt: new Date().toISOString(),531 createdAt: new Date().toISOString(),
487 threadId: ThreadId.makeUnsafe("thread-1"),532 threadId: ThreadId.make("thread-1"),
488 turnId: asTurnId("turn-main"),533 turnId: asTurnId("turn-main"),
489 payload: { state: "completed" },534 payload: { state: "completed" },
490 });535 });
@@ -506,10 +551,10 @@ describe("CheckpointReactor", () => { · 15 unmodified lines
506 await Effect.runPromise(551 await Effect.runPromise(
507 harness.engine.dispatch({552 harness.engine.dispatch({
508 type: "thread.session.set",553 type: "thread.session.set",
509 commandId: CommandId.makeUnsafe("cmd-session-set-capture-claude"),554 commandId: CommandId.make("cmd-session-set-capture-claude"),
510 threadId: ThreadId.makeUnsafe("thread-1"),555 threadId: ThreadId.make("thread-1"),
511 session: {556 session: {
512 threadId: ThreadId.makeUnsafe("thread-1"),557 threadId: ThreadId.make("thread-1"),
513 status: "ready",558 status: "ready",
514 providerName: "claudeAgent",559 providerName: "claudeAgent",
515 runtimeMode: "approval-required",560 runtimeMode: "approval-required",
@@ -523,24 +568,24 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
523 568
524 harness.provider.emit({569 harness.provider.emit({
525 type: "turn.started",570 type: "turn.started",
526 eventId: EventId.makeUnsafe("evt-turn-started-claude-1"),571 eventId: EventId.make("evt-turn-started-claude-1"),
527 provider: "claudeAgent",572 provider: "claudeAgent",
528 createdAt: new Date().toISOString(),573 createdAt: new Date().toISOString(),
529 threadId: ThreadId.makeUnsafe("thread-1"),574 threadId: ThreadId.make("thread-1"),
530 turnId: asTurnId("turn-claude-1"),575 turnId: asTurnId("turn-claude-1"),
531 });576 });
532 await waitForGitRefExists(577 await waitForGitRefExists(
533 harness.cwd,578 harness.cwd,
534 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),579 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
535 );580 );
536 581
537 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");582 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
538 harness.provider.emit({583 harness.provider.emit({
539 type: "turn.completed",584 type: "turn.completed",
540 eventId: EventId.makeUnsafe("evt-turn-completed-claude-1"),585 eventId: EventId.make("evt-turn-completed-claude-1"),
541 provider: "claudeAgent",586 provider: "claudeAgent",
542 createdAt: new Date().toISOString(),587 createdAt: new Date().toISOString(),
543 threadId: ThreadId.makeUnsafe("thread-1"),588 threadId: ThreadId.make("thread-1"),
544 turnId: asTurnId("turn-claude-1"),589 turnId: asTurnId("turn-claude-1"),
545 payload: { state: "completed" },590 payload: { state: "completed" },
546 });591 });
@@ -553,7 +598,7 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
553 598
554 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);599 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
555 expect(600 expect(
556 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),601 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
557 ).toBe(true);602 ).toBe(true);
558 });603 });
559 604
@@ -564,10 +609,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
564 await Effect.runPromise(609 await Effect.runPromise(
565 harness.engine.dispatch({610 harness.engine.dispatch({
566 type: "thread.session.set",611 type: "thread.session.set",
567 commandId: CommandId.makeUnsafe("cmd-session-set-missing-baseline-diff"),612 commandId: CommandId.make("cmd-session-set-missing-baseline-diff"),
568 threadId: ThreadId.makeUnsafe("thread-1"),613 threadId: ThreadId.make("thread-1"),
569 session: {614 session: {
570 threadId: ThreadId.makeUnsafe("thread-1"),615 threadId: ThreadId.make("thread-1"),
571 status: "ready",616 status: "ready",
572 providerName: "codex",617 providerName: "codex",
573 runtimeMode: "approval-required",618 runtimeMode: "approval-required",
@@ -581,11 +626,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
581 626
582 harness.provider.emit({627 harness.provider.emit({
583 type: "turn.completed",628 type: "turn.completed",
584 eventId: EventId.makeUnsafe("evt-turn-completed-missing-baseline"),629 eventId: EventId.make("evt-turn-completed-missing-baseline"),
585 provider: "codex",630 provider: "codex",
586 631
587 createdAt: new Date().toISOString(),632 createdAt: new Date().toISOString(),
588 threadId: ThreadId.makeUnsafe("thread-1"),633 threadId: ThreadId.make("thread-1"),
589 turnId: asTurnId("turn-missing-baseline"),634 turnId: asTurnId("turn-missing-baseline"),
590 payload: { state: "completed" },635 payload: { state: "completed" },
591 });636 });
@@ -614,10 +659,10 @@ describe("CheckpointReactor", () => { · 22 unmodified lines
614 await Effect.runPromise(659 await Effect.runPromise(
615 harness.engine.dispatch({660 harness.engine.dispatch({
616 type: "thread.turn.start",661 type: "thread.turn.start",
617 commandId: CommandId.makeUnsafe("cmd-turn-start-for-baseline"),662 commandId: CommandId.make("cmd-turn-start-for-baseline"),
618 threadId: ThreadId.makeUnsafe("thread-1"),663 threadId: ThreadId.make("thread-1"),
619 message: {664 message: {
620 messageId: MessageId.makeUnsafe("message-user-1"),665 messageId: MessageId.make("message-user-1"),
621 role: "user",666 role: "user",
622 text: "start turn",667 text: "start turn",
623 attachments: [],668 attachments: [],
@@ -630,12 +675,12 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
630 675
631 await waitForGitRefExists(676 await waitForGitRefExists(
632 harness.cwd,677 harness.cwd,
633 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),678 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
634 );679 );
635 expect(680 expect(
636 gitShowFileAtRef(681 gitShowFileAtRef(
637 harness.cwd,682 harness.cwd,
638 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),683 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
639 "README.md",684 "README.md",
640 ),685 ),
641 ).toBe("v1\n");686 ).toBe("v1\n");
@@ -652,10 +697,10 @@ describe("CheckpointReactor", () => { · 10 unmodified lines
652 await Effect.runPromise(697 await Effect.runPromise(
653 harness.engine.dispatch({698 harness.engine.dispatch({
654 type: "thread.session.set",699 type: "thread.session.set",
655 commandId: CommandId.makeUnsafe("cmd-session-set-missing-provider-cwd"),700 commandId: CommandId.make("cmd-session-set-missing-provider-cwd"),
656 threadId: ThreadId.makeUnsafe("thread-1"),701 threadId: ThreadId.make("thread-1"),
657 session: {702 session: {
658 threadId: ThreadId.makeUnsafe("thread-1"),703 threadId: ThreadId.make("thread-1"),
659 status: "running",704 status: "running",
660 providerName: "codex",705 providerName: "codex",
661 runtimeMode: "approval-required",706 runtimeMode: "approval-required",
@@ -670,23 +715,23 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
670 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");715 fs.writeFileSync(path.join(harness.cwd, "README.md"), "v2\n", "utf8");
671 harness.provider.emit({716 harness.provider.emit({
672 type: "turn.completed",717 type: "turn.completed",
673 eventId: EventId.makeUnsafe("evt-turn-completed-missing-provider-cwd"),718 eventId: EventId.make("evt-turn-completed-missing-provider-cwd"),
674 provider: "codex",719 provider: "codex",
675 720
676 createdAt: new Date().toISOString(),721 createdAt: new Date().toISOString(),
677 threadId: ThreadId.makeUnsafe("thread-1"),722 threadId: ThreadId.make("thread-1"),
678 turnId: asTurnId("turn-missing-cwd"),723 turnId: asTurnId("turn-missing-cwd"),
679 payload: { state: "completed" },724 payload: { state: "completed" },
680 });725 });
681 726
682 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");727 await waitForEvent(harness.engine, (event) => event.type === "thread.turn-diff-completed");
683 expect(728 expect(
684 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1)),729 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1)),
685 ).toBe(true);730 ).toBe(true);
686 expect(731 expect(
687 gitShowFileAtRef(732 gitShowFileAtRef(
688 harness.cwd,733 harness.cwd,
689 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),734 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
690 "README.md",735 "README.md",
691 ),736 ),
692 ).toBe("v2\n");737 ).toBe("v2\n");
@@ -699,10 +744,10 @@ describe("CheckpointReactor", () => { · 6 unmodified lines
699 await Effect.runPromise(744 await Effect.runPromise(
700 harness.engine.dispatch({745 harness.engine.dispatch({
701 type: "thread.session.set",746 type: "thread.session.set",
702 commandId: CommandId.makeUnsafe("cmd-session-set-checkpoint-captured"),747 commandId: CommandId.make("cmd-session-set-checkpoint-captured"),
703 threadId: ThreadId.makeUnsafe("thread-1"),748 threadId: ThreadId.make("thread-1"),
704 session: {749 session: {
705 threadId: ThreadId.makeUnsafe("thread-1"),750 threadId: ThreadId.make("thread-1"),
706 status: "ready",751 status: "ready",
707 providerName: "codex",752 providerName: "codex",
708 runtimeMode: "approval-required",753 runtimeMode: "approval-required",
@@ -716,11 +761,11 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
716 761
717 harness.provider.emit({762 harness.provider.emit({
718 type: "checkpoint.captured",763 type: "checkpoint.captured",
719 eventId: EventId.makeUnsafe("evt-checkpoint-captured-3"),764 eventId: EventId.make("evt-checkpoint-captured-3"),
720 provider: "codex",765 provider: "codex",
721 766
722 createdAt: new Date().toISOString(),767 createdAt: new Date().toISOString(),
723 threadId: ThreadId.makeUnsafe("thread-1"),768 threadId: ThreadId.make("thread-1"),
724 turnId: asTurnId("turn-3"),769 turnId: asTurnId("turn-3"),
725 turnCount: 3,770 turnCount: 3,
726 status: "completed",771 status: "completed",
@@ -728,7 +773,7 @@ describe("CheckpointReactor", () => { · 1 unmodified line
728 773
729 await harness.drain();774 await harness.drain();
730 const readModel = await Effect.runPromise(harness.engine.getReadModel());775 const readModel = await Effect.runPromise(harness.engine.getReadModel());
731 const thread = readModel.threads.find((entry) => entry.id === ThreadId.makeUnsafe("thread-1"));776 const thread = readModel.threads.find((entry) => entry.id === ThreadId.make("thread-1"));
732 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(777 expect(thread?.checkpoints.some((checkpoint) => checkpoint.checkpointTurnCount === 3)).toBe(
733 false,778 false,
734 );779 );
@@ -749,10 +794,10 @@ describe("CheckpointReactor", () => { · 14 unmodified lines
749 await Effect.runPromise(794 await Effect.runPromise(
750 harness.engine.dispatch({795 harness.engine.dispatch({
751 type: "thread.session.set",796 type: "thread.session.set",
752 commandId: CommandId.makeUnsafe("cmd-session-set-non-repo-runtime"),797 commandId: CommandId.make("cmd-session-set-non-repo-runtime"),
753 threadId: ThreadId.makeUnsafe("thread-1"),798 threadId: ThreadId.make("thread-1"),
754 session: {799 session: {
755 threadId: ThreadId.makeUnsafe("thread-1"),800 threadId: ThreadId.make("thread-1"),
756 status: "ready",801 status: "ready",
757 providerName: "codex",802 providerName: "codex",
758 runtimeMode: "approval-required",803 runtimeMode: "approval-required",
@@ -766,31 +811,31 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
766 811
767 harness.provider.emit({812 harness.provider.emit({
768 type: "turn.completed",813 type: "turn.completed",
769 eventId: EventId.makeUnsafe("evt-runtime-capture-failure"),814 eventId: EventId.make("evt-runtime-capture-failure"),
770 provider: "codex",815 provider: "codex",
771 816
772 createdAt: new Date().toISOString(),817 createdAt: new Date().toISOString(),
773 threadId: ThreadId.makeUnsafe("thread-1"),818 threadId: ThreadId.make("thread-1"),
774 turnId: asTurnId("turn-runtime-failure"),819 turnId: asTurnId("turn-runtime-failure"),
775 payload: { state: "completed" },820 payload: { state: "completed" },
776 });821 });
777 822
778 harness.provider.emit({823 harness.provider.emit({
779 type: "turn.started",824 type: "turn.started",
780 eventId: EventId.makeUnsafe("evt-turn-started-after-runtime-failure"),825 eventId: EventId.make("evt-turn-started-after-runtime-failure"),
781 provider: "codex",826 provider: "codex",
782 827
783 createdAt: new Date().toISOString(),828 createdAt: new Date().toISOString(),
784 threadId: ThreadId.makeUnsafe("thread-1"),829 threadId: ThreadId.make("thread-1"),
785 turnId: asTurnId("turn-after-runtime-failure"),830 turnId: asTurnId("turn-after-runtime-failure"),
786 });831 });
787 832
788 await waitForGitRefExists(833 await waitForGitRefExists(
789 harness.cwd,834 harness.cwd,
790 checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0),835 checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0),
791 );836 );
792 expect(837 expect(
793 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 0)),838 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 0)),
794 ).toBe(true);839 ).toBe(true);
795 });840 });
796 841
@@ -801,10 +846,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
801 await Effect.runPromise(846 await Effect.runPromise(
802 harness.engine.dispatch({847 harness.engine.dispatch({
803 type: "thread.session.set",848 type: "thread.session.set",
804 commandId: CommandId.makeUnsafe("cmd-session-set"),849 commandId: CommandId.make("cmd-session-set"),
805 threadId: ThreadId.makeUnsafe("thread-1"),850 threadId: ThreadId.make("thread-1"),
806 session: {851 session: {
807 threadId: ThreadId.makeUnsafe("thread-1"),852 threadId: ThreadId.make("thread-1"),
808 status: "ready",853 status: "ready",
809 providerName: "codex",854 providerName: "codex",
810 runtimeMode: "approval-required",855 runtimeMode: "approval-required",
@@ -819,11 +864,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
819 await Effect.runPromise(864 await Effect.runPromise(
820 harness.engine.dispatch({865 harness.engine.dispatch({
821 type: "thread.turn.diff.complete",866 type: "thread.turn.diff.complete",
822 commandId: CommandId.makeUnsafe("cmd-diff-1"),867 commandId: CommandId.make("cmd-diff-1"),
823 threadId: ThreadId.makeUnsafe("thread-1"),868 threadId: ThreadId.make("thread-1"),
824 turnId: asTurnId("turn-1"),869 turnId: asTurnId("turn-1"),
825 completedAt: createdAt,870 completedAt: createdAt,
826 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),871 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
827 status: "ready",872 status: "ready",
828 files: [],873 files: [],
829 checkpointTurnCount: 1,874 checkpointTurnCount: 1,
@@ -833,11 +878,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
833 await Effect.runPromise(878 await Effect.runPromise(
834 harness.engine.dispatch({879 harness.engine.dispatch({
835 type: "thread.turn.diff.complete",880 type: "thread.turn.diff.complete",
836 commandId: CommandId.makeUnsafe("cmd-diff-2"),881 commandId: CommandId.make("cmd-diff-2"),
837 threadId: ThreadId.makeUnsafe("thread-1"),882 threadId: ThreadId.make("thread-1"),
838 turnId: asTurnId("turn-2"),883 turnId: asTurnId("turn-2"),
839 completedAt: createdAt,884 completedAt: createdAt,
840 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),885 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
841 status: "ready",886 status: "ready",
842 files: [],887 files: [],
843 checkpointTurnCount: 2,888 checkpointTurnCount: 2,
@@ -848,8 +893,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
848 await Effect.runPromise(893 await Effect.runPromise(
849 harness.engine.dispatch({894 harness.engine.dispatch({
850 type: "thread.checkpoint.revert",895 type: "thread.checkpoint.revert",
851 commandId: CommandId.makeUnsafe("cmd-revert-request"),896 commandId: CommandId.make("cmd-revert-request"),
852 threadId: ThreadId.makeUnsafe("thread-1"),897 threadId: ThreadId.make("thread-1"),
853 turnCount: 1,898 turnCount: 1,
854 createdAt,899 createdAt,
855 }),900 }),
@@ -863,12 +908,12 @@ describe("CheckpointReactor", () => { · 7 unmodified lines
863 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);908 expect(thread.checkpoints[0]?.checkpointTurnCount).toBe(1);
864 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);909 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
865 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({910 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
866 threadId: ThreadId.makeUnsafe("thread-1"),911 threadId: ThreadId.make("thread-1"),
867 numTurns: 1,912 numTurns: 1,
868 });913 });
869 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");914 expect(fs.readFileSync(path.join(harness.cwd, "README.md"), "utf8")).toBe("v2\n");
870 expect(915 expect(
871 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2)),916 gitRefExists(harness.cwd, checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2)),
872 ).toBe(false);917 ).toBe(false);
873 });918 });
874 919
@@ -879,10 +924,10 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
879 await Effect.runPromise(924 await Effect.runPromise(
880 harness.engine.dispatch({925 harness.engine.dispatch({
881 type: "thread.session.set",926 type: "thread.session.set",
882 commandId: CommandId.makeUnsafe("cmd-session-set-claude"),927 commandId: CommandId.make("cmd-session-set-claude"),
883 threadId: ThreadId.makeUnsafe("thread-1"),928 threadId: ThreadId.make("thread-1"),
884 session: {929 session: {
885 threadId: ThreadId.makeUnsafe("thread-1"),930 threadId: ThreadId.make("thread-1"),
886 status: "ready",931 status: "ready",
887 providerName: "claudeAgent",932 providerName: "claudeAgent",
888 runtimeMode: "approval-required",933 runtimeMode: "approval-required",
@@ -897,11 +942,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
897 await Effect.runPromise(942 await Effect.runPromise(
898 harness.engine.dispatch({943 harness.engine.dispatch({
899 type: "thread.turn.diff.complete",944 type: "thread.turn.diff.complete",
900 commandId: CommandId.makeUnsafe("cmd-diff-claude-1"),945 commandId: CommandId.make("cmd-diff-claude-1"),
901 threadId: ThreadId.makeUnsafe("thread-1"),946 threadId: ThreadId.make("thread-1"),
902 turnId: asTurnId("turn-claude-1"),947 turnId: asTurnId("turn-claude-1"),
903 completedAt: createdAt,948 completedAt: createdAt,
904 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),949 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
905 status: "ready",950 status: "ready",
906 files: [],951 files: [],
907 checkpointTurnCount: 1,952 checkpointTurnCount: 1,
@@ -911,11 +956,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
911 await Effect.runPromise(956 await Effect.runPromise(
912 harness.engine.dispatch({957 harness.engine.dispatch({
913 type: "thread.turn.diff.complete",958 type: "thread.turn.diff.complete",
914 commandId: CommandId.makeUnsafe("cmd-diff-claude-2"),959 commandId: CommandId.make("cmd-diff-claude-2"),
915 threadId: ThreadId.makeUnsafe("thread-1"),960 threadId: ThreadId.make("thread-1"),
916 turnId: asTurnId("turn-claude-2"),961 turnId: asTurnId("turn-claude-2"),
917 completedAt: createdAt,962 completedAt: createdAt,
918 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),963 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
919 status: "ready",964 status: "ready",
920 files: [],965 files: [],
921 checkpointTurnCount: 2,966 checkpointTurnCount: 2,
@@ -926,8 +971,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
926 await Effect.runPromise(971 await Effect.runPromise(
927 harness.engine.dispatch({972 harness.engine.dispatch({
928 type: "thread.checkpoint.revert",973 type: "thread.checkpoint.revert",
929 commandId: CommandId.makeUnsafe("cmd-revert-request-claude"),974 commandId: CommandId.make("cmd-revert-request-claude"),
930 threadId: ThreadId.makeUnsafe("thread-1"),975 threadId: ThreadId.make("thread-1"),
931 turnCount: 1,976 turnCount: 1,
932 createdAt,977 createdAt,
933 }),978 }),
@@ -936,7 +981,7 @@ describe("CheckpointReactor", () => { · 2 unmodified lines
936 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");981 await waitForEvent(harness.engine, (event) => event.type === "thread.reverted");
937 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);982 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(1);
938 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({983 expect(harness.provider.rollbackConversation).toHaveBeenCalledWith({
939 threadId: ThreadId.makeUnsafe("thread-1"),984 threadId: ThreadId.make("thread-1"),
940 numTurns: 1,985 numTurns: 1,
941 });986 });
942 });987 });
@@ -948,10 +993,10 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
948 await Effect.runPromise(993 await Effect.runPromise(
949 harness.engine.dispatch({994 harness.engine.dispatch({
950 type: "thread.session.set",995 type: "thread.session.set",
951 commandId: CommandId.makeUnsafe("cmd-session-set-inline-revert"),996 commandId: CommandId.make("cmd-session-set-inline-revert"),
952 threadId: ThreadId.makeUnsafe("thread-1"),997 threadId: ThreadId.make("thread-1"),
953 session: {998 session: {
954 threadId: ThreadId.makeUnsafe("thread-1"),999 threadId: ThreadId.make("thread-1"),
955 status: "ready",1000 status: "ready",
956 providerName: "codex",1001 providerName: "codex",
957 runtimeMode: "approval-required",1002 runtimeMode: "approval-required",
@@ -966,11 +1011,11 @@ describe("CheckpointReactor", () => { · 8 unmodified lines
966 await Effect.runPromise(1011 await Effect.runPromise(
967 harness.engine.dispatch({1012 harness.engine.dispatch({
968 type: "thread.turn.diff.complete",1013 type: "thread.turn.diff.complete",
969 commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-1"),1014 commandId: CommandId.make("cmd-inline-revert-diff-1"),
970 threadId: ThreadId.makeUnsafe("thread-1"),1015 threadId: ThreadId.make("thread-1"),
971 turnId: asTurnId("turn-1"),1016 turnId: asTurnId("turn-1"),
972 completedAt: createdAt,1017 completedAt: createdAt,
973 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 1),1018 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 1),
974 status: "ready",1019 status: "ready",
975 files: [],1020 files: [],
976 checkpointTurnCount: 1,1021 checkpointTurnCount: 1,
@@ -980,11 +1025,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
980 await Effect.runPromise(1025 await Effect.runPromise(
981 harness.engine.dispatch({1026 harness.engine.dispatch({
982 type: "thread.turn.diff.complete",1027 type: "thread.turn.diff.complete",
983 commandId: CommandId.makeUnsafe("cmd-inline-revert-diff-2"),1028 commandId: CommandId.make("cmd-inline-revert-diff-2"),
984 threadId: ThreadId.makeUnsafe("thread-1"),1029 threadId: ThreadId.make("thread-1"),
985 turnId: asTurnId("turn-2"),1030 turnId: asTurnId("turn-2"),
986 completedAt: createdAt,1031 completedAt: createdAt,
987 checkpointRef: checkpointRefForThreadTurn(ThreadId.makeUnsafe("thread-1"), 2),1032 checkpointRef: checkpointRefForThreadTurn(ThreadId.make("thread-1"), 2),
988 status: "ready",1033 status: "ready",
989 files: [],1034 files: [],
990 checkpointTurnCount: 2,1035 checkpointTurnCount: 2,
@@ -995,8 +1040,8 @@ describe("CheckpointReactor", () => { · 4 unmodified lines
995 await Effect.runPromise(1040 await Effect.runPromise(
996 harness.engine.dispatch({1041 harness.engine.dispatch({
997 type: "thread.checkpoint.revert",1042 type: "thread.checkpoint.revert",
998 commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-1"),1043 commandId: CommandId.make("cmd-sequenced-revert-request-1"),
999 threadId: ThreadId.makeUnsafe("thread-1"),1044 threadId: ThreadId.make("thread-1"),
1000 turnCount: 1,1045 turnCount: 1,
1001 createdAt,1046 createdAt,
1002 }),1047 }),
@@ -1004,8 +1049,8 @@ describe("CheckpointReactor", () => { · 1 unmodified line
1004 await Effect.runPromise(1049 await Effect.runPromise(
1005 harness.engine.dispatch({1050 harness.engine.dispatch({
1006 type: "thread.checkpoint.revert",1051 type: "thread.checkpoint.revert",
1007 commandId: CommandId.makeUnsafe("cmd-sequenced-revert-request-0"),1052 commandId: CommandId.make("cmd-sequenced-revert-request-0"),
1008 threadId: ThreadId.makeUnsafe("thread-1"),1053 threadId: ThreadId.make("thread-1"),
1009 turnCount: 0,1054 turnCount: 0,
1010 createdAt,1055 createdAt,
1011 }),1056 }),
@@ -1015,11 +1060,11 @@ describe("CheckpointReactor", () => { · 3 unmodified lines
1015 1060
1016 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);1061 expect(harness.provider.rollbackConversation).toHaveBeenCalledTimes(2);
1017 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({1062 expect(harness.provider.rollbackConversation.mock.calls[0]?.[0]).toEqual({
1018 threadId: ThreadId.makeUnsafe("thread-1"),1063 threadId: ThreadId.make("thread-1"),
1019 numTurns: 1,1064 numTurns: 1,
1020 });1065 });
1021 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({1066 expect(harness.provider.rollbackConversation.mock.calls[1]?.[0]).toEqual({
1022 threadId: ThreadId.makeUnsafe("thread-1"),1067 threadId: ThreadId.make("thread-1"),
1023 numTurns: 1,1068 numTurns: 1,
1024 });1069 });
1025 });1070 });
@@ -1031,8 +1076,8 @@ describe("CheckpointReactor", () => { · 5 unmodified lines
1031 await Effect.runPromise(1076 await Effect.runPromise(
1032 harness.engine.dispatch({1077 harness.engine.dispatch({
1033 type: "thread.checkpoint.revert",1078 type: "thread.checkpoint.revert",
1034 commandId: CommandId.makeUnsafe("cmd-revert-no-session"),1079 commandId: CommandId.make("cmd-revert-no-session"),
1035 threadId: ThreadId.makeUnsafe("thread-1"),1080 threadId: ThreadId.make("thread-1"),
1036 turnCount: 1,1081 turnCount: 1,
1037 createdAt,1082 createdAt,
1038 }),1083 }),
apps/server/src/orchestration/Layers/CheckpointReactor.ts+31−8
@@ -21,9 +21,10 @@ import { ProviderService } from "../../provider/Services/ProviderService.ts"; · 20 unmodified lines
21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";21import { CheckpointReactor, type CheckpointReactorShape } from "../Services/CheckpointReactor.ts";
22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";22import { OrchestrationEngineService } from "../Services/OrchestrationEngine.ts";
23import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";23import { RuntimeReceiptBus } from "../Services/RuntimeReceiptBus.ts";
24import { CheckpointStoreError } from "../../checkpointing/Errors.ts";24import type { CheckpointStoreError } from "../../checkpointing/Errors.ts";
25import { OrchestrationDispatchError } from "../Errors.ts";25import type { OrchestrationDispatchError } from "../Errors.ts";
26import { isGitRepository } from "../../git/Utils.ts";26import { isGitRepository } from "../../git/Utils.ts";
27import { GitStatusBroadcaster } from "../../git/Services/GitStatusBroadcaster.ts";
27import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";28import { WorkspaceEntries } from "../../workspace/Services/WorkspaceEntries.ts";
28 29
29type ReactorInput =30type ReactorInput =
@@ -37,7 +38,7 @@ type ReactorInput = · 7 unmodified lines
37 };38 };
38 39
39function toTurnId(value: string | undefined): TurnId | null {40function toTurnId(value: string | undefined): TurnId | null {
40 return value === undefined ? null : TurnId.makeUnsafe(String(value));41 return value === undefined ? null : TurnId.make(String(value));
41}42}
42 43
43function sameId(left: string | null | undefined, right: string | null | undefined): boolean {44function sameId(left: string | null | undefined, right: string | null | undefined): boolean {
@@ -61,7 +62,7 @@ function checkpointStatusFromRuntime(status: string | undefined): "ready" | "mis · 17 unmodified lines
61}62}
62 63
63const serverCommandId = (tag: string): CommandId =>64const serverCommandId = (tag: string): CommandId =>
64 CommandId.makeUnsafe(`server:${tag}:${crypto.randomUUID()}`);65 CommandId.make(`server:${tag}:${crypto.randomUUID()}`);
65 66
66const make = Effect.gen(function* () {67const make = Effect.gen(function* () {
67 const orchestrationEngine = yield* OrchestrationEngineService;68 const orchestrationEngine = yield* OrchestrationEngineService;
@@ -69,6 +70,7 @@ const make = Effect.gen(function* () { · 1 unmodified line
69 const checkpointStore = yield* CheckpointStore;70 const checkpointStore = yield* CheckpointStore;
70 const receiptBus = yield* RuntimeReceiptBus;71 const receiptBus = yield* RuntimeReceiptBus;
71 const workspaceEntries = yield* WorkspaceEntries;72 const workspaceEntries = yield* WorkspaceEntries;
73 const gitStatusBroadcaster = yield* GitStatusBroadcaster;
72 74
73 const appendRevertFailureActivity = (input: {75 const appendRevertFailureActivity = (input: {
74 readonly threadId: ThreadId;76 readonly threadId: ThreadId;
@@ -81,7 +83,7 @@ const make = Effect.gen(function* () { · 6 unmodified lines
81 commandId: serverCommandId("checkpoint-revert-failure"),83 commandId: serverCommandId("checkpoint-revert-failure"),
82 threadId: input.threadId,84 threadId: input.threadId,
83 activity: {85 activity: {
84 id: EventId.makeUnsafe(crypto.randomUUID()),86 id: EventId.make(crypto.randomUUID()),
85 tone: "error",87 tone: "error",
86 kind: "checkpoint.revert.failed",88 kind: "checkpoint.revert.failed",
87 summary: "Checkpoint revert failed",89 summary: "Checkpoint revert failed",
@@ -106,7 +108,7 @@ const make = Effect.gen(function* () { · 18 unmodified lines
106 commandId: serverCommandId("checkpoint-capture-failure"),108 commandId: serverCommandId("checkpoint-capture-failure"),
107 threadId: input.threadId,109 threadId: input.threadId,
108 activity: {110 activity: {
109 id: EventId.makeUnsafe(crypto.randomUUID()),111 id: EventId.make(crypto.randomUUID()),
110 tone: "error",112 tone: "error",
111 kind: "checkpoint.capture.failed",113 kind: "checkpoint.capture.failed",
112 summary: "Checkpoint capture failed",114 summary: "Checkpoint capture failed",
@@ -268,7 +270,7 @@ const make = Effect.gen(function* () { · 155 unmodified lines
268 input.thread.messages270 input.thread.messages
269 .toReversed()271 .toReversed()
270 .find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ??272 .find((entry) => entry.role === "assistant" && entry.turnId === input.turnId)?.id ??
271 MessageId.makeUnsafe(`assistant:${input.turnId}`);273 MessageId.make(`assistant:${input.turnId}`);
272 274
273 yield* orchestrationEngine.dispatch({275 yield* orchestrationEngine.dispatch({
274 type: "thread.turn.diff.complete",276 type: "thread.turn.diff.complete",
@@ -305,7 +307,7 @@ const make = Effect.gen(function* () { · 30 unmodified lines
305 commandId: serverCommandId("checkpoint-captured-activity"),307 commandId: serverCommandId("checkpoint-captured-activity"),
306 threadId: input.threadId,308 threadId: input.threadId,
307 activity: {309 activity: {
308 id: EventId.makeUnsafe(crypto.randomUUID()),310 id: EventId.make(crypto.randomUUID()),
309 tone: "info",311 tone: "info",
310 kind: "checkpoint.captured",312 kind: "checkpoint.captured",
311 summary: "Checkpoint captured",313 summary: "Checkpoint captured",
@@ -498,6 +500,26 @@ const make = Effect.gen(function* () { · 186 unmodified lines
498 },500 },
499 );501 );
500 502
503 const refreshLocalGitStatusFromTurnCompletion = Effect.fn(
504 "refreshLocalGitStatusFromTurnCompletion",
505 )(function* (event: Extract<ProviderRuntimeEvent, { type: "turn.completed" }>) {
506 const sessionRuntime = yield* resolveSessionRuntimeForThread(event.threadId);
507 if (Option.isNone(sessionRuntime)) {
508 return;
509 }
510
511 yield* gitStatusBroadcaster.refreshLocalStatus(sessionRuntime.value.cwd).pipe(
512 Effect.catch((error) =>
513 Effect.logWarning("failed to refresh local git status after turn completion", {
514 threadId: event.threadId,
515 turnId: event.turnId ?? null,
516 cwd: sessionRuntime.value.cwd,
517 detail: error.message,
518 }),
519 ),
520 );
521 });
522
501 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(523 const ensurePreTurnBaselineFromDomainTurnStart = Effect.fn(
502 "ensurePreTurnBaselineFromDomainTurnStart",524 "ensurePreTurnBaselineFromDomainTurnStart",
503 )(function* (525 )(function* (
@@ -736,6 +758,7 @@ const make = Effect.gen(function* () { · 232 unmodified lines
736 758
737 if (event.type === "turn.completed") {759 if (event.type === "turn.completed") {
738 const turnId = toTurnId(event.turnId);760 const turnId = toTurnId(event.turnId);
761 yield* refreshLocalGitStatusFromTurnCompletion(event);
739 yield* captureCheckpointFromTurnCompletion(event).pipe(762 yield* captureCheckpointFromTurnCompletion(event).pipe(
740 Effect.catch((error) =>763 Effect.catch((error) =>
741 appendCaptureFailureActivity({764 appendCaptureFailureActivity({
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+77−62
@@ -19,6 +19,7 @@ import { · 18 unmodified lines
19 OrchestrationEventStore,19 OrchestrationEventStore,
20 type OrchestrationEventStoreShape,20 type OrchestrationEventStoreShape,
21} from "../../persistence/Services/OrchestrationEventStore.ts";21} from "../../persistence/Services/OrchestrationEventStore.ts";
22import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
22import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
23import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";24import { OrchestrationProjectionPipelineLive } from "./ProjectionPipeline.ts";
24import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";25import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
@@ -31,10 +32,10 @@ import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts" · 6 unmodified lines
31import { ServerConfig } from "../../config.ts";32import { ServerConfig } from "../../config.ts";
32import * as NodeServices from "@effect/platform-node/NodeServices";33import * as NodeServices from "@effect/platform-node/NodeServices";
33 34
34const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);35const asProjectId = (value: string): ProjectId => ProjectId.make(value);
35const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);36const asMessageId = (value: string): MessageId => MessageId.make(value);
36const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);37const asTurnId = (value: string): TurnId => TurnId.make(value);
37const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);38const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
38 39
39async function createOrchestrationSystem() {40async function createOrchestrationSystem() {
40 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {41 const ServerConfigLayer = ServerConfig.layerTest(process.cwd(), {
@@ -45,6 +46,7 @@ async function createOrchestrationSystem() { · 4 unmodified lines
45 Layer.provide(OrchestrationProjectionPipelineLive),46 Layer.provide(OrchestrationProjectionPipelineLive),
46 Layer.provide(OrchestrationEventStoreLive),47 Layer.provide(OrchestrationEventStoreLive),
47 Layer.provide(OrchestrationCommandReceiptRepositoryLive),48 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
49 Layer.provide(RepositoryIdentityResolverLive),
48 Layer.provide(SqlitePersistenceMemory),50 Layer.provide(SqlitePersistenceMemory),
49 Layer.provideMerge(ServerConfigLayer),51 Layer.provideMerge(ServerConfigLayer),
50 Layer.provideMerge(NodeServices.layer),52 Layer.provideMerge(NodeServices.layer),
@@ -113,7 +115,7 @@ describe("OrchestrationEngine", () => { · 62 unmodified lines
113 ],115 ],
114 threads: [116 threads: [
115 {117 {
116 id: ThreadId.makeUnsafe("thread-bootstrap"),118 id: ThreadId.make("thread-bootstrap"),
117 projectId: asProjectId("project-bootstrap"),119 projectId: asProjectId("project-bootstrap"),
118 title: "Bootstrap Thread",120 title: "Bootstrap Thread",
119 modelSelection: {121 modelSelection: {
@@ -142,10 +144,20 @@ describe("OrchestrationEngine", () => { · 22 unmodified lines
142 Layer.provide(144 Layer.provide(
143 Layer.succeed(ProjectionSnapshotQuery, {145 Layer.succeed(ProjectionSnapshotQuery, {
144 getSnapshot: () => Effect.succeed(projectionSnapshot),146 getSnapshot: () => Effect.succeed(projectionSnapshot),
147 getShellSnapshot: () =>
148 Effect.succeed({
149 snapshotSequence: projectionSnapshot.snapshotSequence,
150 projects: [],
151 threads: [],
152 updatedAt: projectionSnapshot.updatedAt,
153 }),
145 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),154 getCounts: () => Effect.succeed({ projectCount: 1, threadCount: 1 }),
146 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),155 getActiveProjectByWorkspaceRoot: () => Effect.succeed(Option.none()),
156 getProjectShellById: () => Effect.succeed(Option.none()),
147 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),157 getFirstActiveThreadIdByProjectId: () => Effect.succeed(Option.none()),
148 getThreadCheckpointContext: () => Effect.succeed(Option.none()),158 getThreadCheckpointContext: () => Effect.succeed(Option.none()),
159 getThreadShellById: () => Effect.succeed(Option.none()),
160 getThreadDetailById: () => Effect.succeed(Option.none()),
149 }),161 }),
150 ),162 ),
151 Layer.provide(163 Layer.provide(
@@ -181,7 +193,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
181 await system.run(193 await system.run(
182 engine.dispatch({194 engine.dispatch({
183 type: "project.create",195 type: "project.create",
184 commandId: CommandId.makeUnsafe("cmd-project-1-create"),196 commandId: CommandId.make("cmd-project-1-create"),
185 projectId: asProjectId("project-1"),197 projectId: asProjectId("project-1"),
186 title: "Project 1",198 title: "Project 1",
187 workspaceRoot: "/tmp/project-1",199 workspaceRoot: "/tmp/project-1",
@@ -195,8 +207,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
195 await system.run(207 await system.run(
196 engine.dispatch({208 engine.dispatch({
197 type: "thread.create",209 type: "thread.create",
198 commandId: CommandId.makeUnsafe("cmd-thread-1-create"),210 commandId: CommandId.make("cmd-thread-1-create"),
199 threadId: ThreadId.makeUnsafe("thread-1"),211 threadId: ThreadId.make("thread-1"),
200 projectId: asProjectId("project-1"),212 projectId: asProjectId("project-1"),
201 title: "Thread",213 title: "Thread",
202 modelSelection: {214 modelSelection: {
@@ -213,8 +225,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
213 await system.run(225 await system.run(
214 engine.dispatch({226 engine.dispatch({
215 type: "thread.turn.start",227 type: "thread.turn.start",
216 commandId: CommandId.makeUnsafe("cmd-turn-start-1"),228 commandId: CommandId.make("cmd-turn-start-1"),
217 threadId: ThreadId.makeUnsafe("thread-1"),229 threadId: ThreadId.make("thread-1"),
218 message: {230 message: {
219 messageId: asMessageId("msg-1"),231 messageId: asMessageId("msg-1"),
220 role: "user",232 role: "user",
@@ -241,7 +253,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines
241 await system.run(253 await system.run(
242 engine.dispatch({254 engine.dispatch({
243 type: "project.create",255 type: "project.create",
244 commandId: CommandId.makeUnsafe("cmd-project-archive-create"),256 commandId: CommandId.make("cmd-project-archive-create"),
245 projectId: asProjectId("project-archive"),257 projectId: asProjectId("project-archive"),
246 title: "Project Archive",258 title: "Project Archive",
247 workspaceRoot: "/tmp/project-archive",259 workspaceRoot: "/tmp/project-archive",
@@ -255,8 +267,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
255 await system.run(267 await system.run(
256 engine.dispatch({268 engine.dispatch({
257 type: "thread.create",269 type: "thread.create",
258 commandId: CommandId.makeUnsafe("cmd-thread-archive-create"),270 commandId: CommandId.make("cmd-thread-archive-create"),
259 threadId: ThreadId.makeUnsafe("thread-archive"),271 threadId: ThreadId.make("thread-archive"),
260 projectId: asProjectId("project-archive"),272 projectId: asProjectId("project-archive"),
261 title: "Archive me",273 title: "Archive me",
262 modelSelection: {274 modelSelection: {
@@ -274,8 +286,8 @@ describe("OrchestrationEngine", () => { · 11 unmodified lines
274 await system.run(286 await system.run(
275 engine.dispatch({287 engine.dispatch({
276 type: "thread.archive",288 type: "thread.archive",
277 commandId: CommandId.makeUnsafe("cmd-thread-archive"),289 commandId: CommandId.make("cmd-thread-archive"),
278 threadId: ThreadId.makeUnsafe("thread-archive"),290 threadId: ThreadId.make("thread-archive"),
279 }),291 }),
280 );292 );
281 expect(293 expect(
@@ -287,8 +299,8 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines
287 await system.run(299 await system.run(
288 engine.dispatch({300 engine.dispatch({
289 type: "thread.unarchive",301 type: "thread.unarchive",
290 commandId: CommandId.makeUnsafe("cmd-thread-unarchive"),302 commandId: CommandId.make("cmd-thread-unarchive"),
291 threadId: ThreadId.makeUnsafe("thread-archive"),303 threadId: ThreadId.make("thread-archive"),
292 }),304 }),
293 );305 );
294 expect(306 expect(
@@ -308,7 +320,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
308 await system.run(320 await system.run(
309 engine.dispatch({321 engine.dispatch({
310 type: "project.create",322 type: "project.create",
311 commandId: CommandId.makeUnsafe("cmd-project-replay-create"),323 commandId: CommandId.make("cmd-project-replay-create"),
312 projectId: asProjectId("project-replay"),324 projectId: asProjectId("project-replay"),
313 title: "Replay Project",325 title: "Replay Project",
314 workspaceRoot: "/tmp/project-replay",326 workspaceRoot: "/tmp/project-replay",
@@ -322,8 +334,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
322 await system.run(334 await system.run(
323 engine.dispatch({335 engine.dispatch({
324 type: "thread.create",336 type: "thread.create",
325 commandId: CommandId.makeUnsafe("cmd-thread-replay-create"),337 commandId: CommandId.make("cmd-thread-replay-create"),
326 threadId: ThreadId.makeUnsafe("thread-replay"),338 threadId: ThreadId.make("thread-replay"),
327 projectId: asProjectId("project-replay"),339 projectId: asProjectId("project-replay"),
328 title: "replay",340 title: "replay",
329 modelSelection: {341 modelSelection: {
@@ -340,8 +352,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
340 await system.run(352 await system.run(
341 engine.dispatch({353 engine.dispatch({
342 type: "thread.delete",354 type: "thread.delete",
343 commandId: CommandId.makeUnsafe("cmd-thread-replay-delete"),355 commandId: CommandId.make("cmd-thread-replay-delete"),
344 threadId: ThreadId.makeUnsafe("thread-replay"),356 threadId: ThreadId.make("thread-replay"),
345 }),357 }),
346 );358 );
347 359
@@ -366,7 +378,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines
366 await system.run(378 await system.run(
367 engine.dispatch({379 engine.dispatch({
368 type: "project.create",380 type: "project.create",
369 commandId: CommandId.makeUnsafe("cmd-project-stream-create"),381 commandId: CommandId.make("cmd-project-stream-create"),
370 projectId: asProjectId("project-stream"),382 projectId: asProjectId("project-stream"),
371 title: "Stream Project",383 title: "Stream Project",
372 workspaceRoot: "/tmp/project-stream",384 workspaceRoot: "/tmp/project-stream",
@@ -390,8 +402,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines
390 yield* Effect.sleep("10 millis");402 yield* Effect.sleep("10 millis");
391 yield* engine.dispatch({403 yield* engine.dispatch({
392 type: "thread.create",404 type: "thread.create",
393 commandId: CommandId.makeUnsafe("cmd-stream-thread-create"),405 commandId: CommandId.make("cmd-stream-thread-create"),
394 threadId: ThreadId.makeUnsafe("thread-stream"),406 threadId: ThreadId.make("thread-stream"),
395 projectId: asProjectId("project-stream"),407 projectId: asProjectId("project-stream"),
396 title: "domain-stream",408 title: "domain-stream",
397 modelSelection: {409 modelSelection: {
@@ -406,8 +418,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
406 });418 });
407 yield* engine.dispatch({419 yield* engine.dispatch({
408 type: "thread.meta.update",420 type: "thread.meta.update",
409 commandId: CommandId.makeUnsafe("cmd-stream-thread-update"),421 commandId: CommandId.make("cmd-stream-thread-update"),
410 threadId: ThreadId.makeUnsafe("thread-stream"),422 threadId: ThreadId.make("thread-stream"),
411 title: "domain-stream-updated",423 title: "domain-stream-updated",
412 });424 });
413 eventTypes.push((yield* Queue.take(eventQueue)).type);425 eventTypes.push((yield* Queue.take(eventQueue)).type);
@@ -427,7 +439,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
427 await system.run(439 await system.run(
428 engine.dispatch({440 engine.dispatch({
429 type: "project.create",441 type: "project.create",
430 commandId: CommandId.makeUnsafe("cmd-project-ack-create"),442 commandId: CommandId.make("cmd-project-ack-create"),
431 projectId: asProjectId("project-ack"),443 projectId: asProjectId("project-ack"),
432 title: "Ack Project",444 title: "Ack Project",
433 workspaceRoot: "/tmp/project-ack",445 workspaceRoot: "/tmp/project-ack",
@@ -442,8 +454,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
442 await system.run(454 await system.run(
443 engine.dispatch({455 engine.dispatch({
444 type: "thread.create",456 type: "thread.create",
445 commandId: CommandId.makeUnsafe("cmd-thread-ack-create"),457 commandId: CommandId.make("cmd-thread-ack-create"),
446 threadId: ThreadId.makeUnsafe("thread-ack"),458 threadId: ThreadId.make("thread-ack"),
447 projectId: asProjectId("project-ack"),459 projectId: asProjectId("project-ack"),
448 title: "Ack Thread",460 title: "Ack Thread",
449 modelSelection: {461 modelSelection: {
@@ -479,8 +491,8 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
479 system.run(491 system.run(
480 engine.dispatch({492 engine.dispatch({
481 type: "thread.create",493 type: "thread.create",
482 commandId: CommandId.makeUnsafe("cmd-thread-missing-project"),494 commandId: CommandId.make("cmd-thread-missing-project"),
483 threadId: ThreadId.makeUnsafe("thread-missing-project"),495 threadId: ThreadId.make("thread-missing-project"),
484 projectId: asProjectId("project-missing"),496 projectId: asProjectId("project-missing"),
485 title: "Missing Project Thread",497 title: "Missing Project Thread",
486 modelSelection: {498 modelSelection: {
@@ -516,7 +528,7 @@ describe("OrchestrationEngine", () => { · 29 unmodified lines
516 await system.run(528 await system.run(
517 engine.dispatch({529 engine.dispatch({
518 type: "project.create",530 type: "project.create",
519 commandId: CommandId.makeUnsafe("cmd-project-turn-diff-create"),531 commandId: CommandId.make("cmd-project-turn-diff-create"),
520 projectId: asProjectId("project-turn-diff"),532 projectId: asProjectId("project-turn-diff"),
521 title: "Turn Diff Project",533 title: "Turn Diff Project",
522 workspaceRoot: "/tmp/project-turn-diff",534 workspaceRoot: "/tmp/project-turn-diff",
@@ -530,8 +542,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
530 await system.run(542 await system.run(
531 engine.dispatch({543 engine.dispatch({
532 type: "thread.create",544 type: "thread.create",
533 commandId: CommandId.makeUnsafe("cmd-thread-turn-diff-create"),545 commandId: CommandId.make("cmd-thread-turn-diff-create"),
534 threadId: ThreadId.makeUnsafe("thread-turn-diff"),546 threadId: ThreadId.make("thread-turn-diff"),
535 projectId: asProjectId("project-turn-diff"),547 projectId: asProjectId("project-turn-diff"),
536 title: "Turn diff thread",548 title: "Turn diff thread",
537 modelSelection: {549 modelSelection: {
@@ -548,8 +560,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
548 await system.run(560 await system.run(
549 engine.dispatch({561 engine.dispatch({
550 type: "thread.turn.diff.complete",562 type: "thread.turn.diff.complete",
551 commandId: CommandId.makeUnsafe("cmd-turn-diff-complete"),563 commandId: CommandId.make("cmd-turn-diff-complete"),
552 threadId: ThreadId.makeUnsafe("thread-turn-diff"),564 threadId: ThreadId.make("thread-turn-diff"),
553 turnId: asTurnId("turn-1"),565 turnId: asTurnId("turn-1"),
554 completedAt: createdAt,566 completedAt: createdAt,
555 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),567 checkpointRef: asCheckpointRef("refs/t3/checkpoints/thread-turn-diff/turn/1"),
@@ -588,7 +600,7 @@ describe("OrchestrationEngine", () => { · 32 unmodified lines
588 600
589 const flakyStore: OrchestrationEventStoreShape = {601 const flakyStore: OrchestrationEventStoreShape = {
590 append(event) {602 append(event) {
591 if (shouldFailFirstAppend && event.commandId === CommandId.makeUnsafe("cmd-flaky-1")) {603 if (shouldFailFirstAppend && event.commandId === CommandId.make("cmd-flaky-1")) {
592 shouldFailFirstAppend = false;604 shouldFailFirstAppend = false;
593 return Effect.fail(605 return Effect.fail(
594 new PersistenceSqlError({606 new PersistenceSqlError({
@@ -623,6 +635,7 @@ describe("OrchestrationEngine", () => { · 28 unmodified lines
623 Layer.provide(OrchestrationProjectionPipelineLive),635 Layer.provide(OrchestrationProjectionPipelineLive),
624 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),636 Layer.provide(Layer.succeed(OrchestrationEventStore, flakyStore)),
625 Layer.provide(OrchestrationCommandReceiptRepositoryLive),637 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
638 Layer.provide(RepositoryIdentityResolverLive),
626 Layer.provide(SqlitePersistenceMemory),639 Layer.provide(SqlitePersistenceMemory),
627 Layer.provideMerge(ServerConfigLayer),640 Layer.provideMerge(ServerConfigLayer),
628 Layer.provideMerge(NodeServices.layer),641 Layer.provideMerge(NodeServices.layer),
@@ -634,7 +647,7 @@ describe("OrchestrationEngine", () => { · 5 unmodified lines
634 await runtime.runPromise(647 await runtime.runPromise(
635 engine.dispatch({648 engine.dispatch({
636 type: "project.create",649 type: "project.create",
637 commandId: CommandId.makeUnsafe("cmd-project-flaky-create"),650 commandId: CommandId.make("cmd-project-flaky-create"),
638 projectId: asProjectId("project-flaky"),651 projectId: asProjectId("project-flaky"),
639 title: "Flaky Project",652 title: "Flaky Project",
640 workspaceRoot: "/tmp/project-flaky",653 workspaceRoot: "/tmp/project-flaky",
@@ -650,8 +663,8 @@ describe("OrchestrationEngine", () => { · 9 unmodified lines
650 runtime.runPromise(663 runtime.runPromise(
651 engine.dispatch({664 engine.dispatch({
652 type: "thread.create",665 type: "thread.create",
653 commandId: CommandId.makeUnsafe("cmd-flaky-1"),666 commandId: CommandId.make("cmd-flaky-1"),
654 threadId: ThreadId.makeUnsafe("thread-flaky-fail"),667 threadId: ThreadId.make("thread-flaky-fail"),
655 projectId: asProjectId("project-flaky"),668 projectId: asProjectId("project-flaky"),
656 title: "flaky-fail",669 title: "flaky-fail",
657 modelSelection: {670 modelSelection: {
@@ -670,8 +683,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
670 const result = await runtime.runPromise(683 const result = await runtime.runPromise(
671 engine.dispatch({684 engine.dispatch({
672 type: "thread.create",685 type: "thread.create",
673 commandId: CommandId.makeUnsafe("cmd-flaky-2"),686 commandId: CommandId.make("cmd-flaky-2"),
674 threadId: ThreadId.makeUnsafe("thread-flaky-ok"),687 threadId: ThreadId.make("thread-flaky-ok"),
675 projectId: asProjectId("project-flaky"),688 projectId: asProjectId("project-flaky"),
676 title: "flaky-ok",689 title: "flaky-ok",
677 modelSelection: {690 modelSelection: {
@@ -698,7 +711,7 @@ describe("OrchestrationEngine", () => { · 20 unmodified lines
698 projectEvent: (event) => {711 projectEvent: (event) => {
699 if (712 if (
700 shouldFailRequestedProjection &&713 shouldFailRequestedProjection &&
701 event.commandId === CommandId.makeUnsafe("cmd-turn-start-atomic") &&714 event.commandId === CommandId.make("cmd-turn-start-atomic") &&
702 event.type === "thread.turn-start-requested"715 event.type === "thread.turn-start-requested"
703 ) {716 ) {
704 shouldFailRequestedProjection = false;717 shouldFailRequestedProjection = false;
@@ -719,6 +732,7 @@ describe("OrchestrationEngine", () => { · 14 unmodified lines
719 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),732 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
720 Layer.provide(OrchestrationEventStoreLive),733 Layer.provide(OrchestrationEventStoreLive),
721 Layer.provide(OrchestrationCommandReceiptRepositoryLive),734 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
735 Layer.provide(RepositoryIdentityResolverLive),
722 Layer.provide(SqlitePersistenceMemory),736 Layer.provide(SqlitePersistenceMemory),
723 ),737 ),
724 );738 );
@@ -728,7 +742,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines
728 await runtime.runPromise(742 await runtime.runPromise(
729 engine.dispatch({743 engine.dispatch({
730 type: "project.create",744 type: "project.create",
731 commandId: CommandId.makeUnsafe("cmd-project-atomic-create"),745 commandId: CommandId.make("cmd-project-atomic-create"),
732 projectId: asProjectId("project-atomic"),746 projectId: asProjectId("project-atomic"),
733 title: "Atomic Project",747 title: "Atomic Project",
734 workspaceRoot: "/tmp/project-atomic",748 workspaceRoot: "/tmp/project-atomic",
@@ -742,8 +756,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
742 await runtime.runPromise(756 await runtime.runPromise(
743 engine.dispatch({757 engine.dispatch({
744 type: "thread.create",758 type: "thread.create",
745 commandId: CommandId.makeUnsafe("cmd-thread-atomic-create"),759 commandId: CommandId.make("cmd-thread-atomic-create"),
746 threadId: ThreadId.makeUnsafe("thread-atomic"),760 threadId: ThreadId.make("thread-atomic"),
747 projectId: asProjectId("project-atomic"),761 projectId: asProjectId("project-atomic"),
748 title: "atomic",762 title: "atomic",
749 modelSelection: {763 modelSelection: {
@@ -760,8 +774,8 @@ describe("OrchestrationEngine", () => { · 10 unmodified lines
760 774
761 const turnStartCommand = {775 const turnStartCommand = {
762 type: "thread.turn.start" as const,776 type: "thread.turn.start" as const,
763 commandId: CommandId.makeUnsafe("cmd-turn-start-atomic"),777 commandId: CommandId.make("cmd-turn-start-atomic"),
764 threadId: ThreadId.makeUnsafe("thread-atomic"),778 threadId: ThreadId.make("thread-atomic"),
765 message: {779 message: {
766 messageId: asMessageId("msg-atomic-1"),780 messageId: asMessageId("msg-atomic-1"),
767 role: "user" as const,781 role: "user" as const,
@@ -841,7 +855,7 @@ describe("OrchestrationEngine", () => { · 73 unmodified lines
841 projectEvent: (event) => {855 projectEvent: (event) => {
842 if (856 if (
843 shouldFailProjection &&857 shouldFailProjection &&
844 event.commandId === CommandId.makeUnsafe("cmd-thread-meta-sync-fail")858 event.commandId === CommandId.make("cmd-thread-meta-sync-fail")
845 ) {859 ) {
846 shouldFailProjection = false;860 shouldFailProjection = false;
847 return Effect.fail(861 return Effect.fail(
@@ -861,6 +875,7 @@ describe("OrchestrationEngine", () => { · 13 unmodified lines
861 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),875 Layer.provide(Layer.succeed(OrchestrationProjectionPipeline, flakyProjectionPipeline)),
862 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),876 Layer.provide(Layer.succeed(OrchestrationEventStore, nonTransactionalStore)),
863 Layer.provide(OrchestrationCommandReceiptRepositoryLive),877 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
878 Layer.provide(RepositoryIdentityResolverLive),
864 Layer.provide(SqlitePersistenceMemory),879 Layer.provide(SqlitePersistenceMemory),
865 ),880 ),
866 );881 );
@@ -870,7 +885,7 @@ describe("OrchestrationEngine", () => { · 3 unmodified lines
870 await runtime.runPromise(885 await runtime.runPromise(
871 engine.dispatch({886 engine.dispatch({
872 type: "project.create",887 type: "project.create",
873 commandId: CommandId.makeUnsafe("cmd-project-sync-create"),888 commandId: CommandId.make("cmd-project-sync-create"),
874 projectId: asProjectId("project-sync"),889 projectId: asProjectId("project-sync"),
875 title: "Sync Project",890 title: "Sync Project",
876 workspaceRoot: "/tmp/project-sync",891 workspaceRoot: "/tmp/project-sync",
@@ -884,8 +899,8 @@ describe("OrchestrationEngine", () => { · 7 unmodified lines
884 await runtime.runPromise(899 await runtime.runPromise(
885 engine.dispatch({900 engine.dispatch({
886 type: "thread.create",901 type: "thread.create",
887 commandId: CommandId.makeUnsafe("cmd-thread-sync-create"),902 commandId: CommandId.make("cmd-thread-sync-create"),
888 threadId: ThreadId.makeUnsafe("thread-sync"),903 threadId: ThreadId.make("thread-sync"),
889 projectId: asProjectId("project-sync"),904 projectId: asProjectId("project-sync"),
890 title: "sync-before",905 title: "sync-before",
891 modelSelection: {906 modelSelection: {
@@ -904,8 +919,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
904 runtime.runPromise(919 runtime.runPromise(
905 engine.dispatch({920 engine.dispatch({
906 type: "thread.meta.update",921 type: "thread.meta.update",
907 commandId: CommandId.makeUnsafe("cmd-thread-meta-sync-fail"),922 commandId: CommandId.make("cmd-thread-meta-sync-fail"),
908 threadId: ThreadId.makeUnsafe("thread-sync"),923 threadId: ThreadId.make("thread-sync"),
909 title: "sync-after-failed-projection",924 title: "sync-after-failed-projection",
910 }),925 }),
911 ),926 ),
@@ -929,8 +944,8 @@ describe("OrchestrationEngine", () => { · 17 unmodified lines
929 system.run(944 system.run(
930 engine.dispatch({945 engine.dispatch({
931 type: "thread.turn.start",946 type: "thread.turn.start",
932 commandId: CommandId.makeUnsafe("cmd-invariant-missing-thread"),947 commandId: CommandId.make("cmd-invariant-missing-thread"),
933 threadId: ThreadId.makeUnsafe("thread-missing"),948 threadId: ThreadId.make("thread-missing"),
934 message: {949 message: {
935 messageId: asMessageId("msg-missing"),950 messageId: asMessageId("msg-missing"),
936 role: "user",951 role: "user",
@@ -955,7 +970,7 @@ describe("OrchestrationEngine", () => { · 18 unmodified lines
955 await system.run(970 await system.run(
956 engine.dispatch({971 engine.dispatch({
957 type: "project.create",972 type: "project.create",
958 commandId: CommandId.makeUnsafe("cmd-project-duplicate-create"),973 commandId: CommandId.make("cmd-project-duplicate-create"),
959 projectId: asProjectId("project-duplicate"),974 projectId: asProjectId("project-duplicate"),
960 title: "Duplicate Project",975 title: "Duplicate Project",
961 workspaceRoot: "/tmp/project-duplicate",976 workspaceRoot: "/tmp/project-duplicate",
@@ -970,8 +985,8 @@ describe("OrchestrationEngine", () => { · 8 unmodified lines
970 await system.run(985 await system.run(
971 engine.dispatch({986 engine.dispatch({
972 type: "thread.create",987 type: "thread.create",
973 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-1"),988 commandId: CommandId.make("cmd-thread-duplicate-1"),
974 threadId: ThreadId.makeUnsafe("thread-duplicate"),989 threadId: ThreadId.make("thread-duplicate"),
975 projectId: asProjectId("project-duplicate"),990 projectId: asProjectId("project-duplicate"),
976 title: "duplicate",991 title: "duplicate",
977 modelSelection: {992 modelSelection: {
@@ -990,8 +1005,8 @@ describe("OrchestrationEngine", () => { · 12 unmodified lines
990 system.run(1005 system.run(
991 engine.dispatch({1006 engine.dispatch({
992 type: "thread.create",1007 type: "thread.create",
993 commandId: CommandId.makeUnsafe("cmd-thread-duplicate-2"),1008 commandId: CommandId.make("cmd-thread-duplicate-2"),
994 threadId: ThreadId.makeUnsafe("thread-duplicate"),1009 threadId: ThreadId.make("thread-duplicate"),
995 projectId: asProjectId("project-duplicate"),1010 projectId: asProjectId("project-duplicate"),
996 title: "duplicate",1011 title: "duplicate",
997 modelSelection: {1012 modelSelection: {
apps/server/src/orchestration/Layers/ProjectionPipeline.test.ts+627−302
@@ -20,6 +20,7 @@ import { · 19 unmodified lines
20 SqlitePersistenceMemory,20 SqlitePersistenceMemory,
21} from "../../persistence/Layers/Sqlite.ts";21} from "../../persistence/Layers/Sqlite.ts";
22import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";22import { OrchestrationEventStore } from "../../persistence/Services/OrchestrationEventStore.ts";
23import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
23import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";24import { OrchestrationEngineLive } from "./OrchestrationEngine.ts";
24import {25import {
25 ORCHESTRATION_PROJECTOR_NAMES,26 ORCHESTRATION_PROJECTOR_NAMES,
@@ -57,16 +58,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 31 unmodified lines
57 58
58 yield* eventStore.append({59 yield* eventStore.append({
59 type: "project.created",60 type: "project.created",
60 eventId: EventId.makeUnsafe("evt-1"),61 eventId: EventId.make("evt-1"),
61 aggregateKind: "project",62 aggregateKind: "project",
62 aggregateId: ProjectId.makeUnsafe("project-1"),63 aggregateId: ProjectId.make("project-1"),
63 occurredAt: now,64 occurredAt: now,
64 commandId: CommandId.makeUnsafe("cmd-1"),65 commandId: CommandId.make("cmd-1"),
65 causationEventId: null,66 causationEventId: null,
66 correlationId: CommandId.makeUnsafe("cmd-1"),67 correlationId: CommandId.make("cmd-1"),
67 metadata: {},68 metadata: {},
68 payload: {69 payload: {
69 projectId: ProjectId.makeUnsafe("project-1"),70 projectId: ProjectId.make("project-1"),
70 title: "Project 1",71 title: "Project 1",
71 workspaceRoot: "/tmp/project-1",72 workspaceRoot: "/tmp/project-1",
72 defaultModelSelection: null,73 defaultModelSelection: null,
@@ -78,17 +79,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
78 79
79 yield* eventStore.append({80 yield* eventStore.append({
80 type: "thread.created",81 type: "thread.created",
81 eventId: EventId.makeUnsafe("evt-2"),82 eventId: EventId.make("evt-2"),
82 aggregateKind: "thread",83 aggregateKind: "thread",
83 aggregateId: ThreadId.makeUnsafe("thread-1"),84 aggregateId: ThreadId.make("thread-1"),
84 occurredAt: now,85 occurredAt: now,
85 commandId: CommandId.makeUnsafe("cmd-2"),86 commandId: CommandId.make("cmd-2"),
86 causationEventId: null,87 causationEventId: null,
87 correlationId: CommandId.makeUnsafe("cmd-2"),88 correlationId: CommandId.make("cmd-2"),
88 metadata: {},89 metadata: {},
89 payload: {90 payload: {
90 threadId: ThreadId.makeUnsafe("thread-1"),91 threadId: ThreadId.make("thread-1"),
91 projectId: ProjectId.makeUnsafe("project-1"),92 projectId: ProjectId.make("project-1"),
92 title: "Thread 1",93 title: "Thread 1",
93 modelSelection: {94 modelSelection: {
94 provider: "codex",95 provider: "codex",
@@ -104,17 +105,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
104 105
105 yield* eventStore.append({106 yield* eventStore.append({
106 type: "thread.message-sent",107 type: "thread.message-sent",
107 eventId: EventId.makeUnsafe("evt-3"),108 eventId: EventId.make("evt-3"),
108 aggregateKind: "thread",109 aggregateKind: "thread",
109 aggregateId: ThreadId.makeUnsafe("thread-1"),110 aggregateId: ThreadId.make("thread-1"),
110 occurredAt: now,111 occurredAt: now,
111 commandId: CommandId.makeUnsafe("cmd-3"),112 commandId: CommandId.make("cmd-3"),
112 causationEventId: null,113 causationEventId: null,
113 correlationId: CommandId.makeUnsafe("cmd-3"),114 correlationId: CommandId.make("cmd-3"),
114 metadata: {},115 metadata: {},
115 payload: {116 payload: {
116 threadId: ThreadId.makeUnsafe("thread-1"),117 threadId: ThreadId.make("thread-1"),
117 messageId: MessageId.makeUnsafe("message-1"),118 messageId: MessageId.make("message-1"),
118 role: "assistant",119 role: "assistant",
119 text: "hello",120 text: "hello",
120 turnId: null,121 turnId: null,
@@ -182,17 +183,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-base-")))( · 61 unmodified lines
182 183
183 yield* eventStore.append({184 yield* eventStore.append({
184 type: "thread.message-sent",185 type: "thread.message-sent",
185 eventId: EventId.makeUnsafe("evt-attachments"),186 eventId: EventId.make("evt-attachments"),
186 aggregateKind: "thread",187 aggregateKind: "thread",
187 aggregateId: ThreadId.makeUnsafe("thread-attachments"),188 aggregateId: ThreadId.make("thread-attachments"),
188 occurredAt: now,189 occurredAt: now,
189 commandId: CommandId.makeUnsafe("cmd-attachments"),190 commandId: CommandId.make("cmd-attachments"),
190 causationEventId: null,191 causationEventId: null,
191 correlationId: CommandId.makeUnsafe("cmd-attachments"),192 correlationId: CommandId.make("cmd-attachments"),
192 metadata: {},193 metadata: {},
193 payload: {194 payload: {
194 threadId: ThreadId.makeUnsafe("thread-attachments"),195 threadId: ThreadId.make("thread-attachments"),
195 messageId: MessageId.makeUnsafe("message-attachments"),196 messageId: MessageId.make("message-attachments"),
196 role: "user",197 role: "user",
197 text: "Inspect this",198 text: "Inspect this",
198 attachments: [199 attachments: [
@@ -248,17 +249,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 49 unmodified lines
248 249
249 yield* eventStore.append({250 yield* eventStore.append({
250 type: "thread.message-sent",251 type: "thread.message-sent",
251 eventId: EventId.makeUnsafe("evt-attachments-safe"),252 eventId: EventId.make("evt-attachments-safe"),
252 aggregateKind: "thread",253 aggregateKind: "thread",
253 aggregateId: ThreadId.makeUnsafe("thread-attachments-safe"),254 aggregateId: ThreadId.make("thread-attachments-safe"),
254 occurredAt: now,255 occurredAt: now,
255 commandId: CommandId.makeUnsafe("cmd-attachments-safe"),256 commandId: CommandId.make("cmd-attachments-safe"),
256 causationEventId: null,257 causationEventId: null,
257 correlationId: CommandId.makeUnsafe("cmd-attachments-safe"),258 correlationId: CommandId.make("cmd-attachments-safe"),
258 metadata: {},259 metadata: {},
259 payload: {260 payload: {
260 threadId: ThreadId.makeUnsafe("thread-attachments-safe"),261 threadId: ThreadId.make("thread-attachments-safe"),
261 messageId: MessageId.makeUnsafe("message-attachments-safe"),262 messageId: MessageId.make("message-attachments-safe"),
262 role: "user",263 role: "user",
263 text: "Inspect this",264 text: "Inspect this",
264 attachments: [265 attachments: [
@@ -329,16 +330,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 64 unmodified lines
329 330
330 yield* eventStore.append({331 yield* eventStore.append({
331 type: "project.created",332 type: "project.created",
332 eventId: EventId.makeUnsafe("evt-clear-attachments-1"),333 eventId: EventId.make("evt-clear-attachments-1"),
333 aggregateKind: "project",334 aggregateKind: "project",
334 aggregateId: ProjectId.makeUnsafe("project-clear-attachments"),335 aggregateId: ProjectId.make("project-clear-attachments"),
335 occurredAt: now,336 occurredAt: now,
336 commandId: CommandId.makeUnsafe("cmd-clear-attachments-1"),337 commandId: CommandId.make("cmd-clear-attachments-1"),
337 causationEventId: null,338 causationEventId: null,
338 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-1"),339 correlationId: CommandId.make("cmd-clear-attachments-1"),
339 metadata: {},340 metadata: {},
340 payload: {341 payload: {
341 projectId: ProjectId.makeUnsafe("project-clear-attachments"),342 projectId: ProjectId.make("project-clear-attachments"),
342 title: "Project Clear Attachments",343 title: "Project Clear Attachments",
343 workspaceRoot: "/tmp/project-clear-attachments",344 workspaceRoot: "/tmp/project-clear-attachments",
344 defaultModelSelection: null,345 defaultModelSelection: null,
@@ -350,17 +351,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
350 351
351 yield* eventStore.append({352 yield* eventStore.append({
352 type: "thread.created",353 type: "thread.created",
353 eventId: EventId.makeUnsafe("evt-clear-attachments-2"),354 eventId: EventId.make("evt-clear-attachments-2"),
354 aggregateKind: "thread",355 aggregateKind: "thread",
355 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),356 aggregateId: ThreadId.make("thread-clear-attachments"),
356 occurredAt: now,357 occurredAt: now,
357 commandId: CommandId.makeUnsafe("cmd-clear-attachments-2"),358 commandId: CommandId.make("cmd-clear-attachments-2"),
358 causationEventId: null,359 causationEventId: null,
359 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-2"),360 correlationId: CommandId.make("cmd-clear-attachments-2"),
360 metadata: {},361 metadata: {},
361 payload: {362 payload: {
362 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),363 threadId: ThreadId.make("thread-clear-attachments"),
363 projectId: ProjectId.makeUnsafe("project-clear-attachments"),364 projectId: ProjectId.make("project-clear-attachments"),
364 title: "Thread Clear Attachments",365 title: "Thread Clear Attachments",
365 modelSelection: {366 modelSelection: {
366 provider: "codex",367 provider: "codex",
@@ -376,17 +377,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
376 377
377 yield* eventStore.append({378 yield* eventStore.append({
378 type: "thread.message-sent",379 type: "thread.message-sent",
379 eventId: EventId.makeUnsafe("evt-clear-attachments-3"),380 eventId: EventId.make("evt-clear-attachments-3"),
380 aggregateKind: "thread",381 aggregateKind: "thread",
381 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),382 aggregateId: ThreadId.make("thread-clear-attachments"),
382 occurredAt: now,383 occurredAt: now,
383 commandId: CommandId.makeUnsafe("cmd-clear-attachments-3"),384 commandId: CommandId.make("cmd-clear-attachments-3"),
384 causationEventId: null,385 causationEventId: null,
385 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-3"),386 correlationId: CommandId.make("cmd-clear-attachments-3"),
386 metadata: {},387 metadata: {},
387 payload: {388 payload: {
388 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),389 threadId: ThreadId.make("thread-clear-attachments"),
389 messageId: MessageId.makeUnsafe("message-clear-attachments"),390 messageId: MessageId.make("message-clear-attachments"),
390 role: "user",391 role: "user",
391 text: "Has attachments",392 text: "Has attachments",
392 attachments: [393 attachments: [
@@ -407,17 +408,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 14 unmodified lines
407 408
408 yield* eventStore.append({409 yield* eventStore.append({
409 type: "thread.message-sent",410 type: "thread.message-sent",
410 eventId: EventId.makeUnsafe("evt-clear-attachments-4"),411 eventId: EventId.make("evt-clear-attachments-4"),
411 aggregateKind: "thread",412 aggregateKind: "thread",
412 aggregateId: ThreadId.makeUnsafe("thread-clear-attachments"),413 aggregateId: ThreadId.make("thread-clear-attachments"),
413 occurredAt: later,414 occurredAt: later,
414 commandId: CommandId.makeUnsafe("cmd-clear-attachments-4"),415 commandId: CommandId.make("cmd-clear-attachments-4"),
415 causationEventId: null,416 causationEventId: null,
416 correlationId: CommandId.makeUnsafe("cmd-clear-attachments-4"),417 correlationId: CommandId.make("cmd-clear-attachments-4"),
417 metadata: {},418 metadata: {},
418 payload: {419 payload: {
419 threadId: ThreadId.makeUnsafe("thread-clear-attachments"),420 threadId: ThreadId.make("thread-clear-attachments"),
420 messageId: MessageId.makeUnsafe("message-clear-attachments"),421 messageId: MessageId.make("message-clear-attachments"),
421 role: "user",422 role: "user",
422 text: "",423 text: "",
423 attachments: [],424 attachments: [],
@@ -457,16 +458,16 @@ it.layer( · 33 unmodified lines
457 458
458 yield* eventStore.append({459 yield* eventStore.append({
459 type: "project.created",460 type: "project.created",
460 eventId: EventId.makeUnsafe("evt-overwrite-1"),461 eventId: EventId.make("evt-overwrite-1"),
461 aggregateKind: "project",462 aggregateKind: "project",
462 aggregateId: ProjectId.makeUnsafe("project-overwrite"),463 aggregateId: ProjectId.make("project-overwrite"),
463 occurredAt: now,464 occurredAt: now,
464 commandId: CommandId.makeUnsafe("cmd-overwrite-1"),465 commandId: CommandId.make("cmd-overwrite-1"),
465 causationEventId: null,466 causationEventId: null,
466 correlationId: CommandId.makeUnsafe("cmd-overwrite-1"),467 correlationId: CommandId.make("cmd-overwrite-1"),
467 metadata: {},468 metadata: {},
468 payload: {469 payload: {
469 projectId: ProjectId.makeUnsafe("project-overwrite"),470 projectId: ProjectId.make("project-overwrite"),
470 title: "Project Overwrite",471 title: "Project Overwrite",
471 workspaceRoot: "/tmp/project-overwrite",472 workspaceRoot: "/tmp/project-overwrite",
472 defaultModelSelection: null,473 defaultModelSelection: null,
@@ -478,17 +479,17 @@ it.layer( · 5 unmodified lines
478 479
479 yield* eventStore.append({480 yield* eventStore.append({
480 type: "thread.created",481 type: "thread.created",
481 eventId: EventId.makeUnsafe("evt-overwrite-2"),482 eventId: EventId.make("evt-overwrite-2"),
482 aggregateKind: "thread",483 aggregateKind: "thread",
483 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),484 aggregateId: ThreadId.make("thread-overwrite"),
484 occurredAt: now,485 occurredAt: now,
485 commandId: CommandId.makeUnsafe("cmd-overwrite-2"),486 commandId: CommandId.make("cmd-overwrite-2"),
486 causationEventId: null,487 causationEventId: null,
487 correlationId: CommandId.makeUnsafe("cmd-overwrite-2"),488 correlationId: CommandId.make("cmd-overwrite-2"),
488 metadata: {},489 metadata: {},
489 payload: {490 payload: {
490 threadId: ThreadId.makeUnsafe("thread-overwrite"),491 threadId: ThreadId.make("thread-overwrite"),
491 projectId: ProjectId.makeUnsafe("project-overwrite"),492 projectId: ProjectId.make("project-overwrite"),
492 title: "Thread Overwrite",493 title: "Thread Overwrite",
493 modelSelection: {494 modelSelection: {
494 provider: "codex",495 provider: "codex",
@@ -504,17 +505,17 @@ it.layer( · 9 unmodified lines
504 505
505 yield* eventStore.append({506 yield* eventStore.append({
506 type: "thread.message-sent",507 type: "thread.message-sent",
507 eventId: EventId.makeUnsafe("evt-overwrite-3"),508 eventId: EventId.make("evt-overwrite-3"),
508 aggregateKind: "thread",509 aggregateKind: "thread",
509 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),510 aggregateId: ThreadId.make("thread-overwrite"),
510 occurredAt: now,511 occurredAt: now,
511 commandId: CommandId.makeUnsafe("cmd-overwrite-3"),512 commandId: CommandId.make("cmd-overwrite-3"),
512 causationEventId: null,513 causationEventId: null,
513 correlationId: CommandId.makeUnsafe("cmd-overwrite-3"),514 correlationId: CommandId.make("cmd-overwrite-3"),
514 metadata: {},515 metadata: {},
515 payload: {516 payload: {
516 threadId: ThreadId.makeUnsafe("thread-overwrite"),517 threadId: ThreadId.make("thread-overwrite"),
517 messageId: MessageId.makeUnsafe("message-overwrite"),518 messageId: MessageId.make("message-overwrite"),
518 role: "user",519 role: "user",
519 text: "first image",520 text: "first image",
520 attachments: [521 attachments: [
@@ -535,17 +536,17 @@ it.layer( · 14 unmodified lines
535 536
536 yield* eventStore.append({537 yield* eventStore.append({
537 type: "thread.message-sent",538 type: "thread.message-sent",
538 eventId: EventId.makeUnsafe("evt-overwrite-4"),539 eventId: EventId.make("evt-overwrite-4"),
539 aggregateKind: "thread",540 aggregateKind: "thread",
540 aggregateId: ThreadId.makeUnsafe("thread-overwrite"),541 aggregateId: ThreadId.make("thread-overwrite"),
541 occurredAt: later,542 occurredAt: later,
542 commandId: CommandId.makeUnsafe("cmd-overwrite-4"),543 commandId: CommandId.make("cmd-overwrite-4"),
543 causationEventId: null,544 causationEventId: null,
544 correlationId: CommandId.makeUnsafe("cmd-overwrite-4"),545 correlationId: CommandId.make("cmd-overwrite-4"),
545 metadata: {},546 metadata: {},
546 payload: {547 payload: {
547 threadId: ThreadId.makeUnsafe("thread-overwrite"),548 threadId: ThreadId.make("thread-overwrite"),
548 messageId: MessageId.makeUnsafe("message-overwrite"),549 messageId: MessageId.make("message-overwrite"),
549 role: "user",550 role: "user",
550 text: "",551 text: "",
551 attachments: [552 attachments: [
@@ -605,16 +606,16 @@ it.layer( · 53 unmodified lines
605 606
606 yield* appendAndProject({607 yield* appendAndProject({
607 type: "project.created",608 type: "project.created",
608 eventId: EventId.makeUnsafe("evt-rollback-1"),609 eventId: EventId.make("evt-rollback-1"),
609 aggregateKind: "project",610 aggregateKind: "project",
610 aggregateId: ProjectId.makeUnsafe("project-rollback"),611 aggregateId: ProjectId.make("project-rollback"),
611 occurredAt: now,612 occurredAt: now,
612 commandId: CommandId.makeUnsafe("cmd-rollback-1"),613 commandId: CommandId.make("cmd-rollback-1"),
613 causationEventId: null,614 causationEventId: null,
614 correlationId: CorrelationId.makeUnsafe("cmd-rollback-1"),615 correlationId: CorrelationId.make("cmd-rollback-1"),
615 metadata: {},616 metadata: {},
616 payload: {617 payload: {
617 projectId: ProjectId.makeUnsafe("project-rollback"),618 projectId: ProjectId.make("project-rollback"),
618 title: "Project Rollback",619 title: "Project Rollback",
619 workspaceRoot: "/tmp/project-rollback",620 workspaceRoot: "/tmp/project-rollback",
620 defaultModelSelection: null,621 defaultModelSelection: null,
@@ -626,17 +627,17 @@ it.layer( · 5 unmodified lines
626 627
627 yield* appendAndProject({628 yield* appendAndProject({
628 type: "thread.created",629 type: "thread.created",
629 eventId: EventId.makeUnsafe("evt-rollback-2"),630 eventId: EventId.make("evt-rollback-2"),
630 aggregateKind: "thread",631 aggregateKind: "thread",
631 aggregateId: ThreadId.makeUnsafe("thread-rollback"),632 aggregateId: ThreadId.make("thread-rollback"),
632 occurredAt: now,633 occurredAt: now,
633 commandId: CommandId.makeUnsafe("cmd-rollback-2"),634 commandId: CommandId.make("cmd-rollback-2"),
634 causationEventId: null,635 causationEventId: null,
635 correlationId: CorrelationId.makeUnsafe("cmd-rollback-2"),636 correlationId: CorrelationId.make("cmd-rollback-2"),
636 metadata: {},637 metadata: {},
637 payload: {638 payload: {
638 threadId: ThreadId.makeUnsafe("thread-rollback"),639 threadId: ThreadId.make("thread-rollback"),
639 projectId: ProjectId.makeUnsafe("project-rollback"),640 projectId: ProjectId.make("project-rollback"),
640 title: "Thread Rollback",641 title: "Thread Rollback",
641 modelSelection: {642 modelSelection: {
642 provider: "codex",643 provider: "codex",
@@ -662,17 +663,17 @@ it.layer( · 19 unmodified lines
662 const result = yield* Effect.result(663 const result = yield* Effect.result(
663 appendAndProject({664 appendAndProject({
664 type: "thread.message-sent",665 type: "thread.message-sent",
665 eventId: EventId.makeUnsafe("evt-rollback-3"),666 eventId: EventId.make("evt-rollback-3"),
666 aggregateKind: "thread",667 aggregateKind: "thread",
667 aggregateId: ThreadId.makeUnsafe("thread-rollback"),668 aggregateId: ThreadId.make("thread-rollback"),
668 occurredAt: now,669 occurredAt: now,
669 commandId: CommandId.makeUnsafe("cmd-rollback-3"),670 commandId: CommandId.make("cmd-rollback-3"),
670 causationEventId: null,671 causationEventId: null,
671 correlationId: CorrelationId.makeUnsafe("cmd-rollback-3"),672 correlationId: CorrelationId.make("cmd-rollback-3"),
672 metadata: {},673 metadata: {},
673 payload: {674 payload: {
674 threadId: ThreadId.makeUnsafe("thread-rollback"),675 threadId: ThreadId.make("thread-rollback"),
675 messageId: MessageId.makeUnsafe("message-rollback"),676 messageId: MessageId.make("message-rollback"),
676 role: "user",677 role: "user",
677 text: "Rollback me",678 text: "Rollback me",
678 attachments: [679 attachments: [
@@ -721,7 +722,7 @@ it.layer( · 42 unmodified lines
721 const eventStore = yield* OrchestrationEventStore;722 const eventStore = yield* OrchestrationEventStore;
722 const { attachmentsDir } = yield* ServerConfig;723 const { attachmentsDir } = yield* ServerConfig;
723 const now = new Date().toISOString();724 const now = new Date().toISOString();
724 const threadId = ThreadId.makeUnsafe("Thread Revert.Files");725 const threadId = ThreadId.make("Thread Revert.Files");
725 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";726 const keepAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000001";
726 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";727 const removeAttachmentId = "thread-revert-files-00000000-0000-4000-8000-000000000002";
727 const otherThreadAttachmentId =728 const otherThreadAttachmentId =
@@ -734,16 +735,16 @@ it.layer( · 6 unmodified lines
734 735
735 yield* appendAndProject({736 yield* appendAndProject({
736 type: "project.created",737 type: "project.created",
737 eventId: EventId.makeUnsafe("evt-revert-files-1"),738 eventId: EventId.make("evt-revert-files-1"),
738 aggregateKind: "project",739 aggregateKind: "project",
739 aggregateId: ProjectId.makeUnsafe("project-revert-files"),740 aggregateId: ProjectId.make("project-revert-files"),
740 occurredAt: now,741 occurredAt: now,
741 commandId: CommandId.makeUnsafe("cmd-revert-files-1"),742 commandId: CommandId.make("cmd-revert-files-1"),
742 causationEventId: null,743 causationEventId: null,
743 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-1"),744 correlationId: CorrelationId.make("cmd-revert-files-1"),
744 metadata: {},745 metadata: {},
745 payload: {746 payload: {
746 projectId: ProjectId.makeUnsafe("project-revert-files"),747 projectId: ProjectId.make("project-revert-files"),
747 title: "Project Revert Files",748 title: "Project Revert Files",
748 workspaceRoot: "/tmp/project-revert-files",749 workspaceRoot: "/tmp/project-revert-files",
749 defaultModelSelection: null,750 defaultModelSelection: null,
@@ -755,17 +756,17 @@ it.layer( · 5 unmodified lines
755 756
756 yield* appendAndProject({757 yield* appendAndProject({
757 type: "thread.created",758 type: "thread.created",
758 eventId: EventId.makeUnsafe("evt-revert-files-2"),759 eventId: EventId.make("evt-revert-files-2"),
759 aggregateKind: "thread",760 aggregateKind: "thread",
760 aggregateId: threadId,761 aggregateId: threadId,
761 occurredAt: now,762 occurredAt: now,
762 commandId: CommandId.makeUnsafe("cmd-revert-files-2"),763 commandId: CommandId.make("cmd-revert-files-2"),
763 causationEventId: null,764 causationEventId: null,
764 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-2"),765 correlationId: CorrelationId.make("cmd-revert-files-2"),
765 metadata: {},766 metadata: {},
766 payload: {767 payload: {
767 threadId,768 threadId,
768 projectId: ProjectId.makeUnsafe("project-revert-files"),769 projectId: ProjectId.make("project-revert-files"),
769 title: "Thread Revert Files",770 title: "Thread Revert Files",
770 modelSelection: {771 modelSelection: {
771 provider: "codex",772 provider: "codex",
@@ -781,39 +782,39 @@ it.layer( · 9 unmodified lines
781 782
782 yield* appendAndProject({783 yield* appendAndProject({
783 type: "thread.turn-diff-completed",784 type: "thread.turn-diff-completed",
784 eventId: EventId.makeUnsafe("evt-revert-files-3"),785 eventId: EventId.make("evt-revert-files-3"),
785 aggregateKind: "thread",786 aggregateKind: "thread",
786 aggregateId: threadId,787 aggregateId: threadId,
787 occurredAt: now,788 occurredAt: now,
788 commandId: CommandId.makeUnsafe("cmd-revert-files-3"),789 commandId: CommandId.make("cmd-revert-files-3"),
789 causationEventId: null,790 causationEventId: null,
790 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-3"),791 correlationId: CorrelationId.make("cmd-revert-files-3"),
791 metadata: {},792 metadata: {},
792 payload: {793 payload: {
793 threadId,794 threadId,
794 turnId: TurnId.makeUnsafe("turn-keep"),795 turnId: TurnId.make("turn-keep"),
795 checkpointTurnCount: 1,796 checkpointTurnCount: 1,
796 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/1"),797 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/1"),
797 status: "ready",798 status: "ready",
798 files: [],799 files: [],
799 assistantMessageId: MessageId.makeUnsafe("message-keep"),800 assistantMessageId: MessageId.make("message-keep"),
800 completedAt: now,801 completedAt: now,
801 },802 },
802 });803 });
803 804
804 yield* appendAndProject({805 yield* appendAndProject({
805 type: "thread.message-sent",806 type: "thread.message-sent",
806 eventId: EventId.makeUnsafe("evt-revert-files-4"),807 eventId: EventId.make("evt-revert-files-4"),
807 aggregateKind: "thread",808 aggregateKind: "thread",
808 aggregateId: threadId,809 aggregateId: threadId,
809 occurredAt: now,810 occurredAt: now,
810 commandId: CommandId.makeUnsafe("cmd-revert-files-4"),811 commandId: CommandId.make("cmd-revert-files-4"),
811 causationEventId: null,812 causationEventId: null,
812 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-4"),813 correlationId: CorrelationId.make("cmd-revert-files-4"),
813 metadata: {},814 metadata: {},
814 payload: {815 payload: {
815 threadId,816 threadId,
816 messageId: MessageId.makeUnsafe("message-keep"),817 messageId: MessageId.make("message-keep"),
817 role: "assistant",818 role: "assistant",
818 text: "Keep",819 text: "Keep",
819 attachments: [820 attachments: [
@@ -825,7 +826,7 @@ it.layer( · 5 unmodified lines
825 sizeBytes: 5,826 sizeBytes: 5,
826 },827 },
827 ],828 ],
828 turnId: TurnId.makeUnsafe("turn-keep"),829 turnId: TurnId.make("turn-keep"),
829 streaming: false,830 streaming: false,
830 createdAt: now,831 createdAt: now,
831 updatedAt: now,832 updatedAt: now,
@@ -834,39 +835,39 @@ it.layer( · 2 unmodified lines
834 835
835 yield* appendAndProject({836 yield* appendAndProject({
836 type: "thread.turn-diff-completed",837 type: "thread.turn-diff-completed",
837 eventId: EventId.makeUnsafe("evt-revert-files-5"),838 eventId: EventId.make("evt-revert-files-5"),
838 aggregateKind: "thread",839 aggregateKind: "thread",
839 aggregateId: threadId,840 aggregateId: threadId,
840 occurredAt: now,841 occurredAt: now,
841 commandId: CommandId.makeUnsafe("cmd-revert-files-5"),842 commandId: CommandId.make("cmd-revert-files-5"),
842 causationEventId: null,843 causationEventId: null,
843 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-5"),844 correlationId: CorrelationId.make("cmd-revert-files-5"),
844 metadata: {},845 metadata: {},
845 payload: {846 payload: {
846 threadId,847 threadId,
847 turnId: TurnId.makeUnsafe("turn-remove"),848 turnId: TurnId.make("turn-remove"),
848 checkpointTurnCount: 2,849 checkpointTurnCount: 2,
849 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert-files/turn/2"),850 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert-files/turn/2"),
850 status: "ready",851 status: "ready",
851 files: [],852 files: [],
852 assistantMessageId: MessageId.makeUnsafe("message-remove"),853 assistantMessageId: MessageId.make("message-remove"),
853 completedAt: now,854 completedAt: now,
854 },855 },
855 });856 });
856 857
857 yield* appendAndProject({858 yield* appendAndProject({
858 type: "thread.message-sent",859 type: "thread.message-sent",
859 eventId: EventId.makeUnsafe("evt-revert-files-6"),860 eventId: EventId.make("evt-revert-files-6"),
860 aggregateKind: "thread",861 aggregateKind: "thread",
861 aggregateId: threadId,862 aggregateId: threadId,
862 occurredAt: now,863 occurredAt: now,
863 commandId: CommandId.makeUnsafe("cmd-revert-files-6"),864 commandId: CommandId.make("cmd-revert-files-6"),
864 causationEventId: null,865 causationEventId: null,
865 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-6"),866 correlationId: CorrelationId.make("cmd-revert-files-6"),
866 metadata: {},867 metadata: {},
867 payload: {868 payload: {
868 threadId,869 threadId,
869 messageId: MessageId.makeUnsafe("message-remove"),870 messageId: MessageId.make("message-remove"),
870 role: "assistant",871 role: "assistant",
871 text: "Remove",872 text: "Remove",
872 attachments: [873 attachments: [
@@ -878,7 +879,7 @@ it.layer( · 5 unmodified lines
878 sizeBytes: 5,879 sizeBytes: 5,
879 },880 },
880 ],881 ],
881 turnId: TurnId.makeUnsafe("turn-remove"),882 turnId: TurnId.make("turn-remove"),
882 streaming: false,883 streaming: false,
883 createdAt: now,884 createdAt: now,
884 updatedAt: now,885 updatedAt: now,
@@ -898,13 +899,13 @@ it.layer( · 13 unmodified lines
898 899
899 yield* appendAndProject({900 yield* appendAndProject({
900 type: "thread.reverted",901 type: "thread.reverted",
901 eventId: EventId.makeUnsafe("evt-revert-files-7"),902 eventId: EventId.make("evt-revert-files-7"),
902 aggregateKind: "thread",903 aggregateKind: "thread",
903 aggregateId: threadId,904 aggregateId: threadId,
904 occurredAt: now,905 occurredAt: now,
905 commandId: CommandId.makeUnsafe("cmd-revert-files-7"),906 commandId: CommandId.make("cmd-revert-files-7"),
906 causationEventId: null,907 causationEventId: null,
907 correlationId: CorrelationId.makeUnsafe("cmd-revert-files-7"),908 correlationId: CorrelationId.make("cmd-revert-files-7"),
908 metadata: {},909 metadata: {},
909 payload: {910 payload: {
910 threadId,911 threadId,
@@ -930,7 +931,7 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 19 unmodified lines
930 const eventStore = yield* OrchestrationEventStore;931 const eventStore = yield* OrchestrationEventStore;
931 const { attachmentsDir } = yield* ServerConfig;932 const { attachmentsDir } = yield* ServerConfig;
932 const now = new Date().toISOString();933 const now = new Date().toISOString();
933 const threadId = ThreadId.makeUnsafe("Thread Delete.Files");934 const threadId = ThreadId.make("Thread Delete.Files");
934 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";935 const attachmentId = "thread-delete-files-00000000-0000-4000-8000-000000000001";
935 const otherThreadAttachmentId =936 const otherThreadAttachmentId =
936 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";937 "thread-delete-files-extra-00000000-0000-4000-8000-000000000002";
@@ -942,16 +943,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines
942 943
943 yield* appendAndProject({944 yield* appendAndProject({
944 type: "project.created",945 type: "project.created",
945 eventId: EventId.makeUnsafe("evt-delete-files-1"),946 eventId: EventId.make("evt-delete-files-1"),
946 aggregateKind: "project",947 aggregateKind: "project",
947 aggregateId: ProjectId.makeUnsafe("project-delete-files"),948 aggregateId: ProjectId.make("project-delete-files"),
948 occurredAt: now,949 occurredAt: now,
949 commandId: CommandId.makeUnsafe("cmd-delete-files-1"),950 commandId: CommandId.make("cmd-delete-files-1"),
950 causationEventId: null,951 causationEventId: null,
951 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-1"),952 correlationId: CorrelationId.make("cmd-delete-files-1"),
952 metadata: {},953 metadata: {},
953 payload: {954 payload: {
954 projectId: ProjectId.makeUnsafe("project-delete-files"),955 projectId: ProjectId.make("project-delete-files"),
955 title: "Project Delete Files",956 title: "Project Delete Files",
956 workspaceRoot: "/tmp/project-delete-files",957 workspaceRoot: "/tmp/project-delete-files",
957 defaultModelSelection: null,958 defaultModelSelection: null,
@@ -963,17 +964,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 5 unmodified lines
963 964
964 yield* appendAndProject({965 yield* appendAndProject({
965 type: "thread.created",966 type: "thread.created",
966 eventId: EventId.makeUnsafe("evt-delete-files-2"),967 eventId: EventId.make("evt-delete-files-2"),
967 aggregateKind: "thread",968 aggregateKind: "thread",
968 aggregateId: threadId,969 aggregateId: threadId,
969 occurredAt: now,970 occurredAt: now,
970 commandId: CommandId.makeUnsafe("cmd-delete-files-2"),971 commandId: CommandId.make("cmd-delete-files-2"),
971 causationEventId: null,972 causationEventId: null,
972 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-2"),973 correlationId: CorrelationId.make("cmd-delete-files-2"),
973 metadata: {},974 metadata: {},
974 payload: {975 payload: {
975 threadId,976 threadId,
976 projectId: ProjectId.makeUnsafe("project-delete-files"),977 projectId: ProjectId.make("project-delete-files"),
977 title: "Thread Delete Files",978 title: "Thread Delete Files",
978 modelSelection: {979 modelSelection: {
979 provider: "codex",980 provider: "codex",
@@ -989,17 +990,17 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 9 unmodified lines
989 990
990 yield* appendAndProject({991 yield* appendAndProject({
991 type: "thread.message-sent",992 type: "thread.message-sent",
992 eventId: EventId.makeUnsafe("evt-delete-files-3"),993 eventId: EventId.make("evt-delete-files-3"),
993 aggregateKind: "thread",994 aggregateKind: "thread",
994 aggregateId: threadId,995 aggregateId: threadId,
995 occurredAt: now,996 occurredAt: now,
996 commandId: CommandId.makeUnsafe("cmd-delete-files-3"),997 commandId: CommandId.make("cmd-delete-files-3"),
997 causationEventId: null,998 causationEventId: null,
998 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-3"),999 correlationId: CorrelationId.make("cmd-delete-files-3"),
999 metadata: {},1000 metadata: {},
1000 payload: {1001 payload: {
1001 threadId,1002 threadId,
1002 messageId: MessageId.makeUnsafe("message-delete-files"),1003 messageId: MessageId.make("message-delete-files"),
1003 role: "user",1004 role: "user",
1004 text: "Delete",1005 text: "Delete",
1005 attachments: [1006 attachments: [
@@ -1031,13 +1032,13 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 25 unmodified lines
1031 1032
1032 yield* appendAndProject({1033 yield* appendAndProject({
1033 type: "thread.deleted",1034 type: "thread.deleted",
1034 eventId: EventId.makeUnsafe("evt-delete-files-4"),1035 eventId: EventId.make("evt-delete-files-4"),
1035 aggregateKind: "thread",1036 aggregateKind: "thread",
1036 aggregateId: threadId,1037 aggregateId: threadId,
1037 occurredAt: now,1038 occurredAt: now,
1038 commandId: CommandId.makeUnsafe("cmd-delete-files-4"),1039 commandId: CommandId.make("cmd-delete-files-4"),
1039 causationEventId: null,1040 causationEventId: null,
1040 correlationId: CorrelationId.makeUnsafe("cmd-delete-files-4"),1041 correlationId: CorrelationId.make("cmd-delete-files-4"),
1041 metadata: {},1042 metadata: {},
1042 payload: {1043 payload: {
1043 threadId,1044 threadId,
@@ -1071,16 +1072,16 @@ it.layer(Layer.fresh(makeProjectionPipelinePrefixedTestLayer("t3-projection-atta · 27 unmodified lines
1071 1072
1072 yield* eventStore.append({1073 yield* eventStore.append({
1073 type: "thread.deleted",1074 type: "thread.deleted",
1074 eventId: EventId.makeUnsafe("evt-unsafe-thread-delete"),1075 eventId: EventId.make("evt-unsafe-thread-delete"),
1075 aggregateKind: "thread",1076 aggregateKind: "thread",
1076 aggregateId: ThreadId.makeUnsafe(".."),1077 aggregateId: ThreadId.make(".."),
1077 occurredAt: now,1078 occurredAt: now,
1078 commandId: CommandId.makeUnsafe("cmd-unsafe-thread-delete"),1079 commandId: CommandId.make("cmd-unsafe-thread-delete"),
1079 causationEventId: null,1080 causationEventId: null,
1080 correlationId: CorrelationId.makeUnsafe("cmd-unsafe-thread-delete"),1081 correlationId: CorrelationId.make("cmd-unsafe-thread-delete"),
1081 metadata: {},1082 metadata: {},
1082 payload: {1083 payload: {
1083 threadId: ThreadId.makeUnsafe(".."),1084 threadId: ThreadId.make(".."),
1084 deletedAt: now,1085 deletedAt: now,
1085 },1086 },
1086 });1087 });
@@ -1105,16 +1106,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 18 unmodified lines
1105 1106
1106 yield* eventStore.append({1107 yield* eventStore.append({
1107 type: "project.created",1108 type: "project.created",
1108 eventId: EventId.makeUnsafe("evt-a1"),1109 eventId: EventId.make("evt-a1"),
1109 aggregateKind: "project",1110 aggregateKind: "project",
1110 aggregateId: ProjectId.makeUnsafe("project-a"),1111 aggregateId: ProjectId.make("project-a"),
1111 occurredAt: now,1112 occurredAt: now,
1112 commandId: CommandId.makeUnsafe("cmd-a1"),1113 commandId: CommandId.make("cmd-a1"),
1113 causationEventId: null,1114 causationEventId: null,
1114 correlationId: CorrelationId.makeUnsafe("cmd-a1"),1115 correlationId: CorrelationId.make("cmd-a1"),
1115 metadata: {},1116 metadata: {},
1116 payload: {1117 payload: {
1117 projectId: ProjectId.makeUnsafe("project-a"),1118 projectId: ProjectId.make("project-a"),
1118 title: "Project A",1119 title: "Project A",
1119 workspaceRoot: "/tmp/project-a",1120 workspaceRoot: "/tmp/project-a",
1120 defaultModelSelection: null,1121 defaultModelSelection: null,
@@ -1126,17 +1127,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1126 1127
1127 yield* eventStore.append({1128 yield* eventStore.append({
1128 type: "thread.created",1129 type: "thread.created",
1129 eventId: EventId.makeUnsafe("evt-a2"),1130 eventId: EventId.make("evt-a2"),
1130 aggregateKind: "thread",1131 aggregateKind: "thread",
1131 aggregateId: ThreadId.makeUnsafe("thread-a"),1132 aggregateId: ThreadId.make("thread-a"),
1132 occurredAt: now,1133 occurredAt: now,
1133 commandId: CommandId.makeUnsafe("cmd-a2"),1134 commandId: CommandId.make("cmd-a2"),
1134 causationEventId: null,1135 causationEventId: null,
1135 correlationId: CorrelationId.makeUnsafe("cmd-a2"),1136 correlationId: CorrelationId.make("cmd-a2"),
1136 metadata: {},1137 metadata: {},
1137 payload: {1138 payload: {
1138 threadId: ThreadId.makeUnsafe("thread-a"),1139 threadId: ThreadId.make("thread-a"),
1139 projectId: ProjectId.makeUnsafe("project-a"),1140 projectId: ProjectId.make("project-a"),
1140 title: "Thread A",1141 title: "Thread A",
1141 modelSelection: {1142 modelSelection: {
1142 provider: "codex",1143 provider: "codex",
@@ -1152,17 +1153,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
1152 1153
1153 yield* eventStore.append({1154 yield* eventStore.append({
1154 type: "thread.message-sent",1155 type: "thread.message-sent",
1155 eventId: EventId.makeUnsafe("evt-a3"),1156 eventId: EventId.make("evt-a3"),
1156 aggregateKind: "thread",1157 aggregateKind: "thread",
1157 aggregateId: ThreadId.makeUnsafe("thread-a"),1158 aggregateId: ThreadId.make("thread-a"),
1158 occurredAt: now,1159 occurredAt: now,
1159 commandId: CommandId.makeUnsafe("cmd-a3"),1160 commandId: CommandId.make("cmd-a3"),
1160 causationEventId: null,1161 causationEventId: null,
1161 correlationId: CorrelationId.makeUnsafe("cmd-a3"),1162 correlationId: CorrelationId.make("cmd-a3"),
1162 metadata: {},1163 metadata: {},
1163 payload: {1164 payload: {
1164 threadId: ThreadId.makeUnsafe("thread-a"),1165 threadId: ThreadId.make("thread-a"),
1165 messageId: MessageId.makeUnsafe("message-a"),1166 messageId: MessageId.make("message-a"),
1166 role: "assistant",1167 role: "assistant",
1167 text: "hello",1168 text: "hello",
1168 turnId: null,1169 turnId: null,
@@ -1176,17 +1177,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 7 unmodified lines
1176 1177
1177 yield* eventStore.append({1178 yield* eventStore.append({
1178 type: "thread.message-sent",1179 type: "thread.message-sent",
1179 eventId: EventId.makeUnsafe("evt-a4"),1180 eventId: EventId.make("evt-a4"),
1180 aggregateKind: "thread",1181 aggregateKind: "thread",
1181 aggregateId: ThreadId.makeUnsafe("thread-a"),1182 aggregateId: ThreadId.make("thread-a"),
1182 occurredAt: now,1183 occurredAt: now,
1183 commandId: CommandId.makeUnsafe("cmd-a4"),1184 commandId: CommandId.make("cmd-a4"),
1184 causationEventId: null,1185 causationEventId: null,
1185 correlationId: CorrelationId.makeUnsafe("cmd-a4"),1186 correlationId: CorrelationId.make("cmd-a4"),
1186 metadata: {},1187 metadata: {},
1187 payload: {1188 payload: {
1188 threadId: ThreadId.makeUnsafe("thread-a"),1189 threadId: ThreadId.make("thread-a"),
1189 messageId: MessageId.makeUnsafe("message-a"),1190 messageId: MessageId.make("message-a"),
1190 role: "assistant",1191 role: "assistant",
1191 text: " world",1192 text: " world",
1192 turnId: null,1193 turnId: null,
@@ -1232,16 +1233,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 39 unmodified lines
1232 1233
1233 yield* eventStore.append({1234 yield* eventStore.append({
1234 type: "project.created",1235 type: "project.created",
1235 eventId: EventId.makeUnsafe("evt-empty-1"),1236 eventId: EventId.make("evt-empty-1"),
1236 aggregateKind: "project",1237 aggregateKind: "project",
1237 aggregateId: ProjectId.makeUnsafe("project-empty"),1238 aggregateId: ProjectId.make("project-empty"),
1238 occurredAt: now,1239 occurredAt: now,
1239 commandId: CommandId.makeUnsafe("cmd-empty-1"),1240 commandId: CommandId.make("cmd-empty-1"),
1240 causationEventId: null,1241 causationEventId: null,
1241 correlationId: CorrelationId.makeUnsafe("cmd-empty-1"),1242 correlationId: CorrelationId.make("cmd-empty-1"),
1242 metadata: {},1243 metadata: {},
1243 payload: {1244 payload: {
1244 projectId: ProjectId.makeUnsafe("project-empty"),1245 projectId: ProjectId.make("project-empty"),
1245 title: "Project Empty",1246 title: "Project Empty",
1246 workspaceRoot: "/tmp/project-empty",1247 workspaceRoot: "/tmp/project-empty",
1247 defaultModelSelection: null,1248 defaultModelSelection: null,
@@ -1253,17 +1254,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1253 1254
1254 yield* eventStore.append({1255 yield* eventStore.append({
1255 type: "thread.created",1256 type: "thread.created",
1256 eventId: EventId.makeUnsafe("evt-empty-2"),1257 eventId: EventId.make("evt-empty-2"),
1257 aggregateKind: "thread",1258 aggregateKind: "thread",
1258 aggregateId: ThreadId.makeUnsafe("thread-empty"),1259 aggregateId: ThreadId.make("thread-empty"),
1259 occurredAt: now,1260 occurredAt: now,
1260 commandId: CommandId.makeUnsafe("cmd-empty-2"),1261 commandId: CommandId.make("cmd-empty-2"),
1261 causationEventId: null,1262 causationEventId: null,
1262 correlationId: CorrelationId.makeUnsafe("cmd-empty-2"),1263 correlationId: CorrelationId.make("cmd-empty-2"),
1263 metadata: {},1264 metadata: {},
1264 payload: {1265 payload: {
1265 threadId: ThreadId.makeUnsafe("thread-empty"),1266 threadId: ThreadId.make("thread-empty"),
1266 projectId: ProjectId.makeUnsafe("project-empty"),1267 projectId: ProjectId.make("project-empty"),
1267 title: "Thread Empty",1268 title: "Thread Empty",
1268 modelSelection: {1269 modelSelection: {
1269 provider: "codex",1270 provider: "codex",
@@ -1279,17 +1280,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
1279 1280
1280 yield* eventStore.append({1281 yield* eventStore.append({
1281 type: "thread.message-sent",1282 type: "thread.message-sent",
1282 eventId: EventId.makeUnsafe("evt-empty-3"),1283 eventId: EventId.make("evt-empty-3"),
1283 aggregateKind: "thread",1284 aggregateKind: "thread",
1284 aggregateId: ThreadId.makeUnsafe("thread-empty"),1285 aggregateId: ThreadId.make("thread-empty"),
1285 occurredAt: now,1286 occurredAt: now,
1286 commandId: CommandId.makeUnsafe("cmd-empty-3"),1287 commandId: CommandId.make("cmd-empty-3"),
1287 causationEventId: null,1288 causationEventId: null,
1288 correlationId: CorrelationId.makeUnsafe("cmd-empty-3"),1289 correlationId: CorrelationId.make("cmd-empty-3"),
1289 metadata: {},1290 metadata: {},
1290 payload: {1291 payload: {
1291 threadId: ThreadId.makeUnsafe("thread-empty"),1292 threadId: ThreadId.make("thread-empty"),
1292 messageId: MessageId.makeUnsafe("assistant-empty"),1293 messageId: MessageId.make("assistant-empty"),
1293 role: "assistant",1294 role: "assistant",
1294 text: "Hello",1295 text: "Hello",
1295 turnId: null,1296 turnId: null,
@@ -1301,17 +1302,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1301 1302
1302 yield* eventStore.append({1303 yield* eventStore.append({
1303 type: "thread.message-sent",1304 type: "thread.message-sent",
1304 eventId: EventId.makeUnsafe("evt-empty-4"),1305 eventId: EventId.make("evt-empty-4"),
1305 aggregateKind: "thread",1306 aggregateKind: "thread",
1306 aggregateId: ThreadId.makeUnsafe("thread-empty"),1307 aggregateId: ThreadId.make("thread-empty"),
1307 occurredAt: now,1308 occurredAt: now,
1308 commandId: CommandId.makeUnsafe("cmd-empty-4"),1309 commandId: CommandId.make("cmd-empty-4"),
1309 causationEventId: null,1310 causationEventId: null,
1310 correlationId: CorrelationId.makeUnsafe("cmd-empty-4"),1311 correlationId: CorrelationId.make("cmd-empty-4"),
1311 metadata: {},1312 metadata: {},
1312 payload: {1313 payload: {
1313 threadId: ThreadId.makeUnsafe("thread-empty"),1314 threadId: ThreadId.make("thread-empty"),
1314 messageId: MessageId.makeUnsafe("assistant-empty"),1315 messageId: MessageId.make("assistant-empty"),
1315 role: "assistant",1316 role: "assistant",
1316 text: " world",1317 text: " world",
1317 turnId: null,1318 turnId: null,
@@ -1323,17 +1324,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1323 1324
1324 yield* eventStore.append({1325 yield* eventStore.append({
1325 type: "thread.message-sent",1326 type: "thread.message-sent",
1326 eventId: EventId.makeUnsafe("evt-empty-5"),1327 eventId: EventId.make("evt-empty-5"),
1327 aggregateKind: "thread",1328 aggregateKind: "thread",
1328 aggregateId: ThreadId.makeUnsafe("thread-empty"),1329 aggregateId: ThreadId.make("thread-empty"),
1329 occurredAt: now,1330 occurredAt: now,
1330 commandId: CommandId.makeUnsafe("cmd-empty-5"),1331 commandId: CommandId.make("cmd-empty-5"),
1331 causationEventId: null,1332 causationEventId: null,
1332 correlationId: CorrelationId.makeUnsafe("cmd-empty-5"),1333 correlationId: CorrelationId.make("cmd-empty-5"),
1333 metadata: {},1334 metadata: {},
1334 payload: {1335 payload: {
1335 threadId: ThreadId.makeUnsafe("thread-empty"),1336 threadId: ThreadId.make("thread-empty"),
1336 messageId: MessageId.makeUnsafe("assistant-empty"),1337 messageId: MessageId.make("assistant-empty"),
1337 role: "assistant",1338 role: "assistant",
1338 text: "",1339 text: "",
1339 turnId: null,1340 turnId: null,
@@ -1372,16 +1373,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 32 unmodified lines
1372 1373
1373 yield* appendAndProject({1374 yield* appendAndProject({
1374 type: "project.created",1375 type: "project.created",
1375 eventId: EventId.makeUnsafe("evt-conflict-1"),1376 eventId: EventId.make("evt-conflict-1"),
1376 aggregateKind: "project",1377 aggregateKind: "project",
1377 aggregateId: ProjectId.makeUnsafe("project-conflict"),1378 aggregateId: ProjectId.make("project-conflict"),
1378 occurredAt: "2026-02-26T13:00:00.000Z",1379 occurredAt: "2026-02-26T13:00:00.000Z",
1379 commandId: CommandId.makeUnsafe("cmd-conflict-1"),1380 commandId: CommandId.make("cmd-conflict-1"),
1380 causationEventId: null,1381 causationEventId: null,
1381 correlationId: CorrelationId.makeUnsafe("cmd-conflict-1"),1382 correlationId: CorrelationId.make("cmd-conflict-1"),
1382 metadata: {},1383 metadata: {},
1383 payload: {1384 payload: {
1384 projectId: ProjectId.makeUnsafe("project-conflict"),1385 projectId: ProjectId.make("project-conflict"),
1385 title: "Project Conflict",1386 title: "Project Conflict",
1386 workspaceRoot: "/tmp/project-conflict",1387 workspaceRoot: "/tmp/project-conflict",
1387 defaultModelSelection: null,1388 defaultModelSelection: null,
@@ -1393,17 +1394,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1393 1394
1394 yield* appendAndProject({1395 yield* appendAndProject({
1395 type: "thread.created",1396 type: "thread.created",
1396 eventId: EventId.makeUnsafe("evt-conflict-2"),1397 eventId: EventId.make("evt-conflict-2"),
1397 aggregateKind: "thread",1398 aggregateKind: "thread",
1398 aggregateId: ThreadId.makeUnsafe("thread-conflict"),1399 aggregateId: ThreadId.make("thread-conflict"),
1399 occurredAt: "2026-02-26T13:00:01.000Z",1400 occurredAt: "2026-02-26T13:00:01.000Z",
1400 commandId: CommandId.makeUnsafe("cmd-conflict-2"),1401 commandId: CommandId.make("cmd-conflict-2"),
1401 causationEventId: null,1402 causationEventId: null,
1402 correlationId: CorrelationId.makeUnsafe("cmd-conflict-2"),1403 correlationId: CorrelationId.make("cmd-conflict-2"),
1403 metadata: {},1404 metadata: {},
1404 payload: {1405 payload: {
1405 threadId: ThreadId.makeUnsafe("thread-conflict"),1406 threadId: ThreadId.make("thread-conflict"),
1406 projectId: ProjectId.makeUnsafe("project-conflict"),1407 projectId: ProjectId.make("project-conflict"),
1407 title: "Thread Conflict",1408 title: "Thread Conflict",
1408 modelSelection: {1409 modelSelection: {
1409 provider: "codex",1410 provider: "codex",
@@ -1419,37 +1420,37 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
1419 1420
1420 yield* appendAndProject({1421 yield* appendAndProject({
1421 type: "thread.turn-interrupt-requested",1422 type: "thread.turn-interrupt-requested",
1422 eventId: EventId.makeUnsafe("evt-conflict-3"),1423 eventId: EventId.make("evt-conflict-3"),
1423 aggregateKind: "thread",1424 aggregateKind: "thread",
1424 aggregateId: ThreadId.makeUnsafe("thread-conflict"),1425 aggregateId: ThreadId.make("thread-conflict"),
1425 occurredAt: "2026-02-26T13:00:02.000Z",1426 occurredAt: "2026-02-26T13:00:02.000Z",
1426 commandId: CommandId.makeUnsafe("cmd-conflict-3"),1427 commandId: CommandId.make("cmd-conflict-3"),
1427 causationEventId: null,1428 causationEventId: null,
1428 correlationId: CorrelationId.makeUnsafe("cmd-conflict-3"),1429 correlationId: CorrelationId.make("cmd-conflict-3"),
1429 metadata: {},1430 metadata: {},
1430 payload: {1431 payload: {
1431 threadId: ThreadId.makeUnsafe("thread-conflict"),1432 threadId: ThreadId.make("thread-conflict"),
1432 turnId: TurnId.makeUnsafe("turn-interrupted"),1433 turnId: TurnId.make("turn-interrupted"),
1433 createdAt: "2026-02-26T13:00:02.000Z",1434 createdAt: "2026-02-26T13:00:02.000Z",
1434 },1435 },
1435 });1436 });
1436 1437
1437 yield* appendAndProject({1438 yield* appendAndProject({
1438 type: "thread.message-sent",1439 type: "thread.message-sent",
1439 eventId: EventId.makeUnsafe("evt-conflict-4"),1440 eventId: EventId.make("evt-conflict-4"),
1440 aggregateKind: "thread",1441 aggregateKind: "thread",
1441 aggregateId: ThreadId.makeUnsafe("thread-conflict"),1442 aggregateId: ThreadId.make("thread-conflict"),
1442 occurredAt: "2026-02-26T13:00:03.000Z",1443 occurredAt: "2026-02-26T13:00:03.000Z",
1443 commandId: CommandId.makeUnsafe("cmd-conflict-4"),1444 commandId: CommandId.make("cmd-conflict-4"),
1444 causationEventId: null,1445 causationEventId: null,
1445 correlationId: CorrelationId.makeUnsafe("cmd-conflict-4"),1446 correlationId: CorrelationId.make("cmd-conflict-4"),
1446 metadata: {},1447 metadata: {},
1447 payload: {1448 payload: {
1448 threadId: ThreadId.makeUnsafe("thread-conflict"),1449 threadId: ThreadId.make("thread-conflict"),
1449 messageId: MessageId.makeUnsafe("assistant-conflict"),1450 messageId: MessageId.make("assistant-conflict"),
1450 role: "assistant",1451 role: "assistant",
1451 text: "done",1452 text: "done",
1452 turnId: TurnId.makeUnsafe("turn-completed"),1453 turnId: TurnId.make("turn-completed"),
1453 streaming: false,1454 streaming: false,
1454 createdAt: "2026-02-26T13:00:03.000Z",1455 createdAt: "2026-02-26T13:00:03.000Z",
1455 updatedAt: "2026-02-26T13:00:03.000Z",1456 updatedAt: "2026-02-26T13:00:03.000Z",
@@ -1458,22 +1459,22 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
1458 1459
1459 yield* appendAndProject({1460 yield* appendAndProject({
1460 type: "thread.turn-diff-completed",1461 type: "thread.turn-diff-completed",
1461 eventId: EventId.makeUnsafe("evt-conflict-5"),1462 eventId: EventId.make("evt-conflict-5"),
1462 aggregateKind: "thread",1463 aggregateKind: "thread",
1463 aggregateId: ThreadId.makeUnsafe("thread-conflict"),1464 aggregateId: ThreadId.make("thread-conflict"),
1464 occurredAt: "2026-02-26T13:00:04.000Z",1465 occurredAt: "2026-02-26T13:00:04.000Z",
1465 commandId: CommandId.makeUnsafe("cmd-conflict-5"),1466 commandId: CommandId.make("cmd-conflict-5"),
1466 causationEventId: null,1467 causationEventId: null,
1467 correlationId: CorrelationId.makeUnsafe("cmd-conflict-5"),1468 correlationId: CorrelationId.make("cmd-conflict-5"),
1468 metadata: {},1469 metadata: {},
1469 payload: {1470 payload: {
1470 threadId: ThreadId.makeUnsafe("thread-conflict"),1471 threadId: ThreadId.make("thread-conflict"),
1471 turnId: TurnId.makeUnsafe("turn-completed"),1472 turnId: TurnId.make("turn-completed"),
1472 checkpointTurnCount: 1,1473 checkpointTurnCount: 1,
1473 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-conflict/turn/1"),1474 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-conflict/turn/1"),
1474 status: "ready",1475 status: "ready",
1475 files: [],1476 files: [],
1476 assistantMessageId: MessageId.makeUnsafe("assistant-conflict"),1477 assistantMessageId: MessageId.make("assistant-conflict"),
1477 completedAt: "2026-02-26T13:00:04.000Z",1478 completedAt: "2026-02-26T13:00:04.000Z",
1478 },1479 },
1479 });1480 });
@@ -1504,6 +1505,329 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 24 unmodified lines
1504 }),1505 }),
1505 );1506 );
1506 1507
1508 it.effect("clears stale pending approvals from projected shell summaries", () =>
1509 Effect.gen(function* () {
1510 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1511 const eventStore = yield* OrchestrationEventStore;
1512 const sql = yield* SqlClient.SqlClient;
1513 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1514 eventStore
1515 .append(event)
1516 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1517
1518 yield* appendAndProject({
1519 type: "project.created",
1520 eventId: EventId.make("evt-stale-approval-1"),
1521 aggregateKind: "project",
1522 aggregateId: ProjectId.make("project-stale-approval"),
1523 occurredAt: "2026-02-26T12:30:00.000Z",
1524 commandId: CommandId.make("cmd-stale-approval-1"),
1525 causationEventId: null,
1526 correlationId: CorrelationId.make("cmd-stale-approval-1"),
1527 metadata: {},
1528 payload: {
1529 projectId: ProjectId.make("project-stale-approval"),
1530 title: "Project Stale Approval",
1531 workspaceRoot: "/tmp/project-stale-approval",
1532 defaultModelSelection: null,
1533 scripts: [],
1534 createdAt: "2026-02-26T12:30:00.000Z",
1535 updatedAt: "2026-02-26T12:30:00.000Z",
1536 },
1537 });
1538
1539 yield* appendAndProject({
1540 type: "thread.created",
1541 eventId: EventId.make("evt-stale-approval-2"),
1542 aggregateKind: "thread",
1543 aggregateId: ThreadId.make("thread-stale-approval"),
1544 occurredAt: "2026-02-26T12:30:01.000Z",
1545 commandId: CommandId.make("cmd-stale-approval-2"),
1546 causationEventId: null,
1547 correlationId: CorrelationId.make("cmd-stale-approval-2"),
1548 metadata: {},
1549 payload: {
1550 threadId: ThreadId.make("thread-stale-approval"),
1551 projectId: ProjectId.make("project-stale-approval"),
1552 title: "Thread Stale Approval",
1553 modelSelection: {
1554 provider: "codex",
1555 model: "gpt-5-codex",
1556 },
1557 runtimeMode: "approval-required",
1558 interactionMode: "default",
1559 branch: null,
1560 worktreePath: null,
1561 createdAt: "2026-02-26T12:30:01.000Z",
1562 updatedAt: "2026-02-26T12:30:01.000Z",
1563 },
1564 });
1565
1566 yield* appendAndProject({
1567 type: "thread.activity-appended",
1568 eventId: EventId.make("evt-stale-approval-3"),
1569 aggregateKind: "thread",
1570 aggregateId: ThreadId.make("thread-stale-approval"),
1571 occurredAt: "2026-02-26T12:30:02.000Z",
1572 commandId: CommandId.make("cmd-stale-approval-3"),
1573 causationEventId: null,
1574 correlationId: CorrelationId.make("cmd-stale-approval-3"),
1575 metadata: {},
1576 payload: {
1577 threadId: ThreadId.make("thread-stale-approval"),
1578 activity: {
1579 id: EventId.make("activity-stale-approval-requested"),
1580 tone: "approval",
1581 kind: "approval.requested",
1582 summary: "Command approval requested",
1583 payload: {
1584 requestId: "approval-request-stale-1",
1585 requestKind: "command",
1586 },
1587 turnId: null,
1588 createdAt: "2026-02-26T12:30:02.000Z",
1589 },
1590 },
1591 });
1592
1593 yield* appendAndProject({
1594 type: "thread.activity-appended",
1595 eventId: EventId.make("evt-stale-approval-4"),
1596 aggregateKind: "thread",
1597 aggregateId: ThreadId.make("thread-stale-approval"),
1598 occurredAt: "2026-02-26T12:30:03.000Z",
1599 commandId: CommandId.make("cmd-stale-approval-4"),
1600 causationEventId: null,
1601 correlationId: CorrelationId.make("cmd-stale-approval-4"),
1602 metadata: {},
1603 payload: {
1604 threadId: ThreadId.make("thread-stale-approval"),
1605 activity: {
1606 id: EventId.make("activity-stale-approval-failed"),
1607 tone: "error",
1608 kind: "provider.approval.respond.failed",
1609 summary: "Provider approval response failed",
1610 payload: {
1611 requestId: "approval-request-stale-1",
1612 detail: "Unknown pending permission request: approval-request-stale-1",
1613 },
1614 turnId: null,
1615 createdAt: "2026-02-26T12:30:03.000Z",
1616 },
1617 },
1618 });
1619
1620 const approvalRows = yield* sql<{
1621 readonly requestId: string;
1622 readonly status: string;
1623 readonly resolvedAt: string | null;
1624 }>`
1625 SELECT
1626 request_id AS "requestId",
1627 status,
1628 resolved_at AS "resolvedAt"
1629 FROM projection_pending_approvals
1630 WHERE request_id = 'approval-request-stale-1'
1631 `;
1632 assert.deepEqual(approvalRows, [
1633 {
1634 requestId: "approval-request-stale-1",
1635 status: "resolved",
1636 resolvedAt: "2026-02-26T12:30:03.000Z",
1637 },
1638 ]);
1639
1640 const threadRows = yield* sql<{
1641 readonly pendingApprovalCount: number;
1642 }>`
1643 SELECT pending_approval_count AS "pendingApprovalCount"
1644 FROM projection_threads
1645 WHERE thread_id = 'thread-stale-approval'
1646 `;
1647 assert.deepEqual(threadRows, [{ pendingApprovalCount: 0 }]);
1648 }),
1649 );
1650
1651 it.effect("ignores non-stale provider approval response failures", () =>
1652 Effect.gen(function* () {
1653 const projectionPipeline = yield* OrchestrationProjectionPipeline;
1654 const eventStore = yield* OrchestrationEventStore;
1655 const sql = yield* SqlClient.SqlClient;
1656 const appendAndProject = (event: Parameters<typeof eventStore.append>[0]) =>
1657 eventStore
1658 .append(event)
1659 .pipe(Effect.flatMap((savedEvent) => projectionPipeline.projectEvent(savedEvent)));
1660
1661 yield* appendAndProject({
1662 type: "project.created",
1663 eventId: EventId.make("evt-nonstale-approval-1"),
1664 aggregateKind: "project",
1665 aggregateId: ProjectId.make("project-nonstale-approval"),
1666 occurredAt: "2026-02-26T12:45:00.000Z",
1667 commandId: CommandId.make("cmd-nonstale-approval-1"),
1668 causationEventId: null,
1669 correlationId: CorrelationId.make("cmd-nonstale-approval-1"),
1670 metadata: {},
1671 payload: {
1672 projectId: ProjectId.make("project-nonstale-approval"),
1673 title: "Project Non-Stale Approval",
1674 workspaceRoot: "/tmp/project-nonstale-approval",
1675 defaultModelSelection: null,
1676 scripts: [],
1677 createdAt: "2026-02-26T12:45:00.000Z",
1678 updatedAt: "2026-02-26T12:45:00.000Z",
1679 },
1680 });
1681
1682 yield* appendAndProject({
1683 type: "thread.created",
1684 eventId: EventId.make("evt-nonstale-approval-2"),
1685 aggregateKind: "thread",
1686 aggregateId: ThreadId.make("thread-nonstale-approval"),
1687 occurredAt: "2026-02-26T12:45:01.000Z",
1688 commandId: CommandId.make("cmd-nonstale-approval-2"),
1689 causationEventId: null,
1690 correlationId: CorrelationId.make("cmd-nonstale-approval-2"),
1691 metadata: {},
1692 payload: {
1693 threadId: ThreadId.make("thread-nonstale-approval"),
1694 projectId: ProjectId.make("project-nonstale-approval"),
1695 title: "Thread Non-Stale Approval",
1696 modelSelection: {
1697 provider: "codex",
1698 model: "gpt-5-codex",
1699 },
1700 runtimeMode: "approval-required",
1701 interactionMode: "default",
1702 branch: null,
1703 worktreePath: null,
1704 createdAt: "2026-02-26T12:45:01.000Z",
1705 updatedAt: "2026-02-26T12:45:01.000Z",
1706 },
1707 });
1708
1709 yield* appendAndProject({
1710 type: "thread.activity-appended",
1711 eventId: EventId.make("evt-nonstale-approval-3"),
1712 aggregateKind: "thread",
1713 aggregateId: ThreadId.make("thread-nonstale-approval"),
1714 occurredAt: "2026-02-26T12:45:02.000Z",
1715 commandId: CommandId.make("cmd-nonstale-approval-3"),
1716 causationEventId: null,
1717 correlationId: CorrelationId.make("cmd-nonstale-approval-3"),
1718 metadata: {},
1719 payload: {
1720 threadId: ThreadId.make("thread-nonstale-approval"),
1721 activity: {
1722 id: EventId.make("activity-nonstale-approval-requested"),
1723 tone: "approval",
1724 kind: "approval.requested",
1725 summary: "Command approval requested",
1726 payload: {
1727 requestId: "approval-request-nonstale-existing",
1728 requestKind: "command",
1729 },
1730 turnId: null,
1731 createdAt: "2026-02-26T12:45:02.000Z",
1732 },
1733 },
1734 });
1735
1736 yield* appendAndProject({
1737 type: "thread.activity-appended",
1738 eventId: EventId.make("evt-nonstale-approval-4"),
1739 aggregateKind: "thread",
1740 aggregateId: ThreadId.make("thread-nonstale-approval"),
1741 occurredAt: "2026-02-26T12:45:03.000Z",
1742 commandId: CommandId.make("cmd-nonstale-approval-4"),
1743 causationEventId: null,
1744 correlationId: CorrelationId.make("cmd-nonstale-approval-4"),
1745 metadata: {},
1746 payload: {
1747 threadId: ThreadId.make("thread-nonstale-approval"),
1748 activity: {
1749 id: EventId.make("activity-nonstale-approval-failed-existing"),
1750 tone: "error",
1751 kind: "provider.approval.respond.failed",
1752 summary: "Provider approval response failed",
1753 payload: {
1754 requestId: "approval-request-nonstale-existing",
1755 detail: "Provider timed out while responding to approval request",
1756 },
1757 turnId: TurnId.make("turn-nonstale-failure"),
1758 createdAt: "2026-02-26T12:45:03.000Z",
1759 },
1760 },
1761 });
1762
1763 yield* appendAndProject({
1764 type: "thread.activity-appended",
1765 eventId: EventId.make("evt-nonstale-approval-5"),
1766 aggregateKind: "thread",
1767 aggregateId: ThreadId.make("thread-nonstale-approval"),
1768 occurredAt: "2026-02-26T12:45:04.000Z",
1769 commandId: CommandId.make("cmd-nonstale-approval-5"),
1770 causationEventId: null,
1771 correlationId: CorrelationId.make("cmd-nonstale-approval-5"),
1772 metadata: {},
1773 payload: {
1774 threadId: ThreadId.make("thread-nonstale-approval"),
1775 activity: {
1776 id: EventId.make("activity-nonstale-approval-failed-missing"),
1777 tone: "error",
1778 kind: "provider.approval.respond.failed",
1779 summary: "Provider approval response failed",
1780 payload: {
1781 requestId: "approval-request-nonstale-missing",
1782 detail: "Provider timed out while responding to approval request",
1783 },
1784 turnId: null,
1785 createdAt: "2026-02-26T12:45:04.000Z",
1786 },
1787 },
1788 });
1789
1790 const approvalRows = yield* sql<{
1791 readonly requestId: string;
1792 readonly status: string;
1793 readonly turnId: string | null;
1794 readonly createdAt: string;
1795 readonly resolvedAt: string | null;
1796 }>`
1797 SELECT
1798 request_id AS "requestId",
1799 status,
1800 turn_id AS "turnId",
1801 created_at AS "createdAt",
1802 resolved_at AS "resolvedAt"
1803 FROM projection_pending_approvals
1804 WHERE request_id IN (
1805 'approval-request-nonstale-existing',
1806 'approval-request-nonstale-missing'
1807 )
1808 ORDER BY request_id
1809 `;
1810 assert.deepEqual(approvalRows, [
1811 {
1812 requestId: "approval-request-nonstale-existing",
1813 status: "pending",
1814 turnId: null,
1815 createdAt: "2026-02-26T12:45:02.000Z",
1816 resolvedAt: null,
1817 },
1818 ]);
1819
1820 const threadRows = yield* sql<{
1821 readonly pendingApprovalCount: number;
1822 }>`
1823 SELECT pending_approval_count AS "pendingApprovalCount"
1824 FROM projection_threads
1825 WHERE thread_id = 'thread-nonstale-approval'
1826 `;
1827 assert.deepEqual(threadRows, [{ pendingApprovalCount: 1 }]);
1828 }),
1829 );
1830
1507 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>1831 it.effect("does not fallback-retain messages whose turnId is removed by revert", () =>
1508 Effect.gen(function* () {1832 Effect.gen(function* () {
1509 const projectionPipeline = yield* OrchestrationProjectionPipeline;1833 const projectionPipeline = yield* OrchestrationProjectionPipeline;
@@ -1516,16 +1840,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 6 unmodified lines
1516 1840
1517 yield* appendAndProject({1841 yield* appendAndProject({
1518 type: "project.created",1842 type: "project.created",
1519 eventId: EventId.makeUnsafe("evt-revert-1"),1843 eventId: EventId.make("evt-revert-1"),
1520 aggregateKind: "project",1844 aggregateKind: "project",
1521 aggregateId: ProjectId.makeUnsafe("project-revert"),1845 aggregateId: ProjectId.make("project-revert"),
1522 occurredAt: "2026-02-26T12:00:00.000Z",1846 occurredAt: "2026-02-26T12:00:00.000Z",
1523 commandId: CommandId.makeUnsafe("cmd-revert-1"),1847 commandId: CommandId.make("cmd-revert-1"),
1524 causationEventId: null,1848 causationEventId: null,
1525 correlationId: CorrelationId.makeUnsafe("cmd-revert-1"),1849 correlationId: CorrelationId.make("cmd-revert-1"),
1526 metadata: {},1850 metadata: {},
1527 payload: {1851 payload: {
1528 projectId: ProjectId.makeUnsafe("project-revert"),1852 projectId: ProjectId.make("project-revert"),
1529 title: "Project Revert",1853 title: "Project Revert",
1530 workspaceRoot: "/tmp/project-revert",1854 workspaceRoot: "/tmp/project-revert",
1531 defaultModelSelection: null,1855 defaultModelSelection: null,
@@ -1537,17 +1861,17 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 5 unmodified lines
1537 1861
1538 yield* appendAndProject({1862 yield* appendAndProject({
1539 type: "thread.created",1863 type: "thread.created",
1540 eventId: EventId.makeUnsafe("evt-revert-2"),1864 eventId: EventId.make("evt-revert-2"),
1541 aggregateKind: "thread",1865 aggregateKind: "thread",
1542 aggregateId: ThreadId.makeUnsafe("thread-revert"),1866 aggregateId: ThreadId.make("thread-revert"),
1543 occurredAt: "2026-02-26T12:00:01.000Z",1867 occurredAt: "2026-02-26T12:00:01.000Z",
1544 commandId: CommandId.makeUnsafe("cmd-revert-2"),1868 commandId: CommandId.make("cmd-revert-2"),
1545 causationEventId: null,1869 causationEventId: null,
1546 correlationId: CorrelationId.makeUnsafe("cmd-revert-2"),1870 correlationId: CorrelationId.make("cmd-revert-2"),
1547 metadata: {},1871 metadata: {},
1548 payload: {1872 payload: {
1549 threadId: ThreadId.makeUnsafe("thread-revert"),1873 threadId: ThreadId.make("thread-revert"),
1550 projectId: ProjectId.makeUnsafe("project-revert"),1874 projectId: ProjectId.make("project-revert"),
1551 title: "Thread Revert",1875 title: "Thread Revert",
1552 modelSelection: {1876 modelSelection: {
1553 provider: "codex",1877 provider: "codex",
@@ -1563,42 +1887,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 9 unmodified lines
1563 1887
1564 yield* appendAndProject({1888 yield* appendAndProject({
1565 type: "thread.turn-diff-completed",1889 type: "thread.turn-diff-completed",
1566 eventId: EventId.makeUnsafe("evt-revert-3"),1890 eventId: EventId.make("evt-revert-3"),
1567 aggregateKind: "thread",1891 aggregateKind: "thread",
1568 aggregateId: ThreadId.makeUnsafe("thread-revert"),1892 aggregateId: ThreadId.make("thread-revert"),
1569 occurredAt: "2026-02-26T12:00:02.000Z",1893 occurredAt: "2026-02-26T12:00:02.000Z",
1570 commandId: CommandId.makeUnsafe("cmd-revert-3"),1894 commandId: CommandId.make("cmd-revert-3"),
1571 causationEventId: null,1895 causationEventId: null,
1572 correlationId: CorrelationId.makeUnsafe("cmd-revert-3"),1896 correlationId: CorrelationId.make("cmd-revert-3"),
1573 metadata: {},1897 metadata: {},
1574 payload: {1898 payload: {
1575 threadId: ThreadId.makeUnsafe("thread-revert"),1899 threadId: ThreadId.make("thread-revert"),
1576 turnId: TurnId.makeUnsafe("turn-1"),1900 turnId: TurnId.make("turn-1"),
1577 checkpointTurnCount: 1,1901 checkpointTurnCount: 1,
1578 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/1"),1902 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/1"),
1579 status: "ready",1903 status: "ready",
1580 files: [],1904 files: [],
1581 assistantMessageId: MessageId.makeUnsafe("assistant-keep"),1905 assistantMessageId: MessageId.make("assistant-keep"),
1582 completedAt: "2026-02-26T12:00:02.000Z",1906 completedAt: "2026-02-26T12:00:02.000Z",
1583 },1907 },
1584 });1908 });
1585 1909
1586 yield* appendAndProject({1910 yield* appendAndProject({
1587 type: "thread.message-sent",1911 type: "thread.message-sent",
1588 eventId: EventId.makeUnsafe("evt-revert-4"),1912 eventId: EventId.make("evt-revert-4"),
1589 aggregateKind: "thread",1913 aggregateKind: "thread",
1590 aggregateId: ThreadId.makeUnsafe("thread-revert"),1914 aggregateId: ThreadId.make("thread-revert"),
1591 occurredAt: "2026-02-26T12:00:02.100Z",1915 occurredAt: "2026-02-26T12:00:02.100Z",
1592 commandId: CommandId.makeUnsafe("cmd-revert-4"),1916 commandId: CommandId.make("cmd-revert-4"),
1593 causationEventId: null,1917 causationEventId: null,
1594 correlationId: CorrelationId.makeUnsafe("cmd-revert-4"),1918 correlationId: CorrelationId.make("cmd-revert-4"),
1595 metadata: {},1919 metadata: {},
1596 payload: {1920 payload: {
1597 threadId: ThreadId.makeUnsafe("thread-revert"),1921 threadId: ThreadId.make("thread-revert"),
1598 messageId: MessageId.makeUnsafe("assistant-keep"),1922 messageId: MessageId.make("assistant-keep"),
1599 role: "assistant",1923 role: "assistant",
1600 text: "kept",1924 text: "kept",
1601 turnId: TurnId.makeUnsafe("turn-1"),1925 turnId: TurnId.make("turn-1"),
1602 streaming: false,1926 streaming: false,
1603 createdAt: "2026-02-26T12:00:02.100Z",1927 createdAt: "2026-02-26T12:00:02.100Z",
1604 updatedAt: "2026-02-26T12:00:02.100Z",1928 updatedAt: "2026-02-26T12:00:02.100Z",
@@ -1607,42 +1931,42 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
1607 1931
1608 yield* appendAndProject({1932 yield* appendAndProject({
1609 type: "thread.turn-diff-completed",1933 type: "thread.turn-diff-completed",
1610 eventId: EventId.makeUnsafe("evt-revert-5"),1934 eventId: EventId.make("evt-revert-5"),
1611 aggregateKind: "thread",1935 aggregateKind: "thread",
1612 aggregateId: ThreadId.makeUnsafe("thread-revert"),1936 aggregateId: ThreadId.make("thread-revert"),
1613 occurredAt: "2026-02-26T12:00:03.000Z",1937 occurredAt: "2026-02-26T12:00:03.000Z",
1614 commandId: CommandId.makeUnsafe("cmd-revert-5"),1938 commandId: CommandId.make("cmd-revert-5"),
1615 causationEventId: null,1939 causationEventId: null,
1616 correlationId: CorrelationId.makeUnsafe("cmd-revert-5"),1940 correlationId: CorrelationId.make("cmd-revert-5"),
1617 metadata: {},1941 metadata: {},
1618 payload: {1942 payload: {
1619 threadId: ThreadId.makeUnsafe("thread-revert"),1943 threadId: ThreadId.make("thread-revert"),
1620 turnId: TurnId.makeUnsafe("turn-2"),1944 turnId: TurnId.make("turn-2"),
1621 checkpointTurnCount: 2,1945 checkpointTurnCount: 2,
1622 checkpointRef: CheckpointRef.makeUnsafe("refs/t3/checkpoints/thread-revert/turn/2"),1946 checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-revert/turn/2"),
1623 status: "ready",1947 status: "ready",
1624 files: [],1948 files: [],
1625 assistantMessageId: MessageId.makeUnsafe("assistant-remove"),1949 assistantMessageId: MessageId.make("assistant-remove"),
1626 completedAt: "2026-02-26T12:00:03.000Z",1950 completedAt: "2026-02-26T12:00:03.000Z",
1627 },1951 },
1628 });1952 });
1629 1953
1630 yield* appendAndProject({1954 yield* appendAndProject({
1631 type: "thread.message-sent",1955 type: "thread.message-sent",
1632 eventId: EventId.makeUnsafe("evt-revert-6"),1956 eventId: EventId.make("evt-revert-6"),
1633 aggregateKind: "thread",1957 aggregateKind: "thread",
1634 aggregateId: ThreadId.makeUnsafe("thread-revert"),1958 aggregateId: ThreadId.make("thread-revert"),
1635 occurredAt: "2026-02-26T12:00:03.050Z",1959 occurredAt: "2026-02-26T12:00:03.050Z",
1636 commandId: CommandId.makeUnsafe("cmd-revert-6"),1960 commandId: CommandId.make("cmd-revert-6"),
1637 causationEventId: null,1961 causationEventId: null,
1638 correlationId: CorrelationId.makeUnsafe("cmd-revert-6"),1962 correlationId: CorrelationId.make("cmd-revert-6"),
1639 metadata: {},1963 metadata: {},
1640 payload: {1964 payload: {
1641 threadId: ThreadId.makeUnsafe("thread-revert"),1965 threadId: ThreadId.make("thread-revert"),
1642 messageId: MessageId.makeUnsafe("user-remove"),1966 messageId: MessageId.make("user-remove"),
1643 role: "user",1967 role: "user",
1644 text: "removed",1968 text: "removed",
1645 turnId: TurnId.makeUnsafe("turn-2"),1969 turnId: TurnId.make("turn-2"),
1646 streaming: false,1970 streaming: false,
1647 createdAt: "2026-02-26T12:00:03.050Z",1971 createdAt: "2026-02-26T12:00:03.050Z",
1648 updatedAt: "2026-02-26T12:00:03.050Z",1972 updatedAt: "2026-02-26T12:00:03.050Z",
@@ -1651,20 +1975,20 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
1651 1975
1652 yield* appendAndProject({1976 yield* appendAndProject({
1653 type: "thread.message-sent",1977 type: "thread.message-sent",
1654 eventId: EventId.makeUnsafe("evt-revert-7"),1978 eventId: EventId.make("evt-revert-7"),
1655 aggregateKind: "thread",1979 aggregateKind: "thread",
1656 aggregateId: ThreadId.makeUnsafe("thread-revert"),1980 aggregateId: ThreadId.make("thread-revert"),
1657 occurredAt: "2026-02-26T12:00:03.100Z",1981 occurredAt: "2026-02-26T12:00:03.100Z",
1658 commandId: CommandId.makeUnsafe("cmd-revert-7"),1982 commandId: CommandId.make("cmd-revert-7"),
1659 causationEventId: null,1983 causationEventId: null,
1660 correlationId: CorrelationId.makeUnsafe("cmd-revert-7"),1984 correlationId: CorrelationId.make("cmd-revert-7"),
1661 metadata: {},1985 metadata: {},
1662 payload: {1986 payload: {
1663 threadId: ThreadId.makeUnsafe("thread-revert"),1987 threadId: ThreadId.make("thread-revert"),
1664 messageId: MessageId.makeUnsafe("assistant-remove"),1988 messageId: MessageId.make("assistant-remove"),
1665 role: "assistant",1989 role: "assistant",
1666 text: "removed",1990 text: "removed",
1667 turnId: TurnId.makeUnsafe("turn-2"),1991 turnId: TurnId.make("turn-2"),
1668 streaming: false,1992 streaming: false,
1669 createdAt: "2026-02-26T12:00:03.100Z",1993 createdAt: "2026-02-26T12:00:03.100Z",
1670 updatedAt: "2026-02-26T12:00:03.100Z",1994 updatedAt: "2026-02-26T12:00:03.100Z",
@@ -1673,16 +1997,16 @@ it.layer(BaseTestLayer)("OrchestrationProjectionPipeline", (it) => { · 2 unmodified lines
1673 1997
1674 yield* appendAndProject({1998 yield* appendAndProject({
1675 type: "thread.reverted",1999 type: "thread.reverted",
1676 eventId: EventId.makeUnsafe("evt-revert-8"),2000 eventId: EventId.make("evt-revert-8"),
1677 aggregateKind: "thread",2001 aggregateKind: "thread",
1678 aggregateId: ThreadId.makeUnsafe("thread-revert"),2002 aggregateId: ThreadId.make("thread-revert"),
1679 occurredAt: "2026-02-26T12:00:04.000Z",2003 occurredAt: "2026-02-26T12:00:04.000Z",
1680 commandId: CommandId.makeUnsafe("cmd-revert-8"),2004 commandId: CommandId.make("cmd-revert-8"),
1681 causationEventId: null,2005 causationEventId: null,
1682 correlationId: CorrelationId.makeUnsafe("cmd-revert-8"),2006 correlationId: CorrelationId.make("cmd-revert-8"),
1683 metadata: {},2007 metadata: {},
1684 payload: {2008 payload: {
1685 threadId: ThreadId.makeUnsafe("thread-revert"),2009 threadId: ThreadId.make("thread-revert"),
1686 turnCount: 1,2010 turnCount: 1,
1687 },2011 },
1688 });2012 });
@@ -1724,10 +2048,10 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 35 unmodified lines
1724 Layer.provideMerge(persistenceLayer),2048 Layer.provideMerge(persistenceLayer),
1725 );2049 );
1726 2050
1727 const threadId = ThreadId.makeUnsafe("thread-restart");2051 const threadId = ThreadId.make("thread-restart");
1728 const turnId = TurnId.makeUnsafe("turn-restart");2052 const turnId = TurnId.make("turn-restart");
1729 const messageId = MessageId.makeUnsafe("message-restart");2053 const messageId = MessageId.make("message-restart");
1730 const sourcePlanThreadId = ThreadId.makeUnsafe("thread-plan-source");2054 const sourcePlanThreadId = ThreadId.make("thread-plan-source");
1731 const sourcePlanId = "plan-source";2055 const sourcePlanId = "plan-source";
1732 const turnStartedAt = "2026-02-26T14:00:00.000Z";2056 const turnStartedAt = "2026-02-26T14:00:00.000Z";
1733 const sessionSetAt = "2026-02-26T14:00:05.000Z";2057 const sessionSetAt = "2026-02-26T14:00:05.000Z";
@@ -1738,13 +2062,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 4 unmodified lines
1738 2062
1739 yield* eventStore.append({2063 yield* eventStore.append({
1740 type: "thread.turn-start-requested",2064 type: "thread.turn-start-requested",
1741 eventId: EventId.makeUnsafe("evt-restart-1"),2065 eventId: EventId.make("evt-restart-1"),
1742 aggregateKind: "thread",2066 aggregateKind: "thread",
1743 aggregateId: threadId,2067 aggregateId: threadId,
1744 occurredAt: turnStartedAt,2068 occurredAt: turnStartedAt,
1745 commandId: CommandId.makeUnsafe("cmd-restart-1"),2069 commandId: CommandId.make("cmd-restart-1"),
1746 causationEventId: null,2070 causationEventId: null,
1747 correlationId: CorrelationId.makeUnsafe("cmd-restart-1"),2071 correlationId: CorrelationId.make("cmd-restart-1"),
1748 metadata: {},2072 metadata: {},
1749 payload: {2073 payload: {
1750 threadId,2074 threadId,
@@ -1768,13 +2092,13 @@ it.effect("restores pending turn-start metadata across projection pipeline resta · 17 unmodified lines
1768 2092
1769 yield* eventStore.append({2093 yield* eventStore.append({
1770 type: "thread.session-set",2094 type: "thread.session-set",
1771 eventId: EventId.makeUnsafe("evt-restart-2"),2095 eventId: EventId.make("evt-restart-2"),
1772 aggregateKind: "thread",2096 aggregateKind: "thread",
1773 aggregateId: threadId,2097 aggregateId: threadId,
1774 occurredAt: sessionSetAt,2098 occurredAt: sessionSetAt,
1775 commandId: CommandId.makeUnsafe("cmd-restart-2"),2099 commandId: CommandId.make("cmd-restart-2"),
1776 causationEventId: null,2100 causationEventId: null,
1777 correlationId: CorrelationId.makeUnsafe("cmd-restart-2"),2101 correlationId: CorrelationId.make("cmd-restart-2"),
1778 metadata: {},2102 metadata: {},
1779 payload: {2103 payload: {
1780 threadId,2104 threadId,
@@ -1846,6 +2170,7 @@ const engineLayer = it.layer( · 65 unmodified lines
1846 Layer.provide(OrchestrationProjectionPipelineLive),2170 Layer.provide(OrchestrationProjectionPipelineLive),
1847 Layer.provide(OrchestrationEventStoreLive),2171 Layer.provide(OrchestrationEventStoreLive),
1848 Layer.provide(OrchestrationCommandReceiptRepositoryLive),2172 Layer.provide(OrchestrationCommandReceiptRepositoryLive),
2173 Layer.provide(RepositoryIdentityResolverLive),
1849 Layer.provideMerge(SqlitePersistenceMemory),2174 Layer.provideMerge(SqlitePersistenceMemory),
1850 Layer.provideMerge(2175 Layer.provideMerge(
1851 ServerConfig.layerTest(process.cwd(), {2176 ServerConfig.layerTest(process.cwd(), {
@@ -1865,8 +2190,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 13 unmodified lines
1865 2190
1866 yield* engine.dispatch({2191 yield* engine.dispatch({
1867 type: "project.create",2192 type: "project.create",
1868 commandId: CommandId.makeUnsafe("cmd-live-project"),2193 commandId: CommandId.make("cmd-live-project"),
1869 projectId: ProjectId.makeUnsafe("project-live"),2194 projectId: ProjectId.make("project-live"),
1870 title: "Live Project",2195 title: "Live Project",
1871 workspaceRoot: "/tmp/project-live",2196 workspaceRoot: "/tmp/project-live",
1872 defaultModelSelection: {2197 defaultModelSelection: {
@@ -1903,8 +2228,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 30 unmodified lines
1903 2228
1904 yield* engine.dispatch({2229 yield* engine.dispatch({
1905 type: "project.create",2230 type: "project.create",
1906 commandId: CommandId.makeUnsafe("cmd-scripts-project-create"),2231 commandId: CommandId.make("cmd-scripts-project-create"),
1907 projectId: ProjectId.makeUnsafe("project-scripts"),2232 projectId: ProjectId.make("project-scripts"),
1908 title: "Scripts Project",2233 title: "Scripts Project",
1909 workspaceRoot: "/tmp/project-scripts",2234 workspaceRoot: "/tmp/project-scripts",
1910 defaultModelSelection: {2235 defaultModelSelection: {
@@ -1916,8 +2241,8 @@ engineLayer("OrchestrationProjectionPipeline via engine dispatch", (it) => { · 5 unmodified lines
1916 2241
1917 yield* engine.dispatch({2242 yield* engine.dispatch({
1918 type: "project.meta.update",2243 type: "project.meta.update",
1919 commandId: CommandId.makeUnsafe("cmd-scripts-project-update"),2244 commandId: CommandId.make("cmd-scripts-project-update"),
1920 projectId: ProjectId.makeUnsafe("project-scripts"),2245 projectId: ProjectId.make("project-scripts"),
1921 scripts: [2246 scripts: [
1922 {2247 {
1923 id: "script-1",2248 id: "script-1",
apps/server/src/orchestration/Layers/ProjectionPipeline.ts+173−2
@@ -2,6 +2,7 @@ import { · 1 unmodified line
2 ApprovalRequestId,2 ApprovalRequestId,
3 type ChatAttachment,3 type ChatAttachment,
4 type OrchestrationEvent,4 type OrchestrationEvent,
5 ThreadId,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
6import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";7import { Effect, FileSystem, Layer, Option, Path, Stream } from "effect";
7import * as SqlClient from "effect/unstable/sql/SqlClient";8import * as SqlClient from "effect/unstable/sql/SqlClient";
@@ -86,7 +87,89 @@ function extractActivityRequestId(payload: unknown): ApprovalRequestId | null { · 78 unmodified lines
86 return null;87 return null;
87 }88 }
88 const requestId = (payload as Record<string, unknown>).requestId;89 const requestId = (payload as Record<string, unknown>).requestId;
89 return typeof requestId === "string" ? ApprovalRequestId.makeUnsafe(requestId) : null;90 return typeof requestId === "string" ? ApprovalRequestId.make(requestId) : null;
91}
92
93function isStalePendingApprovalFailureDetail(detail: string | null): boolean {
94 if (detail === null) {
95 return false;
96 }
97 return (
98 detail.includes("stale pending approval request") ||
99 detail.includes("unknown pending approval request") ||
100 detail.includes("unknown pending permission request")
101 );
102}
103
104function derivePendingUserInputCountFromActivities(
105 activities: ReadonlyArray<ProjectionThreadActivity>,
106): number {
107 const openRequestIds = new Set<string>();
108 const ordered = [...activities].toSorted(
109 (left, right) =>
110 left.createdAt.localeCompare(right.createdAt) ||
111 left.activityId.localeCompare(right.activityId),
112 );
113
114 for (const activity of ordered) {
115 const requestId = extractActivityRequestId(activity.payload);
116 if (requestId === null) {
117 continue;
118 }
119 const payload =
120 typeof activity.payload === "object" && activity.payload !== null
121 ? (activity.payload as Record<string, unknown>)
122 : null;
123 const detail = typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
124
125 if (activity.kind === "user-input.requested") {
126 openRequestIds.add(requestId);
127 continue;
128 }
129
130 if (activity.kind === "user-input.resolved") {
131 openRequestIds.delete(requestId);
132 continue;
133 }
134
135 if (
136 activity.kind === "provider.user-input.respond.failed" &&
137 detail !== null &&
138 (detail.includes("stale pending user-input request") ||
139 detail.includes("unknown pending user-input request"))
140 ) {
141 openRequestIds.delete(requestId);
142 }
143 }
144
145 return openRequestIds.size;
146}
147
148function deriveHasActionableProposedPlan(input: {
149 readonly latestTurnId: string | null;
150 readonly proposedPlans: ReadonlyArray<ProjectionThreadProposedPlan>;
151}): boolean {
152 const sorted = [...input.proposedPlans].toSorted(
153 (left, right) =>
154 left.updatedAt.localeCompare(right.updatedAt) || left.planId.localeCompare(right.planId),
155 );
156
157 let latestForTurn: ProjectionThreadProposedPlan | null = null;
158 if (input.latestTurnId !== null) {
159 for (let index = sorted.length - 1; index >= 0; index -= 1) {
160 const plan = sorted[index];
161 if (plan?.turnId === input.latestTurnId) {
162 latestForTurn = plan;
163 break;
164 }
165 }
166 }
167 if (latestForTurn !== null) {
168 return latestForTurn.implementedAt === null;
169 }
170
171 const latestPlan = sorted.at(-1) ?? null;
172 return latestPlan !== null && latestPlan.implementedAt === null;
90}173}
91 174
92function retainProjectionMessagesAfterRevert(175function retainProjectionMessagesAfterRevert(
@@ -432,6 +515,48 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 339 unmodified lines
432 }515 }
433 });516 });
434 517
518 const refreshThreadShellSummary = Effect.fn("refreshThreadShellSummary")(function* (
519 threadId: ThreadId,
520 ) {
521 const existingRow = yield* projectionThreadRepository.getById({
522 threadId,
523 });
524 if (Option.isNone(existingRow)) {
525 return;
526 }
527
528 const [messages, proposedPlans, activities, pendingApprovals] = yield* Effect.all([
529 projectionThreadMessageRepository.listByThreadId({ threadId }),
530 projectionThreadProposedPlanRepository.listByThreadId({ threadId }),
531 projectionThreadActivityRepository.listByThreadId({ threadId }),
532 projectionPendingApprovalRepository.listByThreadId({ threadId }),
533 ]);
534
535 const latestUserMessageAt =
536 messages
537 .filter((message) => message.role === "user")
538 .map((message) => message.createdAt)
539 .toSorted()
540 .at(-1) ?? null;
541
542 const pendingApprovalCount = pendingApprovals.filter(
543 (approval) => approval.status === "pending",
544 ).length;
545 const pendingUserInputCount = derivePendingUserInputCountFromActivities(activities);
546 const hasActionableProposedPlan = deriveHasActionableProposedPlan({
547 latestTurnId: existingRow.value.latestTurnId,
548 proposedPlans,
549 });
550
551 yield* projectionThreadRepository.upsert({
552 ...existingRow.value,
553 latestUserMessageAt,
554 pendingApprovalCount,
555 pendingUserInputCount,
556 hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0,
557 });
558 });
559
435 const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(560 const applyThreadsProjection: ProjectorDefinition["apply"] = Effect.fn(
436 "applyThreadsProjection",561 "applyThreadsProjection",
437 )(function* (event, attachmentSideEffects) {562 )(function* (event, attachmentSideEffects) {
@@ -450,6 +575,10 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 12 unmodified lines
450 createdAt: event.payload.createdAt,575 createdAt: event.payload.createdAt,
451 updatedAt: event.payload.updatedAt,576 updatedAt: event.payload.updatedAt,
452 archivedAt: null,577 archivedAt: null,
578 latestUserMessageAt: null,
579 pendingApprovalCount: 0,
580 pendingUserInputCount: 0,
581 hasActionableProposedPlan: 0,
453 deletedAt: null,582 deletedAt: null,
454 });583 });
455 return;584 return;
@@ -554,7 +683,9 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 98 unmodified lines
554 683
555 case "thread.message-sent":684 case "thread.message-sent":
556 case "thread.proposed-plan-upserted":685 case "thread.proposed-plan-upserted":
557 case "thread.activity-appended": {686 case "thread.activity-appended":
687 case "thread.approval-response-requested":
688 case "thread.user-input-response-requested": {
558 const existingRow = yield* projectionThreadRepository.getById({689 const existingRow = yield* projectionThreadRepository.getById({
559 threadId: event.payload.threadId,690 threadId: event.payload.threadId,
560 });691 });
@@ -565,6 +696,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 4 unmodified lines
565 ...existingRow.value,696 ...existingRow.value,
566 updatedAt: event.occurredAt,697 updatedAt: event.occurredAt,
567 });698 });
699 yield* refreshThreadShellSummary(event.payload.threadId);
568 return;700 return;
569 }701 }
570 702
@@ -580,6 +712,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
580 latestTurnId: event.payload.session.activeTurnId,712 latestTurnId: event.payload.session.activeTurnId,
581 updatedAt: event.occurredAt,713 updatedAt: event.occurredAt,
582 });714 });
715 yield* refreshThreadShellSummary(event.payload.threadId);
583 return;716 return;
584 }717 }
585 718
@@ -595,6 +728,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
595 latestTurnId: event.payload.turnId,728 latestTurnId: event.payload.turnId,
596 updatedAt: event.occurredAt,729 updatedAt: event.occurredAt,
597 });730 });
731 yield* refreshThreadShellSummary(event.payload.threadId);
598 return;732 return;
599 }733 }
600 734
@@ -610,6 +744,7 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 9 unmodified lines
610 latestTurnId: null,744 latestTurnId: null,
611 updatedAt: event.occurredAt,745 updatedAt: event.occurredAt,
612 });746 });
747 yield* refreshThreadShellSummary(event.payload.threadId);
613 return;748 return;
614 }749 }
615 750
@@ -1121,6 +1256,42 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti · 505 unmodified lines
1121 });1256 });
1122 return;1257 return;
1123 }1258 }
1259 if (event.payload.activity.kind === "provider.approval.respond.failed") {
1260 const payload =
1261 typeof event.payload.activity.payload === "object" &&
1262 event.payload.activity.payload !== null
1263 ? (event.payload.activity.payload as Record<string, unknown>)
1264 : null;
1265 const detail =
1266 typeof payload?.detail === "string" ? payload.detail.toLowerCase() : null;
1267 if (isStalePendingApprovalFailureDetail(detail)) {
1268 if (Option.isNone(existingRow)) {
1269 return;
1270 }
1271 if (existingRow.value.status === "resolved") {
1272 return;
1273 }
1274 yield* projectionPendingApprovalRepository.upsert({
1275 requestId,
1276 threadId: existingRow.value.threadId,
1277 turnId: existingRow.value.turnId,
1278 status: "resolved",
1279 decision: null,
1280 createdAt: existingRow.value.createdAt,
1281 resolvedAt: event.payload.activity.createdAt,
1282 });
1283 return;
1284 }
1285 return;
1286 }
1287 // Only approval-requested activities should create pending-approval
1288 // rows. Other activity kinds that happen to carry a requestId
1289 // (e.g. user-input.requested / user-input.resolved) must not
1290 // pollute this projection — they have their own accounting via
1291 // derivePendingUserInputCountFromActivities.
1292 if (event.payload.activity.kind !== "approval.requested") {
1293 return;
1294 }
1124 if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {1295 if (Option.isSome(existingRow) && existingRow.value.status === "resolved") {
1125 return;1296 return;
1126 }1297 }
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+410−13
@@ -4,18 +4,22 @@ import { Effect, Layer } from "effect"; · 3 unmodified lines
4import * as SqlClient from "effect/unstable/sql/SqlClient";4import * as SqlClient from "effect/unstable/sql/SqlClient";
5 5
6import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";6import { SqlitePersistenceMemory } from "../../persistence/Layers/Sqlite.ts";
7import { RepositoryIdentityResolverLive } from "../../project/Layers/RepositoryIdentityResolver.ts";
7import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";8import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
8import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";9import { OrchestrationProjectionSnapshotQueryLive } from "./ProjectionSnapshotQuery.ts";
9import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";10import { ProjectionSnapshotQuery } from "../Services/ProjectionSnapshotQuery.ts";
10 11
11const asProjectId = (value: string): ProjectId => ProjectId.makeUnsafe(value);12const asProjectId = (value: string): ProjectId => ProjectId.make(value);
12const asTurnId = (value: string): TurnId => TurnId.makeUnsafe(value);13const asTurnId = (value: string): TurnId => TurnId.make(value);
13const asMessageId = (value: string): MessageId => MessageId.makeUnsafe(value);14const asMessageId = (value: string): MessageId => MessageId.make(value);
14const asEventId = (value: string): EventId => EventId.makeUnsafe(value);15const asEventId = (value: string): EventId => EventId.make(value);
15const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.makeUnsafe(value);16const asCheckpointRef = (value: string): CheckpointRef => CheckpointRef.make(value);
16 17
17const projectionSnapshotLayer = it.layer(18const projectionSnapshotLayer = it.layer(
18 OrchestrationProjectionSnapshotQueryLive.pipe(Layer.provideMerge(SqlitePersistenceMemory)),19 OrchestrationProjectionSnapshotQueryLive.pipe(
20 Layer.provideMerge(RepositoryIdentityResolverLive),
21 Layer.provideMerge(SqlitePersistenceMemory),
22 ),
19);23);
20 24
21projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {25projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
@@ -58,9 +62,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 36 unmodified lines
58 project_id,62 project_id,
59 title,63 title,
60 model_selection_json,64 model_selection_json,
65 runtime_mode,
66 interaction_mode,
61 branch,67 branch,
62 worktree_path,68 worktree_path,
63 latest_turn_id,69 latest_turn_id,
70 latest_user_message_at,
71 pending_approval_count,
72 pending_user_input_count,
73 has_actionable_proposed_plan,
64 created_at,74 created_at,
65 updated_at,75 updated_at,
66 deleted_at76 deleted_at
@@ -70,9 +80,15 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines
70 'project-1',80 'project-1',
71 'Thread 1',81 'Thread 1',
72 '{"provider":"codex","model":"gpt-5-codex"}',82 '{"provider":"codex","model":"gpt-5-codex"}',
83 'full-access',
84 'default',
73 NULL,85 NULL,
74 NULL,86 NULL,
75 'turn-1',87 'turn-1',
88 '2026-02-24T00:00:04.000Z',
89 1,
90 0,
91 0,
76 '2026-02-24T00:00:02.000Z',92 '2026-02-24T00:00:02.000Z',
77 '2026-02-24T00:00:03.000Z',93 '2026-02-24T00:00:03.000Z',
78 NULL94 NULL
@@ -234,6 +250,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 155 unmodified lines
234 id: asProjectId("project-1"),250 id: asProjectId("project-1"),
235 title: "Project 1",251 title: "Project 1",
236 workspaceRoot: "/tmp/project-1",252 workspaceRoot: "/tmp/project-1",
253 repositoryIdentity: null,
237 defaultModelSelection: {254 defaultModelSelection: {
238 provider: "codex",255 provider: "codex",
239 model: "gpt-5-codex",256 model: "gpt-5-codex",
@@ -254,7 +271,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 14 unmodified lines
254 ]);271 ]);
255 assert.deepEqual(snapshot.threads, [272 assert.deepEqual(snapshot.threads, [
256 {273 {
257 id: ThreadId.makeUnsafe("thread-1"),274 id: ThreadId.make("thread-1"),
258 projectId: asProjectId("project-1"),275 projectId: asProjectId("project-1"),
259 title: "Thread 1",276 title: "Thread 1",
260 modelSelection: {277 modelSelection: {
@@ -273,7 +290,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 12 unmodified lines
273 completedAt: "2026-02-24T00:00:08.000Z",290 completedAt: "2026-02-24T00:00:08.000Z",
274 assistantMessageId: asMessageId("message-1"),291 assistantMessageId: asMessageId("message-1"),
275 sourceProposedPlan: {292 sourceProposedPlan: {
276 threadId: ThreadId.makeUnsafe("thread-1"),293 threadId: ThreadId.make("thread-1"),
277 planId: "plan-1",294 planId: "plan-1",
278 },295 },
279 },296 },
@@ -298,7 +315,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 18 unmodified lines
298 turnId: asTurnId("turn-1"),315 turnId: asTurnId("turn-1"),
299 planMarkdown: "# Ship it",316 planMarkdown: "# Ship it",
300 implementedAt: "2026-02-24T00:00:05.500Z",317 implementedAt: "2026-02-24T00:00:05.500Z",
301 implementationThreadId: ThreadId.makeUnsafe("thread-2"),318 implementationThreadId: ThreadId.make("thread-2"),
302 createdAt: "2026-02-24T00:00:05.000Z",319 createdAt: "2026-02-24T00:00:05.000Z",
303 updatedAt: "2026-02-24T00:00:05.500Z",320 updatedAt: "2026-02-24T00:00:05.500Z",
304 },321 },
@@ -326,7 +343,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines
326 },343 },
327 ],344 ],
328 session: {345 session: {
329 threadId: ThreadId.makeUnsafe("thread-1"),346 threadId: ThreadId.make("thread-1"),
330 status: "running",347 status: "running",
331 providerName: "codex",348 providerName: "codex",
332 runtimeMode: "approval-required",349 runtimeMode: "approval-required",
@@ -336,6 +353,81 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 3 unmodified lines
336 },353 },
337 },354 },
338 ]);355 ]);
356
357 const shellSnapshot = yield* snapshotQuery.getShellSnapshot();
358 assert.equal(shellSnapshot.snapshotSequence, 5);
359 assert.deepEqual(shellSnapshot.projects, [
360 {
361 id: asProjectId("project-1"),
362 title: "Project 1",
363 workspaceRoot: "/tmp/project-1",
364 repositoryIdentity: null,
365 defaultModelSelection: {
366 provider: "codex",
367 model: "gpt-5-codex",
368 },
369 scripts: [
370 {
371 id: "script-1",
372 name: "Build",
373 command: "bun run build",
374 icon: "build",
375 runOnWorktreeCreate: false,
376 },
377 ],
378 createdAt: "2026-02-24T00:00:00.000Z",
379 updatedAt: "2026-02-24T00:00:01.000Z",
380 },
381 ]);
382 assert.deepEqual(shellSnapshot.threads, [
383 {
384 id: ThreadId.make("thread-1"),
385 projectId: asProjectId("project-1"),
386 title: "Thread 1",
387 modelSelection: {
388 provider: "codex",
389 model: "gpt-5-codex",
390 },
391 interactionMode: "default",
392 runtimeMode: "full-access",
393 branch: null,
394 worktreePath: null,
395 latestTurn: {
396 turnId: asTurnId("turn-1"),
397 state: "completed",
398 requestedAt: "2026-02-24T00:00:08.000Z",
399 startedAt: "2026-02-24T00:00:08.000Z",
400 completedAt: "2026-02-24T00:00:08.000Z",
401 assistantMessageId: asMessageId("message-1"),
402 sourceProposedPlan: {
403 threadId: ThreadId.make("thread-1"),
404 planId: "plan-1",
405 },
406 },
407 createdAt: "2026-02-24T00:00:02.000Z",
408 updatedAt: "2026-02-24T00:00:03.000Z",
409 archivedAt: null,
410 session: {
411 threadId: ThreadId.make("thread-1"),
412 status: "running",
413 providerName: "codex",
414 runtimeMode: "approval-required",
415 activeTurnId: asTurnId("turn-1"),
416 lastError: null,
417 updatedAt: "2026-02-24T00:00:07.000Z",
418 },
419 latestUserMessageAt: "2026-02-24T00:00:04.000Z",
420 hasPendingApprovals: true,
421 hasPendingUserInput: false,
422 hasActionableProposedPlan: false,
423 },
424 ]);
425
426 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
427 assert.equal(threadDetail._tag, "Some");
428 if (threadDetail._tag === "Some") {
429 assert.deepEqual(threadDetail.value, snapshot.threads[0]);
430 }
339 }),431 }),
340 );432 );
341 433
@@ -468,7 +560,7 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 126 unmodified lines
468 );560 );
469 assert.equal(firstThreadId._tag, "Some");561 assert.equal(firstThreadId._tag, "Some");
470 if (firstThreadId._tag === "Some") {562 if (firstThreadId._tag === "Some") {
471 assert.equal(firstThreadId.value, ThreadId.makeUnsafe("thread-first"));563 assert.equal(firstThreadId.value, ThreadId.make("thread-first"));
472 }564 }
473 }),565 }),
474 );566 );
@@ -591,12 +683,12 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 116 unmodified lines
591 `;683 `;
592 684
593 const context = yield* snapshotQuery.getThreadCheckpointContext(685 const context = yield* snapshotQuery.getThreadCheckpointContext(
594 ThreadId.makeUnsafe("thread-context"),686 ThreadId.make("thread-context"),
595 );687 );
596 assert.equal(context._tag, "Some");688 assert.equal(context._tag, "Some");
597 if (context._tag === "Some") {689 if (context._tag === "Some") {
598 assert.deepEqual(context.value, {690 assert.deepEqual(context.value, {
599 threadId: ThreadId.makeUnsafe("thread-context"),691 threadId: ThreadId.make("thread-context"),
600 projectId: asProjectId("project-context"),692 projectId: asProjectId("project-context"),
601 workspaceRoot: "/tmp/context-workspace",693 workspaceRoot: "/tmp/context-workspace",
602 worktreePath: "/tmp/context-worktree",694 worktreePath: "/tmp/context-worktree",
@@ -624,4 +716,309 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => { · 21 unmodified lines
624 }716 }
625 }),717 }),
626 );718 );
719
720 it.effect("keeps thread detail activity ordering consistent with shell snapshot ordering", () =>
721 Effect.gen(function* () {
722 const snapshotQuery = yield* ProjectionSnapshotQuery;
723 const sql = yield* SqlClient.SqlClient;
724
725 yield* sql`DELETE FROM projection_projects`;
726 yield* sql`DELETE FROM projection_threads`;
727 yield* sql`DELETE FROM projection_thread_activities`;
728 yield* sql`DELETE FROM projection_state`;
729
730 yield* sql`
731 INSERT INTO projection_projects (
732 project_id,
733 title,
734 workspace_root,
735 default_model_selection_json,
736 scripts_json,
737 created_at,
738 updated_at,
739 deleted_at
740 )
741 VALUES (
742 'project-1',
743 'Project 1',
744 '/tmp/project-1',
745 '{"provider":"codex","model":"gpt-5-codex"}',
746 '[]',
747 '2026-04-01T00:00:00.000Z',
748 '2026-04-01T00:00:01.000Z',
749 NULL
750 )
751 `;
752
753 yield* sql`
754 INSERT INTO projection_threads (
755 thread_id,
756 project_id,
757 title,
758 model_selection_json,
759 runtime_mode,
760 interaction_mode,
761 branch,
762 worktree_path,
763 latest_turn_id,
764 latest_user_message_at,
765 pending_approval_count,
766 pending_user_input_count,
767 has_actionable_proposed_plan,
768 created_at,
769 updated_at,
770 deleted_at
771 )
772 VALUES (
773 'thread-1',
774 'project-1',
775 'Thread 1',
776 '{"provider":"codex","model":"gpt-5-codex"}',
777 'full-access',
778 'default',
779 NULL,
780 NULL,
781 NULL,
782 NULL,
783 0,
784 0,
785 0,
786 '2026-04-01T00:00:02.000Z',
787 '2026-04-01T00:00:03.000Z',
788 NULL
789 )
790 `;
791
792 yield* sql`
793 INSERT INTO projection_thread_activities (
794 activity_id,
795 thread_id,
796 turn_id,
797 tone,
798 kind,
799 summary,
800 payload_json,
801 sequence,
802 created_at
803 )
804 VALUES
805 (
806 'activity-unsequenced',
807 'thread-1',
808 NULL,
809 'info',
810 'runtime.note',
811 'unsequenced first',
812 '{"source":"unsequenced"}',
813 NULL,
814 '2026-04-01T00:00:06.000Z'
815 ),
816 (
817 'activity-sequence-2',
818 'thread-1',
819 NULL,
820 'info',
821 'runtime.note',
822 'sequence two',
823 '{"source":"sequence-2"}',
824 2,
825 '2026-04-01T00:00:04.000Z'
826 ),
827 (
828 'activity-sequence-1',
829 'thread-1',
830 NULL,
831 'info',
832 'runtime.note',
833 'sequence one',
834 '{"source":"sequence-1"}',
835 1,
836 '2026-04-01T00:00:05.000Z'
837 )
838 `;
839
840 const snapshot = yield* snapshotQuery.getSnapshot();
841 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
842
843 assert.equal(threadDetail._tag, "Some");
844 if (threadDetail._tag === "Some") {
845 assert.deepEqual(threadDetail.value.activities, snapshot.threads[0]?.activities ?? []);
846 }
847
848 assert.deepEqual(snapshot.threads[0]?.activities ?? [], [
849 {
850 id: asEventId("activity-unsequenced"),
851 tone: "info",
852 kind: "runtime.note",
853 summary: "unsequenced first",
854 payload: { source: "unsequenced" },
855 turnId: null,
856 createdAt: "2026-04-01T00:00:06.000Z",
857 },
858 {
859 id: asEventId("activity-sequence-1"),
860 tone: "info",
861 kind: "runtime.note",
862 summary: "sequence one",
863 payload: { source: "sequence-1" },
864 turnId: null,
865 sequence: 1,
866 createdAt: "2026-04-01T00:00:05.000Z",
867 },
868 {
869 id: asEventId("activity-sequence-2"),
870 tone: "info",
871 kind: "runtime.note",
872 summary: "sequence two",
873 payload: { source: "sequence-2" },
874 turnId: null,
875 sequence: 2,
876 createdAt: "2026-04-01T00:00:04.000Z",
877 },
878 ]);
879 }),
880 );
881
882 it.effect("uses projection_threads.latest_turn_id for targeted thread latest turn queries", () =>
883 Effect.gen(function* () {
884 const snapshotQuery = yield* ProjectionSnapshotQuery;
885 const sql = yield* SqlClient.SqlClient;
886
887 yield* sql`DELETE FROM projection_projects`;
888 yield* sql`DELETE FROM projection_threads`;
889 yield* sql`DELETE FROM projection_turns`;
890
891 yield* sql`
892 INSERT INTO projection_projects (
893 project_id,
894 title,
895 workspace_root,
896 default_model_selection_json,
897 scripts_json,
898 created_at,
899 updated_at,
900 deleted_at
901 )
902 VALUES (
903 'project-1',
904 'Project 1',
905 '/tmp/project-1',
906 '{"provider":"codex","model":"gpt-5-codex"}',
907 '[]',
908 '2026-04-02T00:00:00.000Z',
909 '2026-04-02T00:00:01.000Z',
910 NULL
911 )
912 `;
913
914 yield* sql`
915 INSERT INTO projection_threads (
916 thread_id,
917 project_id,
918 title,
919 model_selection_json,
920 runtime_mode,
921 interaction_mode,
922 branch,
923 worktree_path,
924 latest_turn_id,
925 latest_user_message_at,
926 pending_approval_count,
927 pending_user_input_count,
928 has_actionable_proposed_plan,
929 created_at,
930 updated_at,
931 archived_at,
932 deleted_at
933 )
934 VALUES (
935 'thread-1',
936 'project-1',
937 'Thread 1',
938 '{"provider":"codex","model":"gpt-5-codex"}',
939 'full-access',
940 'default',
941 NULL,
942 NULL,
943 'turn-running',
944 '2026-04-02T00:00:04.000Z',
945 0,
946 0,
947 0,
948 '2026-04-02T00:00:02.000Z',
949 '2026-04-02T00:00:03.000Z',
950 NULL,
951 NULL
952 )
953 `;
954
955 yield* sql`
956 INSERT INTO projection_turns (
957 thread_id,
958 turn_id,
959 pending_message_id,
960 source_proposed_plan_thread_id,
961 source_proposed_plan_id,
962 assistant_message_id,
963 state,
964 requested_at,
965 started_at,
966 completed_at,
967 checkpoint_turn_count,
968 checkpoint_ref,
969 checkpoint_status,
970 checkpoint_files_json
971 )
972 VALUES
973 (
974 'thread-1',
975 'turn-completed',
976 'message-user-1',
977 NULL,
978 NULL,
979 'message-assistant-1',
980 'completed',
981 '2026-04-02T00:00:05.000Z',
982 '2026-04-02T00:00:06.000Z',
983 '2026-04-02T00:00:20.000Z',
984 5,
985 'checkpoint-5',
986 'ready',
987 '[]'
988 ),
989 (
990 'thread-1',
991 'turn-running',
992 'message-user-2',
993 NULL,
994 NULL,
995 NULL,
996 'running',
997 '2026-04-02T00:00:30.000Z',
998 '2026-04-02T00:00:30.000Z',
999 NULL,
1000 NULL,
1001 NULL,
1002 NULL,
1003 '[]'
1004 )
1005 `;
1006
1007 const threadShell = yield* snapshotQuery.getThreadShellById(ThreadId.make("thread-1"));
1008 assert.equal(threadShell._tag, "Some");
1009 if (threadShell._tag === "Some") {
1010 assert.equal(threadShell.value.latestTurn?.turnId, asTurnId("turn-running"));
1011 assert.equal(threadShell.value.latestTurn?.state, "running");
1012 assert.equal(threadShell.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1013 }
1014
1015 const threadDetail = yield* snapshotQuery.getThreadDetailById(ThreadId.make("thread-1"));
1016 assert.equal(threadDetail._tag, "Some");
1017 if (threadDetail._tag === "Some") {
1018 assert.equal(threadDetail.value.latestTurn?.turnId, asTurnId("turn-running"));
1019 assert.equal(threadDetail.value.latestTurn?.state, "running");
1020 assert.equal(threadDetail.value.latestTurn?.startedAt, "2026-04-02T00:00:30.000Z");
1021 }
1022 }),
1023 );
627});1024});
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+860−245
@@ -6,16 +6,19 @@ import { · 5 unmodified lines
6 OrchestrationCheckpointFile,6 OrchestrationCheckpointFile,
7 OrchestrationProposedPlanId,7 OrchestrationProposedPlanId,
8 OrchestrationReadModel,8 OrchestrationReadModel,
9 OrchestrationShellSnapshot,
10 OrchestrationThread,
9 ProjectScript,11 ProjectScript,
10 TurnId,12 TurnId,
11 type OrchestrationCheckpointSummary,13 type OrchestrationCheckpointSummary,
12 type OrchestrationLatestTurn,14 type OrchestrationLatestTurn,
13 type OrchestrationMessage,15 type OrchestrationMessage,
16 type OrchestrationProjectShell,
14 type OrchestrationProposedPlan,17 type OrchestrationProposedPlan,
15 type OrchestrationProject,18 type OrchestrationProject,
16 type OrchestrationSession,19 type OrchestrationSession,
17 type OrchestrationThread,
18 type OrchestrationThreadActivity,20 type OrchestrationThreadActivity,
21 type OrchestrationThreadShell,
19 ModelSelection,22 ModelSelection,
20 ProjectId,23 ProjectId,
21 ThreadId,24 ThreadId,
@@ -38,6 +41,7 @@ import { ProjectionThreadMessage } from "../../persistence/Services/ProjectionTh · 16 unmodified lines
38import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";41import { ProjectionThreadProposedPlan } from "../../persistence/Services/ProjectionThreadProposedPlans.ts";
39import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";42import { ProjectionThreadSession } from "../../persistence/Services/ProjectionThreadSessions.ts";
40import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";43import { ProjectionThread } from "../../persistence/Services/ProjectionThreads.ts";
44import { RepositoryIdentityResolver } from "../../project/Services/RepositoryIdentityResolver.ts";
41import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";45import { ORCHESTRATION_PROJECTOR_NAMES } from "./ProjectionPipeline.ts";
42import {46import {
43 ProjectionSnapshotQuery,47 ProjectionSnapshotQuery,

1208 more lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderCommandReactor.test.ts+232−152

This diff is large: its 984 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+10−11

This diff is large: its 86 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.test.ts+45−49

This diff is large: its 320 lines aren’t shown here.

apps/server/src/orchestration/Layers/ProviderRuntimeIngestion.ts+9−10

This diff is large: its 78 lines aren’t shown here.

apps/server/src/orchestration/Layers/RuntimeReceiptBus.ts+20−2

This diff is large: its 38 lines aren’t shown here.

apps/server/src/orchestration/Normalizer.ts+26−5

This diff is large: its 48 lines aren’t shown here.

apps/server/src/orchestration/Services/CheckpointReactor.ts+4−5

This diff is large: its 20 lines aren’t shown here.

apps/server/src/orchestration/Services/OrchestrationEngine.ts+3−3

This diff is large: its 27 lines aren’t shown here.

apps/server/src/orchestration/Services/OrchestrationReactor.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProjectionPipeline.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+38−2

This diff is large: its 77 lines aren’t shown here.

apps/server/src/orchestration/Services/ProviderCommandReactor.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/ProviderRuntimeIngestion.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/orchestration/Services/RuntimeReceiptBus.ts+21−6

This diff is large: its 38 lines aren’t shown here.

apps/server/src/orchestration/commandInvariants.test.ts+24−26

This diff is large: its 137 lines aren’t shown here.

apps/server/src/orchestration/decider.projectScripts.test.ts+34−34

This diff is large: its 199 lines aren’t shown here.

apps/server/src/orchestration/http.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/server/src/orchestration/projector.test.ts+4−4

This diff is large: its 20 lines aren’t shown here.

apps/server/src/orchestration/runtimeLayer.ts+27−0

This diff is large: its 28 lines aren’t shown here.

apps/server/src/os-jank.test.ts+150−4

This diff is large: its 182 lines aren’t shown here.

apps/server/src/os-jank.ts+65−10

This diff is large: its 94 lines aren’t shown here.

apps/server/src/persistence/Errors.ts+2−0

This diff is large: its 8 lines aren’t shown here.

apps/server/src/persistence/Layers/AuthPairingLinks.ts+209−0

This diff is large: its 210 lines aren’t shown here.

apps/server/src/persistence/Layers/AuthSessions.ts+279−0

This diff is large: its 280 lines aren’t shown here.

apps/server/src/persistence/Layers/OrchestrationEventStore.test.ts+8−8

This diff is large: its 41 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionRepositories.test.ts+9−5

This diff is large: its 49 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionThreadMessages.test.ts+4−4

This diff is large: its 22 lines aren’t shown here.

apps/server/src/persistence/Layers/ProjectionThreads.ts+20−0

This diff is large: its 55 lines aren’t shown here.

apps/server/src/persistence/Migrations.ts+12−0

This diff is large: its 26 lines aren’t shown here.

apps/server/src/persistence/Migrations/020_AuthAccessManagement.ts+42−0

This diff is large: its 43 lines aren’t shown here.

apps/server/src/persistence/Migrations/021_AuthSessionClientMetadata.ts+62−0

This diff is large: its 63 lines aren’t shown here.

apps/server/src/persistence/Migrations/022_AuthSessionLastConnectedAt.ts+17−0

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Migrations/023_ProjectionThreadShellSummary.ts+26−0

This diff is large: its 27 lines aren’t shown here.

apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.test.ts+218−0

This diff is large: its 219 lines aren’t shown here.

apps/server/src/persistence/Migrations/024_BackfillProjectionThreadShellSummary.ts+277−0

This diff is large: its 278 lines aren’t shown here.

apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.test.ts+196−0

This diff is large: its 197 lines aren’t shown here.

apps/server/src/persistence/Migrations/025_CleanupInvalidProjectionPendingApprovals.ts+27−0

This diff is large: its 28 lines aren’t shown here.

apps/server/src/persistence/NodeSqliteClient.ts+10−10

This diff is large: its 70 lines aren’t shown here.

apps/server/src/persistence/Services/AuthPairingLinks.ts+76−0

This diff is large: its 77 lines aren’t shown here.

apps/server/src/persistence/Services/AuthSessions.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/server/src/persistence/Services/OrchestrationCommandReceipts.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/OrchestrationEventStore.ts+3−3

This diff is large: its 25 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionCheckpoints.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionPendingApprovals.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionProjects.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionState.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadActivities.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadMessages.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadProposedPlans.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreadSessions.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionThreads.ts+7−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/persistence/Services/ProjectionTurns.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/persistence/Services/ProviderSessionRuntime.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/process/Layers/TerminalProcessInspector.test.ts+2−2

This diff is large: its 11 lines aren’t shown here.

apps/server/src/process/Layers/TerminalProcessInspector.ts+6−6

This diff is large: its 39 lines aren’t shown here.

apps/server/src/process/Layers/WebPortInspector.test.ts+2−2

This diff is large: its 11 lines aren’t shown here.

apps/server/src/process/Layers/WebPortInspector.ts+3−3

This diff is large: its 20 lines aren’t shown here.

apps/server/src/process/Services/TerminalProcessInspector.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/server/src/process/Services/WebPortInspector.ts+2−2

This diff is large: its 14 lines aren’t shown here.

apps/server/src/process/posix.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/process/posix.ts+2−2

This diff is large: its 10 lines aren’t shown here.

apps/server/src/process/win32.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/process/win32.ts+2−2

This diff is large: its 10 lines aren’t shown here.

apps/server/src/processRunner.test.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/server/src/project/Layers/RepositoryIdentityResolver.test.ts+223−0

This diff is large: its 224 lines aren’t shown here.

apps/server/src/project/Layers/RepositoryIdentityResolver.ts+151−0

This diff is large: its 152 lines aren’t shown here.

apps/server/src/project/Services/ProjectFaviconResolver.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/project/Services/ProjectSetupScriptRunner.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/server/src/project/Services/RepositoryIdentityResolver.ts+12−0

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeAdapter.test.ts+419−69

This diff is large: its 631 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeAdapter.ts+243−92

This diff is large: its 686 lines aren’t shown here.

apps/server/src/provider/Layers/ClaudeProvider.ts+225−74

This diff is large: its 466 lines aren’t shown here.

apps/server/src/provider/Layers/CodexAdapter.test.ts+11−15

This diff is large: its 90 lines aren’t shown here.

apps/server/src/provider/Layers/CodexAdapter.ts+18−21

This diff is large: its 140 lines aren’t shown here.

apps/server/src/provider/Layers/CodexProvider.ts+108−24

This diff is large: its 260 lines aren’t shown here.

apps/server/src/provider/Layers/EventNdjsonLogger.test.ts+46−3

This diff is large: its 73 lines aren’t shown here.

apps/server/src/provider/Layers/EventNdjsonLogger.ts+65−27

This diff is large: its 126 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderAdapterRegistry.test.ts+4−2

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderRegistry.test.ts+263−7

This diff is large: its 347 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderRegistry.ts+138−38

This diff is large: its 216 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderService.test.ts+46−7

This diff is large: its 88 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderService.ts+40−10

This diff is large: its 86 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionDirectory.test.ts+77−5

This diff is large: its 121 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionDirectory.ts+38−12

This diff is large: its 89 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+522−0

This diff is large: its 523 lines aren’t shown here.

apps/server/src/provider/Layers/ProviderSessionReaper.ts+133−0

This diff is large: its 134 lines aren’t shown here.

apps/server/src/provider/Services/ClaudeAdapter.ts+3−3

This diff is large: its 23 lines aren’t shown here.

apps/server/src/provider/Services/ClaudeProvider.ts+3−3

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Services/CodexAdapter.ts+3−3

This diff is large: its 23 lines aren’t shown here.

apps/server/src/provider/Services/CodexProvider.ts+3−3

This diff is large: its 13 lines aren’t shown here.

apps/server/src/provider/Services/ProviderAdapterRegistry.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/provider/Services/ProviderRegistry.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/server/src/provider/Services/ProviderService.ts+3−3

This diff is large: its 26 lines aren’t shown here.

apps/server/src/provider/Services/ProviderSessionDirectory.ts+11−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/provider/Services/ProviderSessionReaper.ts+14−0

This diff is large: its 15 lines aren’t shown here.

apps/server/src/provider/cliVersion.test.ts+17−0

This diff is large: its 18 lines aren’t shown here.

apps/server/src/provider/cliVersion.ts+123−0

This diff is large: its 124 lines aren’t shown here.

apps/server/src/provider/codexAppServer.ts+96−9

This diff is large: its 176 lines aren’t shown here.

apps/server/src/provider/codexCliVersion.ts+4−120

This diff is large: its 139 lines aren’t shown here.

apps/server/src/provider/makeManagedServerProvider.ts+3−2

This diff is large: its 26 lines aren’t shown here.

apps/server/src/provider/providerSnapshot.ts+11−4

This diff is large: its 63 lines aren’t shown here.

apps/server/src/provider/providerStatusCache.test.ts+136−0

This diff is large: its 137 lines aren’t shown here.

apps/server/src/provider/providerStatusCache.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/server/src/server.test.ts+2827−696

This diff is large: its 4066 lines aren’t shown here.

apps/server/src/server.ts+157−87

This diff is large: its 338 lines aren’t shown here.

apps/server/src/serverLifecycleEvents.test.ts+10−0

This diff is large: its 34 lines aren’t shown here.

apps/server/src/serverLifecycleEvents.ts+2−2

This diff is large: its 16 lines aren’t shown here.

apps/server/src/serverLogger.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/server/src/serverRuntimeStartup.test.ts+129−2

This diff is large: its 164 lines aren’t shown here.

apps/server/src/serverRuntimeStartup.ts+143−58

This diff is large: its 320 lines aren’t shown here.

apps/server/src/serverRuntimeState.ts+77−0

This diff is large: its 78 lines aren’t shown here.

apps/server/src/serverSettings.test.ts+37−2

This diff is large: its 94 lines aren’t shown here.

apps/server/src/serverSettings.ts+21−5

This diff is large: its 60 lines aren’t shown here.

apps/server/src/startupAccess.test.ts+79−0

This diff is large: its 80 lines aren’t shown here.

apps/server/src/startupAccess.ts+148−0

This diff is large: its 149 lines aren’t shown here.

apps/server/src/telemetry/Identify.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/telemetry/Layers/AnalyticsService.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/telemetry/Services/AnalyticsService.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/terminal/Layers/BunPTY.ts+5−2

This diff is large: its 19 lines aren’t shown here.

apps/server/src/terminal/Layers/Manager.test.ts+83−12

This diff is large: its 184 lines aren’t shown here.

apps/server/src/terminal/Layers/Manager.ts+96−48

This diff is large: its 299 lines aren’t shown here.

apps/server/src/terminal/Layers/NodePTY.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/server/src/terminal/Layers/NodePTY.ts+27−8

This diff is large: its 56 lines aren’t shown here.

apps/server/src/terminal/Services/Manager.ts+3−3

This diff is large: its 19 lines aren’t shown here.

apps/server/src/terminal/Services/PTY.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspaceEntries.test.ts+98−0

This diff is large: its 117 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspaceEntries.ts+133−129

This diff is large: its 353 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspacePaths.test.ts+18−0

This diff is large: its 25 lines aren’t shown here.

apps/server/src/workspace/Layers/WorkspacePaths.ts+17−2

This diff is large: its 36 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspaceEntries.ts+26−3

This diff is large: its 56 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspaceFileSystem.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/server/src/workspace/Services/WorkspacePaths.ts+20−3

This diff is large: its 57 lines aren’t shown here.

apps/server/src/ws.ts+1006−660

This diff is large: its 1751 lines aren’t shown here.

apps/server/tsconfig.json+1−3

This diff is large: its 11 lines aren’t shown here.

apps/server/vitest.config.ts+1−1

This diff is large: its 8 lines aren’t shown here.

apps/web/index.html+83−2

This diff is large: its 101 lines aren’t shown here.

apps/web/package.json+3−2

This diff is large: its 25 lines aren’t shown here.

apps/web/src/authBootstrap.test.ts+495−0

This diff is large: its 496 lines aren’t shown here.

apps/web/src/branding.test.ts+37−0

This diff is large: its 38 lines aren’t shown here.

apps/web/src/branding.ts+17−3

This diff is large: its 22 lines aren’t shown here.

apps/web/src/chat-scroll.test.ts+0−62

This diff is large: its 63 lines aren’t shown here.

apps/web/src/chat-scroll.ts+0−24

This diff is large: its 25 lines aren’t shown here.

apps/web/src/clientPersistenceStorage.test.ts+80−0

This diff is large: its 81 lines aren’t shown here.

apps/web/src/clientPersistenceStorage.ts+194−0

This diff is large: its 195 lines aren’t shown here.

apps/web/src/commandPaletteStore.ts+32−0

This diff is large: its 33 lines aren’t shown here.

apps/web/src/components/BranchToolbar.logic.test.ts+93−1

This diff is large: its 115 lines aren’t shown here.

apps/web/src/components/BranchToolbar.logic.ts+54−4

This diff is large: its 77 lines aren’t shown here.

apps/web/src/components/BranchToolbar.tsx+83−137

This diff is large: its 255 lines aren’t shown here.

apps/web/src/components/BranchToolbarBranchSelector.tsx+222−148

This diff is large: its 591 lines aren’t shown here.

apps/web/src/components/BranchToolbarEnvModeSelector.tsx+98−0

This diff is large: its 99 lines aren’t shown here.

apps/web/src/components/BranchToolbarEnvironmentSelector.tsx+88−0

This diff is large: its 89 lines aren’t shown here.

apps/web/src/components/ChatMarkdown.browser.tsx+141−0

This diff is large: its 142 lines aren’t shown here.

apps/web/src/components/ChatMarkdown.tsx+294−20

This diff is large: its 369 lines aren’t shown here.

apps/web/src/components/ChatView.browser.tsx+3469−837

This diff is large: its 5020 lines aren’t shown here.

apps/web/src/components/ChatView.logic.test.ts+384−121

This diff is large: its 674 lines aren’t shown here.

apps/web/src/components/ChatView.logic.ts+77−24

This diff is large: its 172 lines aren’t shown here.

apps/web/src/components/ChatView.tsx+1118−2187

This diff is large: its 4440 lines aren’t shown here.

apps/web/src/components/CommandPalette.logic.test.ts+166−0

This diff is large: its 167 lines aren’t shown here.

apps/web/src/components/CommandPalette.logic.ts+359−0

This diff is large: its 360 lines aren’t shown here.

apps/web/src/components/CommandPalette.tsx+1115−0

This diff is large: its 1116 lines aren’t shown here.

apps/web/src/components/CommandPaletteResults.tsx+116−0

This diff is large: its 117 lines aren’t shown here.

apps/web/src/components/ComposerPromptEditor.tsx+610−71

This diff is large: its 937 lines aren’t shown here.

apps/web/src/components/DiffPanel.tsx+35−22

This diff is large: its 137 lines aren’t shown here.

apps/web/src/components/DiffPanelShell.tsx+4−2

This diff is large: its 13 lines aren’t shown here.

apps/web/src/components/GitActionsControl.browser.tsx+294−70

This diff is large: its 469 lines aren’t shown here.

apps/web/src/components/GitActionsControl.logic.test.ts+9−0

This diff is large: its 16 lines aren’t shown here.

apps/web/src/components/GitActionsControl.logic.ts+10−0

This diff is large: its 24 lines aren’t shown here.

apps/web/src/components/GitActionsControl.tsx+138−38

This diff is large: its 305 lines aren’t shown here.

apps/web/src/components/Icons.tsx+139−0

This diff is large: its 153 lines aren’t shown here.

apps/web/src/components/KeybindingsToast.browser.tsx+193−28

This diff is large: its 333 lines aren’t shown here.

apps/web/src/components/NoActiveThreadState.tsx+45−0

This diff is large: its 46 lines aren’t shown here.

apps/web/src/components/PlanSidebar.tsx+20−6

This diff is large: its 87 lines aren’t shown here.

apps/web/src/components/ProjectFavicon.tsx+14−7

This diff is large: its 42 lines aren’t shown here.

apps/web/src/components/PullRequestThreadDialog.tsx+7−3

This diff is large: its 47 lines aren’t shown here.

apps/web/src/components/RightPanelSheet.tsx+30−0

This diff is large: its 31 lines aren’t shown here.

apps/web/src/components/Sidebar.logic.test.ts+134−230

This diff is large: its 654 lines aren’t shown here.

apps/web/src/components/Sidebar.logic.ts+26−62

This diff is large: its 152 lines aren’t shown here.

apps/web/src/components/Sidebar.tsx+2681−1542

This diff is large: its 4700 lines aren’t shown here.

apps/web/src/components/SplashScreen.tsx+9−0

This diff is large: its 10 lines aren’t shown here.

apps/web/src/components/ThreadStatusIndicators.tsx+241−0

This diff is large: its 242 lines aren’t shown here.

apps/web/src/components/ThreadTerminalDrawer.browser.tsx+318−0

This diff is large: its 319 lines aren’t shown here.

apps/web/src/components/ThreadTerminalDrawer.tsx+90−33

This diff is large: its 322 lines aren’t shown here.

apps/web/src/components/WebSocketConnectionSurface.logic.test.ts+113−0

This diff is large: its 114 lines aren’t shown here.

apps/web/src/components/WebSocketConnectionSurface.tsx+390−0

This diff is large: its 391 lines aren’t shown here.

apps/web/src/components/auth/PairingRouteSurface.tsx+195−0

This diff is large: its 196 lines aren’t shown here.

apps/web/src/components/chat/ChangedFilesTree.test.tsx+2−2

This diff is large: its 18 lines aren’t shown here.

apps/web/src/components/chat/ChatComposer.tsx+1999−0

This diff is large: its 2000 lines aren’t shown here.

apps/web/src/components/chat/ChatHeader.tsx+14−1

This diff is large: its 50 lines aren’t shown here.

apps/web/src/components/chat/CompactComposerControlsMenu.browser.tsx+38−29

This diff is large: its 111 lines aren’t shown here.

apps/web/src/components/chat/CompactComposerControlsMenu.tsx+7−3

This diff is large: its 39 lines aren’t shown here.

apps/web/src/components/chat/ComposerCommandMenu.tsx+161−27

This diff is large: its 251 lines aren’t shown here.

apps/web/src/components/chat/ComposerPendingTerminalContexts.test.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx+39−32

This diff is large: its 155 lines aren’t shown here.

apps/web/src/components/chat/ComposerPrimaryActions.test.ts+93−0

This diff is large: its 94 lines aren’t shown here.

apps/web/src/components/chat/ComposerPrimaryActions.tsx+8−3

This diff is large: its 42 lines aren’t shown here.

apps/web/src/components/chat/ExpandedImageDialog.tsx+120−0

This diff is large: its 121 lines aren’t shown here.

apps/web/src/components/chat/MessageCopyButton.tsx+74−12

This diff is large: its 95 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.browser.tsx+160−0

This diff is large: its 161 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.logic.test.ts+291−1

This diff is large: its 301 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.logic.ts+102−61

This diff is large: its 221 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.test.tsx+93−47

This diff is large: its 202 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.tsx+551−421

This diff is large: its 1230 lines aren’t shown here.

apps/web/src/components/chat/MessagesTimeline.virtualization.browser.tsx+0−1037

This diff is large: its 1038 lines aren’t shown here.

apps/web/src/components/chat/OpenInPicker.tsx+13−5

This diff is large: its 63 lines aren’t shown here.

apps/web/src/components/chat/ProposedPlanCard.tsx+5−2

This diff is large: its 34 lines aren’t shown here.

apps/web/src/components/chat/ProviderModelPicker.browser.tsx+6−0

This diff is large: its 27 lines aren’t shown here.

apps/web/src/components/chat/TraitsPicker.browser.tsx+35−25

This diff is large: its 164 lines aren’t shown here.

apps/web/src/components/chat/TraitsPicker.tsx+12−5

This diff is large: its 48 lines aren’t shown here.

apps/web/src/components/chat/composerMenuHighlight.test.ts+51−0

This diff is large: its 52 lines aren’t shown here.

apps/web/src/components/chat/composerMenuHighlight.ts+20−0

This diff is large: its 21 lines aren’t shown here.

apps/web/src/components/chat/composerProviderRegistry.test.tsx+33−1

This diff is large: its 44 lines aren’t shown here.

apps/web/src/components/chat/composerProviderRegistry.tsx+93−53

This diff is large: its 227 lines aren’t shown here.

apps/web/src/components/chat/composerSlashCommandSearch.test.ts+68−0

This diff is large: its 69 lines aren’t shown here.

apps/web/src/components/chat/composerSlashCommandSearch.ts+83−0

This diff is large: its 84 lines aren’t shown here.

apps/web/src/components/composerFooterLayout.test.ts+0−99

This diff is large: its 110 lines aren’t shown here.

apps/web/src/components/composerFooterLayout.ts+0−45

This diff is large: its 56 lines aren’t shown here.

apps/web/src/components/composerInlineChip.ts+3−0

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/desktopUpdate.logic.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/components/settings/ConnectionsSettings.tsx+1432−0

This diff is large: its 1433 lines aren’t shown here.

apps/web/src/components/settings/SettingsPanels.browser.tsx+625−12

This diff is large: its 686 lines aren’t shown here.

apps/web/src/components/settings/SettingsPanels.tsx+200−158

This diff is large: its 532 lines aren’t shown here.

apps/web/src/components/settings/SettingsSidebarNav.tsx+9−5

This diff is large: its 47 lines aren’t shown here.

apps/web/src/components/settings/settingsLayout.tsx+122−0

This diff is large: its 123 lines aren’t shown here.

apps/web/src/components/timelineHeight.test.ts+0−138

This diff is large: its 139 lines aren’t shown here.

apps/web/src/components/timelineHeight.ts+0−107

This diff is large: its 108 lines aren’t shown here.

apps/web/src/components/ui/combobox.tsx+15−0

This diff is large: its 29 lines aren’t shown here.

apps/web/src/components/ui/command.tsx+6−3

This diff is large: its 26 lines aren’t shown here.

apps/web/src/components/ui/qr-code.test.tsx+27−0

This diff is large: its 28 lines aren’t shown here.

apps/web/src/components/ui/qr-code.tsx+81−0

This diff is large: its 82 lines aren’t shown here.

apps/web/src/components/ui/select.tsx+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/components/ui/toast.logic.test.ts+52−1

This diff is large: its 62 lines aren’t shown here.

apps/web/src/components/ui/toast.logic.ts+27−0

This diff is large: its 35 lines aren’t shown here.

apps/web/src/components/ui/toast.tsx+48−32

This diff is large: its 167 lines aren’t shown here.

apps/web/src/composer-editor-mentions.test.ts+88−1

This diff is large: its 107 lines aren’t shown here.

apps/web/src/composer-editor-mentions.ts+185−27

This diff is large: its 267 lines aren’t shown here.

apps/web/src/composer-logic.test.ts+53−0

This diff is large: its 81 lines aren’t shown here.

apps/web/src/composer-logic.ts+50−14

This diff is large: its 121 lines aren’t shown here.

apps/web/src/composerDraftStore.test.ts+360−202

This diff is large: its 1103 lines aren’t shown here.

apps/web/src/composerDraftStore.ts+1652−984

This diff is large: its 3100 lines aren’t shown here.

apps/web/src/composerHandleContext.ts+10−0

This diff is large: its 11 lines aren’t shown here.

apps/web/src/contextMenuFallback.test.ts+221−0

This diff is large: its 222 lines aren’t shown here.

apps/web/src/contextMenuFallback.ts+115−47

This diff is large: its 188 lines aren’t shown here.

apps/web/src/diffRouteSearch.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/editorPreferences.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/web/src/environmentApi.ts+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/environmentGrouping.test.ts+614−0

This diff is large: its 615 lines aren’t shown here.

apps/web/src/environments/primary/auth.ts+373−0

This diff is large: its 374 lines aren’t shown here.

apps/web/src/environments/primary/bootstrap.test.ts+170−0

This diff is large: its 171 lines aren’t shown here.

apps/web/src/environments/primary/context.ts+120−0

This diff is large: its 121 lines aren’t shown here.

apps/web/src/environments/primary/index.ts+35−0

This diff is large: its 36 lines aren’t shown here.

apps/web/src/environments/primary/target.ts+158−0

This diff is large: its 159 lines aren’t shown here.

apps/web/src/environments/remote/api.test.ts+235−0

This diff is large: its 236 lines aren’t shown here.

apps/web/src/environments/remote/api.ts+143−0

This diff is large: its 144 lines aren’t shown here.

apps/web/src/environments/remote/target.ts+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/environments/runtime/catalog.test.ts+141−0

This diff is large: its 142 lines aren’t shown here.

apps/web/src/environments/runtime/catalog.ts+341−0

This diff is large: its 342 lines aren’t shown here.

apps/web/src/environments/runtime/connection.test.ts+249−0

This diff is large: its 250 lines aren’t shown here.

apps/web/src/environments/runtime/connection.ts+173−0

This diff is large: its 174 lines aren’t shown here.

apps/web/src/environments/runtime/index.ts+29−0

This diff is large: its 30 lines aren’t shown here.

apps/web/src/environments/runtime/service.addSavedEnvironment.test.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/web/src/environments/runtime/service.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/src/environments/runtime/service.threadSubscriptions.test.ts+298−0

This diff is large: its 299 lines aren’t shown here.

apps/web/src/environments/runtime/service.ts+1081−0

This diff is large: its 1082 lines aren’t shown here.

apps/web/src/filePathDisplay.test.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/src/filePathDisplay.ts+57−0

This diff is large: its 58 lines aren’t shown here.

apps/web/src/historyBootstrap.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/hooks/useHandleNewThread.ts+112−52

This diff is large: its 239 lines aren’t shown here.

apps/web/src/hooks/useSettings.test.ts+0−16

This diff is large: its 17 lines aren’t shown here.

apps/web/src/hooks/useSettings.ts+101−177

This diff is large: its 318 lines aren’t shown here.

apps/web/src/hooks/useTheme.ts+78−5

This diff is large: its 148 lines aren’t shown here.

apps/web/src/hooks/useThreadActions.ts+138−67

This diff is large: its 323 lines aren’t shown here.

apps/web/src/index.css+73−6

This diff is large: its 145 lines aren’t shown here.

apps/web/src/keybindings.test.ts+55−0

This diff is large: its 90 lines aren’t shown here.

apps/web/src/keybindings.ts+23−0

This diff is large: its 37 lines aren’t shown here.

apps/web/src/lib/chatThreadActions.test.ts+107−0

This diff is large: its 108 lines aren’t shown here.

apps/web/src/lib/chatThreadActions.ts+98−0

This diff is large: its 99 lines aren’t shown here.

apps/web/src/lib/contextWindow.test.ts+2−2

This diff is large: its 15 lines aren’t shown here.

apps/web/src/lib/desktopUpdateReactQuery.test.ts+1−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/lib/gitReactQuery.test.ts+30−42

This diff is large: its 168 lines aren’t shown here.

apps/web/src/lib/gitReactQuery.ts+119−107

This diff is large: its 373 lines aren’t shown here.

apps/web/src/lib/gitStatusState.test.ts+325−0

This diff is large: its 326 lines aren’t shown here.

apps/web/src/lib/gitStatusState.ts+287−0

This diff is large: its 288 lines aren’t shown here.

apps/web/src/lib/projectPaths.test.ts+117−0

This diff is large: its 118 lines aren’t shown here.

apps/web/src/lib/projectPaths.ts+259−0

This diff is large: its 260 lines aren’t shown here.

apps/web/src/lib/projectReactQuery.ts+17−8

This diff is large: its 56 lines aren’t shown here.

apps/web/src/lib/providerReactQuery.test.ts+12−5

This diff is large: its 76 lines aren’t shown here.

apps/web/src/lib/providerReactQuery.ts+11−4

This diff is large: its 51 lines aren’t shown here.

apps/web/src/lib/terminalContext.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/lib/terminalStateCleanup.test.ts+21−14

This diff is large: its 80 lines aren’t shown here.

apps/web/src/lib/terminalStateCleanup.ts+9−11

This diff is large: its 45 lines aren’t shown here.

apps/web/src/lib/threadSort.test.ts+187−0

This diff is large: its 188 lines aren’t shown here.

apps/web/src/lib/threadSort.ts+73−0

This diff is large: its 74 lines aren’t shown here.

apps/web/src/lib/utils.test.ts+1−2

This diff is large: its 7 lines aren’t shown here.

apps/web/src/lib/utils.ts+6−42

This diff is large: its 65 lines aren’t shown here.

apps/web/src/lib/windowControlsOverlay.ts+40−0

This diff is large: its 41 lines aren’t shown here.

apps/web/src/localApi.test.ts+638−0

This diff is large: its 639 lines aren’t shown here.

apps/web/src/localApi.ts+154−0

This diff is large: its 155 lines aren’t shown here.

apps/web/src/logicalProject.ts+157−0

This diff is large: its 158 lines aren’t shown here.

apps/web/src/main.tsx+5−0

This diff is large: its 18 lines aren’t shown here.

apps/web/src/markdown-links.test.ts+42−1

This diff is large: its 56 lines aren’t shown here.

apps/web/src/markdown-links.ts+57−8

This diff is large: its 99 lines aren’t shown here.

apps/web/src/nativeApi.ts+0−31

This diff is large: its 32 lines aren’t shown here.

apps/web/src/observability/clientTracing.ts+144−0

This diff is large: its 145 lines aren’t shown here.

apps/web/src/orchestrationEventEffects.test.ts+15−15

This diff is large: its 101 lines aren’t shown here.

apps/web/src/orchestrationEventEffects.ts+4−4

This diff is large: its 33 lines aren’t shown here.

apps/web/src/orchestrationRecovery.ts+5−1

This diff is large: its 10 lines aren’t shown here.

apps/web/src/pairingUrl.ts+33−0

This diff is large: its 34 lines aren’t shown here.

apps/web/src/pendingUserInput.test.ts+113−55

This diff is large: its 277 lines aren’t shown here.

apps/web/src/pendingUserInput.ts+63−15

This diff is large: its 152 lines aren’t shown here.

apps/web/src/projectScripts.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/providerSkillPresentation.test.ts+57−0

This diff is large: its 58 lines aren’t shown here.

apps/web/src/providerSkillPresentation.ts+52−0

This diff is large: its 53 lines aren’t shown here.

apps/web/src/providerSkillSearch.test.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/web/src/providerSkillSearch.ts+105−0

This diff is large: its 106 lines aren’t shown here.

apps/web/src/pullRequestReference.test.ts+2−2

This diff is large: its 18 lines aren’t shown here.

apps/web/src/pullRequestReference.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/rightPanelLayout.ts+2−0

This diff is large: its 3 lines aren’t shown here.

apps/web/src/routeTree.gen.ts+81−17

This diff is large: its 224 lines aren’t shown here.

apps/web/src/routes/__root.tsx+116−341

This diff is large: its 565 lines aren’t shown here.

apps/web/src/routes/_chat.$environmentId.$threadId.tsxrenamed from apps/web/src/routes/_chat.$threadId.tsx+90−62

This diff is large: its 234 lines aren’t shown here.

apps/web/src/routes/_chat.draft.$draftId.tsx+86−0

This diff is large: its 87 lines aren’t shown here.

apps/web/src/routes/_chat.index.tsx+2−26

This diff is large: its 36 lines aren’t shown here.

apps/web/src/routes/_chat.tsx+41−25

This diff is large: its 123 lines aren’t shown here.

apps/web/src/routes/pair.tsx+40−0

This diff is large: its 41 lines aren’t shown here.

apps/web/src/routes/settings.connections.tsx+7−0

This diff is large: its 8 lines aren’t shown here.

apps/web/src/routes/settings.tsx+6−2

This diff is large: its 22 lines aren’t shown here.

apps/web/src/rpc/client.test.ts+0−231

This diff is large: its 232 lines aren’t shown here.

apps/web/src/rpc/client.ts+0−33

This diff is large: its 34 lines aren’t shown here.

apps/web/src/rpc/protocol.ts+163−13

This diff is large: its 191 lines aren’t shown here.

apps/web/src/rpc/requestLatencyState.test.ts+65−0

This diff is large: its 66 lines aren’t shown here.

apps/web/src/rpc/requestLatencyState.ts+133−0

This diff is large: its 134 lines aren’t shown here.

apps/web/src/rpc/serverState.test.ts+32−6

This diff is large: its 77 lines aren’t shown here.

apps/web/src/rpc/serverState.ts+1−1

This diff is large: its 9 lines aren’t shown here.

apps/web/src/rpc/transportError.test.ts+24−0

This diff is large: its 25 lines aren’t shown here.

apps/web/src/rpc/transportError.ts+23−0

This diff is large: its 24 lines aren’t shown here.

apps/web/src/rpc/wsConnectionState.test.ts+75−0

This diff is large: its 76 lines aren’t shown here.

apps/web/src/rpc/wsConnectionState.ts+199−0

This diff is large: its 200 lines aren’t shown here.

apps/web/src/rpc/wsRpcClient.test.ts+104−0

This diff is large: its 105 lines aren’t shown here.

apps/web/src/rpc/wsRpcClient.tsrenamed from apps/web/src/wsRpcClient.ts+90−41

This diff is large: its 230 lines aren’t shown here.

apps/web/src/rpc/wsTransport.test.ts+1105−0

This diff is large: its 1106 lines aren’t shown here.

apps/web/src/rpc/wsTransport.ts+287−0

This diff is large: its 288 lines aren’t shown here.

apps/web/src/session-logic.test.ts+210−45

This diff is large: its 564 lines aren’t shown here.

apps/web/src/session-logic.ts+216−28

This diff is large: its 349 lines aren’t shown here.

apps/web/src/sidebarProjectGrouping.ts+118−0

This diff is large: its 119 lines aren’t shown here.

apps/web/src/store.test.ts+550−349

This diff is large: its 1300 lines aren’t shown here.

apps/web/src/store.ts+1192−388

This diff is large: its 1931 lines aren’t shown here.

apps/web/src/storeSelectors.ts+62−20

This diff is large: its 89 lines aren’t shown here.

apps/web/src/terminal-links.test.ts+104−0

This diff is large: its 123 lines aren’t shown here.

apps/web/src/terminal-links.ts+112−1

This diff is large: its 134 lines aren’t shown here.

apps/web/src/terminalStateStore.test.ts+127−78

This diff is large: its 399 lines aren’t shown here.

apps/web/src/terminalStateStore.ts+165−127

This diff is large: its 511 lines aren’t shown here.

apps/web/src/threadDerivation.ts+152−0

This diff is large: its 153 lines aren’t shown here.

apps/web/src/threadRoutes.test.ts+67−0

This diff is large: its 68 lines aren’t shown here.

apps/web/src/threadRoutes.ts+59−0

This diff is large: its 60 lines aren’t shown here.

apps/web/src/threadSelectionStore.test.ts+59−59

This diff is large: its 280 lines aren’t shown here.

apps/web/src/threadSelectionStore.ts+51−53

This diff is large: its 173 lines aren’t shown here.

apps/web/src/timestampFormat.test.ts+86−2

This diff is large: its 97 lines aren’t shown here.

apps/web/src/timestampFormat.ts+88−2

This diff is large: its 101 lines aren’t shown here.

apps/web/src/types.ts+28−0

This diff is large: its 61 lines aren’t shown here.

apps/web/src/uiStateStore.test.ts+185−29

This diff is large: its 345 lines aren’t shown here.

apps/web/src/uiStateStore.ts+197−49

This diff is large: its 450 lines aren’t shown here.

apps/web/src/vite-env.d.ts+2−2

This diff is large: its 17 lines aren’t shown here.

apps/web/src/worktreeCleanup.test.ts+15−12

This diff is large: its 80 lines aren’t shown here.

apps/web/src/wsNativeApi.test.ts+0−358

This diff is large: its 359 lines aren’t shown here.

apps/web/src/wsNativeApi.ts+0−107

This diff is large: its 108 lines aren’t shown here.

apps/web/src/wsTransport.test.ts+0−443

This diff is large: its 444 lines aren’t shown here.

apps/web/src/wsTransport.ts+0−131

This diff is large: its 132 lines aren’t shown here.

apps/web/test/authHttpHandlers.ts+41−0

This diff is large: its 42 lines aren’t shown here.

apps/web/test/wsRpcHarness.ts+13−4

This diff is large: its 40 lines aren’t shown here.

apps/web/tsconfig.json+4−0

This diff is large: its 11 lines aren’t shown here.

apps/web/vite.config.ts+48−2

This diff is large: its 85 lines aren’t shown here.

apps/web/vitest.browser.config.ts+8−0

This diff is large: its 22 lines aren’t shown here.

assets/dev/blueprint-icon-composer.icon/Assets/T3.svg+3−0

This diff is large: its 4 lines aren’t shown here.

assets/dev/blueprint-icon-composer.icon/Assets/Texturelabs_Paper_381XL.jpg+0−0

Binary file not shown.

assets/dev/blueprint-icon-composer.icon/Assets/gpt-image-1.5-kn76n9hynne5q3qxy3g91chp2d84t871.png+0−0

Binary file not shown.

assets/dev/blueprint-icon-composer.icon/icon.json+47−0

This diff is large: its 48 lines aren’t shown here.

assets/dev/blueprint-ios-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-macos-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-universal-1024.png+0−0

Binary file not shown.

assets/dev/blueprint-web-apple-touch-180.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon-16x16.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon-32x32.png+0−0

Binary file not shown.

assets/dev/blueprint-web-favicon.ico+0−0

Binary file not shown.

assets/dev/blueprint-windows.ico+0−0

Binary file not shown.

assets/nightly/blueprint-ios-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-macos-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-universal-1024.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-apple-touch-180.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon-16x16.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon-32x32.png+0−0

Binary file not shown.

assets/nightly/blueprint-web-favicon.ico+0−0

Binary file not shown.

assets/nightly/blueprint-windows.ico+0−0

Binary file not shown.

bun.lock+208−27

This diff is large: its 698 lines aren’t shown here.

docs/observability.md+4−4

This diff is large: its 30 lines aren’t shown here.

docs/release.md+37−12

This diff is large: its 85 lines aren’t shown here.

package.json+21−11

This diff is large: its 66 lines aren’t shown here.

packages/client-runtime/package.json+25−0

This diff is large: its 26 lines aren’t shown here.

packages/client-runtime/src/index.ts+2−0

This diff is large: its 3 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.test.ts+92−0

This diff is large: its 93 lines aren’t shown here.

packages/client-runtime/src/knownEnvironment.ts+53−0

This diff is large: its 54 lines aren’t shown here.

packages/client-runtime/src/scoped.ts+64−0

This diff is large: its 65 lines aren’t shown here.

packages/client-runtime/tsconfig.json+4−0

This diff is large: its 5 lines aren’t shown here.

packages/contracts/package.json+1−1

This diff is large: its 8 lines aren’t shown here.

packages/contracts/src/auth.ts+266−0

This diff is large: its 267 lines aren’t shown here.

packages/contracts/src/baseSchemas.ts+7−2

This diff is large: its 28 lines aren’t shown here.

packages/contracts/src/editor.ts+13−11

This diff is large: its 45 lines aren’t shown here.

packages/contracts/src/environment.ts+78−0

This diff is large: its 79 lines aren’t shown here.

packages/contracts/src/filesystem.ts+30−0

This diff is large: its 31 lines aren’t shown here.

packages/contracts/src/git.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/git.ts+50−2

This diff is large: its 103 lines aren’t shown here.

packages/contracts/src/index.ts+17−14

This diff is large: its 32 lines aren’t shown here.

packages/contracts/src/ipc.ts+145−38

This diff is large: its 286 lines aren’t shown here.

packages/contracts/src/keybindings.test.ts+7−1

This diff is large: its 22 lines aren’t shown here.

packages/contracts/src/keybindings.ts+2−1

This diff is large: its 15 lines aren’t shown here.

packages/contracts/src/model.ts+20−10

This diff is large: its 58 lines aren’t shown here.

packages/contracts/src/orchestration.test.ts+18−1

This diff is large: its 40 lines aren’t shown here.

packages/contracts/src/orchestration.ts+140−28

This diff is large: its 321 lines aren’t shown here.

packages/contracts/src/project.ts+1−1

This diff is large: its 7 lines aren’t shown here.

packages/contracts/src/provider.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/provider.ts+3−3

This diff is large: its 25 lines aren’t shown here.

packages/contracts/src/providerRuntime.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/providerRuntime.ts+4−4

This diff is large: its 26 lines aren’t shown here.

packages/contracts/src/rpc.ts+64−27

This diff is large: its 232 lines aren’t shown here.

packages/contracts/src/server.test.ts+26−0

This diff is large: its 27 lines aren’t shown here.

packages/contracts/src/server.ts+40−7

This diff is large: its 89 lines aren’t shown here.

packages/contracts/src/settings.ts+50−27

This diff is large: its 143 lines aren’t shown here.

packages/contracts/src/terminal.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/contracts/src/terminal.ts+3−3

This diff is large: its 17 lines aren’t shown here.

packages/shared/package.json+16−0

This diff is large: its 23 lines aren’t shown here.

packages/shared/src/DrainableWorker.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/KeyedCoalescingWorker.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/Net.test.ts+1−1

This diff is large: its 9 lines aren’t shown here.

packages/shared/src/Net.ts+2−2

This diff is large: its 17 lines aren’t shown here.

packages/shared/src/String.test.ts+1−1

This diff is large: its 8 lines aren’t shown here.

packages/shared/src/cliArgs.test.ts+134−0

This diff is large: its 135 lines aren’t shown here.

packages/shared/src/cliArgs.ts+76−0

This diff is large: its 77 lines aren’t shown here.

packages/shared/src/git.test.ts+135−0

This diff is large: its 136 lines aren’t shown here.

packages/shared/src/git.ts+202−1

This diff is large: its 218 lines aren’t shown here.

packages/shared/src/model.test.ts+2−1

This diff is large: its 17 lines aren’t shown here.

packages/shared/src/model.ts+2−2

This diff is large: its 18 lines aren’t shown here.

packages/shared/src/path.test.ts+34−0

This diff is large: its 35 lines aren’t shown here.

packages/shared/src/path.ts+22−0

This diff is large: its 23 lines aren’t shown here.

packages/shared/src/qrCode.ts+985−0

This diff is large: its 986 lines aren’t shown here.

packages/shared/src/searchRanking.test.ts+95−0

This diff is large: its 96 lines aren’t shown here.

packages/shared/src/searchRanking.ts+192−0

This diff is large: its 193 lines aren’t shown here.

packages/shared/src/serverSettings.test.ts+60−1

This diff is large: its 75 lines aren’t shown here.

packages/shared/src/serverSettings.ts+34−2

This diff is large: its 45 lines aren’t shown here.

packages/shared/src/shell.test.ts+331−1

This diff is large: its 352 lines aren’t shown here.

packages/shared/src/shell.ts+384−20

This diff is large: its 450 lines aren’t shown here.

scripts/build-desktop-artifact.test.ts+101−0

This diff is large: its 102 lines aren’t shown here.

scripts/build-desktop-artifact.ts+225−97

This diff is large: its 619 lines aren’t shown here.

scripts/dev-runner.test.ts+83−31

This diff is large: its 256 lines aren’t shown here.

scripts/dev-runner.ts+40−74

This diff is large: its 225 lines aren’t shown here.

scripts/lib/brand-assets.ts+6−0

This diff is large: its 13 lines aren’t shown here.

scripts/lib/build-target-arch.test.ts+61−0

This diff is large: its 62 lines aren’t shown here.

scripts/lib/build-target-arch.ts+50−0

This diff is large: its 51 lines aren’t shown here.

scripts/lib/resolve-catalog.ts+3−3

This diff is large: its 14 lines aren’t shown here.

scripts/lib/update-manifest.tsrenamed from scripts/merge-mac-update-manifests.ts+58−68

This diff is large: its 296 lines aren’t shown here.

scripts/merge-mac-update-manifests.test.ts+0−108

This diff is large: its 109 lines aren’t shown here.

scripts/merge-update-manifests.test.ts+306−0

This diff is large: its 307 lines aren’t shown here.

scripts/merge-update-manifests.ts+108−0

This diff is large: its 109 lines aren’t shown here.

scripts/mock-update-server.test.ts+104−0

This diff is large: its 105 lines aren’t shown here.

scripts/mock-update-server.ts+147−37

This diff is large: its 192 lines aren’t shown here.

scripts/release-smoke.ts+247−3

This diff is large: its 299 lines aren’t shown here.

scripts/resolve-nightly-release.test.ts+32−0

This diff is large: its 33 lines aren’t shown here.

scripts/resolve-nightly-release.ts+137−0

This diff is large: its 138 lines aren’t shown here.

scripts/resolve-previous-release-tag.ts+205−0

This diff is large: its 206 lines aren’t shown here.

scripts/tsconfig.json+2−4

This diff is large: its 13 lines aren’t shown here.

scripts/update-release-package-versions.test.ts+213−0

This diff is large: its 214 lines aren’t shown here.

scripts/update-release-package-versions.ts+68−86

This diff is large: its 179 lines aren’t shown here.

tsconfig.base.json+8−3

This diff is large: its 17 lines aren’t shown here.

turbo.json+1−1

This diff is large: its 12 lines aren’t shown here.

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t