@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.
- package/dist/apps/cloudflare.d.ts +5 -0
- package/dist/apps/deploy.js +33 -1
- package/dist/apps/migrations.d.ts +3 -0
- package/dist/apps/migrations.js +32 -7
- package/dist/entry-rules.d.ts +11 -0
- package/dist/entry-rules.js +70 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +4 -2
- package/dist/migrations/0069_release_events.sql +16 -0
- package/dist/modules/database.js +22 -3
- package/dist/native-build.d.ts +16 -12
- package/dist/native-build.js +16 -12
- package/dist/plan/boule.js +2 -0
- package/dist/plan/diff.d.ts +2 -0
- package/dist/plan/diff.js +17 -3
- package/dist/plan/executor.d.ts +44 -7
- package/dist/plan/executor.js +89 -18
- package/dist/plan/login-host.d.ts +8 -0
- package/dist/plan/login-host.js +8 -0
- package/dist/plan/plan-apply.d.ts +9 -0
- package/dist/plan/plan-apply.js +31 -1
- package/dist/plan/platform-diff.js +5 -0
- package/dist/plan/platform-org.d.ts +6 -0
- package/dist/plan/platform-providers.d.ts +8 -6
- package/dist/plan/platform-providers.js +22 -9
- package/dist/plan/release-host.d.ts +30 -1
- package/dist/plan/release-host.js +84 -41
- package/dist/plan/release-series-reader.d.ts +26 -0
- package/dist/plan/release-series-reader.js +40 -0
- package/dist/plan/sign-in-archon.d.ts +1 -1
- package/dist/plan/sign-in-archon.js +2 -2
- package/dist/plan/worker-app-database.d.ts +12 -1
- package/dist/plan/worker-app-database.js +13 -0
- package/dist/plan/worker-app.d.ts +27 -2
- package/dist/plan/worker-app.js +52 -7
- package/dist/plan/workspace.d.ts +2 -2
- package/dist/plan/workspace.js +3 -3
- package/dist/release/decide.d.ts +2 -0
- package/dist/release/decide.js +67 -10
- package/dist/release/events.d.ts +13 -0
- package/dist/release/events.js +67 -0
- package/dist/release/health.d.ts +5 -0
- package/dist/release/health.js +3 -3
- package/dist/release/run.d.ts +7 -2
- package/dist/release/run.js +14 -1
- package/dist/release/series.d.ts +58 -0
- package/dist/release/series.js +65 -0
- package/dist/release/state.d.ts +67 -1
- package/dist/release/state.js +69 -0
- package/dist/release/types.d.ts +52 -0
- package/dist/store/postgres.js +29 -3
- package/dist/store/schema.d.ts +112 -0
- package/dist/store/schema.js +9 -0
- package/dist/worker-app-config.d.ts +22 -1
- package/dist/worker-app-config.js +71 -13
- package/dist/worker-app-migrations.d.ts +6 -2
- package/dist/worker-app-migrations.js +1 -0
- package/package.json +1 -1
package/dist/release/decide.js
CHANGED
|
@@ -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' ||
|
|
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
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
+
}
|
package/dist/release/health.d.ts
CHANGED
|
@@ -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<{
|
package/dist/release/health.js
CHANGED
|
@@ -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 >
|
|
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 >
|
|
26
|
+
if (total > maxBytes) {
|
|
27
27
|
await reader.cancel().catch(() => undefined);
|
|
28
28
|
return null;
|
|
29
29
|
}
|
package/dist/release/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/release/run.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { decide, healthRange, } from './decide.js';
|
|
2
|
-
import {
|
|
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
|
+
}
|
package/dist/release/state.d.ts
CHANGED
|
@@ -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[]>;
|