a2a-exposed 0.4.1 → 0.6.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 (47) hide show
  1. package/README.md +3 -4
  2. package/lib/a2a.mjs +6 -2
  3. package/lib/commands.mjs +62 -12
  4. package/lib/deploy.mjs +42 -19
  5. package/lib/main.mjs +32 -11
  6. package/lib/pair.mjs +47 -3
  7. package/lib/status.mjs +4 -4
  8. package/lib/tunnel.mjs +11 -11
  9. package/lib/update.mjs +1 -1
  10. package/package.json +1 -1
  11. package/worker/cloudflare.config.ts +51 -7
  12. package/worker/deploy.env.example +28 -1
  13. package/worker/migrations/0006_mcp.sql +43 -0
  14. package/worker/migrations/0007_cimd.sql +10 -0
  15. package/worker/migrations/0008_oidc_txns.sql +12 -0
  16. package/worker/package.json +6 -2
  17. package/worker/src/approval-oidc.ts +247 -0
  18. package/worker/src/backup.ts +186 -0
  19. package/worker/src/directory.ts +120 -0
  20. package/worker/src/export.ts +156 -0
  21. package/worker/src/hosted.ts +403 -0
  22. package/worker/src/index.ts +1327 -446
  23. package/worker/src/mcp.ts +336 -0
  24. package/worker/src/migrations.ts +22 -0
  25. package/worker/src/pairing.ts +18 -5
  26. package/worker/src/sql-raw.d.ts +5 -0
  27. package/worker/src/storage.ts +100 -0
  28. package/worker/src/tenancy.ts +450 -0
  29. package/worker/src/wake.ts +14 -2
  30. package/worker/test/alarms.test.ts +231 -0
  31. package/worker/test/approval-oidc.test.ts +331 -0
  32. package/worker/test/bridge-thread.mjs +40 -0
  33. package/worker/test/d1.ts +146 -2
  34. package/worker/test/directory.test.ts +90 -0
  35. package/worker/test/export.test.ts +544 -0
  36. package/worker/test/facade.test.ts +1 -1
  37. package/worker/test/harness-worker.ts +80 -0
  38. package/worker/test/hosted.test.ts +221 -0
  39. package/worker/test/mcp.test.ts +697 -0
  40. package/worker/test/migrations.test.ts +5 -5
  41. package/worker/test/pairing.test.ts +20 -7
  42. package/worker/test/register-hooks.mjs +4 -0
  43. package/worker/test/sql-raw-hook.mjs +22 -0
  44. package/worker/test/storage.test.ts +74 -0
  45. package/worker/test/tenancy.test.ts +217 -0
  46. package/worker/test/wake-retry.test.ts +74 -0
  47. package/worker/test/wake.test.ts +18 -0
@@ -0,0 +1,403 @@
1
+ // Hosted entrypoint. Used only when the deploy sets TENANCY=host (see cloudflare.config.ts).
2
+ // With that set and a TENANT_DO binding, `<name>.<TENANT_DOMAIN>` is looked up in the KV directory and forwarded
3
+ // to that tenant's SQLite Durable Object. The object runs the same inbox as self-host (`dispatch` in index.ts)
4
+ // against its own database. Anything else — TENANCY unset, or no TENANT_DO binding — calls the self-host fetch
5
+ // handler on env.DB.
6
+ //
7
+ // Tenant config is a versioned row pushed into the object (`pushConfig`). It does not travel on the request.
8
+ // An object with no row answers 404 and does not create application tables.
9
+ //
10
+ // Hosted wake debounce and the cron's housekeeping run from this object's alarm. The Worker minute cron stays a no-op
11
+ // while TENANCY=host, so there is no shared database to flush. Self-host still uses its minute cron.
12
+ // The daily backup cron runs only when BACKUP_BUCKET is bound, and it returns before any flush.
13
+ // Usage push is not in this change. Approval OIDC, when configured (self-host settings, or the pushed tenant
14
+ // `approval` object), uses the same pages as self-host.
15
+ import { DurableObject } from "cloudflare:workers";
16
+ import worker, { cronFlush, dispatch, nextCronAt, onWakeWrite } from "./index.ts";
17
+ import { doSqlD1, migrateDo, type SqlStorageLike, type TxRunner } from "./storage.ts";
18
+ import { MIGRATIONS } from "./migrations.ts";
19
+ import { bookmarkForTime as readBookmark, exportRows, restoreBookmark as applyBookmark, restartAfterResult, toSql, ExportError, type BookmarkStorage } from "./export.ts";
20
+ import { hostedDailyBackup } from "./backup.ts";
21
+ import { lookupDirectory, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME, type DirectoryEntry } from "./directory.ts";
22
+ import { hostedTenantContext, namespaceForRegion, parseGates, type WorkerBindings } from "./tenancy.ts";
23
+
24
+ export { clearDirectoryCache, directoryKey, lookupDirectory, parseDirectoryEntry, pinDirectoryRegion, tenantNameFromHost, tenantRegion, TENANT_NAME } from "./directory.ts";
25
+ export type { DirectoryEntry };
26
+
27
+ const JSON_HDR = { "content-type": "application/json", "cache-control": "no-store" };
28
+ const notFound = () => Response.json({ error: "no such agent" }, { status: 404, headers: JSON_HDR });
29
+ const unavailable = () => Response.json({ error: "directory unavailable" }, { status: 503, headers: JSON_HDR });
30
+ const suspended = () => Response.json({ error: "this agent is suspended" }, { status: 403, headers: JSON_HDR });
31
+ const gone = () => Response.json({ error: "this agent is gone" }, { status: 410, headers: JSON_HDR });
32
+
33
+ const TENANT_ID = /^[A-Za-z0-9._:-]{1,128}$/;
34
+
35
+ /** Versioned config pushed by the control plane. `secretsEnc` is stored and not decrypted here. */
36
+ export interface TenantConfigPush {
37
+ version: number;
38
+ tenantId: string;
39
+ name: string;
40
+ status: string;
41
+ config?: Record<string, unknown>;
42
+ limits?: Record<string, unknown>;
43
+ approval?: Record<string, unknown>;
44
+ ownerTokenHash?: string | null;
45
+ secretsEnc?: string | null;
46
+ }
47
+
48
+ interface StoredConfig {
49
+ version: number;
50
+ tenantId: string;
51
+ name: string;
52
+ status: string;
53
+ config: Record<string, string | undefined>;
54
+ limits: Record<string, unknown>;
55
+ approval: Record<string, unknown>;
56
+ ownerTokenHash: string | null;
57
+ secretsEnc: string | null;
58
+ }
59
+
60
+ const CREATE_CONFIG = `CREATE TABLE IF NOT EXISTS tenant_config (
61
+ id INTEGER PRIMARY KEY CHECK (id = 1),
62
+ version INTEGER NOT NULL,
63
+ tenant_id TEXT NOT NULL,
64
+ name TEXT NOT NULL,
65
+ status TEXT NOT NULL,
66
+ config_json TEXT NOT NULL,
67
+ limits_json TEXT NOT NULL,
68
+ approval_json TEXT NOT NULL,
69
+ owner_token_hash TEXT,
70
+ secrets_enc TEXT,
71
+ updated_at TEXT NOT NULL
72
+ )`;
73
+
74
+ function readRow(sql: SqlStorageLike): StoredConfig | null {
75
+ const exists = sql.exec("SELECT 1 AS n FROM sqlite_master WHERE type = 'table' AND name = 'tenant_config'").toArray();
76
+ if (!exists.length) return null;
77
+ const rows = sql.exec("SELECT version, tenant_id, name, status, config_json, limits_json, approval_json, owner_token_hash, secrets_enc FROM tenant_config WHERE id = 1").toArray();
78
+ const r = rows[0];
79
+ if (!r) return null;
80
+ return {
81
+ version: Number(r.version),
82
+ tenantId: String(r.tenant_id),
83
+ name: String(r.name),
84
+ status: String(r.status),
85
+ config: stringRecord(JSON.parse(String(r.config_json || "{}"))),
86
+ limits: objectRecord(JSON.parse(String(r.limits_json || "{}"))),
87
+ approval: objectRecord(JSON.parse(String(r.approval_json || "{}"))),
88
+ ownerTokenHash: r.owner_token_hash == null ? null : String(r.owner_token_hash),
89
+ secretsEnc: r.secrets_enc == null ? null : String(r.secrets_enc),
90
+ };
91
+ }
92
+
93
+ function stringRecord(v: unknown): Record<string, string | undefined> {
94
+ const out: Record<string, string | undefined> = {};
95
+ if (!v || typeof v !== "object" || Array.isArray(v)) return out;
96
+ for (const [k, val] of Object.entries(v)) if (typeof val === "string") out[k] = val;
97
+ return out;
98
+ }
99
+
100
+ function objectRecord(v: unknown): Record<string, unknown> {
101
+ if (!v || typeof v !== "object" || Array.isArray(v)) return {};
102
+ return v as Record<string, unknown>;
103
+ }
104
+
105
+ function requireObject(v: unknown, what: string): Record<string, unknown> {
106
+ if (v == null) return {};
107
+ if (typeof v !== "object" || Array.isArray(v)) throw new Error(`pushConfig: ${what} must be an object`);
108
+ return v as Record<string, unknown>;
109
+ }
110
+
111
+ function checkPush(body: TenantConfigPush): void {
112
+ if (!body || typeof body !== "object") throw new Error("pushConfig: body is required");
113
+ if (typeof body.version !== "number" || !Number.isSafeInteger(body.version) || body.version < 1) throw new Error("pushConfig: version must be an integer >= 1");
114
+ if (typeof body.tenantId !== "string" || !TENANT_ID.test(body.tenantId)) throw new Error("pushConfig: tenantId is invalid");
115
+ if (typeof body.name !== "string" || !TENANT_NAME.test(body.name)) throw new Error("pushConfig: name is invalid");
116
+ if (typeof body.status !== "string" || !/^[a-z]{1,32}$/.test(body.status)) throw new Error("pushConfig: status is invalid");
117
+ if (body.ownerTokenHash != null && body.ownerTokenHash !== "" && !/^[0-9a-fA-F]{64}$/.test(body.ownerTokenHash)) throw new Error("pushConfig: ownerTokenHash must be 64 hex characters");
118
+ if (body.secretsEnc != null && typeof body.secretsEnc !== "string") throw new Error("pushConfig: secretsEnc must be a string");
119
+ }
120
+
121
+ /** Both are required. TENANCY=host alone, or a binding without the flag, stays on env.DB. */
122
+ function hostedOn(env: WorkerBindings): env is WorkerBindings & { TENANT_DO: NonNullable<WorkerBindings["TENANT_DO"]> } {
123
+ return env.TENANCY === "host" && !!env.TENANT_DO;
124
+ }
125
+
126
+ function appTables(sql: SqlStorageLike): string[] {
127
+ return sql.exec("SELECT name FROM sqlite_master WHERE type = 'table'").toArray()
128
+ .map((r) => String(r.name))
129
+ .filter((n) => !n.startsWith("sqlite_") && !n.startsWith("_cf_"))
130
+ .sort();
131
+ }
132
+
133
+ export class TenantStore extends DurableObject<WorkerBindings> {
134
+ #sql: SqlStorageLike;
135
+ #tx: TxRunner;
136
+ #db: ReturnType<typeof doSqlD1>;
137
+ #row: StoredConfig | null = null;
138
+
139
+ constructor(ctx: DurableObjectState, env: WorkerBindings) {
140
+ super(ctx, env);
141
+ const storage = ctx.storage as unknown as { sql: SqlStorageLike; transactionSync: TxRunner };
142
+ this.#sql = storage.sql;
143
+ // Call through `storage` so `this` stays bound. An extracted method throws Illegal invocation.
144
+ this.#tx = (fn) => storage.transactionSync(fn);
145
+ this.#db = doSqlD1(this.#sql, this.#tx);
146
+ // Read only. Migrations run once a config row exists, so a scan that instantiates an object writes nothing.
147
+ ctx.blockConcurrencyWhile(async () => {
148
+ this.#row = readRow(this.#sql);
149
+ if (this.#row) migrateDo(this.#sql, this.#tx, MIGRATIONS);
150
+ if (this.#row?.status === "active") await this.#enqueueArm(false);
151
+ });
152
+ }
153
+
154
+ #tenant(fallbackOrigin = "") {
155
+ const row = this.#row;
156
+ if (!row) return null;
157
+ const domain = parseGates(this.env).gates.tenantDomain;
158
+ const publicUrl = domain ? `https://${row.name}.${domain}` : (fallbackOrigin || this.env.PUBLIC_URL || "");
159
+ return hostedTenantContext(this.env, {
160
+ id: row.tenantId, db: this.#db, publicUrl, ownerTokenHash: row.ownerTokenHash,
161
+ config: row.config, limits: row.limits, approval: row.approval, status: row.status,
162
+ });
163
+ }
164
+
165
+ #arming: Promise<void> = Promise.resolve();
166
+
167
+ /** Run alarm updates one at a time, so an earlier read cannot setAlarm over a retry row written later. */
168
+ #enqueueArm(afterRun: boolean): Promise<void> {
169
+ const run = this.#arming.then(() => this.#arm(afterRun), () => this.#arm(afterRun));
170
+ this.#arming = run.then(() => undefined, () => undefined);
171
+ return run;
172
+ }
173
+
174
+ #ectx(background?: Promise<unknown>[]): ExecutionContext {
175
+ const ectx = {
176
+ waitUntil: (p: Promise<unknown>) => {
177
+ const settled = Promise.resolve(p).then(() => undefined, () => undefined);
178
+ if (background) background.push(settled);
179
+ // Arm when the task finishes, not when it was scheduled. sendWake inserts the retry before it resolves.
180
+ this.ctx.waitUntil(settled.then(() => this.#enqueueArm(false)));
181
+ },
182
+ passThroughOnException() {},
183
+ } as ExecutionContext;
184
+ // requeueWake calls this in the same turn as the insert, including after the inbox response has returned.
185
+ onWakeWrite(ectx, () => this.#enqueueArm(false));
186
+ return ectx;
187
+ }
188
+
189
+ /** One alarm per object. `afterRun` waits a second when work is still due, so a full batch cannot spin. */
190
+ async #arm(afterRun: boolean): Promise<void> {
191
+ const storage = this.ctx.storage as unknown as {
192
+ setAlarm(scheduledTime: number): Promise<void>;
193
+ deleteAlarm(): Promise<void>;
194
+ };
195
+ const row = this.#row;
196
+ try {
197
+ if (!row || row.status !== "active") {
198
+ await storage.deleteAlarm();
199
+ return;
200
+ }
201
+ const tenant = this.#tenant();
202
+ if (!tenant) return;
203
+ const at = await nextCronAt(tenant);
204
+ if (at == null || !Number.isFinite(at)) {
205
+ await storage.deleteAlarm();
206
+ return;
207
+ }
208
+ const now = Date.now();
209
+ const when = afterRun && at <= now ? now + 1000 : Math.max(at, now);
210
+ await storage.setAlarm(when);
211
+ } catch (e) {
212
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_arm_failed", error: String(e).slice(0, 200) }));
213
+ }
214
+ }
215
+
216
+ /** Same flush and deletes as the self-host minute cron, for this tenant only. */
217
+ async alarm(): Promise<void> {
218
+ await this.#runAlarm();
219
+ }
220
+
221
+ /**
222
+ * Run the alarm body now. `alarm` itself is reserved and cannot be called over RPC, so tests use this.
223
+ */
224
+ async runAlarm(): Promise<void> {
225
+ await this.#runAlarm();
226
+ }
227
+
228
+ async #runAlarm(): Promise<void> {
229
+ const tenant = this.#row?.status === "active" ? this.#tenant() : null;
230
+ if (tenant) {
231
+ try { await cronFlush(tenant, this.#ectx()); }
232
+ catch (e) { console.log(JSON.stringify({ ts: new Date().toISOString(), event: "alarm_failed", error: String(e).slice(0, 200) })); }
233
+ }
234
+ await this.#enqueueArm(true);
235
+ }
236
+
237
+ /** When the alarm is set to run, or null. No secrets. */
238
+ async alarmAt(): Promise<number | null> {
239
+ const storage = this.ctx.storage as unknown as { getAlarm(): Promise<number | null> };
240
+ try {
241
+ const at = await storage.getAlarm();
242
+ return typeof at === "number" && Number.isFinite(at) ? at : null;
243
+ } catch {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ /**
249
+ * Control-plane push. A version less than or equal to the stored one is ignored, so a reordered or repeated
250
+ * push does not roll the row backwards. The first accepted push creates `tenant_config` and then runs migrations.
251
+ */
252
+ async pushConfig(body: TenantConfigPush): Promise<{ version: number; applied: boolean }> {
253
+ return await this.ctx.blockConcurrencyWhile(async () => {
254
+ checkPush(body);
255
+ const current = this.#row ?? readRow(this.#sql);
256
+ if (current && body.version <= current.version) {
257
+ // A previous push may have stored this version and then failed while migrating. Catch up; don't rewrite the row.
258
+ migrateDo(this.#sql, this.#tx, MIGRATIONS);
259
+ this.#row = current;
260
+ await this.#enqueueArm(false);
261
+ return { version: current.version, applied: false };
262
+ }
263
+ const config = requireObject(body.config, "config");
264
+ const limits = requireObject(body.limits, "limits");
265
+ const approval = requireObject(body.approval, "approval");
266
+ const next: StoredConfig = {
267
+ version: body.version,
268
+ tenantId: body.tenantId,
269
+ name: body.name,
270
+ status: body.status,
271
+ config: stringRecord(config),
272
+ limits,
273
+ approval,
274
+ ownerTokenHash: body.ownerTokenHash ? body.ownerTokenHash.toLowerCase() : null,
275
+ secretsEnc: body.secretsEnc ?? null,
276
+ };
277
+ const now = new Date().toISOString();
278
+ this.#tx(() => {
279
+ this.#sql.exec(CREATE_CONFIG);
280
+ this.#sql.exec(
281
+ `INSERT INTO tenant_config (id, version, tenant_id, name, status, config_json, limits_json, approval_json, owner_token_hash, secrets_enc, updated_at)
282
+ VALUES (1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
283
+ ON CONFLICT(id) DO UPDATE SET
284
+ version = excluded.version, tenant_id = excluded.tenant_id, name = excluded.name, status = excluded.status,
285
+ config_json = excluded.config_json, limits_json = excluded.limits_json, approval_json = excluded.approval_json,
286
+ owner_token_hash = excluded.owner_token_hash, secrets_enc = excluded.secrets_enc, updated_at = excluded.updated_at`,
287
+ next.version, next.tenantId, next.name, next.status,
288
+ JSON.stringify(config), JSON.stringify(limits), JSON.stringify(approval),
289
+ next.ownerTokenHash, next.secretsEnc, now,
290
+ );
291
+ });
292
+ try {
293
+ migrateDo(this.#sql, this.#tx, MIGRATIONS);
294
+ } finally {
295
+ // Remember the row even if a migration throws, so a later request is not stuck on the empty-object 404.
296
+ this.#row = readRow(this.#sql) ?? next;
297
+ }
298
+ await this.#enqueueArm(false);
299
+ return { version: this.#row.version, applied: true };
300
+ });
301
+ }
302
+
303
+ /** No secrets. Tests use this to show a 404 did not create application tables. */
304
+ async storageStatus(): Promise<{ configured: boolean; version: number; tables: string[] }> {
305
+ return { configured: this.#row !== null, version: this.#row?.version ?? 0, tables: appTables(this.#sql) };
306
+ }
307
+
308
+ /** SQL dump of the application tables. Refuses an object that has no config row. */
309
+ async exportSql(): Promise<{ sql: string; exportedAt: string }> {
310
+ return await this.ctx.blockConcurrencyWhile(async () => {
311
+ if (!this.#row) throw new ExportError("exportSql: tenant is not configured");
312
+ const file = await exportRows(this.#db);
313
+ return { sql: toSql(file), exportedAt: file.exportedAt };
314
+ });
315
+ }
316
+
317
+ /** Point-in-time bookmark for this object only. The storage method is required; otherwise this throws. */
318
+ async bookmarkForTime(timeMs: number): Promise<{ bookmark: string }> {
319
+ if (!this.#row) throw new ExportError("bookmarkForTime: tenant is not configured");
320
+ return readBookmark(this.ctx.storage as BookmarkStorage, timeMs);
321
+ }
322
+
323
+ /**
324
+ * Schedule `bookmark` and return `{ ok: true }`. The reset is queued for a later turn: `ctx.abort()` here would
325
+ * fail the RPC before the caller saw that result. An in-flight alarm was for the pre-restore database, so it is not retried.
326
+ */
327
+ async restoreBookmark(bookmark: string): Promise<{ ok: true; undo?: string }> {
328
+ if (!this.#row) throw new ExportError("restoreBookmark: tenant is not configured");
329
+ const tenantId = this.#row.tenantId;
330
+ const out = await applyBookmark(this.ctx.storage as BookmarkStorage, bookmark);
331
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "bookmark_restore", tenant: tenantId, undo: out.undo ?? null }));
332
+ restartAfterResult(
333
+ (p) => this.ctx.waitUntil(p),
334
+ () => this.ctx.abort("restore bookmark", { retryAlarm: false }),
335
+ (ms) => scheduler.wait(ms),
336
+ );
337
+ return out;
338
+ }
339
+
340
+ async fetch(req: Request): Promise<Response> {
341
+ const row = this.#row;
342
+ if (!row) return notFound();
343
+ if (row.status !== "active") {
344
+ await this.#enqueueArm(false);
345
+ if (row.status === "suspended") return suspended();
346
+ if (row.status === "deleting") return gone();
347
+ return notFound();
348
+ }
349
+ const tenant = this.#tenant(new URL(req.url).origin);
350
+ if (!tenant) return notFound();
351
+ // Do not arm from the rows visible here. sendWake runs in waitUntil and, on 429/503, inserts the retry
352
+ // after this response returns. requeueWake arms in that insert. Arm again when those tasks finish so a
353
+ // quiet tenant is on Retry-After, not the rate-row timer from an earlier read.
354
+ const background: Promise<unknown>[] = [];
355
+ const res = await dispatch(req, tenant, this.#ectx(background));
356
+ if (background.length) {
357
+ const pending = background.slice();
358
+ this.ctx.waitUntil(Promise.allSettled(pending).then(() => this.#enqueueArm(false)));
359
+ }
360
+ return res;
361
+ }
362
+ }
363
+
364
+ function routeStatus(entry: DirectoryEntry): Response | null {
365
+ if (entry.status === "deleted") return notFound();
366
+ if (entry.status === "suspended") return suspended();
367
+ if (entry.status === "deleting") return gone();
368
+ if (entry.status !== "active") return notFound();
369
+ return null;
370
+ }
371
+
372
+ export default {
373
+ async fetch(req: Request<unknown, IncomingRequestCfProperties>, env: WorkerBindings, ectx: ExecutionContext): Promise<Response> {
374
+ if (!hostedOn(env)) return worker.fetch(req, env, ectx);
375
+ const domain = parseGates(env).gates.tenantDomain;
376
+ const name = tenantNameFromHost(new URL(req.url).hostname, domain);
377
+ if (!name || !env.TENANT_DIRECTORY) return notFound();
378
+ const entry = await lookupDirectory(env.TENANT_DIRECTORY, name);
379
+ if (!entry) return notFound();
380
+ // Pin once, including for a suspended or deleted row, so a later DATA_REGION change cannot move the object.
381
+ const pinned = await pinDirectoryRegion(env.TENANT_DIRECTORY, name, entry, parseGates(env).gates.dataRegion);
382
+ if (!pinned) return unavailable();
383
+ const early = routeStatus(pinned);
384
+ if (early) return early;
385
+ const region = tenantRegion(pinned);
386
+ const ns = namespaceForRegion(env.TENANT_DO, region);
387
+ // The spike carried the tenant row on this header. Drop it so a client cannot supply config.
388
+ const headers = new Headers(req.headers);
389
+ headers.delete("x-a2a-tenant");
390
+ return ns.get(ns.idFromName(entry.id)).fetch(new Request(req, { headers }));
391
+ },
392
+ // Self-host cron flushes env.DB. Hosted has no single database; each tenant's alarm runs that flush.
393
+ // The daily backup cron (only present when BACKUP_BUCKET is bound) exports each active tenant and returns.
394
+ async scheduled(controller: ScheduledController, env: WorkerBindings, ectx: ExecutionContext): Promise<void> {
395
+ const backup = await hostedDailyBackup(controller, env);
396
+ if (backup) {
397
+ for (const f of backup.failed) console.log(JSON.stringify({ ts: new Date().toISOString(), event: "backup_failed", tenant: f.tenantId, error: f.error }));
398
+ console.log(JSON.stringify({ ts: new Date().toISOString(), event: "backup_done", written: backup.written.length, skipped: backup.skipped.length, failed: backup.failed.length }));
399
+ return;
400
+ }
401
+ if (!hostedOn(env)) await worker.scheduled(controller, env, ectx);
402
+ },
403
+ };