@sanoma-ai/workflows 0.6.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.
- package/README.md +52 -14
- package/dist/apply.d.ts +2 -1
- package/dist/apply.js +29 -7
- package/dist/apply.js.map +1 -1
- package/dist/approvals.d.ts +27 -2
- package/dist/approvals.js +73 -9
- package/dist/approvals.js.map +1 -1
- package/dist/call.d.ts +3 -2
- package/dist/call.js +38 -30
- package/dist/call.js.map +1 -1
- package/dist/cedar.js +24 -9
- package/dist/cedar.js.map +1 -1
- package/dist/change.d.ts +219 -0
- package/dist/change.js +150 -0
- package/dist/change.js.map +1 -0
- package/dist/chats.d.ts +43 -0
- package/dist/chats.js +48 -0
- package/dist/chats.js.map +1 -0
- package/dist/client.d.ts +52 -6
- package/dist/client.js +88 -47
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +75 -8
- package/dist/config.js +103 -13
- package/dist/config.js.map +1 -1
- package/dist/constructors.d.ts +37 -1
- package/dist/constructors.js +6 -2
- package/dist/constructors.js.map +1 -1
- package/dist/database.d.ts +83 -0
- package/dist/database.js +92 -0
- package/dist/database.js.map +1 -0
- package/dist/db.d.ts +19 -1
- package/dist/db.js +51 -7
- package/dist/db.js.map +1 -1
- package/dist/deadlines.d.ts +91 -0
- package/dist/deadlines.js +165 -0
- package/dist/deadlines.js.map +1 -0
- package/dist/define.d.ts +25 -4
- package/dist/define.js +40 -0
- package/dist/define.js.map +1 -1
- package/dist/demo.d.ts +28 -0
- package/dist/demo.js +72 -0
- package/dist/demo.js.map +1 -0
- package/dist/describe.d.ts +5 -2
- package/dist/describe.js +10 -6
- package/dist/describe.js.map +1 -1
- package/dist/errors.d.ts +10 -1
- package/dist/errors.js +13 -0
- package/dist/errors.js.map +1 -1
- package/dist/fake.d.ts +34 -0
- package/dist/fake.js +134 -1
- package/dist/fake.js.map +1 -1
- package/dist/grid.d.ts +34 -0
- package/dist/grid.js +62 -0
- package/dist/grid.js.map +1 -0
- package/dist/host.d.ts +12 -2
- package/dist/host.js +2 -2
- package/dist/host.js.map +1 -1
- package/dist/index.d.ts +15 -8
- package/dist/index.js +10 -5
- package/dist/index.js.map +1 -1
- package/dist/ledger.d.ts +11 -2
- package/dist/ledger.js.map +1 -1
- package/dist/lint.js +2 -2
- package/dist/lint.js.map +1 -1
- package/dist/op.d.ts +21 -2
- package/dist/op.js.map +1 -1
- package/dist/person.d.ts +5 -0
- package/dist/person.js +3 -1
- package/dist/person.js.map +1 -1
- package/dist/policy.d.ts +7 -1
- package/dist/policy.js +2 -1
- package/dist/policy.js.map +1 -1
- package/dist/resource.d.ts +13 -1
- package/dist/resource.js +9 -1
- package/dist/resource.js.map +1 -1
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +15 -4
- package/dist/resources.js.map +1 -1
- package/dist/run.d.ts +5 -0
- package/dist/run.js.map +1 -1
- package/dist/sandbox.d.ts +38 -10
- package/dist/sandbox.js +66 -35
- package/dist/sandbox.js.map +1 -1
- package/dist/scenario.d.ts +18 -1
- package/dist/scenario.js +77 -27
- package/dist/scenario.js.map +1 -1
- package/dist/scenarios.d.ts +10 -1
- package/dist/scenarios.js +19 -4
- package/dist/scenarios.js.map +1 -1
- package/dist/schedules.d.ts +66 -0
- package/dist/schedules.js +158 -0
- package/dist/schedules.js.map +1 -0
- package/dist/schema.d.ts +6 -0
- package/dist/schema.js +13 -3
- package/dist/schema.js.map +1 -1
- package/dist/shared.d.ts +30 -2
- package/dist/shared.js +42 -4
- package/dist/shared.js.map +1 -1
- package/dist/source.d.ts +185 -2
- package/dist/source.js +9 -0
- package/dist/source.js.map +1 -1
- package/dist/supervisor.d.ts +39 -0
- package/dist/supervisor.js +68 -0
- package/dist/supervisor.js.map +1 -0
- package/dist/version.d.ts +3 -1
- package/dist/version.js +3 -1
- package/dist/version.js.map +1 -1
- package/dist/wakeup-driver.d.ts +128 -0
- package/dist/wakeup-driver.js +216 -0
- package/dist/wakeup-driver.js.map +1 -0
- package/dist/wakeup-service.d.ts +83 -0
- package/dist/wakeup-service.js +415 -0
- package/dist/wakeup-service.js.map +1 -0
- package/dist/wakeup-type.d.ts +43 -0
- package/dist/wakeup-type.js +80 -0
- package/dist/wakeup-type.js.map +1 -0
- package/dist/wakeup.d.ts +77 -0
- package/dist/wakeup.js +78 -0
- package/dist/wakeup.js.map +1 -0
- package/dist/worker.d.ts +39 -1
- package/dist/worker.js +125 -38
- package/dist/worker.js.map +1 -1
- package/oxlint.json +3 -1
- package/package.json +10 -2
- package/src/apply.ts +25 -6
- package/src/approvals.ts +89 -16
- package/src/call.ts +40 -29
- package/src/cedar.ts +24 -6
- package/src/change.ts +222 -0
- package/src/chats.ts +114 -0
- package/src/client.ts +145 -55
- package/src/config.ts +149 -21
- package/src/constructors.ts +8 -2
- package/src/database.ts +152 -0
- package/src/db.ts +58 -9
- package/src/deadlines.ts +228 -0
- package/src/define.ts +71 -4
- package/src/demo.ts +87 -0
- package/src/describe.ts +18 -6
- package/src/errors.ts +14 -0
- package/src/fake.ts +149 -0
- package/src/grid.ts +100 -0
- package/src/host.ts +12 -4
- package/src/index.ts +66 -3
- package/src/ledger.ts +15 -3
- package/src/lint.ts +2 -2
- package/src/op.ts +22 -2
- package/src/person.ts +4 -1
- package/src/policy.ts +7 -1
- package/src/resource.ts +22 -3
- package/src/resources.ts +17 -4
- package/src/run.ts +5 -0
- package/src/sandbox.ts +97 -37
- package/src/scenario.ts +97 -28
- package/src/scenarios.ts +21 -5
- package/src/schedules.ts +206 -0
- package/src/schema.ts +16 -3
- package/src/shared.ts +51 -4
- package/src/source.ts +176 -3
- package/src/supervisor.ts +102 -0
- package/src/version.ts +3 -1
- package/src/wakeup-driver.ts +270 -0
- package/src/wakeup-service.ts +504 -0
- package/src/wakeup-type.ts +90 -0
- package/src/wakeup.ts +139 -0
- package/src/worker.ts +176 -43
|
@@ -0,0 +1,504 @@
|
|
|
1
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
+
import { connectMaking, createTable, type Db, openPool } from "./db.ts";
|
|
3
|
+
import { info, warn } from "./log.ts";
|
|
4
|
+
import { generateSecret } from "./secret.ts";
|
|
5
|
+
import { errorMessage } from "./shared.ts";
|
|
6
|
+
|
|
7
|
+
/*
|
|
8
|
+
* `@sanoma-ai/workflows/wakeup-service`: the service that wakes stopped instances, server-side
|
|
9
|
+
* only. An account (minted by whoever runs the service) registers an instance by its URL and the
|
|
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, 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
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** Where the accounts live: one per party that registers instances, its token's hash alone. */
|
|
18
|
+
export const WAKEUP_ACCOUNTS_TABLE = "sanoma_wakeup_accounts";
|
|
19
|
+
/** Where the instances live: each one's URL, its token's hash and its registered wake. */
|
|
20
|
+
export const WAKEUP_INSTANCES_TABLE = "sanoma_wakeup_instances";
|
|
21
|
+
|
|
22
|
+
const TABLES: readonly [what: string, ddl: string][] = [
|
|
23
|
+
[
|
|
24
|
+
"wakeup accounts",
|
|
25
|
+
`CREATE TABLE IF NOT EXISTS ${WAKEUP_ACCOUNTS_TABLE} (id uuid PRIMARY KEY, token_hash text UNIQUE NOT NULL, ` +
|
|
26
|
+
"label text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), revoked_at timestamptz)",
|
|
27
|
+
],
|
|
28
|
+
[
|
|
29
|
+
"wakeup instances",
|
|
30
|
+
`CREATE TABLE IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE} (id uuid PRIMARY KEY, ` +
|
|
31
|
+
`account_id uuid NOT NULL REFERENCES ${WAKEUP_ACCOUNTS_TABLE} (id), url text NOT NULL, ` +
|
|
32
|
+
"token_hash text UNIQUE NOT NULL, at timestamptz, " +
|
|
33
|
+
"called_at timestamptz, attempts integer NOT NULL DEFAULT 0, next_attempt_at timestamptz, " +
|
|
34
|
+
"last_error text, created_at timestamptz NOT NULL DEFAULT now())",
|
|
35
|
+
],
|
|
36
|
+
// Each on its own, so one another process made first does not skip the next.
|
|
37
|
+
[
|
|
38
|
+
"wakeup instances",
|
|
39
|
+
`CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_at ON ${WAKEUP_INSTANCES_TABLE} (at) WHERE at IS NOT NULL`,
|
|
40
|
+
],
|
|
41
|
+
[
|
|
42
|
+
"wakeup instances",
|
|
43
|
+
`CREATE INDEX IF NOT EXISTS ${WAKEUP_INSTANCES_TABLE}_account ON ${WAKEUP_INSTANCES_TABLE} (account_id)`,
|
|
44
|
+
],
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/** How long a call to an instance's `/wake` may take. */
|
|
48
|
+
const CALL_TIMEOUT_MS = 10_000;
|
|
49
|
+
/** The longest the loop sleeps without looking again, whatever is due. */
|
|
50
|
+
const MAX_SLEEP_MS = 60_000;
|
|
51
|
+
/** How many instances are called at once. */
|
|
52
|
+
const CONCURRENCY = 8;
|
|
53
|
+
/** The longest wait between two tries of a failing instance, in seconds. */
|
|
54
|
+
const MAX_BACKOFF_S = 300;
|
|
55
|
+
/**
|
|
56
|
+
* How long a row taken for a call is the taker's: longer than a call may take. Two services on one
|
|
57
|
+
* database never call one row at once, and a call no outcome was recorded for (the process
|
|
58
|
+
* stopped, or the write failed) is tried again once it lapses.
|
|
59
|
+
*/
|
|
60
|
+
const CLAIM_S = 30;
|
|
61
|
+
/** How many due rows a round takes: few enough to be called well within their claim. */
|
|
62
|
+
const ROUND_ROWS = CONCURRENCY * 2;
|
|
63
|
+
/** The most a request's body may hold: a registration is a few hundred bytes. */
|
|
64
|
+
const MAX_BODY = 64 * 1024;
|
|
65
|
+
|
|
66
|
+
/** The shape of `@sanoma-ai/app`'s `IngressRoute`, which `startApp`'s `ingress` takes. */
|
|
67
|
+
export interface WakeupRoute {
|
|
68
|
+
method?: "GET" | "POST" | "PUT" | "DELETE";
|
|
69
|
+
/** `/wakeup`, with every path under it (`prefix`). */
|
|
70
|
+
path: string;
|
|
71
|
+
prefix: true;
|
|
72
|
+
handle(request: Request): Promise<Response>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WakeupServiceOptions {
|
|
76
|
+
/** The service's own Postgres: its tables are made there when missing (the database too). */
|
|
77
|
+
databaseUrl: string;
|
|
78
|
+
/** The clock: what is due, and what `called_at` and `next_attempt_at` are set from. */
|
|
79
|
+
now?: () => Date;
|
|
80
|
+
/** How the instances are called. */
|
|
81
|
+
fetch?: typeof globalThis.fetch;
|
|
82
|
+
/** One `info` line per call, a `warn` per failure; the runtime's logger by default. */
|
|
83
|
+
log?: { info(line: string): void; warn(line: string): void };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export interface WakeupService {
|
|
87
|
+
/** One route answering everything under `/wakeup`, for `startApp`'s `ingress` or a server of its own. */
|
|
88
|
+
routes: WakeupRoute[];
|
|
89
|
+
/** An account that may register instances: its id, and its token, shown this once. */
|
|
90
|
+
mintAccount(label: string): Promise<{ id: string; token: string }>;
|
|
91
|
+
/** Refuses the account's token and its instances' from now on, and wakes its instances no more. */
|
|
92
|
+
revokeAccount(id: string): Promise<void>;
|
|
93
|
+
/** Ends the loop, abandoning the calls in flight (called again once their claim lapses), and closes the pool. */
|
|
94
|
+
stop(): Promise<void>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** A new token: the prefix, then 32 random bytes in base64url. */
|
|
98
|
+
const newToken = (prefix: "swa_" | "swi_") => `${prefix}${generateSecret()}`;
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A new instance token (`swi_…`), which the company mints and stores as `SANOMA_WAKEUP_TOKEN`
|
|
102
|
+
* before anything is applied, so its host is given it from the first apply; the wakeup's create
|
|
103
|
+
* then registers it with the service. The service never mints one.
|
|
104
|
+
*/
|
|
105
|
+
export const mintInstanceToken = () => newToken("swi_");
|
|
106
|
+
|
|
107
|
+
/** The secret a company keeps its instance token in: what its host's `secrets` name and its wakeup's driver reads. */
|
|
108
|
+
export const WAKEUP_TOKEN = "SANOMA_WAKEUP_TOKEN";
|
|
109
|
+
|
|
110
|
+
/** An instance token the service takes: `swi_`, then at least as many base64url characters as a minted one has. */
|
|
111
|
+
export const INSTANCE_TOKEN = /^swi_[\w-]{43,256}$/;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The token's sha256, which is all the tables keep: a token is found by its hash (a unique index),
|
|
115
|
+
* so the secret itself is never compared, and a guess's timing says nothing an attacker can steer.
|
|
116
|
+
*/
|
|
117
|
+
const hashOf = (token: string) => createHash("sha256").update(token).digest("hex");
|
|
118
|
+
|
|
119
|
+
/** A date-time as `Date.toISOString` writes one, or with an offset: four-digit years only. */
|
|
120
|
+
const ISO_DATE_TIME = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(:\d{2}(\.\d{1,9})?)?(Z|[+-]\d{2}:\d{2})$/;
|
|
121
|
+
|
|
122
|
+
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
123
|
+
|
|
124
|
+
const HEADERS = { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" };
|
|
125
|
+
|
|
126
|
+
const json = (status: number, body: unknown) => Response.json(body, { status, headers: HEADERS });
|
|
127
|
+
const problem = (status: number, error: string, headers: Record<string, string> = {}) =>
|
|
128
|
+
Response.json({ error }, { status, headers: { ...HEADERS, ...headers } });
|
|
129
|
+
/** 405, naming the methods the path answers. */
|
|
130
|
+
const notAllowed = (allow: string) => problem(405, `this path answers ${allow} only`, { allow });
|
|
131
|
+
const noContent = () => new Response(null, { status: 204, headers: { "cache-control": "no-store" } });
|
|
132
|
+
|
|
133
|
+
/** The request's bearer token, or undefined without one. */
|
|
134
|
+
function bearer(request: Request): string | undefined {
|
|
135
|
+
const match = /^Bearer (\S+)$/i.exec(request.headers.get("authorization") ?? "");
|
|
136
|
+
return match?.[1];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The request's body as text, or undefined once it holds more than `MAX_BODY` bytes. */
|
|
140
|
+
async function bodyText(request: Request): Promise<string | undefined> {
|
|
141
|
+
if (!request.body) return "";
|
|
142
|
+
const reader = request.body.getReader();
|
|
143
|
+
const chunks: Uint8Array[] = [];
|
|
144
|
+
let size = 0;
|
|
145
|
+
for (let read = await reader.read(); !read.done; read = await reader.read()) {
|
|
146
|
+
size += read.value.length;
|
|
147
|
+
if (size > MAX_BODY) {
|
|
148
|
+
await reader.cancel();
|
|
149
|
+
return undefined;
|
|
150
|
+
}
|
|
151
|
+
chunks.push(read.value);
|
|
152
|
+
}
|
|
153
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The request's JSON object, undefined when its body is not one, or a 413 for one too large. */
|
|
157
|
+
async function jsonBody(request: Request): Promise<Record<string, unknown> | undefined | Response> {
|
|
158
|
+
const text = await bodyText(request);
|
|
159
|
+
if (text === undefined) return problem(413, "the body is too large");
|
|
160
|
+
try {
|
|
161
|
+
const parsed: unknown = JSON.parse(text);
|
|
162
|
+
return typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
163
|
+
? (parsed as Record<string, unknown>)
|
|
164
|
+
: undefined;
|
|
165
|
+
} catch {
|
|
166
|
+
return undefined;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
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` is added to it).
|
|
173
|
+
*/
|
|
174
|
+
export function instanceUrl(value: unknown): { url: string } | { error: string } {
|
|
175
|
+
const refused = { error: "url must be an http(s) URL with no user, password, query or fragment" };
|
|
176
|
+
if (typeof value !== "string" || /[?#]/.test(value) || !URL.canParse(value)) return refused;
|
|
177
|
+
const url = new URL(value);
|
|
178
|
+
if ((url.protocol !== "http:" && url.protocol !== "https:") || url.username || url.password) return refused;
|
|
179
|
+
return { url: url.href.replace(/\/+$/, "") };
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
interface InstanceRow {
|
|
183
|
+
id: string;
|
|
184
|
+
account_id: string;
|
|
185
|
+
url: string;
|
|
186
|
+
at: Date | null;
|
|
187
|
+
called_at: Date | null;
|
|
188
|
+
attempts: number;
|
|
189
|
+
last_error: string | null;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** An instance as `GET` shows it to its account. */
|
|
193
|
+
const shownInstance = (row: InstanceRow) => ({
|
|
194
|
+
id: row.id,
|
|
195
|
+
url: row.url,
|
|
196
|
+
at: row.at?.toISOString() ?? null,
|
|
197
|
+
calledAt: row.called_at?.toISOString() ?? null,
|
|
198
|
+
attempts: row.attempts,
|
|
199
|
+
lastError: row.last_error,
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
/** A row waiting for its call: registered, not called since its `at`. */
|
|
203
|
+
const WAITING = "i.at IS NOT NULL AND (i.called_at IS NULL OR i.called_at < i.at)";
|
|
204
|
+
/** Of an account not revoked. */
|
|
205
|
+
const LIVE = `JOIN ${WAKEUP_ACCOUNTS_TABLE} a ON a.id = i.account_id AND a.revoked_at IS NULL`;
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Runs `use` on a connection to `databaseUrl` once the service's two tables are there: the
|
|
209
|
+
* database and the tables made when missing.
|
|
210
|
+
*/
|
|
211
|
+
async function withTables<T>(databaseUrl: string, use: (db: Db) => Promise<T>): Promise<T> {
|
|
212
|
+
const conn = await connectMaking(databaseUrl);
|
|
213
|
+
try {
|
|
214
|
+
for (const [what, ddl] of TABLES) await createTable(conn, ddl, what);
|
|
215
|
+
return await use(conn);
|
|
216
|
+
} finally {
|
|
217
|
+
await conn.end().catch(() => undefined);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function insertAccount(db: Db, label: string): Promise<{ id: string; token: string }> {
|
|
222
|
+
if (!label.trim()) throw new Error("An account's label must not be empty");
|
|
223
|
+
const id = randomUUID();
|
|
224
|
+
const token = newToken("swa_");
|
|
225
|
+
await db.query(`INSERT INTO ${WAKEUP_ACCOUNTS_TABLE} (id, token_hash, label) VALUES ($1, $2, $3)`, [
|
|
226
|
+
id,
|
|
227
|
+
hashOf(token),
|
|
228
|
+
label,
|
|
229
|
+
]);
|
|
230
|
+
return { id, token };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* An account of the wakeup service on `databaseUrl`, minted without starting the service (its
|
|
235
|
+
* tables made when missing): what `sanoma wakeup account <label>` prints.
|
|
236
|
+
*/
|
|
237
|
+
export async function mintWakeupAccount(databaseUrl: string, label: string): Promise<{ id: string; token: string }> {
|
|
238
|
+
return withTables(databaseUrl, (db) => insertAccount(db, label));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Starts the wakeup service on its own database: its two tables made when missing, its one loop
|
|
243
|
+
* running, and `routes` for whoever serves it (`sanoma wakeup`, or `sanoma serve` with
|
|
244
|
+
* `wakeup: true`). Routes, each JSON and `no-store`:
|
|
245
|
+
*
|
|
246
|
+
* - `POST /wakeup/instances { url, token }`, an account's token → 201 `{ id }` for a new instance
|
|
247
|
+
* token, 200 `{ id }` for one the account registered already, its URL now the given one (a lost
|
|
248
|
+
* reply, or a host that moved; its registered wake kept only when the URL is the same), 409 for
|
|
249
|
+
* another account's
|
|
250
|
+
* - `GET /wakeup/instances/{id}`, its account's token → `{ id, url, at, calledAt, attempts, lastError }`
|
|
251
|
+
* - `DELETE /wakeup/instances/{id}`, its account's token → 204
|
|
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
|
|
254
|
+
*
|
|
255
|
+
* 401 without a token or with an unknown one, 403 for another account's instance, 400 for a bad
|
|
256
|
+
* body, 404 for an unknown id or path.
|
|
257
|
+
*/
|
|
258
|
+
export async function startWakeupService({
|
|
259
|
+
databaseUrl,
|
|
260
|
+
now = () => new Date(),
|
|
261
|
+
fetch = globalThis.fetch,
|
|
262
|
+
log = { info, warn },
|
|
263
|
+
}: WakeupServiceOptions): Promise<WakeupService> {
|
|
264
|
+
await withTables(databaseUrl, async () => {});
|
|
265
|
+
const pool = openPool(databaseUrl, "wakeup service");
|
|
266
|
+
const loop = startLoop({ pool, now, fetch, log });
|
|
267
|
+
|
|
268
|
+
/** The live account the request's token is, or a 401. */
|
|
269
|
+
async function account(request: Request): Promise<string | Response> {
|
|
270
|
+
const token = bearer(request);
|
|
271
|
+
if (!token?.startsWith("swa_")) return problem(401, "an account's token is needed");
|
|
272
|
+
const { rows } = await pool.query<{ id: string }>(
|
|
273
|
+
`SELECT id FROM ${WAKEUP_ACCOUNTS_TABLE} WHERE token_hash = $1 AND revoked_at IS NULL`,
|
|
274
|
+
[hashOf(token)],
|
|
275
|
+
);
|
|
276
|
+
return rows[0]?.id ?? problem(401, "the token is no account's");
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** The account's instance `id`, or a 404, or a 403 when it is another account's. */
|
|
280
|
+
async function owned(accountId: string, id: string): Promise<InstanceRow | Response> {
|
|
281
|
+
if (!UUID.test(id)) return problem(404, "no such instance");
|
|
282
|
+
const { rows } = await pool.query<InstanceRow>(`SELECT * FROM ${WAKEUP_INSTANCES_TABLE} WHERE id = $1`, [id]);
|
|
283
|
+
const row = rows[0];
|
|
284
|
+
if (!row) return problem(404, "no such instance");
|
|
285
|
+
return row.account_id === accountId ? row : problem(403, "the instance is another account's");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function register(request: Request): Promise<Response> {
|
|
289
|
+
const accountId = await account(request);
|
|
290
|
+
if (accountId instanceof Response) return accountId;
|
|
291
|
+
const given = await jsonBody(request);
|
|
292
|
+
if (given instanceof Response) return given;
|
|
293
|
+
const checked = instanceUrl(given?.url);
|
|
294
|
+
if ("error" in checked) return problem(400, checked.error);
|
|
295
|
+
const token = given?.token;
|
|
296
|
+
if (typeof token !== "string" || !INSTANCE_TOKEN.test(token))
|
|
297
|
+
return problem(400, "token must be an instance's token: swi_, then 43 or more base64url characters");
|
|
298
|
+
// The token names the instance: a new one is a new row, one of this account's is moved to the
|
|
299
|
+
// URL given, and another account's is refused (no row returned).
|
|
300
|
+
const { rows } = await pool.query<{ id: string; made: boolean }>(
|
|
301
|
+
`INSERT INTO ${WAKEUP_INSTANCES_TABLE} AS i (id, account_id, url, token_hash) VALUES ($1, $2, $3, $4) ` +
|
|
302
|
+
"ON CONFLICT (token_hash) DO UPDATE SET url = EXCLUDED.url, " +
|
|
303
|
+
"at = CASE WHEN i.url = EXCLUDED.url THEN i.at END, " +
|
|
304
|
+
"called_at = CASE WHEN i.url = EXCLUDED.url THEN i.called_at END, " +
|
|
305
|
+
"attempts = CASE WHEN i.url = EXCLUDED.url THEN i.attempts ELSE 0 END, " +
|
|
306
|
+
"next_attempt_at = CASE WHEN i.url = EXCLUDED.url THEN i.next_attempt_at END, " +
|
|
307
|
+
"last_error = CASE WHEN i.url = EXCLUDED.url THEN i.last_error END " +
|
|
308
|
+
"WHERE i.account_id = EXCLUDED.account_id RETURNING i.id, i.id = $1 AS made",
|
|
309
|
+
[randomUUID(), accountId, checked.url, hashOf(token)],
|
|
310
|
+
);
|
|
311
|
+
const row = rows[0];
|
|
312
|
+
if (!row) {
|
|
313
|
+
return problem(409, "the token is another account's instance's");
|
|
314
|
+
}
|
|
315
|
+
return json(row.made ? 201 : 200, { id: row.id });
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function wake(request: Request): Promise<Response> {
|
|
319
|
+
const token = bearer(request);
|
|
320
|
+
if (!token?.startsWith("swi_")) return problem(401, "an instance's token is needed");
|
|
321
|
+
const { rows } = await pool.query<{ id: string }>(
|
|
322
|
+
`SELECT i.id FROM ${WAKEUP_INSTANCES_TABLE} i ${LIVE} WHERE i.token_hash = $1`,
|
|
323
|
+
[hashOf(token)],
|
|
324
|
+
);
|
|
325
|
+
const id = rows[0]?.id;
|
|
326
|
+
if (!id) return problem(401, "the token is no instance's");
|
|
327
|
+
const given = await jsonBody(request);
|
|
328
|
+
if (given instanceof Response) return given;
|
|
329
|
+
const at = given?.at;
|
|
330
|
+
if (typeof at !== "string" || !ISO_DATE_TIME.test(at) || Number.isNaN(Date.parse(at)))
|
|
331
|
+
return problem(400, "at must be an ISO 8601 date-time");
|
|
332
|
+
// Whatever it had is replaced: a wake that arrives for an older one starts a worker with nothing to do.
|
|
333
|
+
await pool.query(
|
|
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)],
|
|
337
|
+
);
|
|
338
|
+
loop.poke();
|
|
339
|
+
return noContent();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function handle(request: Request): Promise<Response> {
|
|
343
|
+
const path = new URL(request.url).pathname;
|
|
344
|
+
const method = request.method;
|
|
345
|
+
if (path === "/wakeup/instances") {
|
|
346
|
+
return method === "POST" ? register(request) : notAllowed("POST");
|
|
347
|
+
}
|
|
348
|
+
// Its token says which instance it is.
|
|
349
|
+
if (path === "/wakeup/wake") return method === "PUT" ? wake(request) : notAllowed("PUT");
|
|
350
|
+
const match = /^\/wakeup\/instances\/([^/]+)$/.exec(path);
|
|
351
|
+
if (!match) return problem(404, "no such route");
|
|
352
|
+
const id = match[1]!;
|
|
353
|
+
if (method !== "GET" && method !== "DELETE") return notAllowed("GET, DELETE");
|
|
354
|
+
const accountId = await account(request);
|
|
355
|
+
if (accountId instanceof Response) return accountId;
|
|
356
|
+
const row = await owned(accountId, id);
|
|
357
|
+
if (row instanceof Response) return row;
|
|
358
|
+
if (method === "GET") return json(200, shownInstance(row));
|
|
359
|
+
await pool.query(`DELETE FROM ${WAKEUP_INSTANCES_TABLE} WHERE id = $1`, [id]);
|
|
360
|
+
return noContent();
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return {
|
|
364
|
+
routes: [{ path: "/wakeup", prefix: true, handle }],
|
|
365
|
+
mintAccount: (label) => insertAccount(pool, label),
|
|
366
|
+
async revokeAccount(id) {
|
|
367
|
+
if (!UUID.test(id)) return;
|
|
368
|
+
await pool.query(`UPDATE ${WAKEUP_ACCOUNTS_TABLE} SET revoked_at = $2 WHERE id = $1 AND revoked_at IS NULL`, [
|
|
369
|
+
id,
|
|
370
|
+
now(),
|
|
371
|
+
]);
|
|
372
|
+
},
|
|
373
|
+
async stop() {
|
|
374
|
+
await loop.stop();
|
|
375
|
+
await pool.end();
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
interface Due {
|
|
381
|
+
id: string;
|
|
382
|
+
url: string;
|
|
383
|
+
at: Date;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* The one loop: calls every row that is due, at most `CONCURRENCY` at once, then sleeps until the
|
|
388
|
+
* next is (at most `MAX_SLEEP_MS`) or until `poke`. A failed call is tried again after
|
|
389
|
+
* `min(2^attempts s, MAX_BACKOFF_S)`, for as long as it takes: no row is ever dropped.
|
|
390
|
+
*/
|
|
391
|
+
function startLoop({
|
|
392
|
+
pool,
|
|
393
|
+
now,
|
|
394
|
+
fetch,
|
|
395
|
+
log,
|
|
396
|
+
}: {
|
|
397
|
+
pool: ReturnType<typeof openPool>;
|
|
398
|
+
now: () => Date;
|
|
399
|
+
fetch: typeof globalThis.fetch;
|
|
400
|
+
log: { info(line: string): void; warn(line: string): void };
|
|
401
|
+
}) {
|
|
402
|
+
const stopping = new AbortController();
|
|
403
|
+
let poked = false;
|
|
404
|
+
let wakeUp: (() => void) | undefined;
|
|
405
|
+
|
|
406
|
+
/** Calls `row`'s instance once, and records how it went, unless the registration changed meanwhile. */
|
|
407
|
+
async function call(row: Due) {
|
|
408
|
+
const target = `${row.url}/wake`;
|
|
409
|
+
let error: string | undefined;
|
|
410
|
+
try {
|
|
411
|
+
const signal = AbortSignal.any([stopping.signal, AbortSignal.timeout(CALL_TIMEOUT_MS)]);
|
|
412
|
+
// A redirect is no answer: it could send the service anywhere, so it is tried again as a failure.
|
|
413
|
+
const res = await fetch(target, { signal, redirect: "manual" });
|
|
414
|
+
await res.body?.cancel().catch(() => undefined);
|
|
415
|
+
if (!res.ok) error = `answered ${res.status}`;
|
|
416
|
+
} catch (err) {
|
|
417
|
+
error = errorMessage(err);
|
|
418
|
+
}
|
|
419
|
+
// Stopped mid-call: nothing is known of it, so it is called again once its claim lapses.
|
|
420
|
+
if (stopping.signal.aborted) return;
|
|
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";
|
|
424
|
+
if (error === undefined) {
|
|
425
|
+
await pool.query(
|
|
426
|
+
`UPDATE ${WAKEUP_INSTANCES_TABLE} SET called_at = $4, attempts = 0, next_attempt_at = NULL, ` +
|
|
427
|
+
`last_error = NULL ${same}`,
|
|
428
|
+
[row.id, row.at, row.url, now()],
|
|
429
|
+
);
|
|
430
|
+
log.info(`wakeup: called ${target}`);
|
|
431
|
+
} else {
|
|
432
|
+
await pool.query(
|
|
433
|
+
`UPDATE ${WAKEUP_INSTANCES_TABLE} SET attempts = attempts + 1, last_error = $4, ` +
|
|
434
|
+
`next_attempt_at = $5::timestamptz + least(power(2, attempts), ${MAX_BACKOFF_S}) * interval '1 second' ` +
|
|
435
|
+
same,
|
|
436
|
+
[row.id, row.at, row.url, error, now()],
|
|
437
|
+
);
|
|
438
|
+
log.warn(`wakeup: calling ${target} failed (${error}); it is tried again`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/** Calls every due row; returns when the next one is due, or undefined with none waiting. */
|
|
443
|
+
async function round(): Promise<Date | undefined> {
|
|
444
|
+
// Taken for `CLAIM_S`, rows another service holds skipped: each due row is called by one service.
|
|
445
|
+
const { rows } = await pool.query<Due>(
|
|
446
|
+
`UPDATE ${WAKEUP_INSTANCES_TABLE} SET next_attempt_at = $1::timestamptz + interval '${CLAIM_S} seconds' ` +
|
|
447
|
+
`WHERE id IN (SELECT i.id FROM ${WAKEUP_INSTANCES_TABLE} i ${LIVE} WHERE ${WAITING} ` +
|
|
448
|
+
"AND i.at <= $1 AND (i.next_attempt_at IS NULL OR i.next_attempt_at <= $1) " +
|
|
449
|
+
`ORDER BY i.at LIMIT ${ROUND_ROWS} FOR UPDATE OF i SKIP LOCKED) RETURNING id, url, at`,
|
|
450
|
+
[now()],
|
|
451
|
+
);
|
|
452
|
+
const queue = [...rows];
|
|
453
|
+
const worker = async () => {
|
|
454
|
+
for (let row = queue.shift(); row && !stopping.signal.aborted; row = queue.shift()) {
|
|
455
|
+
await call(row).catch((err: unknown) => log.warn(`wakeup: could not record a call: ${errorMessage(err)}`));
|
|
456
|
+
}
|
|
457
|
+
};
|
|
458
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, queue.length) }, worker));
|
|
459
|
+
const next = await pool.query<{ next: Date | null }>(
|
|
460
|
+
`SELECT min(greatest(i.at, coalesce(i.next_attempt_at, i.at))) AS next FROM ${WAKEUP_INSTANCES_TABLE} i ` +
|
|
461
|
+
`${LIVE} WHERE ${WAITING}`,
|
|
462
|
+
);
|
|
463
|
+
return next.rows[0]?.next ?? undefined;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const sleep = (ms: number) =>
|
|
467
|
+
new Promise<void>((resolve) => {
|
|
468
|
+
const timer = setTimeout(resolve, ms);
|
|
469
|
+
wakeUp = () => {
|
|
470
|
+
clearTimeout(timer);
|
|
471
|
+
resolve();
|
|
472
|
+
};
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
const running = (async () => {
|
|
476
|
+
while (!stopping.signal.aborted) {
|
|
477
|
+
poked = false;
|
|
478
|
+
let ms = MAX_SLEEP_MS;
|
|
479
|
+
try {
|
|
480
|
+
const next = await round();
|
|
481
|
+
if (next) ms = Math.min(Math.max(next.getTime() - now().getTime(), 0), MAX_SLEEP_MS);
|
|
482
|
+
} catch (err) {
|
|
483
|
+
if (stopping.signal.aborted) break;
|
|
484
|
+
log.warn(`wakeup: the loop could not read its instances: ${errorMessage(err)}`);
|
|
485
|
+
ms = 5_000;
|
|
486
|
+
}
|
|
487
|
+
// A poke during the round may have come after its last read: look again at once.
|
|
488
|
+
if (!poked && !stopping.signal.aborted) await sleep(ms);
|
|
489
|
+
}
|
|
490
|
+
})();
|
|
491
|
+
|
|
492
|
+
return {
|
|
493
|
+
/** Wakes the loop now, or right after the round in progress. */
|
|
494
|
+
poke() {
|
|
495
|
+
poked = true;
|
|
496
|
+
wakeUp?.();
|
|
497
|
+
},
|
|
498
|
+
async stop() {
|
|
499
|
+
stopping.abort();
|
|
500
|
+
wakeUp?.();
|
|
501
|
+
await running;
|
|
502
|
+
},
|
|
503
|
+
};
|
|
504
|
+
}
|
|
@@ -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>;
|