breakaway 2.0.0-main.49 → 2.0.0-main.50

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.49",
3
+ "version": "2.0.0-main.50",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "breakaway",
9
- "version": "2.0.0-main.49",
9
+ "version": "2.0.0-main.50",
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.49",
3
+ "version": "2.0.0-main.50",
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",
@@ -34,7 +34,9 @@ import {
34
34
  discover,
35
35
  priceResource,
36
36
  reader,
37
+ refuses,
37
38
  round,
39
+ workerConsumer,
38
40
  } from './infra-cloudflare.js';
39
41
 
40
42
  /** @typedef {import('./infra-provider.js').ProviderContext} ProviderContext */
@@ -65,6 +67,8 @@ const LOST = {
65
67
  const ORDER = [
66
68
  ['create', ['d1', 'kv', 'r2', 'queue']],
67
69
  ['update', ['d1', 'kv', 'r2', 'queue', 'container']],
70
+ ['scale', ['queue', 'container']],
71
+ ['restart', ['container']],
68
72
  ['create', ['worker']],
69
73
  ['update', ['worker']],
70
74
  ['create', ['route', 'custom-domain']],
@@ -73,6 +77,13 @@ const ORDER = [
73
77
  ['delete', ['worker']],
74
78
  ['delete', ['container', 'queue', 'r2', 'kv', 'd1']],
75
79
  ];
80
+ /**
81
+ * Settings Cloudflare moves by itself between a plan and its apply (a container application's instance counts), so
82
+ * they never make a change look out of date.
83
+ */
84
+ const LIVE = { container: ['instances', 'active', 'assigned'] };
85
+ const settled = (kind, attrs) =>
86
+ Object.fromEntries(Object.entries(attrs ?? {}).filter(([k]) => !(LIVE[kind] ?? []).includes(k)));
76
87
  const rank = (c) => ORDER.findIndex(([op, kinds]) => op === c.op && kinds.includes(c.kind));
77
88
 
78
89
  /** Binding types whose targets are a resource of a kind Architect makes, and the field that names it. */
@@ -581,8 +592,10 @@ export async function apply(ctx, p) {
581
592
  const exists = h ?? [...have.values()].find((x) => x.kind === c.kind && x.name === c.name);
582
593
  if (exists) throw new Error(`${c.name} already exists: plan again`);
583
594
  } else if (!h) throw new Error(`${c.name} is no longer in ${ctx.environment}: plan again`);
584
- else if (!same(redactAttrs(h.attrs ?? {}), c.before))
595
+ else if (!same(settled(c.kind, redactAttrs(h.attrs ?? {})), settled(c.kind, c.before)))
585
596
  throw new Error(`${c.name} changed since it was planned: plan again`);
597
+ const refused = h ? refuses(h, c.op) : null;
598
+ if (refused) throw new Error(refused);
586
599
  await applyChange(c, { call, a, live, have, made, resolve });
587
600
  steps.push({ resource: c.resource, op: c.op, ok: true });
588
601
  } catch (error) {
@@ -670,6 +683,7 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
670
683
  case 'queue': {
671
684
  const perm = { permission: 'Queues Write' };
672
685
  if (c.op === 'delete') return call('DELETE', `${a}/queues/${id}`, perm);
686
+ if (c.op === 'scale') return scaleConsumer(call, `${a}/queues/${id}`, c.name, after.maxConcurrency, perm);
673
687
  const settings = Object.fromEntries(
674
688
  [
675
689
  ['delivery_delay', after.deliveryDelay],
@@ -677,7 +691,13 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
677
691
  ['message_retention_period', after.retention],
678
692
  ].filter(([, v]) => v !== undefined && v !== null),
679
693
  );
680
- if (c.op === 'update') return call('PATCH', `${a}/queues/${id}`, { ...perm, json: { settings } });
694
+ if (c.op === 'update') {
695
+ if (['deliveryDelay', 'deliveryPaused', 'retention'].some(changed))
696
+ await call('PATCH', `${a}/queues/${id}`, { ...perm, json: { settings } });
697
+ if (changed('maxConcurrency'))
698
+ await scaleConsumer(call, `${a}/queues/${id}`, c.name, after.maxConcurrency, perm);
699
+ return;
700
+ }
681
701
  await call('POST', `${a}/queues`, {
682
702
  ...perm,
683
703
  json: { queue_name: c.name, ...(Object.keys(settings).length ? { settings } : {}) },
@@ -687,11 +707,17 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
687
707
  }
688
708
  case 'container': {
689
709
  const perm = { permission: 'Containers Write' };
690
- if (c.op === 'delete') return call('DELETE', `${a}/containers/applications/${id}`, perm);
691
- return call('PATCH', `${a}/containers/applications/${id}`, {
692
- ...perm,
693
- json: { max_instances: after.maxInstances },
694
- });
710
+ const p = `${a}/containers/applications/${id}`;
711
+ if (c.op === 'delete') return call('DELETE', p, perm);
712
+ if (c.op === 'restart') return restartContainer(call, p, c.name, perm);
713
+ await call('PATCH', p, { ...perm, json: { max_instances: after.maxInstances } });
714
+ // Read it back: the scale is done only once Cloudflare reports the new maximum.
715
+ const app = await call('GET', p, perm);
716
+ if (app?.max_instances !== after.maxInstances)
717
+ throw new Error(
718
+ `Cloudflare still reports ${c.name} at ${app?.max_instances ?? 'no'} max instances, not ${after.maxInstances}`,
719
+ );
720
+ return;
695
721
  }
696
722
  case 'route': {
697
723
  const perm = { permission: 'Workers Routes Write' };
@@ -724,6 +750,65 @@ async function applyChange(c, { call, a, live, have, made, resolve }) {
724
750
  }
725
751
  }
726
752
 
753
+ /**
754
+ * Sets a queue's Worker consumer's `max_concurrency` (BRK-188: `PUT …/consumers/{consumer}`, which replaces the
755
+ * consumer, so its other settings are sent as Cloudflare has them), then reads it back. The consumer is read with the
756
+ * write token at apply, so the plan never holds its ID.
757
+ * @param {ReturnType<typeof writer>} call
758
+ * @param {string} q the queue's path
759
+ * @param {string} name
760
+ * @param {unknown} to
761
+ * @param {{ permission: string }} perm
762
+ */
763
+ async function scaleConsumer(call, q, name, to, perm) {
764
+ const consumer = workerConsumer(await call('GET', `${q}/consumers`, perm));
765
+ if (!consumer?.consumer_id) throw new Error(`${name} has no Worker consuming it, so there’s no concurrency to scale`);
766
+ const p = `${q}/consumers/${encodeURIComponent(String(consumer.consumer_id))}`;
767
+ await call('PUT', p, {
768
+ ...perm,
769
+ json: {
770
+ type: 'worker',
771
+ script_name: String(consumer.script ?? consumer.script_name ?? consumer.service),
772
+ ...(consumer.dead_letter_queue ? { dead_letter_queue: consumer.dead_letter_queue } : {}),
773
+ settings: { ...(consumer.settings ?? {}), max_concurrency: to ?? undefined },
774
+ },
775
+ });
776
+ const now = workerConsumer(await call('GET', `${q}/consumers`, perm));
777
+ if ((now?.settings?.max_concurrency ?? null) !== (to ?? null))
778
+ throw new Error(
779
+ `Cloudflare still reports ${name}’s consumer at ${now?.settings?.max_concurrency ?? 'automatic'} concurrency, not ${to ?? 'automatic'}`,
780
+ );
781
+ }
782
+
783
+ /**
784
+ * Restarts a container application (BRK-188's Restart row): a rollout of the configuration it runs now, so every
785
+ * instance is replaced step by step, each after SIGTERM and up to 15 minutes to drain. The body is the one Wrangler
786
+ * sends for a deploy's rollout (`strategy: rolling`, `kind: full_auto`); Cloudflare's API reference shows the endpoint
787
+ * but not its body, so BRK-207's staging run confirms it. The configuration is read with the write token at apply and
788
+ * passed through as it is: the board never stores it.
789
+ * @param {ReturnType<typeof writer>} call
790
+ * @param {string} p the application's path
791
+ * @param {string} name
792
+ * @param {{ permission: string }} perm
793
+ */
794
+ async function restartContainer(call, p, name, perm) {
795
+ const app = await call('GET', p, perm);
796
+ if (!isObject(app?.configuration)) throw new Error(`Cloudflare didn’t say what ${name} runs, so it wasn’t restarted`);
797
+ const max = Number(app.max_instances ?? 0);
798
+ const rollout = await call('POST', `${p}/rollouts`, {
799
+ ...perm,
800
+ json: {
801
+ description: 'Restarted by breakaway',
802
+ strategy: 'rolling',
803
+ kind: 'full_auto',
804
+ // One step for a single instance, as Wrangler does; otherwise a tenth at a time.
805
+ step_percentage: max < 2 ? 100 : 10,
806
+ target_configuration: app.configuration,
807
+ },
808
+ });
809
+ if (!rollout?.id) throw new Error(`Cloudflare didn’t start a rollout of ${name}`);
810
+ }
811
+
727
812
  /**
728
813
  * Rolls a Worker back to the versions that were live before an apply (`before.versions` of its change): a deployment
729
814
  * of them, as they were split. Cloudflare refuses a rollback across a secret change unless it's forced, and only the
@@ -42,17 +42,57 @@ export const MANAGED = {
42
42
  d1: [],
43
43
  kv: [],
44
44
  r2: ['cors', 'lifecycle'],
45
- queue: ['deliveryDelay', 'deliveryPaused', 'retention'],
45
+ queue: ['deliveryDelay', 'deliveryPaused', 'retention', 'maxConcurrency'],
46
46
  container: ['maxInstances'],
47
47
  route: ['worker'],
48
48
  'custom-domain': ['worker', 'environment'],
49
49
  };
50
50
 
51
- /** BRK-227 adds `scale` to queues and `scale` and `restart` to containers. */
51
+ /**
52
+ * What envelopes may change, by kind (BRK-188's "Scale and restart, for envelopes"; BRK-227): a container application
53
+ * scales its `max_instances` and restarts with a rollout of its configuration, and a queue scales its Worker
54
+ * consumer's `max_concurrency`. Cloudflare scales every other kind itself, so none of them scales or restarts.
55
+ */
56
+ const ENVELOPE = {
57
+ container: { changes: ['scale', 'restart'], scales: 'maxInstances' },
58
+ queue: { changes: ['scale'], scales: 'maxConcurrency' },
59
+ };
52
60
  export const CLOUDFLARE_KINDS = Object.fromEntries(
53
- Object.entries(MANAGED).map(([k, settings]) => [k, { changes: [...BASE], settings: [...settings] }]),
61
+ Object.entries(MANAGED).map(([k, settings]) => [
62
+ k,
63
+ ENVELOPE[k]
64
+ ? { changes: [...BASE, ...ENVELOPE[k].changes], scales: ENVELOPE[k].scales, settings: [...settings] }
65
+ : { changes: [...BASE], settings: [...settings] },
66
+ ]),
54
67
  );
55
68
 
69
+ /** The scheduling policy whose container applications Cloudflare scales and rolls out; any other is the code's. */
70
+ export const DEFAULT_SCHEDULING = 'default';
71
+
72
+ /**
73
+ * Why Cloudflare can't make an envelope's change to this resource, in words, or null when it can. A kind that never
74
+ * scales or restarts says the platform does it; a container application on another scheduling policy (the Durable
75
+ * Object one) is started and stopped by its own code; a queue with no Worker consuming it has no concurrency to set.
76
+ * @param {Resource} r
77
+ * @param {string} op
78
+ * @returns {string | null}
79
+ */
80
+ export function refuses(r, op) {
81
+ if (op !== 'scale' && op !== 'restart') return null;
82
+ if (!CLOUDFLARE_KINDS[r.kind]?.changes.includes(op))
83
+ return r.kind === 'queue'
84
+ ? `${r.name} is a queue: Cloudflare has no restart for one, only its consumer’s concurrency scales`
85
+ : `${r.name} is a ${r.kind}: Cloudflare scales it by itself, so there’s nothing to ${op}`;
86
+ const attrs = /** @type {Record<string, unknown>} */ (r.attrs ?? {});
87
+ if (r.kind === 'container' && attrs.schedulingPolicy !== DEFAULT_SCHEDULING)
88
+ return attrs.schedulingPolicy
89
+ ? `${r.name} runs on the ${attrs.schedulingPolicy} scheduling policy: its own code starts and stops its instances, so Cloudflare can’t ${op} it`
90
+ : `${r.name}’s scheduling policy isn’t known, so it can’t ${op}: discover it again`;
91
+ if (r.kind === 'queue' && !(/** @type {any[]} */ (attrs.consumers ?? []).some((c) => c?.type === 'worker')))
92
+ return `${r.name} has no Worker consuming it, so there’s no concurrency to scale`;
93
+ return null;
94
+ }
95
+
56
96
  /**
57
97
  * The board's read-only token (BRK-188, "Tokens"; BRK-194 keeps it). Read only: no permission ends in Edit or Write.
58
98
  * @type {import('./infra-provider.js').ReadToken['permissions']}
@@ -70,6 +110,10 @@ export const READ_PERMISSIONS = [
70
110
  { name: 'Workers Routes Read', for: 'routes' },
71
111
  ];
72
112
 
113
+ /** The consumer a queue's scale changes: its Worker consumer (Cloudflare allows one), or undefined. */
114
+ export const workerConsumer = (consumers) =>
115
+ (consumers ?? []).find((c) => String(c?.type ?? 'worker') === 'worker' && (c.script ?? c.script_name ?? c.service));
116
+
73
117
  /** At most this many Workers in one environment's scope, and pages of one list: far more than a repository runs. */
74
118
  const MAX_WORKERS = 50;
75
119
  const MAX_PAGES = 20;
@@ -430,7 +474,10 @@ export async function discover(ctx, { live = false } = {}) {
430
474
  deliveryDelay: q.settings?.delivery_delay ?? null,
431
475
  deliveryPaused: Boolean(q.settings?.delivery_paused),
432
476
  retention: q.settings?.message_retention_period ?? null,
477
+ // The Worker consumer's concurrency, which a scale sets (null: Cloudflare picks it).
478
+ maxConcurrency: workerConsumer(consumers)?.settings?.max_concurrency ?? null,
433
479
  consumers: consumers.map((c) => ({
480
+ type: String(c.type ?? 'worker'),
434
481
  worker: String(c.script ?? c.service ?? ''),
435
482
  batchSize: c.settings?.batch_size ?? null,
436
483
  maxRetries: c.settings?.max_retries ?? null,
@@ -1133,6 +1180,7 @@ export const cloudflare = {
1133
1180
  url: 'https://dash.cloudflare.com/profile/api-tokens',
1134
1181
  check: checkToken,
1135
1182
  },
1183
+ refuses,
1136
1184
  discover,
1137
1185
  plan: (ctx, desired) => plan(ctx, desired),
1138
1186
  apply: (ctx, p) => apply(ctx, p),
@@ -187,6 +187,9 @@ export const COST_NOTE_MAX = 500;
187
187
  * @property {string} id short and lowercase, like `fake`
188
188
  * @property {string} name what people see
189
189
  * @property {Record<string, KindSpec>} kinds the resource kinds it discovers and plans
190
+ * @property {(resource: Resource, op: string) => string | null} [refuses] why the platform can't make an envelope's
191
+ * `scale` or `restart` to this one resource although its kind declares it (a setting of the resource rules it out),
192
+ * in words, or null when it can; an act asks it before it plans (BRK-227)
190
193
  * @property {ReadToken} [readToken] the read-only token it needs, if any
191
194
  * @property {(ctx: ProviderContext) => Promise<Discovery>} discover
192
195
  * @property {(ctx: ProviderContext, desired: DesiredState) => Promise<PlanDiff>} plan
@@ -250,6 +253,7 @@ export function checkProvider(provider) {
250
253
  fail(what, `${kind}'s scales names the setting a scale changes, and only a kind that scales has one`);
251
254
  }
252
255
  if (provider.readToken !== undefined) checkReadToken(what, provider.readToken);
256
+ if (provider.refuses !== undefined && typeof provider.refuses !== 'function') fail(what, 'refuses is not a function');
253
257
  if (provider.estimate !== undefined && typeof provider.estimate !== 'function')
254
258
  fail(what, 'estimate is not a function');
255
259
  return provider;