breakaway 2.0.0-main.83 → 2.0.0-main.84

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.
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.83",
3
+ "version": "2.0.0-main.84",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.83",
9
+ "version": "2.0.0-main.84",
10
10
  "license": "FSL-1.1-Apache-2.0",
11
11
  "bin": {
12
12
  "breakaway": "scripts/tasks.mjs"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "breakaway",
3
- "version": "2.0.0-main.83",
3
+ "version": "2.0.0-main.84",
4
4
  "description": "The task board for you and your coding agents: a Cloudflare Worker, its web app, Taskwarrior sync, and the CLI (npx breakaway).",
5
5
  "license": "FSL-1.1-Apache-2.0",
6
6
  "type": "module",
@@ -1162,38 +1162,83 @@ export async function observe(ctx) {
1162
1162
  }
1163
1163
 
1164
1164
  /**
1165
- * A Cloudflare alert, cut down to three fields: its name, when it fired, and the Worker it names. Everything else
1166
- * (its text, data, account and policy IDs) is left out. The board's alert webhook (src/store-routines.js) and the
1167
- * alert history (`events`) both read alerts with this, so an alert heard both ways reads the same.
1165
+ * A Cloudflare alert, cut down to what places it: its name, when it fired, and the Worker, zone, or hostname it names.
1166
+ * Everything else (its text, the rest of its data, account and policy IDs) is left out. The board's alert webhook
1167
+ * (src/store-routines.js) and the alert history (`events`) both read alerts with this, so an alert heard both ways
1168
+ * reads the same. `zoneId` is only for finding the zone's name; it's never stored.
1168
1169
  * @param {any} body a notification webhook's body, or an alert history entry's `alert_body`
1169
- * @returns {{ alert: string | null, at: string | null, worker: string | null }}
1170
+ * @returns {{ alert: string | null, at: string | null, worker: string | null, zone: string | null,
1171
+ * zoneId: string | null, hostname: string | null }}
1170
1172
  */
1171
1173
  export function alertFields(body) {
1172
- const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim()) ?? null;
1174
+ const pick = (...values) => values.find((v) => typeof v === 'string' && v.trim())?.trim() ?? null;
1175
+ const first = (v) => (Array.isArray(v) ? v.find((x) => typeof x === 'string') : v);
1173
1176
  const data = body?.data && typeof body.data === 'object' && !Array.isArray(body.data) ? body.data : {};
1174
1177
  const ts = Number(body?.ts);
1175
1178
  const at =
1176
1179
  Number.isFinite(ts) && ts > 0
1177
1180
  ? new Date(ts < 1e11 ? ts * 1000 : ts)
1178
1181
  : new Date(pick(body?.timestamp, body?.time) ?? Number.NaN);
1182
+ const host = pick(data.hostname, data.host, data.domain, first(data.hostnames), first(data.hosts));
1179
1183
  return {
1180
1184
  alert: pick(body?.alert_name, body?.policy_name, body?.name, body?.alert_type),
1181
1185
  at: Number.isNaN(at.getTime()) ? null : at.toISOString(),
1182
1186
  worker: pick(data.script_name, data.worker_name, data.worker, data.service, data.script),
1187
+ zone: pick(data.zone_name, data.zone)?.toLowerCase() ?? null,
1188
+ zoneId: pick(data.zone_tag, data.zone_id),
1189
+ hostname: host ? host.toLowerCase().replace(/^\*\./u, '').replace(/\.$/u, '') : null,
1183
1190
  };
1184
1191
  }
1185
1192
 
1186
- /** An alert signal's text: the alert's name and the Worker it names. */
1187
- export function alertText(alert, worker) {
1193
+ /** An alert signal's text: the alert's name and what it's on (a Worker, a zone, or a hostname). */
1194
+ export function alertText(alert, on) {
1188
1195
  const name = String(alert ?? '').slice(0, 200) || 'unnamed alert';
1189
- return `Cloudflare alert: ${name}${worker ? ` on ${String(worker).slice(0, 100)}` : ''}`;
1196
+ return `Cloudflare alert: ${name}${on ? ` on ${String(on).slice(0, 100)}` : ''}`;
1197
+ }
1198
+
1199
+ /** The zones an environment's resources use: the zone of each of its routes and custom domains. */
1200
+ export function zonesOf(resources) {
1201
+ const zones = new Set();
1202
+ for (const r of resources ?? []) {
1203
+ if (r.kind !== 'route' && r.kind !== 'custom-domain') continue;
1204
+ const zone = /** @type {any} */ (r.attrs)?.zone;
1205
+ if (typeof zone === 'string' && zone) zones.add(zone.toLowerCase());
1206
+ }
1207
+ return zones;
1208
+ }
1209
+
1210
+ /**
1211
+ * Where an alert goes in one environment (BRK-255), from its fields and the environment's resources: on the Worker it
1212
+ * names when that Worker is the environment's; on the whole environment when the zone it names (or its hostname's
1213
+ * zone) is one the environment's routes or custom domains use; `account` when it names no Worker, zone, or hostname
1214
+ * (it's about the account or the platform, and the board keeps one per provider, not one per environment); or null
1215
+ * when it's another environment's, or no environment's.
1216
+ * @param {ReturnType<typeof alertFields>} fields
1217
+ * @param {Array<{ kind: string, name: string, attrs?: Record<string, unknown> }>} resources
1218
+ * @returns {{ resource: string | null, account: boolean, on: string | null } | null}
1219
+ */
1220
+ export function alertPlace(fields, resources) {
1221
+ if (fields.worker) {
1222
+ const mine = (resources ?? []).some((r) => r.kind === 'worker' && r.name === fields.worker);
1223
+ return mine ? { resource: rid('worker', fields.worker), account: false, on: fields.worker } : null;
1224
+ }
1225
+ if (!fields.zone && !fields.hostname && !fields.zoneId) return { resource: null, account: true, on: null };
1226
+ const zones = zonesOf(resources);
1227
+ const host = fields.hostname;
1228
+ const zone =
1229
+ (fields.zone && zones.has(fields.zone) ? fields.zone : null) ??
1230
+ (host ? [...zones].find((z) => host === z || host.endsWith(`.${z}`)) : null) ??
1231
+ null;
1232
+ return zone ? { resource: null, account: false, on: host ?? zone } : null;
1190
1233
  }
1191
1234
 
1192
1235
  /**
1193
1236
  * The account's alerts since `since`, from its alert history (`GET …/alerting/v3/history`, Notifications Read), as
1194
- * `alert` signals: each on the Worker it names when that Worker is in the environment, or on the whole environment
1195
- * when it names none; an alert about a Worker outside the environment is another environment's. Oldest first. The
1196
- * board's alert webhook reports the same alerts as they fire; the store keeps one of each.
1237
+ * `alert` signals placed by `alertPlace` (BRK-255): on the Worker it names when that Worker is in the environment, on
1238
+ * the whole environment when it names a zone or hostname the environment uses, and marked `account` when it names
1239
+ * none of them; an alert about another environment's Worker or zone is left out. A zone named only by its ID is
1240
+ * looked up in the account's zones (Zone Read). Oldest first. The board's alert webhook reports the same alerts as
1241
+ * they fire; the store keeps one of each, and one of each account-wide alert across the provider's environments.
1197
1242
  * @param {ProviderContext} ctx
1198
1243
  * @param {string} since ISO 8601
1199
1244
  * @returns {Promise<import('./infra-provider.js').Signal[]>}
@@ -1204,11 +1249,25 @@ export async function events(ctx, since) {
1204
1249
  const now = Date.now();
1205
1250
  if (from > now) return [];
1206
1251
  const resources = ctx.resources ?? (await discover(ctx)).resources;
1207
- const workers = new Set(resources.filter((r) => r.kind === 'worker').map((r) => r.name));
1208
1252
  const cf = reader(ctx);
1209
- const a = enc(await accountOf(cf, ctx));
1253
+ const account = await accountOf(cf, ctx);
1254
+ const a = enc(account);
1210
1255
  const window = `since=${enc(new Date(from).toISOString())}&before=${enc(new Date(now).toISOString())}`;
1211
1256
  const history = await cf.all(`/accounts/${a}/alerting/v3/history?${window}`, { permission: 'Notifications Read' });
1257
+ /** @type {Map<string, string> | null} zone names by ID, read only when an alert names a zone by its ID alone */
1258
+ let zoneNames = null;
1259
+ const zoneName = async (id) => {
1260
+ if (!zoneNames) {
1261
+ zoneNames = new Map();
1262
+ try {
1263
+ for (const z of await cf.all(`/zones?account.id=${a}`, { permission: 'Zone Read' }, 50))
1264
+ zoneNames.set(String(z.id), String(z.name).toLowerCase());
1265
+ } catch (error) {
1266
+ if (error?.status !== 403) throw error;
1267
+ }
1268
+ }
1269
+ return zoneNames.get(id) ?? null;
1270
+ };
1212
1271
  /** @type {import('./infra-provider.js').Signal[]} */
1213
1272
  const signals = [];
1214
1273
  for (const entry of history) {
@@ -1222,16 +1281,20 @@ export async function events(ctx, since) {
1222
1281
  const fields = alertFields(body);
1223
1282
  const at = fields.at ?? (Number.isNaN(Date.parse(entry?.sent)) ? null : new Date(entry.sent).toISOString());
1224
1283
  if (!at || Date.parse(at) < from) continue;
1225
- if (fields.worker && !workers.has(fields.worker)) continue;
1284
+ if (!fields.worker && !fields.zone && !fields.hostname && fields.zoneId)
1285
+ fields.zone = await zoneName(fields.zoneId);
1286
+ const place = alertPlace(fields, resources);
1287
+ if (!place) continue;
1226
1288
  signals.push({
1227
1289
  source: 'cloudflare',
1228
1290
  environment: ctx.environment,
1229
- resource: fields.worker ? rid('worker', fields.worker) : null,
1291
+ resource: place.resource,
1230
1292
  kind: 'alert',
1231
1293
  level: 'warning',
1232
1294
  value: null,
1233
1295
  at,
1234
- text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type, fields.worker),
1296
+ text: alertText(fields.alert ?? entry?.name ?? entry?.alert_type, place.on),
1297
+ ...(place.account ? { account: true } : {}),
1235
1298
  });
1236
1299
  }
1237
1300
  return signals.sort((x, y) => Date.parse(x.at) - Date.parse(y.at));
@@ -123,6 +123,8 @@ export const COST_NOTE_MAX = 500;
123
123
  * @property {number | null} value
124
124
  * @property {string} at ISO 8601
125
125
  * @property {string} text short, at most SIGNAL_TEXT_MAX characters
126
+ * @property {boolean} [account] true for an alert about the provider's account or platform as a whole, not this
127
+ * environment (BRK-255): its resource is null, and the board keeps one per provider, not one per environment
126
128
  */
127
129
 
128
130
  /**
@@ -495,6 +497,10 @@ export function checkSignals(provider, ctx, since, signals) {
495
497
  if (s.source !== provider.id) fail(what, `a signal is from ${s.source}, not ${provider.id}`);
496
498
  if (s.environment !== ctx.environment) fail(what, `a signal is for ${s.environment}, not ${ctx.environment}`);
497
499
  if (s.resource !== null && !text(s.resource)) fail(what, 'a signal has a resource that is neither an id nor null');
500
+ if (s.account !== undefined && typeof s.account !== 'boolean')
501
+ fail(what, 'a signal has an account that isn’t true or false');
502
+ if (s.account && (s.resource !== null || s.kind !== 'alert'))
503
+ fail(what, 'an account signal is an alert on no resource');
498
504
  if (!SIGNAL_KINDS.includes(s.kind)) fail(what, `a signal has unknown kind "${s.kind}"`);
499
505
  if (!SIGNAL_LEVELS.includes(s.level)) fail(what, `a signal has unknown level "${s.level}"`);
500
506
  if (s.value !== null && (typeof s.value !== 'number' || !Number.isFinite(s.value)))