@sanoma-ai/workflows 0.7.0 → 0.7.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.
Files changed (72) hide show
  1. package/README.md +9 -5
  2. package/dist/config.d.ts +31 -6
  3. package/dist/config.js +36 -10
  4. package/dist/config.js.map +1 -1
  5. package/dist/constructors.d.ts +37 -1
  6. package/dist/constructors.js +6 -2
  7. package/dist/constructors.js.map +1 -1
  8. package/dist/db.d.ts +9 -21
  9. package/dist/db.js +16 -54
  10. package/dist/db.js.map +1 -1
  11. package/dist/deadlines.d.ts +13 -10
  12. package/dist/deadlines.js +18 -24
  13. package/dist/deadlines.js.map +1 -1
  14. package/dist/fake.d.ts +26 -0
  15. package/dist/fake.js +123 -0
  16. package/dist/fake.js.map +1 -1
  17. package/dist/host.d.ts +2 -3
  18. package/dist/host.js +1 -1
  19. package/dist/host.js.map +1 -1
  20. package/dist/index.d.ts +2 -3
  21. package/dist/index.js +2 -4
  22. package/dist/index.js.map +1 -1
  23. package/dist/lint.js +1 -1
  24. package/dist/lint.js.map +1 -1
  25. package/dist/op.d.ts +8 -1
  26. package/dist/op.js.map +1 -1
  27. package/dist/person.d.ts +5 -0
  28. package/dist/person.js +3 -1
  29. package/dist/person.js.map +1 -1
  30. package/dist/resources.js +5 -3
  31. package/dist/resources.js.map +1 -1
  32. package/dist/source.d.ts +124 -0
  33. package/dist/source.js +9 -0
  34. package/dist/source.js.map +1 -1
  35. package/dist/supervisor.d.ts +5 -4
  36. package/dist/supervisor.js +2 -11
  37. package/dist/supervisor.js.map +1 -1
  38. package/dist/wakeup-driver.d.ts +128 -0
  39. package/dist/wakeup-driver.js +216 -0
  40. package/dist/wakeup-driver.js.map +1 -0
  41. package/dist/wakeup-service.d.ts +4 -4
  42. package/dist/wakeup-service.js +19 -25
  43. package/dist/wakeup-service.js.map +1 -1
  44. package/dist/wakeup-type.d.ts +43 -0
  45. package/dist/wakeup-type.js +80 -0
  46. package/dist/wakeup-type.js.map +1 -0
  47. package/dist/wakeup.d.ts +1 -8
  48. package/dist/wakeup.js +2 -11
  49. package/dist/wakeup.js.map +1 -1
  50. package/dist/worker.d.ts +2 -2
  51. package/dist/worker.js +1 -5
  52. package/dist/worker.js.map +1 -1
  53. package/oxlint.json +1 -0
  54. package/package.json +3 -1
  55. package/src/config.ts +45 -14
  56. package/src/constructors.ts +8 -2
  57. package/src/db.ts +16 -66
  58. package/src/deadlines.ts +22 -28
  59. package/src/fake.ts +137 -0
  60. package/src/host.ts +3 -4
  61. package/src/index.ts +8 -3
  62. package/src/lint.ts +1 -1
  63. package/src/op.ts +8 -1
  64. package/src/person.ts +4 -1
  65. package/src/resources.ts +5 -3
  66. package/src/source.ts +118 -0
  67. package/src/supervisor.ts +7 -12
  68. package/src/wakeup-driver.ts +270 -0
  69. package/src/wakeup-service.ts +21 -28
  70. package/src/wakeup-type.ts +90 -0
  71. package/src/wakeup.ts +6 -17
  72. package/src/worker.ts +3 -7
@@ -0,0 +1,270 @@
1
+ import { z } from "zod";
2
+ import { type CallContext, defineDriver, DriverError, retryableStatus } from "./op.ts";
3
+ import type { PlanAction } from "./resource.ts";
4
+ import { defineSteps } from "./steps.ts";
5
+ import type { WakeupRegistration } from "./wakeup.ts";
6
+ import { instanceUrl, mintInstanceToken, WAKEUP_TOKEN } from "./wakeup-service.ts";
7
+ import { wakeupOps } from "./wakeup-type.ts";
8
+
9
+ // `sanoma.wakeup`'s operations over a wakeup service (`WakeupApi`): the driver's call it over
10
+ // HTTP (plain `fetch`, the service has no SDK), the fake's (`fakeSanomaWakeup` in `fake.ts`)
11
+ // answer from its state. Both go through `wakeupCalls`, so they plan, post the token and refuse
12
+ // alike. `config.ts` puts the two together as the built-in `sanomaWakeup`.
13
+
14
+ /** An instance as the service shows it (`GET /wakeup/instances/{id}`): the fields read here. */
15
+ const instanceSchema = z.object({
16
+ id: z.string(),
17
+ url: z.string(),
18
+ at: z.string().nullable(),
19
+ lastError: z.string().nullable(),
20
+ });
21
+ export type Instance = z.output<typeof instanceSchema>;
22
+
23
+ /** The wakeup service, as the operations use it. Each call fails with a `DriverError`. */
24
+ export interface WakeupApi {
25
+ /**
26
+ * `POST /wakeup/instances { url, token }` with the account's token: the instance the token
27
+ * names, made, or moved to `url` when the account registered it before.
28
+ */
29
+ put(url: string, token: string): Promise<{ id: string }>;
30
+ /** `GET /wakeup/instances/{id}` with the account's token; undefined when there is none (404). */
31
+ get(id: string): Promise<Instance | undefined>;
32
+ /** `DELETE /wakeup/instances/{id}` with the account's token; nothing when there is none. */
33
+ remove(id: string): Promise<void>;
34
+ /** `PUT /wakeup/wake { at }` with the instance's token, which names the instance. */
35
+ wake(registration: WakeupRegistration, token: string): Promise<void>;
36
+ }
37
+
38
+ /** What the service holds of an instance that registered no wake: a new one's, and one moved to a new URL. */
39
+ export const UNREGISTERED = { at: null, lastError: null } as const;
40
+
41
+ export const fail = (message: string, status: number) =>
42
+ new DriverError(`sanoma.wakeup: ${message}`, { retryable: retryableStatus(status), status });
43
+
44
+ /** The driver's own refusal, before or instead of a call: never retried. */
45
+ const refused = (message: string, vendorCode?: string) =>
46
+ new DriverError(`sanoma.wakeup: ${message}`, { retryable: false, ...(vendorCode && { vendorCode }) });
47
+
48
+ /** A wakeup's state: the service's instance, its URL as `host`. */
49
+ const stateOf = ({ id, url, at, lastError }: Instance) => ({ id, host: url, at, lastError });
50
+ type State = ReturnType<typeof stateOf>;
51
+
52
+ /** What `plan` finds: the action, what it would leave, and the state it planned from. */
53
+ interface Planned {
54
+ action: PlanAction;
55
+ planned?: Record<string, unknown>;
56
+ state?: State;
57
+ unknown: string[];
58
+ replace: string[];
59
+ }
60
+
61
+ /** A URL as the service keeps it (`instanceUrl`, the service's own rule), or as given when the service would refuse it. */
62
+ const normalUrl = (url: unknown) => {
63
+ const checked = instanceUrl(url);
64
+ return "url" in checked ? checked.url : String(url);
65
+ };
66
+
67
+ /**
68
+ * The wakeup type's operations and `wakeup.register` over `api()`, made on each call. The
69
+ * instance's token, `SANOMA_WAKEUP_TOKEN`, names the instance: `create` and `update` both post it
70
+ * with the host's URL (minted and stored first when none is, as for a data file written by hand),
71
+ * so a new host is the same instance moved, never a replace; `register` sends it.
72
+ */
73
+ export function wakeupCalls(api: () => WakeupApi) {
74
+ const read = async ({ id }: { id: string }) => {
75
+ const found = await api().get(id);
76
+ return found ? { id, gone: false, state: stateOf(found) } : { id, gone: true };
77
+ };
78
+ /**
79
+ * The instance the stored token names, at the desired host: made or moved, its state as a new
80
+ * one's (a repeat after a lost reply keeps the wake the service holds, which the next read shows).
81
+ */
82
+ const put = async (desired: Record<string, unknown>, { secrets }: CallContext) => {
83
+ const host = normalUrl(desired.host);
84
+ const token = await secrets.ensure(WAKEUP_TOKEN, mintInstanceToken);
85
+ const { id } = await api().put(host, token);
86
+ const state: State = { id, host, ...UNREGISTERED };
87
+ return { id, state };
88
+ };
89
+ const plan = async ({ id, desired }: { id?: string; desired?: Record<string, unknown> }): Promise<Planned> => {
90
+ const state = id === undefined ? undefined : (await read({ id })).state;
91
+ const none = { unknown: [], replace: [] };
92
+ if (!desired) return { action: state ? "delete" : "none", state, ...none };
93
+ const planned = { host: normalUrl(desired.host) };
94
+ if (!state) return { action: "create", planned, unknown: ["id"], replace: [] };
95
+ if (planned.host === state.host) return { action: "none", planned: state, state, ...none };
96
+ // A move forgets the wake registered at the old URL, as the service does.
97
+ return { action: "update", planned: { ...state, ...planned, ...UNREGISTERED }, state, ...none };
98
+ };
99
+ return {
100
+ import: read,
101
+ read,
102
+ plan: async (input: { id?: string; desired?: Record<string, unknown> }) => {
103
+ const { state: _, ...out } = await plan(input);
104
+ return out;
105
+ },
106
+ create: ({ desired }: { desired: Record<string, unknown> }, call: CallContext) => put(desired, call),
107
+ update: async (
108
+ {
109
+ id,
110
+ desired,
111
+ expect,
112
+ }: { id: string; desired: Record<string, unknown>; expect?: { action: PlanAction; replace: string[] } },
113
+ call: CallContext,
114
+ ) => {
115
+ const fresh = await plan({ id, desired });
116
+ if (expect && (expect.action !== fresh.action || expect.replace.join() !== fresh.replace.join())) {
117
+ throw refused(`update: the plan changed (${expect.action}, now ${fresh.action})`, "plan_changed");
118
+ }
119
+ if (fresh.state && fresh.action === "none") return { id, state: fresh.state };
120
+ return put(desired, call);
121
+ },
122
+ delete: async ({ id }: { id: string }) => {
123
+ await api().remove(id);
124
+ return { id, gone: true as const };
125
+ },
126
+ register: async (registration: WakeupRegistration, { secrets }: CallContext) => {
127
+ const token = await secrets.get(WAKEUP_TOKEN);
128
+ if (!token)
129
+ throw refused(
130
+ `register: ${WAKEUP_TOKEN} is not set: connect a wakeup service in /setup, then apply the wakeup`,
131
+ );
132
+ await api().wake(registration, token);
133
+ return {};
134
+ },
135
+ };
136
+ }
137
+
138
+ /** The environment variables the driver reads, on every call. */
139
+ const env = z.object({
140
+ SANOMA_WAKEUP_URL: z.url().optional().describe("the wakeup service's URL: Sanoma's, or your own `sanoma wakeup`"),
141
+ SANOMA_WAKEUP_ACCOUNT_TOKEN: z
142
+ .string()
143
+ .startsWith("swa_", "an account's token starts swa_")
144
+ .optional()
145
+ .describe("an account's token at the wakeup service (swa_…), which creates and deletes the instances"),
146
+ // No check of its form: a host's own wakeup (Cloudflare's alarm) takes this variable too, and
147
+ // the service refuses a token that is not an instance's.
148
+ [WAKEUP_TOKEN]: z
149
+ .string()
150
+ .optional()
151
+ .describe(
152
+ "this instance's token at the wakeup service (swi_…), which /setup mints and the wakeup's create registers",
153
+ ),
154
+ });
155
+
156
+ /** The variable `name`, read when an operation is called, or the driver's refusal. */
157
+ function required(name: "SANOMA_WAKEUP_URL" | "SANOMA_WAKEUP_ACCOUNT_TOKEN"): string {
158
+ const value = process.env[name];
159
+ if (!value) throw refused(`${name} is not set (${env.shape[name].description})`);
160
+ return value;
161
+ }
162
+
163
+ const Posted = z.object({ id: z.string() });
164
+ /** The service's error body. */
165
+ const Problem = z.object({ error: z.string() });
166
+
167
+ const instance = (id: string) => `/wakeup/instances/${encodeURIComponent(id)}`;
168
+
169
+ /** The service at `SANOMA_WAKEUP_URL`, read when an operation is called: each request's failure a `DriverError`. */
170
+ function serviceApi(timeoutMs: number): WakeupApi {
171
+ const base = required("SANOMA_WAKEUP_URL").replace(/\/+$/, "");
172
+ if (!URL.canParse(base)) throw refused("SANOMA_WAKEUP_URL is not a URL");
173
+ const account = () => required("SANOMA_WAKEUP_ACCOUNT_TOKEN");
174
+ /**
175
+ * One request: its reply parsed by `reply` (none: not read), a 404 `undefined` where `absent`
176
+ * says it means no such instance, and any other answer but a 2xx a `DriverError`.
177
+ */
178
+ const send = async <T>(
179
+ method: "GET" | "POST" | "PUT" | "DELETE",
180
+ path: string,
181
+ token: string,
182
+ { reply, body, absent = false }: { reply?: z.ZodType<T>; body?: unknown; absent?: boolean } = {},
183
+ ): Promise<T | undefined> => {
184
+ const where = `${method} ${path}`;
185
+ let res: Response;
186
+ let text: string;
187
+ try {
188
+ res = await fetch(`${base}${path}`, {
189
+ method,
190
+ signal: AbortSignal.timeout(timeoutMs),
191
+ // A redirect is no answer of the service's, and would carry the token elsewhere.
192
+ redirect: "manual",
193
+ headers: {
194
+ authorization: `Bearer ${token}`,
195
+ accept: "application/json",
196
+ ...(body !== undefined && { "content-type": "application/json" }),
197
+ },
198
+ ...(body !== undefined && { body: JSON.stringify(body) }),
199
+ });
200
+ text = await res.text();
201
+ } catch (cause) {
202
+ // No answer: the request may or may not have reached the service.
203
+ const why = (cause as Error).name === "TimeoutError" ? `no reply in ${timeoutMs} ms` : "the request failed";
204
+ throw new DriverError(`sanoma.wakeup: ${where}: ${why}`, { retryable: true, cause });
205
+ }
206
+ const { status } = res;
207
+ let json: unknown;
208
+ try {
209
+ json = text ? JSON.parse(text) : undefined;
210
+ } catch {
211
+ // A proxy's HTML page, say: no body to read.
212
+ }
213
+ // The service's own 404 (its `{ error }`), not another server's at that URL.
214
+ if (status === 404 && absent && Problem.safeParse(json).success) return undefined;
215
+ if (status < 200 || status >= 300)
216
+ throw fail(`${where}: ${Problem.safeParse(json).data?.error ?? `HTTP ${status}`}`, status);
217
+ if (!reply) return undefined;
218
+ const parsed = reply.safeParse(json);
219
+ if (!parsed.success) {
220
+ throw new DriverError(
221
+ `sanoma.wakeup: ${where}: the reply is not a wakeup service's; is SANOMA_WAKEUP_URL right?`,
222
+ {
223
+ retryable: false,
224
+ status,
225
+ cause: parsed.error,
226
+ },
227
+ );
228
+ }
229
+ return parsed.data;
230
+ };
231
+ return {
232
+ put: async (url, token) => {
233
+ try {
234
+ return (await send("POST", "/wakeup/instances", account(), { reply: Posted, body: { url, token } }))!;
235
+ } catch (error) {
236
+ // Another account's instance's token: the advice is the company's, so it is here, not the service's.
237
+ if (!(error instanceof DriverError) || error.status !== 409) throw error;
238
+ throw new DriverError(
239
+ `${error.message}: a new account needs a new instance token (store the new account's token in /setup, which mints one)`,
240
+ { retryable: false, status: 409, cause: error },
241
+ );
242
+ }
243
+ },
244
+ get: (id) => send("GET", instance(id), account(), { reply: instanceSchema, absent: true }),
245
+ async remove(id) {
246
+ await send("DELETE", instance(id), account(), { absent: true });
247
+ },
248
+ async wake({ at }, token) {
249
+ await send("PUT", "/wakeup/wake", token, { body: { at } });
250
+ },
251
+ };
252
+ }
253
+
254
+ /**
255
+ * The real wakeup service at `SANOMA_WAKEUP_URL`: instances created, read and deleted with
256
+ * `SANOMA_WAKEUP_ACCOUNT_TOKEN`, each named by its `SANOMA_WAKEUP_TOKEN` (which `create` and
257
+ * `register` send). `timeoutMs` is how long one request may take: 10 s by default.
258
+ */
259
+ export function sanomaWakeupDriver({ timeoutMs = 10_000 }: { timeoutMs?: number } = {}) {
260
+ return defineDriver(wakeupOps, { wakeup: wakeupCalls(() => serviceApi(timeoutMs)) }, { env });
261
+ }
262
+
263
+ /** The wakeup's step for Gherkin tests: an instance at the service before the run, named `wakeup` (`$wakeup.id`). */
264
+ export const wakeupSteps = defineSteps("sanoma", ({ given }) => {
265
+ given("a sanoma wakeup for {string}", (host: string) => ({
266
+ op: wakeupOps.wakeup.create,
267
+ input: { desired: { host } },
268
+ as: "wakeup",
269
+ }));
270
+ });
@@ -8,10 +8,10 @@ import { errorMessage } from "./shared.ts";
8
8
  * `@sanoma-ai/workflows/wakeup-service`: the service that wakes stopped instances, server-side
9
9
  * only. An account (minted by whoever runs the service) registers an instance by its URL and the
10
10
  * instance's own token, which the company mints (`mintInstanceToken`) and keeps; the instance,
11
- * before it stops, `PUT`s with that token when it wants waking and a
12
- * generation; the service then calls `GET {url}/wake?generation=N`, retrying until an answer is a
13
- * 2xx. It makes its two tables itself, in the database it is given (sanoma-ops's own, when it
14
- * hosts it): never among `db.ts`'s, which every company's database gets.
11
+ * before it stops, `PUT`s with that token when it wants waking, replacing what it asked before;
12
+ * the service then calls `GET {url}/wake`, retrying until an answer is a 2xx. It makes its two
13
+ * tables itself, in the database it is given (sanoma-ops's own, when it hosts it): never among
14
+ * `db.ts`'s, which every company's database gets.
15
15
  */
16
16
 
17
17
  /** Where the accounts live: one per party that registers instances, its token's hash alone. */
@@ -29,7 +29,7 @@ const TABLES: readonly [what: string, ddl: string][] = [
29
29
  "wakeup instances",
30
30
  `CREATE TABLE IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE} (id uuid PRIMARY KEY, ` +
31
31
  `account_id uuid NOT NULL REFERENCES ${WAKEUP_ACCOUNTS_TABLE} (id), url text NOT NULL, ` +
32
- "token_hash text UNIQUE NOT NULL, at timestamptz, generation bigint NOT NULL DEFAULT 0, " +
32
+ "token_hash text UNIQUE NOT NULL, at timestamptz, " +
33
33
  "called_at timestamptz, attempts integer NOT NULL DEFAULT 0, next_attempt_at timestamptz, " +
34
34
  "last_error text, created_at timestamptz NOT NULL DEFAULT now())",
35
35
  ],
@@ -169,7 +169,7 @@ async function jsonBody(request: Request): Promise<Record<string, unknown> | und
169
169
 
170
170
  /**
171
171
  * An instance's URL as it is stored, without a trailing slash, or why it is refused: `http(s)`, no
172
- * user or password, no query, no fragment (`/wake?generation=N` is added to it).
172
+ * user or password, no query, no fragment (`/wake` is added to it).
173
173
  */
174
174
  export function instanceUrl(value: unknown): { url: string } | { error: string } {
175
175
  const refused = { error: "url must be an http(s) URL with no user, password, query or fragment" };
@@ -184,7 +184,6 @@ interface InstanceRow {
184
184
  account_id: string;
185
185
  url: string;
186
186
  at: Date | null;
187
- generation: string;
188
187
  called_at: Date | null;
189
188
  attempts: number;
190
189
  last_error: string | null;
@@ -195,7 +194,6 @@ const shownInstance = (row: InstanceRow) => ({
195
194
  id: row.id,
196
195
  url: row.url,
197
196
  at: row.at?.toISOString() ?? null,
198
- generation: Number(row.generation),
199
197
  calledAt: row.called_at?.toISOString() ?? null,
200
198
  attempts: row.attempts,
201
199
  lastError: row.last_error,
@@ -249,10 +247,10 @@ export async function mintWakeupAccount(databaseUrl: string, label: string): Pro
249
247
  * token, 200 `{ id }` for one the account registered already, its URL now the given one (a lost
250
248
  * reply, or a host that moved; its registered wake kept only when the URL is the same), 409 for
251
249
  * another account's
252
- * - `GET /wakeup/instances/{id}`, its account's token → `{ id, url, at, generation, calledAt, attempts, lastError }`
250
+ * - `GET /wakeup/instances/{id}`, its account's token → `{ id, url, at, calledAt, attempts, lastError }`
253
251
  * - `DELETE /wakeup/instances/{id}`, its account's token → 204
254
- * - `PUT /wakeup/wake { at, generation }`, an instance's own token, which names the instance → 204,
255
- * applied only when `generation` is newer than the one it has, and waking the loop
252
+ * - `PUT /wakeup/wake { at }`, an instance's own token, which names the instance → 204, replacing
253
+ * the wake it had, and waking the loop
256
254
  *
257
255
  * 401 without a token or with an unknown one, 403 for another account's instance, 400 for a bad
258
256
  * body, 404 for an unknown id or path.
@@ -303,7 +301,6 @@ export async function startWakeupService({
303
301
  `INSERT INTO ${WAKEUP_INSTANCES_TABLE} AS i (id, account_id, url, token_hash) VALUES ($1, $2, $3, $4) ` +
304
302
  "ON CONFLICT (token_hash) DO UPDATE SET url = EXCLUDED.url, " +
305
303
  "at = CASE WHEN i.url = EXCLUDED.url THEN i.at END, " +
306
- "generation = CASE WHEN i.url = EXCLUDED.url THEN i.generation ELSE 0 END, " +
307
304
  "called_at = CASE WHEN i.url = EXCLUDED.url THEN i.called_at END, " +
308
305
  "attempts = CASE WHEN i.url = EXCLUDED.url THEN i.attempts ELSE 0 END, " +
309
306
  "next_attempt_at = CASE WHEN i.url = EXCLUDED.url THEN i.next_attempt_at END, " +
@@ -330,17 +327,13 @@ export async function startWakeupService({
330
327
  const given = await jsonBody(request);
331
328
  if (given instanceof Response) return given;
332
329
  const at = given?.at;
333
- const generation = given?.generation;
334
330
  if (typeof at !== "string" || !ISO_DATE_TIME.test(at) || Number.isNaN(Date.parse(at)))
335
331
  return problem(400, "at must be an ISO 8601 date-time");
336
- // A new instance has generation 0, so a registration counts from 1.
337
- if (typeof generation !== "number" || !Number.isSafeInteger(generation) || generation < 1)
338
- return problem(400, "generation must be an integer, 1 or more");
339
- // An older or equal generation changes nothing: callers retry, at least once each.
332
+ // Whatever it had is replaced: a wake that arrives for an older one starts a worker with nothing to do.
340
333
  await pool.query(
341
- `UPDATE ${WAKEUP_INSTANCES_TABLE} SET at = $2, generation = $3, called_at = NULL, attempts = 0, ` +
342
- "next_attempt_at = NULL, last_error = NULL WHERE id = $1 AND generation < $3",
343
- [id, new Date(at), generation],
334
+ `UPDATE ${WAKEUP_INSTANCES_TABLE} SET at = $2, called_at = NULL, attempts = 0, ` +
335
+ "next_attempt_at = NULL, last_error = NULL WHERE id = $1",
336
+ [id, new Date(at)],
344
337
  );
345
338
  loop.poke();
346
339
  return noContent();
@@ -387,7 +380,7 @@ export async function startWakeupService({
387
380
  interface Due {
388
381
  id: string;
389
382
  url: string;
390
- generation: string;
383
+ at: Date;
391
384
  }
392
385
 
393
386
  /**
@@ -412,7 +405,7 @@ function startLoop({
412
405
 
413
406
  /** Calls `row`'s instance once, and records how it went, unless the registration changed meanwhile. */
414
407
  async function call(row: Due) {
415
- const target = `${row.url}/wake?generation=${row.generation}`;
408
+ const target = `${row.url}/wake`;
416
409
  let error: string | undefined;
417
410
  try {
418
411
  const signal = AbortSignal.any([stopping.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
@@ -425,14 +418,14 @@ function startLoop({
425
418
  }
426
419
  // Stopped mid-call: nothing is known of it, so it is called again once its claim lapses.
427
420
  if (stopping.signal.aborted) return;
428
- // Recorded only on the registration called: one replaced (a newer generation), or moved to
429
- // another URL (its generation counted again from 0), is called again.
430
- const same = "WHERE id = $1 AND generation = $2 AND url = $3";
421
+ // Recorded only on the registration called: one replaced (another `at`), or moved to another
422
+ // URL, is called again.
423
+ const same = "WHERE id = $1 AND at = $2 AND url = $3";
431
424
  if (error === undefined) {
432
425
  await pool.query(
433
426
  `UPDATE ${WAKEUP_INSTANCES_TABLE} SET called_at = $4, attempts = 0, next_attempt_at = NULL, ` +
434
427
  `last_error = NULL ${same}`,
435
- [row.id, row.generation, row.url, now()],
428
+ [row.id, row.at, row.url, now()],
436
429
  );
437
430
  log.info(`wakeup: called ${target}`);
438
431
  } else {
@@ -440,7 +433,7 @@ function startLoop({
440
433
  `UPDATE ${WAKEUP_INSTANCES_TABLE} SET attempts = attempts + 1, last_error = $4, ` +
441
434
  `next_attempt_at = $5::timestamptz + least(power(2, attempts), ${MAX_BACKOFF_S}) * interval '1 second' ` +
442
435
  same,
443
- [row.id, row.generation, row.url, error, now()],
436
+ [row.id, row.at, row.url, error, now()],
444
437
  );
445
438
  log.warn(`wakeup: calling ${target} failed (${error}); it is tried again`);
446
439
  }
@@ -453,7 +446,7 @@ function startLoop({
453
446
  `UPDATE ${WAKEUP_INSTANCES_TABLE} SET next_attempt_at = $1::timestamptz + interval '${CLAIM_S} seconds' ` +
454
447
  `WHERE id IN (SELECT i.id FROM ${WAKEUP_INSTANCES_TABLE} i ${LIVE} WHERE ${WAITING} ` +
455
448
  "AND i.at <= $1 AND (i.next_attempt_at IS NULL OR i.next_attempt_at <= $1) " +
456
- `ORDER BY i.at LIMIT ${ROUND_ROWS} FOR UPDATE OF i SKIP LOCKED) RETURNING id, url, generation`,
449
+ `ORDER BY i.at LIMIT ${ROUND_ROWS} FOR UPDATE OF i SKIP LOCKED) RETURNING id, url, at`,
457
450
  [now()],
458
451
  );
459
452
  const queue = [...rows];
@@ -0,0 +1,90 @@
1
+ import { z } from "zod";
2
+ import { type Connector, defineConnector, type OpSpec } from "./op.ts";
3
+ import { SANOMA_INFO } from "./person.ts";
4
+ import { defineResource } from "./resource.ts";
5
+
6
+ // The runtime's own wakeup, `sanoma.wakeup({ host })`: an instance of a wakeup service (`sanoma
7
+ // wakeup`, `wakeup-service.ts`: sanoma-ops's, or one of the company's own) that calls the host's
8
+ // `GET /wake` at the time the worker registered before it stopped. The fallback for a host with no
9
+ // clock of its own; every config with a host type has it (`resolveConfig`), so no connector is
10
+ // needed. Its driver is `wakeup-driver.ts`, its fake `fakeSanomaWakeup` in `fake.ts`, and `config.ts`
11
+ // puts them together as `sanomaWakeup`. The service has no SDK or OpenAPI spec, so
12
+ // the schemas are a cut of its own JSON, by hand.
13
+
14
+ /**
15
+ * The host types a wakeup may name: each one a data file declares a wakeup for. A new host type
16
+ * is added here. `testhost.app` is `@sanoma-ai/testing`'s, for the conformance kits.
17
+ */
18
+ const HOSTS = ["cloudflare.container", "docker.container", "testhost.app"] as const;
19
+
20
+ /** An instance of the wakeup service, as `GET /wakeup/instances/{id}` shows it. */
21
+ const wakeupSchema = z.object({
22
+ host: z
23
+ .string()
24
+ .min(1)
25
+ .describe("The host's URL, which the service calls `/wake` under: a data file names the host's declaration"),
26
+ id: z.string().optional().describe("The instance's id at the service"),
27
+ at: z.string().nullable().optional().describe("When the instance asked to be woken last, null until it has"),
28
+ lastError: z
29
+ .string()
30
+ .nullable()
31
+ .optional()
32
+ .describe("Why the last call to the host's `/wake` failed, null when it answered"),
33
+ });
34
+
35
+ /**
36
+ * `sanoma.wakeup({ host })`: an instance of a wakeup service for the host a data file names,
37
+ * which it calls at the time the worker registers (`sanoma.wakeup.register`). The service names
38
+ * it, so a declaration has no identity: its binding is its id. Apply creates it with the
39
+ * account's token (`SANOMA_WAKEUP_ACCOUNT_TOKEN`) and the instance's own, `SANOMA_WAKEUP_TOKEN`,
40
+ * which the company minted (`/setup`) and which names the instance: a new host is the same
41
+ * instance moved.
42
+ */
43
+ export const wakeup = defineResource({
44
+ vendor: "sanoma",
45
+ type: "wakeup",
46
+ title: "Wakeup",
47
+ schema: wakeupSchema,
48
+ fields: {
49
+ immutable: [],
50
+ vendorOwned: ["id", "at", "lastError"],
51
+ writeOnly: [],
52
+ references: { host: { type: HOSTS, by: ["url"] } },
53
+ },
54
+ writable: true,
55
+ // The service holds its registered wake: apply asks before it deletes one.
56
+ stateless: false,
57
+ wakeup: {
58
+ inputs: { host: "host" },
59
+ register: "sanoma.wakeup.register",
60
+ outputs: (state) => ({ healthy: typeof state.id === "string" && state.lastError == null }),
61
+ },
62
+ });
63
+
64
+ const types = defineConnector("sanoma", { wakeup }, SANOMA_INFO);
65
+
66
+ /** `wakeup.register`, in the wakeup type's own group, beside its resource operations. */
67
+ const registerSpec = {
68
+ register: {
69
+ effect: "write",
70
+ // Each replaces the wake registered before: a retry changes nothing.
71
+ idempotent: true,
72
+ description: "Ask the wakeup service to call the host's /wake at `at`, replacing the wake registered before",
73
+ input: z.object({ at: z.iso.datetime({ offset: true }).describe("When to wake the host, ISO 8601") }),
74
+ output: z.object({}),
75
+ },
76
+ } satisfies Record<string, OpSpec>;
77
+
78
+ const register = defineConnector("sanoma", { wakeup: registerSpec });
79
+
80
+ /** The wakeup's specs: the type's operations and `register` in one group. */
81
+ type WakeupSpecs = { wakeup: (typeof wakeup)["ops"] & typeof registerSpec };
82
+
83
+ /**
84
+ * The wakeup's operations: the type's (`read`, `import`, `plan`, `create`, `update`, `delete`)
85
+ * and `sanoma.wakeup.register` in its group (Supabase's pattern).
86
+ */
87
+ export const wakeupOps = {
88
+ ...types,
89
+ wakeup: { ...types.wakeup, ...register.wakeup },
90
+ } as unknown as Connector<"sanoma", WakeupSpecs>;
package/src/wakeup.ts CHANGED
@@ -7,7 +7,9 @@ import type { DeclaredResource } from "./resources.ts";
7
7
  // The `Wakeup` contract: what a resource type that wakes a stopped instance at a time declares
8
8
  // about itself, beside `Host`. Declaring one in a data file is the whole switch: the worker then
9
9
  // registers its next deadline with it before it stops, and the wakeup calls the host's
10
- // `GET {url}/wake?generation=N` then. The contract is here; the worker's side is `deadlines.ts`.
10
+ // `GET {url}/wake` then: a wake is "resume if stopped", so one that arrives late, twice or for a
11
+ // replaced registration starts a worker that finds nothing to do and stops again. The contract is
12
+ // here; the worker's side is `deadlines.ts`.
11
13
 
12
14
  /** What a wakeup's state says of it. */
13
15
  export interface WakeupOutputs {
@@ -18,18 +20,6 @@ export interface WakeupOutputs {
18
20
  export interface WakeupRegistration {
19
21
  /** When to wake the instance, as an ISO 8601 date-time. */
20
22
  at: string;
21
- /** Counts up with each registration: one with a newer generation replaces the older. */
22
- generation: number;
23
- }
24
-
25
- /**
26
- * The `generation` of a `GET /wake?generation=N` request: a non-negative integer, in digits
27
- * alone; undefined for none or any other.
28
- */
29
- export function wakeGeneration(url: string | URL): number | undefined {
30
- const given = new URL(url).searchParams.get("generation") ?? "";
31
- const generation = Number(given);
32
- return /^\d+$/.test(given) && Number.isSafeInteger(generation) ? generation : undefined;
33
23
  }
34
24
 
35
25
  /**
@@ -48,7 +38,7 @@ export interface WakeupSpec {
48
38
  * The id of the type's own register operation, `<vendor>.<type>.<op>`: an operation in the
49
39
  * type's own group of its connector, beside `read` and `import` (the way Supabase adds
50
40
  * `project.health` beside `project`). The runtime calls it with `WakeupRegistration`,
51
- * at least once per registration; a newer `generation` replaces the older.
41
+ * at least once per registration; each replaces whatever the wakeup held.
52
42
  */
53
43
  register: string;
54
44
  outputs: (state: Record<string, unknown>) => WakeupOutputs;
@@ -97,7 +87,7 @@ export function checkWakeup(
97
87
  }
98
88
 
99
89
  /** A registration every register operation must take: the input `checkWakeupOps` tries. */
100
- const SAMPLE: WakeupRegistration = { at: new Date(0).toISOString(), generation: 1 };
90
+ const SAMPLE: WakeupRegistration = { at: new Date(0).toISOString() };
101
91
 
102
92
  /**
103
93
  * True when `op`'s input takes `sample` and keeps each of its fields: one that strips a field
@@ -132,8 +122,7 @@ export function checkWakeupOps(types: ReadonlyMap<string, Resource>, ops: Readon
132
122
  if (notHosts.length) throw new Error(`${id}: its wakeup's host may name ${notHosts.join(", ")}, which is no host`);
133
123
  const op = ops.get(type.wakeup.register);
134
124
  if (!op) throw new Error(`${id}: its wakeup's register, ${type.wakeup.register}, is no operation of its connector`);
135
- if (!keepsInput(op, { ...SAMPLE }))
136
- throw new Error(`${id}: its wakeup's register, ${op.id}, must take { at, generation }`);
125
+ if (!keepsInput(op, { ...SAMPLE })) throw new Error(`${id}: its wakeup's register, ${op.id}, must take { at }`);
137
126
  }
138
127
  }
139
128
 
package/src/worker.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { DBOS, type ScheduledWorkflowFn } from "@dbos-inc/dbos-sdk";
2
- import { randomUUID } from "node:crypto";
3
2
  import { awaitApproval } from "./approvals.ts";
4
3
  import { buildCtx, decideStart, isInfrastructureError, startHoldOf } from "./call.ts";
5
4
  import { dbosStatusesOf } from "./client.ts";
6
5
  import { declaredWakeup, type ResolvedConfig, resolveConfig, type SanomaConfig } from "./config.ts";
7
6
  import { storedSecrets, watchCredentials } from "./credentials.ts";
8
- import { claimWakeup, DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
7
+ import { DBOS_DATABASE_OPTIONS, lazyPool, prepareDatabase } from "./db.ts";
9
8
  import { deadlineRegistry, type Deadlines, idle, onOwnQueue, type SelfStop, stopWhenIdle } from "./deadlines.ts";
10
9
  import type { WorkflowDefinition } from "./define.ts";
11
10
  import { credentialsOf, unconfigured, unconfiguredSummary } from "./env.ts";
@@ -85,8 +84,8 @@ export interface WorkerOptions {
85
84
  /**
86
85
  * Given, and with a wakeup declared, the worker stops itself when idle (`stopCheckMs`); without
87
86
  * it, nobody would start another, so it never does. Called once the worker has stopped itself, with the deadline it registered with the
88
- * wakeup (null for none) and that registration's generation: a supervisor starts a fresh
89
- * worker on the next request or wake (`shouldWake`), since a stopped one cannot launch again.
87
+ * wakeup (null for none): a supervisor starts a fresh worker on the next write or wake, since a
88
+ * stopped one cannot launch again.
90
89
  */
91
90
  onStopped?: (stopped: SelfStop) => void;
92
91
  /**
@@ -231,13 +230,10 @@ export async function startWorker(config: SanomaConfig, options: WorkerOptions =
231
230
  warn(`the policies do not hold, so every call and start is refused:\n ${resolved.policyProblems.join("\n ")}`);
232
231
  }
233
232
  if (wakeup && options.onStopped) {
234
- const owner = randomUUID();
235
- await claimWakeup(await secrets.db(), owner);
236
233
  const registerId = wakeup.type.wakeup!.register;
237
234
  stopRule = stopWhenIdle({
238
235
  // The row is one more of the runtime's tables: the secrets' pool, on the same database.
239
236
  db: () => secrets.db(),
240
- owner,
241
237
  wakeup: wakeup.binding ?? wakeup.id,
242
238
  // `resolveConfig` checked the operation is the config's (`checkWakeupOps`).
243
239
  register: { op: resolved.ops.get(registerId)!, fn: resolved.drivers.get(registerId) },