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
18 changes: 18 additions & 0 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,17 @@
Last in the block, and post-migration only: it removes rows and
creates nothing, so no step depends on it, and a fresh install
has nothing to remove. -->
<!-- retire-integriq-flow-schema task 2: writes the OpenRegister
nodes/edges graph onto every legacy flow object, IN PLACE and
ADDITIVELY (steps stays as the rollback shape, the two engines
dual-run through the migration window). Idempotent: an object
already carrying nodes is skipped, never overwritten; a flow
the translator refuses is logged and keeps running on
FlowRunnerService. Placed after InitializeRegister so the flow
schema (with its new nodes/edges properties) exists. The occ
command integriq:flow:steps-to-graph re-runs or rolls back the
same migration on demand. -->
<step>OCA\Integriq\Repair\MigrateFlowStepsToGraph</step>
<step>OCA\Integriq\Repair\RemoveRetiredCronJobs</step>
<!-- Written but never registered, so it had never run once: a class
that exists is not a class that runs. It moves openconnector
Expand Down Expand Up @@ -374,6 +385,13 @@
schema or method of its own and an object trigger refuses a partial
scope. WRITES NOTHING; the emitted document ships disabled. -->
<command>OCA\Integriq\Command\RuleToFlow</command>
<!-- retire-integriq-flow-schema task 2: drives the steps-to-graph flow
migration by hand. Dry run by default; the apply flag writes the
graph in place, the rollback flag removes it again (steps is kept
through both directions, so rollback is always possible).
(XML comments must not contain a double hyphen, so the flags are
not written with their leading dashes here.) -->
<command>OCA\Integriq\Command\FlowStepsToGraph</command>
</commands>

<settings>
Expand Down
1 change: 1 addition & 0 deletions docs/features/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ Integriq is an API gateway and integration hub for Nextcloud. It brings enterpri
| [Reliability](reliability.md) | Retry policy, per-Source circuit breaker, sync dead letters | Implemented |
| [Rules](rules.md) | Authentication, file handling, locking, and audit trail rules | Implemented |
| [Jobs](jobs.md) | Cron-based scheduled task execution | Implemented |
| [Flow nodes](flow-nodes.md) | Contributed step types for OpenRegister's flow engine | Implemented |
| [Events & Webhooks](events.md) | CloudEvents emission, subscription, and consumer processing | Implemented |
| [Logging & Monitoring](logging.md) | Call logs, sync logs, and Prometheus metrics | Implemented |
| [Configuration Management](configuration-management.md) | Import/export, configuration groups, slug-based references | Implemented |
Expand Down
99 changes: 99 additions & 0 deletions docs/features/flow-nodes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Flow nodes

## Overview

OpenRegister runs the fleet's one flow engine. Integriq does not run its own graphs. It contributes step types, so a flow can do what Integriq is good at: call an API, run a synchronization, apply a mapping, ask a person, emit an event.

You build the flow in OpenRegister's flow editor. The Integriq steps appear in the palette when both apps are enabled.

| Node | What the step does |
|------|--------------------|
| `openconnector.source-call` | Make one governed API call per item through a configured Source |
| `openconnector.synchronization-run` | Run a configured Synchronization and hand each synchronised object onward |
| `openconnector.source-paginate` | Fetch one page of objects from a Source |
| `openconnector.apply-mapping` | Apply a configured Mapping to every item |
| `openconnector.contract` / `contract-commit` / `contract-sweep` | The decomposed synchronization's contract steps |
| `openconnector.fetch-file` | Fetch a file referenced by an item |
| `openconnector.approval-request` | Pause the run until someone approves or rejects |
| `openconnector.event-emit` | Emit a CloudEvent for every item |

## Call an API from a flow

Add a `source-call` step. Pick a Source, give it a path and a method:

```json
{
"id": "step-apply-label",
"type": "openconnector.source-call",
"config": {
"source": "demo-forge-api",
"endpoint": "/issues/{{issue.number}}/labels",
"method": "POST",
"body": { "labels": ["{{triage.proposedLabel}}"] },
"output": "labelResult"
}
}
```

The step runs once per item. `{{dotted.path}}` placeholders resolve from each item's record, and the response lands under the key you name in `output`. The call goes through `CallService`, so the Source's enablement, host guard, rate limits and call logging all apply unchanged.

## Why there is no raw-URL node

You cannot type a URL into a flow step. The step names a Source, and the endpoint is a path inside that Source's location. An absolute URL, a `//host` path or a `../` escape is rejected before any request goes out.

This is the whole security model, not a missing convenience. A Source is where an administrator decides which hosts may be called, how often, and with which credential. A URL field in a flow document would hand that decision to every flow author and turn the editor into a request forger. If a host is worth calling, give it a Source first.

Credentials follow the same line. A step has no token field. Authentication comes from the Source's `credentialRef`, resolved by the credential broker at call time. No secret ever sits in a flow document.

## Why an unattributed run fails closed

Every call runs as the flow run's owner, read from the run context. When no owner resolves, the step refuses and raises. There is no fallback to an admin, to the Source's creator, or to nobody.

An anonymous authenticated outbound call is the failure we refuse to ship. A loud error names the gap; a silent fallback hides it behind someone else's identity.

## Ask a person: the approval step

`openconnector.approval-request` parks the run and creates a pending approval request. The approvers see it on the Pending approvals page and in their shared task list, like every other Integriq approval.

```json
{
"id": "approve-publish",
"type": "openconnector.approval-request",
"config": {
"question": "Publish this dataset?",
"approverGroup": "data-stewards",
"ttlSeconds": 86400
}
}
```

- **Approved.** The run resumes. The decision, the approver and the comment land on every item under `approval`, so a later step can route on them.
- **Rejected.** By default the run continues and your reject edge reads `approval.decision`. Set `failOnReject: true` when a no should fail the run.
- **Expired.** The run fails. An approval nobody answered never counts as answered.

An answer wakes the run immediately. If that wake-up is ever lost, the step re-checks the approval request itself on its next heartbeat, so a decision is never stranded.

## Emit an event

`openconnector.event-emit` sends one CloudEvent per item through the existing event pipeline. Name a `type` and a `source`, and subscriptions pick it up exactly as they would for any other Integriq event.

## Migrate old step-list flows

Flows built in Integriq's earlier step-list editor still exist as ordered `steps[]`. One command translates them onto the engine's graph shape:

```bash
occ integriq:flow:steps-to-graph # dry run: reports what would happen
occ integriq:flow:steps-to-graph --apply # writes nodes/edges onto each flow
```

The migration is additive and repeatable. `steps` stays on the object, a flow that already carries `nodes` is skipped, and a flow the translator cannot express faithfully is refused with the reasons listed. The same pass also runs automatically on upgrade.

Changed your mind? Roll it back:

```bash
occ integriq:flow:steps-to-graph --rollback --apply
```

## Next steps

Create a [Source](sources.md) for the API you want to call, then open OpenRegister's flow editor and add a `source-call` step against it.
33 changes: 32 additions & 1 deletion l10n/nl.js
Original file line number Diff line number Diff line change
Expand Up @@ -1516,7 +1516,38 @@ OC.L10N.register(
"Open the documentation to keep going": "Open de documentatie om verder te gaan",
"Where the automation lives": "Waar de automatisering zit",
"Flows are what happens without anyone clicking: a synchronization that starts on a schedule, a webhook that fires when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een synchronisatie die op een schema start, een webhook die afgaat wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.",
"Open Flows in the menu": "Open Flows in het menu"
"Open Flows in the menu": "Open Flows in het menu",
"Ask for approval": "Vraag om goedkeuring",
"Pause the flow until someone in the approver group approves or rejects. An expired request fails the run.": "Pauzeer de flow tot iemand uit de goedkeurdersgroep goedkeurt of afwijst. Een verlopen verzoek laat de run mislukken.",
"What is being asked": "Wat wordt er gevraagd",
"Shown to the approvers and written on the request, so a paused flow explains itself.": "Getoond aan de goedkeurders en vastgelegd op het verzoek, zodat een gepauzeerde flow zichzelf uitlegt.",
"Members of this group (and admins) may answer. Required: a request nobody owns is a request nobody answers.": "Leden van deze groep (en beheerders) mogen antwoorden. Verplicht: een verzoek zonder eigenaar wordt nooit beantwoord.",
"Expires after (seconds)": "Verloopt na (seconden)",
"An unanswered request expires and fails the run. Defaults to 24 hours.": "Een onbeantwoord verzoek verloopt en laat de run mislukken. Standaard 24 uur.",
"Treat a rejection as a failure": "Behandel een afwijzing als een fout",
"Off by default: a \"no\" continues the flow with the decision on the items, so a later step can route on it.": "Standaard uit: een \"nee\" laat de flow doorgaan met het besluit op de items, zodat een latere stap erop kan routeren.",
"Field to store the decision in": "Veld waarin het besluit wordt opgeslagen",
"The decision is written onto every item under this field. Defaults to \"approval\".": "Het besluit wordt onder dit veld op elk item geschreven. Standaard \"approval\".",
"Re-check every (minutes)": "Controleer opnieuw elke (minuten)",
"Safety net for a lost answer. Lower is not faster: a decision wakes the run immediately either way.": "Vangnet voor een verloren antwoord. Lager is niet sneller: een besluit wekt de run hoe dan ook direct.",
"Say what is being asked (\"question\"), or nobody can answer it.": "Zeg wat er wordt gevraagd (\"question\"), anders kan niemand antwoorden.",
"Name the approver group (\"approverGroup\"): an approval without an audience never resolves.": "Noem de goedkeurdersgroep (\"approverGroup\"): een goedkeuring zonder publiek wordt nooit afgerond.",
"The \"ttlSeconds\" field must be a positive number of seconds when set.": "Het veld \"ttlSeconds\" moet een positief aantal seconden zijn wanneer het is ingesteld.",
"The approval step has no resume slot; the engine did not dispatch it as a resumable node.": "De goedkeuringsstap heeft geen hervattingsslot; de engine heeft hem niet als hervatbare stap aangeroepen.",
"This run carries no uuid, so an approval could never answer it. The approval step is only usable in a persisted flow run.": "Deze run heeft geen uuid, dus een goedkeuring zou hem nooit kunnen beantwoorden. De goedkeuringsstap werkt alleen in een opgeslagen flowrun.",
"Emit an event": "Verstuur een gebeurtenis",
"Emit a CloudEvent for every item, delivered through the configured event subscriptions.": "Verstuur een CloudEvent voor elk item, afgeleverd via de geconfigureerde abonnementen.",
"The CloudEvent \"type\", e.g. \"nl.example.object.updated\". Subscriptions match on it.": "Het CloudEvent-\"type\", bijvoorbeeld \"nl.example.object.updated\". Abonnementen matchen erop.",
"Event source": "Gebeurtenisbron",
"The CloudEvent \"source\" URI identifying the emitter.": "De CloudEvent-\"source\"-URI die de verzender identificeert.",
"Subject": "Onderwerp",
"Optional CloudEvent \"subject\". Supports {{dotted.path}} placeholders resolved from each item.": "Optioneel CloudEvent-\"subject\". Ondersteunt {{dotted.path}}-plaatshouders, opgelost per item.",
"Output key": "Uitvoersleutel",
"Item key the emit summary is written under. Defaults to \"eventResult\".": "Itemsleutel waaronder de verstuursamenvatting wordt geschreven. Standaard \"eventResult\".",
"Name the event \"type\": an event without a type matches no subscription.": "Noem het gebeurtenis-\"type\": een gebeurtenis zonder type matcht geen enkel abonnement.",
"Name the event \"source\": a CloudEvent must say where it came from.": "Noem de gebeurtenis-\"source\": een CloudEvent moet zeggen waar hij vandaan komt.",
"Step \"%1$s\" failed to emit event \"%2$s\": %3$s": "Stap \"%1$s\" kon gebeurtenis \"%2$s\" niet versturen: %3$s",
"The flow \"%1$s\" cannot be migrated to a graph yet: %2$s unsupported feature(s).": "De flow \"%1$s\" kan nog niet naar een graaf worden gemigreerd: %2$s niet-ondersteunde functie(s)."
},
"nplurals=2; plural=(n != 1);"
)
33 changes: 32 additions & 1 deletion l10n/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -1515,7 +1515,38 @@
"Open the documentation to keep going": "Open de documentatie om verder te gaan",
"Where the automation lives": "Waar de automatisering zit",
"Flows are what happens without anyone clicking: a synchronization that starts on a schedule, a webhook that fires when a record changes. This is where you read and edit them. Nothing to build now.": "Flows zijn wat er gebeurt zonder dat iemand klikt: een synchronisatie die op een schema start, een webhook die afgaat wanneer een record verandert. Hier leest en bewerkt u ze. U hoeft nu niets te bouwen.",
"Open Flows in the menu": "Open Flows in het menu"
"Open Flows in the menu": "Open Flows in het menu",
"Ask for approval": "Vraag om goedkeuring",
"Pause the flow until someone in the approver group approves or rejects. An expired request fails the run.": "Pauzeer de flow tot iemand uit de goedkeurdersgroep goedkeurt of afwijst. Een verlopen verzoek laat de run mislukken.",
"What is being asked": "Wat wordt er gevraagd",
"Shown to the approvers and written on the request, so a paused flow explains itself.": "Getoond aan de goedkeurders en vastgelegd op het verzoek, zodat een gepauzeerde flow zichzelf uitlegt.",
"Members of this group (and admins) may answer. Required: a request nobody owns is a request nobody answers.": "Leden van deze groep (en beheerders) mogen antwoorden. Verplicht: een verzoek zonder eigenaar wordt nooit beantwoord.",
"Expires after (seconds)": "Verloopt na (seconden)",
"An unanswered request expires and fails the run. Defaults to 24 hours.": "Een onbeantwoord verzoek verloopt en laat de run mislukken. Standaard 24 uur.",
"Treat a rejection as a failure": "Behandel een afwijzing als een fout",
"Off by default: a \"no\" continues the flow with the decision on the items, so a later step can route on it.": "Standaard uit: een \"nee\" laat de flow doorgaan met het besluit op de items, zodat een latere stap erop kan routeren.",
"Field to store the decision in": "Veld waarin het besluit wordt opgeslagen",
"The decision is written onto every item under this field. Defaults to \"approval\".": "Het besluit wordt onder dit veld op elk item geschreven. Standaard \"approval\".",
"Re-check every (minutes)": "Controleer opnieuw elke (minuten)",
"Safety net for a lost answer. Lower is not faster: a decision wakes the run immediately either way.": "Vangnet voor een verloren antwoord. Lager is niet sneller: een besluit wekt de run hoe dan ook direct.",
"Say what is being asked (\"question\"), or nobody can answer it.": "Zeg wat er wordt gevraagd (\"question\"), anders kan niemand antwoorden.",
"Name the approver group (\"approverGroup\"): an approval without an audience never resolves.": "Noem de goedkeurdersgroep (\"approverGroup\"): een goedkeuring zonder publiek wordt nooit afgerond.",
"The \"ttlSeconds\" field must be a positive number of seconds when set.": "Het veld \"ttlSeconds\" moet een positief aantal seconden zijn wanneer het is ingesteld.",
"The approval step has no resume slot; the engine did not dispatch it as a resumable node.": "De goedkeuringsstap heeft geen hervattingsslot; de engine heeft hem niet als hervatbare stap aangeroepen.",
"This run carries no uuid, so an approval could never answer it. The approval step is only usable in a persisted flow run.": "Deze run heeft geen uuid, dus een goedkeuring zou hem nooit kunnen beantwoorden. De goedkeuringsstap werkt alleen in een opgeslagen flowrun.",
"Emit an event": "Verstuur een gebeurtenis",
"Emit a CloudEvent for every item, delivered through the configured event subscriptions.": "Verstuur een CloudEvent voor elk item, afgeleverd via de geconfigureerde abonnementen.",
"The CloudEvent \"type\", e.g. \"nl.example.object.updated\". Subscriptions match on it.": "Het CloudEvent-\"type\", bijvoorbeeld \"nl.example.object.updated\". Abonnementen matchen erop.",
"Event source": "Gebeurtenisbron",
"The CloudEvent \"source\" URI identifying the emitter.": "De CloudEvent-\"source\"-URI die de verzender identificeert.",
"Subject": "Onderwerp",
"Optional CloudEvent \"subject\". Supports {{dotted.path}} placeholders resolved from each item.": "Optioneel CloudEvent-\"subject\". Ondersteunt {{dotted.path}}-plaatshouders, opgelost per item.",
"Output key": "Uitvoersleutel",
"Item key the emit summary is written under. Defaults to \"eventResult\".": "Itemsleutel waaronder de verstuursamenvatting wordt geschreven. Standaard \"eventResult\".",
"Name the event \"type\": an event without a type matches no subscription.": "Noem het gebeurtenis-\"type\": een gebeurtenis zonder type matcht geen enkel abonnement.",
"Name the event \"source\": a CloudEvent must say where it came from.": "Noem de gebeurtenis-\"source\": een CloudEvent moet zeggen waar hij vandaan komt.",
"Step \"%1$s\" failed to emit event \"%2$s\": %3$s": "Stap \"%1$s\" kon gebeurtenis \"%2$s\" niet versturen: %3$s",
"The flow \"%1$s\" cannot be migrated to a graph yet: %2$s unsupported feature(s).": "De flow \"%1$s\" kan nog niet naar een graaf worden gemigreerd: %2$s niet-ondersteunde functie(s)."
},
"plurals": {}
}
Loading
Loading