@sanoma-ai/workflows 0.7.1 → 0.8.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.
Files changed (127) hide show
  1. package/README.md +34 -7
  2. package/dist/apps-list.d.ts +90 -0
  3. package/dist/apps-list.js +109 -0
  4. package/dist/apps-list.js.map +1 -0
  5. package/dist/apps.d.ts +42 -0
  6. package/dist/apps.js +137 -0
  7. package/dist/apps.js.map +1 -0
  8. package/dist/call-workflow.js +5 -6
  9. package/dist/call-workflow.js.map +1 -1
  10. package/dist/call.js +6 -3
  11. package/dist/call.js.map +1 -1
  12. package/dist/cedar.d.ts +1 -1
  13. package/dist/cedar.js +6 -4
  14. package/dist/cedar.js.map +1 -1
  15. package/dist/change-steps.d.ts +10 -0
  16. package/dist/change-steps.js +204 -0
  17. package/dist/change-steps.js.map +1 -0
  18. package/dist/change-workflow.d.ts +54 -0
  19. package/dist/change-workflow.js +161 -0
  20. package/dist/change-workflow.js.map +1 -0
  21. package/dist/change.d.ts +121 -24
  22. package/dist/change.js +196 -17
  23. package/dist/change.js.map +1 -1
  24. package/dist/claims.d.ts +58 -0
  25. package/dist/claims.js +27 -0
  26. package/dist/claims.js.map +1 -0
  27. package/dist/client.d.ts +92 -0
  28. package/dist/client.js +268 -6
  29. package/dist/client.js.map +1 -1
  30. package/dist/collection.d.ts +6 -1
  31. package/dist/collection.js +11 -3
  32. package/dist/collection.js.map +1 -1
  33. package/dist/config.d.ts +25 -2
  34. package/dist/config.js +36 -20
  35. package/dist/config.js.map +1 -1
  36. package/dist/db.d.ts +12 -0
  37. package/dist/db.js +15 -0
  38. package/dist/db.js.map +1 -1
  39. package/dist/deadlines.d.ts +5 -2
  40. package/dist/deadlines.js.map +1 -1
  41. package/dist/define.d.ts +30 -14
  42. package/dist/define.js +9 -1
  43. package/dist/define.js.map +1 -1
  44. package/dist/demo.d.ts +15 -4
  45. package/dist/demo.js +27 -12
  46. package/dist/demo.js.map +1 -1
  47. package/dist/describe.d.ts +16 -2
  48. package/dist/describe.js +39 -4
  49. package/dist/describe.js.map +1 -1
  50. package/dist/direct.d.ts +4 -3
  51. package/dist/direct.js +7 -4
  52. package/dist/direct.js.map +1 -1
  53. package/dist/errors.d.ts +2 -0
  54. package/dist/errors.js +2 -0
  55. package/dist/errors.js.map +1 -1
  56. package/dist/index.d.ts +7 -5
  57. package/dist/index.js +6 -3
  58. package/dist/index.js.map +1 -1
  59. package/dist/lint.js +19 -5
  60. package/dist/lint.js.map +1 -1
  61. package/dist/outline.js +13 -6
  62. package/dist/outline.js.map +1 -1
  63. package/dist/paths.d.ts +16 -0
  64. package/dist/paths.js +54 -2
  65. package/dist/paths.js.map +1 -1
  66. package/dist/resources.d.ts +7 -2
  67. package/dist/resources.js +23 -8
  68. package/dist/resources.js.map +1 -1
  69. package/dist/run.d.ts +7 -0
  70. package/dist/run.js.map +1 -1
  71. package/dist/scenario.d.ts +2 -2
  72. package/dist/scenario.js +10 -4
  73. package/dist/scenario.js.map +1 -1
  74. package/dist/scenarios.d.ts +1 -1
  75. package/dist/scenarios.js.map +1 -1
  76. package/dist/sdk.d.ts +1 -1
  77. package/dist/sdk.js +19 -5
  78. package/dist/sdk.js.map +1 -1
  79. package/dist/shared.d.ts +43 -2
  80. package/dist/shared.js +46 -4
  81. package/dist/shared.js.map +1 -1
  82. package/dist/sites.d.ts +26 -5
  83. package/dist/sites.js +56 -20
  84. package/dist/sites.js.map +1 -1
  85. package/dist/source.d.ts +55 -5
  86. package/dist/source.js +4 -0
  87. package/dist/source.js.map +1 -1
  88. package/dist/version.d.ts +8 -0
  89. package/dist/version.js +21 -13
  90. package/dist/version.js.map +1 -1
  91. package/dist/worker.js +50 -3
  92. package/dist/worker.js.map +1 -1
  93. package/oxlint.json +2 -2
  94. package/package.json +1 -1
  95. package/src/apps-list.ts +119 -0
  96. package/src/apps.ts +162 -0
  97. package/src/call-workflow.ts +10 -2
  98. package/src/call.ts +6 -3
  99. package/src/cedar.ts +9 -7
  100. package/src/change-steps.ts +274 -0
  101. package/src/change-workflow.ts +198 -0
  102. package/src/change.ts +257 -27
  103. package/src/claims.ts +79 -0
  104. package/src/client.ts +320 -4
  105. package/src/collection.ts +22 -3
  106. package/src/config.ts +60 -22
  107. package/src/db.ts +23 -0
  108. package/src/deadlines.ts +5 -2
  109. package/src/define.ts +31 -12
  110. package/src/demo.ts +55 -24
  111. package/src/describe.ts +67 -4
  112. package/src/direct.ts +9 -5
  113. package/src/errors.ts +3 -0
  114. package/src/index.ts +14 -0
  115. package/src/lint.ts +20 -5
  116. package/src/outline.ts +15 -6
  117. package/src/paths.ts +57 -2
  118. package/src/resources.ts +27 -9
  119. package/src/run.ts +5 -0
  120. package/src/scenario.ts +14 -4
  121. package/src/scenarios.ts +1 -1
  122. package/src/sdk.ts +21 -7
  123. package/src/shared.ts +60 -5
  124. package/src/sites.ts +70 -24
  125. package/src/source.ts +50 -5
  126. package/src/version.ts +25 -16
  127. package/src/worker.ts +46 -3
@@ -0,0 +1,198 @@
1
+ import type {
2
+ ChangeDecision,
3
+ ChangeDeploy,
4
+ ChangeEvent,
5
+ ChangeFacts,
6
+ ChangeReport,
7
+ ChangeState,
8
+ ChangeVerified,
9
+ changeOps,
10
+ MergeInput,
11
+ } from "./change.ts";
12
+ import { ADMIN, ChangeInput, initialChangeState } from "./change.ts";
13
+ import { defineWorkflow, type WorkflowDefinition } from "./define.ts";
14
+ import { errorCode, isRunControlError, type SanomaError } from "./errors.ts";
15
+ import { approverLabel, type CHANGE_WORKFLOW, errorMessage } from "./shared.ts";
16
+ import type { CheckResult } from "./source.ts";
17
+
18
+ // The built-in `change` workflow: the life of a change to the company's repository, from its
19
+ // draft to live. It opens the draft's branch as a pull request, checks and plans it, asks the
20
+ // policy, reports Sanoma's review on the host, waits in the Inbox for Publish (the approval),
21
+ // squash-merges, deploys and verifies. A workflow like any other, held to the same lint: every
22
+ // operation goes through `ctx`, and what is no operation is a step of `change-steps.ts`.
23
+
24
+ /** What the change workflow does that is no operation, each a step of its own (`change-steps.ts`). */
25
+ export interface ChangeSteps {
26
+ /** `sanoma check` on the draft (`Source.check`), its Permissions grid among it. */
27
+ check(id: string): Promise<CheckResult>;
28
+ /** The facts the policy decides the merge on: from the draft's diff, the waiting runs and `check`. */
29
+ plan(id: string, check: CheckResult): Promise<ChangeFacts>;
30
+ /** The policy's answer to the merge now, the runtime's rule first: who Publish waits for, or why it is denied. */
31
+ ahead(merge: MergeInput): Promise<ChangeDecision>;
32
+ /** Waits for the next event on the change's topic: the one that weighs most of those waiting then, a `merged` or `closed` checked on the host for pull request `number`. */
33
+ next(number: number): Promise<ChangeEvent>;
34
+ /** The event that weighs most of those waiting on the topic now, without waiting, checked as `next` does; undefined for none. */
35
+ queued(number: number): Promise<ChangeEvent | undefined>;
36
+ /** Withdraws every approval of the run approved for the merge, saying `reason`: none lifts a hold on a newer head. */
37
+ retire(reason: string): Promise<void>;
38
+ /** After the merge: the checkout follows `main`, then apply on the declared hosts, as a run of its own this one waits for. */
39
+ deploy(sha: string): Promise<ChangeDeploy>;
40
+ /** After an apply on the hosts: drift on them, as a run of its own; undefined with no host. */
41
+ verify(deploy: ChangeDeploy): Promise<ChangeVerified | undefined>;
42
+ /** Closes the draft the change merged. */
43
+ closeDraft(id: string): Promise<void>;
44
+ /** Publishes where the change is (`CHANGE_EVENT`), for the app. */
45
+ publish(state: ChangeState): Promise<void>;
46
+ }
47
+
48
+ /** What the change workflow uses: its four operations, and `approval`. */
49
+ type ChangeUse = (typeof changeOps.change)[keyof typeof changeOps.change] | "approval";
50
+
51
+ /** The approval Publish is: the Inbox's title for it. */
52
+ const PUBLISH = "Publish the change";
53
+
54
+ /** The `sanoma` status on the host: pending while it can merge, failure while checks fail or the policy denies; and what it waits for. */
55
+ function reviewOf(decision: ChangeDecision, facts: ChangeFacts): { state: "pending" | "failure"; description: string } {
56
+ if (decision.answer === "deny") {
57
+ return { state: "failure", description: `Denied: ${decision.reason ?? "the policy refuses it"}` };
58
+ }
59
+ const waits = decision.title ?? `Waits for ${approverLabel(decision.approver ?? { group: ADMIN })} to publish it`;
60
+ return facts.checksOk
61
+ ? { state: "pending", description: waits }
62
+ : { state: "failure", description: `sanoma check fails. ${waits}` };
63
+ }
64
+
65
+ /**
66
+ * The change workflow over `ops` (`changeOps`, the built-in connector's) and `steps`:
67
+ * `resolveConfig` adds it to every config. It `uses` the four operations and `approval`.
68
+ *
69
+ * A run is the life of one change (run id `change:<draft>`): each round opens the draft's branch
70
+ * as a pull request (`open`, which pushes it), checks it (`steps.check`), plans its facts
71
+ * (`steps.plan`), asks the policy ahead (`steps.ahead`), and reports on the host (`review`). While
72
+ * the policy denies it, the run waits for what happens next (`steps.next`). Otherwise it asks one
73
+ * approval, Publish, of the hold's approver (else whoever drafted it), covering the merge, with the
74
+ * change and its facts as its details; once approved, it merges (`merge`). An event that comes in
75
+ * meanwhile (`SanomaClient.changeEvent`) withdraws the approval, and the run reads it:
76
+ * `commits` plans another round, after withdrawing every approval approved for the merge
77
+ * (`steps.retire`), so none lifts a hold on the new head; `close` closes the pull request; the
78
+ * pull request `closed` or `merged` on the host ends the change as it is. A rejected Publish
79
+ * closes it too. A merge whose branch moved plans another round; one the host refuses waits.
80
+ *
81
+ * Once merged, the deploy (`steps.deploy`): the checkout follows `main`, then apply on the
82
+ * declared hosts, as a run this one waits for, then drift on them (`steps.verify`). A `merged` or
83
+ * `closed` event someone told is checked on the host as it is read (`steps.next`, `steps.queued`); one it does not bear
84
+ * out plans another round. A Publish withdrawn with no event waits, blocked, for the next one.
85
+ * Then the draft is closed. Each phase is published (`steps.publish`, `CHANGE_EVENT`); the run's
86
+ * output is its `ChangeReport`.
87
+ */
88
+ export function changeWorkflow(
89
+ ops: typeof changeOps,
90
+ steps: ChangeSteps,
91
+ ): WorkflowDefinition<readonly ChangeUse[], typeof ChangeInput> {
92
+ return defineWorkflow({
93
+ // The literal, held to the one name: the outline finds a workflow by a string, or a const of its own file.
94
+ name: "change" satisfies typeof CHANGE_WORKFLOW,
95
+ title: "Publish a change",
96
+ trigger: "manual",
97
+ input: ChangeInput,
98
+ uses: [ops.change.open, ops.change.review, ops.change.merge, ops.change.close, "approval"],
99
+ run: async (ctx, input) => {
100
+ const state = initialChangeState(input);
101
+ /** Moves the change on, and publishes where it is. */
102
+ const show = (patch: Partial<ChangeState>) => {
103
+ Object.assign(state, patch);
104
+ return steps.publish(state);
105
+ };
106
+ let report: ChangeReport | undefined;
107
+ while (report === undefined) {
108
+ // An approval is given for a head: one approved for an earlier round lifts no hold on this one.
109
+ await steps.retire("the change was planned again at a new head");
110
+ await show({ phase: "opening", decision: undefined, blocked: undefined });
111
+ const opened = await ctx.sanoma.change.open(input);
112
+ const pullRequest = { number: opened.number, url: opened.url };
113
+ await show({ branch: opened.branch, pullRequest, head: opened.head, phase: "checking" });
114
+ const check = await steps.check(input.id);
115
+ await show({ check, phase: "planning" });
116
+ const facts = await steps.plan(input.id, check);
117
+ const merge = { id: input.id, number: opened.number, head: opened.head, title: input.title, facts };
118
+ const decision = await steps.ahead(merge);
119
+ await show({ facts, decision, phase: "reviewing" });
120
+ await ctx.sanoma.change.review({
121
+ id: input.id,
122
+ number: opened.number,
123
+ head: opened.head,
124
+ ...reviewOf(decision, facts),
125
+ ...(input.page !== undefined && { page: input.page }),
126
+ });
127
+ // What came in while the round was planned decides it before any approval is asked.
128
+ let told = await steps.queued(opened.number);
129
+ let event: ChangeEvent | undefined;
130
+ let blocked = told === undefined && decision.answer === "deny" ? decision.reason : undefined;
131
+ if (told === undefined && blocked === undefined) {
132
+ try {
133
+ await show({ phase: "waiting" });
134
+ await ctx.approval(PUBLISH, {
135
+ approver: decision.approver!,
136
+ covers: [ops.change.merge],
137
+ details: JSON.stringify({ change: { id: input.id, title: input.title, branch: opened.branch }, facts }),
138
+ links: [opened.url, ...(input.page === undefined ? [] : [input.page])],
139
+ });
140
+ // An event told while Publish was being asked found no approval to withdraw: it is read before the merge.
141
+ told = await steps.queued(opened.number);
142
+ if (told === undefined) {
143
+ await show({ phase: "merging" });
144
+ let merged: Awaited<ReturnType<typeof ctx.sanoma.change.merge>>;
145
+ try {
146
+ merged = await ctx.sanoma.change.merge(merge);
147
+ } catch (err) {
148
+ // Approved, but the policy or the host refuses the merge now: the change waits, as for a refusal.
149
+ // A hold the policy puts on the merge itself is decided as Publish is, below.
150
+ if (isRunControlError(err) || errorCode(err)?.startsWith("approval_")) throw err;
151
+ merged = { outcome: "blocked", reason: errorMessage(err) };
152
+ }
153
+ if (merged.outcome === "merged") event = { kind: "merged", sha: merged.sha ?? opened.head };
154
+ else if (merged.outcome === "closed") event = { kind: "closed" };
155
+ else if (merged.outcome === "moved") event = { kind: "commits" };
156
+ else blocked = merged.reason ?? "the host refuses the merge";
157
+ }
158
+ } catch (err) {
159
+ // A withdrawal comes with an event, else Publish was taken back. A rejection of Publish closes the change.
160
+ const code = errorCode(err);
161
+ if (code === "approval_withdrawn") {
162
+ told = await steps.queued(opened.number);
163
+ if (told === undefined) blocked = "Publish was withdrawn";
164
+ } else if (code === "approval_rejected") {
165
+ event = { kind: "close", by: ((err as SanomaError).data.by as { id: string }).id };
166
+ } else throw err;
167
+ }
168
+ }
169
+ if (blocked !== undefined) {
170
+ await show({ phase: "blocked", blocked });
171
+ told = await steps.next(opened.number);
172
+ }
173
+ event ??= told!;
174
+ if (event.kind === "merged") {
175
+ await show({ phase: "deploying" });
176
+ const deploy = await steps.deploy(event.sha);
177
+ const verified = await steps.verify(deploy);
178
+ await steps.closeDraft(input.id);
179
+ const merged = { sha: event.sha, at: await ctx.now(), deploy, ...(verified && { verified }) };
180
+ await show({ phase: "merged", merged });
181
+ report = {
182
+ outcome: "merged",
183
+ head: opened.head,
184
+ pullRequest,
185
+ sha: merged.sha,
186
+ deploy,
187
+ ...(verified && { verified }),
188
+ };
189
+ } else if (event.kind !== "commits") {
190
+ if (event.kind === "close") await ctx.sanoma.change.close({ id: input.id, number: opened.number });
191
+ await show({ phase: "closed", ...(event.kind === "close" && { closedBy: event.by }) });
192
+ report = { outcome: "closed", head: opened.head, pullRequest };
193
+ }
194
+ }
195
+ return report;
196
+ },
197
+ });
198
+ }
package/src/change.ts CHANGED
@@ -1,15 +1,35 @@
1
1
  import { z } from "zod";
2
2
  import type { ApprovalState, Approver } from "./define.ts";
3
- import { defineConnector, defineDriver, type Driver, DriverError } from "./op.ts";
3
+ import { errorInfo } from "./errors.ts";
4
+ import { type CallContext, defineConnector, defineDriver, type Driver, DriverError } from "./op.ts";
4
5
  import { approve, approvedFor, type Decision, deny } from "./policy.ts";
5
6
  import { isChangeOp, type Resource } from "./resource.ts";
6
- import { APPLY_WORKFLOW, CHANGE_MERGE, CHANGE_WORKFLOW, groupNamesOf, type PeopleResource } from "./shared.ts";
7
- import type { CheckResult } from "./source.ts";
7
+ import { currentRun } from "./run.ts";
8
+ import type { Secrets } from "./secret.ts";
9
+ import {
10
+ APPLY_WORKFLOW,
11
+ CHANGE_MERGE,
12
+ CHANGE_WORKFLOW,
13
+ DRAFT_ID,
14
+ errorMessage,
15
+ groupNamesOf,
16
+ type PeopleResource,
17
+ } from "./shared.ts";
18
+ import {
19
+ type CheckResult,
20
+ type RemoteContext,
21
+ type RemoteHalf,
22
+ remoteFor,
23
+ type RepoRef,
24
+ type Source,
25
+ type SourceHost,
26
+ } from "./source.ts";
8
27
 
9
28
  // A change: how a draft of the company's repository goes live, as the built-in `change` workflow
10
- // runs it (lane C5): its four operations on the built-in connector `sanoma`, the facts a policy
11
- // decides its merge on (the attributes of its `Sanoma::Change`), and the runtime's own rule that
12
- // a change to the rules waits for `admin`, before any company policy is asked.
29
+ // runs it (`change-workflow.ts`): its four operations on the built-in connector `sanoma` and their
30
+ // driver over the repository's host, the facts a policy decides its merge on (the attributes of
31
+ // its `Sanoma::Change`), and the runtime's own rule that a change to the rules waits for `admin`,
32
+ // before any company policy is asked.
13
33
 
14
34
  /**
15
35
  * What the planner finds in a change, before the policy is asked: the attributes of the
@@ -24,14 +44,14 @@ export const ChangeFacts = z.object({
24
44
  stranded: z.array(z.string()),
25
45
  /** Whether `sanoma check` passed on the change. */
26
46
  checksOk: z.boolean(),
27
- /** Whether the change edits the rules: a policy or a people file, which wait for `admin` (`runtimeHold`). */
47
+ /** Whether the change edits the rules: a policy (the root's or an app's) or a people file, which wait for `admin` (`runtimeHold`). */
28
48
  rules: z.boolean(),
29
49
  });
30
50
  export type ChangeFacts = z.infer<typeof ChangeFacts>;
31
51
 
32
52
  /** What opening a change takes: its draft's id, and the pull request's title, body and the change page's link. */
33
53
  export const ChangeInput = z.object({
34
- id: z.string().regex(/^[\w-]{1,64}$/),
54
+ id: z.string().regex(DRAFT_ID),
35
55
  title: z.string().min(1),
36
56
  body: z.string().optional(),
37
57
  page: z.string().url().optional(),
@@ -114,13 +134,169 @@ export function reservedFor(
114
134
  return undefined;
115
135
  }
116
136
 
117
- const notBuilt = async (): Promise<never> => {
118
- throw new DriverError("not built", { retryable: false });
119
- };
137
+ /** The branch a change merges into. */
138
+ export const BASE = "main";
120
139
 
121
- /** A placeholder: lane C5 replaces it with the driver over the repository's host. */
122
- export function changeDriver(): Driver {
123
- return defineDriver(changeOps, { change: { open: notBuilt, review: notBuilt, merge: notBuilt, close: notBuilt } });
140
+ /** The company's credentials for a host, as its remote half asks for them (`Secrets` puts the environment first). */
141
+ const remoteContext = (secrets: Secrets): RemoteContext => ({
142
+ async secrets(names) {
143
+ const found = await Promise.all(names.map(async (name) => [name, await secrets.get(name)] as const));
144
+ return new Map(found.filter((entry): entry is readonly [string, string] => Boolean(entry[1])));
145
+ },
146
+ });
147
+
148
+ /** The worker's repository, read from the run; a `DriverError` outside one, or for a worker given none. */
149
+ export function sourceOfRun(): Source {
150
+ const source = currentRun.getStore()?.state.source;
151
+ if (!source) {
152
+ throw new DriverError("This worker was given no repository (`source`), so it cannot change one", {
153
+ retryable: false,
154
+ });
155
+ }
156
+ return source;
157
+ }
158
+
159
+ /** Each host opened, by its remote half, the secrets it reads and the clone URL: its token is minted once and kept while it holds. */
160
+ const openedHosts = new WeakMap<RemoteHalf, WeakMap<Secrets, Map<string, SourceHost>>>();
161
+
162
+ /**
163
+ * The company's repository and its host: `source`, by default the worker's for a call made in a
164
+ * run (a change's driver and its steps, `sourceOfRun`), the clone URL of its `origin`, and that
165
+ * repository (`repo`) on the host whose connector's remote half matches the URL, opened once per
166
+ * remote half, secrets and URL, so its token is kept between calls. A `DriverError` when the
167
+ * repository has no remote (`no_remote`: saving is publishing then, there is nothing to review a
168
+ * branch on) or no connector is for its host (`no_remote_half`).
169
+ */
170
+ export async function repositoryOf(
171
+ remotes: ReadonlyMap<string, RemoteHalf>,
172
+ secrets: Secrets,
173
+ source: Source = sourceOfRun(),
174
+ ): Promise<{ source: Source; url: string; repo: RepoRef; host: SourceHost }> {
175
+ const url = await source.remoteUrl?.();
176
+ if (!url) {
177
+ throw new DriverError("saving is publishing: this repository has no remote", {
178
+ retryable: false,
179
+ vendorCode: "no_remote",
180
+ });
181
+ }
182
+ const found = remoteFor(remotes, url);
183
+ if (!found) {
184
+ throw new DriverError(`No connector's remote half is for ${url}: add the connector of its host`, {
185
+ retryable: false,
186
+ vendorCode: "no_remote_half",
187
+ });
188
+ }
189
+ const bySecrets = openedHosts.get(found.remote) ?? new WeakMap<Secrets, Map<string, SourceHost>>();
190
+ openedHosts.set(found.remote, bySecrets);
191
+ const byUrl = bySecrets.get(secrets) ?? new Map<string, SourceHost>();
192
+ bySecrets.set(secrets, byUrl);
193
+ const host = byUrl.get(url) ?? found.remote.open(found.repo, remoteContext(secrets));
194
+ byUrl.set(url, host);
195
+ return { source, url, repo: found.repo, host };
196
+ }
197
+
198
+ /**
199
+ * Fast-forwards the checkout to the host's `main` (`Source.follow`), as a deploy does first so the
200
+ * hosts and what apply reads are main's: the revision after, why it did not, or the error, never
201
+ * thrown (what is merged is merged; a deploy that cannot follow still applies).
202
+ */
203
+ export async function followBase(
204
+ remotes: ReadonlyMap<string, RemoteHalf>,
205
+ secrets: Secrets,
206
+ source?: Source,
207
+ ): Promise<{ revision: string } | { skipped: string } | { error: string }> {
208
+ try {
209
+ const { source: at, url, host } = await repositoryOf(remotes, secrets, source);
210
+ if (!at.follow) return { skipped: "this repository cannot follow its remote" };
211
+ const moved = await at.follow({ url, token: await host.token(), ref: BASE });
212
+ return "revision" in moved ? { revision: moved.revision } : moved;
213
+ } catch (err) {
214
+ return { error: errorMessage(err) };
215
+ }
216
+ }
217
+
218
+ /**
219
+ * The driver of a change's operations, over the repository the worker was given (`source`, read
220
+ * from the run) and its host (`remotes`, the connectors' remote halves: `remoteFor` its
221
+ * `origin`, opened once and kept: `repositoryOf`).
222
+ *
223
+ * - `open` pushes the draft's branch and opens its pull request into `main` (the open one when it
224
+ * has one): safe to retry. Its `head` is the branch's commit as pushed.
225
+ * - `review` sets the `sanoma` status on the head, linking to the change page: `pending`, or
226
+ * `failure`; never `success`, which only a merge sets.
227
+ * - `merge` reads the pull request: merged already (a retry whose answer was lost) or closed are
228
+ * answered as they are; a branch at another commit than `head`, on the host or in the draft, is
229
+ * `moved`. Else it sets the status `success` and squash-merges at `head`: the host's `head_moved`
230
+ * is `moved`, `not_mergeable` is `blocked` with the host's reason. The branch is then deleted,
231
+ * as far as it can be.
232
+ * - `close` closes the pull request.
233
+ */
234
+ export function changeDriver(remotes: ReadonlyMap<string, RemoteHalf>): Driver {
235
+ const open = (call: CallContext) => repositoryOf(remotes, call.secrets);
236
+ return defineDriver(changeOps, {
237
+ change: {
238
+ async open({ id, title, body }, call) {
239
+ const { source, url, host } = await open(call);
240
+ if (!source.draft || !source.drafts || !source.push) {
241
+ throw new DriverError("This repository keeps no drafts, so it has no change to open", { retryable: false });
242
+ }
243
+ if (!(await source.drafts()).includes(id)) {
244
+ throw new DriverError(`There is no open draft "${id}" to send for review`, {
245
+ retryable: false,
246
+ vendorCode: "no_draft",
247
+ });
248
+ }
249
+ const { branch } = await source.draft(id);
250
+ // The head is read before the push: a commit made in the draft meanwhile is the next round's.
251
+ const head = await source.head(branch);
252
+ await source.push({ url, token: await host.token(), ref: branch });
253
+ const pr = await host.openPullRequest({ branch, base: BASE, title, body: body ?? "" });
254
+ return { number: pr.number, url: pr.url, head, branch };
255
+ },
256
+ async review({ head, state, description, page }, call) {
257
+ const { host } = await open(call);
258
+ await host.setStatus({ sha: head, state, description, ...(page !== undefined && { targetUrl: page }) });
259
+ return {};
260
+ },
261
+ async merge({ number, head, title }, call) {
262
+ const { source, host } = await open(call);
263
+ const pr = await host.pullRequest(number);
264
+ if (pr.state === "merged")
265
+ return { outcome: "merged" as const, ...(pr.mergeSha !== undefined && { sha: pr.mergeSha }) };
266
+ if (pr.state === "closed") return { outcome: "closed" as const };
267
+ // Commits the run has not planned: pushed to the host, or made in the draft and not pushed yet.
268
+ const local = await Promise.resolve(source.head(pr.branch)).catch(() => undefined);
269
+ if (pr.head !== head || (local !== undefined && local !== head)) return { outcome: "moved" as const };
270
+ await host.setStatus({ sha: head, state: "success", description: "Published through Sanoma" });
271
+ let sha: string;
272
+ try {
273
+ ({ sha } = await host.mergePullRequest(number, {
274
+ sha: head,
275
+ method: "squash",
276
+ title: `${title} (#${number})`,
277
+ }));
278
+ } catch (err) {
279
+ // Not merged: Sanoma's status on the head goes back to failing, so the host does not merge it either.
280
+ const reason = errorMessage(err);
281
+ await host
282
+ .setStatus({ sha: head, state: "failure", description: `Not published: ${reason}`.slice(0, 140) })
283
+ .catch(() => undefined);
284
+ const { vendorCode: code } = errorInfo(err);
285
+ if (code === "head_moved") return { outcome: "moved" as const };
286
+ if (code === "not_mergeable") return { outcome: "blocked" as const, reason };
287
+ throw err;
288
+ }
289
+ // Best effort: a branch left behind holds nothing the merge did not take.
290
+ await host.deleteBranch?.(pr.branch).catch(() => undefined);
291
+ return { outcome: "merged" as const, sha };
292
+ },
293
+ async close({ number }, call) {
294
+ const { host } = await open(call);
295
+ await host.closePullRequest(number);
296
+ return { closed: true as const };
297
+ },
298
+ },
299
+ });
124
300
  }
125
301
 
126
302
  /**
@@ -137,15 +313,20 @@ export function touchesOf(files: readonly string[]): string[] {
137
313
  }
138
314
 
139
315
  /** The group the runtime holds a change to the rules for. */
140
- const ADMIN = "admin";
141
- /** The paths in `touches` that are the rules whatever `rules` says: the policies, and the template's people file. */
142
- const RULES = ["policies/", "resources/people.ts"];
316
+ export const ADMIN = "admin";
317
+ /**
318
+ * Whether a path in `touches` is the rules whatever `rules` says: the policies, the root's or an
319
+ * app's (`apps/crm/policies/`), and the template's people file.
320
+ */
321
+ export const isRules = (path: string) =>
322
+ path === "policies/" || path === "resources/people.ts" || /^apps\/[^/]+\/policies\/$/.test(path);
143
323
  /** The runtime rule's id, as a hold's `reasons` name it. */
144
324
  const RULES_WAIT_FOR_ADMIN = "sanoma:rules-wait-for-admin";
145
325
 
146
326
  /**
147
327
  * The runtime's own rule, asked before any company policy and under `allowAll` too: a merge of a
148
- * change to the rules (`facts.rules`, or `touches` naming `policies/` or `resources/people.ts`) waits for `admin`, whoever made it, until an approval
328
+ * change to the rules (`facts.rules`, or `touches` naming `policies/`, an app's `policies/` or
329
+ * `resources/people.ts`) waits for `admin`, whoever made it, until an approval
149
330
  * addressed to `admin` covering the merge is approved; denied while no data file declares the
150
331
  * group. Undefined for anything else, which the company's policy alone decides. `people` are the
151
332
  * running instance's, never the change's, so a change cannot add its author to `admin`.
@@ -158,7 +339,7 @@ export function runtimeHold(
158
339
  ): Decision | undefined {
159
340
  const facts = opId === CHANGE_MERGE ? (input as { facts?: ChangeFacts }).facts : undefined;
160
341
  // The planner's `rules`, or what `touches` already says: a fact left false cannot skip the rule.
161
- if (!facts || !(facts.rules || RULES.some((path) => facts.touches.includes(path)))) return undefined;
342
+ if (!facts || !(facts.rules || facts.touches.some(isRules))) return undefined;
162
343
  if (!groupNamesOf(people).includes(ADMIN)) {
163
344
  return deny(
164
345
  `A change to the policies or the people waits for the group "${ADMIN}", which no data file declares: ` +
@@ -171,12 +352,44 @@ export function runtimeHold(
171
352
  return approve(approver, { title: "A change to the rules waits for admin", reasons: [RULES_WAIT_FOR_ADMIN] });
172
353
  }
173
354
 
174
- /** What a change run is told while it waits: new commits on its branch, a close asked for, or its pull request closed or merged on the host. */
175
- export type ChangeEvent =
176
- | { kind: "commits" }
177
- | { kind: "close"; by: string }
178
- | { kind: "closed" }
179
- | { kind: "merged"; sha: string };
355
+ /**
356
+ * What a change run is told (on `CHANGE_TOPIC`): new commits on its branch, a close someone asked
357
+ * for, or its pull request closed or merged on the host.
358
+ */
359
+ export const ChangeEventSchema = z.discriminatedUnion("kind", [
360
+ z.object({ kind: z.literal("commits") }),
361
+ z.object({ kind: z.literal("close"), by: z.string().min(1) }),
362
+ z.object({ kind: z.literal("closed") }),
363
+ z.object({ kind: z.literal("merged"), sha: z.string().min(1) }),
364
+ ]);
365
+ export type ChangeEvent = z.infer<typeof ChangeEventSchema>;
366
+
367
+ /** What a merge is asked with: the change, its pull request at the head planned, and the facts. */
368
+ export type MergeInput = z.input<typeof changeOps.change.merge.input>;
369
+
370
+ /**
371
+ * The policy's answer to a change's merge, asked ahead (`ChangeState.decision`): `allow`, `hold`
372
+ * for the approver and title the policy gave, or `deny` with its reason. An allowed or held change
373
+ * has an `approver`, whose approval is Publish: the hold's, else whoever drafted it.
374
+ */
375
+ export type ChangeDecision = NonNullable<ChangeState["decision"]>;
376
+
377
+ /**
378
+ * What became of the deploy after a merge: the checkout fast-forwarded to `main` (`follow`: the
379
+ * revision, why not, or its error), then the apply run on the declared hosts, when any.
380
+ */
381
+ export interface ChangeDeploy {
382
+ follow: { revision: string } | { skipped: string } | { error: string };
383
+ /** The apply run, restricted to the declared hosts' ids (`<file>#<export>`), and its error when it failed. */
384
+ apply?: { runId: string; hosts: string[]; error?: string };
385
+ }
386
+
387
+ /** The drift run that read the hosts back after their apply, and the hosts it found not as declared. */
388
+ export interface ChangeVerified {
389
+ runId: string;
390
+ drifted: string[];
391
+ error?: string;
392
+ }
180
393
 
181
394
  /** Where a change is, as its run publishes it (`CHANGE_EVENT`) for the app. */
182
395
  export interface ChangeState {
@@ -208,15 +421,32 @@ export interface ChangeState {
208
421
  };
209
422
  /** The review card on the host: unavailable in this version. */
210
423
  card: { status: "unavailable"; reason: string };
211
- merged?: { sha: string; at: number; deploy?: { runId?: string; followed?: string; skipped?: string } };
424
+ merged?: { sha: string; at: number; deploy?: ChangeDeploy; verified?: ChangeVerified };
425
+ /** Why the change cannot merge now: the policy denies it, or the host refuses the merge. */
212
426
  blocked?: string;
427
+ /** Who closed it, when someone asked (a `close` event, or a rejection of the Publish approval). */
428
+ closedBy?: string;
213
429
  }
214
430
 
431
+ /** The review card on the host, which this version does not write. */
432
+ export const NO_CARD = { status: "unavailable", reason: "This version writes no review card on the host" } as const;
433
+
434
+ /** Where a change is before its run has opened it. */
435
+ export const initialChangeState = (input: ChangeInput): ChangeState => ({
436
+ id: input.id,
437
+ branch: "",
438
+ title: input.title,
439
+ ...(input.page !== undefined && { page: input.page }),
440
+ phase: "opening",
441
+ card: NO_CARD,
442
+ });
443
+
215
444
  /** How a change run ended. */
216
445
  export interface ChangeReport {
217
446
  outcome: "merged" | "closed";
218
447
  head: string;
219
448
  pullRequest: { number: number; url: string };
220
449
  sha?: string;
221
- deploy?: { runId?: string };
450
+ deploy?: ChangeDeploy;
451
+ verified?: ChangeVerified;
222
452
  }
package/src/claims.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Sanoma's claims: what Sanoma says about connectors and apps that their authors cannot say about
2
+ // their own (the tier, whether Sanoma reviewed it, what it blocks), and the solutions, each a kind
3
+ // of customer with the apps that cover their problems and one demo repository. Data, changed by
4
+ // pull request; `sanoma/claims` re-exports it, so a company and its sites import it statically.
5
+ // Browser-safe: nothing here imports anything. Everything else about a connector or an app is
6
+ // its author's, read from npm or GitHub (`sanoma.apps.list`), never copied here.
7
+
8
+ /**
9
+ * Whose a connector or an app is: Sanoma's own, its vendor's (checked by Sanoma), or anyone's. With
10
+ * no claim, `official` is read from the name (the `@sanoma-ai/` scope, the `Sanoma-AI` owner).
11
+ */
12
+ export const TIERS = ["official", "vendor-verified", "community"] as const;
13
+ export type Tier = (typeof TIERS)[number];
14
+
15
+ /** What Sanoma says of a connector package, by its npm name. */
16
+ export interface ConnectorClaim {
17
+ tier: Tier;
18
+ /** Whether Sanoma read its code. */
19
+ reviewed: boolean;
20
+ /** Versions Sanoma blocks. */
21
+ blocked?: string[];
22
+ }
23
+
24
+ /** What Sanoma says of an app's source repository, by `<owner>/<repo>` on GitHub. */
25
+ export interface AppClaim {
26
+ tier: Tier;
27
+ /** Whether Sanoma read its code. */
28
+ reviewed: boolean;
29
+ /** Commits Sanoma blocks, by their full id. */
30
+ blocked?: string[];
31
+ /** The solutions it belongs to, by id. */
32
+ solutions: string[];
33
+ }
34
+
35
+ /**
36
+ * A solution: a kind of customer and the apps that cover their problems, with the one public demo
37
+ * repository (a fictional business of that kind, those apps merged under `apps/`) that a visitor
38
+ * meets it in.
39
+ */
40
+ export interface SolutionClaim {
41
+ /** The solution's name, as its page heads it. */
42
+ title: string;
43
+ /** The kind of customer it is for, in their words: "property managers". */
44
+ kind: string;
45
+ /** Its apps' source repositories, `<owner>/<repo>`. */
46
+ apps: string[];
47
+ /** Its demo: the company repository, `<owner>/<repo>`, and where it runs once it does. */
48
+ demo: { repo: string; url?: string };
49
+ }
50
+
51
+ /** Sanoma's claims, each keyed as its kind is named: a package, a repository, a solution's id. */
52
+ export interface Claims {
53
+ connectors: Readonly<Record<string, ConnectorClaim>>;
54
+ apps: Readonly<Record<string, AppClaim>>;
55
+ solutions: Readonly<Record<string, SolutionClaim>>;
56
+ }
57
+
58
+ /** Sanoma's claims. Empty until the first outside connector, the first app and the first solution exist. */
59
+ export const claims: Claims = {
60
+ connectors: {},
61
+ apps: {},
62
+ solutions: {},
63
+ };
64
+
65
+ /** A connector package's tier: its claim's, else official for the `@sanoma-ai/` scope and community otherwise. */
66
+ export const connectorTier = (pkg: string, from: Claims = claims): Tier =>
67
+ from.connectors[pkg]?.tier ?? (pkg.startsWith("@sanoma-ai/") ? "official" : "community");
68
+
69
+ /** The claim on the app whose source is `repo` (`<owner>/<repo>`, as GitHub names it, in any case). */
70
+ export const appClaim = (repo: string, from: Claims = claims): AppClaim | undefined =>
71
+ Object.entries(from.apps).find(([key]) => key.toLowerCase() === repo.toLowerCase())?.[1];
72
+
73
+ /** The GitHub owner whose repositories are Sanoma's own. */
74
+ export const SANOMA_ORG = "Sanoma-AI";
75
+
76
+ /** An app source's tier: its claim's, else official for the `Sanoma-AI` owner's and community otherwise. */
77
+ export const appTier = (repo: string, from: Claims = claims): Tier =>
78
+ appClaim(repo, from)?.tier ??
79
+ (repo.toLowerCase().startsWith(`${SANOMA_ORG.toLowerCase()}/`) ? "official" : "community");