@wtfalch/foundry 0.64.0 → 0.66.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 (58) 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/entry-rules.d.ts +11 -0
  6. package/dist/entry-rules.js +70 -0
  7. package/dist/index.d.ts +7 -3
  8. package/dist/index.js +4 -2
  9. package/dist/migrations/0069_release_events.sql +16 -0
  10. package/dist/modules/database.js +22 -3
  11. package/dist/native-build.d.ts +16 -12
  12. package/dist/native-build.js +16 -12
  13. package/dist/plan/boule.js +2 -0
  14. package/dist/plan/diff.d.ts +2 -0
  15. package/dist/plan/diff.js +17 -3
  16. package/dist/plan/executor.d.ts +44 -7
  17. package/dist/plan/executor.js +89 -18
  18. package/dist/plan/login-host.d.ts +8 -0
  19. package/dist/plan/login-host.js +8 -0
  20. package/dist/plan/plan-apply.d.ts +9 -0
  21. package/dist/plan/plan-apply.js +31 -1
  22. package/dist/plan/platform-diff.js +5 -0
  23. package/dist/plan/platform-org.d.ts +6 -0
  24. package/dist/plan/platform-providers.d.ts +8 -6
  25. package/dist/plan/platform-providers.js +22 -9
  26. package/dist/plan/release-host.d.ts +30 -1
  27. package/dist/plan/release-host.js +84 -41
  28. package/dist/plan/release-series-reader.d.ts +26 -0
  29. package/dist/plan/release-series-reader.js +40 -0
  30. package/dist/plan/sign-in-archon.d.ts +1 -1
  31. package/dist/plan/sign-in-archon.js +2 -2
  32. package/dist/plan/worker-app-database.d.ts +12 -1
  33. package/dist/plan/worker-app-database.js +13 -0
  34. package/dist/plan/worker-app.d.ts +27 -2
  35. package/dist/plan/worker-app.js +52 -7
  36. package/dist/plan/workspace.d.ts +2 -2
  37. package/dist/plan/workspace.js +3 -3
  38. package/dist/release/decide.d.ts +2 -0
  39. package/dist/release/decide.js +67 -10
  40. package/dist/release/events.d.ts +13 -0
  41. package/dist/release/events.js +67 -0
  42. package/dist/release/health.d.ts +5 -0
  43. package/dist/release/health.js +3 -3
  44. package/dist/release/run.d.ts +7 -2
  45. package/dist/release/run.js +14 -1
  46. package/dist/release/series.d.ts +58 -0
  47. package/dist/release/series.js +65 -0
  48. package/dist/release/state.d.ts +67 -1
  49. package/dist/release/state.js +69 -0
  50. package/dist/release/types.d.ts +52 -0
  51. package/dist/store/postgres.js +29 -3
  52. package/dist/store/schema.d.ts +112 -0
  53. package/dist/store/schema.js +9 -0
  54. package/dist/worker-app-config.d.ts +22 -1
  55. package/dist/worker-app-config.js +71 -13
  56. package/dist/worker-app-migrations.d.ts +6 -2
  57. package/dist/worker-app-migrations.js +1 -0
  58. package/package.json +1 -1
@@ -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,5 +1,6 @@
1
1
  import { decide, healthRange, } from './decide.js';
2
- import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
2
+ import { eventsOf } from './events.js';
3
+ import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
3
4
  import { isStageCopyAction } from './types.js';
4
5
  const REFUSAL_TEXT = {
5
6
  'no-copy-port': 'copy refused: this host cannot restore',
@@ -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);
@@ -79,6 +81,10 @@ export async function runRelease(input) {
79
81
  [k.window]: formatWindow(next.window, next.release),
80
82
  [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
81
83
  };
84
+ // Part of the run's writes: persisted with them, so a failed persist leaves neither. A run with none keeps it.
85
+ const lines = [...refused, ...decision.blocked];
86
+ if (lines.length > 0)
87
+ writes[k.refused] = formatRefusals(stamp, lines);
82
88
  for (const d of recorded.held)
83
89
  if (!next.held.includes(d))
84
90
  writes[k.held(d)] = null;
@@ -93,6 +99,12 @@ export async function runRelease(input) {
93
99
  requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
94
100
  deferredCopy: deferred.length > 0,
95
101
  copy,
102
+ events: eventsOf({
103
+ before: recorded,
104
+ decision,
105
+ actors: new Map(open.map((r) => [r.action, r.actor])),
106
+ now,
107
+ }),
96
108
  };
97
109
  }
98
110
  /**
@@ -132,6 +144,7 @@ export function settleStageCopy(input) {
132
144
  writes[k.stageCopy] = `${stamp}|refused|${result.reason}`;
133
145
  writes[k.stageCopyRetry] = null;
134
146
  refused.push(REFUSAL_TEXT[result.reason] ?? `copy refused: ${result.reason}`);
147
+ writes[k.refused] = formatRefusals(stamp, refused);
135
148
  return { writes, refused, consume: true };
136
149
  }
137
150
  writes[k.stageCopy] = `${stamp}|failed|${result.step}`;
@@ -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
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type Recorded, resolveReleaseConfig } from './decide.js';
3
- import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
3
+ import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
4
4
  /**
5
5
  * The persistence the release runner and Archon share. `getScopeIds` is the
6
6
  * recorded `bg.*` keys of an org's applied state (or of the platform, for the
@@ -26,6 +26,19 @@ export interface ReleaseStore {
26
26
  }[]>;
27
27
  /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
28
  consumeRequests(ids: readonly number[]): Promise<void>;
29
+ /** Appends history lines (`foundry_release_events`), oldest first. Nothing is ever updated or deleted. */
30
+ appendEvents(input: {
31
+ orgId: string;
32
+ entryKey: string;
33
+ events: readonly ReleaseEvent[];
34
+ }): Promise<void>;
35
+ /** The history of one entry, newest first. `since` is inclusive. Rows that no longer parse are skipped. */
36
+ readEvents(input: {
37
+ orgId: string;
38
+ entryKey: string;
39
+ since?: string;
40
+ limit: number;
41
+ }): Promise<ReleaseEvent[]>;
29
42
  }
30
43
  /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
31
44
  export declare function releaseKeys(entry: string): {
@@ -48,9 +61,46 @@ export declare function releaseKeys(entry: string): {
48
61
  stageCopyHandled: string;
49
62
  /** The id of a request whose copy failed once; a second failure consumes it. */
50
63
  stageCopyRetry: string;
64
+ /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
65
+ refused: string;
51
66
  };
52
67
  export declare function formatWeights(w: Record<Color, number>): string;
53
68
  export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
69
+ export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail"];
70
+ /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
71
+ export declare const releaseEventPayload: z.ZodObject<{
72
+ percent: z.ZodOptional<z.ZodNumber>;
73
+ actor: z.ZodOptional<z.ZodString>;
74
+ reason: z.ZodOptional<z.ZodString>;
75
+ release: z.ZodOptional<z.ZodObject<{
76
+ from: z.ZodString;
77
+ to: z.ZodString;
78
+ }, z.core.$strip>>;
79
+ signal: z.ZodOptional<z.ZodObject<{
80
+ requests: z.ZodNumber;
81
+ errors: z.ZodNumber;
82
+ baselineRate: z.ZodNullable<z.ZodNumber>;
83
+ verdict: z.ZodEnum<{
84
+ inconclusive: "inconclusive";
85
+ pass: "pass";
86
+ fail: "fail";
87
+ hard_fail: "hard_fail";
88
+ }>;
89
+ }, z.core.$strip>>;
90
+ }, z.core.$strip>;
91
+ export declare const releaseEventKind: z.ZodEnum<{
92
+ step: "step";
93
+ open: "open";
94
+ check: "check";
95
+ passed: "passed";
96
+ hard_fail: "hard_fail";
97
+ promote: "promote";
98
+ rollback: "rollback";
99
+ pause: "pause";
100
+ resume: "resume";
101
+ step_now: "step_now";
102
+ approve: "approve";
103
+ }>;
54
104
  /** The window JSON is the C2 state plus the active release's `release` field. */
55
105
  export declare function parseWindow(value: string | undefined): {
56
106
  window: WindowState;
@@ -60,6 +110,13 @@ export declare function formatWindow(window: WindowState, release: Recorded['rel
60
110
  export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
61
111
  /** Reads `bg.<id>.stagecopy`; a damaged value reads as absent. */
62
112
  export declare function parseStageCopy(value: string | undefined): StageCopyState | undefined;
113
+ /**
114
+ * The value of `bg.<id>.refused`: the run time and its refused lines, then its blocked lines. Capped at 20 lines
115
+ * of 300 characters so a bad run cannot grow the row. Callers write it only when `lines` is not empty.
116
+ */
117
+ export declare function formatRefusals(at: string, lines: readonly string[]): string;
118
+ /** Reads `bg.<id>.refused`; a damaged value reads as absent. */
119
+ export declare function parseRefusals(value: string | undefined): ReleaseState['refused'];
63
120
  export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
64
121
  /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
65
122
  export declare function readReleaseState(store: ReleaseStore, input: {
@@ -77,3 +134,12 @@ export declare function requestReleaseAction(store: ReleaseStore, input: {
77
134
  actor: string;
78
135
  }): Promise<void>;
79
136
  export { actionSchema as releaseActionSchema };
137
+ export declare const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
138
+ export declare const RELEASE_HISTORY_MAX_LIMIT = 1000;
139
+ /** C1: the history of one entry (a release's steps, checks, actions and outcome), newest first. */
140
+ export declare function readReleaseHistory(store: Pick<ReleaseStore, 'readEvents'>, q: {
141
+ orgId: string;
142
+ entryKey: string;
143
+ since?: string;
144
+ limit?: number;
145
+ }): Promise<ReleaseEvent[]>;