@sanoma-ai/workflows 0.1.0 → 0.4.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.
- package/README.md +186 -76
- package/dist/apply-steps.d.ts +6 -0
- package/dist/apply-steps.js +93 -0
- package/dist/apply-steps.js.map +1 -0
- package/dist/apply.d.ts +197 -0
- package/dist/apply.js +470 -0
- package/dist/apply.js.map +1 -0
- package/dist/approvals.d.ts +11 -6
- package/dist/approvals.js +38 -10
- package/dist/approvals.js.map +1 -1
- package/dist/call-workflow.d.ts +16 -0
- package/dist/call-workflow.js +35 -0
- package/dist/call-workflow.js.map +1 -0
- package/dist/call.d.ts +35 -2
- package/dist/call.js +123 -43
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +87 -0
- package/dist/cedar.js +365 -0
- package/dist/cedar.js.map +1 -0
- package/dist/client.d.ts +123 -9
- package/dist/client.js +355 -34
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +107 -0
- package/dist/collection.js +306 -0
- package/dist/collection.js.map +1 -0
- package/dist/config.d.ts +42 -14
- package/dist/config.js +109 -28
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +2 -0
- package/dist/constructors.js +5 -0
- package/dist/constructors.js.map +1 -0
- package/dist/credentials.d.ts +25 -0
- package/dist/credentials.js +73 -5
- package/dist/credentials.js.map +1 -1
- package/dist/data.d.ts +2 -0
- package/dist/data.js +3 -0
- package/dist/data.js.map +1 -0
- package/dist/datafile.d.ts +38 -3
- package/dist/datafile.js +166 -13
- package/dist/datafile.js.map +1 -1
- package/dist/db.d.ts +5 -3
- package/dist/db.js +88 -21
- package/dist/db.js.map +1 -1
- package/dist/define.d.ts +10 -4
- package/dist/define.js +1 -5
- package/dist/define.js.map +1 -1
- package/dist/describe.d.ts +54 -8
- package/dist/describe.js +84 -11
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +9 -0
- package/dist/direct.js +11 -0
- package/dist/direct.js.map +1 -0
- package/dist/drift.d.ts +64 -12
- package/dist/drift.js +118 -41
- package/dist/drift.js.map +1 -1
- package/dist/errors.d.ts +16 -1
- package/dist/errors.js +30 -1
- package/dist/errors.js.map +1 -1
- package/dist/expectations.d.ts +4 -4
- package/dist/expectations.js +43 -9
- package/dist/expectations.js.map +1 -1
- package/dist/host.d.ts +56 -0
- package/dist/host.js +45 -0
- package/dist/host.js.map +1 -0
- package/dist/index.d.ts +13 -5
- package/dist/index.js +15 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +12 -1
- package/dist/ledger.js +18 -11
- package/dist/ledger.js.map +1 -1
- package/dist/lint.d.ts +5 -4
- package/dist/lint.js +24 -7
- package/dist/lint.js.map +1 -1
- package/dist/lockfile.d.ts +36 -0
- package/dist/lockfile.js +69 -0
- package/dist/lockfile.js.map +1 -0
- package/dist/op.d.ts +9 -0
- package/dist/op.js.map +1 -1
- package/dist/outline.d.ts +2 -0
- package/dist/outline.js +1 -1
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +4 -0
- package/dist/paths.js +17 -3
- package/dist/paths.js.map +1 -1
- package/dist/person.d.ts +61 -0
- package/dist/person.js +69 -0
- package/dist/person.js.map +1 -0
- package/dist/plugin.js +1 -1
- package/dist/plugin.js.map +1 -1
- package/dist/policy.d.ts +51 -55
- package/dist/policy.js +14 -36
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +207 -25
- package/dist/resource.js +184 -30
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +76 -9
- package/dist/resources.js +170 -30
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +31 -1
- package/dist/run.js +6 -1
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +5 -1
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +25 -9
- package/dist/scenario.js +125 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -6
- package/dist/scenarios.js +8 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schema.d.ts +32 -0
- package/dist/schema.js +225 -0
- package/dist/schema.js.map +1 -0
- package/dist/sdk.d.ts +34 -0
- package/dist/sdk.js +168 -0
- package/dist/sdk.js.map +1 -0
- package/dist/secret.d.ts +42 -0
- package/dist/secret.js +58 -0
- package/dist/secret.js.map +1 -0
- package/dist/shared.d.ts +188 -4
- package/dist/shared.js +165 -5
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +52 -0
- package/dist/sites.js +174 -0
- package/dist/sites.js.map +1 -0
- package/dist/source.d.ts +58 -0
- package/dist/source.js +54 -0
- package/dist/source.js.map +1 -0
- package/dist/version.d.ts +3 -2
- package/dist/version.js +3 -2
- package/dist/version.js.map +1 -1
- package/dist/worker.d.ts +6 -2
- package/dist/worker.js +37 -8
- package/dist/worker.js.map +1 -1
- package/dist/writer.d.ts +35 -0
- package/dist/writer.js +201 -0
- package/dist/writer.js.map +1 -0
- package/oxlint.json +5 -16
- package/package.json +11 -1
- package/src/apply-steps.ts +110 -0
- package/src/apply.ts +626 -0
- package/src/approvals.ts +49 -13
- package/src/call-workflow.ts +44 -0
- package/src/call.ts +173 -46
- package/src/cedar.ts +493 -0
- package/src/client.ts +450 -49
- package/src/collection.ts +431 -0
- package/src/config.ts +168 -48
- package/src/constructors.ts +4 -0
- package/src/credentials.ts +90 -5
- package/src/data.ts +10 -0
- package/src/datafile.ts +207 -15
- package/src/db.ts +90 -22
- package/src/define.ts +11 -9
- package/src/describe.ts +146 -23
- package/src/direct.ts +15 -0
- package/src/drift.ts +155 -45
- package/src/errors.ts +36 -1
- package/src/expectations.ts +46 -11
- package/src/host.ts +96 -0
- package/src/index.ts +56 -2
- package/src/ledger.ts +35 -17
- package/src/lint.ts +29 -12
- package/src/lockfile.ts +95 -0
- package/src/op.ts +9 -0
- package/src/outline.ts +1 -1
- package/src/paths.ts +18 -3
- package/src/person.ts +75 -0
- package/src/plugin.ts +1 -1
- package/src/policy.ts +59 -63
- package/src/resource.ts +302 -52
- package/src/resources.ts +226 -30
- package/src/run.ts +33 -1
- package/src/sandbox.ts +5 -1
- package/src/scenario.ts +175 -43
- package/src/scenarios.ts +15 -8
- package/src/schema.ts +261 -0
- package/src/sdk.ts +211 -0
- package/src/secret.ts +92 -0
- package/src/shared.ts +279 -6
- package/src/sites.ts +220 -0
- package/src/source.ts +82 -0
- package/src/version.ts +3 -2
- package/src/worker.ts +45 -11
- package/src/writer.ts +225 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @sanoma-ai/workflows
|
|
2
2
|
|
|
3
|
-
Business processes as TypeScript functions, run durably on [DBOS](https://dbos.dev) with Postgres. Each vendor call is a recorded step, and approvals and sleeps survive worker restarts. An oxlint config and `lintWorkflow` guard workflow
|
|
3
|
+
Business processes as TypeScript functions, run durably on [DBOS](https://dbos.dev) with Postgres. Each vendor call is a recorded step, and approvals and sleeps survive worker restarts. An oxlint config and `lintWorkflow` guard workflow code against accidental non-determinism and accidental ways around the policy. They are lint, not a sandbox: code written to get around them can.
|
|
4
4
|
|
|
5
5
|
## Usage
|
|
6
6
|
|
|
@@ -51,60 +51,28 @@ export const shop = withSetup(defineConnector("shop", specs, info), {
|
|
|
51
51
|
|
|
52
52
|
`withSetup` lays what it is given over what the connector already carries, and keeps it on the connector's vendor record, so a spread copy of the connector keeps it too. A config that needs a driver made otherwise (another bridge, other options) swaps the driver and keeps the fake: `withSetup(github, { driver: () => githubDriver({ bridge }) })`. It is for connector packages and configs, not for workflows, which the lint does not let import it. Both are optional: a connector without a driver declares its operations and implements none, so a workflow using one is refused; a connector without a fake has none for sandbox runs, which the app's connector page says.
|
|
53
53
|
|
|
54
|
-
A
|
|
54
|
+
A config names the workflows and the connectors:
|
|
55
55
|
|
|
56
56
|
```ts
|
|
57
|
-
import {
|
|
58
|
-
|
|
59
|
-
const policy = definePolicy(
|
|
60
|
-
({ op, effect, run }) => {
|
|
61
|
-
if (effect !== "money") return allow();
|
|
62
|
-
// Approved by finance-lead for this operation: not any approval, and not one a workflow
|
|
63
|
-
// addressed to someone else.
|
|
64
|
-
return approvedFor(run.approvals, op.id, "finance-lead") ? allow() : approve("finance-lead");
|
|
65
|
-
},
|
|
66
|
-
{ version: "2026-10-07" },
|
|
67
|
-
);
|
|
57
|
+
import { defineConfig } from "@sanoma-ai/workflows";
|
|
68
58
|
|
|
69
59
|
export default defineConfig({
|
|
70
60
|
workflows: [refund],
|
|
71
61
|
connectors: [shop],
|
|
72
|
-
policy,
|
|
73
62
|
});
|
|
74
63
|
```
|
|
75
64
|
|
|
76
|
-
That is the whole config; the rest is convention, from its `root` (`defineConfig`'s call site's directory, or `root` where that is not the source's, as in a bundle).
|
|
65
|
+
That is the whole config; the rest is convention, from its `root` (`defineConfig`'s call site's directory, or `root` where that is not the source's, as in a bundle). Three conventions give what a config no longer says:
|
|
77
66
|
|
|
78
67
|
- **The app's name** is the `name` in `<root>/package.json`, kept whole (`@acme/ops`), when that file exists and names one; otherwise, and for a config with no root, it is `"sanoma"`. Only the root's own file counts, never one above it, so a parent project's name never becomes the app's; a `package.json` that is not JSON is refused, naming it. The name scopes the app's runs, queues and versions in its database.
|
|
79
68
|
- **The [scenarios](#scenarios-and-sandbox-runs)** are `<root>/scenarios/` when that is a directory; otherwise the config has none.
|
|
69
|
+
- **The [policy](#policies)** is `<root>/policies/**/*.cedar`, decided as the [people](#people) the data files declare; without any, every call and start is allowed (`allowAll`).
|
|
80
70
|
|
|
81
|
-
The [data files](#data-files) are `resources/` in the root. `ledger` and `databaseUrl` are the only other fields. `drivers`, `fakes`, `appName` and `
|
|
71
|
+
The [data files](#data-files) are `resources/` in the root. `ledger` and `databaseUrl` are the only other fields. `drivers`, `fakes`, `appName`, `scenarios` and `policy` are typed `never` on `SanomaConfig`, each deprecated with where it went, so the compiler and the editor say so; an untyped config that still names `drivers` or `fakes` is refused at run time rather than run live on what it no longer reads, and one that names `policy` rather than run allowing everything.
|
|
82
72
|
|
|
83
|
-
|
|
73
|
+
The audit record goes to the ledger, by default `postgresLedger(databaseUrl)` on the config's database (see [Ledger](#ledger)); `ledger: jsonlLedger(dir)` keeps it in files instead, and `memoryLedger()` in this process only, for tests. `resolveConfig(config)` checks a config and derives what the runtime uses from it: the app name, database, version and queue (`sanoma:<appName>`, for every run, [sandbox runs](#scenarios-and-sandbox-runs) included), the operations by id, the driver each vendor's connector attaches, made once for the resolution (`driversByVendor`, in `connectors` order, and their functions by operation id, `drivers`), the makers of their fakes (`fakes`, by vendor), the scenarios directory, the policy (`policy`, with what is wrong in the policies as `policyProblems`), and the config's `ledger` when it gives one; it opens nothing and reads no file outside the root: its `package.json`, whether `scenarios/` is a directory, and the policies with the people files. It throws, with one message, whatever the worker would refuse: a workflow or driver naming an operation the `connectors` don't declare, a workflow redeclaring an operation with another effect, an operation with no driver (`` `shop.order.refund` has no driver: the connector package must attach one ``), two different drivers or fakes for one vendor, two workflows with one name, a `ledger` that is not a store, a `drivers`, `fakes` or `policy` an untyped config still names. `startWorker`, `SanomaClient.connect` and `describeConfig` all call it, so they fail the same way. It adds the built-in [`drift`](#drift) and [`apply`](#apply) workflows when the connectors declare resource types, and [`call`](#direct-calls) when an operation may be called directly. The worker takes each operation's effect, schemas and retry setting from `connectors`, never from the workflow.
|
|
84
74
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
```ts
|
|
88
|
-
interface PolicyCall {
|
|
89
|
-
op: { id: string; vendor: string; resource: string; name: string; effect: Effect }; // no schemas
|
|
90
|
-
effect: Effect; // the same as op.effect
|
|
91
|
-
target?: string; // the instance the call acts on, when the operation declares `target`
|
|
92
|
-
input: unknown; // checked against the operation's schema
|
|
93
|
-
actor: Principal; // who started the run
|
|
94
|
-
run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const call: PolicyCall = {
|
|
98
|
-
op: policyOpOf(shop.order.refund),
|
|
99
|
-
effect: "money",
|
|
100
|
-
input: { id: "ord_1" },
|
|
101
|
-
actor: { id: "alice" },
|
|
102
|
-
run: { id: "r1", workflow: "refund", approvals: [] },
|
|
103
|
-
};
|
|
104
|
-
expect(policy(call)).toEqual(approve("finance-lead"));
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
An operation names the instance it acts on with `target`, a function of its parsed input, so a policy can decide per post or per order rather than per operation. TypeScript types its input `any` there, so annotate it to have it checked:
|
|
75
|
+
An operation names the instance it acts on with `target`, a function of its parsed input, so a policy can decide per post or per order rather than per operation (its Cedar resource, `Sanoma::Target::"order/ord_1"`). TypeScript types its input `any` there, so annotate it to have it checked:
|
|
108
76
|
|
|
109
77
|
```ts
|
|
110
78
|
const shop = defineConnector("shop", {
|
|
@@ -119,23 +87,60 @@ const shop = defineConnector("shop", {
|
|
|
119
87
|
});
|
|
120
88
|
```
|
|
121
89
|
|
|
122
|
-
|
|
90
|
+
The ledger gets one record for the start of the run, each operation call with its decision and the vendor's reply, each approval requested and decided (a policy's hold names the call it holds by its `op` and `opSeq`, the `seq` that call's own record has), each message an approval ignored (`approval.refused`: sent by someone who may not decide it, or not a decision), each sleep (`sleep.started` with `until`, when it ends in ms since the epoch: the time asked for, or for a duration, the record's `at` plus the duration, as the runtime saw it; written for a time already past too), and how the run ended, in a JSONL file per run. A decision is recorded with its `reasons` (the ids of the policies behind it) and, with Cedar policies, their `policyVersion`, a hash of their text. Every record carries `v: 1`, the `app` and the `actor`, and one written inside a `ctx.all` member its `group` (see [Built-ins](#built-ins)); each call's record (`op.called`, `approval.requested`, `sleep.started`) carries its step's `name` (see [Step names](#step-names)); an error is recorded as `{ code?, name, message }`, with `retryable` when the error says, the vendor's `status` and `vendorCode` only from a `DriverError`, and `data` only from the runtime's own errors (the operation, the approval, the input's issues), so an HTTP client's error that escapes a driver does not put the vendor's reply in the ledger. A ledger store's failed append is tried again up to three times; a store throws an error with `retryable: false` for a failure that would only repeat. When a call's record still cannot be written after the vendor replied, the call fails with "<op id> succeeded, but the ledger could not record it" and the run makes no further calls (`run_ended`), so a workflow that catches the failure cannot repeat the side effect unrecorded.
|
|
123
91
|
|
|
124
|
-
People are `Principal`s: `{ id, groups? }`. A run is started as one and a decision is sent as one:
|
|
92
|
+
People are `Principal`s: `{ id, groups?, onBehalfOf? }`. A run is started as one and a decision is sent as one, by id: the groups are the [people files'](#people), never the caller's.
|
|
125
93
|
|
|
126
94
|
```ts
|
|
127
95
|
const client = await SanomaClient.connect(config);
|
|
128
|
-
const runId = await client.start(refund, { id: "ord_1" }, { startedBy: { id: "alice"
|
|
96
|
+
const runId = await client.start(refund, { id: "ord_1" }, { startedBy: { id: "alice" } });
|
|
129
97
|
const approval = await client.decide(runId, { decision: "approve", by: { id: "finance-lead" } }); // status: "approved"
|
|
130
98
|
const run = await client.run(runId); // run.status: "queued" | "running" | "waiting" | "finished" | "failed" | "cancelled"
|
|
131
99
|
```
|
|
132
100
|
|
|
133
|
-
`workflow` is a definition or the name of one in the config's `workflows` (`client.start("refund", …)`); a name the config does not have is refused with `invalid_input`, naming the ones it has. `startedBy` is required. `start(workflow, input, { startedBy, runId })` with a `runId` makes a retried start idempotent: an id that exists returns that run when the workflow, the input (as JSON) and `startedBy` match, and is refused with `invalid_input`, naming what differs, when they do not. Of two starts racing with one new id, the first stands and the second is answered the same way. Errors the runtime and the client throw carry a `code` (`policy_denied`, `approval_rejected`, `not_approver`, `no_pending_approval`, `already_decided`, `run_not_found`, `driver_failed`, `invalid_input`, `run_ended`, `run_running`: `client.result` timed out with the run still going) and `data`. Read it with `errorCode(err)`, not `instanceof`: a run's error comes back from the database as a copy, so `errorCode(await client.result(runId).catch((e) => e))` is `"policy_denied"` for a denied call.
|
|
101
|
+
`workflow` is a definition or the name of one in the config's `workflows` (`client.start("refund", …)`); a name the config does not have is refused with `invalid_input`, naming the ones it has. `startedBy` is required. `start(workflow, input, { startedBy, runId })` with a `runId` makes a retried start idempotent: an id that exists returns that run when the workflow, the input (as JSON) and `startedBy` match, and is refused with `invalid_input`, naming what differs, when they do not. Of two starts racing with one new id, the first stands and the second is answered the same way. Errors the runtime and the client throw carry a `code` (`policy_denied`, `approval_rejected`, `not_approver`, `no_pending_approval`, `already_decided`, `run_not_found`, `driver_failed`, `invalid_input`, `run_ended`, `run_running`: `client.result` timed out with the run still going, `approval_pending`: a [direct call](#direct-calls) is held, `not_admitted`: the data files declare people and not this principal's id) and `data`. Read it with `errorCode(err)`, not `instanceof`: a run's error comes back from the database as a copy, so `errorCode(await client.result(runId).catch((e) => e))` is `"policy_denied"` for a denied call.
|
|
134
102
|
|
|
135
|
-
`startWorker(config, { logLevel })` runs workflows and recovers interrupted runs. `SanomaClient` starts runs, lists them, records approval decisions and reads a run's ledger. `describeConfig(config)`, from `@sanoma-ai/workflows/describe`, returns the same config as plain JSON (its version, each workflow's input as JSON Schema, the operations it may call and its [outline](#outline), each operation's effect and contract: its input as a caller sends it, `io: "input"`, and its output as parsed, `io: "output"`), each vendor's title, logo, package and homepage and whether its connector attaches a fake (`vendors`, by vendor id, the logo as `data:image/svg+xml` URLs), the resource types (`resourceTypes`, see [Resources](#resources)), and the resources the data files declare with what is wrong in them (`resources` and `problems`, see [Data files](#data-files); a workflow's file's problems are on its entry, beside its `file` and `outline`), which is what a UI renders from.
|
|
103
|
+
`startWorker(config, { logLevel })` runs workflows and recovers interrupted runs. `SanomaClient` starts runs, lists them, records approval decisions and reads a run's ledger. `describeConfig(config)`, from `@sanoma-ai/workflows/describe`, returns the same config as plain JSON (its version, each workflow's input as JSON Schema, the operations it may call and its [outline](#outline), each operation's effect and contract: its input as a caller sends it, `io: "input"`, and its output as parsed, `io: "output"`), each vendor's title, logo, package and homepage and whether its connector attaches a fake (`vendors`, by vendor id, the logo as `data:image/svg+xml` URLs), the resource types (`resourceTypes`, see [Resources](#resources)), and the resources the data files declare, each with its binding, the lockfile's orphans and what is wrong in them (`resources`, `orphans` and `problems`, see [Data files](#data-files); a workflow's file's problems are on its entry, beside its `file` and `outline`), which is what a UI renders from.
|
|
136
104
|
|
|
137
105
|
The worker, the client and the app find Postgres at the config's `databaseUrl`, else the `SANOMA_DATABASE_URL` environment variable, else `postgresql://postgres:dbos@localhost:5433/sanoma`, the database `pnpm db:up` starts from this repo's docker compose file. Set one of the first two anywhere but on your own machine.
|
|
138
106
|
|
|
107
|
+
### People
|
|
108
|
+
|
|
109
|
+
People, groups and agents are resources of the built-in `sanoma` connector, declared in a [data file](#data-files) (`resources/people.ts` by convention) and read without running it:
|
|
110
|
+
|
|
111
|
+
```ts
|
|
112
|
+
import { sanoma } from "@sanoma-ai/workflows/resources"; // `sanoma/resources` in a company repo
|
|
113
|
+
|
|
114
|
+
export const ada = sanoma.person({ id: "ada", name: "Ada Ortiz", email: "ada@acme.test", github: "ada-ortiz" });
|
|
115
|
+
export const bo = sanoma.person({ id: "bo" });
|
|
116
|
+
export const refundBot = sanoma.agent({ id: "refund-bot", onBehalfOf: bo });
|
|
117
|
+
export const finance = sanoma.group({ name: "finance", members: [ada] });
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
A membership is written on the group alone. An agent is in the group `agents` besides its own, with `onBehalfOf` the person whose instruction it carries; the runtime's own agents (`scenarios`, `host-check`, `schedule`) are declared by no file. They have no vendor object: no driver, no drift. `readPeople(root)` reads them (again only when a data file changed), and `principalOf(id, people)` is who an id is: its groups, by inverting the members, and its `onBehalfOf`. Once a person is declared, `SanomaClient.start`, `call`, `decide`, `setCredential`, `setSecret` and `clearCredential` admit only a declared id (`not_admitted`, "<id> is not declared in resources/people.ts"), as the app's login admits only a declared person; while none is, any id is taken as given; while the people files have problems, nobody is admitted but the runtime's agents.
|
|
121
|
+
|
|
122
|
+
### Policies
|
|
123
|
+
|
|
124
|
+
The policy is [Cedar](https://docs.cedarpolicy.com): `policies/**/*.cedar` in the root, evaluated in process with `@cedar-policy/cedar-wasm` against a schema built from the resolved config and the people (`schemaOf`). Everything is allowed unless a `forbid` refuses it: the runtime adds the one `permit`, so a company's policies are `forbid`s.
|
|
125
|
+
|
|
126
|
+
```cedar
|
|
127
|
+
@hold("finance")
|
|
128
|
+
@title("Refunds need finance")
|
|
129
|
+
forbid (principal, action in Sanoma::Action::"effect:money", resource)
|
|
130
|
+
unless { principal in Sanoma::Group::"finance" };
|
|
131
|
+
|
|
132
|
+
@id("no-agent-refunds")
|
|
133
|
+
@reason("Agents do not refund")
|
|
134
|
+
forbid (principal in Sanoma::Group::"agents", action == Sanoma::Action::"shop.order.refund", resource);
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups and `agents`, so a misspelt group fails validation), with `onBehalfOf`; each operation's id an action in its effect's group (`effect:money`), on a `Target` (its `target`, else its id; a collection's in `Collection::"<name>"`), with `context.input` from its input schema and, for a collection's save or delete, `context.before`; `workflow:<name>` in the group `start`, on a `Workflow`, with the run's input. `schemaText` prints it; the `sanoma` bin writes it to `.sanoma/schema.cedarschema`.
|
|
138
|
+
- **A hold** is a `forbid` with `@hold("<group>" | "<person id>" | "on-behalf-of")`: the call waits for that approver (`on-behalf-of`: whom the agent acts for), with `@title` as the approval's title. The runtime lifts it once an approval addressed to that approver and covering the operation is approved (`approvedFor`), so a workflow's own `ctx.approval` to the same approver, covering the operation, lets it through. Several holds on one call or start are asked one at a time, in the order written: once one is approved, the policy is asked again. **A deny** is a `forbid` without `@hold`, with `@reason` as its message (else `Refused by <id>`). A policy's id is its `@id`, else `<file>#<n>`; a decision's `reasons` carry the ids into the ledger.
|
|
139
|
+
- **Where it runs**: before every operation call, a run's node, a [direct call](#direct-calls) and [apply](#apply)'s changes (`decideCall`), and before a run starts (see [Approvals](#approvals)). `policy.preview(requests)` asks the same with the input unknown, through Cedar's partial evaluation: `allow`, `hold` with its approver, `deny`, or `depends` on the input, each with the rule behind it; the app's run form and Permissions page show it.
|
|
140
|
+
- **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit`, a `@hold` naming no declared group or person) are `resolveConfig`'s `policyProblems`, one line each, `file:line:col: <policy id>: <message>`. While there are any, the policy refuses every call, start and preview ("The policies do not hold: …"): a repo never runs on part of its rules. `describeConfig(config).policy` carries them as `problems`; `startWorker` warns with them (unless `quiet`), `startApp` lists them and its pages say the policies do not hold, `sanoma check` reports them and runs no scenario, and the `sanoma` bin starts nothing.
|
|
141
|
+
|
|
142
|
+
Later, not yet: the person's numeric GitHub id is stored but unused, so a login renamed and taken by someone else would be admitted as them. The app describes the config when it starts, so a person added while it runs shows in the who-are-you picker and the Permissions grid after a restart (login admits them at once). The Permissions grid asks about workflow starts and direct operations only, not the operations a run calls.
|
|
143
|
+
|
|
139
144
|
### Ledger
|
|
140
145
|
|
|
141
146
|
The ledger is the audit record of a run: who started it, every operation call with the policy's decision, every approval, and how it ended, as `LedgerRecord`s in `seq` order. `client.ledger(runId)` reads it, and the app's run page shows it. Three stores keep it, each a `LedgerStore` (`append`, `read`, and `close` for one that holds a connection), and a config picks one with `ledger`:
|
|
@@ -148,13 +153,15 @@ Every store keeps the first record of an id: a run replayed after a restart writ
|
|
|
148
153
|
|
|
149
154
|
### Approvals
|
|
150
155
|
|
|
151
|
-
An approval comes from the workflow (`ctx.approval(title, { approver, covers?, links?, details? })`) or from the policy holding a call (
|
|
156
|
+
An approval comes from the workflow (`ctx.approval(title, { approver, covers?, links?, details? })`) or from the policy holding a call (a `@hold`). Each is in `run.approvals` with its `status`, so a later call it covers is let through once it is approved.
|
|
157
|
+
|
|
158
|
+
`covers` says which operations an approval stands for, as op ids in the approval's state. A policy hold covers the operation it held; a workflow's approval covers the operations it names, or none. The runtime lifts a hold with `approvedFor(run.approvals, op.id, approver)`: approved, covering this operation, and addressed to the hold's approver. The last part matters: a workflow can request an approval covering any operation from anyone it names, so a check on `covers` alone would let a workflow launder a sign-off through its own `ctx.approval`. Covers name operations, not inputs: an approval that covers `shop.order.refund` covers every later refund call in the run.
|
|
152
159
|
|
|
153
|
-
`
|
|
160
|
+
The approver is a person's id (`"finance-lead"`), or `{ group: "finance" }` for anyone whose principal lists that group in `groups`. A group's name is not a person: `{ id: "finance" }` is not in the group `finance`. `mayDecide(approval, principal, run, people)` is the check the run, the client and the app's Inbox all use; it also refuses the run's own actor while someone else could decide, that is while the approver is a group with another declared person in `people` (the declared people resources; an agent in the group, declared or the runtime's own, does not count; `own change`: nobody decides on their own change, though a person may decide on a run an agent started for them). When nobody else could (the approver is the starter by id, or a group they alone are in), the starter decides, and the `approval.decided` record and the approval say `alone: true`. A policy hold's title is its `@title`, else `<op id> needs <approver>`, the approver shown as `group finance` for a group.
|
|
154
161
|
|
|
155
|
-
|
|
162
|
+
A policy hold's approval also carries the held call: its `op`, its parsed `input`, `opSeq`, the ledger `seq` of the call's `op.called` record, so a UI can show the hold on the call it held, and `reasons`, the ids of the policies that held it.
|
|
156
163
|
|
|
157
|
-
|
|
164
|
+
The policy decides a run's start too, as the action `workflow:<name>` with the input as its context, before `client.start` queues it (the built-in `call`, `apply` and `drift` are not asked; their calls are). A deny throws `policy_denied` and starts nothing; a hold starts the run, which waits on the approval (`op: "workflow:<name>"`, no `opSeq`) before its body runs, and fails if it is rejected.
|
|
158
165
|
|
|
159
166
|
`client.decide(runId, { decision, by, note? }, approvalId?, { timeoutSeconds? })` refuses without sending when the approval is decided already (`already_decided`), when the run has finished, failed or been cancelled (`run_ended`: no run would read the decision), when there is nothing pending (`no_pending_approval`, `run_not_found`) or when `by` may not decide (`not_approver`, naming the approver or group). Otherwise it sends the decision and waits for the run to read it, then returns the approval as decided: the run publishes it on the event `decisionEventOf(approvalId)`. If another decision reached the run first (someone else's, or another of yours), it throws `already_decided` with who decided and how: each message carries an `id` (a new one unless `message.id` is given), and the approval records the one it was decided with as `decidedWith`. The id is also the send's idempotency key, scoped to the approval, so a retried send queues one message. If the run does not read the decision within `timeoutSeconds` (30 by default), for instance because no worker is running, it returns the approval still `pending`; the decision stays queued and the run reads it when it next runs. The run checks the sender again and records anything it refuses as `approval.refused`; when it refuses this decision, `decide` throws `not_approver` rather than returning the approval pending.
|
|
160
167
|
|
|
@@ -166,7 +173,7 @@ A run's ctx calls run one at a time, in program order. Even inside `Promise.all`
|
|
|
166
173
|
|
|
167
174
|
A workflow gets these on `ctx` by listing them in `uses`, next to its operations: `"approval"` (see [Approvals](#approvals)), `"sleep"` and `"all"`. `describeConfig` lists them as each workflow's `builtins`.
|
|
168
175
|
|
|
169
|
-
`ctx.sleep({ until })` takes an ISO 8601 date-time with an offset (`2026-10-07T09:00:00Z`) or epoch milliseconds, and a time already past does not wait; `ctx.sleep({ minutes: 5 })` takes a duration from `ms`, `seconds`, `minutes`, `hours` and `days`, none negative. A request it can't read fails the run with `invalid_input`, as does an operation input its schema refuses. Each sleep writes a `sleep.started` record before it waits, with `until`: the time asked for, or for a duration, the
|
|
176
|
+
`ctx.sleep({ until })` takes an ISO 8601 date-time with an offset (`2026-10-07T09:00:00Z`) or epoch milliseconds, and a time already past does not wait; `ctx.sleep({ minutes: 5 })` takes a duration from `ms`, `seconds`, `minutes`, `hours` and `days`, none negative. A request it can't read fails the run with `invalid_input`, as does an operation input its schema refuses. Each sleep writes a `sleep.started` record before it waits, with `until`: the time asked for, or for a duration, the record's `at` plus the duration, as the runtime saw it.
|
|
170
177
|
|
|
171
178
|
`ctx.all([() => …, () => …])` declares a fan-out: work that could go in any order, such as one post per network. It calls the members one after another, in array order, each awaited before the next starts, and returns their outputs in that order, each typed as its member's. It adds no concurrency: calls through `ctx` run one at a time anyway, and `ctx.all` says what belongs together, so the ledger and a graph of the run can show it. Every record written while a member runs (its operation calls, its approvals, its sleeps) carries `group: { id, index, size, node }`: `id` is `all:<seq>`, the ledger `seq` the run's next record would take when `ctx.all` began (so the same on a replay), `index` the member's position, `size` the member count and `node` the `ctx.all` call's [outline](#outline) node. Records outside a group have no `group`. The first member that throws stops the group: its error is rethrown as it is, and the members after it never run. A `ctx.all` inside a member fails with `invalid_input` (`ctx.all cannot be nested`), as does one started while another is still running (`a ctx.all is already running: await it before the next`) and an argument that is not a list of functions; an empty list returns `[]` and writes nothing.
|
|
172
179
|
|
|
@@ -186,7 +193,7 @@ Every call is a step with a name, which a test and the graph name it by: `ctx.gi
|
|
|
186
193
|
|
|
187
194
|
### Drivers
|
|
188
195
|
|
|
189
|
-
A driver implements a connector's operations with `defineDriver(connector, { resource: { name: (input, call) => … } })`, typed by the connector's schemas, and its package attaches it to the connector it exports (`withSetup`). `call` carries `idempotencyKey`, the same on every retry and replay of one call: pass it to the vendor (or dedupe on it) so a crash between the vendor's reply and the checkpoint does not repeat the side effect. A driver reads its credentials when it is called, never from the config, and declares them (see [Credentials](#credentials)).
|
|
196
|
+
A driver implements a connector's operations with `defineDriver(connector, { resource: { name: (input, call) => … } })`, typed by the connector's schemas, and its package attaches it to the connector it exports (`withSetup`). `call` carries `idempotencyKey`, the same on every retry and replay of one call: pass it to the vendor (or dedupe on it) so a crash between the vendor's reply and the checkpoint does not repeat the side effect. It carries `secrets` too, the store `secret()` markers name (see [Secrets](#secrets)), and `by`, who the call is made as (`run:<id>` inside a run). A driver reads its credentials when it is called, never from the config, and declares them (see [Credentials](#credentials)).
|
|
190
197
|
|
|
191
198
|
When the vendor says no, throw a `DriverError(message, { retryable, status?, vendorCode? })`. An operation declared `idempotent` is tried up to three times (after 1 and 2 seconds) unless the error says `retryable: false` or the reply fails the output schema; any other operation is tried once. When the tries run out, the run fails with the last try's error, not a wrapper: for a `DriverError`, `errorCode(err)` is `"driver_failed"` and the ledger records the vendor's message, status and code. Anything else a driver throws (a `TypeError`, a plain `Error`) fails the run as it is, with no code.
|
|
192
199
|
|
|
@@ -194,7 +201,7 @@ When the vendor says no, throw a `DriverError(message, { retryable, status?, ven
|
|
|
194
201
|
|
|
195
202
|
A driver reads its credentials from the environment when it is called, and declares them in `defineDriver`'s third argument: `defineDriver(acme, impl, { env: z.object({ ACME_API_KEY: z.string().describe("an API key, from Settings, API keys"), ACME_REGION: z.enum(["us", "eu"]).optional().describe("the region; default us") }) })`. Each key is a variable's name, its `.describe()` the one-line manual, `.optional()` marks one that may be unset. The driver reads the raw environment, so `.default()` and `.transform()` are not applied to what it reads (use `.optional()` and name the default in `.describe()`), and a `.refine()` or `.superRefine()` on the `z.object` is not evaluated, only its fields. Only the declared names are read, an empty value counts as unset, and a value is never kept: each variable is set, missing or invalid, with the schema's reason.
|
|
196
203
|
|
|
197
|
-
The environment stays the only interface a driver sees, but a value need not come from a file. Locally, set values live in the `sanoma_credentials` table in the config's database (`databaseUrl`), which the runtime creates on first use. Set one from the app's connector page, or with `client.setCredential("GHOST_ADMIN_API_KEY", value, { by: "alice" })`, and delete it with `client.clearCredential(name, { by })`; `client.credentials()` gives each vendor's statuses, each with its `source` (`environment` or `stored`) and, for a stored one, who set it and when. A name no driver declares, or a value its schema refuses, is refused with the reason, never the value. `startWorker` loads the stored values of the declared variables its environment leaves unset into `process.env` and listens for changes, so a value set or cleared is live without a restart: drivers read `process.env` when they are called. A worker whose listener drops reconnects and loads them again. A variable the process environment sets wins over a stored one, always. The worker starts whether or not every variable is there: it prints one line naming each vendor with a variable missing (and not optional) or invalid, `sanoma: 2 of 5 connectors not configured yet: ghost, resend; set their variables on the connector pages or in the environment` (`unconfigured` names them too; `quiet: true` prints nothing, as `runScenarios` does, its runs calling only the fakes). A call to that vendor then fails, as the call's own failure: a `DriverError` that is not retried, saying why, `<vendor> is not configured: <NAME> (<description>), <NAME> is invalid: <why>; set it on the connector page` (or `set in the environment`, when an invalid value is there: the environment wins over a stored value), checked against the environment at the moment of the call, so a value set on the connector page (or in `process.env`) takes effect on the next call, with no restart. The call's `op.called` record carries it, and the run fails with it like any other final driver failure. `startApp` starts the same way, and its connector pages show and set them.
|
|
204
|
+
The environment stays the only interface a driver sees, but a value need not come from a file. Locally, set values live in the `sanoma_credentials` table in the config's database (`databaseUrl`), which the runtime creates on first use, encrypted with AES-256-GCM under `SANOMA_SECRETS_KEY`, bound to its name (a value moved to another row does not decrypt) (32 random bytes, base64; `sanoma` writes one to `.env` when there is none): storing a value without the key fails, naming it. A value stored before encryption is read as it is, and encrypted on its next write. Set one from the app's connector page, or with `client.setCredential("GHOST_ADMIN_API_KEY", value, { by: "alice" })`, and delete it with `client.clearCredential(name, { by })`; `client.credentials()` gives each vendor's statuses, each with its `source` (`environment` or `stored`) and, for a stored one, who set it and when. A name no driver declares, or a value its schema refuses, is refused with the reason, never the value. `client.setSecret(name, value, { by })` stores a value under any variable's name, declared or not (a data file's `secret()`, or the app's own, such as its GitHub App's keys), checking a declared one as `setCredential` does, and `client.secrets(names)` reads values back for server code, the environment's first, with their `source` and who stored them. `startWorker` loads the stored values of the declared variables its environment leaves unset into `process.env` and listens for changes, so a value set or cleared is live without a restart: drivers read `process.env` when they are called. A worker whose listener drops reconnects and loads them again. A variable the process environment sets wins over a stored one, always. The worker starts whether or not every variable is there: it prints one line naming each vendor with a variable missing (and not optional) or invalid, `sanoma: 2 of 5 connectors not configured yet: ghost, resend; set their variables on the connector pages or in the environment` (`unconfigured` names them too; `quiet: true` prints nothing, as `runScenarios` does, its runs calling only the fakes). A call to that vendor then fails, as the call's own failure: a `DriverError` that is not retried, saying why, `<vendor> is not configured: <NAME> (<description>), <NAME> is invalid: <why>; set it on the connector page` (or `set in the environment`, when an invalid value is there: the environment wins over a stored value), checked against the environment at the moment of the call, so a value set on the connector page (or in `process.env`) takes effect on the next call, with no restart. The call's `op.called` record carries it, and the run fails with it like any other final driver failure. `startApp` starts the same way, and its connector pages show and set them.
|
|
198
205
|
|
|
199
206
|
The values are plain text in the local database: the same exposure as a `.env` file on the same disk. Keep `.env` (`node --env-file-if-exists=.env`) for the bootstrap values a worker needs before it can read the database: the database URL, the bridge binary, the port. A value never reaches the ledger or the description; see [Secrets](#secrets) for why one must never be an input either.
|
|
200
207
|
|
|
@@ -209,25 +216,78 @@ export const repository = defineResource({
|
|
|
209
216
|
vendor: "github",
|
|
210
217
|
type: "repository",
|
|
211
218
|
title: "Repository",
|
|
212
|
-
identity: "name", // how `find` makes the
|
|
219
|
+
identity: "name", // optional, with `find`: how `find` makes the identity, for people
|
|
213
220
|
schema, // a z.object: the fields a read returns, and a data file declares (less the vendor-owned ones)
|
|
214
|
-
fields: { immutable: [], vendorOwned: ["html_url"], writeOnly: [], unordered: ["topics"] }, // and `references`, below
|
|
215
|
-
find: ({ name }) => name, // the
|
|
221
|
+
fields: { immutable: [], vendorOwned: ["html_url", "node_id"], writeOnly: [], unordered: ["topics"] }, // and `references`, below
|
|
222
|
+
find: ({ name }) => name, // optional: the import id that finds a declared resource before it is bound
|
|
223
|
+
bindingId: (state) => String(state.node_id), // optional: the stable id a binding keeps; default `String(state.id)`
|
|
216
224
|
normalize, // optional: the part of a state a drift check compares; default `compareDeclared`
|
|
225
|
+
writable: true, // optional: apply may change its objects (the change operations, below)
|
|
226
|
+
stateless: true, // optional: its objects hold no data, so apply makes one gone again without asking
|
|
227
|
+
afterCreate: [{ op: "acme.repository.protect", input: ({ id, export: name }) => ({ id }), until: (out) => out.ok }], // optional
|
|
217
228
|
});
|
|
218
229
|
export const github = defineConnector("github", { repository });
|
|
219
230
|
```
|
|
220
231
|
|
|
221
|
-
A resource type is a connector's group under its own `type`; `defineConnector` refuses one of another vendor, or under another key, or on a connector without `info.package` (data files import the types from `<package>/resources`), and keeps the list on the connector, beside its vendor info. Its operations are `<vendor>.<type>.import` (`{ id }`,
|
|
232
|
+
A resource type is a connector's group under its own `type`; `defineConnector` refuses one of another vendor, or under another key, or on a connector without `info.package` (data files import the types from `<package>/resources`), and keeps the list on the connector, beside its vendor info. Its operations are `<vendor>.<type>.import` (`{ id }`, an identity or a binding id) and `<vendor>.<type>.read` (`{ id, state?, handle? }`, a state from an earlier call), both answering `{ id, gone: true }` when the vendor has no such object and `{ id, gone: false, state, handle? }` when it has, both effect `read`, idempotent, with the id as the policy's `target`. "Missing" is part of the contract: the driver decides it, so a caller never parses a vendor's error for it. A driver implements them like any other. `handle` is the driver's own data about the object, opaque to everyone else, passed back unchanged (an OpenTofu driver keeps the provider's private data and state version in it); the operations declare it `opaque`, so the ledger records it as `"<handle>"`, never its bytes. Any operation may name such fields (`opaque` in its spec).
|
|
222
233
|
|
|
223
|
-
|
|
234
|
+
A `writable` type has four change operations besides, which the built-in apply workflow (`APPLY_WORKFLOW`, `"apply"`, a name a config's own workflow may not take) calls and no other: `resolveConfig` refuses a workflow whose `uses` names `create`, `update` or `delete` of a declared type, and a run of any other workflow is refused the call. `plan` (effect `read`, idempotent) takes `{ id?, state?, handle?, desired? }` (no `id` for a create, no `desired` for a delete) and returns `{ action, planned?, unknown, replace }`: `none`, `create`, `update`, `replace` or `delete`, the new state without write-only fields, the dotted paths known only after apply, and those whose change replaces the object. `create` (effect `write`, `target` `new:<vendor>.<type>`) takes `{ desired }` and returns `{ id, state, handle? }`, `id` its binding id; `update` takes `{ id, state?, handle?, desired, expect? }` and returns the same, refusing with a `DriverError` whose `vendorCode` is `plan_changed` when the fresh plan's `action` or `replace` differs from `expect`; `delete` (idempotent) takes `{ id, state?, handle? }` and returns `{ id, gone: true }`, an object already gone included. `desired` may hold secret markers at write-only fields, which the driver resolves (`resolveSecrets`) just before the vendor call (`plan` stores no generated secret: one not stored yet gets a throwaway value); no output holds a write-only value.
|
|
235
|
+
|
|
236
|
+
Calling the type declares one resource, which is what a data file does: `repository({ name: "sanoma" })` returns a frozen `Declared`, `{ vendor: "github", type: "repository", name: "sanoma", desired: { name: "sanoma" }, refs: {} }`, branded with its `<vendor>.<type>` under the `DECLARED` symbol. It refuses a field the schema does not have, a field the vendor owns (unless `identity` names it), a value the schema rejects, or an empty identity. A type without `find` (Stripe's, whose objects Stripe names) declares resources without a `name`: they are found only by their bindings ([below](#bindings)).
|
|
224
237
|
|
|
225
238
|
A field may name another declared resource where the type's `fields.references` says so, by dotted path, with the type it names and, in `by`, the fields of that resource the vendor may hold there instead: GitHub's `branch_protection` has `{ repository_id: { type: "github.repository", by: ["name", "node_id"] } }`, since GitHub holds a rule's repository by its node id. So `branch_protection({ repository_id: site, pattern: "main" })` takes the repository `site`. It stands for that resource's `name`: `desired` holds `repository_id: "website"`, which the schema and `find` (the rule is `website:main`) read, and a drift check takes any of the repository's `by` values, read in the same run, as that name; and `refs` says where a resource was named, `{ repository_id: "github.repository:website" }` (a list item's path has its index, `teams.0`). Anywhere else, or of another type, a resource is refused, and the constructor's type (`Declaring`) says the same, so the editor marks it first.
|
|
226
239
|
|
|
227
|
-
Only declared fields are compared for drift, as CloudFormation does: `compareDeclared(fields, state, desired)` keeps the fields `desired` declares, minus `vendorOwned` and `writeOnly` ones, picking objects field by field, list items against the declared item at the same index, and the items of an `unordered` list (a set) against what any declared item declares, sorted, so a set is compared as a multiset whatever order the vendor returns it in. A drift check compares `normalize(actual, desired)` with `normalize(desired, desired)`, leaf by leaf: `diffDeclared(resource, state, desired, accept?)` returns each declared field that differs, `{ path, desired, actual }`, where `accept` gives, by a reference's path, the values that stand for the resource it names. `withoutWriteOnly(fields, desired)` drops write-only values from a declaration. So a field left out is never drift, which covers attributes an OpenTofu provider marks `computed` and `optional` (GitHub's `etag`, `topics`): they are the user's to set, so not `vendorOwned`, but the vendor fills them in when nobody does.
|
|
240
|
+
Only declared fields are compared for drift, as CloudFormation does: `compareDeclared(fields, state, desired)` keeps the fields `desired` declares, minus `vendorOwned` and `writeOnly` ones, picking objects field by field, list items against the declared item at the same index, and the items of an `unordered` list (a set) against what any declared item declares, sorted, so a set is compared as a multiset whatever order the vendor returns it in. A drift check compares `normalize(actual, desired)` with `normalize(desired, desired)`, leaf by leaf: `diffDeclared(resource, state, desired, accept?)` returns each declared field that differs, `{ path, desired, actual }`, where `accept` gives, by a reference's path, the values that stand for the resource it names. `withoutWriteOnly(fields, desired)` drops write-only values from a declaration, keeping a secret's marker, which names a stored value and holds none. So a field left out is never drift, which covers attributes an OpenTofu provider marks `computed` and `optional` (GitHub's `etag`, `topics`): they are the user's to set, so not `vendorOwned`, but the vendor fills them in when nobody does.
|
|
228
241
|
|
|
229
242
|
`describeConfig` lists the resource types as `resourceTypes`, each `{ id: "<vendor>.<type>", vendor, type, title, identity, fields, schema, ops }`: `schema` is its state as JSON Schema, with `$id` `sanoma:resource-type/<id>`, which its operations' contracts `$ref` rather than repeat. `@sanoma-ai/workflows/shared` exports the `Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource` and `ResourceProblem` types.
|
|
230
243
|
|
|
244
|
+
### Collections
|
|
245
|
+
|
|
246
|
+
A collection holds the company's own records, the state no vendor holds. `collection()`, from `@sanoma-ai/workflows/data` (`sanoma/data` in a company repo), declares one with plain values: a name (a lowercase letter, then letters and digits), its key, and typed fields. A company declares its collections in one file, `resources/data.ts`:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
// resources/data.ts
|
|
250
|
+
import { collection } from "sanoma/data";
|
|
251
|
+
|
|
252
|
+
export const outbound = collection({
|
|
253
|
+
name: "outbound",
|
|
254
|
+
key: "company", // a required text field
|
|
255
|
+
fields: {
|
|
256
|
+
company: "text",
|
|
257
|
+
stage: { enum: ["researched", "contacted", "won", "lost"] },
|
|
258
|
+
owner: "person",
|
|
259
|
+
lastTouched: { type: "date", optional: true },
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
A field is `"text"`, `"number"`, `"boolean"`, `"date"` (ISO 8601: a date, or a date and time with its offset) or `"person"` (a person's id), `{ type, optional: true }`, or `{ enum: [...] }` (optional too). A record is typed by them (`RecordOf<typeof outbound.fields>`), and `save` refuses a field that is not declared. The config's `collections` lists them; `sanoma` adds every collection `resources/data.ts` exports when it loads the config, so a company's config lists none.
|
|
265
|
+
|
|
266
|
+
`resources/data.ts` is the one data file that runs: the launcher imports it, and so does a workflow that names a collection's operations (`import { outbound } from "../resources/data.ts"`, the one file outside `workflows/` or `policies/` the workflow lint lets them import, found by its real path). So it may import only `collection` from `sanoma/data` (or `@sanoma-ai/workflows/data`), and each `export const` is a `collection({ … })` with its values written out, which the [data-file lint](#data-files) holds it to. No other data file declares a collection or imports `resources/data.ts`. Their operations are of the vendor `data` (`DATA_VENDOR`, which no connector may take): `data.outbound.get` (`{ key }`: the record, or `null`), `.list` (`{ where?, order?, limit? }`: fields equal to a value or within `{ gt, gte, lt, lte }`, sorted by a field, or `-field` for descending, with missing values last, else by key; values compare as their field's type, text byte by byte (Postgres's `COLLATE "C"`, whatever the database's collation), and one of another type counts as missing; 1 to 1000 records, 100 by default), `.save` (`{ record }`: added, or replacing the one with its key) and `.delete` (`{ key }`: `{ deleted }`). `get` and `list` are effect `read`, `save` and `delete` `write`, all idempotent, and the policy's `target` is `data:outbound/<key>` (`data:outbound` for a list). A workflow names them in `uses` and calls them on `ctx`, as any operation:
|
|
267
|
+
|
|
268
|
+
```ts
|
|
269
|
+
uses: [outbound.get, outbound.save],
|
|
270
|
+
async run(ctx, { company }) {
|
|
271
|
+
const lead = await ctx.data.outbound.get({ key: company });
|
|
272
|
+
if (lead) await ctx.data.outbound.save({ record: { ...lead.record, stage: "contacted" } });
|
|
273
|
+
},
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
`get`, `list` and `save` answer `Saved` records, `{ key, record, updatedAt, updatedBy }`, where `updatedBy` is who the call was made as (`CallContext.by`: `run:<id>` inside a run, the person's id for a [direct call](#direct-calls)). The records are JSON rows of one table the runtime creates beside the ledger, `sanoma_data` (`collection`, `key`, `record`, `updated_at`, `updated_by`), in the config's database. A declaration changes with no migration: a record saved under an older one comes back as it is stored, with `issues` (`{ path, message }`) where the fields no longer accept it. A sandbox run keeps the records in memory, in a fake that starts empty: a scenario seeds a record as it seeds a resource, `Given data.outbound "Acme" has` with a table of fields (the key is the quoted name, and what the table leaves out is made up), and removes one with `Given data.outbound "Acme" is gone`. `describeConfig` lists them as `collections`, each `{ name, title?, key, fields, record, ops, declared? }`, `record` being the JSON Schema `save` accepts, with `$id` `sanoma:collection/<name>`, and `declared` where `resources/data.ts` declares it (`{ id, file, name, span }`, as `readDataFiles` reads it), which the app's Resources page lists. `@sanoma-ai/workflows/shared` exports `DATA_VENDOR` and the `Saved` and `ListQuery` types.
|
|
277
|
+
|
|
278
|
+
### Direct calls
|
|
279
|
+
|
|
280
|
+
An operation may also be called directly, outside a run, as the person on a page: `isDirect(op, types)` says which, the one rule the app and the SDK read (`describeConfig` gives it as each operation's `direct`). A collection's operations may, and a vendor's `read`, but not the runtime's own (`sanoma.source.resolve`) or a resource type's (`isResourceOp`: only drift and apply call those); anything with an effect at a vendor is a workflow's.
|
|
281
|
+
|
|
282
|
+
```ts
|
|
283
|
+
const saved = await client.call("data.outbound.save", { record }, { actor: { id: "ada" }, id: requestId });
|
|
284
|
+
const customer = await client.call("stripe.customer.get", { id }, { actor: { id: "ada" } });
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
`client.call(opId, input, { actor, id? })` refuses an unknown operation, one that may not be called directly and input its schema refuses (`invalid_input`). The policy decides the call as the person, in a run `direct:<actor>:<id>` of the workflow `call` with no approvals (`id` is a new UUID unless given). A deny throws `policy_denied` and writes nothing. An allowed call runs the driver once, without DBOS and without a retry, as `CallContext` `{ idempotencyKey, runId }` both that run id and `by` the person's id; a failure is `driver_failed`. A read returns its output and records nothing, so a page polling it does not fill the ledger. A write is one `op.called` record (seq 0) of that run, and with the default ledger it is in one Postgres transaction with the record it saves: both are kept, or neither. The same id with the same input returns the output recorded, without writing again; with another operation or input it is refused (`invalid_input`, `differs`). When the policy asks for an approval, the call becomes a run of the built-in `call` workflow (`CALL_WORKFLOW`), started as the person with run id `call:<actor>:<id>` and input `{ op, input }`: its one call is held like any run's, shows in `client.approvals`, and once approved is made as the person (a save's `updatedBy` is theirs). `client.call` throws `approval_pending` (`ApprovalPendingError`) with `data` `{ runId, workflow: "call", op }`, and so does the same call again (same id) while that run waits; once it has ended, the same call returns what the run returned (or throws its error), without asking the policy again. A write whose driver failed keeps its failure under its id: call again with another id to try again. The `call` workflow `uses` every operation that may be called directly and has a driver; `resolveConfig` adds it when there is one, and a config's own workflow may not be named `call`.
|
|
288
|
+
|
|
289
|
+
`client.latest(workflow)` is the newest finished run of a workflow, `{ runId, output, at }` (`at` when it finished), or `null`: one query, for a page that shows a workflow's last result.
|
|
290
|
+
|
|
231
291
|
### Data files
|
|
232
292
|
|
|
233
293
|
A company declares the resources it wants in TypeScript data files under `resources/` beside its config, `resources/<area>/<file>.ts`, as its workflows are in `workflows/`. A data file holds plain values and references only, so Sanoma reads it without running it:
|
|
@@ -249,11 +309,11 @@ export default [website, websiteMain];
|
|
|
249
309
|
|
|
250
310
|
Another data file names `website` by importing it: `import { website } from "./github.ts"`.
|
|
251
311
|
|
|
252
|
-
What a data file may hold: named imports, of a connector's constructors from its `<package>/resources` entry (exported under the vendor's id)
|
|
312
|
+
What a data file may hold: named imports, of a connector's constructors from its `<package>/resources` entry (exported under the vendor's id), of resources from other `.ts` data files in `resources/`, and of `secret` from `sanoma/resources` (or `@sanoma-ai/workflows/resources`); `export const <name> = <vendor>.<type>({ … })`, one resource per statement and each name once, whose fields are strings (and template literals without `${}`), numbers, booleans, null, objects, arrays and, where the type's `fields.references` allows one, names of resources the file declares or imports, and, where the type's `fields.writeOnly` lists the field, `secret("NAME")` or `secret("NAME", { generate: true })` with literal arguments (see [Secrets](#secrets)); and `export default [a, b]`, the file's resources. `resources/data.ts` is the exception: it holds the [collections](#collections) and nothing else. Nothing else: no other calls, `new`, functions, spreads, computed keys, member access (`site.name`: name `site`), `process`, `undefined` (leave the field out) or type assertions. Each refusal says what to write instead.
|
|
253
313
|
|
|
254
314
|
The data files are found from the config's root: `defineConfig` records the file it is called from as the config's `file`, and `resolveConfig` gives its directory as `root`. A config bundled into one file sets `root` itself. A config with neither has no data files, and says so, rather than guess the working directory.
|
|
255
315
|
|
|
256
|
-
`readDataFiles(root, connectors)`, from `@sanoma-ai/workflows/describe`, reads every regular `.ts` file under `<root>/resources` (but `*.test.ts`, `*.d.ts` and `node_modules`), parsing it with oxc-parser and never importing it. A constructors import must be exactly a connector's `<info.package>/resources`, matched among the config's `connectors`; each resource is checked by calling its type, as running the file would, with each resource it names given as that resource. It returns `{ resources, problems }`: one `DeclaredResource` per `export const`, by file and then in file order, less any with a problem and any that names one, and the problems, by file, line and column:
|
|
316
|
+
`readDataFiles(root, connectors)`, from `@sanoma-ai/workflows/describe`, reads every regular `.ts` file under `<root>/resources` (but `*.test.ts`, `*.d.ts` and `node_modules`), parsing it with oxc-parser and never importing it. A constructors import must be exactly a connector's `<info.package>/resources`, matched among the config's `connectors`; each resource is checked by calling its type, as running the file would, with each resource it names given as that resource. It returns `{ resources, bindings, orphans, collections, problems }` (`collections`: where `resources/data.ts` declares each collection, `{ id, file, name, span }`, never matched to a connector, a type or the lockfile): one `DeclaredResource` per `export const`, by file and then in file order, less any with a problem and any that names one, and the problems, by file, line and column:
|
|
257
317
|
|
|
258
318
|
```ts
|
|
259
319
|
interface DeclaredResource {
|
|
@@ -261,7 +321,9 @@ interface DeclaredResource {
|
|
|
261
321
|
file: string; // the file, as `id` names it: "resources/identity/github.ts"
|
|
262
322
|
vendor: string; // "github"
|
|
263
323
|
type: string; // "branch_protection"
|
|
264
|
-
name: string; // its
|
|
324
|
+
name: string; // what people call it: its identity, else its export's name: "website:main"
|
|
325
|
+
identity?: string; // the import id that finds it unbound, from `find`: "website:main"
|
|
326
|
+
binding?: string; // the id of the object the lockfile binds it to: "BPR_kwDOabc123"
|
|
265
327
|
span: readonly [start: number, end: number]; // its `export const`, as UTF-16 offsets into the file
|
|
266
328
|
desired: Record<string, unknown>; // its fields as its type checked them: { repository_id: "website", … }
|
|
267
329
|
refs: Record<string, string>; // where it names a resource, to that resource's id: { repository_id: "resources/identity/github.ts#website" }
|
|
@@ -275,10 +337,25 @@ interface ResourceProblem {
|
|
|
275
337
|
}
|
|
276
338
|
```
|
|
277
339
|
|
|
278
|
-
A problem is anything outside the subset, a constructors import of no connector in the config or a data file that is not one, a type the connector does not declare, a value the type refuses (a resource where it takes none among them), a reference cycle, or two resources of one type with one
|
|
340
|
+
A problem is anything outside the subset, a constructors import of no connector in the config or a data file that is not one, a type the connector does not declare, a value the type refuses (a resource where it takes none among them), a reference cycle, or two resources of one type with one identity, and, from the lockfile ([below](#bindings)), a lockfile that is not JSON or not bindings, a binding of another type than its declaration's, or two declarations bound to one object. `describeConfig` lists both, as `resources` and `problems` (and the lockfile's `orphans`), so the app starts with the resources it could read and shows what is wrong in the rest. `readResources(config)` is the strict reading, for a test or CI: it returns the resources, or throws one error listing every problem at `file:line:column`.
|
|
341
|
+
|
|
342
|
+
#### Bindings
|
|
343
|
+
|
|
344
|
+
Which vendor object a declaration means lives in a lockfile beside the data files, `resources/sanoma.lock.json` (`LOCKFILE`), not in the declaration, as `package-lock.json` is beside `package.json`: data files hold intent only, so an edit to a field cannot point a declaration at another object, copying a declaration copies no binding, and a rename is an update. Only apply writes it; it is committed.
|
|
345
|
+
|
|
346
|
+
```jsonc
|
|
347
|
+
{
|
|
348
|
+
"resources/identity/github.ts#website": { "type": "github.repository", "id": "R_kgDOabc123" },
|
|
349
|
+
"resources/billing/stripe.ts#pro": { "type": "stripe.product", "id": "prod_Rx2" },
|
|
350
|
+
}
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
`readDataFiles` joins each declaration to its binding by its key, `<file>#<export>`, as `binding`, returns the lockfile's `bindings`, and lists as `orphans` (`{ key, type, id }`) the bindings no data file declares. A missing lockfile binds nothing. `@sanoma-ai/workflows/describe` exports `readLockfile(root)` (`{ bindings, problems }`), `serializeLockfile(bindings)` (keys sorted, two-space JSON, a trailing newline), `writeLockfile(root, bindings)` (whole or not at all: written beside it, then renamed), and `diffLockfiles(before, after)`, each change a `Rebind`: `{ key, type, kind: "bind" | "rebind" | "unbind", from?, to? }`.
|
|
279
354
|
|
|
280
355
|
oxlint holds `resources/**` to the subset with the rule `sanoma/data-file`, an oxlint JS plugin the package's `oxlint.json` loads from `dist/plugin.js`, so extending it (see [Keeping workflows replay-safe](#keeping-workflows-replay-safe)) covers data files as well; it does not know the config, so whether a connector, type or value is known is the reader's to say, in `describeConfig(config).problems`, which `sanoma check` prints.
|
|
281
356
|
|
|
357
|
+
Sanoma writes data files too, from `@sanoma-ai/workflows/describe`. `setFields(text, exportName, fields, options)` returns the file's text with fields of one resource set, by dotted path as a drift report names them (`{ has_wiki: true, "metadata.owner": "ops" }`; a field the declaration lacks is added): only that `export const` changes, formatted with [oxfmt](https://oxc.rs/docs/guide/usage/formatter), and every other byte of the file stays as it was. It refuses a resource the file does not declare, a path through a value that is not an object or a list, and a value that is or holds a reference (a person changes those); a path whose segments name a field with a dot in its key (`metadata: { "app.owner": … }`) finds that field. `renderDataFile({ imports, exports }, options)` renders a whole new file, which `readDataFile` reads back as given (what it returns renders the file again, less its comments); a reference is a `Reference`, and a secret's marker is written as `secret("NAME")` (or with `{ generate: true }`), the file then importing `secret` from `"sanoma/resources"` (`setFields` adds that import after the file's own when it sets one). `formatOptions(root)` reads the options from `<root>/.oxfmtrc.json`, oxfmt's defaults without one. oxfmt is a dependency of this package, loaded only when a file is written.
|
|
358
|
+
|
|
282
359
|
### Drift
|
|
283
360
|
|
|
284
361
|
`drift` is a built-in workflow: `resolveConfig` adds it to every config whose connectors declare resource types, so the worker runs it, `describeConfig` lists it (its entry has `builtin: true`, a label) and it starts like any other workflow, with the input `{}`. A config's own workflow may not take its name. It `uses` the `import` of each resource type that has a driver, so its version moves with the connectors and drivers, not with the data files, and it is held to the same lint as any workflow: every vendor call goes through `ctx`, so the policy sees each as effect `read` and the ledger records it.
|
|
@@ -286,17 +363,43 @@ oxlint holds `resources/**` to the subset with the rule `sanoma/data-file`, an o
|
|
|
286
363
|
```ts
|
|
287
364
|
const runId = await client.drift({ startedBy: { id: "alice" } }); // start(drift, {}, …)
|
|
288
365
|
const report = await client.driftReport(runId); // waits for it, as `result` does
|
|
289
|
-
// { startedAt, finishedAt, problems: [], resources: [{ id, type, vendor, name, status: "drifted", fields: [{ path: "has_wiki", desired: false, actual: true }] }, …] }
|
|
366
|
+
// { startedAt, finishedAt, problems: [], resources: [{ id, type, vendor, name, binding, status: "drifted", fields: [{ path: "has_wiki", desired: false, actual: true }] }, …] }
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
The run reads the data files and the lockfile itself, in its first step (`drift:data-files`, recorded, so a replay checks the same list): its input is `{}`, and any other is refused, so no caller can hand it other resources. The step records each resource without its write-only values, and the files' problems, which the report carries (`problems`; a file with problems contributes no resources, as in `describeConfig`). The worker loads the reader only then. Then the run imports each resource by its binding, else by its identity, one vendor call each (none for a resource with neither), in the files' order (every run imports: no state is kept between runs yet), and compares the declared fields with `diffDeclared` (above), a reference against the state of the resource it names from the same run. Each resource's `status` is `clean`, `drifted` (with `fields`, each leaf that differs: `{ path, desired, actual }`, a list item's path with its index, a list whose length changed or an `unordered` one whole), `gone` (the import of its binding says so), `absent` (it is not bound; `found` is the binding id of the object its identity found, if any, which apply may bind it to) or `error` (with `error`: the vendor's error, a policy denial, or no driver for its type); one resource's failure does not stop the others, but a failure that ends the run (`isRunControlError`: DBOS cancelling or stopping it) does. `client.drift` throws `invalid_input` when the connectors declare no resource types; `client.driftReport(runId)` throws `invalid_input` for another workflow's run. `DriftReport`, `DriftResult`, `DriftField` and `DriftStatus` are the report's types, also in `@sanoma-ai/workflows/shared`; `client.runs({ workflow: "drift" })` lists drift runs.
|
|
370
|
+
|
|
371
|
+
A host's revision is drift too (see [Hosts](#hosts)): the run resolves its ref with the built-in `sanoma.source.resolve`, and a host whose state runs another commit is `drifted`, with a field at its `revision` path (`desired` the commit the ref names now).
|
|
372
|
+
|
|
373
|
+
### Apply
|
|
374
|
+
|
|
375
|
+
`apply` is the built-in workflow that makes the vendors match the data files, beside `drift` and listed the same way (`builtin: true`). It `uses` the `import` of each resource type with a driver, the change operations of each writable one, and `approval`; it is the only workflow that may call a change operation. Its input is `{ only?, bind?, answers? }` (any other field refused): `only` the declared ids (`<file>#<export>`) or orphaned lockfile keys to plan, else all; `bind`, a declared id to the vendor's id of the object to bind it to; `answers`, by id or key, `recreate` or `forget` for a gone object, `forget` or `delete` for an orphaned binding.
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const runId = await client.apply(
|
|
379
|
+
{ answers: { "resources/infra.ts#store": "recreate" } },
|
|
380
|
+
{ startedBy: { id: "alice" } },
|
|
381
|
+
);
|
|
382
|
+
const report = await client.applyReport(runId); // { plan: { problems, rebinds, steps }, results, lockfile: { written, revision?, bindings } }
|
|
290
383
|
```
|
|
291
384
|
|
|
292
|
-
The run reads the data files
|
|
385
|
+
The run reads the data files and the lockfile in its first step (`apply:data-files`; a secret's marker stays in `desired`, a plain write-only value does not), with the lockfile's `rebinds`: what changed in it since apply last wrote it (`diffLockfiles` against the bindings the latest live apply run's last `apply:lockfile` step wrote), changes someone else made. It imports each declared resource as drift does (by the binding `bind` gives, else the lockfile's, else its identity), then plans each one `only` wants, each after the resources it names, as a `PlanStep` (`{ id, vendor, type, action, … }`): `bind` (to the object `bind` gives, or the one its identity `found`, by its binding id), `create` (unbound and nothing found: `planned` and `unknown` from its type's `plan`), `update` or `replace` (its bound object as `plan` says, with drift's `fields` and the paths that force a replace), `none`, `gone` (its bound object is no longer there: skipped unless answered, since a recreated database looks fine and has lost its data; a type with `stateless: true`, holding no data, is planned as a `create` instead, unless answered), and `orphan` for each binding no declaration has (skipped unless answered: deleting a declaration never deletes its object). What cannot be planned (a read-only type that is absent or drifted, an answer that cannot be done, an id the input names that nothing declares) is one of the plan's `problems`, with no step.
|
|
386
|
+
|
|
387
|
+
When the policy would hold any change call the plan makes (asked ahead, in the step `apply:policy`, as each call would ask it), the run asks one approval of the first approver it names, titled "Apply resource changes", covering every change operation in the plan, with the plan as JSON in its `details`; a policy that lets a covered call through once approved (`approvedFor`) then holds none of them again. Then each step runs, in order: `create`, `update` (with `expect`, the plan's action and replace, so a vendor that now plans otherwise refuses with `plan_changed`), `delete`, or only the lockfile changed. A reference to a resource the vendor holds by another field (`by`) is given that resource's binding id, one made earlier in the run included. Right after any step that changes a binding, that binding is changed on the lockfile as it is then (so another run's change made meanwhile stays), written whole and committed through the worker's `source` (`apply:lockfile`: `Apply: create resources/infra.ts#store (supabase.project abcd…)`, by the person who started the run), so an object made is never left unbound. Right after a create's binding is written, the run calls its type's `afterCreate` operations in order, by id, each given the new object's binding id and its export (a Supabase project waits for its health, then stores its database URL); one with `until` is called again every 5 seconds until its output passes, for up to 10 minutes, and one that never does fails the step, its object still bound. The approval covers the `write` ones among them too. Each step's result is `done`, `skipped` (unanswered) or `failed` (with `error`; the run goes on); a host's that it made or changed carries `host`, its `outputs`. A sandbox run writes nothing: the report's `lockfile.bindings` is its copy. `ApplyPlan`, `PlanStep`, `ApplyReport` and `ApplyResult` are the types, also in `@sanoma-ai/workflows/describe` and `/shared`.
|
|
293
388
|
|
|
294
389
|
This package knows nothing of OpenTofu. Resource types from an OpenTofu provider are generated by [`@sanoma-ai/bridge/tfschema`](../bridge/src/tfschema/AGENTS.md), which turns the provider's schema document into zod schemas and flagged fields (`resources.gen.ts`); `@sanoma-ai/bridge` depends on this package, never the other way round.
|
|
295
390
|
|
|
391
|
+
### Hosts
|
|
392
|
+
|
|
393
|
+
A host is a resource type that runs the company (a container, a Render service, a Fly app): `defineResource({ …, writable: true, host })`, where `host` (`HostSpec`) says where the type takes the runtime's inputs, by dotted path into its fields (`inputs: { source, ref, secrets, port }`, `secrets` a write-only field), which field apply fills with the commit (`revision`, which a data file may not declare: it names a branch, never a commit), what it can do (`capabilities: { lifetime, publicUrl, buildsFromGit, staticEgress }`), and how its state reads (`outputs(state) → { url, revision?, healthy }`). `defineResource` refuses a `host` that names no field, or whose `secrets` is not write-only. A write-only field may be a map of secrets (`secrets: { SANOMA_DATABASE_URL: secret("DATABASE_URL") }`): its markers are kept as one at the field is. A host type is `stateless` (a `ResourceSpec` field, default true for a host): its objects keep no data, so replacing or recreating one is routine. `source` is a clone URL, or a reference to a declared repository with a `by` field holding one.
|
|
394
|
+
|
|
395
|
+
A config whose connectors declare a host type has the built-in operation `sanoma.source.resolve` (`SOURCE_RESOLVE`, effect `read`): `{ url, ref } → { revision }`, by `git ls-remote` on this machine (a branch, then a tag, peeled; a full commit id is itself). Apply calls it for each host it plans, and gives the host the commit as its `revision` and the clone URL as its `source` (a referenced repository's, from its `by` field); a host whose ref does not resolve, or whose repository has no clone URL yet (apply the repository first), is a problem of the plan. The drift run calls it for every host it reads. A host's `secrets` map may name sanoma's own secrets (`SANOMA_…`), and no other field of any type may. `resolveRevision(url, ref)` is the same, outside a run.
|
|
396
|
+
|
|
296
397
|
### Secrets
|
|
297
398
|
|
|
298
399
|
Never put a secret in a workflow's input or an operation's input or output. They are persisted verbatim: DBOS keeps every step's input and output in Postgres, the ledger records each call's input and output, an approval a policy asks for carries the held call's input, and the app shows all of it to anyone who can reach it. A driver reads its credentials when it is called (from the environment or a secret store), and an operation that creates a secret (an API key, a password reset link) returns a reference to where it is stored, not the secret.
|
|
299
400
|
|
|
401
|
+
A secret a resource needs is named, never written. A data file gives `secret("NAME")` (from `sanoma/resources`; `NAME` upper case, as a variable's; sanoma's own, `SANOMA_…`, only in a host's `secrets`) at a write-only field (or an entry of a write-only map), and `secret("NAME", { generate: true })` for one apply makes when none is stored: `database_password: secret("SUPABASE_DB_PASSWORD", { generate: true })`. Declarations, checkpoints and the ledger hold its marker, `{ "$secret": "NAME", "generate": true }`; the resource type refuses one at any other field, and its schema does not judge it. The driver resolves the markers just before the vendor call, `await resolveSecrets(input, call.secrets)`: each is replaced by the stored value, a `generate` one with none stored first made (32 random bytes, base64url) and stored, and one with nothing stored fails the call for good. `call.secrets` is `{ get(name), ensure(name, make) }`: the environment wins, else the `sanoma_credentials` table (see [Credentials](#credentials)); a fake's and a sandbox run's are in memory (`memorySecrets()`, which a driver's tests use too).
|
|
402
|
+
|
|
300
403
|
### Versions
|
|
301
404
|
|
|
302
405
|
Every run is stamped with the application version of the worker that runs it, `<appName>@<version>`, and only a worker on that version recovers it after a restart. Versioning is automatic: the version is a hash of each workflow's name, the source of its `run` function, its operations and its input schema, which is DBOS's own scheme applied to the workflow code rather than to the runtime's registration wrapper (DBOS would otherwise see one identical function for every workflow and never change). The hash cannot see functions `run` calls that live elsewhere, op schemas, drivers or the policy: edit those with no run in flight, or restart on the old code first. To name a version instead (a git commit, say), set DBOS's `DBOS__APPVERSION` environment variable; it is prefixed with the app name the same way. A new version becomes the app's latest when it first starts, and runs queued without a version go to the latest; a worker started on an older version (a rollback) warns instead, unless started with `{ promote: true }`. The hash is of the code as it runs, so TypeScript source and the compiled JavaScript of the same workflow have different versions.
|
|
@@ -307,9 +410,9 @@ Queues used to be one `sanoma` queue for every app and are now `sanoma:<appName>
|
|
|
307
410
|
|
|
308
411
|
## Keeping workflows replay-safe
|
|
309
412
|
|
|
310
|
-
A run is replayed after a restart by calling the function again and reading each step's result back, so workflows
|
|
413
|
+
A run is replayed after a restart by calling the function again and reading each step's result back, so workflows must do the same thing every time, and must reach vendors only through `ctx` so the policy sees every call. Two checks guard against getting that wrong by accident, on files under `workflows/`. They are not a sandbox: they read the source, and code written to get around them can.
|
|
311
414
|
|
|
312
|
-
oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date`, `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `
|
|
415
|
+
oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`Date`, `performance`), randomness (`Math.random`, `crypto`), the network (`fetch`, `WebSocket`), timers, `process`, `globalThis`, `Promise.all` and its kin (use `ctx.all`; nothing races), and imports of `@sanoma-ai/testing`, `@sanoma-ai/app`, `@sanoma-ai/workflows/describe`, `@sanoma-ai/workflows/fake`, `@sanoma-ai/workflows/plugin`, `@sanoma-ai/workflows/scenario`, `@sanoma-ai/workflows/scenarios`, `@sanoma-ai/connector-*/fake` and `@sanoma-ai/connector-*/driver`. From `@sanoma-ai/workflows` it allows only `defineWorkflow`, `mayDecide`, `errorCode`, `z` (which `sanoma` re-exports) and types (the list is `allowImportNames` in `oxlint.json`; the `sanoma` package's own `oxlint.json` allows the same names from `sanoma` and refuses its subpaths): the rest could start runs or approve the run's own approvals (`SanomaClient`, `startWorker`), forge the audit record (`jsonlLedger`, `memoryLedger`: a store keeps the first record per id) or read credentials. Each message names the `ctx` replacement. `sanoma check` runs it, through `sanoma/oxlint.json`, which carries these rules; without `sanoma`, extend this package's from your `.oxlintrc.json` by its path: oxlint resolves `extends` as a file, not a package name, so `@sanoma-ai/workflows/oxlint` would not load. The `workflows/**` glob resolves against your config:
|
|
313
416
|
|
|
314
417
|
```json
|
|
315
418
|
{
|
|
@@ -317,7 +420,7 @@ oxlint, with the rules this package ships in `oxlint.json`, refuses the clock (`
|
|
|
317
420
|
}
|
|
318
421
|
```
|
|
319
422
|
|
|
320
|
-
`describeConfig` (`@sanoma-ai/workflows/describe`) reads each workflow's file once, the one way the worker does, and lists on the workflow's entry, in `problems`, what oxlint can't express: imports come only from `@sanoma-ai/workflows` or `sanoma`, a `@sanoma-ai/connector-<vendor>` package, zod, or a relative file that stays inside the file's `workflows/`
|
|
423
|
+
`describeConfig` (`@sanoma-ai/workflows/describe`) reads each workflow's file once, the one way the worker does, and lists on the workflow's entry, in `problems`, what oxlint can't express: imports come only from `@sanoma-ai/workflows` or `sanoma`, a `@sanoma-ai/connector-<vendor>` package, zod, or a relative file that stays inside the file's `workflows/` directory (so not `../sanoma.config.ts`, which holds the config); no namespace import of either package; no dynamic `import()`; and no `instanceof` against `DriverError`, `SanomaError`, `PolicyDeniedError` or `RejectedError`. On a replay DBOS rethrows a serialized copy of an error, which is no instance of its class, so a branch on `instanceof` would go another way than the first time and the run's steps would fall out of step. Read `errorCode(err)` instead.
|
|
321
424
|
|
|
322
425
|
In each workflow's `run` it also lists what the run's graph could not show, the same problems that make the outline `{ error }` and `startWorker` refuse (see [Outline](#outline)): `ctx` used other than called directly or read for `ctx.runId` (passed to a helper, aliased, destructured, or `run` taking it destructured); a call on `ctx` that is no operation or built-in (`ctx.ghost.post()`); `ctx.all` over members made elsewhere (write the list, or `items.map(…)`, in the call); a `ctx` call inside a function the outline does not read (one defined inside `run`, or a callback not passed to `ctx.all`, `.map`, `.forEach` or `.reduce`) or in a `finally`; and, in `run`, in a `finally` and in the callbacks that make `ctx` calls, a `return` before the end, a `throw` outside a `catch`, a `break` out of a loop or to a label, and `continue`; and a [step name](#step-names) that is not a literal or reads as an operation's id or `Sleep`, and two steps given one name. A function without `ctx` calls computes, and may do as it likes. Each message says what to write instead: inline the helper, use `if`/`else` around the rest, name the call. Each problem has its `line` and `column` in the entry's `file` (an unreadable file, or one that holds no workflow of the entry's name, is the outline's error at 1:1); `sanoma check` prints them, as `problemLine` does, and the app's startup warns of them. A test holds a repo to the same:
|
|
323
426
|
|
|
@@ -373,6 +476,7 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
|
|
|
373
476
|
| Given | `{op} fails once`, `{op} is rate limited once`, `{op} loses its reply once` | Injects the fault into the operation's next call (`failNext`, `rateLimit`, `loseReply`); its step has `fault: true` |
|
|
374
477
|
| Given | `{resource} {string} has` and a two-column table (`field \| value`) or a JSON doc string | The vendor's fake holds the object with these fields: `Given github.repository "website" has` then `\| has_wiki \| true \|` |
|
|
375
478
|
| Given | `{resource} {string} is gone` | The vendor's fake no longer holds the object: `Given github.repository "docs" is gone` |
|
|
479
|
+
| Given | `{who} is acting` | Starts the run as that declared person or agent: `Given refund-bot is acting` (else `runScenario`'s `startedBy`, `scenarios` by default) |
|
|
376
480
|
| When | `{workflow} runs with` and a JSON doc string or a two-column table (`name \| value`) | The workflow the scenario runs, and its input |
|
|
377
481
|
| When | `{workflow} runs` | The same, with the input made up |
|
|
378
482
|
| Any | `{string} is approved by {who}`, `… with note {string}`, and the `rejected` forms | How the approval with that title (or id, such as `approval-2`) is decided |
|
|
@@ -382,14 +486,18 @@ The steps, as [Cucumber expressions](https://github.com/cucumber/cucumber-expres
|
|
|
382
486
|
| Then | `step {string} was called`, `step {string} was not called` | Expects the named step to have run, or not |
|
|
383
487
|
| Then | `step {string} ran {int} time(s)` | Expects the named step to have run that often: `Then step "Draft the post" ran 2 times` |
|
|
384
488
|
| Then | `{op} was denied` | Expects the policy to have denied a call to it: `Then github.repository.import was denied` |
|
|
489
|
+
| Then | `{op} was denied with {string}` | The same, with that reason (a Cedar policy's `@reason`): `Then shop.refunds.create was denied with "Agents do not refund"` |
|
|
385
490
|
| Then | `{op} was held for {who}` | Expects an approval for that approver to have held it: `Then stripe.product.import was held for finance` |
|
|
491
|
+
| Then | `{op} was held for group {string}` | The same, for the group alone, never a person of that id: `Then shop.refunds.create was held for group "finance"` |
|
|
386
492
|
| Then | `{declared} is drifted in {field}` | For a drift run: `Then resources/identity/github.ts#website is drifted in has_wiki` |
|
|
387
|
-
| Then | `{declared} is clean`, `{declared} is gone`
|
|
493
|
+
| Then | `{declared} is clean`, `{declared} is gone`, `{declared} is absent` | For a drift run: `Then resources/identity/github.ts#docs is clean` |
|
|
494
|
+
| Then | `apply creates {declared}` | For an apply run: it created the resource (or made a gone one again): `Then apply creates resources/identity/github.ts#docs` |
|
|
495
|
+
| Then | `{declared} is bound to {string}` | For an apply run: the lockfile it ended with binds it so: `Then resources/identity/rules.ts#docsMain is bound to "BPR_docs:main"` |
|
|
388
496
|
| Then | `the run succeeds`, `the run fails`, `the run fails with {string}` | Expects the run to finish, or to fail (with that error code, such as `"approval_rejected"`) |
|
|
389
497
|
|
|
390
|
-
A resource step names an object a vendor's fake holds, by resource type and import id (
|
|
498
|
+
A resource step names an object a vendor's fake holds, by resource type and import id (a binding id, or an identity: a GitHub repository's name, a branch protection rule's `repository:pattern`; a fake also finds an object by its binding id, such as a repository's `node_id`), for a fake that holds objects (`@sanoma-ai/bridge`'s `tfFake`, as the GitHub and Stripe fakes are; a sandbox run whose scenario seeds a resource of another vendor's fake fails with `invalid_input`). `has` puts the fields on the object (`put`: over the one the vendor has, keeping its other fields, or a new one), each read as a table cell is for an operation's input and checked against the type's schema; `is gone` removes it (`remove`), and is nothing when there is none. The run's own fake is what is seeded. A fake starts a sandbox run with what its replies were recorded with, so a declared resource the recording lacks is `gone` to a drift run until a scenario puts it.
|
|
391
499
|
|
|
392
|
-
The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`. The drift steps read the report a finished [drift](#drift) run returned, so their scenario must run `drift` (a parse error otherwise); a failed one says how the report found the resource: `resources/identity/github.ts#website is drifted in has_wiki, not clean`, `… is gone, not drifted in has_wiki`, or that the report has no such resource.
|
|
500
|
+
The policy steps read the run's ledger: `was denied` holds when the policy's decision on some call to the operation is `deny`, and `was held for {who}` when an approval addressed to that person (by id) or group (by name) holds it, whether the policy asked for it on that call or the workflow asked for one that `covers` it. A failed step says what happened instead: `github.repository.import was allowed, not denied`, `stripe.product.import was held for finance, not marketing-lead`, `shop.refunds.create was denied with "Agents do not refund", not "No"`. When the data files declare people, `is acting` names a declared person or agent (one of the runtime's own too) and `held for group` a declared group or `agents`, else the scenario does not load, naming those declared; while they declare no one, any id and any group go, as a run's start admits anyone then. A scenario has one actor. The drift steps read the report a finished [drift](#drift) run returned, so their scenario must run `drift` (a parse error otherwise), as the apply steps read an apply run's report, and an apply run's input is only what `runs with` gives, never made up; a failed one says how the report found the resource: `resources/identity/github.ts#website is drifted in has_wiki, not clean`, `… is gone, not drifted in has_wiki`, or that the report has no such resource.
|
|
393
501
|
|
|
394
502
|
The `step` steps name a [step](#step-names) of the scenario's workflow, after its `When`: one whose name is no step's, or several's (a default an operation called twice shares), is an error listing the names it has, so a test names one call (an unnamed call with a computed segment, `ctx.ghost.post[action](…)`, is named in a run by the operation it calls, so it shares that operation's id; an approval whose title is no literal is named by its title in a run, which the outline cannot tell apart), and `was called with` checks its operation's input fields. They read the ledger by the `name` its records carry: a step ran when a call by it was made (not denied, rejected or failed for good), a sleep by it started, or the workflow asked for an approval of that title; a policy's hold on a call is that call's, not a run of its own. A call in a loop runs once per pass: `ran 3 times` for three items. A failed one says what happened: `step "Draft the post" ran twice, not 3 times`.
|
|
395
503
|
|
|
@@ -454,18 +562,20 @@ A sandbox run does not survive a worker restart: its fakes keep their state in t
|
|
|
454
562
|
|
|
455
563
|
`RunSummary.sandbox` names the scenario a sandbox run was seeded from (kept in DBOS's `attributes` for the run). Reusing a run id with another `sandbox`, or none, is `invalid_input`, as with another input.
|
|
456
564
|
|
|
457
|
-
`runScenarios(config, { databaseUrl, startedBy?, timeoutMs?, log? })` from `@sanoma-ai/workflows/scenarios` (server-side only, like `/scenario`) runs every scenario of the config as a sandbox run, on a worker it starts on `databaseUrl` with an in-memory ledger and stops after, and returns `{ passed, failed }`: the names of the scenarios whose every check held, and one `{ scenario, step, message }` per step that did not (with `check`'s detail), per run that could not be driven to its end (at its `When` step), and per feature file that did not load (`step: "did not load"`); a config whose root has no `scenarios/` passes and fails nothing. `sanoma check` runs it. `runScenario(scenario, { client, scope, startedBy?, timeoutMs? })` runs one against a worker you started, `scope` being what it was loaded from (the resolved config). To run them as tests, a vitest test each, use `describeScenarios` from [`@sanoma-ai/testing/scenarios`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#scenarios), which wraps the same `runScenario`.
|
|
565
|
+
`runScenarios(config, { databaseUrl, startedBy?, timeoutMs?, log? })` from `@sanoma-ai/workflows/scenarios` (server-side only, like `/scenario`) runs every scenario of the config as a sandbox run, on a worker it starts on `databaseUrl` with an in-memory ledger and stops after, and returns `{ passed, failed }`: the names of the scenarios whose every check held, and one `{ scenario, step, message }` per step that did not (with `check`'s detail), per run that could not be driven to its end (at its `When` step), and per feature file that did not load (`step: "did not load"`); a config whose root has no `scenarios/` passes and fails nothing. `sanoma check` runs it. `startedBy` (an id or a principal) starts each run whose scenario does not say who `is acting`. `runScenario(scenario, { client, scope, startedBy?, timeoutMs? })` runs one against a worker you started, `scope` being what it was loaded from (the resolved config). To run them as tests, a vitest test each, use `describeScenarios` from [`@sanoma-ai/testing/scenarios`](https://github.com/Sanoma-AI/sanoma/blob/main/packages/testing/README.md#scenarios), which wraps the same `runScenario`.
|
|
458
566
|
|
|
459
567
|
## Building your own UI
|
|
460
568
|
|
|
461
569
|
`@sanoma-ai/app` is one UI over a config; another (a Slack bot, an internal tool) can be built on the same pieces, which the package exports for that:
|
|
462
570
|
|
|
463
|
-
- `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `readDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. It is a separate entry so the worker never loads the parser the outline uses, and the lint refuses it in workflow files. `resolveConfig(config)` returns the checked config as a `ResolvedConfig`, with the operations and drivers by id, the resource types by `<vendor>.<type>` (`types`), the workflows by name (the built-
|
|
464
|
-
- `
|
|
571
|
+
- `describeConfig(config)` and its types (`ConfigDescription`, `WorkflowEntry`, `OpEntry`, `VendorEntry`, `ResourceTypeEntry`, `DeclaredResource`, `ResourceProblem`), and `readDataFiles(root, connectors)` and `readResources(config)`, from `@sanoma-ai/workflows/describe`: what to render, as plain JSON, with each workflow's outline. It is a separate entry so the worker never loads the parser the outline uses, and the lint refuses it in workflow files. `resolveConfig(config)` returns the checked config as a `ResolvedConfig`, with the operations and drivers by id, the resource types by `<vendor>.<type>` (`types`), the workflows by name (the built-ins `drift`, `apply` and `call` among them), its `root` and each vendor's `credentials` (`CredentialStatus`); `isOp(x)` tells an operation from a built-in in a workflow's `uses`.
|
|
572
|
+
- `readSites(root, description)`, from `@sanoma-ai/workflows/describe`, which `describeConfig` returns as `sites`: what each site calls, read with oxc and never run, for a workflow's **Used by**. A site is a folder of `sites/` with a `routes/`, named as a URL segment; its `routes/**` and `components/**` (`.ts`, `.tsx`, less tests) are read for each `<RunForm workflow="…" />` whose `RunForm` is imported from `@sanoma-ai/app/ui`, and each use of `api` imported from `sanoma/site`, called or passed on (`queryFn: api.data.leads.list`): `api.<name>` and `api.<name>.<method>` are the workflow whose `apiNameOf` is `<name>`, `api.data.<collection>.<op>` a collection, `api.<vendor>.<resource>.<op>` the operation of that id. Each is a `SiteCall` (`kind`, `target`, `via`, `file`, `line`, `column`, and `page`, the page's path from its `createFileRoute` or `createLazyFileRoute` id, in a page's file); a `SiteEntry` holds a site's `name`, its `title` (a string `defineSite({ title })` in `site.ts`), its `calls` and its `problems`: each call to a workflow, collection or operation the config does not have (`calls workflow "<name>", which does not exist`), or to an operation a page may not call directly, which `sanoma check` fails on. A name not written out (a variable, a template with holes, a computed member) is skipped, as is a call through a namespace import or a destructured `api`.
|
|
573
|
+
- `generateApi(description)` and `writeApi(root, description)`, from `@sanoma-ai/workflows/describe`: a site's typed `api`, `.sanoma/api.ts` (`API_FILE`), from a `ConfigDescription`, types and JSDoc only and the same text for the same description. It holds a record type per collection and `CompanyApi`: `<apiNameOf(workflow)>: WorkflowFn<input>` per workflow, built-ins too; `<vendor>: { <resource>: { <op>: OpFn<input> } }` for the operations `direct` marks; `data: { <collection>: CollectionApi<record> }`; each member documented from its title or description. Then it augments `sanoma/site`'s `Register`. Inputs are typed from their JSON Schema for the kinds a start form draws, constants, and unions of them (`null` among them); anything else, and every output, is `unknown`. A workflow with `describeConfig`'s api-name problem is left out (`problems`). `writeApi` writes only when the text differs (`wrote`).
|
|
574
|
+
- `SanomaClient`: call an operation directly (`call`, see [Direct calls](#direct-calls)), start runs, list them (`runs({ limit, status, workflow })`) or read a workflow's last result (`latest`), read a run's ledger and approvals, decide approvals, with the checks described above, and start a drift check or an apply and read its report (`drift`, `driftReport`, `apply`, `applyReport`, see [Drift](#drift) and [Apply](#apply)).
|
|
465
575
|
- `APPROVALS_EVENT` and `decisionEventOf(approvalId)`: the DBOS events a run publishes its approvals and each decision on, for a UI that reads DBOS directly. `ApprovalMessage` is the zod schema of a decision as a run reads it. `RunArgs` is what a run receives: its input, `startedBy` and, for a sandbox run, its scenario as `sandbox`.
|
|
466
|
-
- `mayDecide(approval, principal)` and `approverLabel(approver)`: who may decide, and how to name them, the same way the run does. `isEnded(status)` and `ENDED_STATUSES`: the run statuses that read no more decisions; `RUN_STATUSES`: every run status, in the order a run may pass through them.
|
|
467
|
-
- Errors: `errorCode(err)` for the code to branch on, `errorMessage(err)` for the text of anything thrown, `invalidInput(what, issues)` to build an `invalid_input` error from zod issues (`InputIssue` is one issue, without symbols in its path), and the classes `SanomaError`, `PolicyDeniedError`, `RejectedError` and `DriverError`. Read codes with `errorCode`, never `instanceof`.
|
|
468
|
-
- `@sanoma-ai/workflows/shared` exports `mayDecide`, `approverLabel`, `errorMessage`, `isEnded`, `ENDED_STATUSES`, `RUN_STATUSES`, `DRIFT_WORKFLOW`, `isLoopback` (whether a host is this machine: `localhost`, `127.x.x.x`, `::1`), `problemAt` (a data-file problem's `file:line:column`), `problemLine` (a problem as one line: `file:line:column: message`, `file: message` or the message), `credentialReady` (a variable is set, or optional and unset) and the `RunStatus` and `CredentialStatus` types, the resource types' types (`Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource`, `ResourceProblem`) and the drift report's (`DriftReport`, `DriftResult`, `DriftField`, `DriftStatus`), with nothing else: no DBOS or Node imports, so a browser bundle can use them. The main entry exports them too.
|
|
576
|
+
- `mayDecide(approval, principal, run, people)` and `approverLabel(approver)`: who may decide, and how to name them, the same way the run does. `isEnded(status)` and `ENDED_STATUSES`: the run statuses that read no more decisions; `RUN_STATUSES`: every run status, in the order a run may pass through them.
|
|
577
|
+
- Errors: `errorCode(err)` for the code to branch on, `errorMessage(err)` for the text of anything thrown, `invalidInput(what, issues)` to build an `invalid_input` error from zod issues (`InputIssue` is one issue, without symbols in its path), and the classes `SanomaError`, `PolicyDeniedError`, `RejectedError`, `ApprovalPendingError` and `DriverError`. Read codes with `errorCode`, never `instanceof`.
|
|
578
|
+
- `@sanoma-ai/workflows/shared` exports `mayDecide`, `approverLabel`, `errorMessage`, `isEnded`, `ENDED_STATUSES`, `RUN_STATUSES`, `DRIFT_WORKFLOW`, `CALL_WORKFLOW`, `isLoopback` (whether a host is this machine: `localhost`, `127.x.x.x`, `::1`), `problemAt` (a data-file problem's `file:line:column`), `problemLine` (a problem as one line: `file:line:column: message`, `file: message` or the message), `credentialReady` (a variable is set, or optional and unset) and the `RunStatus` and `CredentialStatus` types, the resource types' types (`Resource`, `ResourceSpec`, `ResourceFields`, `References`, `FieldReference`, `Declared`, `Declaring`, `DeclaredResource`, `ResourceProblem`) and the drift report's (`DriftReport`, `DriftResult`, `DriftField`, `DriftStatus`), with nothing else: no DBOS or Node imports, so a browser bundle can use them. The main entry exports them too.
|
|
469
579
|
- `LedgerRecord` and `LedgerStore` for the audit record (`LedgerBody` is a record without the fields every record carries, and `LedgerGroup` a record's `ctx.all` tag), `postgresLedger(databaseUrl)` (the default), `jsonlLedger(dir)` and `memoryLedger()` to keep it, and `RUNTIME_VERSION`, this package's version as the runtime reports it.
|
|
470
580
|
|
|
471
581
|
### Outline
|