diff --git a/AGENTS.md b/AGENTS.md index 0bda19a..f6ccd38 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -15,7 +15,6 @@ src/ exec-runner.ts # Shell command execution, stdout JSON parsing script-runner.ts # JavaScript execution via AsyncFunction condition-runner.ts # Conditional branching by return value - args-runner.ts # Argument provider for downstream nodes graph/ builder.ts # Canvas JSON + note frontmatter -> WorkflowGraph parser.ts # Canvas JSON parsing, frontmatter extraction, code block extraction @@ -37,14 +36,13 @@ src/ ## Domain concepts -Six node types. Four are file-backed notes configured via `runestone.*` frontmatter; two are Canvas text nodes used as payloadless markers: +Five node types. Three are file-backed notes configured via `runestone.*` frontmatter; two are Canvas text nodes used as payloadless markers: | Type | Canvas form | Purpose | Input | Output | |------|-------------|---------|-------|--------| | `exec` | file note | Run shell command | `{{input[n]}}` and `{{args.key}}` templates in body/frontmatter | stdout parsed as JSON | | `script` | file note | Run JavaScript | `input`, `args`, `app`, `obsidian` variables | Return value as JSON | | `condition` | file note | Branch execution | Same as script | Return value matched to edge labels | -| `args` | file note | Provide parameters | None (no incoming edges) | Merged into downstream `args` parameter | | `start` | text node `runestone:start` | Mark workflow entry point | None | Empty input to successors | | `end` | text node `runestone:end` | Mark workflow halt point | Any | None (triggers graceful halt) | @@ -57,7 +55,6 @@ Key execution rules: - Cycles allowed but require condition node with exit edge. - Nondirectional edges (`fromEnd: "none"`, `toEnd: "none"`) are excluded. - `runestone.onError: stop` (default) halts workflow; `continue` skips only the failed path. -- `args` nodes still run independently of the start node (they have no incoming edges and provide args in parallel). ## Testing diff --git a/GLOSSARY.md b/GLOSSARY.md index d6a5fae..4ae7699 100644 --- a/GLOSSARY.md +++ b/GLOSSARY.md @@ -6,7 +6,7 @@ This glossary defines domain-specific terms used in the Runestone Obsidian plugi ### Workflow Node -A note referenced from a Canvas that acts as a step in a workflow. Must have `runestone.type` defined in Frontmatter. Canvas nodes of `type: "file"` (for exec/script/condition/args nodes) and `type: "text"` containing the literals `runestone:start` or `runestone:end` (for Start Node / End Node markers) are treated as workflow nodes. +A note referenced from a Canvas that acts as a step in a workflow. Must have `runestone.type` defined in Frontmatter. Canvas nodes of `type: "file"` (for exec/script/condition nodes) and `type: "text"` containing the literals `runestone:start` or `runestone:end` (for Start Node / End Node markers) are treated as workflow nodes. ### exec Node @@ -38,16 +38,6 @@ runestone.type: condition --- ``` -### args Node - -Executes JavaScript from a code block and provides the result as a separate `args` parameter to connected downstream script/condition nodes. Unlike other node types, the output is not passed via `input`. args nodes must not have incoming edges and cannot connect to other args nodes or exec nodes. When multiple args nodes connect to the same target, their outputs are merged into a single `args` object (key conflicts: last wins with a console warning). - -```yaml ---- -runestone.type: args ---- -``` - ### start Node A payloadless marker node represented as a Canvas **text node** whose trimmed content equals the literal `runestone:start`. The start node identifies the entry point of the workflow. @@ -196,7 +186,7 @@ runestone.onError: continue ### Log Panel -A dedicated Runestone view displaying each node's execution state, output (the structured return value), stdout, stderr, execution time, overall workflow progress, and stack traces/error messages on error. The `output` value is rendered as pretty-printed JSON and is shown for every node type (exec, script, condition, args), in addition to the raw stdout for exec nodes. Clicking a node name opens the corresponding note. +A dedicated Runestone view displaying each node's execution state, output (the structured return value), stdout, stderr, execution time, overall workflow progress, and stack traces/error messages on error. The `output` value is rendered as pretty-printed JSON and is shown for every node type (exec, script, condition), in addition to the raw stdout for exec nodes. Clicking a node name opens the corresponding note. ## Obsidian Concepts diff --git a/MIGRATION.md b/MIGRATION.md index cef74a0..a9b69f9 100644 --- a/MIGRATION.md +++ b/MIGRATION.md @@ -6,25 +6,42 @@ This document describes the steps needed to migrate existing Runestone workflows ### Overview -v0.3 introduces explicit **start** and **end** node markers as Canvas text nodes (`runestone:start` and `runestone:end`). The previous implicit rule — "the node with no incoming edges is the start node" — is removed. This is a breaking change: existing workflows will fail pre-execution validation until they are updated. +v0.3 introduces explicit **start** and **end** node markers as Canvas text nodes (`runestone:start` and `runestone:end`). The previous implicit rule — "the node with no incoming edges is the start node" — is removed. + +Furthermore, this release **deprecates and completely removes the `args` node type**. With the introduction of the explicit `start` node, the entry points of workflows are now clearly defined, making the `args` node redundant. + +These are breaking changes: existing workflows will fail pre-execution validation until they are updated. The new markers enable lightweight partial-execution debugging: move the `runestone:start` text node's outgoing edge to redirect the workflow entry point without deleting or re-adding nodes. ### Breaking Changes 1. **Start node identification is now explicit.** Every workflow must contain exactly one Canvas text node whose trimmed content equals `runestone:start`. Workflows that relied on the implicit "no incoming edges" rule will be rejected with a validation error. -2. **`args` nodes no longer satisfy the start-node requirement.** Previously, `args` nodes with no incoming edges coexisted alongside an implicit start node. They still run in parallel with the start node, but they no longer count as candidates for the start node itself. -3. **New `end` node markers (optional).** Reaching any `runestone:end` text node halts the entire workflow gracefully — useful for debugging partial flows. Workflows without end markers behave as before (execution continues until every reachable branch terminates). +2. **Complete removal of the `args` Node.** Notes specifying `runestone.type: args` are no longer recognized by the workflow engine and will trigger a pre-execution validation error. +3. **Backward Compatibility for `args` variables.** To prevent existing scripts and command templates referencing `args` variables or `{{args.key}}` template values from throwing immediate runtime errors (such as `ReferenceError`), the executor will still inject an empty object `{}` for the `args` parameter during execution. +4. **New `end` node markers (optional).** Reaching any `runestone:end` text node halts the entire workflow gracefully — useful for debugging partial flows. Workflows without end markers behave as before (execution continues until every reachable branch terminates). ### Migration Steps For every existing workflow `.canvas` file: -1. Open the Canvas in Obsidian. -2. Add a new **text node** to the canvas. -3. Set its content to exactly `runestone:start` (no extra characters or whitespace). -4. Draw an edge from this text node to the node that should be the entry point (typically the node that had no incoming edges in v0.2). -5. Save the file and run the workflow to confirm it executes from the new start marker. +1. **Explicit Start Node**: + - Open the Canvas in Obsidian. + - Add a new **text node** to the canvas. + - Set its content to exactly `runestone:start` (no extra characters or whitespace). + - Draw an edge from this text node to the node that should be the entry point (typically the node that had no incoming edges in v0.2). + - Save the file and run the workflow to confirm it executes from the new start marker. +2. **Remove `args` Nodes**: + - Remove any existing `args` nodes from your Canvas. + - Refactor parameters or static data previously supplied by `args` nodes using either of the following: + - **Inline definition in script nodes**: + ```js + // Before: const items = args.items; + // After: Define variables directly inside your script + const items = ["Option A", "Option B", "Option C"]; + ``` + - **Using a dedicated configuration script node**: + Place a regular `script` node immediately after the `start` node to output your parameters, and reference them in downstream nodes using the `{{input[n]}}` syntax. Optionally, add `runestone:end` text nodes with incoming edges from the nodes where you want execution to halt. Multiple end markers are allowed. diff --git a/README.md b/README.md index 0e144e9..ffb4f1d 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,7 @@ echo '{"message": "hello {{args.name}}"}' ### script -Executes JavaScript asynchronously. Available variables: `app` (Obsidian App instance), `obsidian` (the `obsidian` module, e.g. `Modal`, `Notice`, `SuggestModal`), `input` (array of outputs from upstream nodes), and `args` (object from connected args nodes, empty `{}` if none). The return value becomes the node output. +Executes JavaScript asynchronously. Available variables: `app` (Obsidian App instance), `obsidian` (the `obsidian` module, e.g. `Modal`, `Notice`, `SuggestModal`), `input` (array of outputs from upstream nodes), and `args` (empty `{}` for backward compatibility). The return value becomes the node output. ````markdown --- @@ -86,7 +86,7 @@ return { result }; ### condition -Evaluates JavaScript and returns a value that is stringified and matched against outgoing edge labels. Must have at least one labeled outgoing edge. An optional unlabeled edge serves as a default (like `default` in a switch statement) when no label matches. Available variables: `app`, `obsidian`, `input` (same as script), and `args` (object from connected args nodes, empty `{}` if none). The original `input` is passed through to the next node, not the condition's return value. Multiple labeled edges may point to the same target node. +Evaluates JavaScript and returns a value that is stringified and matched against outgoing edge labels. Must have at least one labeled outgoing edge. An optional unlabeled edge serves as a default (like `default` in a switch statement) when no label matches. Available variables: `app`, `obsidian`, `input` (same as script), and `args` (empty `{}` for backward compatibility). The original `input` is passed through to the next node, not the condition's return value. Multiple labeled edges may point to the same target node. ````markdown --- @@ -100,41 +100,6 @@ return input[0].count > 10 ? "high" : "low"; Workflows may contain cycles. Every cycle must include a condition node with at least one exit edge leading outside the cycle. -### args - -Provides reusable parameters to downstream nodes. The code block executes JavaScript and must return a plain object. The result is passed as a separate `args` parameter (not via `input`). This enables reusing the same node with different configurations. - -````markdown ---- -runestone.type: args ---- - -```js -return { - items: ["Option A", "Option B", "Option C"], - placeholder: "Select an option", -}; -``` -```` - -The connected script/condition node receives `args` in addition to `app`, `obsidian`, and `input`: - -```js -const items = args.items; -// use items... -``` - -In exec nodes, args are available via `{{args.key}}` template syntax: - -```bash -echo '{"items": "{{args.items}}"}' -``` - -**Constraints:** -- args nodes must not have incoming edges -- args nodes cannot connect to other args nodes -- Multiple args nodes to the same target are merged (key conflicts: last wins with console warning) - ### start A Canvas **text node** whose trimmed content is exactly `runestone:start`. Marks the workflow entry point. Has no payload and produces no output. Every workflow must contain exactly one start marker, with no incoming edges and one or more outgoing edges. Successors of the start marker receive an empty input. @@ -161,7 +126,7 @@ All properties use the `runestone.` prefix. Properties without this prefix are i | Property | Values | Default | Description | |---|---|---|---| -| `runestone.type` | `exec`, `script`, `condition`, `args` | (required) | Node type | +| `runestone.type` | `exec`, `script`, `condition` | (required) | Node type | | `runestone.onError` | `stop`, `continue` | `stop` | Error handling strategy | - `stop`: abort the entire workflow and skip all remaining nodes @@ -187,7 +152,7 @@ Nodes can reference outputs from upstream nodes using `{{input[n].property}}`. - Multiple templates in one string: `echo '{"a": "{{input[0].x}}", "b": "{{input[1].y}}"}'` - Strings are passed as-is; numbers and booleans are converted to strings; objects and arrays are converted to JSON -The immediate successors of the `runestone:start` marker receive an empty input (`[{}]`), so `{{input[0].key}}` references are not meaningful there. Use `args` nodes to supply parameters to start-adjacent nodes. +The immediate successors of the `runestone:start` marker receive an empty input (`[{}]`), so `{{input[0].key}}` references are not meaningful there. ## Settings @@ -231,7 +196,6 @@ A workflow that creates a new note following the [PARA method](https://fortelabs - **Interactive dialogs** — prompt and suggest nodes for user input - **Conditional branching** — routes to different folders (Projects, Areas, Resources, Archives) based on PARA type -- **Args nodes** — reusable configuration passed to multiple nodes - **Join execution** — all branches converge to a final activation node To run: open `workflows/para-note/para-note.canvas` and click the play button. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index da16bfa..eafcdef 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -31,17 +31,6 @@ Runestone is an Obsidian plugin. It allows users to build and execute workflows - Error if no labeled edge matches and no default edge exists - Output passes the input through as-is -### REQ-NODE-004: args Node - -- Specify `runestone.type: args` in the note's Frontmatter -- Write JavaScript in a code block in the note body -- Has access to the Obsidian API (e.g. `app` object, `obsidian` module) -- The return value must be a plain object -- The return value is passed to the connected downstream node as a separate `args` parameter, not as part of `input` -- Multiple args nodes connected to the same target are merged into a single `args` object -- args nodes must not have incoming edges -- args nodes must not connect to other args nodes - ### REQ-NODE-005: start Node - Represented as a Canvas text node (Canvas `type: "text"`) @@ -59,7 +48,7 @@ Runestone is an Obsidian plugin. It allows users to build and execute workflows - It is not displayed in the Log Panel - Reaching any end node halts the entire workflow gracefully: - No new nodes are scheduled - - Already in-flight nodes (exec, script, condition, args) are allowed to complete naturally + - Already in-flight nodes (exec, script, condition) are allowed to complete naturally - The workflow terminates with status `completed` (not `failed`) once all in-flight nodes settle - A workflow may contain zero or more end nodes (see REQ-GRAPH-007) @@ -73,7 +62,7 @@ Runestone is an Obsidian plugin. It allows users to build and execute workflows - Input data can be referenced in Frontmatter values and note body using the `{{input[n].key}}` format - Even when a node has only one input, it is referenced as `{{input[0]}}` -- Args data can be referenced in Frontmatter values and note body using the `{{args.key}}` format +- Args data can be referenced in Frontmatter values and note body using the `{{args.key}}` format (always resolves to an empty object `{}` due to the deprecation of the args node, preserved for backward compatibility) - Args templates are available in exec nodes (command body, `exec.env` values, `exec.workdir`) ### REQ-DATA-003: Multiple Inputs @@ -87,7 +76,7 @@ Runestone is an Obsidian plugin. It allows users to build and execute workflows ### REQ-GRAPH-001: Canvas JSON Parsing - Parse `.canvas` file `nodes` and `edges` to build the graph -- Canvas nodes of `type: "file"` are treated as workflow nodes (exec, script, condition, args) +- Canvas nodes of `type: "file"` are treated as workflow nodes (exec, script, condition) - Canvas nodes of `type: "text"` are treated as workflow nodes only when their trimmed content equals the literal `runestone:start` or `runestone:end` - All other Canvas nodes are excluded from the workflow graph diff --git a/package-lock.json b/package-lock.json index 0784dcb..fcfafc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-plugin-runestone", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "obsidian-plugin-runestone", - "version": "0.1.0", + "version": "0.2.0", "license": "0-BSD", "dependencies": { "obsidian": "latest" diff --git a/skills/runestone-workflow/SKILL.md b/skills/runestone-workflow/SKILL.md index 2f93cab..03108a4 100644 --- a/skills/runestone-workflow/SKILL.md +++ b/skills/runestone-workflow/SKILL.md @@ -3,7 +3,7 @@ name: runestone-workflow description: > Create and modify Runestone workflows for Obsidian Canvas. Use when the user asks to build, edit, or extend visual workflows - with exec, script, condition, or args nodes on Obsidian Canvas files. + with exec, script, or condition nodes on Obsidian Canvas files. Handles .canvas JSON files and node .md files with runestone frontmatter. triggers: - "create a workflow" @@ -45,8 +45,6 @@ Things to know beyond the references: - Recommended spacing: 300px horizontal, 200px vertical - Layout convention: left-to-right, top-to-bottom flow - Node/edge IDs: random lowercase hex string (16 characters) -- Args node code blocks support `await` for asynchronous operations (e.g., `app.vault.read()`) since they are executed via `AsyncFunction`. This is useful for dynamically building candidate lists or reading vault content at execution time -- Prefer placing args nodes above their target script/condition node, connected with `fromSide: "bottom"`, `toSide: "top"`. When layout constraints require it (e.g., args feeding multiple distant nodes), `toSide: "left"` is acceptable. This keeps args connections separate from the main left-to-right flow - Do not set `color` on canvas nodes — Runestone uses colors for execution status visualization Minimal canvas JSON skeleton for quick reference: @@ -99,8 +97,7 @@ After any operation, verify: - **Start node templates**: start nodes must not use template syntax (`{{input...}}`) - **Cycle exit**: every cycle has a condition node with an exit edge leading outside - **Condition edges**: at least one labeled outgoing edge; at most one unlabeled (default) edge -- **Code block presence**: exec, script, condition, and args nodes must contain a code block -- **Args isolation**: args nodes have no incoming edges; must have at least one outgoing edge; not connected to exec or other args nodes +- **Code block presence**: exec, script, and condition nodes must contain a code block - **Edge consistency**: all `fromNode`/`toNode` values reference existing node IDs - **File existence**: all node `file` paths point to existing `.md` files @@ -115,4 +112,3 @@ Available templates: - `exec-node.md` - `script-node.md` - `condition-node.md` -- `args-node.md` diff --git a/skills/runestone-workflow/assets/templates/args-node.md b/skills/runestone-workflow/assets/templates/args-node.md deleted file mode 100644 index 4532847..0000000 --- a/skills/runestone-workflow/assets/templates/args-node.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -runestone.type: args ---- - -```javascript -``` diff --git a/skills/runestone-workflow/references/reusable-node-patterns.md b/skills/runestone-workflow/references/reusable-node-patterns.md index 1de1bc6..6e6aefa 100644 --- a/skills/runestone-workflow/references/reusable-node-patterns.md +++ b/skills/runestone-workflow/references/reusable-node-patterns.md @@ -4,23 +4,24 @@ This document describes design patterns for building reusable nodes that can be ## Concept -A single `.md` file can be referenced from multiple canvas nodes. By injecting configuration through args nodes, the same script node can behave differently in each context. This avoids duplicating logic across workflows. +A single `.md` file can be referenced from multiple canvas nodes. By injecting configuration through upstream configuration script nodes, the same script node can behave differently in each context. This avoids duplicating logic across workflows. ## Key Patterns -### Parameterized Output Key (`args.key`) +### Parameterized Output Key (`config.key`) -Reusable nodes should not hardcode output field names. Instead, accept an output key name via `args.key` (or similar parameter) so the caller controls which field the result is stored under. +Reusable nodes should not hardcode output field names. Instead, accept an output key name via a configuration input (e.g. `config.key`) so the caller controls which field the result is stored under. ```js -// args node: configure the output key +// config node (upstream script node): configure the output key return { key: "title" }; ``` ```js -// script node: use args.key to set the output field name +// reusable script node: use configuration to set the output field name +const config = input.find(x => x.key) || {}; const value = /* ... compute result ... */; -return { ...(input[0] || {}), [args.key]: value }; +return { ...input[0], [config.key]: value }; ``` ### Input Accumulation @@ -28,7 +29,7 @@ return { ...(input[0] || {}), [args.key]: value }; When chaining multiple reusable nodes in sequence, each node must preserve upstream data. Without this, earlier values are lost. Use the spread pattern to merge previous input with the new value: ```js -return { ...(input[0] || {}), [args.key]: value }; +return { ...input[0], [config.key]: value }; ``` This ensures that a pipeline of reusable nodes accumulates all collected values into a single object passed downstream. @@ -52,4 +53,4 @@ Reusable nodes live in `nodes/` (or a similar shared directory), while workflow- ## Example -The [para-note workflow](https://github.com/handlename/obsidian-plugin-runestone/tree/main/vault.example/workflows/para-note) demonstrates these patterns. It uses `prompt-dialog` and `suggest-dialog` script nodes with args nodes that inject configuration (dialog title, placeholder text, selectable items). The args.key pattern allows the same dialog node to store its result under different field names depending on the workflow context. +The [para-note workflow](https://github.com/handlename/obsidian-plugin-runestone/tree/main/vault.example/workflows/para-note) demonstrates these patterns. It uses `prompt-dialog` and `suggest-dialog` script nodes with configuration script nodes that inject configuration (dialog title, placeholder text, selectable items). The config key pattern allows the same dialog node to store its result under different field names depending on the workflow context. diff --git a/skills/runestone-workflow/references/workflow-examples.md b/skills/runestone-workflow/references/workflow-examples.md index c8fbed7..b772974 100644 --- a/skills/runestone-workflow/references/workflow-examples.md +++ b/skills/runestone-workflow/references/workflow-examples.md @@ -257,13 +257,13 @@ return `[${timestamp}] Health check completed with status: ${result.status}`; --- -## Example 3: With Args +## Example 3: With Upstream Config -A workflow where an args node supplies configuration (target directory and file extension) to a script node that counts matching files. An exec node starts the workflow and produces the initial file list. +A workflow where an upstream config node (script node) supplies configuration (target directory and file extension) to a script node that counts matching files. An exec node starts the workflow and produces the initial file list. Both nodes converge at the filter node. ### Node Files -#### `workflows/with-args/list-files.md` +#### `workflows/with-config/list-files.md` ```md --- @@ -278,11 +278,12 @@ echo "{\"files\": $FILES}" ``` ``` -#### `workflows/with-args/search-config.md` +#### `workflows/with-config/search-config.md` ```md --- -runestone.type: args +runestone.type: script +runestone.onError: stop --- ```javascript @@ -294,7 +295,7 @@ return { ``` ``` -#### `workflows/with-args/filter-files.md` +#### `workflows/with-config/filter-files.md` ```md --- @@ -303,10 +304,13 @@ runestone.onError: stop --- ```javascript -// input[0] is the JSON output from the exec node (file listing) -// args contains the configuration from the args node -const { files } = input[0]; -const { extension, maxResults } = args; +// input is an array containing outputs from both upstream nodes +const fileInput = input.find(x => x.files); +const configInput = input.find(x => x.extension); + +const files = fileInput ? fileInput.files : []; +const extension = configInput ? configInput.extension : ".md"; +const maxResults = configInput ? configInput.maxResults : 10; const filtered = files .filter(f => f.endsWith(extension)) @@ -328,7 +332,7 @@ return { { "id": "a9b0c1d2e3f4a5b6", "type": "file", - "file": "workflows/with-args/list-files.md", + "file": "workflows/with-config/list-files.md", "x": 0, "y": 0, "width": 250, @@ -337,7 +341,7 @@ return { { "id": "b0c1d2e3f4a5b6c7", "type": "file", - "file": "workflows/with-args/search-config.md", + "file": "workflows/with-config/search-config.md", "x": 0, "y": 120, "width": 250, @@ -346,7 +350,7 @@ return { { "id": "c1d2e3f4a5b6c7d8", "type": "file", - "file": "workflows/with-args/filter-files.md", + "file": "workflows/with-config/filter-files.md", "x": 300, "y": 0, "width": 250, diff --git a/src/commands/run-canvas.ts b/src/commands/run-canvas.ts index 334dd06..f9ea941 100644 --- a/src/commands/run-canvas.ts +++ b/src/commands/run-canvas.ts @@ -7,7 +7,6 @@ import { executeWorkflow, MarkerLifecycleEvent } from "../engine/executor"; import { runExecNode, ExecContext } from "../engine/node-runners/exec-runner"; import { runScriptNode } from "../engine/node-runners/script-runner"; import { runConditionNode } from "../engine/node-runners/condition-runner"; -import { runArgsNode } from "../engine/node-runners/args-runner"; import { NodeStatus, NodeResult, WorkflowNode, WorkflowEdge, ConditionResult, isWorkflowNode } from "../types"; import { createExecutionState, updateExecutionState } from "../ui/execution-state"; import { CanvasVisualizer } from "../ui/canvas-visualizer"; @@ -86,9 +85,6 @@ async function executeCanvasWorkflow( if (node.config.type === "exec") { return runExecNode(node, input, execContext, args); } - if (node.config.type === "args") { - return runArgsNode(node, app, obsidian); - } return runScriptNode(node, input, app, obsidian, args); }, runConditionNode: async (node: WorkflowNode, input: readonly unknown[], outEdges: readonly WorkflowEdge[], args: Readonly>): Promise => { diff --git a/src/engine/executor.test.ts b/src/engine/executor.test.ts index a4c4125..8d1eee5 100644 --- a/src/engine/executor.test.ts +++ b/src/engine/executor.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { executeWorkflow, MarkerLifecycleEvent, WorkflowCallbacks } from "./executor"; import { GraphNode, MarkerNode, WorkflowGraph, WorkflowNode, WorkflowEdge } from "../types"; -function makeNode(id: string, type: "exec" | "script" | "condition" | "args", body = "", onError: "stop" | "continue" = "stop"): WorkflowNode { +function makeNode(id: string, type: "exec" | "script" | "condition", body = "", onError: "stop" | "continue" = "stop"): WorkflowNode { return { id, filePath: `${id}.md`, config: { type, onError }, body }; } @@ -299,55 +299,6 @@ describe("executeWorkflow", () => { expect(skipped).toContain("b"); }); - it("executes join after condition when non-selected branches have args edges", async () => { - // Workflow: start → cond → (a | b) → join, with args → a and args → b - // When cond selects "a", node "b" has a dismissed condition edge - // AND a completed args edge. "b" should still be skipped. - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("cond", "condition", "```js\nreturn 'a';\n```"), - makeNode("cfg", "args", "```js\nreturn {x:1};\n```"), - makeNode("a", "script"), - makeNode("b", "script"), - makeNode("join", "script"), - ], - [ - makeEdge("e1", "start", "cond"), - makeEdge("e2", "cond", "a", "a"), - makeEdge("e3", "cond", "b", "b"), - makeEdge("e4", "a", "join"), - makeEdge("e5", "b", "join"), - makeEdge("e6", "cfg", "a"), - makeEdge("e7", "cfg", "b"), - ], - "start", - ); - const executed: string[] = []; - const skipped: string[] = []; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input) => { - executed.push(node.id); - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - runConditionNode: async (node, input, outEdges) => ({ - nodeId: node.id, - status: "success", - output: input, - selectedEdgeId: outEdges.find((e) => e.label === "a")?.id, - durationMs: 1, - }), - onNodeStatusChange: (nodeId, status) => { - if (status === "skipped") skipped.push(nodeId); - }, - }), { maxCycleIterations: 1000 }); - - expect(executed).toContain("a"); - expect(executed).toContain("join"); - expect(executed).not.toContain("b"); - expect(skipped).toContain("b"); - }); - it("passes NodeResult to onNodeStatusChange for terminal states", async () => { const graph = makeGraph( [makeNode("a", "exec"), makeNode("b", "exec")], @@ -579,227 +530,7 @@ describe("executeWorkflow", () => { expect(completedEdgeIds).not.toContain("e3"); }); - describe("args node execution", () => { - it("executes args node in parallel with start node", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "start", "target"), - makeEdge("e2", "myargs", "target"), - ], - "start", - ); - const executed: string[] = []; - const results = await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - executed.push(node.id); - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - expect(executed).toContain("start"); - expect(executed).toContain("myargs"); - expect(executed).toContain("target"); - expect(results.every((r) => r.status === "success")).toBe(true); - }); - - it("passes args as separate parameter, not in input array", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "start", "target"), - makeEdge("e2", "myargs", "target"), - ], - "start", - ); - let capturedInput: readonly unknown[] = []; - let capturedArgs: Record = {}; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "target") { - capturedInput = input; - capturedArgs = args; - } - if (node.id === "myargs") return { nodeId: node.id, status: "success", output: { x: 1 }, durationMs: 1 }; - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - - expect(capturedInput).toHaveLength(1); - expect(capturedInput[0]).toEqual({ from: "start" }); - expect(capturedArgs).toEqual({ x: 1 }); - }); - - it("merges multiple args nodes into single object", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("args1", "args"), - makeNode("args2", "args"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "start", "target"), - makeEdge("e2", "args1", "target"), - makeEdge("e3", "args2", "target"), - ], - "start", - ); - let capturedArgs: Record = {}; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "args1") return { nodeId: node.id, status: "success", output: { x: 1 }, durationMs: 1 }; - if (node.id === "args2") return { nodeId: node.id, status: "success", output: { y: 2 }, durationMs: 1 }; - if (node.id === "target") capturedArgs = args; - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - - expect(capturedArgs).toEqual({ x: 1, y: 2 }); - }); - - it("passes empty args when no args node is connected", async () => { - const graph = makeGraph( - [makeNode("a", "exec"), makeNode("b", "exec")], - [makeEdge("e1", "a", "b")], - "a", - ); - let capturedArgs: Record = {}; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "b") capturedArgs = args; - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - - expect(capturedArgs).toEqual({}); - }); - - it("skips target when args node fails with onError: continue", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("myargs", "args", "", "continue"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "start", "target"), - makeEdge("e2", "myargs", "target"), - ], - "start", - ); - const results = await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "myargs") { - return { nodeId: node.id, status: "failure", error: "args failed", durationMs: 1 }; - } - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - - expect(results.find((r) => r.nodeId === "myargs")!.status).toBe("failure"); - expect(results.find((r) => r.nodeId === "target")!.status).toBe("skipped"); - }); - - it("stops entire workflow when args node fails with onError: stop", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("myargs", "args"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "start", "target"), - makeEdge("e2", "myargs", "target"), - ], - "start", - ); - const results = await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "myargs") { - return { nodeId: node.id, status: "failure", error: "args failed", durationMs: 1 }; - } - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - - expect(results.find((r) => r.nodeId === "myargs")!.status).toBe("failure"); - expect(results.find((r) => r.nodeId === "target")!.status).toBe("skipped"); - }); - - it("executes args nodes for startNodeIdOverride target", async () => { - const graph = makeGraph( - [ - makeNode("a", "exec"), - makeNode("b", "exec"), - makeNode("myargs", "args"), - makeNode("c", "script"), - ], - [ - makeEdge("e1", "a", "b"), - makeEdge("e2", "b", "c"), - makeEdge("e3", "myargs", "c"), - ], - "a", - ); - const executed: string[] = []; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - executed.push(node.id); - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000, startNodeIdOverride: "b" }); - - expect(executed).toContain("myargs"); - expect(executed).toContain("b"); - expect(executed).toContain("c"); - expect(executed).not.toContain("a"); - }); - - it("passes args to condition node callback", async () => { - const graph = makeGraph( - [ - makeNode("start", "exec"), - makeNode("myargs", "args"), - makeNode("cond", "condition", "```js\nreturn 'yes';\n```"), - makeNode("target", "exec"), - ], - [ - makeEdge("e1", "start", "cond"), - makeEdge("e2", "myargs", "cond"), - makeEdge("e3", "cond", "target", "yes"), - ], - "start", - ); - let capturedArgs: Record = {}; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - if (node.id === "myargs") return { nodeId: node.id, status: "success", output: { x: 1 }, durationMs: 1 }; - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - runConditionNode: async (node, input, outEdges, args) => { - capturedArgs = args; - return { - nodeId: node.id, - status: "success", - output: input, - selectedEdgeId: outEdges.find((e) => e.label === "yes")?.id, - durationMs: 1, - }; - }, - }), { maxCycleIterations: 1000 }); - - expect(capturedArgs).toEqual({ x: 1 }); - }); - }); describe("start/end marker nodes", () => { // C1: start marker as entry, orphan ignored @@ -935,37 +666,7 @@ describe("executeWorkflow", () => { expect(executed).not.toContain("c"); }); - // C6: args nodes execute independently of start marker - it("executes args nodes alongside start marker", async () => { - const graph = makeGraph( - [ - makeMarker("s", "start"), - makeNode("myargs", "args"), - makeNode("target", "script"), - ], - [ - makeEdge("e1", "s", "target"), - makeEdge("e2", "myargs", "target"), - ], - "s", - ); - const executed: string[] = []; - let capturedArgs: Record = {}; - await executeWorkflow(graph, mockCallbacks({ - runNode: async (node, input, args) => { - executed.push(node.id); - if (node.id === "myargs") { - return { nodeId: node.id, status: "success", output: { x: 1 }, durationMs: 1 }; - } - if (node.id === "target") capturedArgs = args; - return { nodeId: node.id, status: "success", output: { from: node.id }, durationMs: 1 }; - }, - }), { maxCycleIterations: 1000 }); - expect(executed).toContain("myargs"); - expect(executed).toContain("target"); - expect(capturedArgs).toEqual({ x: 1 }); - }); // C7: start marker with multiple outgoing edges => parallel execution it("schedules multiple successors of start in parallel", async () => { diff --git a/src/engine/executor.ts b/src/engine/executor.ts index b1bf247..1d5e09c 100644 --- a/src/engine/executor.ts +++ b/src/engine/executor.ts @@ -141,36 +141,7 @@ export async function executeWorkflow( return; } - if (node.config.type === "args") { - const edges = outgoingEdges.get(nodeId) ?? []; - for (const edge of edges) { - dismissedEdges.add(edge.id); - } - for (const edge of edges) { - const targetId = edge.toNode; - const targetIncoming = incomingEdges.get(targetId) ?? []; - const allSatisfied = targetIncoming.every((e) => completedEdges.has(e.id) || dismissedEdges.has(e.id)); - if (allSatisfied) { - const anyArgsEdgeDismissed = targetIncoming.some((e) => { - const sourceNode = graph.nodes.get(e.fromNode); - if (!sourceNode || !isWorkflowNode(sourceNode)) return false; - if (sourceNode.config.type !== "args") return false; - return dismissedEdges.has(e.id); - }); - if (anyArgsEdgeDismissed) { - const skipResult: NodeResult = { - nodeId: targetId, - status: "skipped", - durationMs: 0, - }; - results.set(targetId, skipResult); - callbacks.onNodeStatusChange(targetId, "skipped", skipResult); - skipDownstream(targetId, graph, outgoingEdges, results, callbacks); - } - } - } - return; - } + skipDownstream(nodeId, graph, outgoingEdges, results, callbacks); return; @@ -205,18 +176,7 @@ export async function executeWorkflow( callbacks.onEdgeCompleted?.(edge.id); const targetId = edge.toNode; const targetIncoming = incomingEdges.get(targetId) ?? []; - - if (node.config.type === "args") { - const currentArgs = nodeArgs.get(targetId) ?? {}; - const argsOutput = result.output as Record ?? {}; - const existingKeys = Object.keys(currentArgs); - for (const key of Object.keys(argsOutput)) { - if (existingKeys.includes(key)) { - console.warn(`[Runestone] Args key "${key}" overwritten for node "${targetId}"`); - } - } - nodeArgs.set(targetId, { ...currentArgs, ...argsOutput }); - } else if (node.config.type === "condition" && Array.isArray(result.output)) { + if (node.config.type === "condition" && Array.isArray(result.output)) { const inputs = nodeInputs.get(targetId) ?? []; inputs.push(...(result.output as unknown[])); nodeInputs.set(targetId, inputs); @@ -239,14 +199,7 @@ export async function executeWorkflow( return upstreamResult && (upstreamResult.status === "failure" || upstreamResult.status === "skipped"); }); - const anyArgsEdgeDismissed = targetIncoming.some((e) => { - const sourceNode = graph.nodes.get(e.fromNode); - if (!sourceNode || !isWorkflowNode(sourceNode)) return false; - if (sourceNode.config.type !== "args") return false; - return dismissedEdges.has(e.id); - }); - - if (anyFailed || anyArgsEdgeDismissed) { + if (anyFailed) { const skipResult: NodeResult = { nodeId: targetId, status: "skipped", @@ -271,13 +224,7 @@ export async function executeWorkflow( const isStartMarker = !!startMarker && isMarkerNode(startMarker) && startMarker.type === "start"; const usingMarkerAsEntry = isStartMarker && effectiveStartNodeId === graph.startNodeId; - const argsNodeIds: string[] = []; - for (const node of graph.nodes.values()) { - if (!isWorkflowNode(node)) continue; - if (node.config.type === "args") { - argsNodeIds.push(node.id); - } - } + const startPromises: Promise[] = []; if (usingMarkerAsEntry) { @@ -286,9 +233,7 @@ export async function executeWorkflow( } else { startPromises.push(executeNode(effectiveStartNodeId, [])); } - for (const argsId of argsNodeIds) { - startPromises.push(executeNode(argsId, [])); - } + await Promise.all(startPromises); if (usingMarkerAsEntry) { @@ -358,8 +303,6 @@ function propagateConditionDismissals( const targetIncoming = incomingEdges.get(targetId) ?? []; const allExecutionEdgesDismissed = targetIncoming.every((e) => { - const sourceNode = graph.nodes.get(e.fromNode); - if (sourceNode && isWorkflowNode(sourceNode) && sourceNode.config.type === "args") return true; return dismissedEdges.has(e.id); }); if (!allExecutionEdgesDismissed) continue; diff --git a/src/engine/node-runners/args-runner.test.ts b/src/engine/node-runners/args-runner.test.ts deleted file mode 100644 index e8b5be0..0000000 --- a/src/engine/node-runners/args-runner.test.ts +++ /dev/null @@ -1,81 +0,0 @@ -import { describe, it, expect } from "vitest"; -import { runArgsNode } from "./args-runner"; -import { WorkflowNode } from "../../types"; - -function makeArgsNode(code: string, onError: "stop" | "continue" = "stop"): WorkflowNode { - return { - id: "test-args", - filePath: "test-args.md", - config: { type: "args", onError }, - body: `\`\`\`js\n${code}\n\`\`\``, - }; -} - -describe("runArgsNode", () => { - it("executes code and returns object result", async () => { - const node = makeArgsNode('return { items: ["a", "b"] };'); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("success"); - expect(result.output).toEqual({ items: ["a", "b"] }); - }); - - it("has access to app parameter", async () => { - const node = makeArgsNode("return { hasApp: typeof app !== 'undefined' };"); - const result = await runArgsNode(node, { mock: true }, {}); - expect(result.status).toBe("success"); - expect(result.output).toEqual({ hasApp: true }); - }); - - it("fails when return value is not an object", async () => { - const node = makeArgsNode('return "not an object";'); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("failure"); - expect(result.error).toContain("object"); - }); - - it("fails when return value is an array", async () => { - const node = makeArgsNode("return [1, 2, 3];"); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("failure"); - expect(result.error).toContain("object"); - }); - - it("fails when return value is null", async () => { - const node = makeArgsNode("return null;"); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("failure"); - expect(result.error).toContain("object"); - }); - - it("fails when no code block found", async () => { - const node: WorkflowNode = { - id: "test-args", - filePath: "test-args.md", - config: { type: "args", onError: "stop" }, - body: "no code block", - }; - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("failure"); - expect(result.error).toContain("code block"); - }); - - it("fails on thrown error", async () => { - const node = makeArgsNode('throw new Error("boom");'); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("failure"); - expect(result.error).toContain("boom"); - }); - - it("supports async code", async () => { - const node = makeArgsNode("const x = await Promise.resolve(42); return { value: x };"); - const result = await runArgsNode(node, {}, {}); - expect(result.status).toBe("success"); - expect(result.output).toEqual({ value: 42 }); - }); - - it("records duration", async () => { - const node = makeArgsNode("return {};"); - const result = await runArgsNode(node, {}, {}); - expect(result.durationMs).toBeGreaterThanOrEqual(0); - }); -}); diff --git a/src/engine/node-runners/args-runner.ts b/src/engine/node-runners/args-runner.ts deleted file mode 100644 index 69fee3d..0000000 --- a/src/engine/node-runners/args-runner.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { NodeResult, WorkflowNode } from "../../types"; -import { extractCodeBlock } from "../../graph/parser"; - -export async function runArgsNode( - node: WorkflowNode, - app: unknown, - obsidianModule: unknown = {}, -): Promise { - const startTime = Date.now(); - try { - const code = extractCodeBlock(node.body); - if (!code) { - return { - nodeId: node.id, - status: "failure", - error: `No code block found in args node "${node.id}" (${node.filePath})`, - durationMs: Date.now() - startTime, - }; - } - - const AsyncFunction = (async function () {}).constructor as - new (...args: string[]) => (...args: unknown[]) => Promise; - - const fn = new AsyncFunction("app", "obsidian", code); - const result = await fn(app, obsidianModule); - - if (result === null || result === undefined || typeof result !== "object" || Array.isArray(result)) { - return { - nodeId: node.id, - status: "failure", - error: `Args node "${node.id}" (${node.filePath}) must return a plain object, got ${Array.isArray(result) ? "array" : String(result)}`, - durationMs: Date.now() - startTime, - }; - } - - return { - nodeId: node.id, - status: "success", - output: result, - durationMs: Date.now() - startTime, - }; - } catch (e) { - return { - nodeId: node.id, - status: "failure", - error: e instanceof Error ? e.message : String(e), - durationMs: Date.now() - startTime, - }; - } -} diff --git a/src/graph/parser.ts b/src/graph/parser.ts index 5db4471..1c35385 100644 --- a/src/graph/parser.ts +++ b/src/graph/parser.ts @@ -1,6 +1,6 @@ import { CanvasData, MarkerNodeType, RunestoneConfig, RunestoneNodeType } from "../types"; -const VALID_NODE_TYPES: readonly string[] = ["exec", "script", "condition", "args"]; +const VALID_NODE_TYPES: readonly string[] = ["exec", "script", "condition"]; const CODE_BLOCK_RE = /```[^\n]*\n([\s\S]*?)```/; const START_MARKER = "runestone:start"; diff --git a/src/graph/validator.test.ts b/src/graph/validator.test.ts index f4a79c1..67fe861 100644 --- a/src/graph/validator.test.ts +++ b/src/graph/validator.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { validate } from "./validator"; import { GraphNode, MarkerNode, ParsedGraph, WorkflowNode, WorkflowEdge } from "../types"; -function makeNode(id: string, type: "exec" | "script" | "condition" | "args", body = ""): WorkflowNode { +function makeNode(id: string, type: "exec" | "script" | "condition", body = ""): WorkflowNode { return { id, filePath: `${id}.md`, @@ -317,21 +317,7 @@ describe("validate", () => { } }); - it("fails when start-adjacent node uses args template syntax", () => { - const graph = makeGraphWithStart( - "a", - [ - { ...makeNode("a", "exec"), body: "echo {{args.name}}" }, - makeNode("b", "exec"), - ], - [makeEdge("e1", "a", "b")], - ); - const result = validate(graph); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.some((e) => e.includes("template") || e.includes("input"))).toBe(true); - } - }); + it("fails when start-adjacent node uses template syntax in exec config", () => { const node: WorkflowNode = { @@ -427,163 +413,5 @@ describe("validate", () => { expect(result.ok).toBe(false); }); - describe("args node validation", () => { - it("permits args nodes alongside the start marker", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "myargs", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(true); - if (result.ok) { - expect(result.graph.startNodeId).toBe("__start__"); - } - }); - - it("fails when args node has incoming edges", () => { - const graph = makeGraphWithStart( - "myargs", - [ - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "myargs", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.some((e) => e.includes("args") && e.includes("incoming"))).toBe(true); - } - }); - - it("fails when args node connects to another args node", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("args1", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("args2", "args", "```js\nreturn { y: 2 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "args1", "args2"), - makeEdge("e3", "args2", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.some((e) => e.includes("args") && e.includes("args"))).toBe(true); - } - }); - - it("allows args node to connect to exec node", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "exec"), - ], - [ - makeEdge("e2", "myargs", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(true); - }); - it("fails when args node has no outgoing edges", () => { - const graph = makeGraphWithStart( - "start", - [ - makeNode("start", "exec"), - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - ], - [], - ); - const result = validate(graph); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.some((e) => e.includes("args") && e.includes("outgoing"))).toBe(true); - } - }); - - it("fails when args node has no code block", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("myargs", "args", "no code block"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "myargs", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.errors.some((e) => e.includes("code block"))).toBe(true); - } - }); - - it("succeeds with valid args node connected to script node", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "myargs", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(true); - }); - - it("succeeds with args node connected to condition node", () => { - const condBody = "```js\nreturn 'yes';\n```"; - const graph = makeGraphWithStart( - "cond", - [ - makeNode("myargs", "args", "```js\nreturn { x: 1 };\n```"), - { - ...makeNode("cond", "condition", condBody), - config: { type: "condition" as const, onError: "stop" as const }, - }, - makeNode("target", "exec"), - ], - [ - makeEdge("e2", "myargs", "cond"), - makeEdge("e3", "cond", "target", "yes"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(true); - }); - - it("succeeds with multiple args nodes to same target", () => { - const graph = makeGraphWithStart( - "target", - [ - makeNode("args1", "args", "```js\nreturn { x: 1 };\n```"), - makeNode("args2", "args", "```js\nreturn { y: 2 };\n```"), - makeNode("target", "script"), - ], - [ - makeEdge("e2", "args1", "target"), - makeEdge("e3", "args2", "target"), - ], - ); - const result = validate(graph); - expect(result.ok).toBe(true); - }); - }); }); diff --git a/src/graph/validator.ts b/src/graph/validator.ts index 55ad855..b8ed804 100644 --- a/src/graph/validator.ts +++ b/src/graph/validator.ts @@ -1,7 +1,7 @@ import { GraphNode, ParsedGraph, WorkflowGraph, isMarkerNode, isWorkflowNode } from "../types"; import { extractCodeBlock } from "./parser"; -const TEMPLATE_RE = /\{\{(?:input|args)/; +const TEMPLATE_RE = /\{\{input/; export type ValidationResult = | { readonly ok: true; readonly graph: WorkflowGraph } @@ -129,36 +129,7 @@ export function validate(graph: ParsedGraph): ValidationResult { } } - if (node.config.type === "args") { - const incoming = incomingCount.get(node.id) ?? 0; - if (incoming > 0) { - errors.push( - `Args node "${node.id}" (${node.filePath}) must not have incoming edges`, - ); - } - - const outEdges = outgoingEdges.get(node.id) ?? []; - if (outEdges.length === 0) { - errors.push( - `Args node "${node.id}" (${node.filePath}) must have at least one outgoing edge`, - ); - } - - for (const edge of outEdges) { - const targetNode = graph.nodes.get(edge.toNode); - if (targetNode && isWorkflowNode(targetNode) && targetNode.config.type === "args") { - errors.push( - `Args node "${node.id}" (${node.filePath}) must not connect to another args node "${edge.toNode}"`, - ); - } - } - if (!extractCodeBlock(node.body)) { - errors.push( - `Args node "${node.id}" (${node.filePath}) must have a code block in the note body`, - ); - } - } } // Cycle exit diff --git a/src/types.ts b/src/types.ts index 44dd471..27b0f95 100644 --- a/src/types.ts +++ b/src/types.ts @@ -31,7 +31,7 @@ export interface CanvasEdge { // Internal types -export type RunestoneNodeType = "exec" | "script" | "condition" | "args" | "start" | "end"; +export type RunestoneNodeType = "exec" | "script" | "condition" | "start" | "end"; export type MarkerNodeType = "start" | "end"; diff --git a/vault.example/workflows/args-exec-example/args-exec-example.canvas b/vault.example/workflows/args-exec-example/args-exec-example.canvas deleted file mode 100644 index fcbb57c..0000000 --- a/vault.example/workflows/args-exec-example/args-exec-example.canvas +++ /dev/null @@ -1,11 +0,0 @@ -{ - "nodes":[ - {"id":"ae03","type":"file","file":"workflows/args-exec-example/greet.md","x":100,"y":0,"width":300,"height":300}, - {"id":"ae02","type":"file","file":"workflows/args-exec-example/config.md","x":100,"y":-380,"width":300,"height":300}, - {"id":"ae00","type":"text","text":"runestone:start","x":-320,"y":110,"width":250,"height":80} - ], - "edges":[ - {"id":"aee02","fromNode":"ae02","fromSide":"bottom","toNode":"ae03","toSide":"top"}, - {"id":"39830732cdd229b7","fromNode":"ae00","fromSide":"right","toNode":"ae03","toSide":"left"} - ] -} \ No newline at end of file diff --git a/vault.example/workflows/args-exec-example/config.md b/vault.example/workflows/args-exec-example/config.md deleted file mode 100644 index 410b39d..0000000 --- a/vault.example/workflows/args-exec-example/config.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -runestone.type: args ---- - -```js -return { - greeting: "hello", - target: "world", -}; -``` diff --git a/vault.example/workflows/args-exec-example/greet.md b/vault.example/workflows/args-exec-example/greet.md deleted file mode 100644 index bc0792f..0000000 --- a/vault.example/workflows/args-exec-example/greet.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -runestone.type: exec ---- - -```bash -echo '{"message": "{{args.greeting}} {{args.target}}"}' -``` diff --git a/vault.example/workflows/para-note/create-note.md b/vault.example/workflows/para-note/create-note.md index d212acd..d05d633 100644 --- a/vault.example/workflows/para-note/create-note.md +++ b/vault.example/workflows/para-note/create-note.md @@ -31,13 +31,14 @@ if (fmMatch) { const templateBody = fmMatch ? fmMatch[2] : templateContent; // Step 2: Build merged frontmatter. -// Merge order (later wins): template defaults → args common → computed values. +// Merge order (later wins): template defaults → input config common → computed values. // - Template frontmatter: type-specific keys defined in the template file -// - args.commonFrontmatter: shared keys injected via note-config args node +// - input config commonFrontmatter: shared keys injected via note-config script node // - Computed values: title, para, created_at set by this script +const noteConfig = input.find(x => x.commonFrontmatter) || {}; const merged = { ...templateFrontmatter, - ...(args.commonFrontmatter || {}), + ...(noteConfig.commonFrontmatter || {}), title: `"${title}"`, para: `"${paraType}"`, created_at: `"${new Date().toISOString()}"`, diff --git a/vault.example/workflows/para-note/note-config.md b/vault.example/workflows/para-note/note-config.md index a0a4cef..5d4ad50 100644 --- a/vault.example/workflows/para-note/note-config.md +++ b/vault.example/workflows/para-note/note-config.md @@ -1,5 +1,5 @@ --- -runestone.type: args +runestone.type: script --- ```js diff --git a/vault.example/workflows/para-note/prompt-config.md b/vault.example/workflows/para-note/prompt-config.md index 7fddc49..80d665d 100644 --- a/vault.example/workflows/para-note/prompt-config.md +++ b/vault.example/workflows/para-note/prompt-config.md @@ -1,5 +1,5 @@ --- -runestone.type: args +runestone.type: script --- ```js diff --git a/vault.example/workflows/para-note/prompt-dialog.md b/vault.example/workflows/para-note/prompt-dialog.md index 8f34f41..5f96a2c 100644 --- a/vault.example/workflows/para-note/prompt-dialog.md +++ b/vault.example/workflows/para-note/prompt-dialog.md @@ -3,27 +3,27 @@ runestone.type: script --- ```javascript -// args.title: dialog title text -// args.placeholder: input placeholder text +// config: { title, placeholder } passed from upstream config script node +const config = input.find(x => x.title) || {}; return new Promise((resolve) => { const modal = new obsidian.Modal(app); - modal.titleEl.setText(args.title); + modal.titleEl.setText(config.title || "Enter value"); - const input = modal.contentEl.createEl("input", { + const inputEl = modal.contentEl.createEl("input", { type: "text", - placeholder: args.placeholder || "", + placeholder: config.placeholder || "", }); - input.style.width = "100%"; + inputEl.style.width = "100%"; - input.addEventListener("keydown", (e) => { + inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter") { modal.close(); - resolve({ value: input.value }); + resolve({ value: inputEl.value }); } }); - modal.onClose = () => resolve({ value: input.value }); + modal.onClose = () => resolve({ value: inputEl.value }); modal.open(); - input.focus(); + inputEl.focus(); }); ``` diff --git a/vault.example/workflows/para-note/suggest-config.md b/vault.example/workflows/para-note/suggest-config.md index f67ed61..303f772 100644 --- a/vault.example/workflows/para-note/suggest-config.md +++ b/vault.example/workflows/para-note/suggest-config.md @@ -1,5 +1,5 @@ --- -runestone.type: args +runestone.type: script --- ```js diff --git a/vault.example/workflows/para-note/suggest-dialog.md b/vault.example/workflows/para-note/suggest-dialog.md index 26bc31b..7f96902 100644 --- a/vault.example/workflows/para-note/suggest-dialog.md +++ b/vault.example/workflows/para-note/suggest-dialog.md @@ -3,13 +3,13 @@ runestone.type: script --- ```javascript -// args.title: dialog placeholder text -// args.items: array of suggestion strings -// input[0].value: value from the previous prompt-dialog +// config: { title, items } passed from upstream config script node +const config = input.find(x => x.items) || {}; +const previousPrompt = input.find(x => x.value) || {}; return new Promise((resolve) => { const modal = new (class extends obsidian.SuggestModal { getSuggestions(query) { - return args.items.filter((item) => + return (config.items || []).filter((item) => item.toLowerCase().includes(query.toLowerCase()) ); } @@ -17,10 +17,10 @@ return new Promise((resolve) => { el.setText(item); } onChooseSuggestion(item) { - resolve({ title: input[0].value, paraType: item }); + resolve({ title: previousPrompt.value, paraType: item }); } })(app); - modal.setPlaceholder(args.title); + modal.setPlaceholder(config.title || "Select item"); modal.open(); }); ```