@protobase/cli 0.67.11 → 0.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -88,7 +88,7 @@ const buildConfigBundle = async ({ projectDir, outFile, bun, extensions = [], co
88
88
  collectComponents(components),
89
89
  extensionImports(projectDir, extensions),
90
90
  servedImports(native),
91
- entry(file, bundleEntryCode(projectDir, extensions, config.files?.images ? [config.files.images] : []))
91
+ entry(file, bundleEntryCode(projectDir, extensions, [...config.files?.images ? [config.files.images] : [], ...config.workflows?.plugins ?? []]))
92
92
  ];
93
93
  await viteBundle({
94
94
  root: projectDir,
@@ -1,4 +1,4 @@
1
1
  //#region package.json
2
- var version = "0.67.11";
2
+ var version = "0.68.0";
3
3
  //#endregion
4
4
  export { version };
@@ -0,0 +1,49 @@
1
+ import { findPackageDirSync, packageName } from "../build/packages/package-dir.js";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ //#region src/config/package-entry.ts
5
+ const conditions = [
6
+ "node",
7
+ "import",
8
+ "default"
9
+ ];
10
+ const targetOf = (target) => {
11
+ if (target === void 0 || target === null) return void 0;
12
+ if (typeof target === "string") return target;
13
+ if (Array.isArray(target)) return target.map(targetOf).find((found) => found !== void 0);
14
+ const condition = Object.keys(target).find((key) => conditions.includes(key));
15
+ return condition === void 0 ? void 0 : targetOf(target[condition]);
16
+ };
17
+ const exportOf = (exported, subpath) => {
18
+ if (typeof exported === "string" || Array.isArray(exported) || exported === null) return subpath === "." ? targetOf(exported) : void 0;
19
+ if (!Object.keys(exported).every((key) => key.startsWith("."))) return subpath === "." ? targetOf(exported) : void 0;
20
+ return targetOf(exported[subpath]);
21
+ };
22
+ const mainOf = (dir, main = "index.js") => {
23
+ const found = [
24
+ main,
25
+ `${main}.js`,
26
+ path.join(main, "index.js")
27
+ ].map((file) => path.join(dir, file)).find((file) => existsSync(file));
28
+ if (!found) throw new Error(`The package in ${dir} has no ${main}`);
29
+ return found;
30
+ };
31
+ /**
32
+ * The file an `import` of `id`, such as `sharp` or `@acme/plugin/steps`, loads from `fromDir`, as Node's ES module
33
+ * resolution finds it: through the package's `exports` with the `import` conditions, otherwise its `main` or
34
+ * `index.js`.
35
+ */
36
+ const packageEntry = (id, fromDir) => {
37
+ const name = packageName(id);
38
+ if (!name) throw new Error(`${id} is no package name`);
39
+ const dir = findPackageDirSync(name, fromDir);
40
+ if (!dir) throw new Error(`Cannot find the package ${name} from ${fromDir}; install it`);
41
+ const manifest = JSON.parse(readFileSync(path.join(dir, "package.json"), "utf8"));
42
+ const subpath = `.${id.slice(name.length)}`;
43
+ if (manifest.exports === void 0) return subpath === "." ? mainOf(dir, manifest.main) : path.join(dir, subpath);
44
+ const target = exportOf(manifest.exports, subpath);
45
+ if (!target) throw new Error(`The package ${name} exports no ${subpath} to import`);
46
+ return path.join(dir, target);
47
+ };
48
+ //#endregion
49
+ export { packageEntry };
@@ -1,14 +1,15 @@
1
1
  import { conventionConfig } from "../project/convention.js";
2
2
  import { uiDirs } from "../project/convention-files.js";
3
3
  import { functionFiles } from "../project/functions-folder.js";
4
- import { createRequire } from "node:module";
4
+ import { packageEntry } from "./package-entry.js";
5
5
  import { existsSync, readdirSync } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { pathToFileURL } from "node:url";
8
8
  //#region src/config/project-files.ts
9
+ const folderFiles = (projectDir, folder) => existsSync(path.join(projectDir, folder)) ? readdirSync(path.join(projectDir, folder)).filter((name) => name.endsWith(".ts") && !/\.(test|d)\.ts$/.test(name)).map((name) => `${folder}/${name}`).sort() : [];
9
10
  /** The files the conventions name besides resources and functions, which a bundle carries for its serve runtime. */
10
11
  const conventionModules = (projectDir) => {
11
- const inFolder = (folder) => existsSync(path.join(projectDir, folder)) ? readdirSync(path.join(projectDir, folder)).filter((name) => name.endsWith(".ts") && !/\.(test|d)\.ts$/.test(name)).map((name) => `${folder}/${name}`) : [];
12
+ const inFolder = (folder) => folderFiles(projectDir, folder);
12
13
  const files = [
13
14
  "auth/hooks.ts",
14
15
  "auth/authenticate.ts",
@@ -26,6 +27,7 @@ const conventionModules = (projectDir) => {
26
27
  ...inFolder("jobs"),
27
28
  ...inFolder("hooks"),
28
29
  ...inFolder("files"),
30
+ ...inFolder("workflows/plugins"),
29
31
  ...files.filter((file) => existsSync(path.join(projectDir, file)))
30
32
  ].sort();
31
33
  };
@@ -42,9 +44,10 @@ const sourceFiles = (projectDir, importModules) => {
42
44
  } },
43
45
  functions: Object.fromEntries(functionFiles(projectDir).map(({ name, file }) => [name, one(file)])),
44
46
  module: (file) => existsSync(path.join(projectDir, file)) ? one(path.join(projectDir, file)) : void 0,
47
+ folder: (folder) => folderFiles(projectDir, folder),
45
48
  package: (name) => import(
46
49
  /* @vite-ignore */
47
- pathToFileURL(createRequire(path.join(projectDir, "package.json")).resolve(name)).href
50
+ pathToFileURL(packageEntry(name, projectDir)).href
48
51
  )
49
52
  };
50
53
  };
@@ -192,13 +192,28 @@ const projectWebhooks = async (config) => {
192
192
  ...ownerRoles && { ownerRoles }
193
193
  };
194
194
  };
195
- /** The workflows options, with the roles of workflows/saved-by-roles.ts when a config's folder has it. */
195
+ const workflowPlugins = async (config, packages) => {
196
+ const project = config.sources.at(-1).files;
197
+ const fromPackages = await Promise.all(packages.map(async (name) => ({
198
+ plugin: (await project.package(name)).default,
199
+ source: name
200
+ })));
201
+ const fromFolders = await Promise.all(config.sources.flatMap(({ files, label }) => files.folder("workflows/plugins").map(async (file) => ({
202
+ plugin: (await files.module(file)()).default,
203
+ source: `${file} of ${label}`
204
+ }))));
205
+ return [...fromPackages, ...fromFolders];
206
+ };
207
+ /** The workflows options, with the roles of workflows/saved-by-roles.ts when a config's folder has it, and the app's plugins. */
196
208
  const projectWorkflows = async (config) => {
197
209
  if (!config.workflows) return void 0;
210
+ const { plugins: packages = [], ...options } = config.workflows;
198
211
  const savedByRoles = await lastDefault(config, "workflows/saved-by-roles.ts");
212
+ const plugins = await workflowPlugins(config, packages);
199
213
  return {
200
- ...config.workflows,
201
- ...savedByRoles && { savedByRoles }
214
+ ...options,
215
+ ...savedByRoles && { savedByRoles },
216
+ ...plugins.length > 0 && { plugins }
202
217
  };
203
218
  };
204
219
  /**
@@ -6,6 +6,7 @@
6
6
  "@protobase/query",
7
7
  "@protobase/server",
8
8
  "@protobase/server/pdf",
9
+ "@protobase/workflows/plugin",
9
10
  "@protobase/server/email",
10
11
  "kysely",
11
12
  "kysely/helpers/postgres",
@@ -8,6 +8,7 @@ const hostModuleIds = [
8
8
  "@protobase/query",
9
9
  "@protobase/server",
10
10
  "@protobase/server/pdf",
11
+ "@protobase/workflows/plugin",
11
12
  ...lazyHostModuleIds,
12
13
  "kysely",
13
14
  "kysely/helpers/postgres",
@@ -21,6 +21,7 @@ import * as reactJsxRuntime from "react/jsx-runtime";
21
21
  import * as zod from "zod";
22
22
  import * as protobaseConfig from "@protobase/config";
23
23
  import * as pdf from "@protobase/server/pdf";
24
+ import * as workflowPlugin from "@protobase/workflows/plugin";
24
25
  //#region src/serve/host-modules.ts
25
26
  const hostModules = {
26
27
  "@protobase/config": protobaseConfig,
@@ -30,6 +31,7 @@ const hostModules = {
30
31
  "@protobase/query": query,
31
32
  "@protobase/server": server,
32
33
  "@protobase/server/pdf": pdf,
34
+ "@protobase/workflows/plugin": workflowPlugin,
33
35
  kysely,
34
36
  "kysely/helpers/postgres": kyselyPostgres,
35
37
  pg: pg$1,
@@ -15,6 +15,7 @@ const projectFiles = ({ resources, functions, modules }, packages) => ({
15
15
  ...resources && { resources },
16
16
  functions,
17
17
  module: (file) => modules[file],
18
+ folder: (folder) => Object.keys(modules).filter((file) => file.startsWith(`${folder}/`) && !file.slice(folder.length + 1).includes("/")).sort(),
18
19
  package: async (name) => {
19
20
  const load = packages[name];
20
21
  if (!load) throw new Error(`The bundle carries no ${name}; build it again`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protobase/cli",
3
- "version": "0.67.11",
3
+ "version": "0.68.0",
4
4
  "description": "The protobase command: scaffold, doctor, dev, build and serve a Protobase project.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,12 +43,13 @@
43
43
  "dependencies": {
44
44
  "@better-auth/core": "^1.7.12",
45
45
  "@hono/node-server": "^2.1.3",
46
- "@protobase/config": "0.67.11",
47
- "@protobase/layout": "0.67.11",
48
- "@protobase/query": "0.67.11",
49
- "@protobase/schema": "0.67.11",
50
- "@protobase/server": "0.67.11",
51
- "@protobase/ui": "0.67.11",
46
+ "@protobase/config": "0.68.0",
47
+ "@protobase/layout": "0.68.0",
48
+ "@protobase/query": "0.68.0",
49
+ "@protobase/schema": "0.68.0",
50
+ "@protobase/server": "0.68.0",
51
+ "@protobase/ui": "0.68.0",
52
+ "@protobase/workflows": "0.68.0",
52
53
  "@react-email/render": "^2.1.0",
53
54
  "@scalar/hono-api-reference": "^0.12.10",
54
55
  "@sentry/vite-plugin": "^11.10.0",
@@ -89,6 +89,7 @@ Reference:
89
89
  - When letting people without an account create records → read [Public forms](references/reference/forms.md)
90
90
  - When running background jobs → read [Jobs](references/reference/jobs.md)
91
91
  - When writing or running workflows, their steps and their runs → read [Workflows](references/reference/workflows.md)
92
+ - When adding trigger or step types to workflows → read [Workflow plugins](references/reference/workflow-plugins.md)
92
93
  - When sending email → read [Email](references/reference/email.md)
93
94
  - When making a PDF file, such as an invoice, a quote or a report → read [PDF documents](references/reference/pdf.md)
94
95
  - When registering users' devices for push notifications → read [Notifications](references/reference/notifications.md)
@@ -11,6 +11,7 @@ The page of one record, with its sections, related records, galleries, charts an
11
11
  - [Unsaved edits](../reference/ui-config.md#editing-a-record): Unsaved changes to a record stay in the browser and can be resumed, and leaving a form, a dialog or a comment with changes asks first.
12
12
  - [Record forms](../reference/ui-config.md#editing-a-record): Relations are picked from a list that narrows as you type, numbers are read as the browser's language writes them, and a refused save says under each field what is wrong.
13
13
  - [Titles per reader](../reference/ui-config.md#titles-per-reader): A record without a name of its own, such as a direct message, is titled for each reader from its fields, such as after the other members, in the sidebar, breadcrumbs, heading, browser tab, relation links, list columns, relation pickers, filters and search.
14
+ - [JSON fields on forms](../reference/plugins.md#json) (beta): A JSON field, such as a workflow run's input, is edited on forms as JSON text and shown written out on several lines.
14
15
  - [Member fields](../reference/plugins.md#members): A field that holds a list of people, such as a private channel's members, picks the organization's members by name on forms and shows them by avatar and name.
15
16
  - [Save feedback](../reference/ui-config.md#the-record-page): A view shows saving on the Save button instead of in a toast.
16
17
  - [Save conflicts](../reference/ui-config.md#editing-a-record): When someone else changed a record while you edited it, Save shows their value beside yours for each field you both changed, and you keep yours or take theirs.
@@ -7,12 +7,20 @@ Graphs of steps the server runs along their edges, written in a language a model
7
7
  - [Workflows](../reference/workflows.md#turning-them-on) (beta): Workflows kept in the app's database, each changed definition checked and kept as a version, run on the app's own jobs.
8
8
  - [Workflow language](../reference/workflows.md#the-document) (beta): The States Language with JSONata expressions, every value read by name, so a model writes and edits it as a small, local change.
9
9
  - [Checked on save](../reference/workflows.md#reading-values-by-name) (beta): A definition that does not fit the language, has an edge to no step, reads a value nothing gives or leaves out an argument its step type needs is refused, naming the step and the field.
10
- - [Workflow editor](../reference/workflows.md#the-editor) (beta): The admin app draws a workflow as a graph that lays itself out, to add, connect and remove steps and fill in each step's form, with every problem the save would refuse shown on its step as it is edited.
10
+ - [Workflow editor](../reference/workflows.md#the-editor) (beta): The admin app draws a workflow as a graph that lays itself out, to add, connect and remove steps of every trigger and step type of the app, its plugins' included, each with a form for its input, with every problem the save would refuse shown on its step as it is edited.
11
11
  - [Branches, steps per item and retries](../reference/workflows.md#states) (beta): Choices, a step per item with their results merged, waits, and per-step retries, timeouts and error paths.
12
12
  - [HTTP request step](../reference/workflows.md#apphttp) (beta): Calls a URL built at run time through the outgoing-address guard, only at public addresses unless the config allows private ones, reading at most 10 MiB of the answer as JSON, text or a file.
13
13
  - [Records steps](../reference/workflows.md#apprecords) (beta): Create, update, get and list records as the workflow's last editor, with the roles they hold now.
14
14
  - [Mail step](../reference/workflows.md#appmailsend) (beta): Sends mail, with files the run downloaded attached, from the app's own sender.
15
- - [Runs and step records](../reference/workflows.md#runs-and-their-steps) (beta): Each run started by hand through the API or from the app's code, with every step's input, output, logs, timing, tries and error recorded, secrets blanked out.
15
+ - [Runs and step records](../reference/workflows.md#runs-and-their-steps) (beta): Each run started by hand through the API, from the app's code or by a trigger, with every step's input, output, logs, timing, tries and error recorded, secrets blanked out.
16
+ - [Run debugger](../reference/workflows.md#following-and-debugging-a-run) (beta): Each workflow lists its runs with their status, who started them, when and for how long; a run's page marks every step's status on the workflow's graph, live while it runs, with each step's input, output, logs, timing, tries and error, secrets blanked out.
17
+ - [Re-run from the failed step](../reference/workflows.md#re-running-a-failed-run) (beta): Once the workflow is fixed, a failed run runs again from the step that failed, keeping what the steps before it gave; a test run starts the workflow with a sample input from its page.
16
18
  - [Runs that survive restarts](../reference/workflows.md#how-a-run-is-run) (beta): A run takes up where it stood after a restart or a crashed worker, never running a saved step again, and every limit fails closed.
17
19
  - [Who may run workflows](../reference/workflows.md#who-may-do-what) (beta): Making, changing and running workflows and reading their runs are each granted by name, within the tenant.
18
- - [Workflows over MCP](../reference/mcp.md#workflows) (beta): AI clients find, write, validate, change by a small edit and test-run workflows as the signed-in person, and read each step of a run to fix the one that failed.
20
+ - [Workflows over MCP](../reference/mcp.md#workflows) (beta): AI clients find, write, validate, change by a small edit and test-run workflows as the signed-in person, and read each step of a run to fix the one that failed and re-run it from there.
21
+ - [Record triggers](../reference/workflows.md#apprecord) (beta): A workflow starts once for each record that becomes ready, at once or when a time in it comes, read as the user who saved the enabled version.
22
+ - [Triggers turned on while the app runs](../reference/workflows.md#turning-a-trigger-on) (beta): Turning a workflow on or changing it registers its trigger on every server process, and turning it off stops it, with no restart.
23
+ - [Workflow plugins](../reference/workflow-plugins.md) (beta): A file of the app or a package adds trigger and step types through the same public interface the built-in types use.
24
+ - [Guarded requests for plugins](../reference/workflow-plugins.md#sending-requests) (beta): A plugin's requests reach only public addresses, follow no redirect and read at most 10 MiB.
25
+ - [Webhook trigger](../reference/workflow-plugins.md#polling-and-webhook-coming-soon) (coming soon): A workflow starts from a signed call to a URL of its own, limited in size and rate.
26
+ - [Polling triggers](../reference/workflow-plugins.md#polling-and-webhook-coming-soon) (coming soon): A workflow starts once for each new item a plugin finds on a schedule.
@@ -211,7 +211,7 @@ export default defineConfig(({ command }) => ({ webhooks: { allowPrivateAddresse
211
211
 
212
212
  ## workflows
213
213
 
214
- - **Type:** `{ schema?: string; secrets?: string[]; allowPrivateAddresses?: boolean }`
214
+ - **Type:** `{ schema?: string; secrets?: string[]; allowPrivateAddresses?: boolean; plugins?: string[] }`
215
215
  - **Default:** off; `{}` turns them on
216
216
 
217
217
  Workflows that people and models make in the app, kept with their versions, runs and steps in a schema of the app's database that `protobase migration` writes, and run by the `workflows.segment` job. Records steps act as each workflow's last editor, with the roles they hold now, read from the app's sign-in; a project that signs people in otherwise exports its own, `async ({ id, tenant }) => [...]`, as the default of `workflows/saved-by-roles.ts`. See [Workflows](../workflows.md).
@@ -247,12 +247,23 @@ export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
247
247
  - **Type:** `boolean`
248
248
  - **Default:** `false`
249
249
 
250
- Lets the `app:http` step reach private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one fails the step, which connects only to the address it checked, so a workflow cannot reach the app's own network.
250
+ Lets the `app:http` step, and every plugin's `fetch`, reach private, loopback and link-local addresses, for development and tests. Off, a URL whose host resolves to one fails the request, which connects only to the address it checked, so a workflow cannot reach the app's own network.
251
251
 
252
252
  ```ts
253
253
  export default defineConfig(({ command }) => ({ workflows: { allowPrivateAddresses: command === 'dev' } }))
254
254
  ```
255
255
 
256
+ ## workflows.plugins
257
+
258
+ - **Type:** `string[]`
259
+ - **Default:** none
260
+
261
+ Packages of [workflow plugins](../workflow-plugins.md) the app runs, by package name, beside the built-in types and the app's own plugins in `workflows/plugins/<name>.ts`. Two plugins of one name stop startup, naming both.
262
+
263
+ ```ts
264
+ export default defineConfig({ workflows: { plugins: ['@acme/workflow-plugin'] } })
265
+ ```
266
+
256
267
  ## notifications
257
268
 
258
269
  - **Type:** `{ ui?: boolean; schema?: string; devices?: { table?: string; columns?: Record<string, string> }; history?: { table?: string; columns?: Record<string, string> } }`
@@ -141,6 +141,7 @@ The other files belong to an option, and are described with it:
141
141
  | `jobs/<name>.ts` | [`jobs`](config/jobs-and-events.md#jobs) |
142
142
  | `webhooks/owner-roles.ts` | [`webhooks`](config/jobs-and-events.md#webhooks) |
143
143
  | `workflows/saved-by-roles.ts` | [`workflows`](config/jobs-and-events.md#workflows) |
144
+ | `workflows/plugins/<name>.ts` | [`workflows.plugins`](config/jobs-and-events.md#workflowsplugins) |
144
145
  | `notifications/store.ts`, `notifications/history-store.ts`, `notifications/senders.ts` | [`notifications`](config/jobs-and-events.md#notifications) |
145
146
  | `files/<name>.ts` | [`files.providers`](config/files.md#filesproviders) |
146
147
 
@@ -163,6 +164,7 @@ export default defineUi({ extends: [tasksUi] })
163
164
  | assistant tools of `assistant/tools.ts` | the extensions' first, in order, then the app's | an error naming the tool and both folders |
164
165
  | MCP tools of `mcp/tools.ts` | the extensions' first, in order, then the app's | an error naming the tool and both folders |
165
166
  | `jobs`, functions, file providers (`files.providers`) | all kept, by name | an error naming the name and both folders |
167
+ | workflow plugins of `workflows/plugins/` | all kept | an error naming the plugin and both sources |
166
168
  | `files.trustedHosts` | all kept | |
167
169
  | the other keys of `files` | each the app's own; otherwise the last config that sets it | |
168
170
  | `writeHooks` | all run, in the same order | |
@@ -92,14 +92,14 @@ In an app with [workflows](workflows.md), the endpoint also offers tools to find
92
92
 
93
93
  | Tool | What it does |
94
94
  | --- | --- |
95
- | `list_workflow_types` | The step types a Task names as its `Resource`, each with its arguments (required or not, and what each takes), its result and the errors it fails with; and the triggers, how the definition holds each and the input a run gets from it. |
95
+ | `list_workflow_types` | The step types a Task names as its `Resource` and the triggers a definition holds, built-in and [plugin](workflow-plugins.md) ones alike, each by id with its label, description, input as JSON Schema and, when the type declares one, its output, the errors a step fails with, and whether the app can run it. |
96
96
  | `list_workflows` | A page of the workflows the person may read, newest change first, without their definitions: `filter` on `name`, `page_size` (20 by default, at most 100) and `page_token`. |
97
97
  | `get_workflow` | One workflow with its whole definition and its ETag. |
98
- | `validate_workflow` | Checks a `definition`, or a workflow's `id` with an `edit` of its saved definition, as a save checks it, and saves nothing: `{ valid: true }`, or `{ valid: false, problems }`. |
98
+ | `validate_workflow` | Checks a `definition`, or a workflow's `id` with an `edit` of its saved definition, as a save checks it, and saves nothing: `{ valid: true }`, or `{ valid: false, problems }`. A record trigger is checked against the resources and fields the person may read, so a resource they cannot read is not a resource of the app. |
99
99
  | `get_workflow_run` | A run's status, input, output, error and cause, with its steps in the order they ran, each with its state, scope, try, status, input, output, assigned variables, logs, error and cause; `state` reads one state's steps, a page of 20 by default. |
100
100
  | `create_workflow` | Creates a workflow from its `name`, `description` and `definition`, and returns its `id`, `version` and ETag. |
101
101
  | `update_workflow` | Changes a workflow's definition by an `edit` or whole, as `definition`, and its `name` or `description`, and returns its `id`, new `version` and ETag. With the `etag` that `get_workflow` gave, it fails when someone changed the workflow since; an edit without one is saved against the version it was applied to. |
102
- | `run_workflow` | Starts a run of a workflow's current version with an `input`, and returns its `run_id` and `status`. |
102
+ | `run_workflow` | Starts a run of a workflow's current version with an `input`, and returns its `run_id` and `status`. With `rerun_of`, a failed run's id, it [re-runs](workflows.md#re-running-a-failed-run) that run from its failed step instead, on its input. |
103
103
 
104
104
  An `edit` is a [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7396) of the saved definition: only what changes, at its place in the document. An object merges into the object there, `null` removes a member, and any other value, a list included, replaces it. Changing one argument of one state:
105
105
 
@@ -107,7 +107,7 @@ An `edit` is a [JSON Merge Patch](https://www.rfc-editor.org/rfc/rfc7396) of the
107
107
  { "id": "...", "edit": { "stateMachine": { "States": { "Notify": { "Arguments": { "url": "https://hooks.example.com/v2" } } } } } }
108
108
  ```
109
109
 
110
- A definition the save check refuses comes back as `{ "error": "invalid-workflow", "message": ..., "problems": [...] }`, each problem `{ state, field, message }` naming the state, the field and what was expected, as `validate_workflow` gives them; nothing is saved. A run goes on in the background on the app's jobs, so `run_workflow` returns while it is queued: the client reads it with `get_workflow_run` until it has `succeeded` or `failed`, fixes the step that failed and runs it again.
110
+ A definition the save check refuses comes back as `{ "error": "invalid-workflow", "message": ..., "problems": [...] }`, each problem `{ state, field, message }` naming the state, the field and what was expected, as `validate_workflow` gives them; nothing is saved. A run goes on in the background on the app's jobs, so `run_workflow` returns while it is queued: the client reads it with `get_workflow_run` until it has `succeeded` or `failed`, fixes the step that failed and re-runs the run from that step.
111
111
 
112
112
  `create_workflow`, `update_workflow` and `run_workflow` come with [writes](#writes), and the person approves each first, as any write, unless they hold one of the [roles without approval](#writes-without-approval); with writes limited to some resources, they save on `workflows` and run on `workflow_runs` only when the list names them. A run does what its steps do, such as requests, mail and record writes, as the workflow's last editor.
113
113
 
@@ -280,8 +280,9 @@ The endpoint serves each request on its own, without an MCP session: a `GET` or
280
280
 
281
281
  - `mcpHandler({ authenticate, resource, scope, server })`: the endpoint as a fetch handler. It checks the host, admits a valid token (`mcpGate`) and serves the request with the server that `server(session, request)` builds for its caller; `request` is the HTTP request, such as for the language the person reads.
282
282
  - `mcpServer({ info, instructions, tools, context, report, rolesWithoutApproval, approvals })`: an MCP server named by `info`, its `name`, `version` and `icons`, such as `mcpIcons(origin, icons)`, the app's icons on `origin`, and offering [assistant tools](assistant.md#the-apps-own-tools), each with its JSON Schema, its `annotations`, MCP's tool hints, and its `title` and `_meta`, and each refusing as [the app's own tools](#the-apps-own-tools) do. `context` is the tools' context without `approve`, with the `locale` its approvals are asked in (English when absent): each call gets its own, which asks the client's user, or approves at once when the session's user holds one of `rolesWithoutApproval`. It is a `ToolContext`, with the app's `records`, `resources()` and `show`, unless every tool is declared with a smaller one, such as `defineTool<BaseToolContext>(...)`, whose context is only `session`, `locale` and `approve`: a host without app records then passes `{ session }`. `report` gets what a tool throws other than a refusal. `approvals` is `mcpApprovals()`, built once and passed to every server the endpoint builds: it keeps the approvals asked for until they are answered or expire. `instructions` is what it tells clients when they connect, by default `mcpInstructions`, the advice on reading records above.
283
- - `mcpTools({ db, sql, writes, commented, workflows, own })`: the endpoint's tools for a session; `writes` is `true`, `false` or `{ resources, rolesWithoutApproval }`, `commented` names the resources with comments, `workflows: true` offers the [workflow tools](#workflows), and `own` are [the app's own tools](#the-apps-own-tools), offered after the rest.
284
- - `workflowReadTools()` and `workflowWriteTools({ resources })`: the workflow tools alone, and `workflowInstructions`, what the endpoint tells clients about the language, to append to `mcpInstructions`.
283
+ - `mcpTools({ db, sql, writes, commented, workflows, own })`: the endpoint's tools for a session; `writes` is `true`, `false` or `{ resources, rolesWithoutApproval }`, `commented` names the resources with comments, `workflows` offers the [workflow tools](#workflows) with the app's types, and `own` are [the app's own tools](#the-apps-own-tools), offered after the rest.
284
+ - `workflowTypes(plugins, models)`: the app's workflow types, a `WorkflowCatalog`, which `workflows` and `workflowReadTools` take. `plugins` are the app's own [workflow plugins](workflow-plugins.md), each `{ plugin, source }`, beside the built-in ones, and `models(name)` gives the model of the app's resource of that name, which a record trigger is checked against. An app with `workflows` needs none of this: its endpoint offers the tools with the app's types; these are for an MCP server of your own.
285
+ - `workflowReadTools(catalog)` and `workflowWriteTools({ resources })`: the workflow tools alone, and `workflowInstructions`, what the endpoint tells clients about the language, to append to `mcpInstructions`.
285
286
  - `readRecordTools()`, or `listResourcesTool()`, `describeResourceTool()`, `listRecordsTool()` and `getRecordTool()`: the read tools alone; `readOnlyQueryTool(db)` is `run_read_only_query`, and `readWriteQueryTool(db)` proposes one write statement that the user approves before it runs.
286
287
  - `commentTools({ commented }, writes)`, or `listCommentsTool({ commented })` and `addCommentTool({ commented, resources })`: the comment tools on the resources named in `commented`, which call the context's `comments`, and `add_comment` only when `writes` is not `false`.
287
288
  - `writeRecordTools({ resources })`, or `createRecordTool(...)`, `updateRecordTool(...)` and `deleteRecordTool(...)`: the single write tools alone, on the listed resources or, without `resources`, on any; `batchWriteTool({ resources })` is `batch_write` alone.
@@ -149,6 +149,10 @@ Each part gets:
149
149
 
150
150
  [Adding a field type](../guides/field-type.md) builds one step by step.
151
151
 
152
+ ### JSON
153
+
154
+ `json` is a field type Protobase brings for any JSON field a form should edit, such as a workflow run's `input`: `r.input.widget('json')`. The form edits the value as JSON text, the record page shows it written out on several lines, and a list's table on one. Text that does not parse yet says so under the box and leaves the value as it last parsed; an emptied box is null. It takes the form's whole width. A plugin that registers a field type named `json` replaces it.
155
+
152
156
  ### Members
153
157
 
154
158
  `members` is a field type Protobase brings, for a JSON field that holds an array of user ids, such as who may read a private channel. No plugin registers it; a view picks it as it picks any other:
@@ -167,6 +171,8 @@ The create form and the record's form pick the members of the organization the s
167
171
 
168
172
  `workflow` is the field type the `workflows` resource draws its `definition` with: the [workflow editor](workflows.md#the-editor). Another JSON field holding a workflow's definition picks it as `r.definition.widget('workflow')`. It takes the form's whole width, and a list's table shows how many steps the workflow has. A plugin that registers a field type named `workflow` replaces it.
169
173
 
174
+ `workflow_run` is the field type the `workflow_runs` resource draws its own `id` with: the run's [trace](workflows.md#following-and-debugging-a-run) on its workflow's graph, with the chosen step's records under it. Another uuid field holding a run's id picks it as `r.run_id.widget('workflow_run')`. It takes the whole width, and a list's table shows the id. A plugin that registers a field type named `workflow_run` replaces it.
175
+
170
176
  ## Building
171
177
 
172
178
  ```sh
@@ -53,7 +53,7 @@ Every commit message follows [Conventional Commits](https://www.conventionalcomm
53
53
 
54
54
  When `main` moved on while the checks ran, the run of the newer push releases both. `bun run release --dry-run` prints the next version and its notes without tagging anything.
55
55
 
56
- A push made with GitHub Actions' own token, such as a pull request the AI review merged, [starts no workflow](https://docs.github.com/en/actions/concepts/security/github_token), but a run by hand always starts one. Running the workflow by hand from `main` without a tag (`gh workflow run release.yml`) releases `main`'s head as a push does, with the same checks, which then run in full, commitlint over the commits since the last `v<version>` tag, and the same release. A release started while another runs waits for it.
56
+ A push made with GitHub Actions' own token, such as a pull request the AI review merged, [starts no workflow](https://docs.github.com/en/actions/concepts/security/github_token), but a run by hand always starts one. Running the workflow by hand from `main` without a tag (`gh workflow run release.yml`) releases `main`'s head as a push does, with the same checks, which then run in full, commitlint over the commits since the head the previous release run of `main` linted, as a push lints those since the previous push (the commits since the last `v<version>` tag when no recent run linted), and the same release. A malformed message that reached `main` so fails one release, not every later one. A release started while another runs waits for it.
57
57
 
58
58
  Running the workflow by hand with a tag (`gh workflow run release.yml -f tag=v0.1.0`) only packs and publishes: the packages at that tag and its version, skipping every version npm already has, and the images, which repeats a missed or half-done publish. A publish that npm fails because it could not read the identity token, or because Sigstore's transparency log already holds its provenance (a 409 after npm's own retry), is tried again up to 3 times, and the 409 counts as published when npm has the version; any other failure fails the run. `node scripts/publish.mjs --dry-run` packs the packages and shows what `npm publish` would send, and `node scripts/publish.mjs --pack`, which CI runs, packs every package without asking npm or publishing. Each package on npm trusts `release.yml` of `PrototeamHQ/protobase` as its publisher; a new package has to be published once by hand before that can be set up. The workflow pushes the images with its own token; a new image on GitHub's registry starts out private and is made public once in its package settings.
59
59
 
@@ -0,0 +1,119 @@
1
+ <!-- Generated by scripts/protobase-skill.ts from docs/website/src/content/docs/reference/workflow-plugins.md: run `bun run docs:skill`, do not edit. -->
2
+
3
+ # Workflow plugins
4
+
5
+ Add trigger and step types to workflows from a file of the app or a package, through the same public interface the built-in types use.
6
+
7
+ :::caution[Beta]
8
+ Plugins add step types and record triggers. Polling and webhook triggers, and connections that sign in to other services, are declared by the interface but not run yet.
9
+ :::
10
+
11
+ A **workflow plugin** adds trigger and step types to an app's [workflows](workflows.md). Its types are named `<plugin>:<key>`, such as `acme:create_invoice`: a Task names a step type as its `Resource`, and a definition's `trigger` holds a trigger type with its input. The built-in types, `app:http`, `app:records.*`, `app:mail.send` and `app:record`, are a plugin like any other, written on this interface alone, so a plugin's types run, are checked and are listed exactly as they are.
12
+
13
+ ## Writing a plugin
14
+
15
+ ```ts
16
+ // workflows/plugins/acme.ts
17
+ import { definePlugin, StepError } from '@protobase/workflows/plugin'
18
+
19
+ export default definePlugin({
20
+ name: 'acme',
21
+ label: 'Acme',
22
+ steps: {
23
+ create_invoice: {
24
+ label: 'Create an invoice',
25
+ description: 'Creates an invoice at Acme for a customer.',
26
+ errorPrefix: 'Acme',
27
+ errors: { Refused: 'Acme refused the invoice; its answer is in the cause' },
28
+ input: {
29
+ type: 'object',
30
+ required: ['customer', 'amount'],
31
+ properties: { customer: { type: 'string' }, amount: { type: 'number', minimum: 0 } },
32
+ },
33
+ output: { type: 'object', properties: { id: { type: 'string' } } },
34
+ run: async ({ input, fetch, signal, redact, idempotencyKey }) => {
35
+ const answer = await fetch('https://api.acme.example/invoices', {
36
+ method: 'POST',
37
+ headers: { 'content-type': 'application/json', 'idempotency-key': idempotencyKey },
38
+ body: JSON.stringify(input),
39
+ signal,
40
+ })
41
+ if (!answer.ok) throw new StepError('Acme.Refused', `${answer.status}: ${redact(await answer.text()).slice(0, 200)}`)
42
+ return { id: ((await answer.json()) as { id: string }).id }
43
+ },
44
+ },
45
+ },
46
+ })
47
+ ```
48
+
49
+ `definePlugin` checks the plugin's names: its `name` is lowercase letters, digits and `_`, each type's key is lowercase words joined by `_` or `.`, and a type that names a connection must find it among the plugin's `connections`.
50
+
51
+ ### Installing it
52
+
53
+ - **The app's own:** the default export of `workflows/plugins/<name>.ts`, found by its path as jobs are. An [extension](config.md#extensions) adds its own from its folder.
54
+ - **From a package:** listed by name in [`workflows.plugins`](config/jobs-and-events.md#workflowsplugins), its default export the plugin. The package is a dependency of the app.
55
+
56
+ Two plugins of one name stop startup, naming both. A plugin is server code that runs with the app's rights, like the app's own code: load only plugins you trust. The interface's version is `pluginApi`, `1`; releases add optional keys to it and never break it.
57
+
58
+ ## Step types
59
+
60
+ | Key | |
61
+ | --- | --- |
62
+ | `label`, `description` | What the type list shows, to a person and to a model. |
63
+ | `input` | The JSON Schema of the Task's `Arguments`, checked before `run`: arguments that do not fit fail the step as `<errorPrefix>.InvalidArguments`, naming the field. |
64
+ | `output` | The JSON Schema of what `run` returns, for the type list. |
65
+ | `errorPrefix`, `errors` | The prefix of its error names, `Step` by default, and the errors it fails with by name, each with what it means. |
66
+ | `connection` | The connection it signs in with, by the name the plugin declares it under. |
67
+ | `run(context)` | Returns the step's result. It throws a `StepError` to fail with an error name of its own; any other error is `States.TaskFailed`. |
68
+
69
+ What `run` gets, and nothing more:
70
+
71
+ | | |
72
+ | --- | --- |
73
+ | `input` | The Task's evaluated `Arguments`, checked. |
74
+ | `fetch(url, init)` | The only way a plugin should send a request: see [Sending requests](#sending-requests). |
75
+ | `records` | `create`, `createMany`, `update`, `get` and `list` of the app's records, through the API as the user who saved the version the run runs, with the roles they hold now: their access rules, tenant, validation and write hooks apply. A refusal fails the step with `Records.Refused`. |
76
+ | `files` | `keep(bytes, name, type)` stores a file of the run and returns its file value; `read(value, field)` reads one the run kept. No other file of the app is reachable. |
77
+ | `mail` | `send(message)` from the app's own sender; `undefined` when the app sends no mail. |
78
+ | `connection` | `accessToken()` of the connection the step declares, or `undefined`. |
79
+ | `runId`, `idempotencyKey` | The run, and a key that every retry of this visit of the step shares, while a later visit of the same state, such as in a loop, gets a new one: send it where the receiver deduplicates. |
80
+ | `signal` | Aborted at the step's timeout, or when the worker stops; pass it on to `fetch`. |
81
+ | `log(message)`, `redact(text)` | A line in the step's logs, and the text with the app's secrets blanked out, both as the runner records them. |
82
+
83
+ ### Sending requests
84
+
85
+ `fetch` resolves the URL's host once, refuses it unless every address is public, and connects to the very address it checked, so a URL built from a run's data cannot reach the app's own network or a cloud metadata address. It follows no redirect: a 3xx comes back as it is, and a plugin that follows one calls `fetch` again with the new URL, whose host is checked in turn. It reads at most `maxBytes` of the answer's body as it comes, 10 MiB by default and at most, and never decodes a `Content-Encoding`, so a large or endless answer fails with `ResponseTooLargeError` instead of filling memory; a refused address fails with `AddressRefusedError`. [`workflows.allowPrivateAddresses`](config/jobs-and-events.md#workflowsallowprivateaddresses) lets it reach private addresses, for development and tests.
86
+
87
+ A plugin sends through `context.fetch`, never through the global `fetch`, Node's `http`, `https`, `net` or `tls`, or an HTTP client of its own: those are not guarded, so a workflow's data could make them reach the app's own network. The built-in plugins are held to this by the repository's boundary check, which fails on any of them.
88
+
89
+ ## Trigger types
90
+
91
+ A trigger type has a `label`, a `description`, an `input` schema that a save checks the trigger's input against, an optional `check(input)` for what a schema cannot say, and a `kind`.
92
+
93
+ ### Ready
94
+
95
+ ```ts
96
+ triggers: {
97
+ overdue: {
98
+ kind: 'ready',
99
+ label: 'Overdue invoice',
100
+ description: 'Starts a run for each invoice three days past its due date.',
101
+ input: { type: 'object', properties: {} },
102
+ ready: () => ({ resource: 'invoices', when: "status = 'sent' AND due_at <= now() - 3d", key: ['due_at'] }),
103
+ },
104
+ },
105
+ ```
106
+
107
+ `ready(input)` returns the resource, the readiness filter `when` and the `key` fields, and the engine runs it as it runs [`app:record`](workflows.md#apprecord): one run per record that becomes ready, at the time its time terms say, read as the user who saved the enabled version.
108
+
109
+ ### Polling and webhook (coming soon)
110
+
111
+ The interface declares `kind: 'polling'`, with `every`, `limit`, `start` and `poll`, and `kind: 'webhook'`, with `receive` and the optional `subscribe`, `unsubscribe` and `renew`. <gap>A plugin's polling or webhook trigger cannot start runs because the polling and webhook triggers are not implemented; the type list marks them unavailable.</gap>
112
+
113
+ ## Connections (coming soon)
114
+
115
+ `connections` declares the sign-ins a plugin's types use: one of the app's sign-in providers by name (`signIn`), or an OAuth provider of its own (`oauth`), with the `scopes` it asks for. A step or trigger names one as its `connection`, and gets only that one, through the workflow's own binding: the context takes no connection id, so a plugin cannot reach another workflow's sign-in. <gap>A type that declares a connection cannot run because connections are not implemented; the type list marks it unavailable, and a save that uses it is refused.</gap>
116
+
117
+ ## The type list
118
+
119
+ [`list_workflow_types`](mcp.md#workflows) over MCP lists every step and trigger type the app has, built-in and plugin ones alike, as their plugins declare them: id, label, description, input and output schemas, errors and connection, with `available: false` and a reason for a type the app cannot run. A save, and `validate_workflow`, check a definition against the same list.
@@ -5,7 +5,7 @@
5
5
  Workflows written as state machines in the States Language with JSONata, checked on every save, run step by step on the app's jobs, with every step recorded.
6
6
 
7
7
  :::caution[Beta]
8
- Workflows run when they are started through the API, over MCP or from the app's code. Triggers and sign-in connections to other services are not built yet.
8
+ Workflows run when they are started through the API, over MCP, from the app's code or by a test run in the admin app, and from record triggers. Webhook and polling triggers and sign-in connections to other services are not built yet.
9
9
  :::
10
10
 
11
11
  A **workflow** is a graph of steps that the server runs one after another along its edges: fetch something, decide, run a step per item, merge the results, write records, send mail. It is written in the **States Language**, the JSON language of state machines, with **JSONata** as its expression language, in a profile of it that reads every value by name. A language model writes and changes it as easily as a person, and the same document is what the server checks and runs. Each run records every step it takes, with what the step read, gave and logged.
@@ -18,20 +18,22 @@ import { defineConfig } from '@protobase/config'
18
18
  export default defineConfig({ workflows: { secrets: ['TICKETS_TOKEN'] } })
19
19
  ```
20
20
 
21
- It adds four resources, over a `workflows` schema that [`protobase migration`](cli.md#migration) writes (`NNN_workflows.sql`), and the `workflows.segment` [job](jobs.md) that runs them:
21
+ It adds five resources, over a `workflows` schema that [`protobase migration`](cli.md#migration) writes (`NNN_workflows.sql`), and the `workflows.segment` [job](jobs.md) that runs them:
22
22
 
23
23
  | Resource | |
24
24
  | --- | --- |
25
25
  | `workflows` | A name, a description and the definition. Each save that changes the definition is checked, and kept as the workflow's next version, saved by the caller. |
26
26
  | `workflow_versions` | Every version of every definition as it was saved, with who saved it and when. Read-only. |
27
- | `workflow_runs` | A run, started by creating it with `workflow_id` and an `input`; it runs the version its workflow had then. Its status, output, error and timing are written by the runner. |
27
+ | `workflow_runs` | A run, started by creating it with `workflow_id` and an `input`, or with `rerun_of` to [re-run a failed run](#re-running-a-failed-run); it runs the version its workflow had then. Its status, output, error and timing are written by the runner. |
28
28
  | `workflow_steps` | Every step of every run. Read-only. |
29
+ | `workflow_triggers` | The trigger of each enabled workflow that has one besides `manual`: its type, status and last error. Read-only. |
29
30
 
30
31
  | `workflows` | |
31
32
  | --- | --- |
32
33
  | `schema` | The schema of its tables, for a database that has a `workflows` schema already. Default `workflows`. |
33
34
  | `secrets` | The environment variables workflows may read, by name. Startup stops when one is unset or shorter than 8 characters. |
34
- | `allowPrivateAddresses` | Lets the `app:http` step reach private, loopback and link-local addresses, for development and tests. Default false. |
35
+ | `allowPrivateAddresses` | Lets the `app:http` step, and every plugin's `fetch`, reach private, loopback and link-local addresses, for development and tests. Default false. |
36
+ | `plugins` | Packages of [workflow plugins](workflow-plugins.md) to run, by name, beside the app's own in `workflows/plugins/<name>.ts`. |
35
37
 
36
38
  Records steps act as each workflow's last editor, with the roles the project's sign-in gives them now. A project that signs people in otherwise exports its own, `async ({ id, tenant }) => [...]`, as the default of `workflows/saved-by-roles.ts` (`createAdmin` takes it as `workflows.savedByRoles`); without either, startup stops.
37
39
 
@@ -41,7 +43,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
41
43
 
42
44
  ```json
43
45
  {
44
- "trigger": { "record": { "events": ["orders.created"] } },
46
+ "trigger": { "app:record": { "resource": "orders", "when": "status = 'placed'" } },
45
47
  "stateMachine": {
46
48
  "QueryLanguage": "JSONata",
47
49
  "StartAt": "Charge",
@@ -52,7 +54,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
52
54
  "Arguments": {
53
55
  "method": "POST",
54
56
  "url": "https://payments.example.com/charges",
55
- "body": { "order_id": "{% $states.context.Execution.Input.after.id %}", "amount": "{% $states.context.Execution.Input.after.total %}" }
57
+ "body": { "order_id": "{% $states.context.Execution.Input.record.id %}", "amount": "{% $states.context.Execution.Input.record.total %}" }
56
58
  },
57
59
  "Retry": [{ "ErrorEquals": ["States.ALL"], "IntervalSeconds": 10, "MaxAttempts": 3, "BackoffRate": 2 }],
58
60
  "Catch": [{ "ErrorEquals": ["States.ALL"], "Next": "MarkFailed", "Assign": { "chargeError": "{% $states.errorOutput %}" } }],
@@ -61,7 +63,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
61
63
  "MarkFailed": {
62
64
  "Type": "Task",
63
65
  "Resource": "app:records.update",
64
- "Arguments": { "resource": "orders", "key": "{% $states.context.Execution.Input.after.id %}", "data": { "status": "payment_failed" } },
66
+ "Arguments": { "resource": "orders", "key": "{% $states.context.Execution.Input.record.id %}", "data": { "status": "payment_failed" } },
65
67
  "Next": "TellOps"
66
68
  },
67
69
  "TellOps": {
@@ -69,7 +71,7 @@ A definition is one JSON document: the workflow's trigger, and the state machine
69
71
  "Resource": "app:mail.send",
70
72
  "Arguments": {
71
73
  "to": "ops@example.com",
72
- "subject": "{% 'Payment failed for order ' & $states.context.Execution.Input.after.id %}",
74
+ "subject": "{% 'Payment failed for order ' & $states.context.Execution.Input.record.id %}",
73
75
  "text": "{% $chargeError.Cause %}"
74
76
  },
75
77
  "End": true
@@ -83,17 +85,33 @@ The document holds no layout: the [editor](#the-editor) lays the graph out from
83
85
 
84
86
  ### Triggers
85
87
 
86
- `trigger` holds exactly one of these; its payload is the run's input.
88
+ `trigger` holds one key: `manual`, or a trigger type `<plugin>:<key>` with its input. The built-in ones are the `app` plugin's; a [workflow plugin](workflow-plugins.md) adds its own the same way, and `list_workflow_types` over MCP lists them all with their inputs.
87
89
 
88
90
  | Trigger | Starts a run | The input holds |
89
91
  | --- | --- | --- |
90
- | `"webhook": {}` | on each POST to the workflow's webhook address | `{ body, headers, query }` |
91
- | `"schedule": { "cron", "timezone" }` | on a 5-field cron, in an IANA time zone (default `UTC`) | `{ scheduled_at }`, ISO 8601 with the zone's offset |
92
- | `"mail": { "mailbox" }` | on each mail arriving in that mailbox | `{ from, to, subject, text, html, received_at, attachments }` |
93
- | `"record": { "events": ["<resource>.<event>"] }` | on each of those record events, such as `deals.created` | `{ event, resource, key, after, before, changed }` |
94
92
  | `"manual": {}` | when someone runs the workflow by hand | the input they give |
93
+ | `"app:record": { "resource", "when", "key" }` | once per record that becomes ready | `{ resource, key, record }` |
95
94
 
96
- A save checks a trigger's shape; runs start through the API, over MCP or from the app's code. <gap>A webhook, schedule, mail or record trigger cannot start runs because the triggers are not implemented.</gap>
95
+ A save checks a trigger's input against its type, and refuses a type the app does not have; besides triggers, runs start through the API, over MCP, from the app's code or by a [test run](#test-runs). <gap>A workflow cannot start from a webhook or a schedule because the webhook and polling triggers are not implemented.</gap>
96
+
97
+ ### Turning a trigger on
98
+
99
+ A trigger starts runs only while its workflow is enabled: set `enabled_version` to the workflow's current `version` through the API, in the admin app or with `update_workflow`, and clear it to turn the workflow off. The trigger is registered at once on every server process, with no restart, and its status, `active` or `failing` with its last error, is the workflow's `workflow_triggers` record. A save of an enabled workflow keeps it enabled on its new version; a new version whose trigger has the same type and input keeps the running trigger as it is, and any other change stops it and starts the new one. A trigger's runs run the enabled version and act as the user who saved it, as any run acts as its version's saver.
100
+
101
+ ### app:record
102
+
103
+ ```json
104
+ { "app:record": { "resource": "invoices", "when": "status = 'sent' AND due_at <= now() - 3d", "key": ["due_at"] } }
105
+ ```
106
+
107
+ Starts one run for each record of `resource` that becomes ready, with `{ resource, key, record }` as the run's input, so the record's fields are read as `$states.context.Execution.Input.record.<field>`. Without `when`, every record is ready once it exists, so each new record starts one run.
108
+
109
+ - **`when`** is a [filter](api.md#filters) on the resource's filterable fields. `now()` may stand only in AND terms of the form `<timestamp or date field> <= now() - <duration>` (or `<`): such a term says when a record becomes ready, and the run starts at that time with no write, such as three days after an invoice's `due_at`. Any other use of `now()`, such as inside an OR, `search()` and `similar()` are refused at save, naming the term.
110
+ - **One run per ready key:** the record's key, plus the values of the `key` fields. A record that stays ready over later writes starts no second run; one whose `due_at` changes is ready again under a new key.
111
+ - **Who reads it:** the record is read through the API as the user who saved the enabled version, in the workflow's tenant, and `when` is checked on what they may read, so a record they cannot read, or a field they cannot see, never reaches a run. A saver who holds no role any more, or whom the API refuses, marks the trigger `failing`; the next write it reads a record on makes it active again, and it then passes over the records it missed. A key field the saver cannot read marks it `failing` too.
112
+ - **When it looks:** a write to the resource, through the API or the database, reaches the trigger through [events](events.md), so the resource must declare some; turning a workflow on also starts runs for the records ready already, 100 per job. No periodic sweep runs.
113
+
114
+ <gap>A workflow cannot start on every write of a record because an every-change trigger is not implemented; a record trigger starts once per ready key.</gap> <gap>An owner is not told when a trigger starts failing because trigger alerts are not implemented.</gap>
97
115
 
98
116
  ### Reading values by name
99
117
 
@@ -113,7 +131,7 @@ A state's `Assign` is evaluated with the variables as they were when the state w
113
131
 
114
132
  The save is refused, naming each state and field, when the document does not fit the profile:
115
133
 
116
- - a field does not belong to its state or has the wrong type, or the trigger is not exactly one of the above, with a 5-field cron and a known time zone;
134
+ - a field does not belong to its state or has the wrong type, or the trigger is not exactly one of `manual` or an available `<plugin>:<key>` type with input that fits that type's schema;
117
135
  - a `Next`, `Default` or catcher names no state, a state is never reached, or a state name is used twice, Maps included;
118
136
  - a Task names a step type the app does not have, or the state type is not one of Task, Choice, Pass, Map, Wait, Succeed and Fail;
119
137
  - an expression does not parse, or reads anything but the names above: the state's input as JSONata's context (a bare field name, `$`, `$$`), `$states.input` outside an item's first state, `$states.context` but for the three reads above, a variable no state that can run before it assigns, or `$eval`;
@@ -121,7 +139,7 @@ The save is refused, naming each state and field, when the document does not fit
121
139
 
122
140
  The problem's `problems` lists them as `{ state, field, message }`, where a state inside a Map is named by its path, such as `Each/Total`. The document's JSON Schema is `definitionSchema` of `@protobase/workflows`, and `checkDefinition` runs the whole check.
123
141
 
124
- <gap>A save cannot refuse a Task's missing or unknown argument because checking `Arguments` per step type is not implemented.</gap> The step fails its run instead, with an error such as `Http.InvalidArguments` whose cause names the argument.
142
+ A save refuses a Task that leaves out an argument its step type needs, as [the editor](#the-editor) describes. <gap>A save cannot refuse a Task's unknown argument because checking `Arguments` against the step type's whole schema at save is not implemented.</gap> The step fails its run instead, with an error such as `Http.InvalidArguments` whose cause names the argument.
125
143
 
126
144
  ### States
127
145
 
@@ -146,16 +164,18 @@ A failing Task or Map fails with an error name: a step type's own, such as `Http
146
164
  A workflow's record page in the admin app, `/workflows/<id>`, and its create form draw the definition as a graph to edit, and save the same document the API saves:
147
165
 
148
166
  - **The graph lays itself out.** The trigger is on top, each state is a box, a Map is a box around the states it runs for each item, and each edge is drawn from the field that names it: a state's `Next`, a Choice's rules (labelled with their condition) and its `Default` ("Otherwise"), and a Task's or Map's catchers ("On error", dashed). The graph is laid out by ELK's layered algorithm, from top to bottom, each time a state or edge is added, removed or renamed; the document keeps no positions, so the graph a person sees is the one a model or the API wrote, and the one they edit is what a model reads back. The page scrolls under the wheel; the canvas zooms with its buttons and by pinching.
149
- - **Adding a step.** *Add a step* adds a Task of a step type, or a Choice, Map, Wait, Pass, Succeed or Fail, named `Step 1`, `Step 2` and so on. It goes into the Map chosen, or the Map the chosen step is in, else into the workflow; it ends the run, or its item, until it is connected, and in a workflow or Map without steps it is the first.
167
+ - **Adding a step.** *Add a step* adds a Task of any step type the app can run, its plugins' included, or a Choice, Map, Wait, Pass, Succeed or Fail, named `Step 1`, `Step 2` and so on. It goes into the Map chosen, or the Map the chosen step is in, else into the workflow; it ends the run, or its item, until it is connected, and in a workflow or Map without steps it is the first.
150
168
  - **Connecting steps.** Drag from a step's handle to another step, or tap one handle and then the other: the bottom handle sets `Next`, a Choice's *When* handle adds a rule and its *Otherwise* handle sets `Default`, a Task's or Map's *On error* handle adds a catcher of `States.ALL`, and the trigger's handle sets `StartAt`. A step leads only to steps of its own machine, so an edge into or out of a Map is refused. The chosen step's form sets the same edges with menus: *Goes on to*, each rule's and catcher's *Then goes to*, and *Otherwise goes to*.
151
169
  - **Removing.** Delete or Backspace removes the chosen step or edge, as does the form's *Remove step*. Removing a step removes the edges to it: a `Next` to it becomes `End`, and a rule, `Default` or catcher naming it goes. Removing the first step of a workflow or Map makes the first step left in it the first.
152
- - **A step's form.** Choosing a step opens its form under the graph: its name, which renames every edge to it, then what its type takes. A Task's form has a field for each argument of its step type, the ones it needs marked, as listed under [Step types](#step-types); a Choice's its rules and `Default`; a Map's `Items` and `MaxConcurrency`; a Wait's `Seconds`; a Pass's and a Succeed's `Output`; a Fail's `Error` and `Cause`. A field takes text, an expression (`{% ... %}`) or JSON; an emptied field is left out. *The step as JSON* edits every field of the step, such as `Retry`, `Assign` and `TimeoutSeconds`. The trigger's form picks the trigger and its schedule, mailbox or events.
170
+ - **A step's form.** Choosing a step opens its form under the graph: its name, which renames every edge to it, then what its type takes. A Task's form has a field for each argument of its step type's input schema, the ones it needs marked, as listed under [Step types](#step-types) or by its plugin: one line of text for a string, one line for a number or `true` and `false`, a menu for an `enum`, names separated by commas for a list of strings, and JSON for anything else; a Choice's its rules and `Default`; a Map's `Items` and `MaxConcurrency`; a Wait's `Seconds`; a Pass's and a Succeed's `Output`; a Fail's `Error` and `Cause`. A field takes text, an expression (`{% ... %}`) or JSON; an emptied field is left out. *The step as JSON* edits every field of the step, such as `Retry`, `Assign` and `TimeoutSeconds`. The trigger's form picks among the app's triggers, its plugins' included, and draws a field for each input of its schema the same way, such as a record trigger's resource, `when` and `key`.
153
171
  - **Problems on their step.** The editor runs the same check as a save on every change, and shows each problem on the step it names, as a count on its box and a list and a marked field in its form; a problem with the trigger or the first step shows on the trigger, and one about the whole document above the graph. Saving writes the definition through the API, where the save hook checks it again: a definition with a problem is refused with the 400 above, whose `errors` hold each problem for the `definition` field, with its state and field in `params`.
154
172
 
155
- A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap>
173
+ A save also refuses a Task that leaves out an argument its step type needs, such as `app:http` without `url`, unless its `Arguments` is one expression, whose value is known only when it runs. A user who may read a workflow but not change it sees the same graph and forms, locked. <gap>The editor cannot undo a single change because an undo history is not implemented.</gap> The editor reads the app's types from `GET /api/workflows/types`, `client.workflowTypes()` of `@protobase/client`: the same list `list_workflow_types` gives over MCP, `{ step_types, triggers }`, which only a user who may read workflows gets. A type the app cannot run, such as a polling or webhook trigger in this version, is not offered, and the check refuses it with the reason; a stored trigger of such a type, or of a type the app does not have, shows its type and input, read-only. <gap>A box on the graph cannot name a plugin's trigger or step type by its label because handing the type list to the canvas is not implemented; it shows the type's id, such as `acme:shout`.</gap>
156
174
 
157
175
  ## Step types
158
176
 
177
+ The built-in step types are the `app` plugin's, written on the same [plugin interface](workflow-plugins.md) as any other; a plugin's step is a Task with `"Resource": "<plugin>:<key>"`. A step's `Arguments` are checked against its type's input schema before it runs: one that does not fit fails as `<Prefix>.InvalidArguments`, such as `Http.InvalidArguments`.
178
+
159
179
  ### app:http
160
180
 
161
181
  One request to a URL built at run time:
@@ -210,9 +230,37 @@ Each step is recorded as a `workflow_steps` record: the state, its type, the sco
210
230
 
211
231
  A workflow reads a secret only by name, `$states.context.Secrets.TICKETS_TOKEN`, and only one the config's `workflows.secrets` lists; the values are never part of the definition. Every value of the listed secrets is blanked out as `[redacted]` wherever it appears in what a run records: a step's input, output, variables, logs, error and cause, and the run's output, error and cause. In a Task's recorded input, every field whose name says it holds a secret, such as `Authorization`, `token` or `apiKey`, is blanked out too. A value an expression computes from a secret, however it changes it, such as `'Basic ' & $base64encode(...)`, is kept whole as `[redacted]`: in a Task's `Arguments` the step type gets the value and the record keeps `[redacted]`; in an `Output`, an `Assign`, a Map's `Items` or a Fail's `Error` and `Cause` the value itself is `[redacted]`, so later states read that. Read a secret in the `Arguments` of the Task that sends it. A secret that comes back changed in a step's result, such as one a server encodes in its answer, is not the value the runner looks for, and is kept as it came. A run's input is kept as its caller gave it, so a secret's value never belongs in it.
212
232
 
233
+ ### Following and debugging a run
234
+
235
+ A workflow's page in the admin app lists its runs, newest first, with their status, who or what started them, when, how long they took, their version and error. A run's page, `/workflow_runs/<id>`, draws the workflow's graph as the run's version has it, the `workflow_run` [field type](plugins.md#workflow) on the run's `id`, with a mark on each state:
236
+
237
+ | Mark | |
238
+ | --- | --- |
239
+ | Succeeded, Caught, Failed | How the state's last step ended; for a state inside a Map, the worst of its items. A failed state's box is red. |
240
+ | Retrying | Its last try failed and a retrier tries it again. |
241
+ | Running | The run is on it, or goes on to it next. |
242
+
243
+ A state that took more than one step, by its tries, its visits or a Map's items, says how many. Choosing a state lists its steps under the graph in the order they ran, 20 at a time with *Show more*: each with its try, the Map item it ran in, its status, when it started and how long it took, its error and cause, input, output, the variables it assigned and its logs, and a link to the step's own page. Choosing the trigger shows the run's input. The page opens on the state that failed, the innermost one when a Map's item failed, so a failed run shows its error and logs at once.
244
+
245
+ While a run is queued, running or waiting, the page reads the run and its steps again every second, through the API as the person, so the marks, the step lists and the run's fields change as it goes, without a reload; it stops once the run has ended. The graph marks the states of a run's first 500 steps; the run's *Steps* list has them all. <gap>The graph cannot mark the steps after a run's first 500 because reading a run's steps in more than one page for the trace is not implemented.</gap> Everything the page shows is what the run recorded, so a [secret](#secrets) shows as `[redacted]` wherever it would have been.
246
+
247
+ ### Re-running a failed run
248
+
249
+ A failed run's page offers *Re-run from the failed step*. It opens the form for a new run with `workflow_id` and `rerun_of` filled in; creating it, through the form or `POST /api/v1/workflow_runs` with `{ "workflow_id": "...", "rerun_of": "..." }`, starts a run that:
250
+
251
+ - runs on the workflow's version now, so fix the step first, and on the earlier run's input: a re-run given an `input` of its own is refused with `rerun-input`;
252
+ - keeps the earlier run's steps, copied as they were, but the try that failed and that state's retried tries before it, so the failed state runs again from its first try and none of the steps before it runs again. A Map that runs again keeps its finished items and runs again only those that had not finished, each from where it stood;
253
+ - is refused, and nothing starts, when the earlier run is not one of the workflow's runs the person may read (404), did not fail (`run-not-failed`), or would go on at a state the version no longer has (`rerun-refused`, naming the state).
254
+
255
+ A kept step's card on the run's page says it is from the earlier run, as it finished before the re-run started; the earlier run's page lists its re-runs.
256
+
257
+ ### Test runs
258
+
259
+ A workflow's page offers *Test run*: the form for a new run of the workflow, whose `input` takes a sample input as JSON, which every state reads as `$states.context.Execution.Input`. Creating it opens the run's page, which follows it as it runs. A test run is a run like any other: its steps do what they do, as the workflow's last editor, with the version saved last.
260
+
213
261
  ## Over MCP
214
262
 
215
- An AI client signed in to the app's [MCP endpoint](mcp.md#workflows) finds, makes, changes and test-runs workflows as the person: it lists the step types and triggers with their arguments, reads and validates definitions, saves one whole or by a small edit, starts a run with an input, and reads each of the run's steps with its input, output, logs and error. The endpoint's instructions describe this language, and a refused definition comes back with its problems, each naming the state and field.
263
+ An AI client signed in to the app's [MCP endpoint](mcp.md#workflows) finds, makes, changes and test-runs workflows as the person: it lists the step types and triggers with their arguments, reads and validates definitions, saves one whole or by a small edit, starts a run with an input, reads each of the run's steps with its input, output, logs and error, and re-runs a failed run from its failed step once the workflow is fixed. The endpoint's instructions describe this language, and a refused definition comes back with its problems, each naming the state and field.
216
264
 
217
265
  ## Who may do what
218
266
 
@@ -220,9 +268,9 @@ The workflow resources take only [named grants](access.md#resources-that-need-na
220
268
 
221
269
  | Grant | |
222
270
  | --- | --- |
223
- | `workflows.create`, `workflows.update`, `workflows.delete` | Make, change and delete workflows. Each save that changes the definition makes the saver the workflow's last editor, whom its records steps act as. |
271
+ | `workflows.create`, `workflows.update`, `workflows.delete` | Make, change and delete workflows, and turn them on and off. Each save that changes the definition makes the saver the workflow's last editor, whom its records steps and its trigger act as. |
224
272
  | `workflow_runs.create` | Run a workflow, with any input. The API writes only the fields a caller reads, so it goes with `workflow_runs.read`, and the caller must read the workflow too. |
225
- | `workflows.read`, `workflow_versions.read`, `workflow_runs.read`, `workflow_steps.read` | Read workflows, their versions, runs and steps, the steps' inputs and outputs included. |
273
+ | `workflows.read`, `workflow_versions.read`, `workflow_runs.read`, `workflow_steps.read`, `workflow_triggers.read` | Read workflows, their versions, runs, steps and triggers, the steps' inputs and outputs included. |
226
274
 
227
275
  ```ts
228
276
  defineRoles({
@@ -231,7 +279,7 @@ defineRoles({
231
279
  })
232
280
  ```
233
281
 
234
- All four are scoped to the tenant like any resource with `.tenant()`. A run takes its workflow's tenant; a start of another tenant's workflow is refused as one of a workflow that is not there.
282
+ All five are scoped to the tenant like any resource with `.tenant()`. A run takes its workflow's tenant; a start of another tenant's workflow is refused as one of a workflow that is not there.
235
283
 
236
284
  ## How a run is run
237
285
 
@@ -239,7 +287,7 @@ The runner is `@protobase/workflows`, a package with no other Protobase dependen
239
287
 
240
288
  - A run is run in **segments**, each a `workflows.segment` job whose payload is the run's id alone: one runs the run from where it stands to its next wait, then queues the next. A Wait whose time is still ahead, or a retry's delay, saves its step with the time the run goes on, ends the segment and queues the next one for that time, so a waiting run holds no worker. A segment hands the rest of the run on after a minute of steps.
241
289
  - Where a run stands is read from its saved steps. A Task's step, and one with a time the run goes on at, is saved before the next state runs; the steps of other states are kept and saved with it, or as the segment ends. Each save is **one statement**, unique by each step's scope and number, so a second segment of the same run that finds one taken stops.
242
- - A worker that stops or dies mid-step leaves that step unsaved: its job is tried again and takes the run up from its last saved step, so a saved step never runs again, and the step that was in progress runs **at least once**, as do the steps kept since the last save, which give the same records again. A step whose effect happened before its result was saved, such as a request that was sent, happens again. Every try of a step gets the same idempotency key, for a step type whose receiver dedupes by it, and `app:mail.send` makes it the mail's Message-ID. A job whose worker died is tried again once its timeout, 10 minutes, has passed.
290
+ - A worker that stops or dies mid-step leaves that step unsaved: its job is tried again and takes the run up from its last saved step, so a saved step never runs again, and the step that was in progress runs **at least once**, as do the steps kept since the last save, which give the same records again. A step whose effect happened before its result was saved, such as a request that was sent, happens again. Every try of a step gets the same idempotency key, for a step type whose receiver dedupes by it, and `app:mail.send` makes it the mail's Message-ID. `app:records.create` keeps it with the records it creates, in their transaction, so a try after they committed creates none again and returns them as the first try created them. A job whose worker died is tried again once its timeout, 10 minutes, has passed.
243
291
  - A Map's items run in the segment that reaches them, each item's steps saved under its own scope; an item that waits ends the segment once the others running settle, and the next segment takes each item up where it stands. A Map's `Items` is evaluated again by a segment that takes up an unfinished Map.
244
292
  - JSONata runs in the server's process under a time and a depth limit per expression; it reaches no file, network or code of the app.
245
293