Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .changeset/flow-loop-per-iteration-containment-lint.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
---
"@objectstack/lint": patch
---

flows: warn on a `loop` body with a fallible node and no containment, and on a `try_catch` with no `catch` (#14394)

Two authoring-time rules in the flow anti-pattern family, both `warning`:

- **`flow-loop-body-uncontained`** — a `loop` whose `body` region runs a node
that can end the run (a record read/write, `http`, `notify`,
`connector_action`, `script`, `subflow`, `map`, `approval`) with no
`try_catch` between the loop and that node. The `loop` executor iterates with
a bare `await` and has no `try`/`catch` at all, so the first failing item ends
the whole run: later items are never processed, and the work already done is
not even reported. The finding names the loop, the node, and the prescribed
spelling.
- **`flow-try-catch-without-catch`** — the near-miss, and the first target
rather than an extra: `catch` is optional in the schema, and omitting it makes
the container fail through, so an author who wrapped the node and stopped
there gets **zero** containment and previously got no diagnostic either.
Measured, the no-`catch` run and the unwrapped control produce identical
output; a `retry` policy only delays that.

Both stay warnings under the family's severity bar: a loop deliberately allowed
to stop at the first failure, and a retry-then-fail `try_catch`, are legitimate
readings the rule cannot disprove.

`content/docs/automation/flows.mdx` documents `loop { try_catch { … } }` as the
per-iteration containment spelling, with the measured minimal handler — one bare
`assignment` node, `edges` and `errorVariable` omitted — and the three `catch`
spellings the schema refuses (`catch` omitted gives no containment; `catch: {}`
and `catch: { nodes: [] }` are rejected, the region's `nodes` being `.min(1)`).

No spec, engine or runtime change: the containment capability already exists and
was measured working (5 of 5 iterations, items 4-5 processed, run completes).
89 changes: 88 additions & 1 deletion content/docs/automation/flows.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -411,7 +411,15 @@ Runs its `body` region once per item of a collection, binding the current item
maxIterations: 500, // hard cap (clamped to the engine ceiling)
body: { // single-entry/single-exit region
nodes: [
{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } },
// Per-iteration containment — see the subsection below. A body node that
// can fail, wrapped in nothing, ends the WHOLE run at the first failure.
{
id: 'guard', type: 'try_catch', label: 'Guarded iteration',
config: {
try: { nodes: [{ id: 'send', type: 'script', label: 'Notify', config: { /* … */ } }], edges: [] },
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
Expand All@@ -422,6 +430,77 @@ Runs its `body` region once per item of a collection, binding the current item
A `loop` node with **no `body`** keeps the legacy flat-graph behavior — the
container is additive.

#### Per-iteration containment: `loop { try_catch { … } }`

A `loop` body has **no error handling of its own**. The container iterates with a
bare `await`, so a body node that returns `success: false` (or throws) propagates
straight out of the loop and ends the run: every later item is never processed,
and the work already done is not even reported. Measured on the engine — a 5-item
sweep whose 3rd item fails touched 3 items, reported `acted: 0`, and finished
`status: failed`.

The containment spelling is a `try_catch` **inside the body**, one per iteration.
Measured with the same 5-item sweep: all 5 iterations run, items 4 and 5 are
processed, and the run completes.

```typescript
{
id: 'each_case',
type: 'loop',
label: 'For each breached case',
config: {
collection: '{cases}',
iteratorVariable: 'currentCase',
body: {
nodes: [
{
id: 'guard',
type: 'try_catch',
label: 'Guarded iteration',
config: {
try: {
nodes: [
{
id: 'notify_owner', type: 'notify', label: 'Notify owner',
config: { title: 'SLA breach', recipients: ['{currentCase.owner}'] },
},
],
edges: [],
},
// The shortest handler that works: ONE bare `assignment` node with no
// `config` at all. `edges` omitted; `errorVariable` omitted (it
// defaults to `$error`).
catch: { nodes: [{ id: 'handled', type: 'assignment', label: 'Handled' }] },
},
},
],
edges: [],
},
},
}
```

**A `catch` region cannot be empty.** `FlowRegionSchema.nodes` is `.min(1)`, so
only the last row below is usable:

| `catch` spelling | result |
|:---|:---|
| omitted entirely | parses — and contains **nothing**: the container fails through exactly like an unwrapped node |
| `catch: {}` | rejected — `catch.nodes`: expected array, received undefined |
| `catch: { nodes: [] }` | rejected — `catch.nodes`: too small, expected at least 1 item |
| `catch: { nodes: [ …one node… ] }` | parses, and contains |

Two authoring-time lint rules cover this pair (both warnings, so neither fails a
build): `flow-loop-body-uncontained` names a loop body running a node that can
fail with no `try_catch` between the loop and it, and
`flow-try-catch-without-catch` names the near-miss — a `try_catch` whose `catch`
is absent, which gives **zero** containment while looking like containment. A
`retry` policy does not substitute: it re-runs the `try` region and then fails
anyway.

Deliberately letting the sweep stop at the first failure is a legitimate choice —
that is why both rules warn rather than gate.

### Parallel block

Declares N branch regions that run **concurrently** and **join implicitly** when
Expand DownExpand Up@@ -465,6 +544,14 @@ events).
}
```

`catch` is optional in the schema, and omitting it is the trap: with no `catch`
the container **fails** when the `try` region fails, so the failure propagates
exactly as if nothing had been wrapped (measured — the no-`catch` run and the
unwrapped control produce identical output). `retry` only delays that. The
`flow-try-catch-without-catch` lint rule names the shape at authoring time; the
minimal handler is one bare `assignment` node, as shown under "Per-iteration
containment" above.

> BPMN `parallel_gateway` / `join_gateway` / `boundary_event` remain in the
> protocol as the **interop** representation and map onto these constructs on
> import/export — they are not the native authoring model.
Expand Down
2 changes: 2 additions & 0 deletions packages/lint/src/index.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -734,6 +734,8 @@ export {
FLOW_MULTIPLE_DEFAULT_EDGES,
FLOW_INERT_NODE_CONDITION,
FLOW_MULTI_WRITE_UNFILTERED,
FLOW_LOOP_BODY_UNCONTAINED,
FLOW_TRY_CATCH_WITHOUT_CATCH,
} from './lint-flow-patterns.js';

export { lintLivenessProperties } from './lint-liveness-properties.js';
Expand Down
Loading
Loading