breakaway 2.0.0-main.98 → 2.0.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.
@@ -13,15 +13,24 @@
13
13
  * (src/infra-cloudflare-analytics.js, one query per dataset for the whole environment), times PRICES, a price table kept
14
14
  * as data with where and when it was read. Plan and apply (BRK-192) are in infra-cloudflare-plan.js.
15
15
  *
16
- * Observe (BRK-191) reads each resource's health from the same analytics over the last few minutes, plus a queue's
17
- * backlog and a container application's instance counts; a route or custom domain takes its Worker's health. Events
16
+ * Observe (BRK-191, BRK-266) reads each resource's health from the same analytics over the last few minutes, allowing
17
+ * for their lag and looking further back for a quiet resource before it calls it idle, plus a queue's backlog and a
18
+ * container application's instance counts; a route or custom domain takes its Worker's health. Events
18
19
  * (BRK-191) reads the account's alert history and reports each alert as a signal on the Worker it names, the same way
19
20
  * the board's alert webhook does (`alertFields`), and `alertSetup` reads which alerts are set up and which reach the
20
21
  * board. Alerts need Notifications Read; the token never has Notifications Write. Pure apart from `fetch`, so the CLI
21
22
  * can import it.
22
23
  */
23
- import { COST_DATASETS, HEALTH_DATASETS, HEALTH_WINDOW_MINUTES, readDataset } from './infra-cloudflare-analytics.js';
24
+ import {
25
+ COST_DATASETS,
26
+ HEALTH_DATASETS,
27
+ HEALTH_LAG_MINUTES,
28
+ HEALTH_WINDOWS,
29
+ HEALTH_WINDOW_MINUTES,
30
+ readDataset,
31
+ } from './infra-cloudflare-analytics.js';
24
32
  import { apply, estimate, plan } from './infra-cloudflare-plan.js';
33
+ import { tokenTemplate, WRITE_SECRET, writePermissions } from './infra-cloudflare-tokens.js';
25
34
 
26
35
  /** @typedef {import('./infra-provider.js').Provider} Provider */
27
36
  /** @typedef {import('./infra-provider.js').ProviderContext} ProviderContext */
@@ -71,22 +80,44 @@ export const CLOUDFLARE_KINDS = Object.fromEntries(
71
80
  ? { changes: [...BASE, ...ENVELOPE[k].changes], scales: ENVELOPE[k].scales, settings: [...settings] }
72
81
  : { changes: [...BASE], settings: [...settings] }),
73
82
  ...(ACCESS.includes(k) ? { access: true } : {}),
83
+ // A Worker is what an environment's discovery starts from (BRK-291).
84
+ ...(k === 'worker' ? { target: true } : {}),
74
85
  },
75
86
  ]),
76
87
  );
77
88
 
78
89
  /**
79
- * How a Worker's binding names the resource it binds, for the console (BRK-262): by the resource's ID in the desired
80
- * state, which the plan resolves, or a Worker by its name.
90
+ * How a Worker's binding names the resource it binds, for the console (BRK-262): in Cloudflare's own field, the one
91
+ * the plan compares with what runs (BINDING_TARGETS; BRK-285), by the resource's ID on Cloudflare (a D1 database, a KV
92
+ * namespace) or its name (a bucket, a queue, a Worker). A database or namespace the plan still makes has no ID yet, so
93
+ * a binding names it by its ID in the desired state, in `resource`, until it runs.
81
94
  * @type {import('./infra-provider.js').BindingTarget[]}
82
95
  */
83
96
  const BINDABLE = [
84
- { type: 'd1', label: 'D1 database', kind: 'd1', field: 'resource', by: 'id' },
85
- { type: 'kv_namespace', label: 'KV namespace', kind: 'kv', field: 'resource', by: 'id' },
86
- { type: 'r2_bucket', label: 'R2 bucket', kind: 'r2', field: 'resource', by: 'id' },
87
- { type: 'queue', label: 'Queue', kind: 'queue', field: 'resource', by: 'id' },
97
+ { type: 'd1', label: 'D1 database', kind: 'd1', field: 'id', by: 'id' },
98
+ { type: 'kv_namespace', label: 'KV namespace', kind: 'kv', field: 'namespace_id', by: 'id' },
99
+ { type: 'r2_bucket', label: 'R2 bucket', kind: 'r2', field: 'bucket_name', by: 'name' },
100
+ { type: 'queue', label: 'Queue', kind: 'queue', field: 'queue_name', by: 'name' },
88
101
  { type: 'service', label: 'Worker', kind: 'worker', field: 'service', by: 'name' },
89
102
  ];
103
+ /** The binding types BINDABLE doesn't list, in words, for a change's lines (WEB-110). */
104
+ const BINDING_WORDS = {
105
+ durable_object_namespace: 'Durable Object',
106
+ assets: 'static assets',
107
+ plain_text: 'variable',
108
+ secret_text: 'secret',
109
+ json: 'variable',
110
+ analytics_engine: 'Analytics Engine',
111
+ ai: 'Workers AI',
112
+ browser: 'Browser Rendering',
113
+ hyperdrive: 'Hyperdrive',
114
+ vectorize: 'Vectorize index',
115
+ version_metadata: 'version metadata',
116
+ workflow: 'Workflow',
117
+ send_email: 'email',
118
+ mtls_certificate: 'mTLS certificate',
119
+ dispatch_namespace: 'dispatch namespace',
120
+ };
90
121
  const CRON = '^\\S+( \\S+){4}$';
91
122
  const VALUES_ELSEWHERE = 'Set with the Worker’s deploy, never here.';
92
123
 
@@ -151,6 +182,7 @@ export const EDITABLE = {
151
182
  label: 'Bindings',
152
183
  type: 'bindings',
153
184
  targets: BINDABLE,
185
+ typeLabels: BINDING_WORDS,
154
186
  help: 'The environment’s databases, namespaces, buckets, queues, and Workers it reaches, each by a name its code uses. Variables and secrets are kept as they are.',
155
187
  },
156
188
  ],
@@ -316,6 +348,193 @@ export const EDITABLE = {
316
348
  */
317
349
  export const editable = (kind) => EDITABLE[kind] ?? null;
318
350
 
351
+ /**
352
+ * How a Worker binds each kind the console adds, as BINDABLE says; `required` when the plan makes one only if a Worker
353
+ * binds it.
354
+ */
355
+ const bindAs = (kind, required = false) => {
356
+ const target = BINDABLE.find((t) => t.kind === kind);
357
+ return target ? { bind: { kind: 'worker', list: 'bindings', target, ...(required ? { required } : {}) } } : {};
358
+ };
359
+ /** Names Cloudflare gives Workers, queues, and containers: lowercase letters, digits, and dashes, up to 63. */
360
+ const DNS_LABEL = '^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$';
361
+ const HOSTNAME = '^(?=.{1,253}$)(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\\.)+[a-z]{2,63}$';
362
+ const ZONE_FIELD = {
363
+ path: 'zone',
364
+ label: 'Zone',
365
+ type: 'text',
366
+ pattern: HOSTNAME,
367
+ help: 'The domain on Cloudflare it’s on, like acme.example: one the environment’s token reaches.',
368
+ };
369
+
370
+ /**
371
+ * The kinds the console may add (BRK-270), every kind Architect plans. Name rules are Cloudflare's as last read
372
+ * (developers.cloudflare.com: Workers, Queues, R2, KV, and D1 limits). A database, namespace, bucket, or queue is made
373
+ * only when a Worker in the environment binds it, so each says how one binds it. A Durable Object class and a container
374
+ * application are made by their Worker's code and deploy, so the plan won't make one: the console adds it to the file,
375
+ * `needsCode` says what code must exist, and the plan finds it by name once the deploy has made it.
376
+ * @type {Record<string, Omit<import('./infra-provider.js').Creatable, 'defaults'> & { defaults?: () => Record<string, unknown> }>}
377
+ */
378
+ export const CREATABLE = {
379
+ worker: {
380
+ label: 'Worker',
381
+ help: 'Runs the code that answers requests. It starts with a script that answers /health, until the repository’s deploy puts its own code on it.',
382
+ name: {
383
+ label: 'Name',
384
+ pattern: DNS_LABEL,
385
+ max: 63,
386
+ help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-jobs.',
387
+ },
388
+ required: ['compatibilityDate'],
389
+ defaults: () => ({ compatibilityDate: new Date().toISOString().slice(0, 10), observability: true }),
390
+ ...bindAs('worker'),
391
+ },
392
+ queue: {
393
+ label: 'Queue',
394
+ help: 'Holds messages one Worker sends until another takes them, one batch at a time.',
395
+ name: {
396
+ label: 'Name',
397
+ pattern: DNS_LABEL,
398
+ max: 63,
399
+ help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-jobs.',
400
+ },
401
+ defaults: () => ({ deliveryDelay: 0, deliveryPaused: false, retention: 345_600 }),
402
+ ...bindAs('queue', true),
403
+ },
404
+ r2: {
405
+ label: 'R2 bucket',
406
+ help: 'Keeps files (objects) by key: uploads, images, exports.',
407
+ name: {
408
+ label: 'Name',
409
+ pattern: '^[a-z0-9][a-z0-9-]{1,61}[a-z0-9]$',
410
+ max: 63,
411
+ help: 'Lowercase letters, digits, and dashes, 3 to 63, unique in the account, like acme-uploads.',
412
+ },
413
+ fields: [
414
+ {
415
+ path: 'location',
416
+ label: 'Location',
417
+ type: 'choice',
418
+ optional: true,
419
+ options: [
420
+ { value: 'wnam', label: 'Western North America' },
421
+ { value: 'enam', label: 'Eastern North America' },
422
+ { value: 'weur', label: 'Western Europe' },
423
+ { value: 'eeur', label: 'Eastern Europe' },
424
+ { value: 'apac', label: 'Asia-Pacific' },
425
+ { value: 'oc', label: 'Oceania' },
426
+ ],
427
+ help: 'Where Cloudflare keeps it, as a hint. Unset puts it near whoever makes it. It can’t move later.',
428
+ },
429
+ ],
430
+ ...bindAs('r2', true),
431
+ },
432
+ kv: {
433
+ label: 'KV namespace',
434
+ help: 'Keeps small values by key, read often and written now and then: settings, flags, sessions.',
435
+ name: {
436
+ label: 'Title',
437
+ pattern: '^\\S(?:.*\\S)?$',
438
+ max: 512,
439
+ help: 'Up to 512 characters, unique in the account, like acme-flags.',
440
+ },
441
+ ...bindAs('kv', true),
442
+ },
443
+ d1: {
444
+ label: 'D1 database',
445
+ help: 'A SQL database (SQLite) for the Worker’s data.',
446
+ name: {
447
+ label: 'Name',
448
+ pattern: '^[a-z0-9][a-z0-9_-]{0,63}$',
449
+ max: 64,
450
+ help: 'Lowercase letters, digits, dashes, and underscores, up to 64, unique in the account, like acme-db.',
451
+ },
452
+ ...bindAs('d1', true),
453
+ },
454
+ route: {
455
+ label: 'Route',
456
+ help: 'Sends the requests that match a pattern on one of your domains to a Worker.',
457
+ name: { ...EDITABLE.route.name, label: 'Pattern', max: 255 },
458
+ fields: [ZONE_FIELD],
459
+ required: ['zone', 'worker'],
460
+ },
461
+ 'custom-domain': {
462
+ label: 'Custom domain',
463
+ help: 'Makes a hostname answer with a Worker, with its DNS record and certificate.',
464
+ name: {
465
+ label: 'Hostname',
466
+ pattern: HOSTNAME,
467
+ max: 253,
468
+ help: 'A hostname on one of your zones, like api.acme.example, that no other Worker has.',
469
+ },
470
+ fields: [ZONE_FIELD],
471
+ required: ['zone', 'worker'],
472
+ },
473
+ 'durable-object': {
474
+ label: 'Durable Object',
475
+ help: 'One object per ID that keeps its own state, made from a class in a Worker’s code: a counter, a room, a lock.',
476
+ name: {
477
+ label: 'Name',
478
+ pattern: '^[a-z0-9][a-z0-9-]{0,62}_[A-Za-z_$][A-Za-z0-9_$]*$',
479
+ max: 128,
480
+ help: 'Its Worker’s name and its class’s, joined by _, like acme-api_Counter.',
481
+ },
482
+ fields: [
483
+ {
484
+ path: 'class',
485
+ label: 'Class',
486
+ type: 'text',
487
+ pattern: '^[A-Za-z_$][A-Za-z0-9_$]*$',
488
+ help: 'The class in the Worker’s code that each object is, like Counter.',
489
+ },
490
+ {
491
+ path: 'script',
492
+ label: 'Worker',
493
+ type: 'resource',
494
+ kinds: ['worker'],
495
+ help: 'The Worker whose code has the class.',
496
+ },
497
+ ],
498
+ required: ['class', 'script'],
499
+ needsCode:
500
+ 'The Worker’s code exports the class, its wrangler config binds it and adds it in a migration, and the Worker is deployed. Until then, the plan won’t apply.',
501
+ },
502
+ container: {
503
+ label: 'Container',
504
+ help: 'Runs a container image next to a Durable Object, for code that can’t run in a Worker.',
505
+ name: {
506
+ label: 'Name',
507
+ pattern: DNS_LABEL,
508
+ max: 63,
509
+ help: 'Lowercase letters, digits, and dashes, up to 63, unique in the account, like acme-render.',
510
+ },
511
+ fields: [
512
+ {
513
+ path: 'image',
514
+ label: 'Image',
515
+ type: 'text',
516
+ help: 'What it runs: the Dockerfile in the repository, like ./Dockerfile, or an image’s address.',
517
+ },
518
+ ],
519
+ required: ['image', 'maxInstances'],
520
+ defaults: () => ({ maxInstances: 1 }),
521
+ needsCode:
522
+ 'The image, the Durable Object class that starts it, and the container in the Worker’s wrangler config, and the Worker is deployed. Until then, the plan won’t apply.',
523
+ },
524
+ };
525
+
526
+ /**
527
+ * Whether the console may add a resource of `kind` (BRK-270), what a new one is given, and its name's rule.
528
+ * @param {string} kind
529
+ * @returns {import('./infra-provider.js').Creatable | null}
530
+ */
531
+ export function creatable(kind) {
532
+ const c = CREATABLE[kind];
533
+ if (!c) return null;
534
+ const { defaults, ...rest } = c;
535
+ return { ...structuredClone(rest), ...(defaults ? { defaults: defaults() } : {}) };
536
+ }
537
+
319
538
  /** The scheduling policy whose container applications Cloudflare scales and rolls out; any other is the code's. */
320
539
  export const DEFAULT_SCHEDULING = 'default';
321
540
 
@@ -619,9 +838,8 @@ function workerAttrs(script, settings, deployments, secrets, schedules) {
619
838
  usageModel: settings.usage_model ?? script.usage_model ?? null,
620
839
  observability: Boolean(settings.observability?.enabled),
621
840
  placement: settings.placement?.mode ?? script.placement_mode ?? null,
622
- bindings: (settings.bindings ?? [])
623
- .map((b) => ({ name: String(b.name), type: String(b.type) }))
624
- .sort((a, b) => a.name.localeCompare(b.name)),
841
+ // What each binding binds, by ID or name (BINDING_TARGETS), so a draft names it as the plan compares it (BRK-285).
842
+ bindings: (settings.bindings ?? []).map(bindingTarget).sort((a, b) => a.name.localeCompare(b.name)),
625
843
  secrets: sorted(secrets.map((s) => String(s.name))),
626
844
  crons: sorted((schedules?.schedules ?? []).map((s) => String(s.cron))),
627
845
  versions: (live?.versions ?? []).map((v) => ({ id: String(v.version_id), percentage: Number(v.percentage) })),
@@ -717,7 +935,65 @@ export async function discover(ctx, { live = false } = {}) {
717
935
  ),
718
936
  );
719
937
  seen.scripts = [...scripts.keys()];
720
- if (!scripts.has(target)) return done();
938
+ /** @param {Set<string>} workers the Workers in scope, whose routes and custom domains are resources */
939
+ async function serving(workers) {
940
+ // Routes, on the zones the token reaches, that send to a Worker in scope. The zones list holds every zone on the
941
+ // account for an account-owned token, so a 403 on one zone's routes is skipped and named in `skipped`, never an
942
+ // error (BRK-254); only when every zone refuses does discovery fail, naming the permission.
943
+ const zones = await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50);
944
+ /** @type {Array<{ zone: string, error: any }>} */
945
+ const refused = [];
946
+ for (const zone of zones) {
947
+ seen.zones[String(zone.name)] = String(zone.id);
948
+ let routes;
949
+ try {
950
+ routes =
951
+ (await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
952
+ } catch (error) {
953
+ if (error?.status !== 403) throw error;
954
+ refused.push({ zone: String(zone.name), error });
955
+ continue;
956
+ }
957
+ for (const route of routes) {
958
+ seen.routes.push({
959
+ pattern: String(route.pattern),
960
+ zone: String(zone.name),
961
+ worker: String(route.script ?? ''),
962
+ });
963
+ if (!workers.has(String(route.script))) continue;
964
+ add({
965
+ id: rid('route', route.id),
966
+ kind: 'route',
967
+ name: String(route.pattern),
968
+ attrs: { zone: zone.name, worker: String(route.script) },
969
+ });
970
+ relate(rid('worker', String(route.script)), rid('route', route.id), 'serves');
971
+ }
972
+ }
973
+ if (refused.length && refused.length === zones.length) throw refused[0].error;
974
+ if (refused.length) skipped.push(routesSkipped(refused));
975
+
976
+ // Custom domains attached to a Worker in scope.
977
+ const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
978
+ for (const d of domains) {
979
+ seen.domains.push({ hostname: String(d.hostname), worker: String(d.service ?? '') });
980
+ if (!workers.has(String(d.service))) continue;
981
+ add({
982
+ id: rid('custom-domain', d.id),
983
+ kind: 'custom-domain',
984
+ name: String(d.hostname),
985
+ attrs: { zone: d.zone_name ?? null, environment: d.environment ?? null, worker: String(d.service) },
986
+ });
987
+ relate(rid('worker', String(d.service)), rid('custom-domain', d.id), 'serves');
988
+ }
989
+ }
990
+
991
+ // A target that doesn't run yet (a new environment built from the console, BRK-291) has nothing in scope, but a plan
992
+ // still needs the zones, routes, and domains, so its first change can serve a hostname (BRK-292).
993
+ if (!scripts.has(target)) {
994
+ if (live) await serving(new Set());
995
+ return done();
996
+ }
721
997
 
722
998
  // The Workers in scope: the target, and the Workers it calls or whose Durable Objects it binds.
723
999
  /** @type {Map<string, any[]>} */
@@ -949,52 +1225,7 @@ export async function discover(ctx, { live = false } = {}) {
949
1225
  missing.push('Containers Read');
950
1226
  }
951
1227
 
952
- // Routes, on the zones the token reaches, that send to a Worker in scope. The zones list holds every zone on the
953
- // account for an account-owned token, so a 403 on one zone's routes is skipped and named in `skipped`, never an
954
- // error (BRK-254); only when every zone refuses does discovery fail, naming the permission.
955
- const zones = await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50);
956
- /** @type {Array<{ zone: string, error: any }>} */
957
- const refused = [];
958
- for (const zone of zones) {
959
- seen.zones[String(zone.name)] = String(zone.id);
960
- let routes;
961
- try {
962
- routes =
963
- (await cf.get(`/zones/${enc(zone.id)}/workers/routes`, { permission: 'Workers Routes Read' })).result ?? [];
964
- } catch (error) {
965
- if (error?.status !== 403) throw error;
966
- refused.push({ zone: String(zone.name), error });
967
- continue;
968
- }
969
- for (const route of routes) {
970
- seen.routes.push({ pattern: String(route.pattern), zone: String(zone.name), worker: String(route.script ?? '') });
971
- if (!workers.has(String(route.script))) continue;
972
- add({
973
- id: rid('route', route.id),
974
- kind: 'route',
975
- name: String(route.pattern),
976
- attrs: { zone: zone.name, worker: String(route.script) },
977
- });
978
- relate(rid('worker', String(route.script)), rid('route', route.id), 'serves');
979
- }
980
- }
981
- if (refused.length && refused.length === zones.length) throw refused[0].error;
982
- if (refused.length) skipped.push(routesSkipped(refused));
983
-
984
- // Custom domains attached to a Worker in scope.
985
- const domains = (await cf.get(`/accounts/${a}/workers/domains`, { permission: WORKERS_READ })).result ?? [];
986
- for (const d of domains) {
987
- seen.domains.push({ hostname: String(d.hostname), worker: String(d.service ?? '') });
988
- if (!workers.has(String(d.service))) continue;
989
- add({
990
- id: rid('custom-domain', d.id),
991
- kind: 'custom-domain',
992
- name: String(d.hostname),
993
- attrs: { zone: d.zone_name ?? null, environment: d.environment ?? null, worker: String(d.service) },
994
- });
995
- relate(rid('worker', String(d.service)), rid('custom-domain', d.id), 'serves');
996
- }
997
-
1228
+ await serving(workers);
998
1229
  return done();
999
1230
  }
1000
1231
 
@@ -1245,11 +1476,16 @@ export const HEALTH_LIMITS = {
1245
1476
 
1246
1477
  const MINUTE = 60_000;
1247
1478
  const pct = (n) => `${Math.round(n * 100)}%`;
1248
- const since = `in the last ${HEALTH_WINDOW_MINUTES} minutes`;
1479
+ /** A health window in words: "in the last 15 minutes", "in the last hour", "in the last day". */
1480
+ const sinceWords = (minutes) =>
1481
+ minutes === 60 ? 'in the last hour' : minutes === 24 * 60 ? 'in the last day' : `in the last ${minutes} minutes`;
1249
1482
 
1250
- /** Health from requests and the ones that failed: unknown with no requests, then by HEALTH_LIMITS. */
1251
- function byErrors(requests, errors, noun = 'requests') {
1252
- if (!requests) return { state: 'unknown', text: `No ${noun} ${since}` };
1483
+ /**
1484
+ * Health from requests and the ones that failed over a window: idle with none (BRK-266: deployed, no traffic, no
1485
+ * errors seen), then by HEALTH_LIMITS.
1486
+ */
1487
+ function byErrors(requests, errors, since, noun = 'requests', alive = '') {
1488
+ if (!requests) return { state: 'idle', text: `Idle: no ${noun} ${since}${alive}` };
1253
1489
  const share = errors / requests;
1254
1490
  const said = `${pct(share)} of ${requests} ${noun} failed ${since}`;
1255
1491
  if (share >= HEALTH_LIMITS.downErrors) return { state: 'down', text: said };
@@ -1258,23 +1494,31 @@ function byErrors(requests, errors, noun = 'requests') {
1258
1494
  }
1259
1495
 
1260
1496
  /**
1261
- * One resource's health from what the analytics and the APIs said.
1497
+ * One resource's health from what the analytics and the APIs said. A quiet resource is idle, never unknown: discover
1498
+ * just found it, which is the metadata read that says it exists, and a Worker's deployment says it serves (BRK-266).
1499
+ * Unknown is only for what the board couldn't read, and says which call.
1262
1500
  * @param {Resource} r
1263
1501
  * @param {Record<string, number> | undefined} u its usage over the window
1264
- * @param {{ backlog_count?: number, oldest_message_timestamp_ms?: number } | undefined} backlog a queue's, now
1502
+ * @param {{ result?: any, refused?: string, missing?: boolean } | undefined} backlog a queue's backlog now, or why not
1265
1503
  * @param {number} now
1504
+ * @param {number} minutes the window its usage came from
1266
1505
  * @returns {{ state: string, text: string }}
1267
1506
  */
1268
- function judge(r, u = {}, backlog, now) {
1507
+ function judge(r, u = {}, backlog, now, minutes = HEALTH_WINDOW_MINUTES) {
1269
1508
  const n = (k) => Number(u[k] ?? 0) || 0;
1270
1509
  const attrs = /** @type {Record<string, any>} */ (r.attrs ?? {});
1510
+ const since = sinceWords(minutes);
1271
1511
  switch (r.kind) {
1272
- case 'worker':
1512
+ case 'worker': {
1273
1513
  if (Array.isArray(attrs.versions) && attrs.versions.length === 0)
1274
1514
  return { state: 'down', text: 'No deployment: it serves nothing' };
1275
- return byErrors(n('requests'), n('errors'));
1515
+ const live = Array.isArray(attrs.versions)
1516
+ ? attrs.versions.reduce((sum, v) => sum + (Number(v?.percentage) || 0), 0)
1517
+ : 0;
1518
+ return byErrors(n('requests'), n('errors'), since, 'requests', live >= 100 ? '; deployed and serving' : '');
1519
+ }
1276
1520
  case 'durable-object':
1277
- return byErrors(n('requests'), n('errors'));
1521
+ return byErrors(n('requests'), n('errors'), since);
1278
1522
  case 'r2': {
1279
1523
  let all = 0;
1280
1524
  let failed = 0;
@@ -1283,11 +1527,11 @@ function judge(r, u = {}, backlog, now) {
1283
1527
  all += v;
1284
1528
  if (Number(k.slice('requests:'.length)) >= 500) failed += v;
1285
1529
  }
1286
- return byErrors(all, failed, 'operations');
1530
+ return byErrors(all, failed, since, 'operations');
1287
1531
  }
1288
1532
  case 'd1': {
1289
1533
  const queries = n('readQueries') + n('writeQueries');
1290
- if (!queries) return { state: 'unknown', text: `No queries ${since}` };
1534
+ if (!queries) return { state: 'idle', text: `Idle: no queries ${since}` };
1291
1535
  const ms = Math.round(n('queryBatchTimeMs'));
1292
1536
  if (ms > HEALTH_LIMITS.slowQueryMs)
1293
1537
  return { state: 'degraded', text: `Queries took ${ms} ms on average ${since}` };
@@ -1296,12 +1540,22 @@ function judge(r, u = {}, backlog, now) {
1296
1540
  case 'kv':
1297
1541
  return n('requests')
1298
1542
  ? { state: 'healthy', text: `${n('requests')} operations ${since}` }
1299
- : { state: 'unknown', text: `No operations ${since}` };
1543
+ : { state: 'idle', text: `Idle: no operations ${since}` };
1300
1544
  case 'queue': {
1301
1545
  if (attrs.deliveryPaused) return { state: 'degraded', text: 'Delivery is paused' };
1302
- if (!backlog) return { state: 'unknown', text: 'Its backlog couldn’t be read' };
1303
- const count = Number(backlog.backlog_count ?? 0) || 0;
1304
- const oldest = Number(backlog.oldest_message_timestamp_ms ?? 0) || 0;
1546
+ const consumers = Array.isArray(attrs.consumers) ? attrs.consumers : null;
1547
+ if (consumers && consumers.length === 0)
1548
+ return { state: 'degraded', text: 'No consumer is attached, so nothing reads its messages' };
1549
+ if (backlog?.refused) return { state: 'unknown', text: backlog.refused };
1550
+ if (!backlog?.result)
1551
+ return {
1552
+ state: 'idle',
1553
+ text: consumers
1554
+ ? 'Idle: a consumer is attached, and Cloudflare has no backlog figures for it'
1555
+ : 'Idle: Cloudflare has no backlog figures for it',
1556
+ };
1557
+ const count = Number(backlog.result.backlog_count ?? 0) || 0;
1558
+ const oldest = Number(backlog.result.oldest_message_timestamp_ms ?? 0) || 0;
1305
1559
  const waited = count && oldest ? Math.floor((now - oldest) / MINUTE) : 0;
1306
1560
  if (waited > HEALTH_LIMITS.staleQueueMinutes)
1307
1561
  return { state: 'degraded', text: `The oldest message has waited ${waited} minutes; ${count} in the backlog` };
@@ -1314,23 +1568,32 @@ function judge(r, u = {}, backlog, now) {
1314
1568
  const active = attrs.active;
1315
1569
  const assigned = attrs.assigned;
1316
1570
  if (typeof active !== 'number' || typeof assigned !== 'number')
1317
- return { state: 'unknown', text: 'Its instance counts aren’t known' };
1571
+ return {
1572
+ state: 'unknown',
1573
+ text: 'Cloudflare gave no instance counts when its application was read (GET …/containers/applications): try again at the next refresh',
1574
+ };
1575
+ if (assigned === 0 && active === 0) return { state: 'idle', text: 'Idle: no instances are assigned' };
1318
1576
  if (assigned > 0 && active === 0) return { state: 'down', text: `None of ${assigned} instances is running` };
1319
1577
  if (active < assigned) return { state: 'degraded', text: `${active} of ${assigned} instances are running` };
1320
1578
  return { state: 'healthy', text: `${active} of ${assigned} instances are running` };
1321
1579
  }
1322
1580
  default:
1323
- return { state: 'unknown', text: 'Cloudflare reports no health for it' };
1581
+ return { state: 'unknown', text: 'Cloudflare reports no health for its kind' };
1324
1582
  }
1325
1583
  }
1326
1584
 
1327
1585
  /**
1328
- * Each resource's health now (BRK-188's "Observe" rows): errors and slowness over the last HEALTH_WINDOW_MINUTES from
1329
- * the analytics, one query per dataset for the whole environment; a queue's backlog now; a container application's
1330
- * instance counts as discover found them; and a route's or custom domain's from the Worker it serves. A dataset
1331
- * Cloudflare won't answer leaves its resources unknown, saying so; anything else (a 429, a 403, Cloudflare out of
1332
- * reach) stops it, so the store keeps the last health. Uses `ctx.resources` when the store passes what discover just
1333
- * found, and discovers otherwise.
1586
+ * Each resource's health now (BRK-188's "Observe" rows; BRK-266): errors and slowness from the analytics, one query
1587
+ * per dataset for the whole environment, over a window that ends HEALTH_LAG_MINUTES back, since Cloudflare's analytics
1588
+ * arrive late. A resource with no traffic in the first of HEALTH_WINDOWS is read again over the next (an hour, then a
1589
+ * day) before it's idle, and its text names the window its verdict came from. A queue's backlog now; a container
1590
+ * application's instance counts as discover found them; and a route's or custom domain's from the Worker it serves.
1591
+ *
1592
+ * Unknown only when the board couldn't read: a dataset Cloudflare won't answer, or a queue's backlog the token may not
1593
+ * read, says which call and what to do, and the store keeps the last health with its age. A longer window Cloudflare
1594
+ * won't answer only stops the fallback. Anything else (a 429, a 403 on the analytics, Cloudflare out of reach) stops
1595
+ * it, so the store keeps the last health. Uses `ctx.resources` when the store passes what discover just found, and
1596
+ * discovers otherwise.
1334
1597
  * @param {ProviderContext} ctx
1335
1598
  * @returns {Promise<import('./infra-provider.js').Health[]>}
1336
1599
  */
@@ -1340,35 +1603,53 @@ export async function observe(ctx) {
1340
1603
  const cf = reader(ctx);
1341
1604
  const account = await accountOf(cf, ctx);
1342
1605
  const now = Date.now();
1343
- const to = new Date(now);
1344
- const from = new Date(now - HEALTH_WINDOW_MINUTES * MINUTE);
1606
+ const to = new Date(now - HEALTH_LAG_MINUTES * MINUTE);
1345
1607
  /** @type {Map<string, Record<string, number>>} usage by resource ID */
1346
1608
  const usage = new Map();
1609
+ /** @type {Map<string, number>} the window each resource's verdict comes from, in minutes */
1610
+ const windowOf = new Map();
1347
1611
  /** @type {Map<string, string>} what the analytics didn't answer, by resource ID */
1348
1612
  const unread = new Map();
1349
1613
  for (const d of Object.values(HEALTH_DATASETS)) {
1350
1614
  const mine = resources.filter((r) => r.kind === d.kind);
1351
1615
  if (!mine.length) continue;
1352
1616
  const byKey = new Map(mine.map((r) => [usageKey(r), r.id]));
1353
- let found;
1354
- try {
1355
- found = await readDataset(ctx, { account, dataset: d, keys: [...byKey.keys()], from, to });
1356
- } catch (error) {
1357
- if (error?.status !== 400) throw error;
1358
- for (const r of mine) unread.set(r.id, d.label);
1359
- continue;
1617
+ let quiet = [...byKey.keys()];
1618
+ for (const [i, minutes] of (d.busy ? HEALTH_WINDOWS : HEALTH_WINDOWS.slice(0, 1)).entries()) {
1619
+ if (!quiet.length) break;
1620
+ const from = new Date(to.getTime() - minutes * MINUTE);
1621
+ let found;
1622
+ try {
1623
+ found = await readDataset(ctx, { account, dataset: d, keys: quiet, from, to });
1624
+ } catch (error) {
1625
+ if (error?.status !== 400) throw error;
1626
+ // The first window unanswered leaves them unread; a longer one only stops looking further back.
1627
+ if (i === 0)
1628
+ for (const r of mine)
1629
+ unread.set(
1630
+ r.id,
1631
+ `Couldn’t read its health: Cloudflare’s analytics didn’t answer for its ${d.label} (${d.dataset}). The last health is kept; if it lasts, check the token has Account Analytics Read`,
1632
+ );
1633
+ break;
1634
+ }
1635
+ for (const key of quiet) windowOf.set(/** @type {string} */ (byKey.get(key)), minutes);
1636
+ for (const [key, u] of found) usage.set(/** @type {string} */ (byKey.get(key)), u);
1637
+ quiet = d.busy ? quiet.filter((key) => !d.busy?.(found.get(key) ?? {})) : [];
1360
1638
  }
1361
- for (const [key, u] of found) usage.set(/** @type {string} */ (byKey.get(key)), u);
1362
1639
  }
1363
- /** @type {Map<string, any>} a queue's backlog now, by resource ID */
1640
+ /** @type {Map<string, { result?: any, refused?: string }>} a queue's backlog now, or why it couldn't be read */
1364
1641
  const backlogs = new Map();
1365
1642
  for (const r of resources.filter((q) => q.kind === 'queue')) {
1366
1643
  const path = `/accounts/${enc(account)}/queues/${enc(r.id.slice('queue:'.length))}/metrics`;
1367
1644
  try {
1368
1645
  const json = await cf.get(path, { permission: 'Queues Read', missingOk: true });
1369
- if (json?.result) backlogs.set(r.id, json.result);
1646
+ backlogs.set(r.id, json?.result ? { result: json.result } : {});
1370
1647
  } catch (error) {
1371
1648
  if (error?.status !== 403) throw error;
1649
+ backlogs.set(r.id, {
1650
+ refused:
1651
+ 'Couldn’t read its backlog: Cloudflare refused GET …/queues/<id>/metrics. Give the read-only token Queues Read on Connections',
1652
+ });
1372
1653
  }
1373
1654
  }
1374
1655
 
@@ -1376,12 +1657,12 @@ export async function observe(ctx) {
1376
1657
  const health = new Map();
1377
1658
  for (const r of resources) {
1378
1659
  if (r.kind === 'route' || r.kind === 'custom-domain') continue;
1379
- const label = unread.get(r.id);
1660
+ const said = unread.get(r.id);
1380
1661
  health.set(
1381
1662
  r.id,
1382
- label
1383
- ? { state: 'unknown', text: `Cloudflare’s analytics didn’t answer for its ${label}` }
1384
- : judge(r, usage.get(r.id), backlogs.get(r.id), now),
1663
+ said
1664
+ ? { state: 'unknown', text: said }
1665
+ : judge(r, usage.get(r.id), backlogs.get(r.id), now, windowOf.get(r.id) ?? HEALTH_WINDOW_MINUTES),
1385
1666
  );
1386
1667
  }
1387
1668
  for (const r of resources) {
@@ -1392,10 +1673,10 @@ export async function observe(ctx) {
1392
1673
  r.id,
1393
1674
  its
1394
1675
  ? { state: its.state, text: `Its Worker, ${worker}: ${its.text}` }
1395
- : { state: 'unknown', text: 'Its Worker isn’t in this environment' },
1676
+ : { state: 'unknown', text: 'Its Worker isn’t in this environment, so the board can’t read its health' },
1396
1677
  );
1397
1678
  }
1398
- const at = to.toISOString();
1679
+ const at = new Date(now).toISOString();
1399
1680
  return resources.map((r) => {
1400
1681
  const h = /** @type {{ state: string, text: string }} */ (health.get(r.id));
1401
1682
  return { resource: r.id, state: h.state, at, text: h.text };
@@ -1659,10 +1940,13 @@ export const cloudflare = {
1659
1940
  permissions: READ_PERMISSIONS,
1660
1941
  url: 'https://dash.cloudflare.com/profile/api-tokens',
1661
1942
  check: checkToken,
1943
+ template: tokenTemplate,
1662
1944
  },
1945
+ writeToken: { secret: WRITE_SECRET, permissions: writePermissions, template: tokenTemplate },
1663
1946
  refuses,
1664
1947
  outside,
1665
1948
  editable,
1949
+ creatable,
1666
1950
  discover,
1667
1951
  plan: (ctx, desired) => plan(ctx, desired),
1668
1952
  apply: (ctx, p) => apply(ctx, p),