From 98f8e854eb73452b349909dfde3797c07c65d074 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Thu, 7 May 2026 12:43:05 -0700 Subject: [PATCH] improvement(tables): extract TablesDetail wrapper, ship trigger followups (#4476) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * ui improvements * Update status pils, make checkbox column sticky * add Run workflow to context menu * Refactor dispatching logic * fix checkbox width to be smaller if csv is small * Add drag behavior for workflows, stop workflow on multi select * fix z index of checkbox to left, add view workflow button * Switch to emcn buttons for Add inputs * Split up workflow sidebar from column sidebar, refactor cells * Lint and add auto run toggle * fix column reordering, add action bar * Create and use emcn square * Reconcile post-merge: drop positionMap, use rowId-based selection Staging refactored Tables UI to decouple from DB position (gutter from array index, checkedRows keyed by rowId, no PositionGapRows). Bring HEAD's action-bar / context-menu helpers in line: contextMenuRowIds, selectedRowIds, actionBarRowIds now key off row.id and walk `rows` directly. Drop the maxPosition / positionMap derived state. Collapse COLUMN_SIDEBAR_WIDTH_CSS to a numeric COLUMN_SIDEBAR_WIDTH used by both the sidebar shell and the table's reserved padding-right. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(table): backfill remapped workflow outputs from execution logs When a workflow column is re-pointed to a different (blockId, path), populate its existing rows with the new output's value pulled from saved execution logs instead of leaving them empty until the next run. Rows where the new mapping has no logged value clear (matching the previous behavior for those rows), but rows where the workflow already has the new output's value surface immediately. Refactor backfillAddedGroupOutputs into a generalized backfillGroupOutputsFromLogs helper with an `overwrite` flag — used in both the added-outputs path (preserves hand-edited values) and the new remapped path (overwrites since the new mapping is the source of truth). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): update column type when remapping workflow output A remap that changes the output's leaf type (string → number, json → boolean, etc.) was leaving the column's declared type stale. The clear- then-backfill flow then failed schema validation on every row, so the backfill silently aborted and the column stayed empty. Resolve the new leaf type via flattenWorkflowOutputs + columnTypeForLeaf for each mappingUpdate, and patch schema.columns[i].type before the schema write. The clear-tx then backfill ordering now works end-to-end across type changes. If the workflow or its target output can't be resolved (workflow deleted, block removed), fall back to leaving the column type alone — the backfill will skip rows whose picked value doesn't match, same as before. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): stringify objects instead of "[object Object]" in cells If a column's declared type lags its row data (e.g. a workflow column mid-remap, where the schema cache hasn't refetched yet but the row data already has the new mapping's value), formatValueForInput and the cell-render text variant fell through to String(value) and rendered "[object Object]". JSON-stringify objects in both spots so the transient skew shows the actual data. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): drop extra left border on workflow group meta header The meta cell had border-r/b/l while regular headers have only border-r/b. With border-separate tables, that extra 1px left border shifted the meta cell's content one pixel right of the columns below it. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): align workflow meta header without dropping its left border Restore border-l and pull the cell back -1px with -ml-px so the visible left border overlaps the previous cell's right border instead of adding 1px to the meta cell's box. Content lines up with the columns below. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): draw meta header left border via ::before pseudo Adding border-l to the meta cell shifted its content right by 1px because table-fixed + border-separate honors the border inside the colspan'd cell's width budget. -ml-px doesn't work on . Render the visible left edge via a ::before at left: -1px instead — paints over the prior cell's right border without consuming any of the meta cell's content area. Content lines up with the columns below. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): add missing barrels, drop doubled-path imports Match the convention used by logs/components: every component folder exposes its public API via index.ts so consumers import from the folder name, not from its internal filenames. - New barrels: column-config-sidebar/, workflow-sidebar/, table-action-bar/, table/cells/, table/headers/. - Rename table-filter/index.tsx → index.ts (barrel is not a component). - Top-level components/index.ts re-exports every sibling folder so external consumers have one import path. - Replace `from '../foo/foo'` doubled paths in table.tsx with the shorter barrel-anchored form. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): introduce TablesDetail wrapper as thin passthrough Phase 1 step 0 of the wrapper extraction (see plan okay-lets-make-a-shimmying-trinket.md). page.tsx now renders TablesDetail, which today is a passthrough to . Subsequent commits lift surface state out of
into this wrapper one piece at a time. The mothership chat path (
) is untouched —
stays exportable as a lower-level component for embedded contexts. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift slideout panel state into TablesDetail wrapper The three right-edge slideout panels (column config, workflow config, execution details) move out of
into the wrapper. The wrapper owns a single useReducer that encodes the at-most-one-open invariant as a discriminated union — opening any one panel automatically closes the others.
emits open requests via three new callback props. Also extract from inline-in-table.tsx to its own folder so the wrapper can compose it cleanly. Update the embedded mothership callsite (resource-content.tsx) to render instead of
. Phase 1 step 1 of the wrapper extraction.
shrinks from 3849 → 3787 lines; grows from 19 → 145 lines. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift delete-table modal + mutation into wrapper The delete-table confirmation modal and `useDeleteTable` mutation move out of
into TablesDetail.
exposes a new `onRequestDeleteTable` callback fired by the page-header Delete action. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift CSV import dialog into wrapper ImportCsvDialog moves out of
. Grid exposes `onRequestImportCsv` fired by the page-header menu item; wrapper owns the open state and renders the dialog. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift RowModal (edit + delete) into wrapper Both RowModal instances move out of
into the wrapper. Grid emits `onOpenRowModal(row)` (Space key) and `onRequestDeleteRows(snapshots)` (context menu). Post-delete cleanup (push undo, clear selection) needs grid-internal state, so the grid populates an `afterDeleteRowsSinkRef` callback that the wrapper's modal `onSuccess` invokes. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift delete-columns modal into wrapper The destructive delete-columns confirmation modal moves into the wrapper. Grid emits `onRequestDeleteColumns(names)`; the cascade itself (per-column mutation, undo push, columnOrder + columnWidths cleanup) stays in the grid as a sink the wrapper invokes on confirm — too grid-internal to lift cleanly. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift run/stop mutations + TableActionBar to wrapper useRunGroup and useCancelTableRuns move out of
into the wrapper, along with the render. Grid receives onRunGroup, onRunRows, onStopRow, onStopRows, onStopAll, and cancelRunsPending as props — used by the per-row gutter Play/Stop, the workflow-group meta-cell run menu, and the right-click context menu's Run/Stop on selection items. Action-bar selection state (actionBarRowIds, runningInActionBar, hasWorkflowColumns) is derived from grid-internal state, so the grid emits a `SelectionSnapshot` via `onSelectionChange` from a useEffect. Wrapper uses the snapshot to drive the floating . Phase 2 step 1 of the wrapper extraction. Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift queryOptions to wrapper queryOptions (filter + sort) moves out of
into the wrapper, making it a single source of truth that drives one useTable call. The wrapper passes the bundle down to the grid; sort/filter handlers in the grid call onQueryOptionsChange. Eliminates the previous double-useTable pattern (one for the grid's filtered/sorted view, one in the wrapper's hardcoded null/null query for sidebar metadata). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): lift page header (breadcrumbs/options/filter) to wrapper Phase 3 of the wrapper extraction. The full page-header surface moves out of
: - ResourceHeader (breadcrumbs, table-rename UI, headerActions, createTrigger) - ResourceOptionsBar (sort + filter toggle) - TableFilter (filter panel — wrapper owns filterOpen state) - RunStatusControl (in the leading actions when runs are active) useRenameTable + useInlineRename for the breadcrumb name move to the wrapper. The grid populates pushTableRenameUndoSinkRef so the rename is still part of the grid's undo stack. Extract NewColumnDropdown and RunStatusControl from inline-in-table.tsx to their own folders so the wrapper composes them cleanly without reaching into the grid's internals. Hoist generateColumnName from grid-internal useCallback to a shared util so both the page-header and inline-header NewColumnDropdowns use the same logic. After this lift
is the data grid only — no page surface, no modals, no slideouts, no breadcrumbs. The selection snapshot now includes totalRunning so the wrapper can render the page-header RunStatusControl from outside the grid. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(tables): cleanup pass on TablesDetail wrapper extraction Six-pass cleanup against the wrapper extraction diff: - Effects: add content-compare bailout to onSelectionChange emit so unchanged snapshots don't churn wrapper re-renders. - Memos: drop unnecessary activeSortState memo, fold into sortConfig. - Callbacks: remove ~10 useCallbacks with no observed reference (sidebars not memoized, modals not memoized, inline arrows on non-memoized children); keep the ones that feed into // (memoized) or grid-side useCallback deps. - Dead props: drop onQueryOptionsChange/onRequestDeleteTable/ onRequestImportCsv from
— the page-header lift made them unused but the props weren't removed. - React Query: drop redundant tableWorkflowGroupsRef (created when onRunRows was useCallback-wrapped; after callback cleanup it can read the query data directly). - emcn: normalize Loader sizing to h-[14px] w-[14px] to match the codebase convention. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): re-seed columnOrder when columns change server-side The metadata-seed effect short-circuited after the first seed, so any later schema change (e.g. adding a workflow output column) couldn't push the new column into local columnOrder. The new column would then fall into the "remaining" bucket of `displayColumns` and render at the end of the table — until the user refreshed and the grid re-mounted with the now-current metadata. Drop the `metadataSeededRef.current` short-circuit from the early return so the effect can also reach the after-first-load re-seed branch, which already does the right thing (only re-seeds when the set of columns changes, leaves pure-reorder cases alone). Co-Authored-By: Claude Opus 4.7 (1M context) * refactor(tables): rename wrapper to
, grid to Match the naming convention used elsewhere in the workspace (workflow.tsx → , base.tsx → , logs.tsx → ). - tables-detail.tsx → table.tsx (exports
) - components/table/ → components/table-grid/ (exports ) - components/table-grid/table.tsx → table-grid.tsx - Drop — was a 3-line passthrough (executionId → useLogByExecutionId → ); inline directly into table.tsx where it's used. - Flatten components/run-status-control/ folder to a single components/run-status-control.tsx file. 25-line single-use component with no internal subdirs — folder was overhead. Matches knowledge's max-badge.tsx precedent. Net: 1 wrapper rename + grid rename + 2 folder collapses, all imports updated. The mothership chat callsite updates from to
. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): don't show "Waiting" for autoRun=false workflow groups A workflow group with autoRun=false never fires from the scheduler — the cell stays empty until the user clicks Run manually. Treating empty cells as "Waiting" misleads the user into thinking the group will auto-fire once deps are filled, which it won't. Skip autoRun=false groups when computing the per-row waiting labels so their cells render the empty-dash instead of the Waiting pill. Co-Authored-By: Claude Opus 4.7 (1M context) * chore(copilot): regenerate tool catalog from copilot dev (#247) Pulls in the workflow_group operations on user_table: add_workflow_group / update_workflow_group / delete_workflow_group / add_workflow_group_output / delete_workflow_group_output / run_workflow_group, plus the autoRun / blockId / dependencies / groupId parameters and a tightened mapping description for import_file. Also picks up biome import-order fixes from `bun run lint`. * improvement(table): action bar in mothership + per-execution mode Three related improvements to the table action bar: 1. Reposition from `position: fixed` to `position: absolute` inside the table's container. Fixed-positioning anchored to the viewport, which centered the bar across the whole window instead of the table panel — wrong in mothership embedded view, where the table sits in the right half. Absolute scopes the bar to the table's bounds. 2. Show the bar for single-execution highlights — when the user selects one workflow-output cell, or 1 row × N cols all within the same workflow group. The bar enters per-execution mode with Run / Stop / View execution buttons targeting that one cell or group. 3. Skip View execution for cancelled cells. A cancelled cell may have been cancelled before the worker ever picked the job up, so its executionId can't be relied on. Tighten the gate everywhere (context menu + action bar) to only `completed` / `error` / `running`. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): backfill on add_workflow_group_output, don't re-run addWorkflowGroupOutput (the one-shot single-output add path used by the copilot user_table tool) was calling triggerWorkflowGroupRun({ mode: 'all' }) after appending the output — that re-fired the workflow on every row. Trace a307ed8fd5fe2d931aa84dedab5a60f0 shows ~75 workflow-group-cell jobs enqueued in the seconds after a single add_workflow_group_output call. Replace with backfillGroupOutputsFromLogs (overwrite: false), the same flow updateWorkflowGroup uses when receiving newOutputColumns. Reads each row's saved trace spans and writes the new output's value back — no compute beyond a JSONB write per row, no double-billing the user for runs they didn't ask for. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): drop sql.raw quote-escaping in column-name interpolation Six call sites in lib/table/service.ts built JSON-key string literals at runtime via `sql.raw(\`'\${name.replace(/'/g, "''")}'\`)` for use with PostgreSQL's `data->'key'` / `data->>'key'` operators. Practically safe (NAME_PATTERN gates column names to alphanumeric+underscore at insert time) but a smelly pattern that breaks the moment validation loosens. Both `data->` and `data->>` accept a parameterized text value as the key, so the `sql.raw` is unnecessary. Replace each with a normal `${name}::text` binding. No behavior change; eliminates the manual quote-escaping surface. Affected sites: renameColumn (the data-rewrite UPDATE), upsertRow's match filter, updateColumnType's IS-NOT-NULL gate, updateColumnConstraints' required-check + unique-duplicate-check. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(copilot-tool): forward autoRun + mappingUpdates on update_workflow_group The sim-side service and contracts already accept both fields, but the copilot tool's update_workflow_group handler was dropping them on the floor. Now `args.autoRun` (toggle the persisted auto-fire flag) and `args.mappingUpdates` (per-output (blockId, path) swap) get forwarded through to updateWorkflowGroup. Pairs with the upcoming copilot-side change that exposes these in the tool catalog JSON / Go handler / prompting (see copilot branch redo-workflow-tools). Co-Authored-By: Claude Opus 4.7 (1M context) * fix(table): keep gutter border visible when hovering Run-row button The per-row Run button sat flush against the row-gutter cell's right border. Its hover background (rounded-rect surface-2) painted over the border line for the 20px height of the button, making the gutter divider appear to disappear at the hovered row. Add mr-px to the button so the hover bg stops 1px short of the cell's right edge, leaving the divider intact. * fix(table): unify auto-fire and manual run paths in scheduler scheduleWorkflowGroupRuns now owns eligibility, autoRun semantics, dep evaluation, and enqueue for both paths. Auto-fire callers omit opts; manual callers (triggerWorkflowGroupRun) pass { groupId, isManualRun: true } to bypass the autoRun=false skip and (for autoRun=false groups) the dep check. Per-row /run-workflow-group route delegates to triggerWorkflowGroupRun with rowIds=[rowId]. Single server-side path for both manual entry points. Also: optimisticallyScheduleNewlyEligibleGroups skips autoRun=false groups so editing a row's data doesn't phantom-mark autoRun=false output cells as Queued. * fix(table): render empty cells as blank, not em-dash Empty cells (any column type) showed an em-dash placeholder. Drop it so empty cells render blank — matches what the user expects when nothing's there. * fix(table): per-row Run fires autoRun=false groups regardless of deps handleRunRow filtered out every group whose deps weren't satisfied, which silently dropped autoRun=false groups (since their deps usually aren't satisfied — that's the whole point of autoRun=false). Click Run row, the autoRun=false group's cells stayed empty. Mirror the scheduler's semantics: autoRun=false bypasses the dep check, autoRun=true still requires deps. * fix ui shape * improvement(table): collapse run ops into run_column, derive action-bar buttons from selection The action bar now reflects what's actually selected: - Selection-driven scope (cells the user highlighted, not their full rows) - Play visible when there's anything empty/failed; Refresh when there's anything completed; both for mixed - run_cell / run_row deleted; everything funnels through run_column - Per-row gutter Play, right-click "Run workflows on N rows", and column-header menu all share the canonical run path - Shared RunMode type from the contract; cleanup pass via /simplify (readExecution / isExecInFlight reuse, runScope helper, flat onViewExecution prop) * chore(copilot): regen tool catalog after dropping run_cell / run_row + dependencies.workflowGroups Mirror the copilot-side catalog change so the generated TS catalog matches the deployed copilot tool surface. * fix(table): atomic per-key writes for executions, plus run-op race fixes The executions blob on user_table_rows was read-modify-written wholesale on every update. Concurrent writers (a column edit and a manual-retry stamp, two pickup calls, a cancel and a cascade) each computed a merge from their own snapshot, and the last writer clobbered keys it never touched — producing stuck "queued" cells, vanished stamps, and stale completed exec records reappearing after retries. Fixes: - updateRow / batchUpdateRows now apply executionsPatch via a SQL jsonb merge expression. Each writer only mutates the keys it explicitly patches; other keys are preserved. Eliminates the cross-key clobber. - writeWorkflowGroupState bypasses the stale-worker guard for `queued` (new scheduler stamp) and `cancelled` (authoritative cancel) writes — those ARE the new authority for the cell. Previously the new run's stamp was being rejected by the same guard meant to block the OLD worker's writes. - skipScheduler flag on UpdateRowData / BatchUpdateByIdData lets the cancel path and runWorkflowGroupsInternal opt out of the implicit auto-fire pass (cancel was waking up siblings; manual-run was racing its own scheduler). - CELL_CONTENT pinned to h-[22px] so status badges don't grow rows. * chore(table): remove table-row sockets, both sides Tables don't use realtime sockets in prod — strip the dead path so we stop paying the per-row HTTP forward + socket emit on every cell write. Polling on running execs already covers reconciliation. Sim side: - service.ts: drop notifyTableRowUpdated/Deleted, notifyTableDeleted, the postRealtimeBridge helper, and all callsites. - hooks/queries/tables.ts: drop the socket subscription block in useTableRows; poll-on-running stays. Remove useEffect / useSocket imports. - app/.../tables/[tableId]/hooks/use-table.ts: drop the merge-on-event useEffect and unused imports. - app/workspace/providers/socket-provider.tsx: drop joinTable/leaveTable, onTableRowUpdated/Deleted/onTableDeleted, currentTableId state, related events + types. Realtime side: - handlers/tables.ts deleted; index.ts no longer wires it. - routes/http.ts: drop /api/table-row-updated, /api/table-row-deleted, /api/table-deleted endpoints. - rooms/{memory,redis}-manager.ts: drop emitToTable, handleTableRowUpdated/ Deleted, handleTableDeleted, related imports. - rooms/types.ts: drop method declarations, TableRowUpdatedPayload type, tableRoomName helper. - middleware/permissions.ts: drop unused verifyTableAccess. Bonus from parallel work: - cell-content typewriter trigger refinement. * fix(table): clearing a workflow output cell also clears its exec record When the user wipes a workflow output column value, the auto-fire reactor needs to be re-armed for that group. Previously, a stale cancelled / error exec record blocked the eligibility predicate (gate at line 79 hard-rejects those statuses on auto-fire) and the cell stayed stuck in its old terminal state — visible as "Cancelled" cells that wouldn't re-run no matter what. Both updateRow and batchUpdateRows now derive an `executionsPatch[gid] = null` for any output column the patch sets to empty. The data clear and the exec clear ride the same SQL transaction, so the row never lands in a stale- status-with-empty-data state. Symmetric to how `completed` already worked via `areOutputsFilled` in the predicate — clearing the cell wins over the prior exec status, regardless of what that status was. (Also revert typewriter-trigger experiment from a parallel session that was in-progress on this branch.) * fix(table): waiting state, optimistic UX, schema-mutation polling, exec cleanup A bundle of small UX + correctness fixes around workflow-cell run state. cell-render.tsx - In-flight (queued/running/pending) now wins over the existing value, so re-runs surface immediately instead of looking like nothing happened until the worker writes the new value. - "Waiting on X" wins over a stale `cancelled` / `error` exec when deps are unmet — clearing a dep now reads as actionable instead of stuck. useRunColumn (hooks/queries/tables.ts) - onSettled now cancels in-flight polls before invalidating. Stops a poll that landed mid-mutation from clobbering the optimistic state with stale data, which produced the queued → cancelled → queued flicker. addWorkflowGroup / updateWorkflowGroup (autoRun toggle on) - Awaits scheduleRunsForTable instead of fire-and-forget. The route returned before the queued exec stamps committed, so the post-mutation refetch saw no in-flight cells and polling never started — cells looked stuck even though the server eventually stamped them. deleteColumn / deleteColumns - Strip orphaned executions[gid] keys when deleting a column orphans its parent group. Without this, stale running/queued exec records lingered on every row forever and inflated the page-header "N running" counter even on tables with no actually-running cells. UI - Action-bar leading label: "Selected N workflow cell(s)". - Context menu: Run / Refresh items mirror the action bar's Play / Refresh split, gated on the same selection-status flags so both surfaces show the actions that match the current state. * refactor(table): consolidate exec-status helpers + fix N-running counter Cleanup pass on the recent table changes — pulls duplicated predicates and SQL snippets into shared helpers and fixes one drift bug along the way. - isExecInFlight: now single export from lib/table/deps.ts. Removed the duplicate in components/table-grid/utils.ts. Used by isGroupEligible (server eligibility) and runningByRowId (client counter). - isOptimisticInFlight: kept local to hooks/queries/tables.ts — renamed from isInFlight to disambiguate from the stricter isExecInFlight. The two predicates differ on `pending` without a jobId: optimistic patches and poll-trigger want the broader version, eligibility wants the strict one. - areOutputsFilled: single export from lib/table/deps.ts, dropped duplicate from workflow-columns.ts. - classifyExecStatusMix: shared row × group walker in table-grid/utils.ts. Replaces two copies of the same loop in table-grid.tsx (selectionStats + contextMenuStats). Both surfaces now have the same short-circuit semantics, including the seen-all-selected-rows early break that contextMenuStats was missing. - stripGroupExecutions: SQL helper in service.ts. Replaces three copies of the `UPDATE user_table_rows SET executions = executions - $gid::text` pattern across deleteColumn / deleteColumns / deleteWorkflowGroup. Drift bug: - runningByRowId / totalRunning counted only `running` and `queued`. Every other in-flight check in the codebase treats post-stamp `pending` as in-flight too, so the page-header "N running" badge briefly dropped to 0 between scheduler stamp and worker pickup. Now uses isExecInFlight. * fix(table): address pr review (drop dead workflowNameById prop, reset didDragRef on dragend, align sidebar width) * fix(table): scope post-clear schedule to targeted groups, forward mode Multi-group manual runs (Run row, gutter Play, action-bar Play across mixed completed + cancelled cells) re-fired completed-and-filled siblings. runWorkflowGroupsInternal cleared only the groups it filtered, then called scheduleRunsForRows with isManualRun: true and no group / mode filter — so the post-clear pass walked every group on the table with default mode 'all', and any autoRun=true completed sibling whose deps were satisfied got queued again. Scope the post-clear call to targetGroups and forward mode. * fix(table): meta-cell drag-leave flicker guard + plumb unique on create * fix(table): strip sibling deps when removing workflow output via updateWorkflowGroup deleteWorkflowGroup already stripped removed-column deps from sibling groups, but updateWorkflowGroup (the path the UI takes when deleting one output of a multi-output group) didn't — schema validation then rejected the update with 'Group X depends on missing column Y'. * improvement(table): debug logs at every cascade decision branch * improvement(table): parallelize queued-stamp writes within concurrency-cap chunks * Simplify stripping column names * fix lint, ci --------- Co-authored-by: Claude Opus 4.7 (1M context) --- apps/realtime/src/handlers/index.ts | 2 - apps/realtime/src/handlers/tables.ts | 73 - apps/realtime/src/middleware/permissions.ts | 48 - apps/realtime/src/rooms/memory-manager.ts | 28 +- apps/realtime/src/rooms/redis-manager.ts | 27 +- apps/realtime/src/rooms/types.ts | 41 - apps/realtime/src/routes/http.ts | 46 - .../api/table/[tableId]/columns/run/route.ts | 48 + .../[tableId]/groups/[groupId]/run/route.ts | 67 - .../app/api/table/[tableId]/groups/route.ts | 4 + .../rows/[rowId]/run-workflow-group/route.ts | 97 -- .../resource-header/resource-header.tsx | 4 + .../resource-content/resource-content.tsx | 4 +- .../column-config-sidebar.tsx | 243 +++ .../column-types.ts | 9 +- .../components/column-config-sidebar/index.ts | 8 + .../column-sidebar/column-sidebar.tsx | 1314 --------------- .../components/context-menu/context-menu.tsx | 57 +- .../tables/[tableId]/components/index.ts | 7 +- .../components/new-column-dropdown/index.ts | 1 + .../new-column-dropdown.tsx | 79 + .../components/run-status-control.tsx | 41 + .../components/table-action-bar/index.ts | 1 + .../table-action-bar/table-action-bar.tsx | 172 ++ .../table-filter/{index.tsx => index.ts} | 0 .../table-grid/cells/cell-content.tsx | 59 + .../table-grid/cells/cell-render.tsx | 265 +++ .../cells/expanded-cell-popover.tsx | 0 .../components/table-grid/cells/index.ts | 4 + .../cells/inline-editors.tsx | 0 .../{table => table-grid}/constants.ts | 5 +- .../headers/column-header-menu.tsx | 79 +- .../table-grid/headers/column-type-icon.tsx | 50 + .../components/table-grid/headers/index.ts | 3 + .../headers/workflow-group-meta-cell.tsx | 178 +- .../[tableId]/components/table-grid/index.ts | 1 + .../table.tsx => table-grid/table-grid.tsx} | 1466 +++++++++-------- .../components/{table => table-grid}/types.ts | 0 .../components/{table => table-grid}/utils.ts | 55 +- .../components/table/cells/cell-content.tsx | 173 -- .../table/headers/column-type-icon.tsx | 59 - .../[tableId]/components/table/index.ts | 1 - .../components/workflow-sidebar/index.ts | 1 + .../workflow-sidebar/run-settings-section.tsx | 48 + .../workflow-sidebar/workflow-sidebar.tsx | 895 ++++++++++ .../tables/[tableId]/hooks/index.ts | 1 - .../[tableId]/hooks/use-row-execution.ts | 95 -- .../tables/[tableId]/hooks/use-table.ts | 133 +- .../[workspaceId]/tables/[tableId]/page.tsx | 2 +- .../[workspaceId]/tables/[tableId]/table.tsx | 685 ++++++++ .../[workspaceId]/tables/[tableId]/utils.ts | 22 +- .../import-csv-dialog/import-csv-dialog.tsx | 10 +- .../mcp-dynamic-args/mcp-dynamic-args.tsx | 14 +- .../panel/components/editor/editor.tsx | 16 +- .../w/[workflowId]/components/panel/panel.tsx | 4 +- .../preview-editor/preview-editor.tsx | 23 +- .../workspace/providers/socket-provider.tsx | 122 -- apps/sim/background/resume-execution.ts | 141 ++ .../background/workflow-column-execution.ts | 50 +- .../field-divider/field-divider.tsx | 42 + apps/sim/components/emcn/components/index.ts | 1 + apps/sim/components/emcn/icons/index.ts | 1 + apps/sim/components/emcn/icons/square.tsx | 27 + apps/sim/hooks/queries/tables.ts | 303 ++-- apps/sim/lib/api/client/errors.ts | 80 + apps/sim/lib/api/contracts/tables.ts | 76 +- apps/sim/lib/billing/cleanup-dispatcher.ts | 67 +- .../lib/copilot/generated/tool-catalog-v1.ts | 134 +- .../lib/copilot/generated/tool-schemas-v1.ts | 137 +- .../copilot/tools/server/table/user-table.ts | 30 +- .../lib/core/async-jobs/backends/database.ts | 136 +- .../core/async-jobs/backends/trigger-dev.ts | 17 + apps/sim/lib/core/async-jobs/inline-abort.ts | 35 - apps/sim/lib/core/async-jobs/types.ts | 28 +- apps/sim/lib/table/cell-write.ts | 51 +- apps/sim/lib/table/deps.ts | 144 ++ apps/sim/lib/table/service.ts | 705 +++++--- apps/sim/lib/table/types.ts | 40 +- apps/sim/lib/table/validation.ts | 13 +- apps/sim/lib/table/workflow-columns.ts | 637 ++++--- apps/sim/tools/exa/search.ts | 2 +- scripts/check-api-validation-contracts.ts | 4 +- 82 files changed, 5832 insertions(+), 3959 deletions(-) delete mode 100644 apps/realtime/src/handlers/tables.ts create mode 100644 apps/sim/app/api/table/[tableId]/columns/run/route.ts delete mode 100644 apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts delete mode 100644 apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{column-sidebar => column-config-sidebar}/column-types.ts (66%) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/{index.tsx => index.ts} (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/cells/expanded-cell-popover.tsx (100%) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/cells/inline-editors.tsx (100%) rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/constants.ts (61%) rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/headers/column-header-menu.tsx (83%) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/headers/workflow-group-meta-cell.tsx (59%) create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/index.ts rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table/table.tsx => table-grid/table-grid.tsx} (74%) rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/types.ts (100%) rename apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/{table => table-grid}/utils.ts (76%) delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-type-icon.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/index.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx delete mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-row-execution.ts create mode 100644 apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx create mode 100644 apps/sim/components/emcn/components/field-divider/field-divider.tsx create mode 100644 apps/sim/components/emcn/icons/square.tsx delete mode 100644 apps/sim/lib/core/async-jobs/inline-abort.ts create mode 100644 apps/sim/lib/table/deps.ts diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index 8977eea550..6ded2e5474 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -2,7 +2,6 @@ import { setupConnectionHandlers } from '@/handlers/connection' import { setupOperationsHandlers } from '@/handlers/operations' import { setupPresenceHandlers } from '@/handlers/presence' import { setupSubblocksHandlers } from '@/handlers/subblocks' -import { setupTableHandlers } from '@/handlers/tables' import { setupVariablesHandlers } from '@/handlers/variables' import { setupWorkflowHandlers } from '@/handlers/workflow' import type { AuthenticatedSocket } from '@/middleware/auth' @@ -14,6 +13,5 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) - setupTableHandlers(socket, roomManager) setupConnectionHandlers(socket, roomManager) } diff --git a/apps/realtime/src/handlers/tables.ts b/apps/realtime/src/handlers/tables.ts deleted file mode 100644 index ae9a7c6f00..0000000000 --- a/apps/realtime/src/handlers/tables.ts +++ /dev/null @@ -1,73 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { AuthenticatedSocket } from '@/middleware/auth' -import { verifyTableAccess } from '@/middleware/permissions' -import { type IRoomManager, tableRoomName } from '@/rooms/types' - -const logger = createLogger('TableHandlers') - -/** - * Wires `join-table` / `leave-table` socket events. Tables don't track presence - * or last-modified state — joining is a thin wrapper around `socket.join` so the - * Sim API → Realtime HTTP bridge can broadcast row updates back to subscribed clients. - */ -export function setupTableHandlers(socket: AuthenticatedSocket, _roomManager: IRoomManager) { - socket.on('join-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') { - socket.emit('join-table-error', { - tableId: tableId ?? null, - error: 'tableId required', - code: 'INVALID_TABLE_ID', - retryable: false, - }) - return - } - - const userId = socket.userId - if (!userId) { - socket.emit('join-table-error', { - tableId, - error: 'Authentication required', - code: 'AUTHENTICATION_REQUIRED', - retryable: false, - }) - return - } - - const { hasAccess } = await verifyTableAccess(userId, tableId) - if (!hasAccess) { - socket.emit('join-table-error', { - tableId, - error: 'Access denied to table', - code: 'ACCESS_DENIED', - retryable: false, - }) - return - } - - const room = tableRoomName(tableId) - socket.join(room) - socket.emit('join-table-success', { tableId, socketId: socket.id }) - logger.debug(`Socket ${socket.id} (user ${userId}) joined ${room}`) - } catch (error) { - logger.error(`Error joining table room:`, error) - socket.emit('join-table-error', { - tableId: null, - error: 'Failed to join table', - code: 'JOIN_TABLE_FAILED', - retryable: true, - }) - } - }) - - socket.on('leave-table', async ({ tableId }: { tableId?: string }) => { - try { - if (!tableId || typeof tableId !== 'string') return - const room = tableRoomName(tableId) - socket.leave(room) - logger.debug(`Socket ${socket.id} left ${room}`) - } catch (error) { - logger.error(`Error leaving table room:`, error) - } - }) -} diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index db97b16f8a..dcc893b147 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -131,51 +131,3 @@ export async function verifyWorkflowAccess( return { hasAccess: false } } } - -/** - * Verify a user has read access to a table by virtue of workspace permission. - * Mirrors `verifyWorkflowAccess` for the table-room socket join check. - */ -export async function verifyTableAccess( - userId: string, - tableId: string -): Promise<{ hasAccess: boolean; workspaceId?: string }> { - try { - const { userTableDefinitions, permissions } = await import('@sim/db') - const tableData = await db - .select({ workspaceId: userTableDefinitions.workspaceId }) - .from(userTableDefinitions) - .where(and(eq(userTableDefinitions.id, tableId), isNull(userTableDefinitions.archivedAt))) - .limit(1) - - if (!tableData.length) { - logger.warn(`Table ${tableId} not found`) - return { hasAccess: false } - } - const { workspaceId } = tableData[0] - if (!workspaceId) return { hasAccess: false } - - const [permissionRow] = await db - .select({ permissionType: permissions.permissionType }) - .from(permissions) - .where( - and( - eq(permissions.userId, userId), - eq(permissions.entityType, 'workspace'), - eq(permissions.entityId, workspaceId) - ) - ) - .limit(1) - - if (!permissionRow?.permissionType) { - logger.warn( - `User ${userId} has no permission for workspace ${workspaceId} (table ${tableId})` - ) - return { hasAccess: false } - } - return { hasAccess: true, workspaceId } - } catch (error) { - logger.error(`Error verifying table access for user ${userId}, table ${tableId}:`, error) - return { hasAccess: false } - } -} diff --git a/apps/realtime/src/rooms/memory-manager.ts b/apps/realtime/src/rooms/memory-manager.ts index 0cd37daf49..a032e785bb 100644 --- a/apps/realtime/src/rooms/memory-manager.ts +++ b/apps/realtime/src/rooms/memory-manager.ts @@ -1,13 +1,6 @@ import { createLogger } from '@sim/logger' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, - type WorkflowRoom, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession, WorkflowRoom } from '@/rooms/types' const logger = createLogger('MemoryRoomManager') @@ -262,23 +255,4 @@ export class MemoryRoomManager implements IRoomManager { logger.info(`Notified ${room.users.size} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets so they don't hold a stale room. Cross-pod safe via socket.io. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/redis-manager.ts b/apps/realtime/src/rooms/redis-manager.ts index 0fb4141790..0e6b3eadf2 100644 --- a/apps/realtime/src/rooms/redis-manager.ts +++ b/apps/realtime/src/rooms/redis-manager.ts @@ -1,13 +1,7 @@ import { createLogger } from '@sim/logger' import { createClient, type RedisClientType } from 'redis' import type { Server } from 'socket.io' -import { - type IRoomManager, - type TableRowUpdatedPayload, - tableRoomName, - type UserPresence, - type UserSession, -} from '@/rooms/types' +import type { IRoomManager, UserPresence, UserSession } from '@/rooms/types' const logger = createLogger('RedisRoomManager') @@ -463,23 +457,4 @@ export class RedisRoomManager implements IRoomManager { const userCount = await this.getUniqueUserCount(workflowId) logger.info(`Notified ${userCount} users about workflow deployment change: ${workflowId}`) } - - emitToTable(tableId: string, event: string, payload: T): void { - this._io.to(tableRoomName(tableId)).emit(event, payload) - } - - async handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise { - this.emitToTable(tableId, 'table-row-updated', { tableId, ...payload }) - } - - async handleTableRowDeleted(tableId: string, rowId: string): Promise { - this.emitToTable(tableId, 'table-row-deleted', { tableId, rowId }) - } - - async handleTableDeleted(tableId: string): Promise { - logger.info(`Handling table deletion notification for ${tableId}`) - this.emitToTable(tableId, 'table-deleted', { tableId, timestamp: Date.now() }) - // Eject sockets across all pods via socket.io's Redis adapter. - await this._io.in(tableRoomName(tableId)).socketsLeave(tableRoomName(tableId)) - } } diff --git a/apps/realtime/src/rooms/types.ts b/apps/realtime/src/rooms/types.ts index 9c15c967d5..9553a427e1 100644 --- a/apps/realtime/src/rooms/types.ts +++ b/apps/realtime/src/rooms/types.ts @@ -143,45 +143,4 @@ export interface IRoomManager { * Handle workflow deployment change - notify users to refresh deployment state */ handleWorkflowDeployed(workflowId: string): Promise - - /** - * Emit an event to all clients in a table room (`table:${tableId}`). - * Tables don't track presence/last-modified state — just pub/sub. - */ - emitToTable(tableId: string, event: string, payload: T): void - - /** - * Notify all clients in a table room of a row write (insert/update/cell-state-change). - * Sim API calls this via the `/api/table-row-updated` HTTP bridge after every successful - * row commit; the client merges the delta into its React Query cache. - */ - handleTableRowUpdated(tableId: string, payload: TableRowUpdatedPayload): Promise - - /** - * Notify all clients in a table room that a row has been deleted. - */ - handleTableRowDeleted(tableId: string, rowId: string): Promise - - /** - * Notify all clients in a table room that the table has been deleted; eject sockets. - */ - handleTableDeleted(tableId: string): Promise } - -/** - * Payload broadcast on `table-row-updated`. Mirrors the shape of `TableRow.data` so - * the client can merge directly into its React Query rows cache. `position` and - * `updatedAt` are included for cache reconciliation; `data` is the full row data - * (not a per-cell delta) — see plan Notes. - */ -export interface TableRowUpdatedPayload { - rowId: string - data: Record - /** Per-workflow-group execution state. Keyed by `WorkflowGroup.id`. */ - executions?: Record - position: number - updatedAt: string | number -} - -/** Socket.IO room name for a table. Namespaced from workflow rooms. */ -export const tableRoomName = (tableId: string): string => `table:${tableId}` diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index 78cd89e63d..0f8ed73cc5 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -150,52 +150,6 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { return } - // Handle table row write notifications from the Sim API - if (req.method === 'POST' && req.url === '/api/table-row-updated') { - try { - const body = await readRequestBody(req) - const { tableId, rowId, data, executions, position, updatedAt } = JSON.parse(body) - await roomManager.handleTableRowUpdated(tableId, { - rowId, - data, - executions, - position, - updatedAt, - }) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row update notification:', error) - sendError(res, 'Failed to process table row update') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-row-deleted') { - try { - const body = await readRequestBody(req) - const { tableId, rowId } = JSON.parse(body) - await roomManager.handleTableRowDeleted(tableId, rowId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table row deletion notification:', error) - sendError(res, 'Failed to process table row deletion') - } - return - } - - if (req.method === 'POST' && req.url === '/api/table-deleted') { - try { - const body = await readRequestBody(req) - const { tableId } = JSON.parse(body) - await roomManager.handleTableDeleted(tableId) - sendSuccess(res) - } catch (error) { - logger.error('Error handling table deletion notification:', error) - sendError(res, 'Failed to process table deletion') - } - return - } - res.writeHead(404, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ error: 'Not found' })) } diff --git a/apps/sim/app/api/table/[tableId]/columns/run/route.ts b/apps/sim/app/api/table/[tableId]/columns/run/route.ts new file mode 100644 index 0000000000..7b997c9c23 --- /dev/null +++ b/apps/sim/app/api/table/[tableId]/columns/run/route.ts @@ -0,0 +1,48 @@ +import { createLogger } from '@sim/logger' +import { type NextRequest, NextResponse } from 'next/server' +import { runColumnContract } from '@/lib/api/contracts/tables' +import { parseRequest } from '@/lib/api/server' +import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' +import { generateRequestId } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { runWorkflowColumn } from '@/lib/table/workflow-columns' +import { accessError, checkAccess } from '@/app/api/table/utils' + +const logger = createLogger('TableRunColumnAPI') + +interface RouteParams { + params: Promise<{ tableId: string }> +} + +/** POST /api/table/[tableId]/columns/run */ +export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { + const requestId = generateRequestId() + try { + const auth = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) + if (!auth.success || !auth.userId) { + return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) + } + const parsed = await parseRequest(runColumnContract, request, { params }) + if (!parsed.success) return parsed.response + const { tableId } = parsed.data.params + const { workspaceId, groupIds, runMode, rowIds } = parsed.data.body + const access = await checkAccess(tableId, auth.userId, 'write') + if (!access.ok) return accessError(access, requestId, tableId) + + const { triggered } = await runWorkflowColumn({ + tableId, + workspaceId, + groupIds, + mode: runMode, + rowIds, + requestId, + }) + return NextResponse.json({ success: true, data: { triggered } }) + } catch (error) { + if (error instanceof Error && error.message === 'Invalid workspace ID') { + return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) + } + logger.error(`run-column failed:`, error) + return NextResponse.json({ error: 'Failed to run columns' }, { status: 500 }) + } +}) diff --git a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts b/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts deleted file mode 100644 index 80f80bb794..0000000000 --- a/apps/sim/app/api/table/[tableId]/groups/[groupId]/run/route.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' -import { runWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; groupId: string }> -} - -/** - * POST /api/table/[tableId]/groups/[groupId]/run - * - * Manually triggers the workflow group for every eligible row in the table. - * Each eligible row's `executions[groupId]` is reset to `pending` so the - * scheduler picks it up and enqueues a per-cell trigger.dev job. Rows whose - * deps aren't satisfied or whose group is already running are skipped. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, groupId } = parsed.data.params - const { workspaceId, runMode, rowIds } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const { triggered } = await triggerWorkflowGroupRun({ - tableId, - groupId, - workspaceId, - mode: runMode, - requestId, - rowIds, - }) - - return NextResponse.json({ success: true, data: { triggered } }) - } catch (error) { - if (error instanceof Error && error.message === 'Workflow group not found') { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - if (error instanceof Error && error.message === 'Invalid workspace ID') { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - logger.error(`run-group failed:`, error) - return NextResponse.json({ error: 'Failed to run group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/api/table/[tableId]/groups/route.ts b/apps/sim/app/api/table/[tableId]/groups/route.ts index 847647fc39..bf74653212 100644 --- a/apps/sim/app/api/table/[tableId]/groups/route.ts +++ b/apps/sim/app/api/table/[tableId]/groups/route.ts @@ -110,6 +110,10 @@ export const PATCH = withRouteHandler(async (request: NextRequest, { params }: R ...(validated.newOutputColumns !== undefined ? { newOutputColumns: validated.newOutputColumns } : {}), + ...(validated.mappingUpdates !== undefined + ? { mappingUpdates: validated.mappingUpdates } + : {}), + ...(validated.autoRun !== undefined ? { autoRun: validated.autoRun } : {}), }, requestId ) diff --git a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts b/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts deleted file mode 100644 index aee786d226..0000000000 --- a/apps/sim/app/api/table/[tableId]/rows/[rowId]/run-workflow-group/route.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { createLogger } from '@sim/logger' -import { generateId } from '@sim/utils/id' -import { type NextRequest, NextResponse } from 'next/server' -import { runRowWorkflowGroupContract } from '@/lib/api/contracts/tables' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { RowExecutionMetadata } from '@/lib/table' -import { updateRow } from '@/lib/table' -import { accessError, checkAccess } from '@/app/api/table/utils' - -const logger = createLogger('TableRunWorkflowGroupAPI') - -interface RouteParams { - params: Promise<{ tableId: string; rowId: string }> -} - -/** - * POST /api/table/[tableId]/rows/[rowId]/run-workflow-group - * - * Manually (re-)runs a workflow group for a single row by force-resetting - * `executions[groupId]` to `pending`. The `updateRow` call fires the - * scheduler which enqueues the cell job. - */ -export const POST = withRouteHandler(async (request: NextRequest, { params }: RouteParams) => { - const requestId = generateRequestId() - - try { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - - const parsed = await parseRequest(runRowWorkflowGroupContract, request, { params }) - if (!parsed.success) return parsed.response - const { tableId, rowId } = parsed.data.params - const { workspaceId, groupId } = parsed.data.body - - const result = await checkAccess(tableId, authResult.userId, 'write') - if (!result.ok) return accessError(result, requestId, tableId) - const { table } = result - - if (table.workspaceId !== workspaceId) { - return NextResponse.json({ error: 'Invalid workspace ID' }, { status: 400 }) - } - - const group = (table.schema.workflowGroups ?? []).find((g) => g.id === groupId) - if (!group) { - return NextResponse.json({ error: 'Workflow group not found' }, { status: 404 }) - } - - const executionId = generateId() - const pendingExec: RowExecutionMetadata = { - status: 'pending', - executionId, - jobId: null, - workflowId: group.workflowId, - error: null, - } - /** - * Clear the group's output cells so the rerun starts visually fresh — - * otherwise stale values from the previous run linger in the UI until the - * new run writes new ones (or doesn't, on error/router-skip). - */ - const clearedData = Object.fromEntries(group.outputs.map((o) => [o.columnName, null])) - const updated = await updateRow( - { - tableId, - rowId, - data: clearedData, - workspaceId, - executionsPatch: { [groupId]: pendingExec }, - }, - table, - requestId - ) - if (updated === null) { - // The cell-task cancellation guard rejected the write — typically a - // racing stop click that already wrote `cancelled` for this run. - // Surface 409 so the caller doesn't poll indefinitely for a run that - // was never enqueued. - return NextResponse.json( - { error: 'Run was cancelled before it could be scheduled' }, - { status: 409 } - ) - } - - return NextResponse.json({ success: true, data: { executionId } }) - } catch (error) { - if (error instanceof Error && error.message === 'Row not found') { - return NextResponse.json({ error: 'Row not found' }, { status: 404 }) - } - logger.error(`run-workflow-group failed:`, error) - return NextResponse.json({ error: 'Failed to run workflow group' }, { status: 500 }) - } -}) diff --git a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx index 2268611578..9b4392d011 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/resource/components/resource-header/resource-header.tsx @@ -55,6 +55,8 @@ interface ResourceHeaderProps { breadcrumbs?: BreadcrumbItem[] create?: CreateAction actions?: HeaderAction[] + /** Arbitrary content rendered in the right-aligned actions row, before `actions`. */ + leadingActions?: React.ReactNode /** Arbitrary content rendered in the right-aligned actions row, before the Create button. */ trailingActions?: React.ReactNode /** @@ -71,6 +73,7 @@ export const ResourceHeader = memo(function ResourceHeader({ breadcrumbs, create, actions, + leadingActions, trailingActions, createTrigger, }: ResourceHeaderProps) { @@ -106,6 +109,7 @@ export const ResourceHeader = memo(function ResourceHeader({ )}
+ {leadingActions} {actions?.map((action) => { const ActionIcon = action.icon return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx index 1adfb0f344..e93fe37cd6 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/resource-content.tsx @@ -2,7 +2,6 @@ import { lazy, memo, Suspense, useEffect, useMemo, useRef } from 'react' import { createLogger } from '@sim/logger' -import { Square } from 'lucide-react' import { useRouter } from 'next/navigation' import { Button, PlayOutline, Skeleton, Tooltip } from '@/components/emcn' import { @@ -10,6 +9,7 @@ import { FileX, Folder as FolderIcon, Library, + Square, SquareArrowUpRight, WorkflowX, } from '@/components/emcn/icons' @@ -43,7 +43,7 @@ import { useUserPermissionsContext, useWorkspacePermissionsContext, } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/components' +import { Table } from '@/app/workspace/[workspaceId]/tables/[tableId]/table' import { useUsageLimits } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/hooks' import { useWorkflowExecution } from '@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution' import { useFolders } from '@/hooks/queries/folders' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx new file mode 100644 index 0000000000..9792b75837 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-config-sidebar.tsx @@ -0,0 +1,243 @@ +'use client' + +import type React from 'react' +import { useState } from 'react' +import { toError } from '@sim/utils/errors' +import { X } from 'lucide-react' +import { Button, Combobox, FieldDivider, Input, Label, Switch, toast } from '@/components/emcn' +import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' +import { cn } from '@/lib/core/utils/cn' +import type { ColumnDefinition } from '@/lib/table' +import { useAddTableColumn, useUpdateColumn } from '@/hooks/queries/tables' +import { PLAIN_COLUMN_TYPE_OPTIONS } from './column-types' + +/** + * Discriminates the two flows the column-config sidebar handles. Workflow + * configuration is a separate component (``) so this surface + * never has to branch on `isWorkflow`. + */ +export type ColumnConfig = + | { mode: 'create'; proposedName: string; type: ColumnDefinition['type'] } + | { mode: 'edit'; columnName: string } + +interface ColumnConfigSidebarProps { + /** When non-null the sidebar is open. */ + config: ColumnConfig | null + onClose: () => void + /** Existing column record for `mode: 'edit'`; ignored otherwise. */ + existingColumn: ColumnDefinition | null + workspaceId: string + tableId: string + /** Notify parent of a rename so it can rewrite local `columnOrder` / + * `columnWidths` keys that reference the old name. */ + onColumnRename?: (oldName: string, newName: string) => void +} + +/** + * Right-edge sidebar for plain (non-workflow) column configuration. Handles + * create (with type pre-chosen by the parent's "+ New column" dropdown) and + * edit. No `isWorkflow` branches — workflow-output columns route through + * `` instead. + * + * Form state seeds from props via lazy `useState` initializers; the parent + * uses `key={config?.columnName ?? 'closed'}` to remount when switching + * columns, eliminating the prop-mirroring `useEffect` the previous combined + * sidebar relied on. + */ +export function ColumnConfigSidebar(props: ColumnConfigSidebarProps) { + // Mount the form body with `key` keyed on the config identity so opening a + // different column / mode remounts and re-seeds state from props. + const open = props.config !== null + return ( + + ) +} + +function configKey(config: ColumnConfig): string { + return config.mode === 'edit' ? `edit:${config.columnName}` : `create:${config.proposedName}` +} + +interface ColumnConfigBodyProps extends Omit { + config: ColumnConfig +} + +function ColumnConfigBody({ + config, + onClose, + existingColumn, + workspaceId, + tableId, + onColumnRename, +}: ColumnConfigBodyProps) { + const updateColumn = useUpdateColumn({ workspaceId, tableId }) + const addColumn = useAddTableColumn({ workspaceId, tableId }) + + const [nameInput, setNameInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.name ?? config.columnName) : config.proposedName + ) + const [typeInput, setTypeInput] = useState(() => + config.mode === 'edit' ? (existingColumn?.type ?? 'string') : config.type + ) + const [uniqueInput, setUniqueInput] = useState(() => + config.mode === 'edit' ? !!existingColumn?.unique : false + ) + const [showValidation, setShowValidation] = useState(false) + const [nameError, setNameError] = useState(null) + + const saveDisabled = updateColumn.isPending || addColumn.isPending + const trimmedName = nameInput.trim() + + async function handleSave() { + if (!trimmedName) { + setShowValidation(true) + return + } + + try { + if (config.mode === 'create') { + await addColumn.mutateAsync({ + name: trimmedName, + type: typeInput, + ...(uniqueInput ? { unique: true } : {}), + }) + toast.success(`Added "${trimmedName}"`) + onClose() + return + } + + const renamed = trimmedName !== config.columnName + const typeChanged = !!existingColumn && existingColumn.type !== typeInput + const uniqueChanged = !!existingColumn && !!existingColumn.unique !== uniqueInput + + const updates: { name?: string; type?: ColumnDefinition['type']; unique?: boolean } = { + ...(renamed ? { name: trimmedName } : {}), + ...(typeChanged ? { type: typeInput } : {}), + ...(uniqueChanged ? { unique: uniqueInput } : {}), + } + if (Object.keys(updates).length === 0) { + onClose() + return + } + + await updateColumn.mutateAsync({ columnName: config.columnName, updates }) + if (renamed) onColumnRename?.(config.columnName, trimmedName) + toast.success(`Saved "${trimmedName}"`) + onClose() + } catch (err) { + // Server validation errors carry a Zod issue array on the body; surface + // them inline next to the offending field instead of as a raw toast. + if (isValidationError(err)) { + const nameIssue = + findValidationIssue(err, ['updates', 'name']) ?? + findValidationIssue(err, ['name']) ?? + findValidationIssue(err, ['columnName']) + if (nameIssue) { + setNameError(nameIssue.message) + return + } + } + toast.error(toError(err).message) + } + } + + return ( +
+
+

Configure column

+ +
+ +
+
+ Column name + { + setNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + aria-invalid={(showValidation && !trimmedName) || nameError ? true : undefined} + /> + {showValidation && !trimmedName && } + {nameError && !(showValidation && !trimmedName) && } +
+ + {config.mode === 'edit' && ( + <> + +
+ Type + ({ + label: o.label, + value: o.type, + icon: o.icon, + }))} + value={typeInput} + onChange={(v) => setTypeInput(v as ColumnDefinition['type'])} + placeholder='Select type' + maxHeight={260} + /> +
+ + )} + + +
+
+ + setUniqueInput(!!v)} + /> +
+
+
+ +
+ + +
+
+ ) +} + +function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { + return ( + + ) +} + +function FieldError({ message }: { message: string }) { + return

{message}

+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts similarity index 66% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts index 10e392e82a..6c9f31ade6 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-types.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/column-types.ts @@ -10,9 +10,9 @@ import { import type { ColumnDefinition } from '@/lib/table' /** - * UI-only column type. `'workflow'` is a virtual selection that lets the user - * configure a workflow group from the sidebar; on save, it expands into N real - * scalar columns + one workflow group, none of which carry a `'workflow'` type. + * UI-only column type. `'workflow'` is the virtual entry users pick from the + * "+ New column" dropdown to spawn a workflow group; the resulting columns are + * stored as scalar types under the hood (none carry `'workflow'`). */ export type SidebarColumnType = ColumnDefinition['type'] | 'workflow' @@ -30,3 +30,6 @@ export const COLUMN_TYPE_OPTIONS: ColumnTypeOption[] = [ { type: 'json', label: 'JSON', icon: TypeJson }, { type: 'workflow', label: 'Workflow', icon: PlayOutline }, ] + +/** Plain column types (no workflow). Used by ``'s type combobox in edit mode. */ +export const PLAIN_COLUMN_TYPE_OPTIONS = COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow') diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts new file mode 100644 index 0000000000..e458001136 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-config-sidebar/index.ts @@ -0,0 +1,8 @@ +export type { ColumnConfig } from './column-config-sidebar' +export { ColumnConfigSidebar } from './column-config-sidebar' +export { + COLUMN_TYPE_OPTIONS, + type ColumnTypeOption, + PLAIN_COLUMN_TYPE_OPTIONS, + type SidebarColumnType, +} from './column-types' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx deleted file mode 100644 index 73017fc25e..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/column-sidebar/column-sidebar.tsx +++ /dev/null @@ -1,1314 +0,0 @@ -'use client' - -import type React from 'react' -import { useEffect, useMemo, useRef, useState } from 'react' -import { toError } from '@sim/utils/errors' -import { generateId } from '@sim/utils/id' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { - ChevronDown, - ChevronRight, - ExternalLink, - Loader2, - Plus, - RepeatIcon, - SplitIcon, - X, -} from 'lucide-react' -import { - Button, - Checkbox, - Combobox, - Expandable, - ExpandableContent, - Input, - Label, - Switch, - Tooltip, - toast, -} from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import type { - AddWorkflowGroupBodyInput, - UpdateWorkflowGroupBodyInput, -} from '@/lib/api/contracts/tables' -import { - putWorkflowNormalizedStateContract, - type WorkflowStateContractInput, -} from '@/lib/api/contracts/workflows' -import { cn } from '@/lib/core/utils/cn' -import type { - ColumnDefinition, - WorkflowGroup, - WorkflowGroupDependencies, - WorkflowGroupOutput, -} from '@/lib/table' -import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' -import { - type FlattenOutputsBlockInput, - type FlattenOutputsEdgeInput, - flattenWorkflowOutputs, - getBlockExecutionOrder, -} from '@/lib/workflows/blocks/flatten-outputs' -import { normalizeInputFormatValue } from '@/lib/workflows/input-format' -import { TriggerUtils } from '@/lib/workflows/triggers/triggers' -import type { InputFormatField } from '@/lib/workflows/types' -import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' -import { getBlock } from '@/blocks' -import { - useAddTableColumn, - useAddWorkflowGroup, - useUpdateColumn, - useUpdateWorkflowGroup, -} from '@/hooks/queries/tables' -import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' -import type { WorkflowMetadata } from '@/stores/workflows/registry/types' -import { COLUMN_SIDEBAR_WIDTH_CSS } from '../table/constants' -import { COLUMN_TYPE_OPTIONS, type SidebarColumnType } from './column-types' - -export type ColumnConfigState = - | { mode: 'edit'; columnName: string } - | { mode: 'new'; columnName: string; workflowId: string; proposedName: string } - | { - mode: 'create' - columnName: string - proposedName: string - /** When present, the sidebar opens with the workflow type pre-selected. */ - workflowId?: string - } - | null - -interface ColumnSidebarProps { - configState: ColumnConfigState - onClose: () => void - /** The current column record for edit mode. Null for new mode or closed. */ - existingColumn: ColumnDefinition | null - allColumns: ColumnDefinition[] - workflowGroups: WorkflowGroup[] - workflows: WorkflowMetadata[] | undefined - workspaceId: string - tableId: string -} - -const OUTPUT_VALUE_SEPARATOR = '::' - -/** Shared dashed-divider style — mirrors the workflow editor's subblock divider. */ -const DASHED_DIVIDER_STYLE = { - backgroundImage: - 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', -} as const - -/** Encodes blockId + path so duplicate field names across blocks stay distinct in the picker UI. */ -const encodeOutputValue = (blockId: string, path: string) => - `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` - -/** Splits an encoded `${blockId}::${path}` into its components for persistence. */ -const decodeOutputValue = (value: string): { blockId: string; path: string } => { - const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) - if (idx === -1) return { blockId: '', path: value } - return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } -} - -interface BlockOutputGroup { - blockId: string - blockName: string - blockType: string - blockIcon: string | React.ComponentType<{ className?: string }> - blockColor: string - paths: string[] -} - -/** - * Loose shape of `useWorkflowState` data — we only need the fields we round-trip - * through PUT /state. Typed locally to avoid pulling the heavy `WorkflowState` - * generic from `@/stores/workflows/workflow/types`. - */ -interface WorkflowStatePayload { - blocks: Record< - string, - { - type: string - subBlocks?: Record - } & Record - > - edges: unknown[] - loops: unknown - parallels: unknown - lastSaved?: number - isDeployed?: boolean -} - -function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { - switch (colType) { - case 'number': - return 'number' - case 'boolean': - return 'boolean' - case 'json': - return 'object' - default: - return 'string' - } -} - -const TagIcon: React.FC<{ - icon: string | React.ComponentType<{ className?: string }> - color: string -}> = ({ icon, color }) => ( -
- {typeof icon === 'string' ? ( - {icon} - ) : ( - (() => { - const IconComponent = icon - return - })() - )} -
-) - -function FieldDivider() { - return ( -
-
-
- ) -} - -/** Mirrors the workflow editor's required-field label: title + asterisk. */ -function FieldLabel({ - htmlFor, - required, - children, -}: { - htmlFor?: string - required?: boolean - children: React.ReactNode -}) { - return ( - - ) -} - -/** Inline validation message styled like the workflow editor's destructive text. */ -function FieldError({ message }: { message: string }) { - return

{message}

-} - -/** - * Tinted inline warning row with a message on the left and an action button - * on the right. Stacks naturally — render multiple in sequence and they line - * up. Color mirrors the group-header deploy badge: `red` for blocking states, - * `amber` for soft warnings. - */ -function WarningRow({ - tone, - message, - action, -}: { - tone: 'red' | 'amber' - message: string - action: React.ReactNode -}) { - return ( -
- - {message} - -
{action}
-
- ) -} - -/** - * Collapsible "Run settings" section. Collapsed by default since outputs are - * the primary focus of the workflow flow — most users never need to touch - * the trigger conditions. The header shows a one-line summary of when the - * group will fire so the current state is visible without expanding. - */ -function RunSettingsSection({ - open, - onOpenChange, - summary, - scalarDepColumns, - groupDepOptions, - deps, - groupDeps, - workflows, - onToggleDep, - onToggleGroupDep, -}: { - open: boolean - onOpenChange: (open: boolean) => void - summary: string - scalarDepColumns: ColumnDefinition[] - groupDepOptions: WorkflowGroup[] - deps: string[] - groupDeps: string[] - workflows: WorkflowMetadata[] | undefined - onToggleDep: (name: string) => void - onToggleGroupDep: (groupId: string) => void -}) { - return ( -
- - - -
- {scalarDepColumns.length === 0 && groupDepOptions.length === 0 ? ( -
- No upstream columns or groups. -
- ) : ( - <> - {scalarDepColumns.map((c, idx) => { - const checked = deps.includes(c.name) - const isLast = idx === scalarDepColumns.length - 1 && groupDepOptions.length === 0 - return ( -
onToggleDep(c.name)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleDep(c.name) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - - - {c.name} - - - {c.type} - -
- ) - })} - {groupDepOptions.map((g, idx) => { - const checked = groupDeps.includes(g.id) - const isLast = idx === groupDepOptions.length - 1 - const wf = workflows?.find((w) => w.id === g.workflowId) - const color = wf?.color ?? 'var(--text-muted)' - const label = g.name ?? wf?.name ?? 'Workflow' - return ( -
onToggleGroupDep(g.id)} - onKeyDown={(e) => { - if (e.key === ' ' || e.key === 'Enter') { - e.preventDefault() - onToggleGroupDep(g.id) - } - }} - className={cn( - 'flex h-[36px] flex-shrink-0 cursor-pointer items-center gap-2.5 px-2.5 hover:bg-[var(--surface-2)]', - !isLast && 'border-[var(--border)] border-b' - )} - > - -
- ) - })} - - )} -
-
-
-
- ) -} - -/** - * Right-edge configuration panel for any column. - * - * Shows name / type / unique for every column, plus workflow-specific fields - * (workflow picker, output field, dependencies, run concurrency) when the - * selected type is `'workflow'`. - * - * Three modes: - * - 'edit': modify an existing column. PATCH sends a unified updates payload. - * - 'new': user picked a workflow via Change type → Workflow → [pick]. Nothing - * is persisted yet. Save writes type + workflowConfig + renames in one PATCH. - * - 'create': user picked a workflow from "Add column"; the column doesn't exist yet - * and Save creates it. - * - * Visual styling mirrors the workflow editor's subblock panel (label above - * control, dashed dividers between fields). - */ -export function ColumnSidebar({ - configState, - onClose, - existingColumn, - allColumns, - workflowGroups, - workflows, - workspaceId, - tableId, -}: ColumnSidebarProps) { - const updateColumn = useUpdateColumn({ workspaceId, tableId }) - const addColumn = useAddTableColumn({ workspaceId, tableId }) - const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId }) - const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId }) - const open = configState !== null - - const columnName = configState ? configState.columnName : '' - - /** - * If the column being edited is a workflow output, resolve its parent group - * so we can populate workflow / outputs / dependencies state from it. - */ - const existingGroup = useMemo(() => { - if (!existingColumn?.workflowGroupId) return undefined - return workflowGroups.find((g) => g.id === existingColumn.workflowGroupId) - }, [existingColumn, workflowGroups]) - - const [nameInput, setNameInput] = useState('') - const [typeInput, setTypeInput] = useState('string') - - const isWorkflow = !!existingGroup || configState?.mode === 'new' || typeInput === 'workflow' - - /** - * Show the Column name field whenever a *specific* column is open: scalar - * columns (create or edit) and per-output workflow columns (edit only). Hide - * it when the surface is the workflow-group as a whole — i.e. creating a - * brand-new workflow column where individual output names are auto-derived. - */ - const showColumnNameField = - !isWorkflow || configState?.mode === 'edit' || configState?.mode === 'new' - - /** - * Columns to the left of the current column — these are the only valid trigger - * dependencies, since a workflow column can't depend on values that haven't been - * filled yet. For 'create' mode the column doesn't exist yet, so every existing - * column counts as left of it. - */ - const otherColumns = useMemo(() => { - if (!configState) return [] - if (configState.mode === 'create') return allColumns - const idx = allColumns.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return allColumns.filter((c) => c.name !== configState.columnName) - return allColumns.slice(0, idx) - }, [configState, allColumns]) - - /** - * Split `otherColumns` into the two dep buckets: - * - `scalarDepColumns` — plain columns; tickable into `dependencies.columns`. - * - `groupDepOptions` — producing workflow groups whose outputs land left of the - * current column; tickable into `dependencies.workflowGroups`. A group only - * shows up here when at least one of its output columns is left-of-current. - * The current group itself is excluded so we never depend on ourselves. - */ - const scalarDepColumns = useMemo( - () => otherColumns.filter((c) => !c.workflowGroupId), - [otherColumns] - ) - const groupDepOptions = useMemo(() => { - const seen = new Set() - const result: WorkflowGroup[] = [] - for (const c of otherColumns) { - if (!c.workflowGroupId) continue - if (seen.has(c.workflowGroupId)) continue - if (existingGroup && c.workflowGroupId === existingGroup.id) continue - const g = workflowGroups.find((gg) => gg.id === c.workflowGroupId) - if (!g) continue - seen.add(c.workflowGroupId) - result.push(g) - } - return result - }, [otherColumns, workflowGroups, existingGroup]) - - const [uniqueInput, setUniqueInput] = useState(false) - const [selectedWorkflowId, setSelectedWorkflowId] = useState('') - /** Plain (non-workflow-output) column names this group waits on. */ - const [deps, setDeps] = useState([]) - /** Producing workflow group ids this group waits on. Workflow-output columns are - * represented by their parent group, since the schema validator forbids depending - * on a workflow-output column directly (`workflow-columns.ts` enforces this). */ - const [groupDeps, setGroupDeps] = useState([]) - /** Encoded `${blockId}::${path}` values — disambiguates duplicate paths in the picker. */ - const [selectedOutputs, setSelectedOutputs] = useState([]) - /** Surfaces required-field errors only after a save attempt, matching the workflow editor's deploy flow. */ - const [showValidation, setShowValidation] = useState(false) - /** Save-time error (network/validation thrown by the mutation). Rendered inline next to the footer - * buttons so it isn't covered by the toaster, which sits over the bottom-right of the panel. */ - const [saveError, setSaveError] = useState(null) - /** Run settings (the trigger-deps picker) starts collapsed — outputs are the - * primary task; configuring run timing is rare. */ - const [runSettingsOpen, setRunSettingsOpen] = useState(false) - - const existingColumnRef = useRef(existingColumn) - existingColumnRef.current = existingColumn - const allColumnsRef = useRef(allColumns) - allColumnsRef.current = allColumns - - useEffect(() => { - if (!open || !configState) return - setShowValidation(false) - setSaveError(null) - setRunSettingsOpen(false) - const existing = existingColumnRef.current - const cols = allColumnsRef.current - const leftOfCurrent = (() => { - if (configState.mode === 'create') return cols - const idx = cols.findIndex((c) => c.name === configState.columnName) - if (idx === -1) return cols.filter((c) => c.name !== configState.columnName) - return cols.slice(0, idx) - })() - // Default deps when there's no persisted group yet: tick every left-of-current - // scalar column + every left-of-current producing group. - const defaultScalarDeps = leftOfCurrent.filter((c) => !c.workflowGroupId).map((c) => c.name) - const defaultGroupDeps = (() => { - const seen = new Set() - for (const c of leftOfCurrent) { - if (c.workflowGroupId) seen.add(c.workflowGroupId) - } - return Array.from(seen) - })() - if (configState.mode === 'edit') { - const group = existing?.workflowGroupId - ? workflowGroups.find((g) => g.id === existing.workflowGroupId) - : undefined - // Surface workflow-typed columns as `'workflow'` in the combobox even - // though they're stored as scalar columns under the hood. - setTypeInput(group ? 'workflow' : (existing?.type ?? 'string')) - setUniqueInput(!!existing?.unique) - setNameInput(existing?.name ?? configState.columnName) - if (group) { - setSelectedWorkflowId(group.workflowId) - // Sanitize legacy persisted deps: any workflow-output column names that - // sneaked into `dependencies.columns` (writes from before the schema - // validator forbade them) are lifted into `workflowGroups` here so the - // sidebar surfaces a re-saveable state. - const persistedCols = group.dependencies?.columns - const persistedGroups = group.dependencies?.workflowGroups - if (persistedCols !== undefined || persistedGroups !== undefined) { - const liftedGroupIds = new Set(persistedGroups ?? []) - const cleanCols: string[] = [] - for (const colName of persistedCols ?? []) { - const c = cols.find((cc) => cc.name === colName) - if (c?.workflowGroupId) liftedGroupIds.add(c.workflowGroupId) - else cleanCols.push(colName) - } - setDeps(cleanCols) - setGroupDeps(Array.from(liftedGroupIds)) - } else { - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - } - setSelectedOutputs([]) // re-encoded against current workflow blocks below - } else { - setSelectedWorkflowId('') - setDeps([]) - setGroupDeps([]) - setSelectedOutputs([]) - } - } else { - const workflowId = - 'workflowId' in configState && configState.workflowId ? configState.workflowId : '' - setTypeInput(workflowId ? 'workflow' : 'string') - setUniqueInput(false) - setNameInput(configState.proposedName) - setSelectedWorkflowId(workflowId) - setDeps(defaultScalarDeps) - setGroupDeps(defaultGroupDeps) - setSelectedOutputs([]) - } - }, [open, configState, workflowGroups]) - - const workflowState = useWorkflowState( - open && isWorkflow && selectedWorkflowId ? selectedWorkflowId : undefined - ) - - /** - * Resolves the unified Start block id and its current `inputFormat` field - * names. The "Add inputs" mutation only adds rows for table columns that - * aren't already represented in the start block — clicking the button when - * everything's covered does nothing, so we hide it in that case. - */ - const startBlockInputs = useMemo<{ - blockId: string | null - existingNames: Set - existing: InputFormatField[] - }>(() => { - const blocks = (workflowState.data as { blocks?: Record } | null) - ?.blocks - if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] } - const candidate = TriggerUtils.findStartBlock(blocks, 'manual') - if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] } - const block = blocks[candidate.blockId] as - | { subBlocks?: Record } - | undefined - const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value) - return { - blockId: candidate.blockId, - existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)), - existing, - } - }, [workflowState.data]) - - const missingInputColumnNames = useMemo(() => { - if (!startBlockInputs.blockId) return [] - return allColumns - .filter( - (c) => - c.name !== columnName && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name) - ) - .map((c) => c.name) - }, [allColumns, columnName, startBlockInputs]) - - const queryClient = useQueryClient() - const addInputsMutation = useMutation({ - mutationFn: async () => { - const wfId = selectedWorkflowId - const startBlockId = startBlockInputs.blockId - const state = workflowState.data as WorkflowStatePayload | null | undefined - if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) { - throw new Error('Nothing to add') - } - const startBlock = state.blocks[startBlockId] - if (!startBlock) throw new Error('Start block missing from workflow') - - const newFields: InputFormatField[] = missingInputColumnNames.map((name) => { - const col = allColumns.find((c) => c.name === name) - return { - id: generateId(), - name, - type: tableColumnTypeToInputType(col?.type), - value: '', - collapsed: false, - } as InputFormatField & { id: string; collapsed: boolean } - }) - - const updatedSubBlock = { - ...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }), - value: [...startBlockInputs.existing, ...newFields], - } - const updatedBlocks = { - ...state.blocks, - [startBlockId]: { - ...startBlock, - subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock }, - }, - } - - const rawBody = { - blocks: updatedBlocks, - edges: state.edges, - loops: state.loops, - parallels: state.parallels, - lastSaved: state.lastSaved ?? Date.now(), - isDeployed: state.isDeployed ?? false, - } - // double-cast-allowed: WorkflowStatePayload is the loose local view of - // useWorkflowState; we round-trip it back to the strict PUT body shape. - const body = rawBody as unknown as WorkflowStateContractInput - await requestJson(putWorkflowNormalizedStateContract, { - params: { id: wfId }, - body, - }) - return missingInputColumnNames.length - }, - onSuccess: (added) => { - queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) }) - toast.success(`Added ${added} input${added === 1 ? '' : 's'} to start block`) - }, - onError: (err) => { - toast.error(toError(err).message) - }, - }) - - const blockOutputGroups = useMemo(() => { - const state = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (!state?.blocks) return [] - - const blocks = Object.values(state.blocks) - const edges = state.edges ?? [] - const flat = flattenWorkflowOutputs(blocks, edges) - if (flat.length === 0) return [] - - const groupsByBlockId = new Map() - for (const f of flat) { - let group = groupsByBlockId.get(f.blockId) - if (!group) { - const blockConfig = getBlock(f.blockType) - const blockColor = blockConfig?.bgColor || '#2F55FF' - let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName - .charAt(0) - .toUpperCase() - if (blockConfig?.icon) blockIcon = blockConfig.icon - else if (f.blockType === 'loop') blockIcon = RepeatIcon - else if (f.blockType === 'parallel') blockIcon = SplitIcon - group = { - blockId: f.blockId, - blockName: f.blockName, - blockType: f.blockType, - blockIcon, - blockColor, - paths: [], - } - groupsByBlockId.set(f.blockId, group) - } - group.paths.push(f.path) - } - // Sort the picker by execution order (start block first) so it matches the - // saved-column ordering. Unreachable blocks sink to the end. - const distances = getBlockExecutionOrder(blocks, edges) - return Array.from(groupsByBlockId.values()).sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - return sa - sb - }) - }, [workflowState.data]) - - /** - * Re-encode persisted `{blockId, path}` entries into the picker's encoded form - * once the workflow's blocks are loaded. Stale entries (block deleted or path - * removed) are dropped silently — the user can re-pick on save. - */ - useEffect(() => { - if (!existingGroup?.outputs.length) return - if (selectedOutputs.length > 0) return - if (blockOutputGroups.length === 0) return - const encoded: string[] = [] - for (const entry of existingGroup.outputs) { - const match = blockOutputGroups.find( - (g) => g.blockId === entry.blockId && g.paths.includes(entry.path) - ) - if (match) encoded.push(encodeOutputValue(entry.blockId, entry.path)) - } - if (encoded.length > 0) setSelectedOutputs(encoded) - }, [blockOutputGroups, selectedOutputs.length, existingGroup]) - - const toggleDep = (name: string) => { - setDeps((prev) => (prev.includes(name) ? prev.filter((d) => d !== name) : [...prev, name])) - } - - const toggleGroupDep = (groupId: string) => { - setGroupDeps((prev) => - prev.includes(groupId) ? prev.filter((d) => d !== groupId) : [...prev, groupId] - ) - } - - const toggleOutput = (encoded: string) => { - setSelectedOutputs((prev) => - prev.includes(encoded) ? prev.filter((v) => v !== encoded) : [...prev, encoded] - ) - } - - const typeOptions = useMemo( - () => - COLUMN_TYPE_OPTIONS.filter((o) => o.type !== 'workflow' || !!existingGroup).map((o) => ({ - label: o.label, - value: o.type, - icon: o.icon, - })), - [existingGroup] - ) - - /** - * One-line summary of the trigger picker shown when Run settings is collapsed. - * Lists the dep names ("Run when X, Y, are filled") so the user can see at a - * glance whether anything's gating the group without expanding the section. - */ - const runSettingsSummary = useMemo(() => { - const names: string[] = [...deps] - for (const gid of groupDeps) { - const g = workflowGroups.find((gg) => gg.id === gid) - const wf = workflows?.find((w) => w.id === g?.workflowId) - const label = g?.name ?? wf?.name ?? 'workflow' - names.push(label) - } - if (names.length === 0) return 'Runs as soon as the group is added' - return `Runs when ${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} filled` - }, [deps, groupDeps, workflowGroups, workflows]) - - /** - * Builds the ordered, deduplicated `(blockId, path)` list from the picker - * state, sorted by execution order. Empty array if the user hasn't picked - * anything. - */ - const buildOrderedPickedOutputs = (): Array<{ - blockId: string - path: string - leafType?: string - }> => { - const seen = new Set() - const outputs: Array<{ blockId: string; path: string; leafType?: string }> = [] - for (const encoded of selectedOutputs) { - if (seen.has(encoded)) continue - seen.add(encoded) - outputs.push(decodeOutputValue(encoded)) - } - const wfState = workflowState.data as - | { - blocks?: Record - edges?: FlattenOutputsEdgeInput[] - } - | null - | undefined - if (wfState?.blocks) { - const blocks = Object.values(wfState.blocks) - const edges = wfState.edges ?? [] - const distances = getBlockExecutionOrder(blocks, edges) - const flat = flattenWorkflowOutputs(blocks, edges) - const indexInFlat = new Map( - flat.map((f, i) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, i]) - ) - const leafTypeByKey = new Map( - flat.map((f) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, f.leafType]) - ) - for (const o of outputs) { - o.leafType = leafTypeByKey.get(`${o.blockId}${OUTPUT_VALUE_SEPARATOR}${o.path}`) - } - outputs.sort((a, b) => { - const da = distances[a.blockId] - const db = distances[b.blockId] - const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da - const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db - if (sa !== sb) return sa - sb - const ia = - indexInFlat.get(`${a.blockId}${OUTPUT_VALUE_SEPARATOR}${a.path}`) ?? - Number.POSITIVE_INFINITY - const ib = - indexInFlat.get(`${b.blockId}${OUTPUT_VALUE_SEPARATOR}${b.path}`) ?? - Number.POSITIVE_INFINITY - return ia - ib - }) - } - return outputs - } - - const handleSave = async () => { - if (!configState) return - setSaveError(null) - const trimmedName = nameInput.trim() - // Name is required iff the field is shown — when configuring a whole - // workflow group at creation time, per-output column names are auto-derived - // and the field is hidden, so don't gate save on it. - const missing: string[] = [] - if (showColumnNameField && !trimmedName) missing.push('a column name') - if (isWorkflow && !selectedWorkflowId) missing.push('a workflow') - if (isWorkflow && selectedWorkflowId && selectedOutputs.length === 0) { - missing.push('at least one output column') - } - if (missing.length > 0) { - setShowValidation(true) - // Surface a short summary near the Save button too — the inline FieldError - // can be scrolled out of view when the panel content is tall. - setSaveError(`Add ${missing.join(' and ')} before saving.`) - return - } - - try { - if (isWorkflow) { - const orderedOutputs = buildOrderedPickedOutputs() - const dependencies: WorkflowGroupDependencies = { - columns: deps, - ...(groupDeps.length > 0 ? { workflowGroups: groupDeps } : {}), - } - - if (existingGroup) { - // Update path: diff outputs, derive new column names for added entries, - // call updateWorkflowGroup so service handles add/remove transactionally. - // If the sidebar was opened on a *specific* workflow-output column and - // the user renamed it, propagate that into the group's `outputs` ref - // (the column rename itself goes through `updateColumn` below, which - // server-side cascades into outputs/deps — but our outgoing payload - // also has to use the new name so the group update doesn't undo it). - const editedColumnName = configState.mode === 'edit' ? configState.columnName : null - const renamedColumn = - editedColumnName && trimmedName && trimmedName !== editedColumnName - ? { from: editedColumnName, to: trimmedName } - : null - const oldKeys = new Set(existingGroup.outputs.map((o) => `${o.blockId}::${o.path}`)) - const taken = new Set( - allColumns.map((c) => - renamedColumn && c.name === renamedColumn.from ? renamedColumn.to : c.name - ) - ) - const fullOutputs: WorkflowGroupOutput[] = [] - const newOutputColumns: NonNullable = [] - for (const o of orderedOutputs) { - const key = `${o.blockId}::${o.path}` - const existing = existingGroup.outputs.find( - (e) => e.blockId === o.blockId && e.path === o.path - ) - if (existing) { - fullOutputs.push( - renamedColumn && existing.columnName === renamedColumn.from - ? { ...existing, columnName: renamedColumn.to } - : existing - ) - } else { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: existingGroup.id, - }) - } - oldKeys.delete(key) - } - if (renamedColumn) { - await updateColumn.mutateAsync({ - columnName: renamedColumn.from, - updates: { name: renamedColumn.to }, - }) - } - await updateWorkflowGroup.mutateAsync({ - groupId: existingGroup.id, - workflowId: selectedWorkflowId, - name: existingGroup.name, - dependencies, - outputs: fullOutputs, - ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), - }) - toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) - } else { - // Create path: build a fresh group with auto-derived column names. - const groupId = generateId() - const taken = new Set(allColumns.map((c) => c.name)) - const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = [] - const groupOutputs: WorkflowGroupOutput[] = [] - for (const o of orderedOutputs) { - const colName = deriveOutputColumnName(o.path, taken) - taken.add(colName) - newOutputColumns.push({ - name: colName, - type: columnTypeForLeaf(o.leafType), - required: false, - unique: false, - workflowGroupId: groupId, - }) - groupOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) - } - const workflowName = - workflows?.find((w) => w.id === selectedWorkflowId)?.name ?? 'Workflow' - const group: WorkflowGroup = { - id: groupId, - workflowId: selectedWorkflowId, - name: workflowName, - dependencies, - outputs: groupOutputs, - } - await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) - toast.success(`Added "${workflowName}"`) - } - } else if (configState.mode === 'create') { - // `isWorkflow` is false here, so `typeInput` is a real ColumnDefinition type. - const scalarType = typeInput as ColumnDefinition['type'] - await addColumn.mutateAsync({ - name: trimmedName, - type: scalarType, - }) - toast.success(`Added "${trimmedName}"`) - } else { - const existing = existingColumnRef.current - const scalarType = typeInput as ColumnDefinition['type'] - const renamed = trimmedName !== configState.columnName - const typeChanged = !!existing && existing.type !== scalarType - const uniqueChanged = !!existing && !!existing.unique !== uniqueInput - - const updates: { - name?: string - type?: ColumnDefinition['type'] - unique?: boolean - } = { - ...(renamed ? { name: trimmedName } : {}), - ...(typeChanged ? { type: scalarType } : {}), - ...(uniqueChanged ? { unique: uniqueInput } : {}), - } - - if (Object.keys(updates).length === 0) { - onClose() - return - } - - await updateColumn.mutateAsync({ - columnName: configState.columnName, - updates, - }) - toast.success(`Saved "${trimmedName}"`) - } - - onClose() - } catch (err) { - setSaveError(toError(err).message) - } - } - - const saveDisabled = updateColumn.isPending || addColumn.isPending - - return ( - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx index dfe0523ba8..f7c6f4a27a 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/context-menu/context-menu.tsx @@ -5,7 +5,17 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowDown, ArrowUp, Duplicate, Eye, Pencil, Trash } from '@/components/emcn/icons' +import { + ArrowDown, + ArrowUp, + Duplicate, + Eye, + Pencil, + PlayOutline, + RefreshCw, + Square, + Trash, +} from '@/components/emcn/icons' import type { ContextMenuState } from '../../types' interface ContextMenuProps { @@ -20,6 +30,18 @@ interface ContextMenuProps { canViewExecution?: boolean canEditCell?: boolean selectedRowCount?: number + /** Fires every workflow group on the row(s), skipping already-completed + * cells. Mirrors the action bar's Play. */ + onRunWorkflows?: () => void + /** Re-runs every workflow group on the row(s), including already-completed + * cells. Mirrors the action bar's Refresh. */ + onRefreshWorkflows?: () => void + /** Cancels every running/queued execution on the row(s) the context menu is acting on. */ + onStopWorkflows?: () => void + /** Total running/queued executions across the row(s) under the context menu. Drives the Stop label and visibility. */ + runningInSelectionCount?: number + /** Whether the table has any workflow columns; gates the run-workflows item. */ + hasWorkflowColumns?: boolean disableEdit?: boolean disableInsert?: boolean disableDelete?: boolean @@ -37,11 +59,26 @@ export function ContextMenu({ canViewExecution = false, canEditCell = true, selectedRowCount = 1, + onRunWorkflows, + onRefreshWorkflows, + onStopWorkflows, + runningInSelectionCount = 0, + hasWorkflowColumns = false, disableEdit = false, disableInsert = false, disableDelete = false, }: ContextMenuProps) { const deleteLabel = selectedRowCount > 1 ? `Delete ${selectedRowCount} rows` : 'Delete row' + const runLabel = + selectedRowCount > 1 + ? `Run empty or failed cells on ${selectedRowCount} rows` + : 'Run empty or failed cells' + const refreshLabel = + selectedRowCount > 1 ? `Re-run all cells on ${selectedRowCount} rows` : 'Re-run all cells' + const stopLabel = + runningInSelectionCount === 1 + ? 'Stop running workflow' + : `Stop ${runningInSelectionCount} running workflows` return ( )} + {hasWorkflowColumns && onRunWorkflows && ( + + + {runLabel} + + )} + {hasWorkflowColumns && onRefreshWorkflows && ( + + + {refreshLabel} + + )} + {hasWorkflowColumns && onStopWorkflows && runningInSelectionCount > 0 && ( + + + {stopLabel} + + )} Insert row above diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts index bc0da8a071..0fca186c0c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/index.ts @@ -1,4 +1,9 @@ +export * from './column-config-sidebar' export * from './context-menu' +export * from './new-column-dropdown' export * from './row-modal' -export * from './table' +export * from './run-status-control' +export * from './table-action-bar' export * from './table-filter' +export * from './table-grid' +export * from './workflow-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts new file mode 100644 index 0000000000..026d9ff58f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/index.ts @@ -0,0 +1 @@ +export { NewColumnDropdown } from './new-column-dropdown' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx new file mode 100644 index 0000000000..8330e581cf --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/new-column-dropdown/new-column-dropdown.tsx @@ -0,0 +1,79 @@ +'use client' + +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/emcn' +import { Plus } from '@/components/emcn/icons' +import type { ColumnDefinition } from '@/lib/table' +import { COLUMN_TYPE_OPTIONS } from '../column-config-sidebar' + +const CELL_HEADER = + 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' + +const HEADER_ADD_COLUMN_ICON = + +interface NewColumnDropdownProps { + /** `'header'` renders the page-header trigger (subtle Button); `'inline-header'` renders + * the in-table column-header `
` so it must be a ` : menu +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx new file mode 100644 index 0000000000..43640d2d8a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/run-status-control.tsx @@ -0,0 +1,41 @@ +'use client' + +import { memo } from 'react' +import { Button } from '@/components/emcn' +import { Loader, Square } from '@/components/emcn/icons' + +interface RunStatusControlProps { + running: number + onStopAll: () => void + isStopping: boolean +} + +/** + * Run-status + Stop-all control rendered in the page header's leading actions + * row when any workflow runs are active. Matches the in-cell running indicator + * (Loader + tertiary text) for consistency. + */ +export const RunStatusControl = memo(function RunStatusControl({ + running, + onStopAll, + isStopping, +}: RunStatusControlProps) { + return ( +
+
+ + {running} + running +
+ +
+ ) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts new file mode 100644 index 0000000000..1e8041624b --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/index.ts @@ -0,0 +1 @@ +export { TableActionBar } from './table-action-bar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx new file mode 100644 index 0000000000..d807b32a02 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-action-bar/table-action-bar.tsx @@ -0,0 +1,172 @@ +'use client' + +import { AnimatePresence, motion } from 'framer-motion' +import { Button, Tooltip } from '@/components/emcn' +import { Eye, PlayOutline, RefreshCw, Square } from '@/components/emcn/icons' +import { cn } from '@/lib/core/utils/cn' + +interface TableActionBarProps { + /** Number of (row × group) cells the run/stop buttons would target. Drives + * the bar's leading label ("N cells"). */ + selectedCellCount: number + /** Total running/queued workflow cells in the selection. Drives Stop. */ + runningCount: number + /** Whether the table has any workflow columns. The bar hides entirely when + * there are none — Run/Stop have nothing to act on. */ + hasWorkflowColumns: boolean + /** Show the Play (incomplete-mode) button — true when any selected cell is + * empty / errored / cancelled. */ + showPlay: boolean + /** Show the Refresh (all-mode) button — true when any selected cell is + * already completed. */ + showRefresh: boolean + /** Smart run: fire workflows only on cells that are empty / errored / + * cancelled. Maps to server `runMode: 'incomplete'`. */ + onPlay: () => void + /** Forceful re-run: fire workflows on every selected cell, including + * completed ones. Maps to server `runMode: 'all'`. */ + onRefresh: () => void + /** Cancel running/queued cells in the selection. */ + onStopWorkflows: () => void + /** When the user has highlighted exactly one workflow cell (or N adjacent + * cells in the same row + group), surface a "View execution" affordance + * alongside the run buttons. Omit when no single-execution view applies. */ + onViewExecution?: () => void + /** Disables actions while a bulk mutation is in flight. */ + isLoading?: boolean + /** Additional className for the floating wrapper — used to lift the bar + * above bottom-anchored UI like a pagination row. */ + className?: string +} + +/** + * Floating action bar shown at the bottom of the table when one or more + * workflow cells are highlighted. Play / Refresh visibility is data-driven: + * Play appears when there's anything empty/failed in the selection; Refresh + * appears when there's anything already completed; both when the selection is + * mixed. + * + * Rendered with `position: absolute` inside the table's container (not + * `fixed`) so it scopes to the table's bounds — important for embedded mode, + * where the table sits inside a panel and a fixed-positioned bar would land + * centered on the whole viewport instead of the panel. + */ +export function TableActionBar({ + selectedCellCount, + runningCount, + hasWorkflowColumns, + showPlay, + showRefresh, + onPlay, + onRefresh, + onStopWorkflows, + onViewExecution, + isLoading = false, + className, +}: TableActionBarProps) { + const visible = + hasWorkflowColumns && + selectedCellCount > 0 && + (showPlay || showRefresh || runningCount > 0 || Boolean(onViewExecution)) + const stopLabel = + runningCount === 1 ? 'Stop running workflow' : `Stop ${runningCount} running workflows` + const playLabel = + selectedCellCount === 1 ? 'Run cell' : `Run ${selectedCellCount} empty or failed cells` + const refreshLabel = selectedCellCount === 1 ? 'Re-run cell' : `Re-run ${selectedCellCount} cells` + + return ( + + {visible && ( + +
+ + {selectedCellCount === 1 + ? 'Selected 1 workflow cell' + : `Selected ${selectedCellCount} workflow cells`} + + +
+ {showPlay && ( + + + + + {playLabel} + + )} + + {showRefresh && ( + + + + + {refreshLabel} + + )} + + {runningCount > 0 && ( + + + + + {stopLabel} + + )} + + {onViewExecution && ( + + + + + View execution + + )} +
+
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-filter/index.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx new file mode 100644 index 0000000000..2fbbe78f19 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-content.tsx @@ -0,0 +1,59 @@ +'use client' + +import type { RowExecutionMetadata } from '@/lib/table' +import type { SaveReason } from '../../../types' +import type { DisplayColumn } from '../types' +import { CellRender, resolveCellRender } from './cell-render' +import { InlineEditor } from './inline-editors' + +interface CellContentProps { + value: unknown + exec?: RowExecutionMetadata + column: DisplayColumn + isEditing: boolean + initialCharacter?: string | null + onSave: (value: unknown, reason: SaveReason) => void + onCancel: () => void + /** + * Human-readable labels for unmet deps on this row+group, used to render a + * "Waiting" pill when the cell hasn't run because something it depends on + * is empty. `undefined` (or empty) means no waiting state. + */ + waitingOnLabels?: string[] +} + +/** + * Glue layer: maps cell inputs to a typed `CellRenderKind` (via the pure + * resolver) and renders the corresponding JSX (via the dumb renderer). The + * inline editor sits on top when `isEditing` is true. Adding a new cell + * appearance is a three-step mechanical change in the colocated files. + */ +export function CellContent({ + value, + exec, + column, + isEditing, + initialCharacter, + onSave, + onCancel, + waitingOnLabels, +}: CellContentProps) { + const kind = resolveCellRender({ value, exec, column, waitingOnLabels }) + + return ( + <> + {isEditing && ( +
+ +
+ )} + + + ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx new file mode 100644 index 0000000000..35eb3d2e8f --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/cell-render.tsx @@ -0,0 +1,265 @@ +'use client' + +import type React from 'react' +import { Badge, Checkbox, Tooltip } from '@/components/emcn' +import { cn } from '@/lib/core/utils/cn' +import type { RowExecutionMetadata } from '@/lib/table' +import { StatusBadge } from '@/app/workspace/[workspaceId]/logs/utils' +import { storageToDisplay } from '../../../utils' +import type { DisplayColumn } from '../types' + +/** + * Discriminated union describing every shape a table cell can take. + * + * Workflow-output cells follow a status state machine: they always render + * *something* (a value, a status pill, or a dash), driven by the combination + * of `executions[groupId]` state and dep satisfaction. Plain (non-workflow) + * cells just render the typed value or empty. + * + * `'empty'` is the universal fallback used by both workflow cells (no exec, + * no value, no waiting) and plain cells (null/undefined value). + * + * Adding a new cell appearance is a three-step mechanical change: add a + * variant here, pick it in `resolveCellRender`, render it in `CellRender`. + * TypeScript's exhaustiveness check on the renderer's `switch` (the + * unreachable default) flags any branch you forgot. + */ +export type CellRenderKind = + // Workflow-output cells + | { kind: 'value'; text: string } + | { kind: 'block-error' } + | { kind: 'running' } + | { kind: 'pending-upstream' } + | { kind: 'queued' } + | { kind: 'cancelled' } + | { kind: 'error' } + | { kind: 'waiting'; labels: string[] } + // Plain typed cells + | { kind: 'boolean'; checked: boolean } + | { kind: 'json'; text: string } + | { kind: 'date'; text: string } + | { kind: 'text'; text: string } + // Universal fallback + | { kind: 'empty' } + +interface ResolveCellRenderInput { + value: unknown + exec: RowExecutionMetadata | undefined + column: DisplayColumn + /** Empty / undefined → not waiting; non-empty → render the Waiting pill. */ + waitingOnLabels: string[] | undefined +} + +/** + * Decide which `CellRenderKind` to render for a cell. Pure — easily + * unit-testable in isolation, no JSX involved. + * + * Order matters for workflow cells: block-error wins over a value (the user + * cares about the failure), value wins over running/queued (we have data + * already), and the running/queued branch deliberately collapses pre-enqueue + * `pending` and post-enqueue `queued` into one `Queued` pill so the cell + * doesn't flicker as the row transitions from one to the other. + */ +export function resolveCellRender({ + value, + exec, + column, + waitingOnLabels, +}: ResolveCellRenderInput): CellRenderKind { + const isNull = value === null || value === undefined + + if (column.workflowGroupId) { + const blockId = column.outputBlockId + const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined + const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false + const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) + + if (blockError) return { kind: 'block-error' } + + // In-flight wins over the existing value: when the group is being re-run, + // the current value is about to be overwritten — surface the run state so + // the user sees the cell is changing. Without this, a queued / running + // re-run on a previously-completed cell looks like nothing happened until + // the new value lands. + const inFlight = + exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' + if (inFlight && !(groupHasBlockErrors && !blockRunning)) { + if (blockRunning) return { kind: 'running' } + if (exec?.status === 'queued' || exec?.status === 'pending') return { kind: 'queued' } + // `running` with this block not in `runningBlockIds` = upstream block + // still going; surface as the amber Pending pill per logs convention. + return { kind: 'pending-upstream' } + } + + if (!isNull) return { kind: 'value', text: stringifyValue(value) } + + // Waiting wins over a stale terminal state: if deps are unmet right now, + // the prior `cancelled` / `error` is informational at best — the cell + // can't actually run until the user fills the missing input. Surface the + // actionable state instead of the stale one. + if (waitingOnLabels && waitingOnLabels.length > 0) { + return { kind: 'waiting', labels: waitingOnLabels } + } + if (exec?.status === 'cancelled') return { kind: 'cancelled' } + if (exec?.status === 'error') return { kind: 'error' } + return { kind: 'empty' } + } + + if (column.type === 'boolean') return { kind: 'boolean', checked: Boolean(value) } + if (isNull) return { kind: 'empty' } + if (column.type === 'json') return { kind: 'json', text: JSON.stringify(value) } + if (column.type === 'date') return { kind: 'date', text: String(value) } + return { kind: 'text', text: stringifyValue(value) } +} + +function stringifyValue(value: unknown): string { + if (typeof value === 'string') return value + if (value === null || value === undefined) return '' + return JSON.stringify(value) +} + +interface CellRenderProps { + kind: CellRenderKind + /** When true the static content sits underneath the InlineEditor overlay + * and should be visually hidden (but kept in flow to preserve cell size). */ + isEditing: boolean +} + +/** + * Pure renderer: takes a `CellRenderKind` and returns the JSX. No business + * logic — adding a new cell appearance means adding a new `case` here. The + * exhaustiveness check on the `switch` (the unreachable default) flags any + * variant you forgot to handle. + */ +export function CellRender({ kind, isEditing }: CellRenderProps): React.ReactElement | null { + switch (kind.kind) { + case 'value': + return ( + + {kind.text} + + ) + + case 'block-error': + case 'error': + return ( + + + + ) + + case 'running': + return ( + + + + ) + + case 'pending-upstream': + return ( + + + + ) + + case 'cancelled': + return ( + + + + ) + + case 'queued': + return ( + + + Queued + + + ) + + case 'waiting': + return ( + + + + + + Waiting + + + + + Waiting on {kind.labels.map((l) => `"${l}"`).join(', ')} + + + + ) + + case 'boolean': + return ( +
+ +
+ ) + + case 'json': + return ( + + {kind.text} + + ) + + case 'date': + return ( + + {storageToDisplay(kind.text)} + + ) + + case 'text': + return ( + + {kind.text} + + ) + + case 'empty': + return null + + default: { + // Exhaustiveness guard: TypeScript flags this branch if a new + // `CellRenderKind` variant is added without a matching `case` above. + const _exhaustive: never = kind + return _exhaustive + } + } +} + +/** + * Workflow-output cells are hand-editable; while editing, the static content + * must stay in flow (so the cell doesn't collapse) but be visually hidden so + * the InlineEditor overlay shows through. Plain wrapper around any non-text + * variant. + */ +function Wrap({ isEditing, children }: { isEditing: boolean; children: React.ReactNode }) { + if (!isEditing) return <>{children} + return
{children}
+} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/expanded-cell-popover.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/expanded-cell-popover.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts new file mode 100644 index 0000000000..c54286afa5 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/index.ts @@ -0,0 +1,4 @@ +export { CellContent } from './cell-content' +export { CellRender, type CellRenderKind, resolveCellRender } from './cell-render' +export { ExpandedCellPopover } from './expanded-cell-popover' +export { InlineEditor } from './inline-editors' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/inline-editors.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/cells/inline-editors.tsx diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts similarity index 61% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts index 28aead3265..69db8b7b4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/constants.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/constants.ts @@ -5,5 +5,6 @@ export const SELECTION_TINT_BG = 'bg-[rgba(37,99,235,0.06)]' * been measured yet and as the initial width for newly-added columns. */ export const COL_WIDTH = 160 -/** Column config sidebar width: roomy by default, bounded on narrow screens. */ -export const COLUMN_SIDEBAR_WIDTH_CSS = 'min(480px, calc(100vw - 48px))' +/** Column config sidebar width in pixels — drives both the sidebar's own width + * and the table's reserved padding-right while a sidebar is open. */ +export const COLUMN_SIDEBAR_WIDTH = 400 diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx similarity index 83% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx index da955ee132..d7a80f4a50 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-header-menu.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-header-menu.tsx @@ -35,8 +35,13 @@ interface ColumnHeaderMenuProps { onDragLeave?: () => void workflows?: WorkflowMetadata[] workflowGroups?: WorkflowGroup[] + /** Source-info entry for workflow-output columns; supplies the producing + * block's icon component. The block's color is intentionally not used. */ sourceInfo?: ColumnSourceInfo onOpenConfig: (columnName: string) => void + /** Opens a popup preview of the column's underlying workflow. Surfaced in + * the chevron menu for workflow-output columns. */ + onViewWorkflow?: (workflowId: string) => void } /** @@ -70,6 +75,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ workflowGroups, sourceInfo, onOpenConfig, + onViewWorkflow, }: ColumnHeaderMenuProps) { const renameInputRef = useRef(null) const didDragRef = useRef(false) @@ -90,10 +96,6 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ ? 'Hide column' : 'Delete workflow' : undefined - const workflowColor = configuredWorkflow?.color - const blockIconInfo = sourceInfo?.blockIconInfo - const blockName = sourceInfo?.blockName - useEffect(() => { if (isRenaming && renameInputRef.current) { renameInputRef.current.focus() @@ -142,8 +144,13 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ e.dataTransfer.effectAllowed = 'move' e.dataTransfer.setData('text/plain', column.name) + // Workflow-output columns drag as a whole group, so the ghost shows + // the group's name (falling back to the workflow's name, then the + // column slug) rather than the individual column slug. + const ghostLabel = ownGroup?.name ?? configuredWorkflow?.name ?? column.name + const ghost = document.createElement('div') - ghost.textContent = column.name + ghost.textContent = ghostLabel ghost.style.cssText = 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' document.body.appendChild(ghost) @@ -152,7 +159,7 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ onDragStart?.(column.name) }, - [column.name, readOnly, isRenaming, onDragStart] + [column.name, ownGroup, configuredWorkflow, readOnly, isRenaming, onDragStart] ) const handleDragOver = useCallback( @@ -181,6 +188,11 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ const th = e.currentTarget as HTMLElement const related = e.relatedTarget as Node | null if (related && th.contains(related)) return + // Don't clear when the cursor is moving to another column header — the + // next dragover will set the right target. Clearing here causes the + // drop indicator to flicker between sibling columns of a workflow + // group (and any adjacent column hop in general). + if (related && related instanceof Element && related.closest('th')) return onDragLeave?.() }, [onDragLeave] @@ -238,8 +250,8 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({
- {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} +
) : (
@@ -288,25 +287,12 @@ export const ColumnHeaderMenu = React.memo(function ColumnHeaderMenu({ > - {column.workflowGroupId ? ( -
- {blockName && ( - - {blockName} - - )} - - {column.headerLabel} - -
- ) : ( - - {column.name} - - )} + + {column.workflowGroupId ? column.headerLabel : column.name} + onViewWorkflow(ownGroup.workflowId) : undefined + } />
)} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx new file mode 100644 index 0000000000..e4e4fc51b2 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/column-type-icon.tsx @@ -0,0 +1,50 @@ +'use client' + +import type React from 'react' +import { + Calendar as CalendarIcon, + PlayOutline, + TypeBoolean, + TypeJson, + TypeNumber, + TypeText, +} from '@/components/emcn/icons' +import type { BlockIconInfo } from '../types' + +export const COLUMN_TYPE_ICONS: Record = { + string: TypeText, + number: TypeNumber, + boolean: TypeBoolean, + date: CalendarIcon, + json: TypeJson, +} + +interface ColumnTypeIconProps { + type: string + /** True for workflow-output columns; renders the producing block's icon + * (or a workflow fallback) instead of the scalar type icon. Workflow + * columns ARE stored as scalar types, so without this `type` would + * otherwise resolve to e.g. `string` and read identically to a plain + * text column. */ + isWorkflowColumn?: boolean + /** Block-icon info from the source-info builder, used for workflow columns + * to surface the producing block's icon. The block's color is intentionally + * ignored — icons render in the plain `text-[var(--text-icon)]` tone like + * every other column-type icon, no per-block tint. */ + blockIconInfo?: BlockIconInfo +} + +/** + * Tiny icon shown next to a column header. Workflow-output columns get the + * producing block's icon (falling back to `PlayOutline`); plain columns get + * their scalar type icon. Both render in the same `text-[var(--text-icon)]` + * tone — no per-workflow color, no colored swatch. + */ +export function ColumnTypeIcon({ type, isWorkflowColumn, blockIconInfo }: ColumnTypeIconProps) { + if (isWorkflowColumn) { + const Icon = blockIconInfo?.icon ?? PlayOutline + return + } + const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText + return +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts new file mode 100644 index 0000000000..8c8ef9f9dc --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/index.ts @@ -0,0 +1,3 @@ +export { ColumnHeaderMenu } from './column-header-menu' +export { COLUMN_TYPE_ICONS, ColumnTypeIcon } from './column-type-icon' +export { ColumnOptionsMenu, WorkflowGroupMetaCell } from './workflow-group-meta-cell' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx similarity index 59% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx index 8b3403053f..51788975ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/workflow-group-meta-cell.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/headers/workflow-group-meta-cell.tsx @@ -1,7 +1,7 @@ 'use client' import type React from 'react' -import { useCallback, useState } from 'react' +import { useCallback, useRef, useState } from 'react' import { DropdownMenu, DropdownMenuContent, @@ -12,7 +12,16 @@ import { DropdownMenuSubTrigger, DropdownMenuTrigger, } from '@/components/emcn' -import { ArrowLeft, ArrowRight, EyeOff, Pencil, PlayOutline, Trash } from '@/components/emcn/icons' +import { + ArrowLeft, + ArrowRight, + Eye, + EyeOff, + Pencil, + PlayOutline, + Trash, +} from '@/components/emcn/icons' +import type { RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import { SELECTION_TINT_BG } from '../constants' @@ -40,8 +49,14 @@ interface ColumnOptionsMenuProps { onDeleteGroup?: () => void /** When provided, the menu is being opened from a workflow-group header and * exposes group-level run actions above the column actions. */ - onRunGroupAll?: () => void - onRunGroupIncomplete?: () => void + onRunColumnAll?: () => void + onRunColumnIncomplete?: () => void + /** When set, surfaces a "Run N selected rows" item above Run all. */ + onRunColumnSelected?: () => void + selectedRowCount?: number + /** When set, the menu surfaces a "View workflow" item that opens a popup + * preview of the configured workflow. */ + onViewWorkflow?: () => void } /** @@ -62,10 +77,14 @@ export function ColumnOptionsMenu({ onInsertRight, onDeleteColumn, onDeleteGroup, - onRunGroupAll, - onRunGroupIncomplete, + onRunColumnAll, + onRunColumnIncomplete, + onRunColumnSelected, + selectedRowCount = 0, + onViewWorkflow, }: ColumnOptionsMenuProps) { - const showRunActions = Boolean(onRunGroupAll && onRunGroupIncomplete) + const showRunActions = Boolean(onRunColumnAll && onRunColumnIncomplete) + const showRunSelected = Boolean(onRunColumnSelected) && selectedRowCount > 0 return ( @@ -97,8 +116,15 @@ export function ColumnOptionsMenu({ Run - onRunGroupAll?.()}>Run all rows - onRunGroupIncomplete?.()}> + {showRunSelected && ( + onRunColumnSelected?.()}> + {`Run ${selectedRowCount} selected ${selectedRowCount === 1 ? 'row' : 'rows'}`} + + )} + onRunColumnAll?.()}> + Run all rows + + onRunColumnIncomplete?.()}> Run empty rows @@ -106,6 +132,12 @@ export function ColumnOptionsMenu({ )} + {onViewWorkflow && ( + onViewWorkflow()}> + + View workflow + + )} onOpenConfig(column.name)}> Edit column @@ -143,12 +175,26 @@ interface WorkflowGroupMetaCellProps { isGroupSelected: boolean onSelectGroup: (startColIndex: number, size: number) => void onOpenConfig: (columnName: string) => void - onRunGroup?: (groupId: string, workflowId: string, mode?: 'all' | 'incomplete') => void + onRunColumn?: (groupId: string, mode?: RunMode, rowIds?: string[]) => void onInsertLeft?: (columnName: string) => void onInsertRight?: (columnName: string) => void onDeleteColumn?: (columnName: string) => void /** Right-click delete on the group header drops the entire workflow group. */ onDeleteGroup?: (groupId: string) => void + /** Row ids in the user's current multi-row selection; when non-empty the + * run menu adds a "Run N selected rows" option. */ + selectedRowIds?: string[] | null + /** Opens a popup preview of the underlying workflow. */ + onViewWorkflow?: (workflowId: string) => void + /** When set, the meta cell becomes draggable and forwards events through + * the same column-reorder pipeline used by individual workflow column + * headers. The whole group moves together because downstream code groups + * fan-out siblings by `workflowGroupId`. */ + onDragStart?: (columnName: string) => void + onDragOver?: (columnName: string, side: 'left' | 'right') => void + onDragEnd?: () => void + onDragLeave?: () => void + readOnly?: boolean } /** @@ -167,11 +213,18 @@ export function WorkflowGroupMetaCell({ isGroupSelected, onSelectGroup, onOpenConfig, - onRunGroup, + onRunColumn, onInsertLeft, onInsertRight, onDeleteColumn, onDeleteGroup, + selectedRowIds, + onViewWorkflow, + onDragStart, + onDragOver, + onDragEnd, + onDragLeave, + readOnly, }: WorkflowGroupMetaCellProps) { const wf = workflows?.find((w) => w.id === workflowId) const color = wf?.color ?? 'var(--text-muted)' @@ -180,14 +233,23 @@ export function WorkflowGroupMetaCell({ const [optionsMenuOpen, setOptionsMenuOpen] = useState(false) const [optionsMenuPosition, setOptionsMenuPosition] = useState({ x: 0, y: 0 }) const [runMenuOpen, setRunMenuOpen] = useState(false) + const didDragRef = useRef(false) + + const selectedCount = selectedRowIds?.length ?? 0 const handleRunAll = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'all') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'all') + }, [groupId, onRunColumn]) const handleRunIncomplete = useCallback(() => { - if (groupId && workflowId) onRunGroup?.(groupId, workflowId, 'incomplete') - }, [groupId, workflowId, onRunGroup]) + if (groupId) onRunColumn?.(groupId, 'incomplete') + }, [groupId, onRunColumn]) + + const handleRunSelected = useCallback(() => { + if (groupId && selectedRowIds && selectedRowIds.length > 0) { + onRunColumn?.(groupId, 'all', selectedRowIds) + } + }, [groupId, onRunColumn, selectedRowIds]) const handleContextMenu = useCallback( (e: React.MouseEvent) => { @@ -207,18 +269,88 @@ export function WorkflowGroupMetaCell({ // should select the group + open the config sidebar. const target = e.target as HTMLElement if (target.closest('button, [role="menuitem"], [role="menu"]')) return + // Drag-vs-click guard: when a drag just ended on this cell, swallow the + // synthetic click so we don't accidentally pop open the sidebar. + if (didDragRef.current) { + didDragRef.current = false + return + } onSelectGroup(startColIndex, size) if (columnName) onOpenConfig(columnName) }, [columnName, onOpenConfig, onSelectGroup, size, startColIndex] ) + const handleDragStart = useCallback( + (e: React.DragEvent) => { + if (readOnly || !onDragStart || !columnName) { + e.preventDefault() + return + } + didDragRef.current = true + e.dataTransfer.effectAllowed = 'move' + e.dataTransfer.setData('text/plain', columnName) + + const ghost = document.createElement('div') + ghost.textContent = name + ghost.style.cssText = + 'position:absolute;top:-9999px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:4px;font-size:13px;font-weight:500;white-space:nowrap;color:var(--text-primary)' + document.body.appendChild(ghost) + e.dataTransfer.setDragImage(ghost, ghost.offsetWidth / 2, ghost.offsetHeight / 2) + requestAnimationFrame(() => ghost.parentNode?.removeChild(ghost)) + + onDragStart(columnName) + }, + [columnName, name, onDragStart, readOnly] + ) + + const handleDragOver = useCallback( + (e: React.DragEvent) => { + if (!onDragOver || !columnName) return + e.preventDefault() + e.dataTransfer.dropEffect = 'move' + const rect = (e.currentTarget as HTMLElement).getBoundingClientRect() + const midX = rect.left + rect.width / 2 + const side = e.clientX < midX ? 'left' : 'right' + onDragOver(columnName, side) + }, + [columnName, onDragOver] + ) + + const handleDragEnd = useCallback(() => { + didDragRef.current = false + onDragEnd?.() + }, [onDragEnd]) + + const handleDragLeave = useCallback( + (e: React.DragEvent) => { + const th = e.currentTarget as HTMLElement + const related = e.relatedTarget as Node | null + if (related && th.contains(related)) return + if (related && related instanceof Element && related.closest('th')) return + onDragLeave?.() + }, + [onDragLeave] + ) + + const handleDrop = useCallback((e: React.DragEvent) => { + e.preventDefault() + }, []) + + const isDraggable = !readOnly && Boolean(onDragStart) + return (
diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/index.ts new file mode 100644 index 0000000000..306193d764 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/index.ts @@ -0,0 +1 @@ +export { type SelectionSnapshot, TableGrid } from './table-grid' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx similarity index 74% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx index cbe1f625f4..bc97570b4f 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/table.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/table-grid.tsx @@ -2,52 +2,23 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' -import { Square } from 'lucide-react' -import { useParams, useRouter } from 'next/navigation' +import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' -import { - Button, - Checkbox, - Download, - Modal, - ModalBody, - ModalContent, - ModalFooter, - ModalHeader, - Skeleton, - toast, - Upload, -} from '@/components/emcn' -import { - Pencil, - PlayOutline, - Plus, - Table as TableIcon, - TableX, - Trash, -} from '@/components/emcn/icons' -import { Loader } from '@/components/emcn/icons/loader' +import { Button, Checkbox, Skeleton, toast } from '@/components/emcn' +import { PlayOutline, Plus, Square, TableX } from '@/components/emcn/icons' +import type { RunMode } from '@/lib/api/contracts/tables' import { cn } from '@/lib/core/utils/cn' import { captureEvent } from '@/lib/posthog/client' -import type { ColumnDefinition, Filter, SortDirection, TableRow as TableRowType } from '@/lib/table' -import type { ColumnOption, SortConfig } from '@/app/workspace/[workspaceId]/components' -import { ResourceHeader, ResourceOptionsBar } from '@/app/workspace/[workspaceId]/components' -import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' +import type { ColumnDefinition, TableRow as TableRowType, WorkflowGroup } from '@/lib/table' +import { getUnmetGroupDeps, isExecInFlight } from '@/lib/table/deps' import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' -import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' -import { useLogByExecutionId } from '@/hooks/queries/logs' import { - downloadTableExport, useAddTableColumn, useBatchCreateTableRows, useBatchUpdateTableRows, - useCancelTableRuns, useCreateTableRow, useDeleteColumn, - useDeleteTable, useDeleteWorkflowGroup, - useRenameTable, - useRunGroup, useUpdateColumn, useUpdateTableMetadata, useUpdateTableRow, @@ -55,28 +26,30 @@ import { } from '@/hooks/queries/tables' import { useInlineRename } from '@/hooks/use-inline-rename' import { extractCreatedRowId, useTableUndo } from '@/hooks/use-table-undo' -import { useLogDetailsUIStore } from '@/stores/logs/store' import type { DeletedRowSnapshot } from '@/stores/table/types' -import { useContextMenu, useRowExecution, useTable } from '../../hooks' +import { useContextMenu, useTable } from '../../hooks' import type { EditingCell, QueryOptions, SaveReason } from '../../types' -import { cleanCellValue, storageToDisplay } from '../../utils' -import { type ColumnConfigState, ColumnSidebar } from '../column-sidebar/column-sidebar' +import { + cleanCellValue, + generateColumnName as sharedGenerateColumnName, + storageToDisplay, +} from '../../utils' +import type { ColumnConfig } from '../column-config-sidebar' import { ContextMenu } from '../context-menu' -import { RowModal } from '../row-modal' -import { TableFilter } from '../table-filter' -import { CellContent } from './cells/cell-content' -import { ExpandedCellPopover } from './cells/expanded-cell-popover' -import { COL_WIDTH, COLUMN_SIDEBAR_WIDTH_CSS, SELECTION_TINT_BG } from './constants' -import { ColumnHeaderMenu } from './headers/column-header-menu' -import { COLUMN_TYPE_ICONS } from './headers/column-type-icon' -import { WorkflowGroupMetaCell } from './headers/workflow-group-meta-cell' +import { NewColumnDropdown } from '../new-column-dropdown' +import { RunStatusControl } from '../run-status-control' +import type { WorkflowConfig } from '../workflow-sidebar' +import { CellContent, ExpandedCellPopover } from './cells' +import { COL_WIDTH, SELECTION_TINT_BG } from './constants' +import { ColumnHeaderMenu, WorkflowGroupMetaCell } from './headers' import type { DisplayColumn } from './types' import { - areRowDepsSatisfied, buildHeaderGroups, type CellCoord, + classifyExecStatusMix, collectRowSnapshots, computeNormalizedSelection, + type ExecStatusMix, expandToDisplayColumns, moveCell, type NormalizedSelection, @@ -120,10 +93,23 @@ function rowSelectionCoversAll(sel: RowSelection, rows: TableRowType[]): boolean const COL_WIDTH_MIN = 80 const COL_WIDTH_AUTO_FIT_MAX = 1000 // Wide enough to host the row-number + per-row run button side by side. -// Single-digit row numbers (rows 1–9) and multi-digit (10+) need to render -// with the play button at the same x-position so the column doesn't reflow +// Single-digit row numbers (rows 1–9) and multi-digit need to render with +// the play button at the same x-position so the column doesn't reflow // row-by-row. -const CHECKBOX_COL_WIDTH = 56 +// +// Bucketed by the table's plan-derived `maxRows`, not the live count: a small +// table sized for ≤9,999 always renders the narrow gutter; an enterprise +// table sized up to 9,999,999 always renders the wide one. The gutter never +// changes width as rows are added. +// +// Tables without workflow columns drop the per-row run button (~28px), so +// the gutter shrinks accordingly. +const CHECKBOX_COL_WIDTH_SMALL_WITH_RUN = 48 +const CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY = 32 +const CHECKBOX_COL_WIDTH_LARGE_WITH_RUN = 68 +const CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY = 52 +/** Bucket boundary: tables sized for >9,999 rows get the wide gutter. */ +const LARGE_ROW_NUMBER_THRESHOLD = 10000 const ADD_COL_WIDTH = 120 const SKELETON_COL_COUNT = 4 const SKELETON_ROW_COUNT = 10 @@ -131,29 +117,156 @@ const ROW_HEIGHT_ESTIMATE = 35 const CELL = 'border-[var(--border)] border-r border-b px-2 py-[7px] align-middle select-none' const CELL_CHECKBOX = - 'border-[var(--border)] border-r border-b px-1 py-[7px] align-middle select-none' + 'sticky left-0 z-[6] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] align-middle select-none' const CELL_HEADER = 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-2 py-[7px] text-left align-middle' const CELL_HEADER_CHECKBOX = - 'border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle' + 'sticky left-0 z-[12] border-[var(--border)] border-r border-b bg-[var(--bg)] px-1 py-[7px] text-center align-middle' +// Fixed height (not min-) so a Badge-rendered status pill doesn't make the row +// grow vs a plain-text neighbor. Sized to comfortably contain the badge; the +// flex centers plain text + badges on the same baseline. const CELL_CONTENT = - 'relative min-h-[20px] min-w-0 overflow-clip text-ellipsis whitespace-nowrap text-small' + 'relative flex h-[22px] min-w-0 items-center overflow-clip text-ellipsis whitespace-nowrap text-small' const SELECTION_OVERLAY = 'pointer-events-none absolute -top-px -right-px -bottom-px -left-px z-[5] border-[2px] border-[var(--selection)]' -interface TableProps { +/** + * Snapshot of grid selection state the wrapper needs to render ``. + * Fired from a `useEffect` so the callback identity doesn't drive re-renders. + */ +export interface SelectionSnapshot { + /** Row ids in the action-bar selection (checkbox-row union with multi-row range). */ + actionBarRowIds: string[] + /** Total running/queued workflow runs across `actionBarRowIds`. */ + runningInActionBarSelection: number + /** Total running/queued workflow runs across ALL rows. Drives the page-header + * RunStatusControl ("N running, Stop all"). */ + totalRunning: number + /** Whether the table has any workflow-output columns (drives the Run/Stop visibility). */ + hasWorkflowColumns: boolean + /** Cells the Play / Refresh / Stop buttons act on. Null when the selection + * contains no workflow output cells. */ + selectedRunScope: { groupIds: string[]; rowIds: string[] } | null + /** Drives Play (`hasIncompleteOrFailed`) / Refresh (`hasCompleted`) / + * Stop (`hasInFlight`) visibility on the action bar. */ + selectionStats: ExecStatusMix + /** + * When the highlight resolves to exactly one workflow-group execution — + * same row, every highlighted column in the same workflow group — describe + * it so the action bar can offer "View execution". Covers both the 1×1 + * single-cell case and 1 row × N cols highlights within one group. `null` + * for multi-row, cross-group, or plain-column selections. + */ + singleWorkflowCell: { + rowId: string + groupId: string + executionId: string | null + /** True iff the exec is in a state that produced a server log + * (completed / error / running). Drives the View execution button. */ + canViewExecution: boolean + } | null +} + +interface TableGridProps { workspaceId?: string tableId?: string embedded?: boolean + /** + * Pixel width to reserve on the right of the table's scroll content for the + * currently-open slideout panel (column config, workflow config, or log + * details). Computed by the wrapper so it can subscribe to whichever panel + * width source is relevant. `0` when no panel is open. + */ + sidebarReservedPx: number + /** + * Open requests fired by the grid (column header click, "+ New column" + * dropdown, context-menu items). The wrapper owns the actual panel state + * and enforces mutual-exclusion (only one slideout open at a time). + */ + onOpenColumnConfig: (cfg: ColumnConfig) => void + onOpenWorkflowConfig: (cfg: WorkflowConfig) => void + onOpenExecutionDetails: (executionId: string) => void + /** Open the row-edit modal for `row`. Wrapper renders the modal. */ + onOpenRowModal: (row: TableRowType) => void + /** Open the row-delete modal for `snapshots`. Wrapper renders the modal. */ + onRequestDeleteRows: (snapshots: DeletedRowSnapshot[]) => void + /** Open the delete-columns confirmation modal for `names`. Wrapper renders the modal. */ + onRequestDeleteColumns: (names: string[]) => void + /** Fire run for a single column (meta-cell Run menu). */ + onRunColumn: (groupId: string, runMode: RunMode, rowIds?: string[]) => void + /** Fire every runnable column on a single row (per-row gutter Play). */ + onRunRow: (rowId: string) => void + /** Fan out a run across every workflow group on `rowIds`. Used by context menu. */ + onRunRows: (rowIds: string[], runMode: RunMode) => void + /** Stop running workflows on `rowIds`. Per-row gutter Stop also funnels through here. */ + onStopRows: (rowIds: string[]) => void + /** Single-row stop for the per-row gutter button. */ + onStopRow: (rowId: string) => void + /** Wholesale cancel — page-header "Stop all". */ + onStopAll: () => void + /** Whether `useCancelTableRuns` is currently in flight. */ + cancelRunsPending: boolean + /** + * Fired whenever the action-bar selection or running-count derivations + * change. Wrapper uses this to render . + */ + onSelectionChange: (state: SelectionSnapshot) => void + /** Filter + sort. Lifted to wrapper so a single `useTable` call serves both. */ + queryOptions: QueryOptions + /** + * Ref the grid populates with its `handleColumnRename` so the wrapper's + * sidebars can fire a column rename back into the grid (rewrites local + * `columnWidths` / `columnOrder` keys). The wrapper just forwards the call. + */ + columnRenameSinkRef: React.MutableRefObject<((oldName: string, newName: string) => void) | null> + /** + * Ref the grid populates with its post-row-delete cleanup (push undo, + * clear selection). The wrapper invokes after the row-delete modal's + * mutation succeeds. + */ + afterDeleteRowsSinkRef: React.MutableRefObject<((snapshots: DeletedRowSnapshot[]) => void) | null> + /** + * Ref the grid populates with its full delete-columns cascade (per-column + * mutation, undo push, columnOrder + columnWidths cleanup). The wrapper's + * delete-columns confirmation modal invokes this on confirm. + */ + confirmDeleteColumnsSinkRef: React.MutableRefObject<((names: string[]) => void) | null> + /** + * Ref the grid populates with its `pushUndo({ type: 'rename-table', ... })` + * call. The wrapper's table-rename `onSave` invokes this so the rename is + * undoable from anywhere in the grid. + */ + pushTableRenameUndoSinkRef: React.MutableRefObject< + ((previousName: string, newName: string) => void) | null + > } -export function Table({ +export function TableGrid({ workspaceId: propWorkspaceId, tableId: propTableId, embedded, -}: TableProps = {}) { + sidebarReservedPx, + onOpenColumnConfig, + onOpenWorkflowConfig, + onOpenExecutionDetails, + onOpenRowModal, + onRequestDeleteRows, + onRequestDeleteColumns, + onRunColumn, + onRunRow, + onRunRows, + onStopRows, + onStopRow, + onStopAll, + cancelRunsPending, + onSelectionChange, + queryOptions, + columnRenameSinkRef, + afterDeleteRowsSinkRef, + confirmDeleteColumnsSinkRef, + pushTableRenameUndoSinkRef, +}: TableGridProps) { const params = useParams() - const router = useRouter() const workspaceId = propWorkspaceId || (params.workspaceId as string) const tableId = propTableId || (params.tableId as string) const posthog = usePostHog() @@ -163,12 +276,6 @@ export function Table({ captureEvent(posthog, 'table_opened', { table_id: tableId, workspace_id: workspaceId }) }, [tableId, workspaceId, posthog]) - const [queryOptions, setQueryOptions] = useState({ - filter: null, - sort: null, - }) - const [editingRow, setEditingRow] = useState(null) - const [deletingRows, setDeletingRows] = useState([]) const [editingCell, setEditingCell] = useState(null) const [initialCharacter, setInitialCharacter] = useState(null) const [expandedCell, setExpandedCell] = useState(null) @@ -178,10 +285,6 @@ export function Table({ const [isColumnSelection, setIsColumnSelection] = useState(false) const lastCheckboxRowRef = useRef(null) const isColumnSelectionRef = useRef(false) - const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) - const [deletingColumns, setDeletingColumns] = useState(null) - const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) - const [columnWidths, setColumnWidths] = useState>({}) const columnWidthsRef = useRef(columnWidths) columnWidthsRef.current = columnWidths @@ -217,7 +320,6 @@ export function Table({ tableWorkflowGroups, workflowStates, columnSourceInfo, - workflowNameById, } = useTable({ workspaceId, tableId, queryOptions }) const fetchNextPageRef = useRef(fetchNextPage) @@ -231,6 +333,9 @@ export function Table({ const userPermissions = useUserPermissionsContext() const canEditRef = useRef(userPermissions.canEdit) canEditRef.current = userPermissions.canEdit + // Refs for callback props read inside effects with stable empty deps. + const onOpenRowModalRef = useRef(onOpenRowModal) + onOpenRowModalRef.current = onOpenRowModal const { contextMenu, @@ -238,7 +343,6 @@ export function Table({ closeContextMenu, } = useContextMenu() - const { runWorkflowGroup } = useRowExecution() const workflowsRef = useRef(workflows) workflowsRef.current = workflows @@ -250,18 +354,21 @@ export function Table({ const updateColumnMutation = useUpdateColumn({ workspaceId, tableId }) const deleteColumnMutation = useDeleteColumn({ workspaceId, tableId }) const updateMetadataMutation = useUpdateTableMetadata({ workspaceId, tableId }) - const cancelRunsMutation = useCancelTableRuns({ workspaceId, tableId }) - const runGroupMutation = useRunGroup({ workspaceId, tableId }) const deleteWorkflowGroupMutation = useDeleteWorkflowGroup({ workspaceId, tableId }) const updateWorkflowGroupMutation = useUpdateWorkflowGroup({ workspaceId, tableId }) - const handleRunGroup = useCallback( - (groupId: string, workflowId: string, runMode: 'all' | 'incomplete' = 'all') => { - runGroupMutation.mutate({ groupId, workflowId, runMode }) + const handleRunColumn = useCallback( + (groupId: string, runMode: RunMode = 'all', rowIds?: string[]) => { + onRunColumn(groupId, runMode, rowIds) }, - // mutate is stable; intentionally excluded from deps - // eslint-disable-next-line react-hooks/exhaustive-deps - [] + [onRunColumn] + ) + + const handleViewWorkflow = useCallback( + (workflowId: string) => { + window.open(`/workspace/${workspaceId}/w/${workflowId}`, '_blank', 'noopener,noreferrer') + }, + [workspaceId] ) function handleColumnOrderChange(order: string[]) { @@ -296,6 +403,9 @@ export function Table({ ...(updatedOrder ? { columnOrder: updatedOrder } : {}), }) } + // Populate the wrapper's sink so its sidebars can fire renames back into + // the grid. Reads through refs, so identity stability isn't required. + columnRenameSinkRef.current = handleColumnRename function getColumnWidths() { return columnWidthsRef.current @@ -341,6 +451,24 @@ export function Table({ return expandToDisplayColumns(ordered, tableWorkflowGroups) }, [columns, columnOrder, tableWorkflowGroups]) + const hasWorkflowColumns = columns.some((c) => !!c.workflowGroupId) + /** + * The sticky left column hosts the row number / checkbox always, plus a + * per-row run button only when the table has workflow columns. Width is + * picked from the table's plan-derived `maxRows` so a free-tier table + * (≤9,999) gets the narrow gutter and an enterprise table (up to + * 9,999,999) gets the wide one. Bucketed, not continuous, so the gutter + * never reflows as rows are added. + */ + const isLargeRowCountTable = (tableData?.maxRows ?? 0) >= LARGE_ROW_NUMBER_THRESHOLD + const checkboxColWidth = isLargeRowCountTable + ? hasWorkflowColumns + ? CHECKBOX_COL_WIDTH_LARGE_WITH_RUN + : CHECKBOX_COL_WIDTH_LARGE_NUMBER_ONLY + : hasWorkflowColumns + ? CHECKBOX_COL_WIDTH_SMALL_WITH_RUN + : CHECKBOX_COL_WIDTH_SMALL_NUMBER_ONLY + const headerGroups = useMemo( () => buildHeaderGroups(displayColumns, tableWorkflowGroups), [displayColumns, tableWorkflowGroups] @@ -357,18 +485,18 @@ export function Table({ const colsWidth = isLoadingTable ? displayColCount * COL_WIDTH : displayColumns.reduce((sum, col) => sum + (columnWidths[col.key] ?? COL_WIDTH), 0) - return CHECKBOX_COL_WIDTH + colsWidth + ADD_COL_WIDTH - }, [isLoadingTable, displayColCount, displayColumns, columnWidths]) + return checkboxColWidth + colsWidth + ADD_COL_WIDTH + }, [isLoadingTable, displayColCount, displayColumns, columnWidths, checkboxColWidth]) const resizeIndicatorLeft = useMemo(() => { if (!resizingColumn) return 0 - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth for (const col of displayColumns) { left += columnWidths[col.key] ?? COL_WIDTH if (col.key === resizingColumn) return left } return 0 - }, [resizingColumn, displayColumns, columnWidths]) + }, [resizingColumn, displayColumns, columnWidths, checkboxColWidth]) const dropColumnBounds = useMemo(() => { if (!dropTargetColumnName || !dragColumnName) return null @@ -389,7 +517,7 @@ export function Table({ (dropSide === 'left' && targetGroupStart === dragGroup + dragGroupSize) if (wouldBeNoOp) return null - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth for (let i = 0; i < cols.length; i++) { const col = cols[i] const w = columnWidths[col.key] ?? COL_WIDTH @@ -408,7 +536,14 @@ export function Table({ left += w } return null - }, [dropTargetColumnName, dragColumnName, dropSide, displayColumns, columnWidths]) + }, [ + dropTargetColumnName, + dragColumnName, + dropSide, + displayColumns, + columnWidths, + checkboxColWidth, + ]) const isAllRowsSelected = useMemo( () => rowSelectionCoversAll(rowSelection, rows), @@ -438,23 +573,6 @@ export function Table({ selectionFocusRef.current = selectionFocus isColumnSelectionRef.current = isColumnSelection - const deleteTableMutation = useDeleteTable(workspaceId) - const renameTableMutation = useRenameTable(workspaceId) - - const tableHeaderRename = useInlineRename({ - onSave: (_id, name) => { - if (tableData) { - pushUndoRef.current({ - type: 'rename-table', - tableId, - previousName: tableData.name, - newName: name, - }) - } - renameTableMutation.mutate({ tableId, name }) - }, - }) - const columnRename = useInlineRename({ onSave: (columnName, newName) => { pushUndoRef.current({ type: 'rename-column', oldName: columnName, newName }) @@ -463,21 +581,6 @@ export function Table({ }, }) - const handleNavigateBack = useCallback(() => { - router.push(`/workspace/${workspaceId}/tables`) - }, [router, workspaceId]) - - const handleDeleteTable = useCallback(async () => { - try { - await deleteTableMutation.mutateAsync(tableId) - setShowDeleteTableConfirm(false) - router.push(`/workspace/${workspaceId}/tables`) - } catch { - setShowDeleteTableConfirm(false) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableId, router, workspaceId]) - const toggleBooleanCell = useCallback( (rowId: string, columnName: string, currentValue: unknown) => { const newValue = !currentValue @@ -543,11 +646,11 @@ export function Table({ } if (snapshots.length > 0) { - setDeletingRows(snapshots) + onRequestDeleteRows(snapshots) } closeContextMenu() - }, [contextMenu.row, closeContextMenu]) + }, [contextMenu.row, closeContextMenu, onRequestDeleteRows]) const handleInsertRow = useCallback( (offset: 0 | 1) => { @@ -575,27 +678,38 @@ export function Table({ const contextMenuColumnInfo = useMemo<{ isWorkflowColumn: boolean executionId: string | null + hasStartedRun: boolean }>(() => { if (!contextMenu.row || !contextMenu.columnName) { - return { isWorkflowColumn: false, executionId: null } + return { isWorkflowColumn: false, executionId: null, hasStartedRun: false } } const column = columnsRef.current.find((c) => c.name === contextMenu.columnName) const groupId = column?.workflowGroupId if (!column || !groupId) { - return { isWorkflowColumn: false, executionId: null } + return { isWorkflowColumn: false, executionId: null, hasStartedRun: false } } const exec = contextMenu.row.executions?.[groupId] - return { isWorkflowColumn: true, executionId: exec?.executionId ?? null } + // Only `completed` / `error` / `running` cells are guaranteed to have a + // server-side execution log. `queued` / `pending` haven't started yet; + // `cancelled` may have been cancelled before the worker ever picked the + // job up, so its executionId can't be relied on either. + const hasStartedRun = + exec?.status === 'completed' || exec?.status === 'error' || exec?.status === 'running' + return { + isWorkflowColumn: true, + executionId: exec?.executionId ?? null, + hasStartedRun, + } }, [contextMenu.row, contextMenu.columnName]) const contextMenuExecutionId = contextMenuColumnInfo.executionId const contextMenuIsWorkflowColumn = contextMenuColumnInfo.isWorkflowColumn + const contextMenuHasStartedRun = contextMenuColumnInfo.hasStartedRun const handleViewExecution = useCallback(() => { if (!contextMenuExecutionId) return - setConfigState(null) - setExecutionDetailsId(contextMenuExecutionId) + onOpenExecutionDetails(contextMenuExecutionId) closeContextMenu() - }, [contextMenuExecutionId, closeContextMenu]) + }, [contextMenuExecutionId, onOpenExecutionDetails, closeContextMenu]) const handleDuplicateRow = useCallback(() => { const contextRow = contextMenu.row @@ -764,6 +878,20 @@ export function Table({ lastCheckboxRowRef.current = null }, []) + // Populate the wrapper's after-delete sink so the row-delete modal can run + // grid cleanup (push undo + clear selection) once its mutation succeeds. + afterDeleteRowsSinkRef.current = (snapshots: DeletedRowSnapshot[]) => { + pushUndoRef.current({ type: 'delete-rows', rows: snapshots }) + handleClearSelection() + } + + // Populate the wrapper's table-rename undo sink. The wrapper's + // breadcrumb rename calls back here so the rename is part of the grid's undo + // stack (Cmd-Z restores the previous name). + pushTableRenameUndoSinkRef.current = (previousName: string, newName: string) => { + pushUndoRef.current({ type: 'rename-table', tableId, previousName, newName }) + } + const handleColumnSelect = useCallback((colIndex: number, shiftKey: boolean) => { const lastRow = rowsRef.current.length - 1 if (lastRow < 0) return @@ -900,19 +1028,29 @@ export function Table({ }, []) const handleColumnDragOver = useCallback((columnName: string, side: 'left' | 'right') => { + const dragged = dragColumnNameRef.current + const cols = schemaColumnsRef.current + const targetCol = cols.find((c) => c.name === columnName) + const targetGid = targetCol?.workflowGroupId + // Suppress drop targeting while hovering siblings of the dragged column's // own group: reordering inside a group is meaningless (the group renders // as a unit) and the chasing indicator just flickers. - const dragged = dragColumnNameRef.current if (dragged) { - const cols = schemaColumnsRef.current const draggedGid = cols.find((c) => c.name === dragged)?.workflowGroupId - const targetGid = cols.find((c) => c.name === columnName)?.workflowGroupId if (draggedGid && draggedGid === targetGid) { if (dropTargetColumnNameRef.current !== null) setDropTargetColumnName(null) return } } + + // Workflow groups: skip per-`
` trigger. Same dropdown content either way. */ + trigger: 'header' | 'inline-header' + disabled: boolean + onPickType: (type: ColumnDefinition['type']) => void + onPickWorkflow: () => void +} + +/** + * "+ New column" dropdown — the single entry point for creating a column. + * Lists every column type plus "Workflow"; picking a type opens the right + * sidebar pre-seeded. + */ +export function NewColumnDropdown({ + trigger, + disabled, + onPickType, + onPickWorkflow, +}: NewColumnDropdownProps) { + const menu = ( + + + {trigger === 'header' ? ( + + ) : ( + + )} + + + {COLUMN_TYPE_OPTIONS.map((option) => { + const Icon = option.icon + const onSelect = + option.type === 'workflow' + ? onPickWorkflow + : () => onPickType(option.type as ColumnDefinition['type']) + return ( + + + {option.label} + + ) + })} + + + ) + + // The in-table trigger lives inside a `
`. The + // header trigger lives in the page header so it sits inline. + return trigger === 'inline-header' ? {menu}
{name} - {onRunGroup && ( + {onRunColumn && (
` writes and let `handleScrollDragOver` + // do the bookkeeping. The scroll handler computes side from the group's + // full bounds, so it stays stable across sibling cursor moves; the per-th + // events would otherwise oscillate name + side as the cursor crosses each + // sibling's midpoint. + if (targetGid) return + if (columnName === dropTargetColumnNameRef.current && side === dropSideRef.current) return setDropTargetColumnName(columnName) setDropSide(side) @@ -931,7 +1069,15 @@ export function Table({ const side = dropSideRef.current if (target && dragged !== target) { const schemaCols = schemaColumnsRef.current - const currentOrder = columnOrderRef.current ?? schemaCols.map((c) => c.name) + // `columnOrder` is the user-edited persisted order. Tables created + // before the server kept it in sync with `addColumn` may have entries + // missing — append any unknown schema names so the dragged column is + // always indexable. The next reorder write persists the reconciled + // list, healing the table going forward. + const persisted = columnOrderRef.current ?? schemaCols.map((c) => c.name) + const known = new Set(persisted) + const missing = schemaCols.map((c) => c.name).filter((n) => !known.has(n)) + const currentOrder = missing.length > 0 ? [...persisted, ...missing] : persisted // Group-aware reorder: a workflow group's outputs must stay contiguous in // the persisted column order (`workflow-columns.ts` validates this on @@ -1055,7 +1201,7 @@ export function Table({ const cols = columnsRef.current const draggedGid = cols.find((c) => c.name === dragColumnNameRef.current)?.workflowGroupId - let left = CHECKBOX_COL_WIDTH + let left = checkboxColWidth let i = 0 while (i < cols.length) { const col = cols[i] @@ -1115,7 +1261,7 @@ export function Table({ }, [tableData?.id]) useEffect(() => { - if (!tableData?.metadata || metadataSeededRef.current) return + if (!tableData?.metadata) return if (!tableData.metadata.columnWidths && !tableData.metadata.columnOrder) return // First load: seed both from the server and remember we've seeded. if (!metadataSeededRef.current) { @@ -1165,6 +1311,87 @@ export function Table({ return () => document.removeEventListener('mouseup', handleMouseUp) }, []) + /** + * Auto-scroll the table while a cell-drag selection is in progress and the + * cursor enters a "hot zone" near the top or bottom of the scroll + * container. Scroll velocity ramps with proximity to the edge (max ~14px / + * frame at the very edge). The horizontal axis is intentionally left out: + * the fixed sticky checkbox column makes left-edge hot zones awkward and + * the table is rarely wider than the viewport in practice. + */ + useEffect(() => { + const HOT_ZONE_PX = 48 + const MAX_VELOCITY_PX = 14 + let pointerX: number | null = null + let pointerY: number | null = null + let rafId: number | null = null + + /** + * After auto-scroll moves the table under the cursor, no `mouseenter` + * fires on newly-revealed cells, so the selection focus would stay stuck + * on whatever cell was under the cursor when the cursor stopped moving. + * Manually re-pick the cell under the (unchanged) cursor coords and feed + * its row/col into the selection so the highlight expands as we scroll. + */ + const updateFocusUnderCursor = () => { + if (pointerX === null || pointerY === null) return + const target = document.elementFromPoint(pointerX, pointerY) + if (!target) return + const td = (target as HTMLElement).closest('td[data-row][data-col]') as HTMLElement | null + if (!td) return + const rowIndex = Number.parseInt(td.getAttribute('data-row') ?? '', 10) + const colIndex = Number.parseInt(td.getAttribute('data-col') ?? '', 10) + if (Number.isNaN(rowIndex) || Number.isNaN(colIndex)) return + setSelectionFocus({ rowIndex, colIndex }) + } + + const tick = () => { + rafId = null + const el = scrollRef.current + if (!isDraggingRef.current || !el || pointerY === null) return + const rect = el.getBoundingClientRect() + const distFromTop = pointerY - rect.top + const distFromBottom = rect.bottom - pointerY + let dy = 0 + if (distFromTop < HOT_ZONE_PX) { + const intensity = 1 - Math.max(0, distFromTop) / HOT_ZONE_PX + dy = -Math.ceil(intensity * MAX_VELOCITY_PX) + } else if (distFromBottom < HOT_ZONE_PX) { + const intensity = 1 - Math.max(0, distFromBottom) / HOT_ZONE_PX + dy = Math.ceil(intensity * MAX_VELOCITY_PX) + } + if (dy !== 0) { + el.scrollTop += dy + updateFocusUnderCursor() + rafId = requestAnimationFrame(tick) + } + } + + const handleMove = (e: MouseEvent) => { + if (!isDraggingRef.current) return + pointerX = e.clientX + pointerY = e.clientY + if (rafId === null) rafId = requestAnimationFrame(tick) + } + + const handleStop = () => { + pointerX = null + pointerY = null + if (rafId !== null) { + cancelAnimationFrame(rafId) + rafId = null + } + } + + document.addEventListener('mousemove', handleMove) + document.addEventListener('mouseup', handleStop) + return () => { + document.removeEventListener('mousemove', handleMove) + document.removeEventListener('mouseup', handleStop) + handleStop() + } + }, []) + useEffect(() => { anchorRowIdRef.current = selectionAnchor ? (rowsRef.current[selectionAnchor.rowIndex]?.id ?? null) @@ -1270,6 +1497,19 @@ export function Table({ const rowArrayIndex = rowsRef.current.findIndex((r) => r.id === rowId) const row = rowArrayIndex !== -1 ? rowsRef.current[rowArrayIndex] : null + + // Workflow-output cell with no value (status pill showing) → enter edit + // mode with a blank input so the user can write a value over the status. + // Escape cancels without persisting. + if (column?.workflowGroupId && row && canEditRef.current) { + const cellValue = row.data[columnName] + if (cellValue === null || cellValue === undefined || cellValue === '') { + setEditingCell({ rowId, columnName }) + setInitialCharacter('') + return + } + } + const colIndex = columnsRef.current.findIndex((c) => c.key === columnKey) let overflows = true if (row && colIndex !== -1) { @@ -1497,7 +1737,7 @@ export function Table({ e.preventDefault() const row = currentRows[anchor.rowIndex] if (row) { - setEditingRow(row) + onOpenRowModalRef.current(row) } return } @@ -1992,25 +2232,10 @@ export function Table({ scrollRef.current?.focus({ preventScroll: true }) }, []) - const generateColumnName = useCallback(() => { - const existing = schemaColumnsRef.current.map((c) => c.name.toLowerCase()) - let name = 'untitled' - let i = 2 - while (existing.includes(name.toLowerCase())) { - name = `untitled_${i}` - i++ - } - return name - }, []) - - const handleAddColumn = useCallback(() => { - // Open the sidebar in `'create'` mode — nothing is persisted until the - // user fills in name/type and hits Save. The sidebar's save flow handles - // both scalar (`addColumn`) and workflow-group (`addWorkflowGroup`) paths. - const name = generateColumnName() - setExecutionDetailsId(null) - setConfigState({ mode: 'create', columnName: name, proposedName: name }) - }, [generateColumnName]) + const generateColumnName = useCallback( + () => sharedGenerateColumnName(schemaColumnsRef.current), + [] + ) const handleChangeType = useCallback((columnName: string, newType: ColumnDefinition['type']) => { const column = columnsRef.current.find((c) => c.name === columnName) @@ -2090,33 +2315,40 @@ export function Table({ ) /** - * Config state for the side panel: - * - `null` → closed. - * - `{ mode: 'edit' }` → configuring an existing column (any type). - * - `{ mode: 'new' }` → user changed an existing column to workflow; not persisted until Save. - * - `{ mode: 'create' }` → user picked a workflow from "Add column"; column doesn't exist yet, - * created on Save in a single POST. + * Open the column-config sidebar pre-seeded with the chosen scalar type. + * Nothing is persisted until the user fills in the name and hits Save. */ - const [configState, setConfigState] = useState(null) - /** Execution id whose run details are open in the slideout. */ - const [executionDetailsId, setExecutionDetailsId] = useState(null) - /** - * Right padding added to the table's scroll content while a slideout panel - * is open, equal to the panel's width. Without it, the rightmost columns are - * clipped under the panel and there's no way to scroll them into view. - * The two panels are mutually exclusive (each opener closes the other). - */ - const logPanelWidth = useLogDetailsUIStore((state) => state.panelWidth) - const sidebarReservedWidth = configState - ? COLUMN_SIDEBAR_WIDTH_CSS - : executionDetailsId - ? `${logPanelWidth}px` - : '0px' + const handleAddColumnOfType = useCallback( + (type: ColumnDefinition['type']) => { + onOpenColumnConfig({ mode: 'create', proposedName: generateColumnName(), type }) + }, + [generateColumnName, onOpenColumnConfig] + ) - const handleConfigureColumn = useCallback((columnName: string) => { - setExecutionDetailsId(null) - setConfigState({ mode: 'edit', columnName }) - }, []) + /** Open the workflow-config sidebar to spawn a brand-new workflow group. */ + const handleAddWorkflowColumn = useCallback(() => { + onOpenWorkflowConfig({ mode: 'create', proposedName: generateColumnName() }) + }, [generateColumnName, onOpenWorkflowConfig]) + + const handleConfigureColumn = useCallback( + (columnName: string) => { + const column = columnsRef.current.find((c) => c.name === columnName) + if (column?.workflowGroupId) { + // Workflow-output column header → single-output sub-mode. + onOpenWorkflowConfig({ mode: 'edit-output', columnName }) + } else { + onOpenColumnConfig({ mode: 'edit', columnName }) + } + }, + [onOpenColumnConfig, onOpenWorkflowConfig] + ) + + const handleConfigureWorkflowGroup = useCallback( + (groupId: string) => { + onOpenWorkflowConfig({ mode: 'edit-group', groupId }) + }, + [onOpenWorkflowConfig] + ) const handleDeleteWorkflowGroup = useCallback( (groupId: string) => { @@ -2194,15 +2426,17 @@ export function Table({ // group with ≥1 output, hide them directly — no destructive-confirm // modal, since the workflow can re-produce the value any time. if (hideWorkflowOutputColumns(names)) return - setDeletingColumns(names) + onRequestDeleteColumns(names) }, - [resolveDeletionNames, hideWorkflowOutputColumns] + [resolveDeletionNames, hideWorkflowOutputColumns, onRequestDeleteColumns] ) - const handleDeleteColumnConfirm = useCallback(() => { - if (!deletingColumns || deletingColumns.length === 0) return - const columnsToDelete = [...deletingColumns] - setDeletingColumns(null) + // Populated as a sink so the wrapper's delete-columns modal can run the + // full cascade (per-column mutation + undo + columnOrder/columnWidths + // cleanup) without lifting any of that grid-internal state. + confirmDeleteColumnsSinkRef.current = (names: string[]) => { + if (!names || names.length === 0) return + const columnsToDelete = [...names] let currentOrder = columnOrderRef.current ? [...columnOrderRef.current] : null const cols = schemaColumnsRef.current @@ -2294,190 +2528,62 @@ export function Table({ setSelectionFocus(null) setIsColumnSelection(false) deleteNext(0) - }, [deletingColumns]) + } - const handleSortChange = useCallback((column: string, direction: SortDirection) => { - setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })) - }, []) - - const handleSortClear = useCallback(() => { - setQueryOptions((prev) => ({ ...prev, sort: null })) - }, []) - - const handleFilterApply = useCallback((filter: Filter | null) => { - setQueryOptions((prev) => ({ ...prev, filter })) - }, []) - - const [filterOpen, setFilterOpen] = useState(false) - - const handleFilterToggle = useCallback(() => { - setFilterOpen((prev) => !prev) - }, []) - - const handleFilterClose = useCallback(() => { - setFilterOpen(false) - }, []) - - const columnOptions = useMemo( - () => - displayColumns.map((col) => ({ - id: col.name, - label: col.name, - type: col.type, - icon: COLUMN_TYPE_ICONS[col.type], - })), - [displayColumns] - ) - - const tableDataRef = useRef(tableData) - tableDataRef.current = tableData - - const handleStartTableRename = useCallback(() => { - const data = tableDataRef.current - if (data) tableHeaderRename.startRename(tableId, data.name) - }, [tableHeaderRename.startRename, tableId]) - - const handleShowDeleteTableConfirm = useCallback(() => { - setShowDeleteTableConfirm(true) - }, []) - - const hasTableData = !!tableData - - const breadcrumbs = useMemo( - () => [ - { label: 'Tables', onClick: handleNavigateBack }, - { - label: tableData?.name ?? '', - editing: tableHeaderRename.editingId - ? { - isEditing: true, - value: tableHeaderRename.editValue, - onChange: tableHeaderRename.setEditValue, - onSubmit: tableHeaderRename.submitRename, - onCancel: tableHeaderRename.cancelRename, - } - : undefined, - dropdownItems: [ - { - label: 'Rename', - icon: Pencil, - disabled: !hasTableData, - onClick: handleStartTableRename, - }, - { - label: 'Delete', - icon: Trash, - disabled: !hasTableData, - onClick: handleShowDeleteTableConfirm, - }, - ], - }, - ], - [ - handleNavigateBack, - tableData?.name, - tableHeaderRename.editingId, - tableHeaderRename.editValue, - tableHeaderRename.setEditValue, - tableHeaderRename.submitRename, - tableHeaderRename.cancelRename, - hasTableData, - handleStartTableRename, - handleShowDeleteTableConfirm, - ] - ) - - const createTrigger = useMemo( - () => - userPermissions.canEdit ? ( - - ) : null, - [handleAddColumn, addColumnMutation.isPending, userPermissions.canEdit] - ) - - const handleExportCsv = useCallback(async () => { - if (!tableData) return - try { - await downloadTableExport(tableData.id, tableData.name) - } catch (err) { - logger.error('Failed to export table:', err) - toast.error('Failed to export table') - } - }, [tableData]) - - const headerActions = useMemo( - () => - tableData - ? [ - { - label: 'Import CSV', - icon: Upload, - onClick: () => setIsImportCsvOpen(true), - disabled: userPermissions.canEdit !== true, - }, - { - label: 'Export CSV', - icon: Download, - onClick: () => void handleExportCsv(), - disabled: tableData.rowCount === 0, - }, - ] - : undefined, - [tableData, userPermissions.canEdit, handleExportCsv] - ) - - const activeSortState = useMemo(() => { - if (!queryOptions.sort) return null - const entries = Object.entries(queryOptions.sort) - if (entries.length === 0) return null - const [column, direction] = entries[0] - return { column, direction } - }, [queryOptions.sort]) - - const sortConfig = useMemo( - () => ({ - options: columnOptions, - active: activeSortState, - onSort: handleSortChange, - onClear: handleSortClear, - }), - [columnOptions, activeSortState, handleSortChange, handleSortClear] - ) - - const selectedRowCount = useMemo(() => { - const contextRow = contextMenu.isOpen ? contextMenu.row : null - if (!contextRow) return 1 - - if (rowSelection.kind === 'all') { - return rows.some((r) => r.id === contextRow.id) ? Math.max(rows.length, 1) : 1 - } - - if (rowSelection.kind === 'some' && rowSelection.ids.has(contextRow.id)) { - let count = 0 + /** + * Row ids the context menu acts on. If the right-clicked row is part of the + * gutter row selection, the materialized selection; if it's inside the active + * range selection, the range; otherwise just the row itself. Used by both the + * count label and the multi-row "Run workflows" action. + */ + const contextMenuRowIds = useMemo(() => { + if (!contextMenu.isOpen || !contextMenu.row) return [] + if ( + !rowSelectionIsEmpty(rowSelection) && + rowSelectionIncludes(rowSelection, contextMenu.row.id) + ) { + const ids: string[] = [] for (const row of rows) { - if (rowSelection.ids.has(row.id)) count++ + if (rowSelectionIncludes(rowSelection, row.id)) ids.push(row.id) } - return Math.max(count, 1) + return ids.length > 0 ? ids : [contextMenu.row.id] } - const sel = normalizedSelection - if (!sel) return 1 - - const contextRowArrayIndex = rows.findIndex((r) => r.id === contextRow.id) - if (contextRowArrayIndex < sel.startRow || contextRowArrayIndex > sel.endRow) return 1 - - const start = Math.max(0, sel.startRow) - const end = Math.min(rows.length - 1, sel.endRow) - return Math.max(end - start + 1, 1) + if (sel) { + const contextRowArrayIndex = rows.findIndex((r) => r.id === contextMenu.row!.id) + const isInSelection = + contextRowArrayIndex >= sel.startRow && contextRowArrayIndex <= sel.endRow + if (isInSelection) { + const ids: string[] = [] + const start = Math.max(0, sel.startRow) + const end = Math.min(rows.length - 1, sel.endRow) + for (let r = start; r <= end; r++) { + const row = rows[r] + if (row) ids.push(row.id) + } + return ids.length > 0 ? ids : [contextMenu.row.id] + } + } + return [contextMenu.row.id] }, [contextMenu.isOpen, contextMenu.row, rowSelection, normalizedSelection, rows]) + const selectedRowCount = contextMenuRowIds.length || 1 + const pendingUpdate = updateRowMutation.isPending ? updateRowMutation.variables : null - const workflowColumnNames = useMemo( - () => columns.filter((c) => !!c.workflowGroupId).map((c) => c.name), - [columns] - ) - const hasWorkflowColumns = workflowColumnNames.length > 0 + /** + * Row ids for the current multi-row selection. Drives "Run N selected rows" + * in the workflow-group run menu — `null` when there's no multi-selection so + * the menu collapses to "Run all rows". + */ + const selectedRowIds = useMemo(() => { + if (rowSelectionIsEmpty(rowSelection)) return null + const ids: string[] = [] + for (const row of rows) { + if (rowSelectionIncludes(rowSelection, row.id)) ids.push(row.id) + } + return ids.length > 0 ? ids : null + }, [rowSelection, rows]) const { runningByRowId, totalRunning } = useMemo(() => { const byRow = new Map() @@ -2486,7 +2592,7 @@ export function Table({ let count = 0 const executions = row.executions ?? {} for (const gid in executions) { - if (executions[gid]?.status === 'running') count++ + if (isExecInFlight(executions[gid])) count++ } if (count > 0) { byRow.set(row.id, count) @@ -2496,42 +2602,216 @@ export function Table({ return { runningByRowId: byRow, totalRunning: total } }, [rows]) - const cancelRunsMutate = cancelRunsMutation.mutate + // Context-menu wrappers: act on `contextMenuRowIds`, then close the menu. + // Mirror the action bar's Play / Refresh split: Play fills empty/failed, + // Refresh re-runs everything (including completed cells). + const handleRunWorkflowsOnSelection = () => { + onRunRows(contextMenuRowIds, 'incomplete') + closeContextMenu() + } + const handleRefreshWorkflowsOnSelection = () => { + onRunRows(contextMenuRowIds, 'all') + closeContextMenu() + } + const handleStopWorkflowsOnSelection = () => { + onStopRows(contextMenuRowIds) + closeContextMenu() + } - const handleStopAll = useCallback(() => { - if (totalRunning === 0) return - cancelRunsMutate({ scope: 'all' }) - }, [totalRunning, cancelRunsMutate]) - - const handleStopRow = useCallback( - (rowId: string) => { - cancelRunsMutate({ scope: 'row', rowId }) - }, - [cancelRunsMutate] + // Total running/queued cells across the rows the context menu is acting on; + // drives the "Stop N running workflows" item, shown only when > 0. + const runningInContextSelection = contextMenuRowIds.reduce( + (total, rowId) => total + (runningByRowId.get(rowId) ?? 0), + 0 ) + // Action-bar selection covers both gutter row-selection AND multi-row + // range selection (clicking + dragging across rows), matching how the + // right-click context menu treats them. Single-row range doesn't trigger + // the bar — only multi-row, since the per-row gutter button already covers + // that case. Gutter selection wins when both exist. + const actionBarRowIds = useMemo(() => { + if (!rowSelectionIsEmpty(rowSelection)) { + const ids: string[] = [] + for (const row of rows) { + if (rowSelectionIncludes(rowSelection, row.id)) ids.push(row.id) + } + return ids + } + const sel = normalizedSelection + if (sel && sel.endRow > sel.startRow) { + const ids: string[] = [] + const start = Math.max(0, sel.startRow) + const end = Math.min(rows.length - 1, sel.endRow) + for (let r = start; r <= end; r++) { + const row = rows[r] + if (row) ids.push(row.id) + } + return ids + } + return [] + }, [rowSelection, normalizedSelection, rows]) + const runningInActionBarSelection = actionBarRowIds.reduce( + (total, rowId) => total + (runningByRowId.get(rowId) ?? 0), + 0 + ) + + /** + * Selection that resolves to exactly one workflow-group execution — same + * row, every highlighted column belonging to the same workflow group. Drives + * the action bar's per-execution mode (View execution / Run cell / Stop + * cell). Includes the single-cell case (1×1) and the "highlight a row's + * workflow outputs" case (1 row × N cols, all in one group). Null for + * multi-row selections, plain columns, or no selection. + */ + const singleWorkflowCell = useMemo(() => { + const sel = normalizedSelection + if (!sel) return null + if (sel.startRow !== sel.endRow) return null + const row = rows[sel.startRow] + if (!row) return null + const firstCol = displayColumns[sel.startCol] + const groupId = firstCol?.workflowGroupId + if (!groupId) return null + // All columns in the highlight must be in the same workflow group, else + // we'd be straddling two executions. + for (let c = sel.startCol + 1; c <= sel.endCol; c++) { + if (displayColumns[c]?.workflowGroupId !== groupId) return null + } + const exec = row.executions?.[groupId] + const status = exec?.status + return { + rowId: row.id, + groupId, + executionId: exec?.executionId ?? null, + canViewExecution: status === 'completed' || status === 'error' || status === 'running', + } + }, [normalizedSelection, rows, displayColumns]) + + const tableWorkflowGroupIds = useMemo( + () => tableWorkflowGroups.map((g) => g.id), + [tableWorkflowGroups] + ) + + // Drives Run vs Refresh visibility on the context menu — same classifier + // the action bar uses, so both surfaces stay in sync. + const contextMenuStats = useMemo( + () => classifyExecStatusMix(rows, new Set(contextMenuRowIds), tableWorkflowGroupIds), + [contextMenuRowIds, rows, tableWorkflowGroupIds] + ) + + // Run scope is derived from one of two selection sources: + // - rowSelection (gutter whole-row selection) → those rows × every workflow group + // - normalizedSelection rectangle covering workflow-output columns → + // rows in the rectangle × distinct workflow groups inside it + const selectedRunScope = useMemo(() => { + if (tableWorkflowGroupIds.length === 0) return null + if (!rowSelectionIsEmpty(rowSelection)) { + const rowIds: string[] = [] + for (const row of rows) { + if (rowSelectionIncludes(rowSelection, row.id)) rowIds.push(row.id) + } + if (rowIds.length === 0) return null + return { groupIds: tableWorkflowGroupIds, rowIds } + } + const sel = normalizedSelection + if (!sel) return null + const groupIdsInRect = new Set() + for (let c = Math.max(0, sel.startCol); c <= sel.endCol; c++) { + const gid = displayColumns[c]?.workflowGroupId + if (gid) groupIdsInRect.add(gid) + } + if (groupIdsInRect.size === 0) return null + const rowIds: string[] = [] + const startRow = Math.max(0, sel.startRow) + const endRow = Math.min(rows.length - 1, sel.endRow) + for (let r = startRow; r <= endRow; r++) { + const row = rows[r] + if (row) rowIds.push(row.id) + } + if (rowIds.length === 0) return null + return { groupIds: [...groupIdsInRect], rowIds } + }, [rowSelection, normalizedSelection, rows, displayColumns, tableWorkflowGroupIds]) + + const selectionStats = useMemo(() => { + if (!selectedRunScope) { + return { hasIncompleteOrFailed: false, hasCompleted: false, hasInFlight: false } + } + return classifyExecStatusMix(rows, new Set(selectedRunScope.rowIds), selectedRunScope.groupIds) + }, [selectedRunScope, rows]) + + // Emit selection snapshots so the wrapper can render . + // The grid can't fold this into individual event handlers (running counts + // come from React Query refetches, not user events) so it's intentionally + // an effect — but we content-compare against the last sent snapshot so a + // re-render where nothing actually changed doesn't churn the wrapper. + const onSelectionChangeRef = useRef(onSelectionChange) + onSelectionChangeRef.current = onSelectionChange + const lastSelectionSnapshotRef = useRef(null) + useEffect(() => { + const prev = lastSelectionSnapshotRef.current + const sameSingleCell = + (prev?.singleWorkflowCell ?? null) === null && singleWorkflowCell === null + ? true + : prev?.singleWorkflowCell && + singleWorkflowCell && + prev.singleWorkflowCell.rowId === singleWorkflowCell.rowId && + prev.singleWorkflowCell.groupId === singleWorkflowCell.groupId && + prev.singleWorkflowCell.executionId === singleWorkflowCell.executionId && + prev.singleWorkflowCell.canViewExecution === singleWorkflowCell.canViewExecution + const sameRunScope = + (prev?.selectedRunScope ?? null) === null && selectedRunScope === null + ? true + : prev?.selectedRunScope && + selectedRunScope && + prev.selectedRunScope.groupIds.length === selectedRunScope.groupIds.length && + prev.selectedRunScope.rowIds.length === selectedRunScope.rowIds.length && + prev.selectedRunScope.groupIds.every((id, i) => id === selectedRunScope.groupIds[i]) && + prev.selectedRunScope.rowIds.every((id, i) => id === selectedRunScope.rowIds[i]) + const sameStats = + prev?.selectionStats && + prev.selectionStats.hasIncompleteOrFailed === selectionStats.hasIncompleteOrFailed && + prev.selectionStats.hasCompleted === selectionStats.hasCompleted && + prev.selectionStats.hasInFlight === selectionStats.hasInFlight + if ( + prev && + sameSingleCell && + sameRunScope && + sameStats && + prev.runningInActionBarSelection === runningInActionBarSelection && + prev.totalRunning === totalRunning && + prev.hasWorkflowColumns === hasWorkflowColumns && + prev.actionBarRowIds.length === actionBarRowIds.length && + prev.actionBarRowIds.every((id, i) => id === actionBarRowIds[i]) + ) { + return + } + const next: SelectionSnapshot = { + actionBarRowIds, + runningInActionBarSelection, + totalRunning, + hasWorkflowColumns, + selectedRunScope, + selectionStats, + singleWorkflowCell, + } + lastSelectionSnapshotRef.current = next + onSelectionChangeRef.current(next) + }, [ + actionBarRowIds, + runningInActionBarSelection, + totalRunning, + hasWorkflowColumns, + selectedRunScope, + selectionStats, + singleWorkflowCell, + ]) + const handleRunRow = useCallback( (rowId: string) => { - if (tableWorkflowGroups.length === 0) return - const target = rowsRef.current.find((r) => r.id === rowId) - if (!target) return - // Only fire groups whose deps are already satisfied for THIS row. The - // cascade picks up downstream groups: when an upstream group completes, - // `scheduleWorkflowGroupRuns` evaluates eligibility and enqueues the - // newly-ready successors automatically. - for (const group of tableWorkflowGroups) { - if (!areRowDepsSatisfied(group, target)) continue - void runWorkflowGroup({ - tableId, - rowId, - workspaceId, - groupId: group.id, - workflowId: group.workflowId, - outputColumnNames: group.outputs.map((o) => o.columnName), - }) - } + onRunRow(rowId) }, - [runWorkflowGroup, tableId, workspaceId, tableWorkflowGroups] + [onRunRow] ) if (!isLoadingTable && !tableData) { @@ -2550,46 +2830,12 @@ export function Table({ return (
- {!embedded && ( - <> - 0 ? ( - - ) : null - } - /> - - - {filterOpen && ( - - )} - - )} - {embedded && totalRunning > 0 && (
)} @@ -2609,8 +2855,8 @@ export function Table({
{isLoadingTable ? ( - + {Array.from({ length: SKELETON_COL_COUNT }).map((_, i) => ( ))} ) : ( - + )} {isLoadingTable ? ( @@ -2655,7 +2905,7 @@ export function Table({ <> {hasWorkflowGroup && ( - @@ -2787,9 +3053,10 @@ export function Table({ onRowToggle={handleRowToggle} runningCount={runningByRowId.get(row.id) ?? 0} hasWorkflowColumns={hasWorkflowColumns} - onStopRow={handleStopRow} + isLargeRowCountTable={isLargeRowCountTable} + onStopRow={onStopRow} onRunRow={handleRunRow} - workflowNameById={workflowNameById} + workflowGroups={tableWorkflowGroups} /> ))} @@ -2822,55 +3089,8 @@ export function Table({ )} - - setConfigState(null)} - existingColumn={ - configState?.mode === 'edit' - ? (columns.find((c) => c.name === configState.columnName) ?? null) - : null - } - allColumns={columns} - workflowGroups={tableWorkflowGroups} - workflows={workflows} - workspaceId={workspaceId} - tableId={tableId} - /> - - setExecutionDetailsId(null)} - /> - {editingRow && tableData && ( - setEditingRow(null)} - table={tableData} - row={editingRow} - onSuccess={() => setEditingRow(null)} - /> - )} - - {deletingRows.length > 0 && tableData && ( - setDeletingRows([])} - table={tableData} - rowIds={deletingRows.map((r) => r.rowId)} - onSuccess={() => { - pushUndo({ type: 'delete-rows', rows: deletingRows }) - setDeletingRows([]) - handleClearSelection() - }} - /> - )} - - - {!embedded && ( - - - Delete Table - -

- Are you sure you want to delete{' '} - {tableData?.name}?{' '} - - All {tableData?.rowCount ?? 0} rows will be removed. - {' '} - You can restore it from Recently Deleted in Settings. -

-
- - - - -
-
- )} - - {tableData && ( - - )} - - { - if (!open) setDeletingColumns(null) - }} - > - - - {deletingColumns && deletingColumns.length > 1 - ? `Delete ${deletingColumns.length} Columns` - : 'Delete Column'} - - -

- {deletingColumns && deletingColumns.length > 1 ? ( - <> - Are you sure you want to delete{' '} - - {deletingColumns.length} columns - - ?{' '} - - ) : ( - <> - Are you sure you want to delete{' '} - - {deletingColumns?.[0]} - - ?{' '} - - )} - - This will remove all data in{' '} - {deletingColumns && deletingColumns.length > 1 ? 'these columns' : 'this column'}. - {' '} - You can undo this action. -

-
- - - - -
-
) } @@ -2996,13 +3139,15 @@ export function Table({ const TableColGroup = React.memo(function TableColGroup({ columns, columnWidths, + checkboxColWidth, }: { columns: DisplayColumn[] columnWidths: Record + checkboxColWidth: number }) { return (
- + {columns.map((col) => ( ))} @@ -3033,10 +3178,15 @@ interface DataRowProps { runningCount: number /** Whether the table has at least one workflow column — controls whether a run/stop icon is rendered. */ hasWorkflowColumns: boolean + /** True for tables sized for >9,999 rows; widens the row-number slot to fit 5–7 digit numbers. */ + isLargeRowCountTable: boolean onStopRow: (rowId: string) => void onRunRow: (rowId: string) => void - /** Lookup from workflow id → human-readable name, used to label running cells. */ - workflowNameById: Record + /** + * The table's workflow groups, used to compute per-row "Waiting on …" labels + * for empty workflow-output cells whose group has unmet dependencies. + */ + workflowGroups: WorkflowGroup[] } function cellRangeRowChanged( @@ -3089,9 +3239,10 @@ function dataRowPropsAreEqual(prev: DataRowProps, next: DataRowProps): boolean { prev.onRowToggle !== next.onRowToggle || prev.runningCount !== next.runningCount || prev.hasWorkflowColumns !== next.hasWorkflowColumns || + prev.isLargeRowCountTable !== next.isLargeRowCountTable || prev.onStopRow !== next.onStopRow || prev.onRunRow !== next.onRunRow || - prev.workflowNameById !== next.workflowNameById + prev.workflowGroups !== next.workflowGroups ) { return false } @@ -3130,28 +3281,52 @@ const DataRow = React.memo(function DataRow({ onRowToggle, runningCount, hasWorkflowColumns, + isLargeRowCountTable, onStopRow, onRunRow, - workflowNameById, + workflowGroups, }: DataRowProps) { const sel = normalizedSelection + /** + * Per-row "Waiting on …" labels keyed by group id. A group has labels iff + * at least one of its dependencies is unmet for this row — drives the + * "Waiting" pill rendered by `CellContent` for empty workflow-output cells. + * Computed once per render rather than per cell so all cells in a group + * share the same array reference. + */ + const waitingByGroupId = React.useMemo(() => { + if (workflowGroups.length === 0) return null + const map = new Map() + for (const group of workflowGroups) { + // autoRun=false groups never fire from the scheduler — there's nothing + // to wait on. The cell stays empty until the user clicks Run manually. + if (group.autoRun === false) continue + const unmet = getUnmetGroupDeps(group, row) + if (unmet.columns.length === 0) continue + map.set(group.id, unmet.columns) + } + return map + }, [workflowGroups, row]) const isMultiCell = sel !== null && (sel.startRow !== sel.endRow || sel.startCol !== sel.endCol) const isRowSelected = isRowChecked return ( onContextMenu(e, row)}> - @@ -3256,7 +3433,11 @@ const DataRow = React.memo(function DataRow({ initialCharacter={isEditing ? initialCharacter : undefined} onSave={(value, reason) => onSave(row.id, column.name, value, reason)} onCancel={onCancel} - workflowNameById={workflowNameById} + waitingOnLabels={ + column.workflowGroupId + ? (waitingByGroupId?.get(column.workflowGroupId) ?? undefined) + : undefined + } /> @@ -3298,41 +3479,6 @@ const TableBodySkeleton = React.memo(function TableBodySkeleton({ ) }) -interface RunStatusControlProps { - running: number - onStopAll: () => void - isStopping: boolean -} - -/** - * Run-status + Stop-all control rendered in the header's trailing actions row. - * Matches the in-cell running indicator (`Loader` + tertiary text) for consistency. - */ -const RunStatusControl = React.memo(function RunStatusControl({ - running, - onStopAll, - isStopping, -}: RunStatusControlProps) { - return ( -
-
- - {running} - running -
- -
- ) -}) - const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ checked, onCheckedChange, @@ -3363,44 +3509,6 @@ const SelectAllCheckbox = React.memo(function SelectAllCheckbox({ ) }) -const AddColumnButton = React.memo(function AddColumnButton({ - onClick, - disabled, -}: { - onClick: () => void - disabled: boolean -}) { - return ( - - ) -}) - -const HEADER_ADD_COLUMN_ICON = - -function HeaderAddColumnTrigger({ onClick, disabled }: { onClick: () => void; disabled: boolean }) { - return ( - - ) -} - const AddRowButton = React.memo(function AddRowButton({ onClick }: { onClick: () => void }) { return (
@@ -3415,21 +3523,3 @@ const AddRowButton = React.memo(function AddRowButton({ onClick }: { onClick: ()
) }) - -/** - * Reuses the logs page's `LogDetails` slideout inside the tables view so a user - * can inspect a workflow run for a cell without leaving the table. The query is - * keyed on `executionId` because that's what's stored on the cell. - */ -function ExecutionDetailsSidebar({ - workspaceId, - executionId, - onClose, -}: { - workspaceId: string - executionId: string | null - onClose: () => void -}) { - const { data: log } = useLogByExecutionId(workspaceId, executionId) - return -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/types.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts similarity index 100% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/types.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/types.ts diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts similarity index 76% rename from apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/utils.ts rename to apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts index 6721e6e60f..73fa8db9c4 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table-grid/utils.ts @@ -123,24 +123,51 @@ export function readExecution( return row?.executions?.[groupId] } +export interface ExecStatusMix { + hasIncompleteOrFailed: boolean + hasCompleted: boolean + hasInFlight: boolean +} + /** - * Client-side mirror of the scheduler's deps predicate. Used to filter the - * row-run button so we don't fire downstream groups whose upstream isn't - * `completed` yet — the cascade handles those once the upstream finishes. + * Walks `(rowIdSet × groupIds)` exec statuses on `rows` and reports which + * status buckets are present. Short-circuits once all three buckets are + * observed and once every selected row has been visited. Drives Play / + * Refresh / Stop visibility on the action bar and the context menu — both + * surfaces use the same shape so they stay in sync. */ -export function areRowDepsSatisfied( - group: WorkflowGroup, - row: { data: Record; executions?: RowExecutions } -): boolean { - const deps = group.dependencies ?? {} - for (const colName of deps.columns ?? []) { - const value = row.data[colName] - if (value === null || value === undefined || value === '') return false +export function classifyExecStatusMix( + rows: TableRowType[], + rowIdSet: ReadonlySet, + groupIds: readonly string[] +): ExecStatusMix { + const result: ExecStatusMix = { + hasIncompleteOrFailed: false, + hasCompleted: false, + hasInFlight: false, } - for (const gid of deps.workflowGroups ?? []) { - if (row.executions?.[gid]?.status !== 'completed') return false + if (rowIdSet.size === 0 || groupIds.length === 0) return result + const target = rowIdSet.size + let seen = 0 + for (const row of rows) { + if (!rowIdSet.has(row.id)) continue + seen++ + for (const groupId of groupIds) { + const status = readExecution(row, groupId)?.status + if (status === 'queued' || status === 'running' || status === 'pending') { + result.hasInFlight = true + } else if (status === 'completed') { + result.hasCompleted = true + } else { + result.hasIncompleteOrFailed = true + } + if (result.hasInFlight && result.hasCompleted && result.hasIncompleteOrFailed) { + return result + } + } + if (seen === target) break } - return true + return result } export function moveCell( diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx deleted file mode 100644 index 5224fd80a2..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/cells/cell-content.tsx +++ /dev/null @@ -1,173 +0,0 @@ -'use client' - -import type React from 'react' -import { Circle } from 'lucide-react' -import { Checkbox } from '@/components/emcn' -import { Loader } from '@/components/emcn/icons/loader' -import { cn } from '@/lib/core/utils/cn' -import type { RowExecutionMetadata } from '@/lib/table' -import type { SaveReason } from '../../../types' -import { storageToDisplay } from '../../../utils' -import type { DisplayColumn } from '../types' -import { InlineEditor } from './inline-editors' - -interface CellContentProps { - value: unknown - exec?: RowExecutionMetadata - column: DisplayColumn - isEditing: boolean - initialCharacter?: string | null - onSave: (value: unknown, reason: SaveReason) => void - onCancel: () => void - workflowNameById?: Record -} - -/** - * Renders the visible content of a single cell. Workflow-output cells follow - * a status-state-machine (block error / value / running / waiting / cancelled - * / dash); plain cells render the typed value. When `isEditing` is true the - * `InlineEditor` overlay sits on top of the static content. - */ -export function CellContent({ - value, - exec, - column, - isEditing, - initialCharacter, - onSave, - onCancel, -}: CellContentProps) { - const isNull = value === null || value === undefined - - let displayContent: React.ReactNode = null - if (column.workflowGroupId) { - const blockId = column.outputBlockId - const blockError = blockId ? exec?.blockErrors?.[blockId] : undefined - const blockRunning = blockId ? (exec?.runningBlockIds?.includes(blockId) ?? false) : false - const hasValue = !isNull - const valueText = - typeof value === 'string' - ? value - : value === null || value === undefined - ? '' - : JSON.stringify(value) - - // Once any block in the group has reported an error, downstream cells - // that haven't started won't run on this attempt — collapse them to dash - // instead of leaving a stale "Waiting" spinner if the cell task didn't - // reach a clean terminal state. - const groupHasBlockErrors = !!(exec?.blockErrors && Object.keys(exec.blockErrors).length > 0) - if (blockError) { - displayContent = ( - - Error - - ) - } else if (hasValue) { - displayContent = ( - - {valueText} - - ) - } else if ( - (exec?.status === 'running' || exec?.status === 'pending') && - !(groupHasBlockErrors && !blockRunning) - ) { - // Motion only when this cell's own block is in flight. Pending and - // upstream-blocked Waiting render as static dots — the moving spinner - // is reserved for "right now, actually running". - if (blockRunning) { - displayContent = ( -
- - - Running - -
- ) - } else { - const label = exec.status === 'pending' ? 'Pending' : 'Waiting' - displayContent = ( -
- - - {label} - -
- ) - } - } else if (exec?.status === 'cancelled') { - displayContent = ( - - Cancelled - - ) - } else { - displayContent = — - } - // Workflow-output cells are hand-editable: hide the status content under - // the InlineEditor when the user opts to edit, then fall through to the - // common return that renders the editor overlay. - if (isEditing) { - displayContent =
{displayContent}
- } - } else if (column.type === 'boolean') { - displayContent = ( -
- - - -
- ) - } else if (!isNull && column.type === 'json') { - displayContent = ( - - {JSON.stringify(value)} - - ) - } else if (!isNull && column.type === 'date') { - displayContent = ( - - {storageToDisplay(String(value))} - - ) - } else if (!isNull) { - displayContent = ( - - {String(value)} - - ) - } - - return ( - <> - {isEditing && ( -
- -
- )} - {displayContent} - - ) -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-type-icon.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-type-icon.tsx deleted file mode 100644 index f0fc0d08be..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/headers/column-type-icon.tsx +++ /dev/null @@ -1,59 +0,0 @@ -'use client' - -import type React from 'react' -import { - Calendar as CalendarIcon, - TypeBoolean, - TypeJson, - TypeNumber, - TypeText, -} from '@/components/emcn/icons' -import type { BlockIconInfo } from '../types' - -export const COLUMN_TYPE_ICONS: Record = { - string: TypeText, - number: TypeNumber, - boolean: TypeBoolean, - date: CalendarIcon, - json: TypeJson, -} - -interface ColumnTypeIconProps { - type: string - workflowColor?: string - blockIconInfo?: BlockIconInfo -} - -/** - * Tiny icon shown next to a column header. For workflow-output columns: - * the producing block's icon (when known) or a colored swatch tinted with - * the workflow's color. For plain columns: the type icon. - */ -export function ColumnTypeIcon({ type, workflowColor, blockIconInfo }: ColumnTypeIconProps) { - if (workflowColor || blockIconInfo) { - if (blockIconInfo) { - const BlockIcon = blockIconInfo.icon - return ( - - - - ) - } - const color = workflowColor ?? 'var(--text-muted)' - return ( - - ) - } - const Icon = COLUMN_TYPE_ICONS[type] ?? TypeText - return -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/index.ts deleted file mode 100644 index f75bc0849d..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/table/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { Table } from './table' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/index.ts new file mode 100644 index 0000000000..6d45862e28 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/index.ts @@ -0,0 +1 @@ +export { type WorkflowConfig, WorkflowSidebar } from './workflow-sidebar' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx new file mode 100644 index 0000000000..320eda4bd1 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/run-settings-section.tsx @@ -0,0 +1,48 @@ +'use client' + +import { Combobox, Label } from '@/components/emcn' +import type { ColumnDefinition } from '@/lib/table' + +interface RunSettingsSectionProps { + /** All columns the group can depend on (left-of-current scalar + workflow + * output columns alike). */ + depOptions: ColumnDefinition[] + /** Column names this group waits on. */ + deps: string[] + onChangeDeps: (next: string[]) => void +} + +/** + * "Run after" picker: which upstream columns must be filled before this group + * fires. Workflow output columns count the same as plain columns — once a + * column is non-empty, the dep is satisfied. Empty selection = the group fires + * on any row change. + */ +export function RunSettingsSection({ depOptions, deps, onChangeDeps }: RunSettingsSectionProps) { + const options = depOptions.map((c) => ({ label: c.name, value: c.name })) + + return ( +
+ + + {deps.length === 0 ? 'Any row change' : `${deps.length} selected`} + + } + /> +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx new file mode 100644 index 0000000000..a1a4a49087 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/components/workflow-sidebar/workflow-sidebar.tsx @@ -0,0 +1,895 @@ +'use client' + +import type React from 'react' +import { useMemo, useState } from 'react' +import { toError } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { useMutation, useQueryClient } from '@tanstack/react-query' +import { ExternalLink, RepeatIcon, SplitIcon, X } from 'lucide-react' +import { + Button, + Combobox, + type ComboboxOptionGroup, + FieldDivider, + Input, + Label, + Loader, + Switch, + Tooltip, + toast, +} from '@/components/emcn' +import { findValidationIssue, isValidationError } from '@/lib/api/client/errors' +import { requestJson } from '@/lib/api/client/request' +import type { + AddWorkflowGroupBodyInput, + UpdateWorkflowGroupBodyInput, +} from '@/lib/api/contracts/tables' +import { + putWorkflowNormalizedStateContract, + type WorkflowStateContractInput, +} from '@/lib/api/contracts/workflows' +import { cn } from '@/lib/core/utils/cn' +import type { + ColumnDefinition, + WorkflowGroup, + WorkflowGroupDependencies, + WorkflowGroupOutput, +} from '@/lib/table' +import { columnTypeForLeaf, deriveOutputColumnName } from '@/lib/table/column-naming' +import { + type FlattenOutputsBlockInput, + type FlattenOutputsEdgeInput, + flattenWorkflowOutputs, + getBlockExecutionOrder, +} from '@/lib/workflows/blocks/flatten-outputs' +import { normalizeInputFormatValue } from '@/lib/workflows/input-format' +import { TriggerUtils } from '@/lib/workflows/triggers/triggers' +import type { InputFormatField } from '@/lib/workflows/types' +import { PreviewWorkflow } from '@/app/workspace/[workspaceId]/w/components/preview' +import { getBlock } from '@/blocks' +import { + useAddWorkflowGroup, + useUpdateColumn, + useUpdateWorkflowGroup, +} from '@/hooks/queries/tables' +import { useWorkflowState, workflowKeys } from '@/hooks/queries/workflows' +import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +import { RunSettingsSection } from './run-settings-section' + +/** + * Discriminates the three flows the workflow sidebar handles: + * - `create`: brand-new workflow group spawned from the "+ New column" dropdown's "Workflow" item. + * - `edit-group`: opened from the workflow-group meta header. Lets the user edit the whole group + * (workflow id, deps, output set, group name). + * - `edit-output`: opened from a single workflow-output column header. Focuses on this column's + * `(blockId, path)` mapping + column rename. Other group-wide controls remain visible but + * secondary. + */ +export type WorkflowConfig = + | { mode: 'create'; proposedName: string } + | { mode: 'edit-group'; groupId: string } + | { mode: 'edit-output'; columnName: string } + +interface WorkflowSidebarProps { + config: WorkflowConfig | null + onClose: () => void + /** All scalar + workflow-output columns on the table. Drives the deps picker + * options and the "missing inputs" prompt. */ + allColumns: ColumnDefinition[] + workflowGroups: WorkflowGroup[] + workflows: WorkflowMetadata[] | undefined + workspaceId: string + tableId: string + /** Notify parent of a per-output-column rename so it can rewrite local + * `columnOrder` / `columnWidths` keys. */ + onColumnRename?: (oldName: string, newName: string) => void +} + +const OUTPUT_VALUE_SEPARATOR = '::' + +const encodeOutputValue = (blockId: string, path: string) => + `${blockId}${OUTPUT_VALUE_SEPARATOR}${path}` + +const decodeOutputValue = (value: string): { blockId: string; path: string } => { + const idx = value.indexOf(OUTPUT_VALUE_SEPARATOR) + if (idx === -1) return { blockId: '', path: value } + return { blockId: value.slice(0, idx), path: value.slice(idx + OUTPUT_VALUE_SEPARATOR.length) } +} + +interface BlockOutputGroup { + blockId: string + blockName: string + blockType: string + blockIcon: string | React.ComponentType<{ className?: string }> + blockColor: string + paths: string[] +} + +interface WorkflowStatePayload { + blocks: Record< + string, + { + type: string + subBlocks?: Record + } & Record + > + edges: unknown[] + loops: unknown + parallels: unknown + lastSaved?: number + isDeployed?: boolean +} + +function tableColumnTypeToInputType(colType: ColumnDefinition['type'] | undefined): string { + switch (colType) { + case 'number': + return 'number' + case 'boolean': + return 'boolean' + case 'json': + return 'object' + default: + return 'string' + } +} + +function RequiredLabel({ htmlFor, children }: { htmlFor?: string; children: React.ReactNode }) { + return ( + + ) +} + +function FieldError({ message }: { message: string }) { + return

{message}

+} + +const TagIcon: React.FC<{ + icon: string | React.ComponentType<{ className?: string }> + color: string +}> = ({ icon, color }) => ( +
+ {typeof icon === 'string' ? ( + {icon} + ) : ( + (() => { + const IconComponent = icon + return + })() + )} +
+) + +/** + * Right-edge sidebar for workflow group configuration. Three flows: + * - create a new group (workflow + outputs + deps), + * - edit an existing group (same fields, plus rename output-column option), + * - edit a single output column's mapping (swap which `(blockId, path)` it + * reads from, rename the column). + * + * All form state lives in ``, which the outer shell + * mounts with `key={configKey(config)}` so opening a different group/column + * remounts and re-seeds state from props (no `useEffect` mirror). + */ +export function WorkflowSidebar(props: WorkflowSidebarProps) { + const open = props.config !== null + return ( + + ) +} + +function configKey(config: WorkflowConfig): string { + switch (config.mode) { + case 'create': + return `create:${config.proposedName}` + case 'edit-group': + return `edit-group:${config.groupId}` + case 'edit-output': + return `edit-output:${config.columnName}` + } +} + +interface WorkflowSidebarBodyProps extends Omit { + config: WorkflowConfig +} + +function WorkflowSidebarBody({ + config, + onClose, + allColumns, + workflowGroups, + workflows, + workspaceId, + tableId, + onColumnRename, +}: WorkflowSidebarBodyProps) { + const updateColumn = useUpdateColumn({ workspaceId, tableId }) + const addWorkflowGroup = useAddWorkflowGroup({ workspaceId, tableId }) + const updateWorkflowGroup = useUpdateWorkflowGroup({ workspaceId, tableId }) + + // Resolve the existing group (if any) and the existing single-output column + // (if `mode === 'edit-output'`) from props. These are derivations — used + // only for seeding the form below and for save-time diffs. + const existingGroup: WorkflowGroup | undefined = (() => { + if (config.mode === 'edit-group') return workflowGroups.find((g) => g.id === config.groupId) + if (config.mode === 'edit-output') { + const col = allColumns.find((c) => c.name === config.columnName) + return col?.workflowGroupId + ? workflowGroups.find((g) => g.id === col.workflowGroupId) + : undefined + } + return undefined + })() + const existingColumn = + config.mode === 'edit-output' + ? (allColumns.find((c) => c.name === config.columnName) ?? null) + : null + + // Anchor column for "left of current" filtering. For create + edit-group we + // treat the anchor as missing (group config sits at the right edge of the + // group); for edit-output the anchor is the column being edited. + const anchorColumnName = config.mode === 'edit-output' ? config.columnName : null + + /** + * Columns "left of current" — these are the only valid trigger dependencies. + * For create + edit-group, every existing column qualifies. For edit-output, + * only columns physically before the anchor. + */ + const otherColumns = (() => { + if (anchorColumnName === null) return allColumns + const idx = allColumns.findIndex((c) => c.name === anchorColumnName) + if (idx === -1) return allColumns.filter((c) => c.name !== anchorColumnName) + return allColumns.slice(0, idx) + })() + + // Every left-of-current column is a valid dep — workflow output columns + // included. Exclude this group's own outputs (you can't depend on yourself). + const ownOutputNames = new Set(existingGroup?.outputs.map((o) => o.columnName) ?? []) + const depOptions = otherColumns.filter((c) => !ownOutputNames.has(c.name)) + + // Default deps for a brand-new group: tick every left-of-current column. + const defaultDeps = depOptions.map((c) => c.name) + + const [selectedWorkflowId, setSelectedWorkflowId] = useState( + () => existingGroup?.workflowId ?? '' + ) + // For existing groups, treat a missing `autoRun` field as `true` (pre-feature + // groups all ran automatically and shouldn't silently flip to manual when + // the user just opens the sidebar). For brand-new groups, default to `false` + // so the user opts in to auto-run explicitly. + const [autoRun, setAutoRun] = useState(() => + existingGroup ? existingGroup.autoRun !== false : false + ) + const [deps, setDeps] = useState( + () => existingGroup?.dependencies?.columns ?? defaultDeps + ) + // `selectedOutputs` is encoded `${blockId}::${path}`. Seeded once `blockOutputGroups` + // resolves (we may not have the workflow blocks loaded at first render); see the + // post-load reconciliation below. + const [selectedOutputs, setSelectedOutputs] = useState([]) + const [outputsHydrated, setOutputsHydrated] = useState(false) + const [columnNameInput, setColumnNameInput] = useState( + () => existingColumn?.name ?? (config.mode === 'create' ? config.proposedName : '') + ) + const [showValidation, setShowValidation] = useState(false) + const [nameError, setNameError] = useState(null) + + const workflowState = useWorkflowState(selectedWorkflowId || undefined) + + /** Resolves the unified Start block id and its current `inputFormat` field + * names. The "Add inputs" mutation only adds rows for table columns that + * aren't already represented in the start block. */ + const startBlockInputs = useMemo<{ + blockId: string | null + existingNames: Set + existing: InputFormatField[] + }>(() => { + const blocks = (workflowState.data as { blocks?: Record } | null) + ?.blocks + if (!blocks) return { blockId: null, existingNames: new Set(), existing: [] } + const candidate = TriggerUtils.findStartBlock(blocks, 'manual') + if (!candidate) return { blockId: null, existingNames: new Set(), existing: [] } + const block = blocks[candidate.blockId] as + | { subBlocks?: Record } + | undefined + const existing = normalizeInputFormatValue(block?.subBlocks?.inputFormat?.value) + return { + blockId: candidate.blockId, + existingNames: new Set(existing.map((f) => f.name).filter((n): n is string => !!n)), + existing, + } + }, [workflowState.data]) + + const missingInputColumnNames = useMemo(() => { + if (!startBlockInputs.blockId) return [] + const anchor = anchorColumnName + return allColumns + .filter( + (c) => + c.name !== anchor && !c.workflowGroupId && !startBlockInputs.existingNames.has(c.name) + ) + .map((c) => c.name) + }, [allColumns, anchorColumnName, startBlockInputs]) + + const queryClient = useQueryClient() + const addInputsMutation = useMutation({ + mutationFn: async () => { + const wfId = selectedWorkflowId + const startBlockId = startBlockInputs.blockId + const state = workflowState.data as WorkflowStatePayload | null | undefined + if (!wfId || !startBlockId || !state || missingInputColumnNames.length === 0) { + throw new Error('Nothing to add') + } + const startBlock = state.blocks[startBlockId] + if (!startBlock) throw new Error('Start block missing from workflow') + + const newFields: InputFormatField[] = missingInputColumnNames.map((name) => { + const col = allColumns.find((c) => c.name === name) + return { + id: generateId(), + name, + type: tableColumnTypeToInputType(col?.type), + value: '', + collapsed: false, + } as InputFormatField & { id: string; collapsed: boolean } + }) + + const updatedSubBlock = { + ...(startBlock.subBlocks?.inputFormat ?? { id: 'inputFormat', type: 'input-format' }), + value: [...startBlockInputs.existing, ...newFields], + } + const updatedBlocks = { + ...state.blocks, + [startBlockId]: { + ...startBlock, + subBlocks: { ...startBlock.subBlocks, inputFormat: updatedSubBlock }, + }, + } + + const rawBody = { + blocks: updatedBlocks, + edges: state.edges, + loops: state.loops, + parallels: state.parallels, + lastSaved: state.lastSaved ?? Date.now(), + isDeployed: state.isDeployed ?? false, + } + // double-cast-allowed: WorkflowStatePayload is the loose local view of + // useWorkflowState; round-trip back to the strict PUT body shape. + const body = rawBody as unknown as WorkflowStateContractInput + await requestJson(putWorkflowNormalizedStateContract, { params: { id: wfId }, body }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: workflowKeys.state(selectedWorkflowId) }) + }, + onError: (err) => { + toast.error(toError(err).message) + }, + }) + + const blockOutputGroups = useMemo(() => { + const state = workflowState.data as + | { + blocks?: Record + edges?: FlattenOutputsEdgeInput[] + } + | null + | undefined + if (!state?.blocks) return [] + + const blocks = Object.values(state.blocks) + const edges = state.edges ?? [] + const flat = flattenWorkflowOutputs(blocks, edges) + if (flat.length === 0) return [] + + const groupsByBlockId = new Map() + for (const f of flat) { + let group = groupsByBlockId.get(f.blockId) + if (!group) { + const blockConfig = getBlock(f.blockType) + const blockColor = blockConfig?.bgColor || '#2F55FF' + let blockIcon: string | React.ComponentType<{ className?: string }> = f.blockName + .charAt(0) + .toUpperCase() + if (blockConfig?.icon) blockIcon = blockConfig.icon + else if (f.blockType === 'loop') blockIcon = RepeatIcon + else if (f.blockType === 'parallel') blockIcon = SplitIcon + group = { + blockId: f.blockId, + blockName: f.blockName, + blockType: f.blockType, + blockIcon, + blockColor, + paths: [], + } + groupsByBlockId.set(f.blockId, group) + } + group.paths.push(f.path) + } + const distances = getBlockExecutionOrder(blocks, edges) + return Array.from(groupsByBlockId.values()).sort((a, b) => { + const da = distances[a.blockId] + const db = distances[b.blockId] + const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da + const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db + return sa - sb + }) + }, [workflowState.data]) + + const outputGroupOptions = useMemo( + () => + blockOutputGroups.map((group) => ({ + section: group.blockName, + sectionElement: ( +
+ + + {group.blockName} + +
+ ), + items: group.paths.map((path) => ({ + label: path, + value: encodeOutputValue(group.blockId, path), + })), + })), + [blockOutputGroups] + ) + + // Once the workflow's blocks are loaded, re-encode persisted `{blockId, path}` + // entries into the picker's encoded form. Stale entries (block deleted or + // path removed) are dropped silently — the user can re-pick on save. + if (!outputsHydrated && existingGroup?.outputs.length && blockOutputGroups.length > 0) { + const encoded: string[] = [] + if (config.mode === 'edit-output' && existingColumn) { + // Single-output sub-mode: only seed the picker with this column's mapping. + const own = existingGroup.outputs.find((o) => o.columnName === existingColumn.name) + if (own) { + const match = blockOutputGroups.find( + (g) => g.blockId === own.blockId && g.paths.includes(own.path) + ) + if (match) encoded.push(encodeOutputValue(own.blockId, own.path)) + } + } else { + for (const entry of existingGroup.outputs) { + const match = blockOutputGroups.find( + (g) => g.blockId === entry.blockId && g.paths.includes(entry.path) + ) + if (match) encoded.push(encodeOutputValue(entry.blockId, entry.path)) + } + } + setSelectedOutputs(encoded) + setOutputsHydrated(true) + } + + /** + * Builds the ordered, deduplicated `(blockId, path)` list from the picker + * state, sorted by execution order. + */ + function buildOrderedPickedOutputs(): Array<{ + blockId: string + path: string + leafType?: string + }> { + const seen = new Set() + const outputs: Array<{ blockId: string; path: string; leafType?: string }> = [] + for (const encoded of selectedOutputs) { + if (seen.has(encoded)) continue + seen.add(encoded) + outputs.push(decodeOutputValue(encoded)) + } + const wfState = workflowState.data as + | { + blocks?: Record + edges?: FlattenOutputsEdgeInput[] + } + | null + | undefined + if (wfState?.blocks) { + const blocks = Object.values(wfState.blocks) + const edges = wfState.edges ?? [] + const distances = getBlockExecutionOrder(blocks, edges) + const flat = flattenWorkflowOutputs(blocks, edges) + const indexInFlat = new Map( + flat.map((f, i) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, i]) + ) + const leafTypeByKey = new Map( + flat.map((f) => [`${f.blockId}${OUTPUT_VALUE_SEPARATOR}${f.path}`, f.leafType]) + ) + for (const o of outputs) { + o.leafType = leafTypeByKey.get(`${o.blockId}${OUTPUT_VALUE_SEPARATOR}${o.path}`) + } + outputs.sort((a, b) => { + const da = distances[a.blockId] + const db = distances[b.blockId] + const sa = da === undefined || da < 0 ? Number.POSITIVE_INFINITY : da + const sb = db === undefined || db < 0 ? Number.POSITIVE_INFINITY : db + if (sa !== sb) return sa - sb + const ia = + indexInFlat.get(`${a.blockId}${OUTPUT_VALUE_SEPARATOR}${a.path}`) ?? + Number.POSITIVE_INFINITY + const ib = + indexInFlat.get(`${b.blockId}${OUTPUT_VALUE_SEPARATOR}${b.path}`) ?? + Number.POSITIVE_INFINITY + return ia - ib + }) + } + return outputs + } + + const isEditOutputMode = config.mode === 'edit-output' + + async function handleSave() { + const trimmedName = columnNameInput.trim() + + const missing: string[] = [] + if (!selectedWorkflowId) missing.push('a workflow') + if (selectedWorkflowId && selectedOutputs.length === 0) missing.push('at least one output') + if (isEditOutputMode && !trimmedName) missing.push('a column name') + if (missing.length > 0) { + setShowValidation(true) + return + } + + try { + const orderedOutputs = buildOrderedPickedOutputs() + const dependencies: WorkflowGroupDependencies = { columns: deps } + + if (existingGroup) { + // edit-output: swap one column's source mapping (and optionally rename + // the column itself). edit-group: full add/remove diff against the + // group's existing outputs. + if (isEditOutputMode && existingColumn) { + const renamedColumn = + trimmedName !== existingColumn.name + ? { from: existingColumn.name, to: trimmedName } + : null + const newPick = orderedOutputs[0] + if (!newPick) throw new Error('Pick an output') + if (renamedColumn) { + await updateColumn.mutateAsync({ + columnName: renamedColumn.from, + updates: { name: renamedColumn.to }, + }) + onColumnRename?.(renamedColumn.from, renamedColumn.to) + } + // Reference the post-rename column name in mappingUpdates. The + // server applies the mapping swap and clears the column's row data + // so the next workflow run repopulates from the new source. + const targetColumnName = renamedColumn?.to ?? existingColumn.name + await updateWorkflowGroup.mutateAsync({ + groupId: existingGroup.id, + workflowId: selectedWorkflowId, + name: existingGroup.name, + dependencies, + mappingUpdates: [ + { columnName: targetColumnName, blockId: newPick.blockId, path: newPick.path }, + ], + }) + toast.success(`Saved "${targetColumnName}"`) + } else { + // edit-group: full output diff with new-column derivation. + const taken = new Set(allColumns.map((c) => c.name)) + const fullOutputs: WorkflowGroupOutput[] = [] + const newOutputColumns: NonNullable = [] + for (const o of orderedOutputs) { + const existingOut = existingGroup.outputs.find( + (e) => e.blockId === o.blockId && e.path === o.path + ) + if (existingOut) { + fullOutputs.push(existingOut) + } else { + const colName = deriveOutputColumnName(o.path, taken) + taken.add(colName) + fullOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) + newOutputColumns.push({ + name: colName, + type: columnTypeForLeaf(o.leafType), + required: false, + unique: false, + workflowGroupId: existingGroup.id, + }) + } + } + await updateWorkflowGroup.mutateAsync({ + groupId: existingGroup.id, + workflowId: selectedWorkflowId, + name: existingGroup.name, + dependencies, + outputs: fullOutputs, + ...(newOutputColumns.length > 0 ? { newOutputColumns } : {}), + autoRun, + }) + toast.success(`Saved "${existingGroup.name ?? 'Workflow'}"`) + } + } else { + // Create path: brand-new group with auto-derived output column names. + const groupId = generateId() + const taken = new Set(allColumns.map((c) => c.name)) + const newOutputColumns: AddWorkflowGroupBodyInput['outputColumns'] = [] + const groupOutputs: WorkflowGroupOutput[] = [] + for (const o of orderedOutputs) { + const colName = deriveOutputColumnName(o.path, taken) + taken.add(colName) + newOutputColumns.push({ + name: colName, + type: columnTypeForLeaf(o.leafType), + required: false, + unique: false, + workflowGroupId: groupId, + }) + groupOutputs.push({ blockId: o.blockId, path: o.path, columnName: colName }) + } + const workflowName = workflows?.find((w) => w.id === selectedWorkflowId)?.name ?? 'Workflow' + const group: WorkflowGroup = { + id: groupId, + workflowId: selectedWorkflowId, + name: workflowName, + dependencies, + outputs: groupOutputs, + autoRun, + } + await addWorkflowGroup.mutateAsync({ group, outputColumns: newOutputColumns }) + toast.success(`Added "${workflowName}"`) + } + onClose() + } catch (err) { + if (isValidationError(err)) { + const nameIssue = + findValidationIssue(err, ['updates', 'name']) ?? + findValidationIssue(err, ['name']) ?? + findValidationIssue(err, ['columnName']) + if (nameIssue) { + setNameError(nameIssue.message) + return + } + } + toast.error(toError(err).message) + } + } + + const saveDisabled = + addWorkflowGroup.isPending || updateWorkflowGroup.isPending || updateColumn.isPending + const titleByMode = { + create: 'Add workflow', + 'edit-group': 'Configure workflow', + 'edit-output': 'Configure output column', + } as const + + // edit-output mode is single-select on the output picker; everywhere else + // is multi-select. Same Combobox shape, different mode. + const outputPickerSingleSelect = isEditOutputMode + + return ( +
+
+

+ {titleByMode[config.mode]} +

+ +
+ +
+ {/* Single-output mode renames this column directly. */} + {isEditOutputMode && ( + <> +
+ Column name + { + setColumnNameInput(e.target.value) + if (nameError) setNameError(null) + }} + spellCheck={false} + autoComplete='off' + aria-invalid={ + (showValidation && !columnNameInput.trim()) || nameError ? true : undefined + } + /> + {showValidation && !columnNameInput.trim() && ( + + )} + {nameError && !(showValidation && !columnNameInput.trim()) && ( + + )} +
+ + + )} + + {selectedWorkflowId && ( + <> +
+
+ + {startBlockInputs.blockId && missingInputColumnNames.length > 0 && ( + + + + + + Adds {missingInputColumnNames.join(', ')} to the workflow's Start block + + + )} +
+
+ {workflowState.isLoading ? ( +
+ +
+ ) : workflowState.data ? ( + <> +
+ +
+ + + + + Open workflow + + + ) : ( +
+ + Unable to load preview + +
+ )} +
+
+ + + )} + +
+ Workflow + ({ label: wf.name, value: wf.id })) ?? []} + value={selectedWorkflowId} + onChange={(v) => setSelectedWorkflowId(v)} + placeholder='Select a workflow' + disabled={!workflows || workflows.length === 0 || isEditOutputMode} + emptyMessage='No manual triggers configured' + maxHeight={260} + searchable + searchPlaceholder='Search workflows...' + error={showValidation && !selectedWorkflowId ? 'Select a workflow' : null} + /> + {showValidation && !selectedWorkflowId && } +
+ + + +
+ {isEditOutputMode ? 'Output' : 'Output columns'} + setSelectedOutputs(v ? [v] : []), + } + : { + multiSelectValues: selectedOutputs, + onMultiSelectChange: setSelectedOutputs, + overlayContent: ( + + {selectedOutputs.length === 0 + ? 'Select outputs' + : `${selectedOutputs.length} selected`} + + ), + })} + /> + {showValidation && selectedWorkflowId && selectedOutputs.length === 0 && ( + + )} +
+ + {!isEditOutputMode && ( + <> + +
+ + setAutoRun(!!v)} + /> +
+ {autoRun && ( + <> + + + + )} + + )} +
+ +
+ + +
+
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts index 762cc58de9..d207594366 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/index.ts @@ -1,3 +1,2 @@ export * from './use-context-menu' -export * from './use-row-execution' export * from './use-table' diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-row-execution.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-row-execution.ts deleted file mode 100644 index 639680919d..0000000000 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-row-execution.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { useCallback } from 'react' -import { createLogger } from '@sim/logger' -import { useMutation, useQueryClient } from '@tanstack/react-query' -import { toast } from '@/components/emcn' -import { requestJson } from '@/lib/api/client/request' -import { runRowWorkflowGroupContract } from '@/lib/api/contracts/tables' -import type { RowExecutionMetadata } from '@/lib/table' -import { - restoreCachedWorkflowCells, - snapshotAndMutateRows, - tableKeys, -} from '@/hooks/queries/tables' - -const logger = createLogger('useRowExecution') - -export interface RunWorkflowGroupParams { - tableId: string - rowId: string - workspaceId: string - groupId: string - /** Group's workflow id — used as the optimistic execution's `workflowId` - * when the row hasn't run this group before. */ - workflowId: string - /** Output column names produced by the group; cleared optimistically so - * stale values from the previous run don't linger in the UI before the - * server response writes the cleared row back. */ - outputColumnNames: string[] -} - -interface UseRowExecutionReturn { - runWorkflowGroup: (params: RunWorkflowGroupParams) => Promise -} - -/** - * Single-row workflow-group runner. Optimistically flips - * `executions[groupId]` to `pending` for the targeted row before the network - * round-trip so the spinner appears instantly. Cache invalidation lives in - * `onSettled` so failed starts still refresh the rows query — otherwise a row - * stuck in stale state would remain in the UI until the next refetch. - */ -export function useRowExecution(): UseRowExecutionReturn { - const queryClient = useQueryClient() - - const mutation = useMutation({ - mutationFn: async (params: RunWorkflowGroupParams) => { - return requestJson(runRowWorkflowGroupContract, { - params: { tableId: params.tableId, rowId: params.rowId }, - body: { workspaceId: params.workspaceId, groupId: params.groupId }, - }) - }, - onMutate: async (params) => { - const snapshots = await snapshotAndMutateRows(queryClient, params.tableId, (r) => { - if (r.id !== params.rowId) return null - const exec = r.executions?.[params.groupId] as RowExecutionMetadata | undefined - const pending: RowExecutionMetadata = { - status: 'pending', - executionId: exec?.executionId ?? null, - jobId: null, - workflowId: exec?.workflowId ?? params.workflowId, - error: null, - } - const nextData = { ...r.data } - for (const colName of params.outputColumnNames) nextData[colName] = null - return { - ...r, - data: nextData, - executions: { ...(r.executions ?? {}), [params.groupId]: pending }, - } - }) - return { snapshots } - }, - onError: (err, _params, context) => { - if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) - const message = err instanceof Error ? err.message : 'Unknown error' - logger.error('Run workflow group failed:', err) - toast.error(`Failed to run workflow: ${message}`) - }, - onSettled: (_data, _err, params) => { - queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(params.tableId) }) - }, - }) - - const runWorkflowGroup = useCallback( - async (params: RunWorkflowGroupParams) => { - await mutation.mutateAsync(params).catch(() => { - // onError already toasted; swallow so callers can fire-and-forget. - }) - }, - // mutateAsync is stable in TanStack Query v5 - // eslint-disable-next-line react-hooks/exhaustive-deps - [] - ) - - return { runWorkflowGroup } -} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts index 258718d831..b3d5b311ad 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/hooks/use-table.ts @@ -1,24 +1,15 @@ 'use client' -import { useCallback, useEffect, useMemo } from 'react' -import { useQueryClient } from '@tanstack/react-query' -import type { - ColumnDefinition, - RowData, - RowExecutions, - TableDefinition, - TableRow, - WorkflowGroup, -} from '@/lib/table' +import { useCallback, useMemo } from 'react' +import type { ColumnDefinition, TableDefinition, TableRow, WorkflowGroup } from '@/lib/table' import { TABLE_LIMITS } from '@/lib/table/constants' import type { FlattenOutputsBlockInput } from '@/lib/workflows/blocks/flatten-outputs' -import { useSocket } from '@/app/workspace/providers/socket-provider' import { getBlock } from '@/blocks' -import { tableKeys, useInfiniteTableRows, useTable as useTableQuery } from '@/hooks/queries/tables' +import { useInfiniteTableRows, useTable as useTableQuery } from '@/hooks/queries/tables' import { useWorkflowStates, useWorkflows } from '@/hooks/queries/workflows' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' import type { WorkflowState } from '@/stores/workflows/workflow/types' -import type { BlockIconInfo, ColumnSourceInfo } from '../components/table/types' +import type { BlockIconInfo, ColumnSourceInfo } from '../components/table-grid/types' import type { QueryOptions } from '../types' const EMPTY_COLUMNS: ColumnDefinition[] = [] @@ -61,8 +52,6 @@ export interface UseTableReturn { /** Pre-resolved icon + block-name info per output column name. Headers read * from this map instead of each subscribing to its own workflow-state query. */ columnSourceInfo: Map - /** `workflowId → workflow.name` lookup for cell labels and execution-detail copy. */ - workflowNameById: Record } /** @@ -77,7 +66,6 @@ export interface UseTableReturn { * single hook return and re-render the world. */ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams): UseTableReturn { - const queryClient = useQueryClient() const { data: tableData, isLoading: isLoadingTable } = useTableQuery(workspaceId, tableId) const { @@ -113,110 +101,6 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) return { hasNextPage: Boolean(result.hasNextPage) } }, [fetchNextPage]) - // Realtime sync: merge `table-row-updated` / `table-row-deleted` socket - // events into the infinite-query cache so cell updates land without - // polling. While any mutation is in flight, defer to a stale-mark — the - // optimistic update guard wins until `onSettled` invalidates. - const { joinTable, leaveTable, onTableRowUpdated, onTableRowDeleted } = useSocket() - useEffect(() => { - if (!tableId) return - joinTable(tableId) - - type Page = { rows: TableRow[]; totalCount: number | null } - type InfiniteData = { pages: Page[]; pageParams: number[] } | undefined - - onTableRowUpdated((event) => { - if (event.tableId !== tableId) return - if (queryClient.isMutating() > 0) { - queryClient.invalidateQueries({ - queryKey: tableKeys.rowsRoot(tableId), - refetchType: 'none', - }) - return - } - queryClient.setQueriesData( - { queryKey: tableKeys.rowsRoot(tableId) }, - (current) => { - if (!current) return current - const incoming: TableRow = { - id: event.rowId, - data: event.data as RowData, - executions: (event.executions as RowExecutions) ?? {}, - position: event.position, - createdAt: '', - updatedAt: - typeof event.updatedAt === 'string' ? event.updatedAt : String(event.updatedAt), - } - let landed = false - const nextPages = current.pages.map((page) => { - const idx = page.rows.findIndex((r) => r.id === event.rowId) - if (idx === -1) return page - landed = true - const merged = { - ...page.rows[idx], - data: incoming.data, - executions: incoming.executions, - updatedAt: incoming.updatedAt, - } - const nextRows = [...page.rows] - nextRows[idx] = merged - return { ...page, rows: nextRows } - }) - if (landed) return { ...current, pages: nextPages } - // Row not in any cached page yet — append to the last page so it - // shows up immediately. The next refetch will reorder by position. - if (current.pages.length === 0) return current - const lastIdx = current.pages.length - 1 - const lastPage = current.pages[lastIdx] - const updatedLast: Page = { - ...lastPage, - rows: [...lastPage.rows, incoming], - totalCount: lastPage.totalCount === null ? null : lastPage.totalCount + 1, - } - const pagesWithAppend = [...current.pages] - pagesWithAppend[lastIdx] = updatedLast - return { ...current, pages: pagesWithAppend } - } - ) - }) - - onTableRowDeleted((event) => { - if (event.tableId !== tableId) return - if (queryClient.isMutating() > 0) { - queryClient.invalidateQueries({ - queryKey: tableKeys.rowsRoot(tableId), - refetchType: 'none', - }) - return - } - queryClient.setQueriesData( - { queryKey: tableKeys.rowsRoot(tableId) }, - (current) => { - if (!current) return current - let removed = false - const nextPages = current.pages.map((page) => { - const next = page.rows.filter((r) => r.id !== event.rowId) - if (next.length === page.rows.length) return page - removed = true - return { - ...page, - rows: next, - totalCount: page.totalCount === null ? null : Math.max(0, page.totalCount - 1), - } - }) - if (!removed) return current - return { ...current, pages: nextPages } - } - ) - }) - - return () => { - leaveTable() - } - // joinTable / leaveTable / on* are stable callbacks; tableId is the only real dep. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableId]) - const { data: workflows } = useWorkflows(workspaceId) const columns = useMemo( @@ -251,14 +135,6 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) return map }, [tableWorkflowGroups, workflowStates]) - const workflowNameById = useMemo(() => { - const map: Record = {} - for (const wf of workflows ?? []) { - map[wf.id] = wf.name - } - return map - }, [workflows]) - return { tableData, isLoadingTable, @@ -273,6 +149,5 @@ export function useTable({ workspaceId, tableId, queryOptions }: UseTableParams) tableWorkflowGroups, workflowStates, columnSourceInfo, - workflowNameById, } } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx index 8ea13d7f0c..5ce3b7d9dd 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/page.tsx @@ -1,5 +1,5 @@ import type { Metadata } from 'next' -import { Table } from './components' +import { Table } from './table' export const metadata: Metadata = { title: 'Table', diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx new file mode 100644 index 0000000000..e9fea357e6 --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx @@ -0,0 +1,685 @@ +'use client' + +import { useCallback, useMemo, useReducer, useRef, useState } from 'react' +import { createLogger } from '@sim/logger' +import { useParams, useRouter } from 'next/navigation' +import { + Button, + Modal, + ModalBody, + ModalContent, + ModalFooter, + ModalHeader, + toast, +} from '@/components/emcn' +import { Download, Pencil, Table as TableIcon, Trash, Upload } from '@/components/emcn/icons' +import type { RunMode } from '@/lib/api/contracts/tables' +import type { ColumnDefinition, Filter, TableRow as TableRowType } from '@/lib/table' +import { + type ColumnOption, + ResourceHeader, + ResourceOptionsBar, + type SortConfig, +} from '@/app/workspace/[workspaceId]/components' +import { LogDetails } from '@/app/workspace/[workspaceId]/logs/components' +import { useUserPermissionsContext } from '@/app/workspace/[workspaceId]/providers/workspace-permissions-provider' +import { ImportCsvDialog } from '@/app/workspace/[workspaceId]/tables/components/import-csv-dialog' +import { useLogByExecutionId } from '@/hooks/queries/logs' +import { + downloadTableExport, + useCancelTableRuns, + useDeleteTable, + useRenameTable, + useRunColumn, +} from '@/hooks/queries/tables' +import { useInlineRename } from '@/hooks/use-inline-rename' +import { useLogDetailsUIStore } from '@/stores/logs/store' +import type { DeletedRowSnapshot } from '@/stores/table/types' +import { + type ColumnConfig, + ColumnConfigSidebar, + NewColumnDropdown, + RowModal, + RunStatusControl, + type SelectionSnapshot, + TableActionBar, + TableFilter, + TableGrid, + type WorkflowConfig, + WorkflowSidebar, +} from './components' +import { COLUMN_SIDEBAR_WIDTH } from './components/table-grid/constants' +import { COLUMN_TYPE_ICONS } from './components/table-grid/headers' +import { useTable } from './hooks' +import type { QueryOptions } from './types' +import { generateColumnName } from './utils' + +const logger = createLogger('Table') + +interface TableProps { + /** When set, the table renders without its page header / breadcrumbs / page-level + * options bar. Used by the mothership chat panel to embed a table inline. */ + embedded?: boolean + /** Identifiers — only set in embedded mode. Page mode reads from `useParams()`. */ + workspaceId?: string + tableId?: string +} + +/** + * Discriminated union encoding the at-most-one-open invariant for the three + * right-edge slideout panels. Driven by a `useReducer` so every transition + * goes through one place — opening a column config can't accidentally leave a + * workflow config open. + */ +type SlideoutState = + | { kind: 'none' } + | { kind: 'column'; config: ColumnConfig } + | { kind: 'workflow'; config: WorkflowConfig } + | { kind: 'execution'; executionId: string } + +type SlideoutAction = + | { type: 'OPEN_COLUMN'; config: ColumnConfig } + | { type: 'OPEN_WORKFLOW'; config: WorkflowConfig } + | { type: 'OPEN_EXECUTION'; executionId: string } + | { type: 'CLOSE' } + +function slideoutReducer(_state: SlideoutState, action: SlideoutAction): SlideoutState { + switch (action.type) { + case 'OPEN_COLUMN': + return { kind: 'column', config: action.config } + case 'OPEN_WORKFLOW': + return { kind: 'workflow', config: action.config } + case 'OPEN_EXECUTION': + return { kind: 'execution', executionId: action.executionId } + case 'CLOSE': + return { kind: 'none' } + } +} + +/** + * Page-level wrapper for the table detail view. Mirrors the shape of + * `logs/logs.tsx`: a thin orchestrator that composes the data grid (``) + * and the page-level surface (sidebars, modals, action bar, breadcrumbs). + * + * Owns the at-most-one-open invariant for the three slideout panels (column + * config, workflow config, execution details) via a single reducer. The grid + * emits open requests via callbacks; the wrapper renders the panels. + * + * Embedded mode skips the page header but otherwise renders the same surface. + */ +export function Table({ + embedded, + workspaceId: propWorkspaceId, + tableId: propTableId, +}: TableProps = {}) { + const params = useParams() + const router = useRouter() + const workspaceId = propWorkspaceId || (params.workspaceId as string) + const tableId = propTableId || (params.tableId as string) + + const [slideout, dispatch] = useReducer(slideoutReducer, { kind: 'none' }) + const [showDeleteTableConfirm, setShowDeleteTableConfirm] = useState(false) + const [isImportCsvOpen, setIsImportCsvOpen] = useState(false) + const [editingRow, setEditingRow] = useState(null) + const [deletingRows, setDeletingRows] = useState([]) + const [deletingColumns, setDeletingColumns] = useState(null) + const [selection, setSelection] = useState({ + actionBarRowIds: [], + runningInActionBarSelection: 0, + totalRunning: 0, + hasWorkflowColumns: false, + selectedRunScope: null, + selectionStats: { hasIncompleteOrFailed: false, hasCompleted: false, hasInFlight: false }, + singleWorkflowCell: null, + }) + const [queryOptions, setQueryOptions] = useState({ filter: null, sort: null }) + const [filterOpen, setFilterOpen] = useState(false) + + const userPermissions = useUserPermissionsContext() + + const onOpenColumnConfig = useCallback((config: ColumnConfig) => { + dispatch({ type: 'OPEN_COLUMN', config }) + }, []) + const onOpenWorkflowConfig = useCallback((config: WorkflowConfig) => { + dispatch({ type: 'OPEN_WORKFLOW', config }) + }, []) + const onOpenExecutionDetails = useCallback((executionId: string) => { + dispatch({ type: 'OPEN_EXECUTION', executionId }) + }, []) + const onCloseSlideout = () => dispatch({ type: 'CLOSE' }) + const onOpenRowModal = (row: TableRowType) => setEditingRow(row) + // useCallback because is memo-wrapped — these flow into + // the breadcrumbs / headerActions memos, whose identity drives that re-render. + const onRequestDeleteTable = useCallback(() => setShowDeleteTableConfirm(true), []) + const onRequestImportCsv = useCallback(() => setIsImportCsvOpen(true), []) + // Used inside grid's `useCallback` deps — identity stability prevents the + // grid's `useCallback` from re-creating on every wrapper re-render. + const onRequestDeleteRows = useCallback((snapshots: DeletedRowSnapshot[]) => { + setDeletingRows(snapshots) + }, []) + const onRequestDeleteColumns = useCallback((names: string[]) => { + setDeletingColumns(names) + }, []) + + /** + * Sink populated by the grid: invoked from sidebar `onColumnRename` so the + * grid can rewrite its local `columnWidths` / `columnOrder` keys after a + * rename. The grid's render assigns to `current`; the wrapper forwards calls. + */ + const columnRenameSinkRef = useRef<((oldName: string, newName: string) => void) | null>(null) + const onColumnRename = (oldName: string, newName: string) => { + columnRenameSinkRef.current?.(oldName, newName) + } + + /** + * Sink the grid populates with its post-row-delete cleanup (push undo, + * clear selection). The wrapper invokes after the row-delete modal's + * mutation succeeds. + */ + const afterDeleteRowsSinkRef = useRef<((snapshots: DeletedRowSnapshot[]) => void) | null>(null) + + /** + * Sink the grid populates with its full delete-columns cascade (per-column + * mutation, undo push, columnOrder + columnWidths cleanup). The wrapper's + * delete-columns confirmation modal invokes this on confirm. + */ + const confirmDeleteColumnsSinkRef = useRef<((names: string[]) => void) | null>(null) + + /** + * Sink the grid populates with its `pushUndo({ type: 'rename-table', ... })` + * call so the wrapper's breadcrumb rename can register an undo entry on the + * grid's undo stack. + */ + const pushTableRenameUndoSinkRef = useRef< + ((previousName: string, newName: string) => void) | null + >(null) + + // Single source of truth for `useTable` — drives both the grid render and + // the wrapper's slideouts/modals. The grid receives the bundle as props. + const { tableData, columns, tableWorkflowGroups, workflows } = useTable({ + workspaceId, + tableId, + queryOptions, + }) + + const runColumnMutation = useRunColumn({ workspaceId, tableId }) + const cancelRunsMutation = useCancelTableRuns({ workspaceId, tableId }) + const runColumnMutate = runColumnMutation.mutate + const cancelRunsMutate = cancelRunsMutation.mutate + + // Canonical run dispatcher. Every UI gesture (column-header menu, per-row + // gutter, action-bar Play/Refresh, right-click context menu) reduces to a + // (groupIds, rowIds?, runMode) triple. Empty groupIds = no-op. + const runScope = useCallback( + (args: { groupIds: string[]; rowIds?: string[]; runMode: RunMode }) => { + if (args.groupIds.length === 0) return + if (args.rowIds && args.rowIds.length === 0) return + runColumnMutate(args) + }, + [runColumnMutate] + ) + + const onRunColumn = useCallback( + (groupId: string, runMode: RunMode, rowIds?: string[]) => { + runScope({ groupIds: [groupId], rowIds, runMode }) + }, + [runScope] + ) + + const onRunRows = useCallback( + (rowIds: string[], runMode: RunMode) => { + runScope({ groupIds: tableWorkflowGroups.map((g) => g.id), rowIds, runMode }) + }, + [runScope, tableWorkflowGroups] + ) + + const onRunRow = useCallback( + (rowId: string) => { + runScope({ + groupIds: tableWorkflowGroups.map((g) => g.id), + rowIds: [rowId], + runMode: 'incomplete', + }) + }, + [runScope, tableWorkflowGroups] + ) + + // useCallback because is React.memo-wrapped — identity stability + // matters for per-row gutter Stop button. + const onStopRow = useCallback( + (rowId: string) => { + cancelRunsMutate({ scope: 'row', rowId }) + }, + [cancelRunsMutate] + ) + + const onStopRows = (rowIds: string[]) => { + if (rowIds.length === 0) return + for (const rowId of rowIds) { + cancelRunsMutate({ scope: 'row', rowId }) + } + } + + // useCallback because is memo-wrapped. + const onStopAll = useCallback(() => { + cancelRunsMutate({ scope: 'all' }) + }, [cancelRunsMutate]) + + const onSelectionChange = (next: SelectionSnapshot) => { + setSelection(next) + } + + const renameTableMutation = useRenameTable(workspaceId) + const tableDataRef = useRef(tableData) + tableDataRef.current = tableData + const tableHeaderRename = useInlineRename({ + onSave: (_id, name) => { + const data = tableDataRef.current + if (data) pushTableRenameUndoSinkRef.current?.(data.name, name) + renameTableMutation.mutate({ tableId, name }) + }, + }) + + const handleNavigateBack = useCallback(() => { + router.push(`/workspace/${workspaceId}/tables`) + }, [router, workspaceId]) + + const handleStartTableRename = useCallback(() => { + const data = tableDataRef.current + if (data) tableHeaderRename.startRename(tableId, data.name) + }, [tableHeaderRename.startRename, tableId]) + + const handleAddColumnOfType = (type: ColumnDefinition['type']) => { + onOpenColumnConfig({ mode: 'create', proposedName: generateColumnName(columns), type }) + } + + const handleAddWorkflowColumn = () => { + onOpenWorkflowConfig({ mode: 'create', proposedName: generateColumnName(columns) }) + } + + const handleExportCsv = useCallback(async () => { + if (!tableData) return + try { + await downloadTableExport(tableData.id, tableData.name) + } catch (err) { + logger.error('Failed to export table:', err) + toast.error('Failed to export table') + } + }, [tableData]) + + const columnOptions = useMemo( + () => + columns.map((col) => ({ + id: col.name, + label: col.name, + type: col.type, + icon: COLUMN_TYPE_ICONS[col.type], + })), + [columns] + ) + + const sortConfig = useMemo(() => { + let active: SortConfig['active'] = null + if (queryOptions.sort) { + const entries = Object.entries(queryOptions.sort) + if (entries.length > 0) { + const [column, direction] = entries[0] + active = { column, direction } + } + } + return { + options: columnOptions, + active, + onSort: (column, direction) => + setQueryOptions((prev) => ({ ...prev, sort: { [column]: direction } })), + onClear: () => setQueryOptions((prev) => ({ ...prev, sort: null })), + } + }, [columnOptions, queryOptions.sort]) + + const handleFilterApply = (filter: Filter | null) => { + setQueryOptions((prev) => ({ ...prev, filter })) + } + + const breadcrumbs = useMemo( + () => [ + { label: 'Tables', onClick: handleNavigateBack }, + { + label: tableData?.name ?? '', + editing: tableHeaderRename.editingId + ? { + isEditing: true, + value: tableHeaderRename.editValue, + onChange: tableHeaderRename.setEditValue, + onSubmit: tableHeaderRename.submitRename, + onCancel: tableHeaderRename.cancelRename, + } + : undefined, + dropdownItems: [ + { + label: 'Rename', + icon: Pencil, + disabled: !tableData, + onClick: handleStartTableRename, + }, + { + label: 'Delete', + icon: Trash, + disabled: !tableData, + onClick: onRequestDeleteTable, + }, + ], + }, + ], + [ + handleNavigateBack, + tableData, + tableHeaderRename.editingId, + tableHeaderRename.editValue, + tableHeaderRename.setEditValue, + tableHeaderRename.submitRename, + tableHeaderRename.cancelRename, + handleStartTableRename, + onRequestDeleteTable, + ] + ) + + const headerActions = useMemo( + () => + tableData + ? [ + { + label: 'Import CSV', + icon: Upload, + onClick: onRequestImportCsv, + disabled: userPermissions.canEdit !== true, + }, + { + label: 'Export CSV', + icon: Download, + onClick: () => void handleExportCsv(), + disabled: tableData.rowCount === 0, + }, + ] + : undefined, + [tableData, userPermissions.canEdit, handleExportCsv, onRequestImportCsv] + ) + + const createTrigger = userPermissions.canEdit ? ( + + ) : null + + const logPanelWidth = useLogDetailsUIStore((state) => state.panelWidth) + const sidebarReservedPx = + slideout.kind === 'column' || slideout.kind === 'workflow' + ? COLUMN_SIDEBAR_WIDTH + : slideout.kind === 'execution' + ? logPanelWidth + : 0 + + const deleteTableMutation = useDeleteTable(workspaceId) + const handleDeleteTable = async () => { + try { + await deleteTableMutation.mutateAsync(tableId) + setShowDeleteTableConfirm(false) + router.push(`/workspace/${workspaceId}/tables`) + } catch { + setShowDeleteTableConfirm(false) + } + } + + const columnConfig = slideout.kind === 'column' ? slideout.config : null + const workflowConfig = slideout.kind === 'workflow' ? slideout.config : null + const executionId = slideout.kind === 'execution' ? slideout.executionId : null + // Fetch the workflow log when the execution-details slideout is open. Reuses + // the logs page's directly — no intermediate wrapper needed for + // a one-line query forward. + const { data: executionLog } = useLogByExecutionId(workspaceId, executionId) + + return ( +
+ {!embedded && ( + <> + 0 ? ( + + ) : null + } + /> + setFilterOpen((prev) => !prev)} + filterActive={filterOpen || !!queryOptions.filter} + /> + {filterOpen && ( + setFilterOpen(false)} + /> + )} + + )} + + {userPermissions.canEdit && ( + + selection.selectedRunScope && + runScope({ ...selection.selectedRunScope, runMode: 'incomplete' }) + } + onRefresh={() => + selection.selectedRunScope && + runScope({ ...selection.selectedRunScope, runMode: 'all' }) + } + onStopWorkflows={() => + selection.selectedRunScope && onStopRows(selection.selectedRunScope.rowIds) + } + onViewExecution={ + selection.singleWorkflowCell?.canViewExecution && + selection.singleWorkflowCell.executionId + ? () => { + const id = selection.singleWorkflowCell?.executionId + if (id) onOpenExecutionDetails(id) + } + : undefined + } + /> + )} + c.name === columnConfig.columnName) ?? null) + : null + } + workspaceId={workspaceId} + tableId={tableId} + onColumnRename={onColumnRename} + /> + + + {tableData && ( + + )} + {editingRow && tableData && ( + setEditingRow(null)} + table={tableData} + row={editingRow} + onSuccess={() => setEditingRow(null)} + /> + )} + {deletingRows.length > 0 && tableData && ( + setDeletingRows([])} + table={tableData} + rowIds={deletingRows.map((r) => r.rowId)} + onSuccess={() => { + afterDeleteRowsSinkRef.current?.(deletingRows) + setDeletingRows([]) + }} + /> + )} + { + if (!open) setDeletingColumns(null) + }} + > + + + {deletingColumns && deletingColumns.length > 1 + ? `Delete ${deletingColumns.length} Columns` + : 'Delete Column'} + + +

+ {deletingColumns && deletingColumns.length > 1 ? ( + <> + Are you sure you want to delete{' '} + + {deletingColumns.length} columns + + ?{' '} + + ) : ( + <> + Are you sure you want to delete{' '} + + {deletingColumns?.[0]} + + ?{' '} + + )} + + This will remove all data in{' '} + {deletingColumns && deletingColumns.length > 1 ? 'these columns' : 'this column'}. + {' '} + You can undo this action. +

+
+ + + + +
+
+ {!embedded && ( + + + Delete Table + +

+ Are you sure you want to delete{' '} + {tableData?.name}?{' '} + + All {tableData?.rowCount ?? 0} rows will be removed. + {' '} + You can restore it from Recently Deleted in Settings. +

+
+ + + + +
+
+ )} +
+ ) +} diff --git a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts index ce561a013b..75c57b6199 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/tables/[tableId]/utils.ts @@ -2,6 +2,21 @@ import type { ColumnDefinition } from '@/lib/table' type BadgeVariant = 'green' | 'blue' | 'purple' | 'orange' | 'teal' | 'gray' +/** + * Pick a fresh "untitled[_N]" name not already taken by `columns`. Used by + * both the page-header and inline-header "New column" dropdowns. + */ +export function generateColumnName(columns: ReadonlyArray<{ name: string }>): string { + const existing = new Set(columns.map((c) => c.name.toLowerCase())) + let name = 'untitled' + let i = 2 + while (existing.has(name.toLowerCase())) { + name = `untitled_${i}` + i++ + } + return name +} + /** * Returns the appropriate badge color variant for a column type */ @@ -51,7 +66,11 @@ export function cleanCellValue(value: unknown, column: ColumnDefinition): unknow } /** - * Format a stored value for display in an input field. + * Format a stored value for display in an input field. Defensive against + * shape drift: a column whose declared type lags its actual data (e.g. a + * workflow column mid-remap, where the schema cache hasn't refetched but + * row data already has the new mapping's value) would otherwise render + * `[object Object]` via `String(value)`. */ export function formatValueForInput(value: unknown, type: string): string { if (value === null || value === undefined) return '' @@ -69,6 +88,7 @@ export function formatValueForInput(value: unknown, type: string): string { return str } } + if (typeof value === 'object') return JSON.stringify(value) return String(value) } diff --git a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx index 9da13375a2..166f7dc06c 100644 --- a/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx +++ b/apps/sim/app/workspace/[workspaceId]/tables/components/import-csv-dialog/import-csv-dialog.tsx @@ -46,8 +46,8 @@ const CREATE_VALUE = '__ create __' /** * Converts the verbose backend error messages into a short, human-friendly * summary suitable for the modal footer. Specifically collapses repeated - * `Row N: Column "X" must be unique. Value "Y" already exists in row row_abc` - * segments into a single concise summary without internal row IDs. + * `Row N: Column "X" must be unique. Value "Y" already exists in row M` + * segments into a single concise summary. */ function summarizeImportError(message: string): string { const uniqueMatches = [ @@ -75,9 +75,9 @@ function summarizeImportError(message: string): string { return rowLimitMatch[0].trim() } - const stripped = message.replace(/\s+in row\s+row_[a-f0-9]+/gi, '').trim() - if (stripped.length > 180) return `${stripped.slice(0, 177)}...` - return stripped + const trimmed = message.trim() + if (trimmed.length > 180) return `${trimmed.slice(0, 177)}...` + return trimmed } interface ImportCsvDialogProps { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx index d197ba041d..97d286ac37 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/mcp-dynamic-args/mcp-dynamic-args.tsx @@ -1,7 +1,7 @@ import { useCallback } from 'react' import { createLogger } from '@sim/logger' import { useParams } from 'next/navigation' -import { Combobox, Label, Slider, Switch } from '@/components/emcn/components' +import { Combobox, FieldDivider, Label, Slider, Switch } from '@/components/emcn/components' import { LongInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/long-input/long-input' import { ShortInput } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/short-input/short-input' import { useSubBlockValue } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/hooks/use-sub-block-value' @@ -357,17 +357,7 @@ export function McpDynamicArgs({ )} {renderParameterInput(paramName, paramSchema as any)} - {showDivider && ( -
-
-
- )} + {showDivider && }
) })} diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx index 1753bc2da4..de88bd656b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/editor.tsx @@ -16,7 +16,7 @@ import { useParams } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' import { useStoreWithEqualityFn } from 'zustand/traditional' -import { Button, Loader, Tooltip } from '@/components/emcn' +import { Button, FieldDivider, Loader, Tooltip } from '@/components/emcn' import { captureEvent } from '@/lib/posthog/client' import { buildCanonicalIndex, @@ -542,9 +542,7 @@ export function Editor() { )} -
-
-
+ )} {subBlocks.length === 0 && !isWorkflowBlock ? ( @@ -605,11 +603,7 @@ export function Editor() { : undefined } /> - {showDivider && ( -
-
-
- )} + {showDivider && }
) })} @@ -660,9 +654,7 @@ export function Editor() { allowExpandInPreview={false} /> {index < advancedOnlySubBlocks.length - 1 && ( -
-
-
+ )}
) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index de69bbd886..fb40c2496b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -4,7 +4,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { useQueryClient } from '@tanstack/react-query' -import { History, Plus, Square } from 'lucide-react' +import { History, Plus } from 'lucide-react' import { useParams, useRouter } from 'next/navigation' import { usePostHog } from 'posthog-js/react' import { useShallow } from 'zustand/react/shallow' @@ -33,7 +33,7 @@ import { PopoverTrigger, Trash, } from '@/components/emcn' -import { Lock, Unlock, Upload } from '@/components/emcn/icons' +import { Lock, Square, Unlock, Upload } from '@/components/emcn/icons' import { VariableIcon } from '@/components/icons' import { requestJson } from '@/lib/api/client/request' import { diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx index 254642e50b..5975d80012 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/preview-editor.tsx @@ -24,6 +24,7 @@ import { ChevronDown, Code, Combobox, + FieldDivider, Input, Label, Tooltip, @@ -1442,15 +1443,7 @@ function PreviewEditorContent({ )}
-
-
-
+
)} @@ -1472,17 +1465,7 @@ function PreviewEditorContent({ subBlockValues={subBlockValues} disabled={true} /> - {index < visibleSubBlocks.length - 1 && ( -
-
-
- )} + {index < visibleSubBlocks.length - 1 && }
))} diff --git a/apps/sim/app/workspace/providers/socket-provider.tsx b/apps/sim/app/workspace/providers/socket-provider.tsx index 4faa626b78..fa43d4cb3e 100644 --- a/apps/sim/app/workspace/providers/socket-provider.tsx +++ b/apps/sim/app/workspace/providers/socket-provider.tsx @@ -56,26 +56,6 @@ interface PresenceUser { selection?: { type: 'block' | 'edge' | 'none'; id?: string } } -interface TableRowUpdatedEvent { - tableId: string - rowId: string - data: Record - /** Per-group execution state. Keyed by `WorkflowGroup.id`. */ - executions?: Record - position: number - updatedAt: string | number -} - -interface TableRowDeletedEvent { - tableId: string - rowId: string -} - -interface TableDeletedEvent { - tableId: string - timestamp: number -} - interface SocketContextType { socket: Socket | null isConnected: boolean @@ -84,13 +64,10 @@ interface SocketContextType { isRetryingWorkflowJoin: boolean authFailed: boolean currentWorkflowId: string | null - currentTableId: string | null currentSocketId: string | null presenceUsers: PresenceUser[] joinWorkflow: (workflowId: string) => void leaveWorkflow: () => void - joinTable: (tableId: string) => void - leaveTable: () => void retryConnection: () => void emitWorkflowOperation: ( workflowId: string, @@ -128,9 +105,6 @@ interface SocketContextType { onWorkflowDeployed: (handler: (data: any) => void) => void onOperationConfirmed: (handler: (data: any) => void) => void onOperationFailed: (handler: (data: any) => void) => void - onTableRowUpdated: (handler: (data: TableRowUpdatedEvent) => void) => void - onTableRowDeleted: (handler: (data: TableRowDeletedEvent) => void) => void - onTableDeleted: (handler: (data: TableDeletedEvent) => void) => void } const SocketContext = createContext({ @@ -141,13 +115,10 @@ const SocketContext = createContext({ isRetryingWorkflowJoin: false, authFailed: false, currentWorkflowId: null, - currentTableId: null, currentSocketId: null, presenceUsers: [], joinWorkflow: () => {}, leaveWorkflow: () => {}, - joinTable: () => {}, - leaveTable: () => {}, retryConnection: () => {}, emitWorkflowOperation: () => {}, emitSubblockUpdate: () => {}, @@ -165,9 +136,6 @@ const SocketContext = createContext({ onWorkflowDeployed: () => {}, onOperationConfirmed: () => {}, onOperationFailed: () => {}, - onTableRowUpdated: () => {}, - onTableRowDeleted: () => {}, - onTableDeleted: () => {}, }) export const useSocket = () => useContext(SocketContext) @@ -201,10 +169,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { urlWorkflowIdRef.current = urlWorkflowId explicitWorkflowIdRef.current = explicitWorkflowId - const [currentTableId, setCurrentTableId] = useState(null) - const currentTableIdRef = useRef(null) - currentTableIdRef.current = currentTableId - const eventHandlers = useRef<{ workflowOperation?: (data: any) => void subblockUpdate?: (data: any) => void @@ -217,9 +181,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { workflowDeployed?: (data: any) => void operationConfirmed?: (data: any) => void operationFailed?: (data: any) => void - tableRowUpdated?: (data: TableRowUpdatedEvent) => void - tableRowDeleted?: (data: TableRowDeletedEvent) => void - tableDeleted?: (data: TableDeletedEvent) => void }>({}) const positionUpdateTimeouts = useRef>(new Map()) @@ -422,10 +383,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { transport: socketInstance.io.engine?.transport?.name, }) executeJoinCommands(joinControllerRef.current.setConnected(true)) - // Re-join the table room after (re)connect so missed events resume. - if (currentTableIdRef.current) { - socketInstance.emit('join-table', { tableId: currentTableIdRef.current }) - } }) socketInstance.on('disconnect', (reason) => { @@ -604,34 +561,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.workflowDeployed?.(data) }) - socketInstance.on('join-table-success', ({ tableId }) => { - if (currentTableIdRef.current !== tableId) { - currentTableIdRef.current = tableId - setCurrentTableId(tableId) - } - logger.debug(`Joined table room ${tableId}`) - }) - - socketInstance.on('join-table-error', ({ tableId, error, code }) => { - logger.warn('join-table-error', { tableId, error, code }) - }) - - socketInstance.on('table-row-updated', (data: TableRowUpdatedEvent) => { - eventHandlers.current.tableRowUpdated?.(data) - }) - - socketInstance.on('table-row-deleted', (data: TableRowDeletedEvent) => { - eventHandlers.current.tableRowDeleted?.(data) - }) - - socketInstance.on('table-deleted', (data: TableDeletedEvent) => { - if (currentTableIdRef.current === data.tableId) { - currentTableIdRef.current = null - setCurrentTableId(null) - } - eventHandlers.current.tableDeleted?.(data) - }) - const rehydrateWorkflowStores = async (workflowId: string, workflowState: any) => { const [ { useOperationQueueStore }, @@ -840,33 +769,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { setExplicitWorkflowId(null) }, []) - const joinTable = useCallback((tableId: string) => { - const s = socketRef.current - if (!s) { - // Defer: when socket connects, the requestedTableId effect below re-emits join-table. - currentTableIdRef.current = tableId - setCurrentTableId(tableId) - return - } - // Idempotent: if we're already in this room, no-op. - if (currentTableIdRef.current === tableId && s.connected) return - // Switching tables: leave the previous room first. - if (currentTableIdRef.current && currentTableIdRef.current !== tableId) { - s.emit('leave-table', { tableId: currentTableIdRef.current }) - } - currentTableIdRef.current = tableId - setCurrentTableId(tableId) - s.emit('join-table', { tableId }) - }, []) - - const leaveTable = useCallback(() => { - const s = socketRef.current - const tableId = currentTableIdRef.current - currentTableIdRef.current = null - setCurrentTableId(null) - if (s && tableId) s.emit('leave-table', { tableId }) - }, []) - /** * Retry socket connection after auth failure. * Call this when user has re-authenticated (e.g., after login redirect). @@ -1115,18 +1017,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { eventHandlers.current.operationFailed = handler }, []) - const onTableRowUpdated = useCallback((handler: (data: TableRowUpdatedEvent) => void) => { - eventHandlers.current.tableRowUpdated = handler - }, []) - - const onTableRowDeleted = useCallback((handler: (data: TableRowDeletedEvent) => void) => { - eventHandlers.current.tableRowDeleted = handler - }, []) - - const onTableDeleted = useCallback((handler: (data: TableDeletedEvent) => void) => { - eventHandlers.current.tableDeleted = handler - }, []) - const contextValue = useMemo( () => ({ socket, @@ -1136,13 +1026,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { isRetryingWorkflowJoin, authFailed, currentWorkflowId, - currentTableId, currentSocketId, presenceUsers, joinWorkflow, leaveWorkflow, - joinTable, - leaveTable, retryConnection, emitWorkflowOperation, emitSubblockUpdate, @@ -1160,9 +1047,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onWorkflowDeployed, onOperationConfirmed, onOperationFailed, - onTableRowUpdated, - onTableRowDeleted, - onTableDeleted, }), [ socket, @@ -1172,13 +1056,10 @@ export function SocketProvider({ children, user }: SocketProviderProps) { isRetryingWorkflowJoin, authFailed, currentWorkflowId, - currentTableId, currentSocketId, presenceUsers, joinWorkflow, leaveWorkflow, - joinTable, - leaveTable, retryConnection, emitWorkflowOperation, emitSubblockUpdate, @@ -1196,9 +1077,6 @@ export function SocketProvider({ children, user }: SocketProviderProps) { onWorkflowDeployed, onOperationConfirmed, onOperationFailed, - onTableRowUpdated, - onTableRowDeleted, - onTableDeleted, ] ) diff --git a/apps/sim/background/resume-execution.ts b/apps/sim/background/resume-execution.ts index 9b04463aa4..f7bd79d2a3 100644 --- a/apps/sim/background/resume-execution.ts +++ b/apps/sim/background/resume-execution.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { task } from '@trigger.dev/sdk' +import type { RowData, RowExecutionMetadata } from '@/lib/table/types' import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager' const logger = createLogger('TriggerResumeExecution') @@ -33,6 +34,135 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { throw new Error(`Paused execution not found: ${pausedExecutionId}`) } + // If this paused execution belongs to a table cell, rehydrate the cell + // context so post-resume block outputs land on the same row + group as + // the original cell task. Without this, blocks that run after the human + // approves write nothing back to the table — the row silently truncates + // at the pause boundary. The original `parentExecutionId` is preserved + // on the cell's `executions[gid]` so it stays one logical execution + // across the pause/resume boundary. + const { findCellContextByExecutionId } = await import('@/lib/table/workflow-columns') + const cellContext = await findCellContextByExecutionId(parentExecutionId) + + let cellOnBlockComplete: ((blockId: string, output: unknown) => Promise) | undefined + let writeCellTerminal: + | ((status: 'completed' | 'error' | 'paused', error: string | null) => Promise) + | undefined + + if (cellContext) { + const { getTableById } = await import('@/lib/table/service') + const { writeWorkflowGroupState, buildOutputsByBlockId } = await import( + '@/lib/table/cell-write' + ) + const { pluckByPath } = await import('@/lib/table/pluck') + + const table = await getTableById(cellContext.tableId) + const group = table?.schema.workflowGroups?.find((g) => g.id === cellContext.groupId) + if (group) { + const outputsByBlockId = buildOutputsByBlockId(group) + const accumulatedData: RowData = {} + const blockErrors: Record = {} + const writeCtx = { + tableId: cellContext.tableId, + rowId: cellContext.rowId, + workspaceId: cellContext.workspaceId, + groupId: cellContext.groupId, + executionId: parentExecutionId, + requestId: `wfgrp-resume-${parentExecutionId}`, + } + let writeChain: Promise = Promise.resolve() + let terminalWritten = false + + cellOnBlockComplete = async (blockId, output) => { + const outputs = outputsByBlockId.get(blockId) + if (!outputs) return + const blockResult = + output && typeof output === 'object' && 'output' in (output as object) + ? (output as { output: unknown }).output + : output + const errorMessage = + blockResult && + typeof blockResult === 'object' && + typeof (blockResult as { error?: unknown }).error === 'string' + ? (blockResult as { error: string }).error + : null + if (errorMessage) { + blockErrors[blockId] = errorMessage + } else { + for (const out of outputs) { + const plucked = pluckByPath(blockResult, out.path) + if (plucked === undefined) continue + accumulatedData[out.columnName] = plucked as RowData[string] + } + } + const dataSnapshot: RowData = { ...accumulatedData } + const blockErrorsSnapshot = { ...blockErrors } + writeChain = writeChain + .then(async () => { + if (terminalWritten) return + const partial: RowExecutionMetadata = { + status: 'running', + executionId: parentExecutionId, + jobId: null, + workflowId: cellContext.workflowId, + error: null, + blockErrors: blockErrorsSnapshot, + } + await writeWorkflowGroupState(writeCtx, { + executionState: partial, + dataPatch: dataSnapshot, + }) + }) + .catch((err) => { + logger.warn( + `Resume per-block partial write failed (table=${cellContext.tableId} row=${cellContext.rowId} group=${cellContext.groupId}):`, + err + ) + }) + } + + writeCellTerminal = async (status, error) => { + terminalWritten = true + await writeChain.catch(() => {}) + // Paused → keep `pending` + sentinel jobId so eligibility predicates + // continue treating the row as in-flight while we wait on another + // pause. Mirrors the initial cell-task pause branch. + const terminal: RowExecutionMetadata = + status === 'paused' + ? { + status: 'pending', + executionId: parentExecutionId, + jobId: `paused-${parentExecutionId}`, + workflowId: cellContext.workflowId, + error: null, + blockErrors, + } + : { + status, + executionId: parentExecutionId, + jobId: null, + workflowId: cellContext.workflowId, + error, + runningBlockIds: [], + blockErrors, + } + await writeWorkflowGroupState(writeCtx, { + executionState: terminal, + dataPatch: accumulatedData, + }) + } + } else { + logger.warn( + 'Cell context found but table or group missing — falling back to plain resume', + { + parentExecutionId, + tableId: cellContext.tableId, + groupId: cellContext.groupId, + } + ) + } + } + const result = await PauseResumeManager.startResumeExecution({ resumeEntryId: payload.resumeEntryId, resumeExecutionId: payload.resumeExecutionId, @@ -40,8 +170,19 @@ export async function executeResumeJob(payload: ResumeExecutionPayload) { contextId: payload.contextId, resumeInput: payload.resumeInput, userId: payload.userId, + ...(cellOnBlockComplete ? { onBlockComplete: cellOnBlockComplete } : {}), }) + if (writeCellTerminal) { + if (result.status === 'paused') { + await writeCellTerminal('paused', null) + } else if (result.success) { + await writeCellTerminal('completed', null) + } else { + await writeCellTerminal('error', result.error ?? 'Workflow execution failed') + } + } + logger.info('Background resume execution completed', { resumeExecutionId, workflowId, diff --git a/apps/sim/background/workflow-column-execution.ts b/apps/sim/background/workflow-column-execution.ts index ba0b03519e..7c4b977d53 100644 --- a/apps/sim/background/workflow-column-execution.ts +++ b/apps/sim/background/workflow-column-execution.ts @@ -36,9 +36,9 @@ export async function executeWorkflowGroupCellJob( const { getTableById, getRowById, updateRow } = await import('@/lib/table/service') const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow') const { loadWorkflowFromNormalizedTables } = await import('@/lib/workflows/persistence/utils') - const { writeWorkflowGroupState, buildOutputsByBlockId } = await import( - '@/lib/table/cell-write' - ) + const { writeWorkflowGroupState, markWorkflowGroupPickedUp, buildOutputsByBlockId } = + await import('@/lib/table/cell-write') + const { stashCellContextForResume } = await import('@/lib/table/workflow-columns') const cellCtx = { tableId, rowId, workspaceId, groupId, executionId, requestId } const writeState = (executionState: RowExecutionMetadata, dataPatch?: RowData) => @@ -112,6 +112,16 @@ export async function executeWorkflowGroupCellJob( return } + // Flip `queued` → `running` to signal the worker has actually started. + // Bail out if the cancel-sticky guard rejects the write (a stop click + // landed between enqueue and pickup). + const queuedExec = row.executions?.[groupId] as RowExecutionMetadata | undefined + const pickedUp = await markWorkflowGroupPickedUp(cellCtx, { + workflowId, + jobId: queuedExec?.jobId ?? null, + }) + if (pickedUp === 'skipped') return + // Output columns produced by THIS group are skipped on input — they're // populated by the run we're starting. Other group's outputs ARE // included (they're plain primitives in `row.data` thanks to the @@ -267,6 +277,36 @@ export async function executeWorkflowGroupCellJob( terminalWritten = true await writeChain.catch(() => {}) + if (result.status === 'paused') { + // HITL pause: keep the row in `pending` so the renderer surfaces it + // the same way logs do, but stamp a sentinel jobId so the scheduler's + // eligibility predicate keeps treating the row as in-flight (no + // re-enqueue while we wait on a human). Resume worker rewrites this + // back to `completed`/`error` once the pause clears. + await writeState( + { + status: 'pending', + executionId, + jobId: `paused-${executionId}`, + workflowId, + error: null, + runningBlockIds: [], + blockErrors, + }, + accumulatedData + ) + await stashCellContextForResume({ + executionId, + tableId, + tableName, + rowId, + groupId, + workflowId, + workspaceId, + }) + return + } + await writeState( { status: result.success ? 'completed' : 'error', @@ -313,10 +353,10 @@ export const workflowGroupCellTask = task({ machine: 'medium-1x', retry: { maxAttempts: 1 }, // Combined with `concurrencyKey: tableId`, caps each table's sub-queue to - // 10 in-flight cell jobs while letting different tables run in parallel. + // 20 in-flight cell jobs while letting different tables run in parallel. queue: { name: 'workflow-group-cell', - concurrencyLimit: 10, + concurrencyLimit: 20, }, run: (payload: WorkflowGroupCellPayload, { signal }) => executeWorkflowGroupCellJob(payload, signal), diff --git a/apps/sim/components/emcn/components/field-divider/field-divider.tsx b/apps/sim/components/emcn/components/field-divider/field-divider.tsx new file mode 100644 index 0000000000..8d1f6b1021 --- /dev/null +++ b/apps/sim/components/emcn/components/field-divider/field-divider.tsx @@ -0,0 +1,42 @@ +import { cn } from '@/lib/core/utils/cn' + +const DASHED_DIVIDER_STYLE = { + backgroundImage: + 'repeating-linear-gradient(to right, var(--border) 0px, var(--border) 6px, transparent 6px, transparent 12px)', +} as const + +export interface FieldDividerProps extends React.HTMLAttributes { + /** + * Adds the `subblock-divider` marker class so the workflow editor's CSS + * (`globals.css` `:has()` rule) can hide the divider when adjacent subblocks + * render empty content. Default `false` — only the workflow editor needs it. + */ + subblockMarker?: boolean +} + +/** + * Dashed horizontal divider used between fields in form-style panels (the + * workflow editor's subblock list, the table column/workflow sidebars). Same + * visual as the existing `subblock-divider` pattern in `editor.tsx`, + * promoted here so consumers don't keep redefining the gradient style. + * + * @example + * ```tsx + * ... + * + * ... + * ``` + */ +function FieldDivider({ className, subblockMarker = false, ...props }: FieldDividerProps) { + return ( +
+
+
+ ) +} + +export { FieldDivider } diff --git a/apps/sim/components/emcn/components/index.ts b/apps/sim/components/emcn/components/index.ts index 0f30eeb09a..56f564bbe2 100644 --- a/apps/sim/components/emcn/components/index.ts +++ b/apps/sim/components/emcn/components/index.ts @@ -59,6 +59,7 @@ export { DropdownMenuTrigger, } from './dropdown-menu/dropdown-menu' export { Expandable, ExpandableContent } from './expandable/expandable' +export { FieldDivider, type FieldDividerProps } from './field-divider/field-divider' export { FormField, type FormFieldProps } from './form-field/form-field' export { Input, type InputProps, inputVariants } from './input/input' export { InputOTP, InputOTPGroup, InputOTPSeparator, InputOTPSlot } from './input-otp/input-otp' diff --git a/apps/sim/components/emcn/icons/index.ts b/apps/sim/components/emcn/icons/index.ts index 9def17d566..781a1a1b09 100644 --- a/apps/sim/components/emcn/icons/index.ts +++ b/apps/sim/components/emcn/icons/index.ts @@ -72,6 +72,7 @@ export { Server } from './server' export { Settings } from './settings' export { ShieldCheck } from './shield-check' export { Sim } from './sim' +export { Square } from './square' export { SquareArrowUpRight } from './square-arrow-up-right' export { Table } from './table' export { TableX } from './table-x' diff --git a/apps/sim/components/emcn/icons/square.tsx b/apps/sim/components/emcn/icons/square.tsx new file mode 100644 index 0000000000..66bea3a4a0 --- /dev/null +++ b/apps/sim/components/emcn/icons/square.tsx @@ -0,0 +1,27 @@ +import type { SVGProps } from 'react' + +/** + * Square icon (stroke/outline version) — used as the "stop" glyph in + * media-control-style buttons (per-row run/stop, table action bar, context + * menus). Same visual as `lucide-react`'s `Square` so existing call sites + * migrate without any visual change. + */ +export function Square(props: SVGProps) { + return ( + + ) +} diff --git a/apps/sim/hooks/queries/tables.ts b/apps/sim/hooks/queries/tables.ts index 34055ea83e..5314220f84 100644 --- a/apps/sim/hooks/queries/tables.ts +++ b/apps/sim/hooks/queries/tables.ts @@ -4,7 +4,6 @@ * React Query hooks for managing user-defined tables. */ -import { useEffect } from 'react' import { createLogger } from '@sim/logger' import { type InfiniteData, @@ -15,6 +14,7 @@ import { useQueryClient, } from '@tanstack/react-query' import { toast } from '@/components/emcn' +import { isValidationError } from '@/lib/api/client/errors' import { requestJson } from '@/lib/api/client/request' import type { ContractJsonResponse } from '@/lib/api/contracts' import { @@ -39,9 +39,10 @@ import { type InsertTableRowBodyInput, listTableRowsContract, listTablesContract, + type RunMode, renameTableContract, restoreTableContract, - runWorkflowGroupContract, + runColumnContract, type TableIdParamsInput, type TableRowParamsInput, type TableRowsQueryInput, @@ -67,7 +68,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupOutput, } from '@/lib/table' -import { useSocket } from '@/app/workspace/providers/socket-provider' +import { optimisticallyScheduleNewlyEligibleGroups } from '@/lib/table/deps' /** Short poll to surface running → completed transitions from the server without a dedicated realtime channel. */ const ROWS_POLL_INTERVAL_WHILE_RUNNING_MS = 1500 @@ -77,13 +78,20 @@ function hasRunningGroupExecution(rows: TableRow[] | undefined): boolean { for (const row of rows) { const executions = row.executions ?? {} for (const key in executions) { - const exec = executions[key] - if (exec?.status === 'running' || exec?.status === 'pending') return true + if (isOptimisticInFlight(executions[key])) return true } } return false } +function hasRunningGroupExecutionInPages(pages: TableRowsResponse[] | undefined): boolean { + if (!pages) return false + for (const page of pages) { + if (hasRunningGroupExecution(page.rows)) return true + } + return false +} + const logger = createLogger('TableQueries') type TableQueryScope = 'active' | 'archived' | 'all' @@ -224,12 +232,9 @@ interface InfiniteTableRowsParams { } /** - * Fetch a single page of rows for a table with pagination/filter/sort. - * - * Subscribes to the realtime `table-row-updated` / `table-row-deleted` socket - * events for this `tableId`; on receipt, merges the delta into every cached - * rows query for the table via `setQueriesData`. Polling stays as a fallback - * gated on `!isConnected` so a brief disconnect window doesn't go stale. + * Fetch a single page of rows for a table with pagination/filter/sort. Polls + * while any cell is in flight so cells reach their terminal state without a + * manual refresh. */ export function useTableRows({ workspaceId, @@ -249,97 +254,6 @@ export function useTableRows({ sort: sort ?? null, includeTotal, }) - const { - isConnected: socketConnected, - joinTable, - leaveTable, - onTableRowUpdated, - onTableRowDeleted, - } = useSocket() - - useEffect(() => { - if (!tableId) return - joinTable(tableId) - - onTableRowUpdated((event) => { - if (event.tableId !== tableId) return - // While an optimistic mutation is in flight, applying the socket delta - // could clobber the optimistic state — defer to onSettled invalidate. - // Mark stale without triggering a refetch (refetchType: 'none') so the - // refetch races neither the in-flight optimistic update nor any - // server-side post-response work the mutation is awaiting (e.g. backfill). - if (queryClient.isMutating() > 0) { - queryClient.invalidateQueries({ - queryKey: tableKeys.rowsRoot(tableId), - refetchType: 'none', - }) - return - } - queryClient.setQueriesData( - { queryKey: tableKeys.rowsRoot(tableId) }, - (current) => { - if (!current) return current - const incoming: TableRow = { - id: event.rowId, - data: event.data as RowData, - executions: (event.executions as RowExecutions) ?? {}, - position: event.position, - createdAt: '', - updatedAt: - typeof event.updatedAt === 'string' ? event.updatedAt : String(event.updatedAt), - } - const idx = current.rows.findIndex((r) => r.id === event.rowId) - if (idx === -1) { - const next = [...current.rows, incoming].sort((a, b) => a.position - b.position) - return { - ...current, - rows: next, - totalCount: current.totalCount === null ? null : current.totalCount + 1, - } - } - const merged = { - ...current.rows[idx], - data: incoming.data, - executions: incoming.executions, - updatedAt: incoming.updatedAt, - } - const next = [...current.rows] - next[idx] = merged - return { ...current, rows: next } - } - ) - }) - - onTableRowDeleted((event) => { - if (event.tableId !== tableId) return - if (queryClient.isMutating() > 0) { - queryClient.invalidateQueries({ - queryKey: tableKeys.rowsRoot(tableId), - refetchType: 'none', - }) - return - } - queryClient.setQueriesData( - { queryKey: tableKeys.rowsRoot(tableId) }, - (current) => { - if (!current) return current - const next = current.rows.filter((r) => r.id !== event.rowId) - if (next.length === current.rows.length) return current - return { - ...current, - rows: next, - totalCount: current.totalCount === null ? null : Math.max(0, current.totalCount - 1), - } - } - ) - }) - - return () => { - leaveTable() - } - // joinTable / leaveTable / on* are stable callbacks; tableId is the only real dep. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [tableId]) return useQuery({ queryKey: [...tableKeys.rowsRoot(tableId), paramsKey] as const, @@ -348,13 +262,8 @@ export function useTableRows({ enabled: Boolean(workspaceId && tableId) && enabled, staleTime: 30 * 1000, placeholderData: keepPreviousData, - // Polling is the fallback for when the socket isn't carrying updates. - // - Pause while any mutation is in flight (optimistic-update guard). - // - Skip while connected (sockets push every cell write). - // - Otherwise poll only while a cell is in `running` state, the original cadence. refetchInterval: (query) => { if (queryClient.isMutating() > 0) return false - if (socketConnected) return false return hasRunningGroupExecution(query.state.data?.rows) ? ROWS_POLL_INTERVAL_WHILE_RUNNING_MS : false @@ -378,6 +287,7 @@ export function useInfiniteTableRows({ sort, enabled = true, }: InfiniteTableRowsParams) { + const queryClient = useQueryClient() const paramsKey = JSON.stringify({ pageSize, filter: filter ?? null, @@ -404,6 +314,22 @@ export function useInfiniteTableRows({ }, enabled: Boolean(workspaceId && tableId) && enabled, staleTime: 30 * 1000, + /** + * Poll while any row has a `pending` or `running` group execution. + * Realtime sockets push every cell write, but cross-network paths + * (trigger.dev workers → realtime ECS, client through CloudFront/proxy) + * occasionally drop events. Polling at the running cadence is the + * safety net so cells reach their terminal state without a refresh. + * No polling when nothing is running and no polling while a mutation + * is in flight (optimistic-update guard). + */ + refetchInterval: (query) => { + if (queryClient.isMutating() > 0) return false + return hasRunningGroupExecutionInPages(query.state.data?.pages) + ? ROWS_POLL_INTERVAL_WHILE_RUNNING_MS + : false + }, + refetchIntervalInBackground: false, }) } @@ -458,6 +384,7 @@ export function useRenameTable(workspaceId: string) { }) }, onError: (error) => { + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: (_data, _error, variables) => { @@ -513,6 +440,11 @@ export function useCreateTableRow({ workspaceId, tableId }: RowMutationContext) reconcileCreatedRow(queryClient, tableId, row) }, + onError: (error) => { + // Validation errors are surfaced inline by the caller (see useUpdateColumn). + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateRowCount(queryClient, tableId) }, @@ -624,6 +556,10 @@ export function useBatchCreateTableRows({ workspaceId, tableId }: RowMutationCon }, }) }, + onError: (error) => { + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) + }, onSettled: () => { invalidateRowCount(queryClient, tableId) }, @@ -651,18 +587,31 @@ export function useUpdateTableRow({ workspaceId, tableId }: RowMutationContext) queryKey: tableKeys.rowsRoot(tableId), }) - patchCachedRows(queryClient, tableId, (row) => - row.id === rowId ? { ...row, data: { ...row.data, ...data } as RowData } : row - ) + const groups = + queryClient.getQueryData(tableKeys.detail(tableId))?.schema + .workflowGroups ?? [] + + patchCachedRows(queryClient, tableId, (row) => { + if (row.id !== rowId) return row + const patch = data as Partial + const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + return { + ...row, + data: { ...row.data, ...patch } as RowData, + ...(nextExecutions ? { executions: nextExecutions } : {}), + } + }) return { previousQueries } }, - onError: (_err, _vars, context) => { + onError: (error, _vars, context) => { if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data) } } + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) }, onSettled: () => { invalidateRowData(queryClient, tableId) @@ -698,21 +647,32 @@ export function useBatchUpdateTableRows({ workspaceId, tableId }: RowMutationCon }) const updateMap = new Map(updates.map((u) => [u.rowId, u.data])) + const groups = + queryClient.getQueryData(tableKeys.detail(tableId))?.schema + .workflowGroups ?? [] patchCachedRows(queryClient, tableId, (row) => { - const patch = updateMap.get(row.id) - if (!patch) return row - return { ...row, data: { ...row.data, ...patch } as RowData } + const raw = updateMap.get(row.id) + if (!raw) return row + const patch = raw as Partial + const nextExecutions = optimisticallyScheduleNewlyEligibleGroups(groups, row, patch) + return { + ...row, + data: { ...row.data, ...patch } as RowData, + ...(nextExecutions ? { executions: nextExecutions } : {}), + } }) return { previousQueries } }, - onError: (_err, _vars, context) => { + onError: (error, _vars, context) => { if (context?.previousQueries) { for (const [queryKey, data] of context.previousQueries) { queryClient.setQueryData(queryKey, data) } } + if (isValidationError(error)) return + toast.error(error.message, { duration: 5000 }) }, onSettled: () => { invalidateRowData(queryClient, tableId) @@ -792,6 +752,8 @@ export function useUpdateColumn({ workspaceId, tableId }: RowMutationContext) { }) }, onError: (error) => { + // Validation errors are surfaced as inline FieldErrors by the caller. + if (isValidationError(error)) return toast.error(error.message, { duration: 5000 }) }, onSettled: () => { @@ -870,7 +832,7 @@ export function useCancelTableRuns({ workspaceId, tableId }: RowMutationContext) const nextExecutions: RowExecutions = { ...executions } for (const gid in executions) { const exec = executions[gid] - if (exec.status !== 'running' && exec.status !== 'pending') continue + if (!isOptimisticInFlight(exec)) continue // Preserve blockErrors so cells that already errored keep their // Error rendering after the stop — only cells without a value or // error should flip to "Cancelled". @@ -1075,17 +1037,13 @@ export function useDeleteColumn({ workspaceId, tableId }: RowMutationContext) { }) } -interface RunGroupVariables { - groupId: string - /** Workflow id sourced from the group's config — used as a fallback for the - * optimistic execution `workflowId` field when the row hasn't run before. */ - workflowId: string - /** - * `all` — fire every dep-satisfied row (default). - * `incomplete` — only rows that have never run or whose last run ended in - * `failed`/`aborted`. Mirrored by the server-side filter. - */ - runMode?: 'all' | 'incomplete' +interface RunColumnVariables { + groupIds: string[] + /** `all` (default) fires every dep-satisfied row; `incomplete` skips rows + * whose last run completed successfully. */ + runMode?: RunMode + /** Restrict to these rows. Server applies the same eligibility predicate. */ + rowIds?: string[] } type InfiniteRowsCache = { pages: TableRowsResponse[]; pageParams: number[] } @@ -1169,47 +1127,82 @@ export function restoreCachedWorkflowCells( } /** - * Trigger a workflow-group run for every eligible row in the table. The server - * filters by deps; this hook optimistically flips each matching row's - * `executions[groupId]` to `pending` immediately so the UI doesn't lag the - * network round-trip. + * Optimistic exec patch — flips every targeted (group, row) execution to + * `pending` so the UI doesn't lag the round-trip. Server eligibility may skip + * some; refetch on settle reconciles. */ -export function useRunGroup({ workspaceId, tableId }: RowMutationContext) { +function buildPendingExec( + prev: RowExecutionMetadata | undefined, + workflowIdFallback?: string +): RowExecutionMetadata { + return { + status: 'pending', + executionId: prev?.executionId ?? null, + jobId: null, + workflowId: prev?.workflowId ?? workflowIdFallback ?? '', + error: null, + } +} + +/** Broader sibling of `isExecInFlight` from `lib/table/deps`: treats any + * `pending` (with or without a jobId) as in-flight. The optimistic-patch + * context uses this to avoid re-marking a cell we just flipped optimistically. + * The eligibility predicate uses the stricter version. */ +function isOptimisticInFlight(exec: RowExecutionMetadata | undefined): boolean { + return exec?.status === 'running' || exec?.status === 'queued' || exec?.status === 'pending' +} + +/** + * The single canonical run mutation. Every UI gesture (single cell, per-row + * Play, action-bar Play/Refresh, column-header menu) maps to a `groupIds` + + * optional `rowIds` shape. Optimistic patch flips targeted (row, group) cells + * to `pending`; refetch on settle reconciles. + */ +export function useRunColumn({ workspaceId, tableId }: RowMutationContext) { const queryClient = useQueryClient() return useMutation({ - mutationFn: async ({ groupId, runMode = 'all' }: RunGroupVariables) => { - return requestJson(runWorkflowGroupContract, { - params: { tableId, groupId }, - body: { workspaceId, runMode }, + mutationFn: async ({ groupIds, runMode = 'all', rowIds }: RunColumnVariables) => { + return requestJson(runColumnContract, { + params: { tableId }, + body: { + workspaceId, + groupIds, + runMode, + ...(rowIds && rowIds.length > 0 ? { rowIds } : {}), + }, }) }, - onMutate: async ({ groupId, workflowId, runMode = 'all' }) => { + onMutate: async ({ groupIds, runMode = 'all', rowIds }) => { + const targetRowIds = rowIds && rowIds.length > 0 ? new Set(rowIds) : null + const targetGroupIds = new Set(groupIds) const snapshots = await snapshotAndMutateRows(queryClient, tableId, (r) => { - const exec = r.executions?.[groupId] as RowExecutionMetadata | undefined - if (exec?.status === 'running' || exec?.status === 'pending') return null - // Mirror the server-side `incomplete` filter so the optimistic update - // doesn't flash `pending` on rows the server is going to skip. - if (runMode === 'incomplete' && exec?.status === 'completed') return null - const pending: RowExecutionMetadata = { - status: 'pending', - executionId: exec?.executionId ?? null, - jobId: null, - workflowId: exec?.workflowId ?? workflowId, - error: null, - } - return { - ...r, - executions: { ...(r.executions ?? {}), [groupId]: pending }, + if (targetRowIds && !targetRowIds.has(r.id)) return null + const executions = r.executions ?? {} + let changed = false + const next: RowExecutions = { ...executions } + for (const groupId of targetGroupIds) { + const exec = executions[groupId] as RowExecutionMetadata | undefined + if (isOptimisticInFlight(exec)) continue + if (runMode === 'incomplete' && exec?.status === 'completed') continue + next[groupId] = buildPendingExec(exec) + changed = true } + if (!changed) return null + return { ...r, executions: next } }) return { snapshots } }, onError: (_err, _variables, context) => { if (context?.snapshots) restoreCachedWorkflowCells(queryClient, context.snapshots) }, - onSettled: () => { - queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + onSettled: async () => { + // Cancel any in-flight poll first — without this, a poll started during + // the mutation but lands AFTER it resolves can clobber the optimistic + // patch with stale data, producing a queued → cancelled → queued flicker + // before the authoritative refetch arrives. + await queryClient.cancelQueries({ queryKey: tableKeys.rowsRoot(tableId) }) + await queryClient.invalidateQueries({ queryKey: tableKeys.rowsRoot(tableId) }) }, }) } @@ -1243,6 +1236,8 @@ interface UpdateWorkflowGroupVariables { dependencies?: WorkflowGroupDependencies outputs?: WorkflowGroupOutput[] newOutputColumns?: UpdateWorkflowGroupBodyInput['newOutputColumns'] + mappingUpdates?: UpdateWorkflowGroupBodyInput['mappingUpdates'] + autoRun?: boolean } export function useUpdateWorkflowGroup({ workspaceId, tableId }: RowMutationContext) { diff --git a/apps/sim/lib/api/client/errors.ts b/apps/sim/lib/api/client/errors.ts index 9fa4dc5b18..ea61e501ee 100644 --- a/apps/sim/lib/api/client/errors.ts +++ b/apps/sim/lib/api/client/errors.ts @@ -25,3 +25,83 @@ export class ApiClientError extends Error { export function isApiClientError(error: unknown): error is ApiClientError { return error instanceof ApiClientError } + +export interface ValidationIssue { + /** Path of the failing field, e.g. ['updates', 'name']. */ + path: ReadonlyArray + /** Human-readable message — uses the schema's custom error string when set. */ + message: string +} + +interface UnknownIssue { + path?: unknown + message?: unknown +} + +function normalizeIssue(raw: unknown): ValidationIssue | null { + if (!raw || typeof raw !== 'object') return null + const { path, message } = raw as UnknownIssue + if (typeof message !== 'string' || message.length === 0) return null + if (!Array.isArray(path)) return null + const cleanPath = path.filter( + (segment): segment is string | number => + typeof segment === 'string' || typeof segment === 'number' + ) + return { path: cleanPath, message } +} + +/** + * Pull a list of validation issues out of an unknown error. Recognises both + * shapes the boundary produces: + * + * - Client-side contract validation: `requestJson` calls `schema.parse(input)` + * before fetch; failure throws a raw `ZodError` whose `.issues` is the array. + * - Server-side contract validation: route returns `{ error, details: [...] }`, + * which `requestJson` re-throws as `ApiClientError` carrying the body. + * + * Returns an empty array when the error isn't a recognised validation shape so + * callers can fall back to toast/log paths. + */ +export function extractValidationIssues(error: unknown): ValidationIssue[] { + if (!error || typeof error !== 'object') return [] + + if (isApiClientError(error)) { + const body = error.body + if (body && typeof body === 'object') { + const details = (body as { details?: unknown }).details + if (Array.isArray(details)) { + return details.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null) + } + } + return [] + } + + const issues = (error as { issues?: unknown }).issues + if (Array.isArray(issues)) { + return issues.map(normalizeIssue).filter((i): i is ValidationIssue => i !== null) + } + return [] +} + +/** + * Match a single issue by suffix path. `pathSuffix` lets callers ignore the + * outer body wrapper — `findValidationIssue(err, ['name'])` matches both + * `path: ['name']` and `path: ['updates', 'name']`. + */ +export function findValidationIssue( + error: unknown, + pathSuffix: ReadonlyArray +): ValidationIssue | null { + const issues = extractValidationIssues(error) + for (const issue of issues) { + if (issue.path.length < pathSuffix.length) continue + const tail = issue.path.slice(issue.path.length - pathSuffix.length) + if (tail.every((segment, i) => segment === pathSuffix[i])) return issue + } + return null +} + +/** True when the error is a recognised validation failure (client or server). */ +export function isValidationError(error: unknown): boolean { + return extractValidationIssues(error).length > 0 +} diff --git a/apps/sim/lib/api/contracts/tables.ts b/apps/sim/lib/api/contracts/tables.ts index 05248757a6..40733c09a8 100644 --- a/apps/sim/lib/api/contracts/tables.ts +++ b/apps/sim/lib/api/contracts/tables.ts @@ -710,7 +710,7 @@ export const deleteTableRowsContract = defineRouteContract({ // ============================================================================ // Workflow group contracts (`/api/table/[tableId]/groups`, `/cancel-runs`, -// `/groups/[groupId]/run`, `/rows/[rowId]/run-workflow-group`) +// `/columns/run`, `/rows/run`, `/rows/[rowId]/cells/[groupId]/run`) // ============================================================================ const workflowGroupOutputSchema = z.object({ @@ -721,7 +721,6 @@ const workflowGroupOutputSchema = z.object({ const workflowGroupDependenciesSchema = z.object({ columns: z.array(z.string()).optional(), - workflowGroups: z.array(z.string()).optional(), }) const workflowGroupOutputColumnSchema = z.object({ @@ -744,6 +743,11 @@ export const addWorkflowGroupBodySchema = z.object({ name: z.string().optional(), dependencies: workflowGroupDependenciesSchema.optional(), outputs: z.array(workflowGroupOutputSchema).min(1), + /** When `false`, the group never auto-fires from the scheduler — it can + * only be triggered manually. Defaults to `true`. Persisted on the + * group; distinct from the top-level `autoRun` below which is a + * one-shot "schedule existing rows on creation" flag. */ + autoRun: z.boolean().optional(), }), outputColumns: z.array(workflowGroupOutputColumnSchema).min(1), /** When false, skip auto-scheduling existing rows after the group is added. @@ -752,6 +756,21 @@ export const addWorkflowGroupBodySchema = z.object({ autoRun: z.boolean().optional(), }) +/** + * Re-points an existing column to a different workflow output. Use when the + * user changes which `(blockId, path)` flows into a column they already have, + * without restructuring the rest of the group's outputs. Distinct from the + * `outputs` add/remove diff: the column keeps its identity, type, deps, and + * row position; only its source mapping changes. Existing row values for the + * column are backfilled from saved execution logs at the new `(blockId, path)` + * — rows whose log has no value for the new mapping end up empty. + */ +const workflowGroupMappingUpdateSchema = z.object({ + columnName: z.string().min(1), + blockId: z.string().min(1), + path: z.string().min(1), +}) + export const updateWorkflowGroupBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), groupId: z.string().min(1), @@ -760,6 +779,14 @@ export const updateWorkflowGroupBodySchema = z.object({ dependencies: workflowGroupDependenciesSchema.optional(), outputs: z.array(workflowGroupOutputSchema).optional(), newOutputColumns: z.array(workflowGroupOutputColumnSchema).optional(), + /** + * Per-column mapping swaps: keep the column, change the source `(blockId, + * path)`. Applied before the `outputs` add/remove diff. Each entry's + * `columnName` must already exist in the group's outputs. + */ + mappingUpdates: z.array(workflowGroupMappingUpdateSchema).optional(), + /** Toggle the group's persisted auto-run flag. Omit to leave unchanged. */ + autoRun: z.boolean().optional(), }) export const deleteWorkflowGroupBodySchema = z.object({ @@ -840,7 +867,7 @@ export const cancelTableRunsContract = defineRouteContract({ }) /** - * Run modes for `POST /api/table/[tableId]/groups/[groupId]/run`: + * Run modes for `POST /api/table/[tableId]/columns/run`: * - `all` — every dep-satisfied row not already running/pending * - `incomplete` — same, but additionally restricted to rows whose group has * never run, or whose last run ended in `failed`/`aborted` @@ -848,46 +875,35 @@ export const cancelTableRunsContract = defineRouteContract({ * Field is named `runMode` (not `mode`) to disambiguate from the table-import * `mode` arg (`append` / `replace`) which lives on a different op. */ -export const runWorkflowGroupBodySchema = z.object({ +/** + * Run a set of workflow groups across the table or a row subset. The single + * canonical user-driven run op — every UI gesture (single cell, per-row Play, + * action-bar Play/Refresh, column-header menu) reduces to a `groupIds` + + * optional `rowIds` shape. AI uses the `run_column` tool op. + */ +export const runColumnBodySchema = z.object({ workspaceId: z.string().min(1, 'Workspace ID is required'), + groupIds: z.array(z.string().min(1)).min(1), runMode: z.enum(['all', 'incomplete']).default('all'), - /** Optional row scope. When provided, only these rows are candidates — the - * same eligibility predicate (deps satisfied, not in-flight, runMode filter) - * still applies, so a passed-in row that's mid-run or has unmet deps is - * silently skipped. Omit to run across the entire table. */ rowIds: z.array(z.string().min(1)).min(1).optional(), }) -export const runWorkflowGroupContract = defineRouteContract({ +export const runColumnContract = defineRouteContract({ method: 'POST', - path: '/api/table/[tableId]/groups/[groupId]/run', - params: groupIdParamsSchema, - body: runWorkflowGroupBodySchema, + path: '/api/table/[tableId]/columns/run', + params: tableIdParamsSchema, + body: runColumnBodySchema, response: { mode: 'json', schema: successResponseSchema(z.object({ triggered: z.number() })), }, }) -export const runRowWorkflowGroupBodySchema = z.object({ - workspaceId: z.string().min(1, 'Workspace ID is required'), - groupId: z.string().min(1, 'Group ID is required'), -}) - -export const runRowWorkflowGroupContract = defineRouteContract({ - method: 'POST', - path: '/api/table/[tableId]/rows/[rowId]/run-workflow-group', - params: tableRowParamsSchema, - body: runRowWorkflowGroupBodySchema, - response: { - mode: 'json', - schema: successResponseSchema(z.object({ executionId: z.string() })), - }, -}) - export type AddWorkflowGroupBodyInput = z.input export type UpdateWorkflowGroupBodyInput = z.input export type DeleteWorkflowGroupBodyInput = z.input export type CancelTableRunsBodyInput = z.input -export type RunWorkflowGroupBodyInput = z.input -export type RunRowWorkflowGroupBodyInput = z.input +export type RunColumnBodyInput = z.input +/** Shared `runMode` union — used by every UI / hook / Mothership site that + * builds a run-column payload. Single source of truth for the literal pair. */ +export type RunMode = NonNullable diff --git a/apps/sim/lib/billing/cleanup-dispatcher.ts b/apps/sim/lib/billing/cleanup-dispatcher.ts index b752e72551..e1dea22e95 100644 --- a/apps/sim/lib/billing/cleanup-dispatcher.ts +++ b/apps/sim/lib/billing/cleanup-dispatcher.ts @@ -1,13 +1,13 @@ import { db } from '@sim/db' import { organization, subscription, workspace } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { tasks } from '@trigger.dev/sdk' import { and, eq, inArray, isNotNull, isNull, sql } from 'drizzle-orm' import { type PlanCategory, sqlIsPaid, sqlIsPro, sqlIsTeam } from '@/lib/billing/plan-helpers' import { ENTITLED_SUBSCRIPTION_STATUSES } from '@/lib/billing/subscriptions/utils' import { getJobQueue } from '@/lib/core/async-jobs' import { shouldExecuteInline } from '@/lib/core/async-jobs/config' +import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { isTriggerAvailable } from '@/lib/knowledge/documents/service' const logger = createLogger('RetentionDispatcher') @@ -143,53 +143,18 @@ export async function resolveCleanupScope( } } -type RunnerFn = (payload: CleanupJobPayload) => Promise - -async function getInlineRunner(jobType: CleanupJobType): Promise { - switch (jobType) { - case 'cleanup-logs': { - const { runCleanupLogs } = await import('@/background/cleanup-logs') - return runCleanupLogs - } - case 'cleanup-soft-deletes': { - const { runCleanupSoftDeletes } = await import('@/background/cleanup-soft-deletes') - return runCleanupSoftDeletes - } - case 'cleanup-tasks': { - const { runCleanupTasks } = await import('@/background/cleanup-tasks') - return runCleanupTasks - } - } -} - -/** - * When the job queue backend is "database" (no Trigger.dev, no BullMQ), the - * enqueued rows just sit in async_jobs forever. Run them inline as fire-and-forget - * promises, following the same pattern as the workflow execution API route. - */ -async function runInlineIfNeeded( - jobQueue: Awaited>, - jobType: CleanupJobType, - jobId: string, - payload: CleanupJobPayload -): Promise { - if (!shouldExecuteInline()) return - const runner = await getInlineRunner(jobType) - void (async () => { - try { - await jobQueue.startJob(jobId) - await runner(payload) - await jobQueue.completeJob(jobId, null) - } catch (error) { - const errorMessage = toError(error).message - logger.error(`[${jobType}] Inline job ${jobId} failed`, { error: errorMessage }) - try { - await jobQueue.markJobFailed(jobId, errorMessage) - } catch (markErr) { - logger.error(`[${jobType}] Failed to mark job ${jobId} as failed`, { markErr }) - } +async function buildCleanupRunner(jobType: CleanupJobType): Promise { + const cleanupRunner = await (async () => { + switch (jobType) { + case 'cleanup-logs': + return (await import('@/background/cleanup-logs')).runCleanupLogs + case 'cleanup-soft-deletes': + return (await import('@/background/cleanup-soft-deletes')).runCleanupSoftDeletes + case 'cleanup-tasks': + return (await import('@/background/cleanup-tasks')).runCleanupTasks } })() + return ((payload) => cleanupRunner(payload as CleanupJobPayload)) as EnqueueOptions['runner'] } /** @@ -214,9 +179,10 @@ export async function dispatchCleanupJobs( for (const plan of plansWithDefaults) { const payload: CleanupJobPayload = { plan } - const jobId = await jobQueue.enqueue(jobType, payload) + const jobId = await jobQueue.enqueue(jobType, payload, { + runner: shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined, + }) jobIds.push(jobId) - await runInlineIfNeeded(jobQueue, jobType, jobId, payload) } // Enterprise: workspaces whose owning org is on an active enterprise sub and @@ -270,12 +236,11 @@ export async function dispatchCleanupJobs( } } else { // Fallback: parallel enqueue via abstraction + const inlineRunner = shouldExecuteInline() ? await buildCleanupRunner(jobType) : undefined const results = await Promise.allSettled( enterpriseRows.map(async (row) => { const payload: CleanupJobPayload = { plan: 'enterprise', workspaceId: row.id } - const jobId = await jobQueue.enqueue(jobType, payload) - await runInlineIfNeeded(jobQueue, jobType, jobId, payload) - return jobId + return jobQueue.enqueue(jobType, payload, { runner: inlineRunner }) }) ) diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts index 5083432bd8..616244da29 100644 --- a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -2792,6 +2792,15 @@ export const UserTable: ToolCatalogEntry = { type: 'object', description: 'Arguments for the operation', properties: { + autoRun: { + type: 'boolean', + description: + "Optional flag for add_workflow_group and update_workflow_group. On add: when true, existing rows whose dependencies are already filled run immediately; default false stages the group silently — call run_column when ready to fire rows. On update: toggle a group's auto-fire behavior on an existing group — false stages it (no auto-runs on dep satisfaction; only manual run_column fires rows), true re-enables auto-fire (rows whose deps fill will be scheduled). Set true on add only if the user explicitly asked to start runs immediately.", + }, + blockId: { + type: 'string', + description: 'Source block ID inside the workflow. Used by add_workflow_group_output.', + }, column: { type: 'object', description: 'Column definition for add_column: { name, type, unique?, position? }', @@ -2799,7 +2808,7 @@ export const UserTable: ToolCatalogEntry = { columnName: { type: 'string', description: - 'Column name (required for rename_column, update_column; use columnNames array for batch delete_column)', + 'Column name. Required for rename_column, update_column, and delete_workflow_group_output (the bound column to drop). Optional for add_workflow_group_output (auto-derived from path when omitted). Use columnNames array for batch delete_column.', }, columnNames: { type: 'array', @@ -2810,6 +2819,19 @@ export const UserTable: ToolCatalogEntry = { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', }, + dependencies: { + type: 'object', + description: + "Dependencies the workflow group requires before running a row. { columns?: string[] } lists input column names that must be filled. Workflow output columns count too — depend on the column produced by an upstream group, not the group itself. The dep graph is column-induced. A group can't depend on its own output columns. Used by add_workflow_group and update_workflow_group.", + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the group runs. Plain columns and upstream-group output columns are both valid here.', + items: { type: 'string' }, + }, + }, + }, description: { type: 'string', description: "Table description (optional for 'create')" }, fileId: { type: 'string', @@ -2826,6 +2848,17 @@ export const UserTable: ToolCatalogEntry = { description: 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', }, + groupId: { + type: 'string', + description: + 'Workflow group ID. Required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output.', + }, + groupIds: { + type: 'array', + description: + 'Array of workflow group IDs. Required for run_column — non-empty list of columns to run.', + items: { type: 'string' }, + }, limit: { type: 'number', description: 'Maximum rows to return or affect (optional, default 100)', @@ -2833,11 +2866,29 @@ export const UserTable: ToolCatalogEntry = { mapping: { type: 'object', description: - 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. When omitted, headers are auto-matched by sanitized name (case-insensitive fallback). Use null to skip a CSV column.', + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. A string maps the CSV header to that table column; null skips that CSV header (it won\'t be imported); omit a header entirely to fall back to auto-mapping by sanitized name (case-insensitive).', additionalProperties: { - type: 'string', + type: ['string', 'null'], description: - 'Target column name on the table. Use null to skip this CSV header instead of a column name.', + "Target column name on the table. null skips that CSV header (it won't be imported); omit it entirely to fall back to auto-mapping.", + }, + }, + mappingUpdates: { + type: 'array', + description: + "Surgical per-output remap for update_workflow_group. Each entry repoints ONE existing output column to a new (blockId, path) without touching the rest of the group. Use this when the user wants to swap which block output flows into a column (e.g. 'point the score column at the new agent block') — the bound column stays, only its source pair changes. Stale row data for remapped columns is cleared and backfilled from saved execution logs where possible (no re-run needed). Use this INSTEAD of resending the full outputs array when the change is scoped to a few columns; use outputs only when the whole group's output set is being restructured. Discover valid (blockId, path) pairs via list_workflow_outputs first.", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'New source block ID for this column.' }, + columnName: { + type: 'string', + description: + 'The existing output column to remap. Must already be bound to this group.', + }, + path: { type: 'string', description: 'New dotted output path on the new block.' }, + }, + required: ['columnName', 'blockId', 'path'], }, }, mode: { @@ -2868,6 +2919,33 @@ export const UserTable: ToolCatalogEntry = { description: 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use .csv for tabular exports. Use a flat path like "files/export.csv" — nested paths are not supported.', }, + outputs: { + type: 'array', + description: + "Outputs to surface as columns. Each entry maps a workflow block output to a table column: { blockId, path, columnName?, columnType? }. blockId is the source block; path is the dotted output path; columnName auto-derives from the path when omitted; columnType defaults from the leaf type when omitted. Used by add_workflow_group for the full output set. For update_workflow_group, prefer add_workflow_group_output / delete_workflow_group_output for individual outputs and mappingUpdates for surgical remap; only pass outputs here when restructuring the whole group's output set in one shot. If unsure about valid (blockId, path) pairs, call list_workflow_outputs first — paths are validated against the live workflow and invalid picks return an error with the valid options. For Agent blocks with structured outputs, the structured fields appear as top-level paths (e.g. summary, industry); there is NO response.content path on a structured agent.", + items: { + type: 'object', + properties: { + blockId: { type: 'string', description: 'Source block ID inside the workflow.' }, + columnName: { + type: 'string', + description: + 'Optional target column name. Auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type. Defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { type: 'string', description: 'Dotted output path on the block.' }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block. Used by add_workflow_group_output.', + }, position: { type: 'integer', description: @@ -2881,21 +2959,36 @@ export const UserTable: ToolCatalogEntry = { }, rowId: { type: 'string', - description: 'Row ID (required for get_row, update_row, delete_row)', + description: + "Row ID. Required for get_row, update_row, delete_row, and for cancel_table_runs when scope:'row'.", }, rowIds: { type: 'array', - description: 'Array of row IDs to delete (for batch_delete_rows)', + description: + 'Array of row IDs. Used by batch_delete_rows (rows to delete) and run_column (optional row scope — when omitted, runs across the whole table; when provided, only these rows are candidates and the server eligibility predicate still applies).', + items: { type: 'string' }, }, rows: { type: 'array', description: 'Array of row data objects (required for batch_insert_rows)', }, + runMode: { + type: 'string', + description: + "Run mode for run_column. 'incomplete' (default) re-runs only rows that never produced output or last failed; 'all' re-runs every dep-satisfied row.", + enum: ['incomplete', 'all'], + }, schema: { type: 'object', description: "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", }, + scope: { + type: 'string', + description: + "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", + enum: ['all', 'row'], + }, sort: { type: 'object', description: @@ -2925,6 +3018,11 @@ export const UserTable: ToolCatalogEntry = { description: 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName)', }, + workflowId: { + type: 'string', + description: + 'ID of the workflow (required for add_workflow_group and list_workflow_outputs).', + }, }, }, operation: { @@ -2951,6 +3049,14 @@ export const UserTable: ToolCatalogEntry = { 'rename_column', 'delete_column', 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + 'list_workflow_outputs', ], }, }, @@ -3289,6 +3395,14 @@ export const UserTableOperation = { renameColumn: 'rename_column', deleteColumn: 'delete_column', updateColumn: 'update_column', + addWorkflowGroup: 'add_workflow_group', + updateWorkflowGroup: 'update_workflow_group', + deleteWorkflowGroup: 'delete_workflow_group', + addWorkflowGroupOutput: 'add_workflow_group_output', + deleteWorkflowGroupOutput: 'delete_workflow_group_output', + runColumn: 'run_column', + cancelTableRuns: 'cancel_table_runs', + listWorkflowOutputs: 'list_workflow_outputs', } as const export type UserTableOperation = (typeof UserTableOperation)[keyof typeof UserTableOperation] @@ -3314,6 +3428,14 @@ export const UserTableOperationValues = [ UserTableOperation.renameColumn, UserTableOperation.deleteColumn, UserTableOperation.updateColumn, + UserTableOperation.addWorkflowGroup, + UserTableOperation.updateWorkflowGroup, + UserTableOperation.deleteWorkflowGroup, + UserTableOperation.addWorkflowGroupOutput, + UserTableOperation.deleteWorkflowGroupOutput, + UserTableOperation.runColumn, + UserTableOperation.cancelTableRuns, + UserTableOperation.listWorkflowOutputs, ] as const export const WorkspaceFileOperation = { diff --git a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts index 5a2e2a196d..8a7aebc3e7 100644 --- a/apps/sim/lib/copilot/generated/tool-schemas-v1.ts +++ b/apps/sim/lib/copilot/generated/tool-schemas-v1.ts @@ -2594,6 +2594,16 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Arguments for the operation', properties: { + autoRun: { + type: 'boolean', + description: + "Optional flag for add_workflow_group and update_workflow_group. On add: when true, existing rows whose dependencies are already filled run immediately; default false stages the group silently — call run_column when ready to fire rows. On update: toggle a group's auto-fire behavior on an existing group — false stages it (no auto-runs on dep satisfaction; only manual run_column fires rows), true re-enables auto-fire (rows whose deps fill will be scheduled). Set true on add only if the user explicitly asked to start runs immediately.", + }, + blockId: { + type: 'string', + description: + 'Source block ID inside the workflow. Used by add_workflow_group_output.', + }, column: { type: 'object', description: 'Column definition for add_column: { name, type, unique?, position? }', @@ -2601,7 +2611,7 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { columnName: { type: 'string', description: - 'Column name (required for rename_column, update_column; use columnNames array for batch delete_column)', + 'Column name. Required for rename_column, update_column, and delete_workflow_group_output (the bound column to drop). Optional for add_workflow_group_output (auto-derived from path when omitted). Use columnNames array for batch delete_column.', }, columnNames: { type: 'array', @@ -2612,6 +2622,21 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { type: 'object', description: 'Row data as key-value pairs (required for insert_row, update_row)', }, + dependencies: { + type: 'object', + description: + "Dependencies the workflow group requires before running a row. { columns?: string[] } lists input column names that must be filled. Workflow output columns count too — depend on the column produced by an upstream group, not the group itself. The dep graph is column-induced. A group can't depend on its own output columns. Used by add_workflow_group and update_workflow_group.", + properties: { + columns: { + type: 'array', + description: + 'Input column names that must be filled before the group runs. Plain columns and upstream-group output columns are both valid here.', + items: { + type: 'string', + }, + }, + }, + }, description: { type: 'string', description: "Table description (optional for 'create')", @@ -2631,6 +2656,19 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'MongoDB-style filter for query_rows, update_rows_by_filter, delete_rows_by_filter', }, + groupId: { + type: 'string', + description: + 'Workflow group ID. Required for update_workflow_group, delete_workflow_group, add_workflow_group_output, delete_workflow_group_output.', + }, + groupIds: { + type: 'array', + description: + 'Array of workflow group IDs. Required for run_column — non-empty list of columns to run.', + items: { + type: 'string', + }, + }, limit: { type: 'number', description: 'Maximum rows to return or affect (optional, default 100)', @@ -2638,11 +2676,35 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { mapping: { type: 'object', description: - 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. When omitted, headers are auto-matched by sanitized name (case-insensitive fallback). Use null to skip a CSV column.', + 'Optional explicit CSV-header → table-column mapping for import_file, as { "csvHeader": "columnName" | null }. A string maps the CSV header to that table column; null skips that CSV header (it won\'t be imported); omit a header entirely to fall back to auto-mapping by sanitized name (case-insensitive).', additionalProperties: { - type: 'string', + type: ['string', 'null'], description: - 'Target column name on the table. Use null to skip this CSV header instead of a column name.', + "Target column name on the table. null skips that CSV header (it won't be imported); omit it entirely to fall back to auto-mapping.", + }, + }, + mappingUpdates: { + type: 'array', + description: + "Surgical per-output remap for update_workflow_group. Each entry repoints ONE existing output column to a new (blockId, path) without touching the rest of the group. Use this when the user wants to swap which block output flows into a column (e.g. 'point the score column at the new agent block') — the bound column stays, only its source pair changes. Stale row data for remapped columns is cleared and backfilled from saved execution logs where possible (no re-run needed). Use this INSTEAD of resending the full outputs array when the change is scoped to a few columns; use outputs only when the whole group's output set is being restructured. Discover valid (blockId, path) pairs via list_workflow_outputs first.", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'New source block ID for this column.', + }, + columnName: { + type: 'string', + description: + 'The existing output column to remap. Must already be bound to this group.', + }, + path: { + type: 'string', + description: 'New dotted output path on the new block.', + }, + }, + required: ['columnName', 'blockId', 'path'], }, }, mode: { @@ -2679,6 +2741,39 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Pipe query_rows results directly to a NEW workspace file. The format is auto-inferred from the file extension: .csv → CSV, .json → JSON, .md → Markdown, etc. Use .csv for tabular exports. Use a flat path like "files/export.csv" — nested paths are not supported.', }, + outputs: { + type: 'array', + description: + "Outputs to surface as columns. Each entry maps a workflow block output to a table column: { blockId, path, columnName?, columnType? }. blockId is the source block; path is the dotted output path; columnName auto-derives from the path when omitted; columnType defaults from the leaf type when omitted. Used by add_workflow_group for the full output set. For update_workflow_group, prefer add_workflow_group_output / delete_workflow_group_output for individual outputs and mappingUpdates for surgical remap; only pass outputs here when restructuring the whole group's output set in one shot. If unsure about valid (blockId, path) pairs, call list_workflow_outputs first — paths are validated against the live workflow and invalid picks return an error with the valid options. For Agent blocks with structured outputs, the structured fields appear as top-level paths (e.g. summary, industry); there is NO response.content path on a structured agent.", + items: { + type: 'object', + properties: { + blockId: { + type: 'string', + description: 'Source block ID inside the workflow.', + }, + columnName: { + type: 'string', + description: + 'Optional target column name. Auto-derived from the path when omitted.', + }, + columnType: { + type: 'string', + description: 'Optional column type. Defaults from the leaf type when omitted.', + enum: ['string', 'number', 'boolean', 'date', 'json'], + }, + path: { + type: 'string', + description: 'Dotted output path on the block.', + }, + }, + required: ['blockId', 'path'], + }, + }, + path: { + type: 'string', + description: 'Dotted output path on the block. Used by add_workflow_group_output.', + }, position: { type: 'integer', description: @@ -2694,21 +2789,38 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { }, rowId: { type: 'string', - description: 'Row ID (required for get_row, update_row, delete_row)', + description: + "Row ID. Required for get_row, update_row, delete_row, and for cancel_table_runs when scope:'row'.", }, rowIds: { type: 'array', - description: 'Array of row IDs to delete (for batch_delete_rows)', + description: + 'Array of row IDs. Used by batch_delete_rows (rows to delete) and run_column (optional row scope — when omitted, runs across the whole table; when provided, only these rows are candidates and the server eligibility predicate still applies).', + items: { + type: 'string', + }, }, rows: { type: 'array', description: 'Array of row data objects (required for batch_insert_rows)', }, + runMode: { + type: 'string', + description: + "Run mode for run_column. 'incomplete' (default) re-runs only rows that never produced output or last failed; 'all' re-runs every dep-satisfied row.", + enum: ['incomplete', 'all'], + }, schema: { type: 'object', description: "Table schema with columns array (required for 'create'). Each column: { name, type, unique? }", }, + scope: { + type: 'string', + description: + "Cancellation scope for cancel_table_runs. 'all' cancels in-flight runs across the whole table; 'row' cancels only the row identified by rowId.", + enum: ['all', 'row'], + }, sort: { type: 'object', description: @@ -2740,6 +2852,11 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { description: 'Map of rowId to value for single-column batch update: { "rowId1": val1, "rowId2": val2 } (for batch_update_rows with columnName)', }, + workflowId: { + type: 'string', + description: + 'ID of the workflow (required for add_workflow_group and list_workflow_outputs).', + }, }, }, operation: { @@ -2766,6 +2883,14 @@ export const TOOL_RUNTIME_SCHEMAS: Record = { 'rename_column', 'delete_column', 'update_column', + 'add_workflow_group', + 'update_workflow_group', + 'delete_workflow_group', + 'add_workflow_group_output', + 'delete_workflow_group_output', + 'run_column', + 'cancel_table_runs', + 'list_workflow_outputs', ], }, }, diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index 22c807dfdb..d3b768d699 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -56,7 +56,7 @@ import type { WorkflowGroupDependencies, WorkflowGroupOutput, } from '@/lib/table/types' -import { cancelWorkflowGroupRuns, triggerWorkflowGroupRun } from '@/lib/table/workflow-columns' +import { cancelWorkflowGroupRuns, runWorkflowColumn } from '@/lib/table/workflow-columns' import { fetchWorkspaceFileBuffer, resolveWorkspaceFileReference, @@ -1283,6 +1283,10 @@ export const userTableServerTool: BaseServerTool dependencies: args.dependencies as WorkflowGroupDependencies | undefined, outputs: updateOutputs, newOutputColumns: args.newOutputColumns as ColumnDefinition[] | undefined, + mappingUpdates: args.mappingUpdates as + | Array<{ columnName: string; blockId: string; path: string }> + | undefined, + autoRun: typeof args.autoRun === 'boolean' ? args.autoRun : undefined, }, requestId ) @@ -1372,13 +1376,21 @@ export const userTableServerTool: BaseServerTool } } - case 'run_workflow_group': { + case 'run_column': { if (!args.tableId) return { success: false, message: 'Table ID is required' } if (!workspaceId) return { success: false, message: 'Workspace ID is required' } - const groupId = args.groupId as string | undefined - if (!groupId) { - return { success: false, message: 'groupId is required for run_workflow_group' } + const rawGroupIds = args.groupIds as unknown + if ( + !Array.isArray(rawGroupIds) || + rawGroupIds.length === 0 || + rawGroupIds.some((id) => typeof id !== 'string' || id.length === 0) + ) { + return { + success: false, + message: 'groupIds must be a non-empty array of group id strings', + } } + const groupIds = rawGroupIds as string[] const runMode = (args.runMode as 'all' | 'incomplete' | undefined) ?? 'incomplete' if (runMode !== 'all' && runMode !== 'incomplete') { return { @@ -1403,18 +1415,18 @@ export const userTableServerTool: BaseServerTool } const requestId = generateId().slice(0, 8) assertNotAborted() - const { triggered } = await triggerWorkflowGroupRun({ + const { triggered } = await runWorkflowColumn({ tableId: args.tableId, - groupId, workspaceId, + groupIds, mode: runMode, - requestId, rowIds, + requestId, }) const scopeLabel = rowIds ? `${rowIds.length} row(s) by id` : runMode return { success: true, - message: `Triggered ${triggered} row(s) for workflow group ${groupId} (${scopeLabel})`, + message: `Triggered ${triggered} row(s) across ${groupIds.length} column(s) (${scopeLabel})`, data: { triggered }, } } diff --git a/apps/sim/lib/core/async-jobs/backends/database.ts b/apps/sim/lib/core/async-jobs/backends/database.ts index b11ee70b14..4c96bb86e9 100644 --- a/apps/sim/lib/core/async-jobs/backends/database.ts +++ b/apps/sim/lib/core/async-jobs/backends/database.ts @@ -1,8 +1,8 @@ import { asyncJobs, db } from '@sim/db' import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' -import { abortInlineJob } from '@/lib/core/async-jobs/inline-abort' import { type EnqueueOptions, JOB_STATUS, @@ -16,6 +16,7 @@ import { const logger = createLogger('DatabaseJobQueue') type AsyncJobRow = typeof asyncJobs.$inferSelect +type Runner = NonNullable function rowToJob(row: AsyncJobRow): Job { return { @@ -34,6 +35,38 @@ function rowToJob(row: AsyncJobRow): Job { } } +const inlineAbortControllers = new Map() + +interface Semaphore { + available: number + waiters: Array<() => void> +} +const semaphores = new Map() + +async function acquireSlot(key: string, limit: number): Promise { + let s = semaphores.get(key) + if (!s) { + s = { available: limit, waiters: [] } + semaphores.set(key, s) + } + if (s.available > 0) { + s.available -= 1 + return + } + await new Promise((resolve) => s.waiters.push(resolve)) +} + +function releaseSlot(key: string): void { + const s = semaphores.get(key) + if (!s) return + const next = s.waiters.shift() + if (next) { + next() + return + } + s.available += 1 +} + export class DatabaseJobQueue implements JobQueueBackend { async enqueue( type: JobType, @@ -56,9 +89,58 @@ export class DatabaseJobQueue implements JobQueueBackend { }) logger.debug('Enqueued job', { jobId, type }) + if (options?.runner) { + this.runInline( + type, + jobId, + payload, + options.runner, + options.concurrencyKey, + options.concurrencyLimit + ) + } return jobId } + async batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise { + if (items.length === 0) return [] + const now = new Date() + const rows = items.map(({ payload, options }) => ({ + id: `run_${generateId().replace(/-/g, '').slice(0, 20)}`, + type, + payload: payload as Record, + status: JOB_STATUS.PENDING, + createdAt: now, + attempts: 0, + maxAttempts: options?.maxAttempts ?? 3, + metadata: (options?.metadata ?? {}) as Record, + updatedAt: now, + })) + + await db.insert(asyncJobs).values(rows) + + logger.debug('Batch-enqueued jobs', { count: rows.length, type }) + + for (let i = 0; i < items.length; i++) { + const { payload, options } = items[i] + if (options?.runner) { + this.runInline( + type, + rows[i].id, + payload, + options.runner, + options.concurrencyKey, + options.concurrencyLimit + ) + } + } + + return rows.map((r) => r.id) + } + async getJob(jobId: string): Promise { const [row] = await db.select().from(asyncJobs).where(eq(asyncJobs.id, jobId)).limit(1) @@ -116,9 +198,14 @@ export class DatabaseJobQueue implements JobQueueBackend { async cancelJob(jobId: string): Promise { // Abort any in-process inline execution first so the running workflow // observes the signal and stops mid-flight. Then mark the row failed so - // any future poller skips it. The DB queue is single-process / dev-only, - // so an in-memory registry is sufficient for cross-call abort. - const aborted = abortInlineJob(jobId) + // any future poller skips it. + const controller = inlineAbortControllers.get(jobId) + let aborted = false + if (controller) { + controller.abort('Cancelled') + inlineAbortControllers.delete(jobId) + aborted = true + } const now = new Date() await db @@ -133,4 +220,45 @@ export class DatabaseJobQueue implements JobQueueBackend { logger.debug('Marked job as cancelled (DB queue)', { jobId, abortedInline: aborted }) } + + /** + * Fire-and-forget IIFE that owns the lifecycle for an inline job: registers + * the abort controller (so `cancelJob` can interrupt mid-flight), acquires + * a concurrency slot if `concurrencyKey` is set, drives + * `startJob → runner → completeJob | markJobFailed`. + */ + private runInline( + type: JobType, + jobId: string, + payload: TPayload, + runner: Runner, + concurrencyKey?: string, + concurrencyLimit?: number + ): void { + const abortController = new AbortController() + inlineAbortControllers.set(jobId, abortController) + void (async () => { + if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { + await acquireSlot(concurrencyKey, concurrencyLimit) + } + try { + await this.startJob(jobId) + await runner(payload, abortController.signal) + await this.completeJob(jobId, null) + } catch (err) { + const message = toError(err).message + logger.error(`[${type}] Inline job ${jobId} failed`, { error: message }) + try { + await this.markJobFailed(jobId, message) + } catch (markErr) { + logger.error(`[${type}] Failed to mark job ${jobId} as failed`, { markErr }) + } + } finally { + inlineAbortControllers.delete(jobId) + if (concurrencyKey && concurrencyLimit && concurrencyLimit > 0) { + releaseSlot(concurrencyKey) + } + } + })() + } } diff --git a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts index 7427108e14..e7d04bb735 100644 --- a/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts +++ b/apps/sim/lib/core/async-jobs/backends/trigger-dev.ts @@ -82,6 +82,23 @@ export class TriggerDevJobQueue implements JobQueueBackend { return handle.id } + async batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise { + if (items.length === 0) return [] + // tasks.batchTrigger returns only a batchId, not per-item run IDs, so we + // can't use it when callers need to track individual runs (e.g. table cell + // tasks need per-row jobIds for cancellation). Sequential `tasks.trigger` + // gives us per-item IDs and naturally preserves input order in the queue. + const ids: string[] = [] + for (const { payload, options } of items) { + const id = await this.enqueue(type, payload, options) + ids.push(id) + } + return ids + } + async getJob(jobId: string): Promise { try { const run = await runs.retrieve(jobId) diff --git a/apps/sim/lib/core/async-jobs/inline-abort.ts b/apps/sim/lib/core/async-jobs/inline-abort.ts deleted file mode 100644 index af4179da39..0000000000 --- a/apps/sim/lib/core/async-jobs/inline-abort.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Process-local registry of `AbortController`s for jobs running inline - * (i.e. on the same Node process that enqueued them — the database-backed - * queue path). The trigger.dev backend does not use this: cancellation there - * is handled by `runs.cancel(jobId)` which interrupts the worker. - * - * Wiring: - * - `runWorkflowColumn` registers a controller after enqueue (keyed by the - * returned `jobId`) and passes its `signal` into the inline task body. - * - `DatabaseJobQueue.cancelJob` looks up the controller and aborts it so - * the running workflow execution can observe the signal mid-flight. - * - The IIFE that owns the controller unregisters in `finally`. - */ -const inlineAbortControllers = new Map() - -export function registerInlineAbort(jobId: string, controller: AbortController): void { - inlineAbortControllers.set(jobId, controller) -} - -export function unregisterInlineAbort(jobId: string): void { - inlineAbortControllers.delete(jobId) -} - -/** - * Aborts the in-process controller for `jobId` if one is registered. Safe to - * call from `cancelJob` regardless of whether the job ran inline. Returns - * true if a controller was found and aborted. - */ -export function abortInlineJob(jobId: string, reason = 'Cancelled'): boolean { - const controller = inlineAbortControllers.get(jobId) - if (!controller) return false - controller.abort(reason) - inlineAbortControllers.delete(jobId) - return true -} diff --git a/apps/sim/lib/core/async-jobs/types.ts b/apps/sim/lib/core/async-jobs/types.ts index 515fe784c2..e1d2c41131 100644 --- a/apps/sim/lib/core/async-jobs/types.ts +++ b/apps/sim/lib/core/async-jobs/types.ts @@ -78,10 +78,24 @@ export interface EnqueueOptions { delayMs?: number tags?: string[] /** - * Trigger.dev concurrency key. Combined with the task's `queue.concurrencyLimit`, - * limits parallel runs sharing this key. The database backend ignores it. + * Combined with the task's `queue.concurrencyLimit`, caps parallel runs + * sharing this key. Trigger.dev enforces server-side; the database backend + * enforces in-process via a FIFO semaphore. */ concurrencyKey?: string + /** + * Per-key concurrency cap. Database backend only — trigger.dev reads this + * from the task definition (`queue.concurrencyLimit`). + */ + concurrencyLimit?: number + /** + * Job body invoked when the queue backend lacks an external worker. + * Trigger.dev ignores this (its workers execute the task definition); + * the database backend kicks it off as a fire-and-forget IIFE so the + * row drives through `processing → completed | failed`. Receives the + * payload and an `AbortSignal` driven by `cancelJob`. + */ + runner?: (payload: TPayload, signal: AbortSignal) => Promise } /** @@ -94,6 +108,16 @@ export interface JobQueueBackend { */ enqueue(type: JobType, payload: TPayload, options?: EnqueueOptions): Promise + /** + * Enqueue multiple jobs as a single batch. Returns one jobId per item, in + * input order. Backends preserve input order in queue dispatch (trigger.dev + * via tasks.batchTrigger, database via a single multi-row INSERT). + */ + batchEnqueue( + type: JobType, + items: Array<{ payload: TPayload; options?: EnqueueOptions }> + ): Promise + /** * Get a job by ID */ diff --git a/apps/sim/lib/table/cell-write.ts b/apps/sim/lib/table/cell-write.ts index 73f4fda251..24826fe5d1 100644 --- a/apps/sim/lib/table/cell-write.ts +++ b/apps/sim/lib/table/cell-write.ts @@ -55,6 +55,25 @@ export async function writeWorkflowGroupState( return 'wrote' } const current = row.executions?.[groupId] as RowExecutionMetadata | undefined + // Stale-worker guard: only blocks writes FROM an old worker (status = + // running / completed / error / pending). A `queued` stamp is the scheduler + // claiming the cell for a brand-new run — the new executionId is supposed + // to overwrite whatever was there. Same for `cancelled` (authoritative). + // Without this carve-out, the new run's stamp gets rejected and the cell + // is stuck in its old state forever. + const isAuthoritativeNewStamp = + payload.executionState.status === 'queued' || payload.executionState.status === 'cancelled' + if ( + !isAuthoritativeNewStamp && + current && + current.executionId && + current.executionId !== executionId + ) { + logger.info( + `Skipping group write — stale worker (table=${tableId} row=${rowId} group=${groupId} mine=${executionId} active=${current.executionId})` + ) + return 'skipped' + } if ( current?.status === 'cancelled' && current.executionId === executionId && @@ -66,11 +85,11 @@ export async function writeWorkflowGroupState( return 'skipped' } // Skip writing `cancelled` state with the guard — that's an authoritative - // write from `cancelWorkflowGroupRuns` and must always land. Cell-task - // writes (running/completed/error) get the SQL guard so an in-flight - // partial can't clobber a stop click that already committed. - const cancellationGuard = - payload.executionState.status === 'cancelled' ? undefined : { groupId, executionId } + // write from `cancelWorkflowGroupRuns` and must always land. New `queued` + // stamps from the scheduler also bypass — they ARE the new authority. Cell- + // task writes (running/completed/error) get the SQL guard so an in-flight + // partial can't clobber a stop click or a newer run that already committed. + const cancellationGuard = isAuthoritativeNewStamp ? undefined : { groupId, executionId } const result = await updateRow( { tableId, @@ -92,6 +111,28 @@ export async function writeWorkflowGroupState( return 'wrote' } +/** + * Flips `queued` → `running` to signal the cell task body has actually been + * picked up by a worker. The renderer uses the `queued` vs `running` distinction + * to label cells "Queued" vs "Waiting" (worker started, this block hasn't run + * yet) — without this marker we couldn't tell if a row was sitting in the + * trigger.dev queue or actively executing. + */ +export async function markWorkflowGroupPickedUp( + ctx: WriteWorkflowGroupContext, + prev: Pick +): Promise<'wrote' | 'skipped'> { + return writeWorkflowGroupState(ctx, { + executionState: { + status: 'running', + executionId: ctx.executionId, + jobId: prev.jobId, + workflowId: prev.workflowId, + error: null, + }, + }) +} + /** Builds the canonical `cancelled` execution state used by every cancel path. * Preserves `blockErrors` from the prior state so errored cells keep * rendering Error after a stop click — only cells that hadn't yet produced diff --git a/apps/sim/lib/table/deps.ts b/apps/sim/lib/table/deps.ts new file mode 100644 index 0000000000..d9b33f59db --- /dev/null +++ b/apps/sim/lib/table/deps.ts @@ -0,0 +1,144 @@ +/** + * Pure dep-satisfaction helpers shared by the server-side scheduler and the + * client UI. Lives in its own file (not `workflow-columns.ts`) so the client + * can import it without pulling in `@sim/db` and other server-only deps. + */ + +import { createLogger } from '@sim/logger' +import type { RowData, RowExecutionMetadata, RowExecutions, TableRow, WorkflowGroup } from './types' + +const logger = createLogger('OptimisticCascade') + +/** + * True when the cell has a worker actively reserved — `queued` / `running`, + * or `pending` after the scheduler stamped a jobId. Single source of truth + * for the "is this exec in flight" classification across the eligibility + * predicate, optimistic patches, status counters, and renderer. `pending` + * without a jobId is the optimistic-flag-only state, not in-flight. + */ +export function isExecInFlight(exec: RowExecutionMetadata | undefined): boolean { + if (!exec) return false + const s = exec.status + if (s === 'queued' || s === 'running') return true + if (s === 'pending' && exec.jobId) return true + return false +} + +/** + * True when every output column the group writes still has a non-empty value + * on this row. The "completed" exec status is metadata, but the cells are the + * source of truth — if the user cleared an output cell, the row is effectively + * incomplete and should be re-run on dep-fill / manual incomplete-mode runs. + */ +export function areOutputsFilled(group: WorkflowGroup, row: TableRow): boolean { + if (group.outputs.length === 0) return true + for (const o of group.outputs) { + const v = row.data[o.columnName] + if (v === null || v === undefined || v === '') return false + } + return true +} + +/** + * Returns true when every column this group depends on is non-empty on this + * row. Workflow output columns count the same as plain columns — the model + * is uniform. + */ +export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): boolean { + const cols = group.dependencies?.columns ?? [] + for (const colName of cols) { + const value = row.data[colName] + if (value === null || value === undefined || value === '') return false + } + return true +} + +export interface UnmetDeps { + /** Column names whose value on this row is empty. */ + columns: string[] +} + +/** + * Like `areGroupDepsSatisfied` but returns *which* columns are unmet, so the + * UI can render "Waiting on column_a, column_b". + */ +export function getUnmetGroupDeps(group: WorkflowGroup, row: TableRow): UnmetDeps { + const cols = group.dependencies?.columns ?? [] + const columns: string[] = [] + for (const colName of cols) { + const value = row.data[colName] + if (value === null || value === undefined || value === '') columns.push(colName) + } + return { columns } +} + +/** + * Optimistic mirror of the server's row-update→scheduler cascade: for every + * workflow group whose deps were unmet *before* the patch and are satisfied + * *after*, return a new `executions` map with that group flipped to + * `pending`. The cell renderer treats `pending` as "Queued", which is what + * the user expects to see immediately after they fill in the missing input — + * not a flash of dash before the server's pending write arrives. + * + * Returns `null` when nothing changed, so callers can short-circuit. + */ +export function optimisticallyScheduleNewlyEligibleGroups( + groups: WorkflowGroup[], + beforeRow: TableRow, + patch: Partial +): RowExecutions | null { + if (groups.length === 0) return null + + const afterRow: TableRow = { + ...beforeRow, + data: { ...beforeRow.data, ...patch } as RowData, + } + + let next: RowExecutions | null = null + let flipped = 0 + let skipped = 0 + for (const group of groups) { + if (group.autoRun === false) { + skipped++ + continue + } + if (!areGroupDepsSatisfied(group, afterRow)) { + skipped++ + continue + } + + const exec = beforeRow.executions?.[group.id] + if (exec?.status === 'queued' || exec?.status === 'running') { + skipped++ + continue + } + if (exec?.status === 'pending' && exec.jobId) { + skipped++ + continue + } + + const isStaleCompleted = exec?.status === 'completed' && !areOutputsFilled(group, afterRow) + const wasSatisfied = areGroupDepsSatisfied(group, beforeRow) + const becameSatisfied = !wasSatisfied + const isRetryable = exec?.status === 'cancelled' || exec?.status === 'error' + if (!becameSatisfied && !isStaleCompleted && !isRetryable && exec) { + skipped++ + continue + } + + flipped++ + if (next === null) next = { ...(beforeRow.executions ?? {}) } + const pending: RowExecutionMetadata = { + status: 'pending', + executionId: exec?.executionId ?? null, + jobId: null, + workflowId: exec?.workflowId ?? group.workflowId, + error: null, + } + next[group.id] = pending + } + if (flipped > 0) { + logger.debug(`[OptimisticCascade] row=${beforeRow.id} flipped=${flipped} skipped=${skipped}`) + } + return next +} diff --git a/apps/sim/lib/table/service.ts b/apps/sim/lib/table/service.ts index 2d8b0a7c96..cfdb544f7c 100644 --- a/apps/sim/lib/table/service.ts +++ b/apps/sim/lib/table/service.ts @@ -12,10 +12,8 @@ import { userTableDefinitions, userTableRows, workflowExecutionLogs } from '@sim import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { and, count, eq, gt, gte, inArray, isNull, sql } from 'drizzle-orm' -import { env } from '@/lib/core/config/env' +import { and, count, eq, gt, gte, inArray, isNull, type SQL, sql } from 'drizzle-orm' import { generateRestoreName } from '@/lib/core/utils/restore-name' -import { getSocketServerUrl } from '@/lib/core/utils/urls' import { COLUMN_TYPES, NAME_PATTERN, TABLE_LIMITS, USER_TABLE_ROWS_SQL_NAME } from './constants' import { buildFilterClause, buildSortClause } from './sql' import { fireTableTrigger } from './trigger' @@ -63,63 +61,15 @@ import { validateTableName, validateTableSchema, } from './validation' -import { assertValidSchema, scheduleWorkflowGroupRuns } from './workflow-columns' +import { + assertValidSchema, + scheduleRunsForRows, + scheduleRunsForTable, + stripGroupDeps, +} from './workflow-columns' const logger = createLogger('TableService') -/** - * Fire-and-forget bridge to the realtime socket server. Mirrors the - * `notifyWorkflowArchived` pattern in `lib/workflows/lifecycle.ts:35`. - * Failures are logged but never thrown — sockets are best-effort, polling - * is the fallback. Each helper sends a single row delta so the realtime - * server can broadcast to subscribed clients in the table room. - */ -function notifyTableRowUpdated(tableId: string, row: TableRow): void { - void fetch(`${getSocketServerUrl()}/api/table-row-updated`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ - tableId, - rowId: row.id, - data: row.data, - executions: row.executions, - position: row.position, - updatedAt: row.updatedAt instanceof Date ? row.updatedAt.toISOString() : row.updatedAt, - }), - }).catch((err) => { - logger.warn(`table-row-updated bridge failed for ${tableId}/${row.id}:`, err) - }) -} - -function notifyTableRowDeleted(tableId: string, rowId: string): void { - void fetch(`${getSocketServerUrl()}/api/table-row-deleted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ tableId, rowId }), - }).catch((err) => { - logger.warn(`table-row-deleted bridge failed for ${tableId}/${rowId}:`, err) - }) -} - -function notifyTableDeleted(tableId: string): void { - void fetch(`${getSocketServerUrl()}/api/table-deleted`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': env.INTERNAL_API_SECRET, - }, - body: JSON.stringify({ tableId }), - }).catch((err) => { - logger.warn(`table-deleted bridge failed for ${tableId}:`, err) - }) -} - export class TableConflictError extends Error { readonly code = 'TABLE_EXISTS' as const constructor(name: string) { @@ -523,13 +473,36 @@ export async function addTableColumn( } const updatedSchema: TableSchema = { ...schema, columns } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) + + // Keep `metadata.columnOrder` in sync: when present, it must list every + // column in `schema.columns`. Splicing the new name in at the same index + // we used in `columns` keeps display ordering aligned with the user's + // intent for `position`-based inserts. + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0 && !existingOrder.includes(column.name)) { + let insertIdx = existingOrder.length + if (column.position !== undefined && column.position >= 0) { + // Anchor on the column previously at `position` — that column shifted + // right by one in `columns`, so the new name slots in at its old spot. + const anchor = schema.columns[column.position]?.name + if (anchor) { + const anchorIdx = existingOrder.indexOf(anchor) + if (anchorIdx !== -1) insertIdx = anchorIdx + } + } + const nextOrder = [...existingOrder] + nextOrder.splice(insertIdx, 0, column.name) + updatedMetadata = { ...table.metadata, columnOrder: nextOrder } + } + + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) const now = new Date() await db .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) .where(eq(userTableDefinitions.id, tableId)) logger.info(`[${requestId}] Added column "${column.name}" to table ${tableId}`) @@ -537,6 +510,7 @@ export async function addTableColumn( return { ...table, schema: updatedSchema, + metadata: updatedMetadata, updatedAt: now, } } @@ -694,7 +668,6 @@ export async function deleteTable(tableId: string, requestId: string): Promise` and `data->>` accept the JSON key as a parameterized text value; + // no need for `sql.raw` interpolation. const matchFilter = typeof targetValue === 'string' - ? sql`${userTableRows.data}->>${sql.raw(`'${targetColumnName}'`)} = ${String(targetValue)}` - : sql`(${userTableRows.data}->${sql.raw(`'${targetColumnName}'`)})::jsonb = ${JSON.stringify(targetValue)}::jsonb` + ? sql`${userTableRows.data}->>${targetColumnName}::text = ${String(targetValue)}` + : sql`(${userTableRows.data}->${targetColumnName}::text)::jsonb = ${JSON.stringify(targetValue)}::jsonb` // Capacity enforcement for the insert path lives in the `increment_user_table_row_count` // trigger (migration 0198). The update path doesn't change row_count, so no check needed. @@ -1418,8 +1385,7 @@ export async function upsertRow( requestId ) } - notifyTableRowUpdated(data.tableId, result.row) - void scheduleWorkflowGroupRuns(table, [result.row]) + void scheduleRunsForRows(table, [result.row]) return result } @@ -1558,6 +1524,39 @@ export async function getRowById( } } +/** + * When a user edit clears a workflow output column to empty, also clear the + * exec record for that group. Without this, a `cancelled` (or `error`) exec + * sticks on the row even after the user wipes the output, blocking the + * auto-fire reactor (which respects terminal states). Treating the cleared + * cell as "user wants this re-armed" matches the rule that cells are the + * source of truth — we already do this for `completed` via + * `areOutputsFilled` in the eligibility predicate; this extends the same + * behavior to error/cancelled by making the data clear remove the exec. + * + * Returns a merged `executionsPatch` (caller's patch + null for groups whose + * outputs were cleared), or the caller's patch unchanged if nothing applies. + */ +function deriveExecClearsForDataPatch( + dataPatch: RowData, + schema: TableSchema, + callerPatch: Record | undefined +): Record | undefined { + const groupsToClear = new Set() + for (const [columnName, value] of Object.entries(dataPatch)) { + const cleared = value === null || value === undefined || value === '' + if (!cleared) continue + const col = schema.columns.find((c) => c.name === columnName) + if (col?.workflowGroupId) groupsToClear.add(col.workflowGroupId) + } + if (groupsToClear.size === 0) return callerPatch + const merged: Record = { ...(callerPatch ?? {}) } + for (const gid of groupsToClear) { + if (!(gid in merged)) merged[gid] = null + } + return merged +} + /** Merges an `executionsPatch` into the row's existing executions blob. */ function applyExecutionsPatch( existing: RowExecutions, @@ -1575,6 +1574,53 @@ function applyExecutionsPatch( return next } +/** + * Builds a SQL expression that applies the given `executionsPatch` to the + * row's `executions` jsonb in-place — set keys for non-null values, delete + * keys for `null` values. Returns null when the patch is empty/missing. + * + * Why server-side: read-modify-write on the entire jsonb blob races between + * concurrent writers (e.g., a column edit and a manual-retry stamp), so the + * last writer wins for keys it didn't touch and clobbers other writers' + * exec updates. Patching keys at the SQL level keeps each writer's changes + * atomic per-key. + */ +function buildExecutionsSqlPatch( + patch: Record | undefined +): SQL | null { + if (!patch) return null + const entries = Object.entries(patch) + if (entries.length === 0) return null + + let expr: SQL = sql`coalesce(${userTableRows.executions}, '{}'::jsonb)` + for (const [gid, value] of entries) { + if (value === null) { + expr = sql`(${expr}) - ${gid}::text` + } else { + expr = sql`(${expr}) || jsonb_build_object(${gid}::text, ${JSON.stringify(value)}::jsonb)` + } + } + return expr +} + +/** + * Strips the given workflow group ids from every row's `executions` jsonb on + * a table — used by the column / group delete paths so stale running/queued + * exec records don't linger and inflate counters after the group is gone. + * The caller wraps in their own transaction. + */ +async function stripGroupExecutions( + trx: Parameters[0]>[0], + tableId: string, + groupIds: Iterable +): Promise { + for (const gid of groupIds) { + await trx.execute( + sql`UPDATE user_table_rows SET executions = executions - ${gid}::text WHERE table_id = ${tableId} AND executions ? ${gid}::text` + ) + } +} + /** * Updates a single row. * @@ -1600,7 +1646,14 @@ export async function updateRow( ...(existingRow.data as RowData), ...data.data, } - const mergedExecutions = applyExecutionsPatch(existingRow.executions, data.executionsPatch) + // Auto-clear exec records for workflow output columns the user just wiped, + // so the auto-fire reactor sees no exec and re-arms the cell. + const effectiveExecutionsPatch = deriveExecClearsForDataPatch( + data.data, + table.schema, + data.executionsPatch + ) + const mergedExecutions = applyExecutionsPatch(existingRow.executions, effectiveExecutionsPatch) // Validate size const sizeValidation = validateRowSize(mergedData) @@ -1631,26 +1684,46 @@ export async function updateRow( const now = new Date() // Cell-task partial writes pass `cancellationGuard` so the SQL update is a - // no-op when a stop click already wrote `cancelled` for this run between - // the in-process read and now. Without this, an in-flight `running` - // partial-write can land after `cancelled` and clobber it. + // no-op when (a) a stop click already wrote `cancelled` for this run, or + // (b) a newer run has taken over the cell with a different executionId. The + // worker is "this run's writes only land if this run is still the active + // run on the cell." Authoritative cancel writes from `cancelWorkflowGroupRuns` + // skip the guard entirely (they don't pass `cancellationGuard`). + // + // SQL-level for atomicity: an in-process read + update would race a + // concurrent stop or rerun. The two clauses are joined by AND because + // either failing means the worker is no longer authoritative. const guard = data.cancellationGuard - // The guard rejects writes only when the DB *already* shows - // `cancelled` + matching executionId. Wrap the JSON traversals in - // `IS DISTINCT FROM` so a missing `executions[groupId]` (NULL) cleanly - // evaluates as "different" — Postgres three-valued logic would otherwise - // make the whole expression NULL and the UPDATE would mistakenly become - // a no-op for any row that has no prior execution record. const whereClause = guard ? and( eq(userTableRows.id, data.rowId), - sql`(executions->${guard.groupId}->>'status' IS DISTINCT FROM 'cancelled' OR executions->${guard.groupId}->>'executionId' IS DISTINCT FROM ${guard.executionId})` + // Reject writes that would land on top of an already-`cancelled` state + // for this same run. Wrapped in IS DISTINCT FROM so a missing exec + // (NULL) cleanly evaluates as "different" rather than NULL-poisoning. + sql`(executions->${guard.groupId}->>'status' IS DISTINCT FROM 'cancelled' OR executions->${guard.groupId}->>'executionId' IS DISTINCT FROM ${guard.executionId})`, + // Reject writes from a stale worker — the cell's active run has moved + // on. `OR exec IS NULL` lets the worker land its first `running` + // stamp on a row that has no prior exec record (initial stamp from + // the scheduler may not have committed yet). + sql`(executions->${guard.groupId} IS NULL OR executions->${guard.groupId}->>'executionId' = ${guard.executionId})` ) : eq(userTableRows.id, data.rowId) + // Apply the executions patch at the SQL level — we never overwrite the full + // executions blob, only the keys the caller explicitly patched. Without + // this, concurrent updateRow calls (e.g., a column edit and a manual + // retry's stamp) would each compute `mergedExecutions` from their own + // in-memory snapshot and the last writer wins, clobbering the other's + // exec keys. The data field still does last-writer-wins because that's + // the user's edit, but exec records are independently keyed by groupId. + const executionsExpr = buildExecutionsSqlPatch(effectiveExecutionsPatch) const updated = await db .update(userTableRows) - .set({ data: mergedData, executions: mergedExecutions, updatedAt: now }) + .set({ + data: mergedData, + ...(executionsExpr ? { executions: executionsExpr } : {}), + updatedAt: now, + }) .where(whereClause) .returning({ id: userTableRows.id }) @@ -1680,15 +1753,9 @@ export async function updateRow( table.schema, requestId ) - // Notify BEFORE the scheduler so this event (carrying the user's data - // update with pre-scheduler executions) reaches the client first. The - // scheduler then fires its own per-write notifications with `pending`/ - // `running` execution state — those land last, so the cached executions - // end on the live state instead of being clobbered by a stale envelope. - notifyTableRowUpdated(data.tableId, updatedRow) // Awaited (not `void`) so cell tasks dispatch their cascade before the // trigger.dev worker tears down on `run()` resolve. - await scheduleWorkflowGroupRuns(table, [updatedRow]) + if (!data.skipScheduler) await scheduleRunsForRows(table, [updatedRow]) return updatedRow } @@ -1730,7 +1797,6 @@ export async function deleteRow( }) logger.info(`[${requestId}] Deleted row ${rowId} from table ${tableId}`) - notifyTableRowDeleted(tableId, rowId) } /** @@ -1851,8 +1917,7 @@ export async function updateRowsByFilter( table.schema, requestId ) - for (const row of updatedRows) notifyTableRowUpdated(data.tableId, row) - void scheduleWorkflowGroupRuns(table, updatedRows) + void scheduleRunsForRows(table, updatedRows) return { affectedCount: matchingRows.length, @@ -1906,11 +1971,19 @@ export async function batchUpdateRows( rowId: string mergedData: RowData mergedExecutions: RowExecutions + executionsPatch?: Record }> = [] for (const update of data.updates) { const existing = existingMap.get(update.rowId)! const merged = { ...existing.data, ...update.data } - const mergedExecutions = applyExecutionsPatch(existing.executions, update.executionsPatch) + // Auto-clear exec records for workflow output columns the user just + // wiped — same rationale as `updateRow`. + const effectiveExecutionsPatch = deriveExecClearsForDataPatch( + update.data, + table.schema, + update.executionsPatch + ) + const mergedExecutions = applyExecutionsPatch(existing.executions, effectiveExecutionsPatch) const sizeValidation = validateRowSize(merged) if (!sizeValidation.valid) { @@ -1922,7 +1995,12 @@ export async function batchUpdateRows( throw new Error(`Row ${update.rowId}: ${schemaValidation.errors.join(', ')}`) } - mergedUpdates.push({ rowId: update.rowId, mergedData: merged, mergedExecutions }) + mergedUpdates.push({ + rowId: update.rowId, + mergedData: merged, + mergedExecutions, + executionsPatch: effectiveExecutionsPatch, + }) } const uniqueColumns = getUniqueColumns(table.schema) @@ -1946,12 +2024,20 @@ export async function batchUpdateRows( await setTableTxTimeouts(trx, { statementMs: 60_000 }) for (let i = 0; i < mergedUpdates.length; i += TABLE_LIMITS.UPDATE_BATCH_SIZE) { const batch = mergedUpdates.slice(i, i + TABLE_LIMITS.UPDATE_BATCH_SIZE) - const updatePromises = batch.map(({ rowId, mergedData, mergedExecutions }) => - trx + // Same as `updateRow`: patch executions at the SQL level when a patch + // is set, so concurrent writers don't clobber each other's keys via + // last-writer-wins on the full jsonb blob. + const updatePromises = batch.map(({ rowId, mergedData, executionsPatch }) => { + const executionsExpr = buildExecutionsSqlPatch(executionsPatch) + return trx .update(userTableRows) - .set({ data: mergedData, executions: mergedExecutions, updatedAt: now }) + .set({ + data: mergedData, + ...(executionsExpr ? { executions: executionsExpr } : {}), + updatedAt: now, + }) .where(eq(userTableRows.id, rowId)) - ) + }) await Promise.all(updatePromises) } }) @@ -1980,11 +2066,7 @@ export async function batchUpdateRows( table.schema, requestId ) - // Same ordering as `updateRow`: notify with the user's data update first - // so the scheduler's later per-write notifications (pending/running) land - // last and stick in the client cache. - for (const row of updatedRowsForTrigger) notifyTableRowUpdated(data.tableId, row) - void scheduleWorkflowGroupRuns(table, updatedRowsForTrigger) + if (!data.skipScheduler) void scheduleRunsForRows(table, updatedRowsForTrigger) return { affectedCount: mergedUpdates.length, @@ -2095,7 +2177,6 @@ export async function deleteRowsByFilter( }) logger.info(`[${requestId}] Deleted ${matchingRows.length} rows from table ${data.tableId}`) - for (const id of rowIds) notifyTableRowDeleted(data.tableId, id) return { affectedCount: matchingRows.length, @@ -2153,7 +2234,6 @@ export async function deleteRowsByIds( const missingRowIds = uniqueRequestedRowIds.filter((id) => !deletedIdSet.has(id)) logger.info(`[${requestId}] Deleted ${deletedIds.length} rows by ID from table ${data.tableId}`) - for (const id of deletedIds) notifyTableRowDeleted(data.tableId, id) return { deletedCount: deletedIds.length, @@ -2221,23 +2301,17 @@ export async function renameColumn( const renamedDeps = group.dependencies?.columns?.map((d) => d === actualOldName ? data.newName : d ) - const renamedGroupDeps = group.dependencies?.workflowGroups - const next = { + return { ...group, outputs: renamedOutputs, - dependencies: { - ...(renamedDeps ? { columns: renamedDeps } : {}), - ...(renamedGroupDeps ? { workflowGroups: renamedGroupDeps } : {}), - }, + ...(renamedDeps ? { dependencies: { columns: renamedDeps } } : {}), } - return next }) const updatedSchema: TableSchema = { ...schema, columns: updatedColumns, ...(updatedGroups.length > 0 ? { workflowGroups: updatedGroups } : {}), } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) const metadata = table.metadata as TableMetadata | null let updatedMetadata = metadata @@ -2251,6 +2325,11 @@ export async function renameColumn( columnOrder: updatedMetadata.columnOrder.map((n) => (n === actualOldName ? data.newName : n)), } } + // Validate against the *post-rename* column order. The schema's workflow + // group outputs already reference the new name, so checking against the old + // columnOrder makes the renamed output look "missing" from its group and + // falsely flags the remaining siblings as non-contiguous. + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) const now = new Date() const statementMs = scaledStatementTimeoutMs(table.rowCount ?? 0, { @@ -2265,8 +2344,10 @@ export async function renameColumn( .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) .where(eq(userTableDefinitions.id, data.tableId)) + // All bindings parameterized — `data->` accepts a text parameter for the + // key, no need to drop into `sql.raw` with hand-rolled quote escaping. await trx.execute( - sql`UPDATE user_table_rows SET data = data - ${actualOldName}::text || jsonb_build_object(${data.newName}::text, data->${sql.raw(`'${actualOldName.replace(/'/g, "''")}'`)}) WHERE table_id = ${data.tableId} AND data ? ${actualOldName}::text` + sql`UPDATE user_table_rows SET data = data - ${actualOldName}::text || jsonb_build_object(${data.newName}::text, data->${actualOldName}::text) WHERE table_id = ${data.tableId} AND data ? ${actualOldName}::text` ) }) @@ -2312,48 +2393,21 @@ export async function deleteColumn( // Drop this column's reference from every group's outputs and `columns` // dependency. If the column is the last output of its parent group, the - // group itself is also removed (a group with zero outputs is invalid), - // and any OTHER group depending on this group has the dep cleared too. + // group itself is also removed (a group with zero outputs is invalid). let groupRemovedId: string | null = null - let updatedGroups = (schema.workflowGroups ?? []).map((group) => { - let next = group - if (ownerGroupId && group.id === ownerGroupId) { - const remaining = group.outputs.filter((o) => o.columnName !== actualName) - if (remaining.length === 0) { - groupRemovedId = group.id + const updatedGroups = (schema.workflowGroups ?? []) + .map((group) => { + let next = group + if (ownerGroupId && group.id === ownerGroupId) { + const remaining = group.outputs.filter((o) => o.columnName !== actualName) + if (remaining.length === 0) { + groupRemovedId = group.id + } + next = { ...next, outputs: remaining } } - next = { ...next, outputs: remaining } - } - const filtered = next.dependencies?.columns?.filter((d) => d !== actualName) - if (filtered && filtered.length !== (next.dependencies?.columns?.length ?? 0)) { - next = { - ...next, - dependencies: { - ...(filtered.length > 0 ? { columns: filtered } : {}), - ...(next.dependencies?.workflowGroups - ? { workflowGroups: next.dependencies.workflowGroups } - : {}), - }, - } - } - return next - }) - if (groupRemovedId) { - const removed = groupRemovedId - updatedGroups = updatedGroups - .filter((g) => g.id !== removed) - .map((g) => - g.dependencies?.workflowGroups - ? { - ...g, - dependencies: { - ...(g.dependencies.columns ? { columns: g.dependencies.columns } : {}), - workflowGroups: g.dependencies.workflowGroups.filter((id) => id !== removed), - }, - } - : g - ) - } + return stripGroupDeps(next, new Set([actualName])) + }) + .filter((g) => g.id !== groupRemovedId) const updatedSchema: TableSchema = { ...schema, @@ -2385,6 +2439,7 @@ export async function deleteColumn( await trx.execute( sql`UPDATE user_table_rows SET data = data - ${actualName}::text WHERE table_id = ${data.tableId} AND data ? ${actualName}::text` ) + if (groupRemovedId) await stripGroupExecutions(trx, data.tableId, [groupRemovedId]) }) logger.info(`[${requestId}] Deleted column "${actualName}" from table ${data.tableId}`) @@ -2443,21 +2498,7 @@ export async function deleteColumns( }) updatedGroups = updatedGroups .filter((g) => !removedGroupIds.has(g.id)) - .map((group) => { - const depCols = group.dependencies?.columns?.filter((d) => !namesToDelete.has(d)) - const depGroups = group.dependencies?.workflowGroups?.filter((id) => !removedGroupIds.has(id)) - const colsChanged = depCols && depCols.length !== (group.dependencies?.columns?.length ?? 0) - const groupsChanged = - depGroups && depGroups.length !== (group.dependencies?.workflowGroups?.length ?? 0) - if (!colsChanged && !groupsChanged) return group - return { - ...group, - dependencies: { - ...(depCols && depCols.length > 0 ? { columns: depCols } : {}), - ...(depGroups && depGroups.length > 0 ? { workflowGroups: depGroups } : {}), - }, - } - }) + .map((group) => stripGroupDeps(group, namesToDelete)) const updatedSchema: TableSchema = { ...schema, columns: remaining, @@ -2491,6 +2532,7 @@ export async function deleteColumns( sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` ) } + await stripGroupExecutions(trx, data.tableId, removedGroupIds) }) logger.info( @@ -2536,8 +2578,6 @@ export async function updateColumnType( return table } - const escapedName = column.name.replace(/'/g, "''") - // Validate existing data is compatible with the new type const rows = await db .select({ id: userTableRows.id, data: userTableRows.data }) @@ -2546,7 +2586,7 @@ export async function updateColumnType( and( eq(userTableRows.tableId, data.tableId), sql`${userTableRows.data} ? ${column.name}`, - sql`${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NOT NULL` + sql`${userTableRows.data}->>${column.name}::text IS NOT NULL` ) ) @@ -2616,8 +2656,6 @@ export async function updateColumnConstraints( `Cannot change constraints on workflow-output column "${column.name}". Constraints aren't applicable to columns whose values come from workflow execution.` ) } - const escapedName = column.name.replace(/'/g, "''") - if (data.required === true && !column.required) { const [result] = await db .select({ count: count() }) @@ -2625,7 +2663,7 @@ export async function updateColumnConstraints( .where( and( eq(userTableRows.tableId, data.tableId), - sql`(NOT (${userTableRows.data} ? ${column.name}) OR ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NULL)` + sql`(NOT (${userTableRows.data} ? ${column.name}) OR ${userTableRows.data}->>${column.name}::text IS NULL)` ) ) @@ -2638,7 +2676,7 @@ export async function updateColumnConstraints( if (data.unique === true && !column.unique) { const duplicates = (await db.execute( - sql`SELECT ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${column.name} AND ${userTableRows.data}->>${sql.raw(`'${escapedName}'`)} IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` + sql`SELECT ${userTableRows.data}->>${column.name}::text AS val, count(*) AS cnt FROM ${userTableRows} WHERE table_id = ${data.tableId} AND ${userTableRows.data} ? ${column.name} AND ${userTableRows.data}->>${column.name}::text IS NOT NULL GROUP BY val HAVING count(*) > 1 LIMIT 1` )) as { val: string; cnt: number }[] if (duplicates.length > 0) { @@ -2713,44 +2751,51 @@ export async function addWorkflowGroup( columns: [...schema.columns, ...data.outputColumns], workflowGroups: [...groups, data.group], } - assertValidSchema(updatedSchema, table.metadata?.columnOrder) + + // Keep `metadata.columnOrder` in sync — see `addTableColumn` for the + // invariant. New output columns get appended in the order the caller + // supplied (matches their position in `schema.columns`). + const existingOrder = table.metadata?.columnOrder + let updatedMetadata = table.metadata + if (existingOrder && existingOrder.length > 0) { + const known = new Set(existingOrder) + const append = data.outputColumns.map((c) => c.name).filter((n) => !known.has(n)) + if (append.length > 0) { + updatedMetadata = { ...table.metadata, columnOrder: [...existingOrder, ...append] } + } + } + + assertValidSchema(updatedSchema, updatedMetadata?.columnOrder) const now = new Date() await db .update(userTableDefinitions) - .set({ schema: updatedSchema, updatedAt: now }) + .set({ schema: updatedSchema, metadata: updatedMetadata, updatedAt: now }) .where(eq(userTableDefinitions.id, data.tableId)) logger.info( `[${requestId}] Added workflow group "${data.group.id}" with ${data.outputColumns.length} output column(s) to table ${data.tableId}` ) - const updatedTable: TableDefinition = { ...table, schema: updatedSchema, updatedAt: now } + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } // Schedule existing rows so already-filled deps trigger immediately. Skipped // when the caller opted out (Mothership stages groups silently — `autoRun: // false` — so the AI can compose multiple changes without firing rows mid-edit). + // Awaited (not `void`) so the response includes the queued exec state — the + // client's post-mutation refetch otherwise lands before the stamps commit + // and the rows query polling never starts. if (data.autoRun !== false) { - void (async () => { - try { - const rowRecords = await db - .select() - .from(userTableRows) - .where(eq(userTableRows.tableId, data.tableId)) - if (rowRecords.length === 0) return - const rows: TableRow[] = rowRecords.map((r) => ({ - id: r.id, - data: r.data as RowData, - executions: (r.executions as RowExecutions) ?? {}, - position: r.position, - createdAt: r.createdAt, - updatedAt: r.updatedAt, - })) - await scheduleWorkflowGroupRuns(updatedTable, rows) - } catch (err) { - logger.error(`[${requestId}] Failed to schedule runs after group add:`, err) - } - })() + try { + await scheduleRunsForTable(updatedTable) + } catch (err) { + logger.error(`[${requestId}] Failed to schedule runs after group add:`, err) + } } return updatedTable @@ -2779,12 +2824,90 @@ export async function updateWorkflowGroup( } const group = groups[groupIndex] - const newOutputs = data.outputs ?? group.outputs + // Apply `mappingUpdates` first: each entry repoints an existing output's + // `(blockId, path)` while preserving the column. We patch the **old** view + // of outputs so the downstream `(blockId, path)`-keyed diff doesn't see the + // swap as a remove+add. The corresponding row data is cleared after the + // schema write so stale values from the old source don't linger. + const mappingUpdates = data.mappingUpdates ?? [] + const remappedColumnNames = new Set() + // Per-column type override resolved from the new mapping's leaf type. Only + // populated when a remap actually changes the column's type — keeps the + // schema patch a no-op when the user repoints to an output of the same + // type. Falls back to leaving the existing type alone if the workflow or + // its target output can't be resolved (workflow deleted, block removed). + const remappedColumnTypes = new Map() + let oldOutputs = group.outputs + if (mappingUpdates.length > 0) { + const updateByName = new Map(mappingUpdates.map((u) => [u.columnName, u])) + for (const u of mappingUpdates) { + const exists = oldOutputs.some((o) => o.columnName === u.columnName) + if (!exists) { + throw new Error( + `Mapping update for unknown column "${u.columnName}" (group ${data.groupId}).` + ) + } + } + oldOutputs = oldOutputs.map((o) => { + const u = updateByName.get(o.columnName) + if (!u) return o + remappedColumnNames.add(o.columnName) + return { ...o, blockId: u.blockId, path: u.path } + }) + + // Resolve the new leaf type for each remap so the column's declared type + // matches what the new mapping produces. Without this, a string→number + // remap would keep `type: 'string'` and validateRowAgainstSchema would + // reject every backfilled value. + try { + const [ + { loadWorkflowFromNormalizedTables }, + { flattenWorkflowOutputs }, + { columnTypeForLeaf }, + ] = await Promise.all([ + import('@/lib/workflows/persistence/utils'), + import('@/lib/workflows/blocks/flatten-outputs'), + import('./column-naming'), + ]) + const targetWorkflowId = data.workflowId ?? group.workflowId + const normalized = await loadWorkflowFromNormalizedTables(targetWorkflowId) + if (normalized) { + const blocks = Object.values(normalized.blocks ?? {}).map((b) => ({ + id: b.id, + type: b.type, + name: b.name, + triggerMode: (b as { triggerMode?: boolean }).triggerMode, + subBlocks: b.subBlocks as Record | undefined, + })) + const flattened = flattenWorkflowOutputs(blocks, normalized.edges ?? []) + const flatByKey = new Map(flattened.map((f) => [`${f.blockId}::${f.path}`, f])) + const colByName = new Map(schema.columns.map((c) => [c.name, c])) + for (const u of mappingUpdates) { + const match = flatByKey.get(`${u.blockId}::${u.path}`) + if (!match) continue + const newType = columnTypeForLeaf(match.leafType) + const oldType = colByName.get(u.columnName)?.type + if (newType && newType !== oldType) { + remappedColumnTypes.set(u.columnName, newType) + } + } + } + } catch (err) { + logger.warn( + `[${requestId}] Could not resolve new leaf types for remap on group ${data.groupId}; leaving column types unchanged:`, + err + ) + } + } + + // If the caller passed `outputs`, that's the new full set. If only + // `mappingUpdates` was sent, the new set is the remapped old set. + const newOutputs = data.outputs ?? oldOutputs const oldKey = (o: WorkflowGroupOutput) => `${o.blockId}::${o.path}` - const oldByKey = new Map(group.outputs.map((o) => [oldKey(o), o])) + const oldByKey = new Map(oldOutputs.map((o) => [oldKey(o), o])) const newByKey = new Map(newOutputs.map((o) => [oldKey(o), o])) - const removed = group.outputs.filter((o) => !newByKey.has(oldKey(o))) + const removed = oldOutputs.filter((o) => !newByKey.has(oldKey(o))) const added = newOutputs.filter((o) => !oldByKey.has(oldKey(o))) const newColDefs = data.newOutputColumns ?? [] const newColByName = new Map(newColDefs.map((c) => [c.name, c])) @@ -2798,7 +2921,12 @@ export async function updateWorkflowGroup( } const removedColumnNames = new Set(removed.map((o) => o.columnName)) - let nextColumns = schema.columns.filter((c) => !removedColumnNames.has(c.name)) + let nextColumns = schema.columns + .filter((c) => !removedColumnNames.has(c.name)) + .map((c) => { + const newType = remappedColumnTypes.get(c.name) + return newType ? { ...c, type: newType } : c + }) if (newColDefs.length > 0) { // Splice the new column defs into the group's contiguous run rather than // appending at the end. The desired in-group order is `newOutputs` (the @@ -2835,8 +2963,13 @@ export async function updateWorkflowGroup( name: data.name ?? group.name, dependencies: data.dependencies ?? group.dependencies, outputs: newOutputs, + ...(data.autoRun !== undefined ? { autoRun: data.autoRun } : {}), } - const nextGroups = groups.map((g, i) => (i === groupIndex ? updatedGroup : g)) + // Removed outputs may be referenced as deps by sibling groups; strip those + // refs so we don't leave dangling-column deps that fail schema validation. + const nextGroups = groups + .map((g, i) => (i === groupIndex ? updatedGroup : g)) + .map((g) => (g.id === updatedGroup.id ? g : stripGroupDeps(g, removedColumnNames))) const updatedSchema: TableSchema = { ...schema, columns: nextColumns, @@ -2882,10 +3015,20 @@ export async function updateWorkflowGroup( sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` ) } + // Remapped columns: clear stale values in-tx so rows the backfill can't + // repopulate (no log, no matching span output) end up empty rather than + // retaining the previous mapping's value. The backfill below then writes + // the new mapping's value into rows where it can find one. + for (const name of remappedColumnNames) { + if (removedColumnNames.has(name)) continue + await trx.execute( + sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` + ) + } }) logger.info( - `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length})` + `[${requestId}] Updated workflow group "${data.groupId}" in table ${data.tableId} (added=${added.length}, removed=${removed.length}, remapped=${remappedColumnNames.size})` ) const updatedTable: TableDefinition = { @@ -2895,18 +3038,22 @@ export async function updateWorkflowGroup( updatedAt: now, } - // Backfill the new outputs from execution logs so already-completed group - // runs surface the just-added columns without re-running the workflow. - // Awaited so the response only returns once row data is consistent — the - // client then refetches and sees the backfilled values immediately. A failed - // backfill is logged but doesn't fail the whole request, since the schema + // Backfill from saved execution logs so already-completed group runs surface + // the schema changes without re-running the workflow. Two passes: + // - added outputs (new columns): never overwrite hand-edited values. + // - remapped outputs (existing column re-pointed): overwrite, since the + // new mapping is the source of truth and the user expects the cell to + // refresh to the new output's value. + // Awaited so the response only returns once row data is consistent. A + // failed backfill is logged but doesn't fail the request — the schema // change has already committed. if (added.length > 0) { try { - await backfillAddedGroupOutputs({ + await backfillGroupOutputsFromLogs({ table: updatedTable, groupId: data.groupId, - addedOutputs: added, + outputs: added, + overwrite: false, requestId, }) } catch (err) { @@ -2916,6 +3063,35 @@ export async function updateWorkflowGroup( ) } } + if (remappedColumnNames.size > 0) { + const remappedOutputs = newOutputs.filter((o) => remappedColumnNames.has(o.columnName)) + try { + await backfillGroupOutputsFromLogs({ + table: updatedTable, + groupId: data.groupId, + outputs: remappedOutputs, + overwrite: true, + requestId, + }) + } catch (err) { + logger.warn( + `[${requestId}] Remap backfill from execution logs failed for ${data.tableId} group ${data.groupId}:`, + err + ) + } + } + + // autoRun toggled false → true: fire deps-satisfied rows now. Mirrors the + // post-add scheduling path so re-enabling auto-fire doesn't require manual + // run clicks for rows that are already eligible. Awaited so the post- + // mutation refetch sees the queued exec stamps. + if (group.autoRun === false && data.autoRun === true) { + try { + await scheduleRunsForTable(updatedTable, { groupId: data.groupId }) + } catch (err) { + logger.error(`[${requestId}] Failed to schedule runs after autoRun toggled on:`, err) + } + } return updatedTable } @@ -3101,32 +3277,35 @@ export async function addWorkflowGroupOutput( `[${requestId}] Added output "${columnName}" (${newColDef.type}) to workflow group "${data.groupId}" in table ${data.tableId}` ) - // Backfill: re-run the group on every dep-satisfied row (including ones - // that previously completed) so the new column actually gets populated. - // Adding an output without values defeats the point — the user wants the - // values, not just the empty header. - void (async () => { - try { - const { triggerWorkflowGroupRun } = await import('./workflow-columns') - const { triggered } = await triggerWorkflowGroupRun({ - tableId: data.tableId, - groupId: data.groupId, - workspaceId: table.workspaceId, - mode: 'all', - requestId, - }) - logger.info( - `[${requestId}] Backfilled ${triggered} row(s) after adding output "${columnName}"` - ) - } catch (err) { - logger.error( - `[${requestId}] Failed to backfill rows after adding output "${columnName}":`, - err - ) - } - })() + // Backfill from saved execution logs — same flow `updateWorkflowGroup` + // uses for added outputs. Reads each row's saved trace spans for the + // group's executionId and writes the new output's value back. Existing + // rows that have hand-edited values are left alone (overwrite: false). + // Cheap compared to re-running the workflow on every row, which is what + // an earlier version of this code did — that mistakenly fanned out N + // workflow-group-cell jobs and burned compute the user didn't ask for. + const updatedTable: TableDefinition = { + ...table, + schema: updatedSchema, + metadata: updatedMetadata, + updatedAt: now, + } + try { + await backfillGroupOutputsFromLogs({ + table: updatedTable, + groupId: data.groupId, + outputs: [newOutput], + overwrite: false, + requestId, + }) + } catch (err) { + logger.warn( + `[${requestId}] Backfill from execution logs failed for ${data.tableId} group ${data.groupId} after adding output "${columnName}":`, + err + ) + } - return { ...table, schema: updatedSchema, metadata: updatedMetadata, updatedAt: now } + return updatedTable } /** @@ -3217,17 +3396,11 @@ export async function deleteWorkflowGroup( } const removedColumnNames = new Set(group.outputs.map((o) => o.columnName)) + // Removed group's output columns may be referenced as deps by sibling groups. + // Strip those refs so we don't leave dangling-column deps behind. const nextGroups = groups .filter((g) => g.id !== data.groupId) - .map((g) => ({ - ...g, - dependencies: g.dependencies?.workflowGroups - ? { - ...(g.dependencies.columns ? { columns: g.dependencies.columns } : {}), - workflowGroups: g.dependencies.workflowGroups.filter((id) => id !== data.groupId), - } - : g.dependencies, - })) + .map((g) => stripGroupDeps(g, removedColumnNames)) const updatedSchema: TableSchema = { ...schema, columns: schema.columns.filter((c) => !removedColumnNames.has(c.name)), @@ -3255,9 +3428,7 @@ export async function deleteWorkflowGroup( sql`UPDATE user_table_rows SET data = data - ${name}::text WHERE table_id = ${data.tableId} AND data ? ${name}::text` ) } - await trx.execute( - sql`UPDATE user_table_rows SET executions = executions - ${data.groupId}::text WHERE table_id = ${data.tableId} AND executions ? ${data.groupId}::text` - ) + await stripGroupExecutions(trx, data.tableId, [data.groupId]) }) logger.info(`[${requestId}] Deleted workflow group "${data.groupId}" from table ${data.tableId}`) @@ -3291,14 +3462,28 @@ function findSpanByBlockId( return undefined } -async function backfillAddedGroupOutputs(opts: { +/** + * Walks completed group executions and pulls each target output's value out of + * the workflow's saved trace spans, writing it back into row data. Used in two + * spots: + * + * - **added** outputs (new columns added to an existing group): `overwrite` + * is false, so rows with a hand-edited value already in the column are + * left alone. + * - **remapped** outputs (existing column re-pointed at a different + * `(blockId, path)`): `overwrite` is true — the new mapping is the source + * of truth, and the user expects the column to refresh to the new + * output's value rather than retain the stale old one. + */ +async function backfillGroupOutputsFromLogs(opts: { table: TableDefinition groupId: string - addedOutputs: WorkflowGroupOutput[] + outputs: WorkflowGroupOutput[] + overwrite: boolean requestId: string }): Promise { - const { table, groupId, addedOutputs, requestId } = opts - if (addedOutputs.length === 0) return + const { table, groupId, outputs, overwrite, requestId } = opts + if (outputs.length === 0) return const { pluckByPath } = await import('./pluck') @@ -3342,8 +3527,8 @@ async function backfillAddedGroupOutputs(opts: { const dataPatch: RowData = {} let mutated = false - for (const out of addedOutputs) { - if ((r.data as RowData)[out.columnName] !== undefined) continue + for (const out of outputs) { + if (!overwrite && (r.data as RowData)[out.columnName] !== undefined) continue const span = findSpanByBlockId(log.traceSpans, out.blockId) if (!span?.output) continue const picked = pluckByPath(span.output, out.path) @@ -3368,7 +3553,7 @@ async function backfillAddedGroupOutputs(opts: { ) logger.info( - `[${requestId}] Backfilled ${updates.length} row(s) for group "${groupId}" in table ${table.id}` + `[${requestId}] Backfilled ${updates.length} row(s) for group "${groupId}" in table ${table.id} (${overwrite ? 'remapped' : 'added'})` ) } diff --git a/apps/sim/lib/table/types.ts b/apps/sim/lib/table/types.ts index e8976275ae..5d6b90d841 100644 --- a/apps/sim/lib/table/types.ts +++ b/apps/sim/lib/table/types.ts @@ -44,15 +44,13 @@ export interface WorkflowGroupOutput { } export interface WorkflowGroupDependencies { - /** Plain columns that must be non-empty before this group runs. */ - columns?: string[] /** - * Other workflow groups that must reach `status: completed` before this - * group runs. The dep graph is a first-class concept — you depend on a - * producing group, never on a sibling output value (which can legitimately - * be null on success). + * Columns that must be non-empty before this group runs. Workflow output + * columns count too — once an upstream group fills its output column, any + * downstream group depending on that column becomes eligible. The user + * model is uniform: deps are columns, not group-completion edges. */ - workflowGroups?: string[] + columns?: string[] } export interface WorkflowGroup { @@ -62,6 +60,13 @@ export interface WorkflowGroup { name?: string dependencies?: WorkflowGroupDependencies outputs: WorkflowGroupOutput[] + /** + * When `false`, the group never auto-fires from the scheduler — it can only + * be triggered manually via the "Run" actions. Defaults to `true` so + * existing groups keep firing on dep satisfaction. Persisted alongside the + * group definition; the scheduler reads it in `isGroupEligible`. + */ + autoRun?: boolean } /** @@ -70,7 +75,7 @@ export interface WorkflowGroup { * values land in `row.data` directly. */ export interface RowExecutionMetadata { - status: 'pending' | 'running' | 'completed' | 'error' | 'cancelled' + status: 'pending' | 'queued' | 'running' | 'completed' | 'error' | 'cancelled' executionId: string | null /** * Async-job id (e.g. trigger.dev run id) for the in-flight execution. @@ -303,6 +308,14 @@ export interface UpdateRowData { * state. `updateRow` returns `null` when the guard rejects the write. */ cancellationGuard?: { groupId: string; executionId: string } + /** + * When true, the post-write `scheduleRunsForRows` call is skipped. Used by + * the cancel path (which is tearing rows down, not waking them up) and by + * the manual-run path (which fires its own `scheduleRunsForRows` with + * `isManualRun: true` and doesn't want a duplicate auto-fire pass on the + * cleared cells). Default false: every other write fires the reactor. + */ + skipScheduler?: boolean } export interface BulkUpdateData { @@ -321,6 +334,8 @@ export interface BatchUpdateByIdData { executionsPatch?: Record }> workspaceId: string + /** Same semantics as `UpdateRowData.skipScheduler`. */ + skipScheduler?: boolean } export interface BulkDeleteData { @@ -401,6 +416,15 @@ export interface UpdateWorkflowGroupData { outputs?: WorkflowGroupOutput[] /** Column definitions for any newly-added outputs. */ newOutputColumns?: ColumnDefinition[] + /** + * Per-column mapping swaps: keep the existing column, repoint it at a new + * `(blockId, path)`. Applied before the `outputs` diff and clears the + * affected columns' row data so the next run repopulates from the new + * source. + */ + mappingUpdates?: Array<{ columnName: string; blockId: string; path: string }> + /** Toggle the group's auto-run flag. Omit to leave it unchanged. */ + autoRun?: boolean } export interface DeleteWorkflowGroupData { diff --git a/apps/sim/lib/table/validation.ts b/apps/sim/lib/table/validation.ts index dbea6a32c4..8f6d2757d4 100644 --- a/apps/sim/lib/table/validation.ts +++ b/apps/sim/lib/table/validation.ts @@ -276,7 +276,7 @@ export function getUniqueColumns(schema: TableSchema): ColumnDefinition[] { export function validateUniqueConstraints( data: RowData, schema: TableSchema, - existingRows: { id: string; data: RowData }[], + existingRows: { id: string; data: RowData; position?: number }[], excludeRowId?: string ): ValidationResult { const errors: string[] = [] @@ -297,8 +297,10 @@ export function validateUniqueConstraints( }) if (duplicate) { + const rowLabel = + typeof duplicate.position === 'number' ? `row ${duplicate.position + 1}` : duplicate.id errors.push( - `Column "${column.name}" must be unique. Value "${value}" already exists in row ${duplicate.id}` + `Column "${column.name}" must be unique. Value "${value}" already exists in ${rowLabel}` ) } } @@ -364,14 +366,14 @@ export async function checkUniqueConstraintsDb( : baseCondition const conflictingRow = await db - .select({ id: userTableRows.id }) + .select({ id: userTableRows.id, position: userTableRows.position }) .from(userTableRows) .where(whereClause) .limit(1) if (conflictingRow.length > 0) { errors.push( - `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].id}` + `Column "${condition.column.name}" must be unique. Value "${condition.value}" already exists in row ${conflictingRow[0].position + 1}` ) } } @@ -474,6 +476,7 @@ export async function checkBatchUniqueConstraintsDb( .select({ id: userTableRows.id, data: userTableRows.data, + position: userTableRows.position, }) .from(userTableRows) .where(and(eq(userTableRows.tableId, tableId), or(...valueConditions))) @@ -504,7 +507,7 @@ export async function checkBatchUniqueConstraintsDb( rowErrors.push(rowError) } - const errorMsg = `Column "${columnName}" must be unique. Value "${rowValue}" already exists in row ${conflict.id}` + const errorMsg = `Column "${columnName}" must be unique. Value "${rowValue}" already exists in row ${conflict.position + 1}` if (!rowError.errors.includes(errorMsg)) { rowError.errors.push(errorMsg) } diff --git a/apps/sim/lib/table/workflow-columns.ts b/apps/sim/lib/table/workflow-columns.ts index b50ae47d08..71b091c2df 100644 --- a/apps/sim/lib/table/workflow-columns.ts +++ b/apps/sim/lib/table/workflow-columns.ts @@ -6,11 +6,13 @@ */ import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' +import { pausedExecutions, userTableRows } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, sql } from 'drizzle-orm' +import { getJobQueue } from '@/lib/core/async-jobs/config' +import type { EnqueueOptions } from '@/lib/core/async-jobs/types' import { buildCancelledExecution, writeWorkflowGroupState } from '@/lib/table/cell-write' import type { RowData, @@ -24,70 +26,123 @@ import type { const logger = createLogger('WorkflowGroupScheduler') -/** - * Returns true when every dependency this group needs is filled. Plain - * columns are filled when their value is non-empty; upstream groups are - * filled when `executions[gid].status === 'completed'`. Used both by the - * scheduler's eligibility check and by the manual "Run group" route, which - * needs the same gate WITHOUT the in-flight / terminal-state check. - */ -export function areGroupDepsSatisfied(group: WorkflowGroup, row: TableRow): boolean { - const deps = group.dependencies ?? {} - for (const colName of deps.columns ?? []) { - const value = row.data[colName] - if (value === null || value === undefined || value === '') return false - } - for (const gid of deps.workflowGroups ?? []) { - if (row.executions?.[gid]?.status !== 'completed') return false - } - return true -} +import { areGroupDepsSatisfied, areOutputsFilled, isExecInFlight } from './deps' + +export { + areGroupDepsSatisfied, + areOutputsFilled, + getUnmetGroupDeps, + isExecInFlight, + optimisticallyScheduleNewlyEligibleGroups, + type UnmetDeps, +} from './deps' /** - * Per-(row, group) eligibility: returns true if a cell job should be enqueued - * for this pair right now. Skip when the group is in flight (`running`, or - * `pending` with a `jobId` already stamped) or in a terminal state. Plain - * `pending` without a jobId is the "ready to dispatch" state — the run route - * sets it and the scheduler is what actually enqueues the job. + * Per-(row, group) eligibility for both the auto-fire reactor and manual + * runs. Manual runs bypass the `autoRun === false` skip, and additionally + * bypass the dep check for `autoRun === false` groups (those are user-model + * "no deps, manual only"). + * + * "Completed" status is treated as stale when any output cell is empty — the + * cells win over the exec metadata, so deleting an output value re-arms the + * row for the cascade and for manual incomplete-mode runs. */ -export function isGroupEligible(group: WorkflowGroup, row: TableRow): boolean { +/** + * Reason codes the eligibility predicate emits. Stable strings so the caller + * can aggregate skip reasons into one summary log per scheduler call instead + * of allocating a per-cell debug line. + */ +export type EligibilityReason = + | 'eligible' + | 'autoRun-off' + | 'in-flight' + | 'completed-on-auto' + | 'error-on-auto' + | 'completed-on-incomplete' + | 'manual-bypass' + | 'deps-unmet' + +export function classifyEligibility( + group: WorkflowGroup, + row: TableRow, + opts?: { isManualRun?: boolean; mode?: 'all' | 'incomplete' } +): EligibilityReason { + const isManualRun = opts?.isManualRun ?? false + const mode = opts?.mode ?? 'all' + + if (group.autoRun === false && !isManualRun) return 'autoRun-off' + const exec = row.executions?.[group.id] + if (isExecInFlight(exec)) return 'in-flight' const status = exec?.status - if ( - status === 'running' || - status === 'completed' || - status === 'error' || - status === 'cancelled' - ) { - return false - } - if (status === 'pending' && exec?.jobId) { - return false - } - return areGroupDepsSatisfied(group, row) + + const completedAndFilled = status === 'completed' && areOutputsFilled(group, row) + if (!isManualRun && completedAndFilled) return 'completed-on-auto' + // Auto-fire skips `error` to avoid infinite-retry loops on a deterministic + // failure. `cancelled` is left runnable — cancellation is user-initiated. + if (!isManualRun && status === 'error') return 'error-on-auto' + if (mode === 'incomplete' && completedAndFilled) return 'completed-on-incomplete' + + if (isManualRun && group.autoRun === false) return 'manual-bypass' + return areGroupDepsSatisfied(group, row) ? 'eligible' : 'deps-unmet' +} + +export function isGroupEligible( + group: WorkflowGroup, + row: TableRow, + opts?: { isManualRun?: boolean; mode?: 'all' | 'incomplete' } +): boolean { + return classifyEligibility(group, row, opts) === 'eligible' } /** - * Iterates workflow groups × rows and enqueues eligible cell jobs. Safe to - * call after any row-write; errors are logged. Concurrency is bounded by the - * trigger.dev queue (`concurrencyKey: tableId`), so this just enqueues. + * Shared options for the three `scheduleRuns*` entry points. `isManualRun` + * flips two gates in the eligibility predicate so a manual click can re-run + * terminal states and bypass the autoRun=false skip. */ -export async function scheduleWorkflowGroupRuns( +export interface ScheduleOpts { + groupId?: string + groupIds?: string[] + isManualRun?: boolean + mode?: 'all' | 'incomplete' +} + +/** + * Re-evaluate eligibility on these specific rows and enqueue runnable cells. + * The hot path: every row write (insert / update / cascade) calls this with the + * just-written row(s). + */ +export async function scheduleRunsForRows( table: TableDefinition, - rows: TableRow[] -): Promise { + rows: TableRow[], + opts?: ScheduleOpts +): Promise<{ triggered: number }> { try { - const groups = table.schema.workflowGroups ?? [] - if (groups.length === 0) return - if (rows.length === 0) return + const allGroups = table.schema.workflowGroups ?? [] + if (allGroups.length === 0) return { triggered: 0 } + if (rows.length === 0) return { triggered: 0 } + + const groupIdFilter = opts?.groupIds + ? new Set(opts.groupIds) + : opts?.groupId + ? new Set([opts.groupId]) + : null + const groups = groupIdFilter ? allGroups.filter((g) => groupIdFilter.has(g.id)) : allGroups + if (groups.length === 0) return { triggered: 0 } const orderedRows = rows.length <= 1 ? rows : [...rows].sort((a, b) => a.position - b.position) const pendingRuns: RunGroupCellOptions[] = [] + const reasonCounts: Partial> = {} for (const row of orderedRows) { for (const group of groups) { - if (!isGroupEligible(group, row)) continue + const reason = classifyEligibility(group, row, { + isManualRun: opts?.isManualRun, + mode: opts?.mode, + }) + reasonCounts[reason] = (reasonCounts[reason] ?? 0) + 1 + if (reason !== 'eligible') continue pendingRuns.push({ tableId: table.id, tableName: table.name, @@ -100,13 +155,122 @@ export async function scheduleWorkflowGroupRuns( } } - if (pendingRuns.length === 0) return + logger.debug( + `[Cascade] table=${table.id} rows=${rows.length} groups=${groups.length} manual=${opts?.isManualRun ?? false} mode=${opts?.mode ?? 'all'} reasons=${JSON.stringify(reasonCounts)}` + ) + + if (pendingRuns.length === 0) return { triggered: 0 } logger.info(`Scheduling ${pendingRuns.length} workflow group cell run(s) for table=${table.id}`) - await Promise.allSettled(pendingRuns.map((opts) => runWorkflowGroupCell(opts))) + const queue = await getJobQueue() + const { executeWorkflowGroupCellJob } = await import('@/background/workflow-column-execution') + const items = pendingRuns.map((runOpts) => ({ + payload: runOpts, + options: { + metadata: { + workflowId: runOpts.workflowId, + workspaceId: runOpts.workspaceId, + correlation: { + executionId: runOpts.executionId, + requestId: `wfgrp-${runOpts.executionId}`, + source: 'workflow' as const, + workflowId: runOpts.workflowId, + triggerType: 'table', + }, + }, + concurrencyKey: runOpts.tableId, + concurrencyLimit: TABLE_CONCURRENCY_LIMIT, + tags: [`tableId:${runOpts.tableId}`, `rowId:${runOpts.rowId}`, `group:${runOpts.groupId}`], + runner: executeWorkflowGroupCellJob as EnqueueOptions['runner'], + }, + })) + + let jobIds: string[] + try { + jobIds = await queue.batchEnqueue('workflow-group-cell', items) + } catch (err) { + logger.error(`Batch enqueue failed for table=${table.id}:`, err) + await Promise.allSettled( + pendingRuns.map((runOpts) => + writeWorkflowGroupState(runOpts, { + executionState: { + status: 'error', + executionId: runOpts.executionId, + jobId: null, + workflowId: runOpts.workflowId, + error: toError(err).message, + }, + }) + ) + ) + return { triggered: 0 } + } + + // Stamp `queued` in chunks of `TABLE_CONCURRENCY_LIMIT`. Within a chunk we + // parallelize the writes (no ordering constraint); across chunks we await + // serially so trigger.dev still picks rows up in submission order — the + // concurrency cap means at most one chunk is in flight per table anyway. + for (let i = 0; i < pendingRuns.length; i += TABLE_CONCURRENCY_LIMIT) { + const chunk = pendingRuns.slice(i, i + TABLE_CONCURRENCY_LIMIT) + const ids = jobIds.slice(i, i + TABLE_CONCURRENCY_LIMIT) + await Promise.all(chunk.map((run, j) => stampQueuedOrCancel(queue, run, ids[j]))) + } + return { triggered: pendingRuns.length } } catch (err) { - logger.error('scheduleWorkflowGroupRuns failed:', err) + logger.error('scheduleRunsForRows failed:', err) + return { triggered: 0 } + } +} + +/** + * Re-evaluate eligibility on every row of the table. Used after schema changes + * (workflow group added, autoRun toggled on) where we don't have a list of + * just-written rows but need to fire any newly-eligible (row × group) pair. + */ +export async function scheduleRunsForTable( + table: TableDefinition, + opts?: ScheduleOpts +): Promise<{ triggered: number }> { + const rows = await fetchAllRows(table.id) + return scheduleRunsForRows(table, rows, opts) +} + +/** + * Re-evaluate eligibility on the rows with these ids. Sugar for callers that + * have row ids but not materialized rows. + */ +export async function scheduleRunsForRowIds( + table: TableDefinition, + rowIds: string[], + opts?: ScheduleOpts +): Promise<{ triggered: number }> { + if (rowIds.length === 0) return { triggered: 0 } + const rows = await fetchRowsByIds(table.id, rowIds) + return scheduleRunsForRows(table, rows, opts) +} + +async function fetchAllRows(tableId: string): Promise { + const records = await db.select().from(userTableRows).where(eq(userTableRows.tableId, tableId)) + return records.map(toTableRow) +} + +async function fetchRowsByIds(tableId: string, rowIds: string[]): Promise { + const records = await db + .select() + .from(userTableRows) + .where(and(eq(userTableRows.tableId, tableId), inArray(userTableRows.id, rowIds))) + return records.map(toTableRow) +} + +function toTableRow(r: typeof userTableRows.$inferSelect): TableRow { + return { + id: r.id, + data: r.data as RowData, + executions: (r.executions as RowExecutions) ?? {}, + position: r.position, + createdAt: r.createdAt, + updatedAt: r.updatedAt, } } @@ -120,127 +284,38 @@ interface RunGroupCellOptions { executionId: string } -/** - * Enqueues a workflow-group cell run as a `workflow-group-cell` async job - * and writes `running` (with the returned `jobId`) onto the row's - * `executions[groupId]`. The actual workflow execution and terminal write - * happen inside the cell task body. Cancellation is authoritative via - * `cancelWorkflowGroupRuns`. - */ -export async function runWorkflowGroupCell(opts: RunGroupCellOptions): Promise { - const { tableId, tableName, rowId, groupId, workflowId, workspaceId, executionId } = opts +/** Per-table concurrency cap. Mirrors trigger.dev's `concurrencyLimit: 20`. */ +const TABLE_CONCURRENCY_LIMIT = 20 - const { getJobQueue, shouldExecuteInline } = await import('@/lib/core/async-jobs/config') - const cellCtx = { tableId, rowId, workspaceId, groupId, executionId } - - const taskPayload = { - tableId, - tableName, - rowId, - groupId, - workflowId, - workspaceId, - executionId, - } - let jobId: string - let queue: Awaited> - try { - queue = await getJobQueue() - jobId = await queue.enqueue('workflow-group-cell', taskPayload, { - metadata: { - workflowId, - workspaceId, - correlation: { - executionId, - requestId: `wfgrp-${executionId}`, - source: 'workflow', - workflowId, - triggerType: 'table', - }, - }, - // Per-table sub-queue throttles cells within a table without blocking other tables. - concurrencyKey: tableId, - tags: [`tableId:${tableId}`, `rowId:${rowId}`, `group:${groupId}`], - }) - } catch (err) { - const message = toError(err).message - logger.error( - `Failed to enqueue workflow-group-cell (table=${tableId} row=${rowId} group=${groupId}):`, - err - ) - await writeWorkflowGroupState(cellCtx, { - executionState: { - status: 'error', - executionId, - jobId: null, - workflowId, - error: message, - }, - }) - return - } - - // Single post-enqueue write: stamps `running` + jobId so the cancel API can - // reach this run from any pod. If cancel won the race the helper bails and - // we abort the just-enqueued job. +async function stampQueuedOrCancel( + queue: Awaited>, + opts: RunGroupCellOptions, + jobId: string +): Promise { let stampResult: 'wrote' | 'skipped' = 'wrote' try { - stampResult = await writeWorkflowGroupState(cellCtx, { + stampResult = await writeWorkflowGroupState(opts, { executionState: { - status: 'running', - executionId, + status: 'queued', + executionId: opts.executionId, jobId, - workflowId, + workflowId: opts.workflowId, error: null, }, }) } catch (err) { logger.error( - `Failed to persist jobId on group execution (table=${tableId} row=${rowId} group=${groupId}):`, + `Failed to stamp queued state (table=${opts.tableId} row=${opts.rowId} group=${opts.groupId}):`, err ) } + if (stampResult === 'skipped') { try { await queue.cancelJob(jobId) } catch (cancelErr) { logger.error(`Failed to cancel orphaned workflow-group-cell job (jobId=${jobId}):`, cancelErr) } - return - } - - // Trigger.dev disabled — execute the task body inline (DB queue records - // rows but doesn't dispatch), mirroring `workflow-execution`. - if (shouldExecuteInline()) { - const { registerInlineAbort, unregisterInlineAbort } = await import( - '@/lib/core/async-jobs/inline-abort' - ) - const abortController = new AbortController() - registerInlineAbort(jobId, abortController) - - void (async () => { - try { - const { executeWorkflowGroupCellJob } = await import( - '@/background/workflow-column-execution' - ) - await queue.startJob(jobId) - await executeWorkflowGroupCellJob(taskPayload, abortController.signal) - await queue.completeJob(jobId, null) - } catch (err) { - const message = toError(err).message - logger.error( - `Inline workflow-group-cell failed (jobId=${jobId} table=${tableId} row=${rowId} group=${groupId}):`, - err - ) - try { - await queue.markJobFailed(jobId, message) - } catch (markErr) { - logger.error('Also failed to mark job as failed:', markErr) - } - } finally { - unregisterInlineAbort(jobId) - } - })() } } @@ -295,9 +370,11 @@ export async function cancelWorkflowGroupRuns(tableId: string, rowId?: string): let cancelledCount = 0 for (const [gid, exec] of Object.entries(executions)) { if (!groupIds.has(gid)) continue - // `pending` covers the post-reset, pre-dispatch window — a stop click - // there must still stick once the scheduler picks the row up. - if (exec.status !== 'running' && exec.status !== 'pending') continue + // `pending` covers the post-reset, pre-dispatch window; `queued` covers + // the post-enqueue, pre-pickup window — a stop click in either state + // must still stick once the worker picks the row up. + if (exec.status !== 'running' && exec.status !== 'queued' && exec.status !== 'pending') + continue if (exec.jobId) jobIds.push(exec.jobId) executionsPatch[gid] = buildCancelledExecution(exec) cancelledCount++ @@ -318,6 +395,11 @@ export async function cancelWorkflowGroupRuns(tableId: string, rowId?: string): ) ) ) + // `skipScheduler: true` — we're tearing rows down, not waking them up. The + // auto-fire reactor would otherwise see independent (row, group) pairs whose + // deps are now satisfied (because the upstream group already wrote its + // output before the cancel) and re-enqueue them, which is exactly what the + // user clicked Stop to prevent. await Promise.allSettled( mutations.map((m) => updateRow( @@ -327,6 +409,7 @@ export async function cancelWorkflowGroupRuns(tableId: string, rowId?: string): data: {}, workspaceId: table.workspaceId, executionsPatch: m.executionsPatch, + skipScheduler: true, }, table, `wfgrp-cancel-${m.rowId}` @@ -340,50 +423,38 @@ export async function cancelWorkflowGroupRuns(tableId: string, rowId?: string): } /** - * Manually triggers a workflow group for every dep-satisfied row in a table. - * `mode: 'all'` re-runs every eligible row; `mode: 'incomplete'` skips rows - * whose group is already `completed`. When `rowIds` is provided, only those - * rows are candidates — the same eligibility predicate still applies, so a - * mid-run row or one with unmet deps is silently skipped. Eligible rows have - * their output cells cleared and their `executions[groupId]` reset to - * `pending`; the scheduler picks them up and enqueues per-cell jobs. Returns - * the number of rows that were marked for re-run. Used by the - * `groups/[groupId]/run` HTTP route and the Copilot/Mothership - * `run_workflow_group` op so both share one eligibility predicate. + * Run a set of groups across the table or a row subset. Single canonical + * user-driven run op — every UI gesture (single cell, per-row Play, action-bar + * Play/Refresh, column-header menu) reduces to this. `mode: 'all'` re-runs + * completed cells; `mode: 'incomplete'` skips them. `groupIds` omitted = every + * workflow group on the table. `rowIds` omitted = every row. */ -export async function triggerWorkflowGroupRun(opts: { +export async function runWorkflowColumn(opts: { tableId: string - groupId: string workspaceId: string mode: 'all' | 'incomplete' requestId: string + groupIds?: string[] rowIds?: string[] }): Promise<{ triggered: number }> { - const { tableId, groupId, workspaceId, mode, requestId, rowIds } = opts + const { tableId, workspaceId, mode, requestId, groupIds, rowIds } = opts const { getTableById, batchUpdateRows } = await import('./service') const table = await getTableById(tableId) if (!table) throw new Error('Table not found') if (table.workspaceId !== workspaceId) throw new Error('Invalid workspace ID') - const group = (table.schema.workflowGroups ?? []).find((g) => g.id === groupId) - if (!group) throw new Error('Workflow group not found') + const allGroups = table.schema.workflowGroups ?? [] + const targetGroups = groupIds ? allGroups.filter((g) => groupIds.includes(g.id)) : allGroups + if (targetGroups.length === 0) return { triggered: 0 } - // Push the in-flight / terminal-state filters into SQL so we don't pull - // every row in the table into Node just to discard most of them. Dependency - // satisfaction is still checked in JS afterwards (it can span multiple - // columns and other groups' statuses, so it's awkward to express in JSONB). - const filters = [ - eq(userTableRows.tableId, tableId), - eq(userTableRows.workspaceId, workspaceId), - sql`(executions->${groupId}->>'status') IS DISTINCT FROM 'running'`, - sql`((executions->${groupId}->>'status') IS DISTINCT FROM 'pending' OR (executions->${groupId}->>'jobId') IS NULL)`, - ] + logger.info( + `[Cascade] [${requestId}] manual run table=${tableId} groups=[${targetGroups.map((g) => g.id).join(',')}] rows=${rowIds ? `[${rowIds.join(',')}]` : 'all'} mode=${mode}` + ) + + const filters = [eq(userTableRows.tableId, tableId), eq(userTableRows.workspaceId, workspaceId)] if (rowIds && rowIds.length > 0) { filters.push(inArray(userTableRows.id, rowIds)) } - if (mode === 'incomplete') { - filters.push(sql`(executions->${groupId}->>'status') IS DISTINCT FROM 'completed'`) - } const candidateRows = await db .select({ id: userTableRows.id, @@ -399,7 +470,15 @@ export async function triggerWorkflowGroupRun(opts: { if (candidateRows.length === 0) return { triggered: 0 } - const eligibleRows = candidateRows.filter((r) => { + // Per-row: collect eligible groups, build cleared data + executionsPatch. + type Update = { + rowId: string + data: RowData + executionsPatch: Record + } + const updates: Update[] = [] + const clearedRows: TableRow[] = [] + for (const r of candidateRows) { const tableRow: TableRow = { id: r.id, data: r.data as RowData, @@ -408,37 +487,65 @@ export async function triggerWorkflowGroupRun(opts: { createdAt: r.createdAt, updatedAt: r.updatedAt, } - try { - return areGroupDepsSatisfied(group, tableRow) - } catch { - return false + const eligibleGroups = targetGroups.filter((g) => + isGroupEligible(g, tableRow, { isManualRun: true, mode }) + ) + if (eligibleGroups.length === 0) continue + + const clearedData: RowData = {} + const executionsPatch: Record = {} + for (const g of eligibleGroups) { + for (const o of g.outputs) clearedData[o.columnName] = null + executionsPatch[g.id] = null } + updates.push({ rowId: r.id, data: clearedData, executionsPatch }) + + const remainingExec = { ...tableRow.executions } + for (const g of eligibleGroups) delete remainingExec[g.id] + clearedRows.push({ + ...tableRow, + data: { ...tableRow.data, ...clearedData }, + executions: remainingExec, + }) + } + + if (updates.length === 0) return { triggered: 0 } + + // `skipScheduler: true` because we fire `scheduleRunsForRows` ourselves + // below with `isManualRun: true`. Without the skip, batchUpdateRows runs the + // auto-fire reactor first and any autoRun=true sibling group whose deps are + // satisfied would race the manual call. + await batchUpdateRows({ tableId, updates, workspaceId, skipScheduler: true }, table, requestId) + + return scheduleRunsForRows(table, clearedRows, { + isManualRun: true, + groupIds: targetGroups.map((g) => g.id), + mode, }) - - if (eligibleRows.length === 0) return { triggered: 0 } - - const clearedData = Object.fromEntries(group.outputs.map((o) => [o.columnName, null])) as RowData - const updates = eligibleRows.map((r) => { - const pendingExec: RowExecutionMetadata = { - status: 'pending', - executionId: generateId(), - jobId: null, - workflowId: group.workflowId, - error: null, - } - return { - rowId: r.id, - data: clearedData, - executionsPatch: { [groupId]: pendingExec }, - } - }) - - const opResult = await batchUpdateRows({ tableId, updates, workspaceId }, table, requestId) - return { triggered: opResult.affectedCount } } // ───────────────────────────── Validation ───────────────────────────── +/** +/** + * Removes the given column names from a group's `dependencies.columns`. When + * the resulting list is empty, drops the `dependencies` field entirely so + * schema validation doesn't see an empty-deps object. Returns the same group + * reference when nothing changed. + */ +export function stripGroupDeps(group: WorkflowGroup, removed: ReadonlySet): WorkflowGroup { + const cols = group.dependencies?.columns + if (!cols || cols.length === 0) return group + const filtered = cols.filter((d) => !removed.has(d)) + if (filtered.length === cols.length) return group + return { + ...group, + ...(filtered.length > 0 + ? { dependencies: { columns: filtered } } + : { dependencies: undefined }), + } +} + /** * Validates schema-level invariants. Run on every `addTableColumn`, * `addWorkflowGroup`, `updateWorkflowGroup`, `renameColumn`, `reorderColumns`, @@ -502,34 +609,27 @@ export function validateSchema(schema: TableSchema, columnOrder: string[] | unde } } - // Dependency integrity. + // Dependency integrity. Deps are columns only — workflow output columns are + // valid deps too (the upstream group fills them, downstream becomes eligible + // when filled). A group can't depend on its own outputs. for (const group of groups) { - const deps = group.dependencies ?? {} - for (const depCol of deps.columns ?? []) { + const ownOutputs = new Set(group.outputs.map((o) => o.columnName)) + for (const depCol of group.dependencies?.columns ?? []) { const col = columnsByName.get(depCol) if (!col) { errors.push(`Group "${group.name ?? group.id}" depends on missing column "${depCol}".`) continue } - if (col.workflowGroupId) { + if (ownOutputs.has(depCol)) { errors.push( - `Group "${group.name ?? group.id}" depends on workflow-output column "${depCol}". Depend on the producing group instead.` + `Group "${group.name ?? group.id}" depends on its own output column "${depCol}".` ) } } - for (const depGroup of deps.workflowGroups ?? []) { - if (!groupsById.has(depGroup)) { - errors.push( - `Group "${group.name ?? group.id}" depends on missing workflow group "${depGroup}".` - ) - } - if (depGroup === group.id) { - errors.push(`Group "${group.name ?? group.id}" depends on itself.`) - } - } } - // Cycle detection on the group dependency graph. + // Cycle detection on the column-induced group graph. An edge A → B exists + // when B depends on a column that A produces. const cycle = findGroupCycle(groups) if (cycle) { errors.push( @@ -549,11 +649,25 @@ export function validateSchema(schema: TableSchema, columnOrder: string[] | unde return errors } -/** Returns the cycle as an ordered list of group ids, or null if acyclic. */ +/** + * Returns the cycle as an ordered list of group ids, or null if acyclic. Edges + * are induced by columns: an edge A → B exists iff B depends on a column that + * A produces. + */ function findGroupCycle(groups: WorkflowGroup[]): string[] | null { + // Map each output column → the group that produces it. + const producerByColumn = new Map() + for (const g of groups) { + for (const o of g.outputs) producerByColumn.set(o.columnName, g.id) + } const adjacency = new Map() for (const g of groups) { - adjacency.set(g.id, g.dependencies?.workflowGroups ?? []) + const upstream = new Set() + for (const depCol of g.dependencies?.columns ?? []) { + const producer = producerByColumn.get(depCol) + if (producer && producer !== g.id) upstream.add(producer) + } + adjacency.set(g.id, [...upstream]) } const VISITING = 1 const VISITED = 2 @@ -590,6 +704,75 @@ interface SplitGroupReport { actual: number[] } +/** + * Cell context stored on `paused_executions.metadata` so the resume worker + * can route post-resume block outputs back to the same `(tableId, rowId, + * groupId)` cell — i.e., one logical cell execution across pause/resume + * cycles instead of two. + */ +export interface CellResumeContext { + tableId: string + tableName: string + rowId: string + groupId: string + workspaceId: string + workflowId: string +} + +interface PausedMetadataPatch { + cellContext?: CellResumeContext + [key: string]: unknown +} + +/** + * Stash the cell context on the matching `paused_executions` row. Called + * by the cell task right after it writes the `pending`/paused state. The + * pause record was written by `PauseResumeManager.persistPauseResult` + * before `executeWorkflow` returned, so the row exists. + */ +export async function stashCellContextForResume( + ctx: CellResumeContext & { executionId: string } +): Promise { + const { executionId, ...cellContext } = ctx + try { + const patch: PausedMetadataPatch = { cellContext } + await db + .update(pausedExecutions) + .set({ + metadata: sql`coalesce(${pausedExecutions.metadata}, '{}'::jsonb) || ${JSON.stringify(patch)}::jsonb`, + updatedAt: new Date(), + }) + .where(eq(pausedExecutions.executionId, executionId)) + } catch (err) { + logger.error( + `Failed to stash cell context on paused_executions (executionId=${executionId}):`, + err + ) + } +} + +/** + * Returns the cell context for an execution if one was stashed at pause + * time. Used by the resume worker to know whether the workflow it's about + * to resume belongs to a table cell — and if so, where to write outputs. + */ +export async function findCellContextByExecutionId( + executionId: string +): Promise { + try { + const [row] = await db + .select({ metadata: pausedExecutions.metadata }) + .from(pausedExecutions) + .where(eq(pausedExecutions.executionId, executionId)) + .limit(1) + const meta = row?.metadata as PausedMetadataPatch | null + return meta?.cellContext ?? null + } catch (err) { + logger.error(`Failed to read cell context for executionId=${executionId}:`, err) + return null + } +} + /** * Returns groups whose output columns occupy non-contiguous positions in the * given columnOrder. Empty array means all groups are cohesive. diff --git a/apps/sim/tools/exa/search.ts b/apps/sim/tools/exa/search.ts index d1a09739ed..c3b2c5c779 100644 --- a/apps/sim/tools/exa/search.ts +++ b/apps/sim/tools/exa/search.ts @@ -127,7 +127,7 @@ export const searchTool: ToolConfig = { }, rateLimit: { mode: 'per_request', - requestsPerMinute: 5, + requestsPerMinute: 60, }, }, diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 3045606207..bbf0d0f249 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 735, - zodRoutes: 735, + totalRoutes: 734, + zodRoutes: 734, nonZodRoutes: 0, } as const
+ {headerGroups.map((g) => g.kind === 'workflow' ? ( handleConfigureWorkflowGroup(g.groupId)} + onRunColumn={userPermissions.canEdit ? handleRunColumn : undefined} + selectedRowIds={selectedRowIds} onInsertLeft={ userPermissions.canEdit ? handleInsertColumnLeft : undefined } @@ -2688,6 +2939,18 @@ export function Table({ onDeleteGroup={ userPermissions.canEdit ? handleDeleteWorkflowGroup : undefined } + onViewWorkflow={handleViewWorkflow} + readOnly={!userPermissions.canEdit} + onDragStart={ + userPermissions.canEdit ? handleColumnDragStart : undefined + } + onDragOver={ + userPermissions.canEdit ? handleColumnDragOver : undefined + } + onDragEnd={userPermissions.canEdit ? handleColumnDragEnd : undefined} + onDragLeave={ + userPermissions.canEdit ? handleColumnDragLeave : undefined + } /> ) : ( ))} {userPermissions.canEdit && ( - )}
{ - if (e.button !== 0) return - onRowToggle(rowIndex, e.shiftKey) - }} - > -
-
+
+
+
{ + if (e.button !== 0) return + onRowToggle(rowIndex, e.shiftKey) + }} + > @@ -3159,7 +3334,7 @@ const DataRow = React.memo(function DataRow({
@@ -3167,14 +3342,16 @@ const DataRow = React.memo(function DataRow({
{hasWorkflowColumns && ( - + )}
- -