@anchrd/intel 0.59.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +496 -0
- package/bin/intel.mjs +134 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.d.ts +42 -0
- package/dist/adapters/cloudflare/cloudflare-flow-workflow.js +89 -0
- package/dist/adapters/cloudflare/cloudflare.d.ts +29 -0
- package/dist/adapters/cloudflare/cloudflare.js +416 -0
- package/dist/adapters/cloudflare/cloudflare.types.d.ts +31 -0
- package/dist/adapters/cloudflare/cloudflare.types.js +1 -0
- package/dist/adapters/content/content.d.ts +3 -0
- package/dist/adapters/content/content.js +128 -0
- package/dist/adapters/content/content.types.d.ts +38 -0
- package/dist/adapters/content/content.types.js +1 -0
- package/dist/adapters/db/db-audit.d.ts +62 -0
- package/dist/adapters/db/db-audit.js +150 -0
- package/dist/adapters/db/db-boards.d.ts +6 -0
- package/dist/adapters/db/db-boards.js +324 -0
- package/dist/adapters/db/db-feed.d.ts +30 -0
- package/dist/adapters/db/db-feed.js +276 -0
- package/dist/adapters/db/db-flows.d.ts +42 -0
- package/dist/adapters/db/db-flows.js +1311 -0
- package/dist/adapters/db/db-grants.d.ts +133 -0
- package/dist/adapters/db/db-grants.js +269 -0
- package/dist/adapters/db/db-indexing.d.ts +3 -0
- package/dist/adapters/db/db-indexing.js +200 -0
- package/dist/adapters/db/db-oauth.d.ts +3 -0
- package/dist/adapters/db/db-oauth.js +26 -0
- package/dist/adapters/db/db-prompts.d.ts +46 -0
- package/dist/adapters/db/db-prompts.js +144 -0
- package/dist/adapters/db/db.d.ts +6 -0
- package/dist/adapters/db/db.js +1687 -0
- package/dist/adapters/db/db.types.d.ts +16 -0
- package/dist/adapters/db/db.types.js +1 -0
- package/dist/adapters/document-converter/document-converter.d.ts +2 -0
- package/dist/adapters/document-converter/document-converter.js +26 -0
- package/dist/adapters/document-converter/document-converter.types.d.ts +17 -0
- package/dist/adapters/document-converter/document-converter.types.js +1 -0
- package/dist/adapters/flow-runtime/flow-runtime.d.ts +3 -0
- package/dist/adapters/flow-runtime/flow-runtime.js +22 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.d.ts +19 -0
- package/dist/adapters/flow-runtime/flow-runtime.types.js +1 -0
- package/dist/adapters/index-queue/index-queue.d.ts +10 -0
- package/dist/adapters/index-queue/index-queue.js +12 -0
- package/dist/adapters/index-queue/index-queue.types.d.ts +11 -0
- package/dist/adapters/index-queue/index-queue.types.js +1 -0
- package/dist/adapters/json-schema/json-schema.d.ts +2 -0
- package/dist/adapters/json-schema/json-schema.js +77 -0
- package/dist/adapters/openid/openid.d.ts +5 -0
- package/dist/adapters/openid/openid.js +301 -0
- package/dist/adapters/portal-tokens/portal-tokens.d.ts +8 -0
- package/dist/adapters/portal-tokens/portal-tokens.js +79 -0
- package/dist/adapters/remote-tools/remote-tools.d.ts +5 -0
- package/dist/adapters/remote-tools/remote-tools.js +129 -0
- package/dist/adapters/semantic-index/semantic-index.d.ts +2 -0
- package/dist/adapters/semantic-index/semantic-index.js +139 -0
- package/dist/adapters/semantic-index/semantic-index.types.d.ts +56 -0
- package/dist/adapters/semantic-index/semantic-index.types.js +1 -0
- package/dist/adapters/session-cookie/session-cookie.d.ts +5 -0
- package/dist/adapters/session-cookie/session-cookie.js +87 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.d.ts +1 -0
- package/dist/adapters/tool-source-policy/tool-source-policy.js +22 -0
- package/dist/audit/audit.d.ts +2 -0
- package/dist/audit/audit.js +87 -0
- package/dist/audit/audit.types.d.ts +23 -0
- package/dist/audit/audit.types.js +1 -0
- package/dist/auth/auth.d.ts +2 -0
- package/dist/auth/auth.js +284 -0
- package/dist/auth/auth.types.d.ts +108 -0
- package/dist/auth/auth.types.js +1 -0
- package/dist/boards/boards.d.ts +44 -0
- package/dist/boards/boards.js +447 -0
- package/dist/boards/boards.types.d.ts +117 -0
- package/dist/boards/boards.types.js +1 -0
- package/dist/build/build.d.ts +2 -0
- package/dist/build/build.js +192 -0
- package/dist/build/build.types.d.ts +17 -0
- package/dist/build/build.types.js +1 -0
- package/dist/bundle/bundle.d.ts +4 -0
- package/dist/bundle/bundle.js +1194 -0
- package/dist/bundle/bundle.types.d.ts +33 -0
- package/dist/bundle/bundle.types.js +1 -0
- package/dist/cli/cli.d.ts +4 -0
- package/dist/cli/cli.js +262 -0
- package/dist/cli/cli.types.d.ts +14 -0
- package/dist/cli/cli.types.js +1 -0
- package/dist/contract/audit.d.ts +50 -0
- package/dist/contract/audit.js +72 -0
- package/dist/contract/board.d.ts +150 -0
- package/dist/contract/board.js +262 -0
- package/dist/contract/bundle.d.ts +147 -0
- package/dist/contract/bundle.js +139 -0
- package/dist/contract/contract.d.ts +112 -0
- package/dist/contract/contract.js +143 -0
- package/dist/contract/feed.d.ts +126 -0
- package/dist/contract/feed.js +149 -0
- package/dist/contract/flow-run.d.ts +346 -0
- package/dist/contract/flow-run.js +194 -0
- package/dist/contract/flow.d.ts +1057 -0
- package/dist/contract/flow.js +552 -0
- package/dist/contract/node.d.ts +551 -0
- package/dist/contract/node.js +456 -0
- package/dist/contract/share.d.ts +216 -0
- package/dist/contract/share.js +134 -0
- package/dist/contract/table.d.ts +171 -0
- package/dist/contract/table.js +117 -0
- package/dist/contract/tool.d.ts +122 -0
- package/dist/contract/tool.js +172 -0
- package/dist/feed/feed.d.ts +2 -0
- package/dist/feed/feed.js +77 -0
- package/dist/feed/feed.types.d.ts +23 -0
- package/dist/feed/feed.types.js +1 -0
- package/dist/flows/flows.d.ts +41 -0
- package/dist/flows/flows.js +2216 -0
- package/dist/flows/flows.types.d.ts +339 -0
- package/dist/flows/flows.types.js +1 -0
- package/dist/http/http.d.ts +9 -0
- package/dist/http/http.js +874 -0
- package/dist/http/http.types.d.ts +22 -0
- package/dist/http/http.types.js +1 -0
- package/dist/indexing/indexing.d.ts +6 -0
- package/dist/indexing/indexing.js +239 -0
- package/dist/indexing/indexing.types.d.ts +11 -0
- package/dist/indexing/indexing.types.js +1 -0
- package/dist/intel/intel.d.ts +3 -0
- package/dist/intel/intel.js +194 -0
- package/dist/intel/intel.types.d.ts +24 -0
- package/dist/intel/intel.types.js +1 -0
- package/dist/mcp/mcp.d.ts +2 -0
- package/dist/mcp/mcp.js +1292 -0
- package/dist/mcp/mcp.types.d.ts +25 -0
- package/dist/mcp/mcp.types.js +1 -0
- package/dist/nodes/document-links/document-links.d.ts +20 -0
- package/dist/nodes/document-links/document-links.js +57 -0
- package/dist/nodes/nodes.d.ts +2 -0
- package/dist/nodes/nodes.js +1667 -0
- package/dist/nodes/nodes.types.d.ts +511 -0
- package/dist/nodes/nodes.types.js +1 -0
- package/dist/prepare/prepare.d.ts +4 -0
- package/dist/prepare/prepare.js +16 -0
- package/dist/prepare/prepare.types.d.ts +9 -0
- package/dist/prepare/prepare.types.js +1 -0
- package/dist/prompts/prompts.d.ts +2 -0
- package/dist/prompts/prompts.js +65 -0
- package/dist/prompts/prompts.types.d.ts +71 -0
- package/dist/prompts/prompts.types.js +1 -0
- package/dist/shared/callers/callers.d.ts +28 -0
- package/dist/shared/callers/callers.js +30 -0
- package/dist/shared/csv/csv.d.ts +13 -0
- package/dist/shared/csv/csv.js +85 -0
- package/dist/shared/document-text/document-text.d.ts +21 -0
- package/dist/shared/document-text/document-text.js +31 -0
- package/dist/shared/gate-authorization/gate-authorization.d.ts +5 -0
- package/dist/shared/gate-authorization/gate-authorization.js +26 -0
- package/dist/shared/grant-expiry/grant-expiry.d.ts +19 -0
- package/dist/shared/grant-expiry/grant-expiry.js +26 -0
- package/dist/shared/intel-error/intel-error.d.ts +22 -0
- package/dist/shared/intel-error/intel-error.js +27 -0
- package/dist/shared/plain-title/plain-title.d.ts +16 -0
- package/dist/shared/plain-title/plain-title.js +45 -0
- package/dist/shared/problem-details/problem-details.d.ts +30 -0
- package/dist/shared/problem-details/problem-details.js +18 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.d.ts +1 -0
- package/dist/shared/report-unexpected-error/report-unexpected-error.js +37 -0
- package/dist/shared/safe-return-path/safe-return-path.d.ts +2 -0
- package/dist/shared/safe-return-path/safe-return-path.js +11 -0
- package/dist/shared/sha256/sha256.d.ts +2 -0
- package/dist/shared/sha256/sha256.js +14 -0
- package/dist/tools/tool-servers/tool-servers.d.ts +46 -0
- package/dist/tools/tool-servers/tool-servers.js +177 -0
- package/dist/tools/tools.d.ts +2 -0
- package/dist/tools/tools.js +207 -0
- package/dist/tools/tools.types.d.ts +56 -0
- package/dist/tools/tools.types.js +1 -0
- package/examples/branding/favicon.svg +5 -0
- package/examples/branding/logo.svg +5 -0
- package/examples/branding/theme.css +16 -0
- package/examples/dev.vars.example +7 -0
- package/examples/intel.json +9 -0
- package/migrations/0000_intel_foundation.sql +203 -0
- package/migrations/0001_portal_native_tools.sql +21 -0
- package/migrations/0002_flows_in_the_knowledge_tree.sql +34 -0
- package/migrations/0003_folder_permissions.sql +211 -0
- package/migrations/0004_subflow_runs.sql +14 -0
- package/migrations/0005_flow_node_cleanup.sql +28 -0
- package/migrations/0005_tables_in_the_knowledge_tree.sql +68 -0
- package/migrations/0006_links_are_written_in_the_text.sql +20 -0
- package/migrations/0007_no_node_waits.sql +69 -0
- package/migrations/0008_three_layers.sql +130 -0
- package/migrations/0009_no_context_policy.sql +48 -0
- package/migrations/0010_no_run_waits_a_year.sql +27 -0
- package/migrations/0011_one_name_for_the_tree.sql +53 -0
- package/migrations/0012_table_snapshots.sql +29 -0
- package/migrations/0017_a_vector_per_card.sql +38 -0
- package/migrations/0018_no_context_policy_at_last.sql +97 -0
- package/migrations/0019_one_name_for_the_grants.sql +52 -0
- package/migrations/0020_cascade_purge_replay.sql +11 -0
- package/migrations/0021_a_flow_carries_its_own_grant.sql +44 -0
- package/migrations/0022_a_cursor_over_the_journal.sql +14 -0
- package/migrations/0023_a_board_and_its_tasks.sql +116 -0
- package/migrations/0024_the_archive_is_a_column.sql +33 -0
- package/migrations/0025_archiving_takes_the_subtree.sql +29 -0
- package/migrations/0026_one_feed_over_two_kinds.sql +23 -0
- package/migrations/0027_the_runs_of_every_flow.sql +27 -0
- package/migrations/0028_a_prompt_name_over_two_kinds.sql +70 -0
- package/package.json +118 -0
- package/src/contract/CLAUDE.md +152 -0
- package/src/contract/README.md +102 -0
- package/src/contract/audit.ts +85 -0
- package/src/contract/board.ts +306 -0
- package/src/contract/bundle.ts +157 -0
- package/src/contract/contract.ts +161 -0
- package/src/contract/feed.ts +165 -0
- package/src/contract/flow-run.ts +241 -0
- package/src/contract/flow.ts +675 -0
- package/src/contract/node.ts +583 -0
- package/src/contract/share.ts +196 -0
- package/src/contract/table.ts +178 -0
- package/src/contract/tool.ts +202 -0
- package/ui/README.md +110 -0
- package/ui/components.json +21 -0
- package/ui/index.html +13 -0
- package/ui/package.json +11 -0
- package/ui/scripts/lint-tokens.mjs +187 -0
- package/ui/src/access-summary/access-summary.tsx +385 -0
- package/ui/src/app/action-slot/action-slot.tsx +27 -0
- package/ui/src/app/app-sidebar/app-sidebar.tsx +71 -0
- package/ui/src/app/app-tree/app-tree.tsx +1175 -0
- package/ui/src/app/app.tsx +87 -0
- package/ui/src/app/header-search/header-search.tsx +294 -0
- package/ui/src/app/reindex-dialog/reindex-dialog.tsx +79 -0
- package/ui/src/app/settings-dialog/settings-dialog.tsx +135 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.ts +68 -0
- package/ui/src/app/sidebar-preferences/sidebar-preferences.types.ts +15 -0
- package/ui/src/app/sidebar-resize-handle/sidebar-resize-handle.tsx +86 -0
- package/ui/src/app/tree-expansion/tree-expansion.ts +107 -0
- package/ui/src/app/tree-expansion/tree-expansion.types.ts +31 -0
- package/ui/src/app/tree-move/tree-move.tsx +427 -0
- package/ui/src/app/tree-reveal/tree-reveal.ts +64 -0
- package/ui/src/app/tree-reveal/tree-reveal.types.ts +16 -0
- package/ui/src/app/user-footer/user-footer.tsx +188 -0
- package/ui/src/app/view-menu/view-menu.tsx +115 -0
- package/ui/src/app-root/app-root.tsx +109 -0
- package/ui/src/app-root/app-root.types.ts +27 -0
- package/ui/src/archive/archive.tsx +417 -0
- package/ui/src/attachment-detail/attachment-detail.ts +52 -0
- package/ui/src/attachment-viewer/attachment-viewer.tsx +166 -0
- package/ui/src/blocknote-view/blocknote-view.tsx +30 -0
- package/ui/src/board/board-assignee/board-assignee-picker.tsx +132 -0
- package/ui/src/board/board-assignee/board-assignee.ts +48 -0
- package/ui/src/board/board-assignee/board-assignee.tsx +75 -0
- package/ui/src/board/board-chip/board-chip.tsx +46 -0
- package/ui/src/board/board-crumbs/board-crumbs.ts +56 -0
- package/ui/src/board/board-crumbs/board-crumbs.tsx +111 -0
- package/ui/src/board/board-data/board-data.ts +195 -0
- package/ui/src/board/board-data/board-data.types.ts +38 -0
- package/ui/src/board/board-dates/board-dates.ts +40 -0
- package/ui/src/board/board-dates/board-dates.tsx +105 -0
- package/ui/src/board/board-kanban/board-kanban.ts +162 -0
- package/ui/src/board/board-kanban/board-kanban.tsx +680 -0
- package/ui/src/board/board-open-task/board-open-task.ts +32 -0
- package/ui/src/board/board-panel/board-panel.tsx +259 -0
- package/ui/src/board/board-settings/board-settings.tsx +226 -0
- package/ui/src/board/board-status/board-status.tsx +60 -0
- package/ui/src/board/board-stripes/board-stripes.ts +128 -0
- package/ui/src/board/board-table/board-table.ts +53 -0
- package/ui/src/board/board-table/board-table.tsx +610 -0
- package/ui/src/board/board-task/board-task.ts +108 -0
- package/ui/src/board/board-task/board-task.tsx +335 -0
- package/ui/src/board/board-title-row/board-title-row.tsx +66 -0
- package/ui/src/branding/branding.tsx +17 -0
- package/ui/src/branding/custom-logo.ts +1 -0
- package/ui/src/branding/favicon.default.svg +5 -0
- package/ui/src/branding/favicon.svg +5 -0
- package/ui/src/components/ui/avatar.tsx +39 -0
- package/ui/src/components/ui/button.tsx +64 -0
- package/ui/src/components/ui/collapsible.tsx +20 -0
- package/ui/src/components/ui/command.tsx +160 -0
- package/ui/src/components/ui/dialog.tsx +143 -0
- package/ui/src/components/ui/dropdown-menu.tsx +187 -0
- package/ui/src/components/ui/input.tsx +21 -0
- package/ui/src/components/ui/popover.tsx +41 -0
- package/ui/src/components/ui/select.tsx +163 -0
- package/ui/src/components/ui/separator.tsx +26 -0
- package/ui/src/components/ui/sheet.tsx +136 -0
- package/ui/src/components/ui/sidebar.tsx +693 -0
- package/ui/src/components/ui/skeleton.tsx +13 -0
- package/ui/src/components/ui/switch.tsx +25 -0
- package/ui/src/components/ui/table.tsx +82 -0
- package/ui/src/components/ui/tabs.tsx +52 -0
- package/ui/src/components/ui/tooltip.tsx +51 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.ts +685 -0
- package/ui/src/data/intel-data-provider/intel-data-provider.types.ts +266 -0
- package/ui/src/data/query-client.ts +18 -0
- package/ui/src/data/request-refusal/refusal-notice.tsx +36 -0
- package/ui/src/data/request-refusal/request-refusal.ts +51 -0
- package/ui/src/data/sign-in/sign-in.ts +97 -0
- package/ui/src/data/sign-in/sign-in.types.ts +15 -0
- package/ui/src/document-link/document-link.tsx +114 -0
- package/ui/src/document-markdown/document-markdown.ts +195 -0
- package/ui/src/editor-schema/editor-schema.ts +23 -0
- package/ui/src/entry-picker/entry-picker.tsx +238 -0
- package/ui/src/feed/feed.tsx +344 -0
- package/ui/src/file-preview/file-preview-view.tsx +496 -0
- package/ui/src/file-preview/file-preview.tsx +28 -0
- package/ui/src/file-preview/pdf-file-preview.tsx +5 -0
- package/ui/src/file-preview/presentation-file-preview.tsx +21 -0
- package/ui/src/file-preview/spreadsheet-file-preview.tsx +5 -0
- package/ui/src/file-preview/word-file-preview.tsx +5 -0
- package/ui/src/flow-runs/flow-runs.tsx +238 -0
- package/ui/src/flows/flows.tsx +1646 -0
- package/ui/src/flows/node-icon/node-icon.ts +34 -0
- package/ui/src/flows/node-palette/node-palette.tsx +214 -0
- package/ui/src/flows/node-palette/node-palette.types.ts +15 -0
- package/ui/src/folder-contents/folder-contents.tsx +321 -0
- package/ui/src/folder-index/folder-index.ts +55 -0
- package/ui/src/frontmatter/frontmatter.tsx +193 -0
- package/ui/src/graph-pane/graph-pane.tsx +53 -0
- package/ui/src/hooks/use-mobile.ts +19 -0
- package/ui/src/i18n/custom.json +4 -0
- package/ui/src/i18n/de.json +609 -0
- package/ui/src/i18n/en.json +609 -0
- package/ui/src/i18n/es.json +609 -0
- package/ui/src/i18n/i18n-context.tsx +65 -0
- package/ui/src/i18n/i18n-languages/i18n-languages.ts +83 -0
- package/ui/src/i18n/i18n.ts +32 -0
- package/ui/src/i18n/i18n.types.ts +49 -0
- package/ui/src/kind-icon.ts +97 -0
- package/ui/src/lib/utils.ts +6 -0
- package/ui/src/main.tsx +68 -0
- package/ui/src/modal/modal.tsx +53 -0
- package/ui/src/node-details/node-details.tsx +545 -0
- package/ui/src/node-editor/node-editor.tsx +304 -0
- package/ui/src/node-graph/graph-notice.tsx +21 -0
- package/ui/src/node-graph/node-graph.ts +214 -0
- package/ui/src/node-graph/node-graph.tsx +295 -0
- package/ui/src/node-import/node-import.tsx +418 -0
- package/ui/src/node-save-error.ts +46 -0
- package/ui/src/node-source/node-source.tsx +128 -0
- package/ui/src/node-table/node-table.tsx +107 -0
- package/ui/src/nodes/nodes.tsx +240 -0
- package/ui/src/rename-resource/rename-resource.ts +80 -0
- package/ui/src/resolved-names/resolved-names.tsx +67 -0
- package/ui/src/resource-error.ts +33 -0
- package/ui/src/resource-menu/resource-menu.tsx +1156 -0
- package/ui/src/router/router-context.ts +6 -0
- package/ui/src/router/router.tsx +67 -0
- package/ui/src/router/router.types.ts +8 -0
- package/ui/src/router/selection-search.ts +88 -0
- package/ui/src/save-button/save-button.tsx +94 -0
- package/ui/src/section-hint/section-hint.tsx +40 -0
- package/ui/src/sign-in-refused/sign-in-refused.tsx +35 -0
- package/ui/src/styles.css +189 -0
- package/ui/src/table-columns/table-columns.tsx +379 -0
- package/ui/src/theme/custom.css +1 -0
- package/ui/src/theme/theme-context.tsx +89 -0
- package/ui/src/theme/theme.ts +70 -0
- package/ui/src/time/relative-time.tsx +41 -0
- package/ui/src/time/time-context.tsx +78 -0
- package/ui/src/time/time.ts +165 -0
- package/ui/src/title-row/title-row.tsx +185 -0
- package/ui/src/tools/tools.tsx +546 -0
- package/ui/src/user-name/user-name.ts +61 -0
- package/ui/src/verbatim/verbatim.tsx +68 -0
- package/ui/tsconfig.json +17 -0
- package/ui/vite.config.ts +129 -0
|
@@ -0,0 +1,1311 @@
|
|
|
1
|
+
import { Flow, FlowGraph, FlowVersion, } from "../../contract/flow.js";
|
|
2
|
+
import { FlowRun } from "../../contract/flow-run.js";
|
|
3
|
+
import { calleeIds, treeLinkKinds } from "../../flows/flows.js";
|
|
4
|
+
import { flowCallable, flowCallableBindings, flowInSubtree, flowInSubtreeBindings, flowVerbBindings, flowVerbQuery, grantColumns, grantInForce, mapGrant, principalColumns, readableOrRunnableCte, subtreeBindings, subtreeCte, } from "./db-grants.js";
|
|
5
|
+
import { promptNameHolder } from "./db-prompts.js";
|
|
6
|
+
const flowColumnNames = [
|
|
7
|
+
"id",
|
|
8
|
+
"parent_id",
|
|
9
|
+
"title",
|
|
10
|
+
"description",
|
|
11
|
+
"owner_id",
|
|
12
|
+
"current_version_id",
|
|
13
|
+
"published_version_id",
|
|
14
|
+
"created_at",
|
|
15
|
+
"updated_at",
|
|
16
|
+
"archived_at",
|
|
17
|
+
"prompt_name",
|
|
18
|
+
];
|
|
19
|
+
const flowColumns = flowColumnNames.join(", ");
|
|
20
|
+
const runColumnNames = [
|
|
21
|
+
"id",
|
|
22
|
+
"flow_id",
|
|
23
|
+
"version_id",
|
|
24
|
+
"status",
|
|
25
|
+
"current_node_id",
|
|
26
|
+
"input_json",
|
|
27
|
+
"output_json",
|
|
28
|
+
"error",
|
|
29
|
+
"initiated_by",
|
|
30
|
+
"parent_run_id",
|
|
31
|
+
"parent_node_id",
|
|
32
|
+
"created_at",
|
|
33
|
+
"updated_at",
|
|
34
|
+
"completed_at",
|
|
35
|
+
];
|
|
36
|
+
const runColumns = runColumnNames.join(", ");
|
|
37
|
+
// Joined queries need the columns qualified. Deriving them from the array keeps both forms in step;
|
|
38
|
+
// splitting the rendered string would break the moment it is reformatted.
|
|
39
|
+
const qualifiedRunColumns = (alias) => runColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
40
|
+
const qualifiedFlowColumns = (alias) => flowColumnNames.map((column) => `${alias}.${column}`).join(", ");
|
|
41
|
+
// The same four kinds `treeLinkNodes` filters on, rendered for SQL. Derived rather than written out
|
|
42
|
+
// a second time: a fifth link kind added to the contract would otherwise reach every reader of a
|
|
43
|
+
// graph except this one, and the miss would look like an empty result rather than an error — which
|
|
44
|
+
// is precisely how the pre-0008 `knowledge` matcher survived unnoticed (#153).
|
|
45
|
+
const treeLinkKindList = treeLinkKinds.map((kind) => `'${kind}'`).join(", ");
|
|
46
|
+
function mapFlow(row) {
|
|
47
|
+
return Flow.parse({
|
|
48
|
+
id: row.id,
|
|
49
|
+
parentId: row.parent_id,
|
|
50
|
+
title: row.title,
|
|
51
|
+
description: row.description,
|
|
52
|
+
ownerId: row.owner_id,
|
|
53
|
+
currentVersionId: row.current_version_id,
|
|
54
|
+
publishedVersionId: row.published_version_id,
|
|
55
|
+
createdAt: row.created_at,
|
|
56
|
+
updatedAt: row.updated_at,
|
|
57
|
+
archivedAt: row.archived_at,
|
|
58
|
+
promptName: row.prompt_name,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
function mapVersion(row) {
|
|
62
|
+
return FlowVersion.parse({
|
|
63
|
+
id: row.id,
|
|
64
|
+
flowId: row.flow_id,
|
|
65
|
+
sequence: row.sequence,
|
|
66
|
+
graph: FlowGraph.parse(JSON.parse(row.graph_json)),
|
|
67
|
+
createdBy: row.created_by,
|
|
68
|
+
createdAt: row.created_at,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
// ⚠️ The one answer to "may this actor see this run", so a single run and a list of them cannot
|
|
72
|
+
// start disagreeing. A run you started is yours to follow — without that, a run of a library flow,
|
|
73
|
+
// shared with `execute` for everyone and readable by nobody (ADR-0004 §3), would vanish the moment
|
|
74
|
+
// it was started. Everything else is the folder the flow is filed in, read through `flowInSubtree`,
|
|
75
|
+
// the very predicate the flow list itself uses.
|
|
76
|
+
//
|
|
77
|
+
// It expects `flow_runs run` joined to `flows flow`, and its bindings follow the CTE's. The bindings
|
|
78
|
+
// live in the factory below because `flowInSubtree` now needs the moment expiry is measured against.
|
|
79
|
+
const runVisible = `(run.initiated_by = ? OR ${flowInSubtree})`;
|
|
80
|
+
/**
|
|
81
|
+
* One page of runs, newest first — and the flow is part of the STATEMENT rather than a bound value
|
|
82
|
+
* (#774, found in the review of #787).
|
|
83
|
+
*
|
|
84
|
+
* ⚠️ **`WHERE (? IS NULL OR run.flow_id = ?)` is the shape that must not be used here**, however
|
|
85
|
+
* naturally it reads. SQLite picks its plan when the statement is prepared, from the text alone —
|
|
86
|
+
* it never sees the value — so an `OR` on the leading index column drops that column as an equality
|
|
87
|
+
* prefix **in both cases**, including the one where a real flow id was passed. Measured on the same
|
|
88
|
+
* engine and schema, `flow_runs_flow_idx (flow_id, created_at DESC)`:
|
|
89
|
+
*
|
|
90
|
+
* -- WHERE run.flow_id = ?
|
|
91
|
+
* SEARCH run USING INDEX flow_runs_flow_idx (flow_id=?)
|
|
92
|
+
*
|
|
93
|
+
* -- WHERE (? IS NULL OR run.flow_id = ?) — identical for a real id AND for null
|
|
94
|
+
* SCAN run | USE TEMP B-TREE FOR ORDER BY
|
|
95
|
+
*
|
|
96
|
+
* The damage is not on the new door but on the old one: `GET /flows/:flowId/runs` has existed all
|
|
97
|
+
* along and is the frequent call, and it would have started scanning every run in the installation
|
|
98
|
+
* without a single test going red. `db-audit.ts` had the same choice one file over and split its
|
|
99
|
+
* statements per kind; this is that decision, applied where the review found it missing.
|
|
100
|
+
*
|
|
101
|
+
* ⚠️ The two shapes lean on two different indexes, and both are needed. Scoped: `flow_runs_flow_idx`,
|
|
102
|
+
* whose `flow_id` prefix an equality serves. Unscoped: `flow_runs_time_idx (created_at DESC,
|
|
103
|
+
* id DESC)` from `0027`, because "every readable flow" has no one `flow_id` to match on and the
|
|
104
|
+
* ordering has to come from somewhere.
|
|
105
|
+
*
|
|
106
|
+
* ⚠️ `failedOnly` and the cursor are shape too, not bindings, and that is not the same decision —
|
|
107
|
+
* they were already written this way. An absent clause binds nothing, so every caller of this
|
|
108
|
+
* function has to build its bindings from the SAME three flags, in this order: the CTE's, the flow
|
|
109
|
+
* id when scoped, `runVisible`'s, the cursor's three when paged, and the limit.
|
|
110
|
+
*/
|
|
111
|
+
export function flowRunsPageQuery(shape) {
|
|
112
|
+
return `${subtreeCte}
|
|
113
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
114
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
115
|
+
WHERE ${shape.scoped ? "run.flow_id = ? AND " : ""}${runVisible}
|
|
116
|
+
${shape.failedOnly ? "AND run.status = 'failed'" : ""}
|
|
117
|
+
${shape.paged ? "AND (run.created_at < ? OR (run.created_at = ? AND run.id < ?))" : ""}
|
|
118
|
+
ORDER BY run.created_at DESC, run.id DESC
|
|
119
|
+
LIMIT ?`;
|
|
120
|
+
}
|
|
121
|
+
function mapStep(row) {
|
|
122
|
+
return {
|
|
123
|
+
runId: row.run_id,
|
|
124
|
+
nodeId: row.node_id,
|
|
125
|
+
outcome: row.outcome,
|
|
126
|
+
branch: row.branch,
|
|
127
|
+
error: row.error,
|
|
128
|
+
completedAt: row.completed_at,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
function mapRun(row) {
|
|
132
|
+
return FlowRun.parse({
|
|
133
|
+
id: row.id,
|
|
134
|
+
flowId: row.flow_id,
|
|
135
|
+
versionId: row.version_id,
|
|
136
|
+
status: row.status,
|
|
137
|
+
currentNodeId: row.current_node_id,
|
|
138
|
+
input: JSON.parse(row.input_json),
|
|
139
|
+
output: row.output_json === null ? null : JSON.parse(row.output_json),
|
|
140
|
+
error: row.error,
|
|
141
|
+
initiatedBy: row.initiated_by,
|
|
142
|
+
parentRunId: row.parent_run_id,
|
|
143
|
+
parentNodeId: row.parent_node_id,
|
|
144
|
+
createdAt: row.created_at,
|
|
145
|
+
updatedAt: row.updated_at,
|
|
146
|
+
completedAt: row.completed_at,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
// A list of IDs as one bound value rather than one placeholder each. D1 caps how many parameters a
|
|
150
|
+
// statement may carry, and the lists here are as long as a graph, a call chain or a drawn level —
|
|
151
|
+
// so an `IN (?, ?, …)` built from the input would be a limit waiting to be hit by real data (#30).
|
|
152
|
+
const idList = "(SELECT value FROM json_each(?))";
|
|
153
|
+
const flowGrantColumns = grantColumns("flow_id");
|
|
154
|
+
export function createFlowRepository(deps) {
|
|
155
|
+
// The folders this actor may open, read through the same walk the tree uses, so the two can never
|
|
156
|
+
// drift apart. The CTE stands in front of the statement, so its bindings come before every other
|
|
157
|
+
// one.
|
|
158
|
+
const readableBindings = (actor) => subtreeBindings(actor, "read", deps.now().toISOString());
|
|
159
|
+
// The other half of the same question since #530: the folders this actor reads come from the walk
|
|
160
|
+
// above, the flows handed to them directly come from `flow_grants`. Always `read` here, because
|
|
161
|
+
// every statement these two appear in together is seeded with `read`.
|
|
162
|
+
const inSubtreeBindings = (actor) => flowInSubtreeBindings(actor, "read", deps.now().toISOString());
|
|
163
|
+
const runVisibleBindings = (actor) => [actor.id, ...inSubtreeBindings(actor)];
|
|
164
|
+
// An absent `parentId` asks for every visible flow; `null` asks for the root of the shared tree.
|
|
165
|
+
// `IS ?` would collapse the two, so the two cases are separate SQL rather than one binding that
|
|
166
|
+
// silently means both.
|
|
167
|
+
//
|
|
168
|
+
// ⚠️ The root is "the top of every region this actor may read", exactly as it is for nodes in
|
|
169
|
+
// `db.ts` (#429) — a flow whose folder the reader may not open would otherwise sit in a level
|
|
170
|
+
// nothing can ever open. `flowInSubtree` is what puts such a row in reach at all (its owner, or
|
|
171
|
+
// `intel/admin`), and this only decides which level it appears in; it can add no flow that
|
|
172
|
+
// predicate has not already allowed.
|
|
173
|
+
const scopeOf = (parentId) => parentId === undefined
|
|
174
|
+
? { clause: "", bindings: [] }
|
|
175
|
+
: parentId === null
|
|
176
|
+
? {
|
|
177
|
+
clause: "AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM allowed))",
|
|
178
|
+
bindings: [],
|
|
179
|
+
}
|
|
180
|
+
: { clause: "AND flow.parent_id = ?", bindings: [parentId] };
|
|
181
|
+
/**
|
|
182
|
+
* One statement for the list the sidebar reads and for the bounded read the relation graph makes,
|
|
183
|
+
* so the visibility predicate cannot drift between them.
|
|
184
|
+
*
|
|
185
|
+
* ⚠️ The order of the two is the whole point of the bounded form (#30): `LIMIT` follows the
|
|
186
|
+
* `WHERE`, so the cut is made among the rows this actor may see and never before them. For the
|
|
187
|
+
* same reason `COUNT(*) OVER ()` is a count of those rows alone — the number the graph reports
|
|
188
|
+
* about its own size would otherwise disclose that something else is there.
|
|
189
|
+
*/
|
|
190
|
+
const visibleFlows = (scopeClause, bounded, includeArchived = false) => `${subtreeCte}
|
|
191
|
+
SELECT ${flowColumns}${bounded ? ", COUNT(*) OVER () AS total" : ""} FROM flows flow
|
|
192
|
+
WHERE ${flowInSubtree} ${includeArchived ? "" : "AND flow.archived_at IS NULL"} ${scopeClause}
|
|
193
|
+
ORDER BY lower(flow.title), flow.id${bounded ? " LIMIT ?" : ""}`;
|
|
194
|
+
return {
|
|
195
|
+
async listVisible(actor, input = {}) {
|
|
196
|
+
// ⚠️ `archivedOnly` overrides the folder rather than narrowing within it: somebody looking
|
|
197
|
+
// for what they archived does not know where it was filed (#113). The visibility predicate
|
|
198
|
+
// is unchanged, so the archive shows no flow the tree would have hidden.
|
|
199
|
+
if (input.archivedOnly === true) {
|
|
200
|
+
const result = await deps.db
|
|
201
|
+
.prepare(`${subtreeCte}
|
|
202
|
+
SELECT ${flowColumns} FROM flows flow
|
|
203
|
+
WHERE ${flowInSubtree} AND flow.archived_at IS NOT NULL
|
|
204
|
+
ORDER BY flow.archived_at DESC, lower(flow.title), flow.id`)
|
|
205
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor))
|
|
206
|
+
.all();
|
|
207
|
+
return (result.results ?? []).map(mapFlow);
|
|
208
|
+
}
|
|
209
|
+
const scope = scopeOf(input.parentId);
|
|
210
|
+
const result = await deps.db
|
|
211
|
+
.prepare(visibleFlows(scope.clause, false, input.includeArchived === true))
|
|
212
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor), ...scope.bindings)
|
|
213
|
+
.all();
|
|
214
|
+
return (result.results ?? []).map(mapFlow);
|
|
215
|
+
},
|
|
216
|
+
async listVisibleBounded(actor, folderId, limit) {
|
|
217
|
+
const scope = scopeOf(folderId);
|
|
218
|
+
const result = await deps.db
|
|
219
|
+
.prepare(visibleFlows(scope.clause, true))
|
|
220
|
+
.bind(...readableBindings(actor), ...inSubtreeBindings(actor), ...scope.bindings, limit)
|
|
221
|
+
.all();
|
|
222
|
+
const rows = result.results ?? [];
|
|
223
|
+
return { items: rows.map(mapFlow), total: rows[0]?.total ?? 0 };
|
|
224
|
+
},
|
|
225
|
+
async getVisible(actor, flowId) {
|
|
226
|
+
const row = await deps.db
|
|
227
|
+
.prepare(`${subtreeCte}
|
|
228
|
+
SELECT ${flowColumns} FROM flows flow
|
|
229
|
+
WHERE flow.id = ? AND ${flowInSubtree}`)
|
|
230
|
+
.bind(...readableBindings(actor), flowId, ...inSubtreeBindings(actor))
|
|
231
|
+
.first();
|
|
232
|
+
return row ? mapFlow(row) : null;
|
|
233
|
+
},
|
|
234
|
+
async listFlowGrants(flowId) {
|
|
235
|
+
const result = await deps.db
|
|
236
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants
|
|
237
|
+
WHERE flow_id = ?
|
|
238
|
+
ORDER BY principal_type, principal_id, verb`)
|
|
239
|
+
.bind(flowId)
|
|
240
|
+
.all();
|
|
241
|
+
return (result.results ?? []).map(mapGrant);
|
|
242
|
+
},
|
|
243
|
+
// ⚠️ Owners and grants out of ONE statement, hence one D1 snapshot, for the reason the node side
|
|
244
|
+
// gives: two parallel SELECTs are not atomic, and a grant written between their snapshots
|
|
245
|
+
// produces a principal list that never existed as a whole.
|
|
246
|
+
//
|
|
247
|
+
// ⚠️ The grants come from BOTH tables. Reading only `flow_grants` here would draw a flow as
|
|
248
|
+
// though the folder above it granted nothing — which is the everyday case, not the exception.
|
|
249
|
+
async listEffectiveFlowAccess(flowId) {
|
|
250
|
+
const row = await deps.db
|
|
251
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id, owner_id) AS (
|
|
252
|
+
SELECT folder.id, folder.parent_id, folder.owner_id
|
|
253
|
+
FROM nodes folder
|
|
254
|
+
JOIN flows flow ON flow.parent_id = folder.id
|
|
255
|
+
WHERE flow.id = ?
|
|
256
|
+
UNION
|
|
257
|
+
SELECT parent.id, parent.parent_id, parent.owner_id
|
|
258
|
+
FROM nodes parent
|
|
259
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
260
|
+
)
|
|
261
|
+
SELECT
|
|
262
|
+
(SELECT json_group_array(owner_id) FROM (
|
|
263
|
+
SELECT DISTINCT owner_id FROM (
|
|
264
|
+
SELECT owner_id FROM flows WHERE id = ?
|
|
265
|
+
UNION
|
|
266
|
+
SELECT owner_id FROM ancestors
|
|
267
|
+
) ORDER BY owner_id
|
|
268
|
+
)) AS owner_ids_json,
|
|
269
|
+
(SELECT json_group_array(json_object(
|
|
270
|
+
'id', id, 'resource_id', resource_id, 'principal_type', principal_type,
|
|
271
|
+
'principal_id', principal_id, 'verb', verb, 'expires_at', expires_at,
|
|
272
|
+
'created_by', created_by, 'created_at', created_at
|
|
273
|
+
))
|
|
274
|
+
FROM (
|
|
275
|
+
SELECT grant_row.id, grant_row.flow_id AS resource_id, grant_row.principal_type,
|
|
276
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
277
|
+
grant_row.created_by, grant_row.created_at
|
|
278
|
+
FROM flow_grants grant_row
|
|
279
|
+
WHERE grant_row.flow_id = ?
|
|
280
|
+
AND ${grantInForce}
|
|
281
|
+
UNION ALL
|
|
282
|
+
SELECT grant_row.id, grant_row.node_id AS resource_id, grant_row.principal_type,
|
|
283
|
+
grant_row.principal_id, grant_row.verb, grant_row.expires_at,
|
|
284
|
+
grant_row.created_by, grant_row.created_at
|
|
285
|
+
FROM node_grants grant_row
|
|
286
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
287
|
+
WHERE ${grantInForce}
|
|
288
|
+
ORDER BY principal_type, principal_id, verb
|
|
289
|
+
)) AS grants_json`)
|
|
290
|
+
.bind(flowId, flowId, flowId, deps.now().toISOString(), deps.now().toISOString())
|
|
291
|
+
.first();
|
|
292
|
+
return {
|
|
293
|
+
ownerIds: JSON.parse(row?.owner_ids_json ?? "[]"),
|
|
294
|
+
items: JSON.parse(row?.grants_json ?? "[]").map(mapGrant),
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
async setFlowGrant(input) {
|
|
298
|
+
const grant = input.grant;
|
|
299
|
+
const { type: principalType, id: principalId } = principalColumns(grant.principal);
|
|
300
|
+
try {
|
|
301
|
+
await deps.db.batch([
|
|
302
|
+
deps.db
|
|
303
|
+
// The verb is part of the key, so re-granting the same verb only refreshes its expiry
|
|
304
|
+
// and never turns one verb into another.
|
|
305
|
+
.prepare(`INSERT INTO flow_grants (
|
|
306
|
+
id, flow_id, principal_type, principal_id, verb,
|
|
307
|
+
expires_at, created_by, created_at
|
|
308
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
309
|
+
ON CONFLICT (flow_id, principal_type, principal_id, verb)
|
|
310
|
+
DO UPDATE SET expires_at = excluded.expires_at`)
|
|
311
|
+
.bind(grant.id, input.flowId, principalType, principalId, grant.verb, grant.expiresAt, grant.createdBy, grant.createdAt),
|
|
312
|
+
deps.db
|
|
313
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
314
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
315
|
+
) SELECT ?, 'flows.share', ?, id, ? FROM flow_grants
|
|
316
|
+
WHERE flow_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
317
|
+
.bind(input.actorId, input.idempotencyKey, grant.createdAt, input.flowId, principalType, principalId, grant.verb),
|
|
318
|
+
deps.db
|
|
319
|
+
.prepare(`INSERT INTO audit_events (
|
|
320
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
321
|
+
) VALUES (?, ?, 'flows.share', 'flow', ?, ?, ?)`)
|
|
322
|
+
.bind(input.auditId, input.actorId, input.flowId, JSON.stringify({ principalType, verb: grant.verb }), grant.createdAt),
|
|
323
|
+
]);
|
|
324
|
+
const stored = await deps.db
|
|
325
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants
|
|
326
|
+
WHERE flow_id = ? AND principal_type = ? AND principal_id = ? AND verb = ?`)
|
|
327
|
+
.bind(input.flowId, principalType, principalId, grant.verb)
|
|
328
|
+
.first();
|
|
329
|
+
if (!stored)
|
|
330
|
+
throw new Error("Flow grant disappeared after upsert");
|
|
331
|
+
return mapGrant(stored);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
const replayed = await deps.db
|
|
335
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
336
|
+
WHERE actor_id = ? AND operation = 'flows.share' AND idempotency_key = ?`)
|
|
337
|
+
.bind(input.actorId, input.idempotencyKey)
|
|
338
|
+
.first();
|
|
339
|
+
if (replayed) {
|
|
340
|
+
const row = await deps.db
|
|
341
|
+
.prepare(`SELECT ${flowGrantColumns} FROM flow_grants WHERE id = ?`)
|
|
342
|
+
.bind(replayed.resource_id)
|
|
343
|
+
.first();
|
|
344
|
+
if (row)
|
|
345
|
+
return mapGrant(row);
|
|
346
|
+
}
|
|
347
|
+
throw error;
|
|
348
|
+
}
|
|
349
|
+
},
|
|
350
|
+
// ⚠️ Whether the row was there is decided BEFORE it is deleted and remembered in the
|
|
351
|
+
// idempotency key's own value, because afterwards nothing can tell "I removed it" from "it was
|
|
352
|
+
// never here" — and a replay that answered `true` to the second would claim a deletion that did
|
|
353
|
+
// not happen. The `1:`/`0:` prefix is the node side's device, and it is the same device here.
|
|
354
|
+
async revokeFlowGrant(input) {
|
|
355
|
+
try {
|
|
356
|
+
await deps.db.batch([
|
|
357
|
+
deps.db
|
|
358
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
359
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
360
|
+
) SELECT ?, 'flows.revoke', ?,
|
|
361
|
+
(CASE WHEN EXISTS (
|
|
362
|
+
SELECT 1 FROM flow_grants WHERE id = ? AND flow_id = ?
|
|
363
|
+
) THEN '1:' ELSE '0:' END) || ?, ?`)
|
|
364
|
+
.bind(input.actorId, input.idempotencyKey, input.grantId, input.flowId, input.grantId, input.occurredAt),
|
|
365
|
+
deps.db
|
|
366
|
+
.prepare("DELETE FROM flow_grants WHERE id = ? AND flow_id = ?")
|
|
367
|
+
.bind(input.grantId, input.flowId),
|
|
368
|
+
deps.db
|
|
369
|
+
.prepare(`INSERT INTO audit_events (
|
|
370
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
371
|
+
) SELECT ?, ?, 'flows.revoke', 'flow', ?,
|
|
372
|
+
json_object(
|
|
373
|
+
'grantId', ?,
|
|
374
|
+
'revoked', json(CASE WHEN substr(resource_id, 1, 2) = '1:' THEN 'true' ELSE 'false' END)
|
|
375
|
+
), ?
|
|
376
|
+
FROM idempotency_keys
|
|
377
|
+
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
378
|
+
.bind(input.auditId, input.actorId, input.flowId, input.grantId, input.occurredAt, input.actorId, input.idempotencyKey),
|
|
379
|
+
]);
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
const replayed = await this.findIdempotentFlowRevocation(input.actorId, input.idempotencyKey);
|
|
383
|
+
if (replayed === null)
|
|
384
|
+
throw error;
|
|
385
|
+
return replayed;
|
|
386
|
+
}
|
|
387
|
+
return ((await this.findIdempotentFlowRevocation(input.actorId, input.idempotencyKey)) ?? false);
|
|
388
|
+
},
|
|
389
|
+
async findIdempotentFlowRevocation(actorId, idempotencyKey) {
|
|
390
|
+
const row = await deps.db
|
|
391
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
392
|
+
WHERE actor_id = ? AND operation = 'flows.revoke' AND idempotency_key = ?`)
|
|
393
|
+
.bind(actorId, idempotencyKey)
|
|
394
|
+
.first();
|
|
395
|
+
if (!row)
|
|
396
|
+
return null;
|
|
397
|
+
return row.resource_id.startsWith("1:");
|
|
398
|
+
},
|
|
399
|
+
async can(actor, flowId, verb) {
|
|
400
|
+
const row = await deps.db
|
|
401
|
+
.prepare(flowVerbQuery)
|
|
402
|
+
.bind(...flowVerbBindings(flowId, actor, verb, deps.now().toISOString()))
|
|
403
|
+
.first();
|
|
404
|
+
return row?.allowed === 1;
|
|
405
|
+
},
|
|
406
|
+
// A library flow is run by people who may not open the folder it lives in, so `read` alone would
|
|
407
|
+
// make the library unwirable. The two grants stay independent (ADR-0004 §2) — they are two
|
|
408
|
+
// walks of the tree — but they are asked in one breath rather than one after the other, because
|
|
409
|
+
// a graph names as many callees as it likes and each of them used to cost its own round trip
|
|
410
|
+
// (#30). Order is the graph's, restored by the caller: SQL sorts, a call list does not.
|
|
411
|
+
async listCallable(actor, flowIds) {
|
|
412
|
+
if (flowIds.length === 0)
|
|
413
|
+
return [];
|
|
414
|
+
const occurredAt = deps.now().toISOString();
|
|
415
|
+
const result = await deps.db
|
|
416
|
+
.prepare(`${readableOrRunnableCte}
|
|
417
|
+
SELECT ${flowColumns} FROM flows flow
|
|
418
|
+
WHERE flow.id IN ${idList} AND ${flowCallable}
|
|
419
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
420
|
+
.bind(...subtreeBindings(actor, "read", occurredAt), ...subtreeBindings(actor, "execute", occurredAt), JSON.stringify(flowIds), ...flowCallableBindings(actor, occurredAt))
|
|
421
|
+
.all();
|
|
422
|
+
return (result.results ?? []).map(mapFlow);
|
|
423
|
+
},
|
|
424
|
+
async getCallable(actor, flowId) {
|
|
425
|
+
return (await this.listCallable(actor, [flowId]))[0] ?? null;
|
|
426
|
+
},
|
|
427
|
+
// ⚠️ `UNION`, never `UNION ALL`, for the same reason as every other walk of this tree: a ring in
|
|
428
|
+
// `parent_id` must end the recursion rather than the database.
|
|
429
|
+
async callReach(callerFolderId, calleeFolderId) {
|
|
430
|
+
// A flow at the root of the tree stands in the root folder, whose subtree is the whole tree.
|
|
431
|
+
if (callerFolderId === null)
|
|
432
|
+
return "subtree";
|
|
433
|
+
// A callee at the root has no folder, so nothing can carry an `execute` for it: not a library.
|
|
434
|
+
if (calleeFolderId === null)
|
|
435
|
+
return "out-of-reach";
|
|
436
|
+
const row = await deps.db
|
|
437
|
+
.prepare(`WITH RECURSIVE ancestors(id, parent_id) AS (
|
|
438
|
+
SELECT id, parent_id FROM nodes WHERE id = ?
|
|
439
|
+
UNION
|
|
440
|
+
SELECT parent.id, parent.parent_id
|
|
441
|
+
FROM nodes parent
|
|
442
|
+
JOIN ancestors child ON child.parent_id = parent.id
|
|
443
|
+
)
|
|
444
|
+
SELECT
|
|
445
|
+
EXISTS (SELECT 1 FROM ancestors WHERE id = ?) AS in_subtree,
|
|
446
|
+
EXISTS (
|
|
447
|
+
SELECT 1 FROM node_grants grant_row
|
|
448
|
+
JOIN ancestors ON ancestors.id = grant_row.node_id
|
|
449
|
+
WHERE grant_row.principal_type = 'organization'
|
|
450
|
+
AND grant_row.verb = 'execute'
|
|
451
|
+
AND ${grantInForce}
|
|
452
|
+
) AS library`)
|
|
453
|
+
.bind(calleeFolderId, callerFolderId, deps.now().toISOString())
|
|
454
|
+
.first();
|
|
455
|
+
if (row?.in_subtree === 1)
|
|
456
|
+
return "subtree";
|
|
457
|
+
// The ADR allows a folder whose `execute` reaches at least as far as the caller's own. The
|
|
458
|
+
// only such folder that can be answered without comparing two principal sets is the one whose
|
|
459
|
+
// `execute` reaches everyone, and the ADR names that as the case in practice.
|
|
460
|
+
return row?.library === 1 ? "library" : "out-of-reach";
|
|
461
|
+
},
|
|
462
|
+
// ⚠️ Two answers out of one statement: the callers this actor may see, by title, and how many
|
|
463
|
+
// more there are. The predicate is `flowInSubtree` — the very one `listVisible` uses — so a
|
|
464
|
+
// caller is named here exactly when it would be listed anywhere else. A second, kinder rule for
|
|
465
|
+
// error messages is how a refusal turns into a way of reading the tree.
|
|
466
|
+
async externalCallers(actor, folderId) {
|
|
467
|
+
const result = await deps.db
|
|
468
|
+
.prepare(`${subtreeCte},
|
|
469
|
+
scope(id) AS (
|
|
470
|
+
SELECT id FROM nodes WHERE id = ?
|
|
471
|
+
UNION
|
|
472
|
+
SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
|
|
473
|
+
)
|
|
474
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
475
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
476
|
+
FROM flows flow
|
|
477
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
478
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
479
|
+
JOIN flows callee ON callee.id = json_extract(node.value, '$.configuration.flowId')
|
|
480
|
+
WHERE json_extract(node.value, '$.kind') = 'subflow'
|
|
481
|
+
AND flow.archived_at IS NULL
|
|
482
|
+
AND callee.parent_id IN (SELECT id FROM scope)
|
|
483
|
+
AND (flow.parent_id IS NULL OR flow.parent_id NOT IN (SELECT id FROM scope))
|
|
484
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
485
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...inSubtreeBindings(actor))
|
|
486
|
+
.all();
|
|
487
|
+
const rows = result.results ?? [];
|
|
488
|
+
return {
|
|
489
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
490
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
491
|
+
};
|
|
492
|
+
},
|
|
493
|
+
// Who calls THIS flow — the sibling of `externalCallers`, which asks the same question about a
|
|
494
|
+
// whole folder (#457). Same shape: what the actor may see by name, the rest as a number, because
|
|
495
|
+
// a refusal must not become a way of learning that a flow one cannot see exists.
|
|
496
|
+
async flowCallers(actor, flowId) {
|
|
497
|
+
const result = await deps.db
|
|
498
|
+
.prepare(`${subtreeCte}
|
|
499
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
500
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
501
|
+
FROM flows flow
|
|
502
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
503
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
504
|
+
WHERE json_extract(node.value, '$.kind') = 'subflow'
|
|
505
|
+
AND json_extract(node.value, '$.configuration.flowId') = ?
|
|
506
|
+
AND flow.archived_at IS NULL
|
|
507
|
+
AND flow.id <> ?
|
|
508
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
509
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...inSubtreeBindings(actor), flowId, flowId)
|
|
510
|
+
.all();
|
|
511
|
+
const rows = result.results ?? [];
|
|
512
|
+
return {
|
|
513
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
514
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
515
|
+
};
|
|
516
|
+
},
|
|
517
|
+
// Published flows whose graph reads THIS node as a step (#457) — the mirror of `nodeReferences`,
|
|
518
|
+
// which asks the same graph the other way round ("which documents do the flows in this folder
|
|
519
|
+
// read"). Same visible/hidden shape as every other refusal: a title only where the actor may see
|
|
520
|
+
// it, the rest counted, so a refusal never becomes a directory of the tree (ADR-0004 §3).
|
|
521
|
+
async flowsUsingNode(actor, nodeId) {
|
|
522
|
+
const result = await deps.db
|
|
523
|
+
.prepare(`${subtreeCte}
|
|
524
|
+
SELECT DISTINCT ${qualifiedFlowColumns("flow")},
|
|
525
|
+
CASE WHEN ${flowInSubtree} THEN 1 ELSE 0 END AS visible
|
|
526
|
+
FROM flows flow
|
|
527
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
528
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
529
|
+
WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
|
|
530
|
+
AND json_extract(node.value, '$.configuration.resourceId') = ?
|
|
531
|
+
AND flow.archived_at IS NULL
|
|
532
|
+
ORDER BY lower(flow.title), flow.id`)
|
|
533
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), ...inSubtreeBindings(actor), nodeId)
|
|
534
|
+
.all();
|
|
535
|
+
const rows = result.results ?? [];
|
|
536
|
+
return {
|
|
537
|
+
visible: rows.filter((row) => row.visible === 1).map((row) => mapFlow(row).title),
|
|
538
|
+
hidden: rows.filter((row) => row.visible !== 1).length,
|
|
539
|
+
};
|
|
540
|
+
},
|
|
541
|
+
// ⚠️ BOTH directions, and the second one was missing (found in review). A run of this flow can be
|
|
542
|
+
// the PARENT of somebody else's run — deleting it takes the record of what started theirs. It can
|
|
543
|
+
// just as well be the CHILD of somebody else's run, and deleting that leaves the caller standing
|
|
544
|
+
// on a sub-flow node whose callee is gone: it can never be completed, and migration 0010's stall
|
|
545
|
+
// rule then sweeps the caller to `failed`. A history with a hole is not a history, in either
|
|
546
|
+
// direction.
|
|
547
|
+
async hasEntangledRuns(flowId) {
|
|
548
|
+
const row = await deps.db
|
|
549
|
+
.prepare(`SELECT 1 AS found
|
|
550
|
+
FROM flow_runs mine
|
|
551
|
+
JOIN flow_runs other
|
|
552
|
+
ON other.parent_run_id = mine.id OR other.id = mine.parent_run_id
|
|
553
|
+
WHERE mine.flow_id = ? AND other.flow_id <> ?
|
|
554
|
+
LIMIT 1`)
|
|
555
|
+
.bind(flowId, flowId)
|
|
556
|
+
.first();
|
|
557
|
+
return row !== null;
|
|
558
|
+
},
|
|
559
|
+
// ⚠️ Archiving deliberately leaves runs in flight alone — restoring the flow is what makes them
|
|
560
|
+
// readable again (`flows.ts`). A purge takes that way back with it, so a run that has not
|
|
561
|
+
// finished refuses the deletion rather than being deleted mid-execution.
|
|
562
|
+
async hasUnfinishedRuns(flowId) {
|
|
563
|
+
const row = await deps.db
|
|
564
|
+
.prepare(`SELECT 1 AS found FROM flow_runs
|
|
565
|
+
WHERE flow_id = ? AND status IN ('queued', 'running', 'waiting') LIMIT 1`)
|
|
566
|
+
.bind(flowId)
|
|
567
|
+
.first();
|
|
568
|
+
return row !== null;
|
|
569
|
+
},
|
|
570
|
+
async purgeFlow(input) {
|
|
571
|
+
const aliveFlow = "EXISTS (SELECT 1 FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL)";
|
|
572
|
+
const deleted = await deps.db.batch([
|
|
573
|
+
// ⚠️ FIRST, reading the title out of the row that falls at the end of this batch. Afterwards
|
|
574
|
+
// nothing can look a name up, and an entry carrying only an id names something nobody can
|
|
575
|
+
// find again (`anchrd/gate#146`).
|
|
576
|
+
deps.db
|
|
577
|
+
.prepare(`INSERT INTO audit_events (
|
|
578
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
579
|
+
) SELECT ?, ?, 'flows.purge', 'flow', f.id,
|
|
580
|
+
json_object('title', f.title, 'parentId', f.parent_id), ?
|
|
581
|
+
FROM flows f WHERE f.id = ? AND f.archived_at IS NOT NULL`)
|
|
582
|
+
.bind(input.auditId, input.actorId, input.occurredAt, input.flowId),
|
|
583
|
+
// The runs are this flow's history: the steps hang off the runs, the runs off the flow.
|
|
584
|
+
// ⚠️ The guard belongs on EVERY statement, not only the last: a batch whose final statement
|
|
585
|
+
// matches nothing still COMMITS, so a restore in between would empty a living flow's history
|
|
586
|
+
// and answer `409` as if nothing had happened.
|
|
587
|
+
deps.db
|
|
588
|
+
.prepare(`DELETE FROM flow_run_steps
|
|
589
|
+
WHERE run_id IN (SELECT id FROM flow_runs WHERE flow_id = ?) AND ${aliveFlow}`)
|
|
590
|
+
.bind(input.flowId, input.flowId),
|
|
591
|
+
deps.db
|
|
592
|
+
.prepare(`DELETE FROM flow_runs WHERE flow_id = ? AND ${aliveFlow}`)
|
|
593
|
+
.bind(input.flowId, input.flowId),
|
|
594
|
+
deps.db
|
|
595
|
+
.prepare(`DELETE FROM flow_versions WHERE flow_id = ? AND ${aliveFlow}`)
|
|
596
|
+
.bind(input.flowId, input.flowId),
|
|
597
|
+
deps.db
|
|
598
|
+
.prepare(`DELETE FROM idempotency_keys WHERE resource_id = ? AND ${aliveFlow}`)
|
|
599
|
+
.bind(input.flowId, input.flowId),
|
|
600
|
+
// The grants that sat on the flow itself (#530). They point at `flows(id)`, so they have to
|
|
601
|
+
// go before the row does — and they carry the same guard as everything above for the same
|
|
602
|
+
// reason: a batch whose last statement matches nothing still commits, and a restore in
|
|
603
|
+
// between would leave a living flow stripped of every grant somebody set on it.
|
|
604
|
+
deps.db
|
|
605
|
+
.prepare(`DELETE FROM flow_grants WHERE flow_id = ? AND ${aliveFlow}`)
|
|
606
|
+
.bind(input.flowId, input.flowId),
|
|
607
|
+
// ⚠️ LAST, and still guarded — a restore between the service's check and this statement
|
|
608
|
+
// would otherwise cost a living flow.
|
|
609
|
+
deps.db
|
|
610
|
+
.prepare("DELETE FROM flows WHERE id = ? AND archived_at IS NOT NULL")
|
|
611
|
+
.bind(input.flowId),
|
|
612
|
+
]);
|
|
613
|
+
return (deleted.at(-1)?.meta?.changes ?? 0) === 0 ? "missing" : { purged: true };
|
|
614
|
+
},
|
|
615
|
+
// The documents the flows in this folder's subtree read, so whoever shares the folder
|
|
616
|
+
// can be told what the grant does not cover (ADR-0004 §4).
|
|
617
|
+
//
|
|
618
|
+
// ⚠️ `flowInSubtree` again, the very predicate `listVisible` uses: a flow this actor may not see
|
|
619
|
+
// must not reach the answer even as a number, because what it reads would then be attributed to
|
|
620
|
+
// a folder they administer. Whether any of the returned IDs may be *named* is the tree's
|
|
621
|
+
// question — this one hands back IDs and no titles.
|
|
622
|
+
//
|
|
623
|
+
// ⚠️ The link kinds, one `resourceId` each — the shape migration 0008 left behind. Before #153
|
|
624
|
+
// this asked for a `knowledge` node with a `resourceIds` array, and both had been rewritten away:
|
|
625
|
+
// the query matched nothing, and the warning it feeds went quiet without ever failing. A stored
|
|
626
|
+
// graph from before 0008 still cannot break it — its kind is not in the list, so it is not read
|
|
627
|
+
// at all — and a node whose configuration carries no `resourceId` is dropped rather than passed
|
|
628
|
+
// on as a null the caller would look up.
|
|
629
|
+
async nodeReferences(actor, folderId) {
|
|
630
|
+
const result = await deps.db
|
|
631
|
+
.prepare(`${subtreeCte},
|
|
632
|
+
scope(id) AS (
|
|
633
|
+
SELECT id FROM nodes WHERE id = ?
|
|
634
|
+
UNION
|
|
635
|
+
SELECT child.id FROM nodes child JOIN scope ON child.parent_id = scope.id
|
|
636
|
+
),
|
|
637
|
+
referencing(resource_id) AS (
|
|
638
|
+
SELECT json_extract(node.value, '$.configuration.resourceId')
|
|
639
|
+
FROM flows flow
|
|
640
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
641
|
+
JOIN json_each(version.graph_json, '$.nodes') node
|
|
642
|
+
WHERE json_extract(node.value, '$.kind') IN (${treeLinkKindList})
|
|
643
|
+
AND flow.archived_at IS NULL
|
|
644
|
+
AND flow.parent_id IN (SELECT id FROM scope)
|
|
645
|
+
AND ${flowInSubtree}
|
|
646
|
+
)
|
|
647
|
+
SELECT DISTINCT resource_id
|
|
648
|
+
FROM referencing
|
|
649
|
+
WHERE resource_id IS NOT NULL
|
|
650
|
+
ORDER BY resource_id`)
|
|
651
|
+
.bind(...subtreeBindings(actor, "read", deps.now().toISOString()), folderId, ...inSubtreeBindings(actor))
|
|
652
|
+
.all();
|
|
653
|
+
return (result.results ?? []).map((row) => row.resource_id);
|
|
654
|
+
},
|
|
655
|
+
// ⚠️ No actor and no `archived_at` condition, both on purpose (#509). The question is whether
|
|
656
|
+
// the row is still there at all, and archiving keeps it — a reference to something archived is
|
|
657
|
+
// not broken, it is waiting for a restore. The caller has already asked the visibility door for
|
|
658
|
+
// every one of these ids and reaches this only for the ones it answered nothing for.
|
|
659
|
+
//
|
|
660
|
+
// ⚠️ An empty list short-circuits rather than building `IN ()`, which SQLite refuses to parse.
|
|
661
|
+
async existingNodes(nodeIds) {
|
|
662
|
+
const wanted = [...new Set(nodeIds)];
|
|
663
|
+
if (wanted.length === 0)
|
|
664
|
+
return [];
|
|
665
|
+
const result = await deps.db
|
|
666
|
+
.prepare(`SELECT id FROM nodes WHERE id IN (${wanted.map(() => "?").join(", ")}) ORDER BY id`)
|
|
667
|
+
.bind(...wanted)
|
|
668
|
+
.all();
|
|
669
|
+
return (result.results ?? []).map((row) => row.id);
|
|
670
|
+
},
|
|
671
|
+
async publishedCallees(flowId) {
|
|
672
|
+
const row = await deps.db
|
|
673
|
+
.prepare(`SELECT flow.title AS title, version.graph_json AS graph_json
|
|
674
|
+
FROM flows flow
|
|
675
|
+
JOIN flow_versions version ON version.id = flow.published_version_id
|
|
676
|
+
WHERE flow.id = ? AND flow.archived_at IS NULL`)
|
|
677
|
+
.bind(flowId)
|
|
678
|
+
.first();
|
|
679
|
+
if (!row)
|
|
680
|
+
return null;
|
|
681
|
+
return {
|
|
682
|
+
title: row.title,
|
|
683
|
+
calleeIds: calleeIds(FlowGraph.parse(JSON.parse(row.graph_json))),
|
|
684
|
+
};
|
|
685
|
+
},
|
|
686
|
+
async findIdempotent(actorId, operation, key) {
|
|
687
|
+
const row = await deps.db
|
|
688
|
+
.prepare(`SELECT resource_id FROM idempotency_keys
|
|
689
|
+
WHERE actor_id = ? AND operation = ? AND idempotency_key = ?`)
|
|
690
|
+
.bind(actorId, operation, key)
|
|
691
|
+
.first();
|
|
692
|
+
return row?.resource_id ?? null;
|
|
693
|
+
},
|
|
694
|
+
async insertFlow(input) {
|
|
695
|
+
const flow = input.flow;
|
|
696
|
+
try {
|
|
697
|
+
await deps.db.batch([
|
|
698
|
+
deps.db
|
|
699
|
+
.prepare(`INSERT INTO flows (
|
|
700
|
+
id, parent_id, title, description, owner_id, current_version_id, published_version_id,
|
|
701
|
+
created_at, updated_at, archived_at
|
|
702
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
703
|
+
.bind(flow.id, flow.parentId, flow.title, flow.description, flow.ownerId, flow.currentVersionId, flow.publishedVersionId, flow.createdAt, flow.updatedAt, flow.archivedAt),
|
|
704
|
+
deps.db
|
|
705
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
706
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
707
|
+
) VALUES (?, 'flows.create', ?, ?, ?)`)
|
|
708
|
+
.bind(input.actorId, input.idempotencyKey, flow.id, flow.createdAt),
|
|
709
|
+
deps.db
|
|
710
|
+
.prepare(`INSERT INTO audit_events (
|
|
711
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
712
|
+
) VALUES (?, ?, 'flows.create', 'flow', ?, '{}', ?)`)
|
|
713
|
+
.bind(input.auditId, input.actorId, flow.id, flow.createdAt),
|
|
714
|
+
]);
|
|
715
|
+
return flow;
|
|
716
|
+
}
|
|
717
|
+
catch (error) {
|
|
718
|
+
const replayedId = await this.findIdempotent(input.actorId, "flows.create", input.idempotencyKey);
|
|
719
|
+
const replayed = replayedId
|
|
720
|
+
? await deps.db
|
|
721
|
+
.prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
|
|
722
|
+
.bind(replayedId)
|
|
723
|
+
.first()
|
|
724
|
+
: null;
|
|
725
|
+
if (replayed)
|
|
726
|
+
return mapFlow(replayed);
|
|
727
|
+
throw error;
|
|
728
|
+
}
|
|
729
|
+
},
|
|
730
|
+
// Renaming and moving touch the flow row and nothing else. `current_version_id` and
|
|
731
|
+
// `published_version_id` are deliberately absent from the SET list: organization must not be
|
|
732
|
+
// able to change what a flow does (ADR-0004).
|
|
733
|
+
async updateFlow(input) {
|
|
734
|
+
const flow = input.flow;
|
|
735
|
+
try {
|
|
736
|
+
await deps.db.batch([
|
|
737
|
+
deps.db
|
|
738
|
+
.prepare(`UPDATE flows SET parent_id = ?, title = ?, description = ?, updated_at = ?
|
|
739
|
+
WHERE id = ? AND updated_at = ?`)
|
|
740
|
+
.bind(flow.parentId, flow.title, flow.description, flow.updatedAt, flow.id, input.baseUpdatedAt),
|
|
741
|
+
deps.db
|
|
742
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
743
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
744
|
+
) SELECT ?, 'flows.update', ?, ?, ?
|
|
745
|
+
WHERE EXISTS (
|
|
746
|
+
SELECT 1 FROM flows
|
|
747
|
+
WHERE id = ? AND parent_id IS ? AND title = ? AND description IS ? AND updated_at = ?
|
|
748
|
+
)`)
|
|
749
|
+
.bind(input.actorId, input.idempotencyKey, flow.id, flow.updatedAt, flow.id, flow.parentId, flow.title, flow.description, flow.updatedAt),
|
|
750
|
+
deps.db
|
|
751
|
+
.prepare(`INSERT INTO audit_events (
|
|
752
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
753
|
+
) SELECT ?, ?, 'flows.update', 'flow', ?, ?, ?
|
|
754
|
+
WHERE EXISTS (
|
|
755
|
+
SELECT 1 FROM idempotency_keys
|
|
756
|
+
WHERE actor_id = ? AND operation = 'flows.update'
|
|
757
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
758
|
+
)`)
|
|
759
|
+
.bind(input.auditId, input.actorId, flow.id, JSON.stringify({ parentId: flow.parentId, title: flow.title }), flow.updatedAt, input.actorId, input.idempotencyKey, flow.id),
|
|
760
|
+
]);
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
if (!(await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey))) {
|
|
764
|
+
throw error;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.update", input.idempotencyKey);
|
|
768
|
+
if (!replayed)
|
|
769
|
+
return "conflict";
|
|
770
|
+
const row = await deps.db
|
|
771
|
+
.prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
|
|
772
|
+
.bind(replayed)
|
|
773
|
+
.first();
|
|
774
|
+
return row ? mapFlow(row) : "conflict";
|
|
775
|
+
},
|
|
776
|
+
// Archiving and restoring are one statement, told apart only by whether `archived_at` is a
|
|
777
|
+
// timestamp or null. Like `updateFlow` it writes the flow row alone: an archived flow keeps its
|
|
778
|
+
// versions and its published version, because coming back out of the archive has to return the
|
|
779
|
+
// flow that went in.
|
|
780
|
+
//
|
|
781
|
+
// ⚠️ The idempotency row is written only if the UPDATE actually matched — same guard as
|
|
782
|
+
// `node.archive` — so a stale `baseUpdatedAt` leaves no key behind that would make the
|
|
783
|
+
// retry of a *lost* write look like a replay of a successful one.
|
|
784
|
+
async archiveFlow(input) {
|
|
785
|
+
try {
|
|
786
|
+
await deps.db.batch([
|
|
787
|
+
/**
|
|
788
|
+
* ⚠️ **`archived_with` is CLEARED here, in both directions** (D71, #733, found in review
|
|
789
|
+
* of #738). A flow that travels through this door travels alone, so it belongs to no
|
|
790
|
+
* group: archiving it on its own must not leave it looking like part of one, and a row
|
|
791
|
+
* that still pointed at a group it no longer belongs to would come back with that group's
|
|
792
|
+
* next restore.
|
|
793
|
+
*
|
|
794
|
+
* The group itself is written by ONE place, the cascade in `archiveNode`. Minting an
|
|
795
|
+
* operation here as well would be a second writer for one column, and it would have no
|
|
796
|
+
* reader: for a restore, "no group" and "somebody else's group" are the same answer.
|
|
797
|
+
*/
|
|
798
|
+
deps.db
|
|
799
|
+
.prepare(`UPDATE flows SET archived_at = ?, archived_with = NULL, updated_at = ?
|
|
800
|
+
WHERE id = ? AND updated_at = ?`)
|
|
801
|
+
.bind(input.archivedAt, input.updatedAt, input.flowId, input.baseUpdatedAt),
|
|
802
|
+
deps.db
|
|
803
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
804
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
805
|
+
) SELECT ?, 'flows.archive', ?, ?, ?
|
|
806
|
+
WHERE EXISTS (
|
|
807
|
+
SELECT 1 FROM flows WHERE id = ? AND archived_at IS ? AND updated_at = ?
|
|
808
|
+
)`)
|
|
809
|
+
.bind(input.actorId, input.idempotencyKey, input.flowId, input.updatedAt, input.flowId, input.archivedAt, input.updatedAt),
|
|
810
|
+
deps.db
|
|
811
|
+
.prepare(`INSERT INTO audit_events (
|
|
812
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
813
|
+
) SELECT ?, ?, 'flows.archive', 'flow', ?, ?, ?
|
|
814
|
+
WHERE EXISTS (
|
|
815
|
+
SELECT 1 FROM idempotency_keys
|
|
816
|
+
WHERE actor_id = ? AND operation = 'flows.archive'
|
|
817
|
+
AND idempotency_key = ? AND resource_id = ?
|
|
818
|
+
)`)
|
|
819
|
+
.bind(input.auditId, input.actorId, input.flowId, JSON.stringify({ archived: input.archivedAt !== null }), input.updatedAt, input.actorId, input.idempotencyKey, input.flowId),
|
|
820
|
+
]);
|
|
821
|
+
}
|
|
822
|
+
catch (error) {
|
|
823
|
+
if (!(await this.findIdempotent(input.actorId, "flows.archive", input.idempotencyKey))) {
|
|
824
|
+
throw error;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.archive", input.idempotencyKey);
|
|
828
|
+
if (!replayed)
|
|
829
|
+
return "conflict";
|
|
830
|
+
const row = await deps.db
|
|
831
|
+
.prepare(`SELECT ${flowColumns} FROM flows WHERE id = ?`)
|
|
832
|
+
.bind(replayed)
|
|
833
|
+
.first();
|
|
834
|
+
return row ? mapFlow(row) : "conflict";
|
|
835
|
+
},
|
|
836
|
+
// Versions are immutable, so a set of them is a single read by definition: the trail of a nested
|
|
837
|
+
// run needs one per level and the relation graph one per flow it draws, and both used to ask
|
|
838
|
+
// level by level (#30). Missing IDs are simply absent from the answer.
|
|
839
|
+
async getVersions(versionIds) {
|
|
840
|
+
if (versionIds.length === 0)
|
|
841
|
+
return [];
|
|
842
|
+
const result = await deps.db
|
|
843
|
+
.prepare(`SELECT id, flow_id, sequence, graph_json, created_by, created_at
|
|
844
|
+
FROM flow_versions WHERE id IN ${idList}`)
|
|
845
|
+
.bind(JSON.stringify(versionIds))
|
|
846
|
+
.all();
|
|
847
|
+
return (result.results ?? []).map(mapVersion);
|
|
848
|
+
},
|
|
849
|
+
async getVersion(versionId) {
|
|
850
|
+
return (await this.getVersions([versionId]))[0] ?? null;
|
|
851
|
+
},
|
|
852
|
+
// `graph_json` is deliberately absent from the SELECT: the history is metadata, and reading
|
|
853
|
+
// every graph of a much-edited flow to draw a table would grow with the number of edits.
|
|
854
|
+
async listVersions(flowId) {
|
|
855
|
+
const result = await deps.db
|
|
856
|
+
.prepare(`SELECT id, flow_id, sequence, created_by, created_at
|
|
857
|
+
FROM flow_versions WHERE flow_id = ? ORDER BY sequence`)
|
|
858
|
+
.bind(flowId)
|
|
859
|
+
.all();
|
|
860
|
+
return (result.results ?? []).map((row) => ({
|
|
861
|
+
id: row.id,
|
|
862
|
+
flowId: row.flow_id,
|
|
863
|
+
sequence: row.sequence,
|
|
864
|
+
createdBy: row.created_by,
|
|
865
|
+
createdAt: row.created_at,
|
|
866
|
+
}));
|
|
867
|
+
},
|
|
868
|
+
async insertVersion(input) {
|
|
869
|
+
const version = input.version;
|
|
870
|
+
try {
|
|
871
|
+
await deps.db.batch([
|
|
872
|
+
deps.db
|
|
873
|
+
.prepare(`INSERT INTO flow_versions (
|
|
874
|
+
id, flow_id, sequence, graph_json, created_by, created_at
|
|
875
|
+
) SELECT ?, ?, ?, ?, ?, ?
|
|
876
|
+
WHERE EXISTS (
|
|
877
|
+
SELECT 1 FROM flows WHERE id = ? AND current_version_id IS ?
|
|
878
|
+
)`)
|
|
879
|
+
.bind(version.id, version.flowId, version.sequence, JSON.stringify(version.graph), version.createdBy, version.createdAt, version.flowId, input.baseVersionId),
|
|
880
|
+
deps.db
|
|
881
|
+
.prepare(`UPDATE flows SET current_version_id = ?, updated_at = ?
|
|
882
|
+
WHERE id = ? AND current_version_id IS ?
|
|
883
|
+
AND EXISTS (SELECT 1 FROM flow_versions WHERE id = ?)`)
|
|
884
|
+
.bind(version.id, version.createdAt, version.flowId, input.baseVersionId, version.id),
|
|
885
|
+
deps.db
|
|
886
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
887
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
888
|
+
) SELECT ?, 'flows.save', ?, ?, ?
|
|
889
|
+
WHERE EXISTS (SELECT 1 FROM flow_versions WHERE id = ?)`)
|
|
890
|
+
.bind(input.actorId, input.idempotencyKey, version.id, version.createdAt, version.id),
|
|
891
|
+
deps.db
|
|
892
|
+
.prepare(`INSERT INTO audit_events (
|
|
893
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
894
|
+
) SELECT ?, ?, 'flows.save', 'flow', ?, ?, ?
|
|
895
|
+
WHERE EXISTS (SELECT 1 FROM flow_versions WHERE id = ?)`)
|
|
896
|
+
.bind(input.auditId, input.actorId, version.flowId, JSON.stringify({ versionId: version.id, sequence: version.sequence }), version.createdAt, version.id),
|
|
897
|
+
]);
|
|
898
|
+
}
|
|
899
|
+
catch (error) {
|
|
900
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.save", input.idempotencyKey);
|
|
901
|
+
if (!replayed)
|
|
902
|
+
throw error;
|
|
903
|
+
}
|
|
904
|
+
const stored = await deps.db
|
|
905
|
+
.prepare("SELECT 1 AS saved FROM flow_versions WHERE id = ?")
|
|
906
|
+
.bind(version.id)
|
|
907
|
+
.first();
|
|
908
|
+
return stored ? "saved" : "conflict";
|
|
909
|
+
},
|
|
910
|
+
async promptNameHolder(actor, name) {
|
|
911
|
+
// One question, one implementation, shared with the node repository — see `db-prompts.ts`.
|
|
912
|
+
return await promptNameHolder(deps, actor, name);
|
|
913
|
+
},
|
|
914
|
+
/**
|
|
915
|
+
* ⚠️ **Two statement forms, not one with a conditional value** (#775). Publishing without
|
|
916
|
+
* mentioning a prompt name must leave the name alone; `SET prompt_name = ?` with the current
|
|
917
|
+
* value read back beforehand would work until two publishes race, and then the second one
|
|
918
|
+
* writes a name the first had just replaced. The form that does not mention the column cannot
|
|
919
|
+
* do that, and the form that does carries its guard.
|
|
920
|
+
*
|
|
921
|
+
* ⚠️ **The guard is on all three statements** (`destructive.md`, `anchrd/intel#457`). A
|
|
922
|
+
* statement matching zero rows is not an error, so an idempotency row and an audit row written
|
|
923
|
+
* without the same condition would book a publication that never happened — and the caller's
|
|
924
|
+
* retry would then be answered out of the replay table.
|
|
925
|
+
*/
|
|
926
|
+
async publish(input) {
|
|
927
|
+
const setsName = input.promptName !== undefined;
|
|
928
|
+
const nameAssignment = setsName ? ", prompt_name = ?" : "";
|
|
929
|
+
const nameBinding = setsName ? [input.promptName] : [];
|
|
930
|
+
// ⚠️ `other.id <> ?` lets a flow keep the name it already holds: republishing an offered flow
|
|
931
|
+
// sends the same name again, and without the exclusion it would collide with itself.
|
|
932
|
+
const nameGuard = setsName
|
|
933
|
+
? `AND (
|
|
934
|
+
? IS NULL
|
|
935
|
+
OR (
|
|
936
|
+
NOT EXISTS (SELECT 1 FROM nodes holder WHERE holder.prompt_name = ?)
|
|
937
|
+
AND NOT EXISTS (
|
|
938
|
+
SELECT 1 FROM flows other WHERE other.prompt_name = ? AND other.id <> ?
|
|
939
|
+
)
|
|
940
|
+
)
|
|
941
|
+
)`
|
|
942
|
+
: "";
|
|
943
|
+
const nameGuardBindings = setsName
|
|
944
|
+
? [input.promptName, input.promptName, input.promptName, input.flowId]
|
|
945
|
+
: [];
|
|
946
|
+
const nameEcho = setsName ? "AND prompt_name IS ?" : "";
|
|
947
|
+
const nameEchoBinding = setsName ? [input.promptName] : [];
|
|
948
|
+
try {
|
|
949
|
+
await deps.db.batch([
|
|
950
|
+
deps.db
|
|
951
|
+
.prepare(`UPDATE flows SET published_version_id = ?${nameAssignment}, updated_at = ?
|
|
952
|
+
WHERE id = ? AND EXISTS (
|
|
953
|
+
SELECT 1 FROM flow_versions WHERE id = ? AND flow_id = ?
|
|
954
|
+
)
|
|
955
|
+
${nameGuard}`)
|
|
956
|
+
.bind(input.versionId, ...nameBinding, input.occurredAt, input.flowId, input.versionId, input.flowId, ...nameGuardBindings),
|
|
957
|
+
deps.db
|
|
958
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
959
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
960
|
+
) SELECT ?, 'flows.publish', ?, ?, ?
|
|
961
|
+
WHERE EXISTS (
|
|
962
|
+
SELECT 1 FROM flows WHERE id = ? AND published_version_id = ? ${nameEcho}
|
|
963
|
+
)`)
|
|
964
|
+
.bind(input.actorId, input.idempotencyKey, input.versionId, input.occurredAt, input.flowId, input.versionId, ...nameEchoBinding),
|
|
965
|
+
deps.db
|
|
966
|
+
.prepare(`INSERT INTO audit_events (
|
|
967
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
968
|
+
) SELECT ?, ?, 'flows.publish', 'flow', ?, ?, ?
|
|
969
|
+
WHERE EXISTS (
|
|
970
|
+
SELECT 1 FROM idempotency_keys
|
|
971
|
+
WHERE actor_id = ? AND operation = 'flows.publish' AND idempotency_key = ?
|
|
972
|
+
)`)
|
|
973
|
+
.bind(input.auditId, input.actorId, input.flowId, JSON.stringify({ versionId: input.versionId }), input.occurredAt, input.actorId, input.idempotencyKey),
|
|
974
|
+
]);
|
|
975
|
+
}
|
|
976
|
+
catch (error) {
|
|
977
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey);
|
|
978
|
+
if (!replayed)
|
|
979
|
+
throw error;
|
|
980
|
+
}
|
|
981
|
+
const published = (await this.findIdempotent(input.actorId, "flows.publish", input.idempotencyKey)) !== null;
|
|
982
|
+
// ⚠️ Which of the two refusals it was, asked only after the write has failed — the same order
|
|
983
|
+
// `updateNode` uses, and for the same reason: deciding first and writing unguarded is the
|
|
984
|
+
// shape that loses the race the guard exists for.
|
|
985
|
+
if (!published && setsName && input.promptName !== null) {
|
|
986
|
+
const taken = await deps.db
|
|
987
|
+
.prepare(`SELECT 1 AS found FROM nodes WHERE prompt_name = ?
|
|
988
|
+
UNION ALL
|
|
989
|
+
SELECT 1 AS found FROM flows WHERE prompt_name = ? AND id <> ?
|
|
990
|
+
LIMIT 1`)
|
|
991
|
+
.bind(input.promptName, input.promptName, input.flowId)
|
|
992
|
+
.first();
|
|
993
|
+
if (taken?.found === 1)
|
|
994
|
+
return "prompt_name_taken";
|
|
995
|
+
}
|
|
996
|
+
return published;
|
|
997
|
+
},
|
|
998
|
+
// The mirror of `publish`, with the same shape of guards: the idempotency row is written only
|
|
999
|
+
// if the UPDATE actually withdrew something, so revoking what was never published leaves no key
|
|
1000
|
+
// behind that would make a later retry look like a success.
|
|
1001
|
+
async unpublish(input) {
|
|
1002
|
+
try {
|
|
1003
|
+
await deps.db.batch([
|
|
1004
|
+
deps.db
|
|
1005
|
+
.prepare(`UPDATE flows SET published_version_id = NULL, updated_at = ?
|
|
1006
|
+
WHERE id = ? AND published_version_id IS NOT NULL`)
|
|
1007
|
+
.bind(input.occurredAt, input.flowId),
|
|
1008
|
+
deps.db
|
|
1009
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1010
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1011
|
+
) SELECT ?, 'flows.unpublish', ?, ?, ?
|
|
1012
|
+
WHERE EXISTS (
|
|
1013
|
+
SELECT 1 FROM flows
|
|
1014
|
+
WHERE id = ? AND published_version_id IS NULL AND updated_at = ?
|
|
1015
|
+
)`)
|
|
1016
|
+
.bind(input.actorId, input.idempotencyKey, input.flowId, input.occurredAt, input.flowId, input.occurredAt),
|
|
1017
|
+
deps.db
|
|
1018
|
+
.prepare(`INSERT INTO audit_events (
|
|
1019
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1020
|
+
) SELECT ?, ?, 'flows.unpublish', 'flow', ?, ?, ?
|
|
1021
|
+
WHERE EXISTS (
|
|
1022
|
+
SELECT 1 FROM idempotency_keys
|
|
1023
|
+
WHERE actor_id = ? AND operation = 'flows.unpublish' AND idempotency_key = ?
|
|
1024
|
+
)`)
|
|
1025
|
+
.bind(input.auditId, input.actorId, input.flowId,
|
|
1026
|
+
// What was withdrawn, so the trail can say which version was live until here.
|
|
1027
|
+
JSON.stringify({ versionId: input.versionId }), input.occurredAt, input.actorId, input.idempotencyKey),
|
|
1028
|
+
]);
|
|
1029
|
+
}
|
|
1030
|
+
catch (error) {
|
|
1031
|
+
if (!(await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey))) {
|
|
1032
|
+
throw error;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
return ((await this.findIdempotent(input.actorId, "flows.unpublish", input.idempotencyKey)) !== null);
|
|
1036
|
+
},
|
|
1037
|
+
async insertRun(input) {
|
|
1038
|
+
const run = input.run;
|
|
1039
|
+
try {
|
|
1040
|
+
await deps.db.batch([
|
|
1041
|
+
deps.db
|
|
1042
|
+
.prepare(`INSERT INTO flow_runs (
|
|
1043
|
+
id, flow_id, version_id, status, current_node_id, input_json, output_json,
|
|
1044
|
+
error, initiated_by, parent_run_id, parent_node_id, created_at, updated_at, completed_at
|
|
1045
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
|
|
1046
|
+
.bind(run.id, run.flowId, run.versionId, run.status, run.currentNodeId, JSON.stringify(run.input), null, run.error, run.initiatedBy, run.parentRunId, run.parentNodeId, run.createdAt, run.updatedAt, run.completedAt),
|
|
1047
|
+
deps.db
|
|
1048
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1049
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1050
|
+
) VALUES (?, 'flows.run', ?, ?, ?)`)
|
|
1051
|
+
.bind(input.actorId, input.idempotencyKey, run.id, run.createdAt),
|
|
1052
|
+
deps.db
|
|
1053
|
+
.prepare(`INSERT INTO audit_events (
|
|
1054
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1055
|
+
) VALUES (?, ?, 'flows.run', 'flow-run', ?, ?, ?)`)
|
|
1056
|
+
.bind(input.auditId, input.actorId, run.id, JSON.stringify({ flowId: run.flowId, versionId: run.versionId }), run.createdAt),
|
|
1057
|
+
]);
|
|
1058
|
+
return run;
|
|
1059
|
+
}
|
|
1060
|
+
catch (error) {
|
|
1061
|
+
const replayedId = await this.findIdempotent(input.actorId, "flows.run", input.idempotencyKey);
|
|
1062
|
+
const replayed = replayedId
|
|
1063
|
+
? await deps.db
|
|
1064
|
+
.prepare(`SELECT ${runColumns} FROM flow_runs WHERE id = ?`)
|
|
1065
|
+
.bind(replayedId)
|
|
1066
|
+
.first()
|
|
1067
|
+
: null;
|
|
1068
|
+
if (replayed)
|
|
1069
|
+
return mapRun(replayed);
|
|
1070
|
+
throw error;
|
|
1071
|
+
}
|
|
1072
|
+
},
|
|
1073
|
+
async getRunVisible(actor, runId) {
|
|
1074
|
+
const row = await deps.db
|
|
1075
|
+
.prepare(`${subtreeCte}
|
|
1076
|
+
SELECT ${qualifiedRunColumns("run")} FROM flow_runs run
|
|
1077
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
1078
|
+
WHERE run.id = ? AND ${runVisible}`)
|
|
1079
|
+
.bind(...readableBindings(actor), runId, ...runVisibleBindings(actor))
|
|
1080
|
+
.first();
|
|
1081
|
+
return row ? mapRun(row) : null;
|
|
1082
|
+
},
|
|
1083
|
+
// Newest first, keyed on `(created_at, id)` so a page holds still while runs keep arriving.
|
|
1084
|
+
//
|
|
1085
|
+
// ⚠️ `runVisible`, the same predicate `getRunVisible` uses one function up. Seeing the flow is
|
|
1086
|
+
// not the same as seeing its runs: a library flow is executable for everyone and readable by
|
|
1087
|
+
// nobody, and the people running it must still find their own runs back.
|
|
1088
|
+
//
|
|
1089
|
+
// ⚠️ A null flow means EVERY flow this actor may read, not "no flow" (#774). The row filter is
|
|
1090
|
+
// untouched by it — `runVisible` decides per row either way — so the wider question cannot
|
|
1091
|
+
// answer with a run the narrow one would have hidden. Which SHAPE of statement asks it is
|
|
1092
|
+
// `flowRunsPageQuery`'s business, and the reason it is a shape rather than a binding is written
|
|
1093
|
+
// there.
|
|
1094
|
+
async listRunsVisible(actor, input) {
|
|
1095
|
+
const scoped = input.flowId !== null;
|
|
1096
|
+
const result = await deps.db
|
|
1097
|
+
.prepare(flowRunsPageQuery({
|
|
1098
|
+
scoped,
|
|
1099
|
+
failedOnly: input.failedOnly,
|
|
1100
|
+
paged: input.cursor !== null && input.cursor !== undefined,
|
|
1101
|
+
}))
|
|
1102
|
+
.bind(...readableBindings(actor), ...(scoped ? [input.flowId] : []), ...runVisibleBindings(actor), ...(input.cursor
|
|
1103
|
+
? [input.cursor.createdAt, input.cursor.createdAt, input.cursor.id]
|
|
1104
|
+
: []), input.limit)
|
|
1105
|
+
.all();
|
|
1106
|
+
return (result.results ?? []).map(mapRun);
|
|
1107
|
+
},
|
|
1108
|
+
// `idList` rather than a placeholder per run: a page of runs is as long as the page size, and
|
|
1109
|
+
// a statement built from the input would carry its length into D1's parameter cap (#30).
|
|
1110
|
+
async failedSteps(runIds) {
|
|
1111
|
+
if (runIds.length === 0)
|
|
1112
|
+
return [];
|
|
1113
|
+
const result = await deps.db
|
|
1114
|
+
.prepare(`SELECT run_id, node_id, outcome, branch, error, completed_at
|
|
1115
|
+
FROM flow_run_steps
|
|
1116
|
+
WHERE outcome = 'failed' AND run_id IN ${idList}`)
|
|
1117
|
+
.bind(JSON.stringify(runIds))
|
|
1118
|
+
.all();
|
|
1119
|
+
return (result.results ?? []).map(mapStep);
|
|
1120
|
+
},
|
|
1121
|
+
async runSteps(runId) {
|
|
1122
|
+
const result = await deps.db
|
|
1123
|
+
.prepare(`SELECT run_id, node_id, outcome, branch, error, completed_at
|
|
1124
|
+
FROM flow_run_steps WHERE run_id = ?
|
|
1125
|
+
ORDER BY completed_at, id`)
|
|
1126
|
+
.bind(runId)
|
|
1127
|
+
.all();
|
|
1128
|
+
return (result.results ?? []).map(mapStep);
|
|
1129
|
+
},
|
|
1130
|
+
// ⚠️ `runVisible` once more, for many call sites at once. The reason a call failed lives in the
|
|
1131
|
+
// called run, and whether it may be repeated is that run's own question — asking it per row is
|
|
1132
|
+
// what would put a query on every line of the list, and answering it with a second, cheaper rule
|
|
1133
|
+
// is what #17, #19 and #20 were each sent back for.
|
|
1134
|
+
//
|
|
1135
|
+
// The pairs travel as one JSON value, like every other list here: a call site is two columns, so
|
|
1136
|
+
// `IN` cannot express it and a placeholder pair per row would carry the page size into D1's
|
|
1137
|
+
// parameter cap.
|
|
1138
|
+
async visibleCallRuns(actor, sites) {
|
|
1139
|
+
if (sites.length === 0)
|
|
1140
|
+
return [];
|
|
1141
|
+
const result = await deps.db
|
|
1142
|
+
.prepare(`${subtreeCte},
|
|
1143
|
+
sites(caller_id, caller_node) AS (
|
|
1144
|
+
SELECT json_extract(site.value, '$.r'), json_extract(site.value, '$.n')
|
|
1145
|
+
FROM json_each(?) site
|
|
1146
|
+
)
|
|
1147
|
+
SELECT run.parent_run_id AS caller_id, run.parent_node_id AS caller_node,
|
|
1148
|
+
run.id AS called_id
|
|
1149
|
+
FROM flow_runs run
|
|
1150
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
1151
|
+
JOIN sites ON sites.caller_id = run.parent_run_id
|
|
1152
|
+
AND sites.caller_node = run.parent_node_id
|
|
1153
|
+
WHERE ${runVisible}`)
|
|
1154
|
+
.bind(...readableBindings(actor), JSON.stringify(sites.map((site) => ({ r: site.runId, n: site.nodeId }))), ...runVisibleBindings(actor))
|
|
1155
|
+
.all();
|
|
1156
|
+
return (result.results ?? []).map((row) => ({
|
|
1157
|
+
runId: row.caller_id,
|
|
1158
|
+
nodeId: row.caller_node,
|
|
1159
|
+
calledRunId: row.called_id,
|
|
1160
|
+
}));
|
|
1161
|
+
},
|
|
1162
|
+
// No ACL here on purpose: the caller has already been answered for the outer run, and the result
|
|
1163
|
+
// of a call belongs to the step that made it. What it is used for is the step's output, never a
|
|
1164
|
+
// route into the called flow — that goes through `getRunVisible` like everything else.
|
|
1165
|
+
async findChildRun(parentRunId, parentNodeId) {
|
|
1166
|
+
const row = await deps.db
|
|
1167
|
+
.prepare(`SELECT ${runColumns} FROM flow_runs
|
|
1168
|
+
WHERE parent_run_id = ? AND parent_node_id = ?`)
|
|
1169
|
+
.bind(parentRunId, parentNodeId)
|
|
1170
|
+
.first();
|
|
1171
|
+
return row ? mapRun(row) : null;
|
|
1172
|
+
},
|
|
1173
|
+
// Both directions in one statement: every caller above the run, and the run each active subflow
|
|
1174
|
+
// step below it started. `UNION` ends both walks even if a repair ever wrote a ring.
|
|
1175
|
+
async runChain(runId) {
|
|
1176
|
+
const result = await deps.db
|
|
1177
|
+
.prepare(`WITH RECURSIVE up(id, parent_run_id) AS (
|
|
1178
|
+
SELECT id, parent_run_id FROM flow_runs WHERE id = ?
|
|
1179
|
+
UNION
|
|
1180
|
+
SELECT parent.id, parent.parent_run_id
|
|
1181
|
+
FROM flow_runs parent JOIN up ON up.parent_run_id = parent.id
|
|
1182
|
+
),
|
|
1183
|
+
down(id, current_node_id) AS (
|
|
1184
|
+
SELECT id, current_node_id FROM flow_runs WHERE id = ?
|
|
1185
|
+
UNION
|
|
1186
|
+
SELECT child.id, child.current_node_id
|
|
1187
|
+
FROM flow_runs child
|
|
1188
|
+
JOIN down ON child.parent_run_id = down.id AND child.parent_node_id = down.current_node_id
|
|
1189
|
+
)
|
|
1190
|
+
SELECT run.id AS run_id, run.flow_id, flow.title AS flow_title, run.version_id,
|
|
1191
|
+
run.parent_run_id, run.current_node_id
|
|
1192
|
+
FROM flow_runs run
|
|
1193
|
+
JOIN flows flow ON flow.id = run.flow_id
|
|
1194
|
+
WHERE run.id IN (SELECT id FROM up) OR run.id IN (SELECT id FROM down)`)
|
|
1195
|
+
.bind(runId, runId)
|
|
1196
|
+
.all();
|
|
1197
|
+
return (result.results ?? []).map((row) => ({
|
|
1198
|
+
runId: row.run_id,
|
|
1199
|
+
flowId: row.flow_id,
|
|
1200
|
+
flowTitle: row.flow_title,
|
|
1201
|
+
versionId: row.version_id,
|
|
1202
|
+
parentRunId: row.parent_run_id,
|
|
1203
|
+
currentNodeId: row.current_node_id,
|
|
1204
|
+
}));
|
|
1205
|
+
},
|
|
1206
|
+
// One write for the run and everything below it. The recursion gathers only non-terminal
|
|
1207
|
+
// descendants, and the root joins the set only while it is itself non-terminal — so a run that
|
|
1208
|
+
// turned terminal between the caller's check and this write cancels nothing at all, and the
|
|
1209
|
+
// missing idempotency row reports the conflict. `flow_run_steps` is deliberately untouched:
|
|
1210
|
+
// cancelling is not a step outcome (#145).
|
|
1211
|
+
async cancelRun(input) {
|
|
1212
|
+
const nonTerminal = "status NOT IN ('completed', 'failed', 'cancelled')";
|
|
1213
|
+
const affected = `WITH RECURSIVE affected(id) AS (
|
|
1214
|
+
SELECT id FROM flow_runs WHERE id = ? AND ${nonTerminal}
|
|
1215
|
+
UNION
|
|
1216
|
+
SELECT child.id FROM flow_runs child
|
|
1217
|
+
JOIN affected ON child.parent_run_id = affected.id
|
|
1218
|
+
WHERE child.${nonTerminal}
|
|
1219
|
+
)`;
|
|
1220
|
+
try {
|
|
1221
|
+
await deps.db.batch([
|
|
1222
|
+
deps.db
|
|
1223
|
+
.prepare(`${affected}
|
|
1224
|
+
UPDATE flow_runs SET status = 'cancelled', current_node_id = NULL,
|
|
1225
|
+
updated_at = ?, completed_at = ?
|
|
1226
|
+
WHERE id IN (SELECT id FROM affected)`)
|
|
1227
|
+
.bind(input.runId, input.occurredAt, input.occurredAt),
|
|
1228
|
+
deps.db
|
|
1229
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1230
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1231
|
+
) SELECT ?, 'flows.cancel', ?, ?, ?
|
|
1232
|
+
WHERE EXISTS (
|
|
1233
|
+
SELECT 1 FROM flow_runs WHERE id = ? AND status = 'cancelled' AND updated_at = ?
|
|
1234
|
+
)`)
|
|
1235
|
+
.bind(input.actorId, input.idempotencyKey, input.runId, input.occurredAt, input.runId, input.occurredAt),
|
|
1236
|
+
deps.db
|
|
1237
|
+
.prepare(`INSERT INTO audit_events (
|
|
1238
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1239
|
+
) SELECT ?, ?, 'flows.cancel', 'flow-run', ?, ?, ?
|
|
1240
|
+
WHERE EXISTS (
|
|
1241
|
+
SELECT 1 FROM idempotency_keys
|
|
1242
|
+
WHERE actor_id = ? AND operation = 'flows.cancel' AND idempotency_key = ?
|
|
1243
|
+
)`)
|
|
1244
|
+
.bind(input.auditId, input.actorId, input.runId, JSON.stringify({ flowId: input.flowId }), input.occurredAt, input.actorId, input.idempotencyKey),
|
|
1245
|
+
]);
|
|
1246
|
+
}
|
|
1247
|
+
catch (error) {
|
|
1248
|
+
if (!(await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey))) {
|
|
1249
|
+
throw error;
|
|
1250
|
+
}
|
|
1251
|
+
}
|
|
1252
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.cancel", input.idempotencyKey);
|
|
1253
|
+
if (replayed !== input.runId)
|
|
1254
|
+
return "conflict";
|
|
1255
|
+
// What this write ended, read back by the timestamp it stamped: the root and its descendants,
|
|
1256
|
+
// narrowed to the rows this very write turned. A child cancelled by an earlier call keeps its
|
|
1257
|
+
// earlier timestamp and stays out, so nothing is signalled twice.
|
|
1258
|
+
const result = await deps.db
|
|
1259
|
+
.prepare(`WITH RECURSIVE affected(id) AS (
|
|
1260
|
+
SELECT id FROM flow_runs WHERE id = ?
|
|
1261
|
+
UNION
|
|
1262
|
+
SELECT child.id FROM flow_runs child JOIN affected ON child.parent_run_id = affected.id
|
|
1263
|
+
)
|
|
1264
|
+
SELECT id FROM flow_runs
|
|
1265
|
+
WHERE id IN (SELECT id FROM affected) AND status = 'cancelled' AND updated_at = ?`)
|
|
1266
|
+
.bind(input.runId, input.occurredAt)
|
|
1267
|
+
.all();
|
|
1268
|
+
return { cancelled: (result.results ?? []).map((row) => row.id) };
|
|
1269
|
+
},
|
|
1270
|
+
async advanceRun(input) {
|
|
1271
|
+
const run = input.run;
|
|
1272
|
+
try {
|
|
1273
|
+
await deps.db.batch([
|
|
1274
|
+
deps.db
|
|
1275
|
+
.prepare(`UPDATE flow_runs SET
|
|
1276
|
+
status = ?, current_node_id = ?, output_json = ?, error = ?,
|
|
1277
|
+
updated_at = ?, completed_at = ?, transition_id = ?
|
|
1278
|
+
WHERE id = ? AND current_node_id = ? AND status IN ('running', 'waiting')`)
|
|
1279
|
+
.bind(run.status, run.currentNodeId, run.output === null ? null : JSON.stringify(run.output), run.error, run.updatedAt, run.completedAt, input.stepId, run.id, input.expectedNodeId),
|
|
1280
|
+
deps.db
|
|
1281
|
+
.prepare(`INSERT INTO flow_run_steps (
|
|
1282
|
+
id, run_id, node_id, outcome, branch, output_json, error, completed_by, completed_at
|
|
1283
|
+
) SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?
|
|
1284
|
+
WHERE EXISTS (
|
|
1285
|
+
SELECT 1 FROM flow_runs WHERE id = ? AND transition_id = ?
|
|
1286
|
+
)`)
|
|
1287
|
+
.bind(input.stepId, run.id, input.expectedNodeId, input.outcome, input.branch, JSON.stringify(input.output), input.error, input.actorId, run.updatedAt, run.id, input.stepId),
|
|
1288
|
+
deps.db
|
|
1289
|
+
.prepare(`INSERT INTO idempotency_keys (
|
|
1290
|
+
actor_id, operation, idempotency_key, resource_id, created_at
|
|
1291
|
+
) SELECT ?, 'flows.complete', ?, ?, ?
|
|
1292
|
+
WHERE EXISTS (SELECT 1 FROM flow_run_steps WHERE id = ?)`)
|
|
1293
|
+
.bind(input.actorId, input.idempotencyKey, run.id, run.updatedAt, input.stepId),
|
|
1294
|
+
deps.db
|
|
1295
|
+
.prepare(`INSERT INTO audit_events (
|
|
1296
|
+
id, actor_id, action, resource_type, resource_id, metadata_json, occurred_at
|
|
1297
|
+
) SELECT ?, ?, 'flows.complete', 'flow-run', ?, ?, ?
|
|
1298
|
+
WHERE EXISTS (SELECT 1 FROM flow_run_steps WHERE id = ?)`)
|
|
1299
|
+
.bind(input.auditId, input.actorId, run.id, JSON.stringify({ nodeId: input.expectedNodeId, outcome: input.outcome }), run.updatedAt, input.stepId),
|
|
1300
|
+
]);
|
|
1301
|
+
}
|
|
1302
|
+
catch (error) {
|
|
1303
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.complete", input.idempotencyKey);
|
|
1304
|
+
if (!replayed)
|
|
1305
|
+
throw error;
|
|
1306
|
+
}
|
|
1307
|
+
const replayed = await this.findIdempotent(input.actorId, "flows.complete", input.idempotencyKey);
|
|
1308
|
+
return replayed === run.id ? "advanced" : "conflict";
|
|
1309
|
+
},
|
|
1310
|
+
};
|
|
1311
|
+
}
|