@sanoma-ai/workflows 0.7.1 → 0.8.1
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 +34 -7
- package/dist/apps-list.d.ts +90 -0
- package/dist/apps-list.js +109 -0
- package/dist/apps-list.js.map +1 -0
- package/dist/apps.d.ts +42 -0
- package/dist/apps.js +137 -0
- package/dist/apps.js.map +1 -0
- package/dist/call-workflow.js +5 -6
- package/dist/call-workflow.js.map +1 -1
- package/dist/call.js +6 -3
- package/dist/call.js.map +1 -1
- package/dist/cedar.d.ts +1 -1
- package/dist/cedar.js +6 -4
- package/dist/cedar.js.map +1 -1
- package/dist/change-steps.d.ts +10 -0
- package/dist/change-steps.js +204 -0
- package/dist/change-steps.js.map +1 -0
- package/dist/change-workflow.d.ts +54 -0
- package/dist/change-workflow.js +161 -0
- package/dist/change-workflow.js.map +1 -0
- package/dist/change.d.ts +121 -24
- package/dist/change.js +196 -17
- package/dist/change.js.map +1 -1
- package/dist/claims.d.ts +58 -0
- package/dist/claims.js +27 -0
- package/dist/claims.js.map +1 -0
- package/dist/client.d.ts +92 -0
- package/dist/client.js +268 -6
- package/dist/client.js.map +1 -1
- package/dist/collection.d.ts +6 -1
- package/dist/collection.js +11 -3
- package/dist/collection.js.map +1 -1
- package/dist/config.d.ts +30 -2
- package/dist/config.js +41 -20
- package/dist/config.js.map +1 -1
- package/dist/db.d.ts +12 -0
- package/dist/db.js +15 -0
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +5 -2
- package/dist/deadlines.js.map +1 -1
- package/dist/define.d.ts +30 -14
- package/dist/define.js +9 -1
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +15 -4
- package/dist/demo.js +27 -12
- package/dist/demo.js.map +1 -1
- package/dist/describe.d.ts +16 -2
- package/dist/describe.js +39 -4
- package/dist/describe.js.map +1 -1
- package/dist/direct.d.ts +4 -3
- package/dist/direct.js +7 -4
- package/dist/direct.js.map +1 -1
- package/dist/errors.d.ts +2 -0
- package/dist/errors.js +2 -0
- package/dist/errors.js.map +1 -1
- package/dist/index.d.ts +7 -5
- package/dist/index.js +6 -3
- package/dist/index.js.map +1 -1
- package/dist/lint.js +19 -5
- package/dist/lint.js.map +1 -1
- package/dist/outline.js +13 -6
- package/dist/outline.js.map +1 -1
- package/dist/paths.d.ts +16 -0
- package/dist/paths.js +54 -2
- package/dist/paths.js.map +1 -1
- package/dist/resources.d.ts +7 -2
- package/dist/resources.js +23 -8
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +7 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.js +2 -2
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +2 -2
- package/dist/scenario.js +10 -4
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +1 -1
- package/dist/scenarios.js.map +1 -1
- package/dist/sdk.d.ts +1 -1
- package/dist/sdk.js +19 -5
- package/dist/sdk.js.map +1 -1
- package/dist/shared.d.ts +43 -2
- package/dist/shared.js +46 -4
- package/dist/shared.js.map +1 -1
- package/dist/sites.d.ts +26 -5
- package/dist/sites.js +56 -20
- package/dist/sites.js.map +1 -1
- package/dist/source.d.ts +55 -5
- package/dist/source.js +4 -0
- package/dist/source.js.map +1 -1
- package/dist/version.d.ts +8 -0
- package/dist/version.js +21 -13
- package/dist/version.js.map +1 -1
- package/dist/worker.js +50 -3
- package/dist/worker.js.map +1 -1
- package/oxlint.json +2 -2
- package/package.json +1 -1
- package/src/apps-list.ts +119 -0
- package/src/apps.ts +162 -0
- package/src/call-workflow.ts +10 -2
- package/src/call.ts +6 -3
- package/src/cedar.ts +9 -7
- package/src/change-steps.ts +274 -0
- package/src/change-workflow.ts +198 -0
- package/src/change.ts +257 -27
- package/src/claims.ts +79 -0
- package/src/client.ts +320 -4
- package/src/collection.ts +22 -3
- package/src/config.ts +66 -22
- package/src/db.ts +23 -0
- package/src/deadlines.ts +5 -2
- package/src/define.ts +31 -12
- package/src/demo.ts +55 -24
- package/src/describe.ts +67 -4
- package/src/direct.ts +9 -5
- package/src/errors.ts +3 -0
- package/src/index.ts +14 -0
- package/src/lint.ts +20 -5
- package/src/outline.ts +15 -6
- package/src/paths.ts +57 -2
- package/src/resources.ts +27 -9
- package/src/run.ts +5 -0
- package/src/sandbox.ts +2 -5
- package/src/scenario.ts +14 -4
- package/src/scenarios.ts +1 -1
- package/src/sdk.ts +21 -7
- package/src/shared.ts +60 -5
- package/src/sites.ts +70 -24
- package/src/source.ts +50 -5
- package/src/version.ts +25 -16
- package/src/worker.ts +46 -3
package/README.md
CHANGED
|
@@ -59,7 +59,9 @@ A model connector attaches a model half, as `model`: `withSetup(connector, { mod
|
|
|
59
59
|
|
|
60
60
|
A git host's connector attaches a remote half, as `remote`: `withSetup(connector, { remote: () => half })`, a `RemoteHalf` made once per resolved config and kept in `ResolvedConfig.remotes` by vendor; `remoteFor(remotes, url)` finds the one whose `matches` reads a clone URL, with the repository. `open(repo, { secrets })` gives a `SourceHost`: the repository's pull requests (find, open, squash-merge at a head, close), the `sanoma` commit status, and `enforcement(base)`, whether the host refuses a change to the branch that is not a merge (`HostEnforcement`); its `token(prefer)` is what a push signs in with. `webhook(request, { secrets })` verifies a delivery and says what happened (`RemoteEvent`: a branch pushed, a pull request closed). It reads its tokens through `secrets` when called, as a model half does. One vendor's connectors attach one `remote`. [`@sanoma-ai/connector-github`](https://www.npmjs.com/package/@sanoma-ai/connector-github#remote-half) is one.
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
`SanomaClient.remoteEvent(event, { by })` does what a webhook says happened (the app's `POST /webhooks/<vendor>` calls it for each event), for the repository of the client's `source` (`SanomaClient.connect(config, { source })`, as the worker's): an event of another repository, or with no source, is ignored. A push to `main` starts apply on the declared hosts (`ResolvedConfig.hosts()`) as the runtime's agent `host-check`, as the run `deploy:<head>`, so a merge made outside Sanoma deploys too: nothing without a host, nor while a change is merging or deploying, which applies `main` itself, nor when the checkout cannot follow `main` first (warned: a redelivery deploys it). A push to `draft/<id>` while the change of `id` is open brings the host's commits into the draft (`Source.follow` on its branch, a fast-forward) and tells the change `commits`; a closed pull request of `draft/<id>` tells it `merged` (with the merge commit) or `closed`, as `by`. The change run checks a merge or a close on the host before it believes it. A delivery repeated does nothing more: the run id, a draft already at the head, and the event's key (`changeEvent`'s `key`, an idempotency key of its message) see to it. `by` is not the Publish approval's approver, so it withdraws nothing: a change waiting on Publish reads the event when Publish is decided.
|
|
63
|
+
|
|
64
|
+
A config names the connectors, and the workflows when no launcher finds them (`sanoma` imports every file of `workflows/` and of each [app's](#apps), so a company's config lists none; one it lists is kept, once):
|
|
63
65
|
|
|
64
66
|
```ts
|
|
65
67
|
import { defineConfig } from "@sanoma-ai/workflows";
|
|
@@ -73,10 +75,10 @@ export default defineConfig({
|
|
|
73
75
|
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:
|
|
74
76
|
|
|
75
77
|
- **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.
|
|
76
|
-
- **The [scenarios](#scenarios-and-sandbox-runs)** are `<root>/scenarios/`
|
|
77
|
-
- **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`).
|
|
78
|
+
- **The [scenarios](#scenarios-and-sandbox-runs)** are the `.feature` files of `<root>/scenarios/` and of each [app's](#apps) `scenarios/`, named by their path from the root (`scenarios/x.feature`, `apps/crm/scenarios/y.feature`); without any such directory the config has none.
|
|
79
|
+
- **The [policy](#policies)** is `<root>/policies/**/*.cedar` and each [app's](#apps) `policies/**/*.cedar`, decided as the [people](#people) the data files declare; without any, every call and start is allowed (`allowAll`).
|
|
78
80
|
|
|
79
|
-
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.
|
|
81
|
+
The [data files](#data-files) are `resources/` in the root and in each [app](#apps). `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.
|
|
80
82
|
|
|
81
83
|
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`, the policies it is made from as `policies`, and what is wrong in them 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. `resolveConfig(config, { fakes: "instance" })` runs the whole instance on the connectors' fakes, as a [demo](#demos) does: each vendor's fake is made once for the resolution (`instanceFakes`, by vendor) and is its driver, so live runs, sandbox runs and direct calls all call it; the collections stay on the database, and a vendor whose connector attaches a driver and no fake is refused, since it would run live. A worker and a client share such a resolution when given it (`startWorker(config, { resolved })`, `SanomaClient.connect(config, { resolved })`), and its `dataStore` is then the caller's to close. 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.
|
|
82
84
|
|
|
@@ -112,6 +114,16 @@ const run = await client.run(runId); // run.status: "queued" | "running" | "wait
|
|
|
112
114
|
|
|
113
115
|
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. The `sanoma` launcher always sets `SANOMA_DATABASE_URL`: when none is named it starts the company's own Postgres ([sanoma's Postgres](../sanoma/README.md#postgres)). Set one of the first two anywhere but on your own machine.
|
|
114
116
|
|
|
117
|
+
### Apps
|
|
118
|
+
|
|
119
|
+
A company's apps are the folders of `apps/`, each holding what the root does: `workflows/`, `resources/` (its `data.ts` too), `policies/`, `scenarios/` and `sites/`. An app is merged into the repo from a source repository, or made there; its `app.ts` says which, `export default defineApp({ source: "https://github.com/acme/crm" })` (`defineApp({})` for the company's own), read with oxc and never run, as `readApps(root)` reads each (`AppEntry`, `{ name, dir, source? }`, by name). A folder's name is the app's (`APP_NAME`: a lowercase letter, then lowercase letters and digits); `describeConfig` refuses another as a problem, and lists the apps as `apps`.
|
|
120
|
+
|
|
121
|
+
What an app's folder declares is named with the folder, from where its file is (`appOfFile`: the nearest directory with a `sanoma.config.ts`, then `apps/<app>/`), never written: a collection `customers` in `apps/crm/resources/data.ts` is `crm.customers`, its operations `data.crm.customers.get` and so on, and a workflow `follow-up` in `apps/crm/workflows/` is `crm.follow-up`, each with `app: "crm"`; what the root declares keeps its name. So two apps may declare one name. A file is an app's while the nearest directory above it with a `sanoma.config.ts` is the company's root (an app may keep its own `package.json`; a `node_modules` ends the search). A workflow calls an app's collection as `ctx.data.crm.customers.get(…)`, and a site as `api.data.crm.customers.list()`; an app's workflow is `api.crmFollowUp` (`apiNameOf`), and its Cedar action `workflow:crm.follow-up`. A site in `apps/crm/sites/pipeline/` is `crm/pipeline`, at `/sites/crm/pipeline`. `describeConfig`'s workflows, collections and sites carry their `app`.
|
|
122
|
+
|
|
123
|
+
The people are the company's: an app's data files declare none (`readPeople` refuses one, naming its file). A workflow may import the root's `resources/data.ts` and any app's. A relative import from one app's files into another's folder is a warning (`crossAppImports(root, apps)`, from `@sanoma-ai/workflows/describe`, which `sanoma check` prints), since a company may take one app without the other: reach the other through `ctx` or `api` instead. A collection or site at the root named as an app is a problem: `api.data.<app>` and `/sites/<app>/` are the app's.
|
|
124
|
+
|
|
125
|
+
Taking an app is a git merge into a draft of the company's `Source` (the `sanoma` package's `localGitSource`). `draft.take({ url, ref?, folder?, author })` fetches the source repository, a public company repo holding one `apps/<folder>/` (`folder` names it when it holds more), by https alone, and merges it with its history as one merge commit. `draft.update({ folder, author })` merges the new commits of the default branch of the URL the app's `app.ts` names, and `source.behind(folder)` counts them, fetched now (both refuse a source that shares no history with the checkout). Either merge takes `apps/<folder>/` (a take, the source's folder whole) and `package.json` (its connectors, both sides kept); every other file stays the company's, as does a file one side deleted, since the source's root is the starter's. Both return an `AppMerge` (`{ folder, revision, merged, conflicts }`): a line both sides changed is committed with git's markers (diff3: ours, the base, theirs) and its file named in `conflicts`, so the draft's checks fail until the agent resolves it. An update needs the take's merge commit in the history: a draft that takes an app is merged into `main` as a merge, not squashed.
|
|
126
|
+
|
|
115
127
|
### People
|
|
116
128
|
|
|
117
129
|
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:
|
|
@@ -151,7 +163,7 @@ permit (principal == Sanoma::Principal::"anonymous", action == Sanoma::Action::"
|
|
|
151
163
|
- **The schema**, namespace `Sanoma`: `Principal` in `Group` (an enum of the declared groups, `agents` and `visitors`, 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`; `sanoma.change.merge` on a `Change` instead, whose attributes are the change's facts; `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`.
|
|
152
164
|
- **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.
|
|
153
165
|
- **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. A request with `inRun: true` asks about an operation inside a run, not a direct call, which only a visitor's answer tells apart.
|
|
154
|
-
- **A change** to the company's repository is decided on its merge, `sanoma.change.merge`, one of four operations of the built-in connector `sanoma` (`changeOps`: `sanoma.change.open`, `.review`, `.merge` and `.close`, effect `write`) that every config has and only the built-in `change` workflow may call, so a policy names it in every repo. Its resource is a `Sanoma::Change` whose attributes are the facts of the change (`ChangeFacts`): `touches` (each path it edits and each parent directory, `touchesOf`: `resources/`, `resources/finance/`, `resources/finance/cards.ts`), `strandedRuns` and `stranded` (the waiting runs of a workflow it edits), `checksOk` and `rules`. Cedar has no quantifier over a set and `like` reads one string, so a rule asks `contains` of a directory: `forbid (principal, action == Sanoma::Action::"sanoma.change.merge", resource is Sanoma::Change) when { resource.touches.contains("resources/finance/") } unless { principal in Sanoma::Group::"finance" };`. A preview asks with the facts unknown: a rule on any change holds, a rule on the facts depends. Before any policy, the runtime's own rule (`runtimeHold`): a merge of a change to the rules (`rules`: a policy or a people file) waits for the group `admin`, with the reason `sanoma:rules-wait-for-admin`, under `allowAll` too, and is denied while no data file declares `admin`; the people are the running instance's, so a change cannot add its author to `admin`. The company's policy is asked once an approval addressed to `admin` covering the merge lifts it. The template's `policies/changes.cedar` holds every change for `admin`.
|
|
166
|
+
- **A change** to the company's repository is decided on its merge, `sanoma.change.merge`, one of four operations of the built-in connector `sanoma` (`changeOps`: `sanoma.change.open`, `.review`, `.merge` and `.close`, effect `write`) that every config has and only the built-in `change` workflow may call, so a policy names it in every repo. The connector's fifth operation, `sanoma.apps.list` (`APPS_LIST`, a read), lists the apps on GitHub (the repositories with the topic `sanoma-app`) with Sanoma's claims about each (`claims.ts`: tier, review, blocked commits, the solutions holding it); a page calls it directly, as the person, so the policy decides it like any direct call, and every config has the built-in `call` workflow for it. Its resource is a `Sanoma::Change` whose attributes are the facts of the change (`ChangeFacts`): `touches` (each path it edits and each parent directory, `touchesOf`: `resources/`, `resources/finance/`, `resources/finance/cards.ts`), `strandedRuns` and `stranded` (the waiting runs of a workflow it edits), `checksOk` and `rules`. Cedar has no quantifier over a set and `like` reads one string, so a rule asks `contains` of a directory, and an app's files are under its own (`apps/crm/resources/`), which a rule on `resources/` does not cover: `forbid (principal, action == Sanoma::Action::"sanoma.change.merge", resource is Sanoma::Change) when { resource.touches.contains("resources/finance/") } unless { principal in Sanoma::Group::"finance" };`. A preview asks with the facts unknown: a rule on any change holds, a rule on the facts depends. Before any policy, the runtime's own rule (`runtimeHold`): a merge of a change to the rules (`rules`: a policy, the root's or an app's, or a people file) waits for the group `admin`, with the reason `sanoma:rules-wait-for-admin`, under `allowAll` too, and is denied while no data file declares `admin`; the people are the running instance's, so a change cannot add its author to `admin`. The company's policy is asked once an approval addressed to `admin` covering the merge lifts it. The template's `policies/changes.cedar` holds every change for `admin`.
|
|
155
167
|
- **Problems** (a file that does not parse, two policies with one id, a template, what Cedar's strict validation finds, a `permit` for anyone but the visitors or `anonymous`, or with a `@hold`, a `@hold` naming no declared group or person, `visitors` among them) 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.
|
|
156
168
|
|
|
157
169
|
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.
|
|
@@ -182,6 +194,19 @@ The policy decides a run's start too, as the action `workflow:<name>` with the i
|
|
|
182
194
|
|
|
183
195
|
An approval that no longer stands, as when its change gets a new commit, is withdrawn: its `status` becomes `withdrawn`, with `withdrawnReason`, `withdrawnAt` and `withdrawnBy` (who, unless its run withdrew it), and the ledger gets `approval.withdrawn` with the same `reason` and `by`. `client.withdraw(runId, approvalId, reason, { by, timeoutSeconds? })` withdraws a pending one, as the run's starter or someone who may decide it (`not_approver` otherwise; a visitor never), and refuses as `decide` does (`already_decided` once it is not pending); the run reads the withdrawal as it would a decision (an `ApprovalWithdrawal` message, `{ withdraw: reason, by, id? }`, on the approval's topic), checks `by` again (refusing as `approval.refused` otherwise), sets `decidedWith`, and the call or `ctx.approval` waiting on it throws `WithdrawnError` (`approval_withdrawn`, with `approvalId` and `reason`); a held call records it as its failed `op.called`, as a rejection. A decision sent after it is `already_decided`. A built-in withdraws its own approved approvals of an operation from its body, so they lift no later hold on it.
|
|
184
196
|
|
|
197
|
+
### Changes
|
|
198
|
+
|
|
199
|
+
A change is how an edit to the company's repository goes live once the repository has a remote (`origin`): an agent's draft, reviewed and published in the app. Without a remote there is no change, and the draft's card says to merge its branch by hand.
|
|
200
|
+
|
|
201
|
+
- **The draft.** A chat's edits are commits on the branch `draft/<id>` (the chat's id), in a checkout of its own under `.sanoma/drafts/<id>` (the `sanoma` package's `localGitSource`), never the live one. The chat's card lists the files and the result of `sanoma check` on the draft, and offers **Send for review** once something is committed and the checks pass.
|
|
202
|
+
- **Send for review** starts the built-in `change` workflow on the draft, as the chat's person and titled as the chat (`SanomaClient.openChange({ id, title, page }, { startedBy })`; the run is `change:<id>`, and `change:<id>~2` for the draft's next change once that one has ended). Each round pushes the branch and opens its pull request into `main` (`sanoma.change.open`), runs `sanoma check` on the draft, with its Permissions grid, works out the facts the policy decides the merge on ([Policies](#policies)), asks the policy about the merge ahead, and sets the `sanoma` commit status on the head, linking the change's page (`sanoma.change.review`): pending, or failure while the checks fail or the policy denies. Then it asks one approval, **Publish the change**, covering `sanoma.change.merge`, of whom the policy's hold names, else of the person who drafted it. A deny waits instead, until something happens to the change.
|
|
203
|
+
- **The review.** The app's Changes section, in the bar when the repository has a remote, lists the changes. A change's page shows where it is, its branch at the head it last planned, its pull request, the files it edits by kind, the Permissions grid of `main` against the branch's, and Publish. The Inbox shows Publish as any approval, with the change linked.
|
|
204
|
+
- **New commits** on the draft start a new round at the new head. The agent tells the change after it commits, which withdraws Publish; a push to the branch on the host, told by its webhook, is read when Publish is decided, which then plans again instead of merging. An approval given in an earlier round lifts no hold on the new head.
|
|
205
|
+
- **Publish**, approved, squash-merges the pull request at the head the round planned, titled `<title> (#<number>)`, sets the status to success and deletes the branch (`sanoma.change.merge`); a head that moved meanwhile is planned again, and a merge the host refuses waits. Then the deploy: the checkout follows `main` (a fast-forward, skipped while another branch is checked out, the checkout has uncommitted changes or its `main` has commits the remote's lacks), apply runs on the declared hosts as the run `<run>:deploy`, drift reads them back as `<run>:verify`, and the draft is closed. The page says Published, with the merge commit and what the deploy did. The change run outlives the deploy's restart of the worker on the merged code ([Versions](#versions)).
|
|
206
|
+
- **Reject** on Publish closes the pull request and the change. A pull request merged on the host deploys as Publish does, and one closed there closes the change: the run checks either on the host before it believes it.
|
|
207
|
+
- **Who publishes.** A change to the rules (a policy, the root's or an app's, or a people file) waits for `admin` whatever the policies say ([Policies](#policies)). A new company's `policies/changes.cedar` holds every other change for `admin` too; replace it with rules on the change's facts when another group should own part of the repository.
|
|
208
|
+
- **The Permissions line.** Under the Permissions grid the app says what the repository's host enforces on `main` (`SourceHost.enforcement`): a pull request, and the `sanoma` check. Where it enforces neither (a private repository on GitHub Free), a push to `main` that does not go through Sanoma is neither reviewed nor decided by the policy.
|
|
209
|
+
|
|
185
210
|
### Calls run one at a time
|
|
186
211
|
|
|
187
212
|
A run's ctx calls run one at a time, in program order. Even inside `Promise.all`, each call waits for the one before it to settle, so two calls a policy holds for approval are asked for one after the other. DBOS matches a replayed call to its recorded result by the order calls reach it, so calls left to race would replay out of step. A call that fails rejects its caller. The calls queued after it still run only if the workflow catches the failure and goes on; otherwise the body has ended, and each queued call is refused with `run_ended` (the run has ended, so the call is never made) instead of reaching its vendor, an approver or a sleep. Write calls one after another, as the workflow means them, and a fan-out with `ctx.all`.
|
|
@@ -262,7 +287,7 @@ Only declared fields are compared for drift, as CloudFormation does: `compareDec
|
|
|
262
287
|
|
|
263
288
|
### Collections
|
|
264
289
|
|
|
265
|
-
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
|
|
290
|
+
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` (and each [app](#apps) in its own, whose collections are named with the app):
|
|
266
291
|
|
|
267
292
|
```ts
|
|
268
293
|
// resources/data.ts
|
|
@@ -439,6 +464,8 @@ A secret a resource needs is named, never written. A data file gives `secret("NA
|
|
|
439
464
|
|
|
440
465
|
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.
|
|
441
466
|
|
|
467
|
+
Change runs are the exception: a change's own deploy restarts the worker on the merged code, a new version, so the run has to outlive its version. Each change run carries a hash of the change workflow's own code (its `changeCode` attribute: what the version hashes of the change workflow, with the runtime's and DBOS's versions, never the company's workflows), and a worker that starts as the app's latest version takes over the unfinished change runs of other versions whose hash is its own, keeping each run's id, its completed steps and the messages it has not read yet (an approval decided, an event told while no worker ran). It says so in one line at `info`. Runs of the company's workflows stay on their version, a change's `apply` and `drift` runs among them; so does a change run whose hash differs, after an upgrade of the runtime.
|
|
468
|
+
|
|
442
469
|
When a worker starts, it warns about unfinished runs it will not pick up, naming them: runs started on another version, and runs queued on another queue. Run the version that started them, or fork each onto the current version with `DBOSClient.forkWorkflow(id, step, { applicationVersion, queueName })` and cancel the original.
|
|
443
470
|
|
|
444
471
|
Queues used to be one `sanoma` queue for every app and are now `sanoma:<appName>`, so runs queued before the change are never started. Sandbox runs had their own queue, `sanoma:<appName>:sandbox`, until they went on the app's; the warning names the runs left on it, and a sandbox run forked or resumed past its seed step fails at once, so start its scenario again or cancel it. On a local database, `pnpm db:down && pnpm db:up` resets it.
|
|
@@ -604,7 +631,7 @@ A scenario with no `When`, in a feature none of whose scenarios has one, is a _w
|
|
|
604
631
|
|
|
605
632
|
### Demos
|
|
606
633
|
|
|
607
|
-
A demo is a company repo run on its connectors' fakes, seeded from its scenarios. Resolve the config with `fakes: "instance"` and start the worker and the client on that one resolution, then `reseed(client, resolved)` from `@sanoma-ai/workflows/scenarios`: it empties `sanoma_data`, resets the instance's fakes, applies every world to the instance (its records saved through the collections' driver, its vendor objects and calls into the instance's fakes; a fault is refused, since every run shares them), and starts each moment as a sandbox run by its person, undriven, so its first approval waits in the Inbox. A sandbox run on such an instance seeds and calls the instance's fakes and the collections' table, not fakes of its own. `reseed` resolves to a `DemoState`, `{ seededAt, worlds, moments, problems }`, each moment with its `person`, `path`, `scenario` and `runId`, and a line in `problems` for each feature file that did not load and each world or moment that failed while the rest went on; `demoState(resolved)` is the last one, for a banner.
|
|
634
|
+
A demo is a company repo run on its connectors' fakes, seeded from its scenarios. Resolve the config with `fakes: "instance"` and start the worker and the client on that one resolution, then `reseed(client, resolved)` from `@sanoma-ai/workflows/scenarios`: it empties `sanoma_data`, resets the instance's fakes, applies every world to the instance (its records saved through the collections' driver, its vendor objects and calls into the instance's fakes; a fault is refused, since every run shares them), and starts each moment as a sandbox run by its person, undriven, so its first approval waits in the Inbox. A sandbox run on such an instance seeds and calls the instance's fakes and the collections' table, not fakes of its own. `reseed` resolves to a `DemoState`, `{ seededAt, worlds, moments, problems }`, each moment with its `person`, `path`, `scenario` and `runId`, and a line in `problems` for each feature file that did not load and each world or moment that failed while the rest went on; `demoState(resolved)` is the last one, for a banner. Before it seeds, it cancels every run of the app that has not ended (`client.cancelUnfinished()`), the last seed's moments among them, so only the new ones wait. `reseed(client, resolved, { only })` seeds one half (`SeedHalf`): `"database"` (the runs cancelled, `sanoma_data` emptied, the worlds' collection records, the moments started) or `"memory"` (the instance's fakes reset, the worlds' vendor objects and calls), so a process over a copy of a seeded database fills its own fakes with `only: "memory"`. A demo's app does all of this itself: `startApp(config, { demo: true })`, which `sanoma demo` runs ([the app's Demo](../app/README.md#demo)); the config's `demo.start` is the URL its banner's "Start with these apps" opens.
|
|
608
635
|
|
|
609
636
|
```ts
|
|
610
637
|
const resolved = resolveConfig(config, { fakes: "instance" });
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { type Claims } from "./claims.ts";
|
|
3
|
+
/** The topic a source repository carries so the listing finds it. */
|
|
4
|
+
export declare const APP_TOPIC = "sanoma-app";
|
|
5
|
+
/** GitHub's REST API, searched without a token (so rate-limited: ten searches a minute per address). */
|
|
6
|
+
export declare const GITHUB_API = "https://api.github.com";
|
|
7
|
+
/** An app's source on GitHub, with what Sanoma claims about it. */
|
|
8
|
+
export declare const AvailableApp: z.ZodObject<{
|
|
9
|
+
repo: z.ZodString;
|
|
10
|
+
url: z.ZodString;
|
|
11
|
+
description: z.ZodOptional<z.ZodString>;
|
|
12
|
+
license: z.ZodOptional<z.ZodString>;
|
|
13
|
+
tier: z.ZodEnum<{
|
|
14
|
+
community: "community";
|
|
15
|
+
official: "official";
|
|
16
|
+
"vendor-verified": "vendor-verified";
|
|
17
|
+
}>;
|
|
18
|
+
reviewed: z.ZodBoolean;
|
|
19
|
+
blocked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
20
|
+
solutions: z.ZodArray<z.ZodObject<{
|
|
21
|
+
id: z.ZodString;
|
|
22
|
+
title: z.ZodString;
|
|
23
|
+
demo: z.ZodOptional<z.ZodString>;
|
|
24
|
+
}, z.core.$strip>>;
|
|
25
|
+
}, z.core.$strip>;
|
|
26
|
+
export type AvailableApp = z.infer<typeof AvailableApp>;
|
|
27
|
+
/** The apps found, Sanoma's first; `error` says why there are none when GitHub could not be searched. */
|
|
28
|
+
export declare const AvailableApps: z.ZodObject<{
|
|
29
|
+
apps: z.ZodArray<z.ZodObject<{
|
|
30
|
+
repo: z.ZodString;
|
|
31
|
+
url: z.ZodString;
|
|
32
|
+
description: z.ZodOptional<z.ZodString>;
|
|
33
|
+
license: z.ZodOptional<z.ZodString>;
|
|
34
|
+
tier: z.ZodEnum<{
|
|
35
|
+
community: "community";
|
|
36
|
+
official: "official";
|
|
37
|
+
"vendor-verified": "vendor-verified";
|
|
38
|
+
}>;
|
|
39
|
+
reviewed: z.ZodBoolean;
|
|
40
|
+
blocked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
41
|
+
solutions: z.ZodArray<z.ZodObject<{
|
|
42
|
+
id: z.ZodString;
|
|
43
|
+
title: z.ZodString;
|
|
44
|
+
demo: z.ZodOptional<z.ZodString>;
|
|
45
|
+
}, z.core.$strip>>;
|
|
46
|
+
}, z.core.$strip>>;
|
|
47
|
+
error: z.ZodOptional<z.ZodString>;
|
|
48
|
+
}, z.core.$strip>;
|
|
49
|
+
export type AvailableApps = z.infer<typeof AvailableApps>;
|
|
50
|
+
/** The built-in `sanoma.apps.list` (`APPS_LIST`), which `resolveConfig` registers in every config with its driver. */
|
|
51
|
+
export declare const appsListOps: import("./op.ts").Connector<"sanoma", {
|
|
52
|
+
readonly apps: {
|
|
53
|
+
readonly list: {
|
|
54
|
+
readonly effect: "read";
|
|
55
|
+
readonly idempotent: true;
|
|
56
|
+
readonly description: "List the apps on GitHub (the topic sanoma-app), with Sanoma's claims about each";
|
|
57
|
+
readonly input: z.ZodObject<{}, z.core.$strip>;
|
|
58
|
+
readonly output: z.ZodObject<{
|
|
59
|
+
apps: z.ZodArray<z.ZodObject<{
|
|
60
|
+
repo: z.ZodString;
|
|
61
|
+
url: z.ZodString;
|
|
62
|
+
description: z.ZodOptional<z.ZodString>;
|
|
63
|
+
license: z.ZodOptional<z.ZodString>;
|
|
64
|
+
tier: z.ZodEnum<{
|
|
65
|
+
community: "community";
|
|
66
|
+
official: "official";
|
|
67
|
+
"vendor-verified": "vendor-verified";
|
|
68
|
+
}>;
|
|
69
|
+
reviewed: z.ZodBoolean;
|
|
70
|
+
blocked: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
71
|
+
solutions: z.ZodArray<z.ZodObject<{
|
|
72
|
+
id: z.ZodString;
|
|
73
|
+
title: z.ZodString;
|
|
74
|
+
demo: z.ZodOptional<z.ZodString>;
|
|
75
|
+
}, z.core.$strip>>;
|
|
76
|
+
}, z.core.$strip>>;
|
|
77
|
+
error: z.ZodOptional<z.ZodString>;
|
|
78
|
+
}, z.core.$strip>;
|
|
79
|
+
};
|
|
80
|
+
};
|
|
81
|
+
}>;
|
|
82
|
+
/** The driver of `sanoma.apps.list`: GitHub's search, now. */
|
|
83
|
+
export declare const appsListDriver: import("./op.ts").Driver;
|
|
84
|
+
/**
|
|
85
|
+
* The repositories GitHub finds with the topic `sanoma-app`, not archived, the first hundred, each with what
|
|
86
|
+
* `from` claims about it (the claims by default), official first, then by name. A repository
|
|
87
|
+
* GitHub describes otherwise than expected is left out. When GitHub cannot be searched the list
|
|
88
|
+
* is empty, and says why.
|
|
89
|
+
*/
|
|
90
|
+
export declare function availableApps(from?: Claims): Promise<AvailableApps>;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { appClaim, appTier, claims, TIERS } from "./claims.js";
|
|
3
|
+
import { defineConnector, defineDriver } from "./op.js";
|
|
4
|
+
import { errorMessage } from "./shared.js";
|
|
5
|
+
// The apps a company could take: GitHub's repositories with the topic `sanoma-app`, read when
|
|
6
|
+
// asked and never stored, joined with Sanoma's claims (`claims.ts`). One built-in read operation,
|
|
7
|
+
// `sanoma.apps.list`, which the app's Apps page and a public site's pages both call (`isDirect`
|
|
8
|
+
// admits it), under the policy as the person asking.
|
|
9
|
+
/** The topic a source repository carries so the listing finds it. */
|
|
10
|
+
export const APP_TOPIC = "sanoma-app";
|
|
11
|
+
/** GitHub's REST API, searched without a token (so rate-limited: ten searches a minute per address). */
|
|
12
|
+
export const GITHUB_API = "https://api.github.com";
|
|
13
|
+
/** How long GitHub gets to answer, so a slow or absent network only empties the list. */
|
|
14
|
+
const TIMEOUT_MS = 5_000;
|
|
15
|
+
/** One repository of GitHub's search answer: what the list shows of it. */
|
|
16
|
+
const Found = z.object({
|
|
17
|
+
full_name: z.string().regex(/^[\w.-]+\/[\w.-]+$/),
|
|
18
|
+
html_url: z.url({ protocol: /^https$/, hostname: /^github\.com$/ }),
|
|
19
|
+
description: z.string().nullish(),
|
|
20
|
+
license: z.object({ spdx_id: z.string().nullish() }).nullish(),
|
|
21
|
+
});
|
|
22
|
+
const Search = z.object({ items: z.array(z.unknown()) });
|
|
23
|
+
/** An app's source on GitHub, with what Sanoma claims about it. */
|
|
24
|
+
export const AvailableApp = z.object({
|
|
25
|
+
/** `<owner>/<repo>`, as GitHub names it. */
|
|
26
|
+
repo: z.string(),
|
|
27
|
+
/** Its page on GitHub, `https://github.com/<owner>/<repo>`: what `app.ts`'s `source` names once taken. */
|
|
28
|
+
url: z.string(),
|
|
29
|
+
description: z.string().optional(),
|
|
30
|
+
/** Its licence's SPDX id, when GitHub recognizes one. */
|
|
31
|
+
license: z.string().optional(),
|
|
32
|
+
/** Its claim's tier, else official for `Sanoma-AI`'s, else community (`appTier`). */
|
|
33
|
+
tier: z.enum(TIERS),
|
|
34
|
+
reviewed: z.boolean(),
|
|
35
|
+
/** Its commits Sanoma blocks. */
|
|
36
|
+
blocked: z.array(z.string()).optional(),
|
|
37
|
+
/** The solutions it is in, each with its demo's URL once that runs: where "Try it" goes. */
|
|
38
|
+
solutions: z.array(z.object({ id: z.string(), title: z.string(), demo: z.string().optional() })),
|
|
39
|
+
});
|
|
40
|
+
/** The apps found, Sanoma's first; `error` says why there are none when GitHub could not be searched. */
|
|
41
|
+
export const AvailableApps = z.object({ apps: z.array(AvailableApp), error: z.string().optional() });
|
|
42
|
+
/** The built-in `sanoma.apps.list` (`APPS_LIST`), which `resolveConfig` registers in every config with its driver. */
|
|
43
|
+
export const appsListOps = defineConnector("sanoma", {
|
|
44
|
+
apps: {
|
|
45
|
+
list: {
|
|
46
|
+
effect: "read",
|
|
47
|
+
idempotent: true,
|
|
48
|
+
description: `List the apps on GitHub (the topic ${APP_TOPIC}), with Sanoma's claims about each`,
|
|
49
|
+
input: z.object({}),
|
|
50
|
+
output: AvailableApps,
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
/** The driver of `sanoma.apps.list`: GitHub's search, now. */
|
|
55
|
+
export const appsListDriver = defineDriver(appsListOps, { apps: { list: () => availableApps() } });
|
|
56
|
+
/** An app found, with what `from` claims about it. */
|
|
57
|
+
function listed(found, from) {
|
|
58
|
+
const repo = found.full_name;
|
|
59
|
+
const claim = appClaim(repo, from);
|
|
60
|
+
const solutions = (claim?.solutions ?? []).flatMap((id) => {
|
|
61
|
+
const solution = from.solutions[id];
|
|
62
|
+
return solution ? [{ id, title: solution.title, ...(solution.demo.url && { demo: solution.demo.url }) }] : [];
|
|
63
|
+
});
|
|
64
|
+
return {
|
|
65
|
+
repo,
|
|
66
|
+
url: found.html_url,
|
|
67
|
+
...(found.description && { description: found.description }),
|
|
68
|
+
...(found.license?.spdx_id && found.license.spdx_id !== "NOASSERTION" && { license: found.license.spdx_id }),
|
|
69
|
+
tier: appTier(repo, from),
|
|
70
|
+
reviewed: claim?.reviewed ?? false,
|
|
71
|
+
...(claim?.blocked?.length && { blocked: claim.blocked }),
|
|
72
|
+
solutions,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/** 1 for an official app, which the list shows first. */
|
|
76
|
+
const official = (app) => Number(app.tier === "official");
|
|
77
|
+
/**
|
|
78
|
+
* The repositories GitHub finds with the topic `sanoma-app`, not archived, the first hundred, each with what
|
|
79
|
+
* `from` claims about it (the claims by default), official first, then by name. A repository
|
|
80
|
+
* GitHub describes otherwise than expected is left out. When GitHub cannot be searched the list
|
|
81
|
+
* is empty, and says why.
|
|
82
|
+
*/
|
|
83
|
+
export async function availableApps(from = claims) {
|
|
84
|
+
const url = `${GITHUB_API}/search/repositories?q=${encodeURIComponent(`topic:${APP_TOPIC} archived:false`)}&per_page=100`;
|
|
85
|
+
let items;
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(url, {
|
|
88
|
+
headers: { accept: "application/vnd.github+json", "user-agent": "sanoma", "x-github-api-version": "2022-11-28" },
|
|
89
|
+
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
90
|
+
});
|
|
91
|
+
if (!res.ok) {
|
|
92
|
+
await res.body?.cancel();
|
|
93
|
+
throw new Error(`${new URL(url).host} answered ${res.status} to ${new URL(url).pathname}`);
|
|
94
|
+
}
|
|
95
|
+
items = Search.parse(await res.json()).items;
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
return { apps: [], error: `Could not search GitHub: ${errorMessage(err)}` };
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
apps: items
|
|
102
|
+
.flatMap((item) => {
|
|
103
|
+
const found = Found.safeParse(item);
|
|
104
|
+
return found.success ? [listed(found.data, from)] : [];
|
|
105
|
+
})
|
|
106
|
+
.toSorted((a, b) => official(b) - official(a) || a.repo.localeCompare(b.repo, "en")),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=apps-list.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apps-list.js","sourceRoot":"","sources":["../src/apps-list.ts"],"names":[],"mappings":"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"}
|
package/dist/apps.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type LintProblem } from "./ast.ts";
|
|
2
|
+
/** An app: a folder of `apps/`, by its name, and the source repository its `app.ts` names. */
|
|
3
|
+
export interface AppEntry {
|
|
4
|
+
/** The folder's name, which prefixes what it declares (`crm.contacts`) and is the app's title. */
|
|
5
|
+
name: string;
|
|
6
|
+
/** The folder, relative to the root, with `/`: `apps/crm`. */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** `defineApp({ source })` in its `app.ts`: the repository it was taken from; absent for the company's own app. */
|
|
9
|
+
source?: string;
|
|
10
|
+
}
|
|
11
|
+
/** What `app.ts` declares: the source repository's URL, when the app was taken from one. */
|
|
12
|
+
export interface AppDefinition {
|
|
13
|
+
source?: string;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Declares an app in its folder's `app.ts`, read without running it: `defineApp({ source:
|
|
17
|
+
* "https://github.com/acme/crm" })`, the repository it was taken from; `defineApp({})` for an app
|
|
18
|
+
* the company made itself.
|
|
19
|
+
*/
|
|
20
|
+
export declare function defineApp(definition: AppDefinition): AppDefinition;
|
|
21
|
+
/** What is wrong with a company's apps, at a file relative to the root. */
|
|
22
|
+
export type AppProblem = LintProblem & {
|
|
23
|
+
file: string;
|
|
24
|
+
};
|
|
25
|
+
/** The apps under `root` (`appsAt`), without what is wrong with them. */
|
|
26
|
+
export declare const readApps: (root: string | undefined) => AppEntry[];
|
|
27
|
+
/**
|
|
28
|
+
* The apps under `root`: each folder of `apps/` whose name is an `APP_NAME`, sorted, with what is
|
|
29
|
+
* wrong in its `app.ts` and each folder refused for its name as `problems`. `app.ts` is read
|
|
30
|
+
* with oxc, never run: its default export must be `defineApp({ … })`, from `sanoma`, written out,
|
|
31
|
+
* and `source` a string.
|
|
32
|
+
*/
|
|
33
|
+
export declare function appsAt(root: string | undefined): {
|
|
34
|
+
apps: AppEntry[];
|
|
35
|
+
problems: AppProblem[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* Each import, from a file under one app's folder, of a file under another's, at its place: a
|
|
39
|
+
* warning, since a company may take one app without the other. Static imports, re-exports and
|
|
40
|
+
* `import("…")` with a literal path; relative specifiers only.
|
|
41
|
+
*/
|
|
42
|
+
export declare function crossAppImports(root: string | undefined, apps: readonly AppEntry[]): AppProblem[];
|
package/dist/apps.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
2
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { childrenOf, readSource } from "./ast.js";
|
|
4
|
+
import { keyName } from "./datafile.js";
|
|
5
|
+
import { stringValue, unwrap } from "./outline.js";
|
|
6
|
+
import { APPS_DIR, filesUnder, inside, isDirectory, RELATIVE } from "./paths.js";
|
|
7
|
+
import { APP_NAME } from "./shared.js";
|
|
8
|
+
/**
|
|
9
|
+
* Declares an app in its folder's `app.ts`, read without running it: `defineApp({ source:
|
|
10
|
+
* "https://github.com/acme/crm" })`, the repository it was taken from; `defineApp({})` for an app
|
|
11
|
+
* the company made itself.
|
|
12
|
+
*/
|
|
13
|
+
export function defineApp(definition) {
|
|
14
|
+
return Object.freeze({ ...definition });
|
|
15
|
+
}
|
|
16
|
+
/** The apps under `root` (`appsAt`), without what is wrong with them. */
|
|
17
|
+
export const readApps = (root) => appsAt(root).apps;
|
|
18
|
+
/**
|
|
19
|
+
* The apps under `root`: each folder of `apps/` whose name is an `APP_NAME`, sorted, with what is
|
|
20
|
+
* wrong in its `app.ts` and each folder refused for its name as `problems`. `app.ts` is read
|
|
21
|
+
* with oxc, never run: its default export must be `defineApp({ … })`, from `sanoma`, written out,
|
|
22
|
+
* and `source` a string.
|
|
23
|
+
*/
|
|
24
|
+
export function appsAt(root) {
|
|
25
|
+
const dir = root === undefined ? undefined : join(root, APPS_DIR);
|
|
26
|
+
if (dir === undefined || !isDirectory(dir))
|
|
27
|
+
return { apps: [], problems: [] };
|
|
28
|
+
const apps = [];
|
|
29
|
+
const problems = [];
|
|
30
|
+
const folders = readdirSync(dir, { withFileTypes: true })
|
|
31
|
+
.filter((d) => d.isDirectory())
|
|
32
|
+
.map((d) => d.name)
|
|
33
|
+
.toSorted();
|
|
34
|
+
for (const name of folders) {
|
|
35
|
+
const at = `${APPS_DIR}/${name}`;
|
|
36
|
+
if (!APP_NAME.test(name)) {
|
|
37
|
+
problems.push({
|
|
38
|
+
file: at,
|
|
39
|
+
line: 1,
|
|
40
|
+
column: 1,
|
|
41
|
+
message: `${at} is not an app: an app's folder name is a lowercase letter, then lowercase letters and digits`,
|
|
42
|
+
});
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
const read = appOptionsOf(join(dir, name, "app.ts"));
|
|
46
|
+
problems.push(...read.problems.map((p) => ({ file: `${at}/app.ts`, ...p })));
|
|
47
|
+
apps.push({ name, dir: at, ...(read.source !== undefined && { source: read.source }) });
|
|
48
|
+
}
|
|
49
|
+
return { apps, problems };
|
|
50
|
+
}
|
|
51
|
+
/** Where `defineApp` comes from in `app.ts`. */
|
|
52
|
+
const APP_SOURCES = new Set(["sanoma", "@sanoma-ai/workflows"]);
|
|
53
|
+
/** `app.ts`'s `source`, read without running it, and what is wrong there; nothing for no file. */
|
|
54
|
+
function appOptionsOf(path) {
|
|
55
|
+
if (!existsSync(path))
|
|
56
|
+
return { problems: [] };
|
|
57
|
+
const file = readSource(path);
|
|
58
|
+
if (typeof file === "string")
|
|
59
|
+
return { problems: [{ line: 1, column: 1, message: file }] };
|
|
60
|
+
const problems = [...file.problems];
|
|
61
|
+
const problem = (at, message) => problems.push({ ...file.at(at.start), message });
|
|
62
|
+
const unread = 'app.ts is read without running it: its default export is `defineApp({ source })` from "sanoma", written out';
|
|
63
|
+
const exported = file.program.body.find((s) => s.type === "ExportDefaultDeclaration");
|
|
64
|
+
const call = unwrap(exported?.declaration);
|
|
65
|
+
const callee = unwrap(call?.callee);
|
|
66
|
+
const options = unwrap(call?.arguments?.[0]);
|
|
67
|
+
const defineApps = new Set();
|
|
68
|
+
for (const statement of file.program.body) {
|
|
69
|
+
if (statement.type !== "ImportDeclaration" || !APP_SOURCES.has(statement.source.value))
|
|
70
|
+
continue;
|
|
71
|
+
for (const s of statement.specifiers) {
|
|
72
|
+
if (s.type === "ImportSpecifier" && keyName(s.imported) === "defineApp")
|
|
73
|
+
defineApps.add(s.local.name);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
const written = call?.type === "CallExpression" &&
|
|
77
|
+
callee?.type === "Identifier" &&
|
|
78
|
+
defineApps.has(callee.name) &&
|
|
79
|
+
(options === undefined || options.type === "ObjectExpression");
|
|
80
|
+
if (!written) {
|
|
81
|
+
problem(exported ?? file.program, unread);
|
|
82
|
+
return { problems };
|
|
83
|
+
}
|
|
84
|
+
const property = options?.properties.findLast((p) => p.type === "Property" && !p.computed && keyName(p.key) === "source");
|
|
85
|
+
const source = property && stringValue(property.value);
|
|
86
|
+
if (property && source === undefined)
|
|
87
|
+
problem(property, "`source` is read without running app.ts: write it as a string");
|
|
88
|
+
return { ...(source !== undefined && { source }), problems };
|
|
89
|
+
}
|
|
90
|
+
/** A source file under an app: `.ts` or `.tsx`, but a declaration. */
|
|
91
|
+
const isSource = (name) => /\.tsx?$/.test(name) && !name.endsWith(".d.ts");
|
|
92
|
+
/**
|
|
93
|
+
* Each import, from a file under one app's folder, of a file under another's, at its place: a
|
|
94
|
+
* warning, since a company may take one app without the other. Static imports, re-exports and
|
|
95
|
+
* `import("…")` with a literal path; relative specifiers only.
|
|
96
|
+
*/
|
|
97
|
+
export function crossAppImports(root, apps) {
|
|
98
|
+
if (root === undefined)
|
|
99
|
+
return [];
|
|
100
|
+
const warnings = [];
|
|
101
|
+
for (const app of apps) {
|
|
102
|
+
const own = join(root, app.dir);
|
|
103
|
+
for (const path of filesUnder(own, isSource)) {
|
|
104
|
+
const file = readSource(path);
|
|
105
|
+
if (typeof file === "string")
|
|
106
|
+
continue;
|
|
107
|
+
const rel = relative(root, path).split(sep).join("/");
|
|
108
|
+
const visit = (node) => {
|
|
109
|
+
if (!node)
|
|
110
|
+
return;
|
|
111
|
+
const specifier = node.type === "ImportDeclaration" ||
|
|
112
|
+
node.type === "ExportAllDeclaration" ||
|
|
113
|
+
node.type === "ExportNamedDeclaration"
|
|
114
|
+
? node.source?.value
|
|
115
|
+
: node.type === "ImportExpression"
|
|
116
|
+
? stringValue(node.source)
|
|
117
|
+
: undefined;
|
|
118
|
+
if (typeof specifier === "string" && RELATIVE.test(specifier)) {
|
|
119
|
+
const target = resolve(dirname(path), specifier);
|
|
120
|
+
const other = apps.find((a) => a !== app && inside(join(root, a.dir), target));
|
|
121
|
+
if (other) {
|
|
122
|
+
warnings.push({
|
|
123
|
+
file: rel,
|
|
124
|
+
...file.at(node.start),
|
|
125
|
+
message: `imports "${specifier}" from the app ${other.name}: a company may take ${app.name} without it, so reach it through ctx or api instead`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const child of childrenOf(node))
|
|
130
|
+
visit(child);
|
|
131
|
+
};
|
|
132
|
+
visit(file.program);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return warnings;
|
|
136
|
+
}
|
|
137
|
+
//# sourceMappingURL=apps.js.map
|
package/dist/apps.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"apps.js","sourceRoot":"","sources":["../src/apps.ts"],"names":[],"mappings":"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"}
|
package/dist/call-workflow.js
CHANGED
|
@@ -2,10 +2,6 @@ import { z } from "zod";
|
|
|
2
2
|
import { defineWorkflow } from "./define.js";
|
|
3
3
|
import { isDirect } from "./direct.js";
|
|
4
4
|
export { CALL_WORKFLOW } from "./shared.js";
|
|
5
|
-
// The built-in `call` workflow: one operation that may be called directly (`isDirect`), as a run.
|
|
6
|
-
// `SanomaClient.call` starts it, as the person, when the policy holds a direct call for an
|
|
7
|
-
// approval, so the approval reaches the Inbox like any run's. It is a workflow like any other,
|
|
8
|
-
// held to the same lint: the call goes through `ctx`, under the policy, in the ledger.
|
|
9
5
|
/** The call workflow's input: the operation's id, and its input. */
|
|
10
6
|
export const CallInput = z.object({ op: z.string(), input: z.unknown() });
|
|
11
7
|
/**
|
|
@@ -27,8 +23,11 @@ export function callWorkflow(ops, drivers, types) {
|
|
|
27
23
|
uses,
|
|
28
24
|
// `ctx` refuses an operation that is not in `uses`: one that may not be called directly, or has no driver.
|
|
29
25
|
run: async (ctx, input) => {
|
|
30
|
-
|
|
31
|
-
|
|
26
|
+
// Four segments for an app's collection: `data.<app>.<collection>.<op>`.
|
|
27
|
+
const [vendor = "", a = "", b = "", c] = input.op.split(".");
|
|
28
|
+
return c === undefined
|
|
29
|
+
? ctx[vendor][a][b](input.input)
|
|
30
|
+
: ctx[vendor][a][b][c](input.input);
|
|
32
31
|
},
|
|
33
32
|
});
|
|
34
33
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"call-workflow.js","sourceRoot":"","sources":["../src/call-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAA2B,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvC,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;
|
|
1
|
+
{"version":3,"file":"call-workflow.js","sourceRoot":"","sources":["../src/call-workflow.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AACxB,OAAO,EAAE,cAAc,EAA2B,MAAM,aAAa,CAAC;AACtE,OAAO,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AAIvC,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAU5C,oEAAoE;AACpE,MAAM,CAAC,MAAM,SAAS,GAAG,CAAC,CAAC,MAAM,CAAC,EAAE,EAAE,EAAE,CAAC,CAAC,MAAM,EAAE,EAAE,KAAK,EAAE,CAAC,CAAC,OAAO,EAAE,EAAE,CAAC,CAAC;AAE1E;;;;;GAKG;AACH,MAAM,UAAU,YAAY,CAC1B,GAA4B,EAC5B,OAAqC,EACrC,KAAmC;IAEnC,MAAM,IAAI,GAAG,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,CAAC;SAC3B,MAAM,CAAC,CAAC,EAAE,EAAE,EAAE,CAAC,QAAQ,CAAC,EAAE,EAAE,KAAK,CAAC,IAAI,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,EAAE,CAAC,CAAC;SACzD,QAAQ,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC,aAAa,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC;IAChD,OAAO,cAAc,CAAC;QACpB,2GAA2G;QAC3G,IAAI,EAAE,MAAqC;QAC3C,KAAK,EAAE,mBAAmB;QAC1B,OAAO,EAAE,QAAQ;QACjB,KAAK,EAAE,SAAS;QAChB,IAAI;QACJ,2GAA2G;QAC3G,GAAG,EAAE,KAAK,EAAE,GAAG,EAAE,KAAK,EAAE,EAAE;YACxB,yEAAyE;YACzE,MAAM,CAAC,MAAM,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,CAAC,GAAG,KAAK,CAAC,EAAE,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;YAC7D,OAAO,CAAC,KAAK,SAAS;gBACpB,CAAC,CAAC,GAAG,CAAC,MAAM,CAAE,CAAC,CAAC,CAAE,CAAC,CAAC,CAAE,CAAC,KAAK,CAAC,KAAK,CAAC;gBACnC,CAAC,CAAE,GAAyF,CAAC,MAAM,CAAE,CAAC,CAAC,CAAE,CAAC,CAAC,CAAE,CACzG,CAAC,CACD,CAAC,KAAK,CAAC,KAAK,CAAC,CAAC;QACtB,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
|
package/dist/call.js
CHANGED
|
@@ -105,9 +105,12 @@ export function buildCtx(wf, run) {
|
|
|
105
105
|
};
|
|
106
106
|
const members = {};
|
|
107
107
|
for (const op of uses.filter(isOp)) {
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
108
|
+
// By the id's segments: an app's collection, `data.crm.contacts.get`, is `ctx.data.crm.contacts.get`.
|
|
109
|
+
const group = op.id
|
|
110
|
+
.split(".")
|
|
111
|
+
.slice(0, -1)
|
|
112
|
+
.reduce((at, key) => (at[key] ??= {}), members);
|
|
113
|
+
group[op.name] = placed(op.id, (node, input) => serial(run, () => callOp(run, op.id, input, { node: node.path, name: named(node) ?? op.id })));
|
|
111
114
|
}
|
|
112
115
|
members.runId = run.id;
|
|
113
116
|
members.now = () => serial(run, () => DBOS.now());
|