@protobase/server 0.67.10 → 0.67.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/package.js +1 -1
- package/dist/workflows/input-limits.js +4 -2
- package/dist/workflows/mcp/workflow-read-tools.js +2 -0
- package/dist/workflows/mcp/workflow-write-tools.js +12 -3
- package/dist/workflows/rerun.js +36 -0
- package/dist/workflows/save-hook.js +22 -3
- package/dist/workflows/schema-sql.js +8 -2
- package/dist/workflows/schema.js +1 -1
- package/dist/workflows/start-hook.js +9 -3
- package/dist/workflows/step-types.js +3 -10
- package/dist/workflows/store.js +9 -6
- package/dist/workflows/workflow-engine.js +1 -1
- package/dist/workflows/workflows-module.d.ts +2 -0
- package/package.json +5 -5
package/dist/package.js
CHANGED
|
@@ -12,11 +12,13 @@ const checkRunInput = (input) => {
|
|
|
12
12
|
};
|
|
13
13
|
/**
|
|
14
14
|
* The same limits on a run's input as the API sent it, before the body is validated, whose walk of a deeply nested
|
|
15
|
-
* value would overflow the call stack.
|
|
15
|
+
* value would overflow the call stack; a re-run takes the input of the run it re-runs, so it is given none.
|
|
16
16
|
*/
|
|
17
17
|
const runInputCheck = async ({ resource, operation, body }) => {
|
|
18
18
|
if (resource !== "workflow_runs" || operation !== "create" || typeof body !== "object" || body === null) return;
|
|
19
|
-
|
|
19
|
+
const { input, rerun_of } = body;
|
|
20
|
+
if (rerun_of != null && input != null) throw badRequest("rerun-input", "A re-run runs on the input of the run it re-runs: give it no input");
|
|
21
|
+
checkRunInput(input);
|
|
20
22
|
};
|
|
21
23
|
//#endregion
|
|
22
24
|
export { checkRunInput, runInputCheck };
|
|
@@ -108,12 +108,16 @@ const updateWorkflowTool = (input = {}) => defineTool({
|
|
|
108
108
|
/** Starts a run of a workflow with an input once the user approves it. */
|
|
109
109
|
const runWorkflowTool = (input = {}) => defineTool({
|
|
110
110
|
name: "run_workflow",
|
|
111
|
-
description: `Starts a run of a workflow's current version with an input, which its states read as $states.context.Execution.Input, and returns JSON { run_id, status }. The run goes on in the background, doing what its steps do (requests, mail, record writes) as the workflow's last editor: read it with get_workflow_run until its status is succeeded or failed. The user approves each run in their client first.${onlyNote(input)}`,
|
|
111
|
+
description: `Starts a run of a workflow's current version with an input, which its states read as $states.context.Execution.Input, and returns JSON { run_id, status }. With rerun_of, the id of a failed run of the workflow, it re-runs that run instead: on its input, keeping the steps before the one that failed, and going on from that step on the current version, so fix the workflow first. The run goes on in the background, doing what its steps do (requests, mail, record writes) as the workflow's last editor: read it with get_workflow_run until its status is succeeded or failed. The user approves each run in their client first.${onlyNote(input)}`,
|
|
112
112
|
parameters: {
|
|
113
113
|
type: "object",
|
|
114
114
|
properties: {
|
|
115
115
|
id: idParameter,
|
|
116
|
-
input: { description: "The run's input, any JSON value, at most 1 MiB; an empty object by default." }
|
|
116
|
+
input: { description: "The run's input, any JSON value, at most 1 MiB; an empty object by default. Not with rerun_of." },
|
|
117
|
+
rerun_of: {
|
|
118
|
+
type: "string",
|
|
119
|
+
description: "The id of a failed run of this workflow to re-run from its failed step."
|
|
120
|
+
}
|
|
117
121
|
},
|
|
118
122
|
required: ["id"]
|
|
119
123
|
},
|
|
@@ -126,7 +130,12 @@ const runWorkflowTool = (input = {}) => defineTool({
|
|
|
126
130
|
if (!id) return "Pass { \"id\": \"<id>\", \"input\": { ... } }.";
|
|
127
131
|
const refusal = outside(input, "workflow_runs");
|
|
128
132
|
if (refusal) return refusal;
|
|
129
|
-
const
|
|
133
|
+
const rerun = textOf(args.rerun_of);
|
|
134
|
+
if (rerun && args.input !== void 0) return "A re-run runs on the input of the run it re-runs: pass rerun_of without input.";
|
|
135
|
+
const data = rerun ? {
|
|
136
|
+
workflow_id: id,
|
|
137
|
+
rerun_of: rerun
|
|
138
|
+
} : {
|
|
130
139
|
workflow_id: id,
|
|
131
140
|
input: args.input ?? {}
|
|
132
141
|
};
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { badRequest, notFound } from "../problem.js";
|
|
2
|
+
import { requestAccess, requireOperation } from "../request-access.js";
|
|
3
|
+
import { deletedRows } from "../params.js";
|
|
4
|
+
import { fetchReadable } from "../fetch-readable.js";
|
|
5
|
+
import { sql } from "kysely";
|
|
6
|
+
import { rerunSteps } from "@protobase/workflows";
|
|
7
|
+
//#region src/workflows/rerun.ts
|
|
8
|
+
/**
|
|
9
|
+
* Starts a re-run where the failed run it re-runs left off: refuses a run the caller cannot read (404), one of another
|
|
10
|
+
* workflow and one that did not fail, and copies the steps a re-run keeps to the new run, in one statement, so the
|
|
11
|
+
* runner takes it up after them on the workflow's version now. The table's trigger gave it the earlier run's input.
|
|
12
|
+
*/
|
|
13
|
+
const keepEarlierSteps = async (trx, deps, schema, event) => {
|
|
14
|
+
const run = String(event.after.id);
|
|
15
|
+
const earlier = String(event.after.rerun_of);
|
|
16
|
+
const access = await requestAccess(deps, deps.registry.find("workflow_runs"), {
|
|
17
|
+
user: event.user,
|
|
18
|
+
...event.tenant !== void 0 && { tenant: event.tenant }
|
|
19
|
+
});
|
|
20
|
+
requireOperation(access, "read");
|
|
21
|
+
const found = await fetchReadable(trx, access, [earlier], deletedRows(access.full, false), { fields: [access.full.fields.workflow_id, access.full.fields.status] });
|
|
22
|
+
if (!found || found.record.workflow_id !== event.after.workflow_id) throw notFound("No run of this workflow to re-run with this key");
|
|
23
|
+
if (found.record.status !== "failed") throw badRequest("run-not-failed", `Only a failed run is re-run; this one is ${String(found.record.status)}`);
|
|
24
|
+
const { rows: versions } = await sql`select v.definition from ${sql.id(schema, "runs")} r
|
|
25
|
+
join ${sql.id(schema, "versions")} v on v.workflow_id = r.workflow_id and v.version = r.version where r.id = ${run}`.execute(trx);
|
|
26
|
+
const steps = sql.id(schema, "steps");
|
|
27
|
+
const { rows } = await sql`select id, scope, seq, state, status, next from ${steps} where run_id = ${earlier}`.execute(trx);
|
|
28
|
+
const kept = rerunSteps(versions[0].definition.stateMachine, rows);
|
|
29
|
+
if ("problem" in kept) throw badRequest("rerun-refused", `The run cannot be re-run on the workflow as it is now: ${kept.problem}`);
|
|
30
|
+
if (kept.steps.length === 0) return;
|
|
31
|
+
const columns = sql.raw("scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at");
|
|
32
|
+
await sql`insert into ${steps} (run_id, tenant_id, ${columns}) select ${run}, tenant_id, ${columns} from ${steps}
|
|
33
|
+
where id in (${sql.join(kept.steps.map((step) => step.id))})`.execute(trx);
|
|
34
|
+
};
|
|
35
|
+
//#endregion
|
|
36
|
+
export { keepEarlierSteps };
|
|
@@ -1,18 +1,37 @@
|
|
|
1
1
|
import { badRequest } from "../problem.js";
|
|
2
|
+
import { requiredStepInputs } from "@protobase/schema/internal";
|
|
2
3
|
import { sql } from "kysely";
|
|
3
4
|
import { checkDefinition, problemText } from "@protobase/workflows";
|
|
4
5
|
//#region src/workflows/save-hook.ts
|
|
5
6
|
/**
|
|
6
7
|
* Checks a workflow's definition on every save that changes it, against the language's schema and the named-reads
|
|
7
8
|
* profile, with the step types the app has, and keeps it as the version the table's trigger numbered, saved by the
|
|
8
|
-
* caller. A refusal is a 400 problem naming each state and field, also listed as `problems
|
|
9
|
+
* caller. A refusal is a 400 problem naming each state and field, also listed as `problems`, and as the `definition`
|
|
10
|
+
* field's `errors` with the state and field in their `params`, for a form to show each on its step; the write rolls back.
|
|
9
11
|
*/
|
|
10
12
|
const workflowSaveHook = (schema, stepTypes) => async (event, trx) => {
|
|
11
13
|
if (event.resource.name !== "workflows" || !event.after) return;
|
|
12
14
|
if (event.operation !== "create" && event.operation !== "update") return;
|
|
13
15
|
if (event.operation === "update" && event.after.version === event.before?.version) return;
|
|
14
|
-
const problems = checkDefinition(event.after.definition, {
|
|
15
|
-
|
|
16
|
+
const problems = checkDefinition(event.after.definition, {
|
|
17
|
+
resources: stepTypes,
|
|
18
|
+
required: requiredStepInputs
|
|
19
|
+
});
|
|
20
|
+
if (problems.length > 0) {
|
|
21
|
+
const errors = problems.map((problem) => ({
|
|
22
|
+
code: "invalid-workflow",
|
|
23
|
+
field: "definition",
|
|
24
|
+
message: problemText(problem),
|
|
25
|
+
params: {
|
|
26
|
+
state: problem.state,
|
|
27
|
+
field: problem.field
|
|
28
|
+
}
|
|
29
|
+
}));
|
|
30
|
+
throw badRequest("invalid-workflow", `The workflow cannot be saved: ${problems.map(problemText).join("; ")}`, {
|
|
31
|
+
problems,
|
|
32
|
+
errors
|
|
33
|
+
});
|
|
34
|
+
}
|
|
16
35
|
await sql`insert into ${sql.id(schema, "versions")} (workflow_id, version, definition, saved_by, tenant_id)
|
|
17
36
|
select w.id, w.version, w.definition, ${String(event.user.id)}, w.tenant_id from ${sql.id(schema, "workflows")} w where w.id = ${String(event.after.id)}`.execute(trx);
|
|
18
37
|
};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { quoteIdent } from "../changes/capture-sql.js";
|
|
2
2
|
/** The comment the schema's function carries, which the startup check reads. */
|
|
3
|
-
const workflowsComment = `Protobase workflows
|
|
3
|
+
const workflowsComment = `Protobase workflows 4`;
|
|
4
4
|
const commentVersion = (comment) => {
|
|
5
5
|
const match = comment?.match(/^Protobase workflows (\d+)$/);
|
|
6
6
|
return match ? Number(match[1]) : void 0;
|
|
@@ -46,6 +46,7 @@ create table if not exists ${s}."runs" (
|
|
|
46
46
|
"cause" text,
|
|
47
47
|
"resume_at" timestamptz,
|
|
48
48
|
"started_by" text,
|
|
49
|
+
"rerun_of" uuid references ${s}."runs" ("id") on delete set null,
|
|
49
50
|
"tenant_id" text,
|
|
50
51
|
"created_at" timestamptz not null default now(),
|
|
51
52
|
"finished_at" timestamptz,
|
|
@@ -88,6 +89,8 @@ create index if not exists "workflows_name_search" on ${s}."workflows" using gin
|
|
|
88
89
|
create index if not exists "versions_tenant" on ${s}."versions" ("tenant_id");
|
|
89
90
|
alter table ${s}."runs" add column if not exists "key" text;
|
|
90
91
|
create unique index if not exists "runs_key" on ${s}."runs" ("workflow_id", "key");
|
|
92
|
+
alter table ${s}."runs" add column if not exists "rerun_of" uuid references ${s}."runs" ("id") on delete set null;
|
|
93
|
+
create index if not exists "runs_rerun_of" on ${s}."runs" ("rerun_of");
|
|
91
94
|
create index if not exists "runs_workflow" on ${s}."runs" ("workflow_id", "created_at");
|
|
92
95
|
create index if not exists "runs_tenant" on ${s}."runs" ("tenant_id");
|
|
93
96
|
create index if not exists "runs_status" on ${s}."runs" ("status");
|
|
@@ -102,7 +105,7 @@ const workflowsTriggers = ["workflow_version", "run_version"];
|
|
|
102
105
|
* A workflow numbers its next version as a save changes its definition, in the row the write returns, so its ETag is
|
|
103
106
|
* the saved row's; the save hook keeps that version's definition and who saved it. A run takes its workflow's current
|
|
104
107
|
* version as it is created, and refuses a workflow of another tenant as one that is not there, and one that has no
|
|
105
|
-
* saved version.
|
|
108
|
+
* saved version. A re-run takes the input of the run it re-runs, which the start hook checks.
|
|
106
109
|
*/
|
|
107
110
|
const functionsSql = (schema) => {
|
|
108
111
|
const s = quoteIdent(schema);
|
|
@@ -136,6 +139,9 @@ begin
|
|
|
136
139
|
raise exception 'Workflow % has no saved version', new.workflow_id using errcode = '23503';
|
|
137
140
|
end if;
|
|
138
141
|
new.version := found.version;
|
|
142
|
+
if new.rerun_of is not null then
|
|
143
|
+
select r.input into new.input from ${s}."runs" r where r.id = new.rerun_of and r.workflow_id = new.workflow_id;
|
|
144
|
+
end if;
|
|
139
145
|
return new;
|
|
140
146
|
end
|
|
141
147
|
$$;
|
package/dist/workflows/schema.js
CHANGED
|
@@ -13,7 +13,7 @@ const readWorkflowsSchema = async (db, schema) => {
|
|
|
13
13
|
const row = rows[0];
|
|
14
14
|
return {
|
|
15
15
|
tables: row.found,
|
|
16
|
-
current: row.found && commentVersion(row.comment) ===
|
|
16
|
+
current: row.found && commentVersion(row.comment) === 4 && row.triggers === workflowsTriggers.length
|
|
17
17
|
};
|
|
18
18
|
};
|
|
19
19
|
/**
|
|
@@ -3,13 +3,15 @@ import { requestAccess, requireOperation } from "../request-access.js";
|
|
|
3
3
|
import { deletedRows } from "../params.js";
|
|
4
4
|
import { fetchReadable } from "../fetch-readable.js";
|
|
5
5
|
import { checkRunInput } from "./input-limits.js";
|
|
6
|
+
import { keepEarlierSteps } from "./rerun.js";
|
|
6
7
|
//#region src/workflows/start-hook.ts
|
|
7
8
|
/**
|
|
8
9
|
* Starts a run as it is created through the API, in the write's transaction: it refuses an input over its limits and
|
|
9
|
-
* a workflow the caller cannot read in their tenant (404, as the API answers for it),
|
|
10
|
-
* segment, which runs once the write commits. The resource
|
|
10
|
+
* a workflow the caller cannot read in their tenant (404, as the API answers for it), takes a re-run up after the steps
|
|
11
|
+
* it keeps of the run it re-runs, and queues the run's first segment, which runs once the write commits. The resource
|
|
12
|
+
* records who started it, as the run's author.
|
|
11
13
|
*/
|
|
12
|
-
const runStartHook = (runner, deps) => async (event, trx) => {
|
|
14
|
+
const runStartHook = (runner, deps, schema) => async (event, trx) => {
|
|
13
15
|
if (event.resource.name !== "workflow_runs" || event.operation !== "create" || !event.after) return;
|
|
14
16
|
checkRunInput(event.after.input);
|
|
15
17
|
const entry = deps().registry.find("workflows");
|
|
@@ -20,6 +22,10 @@ const runStartHook = (runner, deps) => async (event, trx) => {
|
|
|
20
22
|
requireOperation(access, "read");
|
|
21
23
|
const key = access.full.fields[access.full.primaryKey[0]];
|
|
22
24
|
if (!await fetchReadable(trx, access, [String(event.after.workflow_id)], deletedRows(access.full, false), { fields: [key] })) throw notFound(`No ${entry.name} with this key`);
|
|
25
|
+
if (event.after.rerun_of) await keepEarlierSteps(trx, deps(), schema, {
|
|
26
|
+
...event,
|
|
27
|
+
after: event.after
|
|
28
|
+
});
|
|
23
29
|
await runner.start(trx, String(event.after.id));
|
|
24
30
|
};
|
|
25
31
|
//#endregion
|
|
@@ -2,17 +2,10 @@ import { httpStep } from "./http-step.js";
|
|
|
2
2
|
import { mailStep } from "./mail-step.js";
|
|
3
3
|
import { pieceStep } from "./piece-step.js";
|
|
4
4
|
import { recordsSteps } from "./records-step.js";
|
|
5
|
+
import { workflowStepTypes } from "@protobase/schema/internal";
|
|
5
6
|
//#region src/workflows/step-types.ts
|
|
6
|
-
/** The step types a Task names as its `Resource`, as the
|
|
7
|
-
const stepTypeNames =
|
|
8
|
-
"app:http",
|
|
9
|
-
"app:records.create",
|
|
10
|
-
"app:records.update",
|
|
11
|
-
"app:records.get",
|
|
12
|
-
"app:records.list",
|
|
13
|
-
"app:mail.send",
|
|
14
|
-
"app:piece"
|
|
15
|
-
];
|
|
7
|
+
/** The step types a Task names as its `Resource`, as the editor offers them. */
|
|
8
|
+
const stepTypeNames = Object.keys(workflowStepTypes);
|
|
16
9
|
/** The step types of one segment, which sends mail with the mailer of the job that runs it. */
|
|
17
10
|
const stepTypes = ({ deps, schema, roles, allowPrivateAddresses }, mail) => {
|
|
18
11
|
const files = () => deps().files;
|
package/dist/workflows/store.js
CHANGED
|
@@ -17,15 +17,18 @@ const postgresRunStore = (schema) => {
|
|
|
17
17
|
from ${steps} where run_id = ${id} order by scope, seq`.execute(db);
|
|
18
18
|
return rows;
|
|
19
19
|
},
|
|
20
|
-
save: async (db, id,
|
|
20
|
+
save: async (db, id, records) => {
|
|
21
|
+
const row = (step) => sql`(${step.scope}, ${step.seq}::integer, ${step.state}, ${step.type}, ${step.status}, ${step.attempt}::integer,
|
|
22
|
+
${json(step.input)}::jsonb, ${json(step.output)}::jsonb, ${json(step.assigned)}::jsonb, ${step.next}, ${step.resumeAt ?? null}::timestamptz,
|
|
23
|
+
${step.error ?? null}, ${step.cause ?? null}, ${JSON.stringify(step.logs)}::jsonb, ${step.startedAt}::timestamptz, ${step.finishedAt}::timestamptz)`;
|
|
21
24
|
const { rows } = await sql`insert into ${steps}
|
|
22
25
|
(run_id, tenant_id, scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at)
|
|
23
|
-
select r.id, r.tenant_id,
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
26
|
+
select r.id, r.tenant_id, s.*
|
|
27
|
+
from ${runs} r, (values ${sql.join(records.map(row))})
|
|
28
|
+
as s (scope, seq, state, type, status, attempt, input, output, assigned, next, resume_at, error, cause, logs, started_at, finished_at)
|
|
29
|
+
where r.id = ${id}
|
|
27
30
|
on conflict (run_id, scope, seq) do nothing returning id`.execute(db);
|
|
28
|
-
return rows.length
|
|
31
|
+
return rows.length === records.length;
|
|
29
32
|
},
|
|
30
33
|
update: async (db, id, change) => {
|
|
31
34
|
const ended = change.status === "succeeded" || change.status === "failed";
|
|
@@ -28,7 +28,7 @@ const workflowEngine = (options, deps, auth) => {
|
|
|
28
28
|
secrets
|
|
29
29
|
});
|
|
30
30
|
return {
|
|
31
|
-
hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps)],
|
|
31
|
+
hooks: [workflowSaveHook(schema, stepTypeNames), runStartHook(runner, deps, schema)],
|
|
32
32
|
writeChecks: [runInputCheck],
|
|
33
33
|
job
|
|
34
34
|
};
|
|
@@ -54,6 +54,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
|
|
|
54
54
|
readonly cause: import("@protobase/schema").Field<string | null>;
|
|
55
55
|
readonly resume_at: import("@protobase/schema").Field<string | null>;
|
|
56
56
|
readonly started_by: import("@protobase/schema").Field<string | null>;
|
|
57
|
+
readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
|
|
57
58
|
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
58
59
|
readonly created_at: import("@protobase/schema").Field<string>;
|
|
59
60
|
readonly finished_at: import("@protobase/schema").Field<string | null>;
|
|
@@ -71,6 +72,7 @@ export declare const workflowsModule: (options?: WorkflowsOptions) => {
|
|
|
71
72
|
readonly cause: import("@protobase/schema").Field<string | null>;
|
|
72
73
|
readonly resume_at: import("@protobase/schema").Field<string | null>;
|
|
73
74
|
readonly started_by: import("@protobase/schema").Field<string | null>;
|
|
75
|
+
readonly rerun_of: import("@protobase/schema").Field<string | number | null>;
|
|
74
76
|
readonly tenant_id: import("@protobase/schema").Field<string | null>;
|
|
75
77
|
readonly created_at: import("@protobase/schema").Field<string>;
|
|
76
78
|
readonly finished_at: import("@protobase/schema").Field<string | null>;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@protobase/server",
|
|
3
|
-
"version": "0.67.
|
|
3
|
+
"version": "0.67.12",
|
|
4
4
|
"description": "The Protobase REST API as a Hono app, with Better Auth sign-in.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -44,10 +44,10 @@
|
|
|
44
44
|
"@better-auth/sso": "^1.7.12",
|
|
45
45
|
"@livekit/protocol": "1.53.1",
|
|
46
46
|
"@modelcontextprotocol/server": "^2.3.1",
|
|
47
|
-
"@protobase/layout": "0.67.
|
|
48
|
-
"@protobase/query": "0.67.
|
|
49
|
-
"@protobase/schema": "0.67.
|
|
50
|
-
"@protobase/workflows": "0.67.
|
|
47
|
+
"@protobase/layout": "0.67.12",
|
|
48
|
+
"@protobase/query": "0.67.12",
|
|
49
|
+
"@protobase/schema": "0.67.12",
|
|
50
|
+
"@protobase/workflows": "0.67.12",
|
|
51
51
|
"@react-email/render": "^2.1.0",
|
|
52
52
|
"@react-pdf/primitives": "^4.4.0",
|
|
53
53
|
"@react-pdf/renderer": "^4.9.0",
|