@sanoma-ai/workflows 0.6.0 → 0.7.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 (149) hide show
  1. package/README.md +48 -14
  2. package/dist/apply.d.ts +2 -1
  3. package/dist/apply.js +29 -7
  4. package/dist/apply.js.map +1 -1
  5. package/dist/approvals.d.ts +27 -2
  6. package/dist/approvals.js +73 -9
  7. package/dist/approvals.js.map +1 -1
  8. package/dist/call.d.ts +3 -2
  9. package/dist/call.js +38 -30
  10. package/dist/call.js.map +1 -1
  11. package/dist/cedar.js +24 -9
  12. package/dist/cedar.js.map +1 -1
  13. package/dist/change.d.ts +219 -0
  14. package/dist/change.js +150 -0
  15. package/dist/change.js.map +1 -0
  16. package/dist/chats.d.ts +43 -0
  17. package/dist/chats.js +48 -0
  18. package/dist/chats.js.map +1 -0
  19. package/dist/client.d.ts +52 -6
  20. package/dist/client.js +88 -47
  21. package/dist/client.js.map +1 -1
  22. package/dist/config.d.ts +47 -5
  23. package/dist/config.js +71 -7
  24. package/dist/config.js.map +1 -1
  25. package/dist/database.d.ts +83 -0
  26. package/dist/database.js +92 -0
  27. package/dist/database.js.map +1 -0
  28. package/dist/db.d.ts +31 -1
  29. package/dist/db.js +89 -7
  30. package/dist/db.js.map +1 -1
  31. package/dist/deadlines.d.ts +88 -0
  32. package/dist/deadlines.js +171 -0
  33. package/dist/deadlines.js.map +1 -0
  34. package/dist/define.d.ts +25 -4
  35. package/dist/define.js +40 -0
  36. package/dist/define.js.map +1 -1
  37. package/dist/demo.d.ts +28 -0
  38. package/dist/demo.js +72 -0
  39. package/dist/demo.js.map +1 -0
  40. package/dist/describe.d.ts +5 -2
  41. package/dist/describe.js +10 -6
  42. package/dist/describe.js.map +1 -1
  43. package/dist/errors.d.ts +10 -1
  44. package/dist/errors.js +13 -0
  45. package/dist/errors.js.map +1 -1
  46. package/dist/fake.d.ts +8 -0
  47. package/dist/fake.js +11 -1
  48. package/dist/fake.js.map +1 -1
  49. package/dist/grid.d.ts +34 -0
  50. package/dist/grid.js +62 -0
  51. package/dist/grid.js.map +1 -0
  52. package/dist/host.d.ts +13 -2
  53. package/dist/host.js +2 -2
  54. package/dist/host.js.map +1 -1
  55. package/dist/index.d.ts +16 -8
  56. package/dist/index.js +12 -5
  57. package/dist/index.js.map +1 -1
  58. package/dist/ledger.d.ts +11 -2
  59. package/dist/ledger.js.map +1 -1
  60. package/dist/lint.js +1 -1
  61. package/dist/lint.js.map +1 -1
  62. package/dist/op.d.ts +14 -2
  63. package/dist/op.js.map +1 -1
  64. package/dist/policy.d.ts +7 -1
  65. package/dist/policy.js +2 -1
  66. package/dist/policy.js.map +1 -1
  67. package/dist/resource.d.ts +13 -1
  68. package/dist/resource.js +9 -1
  69. package/dist/resource.js.map +1 -1
  70. package/dist/resources.d.ts +2 -0
  71. package/dist/resources.js +10 -1
  72. package/dist/resources.js.map +1 -1
  73. package/dist/run.d.ts +5 -0
  74. package/dist/run.js.map +1 -1
  75. package/dist/sandbox.d.ts +38 -10
  76. package/dist/sandbox.js +66 -35
  77. package/dist/sandbox.js.map +1 -1
  78. package/dist/scenario.d.ts +18 -1
  79. package/dist/scenario.js +77 -27
  80. package/dist/scenario.js.map +1 -1
  81. package/dist/scenarios.d.ts +10 -1
  82. package/dist/scenarios.js +19 -4
  83. package/dist/scenarios.js.map +1 -1
  84. package/dist/schedules.d.ts +66 -0
  85. package/dist/schedules.js +158 -0
  86. package/dist/schedules.js.map +1 -0
  87. package/dist/schema.d.ts +6 -0
  88. package/dist/schema.js +13 -3
  89. package/dist/schema.js.map +1 -1
  90. package/dist/shared.d.ts +30 -2
  91. package/dist/shared.js +42 -4
  92. package/dist/shared.js.map +1 -1
  93. package/dist/source.d.ts +61 -2
  94. package/dist/source.js.map +1 -1
  95. package/dist/supervisor.d.ts +38 -0
  96. package/dist/supervisor.js +77 -0
  97. package/dist/supervisor.js.map +1 -0
  98. package/dist/version.d.ts +3 -1
  99. package/dist/version.js +3 -1
  100. package/dist/version.js.map +1 -1
  101. package/dist/wakeup-service.d.ts +83 -0
  102. package/dist/wakeup-service.js +421 -0
  103. package/dist/wakeup-service.js.map +1 -0
  104. package/dist/wakeup.d.ts +84 -0
  105. package/dist/wakeup.js +87 -0
  106. package/dist/wakeup.js.map +1 -0
  107. package/dist/worker.d.ts +39 -1
  108. package/dist/worker.js +130 -39
  109. package/dist/worker.js.map +1 -1
  110. package/oxlint.json +2 -1
  111. package/package.json +8 -2
  112. package/src/apply.ts +25 -6
  113. package/src/approvals.ts +89 -16
  114. package/src/call.ts +40 -29
  115. package/src/cedar.ts +24 -6
  116. package/src/change.ts +222 -0
  117. package/src/chats.ts +114 -0
  118. package/src/client.ts +145 -55
  119. package/src/config.ts +110 -13
  120. package/src/database.ts +152 -0
  121. package/src/db.ts +108 -9
  122. package/src/deadlines.ts +234 -0
  123. package/src/define.ts +71 -4
  124. package/src/demo.ts +87 -0
  125. package/src/describe.ts +18 -6
  126. package/src/errors.ts +14 -0
  127. package/src/fake.ts +12 -0
  128. package/src/grid.ts +100 -0
  129. package/src/host.ts +13 -4
  130. package/src/index.ts +61 -3
  131. package/src/ledger.ts +15 -3
  132. package/src/lint.ts +1 -1
  133. package/src/op.ts +15 -2
  134. package/src/policy.ts +7 -1
  135. package/src/resource.ts +22 -3
  136. package/src/resources.ts +12 -1
  137. package/src/run.ts +5 -0
  138. package/src/sandbox.ts +97 -37
  139. package/src/scenario.ts +97 -28
  140. package/src/scenarios.ts +21 -5
  141. package/src/schedules.ts +206 -0
  142. package/src/schema.ts +16 -3
  143. package/src/shared.ts +51 -4
  144. package/src/source.ts +58 -3
  145. package/src/supervisor.ts +107 -0
  146. package/src/version.ts +3 -1
  147. package/src/wakeup-service.ts +511 -0
  148. package/src/wakeup.ts +150 -0
  149. package/src/worker.ts +181 -44
package/src/host.ts CHANGED
@@ -4,8 +4,17 @@ import type { z } from "zod";
4
4
  // a Fly app) declares about itself, so hosts are swappable and one conformance kit decides
5
5
  // whether one works. A host is a resource type like any other; this is what sets it apart.
6
6
 
7
- /** How a host keeps the runtime up: `long-lived`, or (later) woken on a schedule. */
8
- export type HostLifetime = "long-lived" | "wake-on-schedule";
7
+ /**
8
+ * How a host keeps the runtime up:
9
+ * - `long-lived`: the process is restarted if it exits, and never stopped for being quiet (Docker).
10
+ * - `stops-on-exit`: the host stops the instance when its process exits, and starts it again on
11
+ * the next request (Fly).
12
+ * - `stops-when-quiet`: the host stops the instance after a while without inbound requests, and
13
+ * starts it again on the next request (a free Render service, Cloudflare Containers).
14
+ *
15
+ * A host that stops needs a declared `Wakeup` for what is due at a time while it is stopped.
16
+ */
17
+ export type HostLifetime = "long-lived" | "stops-on-exit" | "stops-when-quiet";
9
18
 
10
19
  /** What a host can do, for choosing one. */
11
20
  export interface HostCapabilities {
@@ -53,10 +62,10 @@ export interface HostSpec {
53
62
  outputs: (state: Record<string, unknown>) => HostOutputs;
54
63
  }
55
64
 
56
- const LIFETIMES: readonly HostLifetime[] = ["long-lived", "wake-on-schedule"];
65
+ const LIFETIMES: readonly HostLifetime[] = ["long-lived", "stops-on-exit", "stops-when-quiet"];
57
66
 
58
67
  /** True when `schema` has a field at the dotted `path`, looking through optional, nullable and default wrappers. */
59
- function hasPath(schema: z.ZodType, path: string): boolean {
68
+ export function hasPath(schema: z.ZodType, path: string): boolean {
60
69
  let at: z.ZodType | undefined = schema;
61
70
  for (const key of path.split(".")) {
62
71
  at = unwrap(at);
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export {
14
14
  isSvgElement,
15
15
  type LoginContext,
16
16
  type LoginHalf,
17
+ type ModelContext,
17
18
  type Op,
18
19
  type OpSpec,
19
20
  type ResourceGroup,
@@ -33,6 +34,7 @@ export {
33
34
  type Ctx,
34
35
  type SleepRequest,
35
36
  type StepOptions,
37
+ type Trigger,
36
38
  type Use,
37
39
  type WorkflowDefinition,
38
40
  } from "./define.ts";
@@ -51,7 +53,14 @@ export {
51
53
  type ResourceSpec,
52
54
  withoutWriteOnly,
53
55
  } from "./resource.ts";
54
- export { defineConfig, resolveConfig, resolveDatabaseUrl, type ResolvedConfig, type SanomaConfig } from "./config.ts";
56
+ export {
57
+ declaredWakeup,
58
+ defineConfig,
59
+ resolveConfig,
60
+ resolveDatabaseUrl,
61
+ type ResolvedConfig,
62
+ type SanomaConfig,
63
+ } from "./config.ts";
55
64
  // `sanoma` itself (the people types' constructors) is at `@sanoma-ai/workflows/resources`, for data files.
56
65
  // `collection()` itself is at `@sanoma-ai/workflows/data`, for `resources/data.ts`; this tells one apart, as the launcher does.
57
66
  export { isCollection } from "./collection.ts";
@@ -66,6 +75,16 @@ export {
66
75
  type PlanStep,
67
76
  } from "./apply.ts";
68
77
  export { CALL_WORKFLOW, CallInput } from "./call-workflow.ts";
78
+ export {
79
+ type ChangeEvent,
80
+ ChangeFacts,
81
+ ChangeInput,
82
+ changeOps,
83
+ type ChangeReport,
84
+ type ChangeState,
85
+ runtimeHold,
86
+ touchesOf,
87
+ } from "./change.ts";
69
88
  export { isDirect } from "./direct.ts";
70
89
  export {
71
90
  DRIFT_WORKFLOW,
@@ -113,6 +132,7 @@ export {
113
132
  PolicyDeniedError,
114
133
  RejectedError,
115
134
  SanomaError,
135
+ WithdrawnError,
116
136
  } from "./errors.ts";
117
137
  export {
118
138
  jsonlLedger,
@@ -123,10 +143,15 @@ export {
123
143
  type LedgerRecord,
124
144
  type LedgerStore,
125
145
  } from "./ledger.ts";
126
- export { ApprovalMessage, APPROVALS_EVENT, decisionEventOf } from "./approvals.ts";
146
+ export { ApprovalMessage, APPROVALS_EVENT, ApprovalWithdrawal, decisionEventOf } from "./approvals.ts";
127
147
  export { RUNTIME_VERSION } from "./version.ts";
128
148
  export type { RunArgs } from "./run.ts";
129
149
  export { startWorker, type Worker, type WorkerOptions } from "./worker.ts";
150
+ export { superviseWorker, type Supervisor, type SupervisorOptions } from "./supervisor.ts";
151
+ export type { DeadlineSource, Deadlines, SelfStop, Wait } from "./deadlines.ts";
152
+ // Whether a wake is the one the instance's worker last registered, for the `/wake` route.
153
+ export { shouldWake } from "./db.ts";
154
+ export { nextFire, nextScheduledFire } from "./schedules.ts";
130
155
  // `secret` itself is at `@sanoma-ai/workflows/resources`, for data files; this is the drivers' side.
131
156
  export {
132
157
  generateSecret,
@@ -137,10 +162,36 @@ export {
137
162
  type Secret,
138
163
  type Secrets,
139
164
  } from "./secret.ts";
140
- export { resolveRevision, SOURCE_RESOLVE, type Source } from "./source.ts";
165
+ export {
166
+ type CheckResult,
167
+ type Draft,
168
+ type DraftDiff,
169
+ resolveRevision,
170
+ SOURCE_RESOLVE,
171
+ type Source,
172
+ } from "./source.ts";
141
173
  export type { HostCapabilities, HostLifetime, HostOutputs, HostSpec } from "./host.ts";
174
+ export {
175
+ checkDatabase,
176
+ type DatabaseCapabilities,
177
+ type DatabaseOutputs,
178
+ type DatabaseSpec,
179
+ databaseUrlSecret,
180
+ storeDatabaseUrl,
181
+ } from "./database.ts";
182
+ export type { Chat, Chats } from "./chats.ts";
183
+ export {
184
+ checkWakeup,
185
+ type DeclaredWakeup,
186
+ type WakeupOutputs,
187
+ type WakeupRegistration,
188
+ type WakeupSpec,
189
+ wakeGeneration,
190
+ wakeupHostTypes,
191
+ } from "./wakeup.ts";
142
192
  export {
143
193
  type CallOptions,
194
+ type ClientOptions,
144
195
  SanomaClient,
145
196
  type RunsFilter,
146
197
  type RunSummary,
@@ -156,11 +207,18 @@ export {
156
207
  ANONYMOUS_PRINCIPAL,
157
208
  approverLabel,
158
209
  approverOf,
210
+ CHANGE_CLOSE,
211
+ CHANGE_EVENT,
212
+ CHANGE_MERGE,
213
+ CHANGE_OPEN,
214
+ CHANGE_REVIEW,
215
+ CHANGE_WORKFLOW,
159
216
  declaresPeople,
160
217
  type PeopleResource,
161
218
  personOf,
162
219
  principalOf,
163
220
  RUNTIME_AGENTS,
221
+ SCHEDULE_AGENT,
164
222
  credentialReady,
165
223
  DEFAULT_SLEEP_NAME,
166
224
  ENDED_STATUSES,
package/src/ledger.ts CHANGED
@@ -17,7 +17,7 @@ import { errorMessage } from "./shared.ts";
17
17
  * decision, every approval, and how it ended. Records are append-only.
18
18
  *
19
19
  * `id` is deterministic (`<runId>:<type>:<seq>`, `<runId>:<type>:<approvalId>` for an
20
- * approval requested or decided, `<runId>:approval.refused:<approvalId>:refused:<n>` for
20
+ * approval requested, decided or withdrawn, `<runId>:approval.refused:<approvalId>:refused:<n>` for
21
21
  * the nth message an approval ignored) so that when DBOS replays a run after a restart,
22
22
  * the records it writes again have the same ids and the store skips them. `seq` orders a
23
23
  * run's records. `v` is the record format's version; `app` is the config's `appName`.
@@ -34,7 +34,12 @@ export type LedgerRecord = {
34
34
  /** Set on every record written while a `ctx.all` member runs; absent outside one. */
35
35
  group?: LedgerGroup;
36
36
  } & (
37
- | { type: "run.started"; input: unknown }
37
+ | {
38
+ type: "run.started";
39
+ input: unknown;
40
+ /** For a run its schedule started (`trigger: { schedule }`): the tick's time, ISO 8601, which may be before `at`. */
41
+ scheduledFor?: string;
42
+ }
38
43
  | {
39
44
  type: "op.called";
40
45
  op: string;
@@ -61,7 +66,7 @@ export type LedgerRecord = {
61
66
  durationMs: number;
62
67
  /** Which try produced the output or the final error, from 1. Idempotent operations are retried. */
63
68
  attempt?: number;
64
- /** The approval that held the call, when its rejection stopped it. */
69
+ /** The approval that held the call, when its rejection or withdrawal stopped it. */
65
70
  approval?: string;
66
71
  }
67
72
  | {
@@ -94,6 +99,13 @@ export type LedgerRecord = {
94
99
  /** Set when `by` started the run: nobody else could decide (`mayDecide`). */
95
100
  alone?: true;
96
101
  }
102
+ /** The approval no longer stands: withdrawn while pending, or retired by its run once approved. */
103
+ | {
104
+ type: "approval.withdrawn";
105
+ approval: string;
106
+ /** Who withdrew it, when someone did rather than its run. */ by?: string;
107
+ reason: string;
108
+ }
97
109
  /**
98
110
  * A `ctx.sleep` began; `until` is when it ends, in ms since the epoch: the time asked for, or
99
111
  * for a duration the record's `at` plus the duration, as the runtime saw it. Written
package/src/lint.ts CHANGED
@@ -55,7 +55,7 @@ const NOT_ALLOWED =
55
55
  * copy, which is no instance of them, so a branch on it goes another way than the first time
56
56
  * and the run's steps fall out of step. Read the code with `errorCode(err)` instead.
57
57
  */
58
- const ERROR_CLASSES = new Set(["DriverError", "SanomaError", "PolicyDeniedError", "RejectedError"]);
58
+ const ERROR_CLASSES = new Set(["DriverError", "SanomaError", "PolicyDeniedError", "RejectedError", "WithdrawnError"]);
59
59
 
60
60
  /**
61
61
  * Checks a workflow file. Imports allowed: the names in `WORKFLOW_IMPORTS` and any
package/src/op.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { ProviderV4 } from "@ai-sdk/provider";
1
2
  import type { z } from "zod";
2
3
  import type { Fake } from "./fake.ts";
3
4
  import type { Secrets } from "./secret.ts";
@@ -306,8 +307,8 @@ export interface Driver {
306
307
  * What a connector package attaches to its connector (`withSetup`), for the config's resolver
307
308
  * to call: the config then names the connector alone, and never a driver, a fake, steps or a
308
309
  * login. Any may be missing: a workflow using an operation of a connector with no driver is
309
- * refused, a connector with no fake has none for sandbox runs, and one with no login half signs
310
- * no visitor in.
310
+ * refused, a connector with no fake has none for sandbox runs, one with no login half signs no
311
+ * visitor in, and one with no model half gives the app no models.
311
312
  */
312
313
  export interface ConnectorSetup {
313
314
  /** Makes the live driver, which reads its credentials from the environment when called: once per resolved config. */
@@ -321,8 +322,20 @@ export interface ConnectorSetup {
321
322
  * starts; a login that needs tables migrates them here (serialized, idempotent).
322
323
  */
323
324
  login?: (context: LoginContext) => LoginHalf | Promise<LoginHalf>;
325
+ /**
326
+ * Makes the connector's AI SDK provider, the vendor's adapter as the transport: by the app,
327
+ * once per resolved config and client (again after one that throws), whose registry names its
328
+ * models `<vendor>/<model-id>`. It answers `languageModel("default")` with the connector's own
329
+ * default model, and reads its credentials through `context.secrets` when a model is called, so
330
+ * a key set on the connector page later is used (unless the environment sets one, which wins),
331
+ * and a missing one fails that call.
332
+ */
333
+ model?: (context: ModelContext) => ProviderV4 | Promise<ProviderV4>;
324
334
  }
325
335
 
336
+ /** What the app gives a connector's model half (`ConnectorSetup.model`): the secrets, as a login half reads them. */
337
+ export type ModelContext = Pick<LoginContext, "secrets">;
338
+
326
339
  /** What the app gives a connector's login half (`ConnectorSetup.login`) when it makes it for a public site. */
327
340
  export interface LoginContext {
328
341
  /** The company's database (`resolved.databaseUrl`), where a login like Better Auth keeps its users. */
package/src/policy.ts CHANGED
@@ -32,6 +32,11 @@ export interface PolicyCall {
32
32
  run: { id: string; workflow: string; approvals: readonly ApprovalState[] };
33
33
  /** For a collection's `save` or `delete`: the record as it was, when there was one. */
34
34
  before?: unknown;
35
+ /**
36
+ * The attributes of the entity the call acts on, when its action is on one with attributes: a
37
+ * `sanoma.change.merge`'s `Sanoma::Change`, the change's facts (`ChangeFacts`).
38
+ */
39
+ entity?: Record<string, unknown>;
35
40
  }
36
41
 
37
42
  /**
@@ -145,7 +150,8 @@ export const denyAll = (reason: string): Policy => {
145
150
  /**
146
151
  * True when an approval in the run was approved, covers the operation, and was addressed to
147
152
  * `approver`. Check all three: a workflow can request an approval covering any operation from
148
- * anyone it names, so an approval's `covers` alone does not say the right person approved.
153
+ * anyone it names, so an approval's `covers` alone does not say the right person approved. A
154
+ * withdrawn approval is no longer approved, so it lifts nothing.
149
155
  */
150
156
  export function approvedFor(approvals: readonly ApprovalState[], opId: string, approver: Approver): boolean {
151
157
  return approvals.some(
package/src/resource.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { z } from "zod";
2
+ import { checkDatabase, type DatabaseSpec } from "./database.ts";
2
3
  import { checkHost, type HostSpec } from "./host.ts";
3
4
  import { type Connector, type OpSpec, RESOURCE, VENDOR } from "./op.ts";
4
5
  import { isSanomaSecret, isSecret, type Secret } from "./secret.ts";
6
+ import { checkWakeup, type WakeupSpec } from "./wakeup.ts";
5
7
 
6
8
  /** Which fields of a resource are flagged, by dotted path (`pages.html_url`; a list's items have no index). */
7
9
  export interface ResourceFields {
@@ -79,6 +81,16 @@ export interface ResourceSpec<S extends z.ZodObject = z.ZodObject> {
79
81
  writable?: boolean;
80
82
  /** Set when the type meets the `Host` contract: it runs the company. Such a type is `writable` and `stateless`. */
81
83
  host?: HostSpec;
84
+ /**
85
+ * Set when the type meets the `Wakeup` contract: it wakes a stopped instance at a time. Such a
86
+ * type is `writable`, and its connector has the operation `register` names.
87
+ */
88
+ wakeup?: WakeupSpec;
89
+ /**
90
+ * Set when the type meets the `Database` contract: it holds the company's database. Such a type
91
+ * is `writable`, not `stateless`, and its `afterCreate` calls the operation `storeUrl` names.
92
+ */
93
+ database?: DatabaseSpec;
82
94
  /**
83
95
  * True when its objects hold no data, so one gone at the vendor is created again by apply
84
96
  * without asking (a host running the company's code); otherwise apply asks first, as a
@@ -269,10 +281,13 @@ export interface Resource<
269
281
  extends
270
282
  Readonly<
271
283
  Required<
272
- Omit<ResourceSpec<S>, "identity" | "find" | "writable" | "host" | "stateless" | "afterCreate" | "vendorless">
284
+ Omit<
285
+ ResourceSpec<S>,
286
+ "identity" | "find" | "writable" | "host" | "wakeup" | "database" | "stateless" | "afterCreate" | "vendorless"
287
+ >
273
288
  >
274
289
  >,
275
- Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "afterCreate">> {
290
+ Readonly<Pick<ResourceSpec<S>, "identity" | "find" | "host" | "wakeup" | "database" | "afterCreate">> {
276
291
  (desired: Declaring<z.input<S>, R>): Declared<Id, z.input<S>>;
277
292
  /** `read` and `import`, each effect `read`, idempotent, with the id as `target`; for a writable type, the change operations too. */
278
293
  readonly ops: ResourceOps<S, W>;
@@ -536,10 +551,12 @@ export function defineResource<
536
551
  >(
537
552
  spec: ResourceSpec<S> & { vendor: V; type: T; fields: { references?: R }; writable?: W },
538
553
  ): Resource<S, `${V}.${T}`, R, W> {
539
- const { vendor, type, schema, fields, find, identity, host } = spec;
554
+ const { vendor, type, schema, fields, find, identity, host, wakeup, database } = spec;
540
555
  const writable = (spec.writable ?? false) as W;
541
556
  const what: `${V}.${T}` = `${vendor}.${type}`;
542
557
  if (host) checkHost(what, host, spec);
558
+ if (wakeup) checkWakeup(what, wakeup, spec);
559
+ if (database) checkDatabase(what, database, spec);
543
560
  if (spec.vendorless && (writable || host))
544
561
  throw new Error(`${what}: a vendorless type has no object to change or host`);
545
562
  const title = spec.title.toLowerCase();
@@ -677,6 +694,8 @@ export function defineResource<
677
694
  normalize: spec.normalize ?? ((state: State, desired: z.input<S>) => compareDeclared(fields, state, desired)),
678
695
  writable,
679
696
  ...(host && { host }),
697
+ ...(wakeup && { wakeup }),
698
+ ...(database && { database }),
680
699
  stateless: spec.stateless ?? host !== undefined,
681
700
  vendorless: spec.vendorless ?? false,
682
701
  ...(spec.afterCreate && { afterCreate: spec.afterCreate }),
package/src/resources.ts CHANGED
@@ -112,7 +112,7 @@ export interface DeclaredPeople {
112
112
  }
113
113
 
114
114
  /** A file that imports the runtime's constructors, `sanoma` (by any local name): the files `readPeople` reads. */
115
- const DECLARES_PEOPLE = /\{[^}]*\bsanoma\b[^}]*\}\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/;
115
+ export const DECLARES_PEOPLE = /\{[^}]*\bsanoma\b[^}]*\}\s*from\s*["'](?:sanoma|@sanoma-ai\/workflows)\/resources["']/;
116
116
  /** The last read of the people under each root, by what its data files were then (`filesStamp`). */
117
117
  const peopleRead = new Map<string, { stamp: string; read: DeclaredPeople }>();
118
118
 
@@ -424,6 +424,17 @@ export function readDataFiles(
424
424
  done.set(id, undefined);
425
425
  }
426
426
  }
427
+ // An instance registers with one wakeup: several are each a problem, and left out, as is any that names one.
428
+ const wakeups = [...declarations].filter(
429
+ ([, { file, exp }]) => types.get(`${vendors.get(file)!.get(exp.vendor)}.${exp.type}`)?.wakeup,
430
+ );
431
+ if (wakeups.length > 1) {
432
+ for (const [id, { file, exp }] of wakeups) {
433
+ const others = wakeups.filter(([other]) => other !== id).map(([other]) => other);
434
+ file.problem(exp.start, `an instance has one wakeup: leave out this one or ${others.join(", ")}`);
435
+ done.set(id, undefined);
436
+ }
437
+ }
427
438
  const keys = new Set([...files.values()].flatMap((f) => f.data.declared.map((name) => `${f.rel}#${name}`)));
428
439
  const orphans = Object.entries(lock.bindings)
429
440
  .filter(([key]) => !keys.has(key))
package/src/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AsyncLocalStorage } from "node:async_hooks";
2
2
  import type { Collection } from "./collection.ts";
3
+ import type { DeadlineRegistry } from "./deadlines.ts";
3
4
  import type { ApprovalState, Approver, Principal, WorkflowDefinition } from "./define.ts";
4
5
  import type { Fake } from "./fake.ts";
5
6
  import type { LedgerStore } from "./ledger.ts";
@@ -45,6 +46,8 @@ export interface WorkerState {
45
46
  drivers: Map<string, DriverFn>;
46
47
  /** The makers of the config's fake vendors, by vendor id: a sandbox run makes its own of each when it is seeded. */
47
48
  fakes: Map<string, () => Fake<any, any>>;
49
+ /** The instance's fakes, by vendor id, when it runs on them (`fakes: "instance"`): its sandbox runs seed and call these, not fakes of their own. */
50
+ instanceFakes?: ReadonlyMap<string, Fake<any, any>>;
48
51
  /** Where sandbox runs' scenarios are, the resource types their steps may name, and the steps the connectors ship. */
49
52
  scenarios?: URL;
50
53
  /** The worker's `source`, when it was given one. */
@@ -75,6 +78,8 @@ export interface WorkerState {
75
78
  * how it really ends (see `isInfrastructureError`).
76
79
  */
77
80
  stopped: boolean;
81
+ /** Which of its runs are executing and which wait, until when: what the worker's `idle` reads. */
82
+ deadlines: DeadlineRegistry;
78
83
  }
79
84
 
80
85
  /** What a run keeps in memory. Rebuilt the same way when DBOS replays the run. */
package/src/sandbox.ts CHANGED
@@ -4,7 +4,7 @@ import { opIdsOf } from "./define.ts";
4
4
  import { SanomaError } from "./errors.ts";
5
5
  import type { Fake, ResourceFake } from "./fake.ts";
6
6
  import { entry, type LedgerRecord, write } from "./ledger.ts";
7
- import type { DriverFn } from "./op.ts";
7
+ import type { CallContext, DriverFn, Op } from "./op.ts";
8
8
  import type { Run } from "./run.ts";
9
9
  import { memorySecrets } from "./secret.ts";
10
10
  import { type Given, loadScenarios, type Scenario } from "./scenario.ts";
@@ -58,7 +58,7 @@ export function seedResource(
58
58
  scenario: string,
59
59
  copy: ResourceFake,
60
60
  given: Extract<Given, { resource: string }>,
61
- ): Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"][number] {
61
+ ): Seeds[number] {
62
62
  const { resource, type, name } = given;
63
63
  try {
64
64
  if ("gone" in given) {
@@ -77,16 +77,81 @@ export function seedResource(
77
77
  }
78
78
  }
79
79
 
80
+ /** What a scenario's `Given` steps seeded, as its `scenario.seeded` record has it. */
81
+ export type Seeds = Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"];
82
+
83
+ /**
84
+ * Where a scenario's `Given` steps go (`applyGivens`): a sandbox run's own fakes (`run`), or the
85
+ * instance a demo runs (`instance`), its collections in the config's database and every vendor
86
+ * on its one fake, which every run shares, so a fault, injected into a call some other run would
87
+ * make, is refused there.
88
+ */
89
+ export interface GivenTarget {
90
+ kind: "run" | "instance";
91
+ /** What a `Given` operation is called through, by operation id. */
92
+ drivers: ReadonlyMap<string, DriverFn>;
93
+ /** The fakes, by vendor id: what a `Given` resource is put in, and a `run`'s faults injected into. */
94
+ fakes: ReadonlyMap<string, Fake<unknown>>;
95
+ /** The operations by id, for a fault's vendor. */
96
+ ops: ReadonlyMap<string, Op>;
97
+ /** The context of the call the `i`th `Given` makes. */
98
+ call(i: number, op: string): CallContext;
99
+ }
100
+
101
+ /**
102
+ * Applies the scenario's `Given` steps to the target: calls each operation through its drivers,
103
+ * puts or removes each resource in its vendor's fake, then, for a `run`, injects the faults.
104
+ * Checks first that every operation it seeds has a driver there, that every resource's vendor has
105
+ * a fake holding resources, and, for an `instance`, that it injects no fault. Returns what it
106
+ * seeded, in order.
107
+ */
108
+ export async function applyGivens(scenario: Pick<Scenario, "name" | "given">, target: GivenTarget): Promise<Seeds> {
109
+ const { name, given } = scenario;
110
+ const fault = given.find((g) => "fault" in g);
111
+ if (fault && target.kind === "instance") {
112
+ throw new SanomaError(
113
+ "invalid_input",
114
+ `Scenario "${name}" injects a fault ("${fault.step}"), but its Givens go to the instance, whose fakes every run shares`,
115
+ { scenario: name, step: fault.step },
116
+ );
117
+ }
118
+ const missing = [...new Set(given.flatMap((g) => ("op" in g && !target.drivers.has(g.op) ? [g.op] : [])))];
119
+ if (missing.length) {
120
+ throw new SanomaError(
121
+ "invalid_input",
122
+ `Scenario "${name}" seeds ${missing.join(", ")}, but no fake its connectors attach implements ${missing.length > 1 ? "them" : "it"}`,
123
+ { scenario: name, missing },
124
+ );
125
+ }
126
+ const plain = missingResourceFake(scenario, target.fakes);
127
+ if (plain) throw plain;
128
+ const seeded: Seeds = [];
129
+ for (const [i, g] of given.entries()) {
130
+ if ("fault" in g) continue;
131
+ if ("op" in g) {
132
+ const output = await target.drivers.get(g.op)!(g.input, target.call(i, g.op));
133
+ seeded.push({ op: g.op, input: g.input, output });
134
+ continue;
135
+ }
136
+ // Checked above: the vendor's fake holds resources.
137
+ seeded.push(seedResource(name, target.fakes.get(g.vendor) as Fake<unknown> & ResourceFake, g));
138
+ }
139
+ for (const g of given) {
140
+ if ("fault" in g) target.fakes.get(target.ops.get(g.op)!.vendor)![g.fault](g.op);
141
+ }
142
+ return seeded;
143
+ }
144
+
80
145
  /**
81
146
  * Seeds a sandbox run of the scenario `name`, from the worker, in one step: reads the scenario,
82
- * checks that it runs this workflow, makes the run's own fakes (each vendor's maker, called once
83
- * for the run), checks that every operation the workflow uses or the scenario seeds has one and
84
- * that every resource it seeds has a fake holding resources, calls each `Given` operation
85
- * through them, puts or removes each `Given` resource in them, injects the faults into them,
86
- * then puts their functions on the run. The run then calls those only, so sandbox runs share no
87
- * state when the makers make a new fake each. The step's checkpoint never stands in for them: a
88
- * run whose seeding is replayed from it has none, and fails, since a sandbox run does not
89
- * survive a worker restart.
147
+ * checks that it runs this workflow, picks the run's fakes, checks that every operation the
148
+ * workflow uses has one, then applies the scenario's `Given` steps to them
149
+ * (`applyGivens`) and puts their functions on the run. The run then calls those only. Its fakes
150
+ * are its own (each vendor's maker, called once for the run), so sandbox runs share no state when
151
+ * the makers make a new fake each; on an instance that runs on fakes (`instanceFakes`) they are
152
+ * the instance's, which its sandbox runs share, as its live drivers are. The step's checkpoint
153
+ * never stands in for a run's own: a run whose seeding is replayed from it has none, and fails,
154
+ * since a sandbox run does not survive a worker restart; on an instance's fakes it goes on with them.
90
155
  */
91
156
  export async function seedSandbox(run: Run, name: string): Promise<void> {
92
157
  const { state } = run;
@@ -103,19 +168,22 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
103
168
  throw new SanomaError("invalid_input", `No scenario named "${name}"${why}`, { scenario: name });
104
169
  }
105
170
  if (scenario.workflow !== run.workflow) {
106
- throw new SanomaError("invalid_input", `Scenario "${name}" runs ${scenario.workflow}, not ${run.workflow}`, {
171
+ const runs = scenario.workflow === undefined ? "is a world, which runs nothing" : `runs ${scenario.workflow}`;
172
+ throw new SanomaError("invalid_input", `Scenario "${name}" ${runs}, not ${run.workflow}`, {
107
173
  scenario: name,
108
- workflow: scenario.workflow,
174
+ ...(scenario.workflow !== undefined && { workflow: scenario.workflow }),
109
175
  });
110
176
  }
111
- const fakes = new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
112
- const drivers = indexDrivers(
113
- [...fakes.values()].map((f) => f.driver),
114
- state.ops,
115
- );
116
- const uses = opIdsOf(state.workflows.get(run.workflow)!);
117
- const seeding = scenario.given.flatMap((g) => ("op" in g ? [g.op] : []));
118
- const missing = [...new Set([...uses, ...seeding])].filter((id) => !drivers.has(id));
177
+ const instance = state.instanceFakes;
178
+ const fakes = instance ?? new Map([...state.fakes].map(([vendor, make]) => [vendor, make()]));
179
+ const drivers = instance
180
+ ? state.drivers
181
+ : indexDrivers(
182
+ [...fakes.values()].map((f) => f.driver),
183
+ state.ops,
184
+ );
185
+ // What the scenario seeds is checked by `applyGivens`.
186
+ const missing = opIdsOf(state.workflows.get(run.workflow)!).filter((id) => !drivers.has(id));
119
187
  if (missing.length) {
120
188
  throw new SanomaError(
121
189
  "invalid_input",
@@ -123,28 +191,20 @@ export async function seedSandbox(run: Run, name: string): Promise<void> {
123
191
  { scenario: name, missing },
124
192
  );
125
193
  }
126
- const plain = missingResourceFake(scenario, fakes);
127
- if (plain) throw plain;
128
- const seeded: Extract<LedgerRecord, { type: "scenario.seeded" }>["seeds"] = [];
129
- for (const [i, given] of scenario.given.entries()) {
130
- if ("fault" in given) continue;
131
- if ("op" in given) {
132
- const call = { idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId: given.op, attempt: 1, secrets };
133
- const output = await drivers.get(given.op)!(given.input, call);
134
- seeded.push({ op: given.op, input: given.input, output });
135
- continue;
136
- }
137
- // Checked above: the vendor's fake holds resources.
138
- seeded.push(seedResource(name, fakes.get(given.vendor) as Fake<unknown> & ResourceFake, given));
139
- }
140
- for (const given of scenario.given) {
141
- if ("fault" in given) fakes.get(state.ops.get(given.op)!.vendor)![given.fault](given.op);
142
- }
194
+ const seeded = await applyGivens(scenario, {
195
+ kind: instance ? "instance" : "run",
196
+ drivers,
197
+ fakes,
198
+ ops: state.ops,
199
+ call: (i, opId) => ({ idempotencyKey: `${run.id}:seed:${i}`, runId: run.id, opId, attempt: 1, secrets }),
200
+ });
143
201
  copies = drivers;
144
202
  return { seeds: seeded };
145
203
  },
146
204
  { name: "sandbox:seed" },
147
205
  );
206
+ // Replayed on an instance that runs on fakes: they are the instance's, still there in this process.
207
+ if (!copies && state.instanceFakes) copies = state.drivers;
148
208
  if (!copies) {
149
209
  // Replayed from its checkpoint (after a worker restart, or in a fork), so the run has no fakes.
150
210
  // The records written before the restart hold the seqs from here on: the failure goes after them.