@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,1017 @@
1
+ import { ItemsInputNormalizer, RunFinishedAtFactory } from "@codemation/core/browser";
2
+ import type { WorkflowCredentialHealthSlotDto } from "@codemation/host-src/application/contracts/CredentialContractsRegistry";
3
+ import { ApiPaths } from "@codemation/host-src/presentation/http/ApiPaths";
4
+ import { codemationApiClient } from "../../../../api/CodemationApiClient";
5
+ import { format, isToday, isYesterday } from "date-fns";
6
+ import prettyMilliseconds from "pretty-ms";
7
+ import type {
8
+ ConnectionInvocationRecord,
9
+ Items,
10
+ NodeExecutionSnapshot,
11
+ PersistedRunState,
12
+ PersistedWorkflowSnapshot,
13
+ RunCurrentState,
14
+ RunSummary,
15
+ WorkflowDebuggerOverlayState,
16
+ WorkflowDto,
17
+ } from "../../hooks/realtime/realtime";
18
+ import { PersistedWorkflowSnapshotMapper } from "./PersistedWorkflowSnapshotMapper";
19
+ import type { BinaryAttachment } from "@codemation/core/browser";
20
+ import type {
21
+ ExecutionNode,
22
+ ExecutionTreeNode,
23
+ InspectorMode,
24
+ NodeExecutionError,
25
+ PinBinaryMapsByItemIndex,
26
+ PortEntries,
27
+ ViewedWorkflowContext,
28
+ WorkflowExecutionInspectorAttachmentModel,
29
+ WorkflowNode,
30
+ } from "./workflowDetailTypes";
31
+
32
+ export type RunWorkflowResult = Readonly<{
33
+ runId: string;
34
+ workflowId: string;
35
+ status: string;
36
+ startedAt?: string;
37
+ state: PersistedRunState | null;
38
+ }>;
39
+ export type RunWorkflowMode = "manual" | "debug";
40
+ export type RunWorkflowRequest = Readonly<{
41
+ items?: Items;
42
+ currentState?: RunCurrentState;
43
+ startAt?: string;
44
+ stopAt?: string;
45
+ clearFromNodeId?: string;
46
+ mode?: RunWorkflowMode;
47
+ sourceRunId?: string;
48
+ }>;
49
+
50
+ type InspectableExecutionState = Readonly<{
51
+ mutableState?: PersistedRunState["mutableState"];
52
+ nodeSnapshotsByNodeId: PersistedRunState["nodeSnapshotsByNodeId"];
53
+ connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>;
54
+ }>;
55
+
56
+ export class WorkflowDetailPresenter {
57
+ private static readonly persistedWorkflowDtoMapper = new PersistedWorkflowSnapshotMapper();
58
+ private static readonly itemsInputNormalizer = new ItemsInputNormalizer();
59
+ private static readonly visibleExecutionStatuses = new Set<NodeExecutionSnapshot["status"]>([
60
+ "queued",
61
+ "running",
62
+ "completed",
63
+ "failed",
64
+ ]);
65
+
66
+ static async runWorkflow(
67
+ workflowId: string,
68
+ workflow: WorkflowDto | undefined,
69
+ request: RunWorkflowRequest = {},
70
+ ): Promise<RunWorkflowResult> {
71
+ const shouldSynthesizeTriggerItems = this.shouldSynthesizeTriggerItems(workflow, request);
72
+ const items = request.items ?? (shouldSynthesizeTriggerItems ? undefined : this.createRunItems(workflow));
73
+ return await codemationApiClient.postJson<RunWorkflowResult>(ApiPaths.runs(), {
74
+ workflowId,
75
+ items,
76
+ synthesizeTriggerItems: shouldSynthesizeTriggerItems,
77
+ currentState: request.currentState,
78
+ startAt: request.startAt,
79
+ stopAt: request.stopAt,
80
+ clearFromNodeId: request.clearFromNodeId,
81
+ mode: request.mode,
82
+ sourceRunId: request.sourceRunId,
83
+ });
84
+ }
85
+
86
+ static createOptimisticTriggerFetchSnapshot(
87
+ workflowId: string,
88
+ workflow: WorkflowDto | undefined,
89
+ request: RunWorkflowRequest,
90
+ ): NodeExecutionSnapshot | undefined {
91
+ const triggerNodeId = this.resolveTriggerTestNodeId(workflow, request);
92
+ if (!triggerNodeId) {
93
+ return undefined;
94
+ }
95
+ const updatedAt = new Date().toISOString();
96
+ return {
97
+ runId: `optimistic_trigger_fetch:${workflowId}:${triggerNodeId}`,
98
+ workflowId,
99
+ nodeId: triggerNodeId,
100
+ status: "running",
101
+ startedAt: updatedAt,
102
+ updatedAt,
103
+ inputsByPort: {},
104
+ };
105
+ }
106
+
107
+ static async runNode(
108
+ runId: string,
109
+ nodeId: string,
110
+ items: Items | undefined,
111
+ mode?: RunWorkflowMode,
112
+ synthesizeTriggerItems?: boolean,
113
+ ): Promise<RunWorkflowResult> {
114
+ return await codemationApiClient.postJson<RunWorkflowResult>(ApiPaths.runNode(runId, nodeId), {
115
+ items,
116
+ mode,
117
+ synthesizeTriggerItems,
118
+ });
119
+ }
120
+
121
+ static async updatePinnedInput(runId: string, nodeId: string, items: Items | undefined): Promise<PersistedRunState> {
122
+ return await codemationApiClient.patchJson<PersistedRunState>(ApiPaths.runNodePin(runId, nodeId), { items });
123
+ }
124
+
125
+ static async updateWorkflowSnapshot(
126
+ runId: string,
127
+ workflowSnapshot: PersistedWorkflowSnapshot,
128
+ ): Promise<PersistedRunState> {
129
+ return await codemationApiClient.patchJson<PersistedRunState>(ApiPaths.runWorkflowSnapshot(runId), {
130
+ workflowSnapshot,
131
+ });
132
+ }
133
+
134
+ static createRunItems(workflow: WorkflowDto | undefined): Items {
135
+ if (this.isTriggerStartedWorkflow(workflow)) {
136
+ return [];
137
+ }
138
+ return [{ json: {} }];
139
+ }
140
+
141
+ static formatDateTime(value: string | undefined): string {
142
+ if (!value) return "Pending";
143
+ const date = new Date(value);
144
+ const time = format(date, "HH:mm:ss");
145
+ if (isToday(date)) return `Today ${time}`;
146
+ if (isYesterday(date)) return `Yesterday ${time}`;
147
+ return format(date, "d MMM yyyy HH:mm:ss");
148
+ }
149
+
150
+ /** Primary label for run list rows: clearer date + time than {@link formatDateTime}. */
151
+ static formatRunListWhen(value: string | undefined): string {
152
+ if (!value) return "—";
153
+ const date = new Date(value);
154
+ if (Number.isNaN(date.getTime())) return "—";
155
+ const time = format(date, "HH:mm");
156
+ if (isToday(date)) return `Today · ${time}`;
157
+ if (isYesterday(date)) return `Yesterday · ${time}`;
158
+ return format(date, "EEE d MMM yyyy · HH:mm");
159
+ }
160
+
161
+ static formatRunListDurationLine(run: Pick<RunSummary, "startedAt" | "finishedAt" | "status">): string {
162
+ if (run.status === "running") return "Still running…";
163
+ if (run.status === "pending") return "Waiting…";
164
+ if (!run.startedAt) return "";
165
+ const startMs = new Date(run.startedAt).getTime();
166
+ if (Number.isNaN(startMs)) return "";
167
+ if (run.finishedAt) {
168
+ const endMs = new Date(run.finishedAt).getTime();
169
+ if (!Number.isNaN(endMs)) {
170
+ return prettyMilliseconds(Math.max(0, endMs - startMs), { compact: true });
171
+ }
172
+ }
173
+ return "—";
174
+ }
175
+
176
+ static getNodeDisplayName(node: WorkflowNode | undefined, fallback: string | null): string {
177
+ return node?.name ?? node?.type ?? fallback ?? "Unknown node";
178
+ }
179
+
180
+ static getSnapshotTimestamp(snapshot: NodeExecutionSnapshot | undefined): string | undefined {
181
+ return snapshot?.finishedAt ?? snapshot?.updatedAt ?? snapshot?.startedAt ?? snapshot?.queuedAt;
182
+ }
183
+
184
+ static formatDurationLabel(snapshot: NodeExecutionSnapshot | undefined): string | null {
185
+ const durationMs = WorkflowDetailPresenter.getSnapshotDurationMs(snapshot);
186
+ if (durationMs === null) {
187
+ return null;
188
+ }
189
+ return `Took ${prettyMilliseconds(durationMs, { unitCount: 3, separateMilliseconds: true })}`;
190
+ }
191
+
192
+ static getDefaultInspectorMode(_snapshot: NodeExecutionSnapshot | undefined): InspectorMode {
193
+ return "output";
194
+ }
195
+
196
+ static getPreferredWorkflowNodeId(workflow: WorkflowDto | undefined): string | null {
197
+ if (!workflow) {
198
+ return null;
199
+ }
200
+ return (
201
+ workflow.nodes.find((node) => node.role === "agent")?.id ??
202
+ workflow.nodes.find((node) => node.kind !== "trigger")?.id ??
203
+ workflow.nodes[0]?.id ??
204
+ null
205
+ );
206
+ }
207
+
208
+ static inspectorSelectionAnchorsDisplayedWorkflow(
209
+ nodeId: string | null,
210
+ workflow: WorkflowDto | undefined,
211
+ connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>,
212
+ ): boolean {
213
+ if (!nodeId || !workflow?.nodes.length) {
214
+ return false;
215
+ }
216
+ if (workflow.nodes.some((n) => n.id === nodeId)) {
217
+ return true;
218
+ }
219
+ return Boolean(connectionInvocations?.some((inv) => inv.invocationId === nodeId));
220
+ }
221
+
222
+ /**
223
+ * Maps inspector selection id (workflow node id or LLM/tool {@link ConnectionInvocationRecord#invocationId})
224
+ * to the workflow node id used for canvas chrome (selection ring, properties panel workflow lookup).
225
+ */
226
+ static resolveCanvasWorkflowNodeIdForHighlight(
227
+ selectedId: string | null,
228
+ workflow: WorkflowDto | undefined,
229
+ connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>,
230
+ ): string | null {
231
+ if (!selectedId || !workflow?.nodes.length) {
232
+ return null;
233
+ }
234
+ if (workflow.nodes.some((n) => n.id === selectedId)) {
235
+ return selectedId;
236
+ }
237
+ const inv = connectionInvocations?.find((i) => i.invocationId === selectedId);
238
+ return inv?.connectionNodeId ?? null;
239
+ }
240
+
241
+ static resolveInspectorNodeIdForCanvasPick(
242
+ canvasWorkflowNodeId: string,
243
+ workflow: WorkflowDto | undefined,
244
+ nodeSnapshotsByNodeId: Readonly<Record<string, NodeExecutionSnapshot>> | undefined,
245
+ connectionInvocations?: ReadonlyArray<ConnectionInvocationRecord>,
246
+ ): string {
247
+ const wfNode = workflow?.nodes.find((n) => n.id === canvasWorkflowNodeId);
248
+ if (!wfNode) {
249
+ return canvasWorkflowNodeId;
250
+ }
251
+ const invocationsForEdge = (connectionInvocations ?? []).filter(
252
+ (inv) => inv.connectionNodeId === canvasWorkflowNodeId,
253
+ );
254
+ if (invocationsForEdge.length > 0) {
255
+ const ordered = [...invocationsForEdge].sort((left, right) => right.updatedAt.localeCompare(left.updatedAt));
256
+ return ordered[0]!.invocationId;
257
+ }
258
+ const snapshots = Object.values(nodeSnapshotsByNodeId ?? {}).filter((snapshot) =>
259
+ this.visibleExecutionStatuses.has(snapshot.status),
260
+ );
261
+ const matching = snapshots.filter((snapshot) => {
262
+ if (wfNode.role === "languageModel") {
263
+ return snapshot.nodeId === canvasWorkflowNodeId;
264
+ }
265
+ if (wfNode.role === "tool") {
266
+ return snapshot.nodeId === canvasWorkflowNodeId;
267
+ }
268
+ return snapshot.nodeId === canvasWorkflowNodeId;
269
+ });
270
+ if (matching.length === 0) {
271
+ return canvasWorkflowNodeId;
272
+ }
273
+ matching.sort((left, right) => {
274
+ const leftTs = this.getSnapshotTimestamp(left) ?? "";
275
+ const rightTs = this.getSnapshotTimestamp(right) ?? "";
276
+ return rightTs.localeCompare(leftTs);
277
+ });
278
+ return matching[0]!.nodeId;
279
+ }
280
+
281
+ static sortPortEntries(value: Readonly<Record<string, Items>> | undefined): PortEntries {
282
+ return Object.entries(value ?? {}).sort(([left], [right]) => {
283
+ if (left === right) return 0;
284
+ if (left === "main") return -1;
285
+ if (right === "main") return 1;
286
+ return left.localeCompare(right);
287
+ });
288
+ }
289
+
290
+ static resolveSelectedPort(entries: PortEntries, current: string | null): string | null {
291
+ if (entries.length === 0) return null;
292
+ if (current && entries.some(([portName]) => portName === current)) return current;
293
+ return entries.find(([, items]) => items.length > 0)?.[0] ?? entries[0]![0];
294
+ }
295
+
296
+ static applyPinnedOutputToPortEntries(entries: PortEntries, pinnedOutput: Items | undefined): PortEntries {
297
+ if (typeof pinnedOutput === "undefined") {
298
+ return entries;
299
+ }
300
+ return [["main", pinnedOutput], ...entries.filter(([portName]) => portName !== "main")];
301
+ }
302
+
303
+ static toJsonValue(items: Items | undefined): unknown {
304
+ if (!items || items.length === 0) return undefined;
305
+ const jsonValues = items.map((item) => item.json);
306
+ return jsonValues.length === 1 ? jsonValues[0] : jsonValues;
307
+ }
308
+
309
+ static resolveBinaryContentUrl(
310
+ workflowId: string,
311
+ viewContext: ViewedWorkflowContext,
312
+ attachment: BinaryAttachment,
313
+ ): string {
314
+ if (viewContext === "live-workflow") {
315
+ return ApiPaths.workflowOverlayBinaryContent(workflowId, attachment.id);
316
+ }
317
+ return ApiPaths.runBinaryContent(attachment.runId, attachment.id);
318
+ }
319
+
320
+ static toAttachmentModels(
321
+ items: Items | undefined,
322
+ workflowId: string,
323
+ viewContext: ViewedWorkflowContext,
324
+ ): ReadonlyArray<WorkflowExecutionInspectorAttachmentModel> {
325
+ if (!items) {
326
+ return [];
327
+ }
328
+ const attachments: WorkflowExecutionInspectorAttachmentModel[] = [];
329
+ for (let itemIndex = 0; itemIndex < items.length; itemIndex += 1) {
330
+ const item = items[itemIndex]!;
331
+ for (const [name, attachment] of Object.entries(item.binary ?? {})) {
332
+ attachments.push({
333
+ key: `${itemIndex}:${name}:${attachment.id}`,
334
+ itemIndex,
335
+ name,
336
+ contentUrl: this.resolveBinaryContentUrl(workflowId, viewContext, attachment),
337
+ attachment,
338
+ });
339
+ }
340
+ }
341
+ return attachments;
342
+ }
343
+
344
+ static extractBinaryMapsFromItems(items: Items | undefined): PinBinaryMapsByItemIndex {
345
+ return (items ?? []).map((item) => ({ ...(item.binary ?? {}) }));
346
+ }
347
+
348
+ static reindexBinaryMapsForItemCount(maps: PinBinaryMapsByItemIndex, itemCount: number): PinBinaryMapsByItemIndex {
349
+ const next: Array<Readonly<Record<string, BinaryAttachment>>> = [];
350
+ for (let i = 0; i < itemCount; i += 1) {
351
+ next.push(i < maps.length ? { ...maps[i] } : {});
352
+ }
353
+ return next;
354
+ }
355
+
356
+ static mergePinOutputJsonWithBinaryMaps(jsonText: string, binaryMapsByItemIndex: PinBinaryMapsByItemIndex): Items {
357
+ const parsed = this.parseEditableItems(jsonText);
358
+ return parsed.map((item, index) => ({
359
+ json: item.json,
360
+ binary: { ...(binaryMapsByItemIndex[index] ?? {}) },
361
+ }));
362
+ }
363
+
364
+ static async uploadOverlayPinnedBinary(
365
+ args: Readonly<{
366
+ workflowId: string;
367
+ nodeId: string;
368
+ itemIndex: number;
369
+ attachmentName: string;
370
+ file: File;
371
+ }>,
372
+ ): Promise<BinaryAttachment> {
373
+ const form = new FormData();
374
+ form.set("file", args.file);
375
+ form.set("nodeId", args.nodeId);
376
+ form.set("itemIndex", String(args.itemIndex));
377
+ form.set("attachmentName", args.attachmentName);
378
+ const body = await codemationApiClient.postFormData<{ attachment: BinaryAttachment }>(
379
+ ApiPaths.workflowDebuggerOverlayBinaryUpload(args.workflowId),
380
+ form,
381
+ );
382
+ return body.attachment;
383
+ }
384
+
385
+ static getRunQueryKey(runId: string): readonly ["run", string] {
386
+ return ["run", runId];
387
+ }
388
+
389
+ static getWorkflowRunsQueryKey(workflowId: string): readonly ["workflow-runs", string] {
390
+ return ["workflow-runs", workflowId];
391
+ }
392
+
393
+ static getWorkflowDebuggerOverlayQueryKey(workflowId: string): readonly ["workflow-debugger-overlay", string] {
394
+ return ["workflow-debugger-overlay", workflowId];
395
+ }
396
+
397
+ static toRunSummary(state: PersistedRunState): RunSummary {
398
+ return {
399
+ runId: state.runId,
400
+ workflowId: state.workflowId,
401
+ startedAt: state.startedAt,
402
+ status: state.status,
403
+ finishedAt: RunFinishedAtFactory.resolveIso(state),
404
+ parent: state.parent,
405
+ executionOptions: state.executionOptions,
406
+ };
407
+ }
408
+
409
+ static mergeRunSummaryList(
410
+ existing: ReadonlyArray<RunSummary> | undefined,
411
+ summary: RunSummary,
412
+ ): ReadonlyArray<RunSummary> {
413
+ const current = [...(existing ?? [])];
414
+ const index = current.findIndex((entry) => entry.runId === summary.runId);
415
+ if (index >= 0) {
416
+ current[index] = summary;
417
+ } else {
418
+ current.unshift(summary);
419
+ }
420
+ current.sort((left, right) => right.startedAt.localeCompare(left.startedAt));
421
+ return current;
422
+ }
423
+
424
+ static getErrorHeadline(error: NodeExecutionError | undefined): string {
425
+ if (!error) return "Execution failed";
426
+ return error.name && error.name !== "Error" ? `${error.name}: ${error.message}` : error.message;
427
+ }
428
+
429
+ static getErrorStack(error: NodeExecutionError | undefined): string | null {
430
+ if (!error) return null;
431
+ return error.stack?.trim() || null;
432
+ }
433
+
434
+ static getErrorClipboardText(error: NodeExecutionError | undefined): string {
435
+ if (!error) return "";
436
+ return [this.getErrorHeadline(error), this.getErrorStack(error)]
437
+ .filter((value): value is string => Boolean(value))
438
+ .join("\n\n");
439
+ }
440
+
441
+ static isTriggerStartedWorkflow(workflow: WorkflowDto | undefined): boolean {
442
+ return workflow?.nodes.some((node) => node.kind === "trigger") ?? false;
443
+ }
444
+
445
+ private static shouldSynthesizeTriggerItems(workflow: WorkflowDto | undefined, request: RunWorkflowRequest): boolean {
446
+ return Boolean(this.resolveTriggerTestNodeId(workflow, request));
447
+ }
448
+
449
+ private static resolveTriggerTestNodeId(
450
+ workflow: WorkflowDto | undefined,
451
+ request: RunWorkflowRequest,
452
+ ): string | undefined {
453
+ if (request.items !== undefined) {
454
+ return undefined;
455
+ }
456
+ if (request.stopAt && this.isTriggerNode(workflow, request.stopAt)) {
457
+ return request.stopAt;
458
+ }
459
+ if (request.startAt && this.isTriggerNode(workflow, request.startAt)) {
460
+ return request.startAt;
461
+ }
462
+ if (!request.stopAt && this.isTriggerStartedWorkflow(workflow)) {
463
+ return workflow?.nodes.find((node) => node.kind === "trigger")?.id;
464
+ }
465
+ return undefined;
466
+ }
467
+
468
+ private static isTriggerNode(workflow: WorkflowDto | undefined, nodeId: string): boolean {
469
+ return workflow?.nodes.find((node) => node.id === nodeId)?.kind === "trigger";
470
+ }
471
+
472
+ static getExecutionModeLabel(
473
+ run: Pick<RunSummary, "executionOptions"> | Pick<PersistedRunState, "executionOptions"> | undefined,
474
+ ): string | null {
475
+ const mode = run?.executionOptions?.mode;
476
+ if (mode === "manual") return "Manual";
477
+ if (mode === "debug") return "Debug";
478
+ return null;
479
+ }
480
+
481
+ static isMutableExecution(run: Pick<PersistedRunState, "executionOptions"> | undefined): boolean {
482
+ return Boolean(run?.executionOptions?.isMutable);
483
+ }
484
+
485
+ private static getSnapshotDurationMs(snapshot: NodeExecutionSnapshot | undefined): number | null {
486
+ if (!snapshot?.startedAt || !snapshot.finishedAt) {
487
+ return null;
488
+ }
489
+ const startedAt = Date.parse(snapshot.startedAt);
490
+ const finishedAt = Date.parse(snapshot.finishedAt);
491
+ if (!Number.isFinite(startedAt) || !Number.isFinite(finishedAt) || finishedAt < startedAt) {
492
+ return null;
493
+ }
494
+ return finishedAt - startedAt;
495
+ }
496
+
497
+ static async replaceWorkflowDebuggerOverlay(
498
+ workflowId: string,
499
+ currentState: WorkflowDebuggerOverlayState["currentState"],
500
+ ): Promise<WorkflowDebuggerOverlayState> {
501
+ return await codemationApiClient.putJson<WorkflowDebuggerOverlayState>(
502
+ ApiPaths.workflowDebuggerOverlay(workflowId),
503
+ {
504
+ currentState,
505
+ },
506
+ );
507
+ }
508
+
509
+ static async copyRunToDebuggerOverlay(
510
+ workflowId: string,
511
+ sourceRunId: string,
512
+ ): Promise<WorkflowDebuggerOverlayState> {
513
+ return await codemationApiClient.postJson<WorkflowDebuggerOverlayState>(
514
+ ApiPaths.workflowDebuggerOverlayCopyRun(workflowId),
515
+ {
516
+ sourceRunId,
517
+ },
518
+ );
519
+ }
520
+
521
+ static workflowFromSnapshot(
522
+ snapshot: PersistedWorkflowSnapshot | undefined,
523
+ fallback: WorkflowDto | undefined,
524
+ ): WorkflowDto | undefined {
525
+ if (!snapshot) {
526
+ return fallback;
527
+ }
528
+ return this.persistedWorkflowDtoMapper.map(snapshot);
529
+ }
530
+
531
+ static resolveViewedWorkflow(
532
+ args: Readonly<{ selectedRun?: PersistedRunState; liveWorkflow?: WorkflowDto }>,
533
+ ): WorkflowDto | undefined {
534
+ return this.workflowFromSnapshot(args.selectedRun?.workflowSnapshot, args.liveWorkflow);
535
+ }
536
+
537
+ static createWorkflowStructureSignature(workflow: WorkflowDto | undefined): string {
538
+ return JSON.stringify(workflow ?? null);
539
+ }
540
+
541
+ static getPinnedOutput(
542
+ currentState: InspectableExecutionState | undefined,
543
+ nodeId: string | null,
544
+ ): Items | undefined {
545
+ if (!currentState || !nodeId) {
546
+ return undefined;
547
+ }
548
+ return currentState.mutableState?.nodesById?.[nodeId]?.pinnedOutputsByPort?.main;
549
+ }
550
+
551
+ static reconcileCurrentStateWithWorkflow(
552
+ currentState: WorkflowDebuggerOverlayState["currentState"] | undefined,
553
+ workflow: WorkflowDto | undefined,
554
+ ): WorkflowDebuggerOverlayState["currentState"] | undefined {
555
+ if (!currentState || !workflow) {
556
+ return currentState;
557
+ }
558
+ const workflowNodeIds = new Set(workflow.nodes.map((node) => node.id));
559
+ return {
560
+ outputsByNode: Object.fromEntries(
561
+ Object.entries(currentState.outputsByNode).filter(([nodeId]) =>
562
+ this.isCompatibleWorkflowNodeId(workflowNodeIds, nodeId),
563
+ ),
564
+ ),
565
+ nodeSnapshotsByNodeId: Object.fromEntries(
566
+ Object.entries(currentState.nodeSnapshotsByNodeId).filter(([nodeId]) =>
567
+ this.isCompatibleWorkflowNodeId(workflowNodeIds, nodeId),
568
+ ),
569
+ ),
570
+ connectionInvocations: currentState.connectionInvocations?.filter(
571
+ (inv) => workflowNodeIds.has(inv.connectionNodeId) && workflowNodeIds.has(inv.parentAgentNodeId),
572
+ ),
573
+ mutableState: currentState.mutableState
574
+ ? {
575
+ nodesById: Object.fromEntries(
576
+ Object.entries(currentState.mutableState.nodesById).filter(([nodeId]) =>
577
+ this.isCompatibleWorkflowNodeId(workflowNodeIds, nodeId),
578
+ ),
579
+ ),
580
+ }
581
+ : undefined,
582
+ };
583
+ }
584
+
585
+ static createLiveRunCurrentState(
586
+ request: RunWorkflowRequest,
587
+ currentState:
588
+ | Pick<RunCurrentState, "outputsByNode" | "nodeSnapshotsByNodeId" | "mutableState" | "connectionInvocations">
589
+ | undefined,
590
+ ): RunCurrentState {
591
+ if (this.shouldStartWorkflowFromCleanState(request)) {
592
+ return this.createCleanRunCurrentState(currentState);
593
+ }
594
+ return this.cloneRunCurrentState(currentState);
595
+ }
596
+
597
+ static toEditableJson(items: Items | undefined): string {
598
+ const value = this.toJsonValue(items);
599
+ return JSON.stringify(value ?? {}, null, 2);
600
+ }
601
+
602
+ /**
603
+ * Initial JSON for the pin-output editor only: always a top-level JSON array of per-item payloads.
604
+ * Matches engine `Items` semantics and the Binaries tab (item indices). Display code elsewhere may still
605
+ * use {@link toEditableJson} to reduce noise for single items.
606
+ */
607
+ static toPinOutputEditorJson(items: Items | undefined): string {
608
+ if (items === undefined) {
609
+ return JSON.stringify([{}], null, 2);
610
+ }
611
+ if (items.length === 0) {
612
+ return JSON.stringify([], null, 2);
613
+ }
614
+ return JSON.stringify(
615
+ items.map((item) => item.json),
616
+ null,
617
+ 2,
618
+ );
619
+ }
620
+
621
+ /**
622
+ * Ensures pin-output editor submissions are a JSON array at the top level (`{}` → `[{}]`, `[{}]` unchanged).
623
+ * API / {@link parseEditableItems} already accept both; this keeps the saved text aligned with the engine model.
624
+ */
625
+ static formatPinOutputJsonForSubmit(text: string): string {
626
+ const parsed = JSON.parse(text) as unknown;
627
+ if (parsed === null || parsed === undefined) {
628
+ return JSON.stringify([], null, 2);
629
+ }
630
+ const array = Array.isArray(parsed) ? parsed : [parsed];
631
+ return JSON.stringify(array, null, 2);
632
+ }
633
+
634
+ static parseEditableItems(text: string): Items {
635
+ const parsed = JSON.parse(text) as unknown;
636
+ return this.itemsInputNormalizer.normalize(parsed);
637
+ }
638
+
639
+ static parseWorkflowSnapshot(text: string): PersistedWorkflowSnapshot {
640
+ return JSON.parse(text) as PersistedWorkflowSnapshot;
641
+ }
642
+
643
+ static buildExecutionNodes(
644
+ workflow: WorkflowDto | undefined,
645
+ executionState: InspectableExecutionState | undefined,
646
+ ): ReadonlyArray<ExecutionNode> {
647
+ if (!workflow) return [];
648
+ const snapshots = Object.values(executionState?.nodeSnapshotsByNodeId ?? {}).filter((snapshot) =>
649
+ this.visibleExecutionStatuses.has(snapshot.status),
650
+ );
651
+ const executionStateForInvocations: InspectableExecutionState | undefined =
652
+ executionState === undefined
653
+ ? undefined
654
+ : {
655
+ ...executionState,
656
+ connectionInvocations: this.normalizeConnectionInvocations(executionState.connectionInvocations),
657
+ };
658
+ return workflow.nodes
659
+ .flatMap((node) => this.createExecutionNodesForWorkflowNode(node, snapshots, executionStateForInvocations))
660
+ .sort((left, right) => this.compareExecutionNodes(left, right));
661
+ }
662
+
663
+ /**
664
+ * Required credential slots that are still unbound (excluding optional credential slots).
665
+ */
666
+ static resolveCredentialAttention(
667
+ args: Readonly<{
668
+ workflow: WorkflowDto | undefined;
669
+ slots: ReadonlyArray<WorkflowCredentialHealthSlotDto> | undefined;
670
+ }>,
671
+ ): Readonly<{ attentionNodeIds: ReadonlySet<string>; summaryLines: ReadonlyArray<string> }> {
672
+ const slots = args.slots ?? [];
673
+ const workflow = args.workflow;
674
+ const attentionNodeIds = new Set<string>();
675
+ const summaryLines: string[] = [];
676
+ for (const slot of slots) {
677
+ if (slot.health.status !== "unbound") {
678
+ continue;
679
+ }
680
+ attentionNodeIds.add(slot.nodeId);
681
+ const label = slot.nodeName ?? workflow?.nodes.find((n) => n.id === slot.nodeId)?.name ?? slot.nodeId;
682
+ summaryLines.push(`${label} · ${slot.requirement.label}`);
683
+ }
684
+ return { attentionNodeIds, summaryLines };
685
+ }
686
+
687
+ static buildExecutionTreeData(nodes: ReadonlyArray<ExecutionNode>): ReadonlyArray<ExecutionTreeNode> {
688
+ const treeKeys = this.computeExecutionTreeStableKeys(nodes);
689
+ const treeNodesByKey = new Map<string, ExecutionTreeNode>();
690
+ const rootNodes: ExecutionTreeNode[] = [];
691
+
692
+ for (let i = 0; i < nodes.length; i++) {
693
+ const { node, snapshot } = nodes[i]!;
694
+ const treeKey = treeKeys[i]!;
695
+ treeNodesByKey.set(treeKey, {
696
+ key: treeKey,
697
+ title: node.name ?? node.type ?? node.id,
698
+ workflowNode: node,
699
+ snapshot,
700
+ children: [],
701
+ });
702
+ }
703
+
704
+ for (let i = 0; i < nodes.length; i++) {
705
+ const { node } = nodes[i]!;
706
+ const treeKey = treeKeys[i]!;
707
+ const treeNode = treeNodesByKey.get(treeKey);
708
+ if (!treeNode) continue;
709
+ if (!node.parentNodeId) {
710
+ rootNodes.push(treeNode);
711
+ continue;
712
+ }
713
+ const parentTreeNode = treeNodesByKey.get(node.parentNodeId);
714
+ if (!parentTreeNode) {
715
+ rootNodes.push(treeNode);
716
+ continue;
717
+ }
718
+ const existingChildren = Array.isArray(parentTreeNode.children) ? [...parentTreeNode.children] : [];
719
+ existingChildren.push(treeNode);
720
+ parentTreeNode.children = existingChildren;
721
+ }
722
+
723
+ this.sortExecutionTree(rootNodes);
724
+ return rootNodes;
725
+ }
726
+
727
+ /** Resolves rc-tree selected key when tree keys use disambiguation suffixes for duplicate execution node ids. */
728
+ static resolveExecutionTreeKeyForNodeId(
729
+ executionNodes: ReadonlyArray<ExecutionNode>,
730
+ selectedNodeId: string | null,
731
+ ): string | null {
732
+ if (!selectedNodeId) {
733
+ return null;
734
+ }
735
+ const treeKeys = this.computeExecutionTreeStableKeys(executionNodes);
736
+ for (let i = executionNodes.length - 1; i >= 0; i--) {
737
+ if (executionNodes[i]!.node.id === selectedNodeId) {
738
+ return treeKeys[i]!;
739
+ }
740
+ }
741
+ return selectedNodeId;
742
+ }
743
+
744
+ static collectExecutionTreeKeys(nodes: ReadonlyArray<ExecutionTreeNode>): ReadonlyArray<string> {
745
+ const keys: string[] = [];
746
+ this.collectExecutionTreeKeysRecursive(nodes, keys);
747
+ return keys;
748
+ }
749
+
750
+ private static collectExecutionTreeKeysRecursive(nodes: ReadonlyArray<ExecutionTreeNode>, keys: string[]): void {
751
+ for (const node of nodes) {
752
+ keys.push(String(node.key));
753
+ const children = Array.isArray(node.children) ? (node.children as ExecutionTreeNode[]) : [];
754
+ this.collectExecutionTreeKeysRecursive(children, keys);
755
+ }
756
+ }
757
+
758
+ private static compareExecutionNodes(left: ExecutionNode, right: ExecutionNode): number {
759
+ const timestampComparison = (this.getSnapshotTimestamp(left.snapshot) ?? "").localeCompare(
760
+ this.getSnapshotTimestamp(right.snapshot) ?? "",
761
+ );
762
+ if (timestampComparison !== 0) return timestampComparison;
763
+ const idTie = left.node.id.localeCompare(right.node.id);
764
+ if (idTie !== 0) return idTie;
765
+ const roleComparison = this.compareExecutionNodeRoles(left.node.role, right.node.role);
766
+ if (roleComparison !== 0) return roleComparison;
767
+ return this.getNodeDisplayName(left.node, left.node.id).localeCompare(
768
+ this.getNodeDisplayName(right.node, right.node.id),
769
+ );
770
+ }
771
+
772
+ private static computeExecutionTreeStableKeys(nodes: ReadonlyArray<ExecutionNode>): ReadonlyArray<string> {
773
+ const keyCounts = new Map<string, number>();
774
+ for (const { node } of nodes) {
775
+ keyCounts.set(node.id, (keyCounts.get(node.id) ?? 0) + 1);
776
+ }
777
+ const hasCollision = [...keyCounts.values()].some((count) => count > 1);
778
+ if (!hasCollision) {
779
+ return nodes.map(({ node }) => node.id);
780
+ }
781
+ const used = new Set<string>();
782
+ const keys: string[] = [];
783
+ for (let i = 0; i < nodes.length; i++) {
784
+ const { node } = nodes[i]!;
785
+ let key = node.id;
786
+ if (used.has(key)) {
787
+ let suffix = 1;
788
+ while (used.has(`${node.id}__${suffix}`)) {
789
+ suffix += 1;
790
+ }
791
+ key = `${node.id}__${suffix}`;
792
+ }
793
+ used.add(key);
794
+ keys.push(key);
795
+ }
796
+ return keys;
797
+ }
798
+
799
+ /**
800
+ * Deduplicates connection invocation rows by `invocationId` (keeps the newest `updatedAt`),
801
+ * then sorts for stable UI ordering. Use this for canvas badges and anywhere the execution
802
+ * tree should stay consistent with persisted run state.
803
+ */
804
+ static normalizeConnectionInvocations(
805
+ invocations: ReadonlyArray<ConnectionInvocationRecord> | undefined,
806
+ ): ReadonlyArray<ConnectionInvocationRecord> {
807
+ if (!invocations || invocations.length === 0) {
808
+ return [];
809
+ }
810
+ const byId = new Map<string, ConnectionInvocationRecord>();
811
+ for (const inv of invocations) {
812
+ const prev = byId.get(inv.invocationId);
813
+ if (!prev || prev.updatedAt.localeCompare(inv.updatedAt) <= 0) {
814
+ byId.set(inv.invocationId, inv);
815
+ }
816
+ }
817
+ return [...byId.values()].sort((left, right) => {
818
+ const t = left.updatedAt.localeCompare(right.updatedAt);
819
+ if (t !== 0) return t;
820
+ return left.invocationId.localeCompare(right.invocationId);
821
+ });
822
+ }
823
+
824
+ private static compareExecutionNodeRoles(leftRole: string | undefined, rightRole: string | undefined): number {
825
+ const leftPriority = this.getExecutionNodeRolePriority(leftRole);
826
+ const rightPriority = this.getExecutionNodeRolePriority(rightRole);
827
+ return leftPriority - rightPriority;
828
+ }
829
+
830
+ private static getExecutionNodeRolePriority(role: string | undefined): number {
831
+ if (role === "agent") return 0;
832
+ if (role === "languageModel") return 1;
833
+ if (role === "tool") return 2;
834
+ return 3;
835
+ }
836
+
837
+ private static isCompatibleWorkflowNodeId(workflowNodeIds: ReadonlySet<string>, nodeId: string): boolean {
838
+ return workflowNodeIds.has(nodeId);
839
+ }
840
+
841
+ private static sortExecutionTree(nodes: ExecutionTreeNode[]): void {
842
+ nodes.sort((left, right) => {
843
+ return this.compareExecutionNodes(
844
+ {
845
+ node: left.workflowNode!,
846
+ snapshot: left.snapshot,
847
+ },
848
+ {
849
+ node: right.workflowNode!,
850
+ snapshot: right.snapshot,
851
+ },
852
+ );
853
+ });
854
+ for (const node of nodes) {
855
+ const children = Array.isArray(node.children) ? (node.children as ExecutionTreeNode[]) : [];
856
+ this.sortExecutionTree(children);
857
+ node.children = children;
858
+ node.isLeaf = children.length === 0;
859
+ }
860
+ }
861
+
862
+ private static createExecutionNodesForWorkflowNode(
863
+ node: WorkflowNode,
864
+ snapshots: ReadonlyArray<NodeExecutionSnapshot>,
865
+ executionState: InspectableExecutionState | undefined,
866
+ ): ReadonlyArray<ExecutionNode> {
867
+ const invocations = (executionState?.connectionInvocations ?? []).filter((inv) => inv.connectionNodeId === node.id);
868
+ if ((node.role === "languageModel" || node.role === "tool") && invocations.length > 0) {
869
+ const ordered = [...invocations].sort((left, right) => {
870
+ const t = left.updatedAt.localeCompare(right.updatedAt);
871
+ if (t !== 0) return t;
872
+ return left.invocationId.localeCompare(right.invocationId);
873
+ });
874
+ return ordered.map((inv) => ({
875
+ node: this.createInvocationExecutionNode(node, inv.invocationId),
876
+ snapshot: this.snapshotFromConnectionInvocation(inv),
877
+ workflowConnectionNodeId: node.id,
878
+ }));
879
+ }
880
+ const matchingSnapshots = this.resolveMatchingSnapshots(node, snapshots);
881
+ if (matchingSnapshots.length === 0) {
882
+ return [];
883
+ }
884
+ if (!this.shouldCreateAttachmentInvocations(node, matchingSnapshots)) {
885
+ return matchingSnapshots.map((snapshot) => ({
886
+ node: snapshot.nodeId === node.id ? node : this.createSyntheticExecutionNode(node, snapshot),
887
+ snapshot,
888
+ }));
889
+ }
890
+ return matchingSnapshots
891
+ .filter((snapshot) => snapshot.nodeId !== node.id)
892
+ .map((snapshot) => ({
893
+ node: this.createSyntheticExecutionNode(node, snapshot),
894
+ snapshot,
895
+ }));
896
+ }
897
+
898
+ private static createInvocationExecutionNode(baseNode: WorkflowNode, invocationId: string): WorkflowNode {
899
+ return {
900
+ ...baseNode,
901
+ id: invocationId,
902
+ };
903
+ }
904
+
905
+ private static snapshotFromConnectionInvocation(inv: ConnectionInvocationRecord): NodeExecutionSnapshot {
906
+ const mainIn = this.jsonValueToMainItems(inv.managedInput);
907
+ const mainOut = this.jsonValueToMainItems(inv.managedOutput);
908
+ return {
909
+ runId: inv.runId,
910
+ workflowId: inv.workflowId,
911
+ nodeId: inv.invocationId,
912
+ activationId: inv.parentAgentActivationId,
913
+ parent: { runId: inv.runId, workflowId: inv.workflowId, nodeId: inv.parentAgentNodeId },
914
+ status: inv.status,
915
+ queuedAt: inv.queuedAt,
916
+ startedAt: inv.startedAt,
917
+ finishedAt: inv.finishedAt,
918
+ updatedAt: inv.updatedAt,
919
+ inputsByPort: mainIn ? { main: mainIn } : undefined,
920
+ outputs: mainOut ? { main: mainOut } : undefined,
921
+ error: inv.error,
922
+ };
923
+ }
924
+
925
+ private static jsonValueToMainItems(value: unknown | undefined): Items | undefined {
926
+ if (value === undefined) {
927
+ return undefined;
928
+ }
929
+ if (value === null) {
930
+ return [{ json: {} }];
931
+ }
932
+ if (Array.isArray(value)) {
933
+ return value.map((json) => ({ json: json as object }));
934
+ }
935
+ return [{ json: value as object }];
936
+ }
937
+
938
+ private static resolveMatchingSnapshots(
939
+ node: WorkflowNode,
940
+ snapshots: ReadonlyArray<NodeExecutionSnapshot>,
941
+ ): ReadonlyArray<NodeExecutionSnapshot> {
942
+ return snapshots.filter((snapshot) => {
943
+ if (node.role === "languageModel") {
944
+ return snapshot.nodeId === node.id;
945
+ }
946
+ if (node.role === "tool") {
947
+ return snapshot.nodeId === node.id;
948
+ }
949
+ return snapshot.nodeId === node.id;
950
+ });
951
+ }
952
+
953
+ private static shouldCreateAttachmentInvocations(
954
+ node: WorkflowNode,
955
+ snapshots: ReadonlyArray<NodeExecutionSnapshot>,
956
+ ): boolean {
957
+ if (node.role !== "languageModel" && node.role !== "tool") {
958
+ return false;
959
+ }
960
+ return snapshots.some((snapshot) => snapshot.nodeId !== node.id);
961
+ }
962
+
963
+ private static createSyntheticExecutionNode(node: WorkflowNode, snapshot: NodeExecutionSnapshot): WorkflowNode {
964
+ return {
965
+ ...node,
966
+ id: snapshot.nodeId,
967
+ };
968
+ }
969
+
970
+ private static shouldStartWorkflowFromCleanState(request: RunWorkflowRequest): boolean {
971
+ return !request.startAt && !request.stopAt && !request.clearFromNodeId && !request.sourceRunId;
972
+ }
973
+
974
+ private static createCleanRunCurrentState(
975
+ currentState:
976
+ | Pick<RunCurrentState, "outputsByNode" | "nodeSnapshotsByNodeId" | "mutableState" | "connectionInvocations">
977
+ | undefined,
978
+ ): RunCurrentState {
979
+ return {
980
+ outputsByNode: {},
981
+ nodeSnapshotsByNodeId: {},
982
+ connectionInvocations: [],
983
+ mutableState: this.cloneMutableState(currentState?.mutableState),
984
+ };
985
+ }
986
+
987
+ private static cloneRunCurrentState(
988
+ currentState:
989
+ | Pick<RunCurrentState, "outputsByNode" | "nodeSnapshotsByNodeId" | "mutableState" | "connectionInvocations">
990
+ | undefined,
991
+ ): RunCurrentState {
992
+ return {
993
+ outputsByNode: JSON.parse(JSON.stringify(currentState?.outputsByNode ?? {})) as RunCurrentState["outputsByNode"],
994
+ nodeSnapshotsByNodeId: JSON.parse(
995
+ JSON.stringify(currentState?.nodeSnapshotsByNodeId ?? {}),
996
+ ) as RunCurrentState["nodeSnapshotsByNodeId"],
997
+ connectionInvocations: currentState?.connectionInvocations
998
+ ? (JSON.parse(JSON.stringify(currentState.connectionInvocations)) as NonNullable<
999
+ RunCurrentState["connectionInvocations"]
1000
+ >)
1001
+ : undefined,
1002
+ mutableState: this.cloneMutableState(currentState?.mutableState),
1003
+ };
1004
+ }
1005
+
1006
+ private static cloneMutableState(
1007
+ mutableState: RunCurrentState["mutableState"] | undefined,
1008
+ ): NonNullable<RunCurrentState["mutableState"]> {
1009
+ return JSON.parse(
1010
+ JSON.stringify(
1011
+ mutableState ?? {
1012
+ nodesById: {},
1013
+ },
1014
+ ),
1015
+ ) as NonNullable<RunCurrentState["mutableState"]>;
1016
+ }
1017
+ }