breakaway 2.0.0-main.63 → 2.0.0-main.65

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.63",
3
+ "version": "2.0.0-main.65",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.63",
9
+ "version": "2.0.0-main.65",
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.63",
3
+ "version": "2.0.0-main.65",
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",
@@ -33,6 +33,7 @@
33
33
  "src/infra-plans.js",
34
34
  "src/infra-policy.js",
35
35
  "src/infra-scaling.js",
36
+ "src/infra-short-lived.js",
36
37
  "src/infra-providers.js",
37
38
  "src/infra-runner.js",
38
39
  "src/infra-templates.js",
@@ -15,12 +15,14 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from
15
15
  import { dirname, join } from 'node:path';
16
16
  import { fileURLToPath } from 'node:url';
17
17
  import { DESIRED_DIR, environmentOfFile } from '../../src/infra-desired.js';
18
+ import { SHORT_LIVED_FILE } from '../../src/infra-short-lived.js';
18
19
  import { checkApplyResult } from '../../src/infra-provider.js';
19
20
  import { providers as registry } from '../../src/infra-providers.js';
20
21
  import {
21
22
  RUNNER_HEADER,
22
23
  RUNNER_WORKFLOW,
23
24
  RunnerError,
25
+ SHORT_LIVED_GITHUB_ENVIRONMENT,
24
26
  checkRunInputs,
25
27
  checkRunPlan,
26
28
  planDigest,
@@ -67,18 +69,22 @@ export function environmentsIn(names) {
67
69
  }
68
70
 
69
71
  /**
70
- * The runner's workflow for these environments, applied from `branch`, running breakaway `version`.
71
- * @param {{ environments: string[], branch: string, version: string }} input
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
72
76
  * @param {string} template template/infra/apply.yml
73
77
  * @returns {{ path: string, text: string }}
74
78
  */
75
- export function renderRunner({ environments, branch, version }, template) {
79
+ export function renderRunner({ environments, branch, version, shortLived = false }, template) {
76
80
  if (!BRANCH.test(branch)) bad(`${branch.slice(0, 80)} isn't a branch's name.`);
77
81
  if (!/^\d+\.\d+\.\d+(?:-[\w.]+)?$/u.test(version)) bad(`${version} isn't a breakaway release.`);
78
82
  const text = fill(template, {
79
83
  header: HEADER,
80
84
  branchName: branch,
81
85
  version,
86
+ shortLived,
87
+ fixedOnly: !shortLived,
82
88
  environments: environments.map((name) => `- ${JSON.stringify(name)}`),
83
89
  });
84
90
  const problems = lintWorkflow(text);
@@ -145,9 +151,11 @@ function init(opts, { cwd, log, error, version = VERSION }) {
145
151
  bad(
146
152
  `${DESIRED_DIR}/ isn't here: write the desired state of each environment there first (<environment>.json), then run this again.`,
147
153
  );
148
- const environments = environmentsIn(readdirSync(folder));
154
+ const names = readdirSync(folder);
155
+ const environments = environmentsIn(names);
156
+ const shortLived = names.includes(SHORT_LIVED_FILE);
149
157
  const branch = opts.branch ? String(opts.branch) : defaultBranch(root);
150
- const file = renderRunner({ environments, branch, version }, readFileSync(TEMPLATE, 'utf8'));
158
+ const file = renderRunner({ environments, branch, version, shortLived }, readFileSync(TEMPLATE, 'utf8'));
151
159
  const at = join(root, file.path);
152
160
  const step = initStep(file, existsSync(at) ? readFileSync(at, 'utf8') : null, { update: Boolean(opts.update) });
153
161
  if (step.refused) {
@@ -162,9 +170,11 @@ function init(opts, { cwd, log, error, version = VERSION }) {
162
170
  mkdirSync(dirname(at), { recursive: true });
163
171
  writeFileSync(at, file.text);
164
172
  }
165
- log(`${opts['dry-run'] ? 'Would write' : 'Wrote'} ${file.path}, for ${environments.join(', ')}, from ${branch}.`);
166
173
  log(
167
- `Before the board can apply a plan, the owner makes, for each environment, a GitHub environment of the same name that only ${branch} may use, with that environment's write token as the secret CLOUDFLARE_API_TOKEN (the board's read permissions, plus Cloudflare's Workers Editor on that environment's Workers, or the legacy Workers Scripts Write, and only the Write permissions its file needs); sets the repository variable BREAKAWAY_URL to the board's address; and gives the board's GitHub App Actions: read and write here. Agents never do these, and never run the workflow.`,
174
+ `${opts['dry-run'] ? 'Would write' : 'Wrote'} ${file.path}, for ${environments.join(', ')}${shortLived ? ' and short-lived environments' : ''}, from ${branch}.`,
175
+ );
176
+ log(
177
+ `Before the board can apply a plan, the owner makes, for each environment, a GitHub environment of the same name that only ${branch} may use${shortLived ? `, and one more named ${SHORT_LIVED_GITHUB_ENVIRONMENT} that every short-lived environment applies in` : ''}, with that environment's write token as the secret CLOUDFLARE_API_TOKEN (the board's read permissions, plus Cloudflare's Workers Editor on that environment's Workers, or the legacy Workers Scripts Write, and only the Write permissions its file needs); sets the repository variable BREAKAWAY_URL to the board's address; and gives the board's GitHub App Actions: read and write here. Agents never do these, and never run the workflow.`,
168
178
  );
169
179
  return 0;
170
180
  }
@@ -256,7 +266,7 @@ async function apply(io) {
256
266
  const token = io.env.BREAKAWAY_WRITE_TOKEN;
257
267
  if (!token)
258
268
  throw new RunnerError(
259
- `${plan.environment}'s GitHub environment has no write token: the owner adds it as the secret CLOUDFLARE_API_TOKEN.`,
269
+ `The GitHub environment this run applies in has no write token for ${plan.environment}: the owner adds it there as the secret CLOUDFLARE_API_TOKEN.`,
260
270
  );
261
271
  await report(io, state, { step: 'applying' });
262
272
  let result;
@@ -30,6 +30,21 @@ export const RUNNER_HEADER = 'Breakaway-Runner-Token';
30
30
  export const PLAN_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/u;
31
31
  /** An environment's name, as its desired-state file is named (BRK-180). */
32
32
  const ENVIRONMENT = /^[a-z0-9][a-z0-9-]{0,39}$/u;
33
+
34
+ /**
35
+ * The one GitHub environment every short-lived environment's run uses (BRK-242): its write token is there, since a
36
+ * short-lived environment's name is new for each task. No environment can be named this (RESERVED_FILES).
37
+ */
38
+ export const SHORT_LIVED_GITHUB_ENVIRONMENT = 'short-lived';
39
+
40
+ /**
41
+ * The GitHub environment an environment's run applies in, and the one its OIDC token must name: `short-lived` for
42
+ * every short-lived environment, otherwise the environment's own name. The provider's scope still keeps each apply
43
+ * inside its own environment's resources.
44
+ * @param {{ name: string, kind: string }} env
45
+ */
46
+ export const runnerEnvironment = (env) => (env.kind === 'short-lived' ? SHORT_LIVED_GITHUB_ENVIRONMENT : env.name);
47
+
33
48
  /** The states a plan may be in when the board hands it to its run: approved, or applying once the board started it. */
34
49
  export const RUNNABLE_STATES = ['approved', 'applying'];
35
50
  /** What a run reports, in order: about to apply, then one of the two ends. */
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Architect's short-lived environments, the pure part (docs/specs/IDEA-19-architect.md, "Short-lived environments";
3
+ * BRK-200): a task asks for an environment of its own, the board makes it from the repository's template through a
4
+ * plan, and removes it through a plan when the task closes, or once it has outlived the grace period.
5
+ *
6
+ * The template is `.github/breakaway-infra/short-lived.json` on the repository's default branch, a reserved name
7
+ * (RESERVED_FILES in infra-desired.js): a desired-state file with a `provider` and a `target`, where `{environment}` in
8
+ * a resource's id or name, or in the target, becomes the new environment's name, so each task's resources are its own:
9
+ *
10
+ * { "version": 1, "provider": "cloudflare", "target": "app-{environment}",
11
+ * "resources": [ { "id": "app-{environment}", "kind": "worker", "name": "app-{environment}" } ] }
12
+ *
13
+ * Pure and Node-safe, so the CLI can import it: no store and no network. The store's part is
14
+ * store-infra-short-lived.js.
15
+ */
16
+ import { checkDesiredFile, DESIRED_DIR } from './infra-desired.js';
17
+ import { checkTarget } from './infra-environments.js';
18
+ import { InputError } from './model.js';
19
+
20
+ /** @typedef {import('./infra-provider.js').DesiredState} DesiredState */
21
+ /** @typedef {import('./infra-desired.js').DesiredError} DesiredError */
22
+
23
+ export const SHORT_LIVED_FILE = 'short-lived.json';
24
+ export const SHORT_LIVED_PATH = `${DESIRED_DIR}/${SHORT_LIVED_FILE}`;
25
+ /** What a template's names put the environment's name in place of. */
26
+ export const PLACEHOLDER = '{environment}';
27
+ /** The tag a task asks for its own environment with. The owner can ask for one from the board too. */
28
+ export const ASK_TAG = 'environment';
29
+ /** The most short-lived environments one repository has at once, so a tag on many tasks can't make many. */
30
+ export const MAX_SHORT_LIVED = 3;
31
+ /** How long a task's environment lasts while the task stays open, and how long a removal the owner rejected waits. */
32
+ export const GRACE_MS = 14 * 86_400_000;
33
+ /** How long the board waits to try again when a plan couldn't be made: the provider didn't answer, or it's frozen. */
34
+ export const RETRY_MS = 15 * 60_000;
35
+ /** How long a request the board couldn't take (no template, the cap) waits before the board looks at it again. */
36
+ export const REFUSED_RETRY_MS = 60 * 60_000;
37
+
38
+ /**
39
+ * Where a request is: `creating` until the plan that makes it ends, `ready` after, `removing` once the plan that
40
+ * removes it is made, `removed` once it's gone (the row stays, so a tag doesn't ask again), and `refused` when the
41
+ * board couldn't make it (no template, no provider, the cap), with why.
42
+ */
43
+ export const SHORT_LIVED_STATES = ['creating', 'ready', 'removing', 'removed', 'refused'];
44
+
45
+ /** A plan's states that are still open: one in these hasn't ended yet. */
46
+ export const OPEN_PLAN_STATES = ['draft', 'waiting', 'approved', 'applying'];
47
+
48
+ /** The longest name an environment has (infra-environments.js), which a template's names must still fit with. */
49
+ const LONGEST = 'x'.repeat(40);
50
+
51
+ /**
52
+ * The name a task's environment takes: its work ID, lowercased (`brk-12`), or `task-` and the start of its UUID when
53
+ * it has none yet.
54
+ * @param {{ uuid: string, wid?: string | null }} task
55
+ */
56
+ export function shortLivedName(task) {
57
+ const wid = String(task.wid ?? '')
58
+ .trim()
59
+ .toLowerCase();
60
+ if (/^[a-z0-9][a-z0-9-]{0,39}$/u.test(wid)) return wid;
61
+ return `task-${String(task.uuid).replace(/-/gu, '').slice(0, 8).toLowerCase()}`;
62
+ }
63
+
64
+ /** @param {string} text @param {string} name */
65
+ const fill = (text, name) => text.split(PLACEHOLDER).join(name);
66
+
67
+ /**
68
+ * Checks a template's text. The result is the template, or the first thing wrong with it, with its line and field,
69
+ * the way a desired-state file's are (infra-desired.js). With `provider` (the template's provider from the registry,
70
+ * when it's connected), each resource's kind must be one it declares.
71
+ * @param {string} source
72
+ * @param {{ provider?: import('./infra-provider.js').Provider | null }} [options]
73
+ * @returns {{ ok: true, template: { provider: string, target: string, resources: DesiredState['resources'] } } | { ok: false, error: DesiredError }}
74
+ */
75
+ export function checkShortLivedTemplate(source, { provider = null } = {}) {
76
+ const checked = checkDesiredFile(source, { provider, extra: ['target'] });
77
+ if ('error' in checked) return { ok: false, error: checked.error };
78
+ const file = JSON.parse(String(source));
79
+ const wrong = (field, message) => ({ ok: /** @type {const} */ (false), error: { line: null, field, message } });
80
+ if (!checked.provider)
81
+ return wrong('provider', 'provider is the ID of the provider short-lived environments run on, like cloudflare');
82
+ if (typeof file.target !== 'string' || !file.target.includes(PLACEHOLDER))
83
+ return wrong(
84
+ 'target',
85
+ `target is what each environment runs on at its provider, with ${PLACEHOLDER} in it, like app-${PLACEHOLDER}`,
86
+ );
87
+ try {
88
+ checkTarget(fill(file.target, LONGEST));
89
+ } catch (error) {
90
+ return wrong('target', error.message);
91
+ }
92
+ const ids = checked.desired.resources.filter((r) => !r.id.includes(PLACEHOLDER));
93
+ if (ids.length)
94
+ return wrong(
95
+ 'resources',
96
+ `${ids[0].id} has no ${PLACEHOLDER} in its id: every task’s environment would share it, so put the name in`,
97
+ );
98
+ return {
99
+ ok: true,
100
+ template: { provider: checked.provider, target: file.target, resources: checked.desired.resources },
101
+ };
102
+ }
103
+
104
+ /**
105
+ * One environment's provider, target, and desired state, from the template and its name.
106
+ * @param {{ provider: string, target: string, resources: DesiredState['resources'] }} template
107
+ * @param {string} name
108
+ * @returns {{ provider: string, target: string, desired: DesiredState }}
109
+ */
110
+ export function renderShortLived(template, name) {
111
+ const target = checkTarget(fill(template.target, name));
112
+ if (!target) throw new InputError('the template’s target is empty');
113
+ return {
114
+ provider: template.provider,
115
+ target,
116
+ desired: {
117
+ resources: template.resources.map((r) => ({
118
+ ...structuredClone(r),
119
+ id: fill(r.id, name),
120
+ name: fill(r.name, name),
121
+ })),
122
+ },
123
+ };
124
+ }
125
+
126
+ /**
127
+ * What the board does next for a request, from where it is and its task (pure, so the order is tested on its own):
128
+ * `create` (make the plan that makes it), `reject-create` (the task closed while that plan still waits for the owner),
129
+ * `remove` (make the plan that removes it), `removed` (its removal applied, or its environment is gone), `ready` (the
130
+ * plan that made it ended), `keep` (the owner rejected a removal or it failed: wait out the grace), or `wait`.
131
+ * @param {{ state: string, created: number, next_try: number | null, create_plan: number | null, remove_plan: number | null }} row
132
+ * @param {{ closed: boolean, environment: boolean, createPlan: string | null, removePlan: string | null, now: number }} at
133
+ * `createPlan` and `removePlan` are those plans' states, when there are any
134
+ */
135
+ export function shortLivedNext(row, { closed, environment, createPlan, removePlan, now }) {
136
+ if (row.state === 'removed' || row.state === 'refused') return 'wait';
137
+ if (!environment) return 'removed';
138
+ const due = !row.next_try || row.next_try <= now;
139
+ if (removePlan) {
140
+ if (removePlan === 'applied') return 'removed';
141
+ if (OPEN_PLAN_STATES.includes(removePlan)) return 'wait';
142
+ return 'keep';
143
+ }
144
+ if (createPlan && OPEN_PLAN_STATES.includes(createPlan)) {
145
+ if (closed && ['draft', 'waiting'].includes(createPlan)) return 'reject-create';
146
+ return 'wait';
147
+ }
148
+ const expired = now - row.created >= GRACE_MS;
149
+ if ((closed || expired) && due) return 'remove';
150
+ if (row.state === 'creating' && createPlan) return 'ready';
151
+ if (row.state === 'creating' && !createPlan && !closed && due) return 'create';
152
+ return 'wait';
153
+ }
154
+
155
+ /**
156
+ * A request as the API shows it.
157
+ * @param {Record<string, any>} row
158
+ * @param {{ task: { uuid: string, wid: string | null, description: string } | null }} context
159
+ */
160
+ export function shortLivedView(row, { task }) {
161
+ const at = (/** @type {number | null} */ ms) => (ms ? new Date(Number(ms)).toISOString() : null);
162
+ return {
163
+ task,
164
+ repo: row.repo,
165
+ name: row.name,
166
+ environment: row.environment === null || row.environment === undefined ? null : Number(row.environment),
167
+ state: row.state,
168
+ askedBy: row.asked_by,
169
+ createPlan: row.create_plan ? `plan-${row.create_plan}` : null,
170
+ removePlan: row.remove_plan ? `plan-${row.remove_plan}` : null,
171
+ error: row.error ?? null,
172
+ nextTry: at(row.next_try),
173
+ removeBy: row.state === 'removed' ? null : at(Number(row.created) + GRACE_MS),
174
+ created: at(row.created),
175
+ updated: at(row.updated),
176
+ };
177
+ }
@@ -3,8 +3,9 @@
3
3
  # Apply infrastructure: applies one plan the owner approved on the board to one environment, and nothing else. The
4
4
  # board starts it when a plan is approved; without a plan the board approved for this environment and this run, it
5
5
  # stops before it reads the write token. That token is CLOUDFLARE_API_TOKEN, a secret of the GitHub environment named
6
- # for the environment, which only {{branchName}} may use. It checks out none of this repository's code: it runs
7
- # breakaway's runner, pinned to {{version}}, and reports each step to the board. Agents never run this workflow.
6
+ # for the environment (for every short-lived environment, the one GitHub environment named short-lived), which only
7
+ # {{branchName}} may use. It checks out none of this repository's code: it runs breakaway's runner, pinned to
8
+ # {{version}}, and reports each step to the board. Agents never run this workflow.
8
9
  name: Apply infrastructure
9
10
  run-name: Apply plan ${{ inputs.plan }} to ${{ inputs.environment }}
10
11
 
@@ -18,9 +19,19 @@ on:
18
19
  environment:
19
20
  description: The environment the plan is for
20
21
  required: true
22
+ {{#if shortLived}}
23
+ type: string
24
+ {{/if}}
25
+ {{#if fixedOnly}}
21
26
  type: choice
22
27
  options:
23
28
  {{@environments}}
29
+ {{/if}}
30
+ github_environment:
31
+ description: The GitHub environment that holds its write token, when it isn't the environment's own (short-lived)
32
+ required: false
33
+ default: ''
34
+ type: string
24
35
 
25
36
  permissions: {}
26
37
 
@@ -35,7 +46,7 @@ jobs:
35
46
  if: github.ref == 'refs/heads/{{branchName}}'
36
47
  runs-on: ubuntu-latest
37
48
  timeout-minutes: 30
38
- environment: ${{ inputs.environment }}
49
+ environment: ${{ inputs.github_environment || inputs.environment }}
39
50
  # id-token proves to the board which repository, environment, workflow, and run asks for the plan.
40
51
  permissions:
41
52
  contents: read