@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,2216 @@
|
|
|
1
|
+
import { flowNodeLayer } from "../contract/flow.js";
|
|
2
|
+
import { callersPhrase, namedOrCounted } from "../shared/callers/callers.js";
|
|
3
|
+
import { requireFutureExpiry } from "../shared/grant-expiry/grant-expiry.js";
|
|
4
|
+
import { IntelError } from "../shared/intel-error/intel-error.js";
|
|
5
|
+
import { plainTitle } from "../shared/plain-title/plain-title.js";
|
|
6
|
+
function invalid(detail) {
|
|
7
|
+
throw new IntelError(400, "flow_graph_invalid", detail);
|
|
8
|
+
}
|
|
9
|
+
// A call chain deeper than this is a runaway rather than a design. It also bounds the publish-time
|
|
10
|
+
// walk and the run trail, both of which follow data that other writers can change.
|
|
11
|
+
const MaxCallDepth = 20;
|
|
12
|
+
// All four, and unlike a node this needs no lookup: every verb means something on a flow. `read`
|
|
13
|
+
// opens it, `write` edits it, `execute` runs it, `share` passes access on (#530).
|
|
14
|
+
const flowVerbs = ["read", "write", "execute", "share"];
|
|
15
|
+
/**
|
|
16
|
+
* The grantee as the ACL sees them, and as nothing else: an identity with no capability of its own.
|
|
17
|
+
*
|
|
18
|
+
* ⚠️ Never `isAdmin` and never `canRun`. Whether Gate hands this person `intel/admin` or `flows/run`
|
|
19
|
+
* is Gate's to know, so the warning built on this can be pessimistic and never permissive — it may
|
|
20
|
+
* say "they will not be able to reach this" about somebody who turns out to be able to. The other
|
|
21
|
+
* direction would be a warning that stays silent about a real gap.
|
|
22
|
+
*
|
|
23
|
+
* A grant to an email address is judged as that address, which is what the grant will be attached
|
|
24
|
+
* to; a second grant the same person holds under their user id is not folded in.
|
|
25
|
+
*/
|
|
26
|
+
function asPrincipalActor(principal) {
|
|
27
|
+
if (principal.type === "user")
|
|
28
|
+
return { id: principal.id, email: "", canRun: false };
|
|
29
|
+
if (principal.type === "email")
|
|
30
|
+
return { id: "", email: principal.email, canRun: false };
|
|
31
|
+
return { id: "", email: "", canRun: false };
|
|
32
|
+
}
|
|
33
|
+
// The four link kinds that name something in the shared tree (D25). `tool` is a link too, but it
|
|
34
|
+
// names a portal tool rather than a resource, and every rule about reachability applies to these
|
|
35
|
+
// four and not to it.
|
|
36
|
+
export const treeLinkKinds = ["folder", "document", "upload", "table"];
|
|
37
|
+
/**
|
|
38
|
+
* One place reads a graph for each kind of step it contains, and everything else is derived from
|
|
39
|
+
* these three. The publish-time rule, the freeze, the sidebar, the relation graph, the requirements
|
|
40
|
+
* list and the repository's cycle walk all ask about the same nodes; a second `kind === "…"` walk
|
|
41
|
+
* beside them would drift quietly, because both would keep returning something plausible.
|
|
42
|
+
*/
|
|
43
|
+
export function subflowNodes(graph) {
|
|
44
|
+
return graph.nodes.filter((node) => node.kind === "subflow");
|
|
45
|
+
}
|
|
46
|
+
// ⚠️ Kept as one list although it is now four kinds: every caller asks "what does this graph read
|
|
47
|
+
// from the tree", never "which of the four". Splitting the callers to match the split in the schema
|
|
48
|
+
// would multiply four ways at each of the six call sites for no answer anyone wants.
|
|
49
|
+
export function treeLinkNodes(graph) {
|
|
50
|
+
return graph.nodes.filter((node) => treeLinkKinds.includes(node.kind));
|
|
51
|
+
}
|
|
52
|
+
export function toolNodes(graph) {
|
|
53
|
+
return graph.nodes.filter((node) => node.kind === "tool");
|
|
54
|
+
}
|
|
55
|
+
// The one resource a link names. Its own function because four kinds answer it the same way, and a
|
|
56
|
+
// caller that switched on the kind to read the same field would invite a fifth kind to be forgotten.
|
|
57
|
+
export function resourceIdOf(node) {
|
|
58
|
+
return node.configuration.resourceId;
|
|
59
|
+
}
|
|
60
|
+
// The flows a graph calls, in the order the nodes name them and without repetition. `subflowNodes`
|
|
61
|
+
// answers "which calls", this answers "which flows" — a flow called twice with two different version
|
|
62
|
+
// choices is two calls and one callee, and the freeze has to see both.
|
|
63
|
+
export function calleeIds(graph) {
|
|
64
|
+
const ids = [];
|
|
65
|
+
for (const node of subflowNodes(graph)) {
|
|
66
|
+
if (!ids.includes(node.configuration.flowId))
|
|
67
|
+
ids.push(node.configuration.flowId);
|
|
68
|
+
}
|
|
69
|
+
return ids;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* The documents a graph names and the tool SERVERS it reaches for, flattened and without repetition.
|
|
73
|
+
* The requirements list, the publish-time check and the run's first tool check read this one answer,
|
|
74
|
+
* so they cannot disagree about what a flow touches. A caller that needs to know *which step* names
|
|
75
|
+
* a document reads the node lists above instead — the relation graph draws exactly that edge.
|
|
76
|
+
*
|
|
77
|
+
* ⚠️ Servers, not functions, since #489. A step no longer names one function, so "what does this
|
|
78
|
+
* flow need" cannot be answered with a function list any more — and answering it with the functions
|
|
79
|
+
* a step MIGHT call would be a guess about a choice that is made while the flow runs.
|
|
80
|
+
*/
|
|
81
|
+
export function graphReferences(graph) {
|
|
82
|
+
const nodes = [];
|
|
83
|
+
const servers = [];
|
|
84
|
+
for (const node of treeLinkNodes(graph)) {
|
|
85
|
+
if (!nodes.includes(node.configuration.resourceId)) {
|
|
86
|
+
nodes.push(node.configuration.resourceId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
for (const node of toolNodes(graph)) {
|
|
90
|
+
if (!servers.includes(node.configuration.server))
|
|
91
|
+
servers.push(node.configuration.server);
|
|
92
|
+
}
|
|
93
|
+
return { nodes, servers };
|
|
94
|
+
}
|
|
95
|
+
// ⚠️ The reason, in the words the person can act on, and not one word more. How many documents a
|
|
96
|
+
// step cannot reach is something they may know; which ones they are is the very thing the ACL is
|
|
97
|
+
// keeping from them, so the step is named and the documents are only counted (#17, #19).
|
|
98
|
+
function treeLinkStepDetail(label, missing) {
|
|
99
|
+
return missing === 1
|
|
100
|
+
? `One document this step needs is not readable for you: ${label}`
|
|
101
|
+
: `${missing} documents this step needs are not readable for you: ${label}`;
|
|
102
|
+
}
|
|
103
|
+
// ⚠️ For tools this is the only honest moment there is. The catalog is a live tools/list with the
|
|
104
|
+
// requesting user's own token (ADR-0003), so nobody can be told in advance what someone else would
|
|
105
|
+
// see — but the person in front of the failure can be told exactly where to go.
|
|
106
|
+
// ⚠️ Servers, not tools, since #489 — and the sentence says so. A step names a server, so a person
|
|
107
|
+
// told "you do not have access to this tool: notion" would go looking for a function by that name
|
|
108
|
+
// and find none. What they can act on is the server: it is what the portal grants and what they can
|
|
109
|
+
// ask to be granted.
|
|
110
|
+
function toolStepDetail(missing) {
|
|
111
|
+
return missing.length === 1
|
|
112
|
+
? `You do not reach this tool server in the portal: ${missing.join(", ")}`
|
|
113
|
+
: `You do not reach these tool servers in the portal: ${missing.join(", ")}`;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* ⚠️ How every refusal on this file names a node: by the label the canvas draws, because the id
|
|
117
|
+
* appears nowhere on screen. `Node n-doc is a link and must be attached to a step` sent an author
|
|
118
|
+
* looking for a string the editor never draws, and the way to the cause led through the contract
|
|
119
|
+
* schema instead of through the sentence (#508). The id stays beside it: a graph arrives over MCP
|
|
120
|
+
* as readily as from the canvas, and there the id is what the caller holds.
|
|
121
|
+
*
|
|
122
|
+
* ⚠️ One function rather than one spelling per call site (#551): the publish-time refusals name a
|
|
123
|
+
* node too, and two places building the same string by hand is how the two halves of one sentence
|
|
124
|
+
* drift while both keep looking right.
|
|
125
|
+
*/
|
|
126
|
+
function nodeName(node) {
|
|
127
|
+
return `"${node.label}" (${node.id})`;
|
|
128
|
+
}
|
|
129
|
+
export function compileFlow(graph) {
|
|
130
|
+
const nodes = new Map();
|
|
131
|
+
// ⚠️ It stands ABOVE the loop that fills the map on purpose, so the duplicate-id refusal reaches
|
|
132
|
+
// it too — that one names the node the map already holds, which is the FIRST of the two. An id
|
|
133
|
+
// nothing knows falls back to the bare id, which is what the dangling-edge case wants anyway.
|
|
134
|
+
const named = (id) => {
|
|
135
|
+
const node = nodes.get(id);
|
|
136
|
+
return node ? nodeName(node) : id;
|
|
137
|
+
};
|
|
138
|
+
for (const node of graph.nodes) {
|
|
139
|
+
if (nodes.has(node.id))
|
|
140
|
+
invalid(`Duplicate node ID: ${named(node.id)}`);
|
|
141
|
+
nodes.set(node.id, node);
|
|
142
|
+
}
|
|
143
|
+
// ⚠️ Every arity rule below counts flow edges only. A context edge says "this belongs to that
|
|
144
|
+
// step", not "this runs after it" — counted in, it would give its target a second incoming edge
|
|
145
|
+
// and its source a second branch, and every graph carrying context would be rejected (#37).
|
|
146
|
+
const edgeIds = new Set();
|
|
147
|
+
const incoming = new Map();
|
|
148
|
+
const outgoing = new Map();
|
|
149
|
+
const attachments = new Map();
|
|
150
|
+
const attachedTo = new Map();
|
|
151
|
+
for (const edge of graph.edges) {
|
|
152
|
+
if (edgeIds.has(edge.id))
|
|
153
|
+
invalid(`Duplicate edge ID: ${edge.id}`);
|
|
154
|
+
edgeIds.add(edge.id);
|
|
155
|
+
if (!nodes.has(edge.source) || !nodes.has(edge.target))
|
|
156
|
+
invalid(`Dangling edge: ${edge.id}`);
|
|
157
|
+
if (edge.kind === "context") {
|
|
158
|
+
if (edge.source === edge.target)
|
|
159
|
+
invalid(`Context edge ${edge.id} cannot attach a node to itself`);
|
|
160
|
+
// One holder per attached node: two steps claiming the same material would make "what does
|
|
161
|
+
// this step work with" answerable two ways, and the second answer would never be shown.
|
|
162
|
+
if (attachedTo.has(edge.target))
|
|
163
|
+
invalid(`Node ${named(edge.target)} is already attached to ${named(attachedTo.get(edge.target) ?? "")}`);
|
|
164
|
+
attachedTo.set(edge.target, edge.source);
|
|
165
|
+
attachments.set(edge.source, [...(attachments.get(edge.source) ?? []), edge]);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
incoming.set(edge.target, (incoming.get(edge.target) ?? 0) + 1);
|
|
169
|
+
outgoing.set(edge.source, [...(outgoing.get(edge.source) ?? []), edge]);
|
|
170
|
+
}
|
|
171
|
+
const triggers = graph.nodes.filter((node) => node.kind === "trigger");
|
|
172
|
+
if (triggers.length !== 1)
|
|
173
|
+
invalid("A flow requires exactly one trigger");
|
|
174
|
+
if (!graph.nodes.some((node) => node.kind === "output"))
|
|
175
|
+
invalid("A flow requires an output");
|
|
176
|
+
const trigger = triggers[0];
|
|
177
|
+
if (!trigger)
|
|
178
|
+
invalid("A flow requires exactly one trigger");
|
|
179
|
+
// The start is where the flow begins, not material a step works with. Attaching it would take it
|
|
180
|
+
// out of the run of steps below and leave the graph without a beginning.
|
|
181
|
+
if (attachedTo.has(trigger.id))
|
|
182
|
+
invalid("The trigger cannot be attached as context");
|
|
183
|
+
for (const node of graph.nodes) {
|
|
184
|
+
const parents = incoming.get(node.id) ?? 0;
|
|
185
|
+
const children = outgoing.get(node.id) ?? [];
|
|
186
|
+
const layer = flowNodeLayer[node.kind];
|
|
187
|
+
const holderId = attachedTo.get(node.id);
|
|
188
|
+
// ⚠️ The layer rule of D25, and the reason it lives here rather than in the editor: a graph
|
|
189
|
+
// arrives over MCP as readily as from the canvas. A link is what a step works with and is never
|
|
190
|
+
// in the order of work; a marker and a step are the order of work and are never material.
|
|
191
|
+
// Before this, a link could stand in the chain — which is how a start with an attachment once
|
|
192
|
+
// began its run at the attachment (#37).
|
|
193
|
+
if (layer === "link" && holderId === undefined) {
|
|
194
|
+
invalid(`Node ${named(node.id)} is a link and must be attached to a step`);
|
|
195
|
+
}
|
|
196
|
+
if (layer !== "link" && holderId !== undefined) {
|
|
197
|
+
invalid(`Node ${named(node.id)} is not a link and cannot be attached to another node`);
|
|
198
|
+
}
|
|
199
|
+
if (holderId !== undefined) {
|
|
200
|
+
if (parents !== 0 || children.length !== 0) {
|
|
201
|
+
invalid(`Node ${named(node.id)} is attached as context and cannot also be a step`);
|
|
202
|
+
}
|
|
203
|
+
const holder = nodes.get(holderId);
|
|
204
|
+
// Only a step holds material. A marker carries nothing at all, and a `subflow` is a call: the
|
|
205
|
+
// flow it names brings its own links, and lending it one from here would be steering another
|
|
206
|
+
// flow from outside.
|
|
207
|
+
if (holder && (flowNodeLayer[holder.kind] !== "step" || holder.kind === "subflow")) {
|
|
208
|
+
invalid(`Node ${named(node.id)} can only be attached to an instruction or a condition`);
|
|
209
|
+
}
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
if (node.kind === "trigger" && parents !== 0)
|
|
213
|
+
invalid("The trigger cannot have an incoming edge");
|
|
214
|
+
if (node.kind !== "trigger" && parents !== 1) {
|
|
215
|
+
invalid(`Node ${named(node.id)} requires exactly one incoming edge`);
|
|
216
|
+
}
|
|
217
|
+
if (node.kind === "output" && children.length !== 0)
|
|
218
|
+
invalid(`Output ${named(node.id)} must be terminal`);
|
|
219
|
+
if (node.kind === "condition") {
|
|
220
|
+
if (children.length < 2)
|
|
221
|
+
invalid(`Node ${named(node.id)} requires at least two branches`);
|
|
222
|
+
const handles = new Set(children.map((edge) => edge.sourceHandle));
|
|
223
|
+
if (handles.has(null) || handles.size !== children.length) {
|
|
224
|
+
invalid(`Node ${named(node.id)} requires unique branch handles`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
else if (node.kind !== "output") {
|
|
228
|
+
if (children.length !== 1)
|
|
229
|
+
invalid(`Node ${named(node.id)} requires exactly one outgoing edge`);
|
|
230
|
+
if (children[0]?.sourceHandle !== null)
|
|
231
|
+
invalid(`Node ${named(node.id)} cannot define a branch handle`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
const visited = new Set();
|
|
235
|
+
const active = new Set();
|
|
236
|
+
function visit(nodeId) {
|
|
237
|
+
if (active.has(nodeId))
|
|
238
|
+
invalid("Flow graphs cannot contain cycles");
|
|
239
|
+
if (visited.has(nodeId))
|
|
240
|
+
return;
|
|
241
|
+
active.add(nodeId);
|
|
242
|
+
// Only flow edges are walked: the cycle rule is about the order of work, and context is not in
|
|
243
|
+
// it. Attachments are marked visited so reachability still accounts for them, but stepping
|
|
244
|
+
// through one could never come back around — an attached node carries no flow edges at all.
|
|
245
|
+
//
|
|
246
|
+
// ⚠️ Context is one level deep, on purpose. Marking an attachment visited without recursing
|
|
247
|
+
// means material hung off material is unreachable and the graph is refused. A chain of "what
|
|
248
|
+
// this works with, works with…" is a structure nobody asked for, and allowing it would make
|
|
249
|
+
// "what does this step work with" a traversal instead of a lookup.
|
|
250
|
+
for (const edge of outgoing.get(nodeId) ?? [])
|
|
251
|
+
visit(edge.target);
|
|
252
|
+
for (const edge of attachments.get(nodeId) ?? [])
|
|
253
|
+
visited.add(edge.target);
|
|
254
|
+
active.delete(nodeId);
|
|
255
|
+
visited.add(nodeId);
|
|
256
|
+
}
|
|
257
|
+
visit(trigger.id);
|
|
258
|
+
if (visited.size !== graph.nodes.length)
|
|
259
|
+
invalid("Every node must be reachable from the trigger");
|
|
260
|
+
return { graph, triggerId: trigger.id };
|
|
261
|
+
}
|
|
262
|
+
// What a step says when it failed and left no reason behind, and what a call says when the reason
|
|
263
|
+
// belongs to a run the asker may not see. Both are written once and read from two places — the
|
|
264
|
+
// moment a run records, and the moment somebody reads it back — so a list cannot invent a third
|
|
265
|
+
// wording for the same event.
|
|
266
|
+
const StepFailedDetail = "Flow step failed";
|
|
267
|
+
function calledStepDetail(label) {
|
|
268
|
+
return `The called flow did not finish: ${label}`;
|
|
269
|
+
}
|
|
270
|
+
function nodeFor(version, nodeId) {
|
|
271
|
+
return nodeId ? (version.graph.nodes.find((node) => node.id === nodeId) ?? null) : null;
|
|
272
|
+
}
|
|
273
|
+
// Why a run started, read off the graph it ran rather than off a column. A called run says so
|
|
274
|
+
// through `parentRunId`; everything else was started by hand, because `manual` is the only mode a
|
|
275
|
+
// trigger node still has (#39).
|
|
276
|
+
//
|
|
277
|
+
// ⚠️ Still read from the graph rather than shortened to a constant. The graph is where the answer
|
|
278
|
+
// belongs, and if a second way of starting a flow is ever added it is added there — a hard-coded
|
|
279
|
+
// `manual` here would be a lie the day that happens, and nothing would point at this line.
|
|
280
|
+
function triggerOf(version, run) {
|
|
281
|
+
if (run.parentRunId)
|
|
282
|
+
return "subflow";
|
|
283
|
+
const trigger = version.graph.nodes.find((node) => node.kind === "trigger");
|
|
284
|
+
// `compileFlow` insists on exactly one trigger before a version can be saved, so the fallback is
|
|
285
|
+
// unreachable for anything that ever ran — it exists because the type cannot say that.
|
|
286
|
+
return trigger?.configuration.mode ?? "manual";
|
|
287
|
+
}
|
|
288
|
+
// Wall-clock duration of a finished run. A run still going has no answer yet, and inventing "so far"
|
|
289
|
+
// here would be a number that is stale the moment it is read.
|
|
290
|
+
function durationOf(run) {
|
|
291
|
+
if (!run.completedAt)
|
|
292
|
+
return null;
|
|
293
|
+
return Math.max(0, Date.parse(run.completedAt) - Date.parse(run.createdAt));
|
|
294
|
+
}
|
|
295
|
+
// A call site is two identifiers, and a Map wants one key. Written once so the lookup and the fill
|
|
296
|
+
// cannot disagree about how the pair is spelled; `\u0000` cannot occur in either half.
|
|
297
|
+
function callKey(runId, nodeId) {
|
|
298
|
+
return `${runId}\u0000${nodeId}`;
|
|
299
|
+
}
|
|
300
|
+
// What a call step may say to this asker. A called run they may see hands its own text over whole;
|
|
301
|
+
// otherwise the caller's step names itself and nothing else crosses the boundary. A step that
|
|
302
|
+
// recorded no reason keeps none — that is a completed call, not a silent failure.
|
|
303
|
+
function callDetail(calledRunId, label, stored) {
|
|
304
|
+
if (calledRunId)
|
|
305
|
+
return stored;
|
|
306
|
+
return stored === null ? null : calledStepDetail(label);
|
|
307
|
+
}
|
|
308
|
+
// The page marker of the run list. `created_at` alone is not unique — two runs of the same flow can
|
|
309
|
+
// share a millisecond — so the row's ID rides along and the keyset stays exact.
|
|
310
|
+
function encodeCursor(run) {
|
|
311
|
+
return `${run.createdAt}|${run.id}`;
|
|
312
|
+
}
|
|
313
|
+
function decodeCursor(cursor) {
|
|
314
|
+
if (!cursor)
|
|
315
|
+
return null;
|
|
316
|
+
const separator = cursor.indexOf("|");
|
|
317
|
+
if (separator <= 0)
|
|
318
|
+
throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
|
|
319
|
+
const createdAt = cursor.slice(0, separator);
|
|
320
|
+
const id = cursor.slice(separator + 1);
|
|
321
|
+
if (!id)
|
|
322
|
+
throw new IntelError(400, "flow_run_cursor_invalid", "Page cursor is invalid");
|
|
323
|
+
return { createdAt, id };
|
|
324
|
+
}
|
|
325
|
+
// ⚠️ Named where the reader may see it, counted where they may not — the same rule the folder's
|
|
326
|
+
// refusal follows, out of the same place (`callersPhrase`): a refusal must not become a way of
|
|
327
|
+
// learning that a flow one cannot see exists (ADR-0004 §3).
|
|
328
|
+
//
|
|
329
|
+
// ⚠️ The SENTENCE stays its own, and that is deliberate (#593): the folder's says a grant cannot be
|
|
330
|
+
// narrowed yet, this one says a flow cannot be deleted for good. Same facts, different consequence
|
|
331
|
+
// — a single parameterised sentence would save a string and cost each refusal its reason.
|
|
332
|
+
function callersDetail(callers) {
|
|
333
|
+
return `Published flows still call this one: ${callersPhrase(callers)}. Change or unpublish them first.`;
|
|
334
|
+
}
|
|
335
|
+
export function createFlows(deps) {
|
|
336
|
+
async function requireFlow(actor, flowId) {
|
|
337
|
+
const flow = await deps.repository.getVisible(actor, flowId);
|
|
338
|
+
if (!flow || flow.archivedAt)
|
|
339
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
340
|
+
return flow;
|
|
341
|
+
}
|
|
342
|
+
// ⚠️ On the execution path a flow is resolved by `read` or `execute`, never by `read` alone. A
|
|
343
|
+
// library folder carries `execute` for everyone and nothing else (ADR-0004 §2/§3), so insisting on
|
|
344
|
+
// `read` here would turn every library flow into a 404 for exactly the people it exists for — and
|
|
345
|
+
// the call rule in section 3 would have nothing left to permit.
|
|
346
|
+
/**
|
|
347
|
+
* Which of these flows call another one that THIS actor may also see (#59).
|
|
348
|
+
*
|
|
349
|
+
* ⚠️ Per reader, and that is the point: a flow whose only call is hidden from this person has
|
|
350
|
+
* nothing to unfold, so a chevron there would promise what opening cannot deliver. `listCalls`
|
|
351
|
+
* filters the same way — asking the same question here, rather than reading the graph and hoping,
|
|
352
|
+
* is the only way the two cannot drift apart.
|
|
353
|
+
*
|
|
354
|
+
* ⚠️ Two reads for the whole level, never one per flow (#30): the versions come back together and
|
|
355
|
+
* the callees go out together.
|
|
356
|
+
*/
|
|
357
|
+
async function callableCallers(actor, flows) {
|
|
358
|
+
const versionOf = new Map();
|
|
359
|
+
for (const flow of flows) {
|
|
360
|
+
const versionId = flow.currentVersionId ?? flow.publishedVersionId;
|
|
361
|
+
if (versionId)
|
|
362
|
+
versionOf.set(flow.id, versionId);
|
|
363
|
+
}
|
|
364
|
+
if (versionOf.size === 0)
|
|
365
|
+
return [];
|
|
366
|
+
const versions = new Map((await deps.repository.getVersions([...new Set(versionOf.values())])).map((version) => [
|
|
367
|
+
version.id,
|
|
368
|
+
version,
|
|
369
|
+
]));
|
|
370
|
+
const wanted = new Map();
|
|
371
|
+
for (const [flowId, versionId] of versionOf) {
|
|
372
|
+
const version = versions.get(versionId);
|
|
373
|
+
if (version)
|
|
374
|
+
wanted.set(flowId, calleeIds(version.graph));
|
|
375
|
+
}
|
|
376
|
+
const everyCallee = [...new Set([...wanted.values()].flat())];
|
|
377
|
+
if (everyCallee.length === 0)
|
|
378
|
+
return [];
|
|
379
|
+
const reachable = new Set((await deps.repository.listCallable(actor, everyCallee))
|
|
380
|
+
.filter((callee) => !callee.archivedAt)
|
|
381
|
+
.map((callee) => callee.id));
|
|
382
|
+
return [...wanted.entries()]
|
|
383
|
+
.filter(([, callees]) => callees.some((callee) => reachable.has(callee)))
|
|
384
|
+
.map(([flowId]) => flowId);
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* The flow a run already belongs to, as this actor may see it.
|
|
388
|
+
*
|
|
389
|
+
* ⚠️ The same ACL as `requireRunnableFlow` and deliberately without its archived refusal. This is
|
|
390
|
+
* the answer to "what should archiving do to a run in flight" (#112): archiving decides what may
|
|
391
|
+
* be STARTED, never what happens to what is already going. The other reading — refusing here —
|
|
392
|
+
* is what produced the state that ticket was written about: the step was recorded and the caller
|
|
393
|
+
* was told 404, with no way to read the run afterwards to find out otherwise.
|
|
394
|
+
*/
|
|
395
|
+
async function requireStartedFlow(actor, flowId) {
|
|
396
|
+
const flow = await deps.repository.getCallable(actor, flowId);
|
|
397
|
+
if (!flow)
|
|
398
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
399
|
+
return flow;
|
|
400
|
+
}
|
|
401
|
+
// The same flow for something that is about to START — a run, a validation. An archived flow is
|
|
402
|
+
// absent here, which is the whole of what archiving does.
|
|
403
|
+
async function requireRunnableFlow(actor, flowId) {
|
|
404
|
+
const flow = await requireStartedFlow(actor, flowId);
|
|
405
|
+
if (flow.archivedAt)
|
|
406
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
407
|
+
return flow;
|
|
408
|
+
}
|
|
409
|
+
// Every surface maps the Gate capability into the actor, so the rule lives here once instead of
|
|
410
|
+
// being restated per surface. Checked before any storage access.
|
|
411
|
+
function requireRun(actor) {
|
|
412
|
+
if (!actor.canRun) {
|
|
413
|
+
throw new IntelError(403, "flow_run_forbidden", "Flow run permission is required");
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Managing a flow's sharing is `share` on the flow — held directly, passed down by a folder above
|
|
417
|
+
// it, or held by owning it. Asked before any grant row is read or written, so a refusal leaves no
|
|
418
|
+
// trace and tells the caller nothing about what grants exist.
|
|
419
|
+
async function requireShareable(actor, flowId) {
|
|
420
|
+
const flow = await requireFlow(actor, flowId);
|
|
421
|
+
if (!(await deps.repository.can(actor, flow.id, "share"))) {
|
|
422
|
+
throw new IntelError(403, "flow_forbidden", "Sharing of this flow cannot be managed");
|
|
423
|
+
}
|
|
424
|
+
return flow;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* What this grant does NOT reach, described to whoever just made it (ADR-0004 §4).
|
|
428
|
+
*
|
|
429
|
+
* Two halves, and the second is the one a folder grant never needed. A grant on a folder covered
|
|
430
|
+
* every flow beneath it, so the sub-flows were covered with it; a grant on ONE flow stops at that
|
|
431
|
+
* flow, and the flows it calls are then a separate grant somebody has to make.
|
|
432
|
+
*
|
|
433
|
+
* ⚠️ It is a warning and not a refusal, for the reason the node side gives: blocking would force
|
|
434
|
+
* everyone whose flow reads a central policy document to duplicate it. What makes the warning
|
|
435
|
+
* enough — and what makes this whole feature possible at all — is that `validate` answers the same
|
|
436
|
+
* question later, for the person about to run, at the moment they run (#530).
|
|
437
|
+
*
|
|
438
|
+
* ⚠️ Titles only where the SHARER may see them. Whoever holds `share` on one flow must not learn
|
|
439
|
+
* the titles of documents or flows they cannot reach themselves, so the rest is a number. A
|
|
440
|
+
* warning must not become a way of reading the tree (ADR-0004 §3, #17's review).
|
|
441
|
+
*/
|
|
442
|
+
async function withShareWarnings(actor, flow, grant, principal) {
|
|
443
|
+
const grantee = asPrincipalActor(principal);
|
|
444
|
+
// What the published version does is what a grantee will run. A flow with nothing published
|
|
445
|
+
// has nothing to warn about yet, and the draft is not what `execute` would reach.
|
|
446
|
+
const versionId = flow.publishedVersionId;
|
|
447
|
+
const version = versionId ? await deps.repository.getVersion(versionId) : null;
|
|
448
|
+
if (!version) {
|
|
449
|
+
return { grant, unreadable: empty(), unrunnable: empty() };
|
|
450
|
+
}
|
|
451
|
+
const unreadable = empty();
|
|
452
|
+
// Deduplicated: a document two steps both name is one thing the grantee cannot read, not two.
|
|
453
|
+
for (const nodeId of [...new Set(graphReferences(version.graph).nodes)]) {
|
|
454
|
+
if (await deps.visibleNodes(grantee, nodeId))
|
|
455
|
+
continue;
|
|
456
|
+
const node = await deps.visibleNodes(actor, nodeId);
|
|
457
|
+
if (node)
|
|
458
|
+
unreadable.titles.push(node.title);
|
|
459
|
+
else
|
|
460
|
+
unreadable.hidden += 1;
|
|
461
|
+
}
|
|
462
|
+
const unrunnable = empty();
|
|
463
|
+
for (const calleeId of calleeIds(version.graph)) {
|
|
464
|
+
if (await deps.repository.can(grantee, calleeId, "execute"))
|
|
465
|
+
continue;
|
|
466
|
+
const callee = await deps.repository.getVisible(actor, calleeId);
|
|
467
|
+
if (callee)
|
|
468
|
+
unrunnable.titles.push(callee.title);
|
|
469
|
+
else
|
|
470
|
+
unrunnable.hidden += 1;
|
|
471
|
+
}
|
|
472
|
+
return { grant, unreadable, unrunnable };
|
|
473
|
+
}
|
|
474
|
+
function empty() {
|
|
475
|
+
return { titles: [], hidden: 0 };
|
|
476
|
+
}
|
|
477
|
+
async function requireEdit(actor, flowId) {
|
|
478
|
+
const flow = await requireFlow(actor, flowId);
|
|
479
|
+
if (!(await deps.repository.can(actor, flowId, "write"))) {
|
|
480
|
+
throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
|
|
481
|
+
}
|
|
482
|
+
return flow;
|
|
483
|
+
}
|
|
484
|
+
// Seeing a flow and starting it are two grants, not one (ADR-0004 §2): /crm is readable for sales
|
|
485
|
+
// and executable only for billing. This is asked before anything a run would touch — no
|
|
486
|
+
// idempotency lookup, no portal call, no run row — so a refusal leaves no trace behind.
|
|
487
|
+
async function requireExecute(actor, flowId) {
|
|
488
|
+
if (!(await deps.repository.can(actor, flowId, "execute"))) {
|
|
489
|
+
throw new IntelError(403, "flow_execute_forbidden", "Flow execute permission is required");
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Everything that stands between this actor and a run of this flow, gathered rather than thrown.
|
|
494
|
+
*
|
|
495
|
+
* ⚠️ THE reason this function exists rather than a second list beside `start`: two lists drift.
|
|
496
|
+
* They drift silently, and the direction they drift in is the worst one — `validate` keeps saying
|
|
497
|
+
* "it would run" while `start` refuses, which sends the reader looking anywhere but at the
|
|
498
|
+
* problem. `start` calls this and throws on the first entry; `validate` calls it and returns all
|
|
499
|
+
* of them. A check added here is therefore added to both, or to neither.
|
|
500
|
+
*/
|
|
501
|
+
async function collectRunProblems(actor, flow, pinnedVersionId) {
|
|
502
|
+
const problems = [];
|
|
503
|
+
if (!(await deps.repository.can(actor, flow.id, "execute"))) {
|
|
504
|
+
problems.push({
|
|
505
|
+
status: 403,
|
|
506
|
+
code: "flow_execute_forbidden",
|
|
507
|
+
detail: "Flow execute permission is required",
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
// Asked even for a frozen call: a flow whose publication was withdrawn is not something a
|
|
511
|
+
// pinned version may quietly keep running.
|
|
512
|
+
if (!flow.publishedVersionId) {
|
|
513
|
+
problems.push({
|
|
514
|
+
status: 409,
|
|
515
|
+
code: "flow_not_published",
|
|
516
|
+
detail: "Flow has no published version",
|
|
517
|
+
});
|
|
518
|
+
return { problems, version: null, first: null };
|
|
519
|
+
}
|
|
520
|
+
// A frozen call runs its own version; everything else runs what is published now.
|
|
521
|
+
const version = await requireVersion(pinnedVersionId ?? flow.publishedVersionId, flow.id);
|
|
522
|
+
let first = null;
|
|
523
|
+
try {
|
|
524
|
+
const compiled = compileFlow(version.graph);
|
|
525
|
+
// ⚠️ Flow edges only. A start carrying context would otherwise begin the run at the attached
|
|
526
|
+
// node, because that edge can come first in the list (#37).
|
|
527
|
+
first =
|
|
528
|
+
version.graph.edges.find((edge) => edge.kind === "flow" && edge.source === compiled.triggerId)?.target ?? null;
|
|
529
|
+
if (!first)
|
|
530
|
+
invalid("The trigger requires an outgoing edge");
|
|
531
|
+
}
|
|
532
|
+
catch (error) {
|
|
533
|
+
problems.push({
|
|
534
|
+
status: 400,
|
|
535
|
+
code: "flow_graph_invalid",
|
|
536
|
+
detail: error instanceof IntelError ? error.message : "The graph cannot be compiled",
|
|
537
|
+
});
|
|
538
|
+
return { problems, version, first: null };
|
|
539
|
+
}
|
|
540
|
+
// The flow may have been built by someone with wider portal access. Naming the missing tools
|
|
541
|
+
// before the first step beats failing halfway through with a portal error the user cannot
|
|
542
|
+
// place — and the portal is where they can do something about it.
|
|
543
|
+
//
|
|
544
|
+
// ⚠️ ONE entry per missing TOOL, and the unit is the decision (#435). A problem list is a
|
|
545
|
+
// count, so the number of entries has to be the number of things left to do — and what a
|
|
546
|
+
// person does about a missing tool is grant it once in the portal, whether one step reaches
|
|
547
|
+
// for it or six. `graphReferences` already folds the graph down to distinct tool names, which
|
|
548
|
+
// is why a tool hanging off several steps arrives here once. Deliberately NOT one entry per
|
|
549
|
+
// step: the same portal click would then be listed several times, and the reader would go
|
|
550
|
+
// looking for several causes.
|
|
551
|
+
//
|
|
552
|
+
// The one consequence to know about: `start` throws the FIRST entry, so it now names the first
|
|
553
|
+
// missing tool rather than all of them — the same as it has always done for several
|
|
554
|
+
// unpublished sub-flows below. `validate` is what lists every reason at once, and it does.
|
|
555
|
+
for (const server of await deps.unavailableServers(actor, graphReferences(version.graph).servers)) {
|
|
556
|
+
problems.push({
|
|
557
|
+
status: 403,
|
|
558
|
+
code: "flow_tools_unavailable",
|
|
559
|
+
detail: toolStepDetail([server]),
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
try {
|
|
563
|
+
// ⚠️ Tree links only. The loop above has already asked about every tool in the graph — the
|
|
564
|
+
// first step's included — so the tool half of `requireNodeAuthorized` would answer a second
|
|
565
|
+
// time about the same names, which is the duplicate #435 reported. Every place that actually
|
|
566
|
+
// hands a step over still calls the whole of `requireNodeAuthorized`.
|
|
567
|
+
await requireTreeLinksAuthorized(actor, nodeFor(version, first), version.graph);
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (!(error instanceof IntelError))
|
|
571
|
+
throw error;
|
|
572
|
+
problems.push({ status: error.status, code: error.code, detail: error.message });
|
|
573
|
+
}
|
|
574
|
+
// Every flow this one calls has to be published, or the call fails halfway through a run rather
|
|
575
|
+
// than before it. `publish` refuses this at freeze time; a flow whose callee was un-published
|
|
576
|
+
// afterwards is the case only a check on demand can catch.
|
|
577
|
+
for (const node of subflowNodes(version.graph)) {
|
|
578
|
+
const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
|
|
579
|
+
if (!callee || callee.archivedAt) {
|
|
580
|
+
problems.push({
|
|
581
|
+
status: 409,
|
|
582
|
+
code: "flow_subflow_unavailable",
|
|
583
|
+
detail: `The called flow is not available: ${node.label}`,
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
else if (!callee.publishedVersionId) {
|
|
587
|
+
problems.push({
|
|
588
|
+
status: 409,
|
|
589
|
+
code: "flow_subflow_not_published",
|
|
590
|
+
detail: `The called flow has no published version: ${node.label}`,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { problems, version, first };
|
|
595
|
+
}
|
|
596
|
+
// A flow's parent is a folder of the tree, so the answer comes from the node service rather than from a
|
|
597
|
+
// second permission model here. `null` is the root and needs no permission of its own — the same
|
|
598
|
+
// as creating a folder at the root does.
|
|
599
|
+
async function requireFolder(actor, parentId) {
|
|
600
|
+
if (parentId === null)
|
|
601
|
+
return;
|
|
602
|
+
const access = await deps.folderAccess(actor, parentId);
|
|
603
|
+
if (access === "missing") {
|
|
604
|
+
throw new IntelError(404, "flow_parent_not_found", "Parent folder was not found");
|
|
605
|
+
}
|
|
606
|
+
if (access === "not-a-folder") {
|
|
607
|
+
throw new IntelError(409, "parent_not_folder", "A flow's parent must be a folder");
|
|
608
|
+
}
|
|
609
|
+
if (access === "forbidden") {
|
|
610
|
+
throw new IntelError(403, "node_forbidden", "Destination folder cannot be edited");
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
async function requireVersion(versionId, flowId) {
|
|
614
|
+
const version = await deps.repository.getVersion(versionId);
|
|
615
|
+
if (!version || (flowId && version.flowId !== flowId)) {
|
|
616
|
+
throw new IntelError(404, "flow_version_not_found", "Flow version was not found");
|
|
617
|
+
}
|
|
618
|
+
return version;
|
|
619
|
+
}
|
|
620
|
+
// Which step of which flow is running, outermost caller first. Built from the stored chain rather
|
|
621
|
+
// than from the run at hand, so a caller looking at the outer run sees the step inside the called
|
|
622
|
+
// flow, and a caller looking at the called run sees who called it.
|
|
623
|
+
async function trailFor(run, known) {
|
|
624
|
+
const chain = await deps.repository.runChain(run.id);
|
|
625
|
+
const byId = new Map(chain.map((entry) => [entry.runId, entry]));
|
|
626
|
+
const child = new Map();
|
|
627
|
+
for (const entry of chain) {
|
|
628
|
+
if (entry.parentRunId && byId.has(entry.parentRunId))
|
|
629
|
+
child.set(entry.parentRunId, entry.runId);
|
|
630
|
+
}
|
|
631
|
+
let head = chain.find((entry) => !entry.parentRunId || !byId.has(entry.parentRunId));
|
|
632
|
+
const ordered = [];
|
|
633
|
+
while (head && ordered.length < MaxCallDepth) {
|
|
634
|
+
ordered.push(head);
|
|
635
|
+
const next = child.get(head.runId);
|
|
636
|
+
head = next ? byId.get(next) : undefined;
|
|
637
|
+
}
|
|
638
|
+
// ⚠️ The versions in one read, after the walk rather than inside it (#30). This function sits on
|
|
639
|
+
// the path every step of every run takes, so a query per level was a cost paid per step and
|
|
640
|
+
// growing with the depth of the call chain — invisible at two levels, linear at twenty. The
|
|
641
|
+
// version already in hand is not asked for again, and a label whose version is missing stays
|
|
642
|
+
// `null` exactly as it did.
|
|
643
|
+
const versions = new Map([[known.id, known]]);
|
|
644
|
+
const missing = [...new Set(ordered.map((entry) => entry.versionId))].filter((versionId) => !versions.has(versionId));
|
|
645
|
+
for (const version of await deps.repository.getVersions(missing)) {
|
|
646
|
+
versions.set(version.id, version);
|
|
647
|
+
}
|
|
648
|
+
return ordered.map((entry) => {
|
|
649
|
+
const version = versions.get(entry.versionId);
|
|
650
|
+
return {
|
|
651
|
+
runId: entry.runId,
|
|
652
|
+
flowId: entry.flowId,
|
|
653
|
+
flowTitle: entry.flowTitle,
|
|
654
|
+
nodeId: entry.currentNodeId,
|
|
655
|
+
nodeLabel: version ? (nodeFor(version, entry.currentNodeId)?.label ?? null) : null,
|
|
656
|
+
};
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
// The documents of a list this actor may reach, named. Distinct IDs only, so a reference written
|
|
660
|
+
// twice is one document; an ID that is missing from the answer is one they cannot reach.
|
|
661
|
+
//
|
|
662
|
+
// ⚠️ It is `deps.visibleNodes` and nothing else, which is what keeps the authorization check,
|
|
663
|
+
// the requirements list and the relation graph on one rule. Only `id` and `title` travel onwards:
|
|
664
|
+
// the node also carries `parentId`, `ownerId` and its version, and where a document sits in the
|
|
665
|
+
// tree is not part of the question "what does this flow need" (#17, #19).
|
|
666
|
+
async function reachableNodes(actor, resourceIds) {
|
|
667
|
+
const reachable = [];
|
|
668
|
+
for (const resourceId of new Set(resourceIds)) {
|
|
669
|
+
const node = await deps.visibleNodes(actor, resourceId);
|
|
670
|
+
if (node)
|
|
671
|
+
reachable.push({ id: node.id, title: node.title });
|
|
672
|
+
}
|
|
673
|
+
return reachable;
|
|
674
|
+
}
|
|
675
|
+
// ⚠️ ADR-0004 §4, and the reason a subflow is not a way around anything. Before a node or
|
|
676
|
+
// Tool step is handed to whoever will carry it out — first run, retry and resume alike — the
|
|
677
|
+
// current Gate identity and the resource ACL are asked again. A called flow's steps come through
|
|
678
|
+
// this same function, so a document the user may not read stays unreadable however deep the call
|
|
679
|
+
// sits, and a flow grant keeps protecting the procedure rather than the data.
|
|
680
|
+
// ⚠️ Reads what hangs off the step, not the step itself (D25). A document or a tool is a link on a
|
|
681
|
+
// `context` edge now, and a run never stands on one — so asking "is this node a tree link"
|
|
682
|
+
// would ask about a node the run can no longer reach, and every check here would silently pass.
|
|
683
|
+
async function requireNodeAuthorized(actor, node, graph) {
|
|
684
|
+
await requireTreeLinksAuthorized(actor, node, graph);
|
|
685
|
+
await requireToolsAuthorized(actor, node, graph);
|
|
686
|
+
}
|
|
687
|
+
// The material of one step: what hangs off it on a `context` edge, as nodes.
|
|
688
|
+
function attachedNodes(node, graph) {
|
|
689
|
+
return graph.edges
|
|
690
|
+
.filter((edge) => edge.kind === "context" && edge.source === node.id)
|
|
691
|
+
.flatMap((edge) => graph.nodes.filter((candidate) => candidate.id === edge.target));
|
|
692
|
+
}
|
|
693
|
+
// ⚠️ The two halves are separate functions only because `collectRunProblems` needs the first one
|
|
694
|
+
// without the second (#435): every tool of the whole graph is already asked about there, so
|
|
695
|
+
// asking again for the first step reported the same tool twice. `requireNodeAuthorized` above is
|
|
696
|
+
// what every execution boundary calls, and it is still both halves — splitting the CHECK would
|
|
697
|
+
// be the drift this file spends a page warning about; splitting the FUNCTION is not.
|
|
698
|
+
async function requireTreeLinksAuthorized(actor, node, graph) {
|
|
699
|
+
if (!node)
|
|
700
|
+
return;
|
|
701
|
+
const attached = attachedNodes(node, graph);
|
|
702
|
+
const wanted = [...new Set(treeLinkNodes({ ...graph, nodes: attached }).map(resourceIdOf))];
|
|
703
|
+
if (!wanted.length)
|
|
704
|
+
return;
|
|
705
|
+
const reachable = await reachableNodes(actor, wanted);
|
|
706
|
+
if (reachable.length !== wanted.length) {
|
|
707
|
+
throw new IntelError(403, "flow_node_forbidden", treeLinkStepDetail(node.label, wanted.length - reachable.length));
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
async function requireToolsAuthorized(actor, node, graph) {
|
|
711
|
+
if (!node)
|
|
712
|
+
return;
|
|
713
|
+
const servers = attachedNodes(node, graph)
|
|
714
|
+
.filter((candidate) => candidate.kind === "tool")
|
|
715
|
+
.map((candidate) => candidate.configuration.server);
|
|
716
|
+
if (!servers.length)
|
|
717
|
+
return;
|
|
718
|
+
const missing = await deps.unavailableServers(actor, [...new Set(servers)]);
|
|
719
|
+
if (missing.length) {
|
|
720
|
+
throw new IntelError(403, "flow_tools_unavailable", toolStepDetail(missing));
|
|
721
|
+
}
|
|
722
|
+
}
|
|
723
|
+
/**
|
|
724
|
+
* ⚠️ The boundary this whole slice turns on. A sub-flow step's reason for failing was written
|
|
725
|
+
* inside the *called* run, and that run is a run of its own with its own authorization — a library
|
|
726
|
+
* flow carries `execute` for everyone and `read` for nobody (ADR-0004 §3), so somebody who may
|
|
727
|
+
* read the calling flow can easily have no claim at all on what happened inside the called one.
|
|
728
|
+
*
|
|
729
|
+
* So the call sites are asked through the very predicate that opens a single run, and never
|
|
730
|
+
* through a second rule written for display. A site that comes back is one whose called run this
|
|
731
|
+
* actor may see: the stored text then travels and the called run is named so the reader can
|
|
732
|
+
* follow it. A site that stays out is reported by the *caller's* own label — the caller's to give
|
|
733
|
+
* — with a text that says no more than that the call did not finish. Named what they may see,
|
|
734
|
+
* counted the rest (#17, #19, #20).
|
|
735
|
+
*
|
|
736
|
+
* A whole page of sites in one read (#30): a list that asked per row would put two queries on
|
|
737
|
+
* every failed line, which is exactly the shape that ticket removed everywhere else.
|
|
738
|
+
*/
|
|
739
|
+
async function visibleCalls(actor, sites) {
|
|
740
|
+
if (sites.length === 0)
|
|
741
|
+
return new Map();
|
|
742
|
+
const found = await deps.repository.visibleCallRuns(actor, sites);
|
|
743
|
+
return new Map(found.map((call) => [callKey(call.runId, call.nodeId), call.calledRunId]));
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* What a run looks like right now: the run as this actor may read it, the node it stands on, and
|
|
747
|
+
* the trail it belongs to. Data only — it asks nothing.
|
|
748
|
+
*
|
|
749
|
+
* ⚠️ Split out of `step` because of the order this used to be in (#112). `completeStep` writes
|
|
750
|
+
* the step and then shapes its answer, and shaping used to authorize a second time — so a check
|
|
751
|
+
* that failed AFTER the write told the caller "404" about work that had happened, and the retry
|
|
752
|
+
* replayed into the same 404 forever. Whoever just completed a step may be told what became of
|
|
753
|
+
* it. Whether they may run the NEXT one is asked when they run it, by the call that runs it.
|
|
754
|
+
*
|
|
755
|
+
* `narrowed` stays: it decides how much of a failure's text this reader may see, which is
|
|
756
|
+
* disclosure and not permission.
|
|
757
|
+
*/
|
|
758
|
+
// ⚠️ The version is passed in rather than looked up. Both callers already hold it, and reading it
|
|
759
|
+
// again here cost one extra round trip per answer — which the query-count test caught (#30).
|
|
760
|
+
async function runState(actor, run, version) {
|
|
761
|
+
const node = nodeFor(version, run.currentNodeId);
|
|
762
|
+
return { run: await narrowed(actor, run, version), node, trail: await trailFor(run, version) };
|
|
763
|
+
}
|
|
764
|
+
// The same picture for somebody ASKING for it rather than having just acted: the flow has to
|
|
765
|
+
// resolve and the node they would be handed has to be one they may execute.
|
|
766
|
+
async function step(actor, run) {
|
|
767
|
+
await requireStartedFlow(actor, run.flowId);
|
|
768
|
+
const version = await requireVersion(run.versionId, run.flowId);
|
|
769
|
+
await requireNodeAuthorized(actor, nodeFor(version, run.currentNodeId), version.graph);
|
|
770
|
+
return await runState(actor, run, version);
|
|
771
|
+
}
|
|
772
|
+
// A failed run carries the text of the step that failed, and for a call that text came out of
|
|
773
|
+
// another run. It is narrowed here rather than at the door it entered through: what storage keeps
|
|
774
|
+
// is what happened, and who may read it is a question about the person asking, not about the row.
|
|
775
|
+
//
|
|
776
|
+
// Only a failed run pays for this, and then two reads at most — a run that is still going leaves
|
|
777
|
+
// before the first one.
|
|
778
|
+
async function narrowed(actor, run, version) {
|
|
779
|
+
if (run.status !== "failed" || !run.error)
|
|
780
|
+
return run;
|
|
781
|
+
const failed = (await deps.repository.failedSteps([run.id]))[0];
|
|
782
|
+
const node = failed ? nodeFor(version, failed.nodeId) : null;
|
|
783
|
+
if (!failed || node?.kind !== "subflow")
|
|
784
|
+
return run;
|
|
785
|
+
const calls = await visibleCalls(actor, [{ runId: run.id, nodeId: failed.nodeId }]);
|
|
786
|
+
const detail = callDetail(calls.get(callKey(run.id, failed.nodeId)), node.label, run.error);
|
|
787
|
+
return detail === run.error ? run : { ...run, error: detail };
|
|
788
|
+
}
|
|
789
|
+
// ADR-0004 §3, checked where it can still be answered honestly: at publish time, naming the reason
|
|
790
|
+
// rather than the error. A call is allowed into the caller's own folder or below it, or into a
|
|
791
|
+
// folder whose `execute` reaches at least as far as the caller's own — in practice `execute` for
|
|
792
|
+
// everyone, the library.
|
|
793
|
+
async function requireCallRule(actor, flow, graph) {
|
|
794
|
+
for (const calleeId of calleeIds(graph)) {
|
|
795
|
+
const callee = await deps.repository.getCallable(actor, calleeId);
|
|
796
|
+
if (!callee || callee.archivedAt) {
|
|
797
|
+
throw new IntelError(409, "flow_subflow_unavailable", `Called flow is unavailable: ${calleeId}`);
|
|
798
|
+
}
|
|
799
|
+
if (callee.id === flow.id)
|
|
800
|
+
continue;
|
|
801
|
+
if ((await deps.repository.callReach(flow.parentId, callee.parentId)) === "out-of-reach") {
|
|
802
|
+
throw new IntelError(409, "flow_subflow_out_of_reach", `${flow.title} may not call ${callee.title}: it is neither in ${flow.title}'s folder nor below it, and its folder is not executable for everyone. Move it into the calling flow's subtree, or share its folder's execute with the organization.`);
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
// Calls point downwards, so a cycle cannot form inside one subtree. It can come back over the
|
|
807
|
+
// library edge, and that is what this walks — once, at publish time, naming the whole chain
|
|
808
|
+
// instead of the one edge that closed it.
|
|
809
|
+
async function requireNoCallCycle(flow, graph) {
|
|
810
|
+
const titles = new Map([[flow.id, flow.title]]);
|
|
811
|
+
const settled = new Set();
|
|
812
|
+
const named = (ids) => ids.map((id) => titles.get(id) ?? id).join(" → ");
|
|
813
|
+
async function walk(callees, path) {
|
|
814
|
+
for (const calleeId of callees) {
|
|
815
|
+
const closes = path.indexOf(calleeId);
|
|
816
|
+
if (closes !== -1) {
|
|
817
|
+
throw new IntelError(409, "flow_subflow_cycle", `Publishing would close a call cycle: ${named([...path.slice(closes), calleeId])}`);
|
|
818
|
+
}
|
|
819
|
+
if (settled.has(calleeId))
|
|
820
|
+
continue;
|
|
821
|
+
const next = await deps.repository.publishedCallees(calleeId);
|
|
822
|
+
settled.add(calleeId);
|
|
823
|
+
if (!next)
|
|
824
|
+
continue;
|
|
825
|
+
titles.set(calleeId, next.title);
|
|
826
|
+
if (path.length >= MaxCallDepth) {
|
|
827
|
+
throw new IntelError(409, "flow_subflow_too_deep", `Calls are nested more than ${MaxCallDepth} deep: ${named([...path, calleeId])}`);
|
|
828
|
+
}
|
|
829
|
+
await walk(next.calleeIds, [...path, calleeId]);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
await walk(calleeIds(graph), [flow.id]);
|
|
833
|
+
}
|
|
834
|
+
// What each call of a graph will take, as the publishing author may see it (ADR-0004 §5). One
|
|
835
|
+
// reader for the preview and the freeze alike: if the screen computed this and publishing computed
|
|
836
|
+
// it again, the two would drift and the author would have agreed to something else than what
|
|
837
|
+
// happened.
|
|
838
|
+
//
|
|
839
|
+
// ⚠️ A callee this actor cannot reach is left out rather than described. The preview is a list of
|
|
840
|
+
// titles, and a title is exactly what an unreachable flow must not hand out — the same mistake the
|
|
841
|
+
// review of #17 found in an error message.
|
|
842
|
+
async function calls(actor, graph) {
|
|
843
|
+
const seen = new Map();
|
|
844
|
+
const items = [];
|
|
845
|
+
for (const node of subflowNodes(graph)) {
|
|
846
|
+
const calleeId = node.configuration.flowId;
|
|
847
|
+
if (!seen.has(calleeId)) {
|
|
848
|
+
const found = await deps.repository.getCallable(actor, calleeId);
|
|
849
|
+
seen.set(calleeId, found && !found.archivedAt ? found : null);
|
|
850
|
+
}
|
|
851
|
+
const callee = seen.get(calleeId) ?? null;
|
|
852
|
+
if (!callee)
|
|
853
|
+
continue;
|
|
854
|
+
const selection = node.configuration.version;
|
|
855
|
+
const pinned = selection.mode === "pinned" ? selection.versionId : (callee.publishedVersionId ?? null);
|
|
856
|
+
// ⚠️ Resolved *against the callee*, never by identifier alone. `getVersion` returns whatever
|
|
857
|
+
// version carries that ID, whichever flow it belongs to — so a pin naming a foreign version
|
|
858
|
+
// would have its sequence reported here, a fact about a flow the reader was never shown, and
|
|
859
|
+
// the very same pin would then be frozen into the published graph. This is the check the
|
|
860
|
+
// freeze makes, made in the same words, so the two cannot drift apart.
|
|
861
|
+
const version = pinned ? await requireVersion(pinned, calleeId) : null;
|
|
862
|
+
items.push({
|
|
863
|
+
nodeId: node.id,
|
|
864
|
+
nodeLabel: node.label,
|
|
865
|
+
calleeId,
|
|
866
|
+
calleeTitle: callee.title,
|
|
867
|
+
mode: selection.mode,
|
|
868
|
+
// A call that follows has no answer until it runs, and saying "version 4" here would be a
|
|
869
|
+
// promise the next publication of the callee breaks.
|
|
870
|
+
versionId: selection.mode === "follows" ? null : pinned,
|
|
871
|
+
versionSequence: selection.mode === "follows" ? null : (version?.sequence ?? null),
|
|
872
|
+
freezes: selection.mode === "latest",
|
|
873
|
+
available: Boolean(callee.publishedVersionId),
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
return items;
|
|
877
|
+
}
|
|
878
|
+
// ⚠️ ADR-0004 §5, and the reason a published flow keeps doing what it did. `latest` is replaced by
|
|
879
|
+
// the callee's published version here and nowhere else; without it, changing a building block
|
|
880
|
+
// would silently change every flow that uses it. `follows` is left exactly as it is — it was
|
|
881
|
+
// chosen on purpose, and overwriting it would take that choice away again.
|
|
882
|
+
//
|
|
883
|
+
// Versions are immutable, so this cannot rewrite the one being published: it returns the frozen
|
|
884
|
+
// graph and the caller appends it as a new version.
|
|
885
|
+
async function freeze(actor, graph) {
|
|
886
|
+
let changed = false;
|
|
887
|
+
const nodes = [];
|
|
888
|
+
for (const node of graph.nodes) {
|
|
889
|
+
// A tool step is frozen the same way a sub-flow call is: what it may reach is pinned at the
|
|
890
|
+
// moment of publishing (#489). The check above has already refused an unreachable server and
|
|
891
|
+
// a surface that moved since a previous publish, so this only writes down what it confirmed.
|
|
892
|
+
if (node.kind === "tool") {
|
|
893
|
+
const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
|
|
894
|
+
if (fingerprint !== null && fingerprint !== node.configuration.fingerprint) {
|
|
895
|
+
nodes.push({ ...node, configuration: { ...node.configuration, fingerprint } });
|
|
896
|
+
changed = true;
|
|
897
|
+
continue;
|
|
898
|
+
}
|
|
899
|
+
nodes.push(node);
|
|
900
|
+
continue;
|
|
901
|
+
}
|
|
902
|
+
if (node.kind !== "subflow") {
|
|
903
|
+
nodes.push(node);
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const selection = node.configuration.version;
|
|
907
|
+
if (selection.mode === "pinned") {
|
|
908
|
+
// A pin is only worth anything while the version behind it is still there. Publishing a
|
|
909
|
+
// dangling pin would produce a flow that fails at its first call instead of at publish time.
|
|
910
|
+
await requireVersion(selection.versionId, node.configuration.flowId);
|
|
911
|
+
nodes.push(node);
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
// A call that rides along is left untouched, unpublished callee included: it resolves at run
|
|
915
|
+
// time by definition, and refusing here would forbid building a caller before its building
|
|
916
|
+
// block is finished — which is not what choosing "always latest" asked for.
|
|
917
|
+
if (selection.mode === "follows") {
|
|
918
|
+
nodes.push(node);
|
|
919
|
+
continue;
|
|
920
|
+
}
|
|
921
|
+
const callee = await deps.repository.getCallable(actor, node.configuration.flowId);
|
|
922
|
+
if (!callee?.publishedVersionId) {
|
|
923
|
+
// There is nothing to freeze to. Refused here, where the reason is readable, rather than at
|
|
924
|
+
// the first run of the call, where it would arrive as someone else's flow failing.
|
|
925
|
+
throw new IntelError(409, "flow_subflow_not_published", `The called flow has nothing published to freeze to: ${node.label}`);
|
|
926
|
+
}
|
|
927
|
+
changed = true;
|
|
928
|
+
nodes.push({
|
|
929
|
+
...node,
|
|
930
|
+
configuration: {
|
|
931
|
+
...node.configuration,
|
|
932
|
+
version: { mode: "pinned", versionId: callee.publishedVersionId },
|
|
933
|
+
},
|
|
934
|
+
});
|
|
935
|
+
}
|
|
936
|
+
return changed ? { ...graph, nodes } : null;
|
|
937
|
+
}
|
|
938
|
+
// The place a call is made from. It names a step, never a permission: the callee's `execute` was
|
|
939
|
+
// already asked of the user, and the caller's is asked again here, so a grant revoked while the
|
|
940
|
+
// outer run waited cannot be walked around by starting the inner one.
|
|
941
|
+
async function requireCallSite(actor, parent, calleeId) {
|
|
942
|
+
const run = await deps.repository.getRunVisible(actor, parent.runId);
|
|
943
|
+
if (!run)
|
|
944
|
+
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
945
|
+
if (run.initiatedBy !== actor.id) {
|
|
946
|
+
throw new IntelError(403, "flow_call_site_forbidden", "The calling run belongs to someone else");
|
|
947
|
+
}
|
|
948
|
+
await requireExecute(actor, run.flowId);
|
|
949
|
+
if (run.status !== "running") {
|
|
950
|
+
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
951
|
+
}
|
|
952
|
+
if (run.currentNodeId !== parent.nodeId) {
|
|
953
|
+
throw new IntelError(409, "flow_step_conflict", "A different step is currently active");
|
|
954
|
+
}
|
|
955
|
+
const version = await requireVersion(run.versionId, run.flowId);
|
|
956
|
+
const node = nodeFor(version, parent.nodeId);
|
|
957
|
+
if (node?.kind !== "subflow" || node.configuration.flowId !== calleeId) {
|
|
958
|
+
throw new IntelError(409, "flow_call_site_invalid", "That step does not call this flow");
|
|
959
|
+
}
|
|
960
|
+
// ⚠️ The version the call takes comes from the calling graph, which is immutable, and never from
|
|
961
|
+
// the request. A frozen call runs what it was frozen to however often the callee is published
|
|
962
|
+
// afterwards; a call that follows resolves to whatever is published at this moment, and the run
|
|
963
|
+
// row records which of the two it turned out to be (ADR-0004 §5).
|
|
964
|
+
const selection = node.configuration.version;
|
|
965
|
+
return { run, versionId: selection.mode === "pinned" ? selection.versionId : null };
|
|
966
|
+
}
|
|
967
|
+
return {
|
|
968
|
+
async list(actor, input = {}) {
|
|
969
|
+
const items = await deps.repository.listVisible(actor, input);
|
|
970
|
+
return { items, withCalls: await callableCallers(actor, items) };
|
|
971
|
+
},
|
|
972
|
+
async get(actor, flowId) {
|
|
973
|
+
const flow = await requireFlow(actor, flowId);
|
|
974
|
+
return {
|
|
975
|
+
flow,
|
|
976
|
+
version: flow.currentVersionId
|
|
977
|
+
? await requireVersion(flow.currentVersionId, flow.id)
|
|
978
|
+
: null,
|
|
979
|
+
};
|
|
980
|
+
},
|
|
981
|
+
// The history of one flow, oldest first (#144). `requireFlow`, the same door as `get`: the
|
|
982
|
+
// history describes a flow this actor may already open, and it holds nothing a reader of the
|
|
983
|
+
// current graph could not learn — only when each edit happened and which one is live.
|
|
984
|
+
async listVersions(actor, flowId) {
|
|
985
|
+
const flow = await requireFlow(actor, flowId);
|
|
986
|
+
const items = await deps.repository.listVersions(flow.id);
|
|
987
|
+
return {
|
|
988
|
+
flowId: flow.id,
|
|
989
|
+
// Marked here, off the flow row already in hand: `published_version_id` is the flow's one
|
|
990
|
+
// answer, and asking storage to join it in would be a second place that could disagree.
|
|
991
|
+
items: items.map((version) => ({
|
|
992
|
+
...version,
|
|
993
|
+
published: version.id === flow.publishedVersionId,
|
|
994
|
+
})),
|
|
995
|
+
};
|
|
996
|
+
},
|
|
997
|
+
// One version with its graph (#144). The flow resolves first, so a version ID belonging to a
|
|
998
|
+
// flow this actor may not see answers exactly like one that does not exist — `requireVersion`
|
|
999
|
+
// already refuses a version filed under another flow, in the same words.
|
|
1000
|
+
async getVersion(actor, input) {
|
|
1001
|
+
await requireFlow(actor, input.flowId);
|
|
1002
|
+
return await requireVersion(input.versionId, input.flowId);
|
|
1003
|
+
},
|
|
1004
|
+
// What accesses what, for the WHOLE subtree a folder holds (#825; one level until then, #19).
|
|
1005
|
+
// Flows answers it because the edges live in flow graphs; the node service answers which nodes
|
|
1006
|
+
// exist and who may see them, because the tree and its ACLs are the node service's (ADR-0004 §1).
|
|
1007
|
+
//
|
|
1008
|
+
// ⚠️ Every node passes an authorization before it is drawn, and a node that fails it is left out
|
|
1009
|
+
// entirely — no placeholder, no count, no edge. An edge to a grey box would already say that
|
|
1010
|
+
// something is there and that this flow touches it, which is the whole of what was meant to stay
|
|
1011
|
+
// hidden. `omitted` counts what the size limit cut, and nothing else.
|
|
1012
|
+
async relationGraph(actor, input) {
|
|
1013
|
+
const nodes = new Map();
|
|
1014
|
+
const edges = [];
|
|
1015
|
+
const dropped = new Set();
|
|
1016
|
+
const nodesSeen = new Map();
|
|
1017
|
+
const flowSeen = new Map();
|
|
1018
|
+
// Every folder actually walked, and every id its own bounded read actually returned. The two
|
|
1019
|
+
// together tell a later `place()` apart from a genuinely new omission: a node whose folder WAS
|
|
1020
|
+
// walked but who never came back within THAT folder's own bound is already inside `unread`,
|
|
1021
|
+
// and counting it again the moment a `reads`/`calls` edge reaches for it would make `omitted`
|
|
1022
|
+
// drift away from the number of nodes actually left out (#30). This generalises what used to
|
|
1023
|
+
// be a single `levelFolderId`/`levelIds` pair to the whole subtree the walk now covers (#825).
|
|
1024
|
+
const visitedFolderIds = new Set();
|
|
1025
|
+
const readIds = new Set();
|
|
1026
|
+
let unread = 0;
|
|
1027
|
+
// ⚠️ The radial tree's own structure (#825): a node reached again — first as a satellite of
|
|
1028
|
+
// some other flow's `reads` edge, later as a real member of the walked subtree — keeps the
|
|
1029
|
+
// TREE's placement, never the satellite's. `inScope` is what tells the two apart; depth and
|
|
1030
|
+
// parent travel with it. `realParentId` is bookkeeping only, never drawn: it is the node's
|
|
1031
|
+
// actual place in the tree, which for a satellite is not the same thing as the `null` parent
|
|
1032
|
+
// in its own `RelationNode` (see `seenNode`).
|
|
1033
|
+
function place(node, realParentId) {
|
|
1034
|
+
const existing = nodes.get(node.id);
|
|
1035
|
+
if (existing) {
|
|
1036
|
+
if (node.inScope && !existing.inScope)
|
|
1037
|
+
nodes.set(node.id, node);
|
|
1038
|
+
return true;
|
|
1039
|
+
}
|
|
1040
|
+
if (nodes.size >= input.limit) {
|
|
1041
|
+
if (!(visitedFolderIds.has(realParentId) && !readIds.has(node.id))) {
|
|
1042
|
+
dropped.add(node.id);
|
|
1043
|
+
}
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
nodes.set(node.id, node);
|
|
1047
|
+
return true;
|
|
1048
|
+
}
|
|
1049
|
+
// ⚠️ Archived is decided here rather than behind the port, beside the identical rule
|
|
1050
|
+
// `calleeFlow` applies one function down. The port answers one question — may this actor see
|
|
1051
|
+
// it — because the run path asks it too, and a document being archived must not start
|
|
1052
|
+
// refusing steps under a message that names the wrong reason. What a drawing leaves out and
|
|
1053
|
+
// what a run refuses are two decisions; only the first belongs to #19.
|
|
1054
|
+
//
|
|
1055
|
+
// ⚠️ A node reached only this way is never part of the tree (#825): its own `RelationNode`
|
|
1056
|
+
// carries no parent and borrows the depth of whichever flow pointed at it, so the renderer
|
|
1057
|
+
// draws it as a satellite on a dashed `reads` edge rather than inventing a membership arc for
|
|
1058
|
+
// it. `parentId` in the RETURNED pair is the node's real parent all the same — `place` still
|
|
1059
|
+
// needs it to tell a genuinely new omission from one `unread` already counted.
|
|
1060
|
+
async function seenNode(nodeId, atDepth) {
|
|
1061
|
+
if (!nodesSeen.has(nodeId)) {
|
|
1062
|
+
const found = await deps.visibleNodes(actor, nodeId);
|
|
1063
|
+
nodesSeen.set(nodeId, found && !found.archivedAt
|
|
1064
|
+
? {
|
|
1065
|
+
node: {
|
|
1066
|
+
id: found.id,
|
|
1067
|
+
kind: found.kind,
|
|
1068
|
+
title: found.title,
|
|
1069
|
+
inScope: false,
|
|
1070
|
+
parentId: null,
|
|
1071
|
+
depth: atDepth,
|
|
1072
|
+
},
|
|
1073
|
+
parentId: found.parentId,
|
|
1074
|
+
}
|
|
1075
|
+
: null);
|
|
1076
|
+
}
|
|
1077
|
+
return nodesSeen.get(nodeId) ?? null;
|
|
1078
|
+
}
|
|
1079
|
+
// The rows are already in hand — every callee of the subtree was read in one statement below —
|
|
1080
|
+
// so this is the archived rule and nothing else.
|
|
1081
|
+
function calleeFlow(flowId) {
|
|
1082
|
+
const found = flowSeen.get(flowId);
|
|
1083
|
+
return found && !found.archivedAt ? found : null;
|
|
1084
|
+
}
|
|
1085
|
+
// The subtree itself first, so a big folder spends its budget on its own contents rather than
|
|
1086
|
+
// on whatever the first flow in it happens to reach.
|
|
1087
|
+
//
|
|
1088
|
+
// ⚠️ `limit` bounds what is *read* as well as what is drawn (#30). Both reads stop at it, and
|
|
1089
|
+
// what they stopped short of comes back as a count instead of as rows. The order inside those
|
|
1090
|
+
// statements is the part that must not move: the visibility predicate first and the bound
|
|
1091
|
+
// after it, so the cut falls among the rows this actor may see — a bound applied first would
|
|
1092
|
+
// let a row they may never see take up a place in the picture, and the number below would then
|
|
1093
|
+
// be saying that it is there.
|
|
1094
|
+
const scopeFlows = [];
|
|
1095
|
+
if (input.scope.of === "flow") {
|
|
1096
|
+
const flow = await requireFlow(actor, input.scope.flowId);
|
|
1097
|
+
place({ id: flow.id, kind: "flow", title: flow.title, inScope: true, parentId: null, depth: 0 }, null);
|
|
1098
|
+
scopeFlows.push(flow);
|
|
1099
|
+
}
|
|
1100
|
+
else {
|
|
1101
|
+
const rootFolderId = input.scope.folderId;
|
|
1102
|
+
// The folder itself sits at the centre (depth 0). `null` names the whole tree, which has no
|
|
1103
|
+
// single folder to put there — its top-level items become the first ring instead, exactly as
|
|
1104
|
+
// a real root's children would (#825).
|
|
1105
|
+
if (rootFolderId !== null) {
|
|
1106
|
+
const root = await deps.visibleNodes(actor, rootFolderId);
|
|
1107
|
+
if (root && !root.archivedAt) {
|
|
1108
|
+
place({
|
|
1109
|
+
id: root.id,
|
|
1110
|
+
kind: root.kind,
|
|
1111
|
+
title: root.title,
|
|
1112
|
+
inScope: true,
|
|
1113
|
+
parentId: null,
|
|
1114
|
+
depth: 0,
|
|
1115
|
+
}, root.parentId);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
// Breadth-first over the whole subtree, shallowest folders first, so the budget above is
|
|
1119
|
+
// spent close to the root rather than on whichever branch happens to be walked first. A new
|
|
1120
|
+
// folder is queued only while there is still room for what it might hold; one already queued
|
|
1121
|
+
// is still visited in full, so what it could not fit is counted rather than silently dropped.
|
|
1122
|
+
const queue = [
|
|
1123
|
+
{ folderId: rootFolderId, childDepth: 1 },
|
|
1124
|
+
];
|
|
1125
|
+
while (queue.length > 0) {
|
|
1126
|
+
const next = queue.shift();
|
|
1127
|
+
if (!next)
|
|
1128
|
+
break;
|
|
1129
|
+
const { folderId, childDepth } = next;
|
|
1130
|
+
visitedFolderIds.add(folderId);
|
|
1131
|
+
const children = await deps.nodeChildren(actor, folderId, input.limit);
|
|
1132
|
+
unread += children.total - children.items.length;
|
|
1133
|
+
for (const item of children.items) {
|
|
1134
|
+
readIds.add(item.id);
|
|
1135
|
+
const kept = place({
|
|
1136
|
+
id: item.id,
|
|
1137
|
+
kind: item.kind,
|
|
1138
|
+
title: item.title,
|
|
1139
|
+
inScope: true,
|
|
1140
|
+
parentId: folderId,
|
|
1141
|
+
depth: childDepth,
|
|
1142
|
+
}, folderId);
|
|
1143
|
+
if (kept && item.kind === "folder" && nodes.size < input.limit) {
|
|
1144
|
+
queue.push({ folderId: item.id, childDepth: childDepth + 1 });
|
|
1145
|
+
}
|
|
1146
|
+
}
|
|
1147
|
+
const filed = await deps.repository.listVisibleBounded(actor, folderId, input.limit);
|
|
1148
|
+
unread += filed.total - filed.items.length;
|
|
1149
|
+
for (const flow of filed.items) {
|
|
1150
|
+
readIds.add(flow.id);
|
|
1151
|
+
if (place({
|
|
1152
|
+
id: flow.id,
|
|
1153
|
+
kind: "flow",
|
|
1154
|
+
title: flow.title,
|
|
1155
|
+
inScope: true,
|
|
1156
|
+
parentId: folderId,
|
|
1157
|
+
depth: childDepth,
|
|
1158
|
+
}, folderId)) {
|
|
1159
|
+
scopeFlows.push(flow);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
// The draft is what the author is looking at; the published graph is what a flow without one
|
|
1165
|
+
// still does. Reading neither would leave a flow in the picture with no edges at all.
|
|
1166
|
+
const versions = new Map((await deps.repository.getVersions([
|
|
1167
|
+
...new Set(scopeFlows
|
|
1168
|
+
.map((flow) => flow.currentVersionId ?? flow.publishedVersionId)
|
|
1169
|
+
.filter((versionId) => versionId !== null)),
|
|
1170
|
+
])).map((version) => [version.id, version]));
|
|
1171
|
+
const drawable = scopeFlows.flatMap((flow) => {
|
|
1172
|
+
const version = versions.get(flow.currentVersionId ?? flow.publishedVersionId ?? "");
|
|
1173
|
+
return version ? [{ flow, version }] : [];
|
|
1174
|
+
});
|
|
1175
|
+
// One read for the versions of the subtree and one for everything it calls, instead of a pair
|
|
1176
|
+
// per flow (#30). Both are bounded by the subtree, which is bounded by `limit`. Nothing below
|
|
1177
|
+
// changes because of it: the walk is the same walk in the same order, over rows already in
|
|
1178
|
+
// hand, so what is drawn and what is cut are what they were.
|
|
1179
|
+
for (const callee of await deps.repository.listCallable(actor, [
|
|
1180
|
+
...new Set(drawable.flatMap(({ version }) => subflowNodes(version.graph).map((node) => node.configuration.flowId))),
|
|
1181
|
+
])) {
|
|
1182
|
+
flowSeen.set(callee.id, callee);
|
|
1183
|
+
}
|
|
1184
|
+
for (const { flow, version } of drawable) {
|
|
1185
|
+
// Already placed above — this only borrows its depth for whatever it reaches or calls.
|
|
1186
|
+
const referrerDepth = nodes.get(flow.id)?.depth ?? 0;
|
|
1187
|
+
// The node lists rather than a walk of `graph.nodes` with two `kind` tests: an edge has to
|
|
1188
|
+
// name the step it comes from, so this is the one caller that needs the nodes themselves
|
|
1189
|
+
// and not the flattened `graphReferences`.
|
|
1190
|
+
for (const node of treeLinkNodes(version.graph)) {
|
|
1191
|
+
const resourceId = resourceIdOf(node);
|
|
1192
|
+
const target = await seenNode(resourceId, referrerDepth);
|
|
1193
|
+
if (!target || !place(target.node, target.parentId))
|
|
1194
|
+
continue;
|
|
1195
|
+
edges.push({
|
|
1196
|
+
id: `reads:${flow.id}:${node.id}:${resourceId}`,
|
|
1197
|
+
source: flow.id,
|
|
1198
|
+
target: resourceId,
|
|
1199
|
+
relation: "reads",
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
for (const node of subflowNodes(version.graph)) {
|
|
1203
|
+
const callee = calleeFlow(node.configuration.flowId);
|
|
1204
|
+
if (!callee ||
|
|
1205
|
+
!place({
|
|
1206
|
+
id: callee.id,
|
|
1207
|
+
kind: "flow",
|
|
1208
|
+
title: callee.title,
|
|
1209
|
+
inScope: false,
|
|
1210
|
+
parentId: null,
|
|
1211
|
+
depth: referrerDepth,
|
|
1212
|
+
}, callee.parentId)) {
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1215
|
+
edges.push({
|
|
1216
|
+
id: `calls:${flow.id}:${node.id}:${callee.id}`,
|
|
1217
|
+
source: flow.id,
|
|
1218
|
+
target: callee.id,
|
|
1219
|
+
relation: "calls",
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
// The outer ring (#825). Only a node the viewer holds `share` on gets an access edge at all —
|
|
1224
|
+
// `node_effective_access_list` refuses without it, and this graph must not hand out, node by
|
|
1225
|
+
// node, an answer that tool refuses right next to it. Bounded to the three rings the renderer
|
|
1226
|
+
// still draws as their own circles (depth 0 to 2, "drei Ringe offen"): the fourth ring on is
|
|
1227
|
+
// bundled into one labelled sector (#825), which has nowhere for a line to land. `flow` nodes
|
|
1228
|
+
// are skipped — sharing a flow and sharing a node are two different tables, and this reads only
|
|
1229
|
+
// the second.
|
|
1230
|
+
const users = new Map();
|
|
1231
|
+
const access = [];
|
|
1232
|
+
if (deps.nodeShareable && deps.nodeEffectiveAccess) {
|
|
1233
|
+
const nodeShareable = deps.nodeShareable;
|
|
1234
|
+
const nodeEffectiveAccess = deps.nodeEffectiveAccess;
|
|
1235
|
+
const candidates = [...nodes.values()].filter((node) => node.kind !== "flow" && node.depth <= 2);
|
|
1236
|
+
await Promise.all(candidates.map(async (node) => {
|
|
1237
|
+
if (!(await nodeShareable(actor, node.id)))
|
|
1238
|
+
return;
|
|
1239
|
+
const effective = await nodeEffectiveAccess(node.id);
|
|
1240
|
+
const userIds = new Set([
|
|
1241
|
+
...effective.ownerIds,
|
|
1242
|
+
...effective.items
|
|
1243
|
+
.map((grant) => grant.principal)
|
|
1244
|
+
.filter((principal) => principal.type === "user")
|
|
1245
|
+
.map((principal) => principal.id),
|
|
1246
|
+
]);
|
|
1247
|
+
for (const userId of userIds) {
|
|
1248
|
+
users.set(userId, { id: userId });
|
|
1249
|
+
access.push({ userId, nodeId: node.id });
|
|
1250
|
+
}
|
|
1251
|
+
}));
|
|
1252
|
+
}
|
|
1253
|
+
return {
|
|
1254
|
+
scope: input.scope,
|
|
1255
|
+
nodes: [...nodes.values()],
|
|
1256
|
+
edges,
|
|
1257
|
+
users: [...users.values()],
|
|
1258
|
+
access,
|
|
1259
|
+
// What did not fit and what was never read for lack of room: two ways of being left out,
|
|
1260
|
+
// one number, and neither of them a permission (#19).
|
|
1261
|
+
omitted: dropped.size + unread,
|
|
1262
|
+
limit: input.limit,
|
|
1263
|
+
};
|
|
1264
|
+
},
|
|
1265
|
+
// Read out of the graph, never out of `parent_id`: what a flow calls is what it does, and where
|
|
1266
|
+
// it is filed is only where it is filed. A flow used by three callers is listed under all three.
|
|
1267
|
+
async listCalls(actor, flowId) {
|
|
1268
|
+
const flow = await requireFlow(actor, flowId);
|
|
1269
|
+
const versionId = flow.currentVersionId ?? flow.publishedVersionId;
|
|
1270
|
+
if (!versionId)
|
|
1271
|
+
return { items: [], withCalls: [] };
|
|
1272
|
+
const version = await requireVersion(versionId, flow.id);
|
|
1273
|
+
const wanted = calleeIds(version.graph);
|
|
1274
|
+
// One read for every callee rather than one per callee (#30). This is what the sidebar asks
|
|
1275
|
+
// when a flow is unfolded, so it runs while somebody is only looking around.
|
|
1276
|
+
//
|
|
1277
|
+
// ⚠️ The order stays the graph's, restored here from the storage layer's. A call list answers
|
|
1278
|
+
// "what do I run, and how", and that is the order the steps stand in; a callee this actor may
|
|
1279
|
+
// neither open nor run is absent from the answer, exactly as it was.
|
|
1280
|
+
const callable = new Map((await deps.repository.listCallable(actor, wanted)).map((callee) => [callee.id, callee]));
|
|
1281
|
+
const items = [];
|
|
1282
|
+
for (const calleeId of wanted) {
|
|
1283
|
+
const callee = callable.get(calleeId);
|
|
1284
|
+
if (callee && !callee.archivedAt)
|
|
1285
|
+
items.push(callee);
|
|
1286
|
+
}
|
|
1287
|
+
// A called flow calls flows of its own: the same question, one level deeper (#59).
|
|
1288
|
+
return { items, withCalls: await callableCallers(actor, items) };
|
|
1289
|
+
},
|
|
1290
|
+
// "What this flow needs", straight out of the graph: no arithmetic, nothing that can go stale,
|
|
1291
|
+
// and no claim about whether anyone may reach it. A standing "this flow has conflicts" badge
|
|
1292
|
+
// would be wrong for tools by construction — the catalog is a live query with the requesting
|
|
1293
|
+
// user's own token (ADR-0003) — and out of date for documents most of the time.
|
|
1294
|
+
/**
|
|
1295
|
+
* Would this flow start, for the person asking, right now? It answers and changes nothing: no
|
|
1296
|
+
* run row, no idempotency key, no audit event (#72).
|
|
1297
|
+
*
|
|
1298
|
+
* ⚠️ `read` is enough, deliberately. Somebody who may see a flow but not run it is exactly who
|
|
1299
|
+
* needs this — "you are missing execute" is the answer they came for, and refusing to answer
|
|
1300
|
+
* would leave them guessing at the one thing that is easy to say. Nothing is named here that a
|
|
1301
|
+
* reader could not already read out of the graph.
|
|
1302
|
+
*
|
|
1303
|
+
* ⚠️ Not cached and not stored anywhere. The tool catalog is a live query with the asking
|
|
1304
|
+
* user's own token (ADR-0003); a kept answer would be a claim about a moment that has passed.
|
|
1305
|
+
*/
|
|
1306
|
+
async validate(actor, flowId) {
|
|
1307
|
+
const flow = await requireRunnableFlow(actor, flowId);
|
|
1308
|
+
const { problems, version } = await collectRunProblems(actor, flow, null);
|
|
1309
|
+
return {
|
|
1310
|
+
flowId: flow.id,
|
|
1311
|
+
versionId: version?.id ?? null,
|
|
1312
|
+
problems: problems.map(({ code, detail }) => ({ code, detail })),
|
|
1313
|
+
checkedAt: deps.now().toISOString(),
|
|
1314
|
+
};
|
|
1315
|
+
},
|
|
1316
|
+
async listRequirements(actor, flowId) {
|
|
1317
|
+
const flow = await requireFlow(actor, flowId);
|
|
1318
|
+
// The draft is what the editor is looking at; a flow with only a published version has
|
|
1319
|
+
// nothing else to describe.
|
|
1320
|
+
const versionId = flow.currentVersionId ?? flow.publishedVersionId;
|
|
1321
|
+
if (!versionId) {
|
|
1322
|
+
return {
|
|
1323
|
+
flowId: flow.id,
|
|
1324
|
+
versionId: null,
|
|
1325
|
+
nodes: [],
|
|
1326
|
+
invalidNodes: [],
|
|
1327
|
+
hiddenNodes: 0,
|
|
1328
|
+
servers: [],
|
|
1329
|
+
};
|
|
1330
|
+
}
|
|
1331
|
+
const version = await requireVersion(versionId, flow.id);
|
|
1332
|
+
const referenced = graphReferences(version.graph);
|
|
1333
|
+
// ⚠️ The same lookup a tree link passes through, not a second rule written for a list.
|
|
1334
|
+
// What it hands back is named; a title is exactly what someone without access may not learn
|
|
1335
|
+
// (#17, #19). Tool names are not filtered: they come from a graph this actor may already
|
|
1336
|
+
// read, and whether the portal offers them is a question only their own token can answer.
|
|
1337
|
+
const reachable = await reachableNodes(actor, referenced.nodes);
|
|
1338
|
+
const named = new Set(reachable.map((reference) => reference.id));
|
|
1339
|
+
const unreachable = referenced.nodes.filter((resourceId) => !named.has(resourceId));
|
|
1340
|
+
// ⚠️ What is left over used to be ONE number, and the panel read it out as "you cannot see
|
|
1341
|
+
// it" — a sentence about a permission, in front of a link that names nothing at all (#509).
|
|
1342
|
+
// The two suggest opposite actions and only one of them helps: with a permission one asks for
|
|
1343
|
+
// access, with a dead reference the step has to be replaced before the flow can run.
|
|
1344
|
+
//
|
|
1345
|
+
// ⚠️ The second question is asked ONLY about what the visibility door already refused, and it
|
|
1346
|
+
// is asked without an actor because its answer does not depend on one. That is the narrowest
|
|
1347
|
+
// shape this distinction can have — the alternative, a lookup that is kinder than the door it
|
|
1348
|
+
// describes, is what #17 and #19 were sent back for. It is still a disclosure and it is a
|
|
1349
|
+
// deliberate one (#492): for an id the caller wrote into this graph themselves, they learn
|
|
1350
|
+
// whether the tree still holds a row for it.
|
|
1351
|
+
const alive = new Set(await deps.repository.existingNodes(unreachable));
|
|
1352
|
+
const invalidNodes = unreachable.filter((resourceId) => !alive.has(resourceId));
|
|
1353
|
+
return {
|
|
1354
|
+
flowId: flow.id,
|
|
1355
|
+
versionId: version.id,
|
|
1356
|
+
nodes: reachable,
|
|
1357
|
+
invalidNodes,
|
|
1358
|
+
hiddenNodes: unreachable.length - invalidNodes.length,
|
|
1359
|
+
servers: referenced.servers,
|
|
1360
|
+
};
|
|
1361
|
+
},
|
|
1362
|
+
async listGrants(actor, flowId) {
|
|
1363
|
+
const flow = await requireShareable(actor, flowId);
|
|
1364
|
+
return {
|
|
1365
|
+
resourceId: flow.id,
|
|
1366
|
+
applicableVerbs: flowVerbs,
|
|
1367
|
+
items: await deps.repository.listFlowGrants(flow.id),
|
|
1368
|
+
};
|
|
1369
|
+
},
|
|
1370
|
+
async listEffectiveAccess(actor, flowId) {
|
|
1371
|
+
const flow = await requireShareable(actor, flowId);
|
|
1372
|
+
const effective = await deps.repository.listEffectiveFlowAccess(flow.id);
|
|
1373
|
+
// ⚠️ **Empty here, and that is a gap rather than a decision** (anchrd/intel#807). A flow
|
|
1374
|
+
// inherits its grants from the FOLDERS above it, and naming one means reading a node title
|
|
1375
|
+
// through the node repository's visibility predicate — which this service deliberately does
|
|
1376
|
+
// not hold (`FlowRepository` knows flows). The screen falls back to saying the access is
|
|
1377
|
+
// inherited without saying from where, which is the same thing it says for a folder the
|
|
1378
|
+
// reader may not see, so nothing here reads as broken.
|
|
1379
|
+
return { resourceId: flow.id, ...effective, sources: [] };
|
|
1380
|
+
},
|
|
1381
|
+
async share(actor, input) {
|
|
1382
|
+
const flow = await requireShareable(actor, input.flowId);
|
|
1383
|
+
const replayedId = await deps.repository.findIdempotent(actor.id, "flows.share", input.idempotencyKey);
|
|
1384
|
+
const principal = input.principal.type === "email"
|
|
1385
|
+
? { type: "email", email: input.principal.email.toLowerCase() }
|
|
1386
|
+
: input.principal;
|
|
1387
|
+
if (replayedId) {
|
|
1388
|
+
const replayed = (await deps.repository.listFlowGrants(flow.id)).find((grant) => grant.id === replayedId);
|
|
1389
|
+
// A replay describes the same access the first attempt did, so the warning is asked again
|
|
1390
|
+
// rather than remembered: what the grantee can reach may have changed since.
|
|
1391
|
+
if (replayed)
|
|
1392
|
+
return await withShareWarnings(actor, flow, replayed, replayed.principal);
|
|
1393
|
+
}
|
|
1394
|
+
// ⚠️ The same rule as on a node grant, in the same place and for the same two reasons (#442):
|
|
1395
|
+
// in front of `setFlowGrant` so nothing that could never work is written, behind the replay so
|
|
1396
|
+
// a retry with the same key still answers with the grant its first attempt made. The long
|
|
1397
|
+
// form of the argument stands at the node grant in `nodes.ts`.
|
|
1398
|
+
requireFutureExpiry(input.expiresAt, deps.now());
|
|
1399
|
+
const timestamp = deps.now().toISOString();
|
|
1400
|
+
const grant = await deps.repository.setFlowGrant({
|
|
1401
|
+
grant: {
|
|
1402
|
+
id: deps.id(),
|
|
1403
|
+
resourceId: flow.id,
|
|
1404
|
+
principal,
|
|
1405
|
+
verb: input.verb,
|
|
1406
|
+
expiresAt: input.expiresAt,
|
|
1407
|
+
createdBy: actor.id,
|
|
1408
|
+
createdAt: timestamp,
|
|
1409
|
+
},
|
|
1410
|
+
flowId: flow.id,
|
|
1411
|
+
actorId: actor.id,
|
|
1412
|
+
idempotencyKey: input.idempotencyKey,
|
|
1413
|
+
auditId: deps.id(),
|
|
1414
|
+
});
|
|
1415
|
+
// ⚠️ After the grant is written, never before. The answer describes the access now in force,
|
|
1416
|
+
// and a warning computed a moment earlier would still list what this very grant just opened.
|
|
1417
|
+
return await withShareWarnings(actor, flow, grant, principal);
|
|
1418
|
+
},
|
|
1419
|
+
async revokeGrant(actor, input) {
|
|
1420
|
+
const flow = await requireShareable(actor, input.flowId);
|
|
1421
|
+
const replayed = await deps.repository.findIdempotentFlowRevocation(actor.id, input.idempotencyKey);
|
|
1422
|
+
if (replayed !== null)
|
|
1423
|
+
return { revoked: replayed };
|
|
1424
|
+
return {
|
|
1425
|
+
revoked: await deps.repository.revokeFlowGrant({
|
|
1426
|
+
flowId: flow.id,
|
|
1427
|
+
grantId: input.grantId,
|
|
1428
|
+
actorId: actor.id,
|
|
1429
|
+
idempotencyKey: input.idempotencyKey,
|
|
1430
|
+
auditId: deps.id(),
|
|
1431
|
+
occurredAt: deps.now().toISOString(),
|
|
1432
|
+
}),
|
|
1433
|
+
};
|
|
1434
|
+
},
|
|
1435
|
+
async create(actor, input) {
|
|
1436
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.create", input.idempotencyKey);
|
|
1437
|
+
if (replayed)
|
|
1438
|
+
return await requireFlow(actor, replayed);
|
|
1439
|
+
await requireFolder(actor, input.parentId);
|
|
1440
|
+
const occurredAt = deps.now().toISOString();
|
|
1441
|
+
return await deps.repository.insertFlow({
|
|
1442
|
+
flow: {
|
|
1443
|
+
// A flow is offered at `flow_publish` and never at creation: publishing is the decision
|
|
1444
|
+
// that it is for other people, and an unpublished flow is in nobody's catalogue (#775).
|
|
1445
|
+
promptName: null,
|
|
1446
|
+
id: deps.id(),
|
|
1447
|
+
parentId: input.parentId,
|
|
1448
|
+
title: plainTitle(input.title),
|
|
1449
|
+
description: input.description,
|
|
1450
|
+
ownerId: actor.id,
|
|
1451
|
+
currentVersionId: null,
|
|
1452
|
+
publishedVersionId: null,
|
|
1453
|
+
createdAt: occurredAt,
|
|
1454
|
+
updatedAt: occurredAt,
|
|
1455
|
+
archivedAt: null,
|
|
1456
|
+
},
|
|
1457
|
+
actorId: actor.id,
|
|
1458
|
+
idempotencyKey: input.idempotencyKey,
|
|
1459
|
+
auditId: deps.id(),
|
|
1460
|
+
});
|
|
1461
|
+
},
|
|
1462
|
+
// Rename and move. It reads and writes the flow row alone: no version is appended, no published
|
|
1463
|
+
// version is touched and no run is signalled, so tidying up the tree cannot change what a flow
|
|
1464
|
+
// does (ADR-0004).
|
|
1465
|
+
async update(actor, input) {
|
|
1466
|
+
const current = await requireEdit(actor, input.flowId);
|
|
1467
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.update", input.idempotencyKey);
|
|
1468
|
+
if (replayed)
|
|
1469
|
+
return await requireFlow(actor, replayed);
|
|
1470
|
+
if (input.parentId !== undefined)
|
|
1471
|
+
await requireFolder(actor, input.parentId);
|
|
1472
|
+
const updated = await deps.repository.updateFlow({
|
|
1473
|
+
flow: {
|
|
1474
|
+
...current,
|
|
1475
|
+
parentId: input.parentId === undefined ? current.parentId : input.parentId,
|
|
1476
|
+
title: input.title === undefined ? current.title : plainTitle(input.title),
|
|
1477
|
+
description: input.description === undefined ? current.description : input.description,
|
|
1478
|
+
updatedAt: deps.now().toISOString(),
|
|
1479
|
+
},
|
|
1480
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
1481
|
+
actorId: actor.id,
|
|
1482
|
+
idempotencyKey: input.idempotencyKey,
|
|
1483
|
+
auditId: deps.id(),
|
|
1484
|
+
});
|
|
1485
|
+
if (updated === "conflict") {
|
|
1486
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
1487
|
+
}
|
|
1488
|
+
return updated;
|
|
1489
|
+
},
|
|
1490
|
+
/**
|
|
1491
|
+
* ⚠️ The flow half of #457 — after this a flow is really gone, with its versions and the record
|
|
1492
|
+
* of every run it ever had. Read the decisions on that issue before touching it.
|
|
1493
|
+
*/
|
|
1494
|
+
async purge(actor, input) {
|
|
1495
|
+
const current = await deps.repository.getVisible(actor, input.flowId);
|
|
1496
|
+
if (!current)
|
|
1497
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1498
|
+
if (!(await deps.repository.can(actor, input.flowId, "write"))) {
|
|
1499
|
+
throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
|
|
1500
|
+
}
|
|
1501
|
+
// ⚠️ THE structural guard, same as the node's: a living flow has no path into nothing.
|
|
1502
|
+
if (!current.archivedAt) {
|
|
1503
|
+
throw new IntelError(409, "flow_not_archived", "Only an archived flow can be deleted for good. Archive it first.");
|
|
1504
|
+
}
|
|
1505
|
+
// A published caller would break at RUN TIME, in front of somebody who did not order this.
|
|
1506
|
+
// ⚠️ The callers travel as DATA beside the sentence (#593, the second half of #448): the
|
|
1507
|
+
// archive shows this refusal, and a German or Spanish screen could otherwise do nothing but
|
|
1508
|
+
// print the English one. The sentence stays for a model over MCP, which has nothing to
|
|
1509
|
+
// formulate with.
|
|
1510
|
+
const callers = await deps.repository.flowCallers(actor, current.id);
|
|
1511
|
+
if (callers.visible.length || callers.hidden) {
|
|
1512
|
+
throw new IntelError(409, "flow_in_use_by_flow", callersDetail(callers), namedOrCounted(callers));
|
|
1513
|
+
}
|
|
1514
|
+
// ⚠️ The one thing a flow has that a node does not: its runs can be the PARENT of runs
|
|
1515
|
+
// belonging to other flows. Deleting them would take the record of who started those runs
|
|
1516
|
+
// with it — a history with a hole is not a history, so this refuses instead.
|
|
1517
|
+
if (await deps.repository.hasEntangledRuns(current.id)) {
|
|
1518
|
+
throw new IntelError(409, "flow_runs_entangled", "Runs of this flow are tied to runs of other flows. Deleting them would leave those without the record of what started them, or without the run they are waiting for.");
|
|
1519
|
+
}
|
|
1520
|
+
// ⚠️ Archiving deliberately leaves a run in flight alone, because restoring the flow makes it
|
|
1521
|
+
// readable again. A purge takes that way back with it — so an unfinished run refuses.
|
|
1522
|
+
if (await deps.repository.hasUnfinishedRuns(current.id)) {
|
|
1523
|
+
throw new IntelError(409, "flow_runs_unfinished", "This flow still has runs that have not finished. Wait for them or cancel them first.");
|
|
1524
|
+
}
|
|
1525
|
+
const purged = await deps.repository.purgeFlow({
|
|
1526
|
+
flowId: current.id,
|
|
1527
|
+
actorId: actor.id,
|
|
1528
|
+
auditId: deps.id(),
|
|
1529
|
+
occurredAt: deps.now().toISOString(),
|
|
1530
|
+
});
|
|
1531
|
+
if (purged === "missing") {
|
|
1532
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
1533
|
+
}
|
|
1534
|
+
return { purged: true, title: current.title };
|
|
1535
|
+
},
|
|
1536
|
+
/**
|
|
1537
|
+
* Archive a flow, or take it back out again.
|
|
1538
|
+
*
|
|
1539
|
+
* ⚠️ The one place that resolves the flow through `getVisible` instead of `requireFlow`. Every
|
|
1540
|
+
* other entry point treats an archived flow as absent — that is the whole point of archiving —
|
|
1541
|
+
* but the call that restores one has to be able to find it, and `requireFlow` answers 404 for
|
|
1542
|
+
* exactly the rows this call exists for. The visibility predicate is the same one; only the
|
|
1543
|
+
* `archivedAt` refusal is left out.
|
|
1544
|
+
*
|
|
1545
|
+
* `write` and nothing more, asked before the row is touched: archiving is organization, the same
|
|
1546
|
+
* grant that renames and moves (ADR-0004). It appends no version and withdraws no publication.
|
|
1547
|
+
*
|
|
1548
|
+
* ⚠️ It does NOT leave runs in flight alone, however much the word "archive" suggests it would.
|
|
1549
|
+
* `completeStep` writes before it shapes its answer: the step is recorded, and `step()` then
|
|
1550
|
+
* resolves the flow and throws 404 for the archived one. The caller is told the step failed
|
|
1551
|
+
* while storage has it done, and a retry replays into the same 404. Restoring the flow is what
|
|
1552
|
+
* makes the run readable again. That belongs to the run machine and has its own ticket
|
|
1553
|
+
* (issue 112) — archiving is only the easiest way to walk into it.
|
|
1554
|
+
*/
|
|
1555
|
+
async archive(actor, input) {
|
|
1556
|
+
const current = await deps.repository.getVisible(actor, input.flowId);
|
|
1557
|
+
if (!current)
|
|
1558
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1559
|
+
if (!(await deps.repository.can(actor, input.flowId, "write"))) {
|
|
1560
|
+
throw new IntelError(403, "flow_edit_forbidden", "Flow cannot be edited");
|
|
1561
|
+
}
|
|
1562
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.archive", input.idempotencyKey);
|
|
1563
|
+
// Read back the way this call reads anything, or replaying an archive would 404 on the row it
|
|
1564
|
+
// just archived.
|
|
1565
|
+
if (replayed) {
|
|
1566
|
+
const flow = await deps.repository.getVisible(actor, replayed);
|
|
1567
|
+
if (!flow)
|
|
1568
|
+
throw new IntelError(404, "flow_not_found", "Flow was not found");
|
|
1569
|
+
return flow;
|
|
1570
|
+
}
|
|
1571
|
+
/**
|
|
1572
|
+
* ⚠️ **A flow cannot be restored on its own into an archived folder** (D71, #733, found in
|
|
1573
|
+
* review of #738). The same refusal `nodes.archive` carries, and for the same reason: a live
|
|
1574
|
+
* flow under an archived folder is reached by a purge of that folder, which checks
|
|
1575
|
+
* `archived_at` on its root alone and walks the whole subtree.
|
|
1576
|
+
*/
|
|
1577
|
+
if (!input.archived && current.parentId !== null) {
|
|
1578
|
+
// ⚠️ Asked WITHOUT the visibility predicate, and that is the difference between a guard and
|
|
1579
|
+
// a decoration: a flow is reachable through `flow_grants` on its own, so its grantee may not
|
|
1580
|
+
// see the folder it is filed in. `folderAccess` answers `missing` for such a folder, the
|
|
1581
|
+
// guard would not fire, and a live flow would hang under an archived one — where the purge
|
|
1582
|
+
// of that folder takes it through `parent_id IN tree`.
|
|
1583
|
+
const folder = await deps.parentArchivedAt(current.parentId);
|
|
1584
|
+
if (folder?.archivedAt) {
|
|
1585
|
+
throw new IntelError(409, "parent_archived", "The folder this flow is filed in is archived, so it cannot be restored on its own. Restore the folder first, and this comes back with it.");
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
const updatedAt = deps.now().toISOString();
|
|
1589
|
+
const updated = await deps.repository.archiveFlow({
|
|
1590
|
+
flowId: current.id,
|
|
1591
|
+
baseUpdatedAt: input.baseUpdatedAt,
|
|
1592
|
+
archivedAt: input.archived ? updatedAt : null,
|
|
1593
|
+
updatedAt,
|
|
1594
|
+
actorId: actor.id,
|
|
1595
|
+
idempotencyKey: input.idempotencyKey,
|
|
1596
|
+
auditId: deps.id(),
|
|
1597
|
+
});
|
|
1598
|
+
if (updated === "conflict") {
|
|
1599
|
+
throw new IntelError(409, "flow_update_conflict", "Flow was changed by another editor");
|
|
1600
|
+
}
|
|
1601
|
+
return updated;
|
|
1602
|
+
},
|
|
1603
|
+
async save(actor, input) {
|
|
1604
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.save", input.idempotencyKey);
|
|
1605
|
+
if (replayed) {
|
|
1606
|
+
const version = await requireVersion(replayed, input.flowId);
|
|
1607
|
+
return { flow: await requireFlow(actor, input.flowId), version };
|
|
1608
|
+
}
|
|
1609
|
+
const flow = await requireEdit(actor, input.flowId);
|
|
1610
|
+
compileFlow(input.graph);
|
|
1611
|
+
if (flow.currentVersionId !== input.baseVersionId) {
|
|
1612
|
+
throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
|
|
1613
|
+
}
|
|
1614
|
+
const previous = flow.currentVersionId
|
|
1615
|
+
? await requireVersion(flow.currentVersionId, flow.id)
|
|
1616
|
+
: null;
|
|
1617
|
+
const occurredAt = deps.now().toISOString();
|
|
1618
|
+
const version = {
|
|
1619
|
+
id: deps.id(),
|
|
1620
|
+
flowId: flow.id,
|
|
1621
|
+
sequence: (previous?.sequence ?? 0) + 1,
|
|
1622
|
+
graph: input.graph,
|
|
1623
|
+
createdBy: actor.id,
|
|
1624
|
+
createdAt: occurredAt,
|
|
1625
|
+
};
|
|
1626
|
+
const result = await deps.repository.insertVersion({
|
|
1627
|
+
version,
|
|
1628
|
+
baseVersionId: input.baseVersionId,
|
|
1629
|
+
actorId: actor.id,
|
|
1630
|
+
idempotencyKey: input.idempotencyKey,
|
|
1631
|
+
auditId: deps.id(),
|
|
1632
|
+
});
|
|
1633
|
+
if (result === "conflict") {
|
|
1634
|
+
throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
|
|
1635
|
+
}
|
|
1636
|
+
return { flow: await requireFlow(actor, flow.id), version };
|
|
1637
|
+
},
|
|
1638
|
+
// What publishing will do to the calls, before it does it (ADR-0004 §5). It reads and writes
|
|
1639
|
+
// nothing, and it asks for `write` rather than `read`: only someone who could publish has any
|
|
1640
|
+
// business reading which versions a publication would pin.
|
|
1641
|
+
async previewPublish(actor, input) {
|
|
1642
|
+
const flow = await requireEdit(actor, input.flowId);
|
|
1643
|
+
const version = await requireVersion(input.versionId, flow.id);
|
|
1644
|
+
// ⚠️ `available` is asked with the VERY call that publishing uses, not with a cheaper one that
|
|
1645
|
+
// answers a similar question. `unavailableServers` only says whether a server reaches
|
|
1646
|
+
// anything; publishing asks whether THIS step's surface is whole — and those differ exactly
|
|
1647
|
+
// where it hurts: a narrowed step whose allowed function disappeared keeps a reachable
|
|
1648
|
+
// server, so the cheap question says "fine" and the confirmation then answers 409. A preview
|
|
1649
|
+
// that promises a freeze the confirmation denies is worse than no preview.
|
|
1650
|
+
const tools = await Promise.all(toolNodes(version.graph).map(async (node) => ({
|
|
1651
|
+
nodeId: node.id,
|
|
1652
|
+
nodeLabel: node.label,
|
|
1653
|
+
server: node.configuration.server,
|
|
1654
|
+
allow: node.configuration.allow,
|
|
1655
|
+
available: (await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow)) !== null,
|
|
1656
|
+
})));
|
|
1657
|
+
return {
|
|
1658
|
+
flowId: flow.id,
|
|
1659
|
+
versionId: version.id,
|
|
1660
|
+
calls: await calls(actor, version.graph),
|
|
1661
|
+
tools,
|
|
1662
|
+
};
|
|
1663
|
+
},
|
|
1664
|
+
async publish(actor, input) {
|
|
1665
|
+
const flow = await requireEdit(actor, input.flowId);
|
|
1666
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.publish", input.idempotencyKey);
|
|
1667
|
+
if (replayed)
|
|
1668
|
+
return await requireFlow(actor, flow.id);
|
|
1669
|
+
const version = await requireVersion(input.versionId, flow.id);
|
|
1670
|
+
compileFlow(version.graph);
|
|
1671
|
+
const wanted = [...new Set(treeLinkNodes(version.graph).map(resourceIdOf))];
|
|
1672
|
+
const reachable = new Set((await reachableNodes(actor, wanted)).map((reference) => reference.id));
|
|
1673
|
+
// ⚠️ Asked over the deduplicated resource ids and REFUSED over the nodes (#551). The resource
|
|
1674
|
+
// id is what the ACL answers about, and it is also the one thing the author cannot look up:
|
|
1675
|
+
// it stands in no place the editor draws, so `Node reference is unavailable: n-01H…` named
|
|
1676
|
+
// the failure after the only participant nobody can find. The link node beside it carries the
|
|
1677
|
+
// label on the canvas, so the refusal names that instead.
|
|
1678
|
+
//
|
|
1679
|
+
// ⚠️ Naming the label reveals nothing the ACL is keeping: it is the author's own text in
|
|
1680
|
+
// their own graph, already on their screen — unlike the run-time refusal, which counts the
|
|
1681
|
+
// documents rather than naming them because there the READER may not see them (#17, #19).
|
|
1682
|
+
for (const node of treeLinkNodes(version.graph)) {
|
|
1683
|
+
if (!reachable.has(resourceIdOf(node))) {
|
|
1684
|
+
throw new IntelError(409, "flow_node_unavailable", `Node ${nodeName(node)} names a document you cannot reach`);
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
for (const node of version.graph.nodes) {
|
|
1688
|
+
if (node.kind === "tool") {
|
|
1689
|
+
// ⚠️ The fingerprint covers the whole surface this step may use — the server and, when
|
|
1690
|
+
// the step narrows it, exactly those functions with their schemas (#489). Freezing one
|
|
1691
|
+
// function was enough while a step named one; a step that may use any function of a
|
|
1692
|
+
// server would otherwise silently inherit functions the provider added AFTER publishing.
|
|
1693
|
+
const fingerprint = await deps.toolSurfaceFingerprint(actor, node.configuration.server, node.configuration.allow);
|
|
1694
|
+
if (!fingerprint) {
|
|
1695
|
+
throw new IntelError(409, "flow_tool_unavailable", `Tool server is unavailable: ${node.label}`);
|
|
1696
|
+
}
|
|
1697
|
+
// ⚠️ A step that has never been published carries no fingerprint, and that is not a
|
|
1698
|
+
// mismatch — it is the state every draft starts in. Publishing is what FREEZES the
|
|
1699
|
+
// surface, exactly as it turns a `latest` sub-flow call into a pinned one, and `freeze`
|
|
1700
|
+
// below writes the value in.
|
|
1701
|
+
//
|
|
1702
|
+
// Demanding it up front would mean the editor had to produce it, and nothing hands it
|
|
1703
|
+
// one: the value is derived from the asking user's own live catalog. Requiring it would
|
|
1704
|
+
// make every tool step unpublishable rather than safe.
|
|
1705
|
+
if (node.configuration.fingerprint !== null &&
|
|
1706
|
+
node.configuration.fingerprint !== fingerprint) {
|
|
1707
|
+
throw new IntelError(409, "flow_tool_schema_changed", `Review the current schema before publishing: ${node.label}`);
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
}
|
|
1711
|
+
// Both refusals happen before the flow is published, so a call that breaks the rule never
|
|
1712
|
+
// becomes something a run could follow.
|
|
1713
|
+
await requireCallRule(actor, flow, version.graph);
|
|
1714
|
+
await requireNoCallCycle(flow, version.graph);
|
|
1715
|
+
// ADR-0004 §5: `latest` becomes the concrete version here. A version is immutable, so the
|
|
1716
|
+
// freeze cannot rewrite the one being published — it appends the frozen graph as the next
|
|
1717
|
+
// version and publishes that. The author saw the same list through `previewPublish` first.
|
|
1718
|
+
const frozenGraph = await freeze(actor, version.graph);
|
|
1719
|
+
let publishable = version;
|
|
1720
|
+
if (frozenGraph) {
|
|
1721
|
+
// ⚠️ Keyed on the version being frozen, not on the request. Freezing version X is a
|
|
1722
|
+
// function of X, so every attempt at it has to land on the one version the first attempt
|
|
1723
|
+
// appended: a retry whose answer was lost, and a second confirmation of the same preview,
|
|
1724
|
+
// are re-entries rather than repetitions. With a per-request key the second attempt would
|
|
1725
|
+
// append a second version — and could then never publish, because the draft it was based on
|
|
1726
|
+
// is no longer the current one and the guard below would refuse it forever.
|
|
1727
|
+
const freezeKey = `freeze:${version.id}`;
|
|
1728
|
+
const already = await deps.repository.findIdempotent(actor.id, "flows.save", freezeKey);
|
|
1729
|
+
if (already) {
|
|
1730
|
+
publishable = await requireVersion(already, flow.id);
|
|
1731
|
+
}
|
|
1732
|
+
else {
|
|
1733
|
+
// Appending only ever continues the draft. Freezing an older version would fork the
|
|
1734
|
+
// chain and leave the editor holding a version that is no longer the flow's current one.
|
|
1735
|
+
if (flow.currentVersionId !== version.id) {
|
|
1736
|
+
throw new IntelError(409, "flow_publish_not_current", "Only the current version can be published while it still calls a latest sub-flow");
|
|
1737
|
+
}
|
|
1738
|
+
const frozen = {
|
|
1739
|
+
id: deps.id(),
|
|
1740
|
+
flowId: flow.id,
|
|
1741
|
+
sequence: version.sequence + 1,
|
|
1742
|
+
graph: frozenGraph,
|
|
1743
|
+
createdBy: actor.id,
|
|
1744
|
+
createdAt: deps.now().toISOString(),
|
|
1745
|
+
};
|
|
1746
|
+
const saved = await deps.repository.insertVersion({
|
|
1747
|
+
version: frozen,
|
|
1748
|
+
baseVersionId: version.id,
|
|
1749
|
+
actorId: actor.id,
|
|
1750
|
+
idempotencyKey: freezeKey,
|
|
1751
|
+
auditId: deps.id(),
|
|
1752
|
+
});
|
|
1753
|
+
if (saved === "conflict") {
|
|
1754
|
+
throw new IntelError(409, "flow_version_conflict", "Flow changed since it was loaded");
|
|
1755
|
+
}
|
|
1756
|
+
publishable = frozen;
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
const published = await deps.repository.publish({
|
|
1760
|
+
flowId: flow.id,
|
|
1761
|
+
versionId: publishable.id,
|
|
1762
|
+
actorId: actor.id,
|
|
1763
|
+
idempotencyKey: input.idempotencyKey,
|
|
1764
|
+
auditId: deps.id(),
|
|
1765
|
+
occurredAt: deps.now().toISOString(),
|
|
1766
|
+
// ⚠️ Spread rather than assigned, and `exactOptionalPropertyTypes` is only half the reason.
|
|
1767
|
+
// The repository distinguishes three states, and "the field is present carrying
|
|
1768
|
+
// `undefined`" is not one of them: it has to be ABSENT for "leave the name alone".
|
|
1769
|
+
...(input.promptName === undefined ? {} : { promptName: input.promptName }),
|
|
1770
|
+
});
|
|
1771
|
+
if (published === "prompt_name_taken") {
|
|
1772
|
+
/**
|
|
1773
|
+
* ⚠️ **The holder is named only to somebody who may see it** — the same sentence
|
|
1774
|
+
* `node_update` gives, out of the same query (`db-prompts.ts`). The catalogue is one across
|
|
1775
|
+
* documents and flows, so the name a flow is refused for may well be held by a document,
|
|
1776
|
+
* and the refusal has to be able to say so.
|
|
1777
|
+
*/
|
|
1778
|
+
const holder = await deps.repository.promptNameHolder(actor, input.promptName);
|
|
1779
|
+
const held = holder === null
|
|
1780
|
+
? "It is already taken."
|
|
1781
|
+
: holder.title === null
|
|
1782
|
+
? `A ${holder.kind} you cannot see already uses it.`
|
|
1783
|
+
: `The ${holder.kind} "${holder.title}" already uses it.`;
|
|
1784
|
+
throw new IntelError(409, "prompt_name_taken", `The slash command "${input.promptName}" is not free. ${held} Pick another name — names are never changed for you, because the catalogue would then mean a different thing to every reader.`);
|
|
1785
|
+
}
|
|
1786
|
+
if (!published)
|
|
1787
|
+
throw new IntelError(409, "flow_publish_conflict", "Flow could not be published");
|
|
1788
|
+
return await requireFlow(actor, flow.id);
|
|
1789
|
+
},
|
|
1790
|
+
/**
|
|
1791
|
+
* Withdraw the publication (#146) — the operation the refusal in `knowledge.ts` has promised
|
|
1792
|
+
* all along ("Change or unpublish them before narrowing the folder"). `published_version_id`
|
|
1793
|
+
* goes back to null and nothing else moves: every version is kept, and republishing any of
|
|
1794
|
+
* them is one `publish` away.
|
|
1795
|
+
*
|
|
1796
|
+
* What it means downstream is already decided by the checks that exist: new starts answer
|
|
1797
|
+
* `flow_not_published`, a caller's `validate` reports `flow_subflow_not_published` for the
|
|
1798
|
+
* dead call, and runs in flight keep their immutable version — publication decides what may
|
|
1799
|
+
* START, never what happens to what is already going (#112). The narrowing guard reads only
|
|
1800
|
+
* published graphs, so an unpublished caller frees its library folder.
|
|
1801
|
+
*/
|
|
1802
|
+
async unpublish(actor, input) {
|
|
1803
|
+
const flow = await requireEdit(actor, input.flowId);
|
|
1804
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.unpublish", input.idempotencyKey);
|
|
1805
|
+
if (replayed)
|
|
1806
|
+
return await requireFlow(actor, flow.id);
|
|
1807
|
+
if (!flow.publishedVersionId) {
|
|
1808
|
+
throw new IntelError(409, "flow_not_published", "Flow has no published version");
|
|
1809
|
+
}
|
|
1810
|
+
const withdrawn = await deps.repository.unpublish({
|
|
1811
|
+
flowId: flow.id,
|
|
1812
|
+
versionId: flow.publishedVersionId,
|
|
1813
|
+
actorId: actor.id,
|
|
1814
|
+
idempotencyKey: input.idempotencyKey,
|
|
1815
|
+
auditId: deps.id(),
|
|
1816
|
+
occurredAt: deps.now().toISOString(),
|
|
1817
|
+
});
|
|
1818
|
+
// The write matched nothing: another writer withdrew the publication first. The state is
|
|
1819
|
+
// what this call asked for, but saying "done" would hide that the race happened.
|
|
1820
|
+
if (!withdrawn) {
|
|
1821
|
+
throw new IntelError(409, "flow_not_published", "Flow has no published version");
|
|
1822
|
+
}
|
|
1823
|
+
return await requireFlow(actor, flow.id);
|
|
1824
|
+
},
|
|
1825
|
+
async start(actor, input) {
|
|
1826
|
+
requireRun(actor);
|
|
1827
|
+
// ⚠️ The whole guarantee of a subflow sits on this line staying where it is. A called run is
|
|
1828
|
+
// started through the same door as any other: the user's own `execute` on the called flow is
|
|
1829
|
+
// asked first, and the calling flow lends nothing. Without it a subflow node would be a way
|
|
1830
|
+
// around every folder grant in the tree (ADR-0004 §4).
|
|
1831
|
+
await requireExecute(actor, input.flowId);
|
|
1832
|
+
const call = input.parent ? await requireCallSite(actor, input.parent, input.flowId) : null;
|
|
1833
|
+
const parent = call?.run ?? null;
|
|
1834
|
+
// One call site starts one run. A repeated call hands back the run it already made instead of
|
|
1835
|
+
// a second one whose result nothing would ever read.
|
|
1836
|
+
if (parent && input.parent) {
|
|
1837
|
+
const existing = await deps.repository.findChildRun(parent.id, input.parent.nodeId);
|
|
1838
|
+
if (existing)
|
|
1839
|
+
return await step(actor, existing);
|
|
1840
|
+
}
|
|
1841
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.run", input.idempotencyKey);
|
|
1842
|
+
if (replayed) {
|
|
1843
|
+
const run = await deps.repository.getRunVisible(actor, replayed);
|
|
1844
|
+
if (run) {
|
|
1845
|
+
if (run.status === "running" || run.status === "queued")
|
|
1846
|
+
await deps.runtime.start(run.id);
|
|
1847
|
+
return await step(actor, run);
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
const flow = await requireRunnableFlow(actor, input.flowId);
|
|
1851
|
+
// ⚠️ The same list `validate` reads, and the reason it is one list: a check that only one of
|
|
1852
|
+
// them knows is a check the other silently skips. Thrown here at the first entry, because a
|
|
1853
|
+
// start is an action and an action either happens or says why not.
|
|
1854
|
+
//
|
|
1855
|
+
// Nothing has been written at this point, so a refusal leaves nothing behind at all — not a
|
|
1856
|
+
// run, not an idempotency key, not an audit event.
|
|
1857
|
+
const check = await collectRunProblems(actor, flow, call?.versionId ?? null);
|
|
1858
|
+
const problem = check.problems[0];
|
|
1859
|
+
if (problem)
|
|
1860
|
+
throw new IntelError(problem.status, problem.code, problem.detail);
|
|
1861
|
+
const version = check.version;
|
|
1862
|
+
const first = check.first;
|
|
1863
|
+
if (!version || !first)
|
|
1864
|
+
invalid("The flow cannot be started");
|
|
1865
|
+
const occurredAt = deps.now().toISOString();
|
|
1866
|
+
// ⚠️ A flow whose start leads straight to its end has nothing to carry out, and the end is a
|
|
1867
|
+
// marker rather than a step (D25). It is the shape every flow has the moment it is created,
|
|
1868
|
+
// so it must run rather than park a run on a node nobody can complete. What it hands back is
|
|
1869
|
+
// its input: the only thing that passed through it.
|
|
1870
|
+
const empty = nodeFor(version, first)?.kind === "output";
|
|
1871
|
+
const run = {
|
|
1872
|
+
id: deps.id(),
|
|
1873
|
+
flowId: flow.id,
|
|
1874
|
+
versionId: version.id,
|
|
1875
|
+
status: empty ? "completed" : "running",
|
|
1876
|
+
currentNodeId: empty ? null : first,
|
|
1877
|
+
input: input.input,
|
|
1878
|
+
output: empty ? input.input : null,
|
|
1879
|
+
error: null,
|
|
1880
|
+
initiatedBy: actor.id,
|
|
1881
|
+
parentRunId: parent?.id ?? null,
|
|
1882
|
+
parentNodeId: parent ? (input.parent?.nodeId ?? null) : null,
|
|
1883
|
+
createdAt: occurredAt,
|
|
1884
|
+
updatedAt: occurredAt,
|
|
1885
|
+
completedAt: empty ? occurredAt : null,
|
|
1886
|
+
};
|
|
1887
|
+
const inserted = await deps.repository.insertRun({
|
|
1888
|
+
run,
|
|
1889
|
+
actorId: actor.id,
|
|
1890
|
+
idempotencyKey: input.idempotencyKey,
|
|
1891
|
+
auditId: deps.id(),
|
|
1892
|
+
});
|
|
1893
|
+
await deps.runtime.start(inserted.id);
|
|
1894
|
+
return await step(actor, inserted);
|
|
1895
|
+
},
|
|
1896
|
+
async getRun(actor, runId) {
|
|
1897
|
+
const run = await deps.repository.getRunVisible(actor, runId);
|
|
1898
|
+
if (!run)
|
|
1899
|
+
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
1900
|
+
return await step(actor, run);
|
|
1901
|
+
},
|
|
1902
|
+
/**
|
|
1903
|
+
* What this flow has done, newest first (#35). Until now a run could only be found by an ID
|
|
1904
|
+
* somebody still had, and closing the tab lost it — with nothing to look at afterwards, a run
|
|
1905
|
+
* that failed overnight was a run nobody heard about.
|
|
1906
|
+
*
|
|
1907
|
+
* ⚠️ Two rules meet here and they are deliberately different ones. Whether the *flow* exists for
|
|
1908
|
+
* this actor is `read` or `execute`, exactly as the run path asks it — insisting on `read` would
|
|
1909
|
+
* make a library flow's own runner unable to find their run back. Which *runs* then appear is
|
|
1910
|
+
* `getRunVisible`, per row, in the repository: their own, plus every run of a flow they may
|
|
1911
|
+
* read. So an execute-only runner sees their own and nobody else's, and the answer cannot drift
|
|
1912
|
+
* from what opening a single run would say.
|
|
1913
|
+
*
|
|
1914
|
+
* What comes back is what a run did, never what it produced: `FlowRunSummary` carries no input
|
|
1915
|
+
* and no output, because a run reaches nodes and tools with the rights of whoever started it.
|
|
1916
|
+
*/
|
|
1917
|
+
async listRuns(actor, input) {
|
|
1918
|
+
// The history of a flow that has since been archived stays readable — the runs happened, and
|
|
1919
|
+
// archiving is about what may start next (#112).
|
|
1920
|
+
//
|
|
1921
|
+
// ⚠️ Without a flow there is nothing to require, and skipping the check does NOT widen the
|
|
1922
|
+
// answer (#774): `requireStartedFlow` decides whether one named flow exists FOR this actor,
|
|
1923
|
+
// while which runs appear is decided per row in the repository — their own, plus every run of
|
|
1924
|
+
// a flow they may read. Asking it for a flow nobody named would mean inventing one.
|
|
1925
|
+
const flowId = input.flowId === null ? null : (await requireStartedFlow(actor, input.flowId)).id;
|
|
1926
|
+
// One row beyond the page: it answers "is there more" and is dropped rather than shown, so a
|
|
1927
|
+
// count over the whole table is never needed to draw a "next" affordance.
|
|
1928
|
+
const rows = await deps.repository.listRunsVisible(actor, {
|
|
1929
|
+
flowId,
|
|
1930
|
+
failedOnly: input.failedOnly,
|
|
1931
|
+
limit: input.limit + 1,
|
|
1932
|
+
cursor: decodeCursor(input.cursor),
|
|
1933
|
+
});
|
|
1934
|
+
const page = rows.slice(0, input.limit);
|
|
1935
|
+
const failed = new Map((await deps.repository.failedSteps(page.map((run) => run.id))).map((row) => [
|
|
1936
|
+
row.runId,
|
|
1937
|
+
row,
|
|
1938
|
+
]));
|
|
1939
|
+
// ⚠️ Four reads for a page, whatever its length: the runs, their failed steps, the versions
|
|
1940
|
+
// they took, and the call sites among those failures. Runs of one flow mostly share a version
|
|
1941
|
+
// and most failures are not calls, so all three follow-ups are asked once for the whole page
|
|
1942
|
+
// rather than once per line (#30).
|
|
1943
|
+
const versions = new Map((await deps.repository.getVersions([...new Set(page.map((run) => run.versionId))])).map((version) => [version.id, version]));
|
|
1944
|
+
const sites = [];
|
|
1945
|
+
for (const run of page) {
|
|
1946
|
+
const record = failed.get(run.id);
|
|
1947
|
+
const version = record ? versions.get(run.versionId) : undefined;
|
|
1948
|
+
if (record && version && nodeFor(version, record.nodeId)?.kind === "subflow") {
|
|
1949
|
+
sites.push({ runId: run.id, nodeId: record.nodeId });
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
const calls = await visibleCalls(actor, sites);
|
|
1953
|
+
const items = [];
|
|
1954
|
+
for (const run of page) {
|
|
1955
|
+
const version = versions.get(run.versionId) ?? null;
|
|
1956
|
+
const record = failed.get(run.id);
|
|
1957
|
+
let failure = null;
|
|
1958
|
+
if (record) {
|
|
1959
|
+
const node = version ? nodeFor(version, record.nodeId) : null;
|
|
1960
|
+
const label = node?.label ?? record.nodeId;
|
|
1961
|
+
const stored = record.error ?? StepFailedDetail;
|
|
1962
|
+
const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
|
|
1963
|
+
failure = {
|
|
1964
|
+
nodeId: record.nodeId,
|
|
1965
|
+
nodeLabel: label,
|
|
1966
|
+
detail: node?.kind === "subflow"
|
|
1967
|
+
? (callDetail(calledRunId, label, stored) ?? stored)
|
|
1968
|
+
: stored,
|
|
1969
|
+
calledRunId: calledRunId ?? null,
|
|
1970
|
+
};
|
|
1971
|
+
}
|
|
1972
|
+
items.push({
|
|
1973
|
+
id: run.id,
|
|
1974
|
+
flowId: run.flowId,
|
|
1975
|
+
versionId: run.versionId,
|
|
1976
|
+
status: run.status,
|
|
1977
|
+
// A version that has gone missing cannot say how the run was triggered, and `manual` is
|
|
1978
|
+
// what every run that was not a call was started by (#39).
|
|
1979
|
+
trigger: version ? triggerOf(version, run) : run.parentRunId ? "subflow" : "manual",
|
|
1980
|
+
startedAt: run.createdAt,
|
|
1981
|
+
completedAt: run.completedAt,
|
|
1982
|
+
durationMs: durationOf(run),
|
|
1983
|
+
initiatedBy: run.initiatedBy,
|
|
1984
|
+
parentRunId: run.parentRunId,
|
|
1985
|
+
failure,
|
|
1986
|
+
});
|
|
1987
|
+
}
|
|
1988
|
+
const last = page.at(-1);
|
|
1989
|
+
return {
|
|
1990
|
+
items,
|
|
1991
|
+
nextCursor: rows.length > page.length && last ? encodeCursor(last) : null,
|
|
1992
|
+
};
|
|
1993
|
+
},
|
|
1994
|
+
/**
|
|
1995
|
+
* One run step by step, which is the answer to "what went wrong" when the failure is three steps
|
|
1996
|
+
* in. The failed node is reported rather than drawn onto the canvas: a run took an immutable
|
|
1997
|
+
* version, the editor shows the current one, and marking a node in the wrong graph would point
|
|
1998
|
+
* confidently at the wrong step. Shipping the run's own graph is a bigger slice than #35 asks
|
|
1999
|
+
* for, so it stays a list of steps and the reason is written here rather than lost.
|
|
2000
|
+
*
|
|
2001
|
+
* ⚠️ `getRunVisible` alone opens this, and the current step is deliberately *not* re-authorized
|
|
2002
|
+
* the way `getRun` does it. `getRun` hands out the next thing to do and must therefore ask again
|
|
2003
|
+
* whether this person may still do it; history says what already happened, and a document
|
|
2004
|
+
* withdrawn since must not rewrite the record of a run that read it while it was granted.
|
|
2005
|
+
*/
|
|
2006
|
+
async listRunSteps(actor, runId) {
|
|
2007
|
+
const run = await deps.repository.getRunVisible(actor, runId);
|
|
2008
|
+
if (!run)
|
|
2009
|
+
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
2010
|
+
const version = await requireVersion(run.versionId, run.flowId);
|
|
2011
|
+
const records = await deps.repository.runSteps(run.id);
|
|
2012
|
+
// Every call this run made, asked once for the whole history rather than once per step (#30).
|
|
2013
|
+
const calls = await visibleCalls(actor, records
|
|
2014
|
+
.filter((record) => nodeFor(version, record.nodeId)?.kind === "subflow")
|
|
2015
|
+
.map((record) => ({ runId: run.id, nodeId: record.nodeId })));
|
|
2016
|
+
const steps = [];
|
|
2017
|
+
for (const record of records) {
|
|
2018
|
+
const node = nodeFor(version, record.nodeId);
|
|
2019
|
+
const label = node?.label ?? record.nodeId;
|
|
2020
|
+
const calledRunId = node?.kind === "subflow" ? calls.get(callKey(run.id, record.nodeId)) : undefined;
|
|
2021
|
+
const detail = node?.kind === "subflow" ? callDetail(calledRunId, label, record.error) : record.error;
|
|
2022
|
+
steps.push({
|
|
2023
|
+
nodeId: record.nodeId,
|
|
2024
|
+
nodeLabel: label,
|
|
2025
|
+
outcome: record.outcome,
|
|
2026
|
+
branch: record.branch,
|
|
2027
|
+
detail: record.outcome === "failed" ? (detail ?? StepFailedDetail) : detail,
|
|
2028
|
+
calledRunId: calledRunId ?? null,
|
|
2029
|
+
completedAt: record.completedAt,
|
|
2030
|
+
});
|
|
2031
|
+
}
|
|
2032
|
+
return {
|
|
2033
|
+
runId: run.id,
|
|
2034
|
+
flowId: run.flowId,
|
|
2035
|
+
status: run.status,
|
|
2036
|
+
steps,
|
|
2037
|
+
trail: await trailFor(run, version),
|
|
2038
|
+
};
|
|
2039
|
+
},
|
|
2040
|
+
/**
|
|
2041
|
+
* End a run on purpose (#145). Until this existed the only way off a parked manual step was
|
|
2042
|
+
* `completeStep` with `outcome: "failed"`, which recorded a step failure that never happened.
|
|
2043
|
+
* Cancelling writes no step row at all: the run ends, the step history stays true.
|
|
2044
|
+
*
|
|
2045
|
+
* ⚠️ Whoever may start a run may end it: the same Gate capability and the same `execute` the
|
|
2046
|
+
* start asked for, asked again now — a grant revoked since the start must not keep the holder
|
|
2047
|
+
* of an old run in control of it. Deliberately NOT `initiatedBy === actor.id`: a second person
|
|
2048
|
+
* with `execute` on the flow may end a colleague's stuck run, exactly as they could have
|
|
2049
|
+
* started the same run themselves.
|
|
2050
|
+
*
|
|
2051
|
+
* The child runs of a cancelled caller are cancelled with it, in the same write. A caller
|
|
2052
|
+
* standing on a subflow node is waiting for its child by design; ending the caller alone would
|
|
2053
|
+
* leave the child working for a result nobody will ever read.
|
|
2054
|
+
*/
|
|
2055
|
+
async cancelRun(actor, input) {
|
|
2056
|
+
requireRun(actor);
|
|
2057
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.cancel", input.idempotencyKey);
|
|
2058
|
+
if (replayed) {
|
|
2059
|
+
const run = await deps.repository.getRunVisible(actor, replayed);
|
|
2060
|
+
if (run)
|
|
2061
|
+
return await step(actor, run);
|
|
2062
|
+
}
|
|
2063
|
+
const current = await deps.repository.getRunVisible(actor, input.runId);
|
|
2064
|
+
if (!current)
|
|
2065
|
+
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
2066
|
+
await requireExecute(actor, current.flowId);
|
|
2067
|
+
if (current.status !== "running" && current.status !== "queued") {
|
|
2068
|
+
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
2069
|
+
}
|
|
2070
|
+
const occurredAt = deps.now().toISOString();
|
|
2071
|
+
const result = await deps.repository.cancelRun({
|
|
2072
|
+
runId: current.id,
|
|
2073
|
+
flowId: current.flowId,
|
|
2074
|
+
actorId: actor.id,
|
|
2075
|
+
occurredAt,
|
|
2076
|
+
idempotencyKey: input.idempotencyKey,
|
|
2077
|
+
auditId: deps.id(),
|
|
2078
|
+
});
|
|
2079
|
+
if (result === "conflict") {
|
|
2080
|
+
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
2081
|
+
}
|
|
2082
|
+
// Every durable wait among the ended runs is woken, children included: each workflow reads
|
|
2083
|
+
// its terminal status and finishes now instead of stalling out on its own timeout.
|
|
2084
|
+
for (const cancelledId of result.cancelled) {
|
|
2085
|
+
await deps.runtime.signal(cancelledId);
|
|
2086
|
+
}
|
|
2087
|
+
const version = await requireVersion(current.versionId, current.flowId);
|
|
2088
|
+
// ⚠️ `runState`, not `step`: the write has happened, and asking permission again AFTER a
|
|
2089
|
+
// write is what turned completed work into a 404 for its own caller (#112).
|
|
2090
|
+
return await runState(actor, {
|
|
2091
|
+
...current,
|
|
2092
|
+
status: "cancelled",
|
|
2093
|
+
currentNodeId: null,
|
|
2094
|
+
updatedAt: occurredAt,
|
|
2095
|
+
completedAt: occurredAt,
|
|
2096
|
+
}, version);
|
|
2097
|
+
},
|
|
2098
|
+
async completeStep(actor, input) {
|
|
2099
|
+
requireRun(actor);
|
|
2100
|
+
const replayed = await deps.repository.findIdempotent(actor.id, "flows.complete", input.idempotencyKey);
|
|
2101
|
+
if (replayed) {
|
|
2102
|
+
const run = await deps.repository.getRunVisible(actor, replayed);
|
|
2103
|
+
if (run)
|
|
2104
|
+
return await step(actor, run);
|
|
2105
|
+
}
|
|
2106
|
+
const current = await deps.repository.getRunVisible(actor, input.runId);
|
|
2107
|
+
if (!current)
|
|
2108
|
+
throw new IntelError(404, "flow_run_not_found", "Flow run was not found");
|
|
2109
|
+
// Rechecked here rather than trusted from the start: a grant revoked between two steps must
|
|
2110
|
+
// stop the next one, not only the next run.
|
|
2111
|
+
await requireExecute(actor, current.flowId);
|
|
2112
|
+
if (current.status !== "running") {
|
|
2113
|
+
throw new IntelError(409, "flow_run_terminal", "Flow run is already terminal");
|
|
2114
|
+
}
|
|
2115
|
+
if (current.currentNodeId !== input.nodeId) {
|
|
2116
|
+
throw new IntelError(409, "flow_step_conflict", "A different step is currently active");
|
|
2117
|
+
}
|
|
2118
|
+
const version = await requireVersion(current.versionId, current.flowId);
|
|
2119
|
+
const node = nodeFor(version, input.nodeId);
|
|
2120
|
+
if (!node) {
|
|
2121
|
+
throw new IntelError(500, "flow_version_corrupt", "Current run node is missing from the immutable version");
|
|
2122
|
+
}
|
|
2123
|
+
// Asked again before the result is recorded, not only when the step was handed out: a grant
|
|
2124
|
+
// revoked while the step was being carried out must stop it from landing.
|
|
2125
|
+
await requireNodeAuthorized(actor, node, version.graph);
|
|
2126
|
+
// ⚠️ A subflow step is finished by the run it started, and by nothing the caller of this
|
|
2127
|
+
// surface says. Outcome, output and error are all read back out of that run: the outcome too,
|
|
2128
|
+
// or a client could mark a call that succeeded as failed and send the flow down a branch the
|
|
2129
|
+
// called flow never asked for. The state of a run belongs to the server.
|
|
2130
|
+
let outcome = input.outcome;
|
|
2131
|
+
let stepOutput = input.output;
|
|
2132
|
+
let stepError = input.error;
|
|
2133
|
+
if (node.kind === "subflow") {
|
|
2134
|
+
const called = await deps.repository.findChildRun(current.id, node.id);
|
|
2135
|
+
if (!called) {
|
|
2136
|
+
throw new IntelError(409, "flow_subflow_not_started", `Start the called flow before completing this step: ${node.label}`);
|
|
2137
|
+
}
|
|
2138
|
+
if (called.status === "completed") {
|
|
2139
|
+
outcome = "completed";
|
|
2140
|
+
stepOutput = called.output;
|
|
2141
|
+
stepError = null;
|
|
2142
|
+
}
|
|
2143
|
+
else if (called.status === "failed" || called.status === "cancelled") {
|
|
2144
|
+
outcome = "failed";
|
|
2145
|
+
stepOutput = null;
|
|
2146
|
+
stepError = called.error ?? calledStepDetail(node.label);
|
|
2147
|
+
}
|
|
2148
|
+
else {
|
|
2149
|
+
throw new IntelError(409, "flow_subflow_running", `The called flow is still running: ${node.label}`);
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
const occurredAt = deps.now().toISOString();
|
|
2153
|
+
let status = "running";
|
|
2154
|
+
let currentNodeId = null;
|
|
2155
|
+
let output = current.output;
|
|
2156
|
+
let error = null;
|
|
2157
|
+
let completedAt = null;
|
|
2158
|
+
if (outcome === "failed") {
|
|
2159
|
+
status = "failed";
|
|
2160
|
+
error = stepError ?? StepFailedDetail;
|
|
2161
|
+
completedAt = occurredAt;
|
|
2162
|
+
}
|
|
2163
|
+
else {
|
|
2164
|
+
// ⚠️ Flow edges only, for the same reason as the start above: what a step works with is not
|
|
2165
|
+
// where the run goes next (#37).
|
|
2166
|
+
const edges = version.graph.edges.filter((edge) => edge.kind === "flow" && edge.source === node.id);
|
|
2167
|
+
const edge = node.kind === "condition"
|
|
2168
|
+
? edges.find((candidate) => candidate.sourceHandle === input.branch)
|
|
2169
|
+
: edges[0];
|
|
2170
|
+
if (!edge) {
|
|
2171
|
+
throw new IntelError(400, "flow_branch_invalid", "A valid branch is required");
|
|
2172
|
+
}
|
|
2173
|
+
// ⚠️ The end is reached, never carried out (D25). It marks where the flow stops and holds
|
|
2174
|
+
// nothing to do, so handing it out as a step would ask the agent to perform an empty node
|
|
2175
|
+
// and then wait for the answer. The run ends here instead, with what the step that just
|
|
2176
|
+
// finished handed in — which is what "the result of a run" now means.
|
|
2177
|
+
if (nodeFor(version, edge.target)?.kind === "output") {
|
|
2178
|
+
status = "completed";
|
|
2179
|
+
output = stepOutput;
|
|
2180
|
+
completedAt = occurredAt;
|
|
2181
|
+
}
|
|
2182
|
+
else {
|
|
2183
|
+
currentNodeId = edge.target;
|
|
2184
|
+
}
|
|
2185
|
+
}
|
|
2186
|
+
const next = {
|
|
2187
|
+
...current,
|
|
2188
|
+
status,
|
|
2189
|
+
currentNodeId,
|
|
2190
|
+
output,
|
|
2191
|
+
error,
|
|
2192
|
+
updatedAt: occurredAt,
|
|
2193
|
+
completedAt,
|
|
2194
|
+
};
|
|
2195
|
+
const advanced = await deps.repository.advanceRun({
|
|
2196
|
+
run: next,
|
|
2197
|
+
expectedNodeId: input.nodeId,
|
|
2198
|
+
actorId: actor.id,
|
|
2199
|
+
stepId: deps.id(),
|
|
2200
|
+
outcome,
|
|
2201
|
+
branch: input.branch,
|
|
2202
|
+
output: stepOutput,
|
|
2203
|
+
error: stepError,
|
|
2204
|
+
idempotencyKey: input.idempotencyKey,
|
|
2205
|
+
auditId: deps.id(),
|
|
2206
|
+
});
|
|
2207
|
+
if (advanced === "conflict") {
|
|
2208
|
+
throw new IntelError(409, "flow_step_conflict", "Flow step was already completed");
|
|
2209
|
+
}
|
|
2210
|
+
await deps.runtime.signal(next.id);
|
|
2211
|
+
// ⚠️ `runState`, not `step`: the write above has happened. Asking again here is what turned a
|
|
2212
|
+
// completed step into a 404 for the caller (#112).
|
|
2213
|
+
return await runState(actor, next, version);
|
|
2214
|
+
},
|
|
2215
|
+
};
|
|
2216
|
+
}
|