breakaway 2.0.0-main.99 → 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.
@@ -14,7 +14,7 @@ import { execFileSync } from 'node:child_process';
14
14
  import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
- import { DESIRED_DIR, environmentOfFile } from '../../src/infra-desired.js';
17
+ import { DESIRED_DIR } from '../../src/infra-desired.js';
18
18
  import { SHORT_LIVED_FILE } from '../../src/infra-short-lived.js';
19
19
  import { checkApplyResult } from '../../src/infra-provider.js';
20
20
  import { providers as registry } from '../../src/infra-providers.js';
@@ -29,15 +29,21 @@ import {
29
29
  runPath,
30
30
  runReport,
31
31
  } from '../../src/infra-runner.js';
32
- import { fill, lintWorkflow } from './pipeline.js';
32
+ import {
33
+ RUNNER_RENDER_HEADER,
34
+ RUNNER_TEMPLATE,
35
+ RunnerRenderError,
36
+ environmentsIn,
37
+ renderRunner,
38
+ runnerStep,
39
+ } from '../../src/infra-runner-render.js';
33
40
 
34
41
  /** The first line of the rendered workflow: how `--update` knows the file is its own to replace. */
35
- export const HEADER = `Rendered by npx breakaway infra init from the environments in ${DESIRED_DIR}/: add one there and run npx breakaway infra init --update, not this file.`;
42
+ export const HEADER = RUNNER_RENDER_HEADER;
36
43
  /** Where the runner keeps the checked plan between its steps, in the job's temporary folder. */
37
44
  export const STATE_FILE = 'breakaway-infra-run.json';
38
- const BRANCH = /^(?!.*\.\.)[A-Za-z0-9._/-]{1,100}$/u;
39
45
  const HERE = dirname(fileURLToPath(import.meta.url));
40
- const TEMPLATE = join(HERE, '..', '..', 'template', 'infra', 'apply.yml');
46
+ const TEMPLATE = join(HERE, '..', '..', RUNNER_TEMPLATE);
41
47
  const USER_AGENT = 'breakaway-infra-runner';
42
48
 
43
49
  /** A mistake `infra init` can name. */
@@ -48,66 +54,14 @@ const bad = (message) => {
48
54
 
49
55
  // ---- infra init ----------------------------------------------------------------------------
50
56
 
51
- /**
52
- * The environments the runner offers, from the file names in the desired-state folder: each `<environment>.json`,
53
- * sorted, skipping policy.json, scaling.json, and anything that isn't JSON.
54
- * @param {string[]} names
55
- */
56
- export function environmentsIn(names) {
57
- const environments = [];
58
- for (const name of names) {
59
- const found = environmentOfFile(name);
60
- if (!found) continue;
61
- if ('problem' in found) throw new InfraError(`${DESIRED_DIR}/${found.problem}.`);
62
- environments.push(found.environment);
63
- }
64
- if (!environments.length)
65
- bad(
66
- `${DESIRED_DIR}/ has no environment's file, so there's nothing to apply to: write <environment>.json for each (like staging.json), then run this again.`,
67
- );
68
- return environments.sort();
69
- }
70
-
71
- /**
72
- * The runner's workflow for these environments, applied from `branch`, running breakaway `version`. With `shortLived`
73
- * (the folder has short-lived.json), the environment is any name, since each short-lived one is new, and the run may
74
- * name the GitHub environment that holds the write token: `short-lived` for all of them (BRK-242).
75
- * @param {{ environments: string[], branch: string, version: string, shortLived?: boolean }} input
76
- * @param {string} template template/infra/apply.yml
77
- * @returns {{ path: string, text: string }}
78
- */
79
- export function renderRunner({ environments, branch, version, shortLived = false }, template) {
80
- if (!BRANCH.test(branch)) bad(`${branch.slice(0, 80)} isn't a branch's name.`);
81
- if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) bad(`${version} isn't a breakaway release.`);
82
- const text = fill(template, {
83
- header: HEADER,
84
- branchName: branch,
85
- version,
86
- shortLived,
87
- fixedOnly: !shortLived,
88
- environments: environments.map((name) => `- ${JSON.stringify(name)}`),
89
- });
90
- const problems = lintWorkflow(text);
91
- if (problems.length) throw new Error(`${RUNNER_WORKFLOW} renders with problems: ${problems.join('; ')}`);
92
- return { path: RUNNER_WORKFLOW, text };
93
- }
57
+ // The render is shared with the board (src/infra-runner-render.js, BRK-307), so both write the same file.
58
+ export { environmentsIn, renderRunner };
94
59
 
95
60
  /**
96
61
  * What `infra init` does with the rendered file, given what's there (null when nothing): write it, leave it (the
97
62
  * same), or refuse it (the repository's own, or rendered before when `update` isn't set).
98
- * @param {{ path: string, text: string }} file
99
- * @param {string | null} there
100
- * @param {{ update?: boolean }} [options]
101
- * @returns {{ write: boolean, same?: boolean, refused?: string }}
102
63
  */
103
- export function initStep(file, there, { update = false } = {}) {
104
- if (there === null) return { write: true };
105
- if (there === file.text) return { write: false, same: true };
106
- if (!there.startsWith(`# ${HEADER}`))
107
- return { write: false, refused: "it's the repository's own, not rendered: rename it, then run infra init again" };
108
- if (!update) return { write: false, refused: 'it differs from what infra init renders: run infra init --update' };
109
- return { write: true };
110
- }
64
+ export const initStep = runnerStep;
111
65
 
112
66
  /** The checkout's top folder, or the folder the command runs in. */
113
67
  function topOf(cwd) {
@@ -341,7 +295,7 @@ export async function run(args, opts, io = {}) {
341
295
  if (command !== 'init') bad('infra has init. npx breakaway help says what it does.');
342
296
  return init(opts, { cwd, log, error, version: io.version });
343
297
  } catch (e) {
344
- if (!(e instanceof InfraError)) throw e;
298
+ if (!(e instanceof InfraError) && !(e instanceof RunnerRenderError)) throw e;
345
299
  error(`infra: ${e.message}`);
346
300
  return 1;
347
301
  }
@@ -13,6 +13,9 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
13
13
  import { dirname, join } from 'node:path';
14
14
  import { fileURLToPath } from 'node:url';
15
15
  import { YamlError, parseYaml as readYaml } from '../../src/yaml.js';
16
+ import { fill, lintWorkflow } from '../../src/workflow-render.js';
17
+
18
+ export { fill, lintWorkflow };
16
19
 
17
20
  export const CONFIG_PATH = '.github/breakaway-pipeline.json';
18
21
  export const DEPLOY_PATHS = '.github/deploy-paths.json';
@@ -43,8 +46,6 @@ export const HELPERS = {
43
46
  rollback: ['scripts/deploy-plan.mjs', 'scripts/record-deployment.mjs'],
44
47
  release: ['scripts/deploy-plan.mjs', 'scripts/package-release.mjs'],
45
48
  };
46
- /** The secrets a rendered workflow may read: each lives in a GitHub environment only the default branch can use. */
47
- const SECRETS = new Set(['CLOUDFLARE_API_TOKEN', 'NPM_TOKEN']);
48
49
 
49
50
  /** A config's or a workflow's mistake, in words the owner or the agent can act on. */
50
51
  export class PipelineError extends Error {}
@@ -233,46 +234,6 @@ export function checkConfig(raw, { packageJson = () => null } = {}) {
233
234
  return { workers, wranglerEnv, branch, checks, install, build, beforeDeploy, deployPaths, healthCheck, package: pkg };
234
235
  }
235
236
 
236
- /**
237
- * A template with its values: `{{key}}` is replaced inline, a line that is only `{{@key}}` becomes the list's lines at
238
- * that indentation (none for an empty list), and lines between `{{#if key}}` and `{{/if}}` stay only when the value is
239
- * truthy.
240
- * @param {string} template
241
- * @param {Record<string, any>} values
242
- */
243
- export function fill(template, values) {
244
- const out = [];
245
- const keep = [true];
246
- for (const line of template.split('\n')) {
247
- const cond = /^\s*\{\{#if (\w+)\}\}\s*$/u.exec(line);
248
- if (cond) {
249
- keep.push(keep.at(-1) && Boolean(values[cond[1]]));
250
- continue;
251
- }
252
- if (/^\s*\{\{\/if\}\}\s*$/u.test(line)) {
253
- if (keep.length === 1) throw new Error('{{/if}} without {{#if}}');
254
- keep.pop();
255
- continue;
256
- }
257
- if (!keep.at(-1)) continue;
258
- const list = /^(\s*)\{\{@(\w+)\}\}\s*$/u.exec(line);
259
- if (list) {
260
- const items = values[list[2]];
261
- if (!Array.isArray(items)) throw new Error(`{{@${list[2]}}} needs a list`);
262
- for (const item of items) out.push(`${list[1]}${item}`);
263
- continue;
264
- }
265
- out.push(
266
- line.replace(/(?<!\$)\{\{(\w+)\}\}/gu, (_, key) => {
267
- if (!(key in values)) throw new Error(`the template's {{${key}}} has no value`);
268
- return String(values[key]);
269
- }),
270
- );
271
- }
272
- if (keep.length !== 1) throw new Error('{{#if}} without {{/if}}');
273
- return out.join('\n');
274
- }
275
-
276
237
  const quote = (value) => JSON.stringify(value ?? '');
277
238
 
278
239
  /** The values the templates take, from a checked config. */
@@ -344,134 +305,6 @@ export function parseYaml(text) {
344
305
  }
345
306
  }
346
307
 
347
- const CONTEXTS = new Set([
348
- 'github',
349
- 'inputs',
350
- 'secrets',
351
- 'vars',
352
- 'steps',
353
- 'env',
354
- 'job',
355
- 'needs',
356
- 'runner',
357
- 'matrix',
358
- 'strategy',
359
- ]);
360
-
361
- /** The `${{ }}` expressions in a value, and the value itself when it's an `if:` (an expression without the braces). */
362
- function expressionsIn(text, isIf) {
363
- const found = [...String(text).matchAll(/\$\{\{([\s\S]*?)\}\}/gu)].map((m) => m[1]);
364
- if (isIf && !found.length) found.push(String(text));
365
- return found;
366
- }
367
-
368
- /** Every string under a value, with whether it's an `if:`. */
369
- function stringsIn(value, key = '') {
370
- if (typeof value === 'string') return [{ text: value, isIf: key === 'if' }];
371
- if (Array.isArray(value)) return value.flatMap((item) => stringsIn(item));
372
- if (isObject(value)) return Object.entries(value).flatMap(([k, v]) => stringsIn(v, k));
373
- return [];
374
- }
375
-
376
- /**
377
- * The problems GitHub (or actionlint) would find in a rendered workflow, as messages; none when it's sound. It parses
378
- * the YAML, then checks the shape (on, permissions, jobs with runs-on, a timeout, and steps), that every action is
379
- * pinned to a commit, that every expression names a context Actions has, a step that ran before, an input the workflow
380
- * takes, or a secret the pipeline uses, that secrets are read only in a job with an environment, that run scripts take
381
- * no `${{ }}` (they read env instead), and that nothing of the template is left over.
382
- * @param {string} text
383
- * @returns {string[]}
384
- */
385
- export function lintWorkflow(text) {
386
- const problems = [];
387
- const leftover = text.replace(/\$\{\{[\s\S]*?\}\}/gu, '').match(/\{\{[#/@]?\w+\}\}/u);
388
- if (leftover) problems.push(`the template's ${leftover[0]} is left over`);
389
- let doc;
390
- try {
391
- doc = parseYaml(text);
392
- } catch (error) {
393
- return [...problems, error.message];
394
- }
395
- if (typeof doc.name !== 'string' || !doc.name) problems.push('it has no name');
396
- if (!isObject(doc.on)) problems.push('it has no on: triggers');
397
- else if ('pull_request_target' in doc.on) problems.push('pull_request_target runs untrusted code with secrets');
398
- if (!isObject(doc.permissions) || Object.keys(doc.permissions).length)
399
- problems.push('the workflow sets permissions: {}, and each job asks for what it needs');
400
- if (!isObject(doc.jobs) || !Object.keys(doc.jobs).length) return [...problems, 'it has no jobs'];
401
- const inputs = Object.keys(doc.on?.workflow_dispatch?.inputs ?? {});
402
- for (const [name, input] of Object.entries(doc.on?.workflow_dispatch?.inputs ?? {}))
403
- if (!['string', 'boolean', 'choice', 'number', 'environment'].includes(input?.type))
404
- problems.push(`input ${name} has no type`);
405
-
406
- const checkExpressions = (strings, where, steps, jobLevel) => {
407
- for (const { text: value, isIf } of strings)
408
- for (const expression of expressionsIn(value, isIf)) {
409
- const code = expression.replace(/'(?:[^']|'')*'/gu, "''");
410
- for (const m of code.matchAll(/(?<![\w.-])([A-Za-z_][\w-]*)((?:\.[\w-]+)*)/gu)) {
411
- const [, context, path] = m;
412
- if (!path) {
413
- if (!/^\s*\(/u.test(code.slice(m.index + context.length)) && !['true', 'false', 'null'].includes(context))
414
- problems.push(`${where}: "${context}" isn't a context or a function`);
415
- continue;
416
- }
417
- const [first] = path.slice(1).split('.');
418
- if (!CONTEXTS.has(context)) problems.push(`${where}: no context "${context}"`);
419
- else if (context === 'secrets' && !SECRETS.has(first))
420
- problems.push(`${where}: the pipeline uses no secret ${first}`);
421
- else if (context === 'inputs' && !inputs.includes(first)) problems.push(`${where}: no input ${first}`);
422
- else if (context === 'steps' && !steps.includes(first))
423
- problems.push(`${where}: no step ${first} before this one`);
424
- else if (context === 'env' && jobLevel) problems.push(`${where}: env isn't available here`);
425
- }
426
- }
427
- };
428
- checkExpressions(stringsIn({ on: doc.on, env: doc.env, concurrency: doc.concurrency }), 'the workflow', [], false);
429
-
430
- for (const [jobName, job] of Object.entries(doc.jobs)) {
431
- const where = `job ${jobName}`;
432
- if (!isObject(job)) {
433
- problems.push(`${where} isn't a mapping`);
434
- continue;
435
- }
436
- if (!job['runs-on']) problems.push(`${where} has no runs-on`);
437
- if (typeof job['timeout-minutes'] !== 'number') problems.push(`${where} has no timeout-minutes`);
438
- if (!isObject(job.permissions)) problems.push(`${where} doesn't say its permissions`);
439
- for (const need of [job.needs ?? []].flat())
440
- if (!(need in doc.jobs)) problems.push(`${where} needs ${need}, which isn't a job`);
441
- const { steps = [], ...rest } = job;
442
- checkExpressions(stringsIn({ if: rest.if }), where, [], true);
443
- checkExpressions(stringsIn({ ...rest, if: undefined, outputs: undefined }), where, [], false);
444
- // A job's outputs read its steps once they've all run.
445
- const stepIds = Array.isArray(steps) ? steps.map((s) => s?.id).filter(Boolean) : [];
446
- checkExpressions(stringsIn({ outputs: rest.outputs }), `${where}'s outputs`, stepIds, false);
447
- if (stringsIn(job).some(({ text: value }) => /\bsecrets\./u.test(value)) && !job.environment)
448
- problems.push(`${where} reads a secret outside an environment`);
449
- if (!Array.isArray(steps) || !steps.length) {
450
- problems.push(`${where} has no steps`);
451
- continue;
452
- }
453
- const ids = [];
454
- steps.forEach((step, i) => {
455
- const at = `${where}, step ${i + 1}${step?.name ? ` (${step.name})` : ''}`;
456
- if (!isObject(step)) {
457
- problems.push(`${at} isn't a mapping`);
458
- return;
459
- }
460
- if ('uses' in step === 'run' in step) problems.push(`${at} has one of uses or run`);
461
- if ('uses' in step && !/^[\w.-]+\/[\w./-]+@[0-9a-f]{40}$/u.test(String(step.uses)))
462
- problems.push(`${at} uses ${step.uses}, which isn't pinned to a commit`);
463
- if (typeof step.run === 'string' && step.run.includes('${{'))
464
- problems.push(`${at} puts \${{ }} in its script: pass it through env`);
465
- checkExpressions(stringsIn(step), at, ids, false);
466
- if (step.id !== undefined) {
467
- if (ids.includes(step.id)) problems.push(`${at} repeats the id ${step.id}`);
468
- ids.push(String(step.id));
469
- }
470
- });
471
- }
472
- return problems;
473
- }
474
-
475
308
  /** The run scripts of a parsed workflow, `[{ where, script }]`, for a shell's syntax check. */
476
309
  export function runScripts(text) {
477
310
  const doc = parseYaml(text);
package/scripts/tasks.mjs CHANGED
@@ -371,13 +371,16 @@ Working
371
371
  --pipeline <file.json|none> sets its deploy pipeline ({"workers": {"staging", "production"}, "package": "<npm name>", "workflows": {...}, "deployPaths"},
372
372
  with workers, package, or both) or clears it
373
373
  features add <slug> new feature: its tasks join by carrying <slug> as a tag [--title <text>]
374
- [--brief <text> | --brief-file <path>] [--release <x.y.z>] (agents add one without a release)
374
+ [--brief <text> | --brief-file <path>] [--release <x.y.z>]
375
375
  --from <ref> (owner): made from the group <ref> is in on the Dependencies view: its open tasks
376
376
  join, and tasks already in another feature stay there
377
- features modify <slug> change one (owner): --title, --brief, --brief-file, --release <x.y.z|none>, --state open|shipped
377
+ features modify <slug> change one: --title, --brief, --brief-file, --release <x.y.z|none>; --state open|shipped is the
378
+ owner's. An agent's change shows in Activity and on the feature, and the owner can undo it
379
+ features pull <x.y.z> pull a release's open tasks, and what they wait for, into now [--into next] [--dry-run];
380
+ the next release goes in first. An agent's pull shows on the feature, and the owner can undo it
378
381
  routines add <slug> new routine (owner, or the agent of a routine maker's task) --name <text> --prompt <text> | --prompt-file <path> [--done-when <text>] [--horizon now|next|later] [--gap <minutes>] [--daily <n>]
379
382
  [--repo <slug>] the repository it runs in (default: the checkout's)
380
- routines modify <slug> change one (owner, or the agent of the routine maker's task that made it): the same options (--repo <slug> moves it), and --enabled yes|no; --schedule "0 9 * * 1" runs it on a cron schedule (UTC), --schedule "" clears it; --trigger-start auto|wait sets whether a webhook or GitHub event starts the agent or waits for your Start; --github-events pr_merged,release_published,workflow_failed,issue_opened,issue_reopened (or "") starts it on those GitHub events
383
+ routines modify <slug> change one (owner, or the agent of the routine maker's task that made it): the same options (--repo <slug> moves it), and --enabled yes|no; --schedule "0 9 * * 1" runs it on a cron schedule (UTC), --schedule "" clears it; --trigger-start auto|wait sets whether a webhook or GitHub event starts the agent or waits for your Start; --github-events pr_merged,release_published,workflow_failed,issue_opened,issue_reopened (or "") starts it on those GitHub events; --infra-events plan.failed,drift.found:environment=staging,budget.crossed:percent=80 (or "", the owner's) starts it on those infrastructure events, each with filters environment=, kind=, resource= (several joined by +)
381
384
  routines trigger <slug> new webhook/API trigger (owner): prints its secret once [--label <text>]
382
385
  routines revoke <slug> <id> revoke a trigger
383
386
  routines pause|resume stop or allow every routine [--daily-all <n>] sets the cap for all routines a day
@@ -1320,6 +1323,7 @@ const commands = {
1320
1323
  enabled: yes(opts.enabled),
1321
1324
  triggerStart: opts['trigger-start'],
1322
1325
  githubEvents: opts['github-events'],
1326
+ infraEvents: opts['infra-events'],
1323
1327
  gapMinutes: opts.gap === undefined ? undefined : Number(opts.gap),
1324
1328
  dailyCap: opts.daily === undefined ? undefined : Number(opts.daily),
1325
1329
  };
@@ -1426,7 +1430,7 @@ const commands = {
1426
1430
  ...(d.routines.length ? [''] : ['', 'No routines yet: routines add <slug> --name … --prompt …']),
1427
1431
  ...d.routines.map(
1428
1432
  (x) =>
1429
- ` ${x.slug.padEnd(16)} ${several ? `${x.repo}: ` : ''}${x.enabled ? 'on ' : 'off'} ${x.openRun ? `running ${x.openRun.wid}` : x.lastRun ? `last ${x.lastRun.wid ?? ''} ${ago(x.lastRun.at)}` : 'never run'}, ${x.runsToday} of ${x.dailyCap} today${x.schedule ? `, schedule ${x.schedule}${x.nextRun ? ` (next ${x.nextRun.slice(0, 16).replace('T', ' ')} UTC)` : ''}` : ''}${x.triggers?.length ? `, ${x.triggers.length} trigger${x.triggers.length === 1 ? '' : 's'} (${x.triggerStart === 'auto' ? 'start by themselves' : 'wait for Start'}; ids ${x.triggers.map((t) => t.id).join(', ')})` : ''}${x.disabledReason ? `, off: ${x.disabledReason}` : ''}`,
1433
+ ` ${x.slug.padEnd(16)} ${several ? `${x.repo}: ` : ''}${x.enabled ? 'on ' : 'off'} ${x.openRun ? `running ${x.openRun.wid}` : x.lastRun ? `last ${x.lastRun.wid ?? ''} ${ago(x.lastRun.at)}` : 'never run'}, ${x.runsToday} of ${x.dailyCap} today${x.schedule ? `, schedule ${x.schedule}${x.nextRun ? ` (next ${x.nextRun.slice(0, 16).replace('T', ' ')} UTC)` : ''}` : ''}${x.triggers?.length ? `, ${x.triggers.length} trigger${x.triggers.length === 1 ? '' : 's'} (${x.triggerStart === 'auto' ? 'start by themselves' : 'wait for Start'}; ids ${x.triggers.map((t) => t.id).join(', ')})` : ''}${x.infraEvents?.length ? `, on ${x.infraEvents.map((e) => e.event).join(', ')}` : ''}${x.disabledReason ? `, off: ${x.disabledReason}` : ''}`,
1430
1434
  ),
1431
1435
  ].join('\n'),
1432
1436
  );
@@ -1550,6 +1554,21 @@ const commands = {
1550
1554
  print({ feature }, (d) => `Saved ${d.feature.slug}.`);
1551
1555
  return;
1552
1556
  }
1557
+ if (sub === 'pull') {
1558
+ const release = need(args[1], 'release').replace(/^v(?=\d)/u, '');
1559
+ const into = opts.into ?? 'now';
1560
+ const dryRun = Boolean(opts['dry-run']);
1561
+ const pulled = await call('POST', `releases/${enc(release)}/pull`, { into, dryRun, ...(by ? { by } : {}) });
1562
+ print(pulled, (d) =>
1563
+ [
1564
+ `${d.dryRun ? 'Would move' : 'Moved'} ${d.tasks.length} ${d.tasks.length === 1 ? 'task' : 'tasks'} of ${d.release} into ${d.into}:`,
1565
+ ...d.tasks.map(
1566
+ (t) => ` ${t.wid ?? t.uuid.slice(0, 8)} ${t.description}${t.chain ? ' (it waits for this)' : ''}`,
1567
+ ),
1568
+ ].join('\n'),
1569
+ );
1570
+ return;
1571
+ }
1553
1572
  if (sub === 'show') {
1554
1573
  const { feature } = await call('GET', `features/${enc(need(args[1], 'feature').toLowerCase())}`);
1555
1574
  print({ feature }, (d) => featureLines(d.feature).join('\n'));
@@ -249,14 +249,28 @@ export const COST_DATASETS = {
249
249
  },
250
250
  };
251
251
 
252
- /** How far back health looks: errors and slowness in the last this many minutes (BRK-188's "Observe" rows). */
252
+ /** How far back health looks first: errors and slowness in the last this many minutes (BRK-188's "Observe" rows). */
253
253
  export const HEALTH_WINDOW_MINUTES = 15;
254
254
 
255
255
  /**
256
- * The datasets health reads (BRK-188's "Observe" rows), one query each for the whole environment, over the last
257
- * HEALTH_WINDOW_MINUTES. Which fields each dataset serves at minute resolution is checked against a real account when
258
- * the owner first tries Architect (BRK-205); a dataset Cloudflare won't answer leaves its resources' health unknown.
259
- * @type {Record<string, Dataset>}
256
+ * How far behind now the health window ends (BRK-266): Cloudflare's analytics arrive a few minutes late, so the newest
257
+ * minutes are always empty and a window ending at now would see a busy resource fall quiet.
258
+ */
259
+ export const HEALTH_LAG_MINUTES = 5;
260
+
261
+ /**
262
+ * The windows health reads, shortest first (BRK-266): a resource with no traffic in one is read again over the next
263
+ * before it's called idle, and its verdict says which window it came from.
264
+ */
265
+ export const HEALTH_WINDOWS = [HEALTH_WINDOW_MINUTES, 60, 24 * 60];
266
+
267
+ /**
268
+ * The datasets health reads (BRK-188's "Observe" rows), one query each for the whole environment, over the first of
269
+ * HEALTH_WINDOWS, and again over the longer ones only for the resources it found quiet. `busy` says whether a
270
+ * resource's usage counts as traffic; a dataset without one (the queues' backlog) is read once. Which fields each
271
+ * dataset serves at minute resolution is checked against a real account when the owner first tries Architect
272
+ * (BRK-205); a dataset Cloudflare won't answer leaves its resources' health unread, saying which.
273
+ * @type {Record<string, Dataset & { busy?: (u: Record<string, number>) => boolean }>}
260
274
  */
261
275
  export const HEALTH_DATASETS = {
262
276
  workers: {
@@ -266,6 +280,7 @@ export const HEALTH_DATASETS = {
266
280
  time: 'datetime',
267
281
  sum: ['requests', 'errors'],
268
282
  label: 'requests and errors',
283
+ busy: (u) => (u.requests ?? 0) > 0,
269
284
  },
270
285
  durableObjects: {
271
286
  dataset: 'durableObjectsInvocationsAdaptiveGroups',
@@ -274,6 +289,7 @@ export const HEALTH_DATASETS = {
274
289
  time: 'datetime',
275
290
  sum: ['requests', 'errors'],
276
291
  label: 'requests and errors',
292
+ busy: (u) => (u.requests ?? 0) > 0,
277
293
  },
278
294
  d1: {
279
295
  dataset: 'd1AnalyticsAdaptiveGroups',
@@ -283,6 +299,7 @@ export const HEALTH_DATASETS = {
283
299
  sum: ['readQueries', 'writeQueries'],
284
300
  avg: ['queryBatchTimeMs'],
285
301
  label: 'queries and their time',
302
+ busy: (u) => (u.readQueries ?? 0) + (u.writeQueries ?? 0) > 0,
286
303
  },
287
304
  kv: {
288
305
  dataset: 'kvOperationsAdaptiveGroups',
@@ -291,6 +308,7 @@ export const HEALTH_DATASETS = {
291
308
  time: 'datetime',
292
309
  sum: ['requests'],
293
310
  label: 'operations',
311
+ busy: (u) => (u.requests ?? 0) > 0,
294
312
  },
295
313
  r2: {
296
314
  dataset: 'r2OperationsAdaptiveGroups',
@@ -300,6 +318,7 @@ export const HEALTH_DATASETS = {
300
318
  sum: ['requests'],
301
319
  by: 'responseStatusCode',
302
320
  label: 'operations by response status',
321
+ busy: (u) => Object.entries(u).some(([k, v]) => k.startsWith('requests:') && v > 0),
303
322
  },
304
323
  queues: {
305
324
  dataset: 'queuesBacklogAdaptiveGroups',
@@ -266,8 +266,13 @@ function bindingChange(worker, live, wanted, resolve) {
266
266
  export async function plan(ctx, desired) {
267
267
  const found = await discover(ctx, { live: true });
268
268
  const live = /** @type {LiveAccount} */ (found.live);
269
- if (!live.account || !live.scripts.includes(String(ctx.scope?.target ?? '')))
270
- refuse(`${ctx.scope?.target ?? 'the target'} isn’t a Worker on the account yet: deploy it first, then plan`);
269
+ const target = String(ctx.scope?.target ?? '');
270
+ // A target the desired state makes (a new environment built from the console, BRK-291) is planned like any Worker.
271
+ const makes = desired.resources.some((r) => r.kind === 'worker' && r.name === target);
272
+ if (!live.account || !(live.scripts.includes(target) || (target && makes)))
273
+ refuse(
274
+ `${ctx.scope?.target ?? 'the target'} isn’t a Worker on the account yet: deploy it first, or add it to the desired state, then plan`,
275
+ );
271
276
  if (ctx.scope?.board && ctx.scope.board === ctx.scope.target)
272
277
  refuse(
273
278
  `${ctx.scope.target} is the Worker this board runs on: Architect only observes it, so it never plans for it`,
@@ -600,6 +605,35 @@ function writer(ctx) {
600
605
  };
601
606
  }
602
607
 
608
+ /** The module a new Worker starts with (BRK-270): it answers /health until the repository's deploy puts its code on it. */
609
+ export const STARTER_MODULE = 'starter.js';
610
+ export const STARTER_SCRIPT = `// Made by breakaway's Architect. The repository's deploy replaces it.
611
+ export default {
612
+ fetch(request) {
613
+ if (new URL(request.url).pathname === '/health') return Response.json({ ok: true, starter: true });
614
+ return new Response('This Worker has no code yet: its deploy puts it here.', { status: 503 });
615
+ },
616
+ };
617
+ `;
618
+
619
+ /**
620
+ * A new Worker's first upload: the starter module, with the change's settings and bindings. A Durable Object binding
621
+ * waits for the deploy, since its class is in the Worker's own code.
622
+ */
623
+ function starterUpload(after, resolve) {
624
+ const { keep_bindings: _, ...settings } = workerSettings(after, [], resolve);
625
+ const metadata = {
626
+ ...settings,
627
+ main_module: STARTER_MODULE,
628
+ bindings: settings.bindings.filter((b) => b.type !== 'durable_object_namespace'),
629
+ compatibility_date: settings.compatibility_date ?? new Date().toISOString().slice(0, 10),
630
+ };
631
+ const form = new FormData();
632
+ form.append('metadata', new Blob([JSON.stringify(metadata)], { type: 'application/json' }));
633
+ form.append(STARTER_MODULE, new Blob([STARTER_SCRIPT], { type: 'application/javascript+module' }), STARTER_MODULE);
634
+ return form;
635
+ }
636
+
603
637
  /** A Worker's settings, as `PATCH …/settings` takes them, from a change's `after` and its live bindings. */
604
638
  function workerSettings(after, live, resolve) {
605
639
  const byName = new Map(live.map((b) => [b.name, b]));
@@ -689,8 +723,9 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
689
723
  const perm = { permission: WORKERS_WRITE.edit };
690
724
  if (c.op === 'delete') return call('DELETE', p, { permission: WORKERS_WRITE.delete });
691
725
  if (c.op === 'create') {
692
- // The Worker alone: its first version, with its code, comes from its deploy.
726
+ // The Worker, then a starter version that answers /health, with its settings: its deploy replaces the code.
693
727
  await call('POST', `${a}/workers/workers`, { permission: WORKERS_WRITE.create, json: { name: c.name } });
728
+ await call('PUT', p, { permission: WORKERS_WRITE.create, form: starterUpload(after, resolve) });
694
729
  if (Array.isArray(after.crons) && after.crons.length)
695
730
  await call('PUT', `${p}/schedules`, { ...perm, json: after.crons.map((cron) => ({ cron })) });
696
731
  return;
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Cloudflare's tokens for guided setup (BRK-304; docs/specs/IDEA-19-architect.md, "Tokens"): which permissions each
3
+ * environment's write token needs, from the kinds its desired state declares, and the link that opens Cloudflare's
4
+ * create-token page with as many of them filled in as Cloudflare allows. Pure: no store, no network, and it runs in
5
+ * Node as well as the Worker. The board never makes, sees, or keeps a write token: it only says what to make.
6
+ *
7
+ * Cloudflare's template URLs ("API token template URLs", developers.cloudflare.com/fundamentals/api/how-to/
8
+ * account-owned-token-template/, read 8 Oct 2026) prefill an account API token's permissions by key, but only the
9
+ * keys that page lists. Workers roles (Metadata Read-Only, Editor, Admin; BRK-243), Containers, and Notifications have
10
+ * no listed key, and the legacy `workers_scripts` key would give Content Read-Only (code) or Editor on every Worker, so
11
+ * those are never prefilled: the setup names them to add by hand.
12
+ */
13
+ import { READ_PERMISSIONS, WORKERS_READ } from './infra-cloudflare.js';
14
+
15
+ /** The secret the apply workflow reads the write token from, in each environment's GitHub environment. */
16
+ export const WRITE_SECRET = 'CLOUDFLARE_API_TOKEN';
17
+
18
+ /** Where an account API token is made, with `permissionGroupKeys` and `name` added (the account is picked there). */
19
+ export const TEMPLATE_BASE = 'https://dash.cloudflare.com/?to=/:account/api-tokens';
20
+
21
+ /** The permissions a template URL can prefill, by the name the token page gives them. */
22
+ export const TEMPLATE_KEYS = {
23
+ 'Workers KV Storage Read': { key: 'workers_kv_storage', type: 'read' },
24
+ 'Workers KV Storage Write': { key: 'workers_kv_storage', type: 'edit' },
25
+ 'Workers R2 Storage Read': { key: 'workers_r2', type: 'read' },
26
+ 'Workers R2 Storage Write': { key: 'workers_r2', type: 'edit' },
27
+ 'D1 Read': { key: 'd1', type: 'read' },
28
+ 'D1 Write': { key: 'd1', type: 'edit' },
29
+ 'Queues Read': { key: 'queues', type: 'read' },
30
+ 'Queues Write': { key: 'queues', type: 'edit' },
31
+ 'Account Analytics Read': { key: 'account_analytics', type: 'read' },
32
+ 'Zone Read': { key: 'zone', type: 'read' },
33
+ 'Workers Routes Read': { key: 'workers_routes', type: 'read' },
34
+ 'Workers Routes Write': { key: 'workers_routes', type: 'edit' },
35
+ };
36
+
37
+ /** The permissions that are given on zones, not the account: only the zones the environments use. */
38
+ const ZONE = new Set(['Zone Read', 'Workers Routes Read', 'Workers Routes Write']);
39
+
40
+ /**
41
+ * One permission a token needs, where it's given, and why.
42
+ * @typedef {object} SetupPermission
43
+ * @property {string} name as Cloudflare's token page names it
44
+ * @property {string} scope where it's given: `account`, `zones`, `workers` (only the Workers named in `workers`), or
45
+ * `workers-product` (every Worker on the account)
46
+ * @property {string} for what it's for, in a few words
47
+ * @property {string[]} [legacy] older names for the same access
48
+ * @property {string[]} [workers] with scope `workers`, the Workers to choose
49
+ * @property {boolean} [once] only for the first apply that makes a Worker: give it, then take it away
50
+ */
51
+
52
+ /** The board's read token, with where each permission is given. */
53
+ export const readPermissions = () =>
54
+ READ_PERMISSIONS.map(
55
+ (p) =>
56
+ /** @type {SetupPermission} */ ({
57
+ ...p,
58
+ scope: p.name === WORKERS_READ ? 'workers-product' : ZONE.has(p.name) ? 'zones' : 'account',
59
+ }),
60
+ );
61
+
62
+ /** The Write permission each kind needs beyond Workers Editor, and what it's for. */
63
+ const KIND_WRITES = [
64
+ { kinds: ['route', 'custom-domain'], name: 'Workers Routes Write', for: 'routes and custom domains, on their zones' },
65
+ { kinds: ['d1'], name: 'D1 Write', for: 'D1 databases' },
66
+ { kinds: ['kv'], name: 'Workers KV Storage Write', for: 'KV namespaces' },
67
+ { kinds: ['r2'], name: 'Workers R2 Storage Write', for: 'R2 buckets' },
68
+ { kinds: ['queue'], name: 'Queues Write', for: 'queues' },
69
+ { kinds: ['container'], name: 'Containers Write', for: 'container applications' },
70
+ ];
71
+
72
+ /**
73
+ * What one environment's write token needs: the read token's permissions (apply discovers again with it), Workers
74
+ * Editor on its Workers (on the Workers product when it declares custom domains, which have no per-Worker role yet),
75
+ * and only the Write permissions for the kinds it declares. A Worker the desired state names that doesn't run yet
76
+ * needs Workers Admin for the apply that makes it, never as part of the standing token.
77
+ * @param {{ resources?: Array<{ kind: string, name: string }> } | null} desired the environment's desired state, null
78
+ * when it has none yet
79
+ * @param {{ running?: string[] }} [options] the Workers that already run in the environment, by name
80
+ * @returns {SetupPermission[]}
81
+ */
82
+ export function writePermissions(desired, { running = [] } = {}) {
83
+ const resources = desired?.resources ?? [];
84
+ const kinds = new Set(resources.map((r) => r.kind));
85
+ const workers = [...new Set(resources.filter((r) => r.kind === 'worker').map((r) => r.name))].sort();
86
+ const domains = kinds.has('custom-domain');
87
+ /** @type {SetupPermission[]} */
88
+ const out = readPermissions();
89
+ out.push({
90
+ name: 'Workers Editor',
91
+ legacy: ['Workers Scripts Write'],
92
+ scope: domains || !workers.length ? 'workers-product' : 'workers',
93
+ ...(domains || !workers.length ? {} : { workers }),
94
+ for: domains
95
+ ? 'changing its Workers and their custom domains (custom domains have no per-Worker role yet)'
96
+ : 'changing its Workers and their Durable Objects',
97
+ });
98
+ for (const w of KIND_WRITES)
99
+ if (w.kinds.some((k) => kinds.has(k)))
100
+ out.push({ name: w.name, scope: ZONE.has(w.name) ? 'zones' : 'account', for: w.for });
101
+ const have = new Set(running);
102
+ const missing = workers.filter((w) => !have.has(w));
103
+ if (missing.length)
104
+ out.push({
105
+ name: 'Workers Admin',
106
+ scope: 'workers-product',
107
+ once: true,
108
+ for: `making ${missing.join(', ')}, which ${missing.length === 1 ? 'doesn’t' : 'don’t'} run yet: give it for the first apply, then take it away`,
109
+ });
110
+ return out;
111
+ }
112
+
113
+ /**
114
+ * The link that opens Cloudflare's create-token page for an account API token with every permission it can prefill,
115
+ * and the ones to add by hand. Workers Editor scoped to chosen Workers, and Workers Admin, are always by hand.
116
+ * @param {Array<{ name: string }>} permissions
117
+ * @param {string} name the token's name, prefilled
118
+ * @returns {{ url: string, prefilled: string[], byHand: string[] }}
119
+ */
120
+ export function tokenTemplate(permissions, name) {
121
+ const keys = [];
122
+ const prefilled = [];
123
+ const byHand = [];
124
+ for (const p of permissions) {
125
+ const key = TEMPLATE_KEYS[/** @type {keyof typeof TEMPLATE_KEYS} */ (p.name)];
126
+ if (key) {
127
+ keys.push(key);
128
+ prefilled.push(p.name);
129
+ } else byHand.push(p.name);
130
+ }
131
+ const query = `permissionGroupKeys=${encodeURIComponent(JSON.stringify(keys))}&name=${encodeURIComponent(name)}`;
132
+ return { url: `${TEMPLATE_BASE}&${query}`, prefilled, byHand };
133
+ }