@wtfalch/foundry 0.65.0 → 0.67.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.
Files changed (88) hide show
  1. package/dist/apps/cloudflare.d.ts +5 -0
  2. package/dist/apps/deploy.js +33 -1
  3. package/dist/apps/migrations.d.ts +3 -0
  4. package/dist/apps/migrations.js +32 -7
  5. package/dist/catalogue/copy-en.js +5 -0
  6. package/dist/catalogue/integrations.js +5 -0
  7. package/dist/entry-rules.d.ts +11 -0
  8. package/dist/entry-rules.js +70 -0
  9. package/dist/index.d.ts +9 -4
  10. package/dist/index.js +5 -2
  11. package/dist/migrations/0069_release_events.sql +16 -0
  12. package/dist/modules/analytics.d.ts +8 -0
  13. package/dist/modules/analytics.js +28 -0
  14. package/dist/modules/database.js +22 -3
  15. package/dist/modules/index.js +2 -0
  16. package/dist/native-build.d.ts +16 -12
  17. package/dist/native-build.js +16 -12
  18. package/dist/org.d.ts +9 -1
  19. package/dist/org.js +4 -1
  20. package/dist/plan/analytics-boule.d.ts +17 -0
  21. package/dist/plan/analytics-boule.js +43 -0
  22. package/dist/plan/analytics.d.ts +58 -0
  23. package/dist/plan/analytics.js +89 -0
  24. package/dist/plan/boule.js +2 -0
  25. package/dist/plan/company-backups.js +2 -0
  26. package/dist/plan/diff.d.ts +4 -0
  27. package/dist/plan/diff.js +103 -3
  28. package/dist/plan/entry-analytics-key.d.ts +35 -0
  29. package/dist/plan/entry-analytics-key.js +134 -0
  30. package/dist/plan/executor.d.ts +79 -6
  31. package/dist/plan/executor.js +379 -19
  32. package/dist/plan/hostnames.js +4 -0
  33. package/dist/plan/index.d.ts +2 -0
  34. package/dist/plan/index.js +1 -0
  35. package/dist/plan/live.d.ts +1 -1
  36. package/dist/plan/locked-hostnames.js +3 -0
  37. package/dist/plan/login-host.d.ts +8 -0
  38. package/dist/plan/login-host.js +8 -0
  39. package/dist/plan/org-names.d.ts +3 -3
  40. package/dist/plan/org-names.js +3 -0
  41. package/dist/plan/own-limits.d.ts +6 -0
  42. package/dist/plan/own-limits.js +1 -0
  43. package/dist/plan/plan-apply.d.ts +12 -0
  44. package/dist/plan/plan-apply.js +40 -1
  45. package/dist/plan/platform-diff.d.ts +17 -1
  46. package/dist/plan/platform-diff.js +40 -2
  47. package/dist/plan/platform-org.d.ts +6 -0
  48. package/dist/plan/platform-providers.d.ts +8 -6
  49. package/dist/plan/platform-providers.js +56 -10
  50. package/dist/plan/providers-readers.js +16 -1
  51. package/dist/plan/release-host.d.ts +30 -1
  52. package/dist/plan/release-host.js +84 -41
  53. package/dist/plan/release-series-reader.d.ts +26 -0
  54. package/dist/plan/release-series-reader.js +40 -0
  55. package/dist/plan/sign-in-archon.d.ts +1 -1
  56. package/dist/plan/sign-in-archon.js +2 -2
  57. package/dist/plan/stage-services.d.ts +3 -1
  58. package/dist/plan/stage-services.js +2 -1
  59. package/dist/plan/uptime-worker.js +53 -3
  60. package/dist/plan/worker-app-database.d.ts +12 -1
  61. package/dist/plan/worker-app-database.js +13 -0
  62. package/dist/plan/worker-app.d.ts +27 -2
  63. package/dist/plan/worker-app.js +52 -7
  64. package/dist/plan/workspace.d.ts +2 -2
  65. package/dist/plan/workspace.js +3 -3
  66. package/dist/release/decide.d.ts +2 -0
  67. package/dist/release/decide.js +67 -10
  68. package/dist/release/events.d.ts +13 -0
  69. package/dist/release/events.js +67 -0
  70. package/dist/release/health.d.ts +5 -0
  71. package/dist/release/health.js +3 -3
  72. package/dist/release/run.d.ts +7 -2
  73. package/dist/release/run.js +8 -0
  74. package/dist/release/series.d.ts +58 -0
  75. package/dist/release/series.js +65 -0
  76. package/dist/release/state.d.ts +58 -1
  77. package/dist/release/state.js +45 -0
  78. package/dist/release/types.d.ts +44 -0
  79. package/dist/slug.d.ts +1 -1
  80. package/dist/slug.js +5 -2
  81. package/dist/store/postgres.js +29 -3
  82. package/dist/store/schema.d.ts +112 -0
  83. package/dist/store/schema.js +9 -0
  84. package/dist/worker-app-config.d.ts +22 -1
  85. package/dist/worker-app-config.js +73 -15
  86. package/dist/worker-app-migrations.d.ts +6 -2
  87. package/dist/worker-app-migrations.js +1 -0
  88. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomBytes } from 'node:crypto';
2
- import { APPS_DOMAIN } from '../org.js';
2
+ import { APPS_DOMAIN, heldEnvRef } from '../org.js';
3
3
  import { WORKER_APP_KEYS_SECRET, WORKER_APP_KEYS_URL_VAR, workerAppHasBuild, workerAppHostId, } from '../worker-app-config.js';
4
4
  import { WORKER_APP_DNS_RECORD, pendingWorkerAppMigrations, strayContainerImage, withCallTimeout, workerAppRegistryPrefix, workerAppShape, workerAppUpload, } from '../worker-app-upload.js';
5
5
  import { entryAuthEnv } from './broker-entries.js';
@@ -92,6 +92,10 @@ export const WORKER_APP_AUTH_KEY_SECRET = 'AUTH_APP_KEY';
92
92
  */
93
93
  export const workerAppSecretRef = (orgId, entryKey, name) => `foundry:worker-app:${orgId}:${createHash('sha256').update(`${entryKey}:${name}`).digest('hex').slice(0, 32)}`;
94
94
  const secretVersion = (value) => `g1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
95
+ /** The version recorded for a `heldSecret` value: a short hash, so a changed value is delivered again. Never the value. */
96
+ export const heldSecretVersion = (value) => `h1-${createHash('sha256').update(value).digest('hex').slice(0, 16)}`;
97
+ /** `<entry key>/<NAME>` of a `heldSecret`: how the plan's readiness names it. */
98
+ export const heldSecretId = (entryKey, name) => `${entryKey}/${name}`;
95
99
  /** Foundry mints a new credential this long before the held one expires. */
96
100
  export const WORKER_APP_CREDENTIAL_RENEW_BEFORE_MS = 30 * 24 * 60 * 60 * 1000;
97
101
  /**
@@ -112,8 +116,8 @@ export const workerAppApex = (org, entry) => entry && workspaceWorkerParent(entr
112
116
  : org.hosting.usesDomain && org.domain
113
117
  ? org.domain
114
118
  : WORKER_APP_OIKOS_ZONE;
115
- /** True when the want has a secret that must be held (a generated one, the broker key, a custody credential). */
116
- const holdsGenerated = (want) => want.secrets.length > 0;
119
+ /** True when the want has a secret foundry must hold (a generated one, the broker key, a custody credential). A `heldSecret` is the company's: it is only read. */
120
+ const holdsGenerated = (want) => want.secrets.some((s) => s.source !== 'heldSecret');
117
121
  /**
118
122
  * What the Worker must look like, or the inputs still missing (names only).
119
123
  * `issuer` is the sign-in issuer the plan read; `keysUrl` the custody Worker.
@@ -123,8 +127,16 @@ export function wantedWorkerApp(org, entry, recorded, input) {
123
127
  const missing = [];
124
128
  const vars = { ...app.vars };
125
129
  const secrets = [];
130
+ const unset = [];
126
131
  for (const [name, source] of Object.entries(app.secrets ?? {})) {
127
- if (source.from === 'keysCustody') {
132
+ if (source.from === 'heldSecret') {
133
+ const version = input.heldSecrets?.[heldSecretId(entry.key, name)];
134
+ if (version)
135
+ secrets.push({ name, source: 'heldSecret', version });
136
+ else
137
+ unset.push(name);
138
+ }
139
+ else if (source.from === 'keysCustody') {
128
140
  const hostId = workerAppHostId(org.id, entry.key);
129
141
  if (input.keysUrl === undefined)
130
142
  missing.push('the keys custody port');
@@ -165,7 +177,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
165
177
  if (missing.length > 0)
166
178
  return { missing };
167
179
  const shape = workerAppShape(app);
180
+ const crons = [...(app.triggers?.crons ?? [])].sort();
168
181
  return {
182
+ unset: unset.sort(),
169
183
  want: {
170
184
  name: workerAppName(org, entry),
171
185
  artifactUrl: shape.artifactUrl,
@@ -182,6 +196,9 @@ export function wantedWorkerApp(org, entry, recorded, input) {
182
196
  ? {
183
197
  database: {
184
198
  appendOnly: [...(app.database.appendOnly ?? [])].sort(),
199
+ ...(app.database.noDelete?.length
200
+ ? { noDelete: [...app.database.noDelete].sort() }
201
+ : {}),
185
202
  ...(app.database.grants?.length ? { grants: [...app.database.grants].sort() } : {}),
186
203
  ...(app.database.migrationsSha256
187
204
  ? { migrationsSha256: app.database.migrationsSha256 }
@@ -189,6 +206,7 @@ export function wantedWorkerApp(org, entry, recorded, input) {
189
206
  },
190
207
  }
191
208
  : {}),
209
+ ...(crons.length > 0 ? { crons } : {}),
192
210
  secrets: secrets.sort((a, b) => a.name.localeCompare(b.name)),
193
211
  },
194
212
  };
@@ -321,7 +339,7 @@ export function planWorkerApps(org, recordedIds, input) {
321
339
  block(`${wanted.missing.join(', ')} not recorded yet`);
322
340
  continue;
323
341
  }
324
- const { want } = wanted;
342
+ const { want, unset } = wanted;
325
343
  const stray = strayContainerImage(app, input.registryAccountId);
326
344
  if (stray) {
327
345
  block(input.registryAccountId === undefined
@@ -345,7 +363,9 @@ export function planWorkerApps(org, recordedIds, input) {
345
363
  ? `its Access service token ${recordedIds[remoteDatabaseKeys(entry.key).undelivered]} was minted and its secret never held; delete the token in Cloudflare and clear the recorded id before minting again`
346
364
  : !input.canHoldSecrets
347
365
  ? 'its database credentials need a held-key store that can put, and the host has none'
348
- : null;
366
+ : want.database.noDelete?.length && !db.noDeleteCheck
367
+ ? "its noDelete tables need a database port that reads the role's rights back (deletableTables), and the host's has none"
368
+ : null;
349
369
  if (why) {
350
370
  block(why);
351
371
  continue;
@@ -381,7 +401,7 @@ export function planWorkerApps(org, recordedIds, input) {
381
401
  desired: { mode: 'apply', ...want },
382
402
  },
383
403
  step: {
384
- description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}`,
404
+ description: `${first ? 'deploy' : 'update'} ${at} at ${want.host}${risk}${renew ? ' (renews its keys custody credential, which expires within 30 days)' : ''}${unset.length > 0 ? ` (no value is held yet for secret ${unset.join(', ')}: the Worker runs without ${unset.length === 1 ? 'it' : 'them'} until the company adds ${unset.length === 1 ? 'it' : 'them'}, then the next apply sets ${unset.length === 1 ? 'it' : 'them'})` : ''}`,
385
405
  destroys: deletes.length > 0,
386
406
  migrates: renames.length > 0,
387
407
  },
@@ -465,6 +485,15 @@ export async function applyWorkerApp(deps, org, action, context) {
465
485
  }
466
486
  // 2. Secrets: before the route, so the Worker never serves without them.
467
487
  await deliverSecrets(deps, org, entryKey, want, context, at, call);
488
+ // 2b. Cron triggers, once the script and its secrets are there. An empty list clears the ones a
489
+ // previous apply set; a Worker that never had any makes no call. Idempotent, so a crash here is
490
+ // repaired by the next apply (the applied marker is not written yet).
491
+ const crons = want.crons ?? [];
492
+ const hadCrons = (parseJson(recorded0[workerAppAppliedScopeKey(entryKey)], {}).crons ?? [])
493
+ .length > 0;
494
+ if (crons.length > 0 || hadCrons) {
495
+ await call('set the cron triggers', () => workers.setSchedules(want.name, crons));
496
+ }
468
497
  // 3. Containers.
469
498
  await syncContainers(deps, entryKey, want, context, at, call);
470
499
  // 4. The record on the org's own zone, 5. the route.
@@ -532,6 +561,22 @@ async function deliverSecrets(deps, org, entryKey, want, context, at, call) {
532
561
  if (delivered[secret.name] !== version)
533
562
  generated.push({ name: secret.name, value, version });
534
563
  }
564
+ // Held secrets: the company's value, read from the held-key store. The plan found each one held; a
565
+ // value gone since stops the step, and the error names the ref, never a value.
566
+ for (const secret of want.secrets) {
567
+ if (secret.source !== 'heldSecret')
568
+ continue;
569
+ const ref = heldEnvRef(org.id, entryKey, secret.name);
570
+ const value = await call('read a held secret', async () => heldKeys?.resolve(ref));
571
+ if (!value) {
572
+ throw new Error(`${at}: held secret ${secret.name} is no longer held (ref ${ref}); the next plan leaves it off the Worker`);
573
+ }
574
+ deps.secret?.(value);
575
+ const version = heldSecretVersion(value);
576
+ if (delivered[secret.name] !== version) {
577
+ generated.push({ name: secret.name, value, version });
578
+ }
579
+ }
535
580
  if (generated.length > 0) {
536
581
  await call('deliver secrets', () => workers.setSecrets(want.name, generated));
537
582
  for (const { name, version } of generated)
@@ -37,8 +37,8 @@ export declare const peopleHash: (emails: readonly string[]) => string;
37
37
  export declare function withoutDarkEntries(org: DeclaredOrg): DeclaredOrg;
38
38
  /**
39
39
  * The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
40
- * executor deploy it like any other. The copy has no database and no sign-in env (the workspace gate is its
41
- * sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
40
+ * executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
41
+ * sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
42
42
  * workspaces are read from when `org` has had dark entries removed.
43
43
  */
44
44
  export declare function withWorkspaceWorkers(org: DeclaredOrg, declared?: DeclaredOrg): DeclaredOrg;
@@ -67,8 +67,8 @@ export function withoutDarkEntries(org) {
67
67
  }
68
68
  /**
69
69
  * The org with a second Worker app per Worker-app workspace, keyed `<key>-work`, so the Worker app planner and
70
- * executor deploy it like any other. The copy has no database and no sign-in env (the workspace gate is its
71
- * sign-in; a database would be the live one) and is served at the workspace host. `declared` names the org the
70
+ * executor deploy it like any other. The copy has no database, no sign-in env and no cron triggers (the workspace gate is its
71
+ * sign-in; a database would be the live one, and a schedule would run its jobs with nothing to run them on) and is served at the workspace host. `declared` names the org the
72
72
  * workspaces are read from when `org` has had dark entries removed.
73
73
  */
74
74
  export function withWorkspaceWorkers(org, declared = org) {
@@ -79,7 +79,7 @@ export function withWorkspaceWorkers(org, declared = org) {
79
79
  if (!entry.workspace || !entry.workerApp)
80
80
  continue;
81
81
  const { workspace: _w, live: _l, hostedName: _h, uses: _u, workerApp, ...rest } = entry;
82
- const { database: _d, authEnv: _a, ...app } = workerApp;
82
+ const { database: _d, authEnv: _a, triggers: _t, ...app } = workerApp;
83
83
  const copy = {
84
84
  ...rest,
85
85
  key: `${entry.key}-work`,
@@ -106,6 +106,8 @@ export interface Decision {
106
106
  refused: string[];
107
107
  /** The reason a `copy_prod_to_stage` request was refused here, for `bg.<id>.stagecopy`; else null. */
108
108
  stageRefusal: string | null;
109
+ /** The requested actions that took effect, in order (the same objects as `input.actions`). */
110
+ applied: ReleaseAction[];
109
111
  }
110
112
  export declare function normalizeSteps(steps: readonly number[] | undefined): number[];
111
113
  /** The time a health read should cover: from the step or window start to now. */
@@ -68,7 +68,11 @@ function dailyRate(h) {
68
68
  /** The time a health read should cover: from the step or window start to now. */
69
69
  export function healthRange(r, now) {
70
70
  const w = r.window;
71
- if (w.kind === 'step' || w.kind === 'inconclusive' || w.kind === 'window' || w.kind === 'cap') {
71
+ if (w.kind === 'step' ||
72
+ w.kind === 'inconclusive' ||
73
+ w.kind === 'paused' ||
74
+ w.kind === 'window' ||
75
+ w.kind === 'cap') {
72
76
  if (w.kind === 'inconclusive' && w.percent === 0)
73
77
  return null;
74
78
  return { from: w.since, to: now.toISOString() };
@@ -81,6 +85,7 @@ function applyActions(r, actions, now, out, kind) {
81
85
  let ended = false;
82
86
  let approved = false;
83
87
  let opened = false;
88
+ let stepNow = false;
84
89
  const stageRequests = [];
85
90
  for (const a of actions) {
86
91
  if (a.kind === 'deploy_stage') {
@@ -97,8 +102,10 @@ function applyActions(r, actions, now, out, kind) {
97
102
  const waiting = (kind.advance === 'approval' || capped) &&
98
103
  cur.release !== null &&
99
104
  ((w.kind === 'step' && w.percent > 0) || (w.kind === 'inconclusive' && w.percent > 0));
100
- if (waiting)
105
+ if (waiting) {
101
106
  approved = true;
107
+ out.applied.push(a);
108
+ }
102
109
  else
103
110
  out.refused.push('approve_step refused: no step is waiting for approval');
104
111
  }
@@ -106,10 +113,42 @@ function applyActions(r, actions, now, out, kind) {
106
113
  if (cur.window.kind === 'staff' && cur.release) {
107
114
  cur = stepTo(cur, cur.release, kind.steps[0], now);
108
115
  opened = true;
116
+ out.applied.push(a);
109
117
  }
110
118
  else
111
119
  out.refused.push('open refused: no release is waiting for staff');
112
120
  }
121
+ else if (a.kind === 'pause') {
122
+ const w = cur.window;
123
+ if (w.kind === 'paused')
124
+ out.refused.push('pause refused: the release is already paused');
125
+ else if (cur.release && (w.kind === 'step' || (w.kind === 'inconclusive' && w.percent > 0))) {
126
+ cur = { ...cur, window: { kind: 'paused', percent: w.percent, since: w.since } };
127
+ stepNow = false;
128
+ out.applied.push(a);
129
+ }
130
+ else
131
+ out.refused.push('pause refused: no step is running');
132
+ }
133
+ else if (a.kind === 'resume') {
134
+ const w = cur.window;
135
+ if (w.kind === 'paused' && cur.release) {
136
+ cur = { ...cur, window: { kind: 'step', percent: w.percent, since: w.since } };
137
+ out.applied.push(a);
138
+ }
139
+ else
140
+ out.refused.push('resume refused: the release is not paused');
141
+ }
142
+ else if (a.kind === 'step_now') {
143
+ const w = cur.window;
144
+ if (cur.release &&
145
+ (w.kind === 'step' || w.kind === 'paused' || (w.kind === 'inconclusive' && w.percent > 0))) {
146
+ stepNow = true;
147
+ out.applied.push(a);
148
+ }
149
+ else
150
+ out.refused.push('step_now refused: no step is running');
151
+ }
113
152
  else if (a.kind === 'clear_held') {
114
153
  cur = { ...cur, held: cur.held.filter((d) => d !== a.digest) };
115
154
  }
@@ -134,6 +173,7 @@ function applyActions(r, actions, now, out, kind) {
134
173
  };
135
174
  out.effects.push({ kind: 'deploy', color: to, digest });
136
175
  promoted = true;
176
+ out.applied.push(a);
137
177
  }
138
178
  }
139
179
  else if (a.kind === 'copy_prod_to_stage' || a.kind === 'undo_stage_copy') {
@@ -145,6 +185,7 @@ function applyActions(r, actions, now, out, kind) {
145
185
  else {
146
186
  cur = rollback(cur, now, 'operator');
147
187
  ended = true;
188
+ out.applied.push(a);
148
189
  }
149
190
  }
150
191
  else if (a.kind === 'decide_at_cap') {
@@ -153,11 +194,13 @@ function applyActions(r, actions, now, out, kind) {
153
194
  else if (a.decision === 'rollback') {
154
195
  cur = rollback(cur, now, 'operator at cap');
155
196
  ended = true;
197
+ out.applied.push(a);
156
198
  }
157
199
  else {
158
200
  out.effects.push({ kind: 'stop', color: cur.release.from });
159
201
  cur = { ...cur, window: { kind: 'passed', at: now.toISOString() }, release: null };
160
202
  ended = true;
203
+ out.applied.push(a);
161
204
  }
162
205
  }
163
206
  }
@@ -181,7 +224,7 @@ function applyActions(r, actions, now, out, kind) {
181
224
  out.effects.push({ kind: 'stage_copy', mode: copy ? 'copy' : 'undo' });
182
225
  }
183
226
  }
184
- return { recorded: cur, promoted, ended, approved, opened };
227
+ return { recorded: cur, promoted, ended, approved, opened, stepNow };
185
228
  }
186
229
  /**
187
230
  * The whole release decision for one run: from the recorded state, the
@@ -196,6 +239,7 @@ export function decide(input) {
196
239
  effects: [],
197
240
  refused: [],
198
241
  stageRefusal: null,
242
+ applied: [],
199
243
  };
200
244
  const advance = input.advance ?? DEFAULT_ADVANCE;
201
245
  const minRequests = input.minRequests ?? RELATIVE_MIN_REQUESTS;
@@ -268,19 +312,31 @@ export function decide(input) {
268
312
  };
269
313
  if (!atWindow) {
270
314
  const pct = r.window.percent;
315
+ const paused = r.window.kind === 'paused';
271
316
  const holdDone = elapsed >= holdMs;
272
- const stepUp = advance === 'time'
273
- ? holdDone && verdict !== 'fail'
274
- : advance === 'signal'
275
- ? (holdDone && verdict === 'pass') || (applied.approved && verdict !== 'fail')
276
- : applied.approved && verdict !== 'fail';
277
- if (applied.approved && advance !== 'time' && verdict === 'fail')
317
+ const nowStep = applied.stepNow && verdict !== 'fail';
318
+ // A pause holds the step: only step_now leaves it. Hard fails and failed checks still roll back above.
319
+ const stepUp = paused
320
+ ? nowStep
321
+ : nowStep ||
322
+ (advance === 'time'
323
+ ? holdDone && verdict !== 'fail'
324
+ : advance === 'signal'
325
+ ? (holdDone && verdict === 'pass') || (applied.approved && verdict !== 'fail')
326
+ : applied.approved && verdict !== 'fail');
327
+ if (applied.approved && advance !== 'time' && verdict === 'fail') {
278
328
  out.refused.push('approve_step refused: the error rate is failing');
329
+ out.applied = out.applied.filter((x) => x.kind !== 'approve_step');
330
+ }
331
+ if (applied.stepNow && verdict === 'fail') {
332
+ out.refused.push('step_now refused: the error rate is failing');
333
+ out.applied = out.applied.filter((x) => x.kind !== 'step_now');
334
+ }
279
335
  if (stepUp) {
280
336
  const next = steps.find((s) => s > pct);
281
337
  r = stepTo(r, rel, next, now);
282
338
  }
283
- else {
339
+ else if (!paused) {
284
340
  const kind = verdict === 'inconclusive' ? 'inconclusive' : 'step';
285
341
  r = {
286
342
  ...r,
@@ -320,6 +376,7 @@ export function decide(input) {
320
376
  blocked,
321
377
  refused: out.refused,
322
378
  stageRefusal: out.stageRefusal,
379
+ applied: out.applied,
323
380
  };
324
381
  }
325
382
  function stepTo(r, rel, percent, now) {
@@ -0,0 +1,13 @@
1
+ import type { Decision, Recorded } from './decide.js';
2
+ import type { ReleaseAction, ReleaseEvent } from './types.js';
3
+ /**
4
+ * The history lines of one run: the actions that took effect, then the check, then what the run did with the
5
+ * release (a step, a pass, a rollback or a hard fail). Pure: `before` is what the run read, `decision` what it
6
+ * decided. `actors` names who asked for each requested action (by the action object `decide` was given).
7
+ */
8
+ export declare function eventsOf(input: {
9
+ before: Recorded;
10
+ decision: Decision;
11
+ actors: ReadonlyMap<ReleaseAction, string>;
12
+ now: Date;
13
+ }): ReleaseEvent[];
@@ -0,0 +1,67 @@
1
+ const percentOf = (w) => w.kind === 'step' || w.kind === 'inconclusive' || w.kind === 'paused' ? w.percent : undefined;
2
+ /**
3
+ * The history lines of one run: the actions that took effect, then the check, then what the run did with the
4
+ * release (a step, a pass, a rollback or a hard fail). Pure: `before` is what the run read, `decision` what it
5
+ * decided. `actors` names who asked for each requested action (by the action object `decide` was given).
6
+ */
7
+ export function eventsOf(input) {
8
+ const { before, decision, actors } = input;
9
+ const after = decision.recorded;
10
+ const at = input.now.toISOString();
11
+ const events = [];
12
+ const percent = percentOf(before.window) ?? percentOf(after.window);
13
+ const withPercent = percent === undefined ? {} : { percent };
14
+ const actorOf = (action) => {
15
+ const actor = action ? actors.get(action) : undefined;
16
+ return actor ? { actor } : {};
17
+ };
18
+ for (const a of decision.applied) {
19
+ if (a.kind === 'promote' && after.release) {
20
+ events.push({
21
+ at,
22
+ kind: 'promote',
23
+ release: { from: after.release.from, to: after.release.to },
24
+ ...actorOf(a),
25
+ });
26
+ }
27
+ else if (a.kind === 'approve_step') {
28
+ events.push({ at, kind: 'approve', ...withPercent, ...actorOf(a) });
29
+ }
30
+ else if (a.kind === 'open' ||
31
+ a.kind === 'pause' ||
32
+ a.kind === 'resume' ||
33
+ a.kind === 'step_now') {
34
+ events.push({ at, kind: a.kind, ...withPercent, ...actorOf(a) });
35
+ }
36
+ }
37
+ if (decision.signal)
38
+ events.push({ at, kind: 'check', ...withPercent, signal: decision.signal });
39
+ const w = after.window;
40
+ const release = before.release
41
+ ? { release: { from: before.release.from, to: before.release.to } }
42
+ : {};
43
+ const operator = decision.applied.find((a) => a.kind === 'rollback' || a.kind === 'decide_at_cap');
44
+ const was = before.window;
45
+ const newStep = w.kind === 'step'
46
+ ? !('percent' in was && 'since' in was && was.percent === w.percent && was.since === w.since)
47
+ : w.kind === 'window' && was.kind !== 'window';
48
+ if (newStep && (w.kind === 'step' || w.kind === 'window')) {
49
+ events.push({ at, kind: 'step', percent: w.kind === 'step' ? w.percent : 100 });
50
+ }
51
+ else if (w.kind === 'rolled_back' && was.kind !== 'rolled_back') {
52
+ const hard = w.reason.startsWith('hard fail');
53
+ events.push({
54
+ at,
55
+ kind: hard ? 'hard_fail' : 'rollback',
56
+ ...withPercent,
57
+ reason: w.reason,
58
+ ...release,
59
+ ...(hard && decision.signal ? { signal: decision.signal } : {}),
60
+ ...(w.reason.startsWith('operator') ? actorOf(operator) : {}),
61
+ });
62
+ }
63
+ else if (w.kind === 'passed' && was.kind !== 'passed') {
64
+ events.push({ at, kind: 'passed', ...release, ...actorOf(operator) });
65
+ }
66
+ return events;
67
+ }
@@ -6,6 +6,11 @@ export declare const HEALTH_READ_TIMEOUT_MS = 10000;
6
6
  export declare const ADDRESS_CHECK_TIMEOUT_MS = 10000;
7
7
  /** A health body larger than this is refused unread (a runaway or hostile response must not fill memory). */
8
8
  export declare const HEALTH_BODY_MAX_BYTES: number;
9
+ /**
10
+ * The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
11
+ * stops at the cap, so an endless body is never held.
12
+ */
13
+ export declare function readCappedText(res: Response, maxBytes?: number): Promise<string | null>;
9
14
  /** C1's 200 body. */
10
15
  export declare const healthBody: z.ZodObject<{
11
16
  colors: z.ZodObject<{
@@ -9,9 +9,9 @@ export const HEALTH_BODY_MAX_BYTES = 64 * 1024;
9
9
  * The response body as text, or null when it is larger than {@link HEALTH_BODY_MAX_BYTES}. Reads in chunks and
10
10
  * stops at the cap, so an endless body is never held.
11
11
  */
12
- async function readCappedText(res) {
12
+ export async function readCappedText(res, maxBytes = HEALTH_BODY_MAX_BYTES) {
13
13
  const declared = Number(res.headers.get('content-length'));
14
- if (Number.isFinite(declared) && declared > HEALTH_BODY_MAX_BYTES)
14
+ if (Number.isFinite(declared) && declared > maxBytes)
15
15
  return null;
16
16
  if (!res.body)
17
17
  return '';
@@ -23,7 +23,7 @@ async function readCappedText(res) {
23
23
  if (done)
24
24
  break;
25
25
  total += value.byteLength;
26
- if (total > HEALTH_BODY_MAX_BYTES) {
26
+ if (total > maxBytes) {
27
27
  await reader.cancel().catch(() => undefined);
28
28
  return null;
29
29
  }
@@ -1,6 +1,6 @@
1
1
  import { type Decision, type Observation, type ReleaseEffect, type ReleaseEntryConfig } from './decide.js';
2
2
  import { type ReleaseStore } from './state.js';
3
- import { type Color } from './types.js';
3
+ import { type Color, type ReleaseEvent } from './types.js';
4
4
  /** What a stage copy request came to. Fixed text only: a reason, a step name or an id. */
5
5
  export type StageCopyOutcome = {
6
6
  outcome: 'ok';
@@ -55,6 +55,11 @@ export interface ReleaseRunResult {
55
55
  * request is not in `requestIds`: {@link settleStageCopy} says when it is consumed.
56
56
  */
57
57
  copy: StageCopyJob | null;
58
+ /**
59
+ * The history lines of this run (`foundry_release_events`). The caller appends them with `store.appendEvents`
60
+ * after it persists `writes`, so a run that fails leaves none.
61
+ */
62
+ events: ReleaseEvent[];
58
63
  }
59
64
  /** One stage copy request a run decided to act on. */
60
65
  export interface StageCopyJob {
@@ -71,7 +76,7 @@ export interface StageCopyJob {
71
76
  * the recorded keys as they were: the next run decides again from them.
72
77
  */
73
78
  export declare function runRelease(input: {
74
- store: ReleaseStore;
79
+ store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'>;
75
80
  orgId: string;
76
81
  entryKey: string;
77
82
  entry: ReleaseEntryConfig;
@@ -1,4 +1,5 @@
1
1
  import { decide, healthRange, } from './decide.js';
2
+ import { eventsOf } from './events.js';
2
3
  import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
3
4
  import { isStageCopyAction } from './types.js';
4
5
  const REFUSAL_TEXT = {
@@ -23,6 +24,7 @@ export async function runRelease(input) {
23
24
  requestIds: [],
24
25
  deferredCopy: false,
25
26
  copy: null,
27
+ events: [],
26
28
  };
27
29
  }
28
30
  const k = releaseKeys(entryKey);
@@ -97,6 +99,12 @@ export async function runRelease(input) {
97
99
  requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
98
100
  deferredCopy: deferred.length > 0,
99
101
  copy,
102
+ events: eventsOf({
103
+ before: recorded,
104
+ decision,
105
+ actors: new Map(open.map((r) => [r.action, r.actor])),
106
+ now,
107
+ }),
100
108
  };
101
109
  }
102
110
  /**
@@ -0,0 +1,58 @@
1
+ import { z } from 'zod';
2
+ import type { ReleaseSeries } from './types.js';
3
+ /** The reporting series read has this long to answer. */
4
+ export declare const SERIES_READ_TIMEOUT_MS = 15000;
5
+ /** 8 days of 10-minute buckets is about 1150 buckets of about 300 bytes; a larger body is refused unread. */
6
+ export declare const SERIES_BODY_MAX_BYTES: number;
7
+ /** The longest range reporting serves. */
8
+ export declare const SERIES_MAX_DAYS = 8;
9
+ export declare const SERIES_BUCKET_MINUTES: readonly [10, 20, 60];
10
+ /** C2's 200 body. */
11
+ export declare const seriesBody: z.ZodObject<{
12
+ bucketMinutes: z.ZodNumber;
13
+ buckets: z.ZodArray<z.ZodObject<{
14
+ start: z.ZodString;
15
+ colors: z.ZodObject<{
16
+ blue: z.ZodObject<{
17
+ requests: z.ZodNumber;
18
+ errors: z.ZodNumber;
19
+ slow: z.ZodNumber;
20
+ durationMsSum: z.ZodNumber;
21
+ signIns: z.ZodNumber;
22
+ signInFailures: z.ZodNumber;
23
+ }, z.core.$strip>;
24
+ green: z.ZodObject<{
25
+ requests: z.ZodNumber;
26
+ errors: z.ZodNumber;
27
+ slow: z.ZodNumber;
28
+ durationMsSum: z.ZodNumber;
29
+ signIns: z.ZodNumber;
30
+ signInFailures: z.ZodNumber;
31
+ }, z.core.$strip>;
32
+ }, z.core.$strip>;
33
+ }, z.core.$strip>>;
34
+ }, z.core.$strip>;
35
+ export interface ReleaseSeriesQuery {
36
+ from: string;
37
+ to: string;
38
+ /** Bucket minutes: 10, 20 (default) or 60. */
39
+ bucket?: (typeof SERIES_BUCKET_MINUTES)[number];
40
+ }
41
+ /**
42
+ * `GET <address>/api/reporting/release-series?from&to&bucket` with the bearer token. Returns null for any
43
+ * failure (a bad range, network, timeout, status, body shape): the page then shows no chart rather than a wrong
44
+ * one. Nothing about the response or the token reaches a log or an error.
45
+ */
46
+ export declare function fetchReleaseSeries(input: {
47
+ address: string;
48
+ token: string;
49
+ headers?: Record<string, string>;
50
+ fetchImpl?: typeof fetch;
51
+ query: ReleaseSeriesQuery;
52
+ }): Promise<ReleaseSeries | null>;
53
+ /** Something that can read one entry's series: a {@link import('../plan/release-host.js').ReleaseHost}. */
54
+ export interface ReleaseSeriesHost {
55
+ readSeries?(entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
56
+ }
57
+ /** C2: the entry's requests, errors, slow requests and sign-ins per bucket and color, or null when unavailable. */
58
+ export declare function readReleaseSeries(host: ReleaseSeriesHost, entry: string, query: ReleaseSeriesQuery): Promise<ReleaseSeries | null>;
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ import { readCappedText } from './health.js';
3
+ /** The reporting series read has this long to answer. */
4
+ export const SERIES_READ_TIMEOUT_MS = 15_000;
5
+ /** 8 days of 10-minute buckets is about 1150 buckets of about 300 bytes; a larger body is refused unread. */
6
+ export const SERIES_BODY_MAX_BYTES = 1024 * 1024;
7
+ /** The longest range reporting serves. */
8
+ export const SERIES_MAX_DAYS = 8;
9
+ export const SERIES_BUCKET_MINUTES = [10, 20, 60];
10
+ const count = z.number().finite().nonnegative();
11
+ const color = z.object({
12
+ requests: count,
13
+ errors: count,
14
+ slow: count,
15
+ durationMsSum: count,
16
+ signIns: count,
17
+ signInFailures: count,
18
+ });
19
+ /** C2's 200 body. */
20
+ export const seriesBody = z.object({
21
+ bucketMinutes: z.number().int().positive(),
22
+ buckets: z.array(z.object({
23
+ start: z.string().refine((v) => !Number.isNaN(Date.parse(v))),
24
+ colors: z.object({ blue: color, green: color }),
25
+ })),
26
+ });
27
+ /**
28
+ * `GET <address>/api/reporting/release-series?from&to&bucket` with the bearer token. Returns null for any
29
+ * failure (a bad range, network, timeout, status, body shape): the page then shows no chart rather than a wrong
30
+ * one. Nothing about the response or the token reaches a log or an error.
31
+ */
32
+ export async function fetchReleaseSeries(input) {
33
+ const { from, to } = input.query;
34
+ const bucket = input.query.bucket ?? 20;
35
+ const span = Date.parse(to) - Date.parse(from);
36
+ if (!(span > 0) || span > SERIES_MAX_DAYS * 86_400_000)
37
+ return null;
38
+ if (!SERIES_BUCKET_MINUTES.includes(bucket))
39
+ return null;
40
+ try {
41
+ const url = new URL('/api/reporting/release-series', input.address);
42
+ url.searchParams.set('from', from);
43
+ url.searchParams.set('to', to);
44
+ url.searchParams.set('bucket', String(bucket));
45
+ const res = await (input.fetchImpl ?? fetch)(url, {
46
+ headers: { ...input.headers, authorization: `Bearer ${input.token}` },
47
+ signal: AbortSignal.timeout(SERIES_READ_TIMEOUT_MS),
48
+ });
49
+ if (!res.ok)
50
+ return null;
51
+ const text = await readCappedText(res, SERIES_BODY_MAX_BYTES);
52
+ if (text === null)
53
+ return null;
54
+ const parsed = seriesBody.safeParse(JSON.parse(text));
55
+ return parsed.success ? parsed.data : null;
56
+ }
57
+ catch {
58
+ // Network error, timeout or invalid JSON: all the same to the caller.
59
+ return null;
60
+ }
61
+ }
62
+ /** C2: the entry's requests, errors, slow requests and sign-ins per bucket and color, or null when unavailable. */
63
+ export async function readReleaseSeries(host, entry, query) {
64
+ return host.readSeries ? host.readSeries(entry, query) : null;
65
+ }