@codemation/next-host 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (206) hide show
  1. package/README.md +25 -0
  2. package/app/(shell)/credentials/page.tsx +5 -0
  3. package/app/(shell)/dashboard/page.tsx +14 -0
  4. package/app/(shell)/layout.tsx +11 -0
  5. package/app/(shell)/page.tsx +5 -0
  6. package/app/(shell)/users/page.tsx +5 -0
  7. package/app/(shell)/workflows/[workflowId]/page.tsx +19 -0
  8. package/app/(shell)/workflows/page.tsx +5 -0
  9. package/app/api/[[...path]]/route.ts +40 -0
  10. package/app/api/auth/[...nextauth]/route.ts +3 -0
  11. package/app/globals.css +997 -0
  12. package/app/invite/[token]/page.tsx +10 -0
  13. package/app/layout.tsx +65 -0
  14. package/app/login/layout.tsx +25 -0
  15. package/app/login/page.tsx +22 -0
  16. package/components.json +21 -0
  17. package/docs/FORMS.md +46 -0
  18. package/docs/TAILWIND_SHADCN_MIGRATION.md +89 -0
  19. package/eslint.config.mjs +56 -0
  20. package/middleware.ts +29 -0
  21. package/next-env.d.ts +6 -0
  22. package/next.config.ts +34 -0
  23. package/package.json +76 -0
  24. package/postcss.config.mjs +7 -0
  25. package/public/canvas-icons/builtin/openai.svg +5 -0
  26. package/src/api/CodemationApiClient.ts +107 -0
  27. package/src/api/CodemationApiHttpError.ts +17 -0
  28. package/src/auth/CodemationNextAuthConfigResolver.ts +14 -0
  29. package/src/auth/CodemationNextAuthOAuthProviderDescriptorMapper.ts +30 -0
  30. package/src/auth/CodemationNextAuthOAuthProviderSnapshotResolver.ts +17 -0
  31. package/src/auth/CodemationNextAuthProviderCatalog.ts +107 -0
  32. package/src/auth/codemationEdgeAuth.ts +25 -0
  33. package/src/auth/codemationNextAuth.ts +32 -0
  34. package/src/components/Codemation.tsx +6 -0
  35. package/src/components/CodemationDataTable.tsx +37 -0
  36. package/src/components/CodemationDialog.tsx +137 -0
  37. package/src/components/CodemationFormattedDateTime.tsx +46 -0
  38. package/src/components/GoogleColorGIcon.tsx +39 -0
  39. package/src/components/OauthProviderIcon.tsx +33 -0
  40. package/src/components/PasswordStrengthMeter.tsx +59 -0
  41. package/src/components/forms/index.ts +28 -0
  42. package/src/components/json/JsonMonacoEditor.tsx +75 -0
  43. package/src/components/oauthProviderIconData.ts +17 -0
  44. package/src/components/ui/alert.tsx +56 -0
  45. package/src/components/ui/badge.tsx +40 -0
  46. package/src/components/ui/button.tsx +64 -0
  47. package/src/components/ui/card.tsx +70 -0
  48. package/src/components/ui/collapsible.tsx +26 -0
  49. package/src/components/ui/dialog.tsx +137 -0
  50. package/src/components/ui/dropdown-menu.tsx +238 -0
  51. package/src/components/ui/form.tsx +147 -0
  52. package/src/components/ui/input.tsx +19 -0
  53. package/src/components/ui/label.tsx +26 -0
  54. package/src/components/ui/scroll-area.tsx +47 -0
  55. package/src/components/ui/select.tsx +169 -0
  56. package/src/components/ui/separator.tsx +28 -0
  57. package/src/components/ui/switch.tsx +28 -0
  58. package/src/components/ui/table.tsx +72 -0
  59. package/src/components/ui/tabs.tsx +76 -0
  60. package/src/components/ui/textarea.tsx +18 -0
  61. package/src/components/ui/toggle.tsx +41 -0
  62. package/src/features/credentials/components/CredentialConfirmDialog.tsx +58 -0
  63. package/src/features/credentials/components/CredentialDialog.tsx +252 -0
  64. package/src/features/credentials/components/CredentialDialogFeedback.tsx +36 -0
  65. package/src/features/credentials/components/CredentialDialogFieldRows.tsx +257 -0
  66. package/src/features/credentials/components/CredentialDialogFormSections.tsx +230 -0
  67. package/src/features/credentials/components/CredentialEnvFieldStatusRow.tsx +64 -0
  68. package/src/features/credentials/components/CredentialFieldCopyButton.tsx +48 -0
  69. package/src/features/credentials/components/CredentialsScreenHealthBadge.tsx +21 -0
  70. package/src/features/credentials/components/CredentialsScreenInstancesTable.tsx +108 -0
  71. package/src/features/credentials/components/CredentialsScreenTestFailureAlert.tsx +33 -0
  72. package/src/features/credentials/hooks/useCredentialCreateDialog.ts +33 -0
  73. package/src/features/credentials/hooks/useCredentialDialogSession.ts +616 -0
  74. package/src/features/credentials/hooks/useCredentialsScreen.ts +213 -0
  75. package/src/features/credentials/lib/credentialFieldHelpers.ts +35 -0
  76. package/src/features/credentials/lib/credentialFormTypes.ts +1 -0
  77. package/src/features/credentials/lib/credentialInstanceTestPayloadParser.ts +10 -0
  78. package/src/features/credentials/screens/CredentialsScreen.tsx +187 -0
  79. package/src/features/invite/screens/InviteAcceptScreen.tsx +190 -0
  80. package/src/features/users/components/UsersInviteDialog.tsx +121 -0
  81. package/src/features/users/components/UsersRegenerateDialog.tsx +81 -0
  82. package/src/features/users/components/UsersScreenUserStatusBadge.tsx +19 -0
  83. package/src/features/users/schemas/usersInviteFormSchema.ts +7 -0
  84. package/src/features/users/screens/UsersScreen.tsx +240 -0
  85. package/src/features/workflows/components/WorkflowListFolderSection.tsx +91 -0
  86. package/src/features/workflows/components/WorkflowListItemCard.tsx +67 -0
  87. package/src/features/workflows/components/WorkflowListRoot.tsx +39 -0
  88. package/src/features/workflows/components/WorkflowsListTree.tsx +28 -0
  89. package/src/features/workflows/components/canvas/CanvasNodeChromeTooltip.tsx +96 -0
  90. package/src/features/workflows/components/canvas/CanvasNodeIconSlot.tsx +25 -0
  91. package/src/features/workflows/components/canvas/VisibleNodeStatusResolver.tsx +84 -0
  92. package/src/features/workflows/components/canvas/WorkflowCanvas.tsx +248 -0
  93. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNode.tsx +182 -0
  94. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAccents.tsx +73 -0
  95. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAgentBottomSourceHandles.tsx +43 -0
  96. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeAgentLabels.tsx +47 -0
  97. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeCard.tsx +202 -0
  98. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeHandles.tsx +77 -0
  99. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeLabelBelow.tsx +51 -0
  100. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeMainGlyph.tsx +64 -0
  101. package/src/features/workflows/components/canvas/WorkflowCanvasCodemationNodeToolbar.tsx +95 -0
  102. package/src/features/workflows/components/canvas/WorkflowCanvasLoadingPlaceholder.tsx +69 -0
  103. package/src/features/workflows/components/canvas/WorkflowCanvasNodeIcon.tsx +102 -0
  104. package/src/features/workflows/components/canvas/WorkflowCanvasSimpleIconGlyph.tsx +21 -0
  105. package/src/features/workflows/components/canvas/WorkflowCanvasStraightCountEdge.tsx +33 -0
  106. package/src/features/workflows/components/canvas/WorkflowCanvasStructureSignature.tsx +7 -0
  107. package/src/features/workflows/components/canvas/WorkflowCanvasSymmetricForkEdge.tsx +32 -0
  108. package/src/features/workflows/components/canvas/WorkflowCanvasToolbarIconButton.tsx +95 -0
  109. package/src/features/workflows/components/canvas/lib/WorkflowCanvasBuiltinIconRegistry.ts +26 -0
  110. package/src/features/workflows/components/canvas/lib/WorkflowCanvasEdgeCountResolver.ts +51 -0
  111. package/src/features/workflows/components/canvas/lib/WorkflowCanvasEdgeStyleResolver.ts +35 -0
  112. package/src/features/workflows/components/canvas/lib/WorkflowCanvasLabelLayoutEstimator.ts +42 -0
  113. package/src/features/workflows/components/canvas/lib/WorkflowCanvasOverlapResolver.ts +78 -0
  114. package/src/features/workflows/components/canvas/lib/WorkflowCanvasPortOrderResolver.ts +25 -0
  115. package/src/features/workflows/components/canvas/lib/WorkflowCanvasRoundedOrthogonalPathPlanner.ts +56 -0
  116. package/src/features/workflows/components/canvas/lib/WorkflowCanvasSiIconRegistry.ts +18 -0
  117. package/src/features/workflows/components/canvas/lib/WorkflowCanvasSymmetricForkPathPlanner.ts +43 -0
  118. package/src/features/workflows/components/canvas/lib/layoutWorkflow.ts +315 -0
  119. package/src/features/workflows/components/canvas/lib/workflowCanvasEdgeGeometry.ts +3 -0
  120. package/src/features/workflows/components/canvas/lib/workflowCanvasEmbeddedStyles.ts +62 -0
  121. package/src/features/workflows/components/canvas/lib/workflowCanvasFlowTypes.ts +10 -0
  122. package/src/features/workflows/components/canvas/lib/workflowCanvasNodeData.ts +41 -0
  123. package/src/features/workflows/components/canvas/lib/workflowCanvasNodeGeometry.ts +99 -0
  124. package/src/features/workflows/components/canvas/workflowCanvasNodeChrome.tsx +46 -0
  125. package/src/features/workflows/components/realtime/RealtimeContext.tsx +14 -0
  126. package/src/features/workflows/components/realtime/WorkflowRealtimeProvider.tsx +15 -0
  127. package/src/features/workflows/components/workflowDetail/NodeCredentialBindingRow.tsx +209 -0
  128. package/src/features/workflows/components/workflowDetail/NodeCredentialBindingsSection.tsx +227 -0
  129. package/src/features/workflows/components/workflowDetail/NodePropertiesConfigSection.tsx +51 -0
  130. package/src/features/workflows/components/workflowDetail/NodePropertiesPanelHeader.tsx +50 -0
  131. package/src/features/workflows/components/workflowDetail/NodePropertiesSlidePanel.tsx +134 -0
  132. package/src/features/workflows/components/workflowDetail/WorkflowActivationErrorDialog.tsx +71 -0
  133. package/src/features/workflows/components/workflowDetail/WorkflowActivationHeaderControl.tsx +64 -0
  134. package/src/features/workflows/components/workflowDetail/WorkflowDetailIcons.tsx +52 -0
  135. package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspector.tsx +110 -0
  136. package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorDetailBody.tsx +213 -0
  137. package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorPanes.tsx +239 -0
  138. package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorSidebarResizer.tsx +31 -0
  139. package/src/features/workflows/components/workflowDetail/WorkflowExecutionInspectorTreePanel.tsx +133 -0
  140. package/src/features/workflows/components/workflowDetail/WorkflowInspectorAttachmentGroupingPresenter.tsx +31 -0
  141. package/src/features/workflows/components/workflowDetail/WorkflowInspectorAttachmentList.tsx +118 -0
  142. package/src/features/workflows/components/workflowDetail/WorkflowInspectorBinaryView.tsx +15 -0
  143. package/src/features/workflows/components/workflowDetail/WorkflowInspectorErrorView.tsx +107 -0
  144. package/src/features/workflows/components/workflowDetail/WorkflowInspectorJsonView.tsx +114 -0
  145. package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyTreePresenter.tsx +132 -0
  146. package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyTreeViewRenderer.tsx +147 -0
  147. package/src/features/workflows/components/workflowDetail/WorkflowInspectorPrettyView.tsx +65 -0
  148. package/src/features/workflows/components/workflowDetail/WorkflowInspectorViews.tsx +5 -0
  149. package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorBinaryAttachmentRow.tsx +74 -0
  150. package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorBinaryUploadRow.tsx +69 -0
  151. package/src/features/workflows/components/workflowDetail/WorkflowJsonEditorDialog.tsx +254 -0
  152. package/src/features/workflows/components/workflowDetail/WorkflowRunsList.tsx +89 -0
  153. package/src/features/workflows/components/workflowDetail/WorkflowRunsSidebar.tsx +50 -0
  154. package/src/features/workflows/hooks/canvas/useWorkflowCanvasVisibleNodeStatuses.ts +14 -0
  155. package/src/features/workflows/hooks/realtime/realtime.tsx +271 -0
  156. package/src/features/workflows/hooks/realtime/runQueryPolling.ts +34 -0
  157. package/src/features/workflows/hooks/realtime/useWorkflowRealtimeInfrastructure.ts +541 -0
  158. package/src/features/workflows/hooks/realtime/useWorkflowRealtimeShowDisconnectedBadge.ts +9 -0
  159. package/src/features/workflows/hooks/workflowDetail/useWorkflowDetailController.tsx +1300 -0
  160. package/src/features/workflows/lib/realtime/realtimeApi.ts +78 -0
  161. package/src/features/workflows/lib/realtime/realtimeClientBridge.ts +52 -0
  162. package/src/features/workflows/lib/realtime/realtimeDomainTypes.ts +191 -0
  163. package/src/features/workflows/lib/realtime/realtimeQueryKeys.ts +15 -0
  164. package/src/features/workflows/lib/realtime/realtimeRunMutations.ts +167 -0
  165. package/src/features/workflows/lib/realtime/workflowTypes.ts +5 -0
  166. package/src/features/workflows/lib/workflowDetail/PersistedWorkflowSnapshotMapper.ts +205 -0
  167. package/src/features/workflows/lib/workflowDetail/WorkflowActivationHttpErrorFormat.ts +32 -0
  168. package/src/features/workflows/lib/workflowDetail/WorkflowDetailPresenter.ts +1017 -0
  169. package/src/features/workflows/lib/workflowDetail/WorkflowDetailUrlCodec.ts +70 -0
  170. package/src/features/workflows/lib/workflowDetail/workflowDetailTypes.ts +152 -0
  171. package/src/features/workflows/lib/workflowDetailTreeStyles.ts +65 -0
  172. package/src/features/workflows/screens/WorkflowDetailScreen.tsx +236 -0
  173. package/src/features/workflows/screens/WorkflowDetailScreenInspectorPanel.tsx +55 -0
  174. package/src/features/workflows/screens/WorkflowsList.tsx +35 -0
  175. package/src/features/workflows/screens/WorkflowsScreen.tsx +31 -0
  176. package/src/index.ts +1 -0
  177. package/src/lib/utils.ts +6 -0
  178. package/src/middleware/CodemationNextHostMiddlewarePathRules.ts +31 -0
  179. package/src/providers/CodemationSessionProvider.tsx +23 -0
  180. package/src/providers/Providers.tsx +36 -0
  181. package/src/providers/RealtimeBoundary.tsx +17 -0
  182. package/src/providers/WhitelabelProvider.tsx +22 -0
  183. package/src/server/CodemationAuthPrismaClient.ts +21 -0
  184. package/src/server/CodemationNextHost.ts +379 -0
  185. package/src/shell/AppLayout.tsx +141 -0
  186. package/src/shell/AppLayoutNavItems.tsx +129 -0
  187. package/src/shell/AppLayoutPageHeader.tsx +79 -0
  188. package/src/shell/AppLayoutSidebarBrand.tsx +33 -0
  189. package/src/shell/AppMainContent.tsx +17 -0
  190. package/src/shell/AppShellHeaderActions.tsx +12 -0
  191. package/src/shell/AppShellHeaderActionsAuthenticated.tsx +51 -0
  192. package/src/shell/CodemationNextClientShell.tsx +17 -0
  193. package/src/shell/CredentialsSignInRedirectResolver.ts +21 -0
  194. package/src/shell/LoginPageClient.tsx +231 -0
  195. package/src/shell/WorkflowDetailChromeContext.tsx +42 -0
  196. package/src/shell/WorkflowFolderTreeBuilder.ts +62 -0
  197. package/src/shell/WorkflowFolderUi.ts +42 -0
  198. package/src/shell/WorkflowSidebarNavFolder.tsx +112 -0
  199. package/src/shell/WorkflowSidebarNavTree.tsx +68 -0
  200. package/src/shell/appLayoutPageTitle.ts +16 -0
  201. package/src/shell/appLayoutSidebarIcons.tsx +108 -0
  202. package/src/whitelabel/CodemationWhitelabelSnapshot.ts +4 -0
  203. package/src/whitelabel/CodemationWhitelabelSnapshotFactory.ts +18 -0
  204. package/tsconfig.json +40 -0
  205. package/tsconfig.tsbuildinfo +1 -0
  206. package/vitest.config.ts +34 -0
@@ -0,0 +1,34 @@
1
+ import type { PersistedRunState } from "../../lib/realtime/realtimeDomainTypes";
2
+
3
+ export function resolveRunPollingIntervalMs(args: {
4
+ runState: PersistedRunState | undefined;
5
+ pollWhileNonTerminalMs: number | undefined;
6
+ }): number | false {
7
+ if (!args.runState || !args.pollWhileNonTerminalMs) {
8
+ return false;
9
+ }
10
+ if (args.runState.status === "completed" || args.runState.status === "failed") {
11
+ return false;
12
+ }
13
+ return args.pollWhileNonTerminalMs;
14
+ }
15
+
16
+ export function resolveFetchedRunState(args: {
17
+ incoming: PersistedRunState;
18
+ previous: PersistedRunState | undefined;
19
+ }): PersistedRunState {
20
+ const { incoming, previous } = args;
21
+ if (!previous) {
22
+ return incoming;
23
+ }
24
+ if (previous.status === "completed" && incoming.status !== "completed") {
25
+ return previous;
26
+ }
27
+ if (previous.status === "failed" && incoming.status === "pending") {
28
+ return previous;
29
+ }
30
+ if (previous.status === "running" && incoming.status === "pending") {
31
+ return previous;
32
+ }
33
+ return incoming;
34
+ }
@@ -0,0 +1,541 @@
1
+ import { ApiPaths } from "@codemation/host-src/presentation/http/ApiPaths";
2
+ import type { Logger } from "@codemation/host-src/application/logging/Logger";
3
+ import { useQueryClient } from "@tanstack/react-query";
4
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
5
+
6
+ import type { RealtimeContextValue } from "../../components/realtime/RealtimeContext";
7
+ import { applyWorkflowEvent } from "../../lib/realtime/realtimeRunMutations";
8
+ import {
9
+ getRealtimeBridge,
10
+ minimumRealtimeActiveVisibilityMs,
11
+ persistentRealtimeDisconnectWarningDelayMs,
12
+ RealtimeReadyState,
13
+ type RealtimeClientMessage,
14
+ type RealtimeReadyValue,
15
+ type RealtimeServerMessage,
16
+ } from "../../lib/realtime/realtimeClientBridge";
17
+ import {
18
+ runQueryKey,
19
+ workflowDevBuildStateQueryKey,
20
+ workflowQueryKey,
21
+ workflowsQueryKey,
22
+ } from "../../lib/realtime/realtimeQueryKeys";
23
+ import type { PersistedRunState, WorkflowDevBuildState } from "../../lib/realtime/realtimeDomainTypes";
24
+
25
+ export function useWorkflowRealtimeInfrastructure(
26
+ args: Readonly<{ logger: Logger; websocketPort?: string }>,
27
+ ): RealtimeContextValue {
28
+ const { logger, websocketPort } = args;
29
+ const queryClient = useQueryClient();
30
+ const desiredWorkflowCountsRef = useRef(new Map<string, number>());
31
+ const pendingOutgoingMessagesRef = useRef<RealtimeClientMessage[]>([]);
32
+ const activeStatusShownAtByNodeKeyRef = useRef(new Map<string, number>());
33
+ const terminalEventTimeoutIdByNodeKeyRef = useRef(new Map<string, number>());
34
+ const socketRef = useRef<WebSocket | null>(null);
35
+ const reconnectTimeoutRef = useRef<number | null>(null);
36
+ const disconnectWarningTimeoutRef = useRef<number | null>(null);
37
+ const readyStateRef = useRef<RealtimeReadyValue>(RealtimeReadyState.UNINSTANTIATED);
38
+ const hasOpenedConnectionRef = useRef(false);
39
+ const hasLoggedUnavailableTransportRef = useRef(false);
40
+ const pendingDisconnectReasonRef = useRef<string | null>(null);
41
+ const [readyState, setReadyState] = useState<RealtimeReadyValue>(RealtimeReadyState.UNINSTANTIATED);
42
+ const sendJsonMessageRef = useRef<(message: RealtimeClientMessage) => boolean>(() => false);
43
+ const [, setActiveWorkflowIds] = useState<ReadonlyArray<string>>([]);
44
+ const websocketUrl = useMemo(() => {
45
+ if (typeof window === "undefined") return "";
46
+ const protocol = window.location.protocol === "https:" ? "wss" : "ws";
47
+ const port = websocketPort ?? window.location.port;
48
+ const host = `${window.location.hostname}${port !== undefined && port !== "" ? `:${port}` : ""}`;
49
+ return `${protocol}://${host}${ApiPaths.workflowWebsocket()}`;
50
+ }, [websocketPort]);
51
+ const devGatewayWebsocketUrl = useMemo(() => {
52
+ if (typeof window === "undefined") return "";
53
+ const protocol = window.location.protocol === "https:" ? "wss" : "ws";
54
+ const port = websocketPort ?? window.location.port;
55
+ const host = `${window.location.hostname}${port !== undefined && port !== "" ? `:${port}` : ""}`;
56
+ return `${protocol}://${host}${ApiPaths.devGatewaySocket()}`;
57
+ }, [websocketPort]);
58
+ const shouldConnect = Boolean(websocketUrl);
59
+
60
+ readyStateRef.current = readyState;
61
+ const clearPendingDisconnectWarning = useCallback((): void => {
62
+ if (disconnectWarningTimeoutRef.current === null) {
63
+ return;
64
+ }
65
+ window.clearTimeout(disconnectWarningTimeoutRef.current);
66
+ disconnectWarningTimeoutRef.current = null;
67
+ }, []);
68
+ const schedulePersistentDisconnectWarning = useCallback(
69
+ (reason: string): void => {
70
+ pendingDisconnectReasonRef.current = reason;
71
+ if (disconnectWarningTimeoutRef.current !== null) {
72
+ return;
73
+ }
74
+ disconnectWarningTimeoutRef.current = window.setTimeout(() => {
75
+ disconnectWarningTimeoutRef.current = null;
76
+ if (readyStateRef.current === RealtimeReadyState.OPEN) {
77
+ pendingDisconnectReasonRef.current = null;
78
+ return;
79
+ }
80
+ logger.warn(
81
+ `websocket transport is still unavailable after ${persistentRealtimeDisconnectWarningDelayMs}ms at ${websocketUrl}: ${pendingDisconnectReasonRef.current ?? reason}`,
82
+ );
83
+ }, persistentRealtimeDisconnectWarningDelayMs);
84
+ },
85
+ [logger, websocketUrl],
86
+ );
87
+ const clearPendingTerminalEventDelay = useCallback((nodeKey: string): void => {
88
+ const timeoutId = terminalEventTimeoutIdByNodeKeyRef.current.get(nodeKey);
89
+ if (timeoutId === undefined) return;
90
+ window.clearTimeout(timeoutId);
91
+ terminalEventTimeoutIdByNodeKeyRef.current.delete(nodeKey);
92
+ }, []);
93
+ const clearRunRealtimeDelays = useCallback(
94
+ (runId: string): void => {
95
+ const runPrefix = `${runId}:`;
96
+ for (const nodeKey of terminalEventTimeoutIdByNodeKeyRef.current.keys()) {
97
+ if (!nodeKey.startsWith(runPrefix)) continue;
98
+ clearPendingTerminalEventDelay(nodeKey);
99
+ }
100
+ for (const nodeKey of activeStatusShownAtByNodeKeyRef.current.keys()) {
101
+ if (!nodeKey.startsWith(runPrefix)) continue;
102
+ activeStatusShownAtByNodeKeyRef.current.delete(nodeKey);
103
+ }
104
+ },
105
+ [clearPendingTerminalEventDelay],
106
+ );
107
+ const handleRealtimeServerMessage = useCallback(
108
+ (message: RealtimeServerMessage) => {
109
+ if (message.kind === "event") {
110
+ const eventDetails =
111
+ "snapshot" in message.event && message.event.snapshot
112
+ ? `:${message.event.snapshot.nodeId}:${message.event.snapshot.status}`
113
+ : "";
114
+ if ("snapshot" in message.event && message.event.snapshot) {
115
+ logger.info(`realtime snapshot event node=${message.event.snapshot.nodeId} kind=${message.event.kind}`);
116
+ }
117
+ logger.debug(`received websocket event ${message.event.kind}:${message.event.workflowId}${eventDetails}`);
118
+ if (message.event.kind === "runSaved") {
119
+ clearRunRealtimeDelays(message.event.runId);
120
+ applyWorkflowEvent(queryClient, message.event);
121
+ const currentRunState = queryClient.getQueryData<PersistedRunState>(runQueryKey(message.event.runId));
122
+ logger.info(
123
+ `cache after runSaved run=${message.event.runId} status=${currentRunState?.status ?? "missing"} pending=${currentRunState?.pending?.nodeId ?? "no"} snapshots=${Object.entries(
124
+ currentRunState?.nodeSnapshotsByNodeId ?? {},
125
+ )
126
+ .map(([nodeId, snapshot]) => `${nodeId}:${snapshot.status}`)
127
+ .join(",")}`,
128
+ );
129
+ return;
130
+ }
131
+ if (
132
+ message.event.kind === "nodeQueued" ||
133
+ message.event.kind === "nodeStarted" ||
134
+ message.event.kind === "nodeCompleted" ||
135
+ message.event.kind === "nodeFailed"
136
+ ) {
137
+ const nodeKey = `${message.event.runId}:${message.event.snapshot.nodeId}`;
138
+ if (message.event.kind === "nodeQueued" || message.event.kind === "nodeStarted") {
139
+ clearPendingTerminalEventDelay(nodeKey);
140
+ activeStatusShownAtByNodeKeyRef.current.set(nodeKey, Date.now());
141
+ applyWorkflowEvent(queryClient, message.event);
142
+ return;
143
+ }
144
+
145
+ const activeStatusShownAt = activeStatusShownAtByNodeKeyRef.current.get(nodeKey);
146
+ if (activeStatusShownAt !== undefined) {
147
+ const remainingVisibilityMs = minimumRealtimeActiveVisibilityMs - (Date.now() - activeStatusShownAt);
148
+ if (remainingVisibilityMs > 0) {
149
+ clearPendingTerminalEventDelay(nodeKey);
150
+ const timeoutId = window.setTimeout(() => {
151
+ activeStatusShownAtByNodeKeyRef.current.delete(nodeKey);
152
+ terminalEventTimeoutIdByNodeKeyRef.current.delete(nodeKey);
153
+ applyWorkflowEvent(queryClient, message.event);
154
+ }, remainingVisibilityMs);
155
+ terminalEventTimeoutIdByNodeKeyRef.current.set(nodeKey, timeoutId);
156
+ return;
157
+ }
158
+ }
159
+
160
+ clearPendingTerminalEventDelay(nodeKey);
161
+ activeStatusShownAtByNodeKeyRef.current.delete(nodeKey);
162
+ applyWorkflowEvent(queryClient, message.event);
163
+ const currentRunState = queryClient.getQueryData<PersistedRunState>(runQueryKey(message.event.runId));
164
+ logger.info(
165
+ `cache after ${message.event.kind} run=${message.event.runId} node=${message.event.snapshot.nodeId} status=${currentRunState?.status ?? "missing"} pending=${currentRunState?.pending?.nodeId ?? "no"} nodeStatus=${currentRunState?.nodeSnapshotsByNodeId?.[message.event.snapshot.nodeId]?.status ?? "missing"}`,
166
+ );
167
+ return;
168
+ }
169
+ applyWorkflowEvent(queryClient, message.event);
170
+ return;
171
+ }
172
+
173
+ if (message.kind === "subscribed") {
174
+ logger.info(`subscribed to room ${message.roomId}`);
175
+ return;
176
+ }
177
+
178
+ if (message.kind === "unsubscribed") {
179
+ logger.info(`unsubscribed from room ${message.roomId}`);
180
+ return;
181
+ }
182
+
183
+ if (message.kind === "workflowChanged") {
184
+ logger.info(`workflow changed ${message.workflowId}`);
185
+ const workflowRefreshStarted = performance.now();
186
+ if (process.env.NEXT_PUBLIC_CODEMATION_PERFORMANCE_LOGGING === "true") {
187
+ logger.info(
188
+ `[codemation-dev-timing] workflowChanged received workflowId=${message.workflowId} t=${workflowRefreshStarted.toFixed(1)}`,
189
+ );
190
+ }
191
+ queryClient.setQueryData<WorkflowDevBuildState>(
192
+ workflowDevBuildStateQueryKey(message.workflowId),
193
+ (existing) => ({
194
+ state: "building",
195
+ updatedAt: existing?.updatedAt ?? new Date().toISOString(),
196
+ buildVersion: existing?.buildVersion,
197
+ awaitingWorkflowRefreshAt: new Date().toISOString(),
198
+ }),
199
+ );
200
+ void queryClient.invalidateQueries({ queryKey: workflowQueryKey(message.workflowId) });
201
+ void queryClient.invalidateQueries({ queryKey: workflowsQueryKey });
202
+ void queryClient
203
+ .refetchQueries({ queryKey: workflowQueryKey(message.workflowId), type: "active" })
204
+ .then(() => {
205
+ if (process.env.NEXT_PUBLIC_CODEMATION_PERFORMANCE_LOGGING === "true") {
206
+ logger.info(
207
+ `[codemation-dev-timing] workflow refetch finished workflowId=${message.workflowId} +${(performance.now() - workflowRefreshStarted).toFixed(1)}ms from workflowChanged`,
208
+ );
209
+ }
210
+ })
211
+ .catch(() => {
212
+ // Refetch errors are surfaced via query state; timing log only on success path.
213
+ });
214
+ return;
215
+ }
216
+
217
+ if (message.kind === "devBuildStarted") {
218
+ logger.info(`workflow rebuild started ${message.workflowId}`);
219
+ queryClient.setQueryData<WorkflowDevBuildState>(workflowDevBuildStateQueryKey(message.workflowId), {
220
+ state: "building",
221
+ updatedAt: new Date().toISOString(),
222
+ buildVersion: message.buildVersion,
223
+ awaitingWorkflowRefreshAt: new Date().toISOString(),
224
+ });
225
+ return;
226
+ }
227
+
228
+ if (message.kind === "devBuildCompleted") {
229
+ logger.info(`workflow rebuild completed ${message.workflowId} revision=${message.buildVersion}`);
230
+ const completedAt = new Date().toISOString();
231
+ queryClient.setQueryData<WorkflowDevBuildState>(workflowDevBuildStateQueryKey(message.workflowId), () => ({
232
+ state: "building",
233
+ updatedAt: completedAt,
234
+ buildVersion: message.buildVersion,
235
+ awaitingWorkflowRefreshAt: completedAt,
236
+ }));
237
+ void queryClient.invalidateQueries({ queryKey: workflowQueryKey(message.workflowId) });
238
+ void queryClient.refetchQueries({ queryKey: workflowQueryKey(message.workflowId), type: "active" });
239
+ return;
240
+ }
241
+
242
+ if (message.kind === "devBuildFailed") {
243
+ logger.error(`workflow rebuild failed ${message.workflowId}: ${message.message}`);
244
+ queryClient.setQueryData<WorkflowDevBuildState>(workflowDevBuildStateQueryKey(message.workflowId), {
245
+ state: "failed",
246
+ updatedAt: new Date().toISOString(),
247
+ message: message.message,
248
+ awaitingWorkflowRefreshAt: undefined,
249
+ });
250
+ return;
251
+ }
252
+
253
+ if (message.kind === "error") {
254
+ logger.error(`websocket error message: ${message.message}`);
255
+ return;
256
+ }
257
+
258
+ logger.debug(`websocket control message ${message.kind}`);
259
+ },
260
+ [clearPendingTerminalEventDelay, clearRunRealtimeDelays, logger, queryClient],
261
+ );
262
+ const handleRealtimeServerMessageRef = useRef(handleRealtimeServerMessage);
263
+ handleRealtimeServerMessageRef.current = handleRealtimeServerMessage;
264
+
265
+ const sendJsonMessage = useCallback((message: RealtimeClientMessage): boolean => {
266
+ if (socketRef.current?.readyState !== WebSocket.OPEN) {
267
+ pendingOutgoingMessagesRef.current.push(message);
268
+ return false;
269
+ }
270
+ socketRef.current.send(JSON.stringify(message));
271
+ return true;
272
+ }, []);
273
+ const canSendJsonMessage = useCallback((): boolean => socketRef.current?.readyState === WebSocket.OPEN, []);
274
+ sendJsonMessageRef.current = sendJsonMessage;
275
+
276
+ useEffect(() => {
277
+ if (!shouldConnect || !websocketUrl) {
278
+ if (reconnectTimeoutRef.current !== null) {
279
+ window.clearTimeout(reconnectTimeoutRef.current);
280
+ reconnectTimeoutRef.current = null;
281
+ }
282
+ pendingDisconnectReasonRef.current = null;
283
+ clearPendingDisconnectWarning();
284
+ socketRef.current?.close();
285
+ socketRef.current = null;
286
+ setReadyState(RealtimeReadyState.UNINSTANTIATED);
287
+ return;
288
+ }
289
+
290
+ let disposed = false;
291
+ const connect = () => {
292
+ if (disposed) {
293
+ return;
294
+ }
295
+ if (reconnectTimeoutRef.current !== null) {
296
+ window.clearTimeout(reconnectTimeoutRef.current);
297
+ reconnectTimeoutRef.current = null;
298
+ }
299
+ setReadyState(RealtimeReadyState.CONNECTING);
300
+ const socket = new WebSocket(websocketUrl);
301
+ socketRef.current = socket;
302
+
303
+ socket.addEventListener("open", () => {
304
+ if (disposed || socketRef.current !== socket) {
305
+ return;
306
+ }
307
+ hasOpenedConnectionRef.current = true;
308
+ hasLoggedUnavailableTransportRef.current = false;
309
+ pendingDisconnectReasonRef.current = null;
310
+ clearPendingDisconnectWarning();
311
+ setReadyState(RealtimeReadyState.OPEN);
312
+ logger.info(`websocket transport opened to ${websocketUrl}`);
313
+ const queuedMessages = pendingOutgoingMessagesRef.current.splice(0, pendingOutgoingMessagesRef.current.length);
314
+ for (const queuedMessage of queuedMessages) {
315
+ socket.send(JSON.stringify(queuedMessage));
316
+ }
317
+ });
318
+
319
+ socket.addEventListener("message", (event) => {
320
+ if (typeof event.data !== "string") {
321
+ return;
322
+ }
323
+ try {
324
+ const parsedMessage = JSON.parse(event.data) as RealtimeServerMessage;
325
+ if (parsedMessage.kind === "event") {
326
+ const eventDetails =
327
+ "snapshot" in parsedMessage.event && parsedMessage.event.snapshot
328
+ ? ` node=${parsedMessage.event.snapshot.nodeId} status=${parsedMessage.event.snapshot.status}`
329
+ : "";
330
+ logger.info(`raw websocket event kind=${parsedMessage.event.kind}${eventDetails}`);
331
+ } else {
332
+ logger.info(`raw websocket control kind=${parsedMessage.kind}`);
333
+ }
334
+ handleRealtimeServerMessageRef.current(parsedMessage);
335
+ } catch (error) {
336
+ const exception = error instanceof Error ? error : new Error(String(error));
337
+ logger.error(`failed to parse websocket message for ${websocketUrl}: ${exception.message}`);
338
+ }
339
+ });
340
+
341
+ socket.addEventListener("error", () => {
342
+ if (!hasOpenedConnectionRef.current) {
343
+ if (!hasLoggedUnavailableTransportRef.current) {
344
+ hasLoggedUnavailableTransportRef.current = true;
345
+ logger.debug(`websocket transport is not available yet at ${websocketUrl}`);
346
+ }
347
+ return;
348
+ }
349
+ schedulePersistentDisconnectWarning("transport error while reconnecting");
350
+ });
351
+
352
+ socket.addEventListener("close", (event) => {
353
+ if (socketRef.current === socket) {
354
+ socketRef.current = null;
355
+ }
356
+ setReadyState(RealtimeReadyState.CLOSED);
357
+ if (!hasOpenedConnectionRef.current && !hasLoggedUnavailableTransportRef.current) {
358
+ hasLoggedUnavailableTransportRef.current = true;
359
+ logger.debug(`websocket transport is not available yet at ${websocketUrl}`);
360
+ }
361
+ if (hasOpenedConnectionRef.current) {
362
+ schedulePersistentDisconnectWarning(
363
+ `closed code=${event.code} reason=${event.reason || "no-reason"} clean=${event.wasClean}`,
364
+ );
365
+ }
366
+ if (disposed) {
367
+ return;
368
+ }
369
+ reconnectTimeoutRef.current = window.setTimeout(() => {
370
+ reconnectTimeoutRef.current = null;
371
+ connect();
372
+ }, 1000);
373
+ });
374
+ };
375
+
376
+ connect();
377
+
378
+ return () => {
379
+ disposed = true;
380
+ if (reconnectTimeoutRef.current !== null) {
381
+ window.clearTimeout(reconnectTimeoutRef.current);
382
+ reconnectTimeoutRef.current = null;
383
+ }
384
+ clearPendingDisconnectWarning();
385
+ if (socketRef.current) {
386
+ setReadyState(RealtimeReadyState.CLOSING);
387
+ socketRef.current.close();
388
+ socketRef.current = null;
389
+ }
390
+ };
391
+ }, [clearPendingDisconnectWarning, logger, schedulePersistentDisconnectWarning, shouldConnect, websocketUrl]);
392
+
393
+ useEffect(() => {
394
+ if (!shouldConnect || !devGatewayWebsocketUrl) {
395
+ return;
396
+ }
397
+ const socket = new WebSocket(devGatewayWebsocketUrl);
398
+ socket.addEventListener("message", (event) => {
399
+ if (typeof event.data !== "string") {
400
+ return;
401
+ }
402
+ try {
403
+ const parsed = JSON.parse(event.data) as { kind?: string; message?: string };
404
+ if (parsed.kind === "devBuildCompleted") {
405
+ void queryClient.invalidateQueries({ queryKey: workflowsQueryKey });
406
+ void queryClient.invalidateQueries({
407
+ predicate: (q) =>
408
+ Array.isArray(q.queryKey) && q.queryKey[0] === "workflow" && typeof q.queryKey[1] === "string",
409
+ });
410
+ for (const query of queryClient.getQueryCache().findAll({ queryKey: ["workflow-dev-build-state"] })) {
411
+ const key = query.queryKey;
412
+ if (Array.isArray(key) && key[0] === "workflow-dev-build-state" && typeof key[1] === "string") {
413
+ queryClient.setQueryData<WorkflowDevBuildState>(workflowDevBuildStateQueryKey(key[1]), {
414
+ state: "idle",
415
+ updatedAt: new Date().toISOString(),
416
+ });
417
+ }
418
+ }
419
+ }
420
+ if (parsed.kind === "devBuildFailed" && typeof parsed.message === "string") {
421
+ logger.error(`consumer rebuild failed: ${parsed.message}`);
422
+ }
423
+ } catch {
424
+ // ignore malformed gateway dev messages
425
+ }
426
+ });
427
+ return () => {
428
+ socket.close();
429
+ };
430
+ }, [devGatewayWebsocketUrl, logger, queryClient, shouldConnect]);
431
+
432
+ useEffect(() => {
433
+ return () => {
434
+ clearPendingDisconnectWarning();
435
+ for (const timeoutId of terminalEventTimeoutIdByNodeKeyRef.current.values()) {
436
+ window.clearTimeout(timeoutId);
437
+ }
438
+ terminalEventTimeoutIdByNodeKeyRef.current.clear();
439
+ activeStatusShownAtByNodeKeyRef.current.clear();
440
+ };
441
+ }, [clearPendingDisconnectWarning]);
442
+
443
+ useEffect(() => {
444
+ if (readyState === RealtimeReadyState.OPEN) {
445
+ logger.info("websocket readyState changed to OPEN");
446
+ return;
447
+ }
448
+ if (
449
+ readyState === RealtimeReadyState.CLOSED &&
450
+ hasOpenedConnectionRef.current &&
451
+ disconnectWarningTimeoutRef.current === null &&
452
+ pendingDisconnectReasonRef.current !== null
453
+ ) {
454
+ logger.warn("websocket readyState changed to CLOSED");
455
+ }
456
+ }, [logger, readyState]);
457
+
458
+ useEffect(() => {
459
+ if (readyState !== RealtimeReadyState.OPEN) return;
460
+ for (const workflowId of desiredWorkflowCountsRef.current.keys()) {
461
+ const sent = sendJsonMessage({ kind: "subscribe", roomId: workflowId } satisfies RealtimeClientMessage);
462
+ logger.debug(`${sent ? "sent" : "queued"} subscribe for workflow ${workflowId}`);
463
+ }
464
+ }, [logger, readyState, sendJsonMessage]);
465
+
466
+ const retainWorkflowSubscription = useCallback(
467
+ (workflowId: string) => {
468
+ const nextCount = (desiredWorkflowCountsRef.current.get(workflowId) ?? 0) + 1;
469
+ desiredWorkflowCountsRef.current.set(workflowId, nextCount);
470
+ setActiveWorkflowIds((current) => {
471
+ const next = [...desiredWorkflowCountsRef.current.keys()];
472
+ return current.length === next.length && current.every((value, index) => value === next[index])
473
+ ? current
474
+ : next;
475
+ });
476
+ if (nextCount === 1 && readyStateRef.current === RealtimeReadyState.OPEN && canSendJsonMessage()) {
477
+ const sent = sendJsonMessageRef.current({
478
+ kind: "subscribe",
479
+ roomId: workflowId,
480
+ } satisfies RealtimeClientMessage);
481
+ logger.debug(`${sent ? "sent" : "queued"} retain subscription for workflow ${workflowId}`);
482
+ }
483
+
484
+ return () => {
485
+ const currentCount = desiredWorkflowCountsRef.current.get(workflowId) ?? 0;
486
+ if (currentCount <= 1) {
487
+ desiredWorkflowCountsRef.current.delete(workflowId);
488
+ setActiveWorkflowIds((current) => {
489
+ const next = [...desiredWorkflowCountsRef.current.keys()];
490
+ return current.length === next.length && current.every((value, index) => value === next[index])
491
+ ? current
492
+ : next;
493
+ });
494
+ if (readyStateRef.current === RealtimeReadyState.OPEN && canSendJsonMessage()) {
495
+ const sent = sendJsonMessageRef.current({
496
+ kind: "unsubscribe",
497
+ roomId: workflowId,
498
+ } satisfies RealtimeClientMessage);
499
+ logger.debug(`${sent ? "sent" : "queued"} unsubscribe for workflow ${workflowId}`);
500
+ }
501
+ return;
502
+ }
503
+ desiredWorkflowCountsRef.current.set(workflowId, currentCount - 1);
504
+ setActiveWorkflowIds((current) => {
505
+ const next = [...desiredWorkflowCountsRef.current.keys()];
506
+ return current.length === next.length && current.every((value, index) => value === next[index])
507
+ ? current
508
+ : next;
509
+ });
510
+ };
511
+ },
512
+ [canSendJsonMessage, logger],
513
+ );
514
+
515
+ const value = useMemo<RealtimeContextValue>(
516
+ () => ({
517
+ retainWorkflowSubscription,
518
+ isConnected: readyState === RealtimeReadyState.OPEN,
519
+ showDisconnectedBadge: readyState === RealtimeReadyState.CLOSED,
520
+ }),
521
+ [readyState, retainWorkflowSubscription],
522
+ );
523
+
524
+ useEffect(() => {
525
+ const bridge = getRealtimeBridge();
526
+ bridge.retainWorkflowSubscription = retainWorkflowSubscription;
527
+ for (const listener of bridge.listeners) {
528
+ listener();
529
+ }
530
+ return () => {
531
+ if (bridge.retainWorkflowSubscription === retainWorkflowSubscription) {
532
+ bridge.retainWorkflowSubscription = null;
533
+ }
534
+ for (const listener of bridge.listeners) {
535
+ listener();
536
+ }
537
+ };
538
+ }, [retainWorkflowSubscription]);
539
+
540
+ return value;
541
+ }
@@ -0,0 +1,9 @@
1
+ "use client";
2
+
3
+ import { useContext } from "react";
4
+
5
+ import { RealtimeContext } from "../../components/realtime/RealtimeContext";
6
+
7
+ export function useWorkflowRealtimeShowDisconnectedBadge(): boolean {
8
+ return useContext(RealtimeContext)?.showDisconnectedBadge ?? false;
9
+ }