@wtfalch/foundry 0.41.2 → 0.43.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 (71) hide show
  1. package/dist/bootstrap/versions.d.ts +2 -0
  2. package/dist/index.d.ts +7 -3
  3. package/dist/index.js +4 -2
  4. package/dist/migrations/0054_org_signin_in_app.sql +6 -0
  5. package/dist/migrations/0055_release_requests.sql +20 -0
  6. package/dist/migrations/0056_platform_archon_blue_green.sql +7 -0
  7. package/dist/org.d.ts +30 -0
  8. package/dist/org.js +52 -1
  9. package/dist/plan/agora.d.ts +4 -1
  10. package/dist/plan/agora.js +16 -2
  11. package/dist/plan/archon-blue-green.d.ts +54 -0
  12. package/dist/plan/archon-blue-green.js +72 -0
  13. package/dist/plan/blue-green-apply.d.ts +82 -0
  14. package/dist/plan/blue-green-apply.js +360 -0
  15. package/dist/plan/blue-green.d.ts +223 -0
  16. package/dist/plan/blue-green.js +444 -0
  17. package/dist/plan/boule.d.ts +5 -2
  18. package/dist/plan/boule.js +14 -2
  19. package/dist/plan/cloudflared-connector.d.ts +26 -0
  20. package/dist/plan/cloudflared-connector.js +37 -0
  21. package/dist/plan/coolify-previews.d.ts +10 -0
  22. package/dist/plan/coolify-previews.js +14 -0
  23. package/dist/plan/diff.d.ts +6 -0
  24. package/dist/plan/diff.js +123 -11
  25. package/dist/plan/edge-ratelimits.d.ts +5 -0
  26. package/dist/plan/edge-ratelimits.js +16 -6
  27. package/dist/plan/executor.d.ts +79 -0
  28. package/dist/plan/executor.js +527 -31
  29. package/dist/plan/login-host.d.ts +23 -2
  30. package/dist/plan/login-host.js +30 -5
  31. package/dist/plan/own-diff.js +6 -0
  32. package/dist/plan/plan-apply.d.ts +51 -0
  33. package/dist/plan/plan-apply.js +222 -0
  34. package/dist/plan/platform-diff.d.ts +9 -0
  35. package/dist/plan/platform-diff.js +28 -1
  36. package/dist/plan/platform-org.d.ts +6 -0
  37. package/dist/plan/platform-org.js +5 -0
  38. package/dist/plan/platform-providers.d.ts +31 -2
  39. package/dist/plan/platform-providers.js +345 -13
  40. package/dist/plan/providers-readers.js +6 -2
  41. package/dist/plan/proxy-labels.d.ts +2 -2
  42. package/dist/plan/proxy-labels.js +12 -4
  43. package/dist/plan/release-host.d.ts +59 -0
  44. package/dist/plan/release-host.js +239 -0
  45. package/dist/plan/remote-database.d.ts +124 -0
  46. package/dist/plan/remote-database.js +373 -0
  47. package/dist/plan/sign-in-archon.d.ts +46 -0
  48. package/dist/plan/sign-in-archon.js +71 -0
  49. package/dist/plan/sign-in-bindings.d.ts +16 -0
  50. package/dist/plan/sign-in-bindings.js +44 -0
  51. package/dist/plan/sign-in.d.ts +135 -0
  52. package/dist/plan/sign-in.js +172 -0
  53. package/dist/platform.d.ts +11 -1
  54. package/dist/platform.js +4 -1
  55. package/dist/release/decide.d.ts +95 -0
  56. package/dist/release/decide.js +242 -0
  57. package/dist/release/health.d.ts +48 -0
  58. package/dist/release/health.js +61 -0
  59. package/dist/release/run.d.ts +40 -0
  60. package/dist/release/run.js +56 -0
  61. package/dist/release/state.d.ts +64 -0
  62. package/dist/release/state.js +126 -0
  63. package/dist/release/types.d.ts +54 -0
  64. package/dist/release/types.js +4 -0
  65. package/dist/store/postgres.d.ts +8 -0
  66. package/dist/store/postgres.js +62 -3
  67. package/dist/store/schema.d.ts +171 -0
  68. package/dist/store/schema.js +14 -0
  69. package/package.json +1 -1
  70. package/versions.digests.json +2 -1
  71. package/versions.json +2 -1
@@ -0,0 +1,242 @@
1
+ import { otherColor, } from './types.js';
2
+ /** ADR 0001 rule 5: the default steps and the least time on each. */
3
+ export const DEFAULT_STEPS = [5, 10, 25, 50, 100];
4
+ export const DEFAULT_STEP_HOLD_MINUTES = 60;
5
+ /** Rule 7: errors this share of requests, with at least HARD_FAIL_MIN_REQUESTS, roll back at any level. */
6
+ export const HARD_FAIL_ERROR_RATE = 0.9;
7
+ export const HARD_FAIL_MIN_REQUESTS = 2;
8
+ /** Rule 8: a relative fail needs this many requests and this many failed checks in a row. */
9
+ export const RELATIVE_MIN_REQUESTS = 20;
10
+ export const RELATIVE_FAILED_CHECKS = 3;
11
+ /** A relative check counts only this long after the last counted one, so runs close together (applies also run releases) are one check. */
12
+ export const CHECK_INTERVAL_MINUTES = 10;
13
+ /** The new color matches the baseline while its error rate is at most baseline plus this (absolute). */
14
+ export const ERROR_RATE_MARGIN = 0.02;
15
+ /** Rules 12 and 13: the window at 100% closes after this long, and at most this long. */
16
+ export const WINDOW_MIN_HOURS = 24;
17
+ export const WINDOW_CAP_DAYS = 7;
18
+ /** The new color must be running and answer on its own address within this time of the deploy. */
19
+ export const DEPLOY_TIMEOUT_MINUTES = 30;
20
+ const MINUTE = 60_000;
21
+ const HOUR = 60 * MINUTE;
22
+ const DAY = 24 * HOUR;
23
+ export function normalizeSteps(steps) {
24
+ const wanted = steps && steps.length > 0 ? steps : DEFAULT_STEPS;
25
+ const valid = [...new Set(wanted.filter((n) => Number.isInteger(n) && n >= 1 && n <= 100))].sort((a, b) => a - b);
26
+ if (valid.length === 0)
27
+ return [...DEFAULT_STEPS];
28
+ if (valid[valid.length - 1] !== 100)
29
+ valid.push(100);
30
+ return valid;
31
+ }
32
+ function weightsFor(to, percent) {
33
+ return { [to]: percent, [otherColor(to)]: 100 - percent };
34
+ }
35
+ function rollback(r, now, reason) {
36
+ const rel = r.release;
37
+ if (!rel)
38
+ return r;
39
+ return {
40
+ ...r,
41
+ live: rel.from,
42
+ weights: weightsFor(rel.from, 100),
43
+ window: { kind: 'rolled_back', at: now.toISOString(), reason },
44
+ release: null,
45
+ held: r.held.includes(rel.digest) ? r.held : [...r.held, rel.digest],
46
+ };
47
+ }
48
+ function rate(c) {
49
+ return c.requests > 0 ? c.errors / c.requests : 0;
50
+ }
51
+ function dailyRate(h) {
52
+ return h.daily.requestsPerDay > 0 ? h.daily.errorsPerDay / h.daily.requestsPerDay : 0;
53
+ }
54
+ /** The time a health read should cover: from the step or window start to now. */
55
+ export function healthRange(r, now) {
56
+ const w = r.window;
57
+ if (w.kind === 'step' || w.kind === 'inconclusive' || w.kind === 'window' || w.kind === 'cap') {
58
+ if (w.kind === 'inconclusive' && w.percent === 0)
59
+ return null;
60
+ return { from: w.since, to: now.toISOString() };
61
+ }
62
+ return null;
63
+ }
64
+ function applyActions(r, actions, now, out) {
65
+ let cur = r;
66
+ let promoted = false;
67
+ let ended = false;
68
+ for (const a of actions) {
69
+ if (a.kind === 'clear_held') {
70
+ cur = { ...cur, held: cur.held.filter((d) => d !== a.digest) };
71
+ }
72
+ else if (a.kind === 'promote') {
73
+ const idle = !cur.release;
74
+ const digest = cur.stageDigest;
75
+ if (!idle)
76
+ out.refused.push('promote refused: a release is running');
77
+ else if (!digest)
78
+ out.refused.push('promote refused: stage has no digest');
79
+ else if (cur.held.includes(digest))
80
+ out.refused.push('promote refused: digest is held');
81
+ else if (digest === cur.digests[cur.live])
82
+ out.refused.push('promote refused: digest is already live');
83
+ else {
84
+ const to = otherColor(cur.live);
85
+ cur = {
86
+ ...cur,
87
+ digests: { ...cur.digests, [to]: digest },
88
+ window: { kind: 'inconclusive', percent: 0, since: now.toISOString() },
89
+ release: { from: cur.live, to, digest, fails: 0 },
90
+ };
91
+ out.effects.push({ kind: 'deploy', color: to, digest });
92
+ promoted = true;
93
+ }
94
+ }
95
+ else if (a.kind === 'rollback') {
96
+ if (!cur.release)
97
+ out.refused.push('rollback refused: no release is running');
98
+ else {
99
+ cur = rollback(cur, now, 'operator');
100
+ ended = true;
101
+ }
102
+ }
103
+ else if (a.kind === 'decide_at_cap') {
104
+ if (cur.window.kind !== 'cap' || !cur.release)
105
+ out.refused.push('decision refused: no release is at the cap');
106
+ else if (a.decision === 'rollback') {
107
+ cur = rollback(cur, now, 'operator at cap');
108
+ ended = true;
109
+ }
110
+ else {
111
+ out.effects.push({ kind: 'stop', color: cur.release.from });
112
+ cur = { ...cur, window: { kind: 'passed', at: now.toISOString() }, release: null };
113
+ ended = true;
114
+ }
115
+ }
116
+ }
117
+ return { recorded: cur, promoted, ended };
118
+ }
119
+ /**
120
+ * The whole release decision for one run: from the recorded state, the
121
+ * actions requested, one observation and the time, the next recorded state.
122
+ * Pure: it reads nothing and writes nothing (ADR 0001 rules 5, 7-13, 16).
123
+ */
124
+ export function decide(input) {
125
+ const { now, observation } = input;
126
+ const steps = normalizeSteps(input.steps);
127
+ const holdMs = (input.stepHoldMinutes ?? DEFAULT_STEP_HOLD_MINUTES) * MINUTE;
128
+ const out = { effects: [], refused: [] };
129
+ const applied = applyActions(input.recorded, input.actions, now, out);
130
+ let r = applied.recorded;
131
+ let signal = null;
132
+ const blocked = [];
133
+ const rel = r.release;
134
+ if (!applied.promoted && !applied.ended && rel) {
135
+ const since = Date.parse('since' in r.window ? r.window.since : '');
136
+ const elapsed = now.getTime() - since;
137
+ const hard = observation.status === 'crash_loop'
138
+ ? 'hard fail: crash loop'
139
+ : observation.address === 'failed'
140
+ ? 'hard fail: address check failed'
141
+ : null;
142
+ if (r.window.kind === 'inconclusive' && r.window.percent === 0) {
143
+ if (hard)
144
+ r = rollback(r, now, hard);
145
+ else if (observation.status === 'running' && observation.address === 'ok') {
146
+ const first = steps[0];
147
+ r = stepTo(r, rel, first, now);
148
+ }
149
+ else if (elapsed >= DEPLOY_TIMEOUT_MINUTES * MINUTE)
150
+ r = rollback(r, now, 'deploy timeout');
151
+ }
152
+ else {
153
+ const health = observation.health;
154
+ const newC = health?.colors[rel.to] ?? { requests: 0, errors: 0 };
155
+ const atWindow = r.window.kind === 'window' || r.window.kind === 'cap';
156
+ let baseline = null;
157
+ if (health) {
158
+ const old = health.colors[rel.from];
159
+ baseline = !atWindow && old.requests > 0 ? rate(old) : dailyRate(health);
160
+ }
161
+ const hardRate = newC.requests >= HARD_FAIL_MIN_REQUESTS && rate(newC) >= HARD_FAIL_ERROR_RATE;
162
+ let verdict;
163
+ if (hard || hardRate)
164
+ verdict = 'hard_fail';
165
+ else if (!health || baseline === null || newC.requests < RELATIVE_MIN_REQUESTS)
166
+ verdict = 'inconclusive';
167
+ else
168
+ verdict = rate(newC) > baseline + ERROR_RATE_MARGIN ? 'fail' : 'pass';
169
+ signal = { requests: newC.requests, errors: newC.errors, baselineRate: baseline, verdict };
170
+ if (verdict === 'hard_fail') {
171
+ r = rollback(r, now, hard ?? 'hard fail: error rate');
172
+ }
173
+ else {
174
+ const spaced = !rel.checkedAt ||
175
+ now.getTime() - Date.parse(rel.checkedAt) >= CHECK_INTERVAL_MINUTES * MINUTE;
176
+ const counted = spaced && verdict !== 'inconclusive';
177
+ const fails = !counted ? rel.fails : verdict === 'fail' ? rel.fails + 1 : 0;
178
+ if (fails >= RELATIVE_FAILED_CHECKS) {
179
+ r = rollback(r, now, 'relative fail: failed checks in a row');
180
+ }
181
+ else {
182
+ r = {
183
+ ...r,
184
+ release: { ...rel, fails, ...(counted ? { checkedAt: now.toISOString() } : {}) },
185
+ };
186
+ if (!atWindow) {
187
+ const pct = r.window.percent;
188
+ const holdDone = elapsed >= holdMs;
189
+ if (holdDone && verdict !== 'fail') {
190
+ const next = steps.find((s) => s > pct);
191
+ r = stepTo(r, rel, next, now);
192
+ }
193
+ else {
194
+ const kind = verdict === 'inconclusive' ? 'inconclusive' : 'step';
195
+ r = {
196
+ ...r,
197
+ window: { kind, percent: pct, since: r.window.since },
198
+ };
199
+ }
200
+ }
201
+ else {
202
+ const required = Math.max(1, Math.ceil(health?.daily.requestsPerDay ?? 0));
203
+ const closable = health !== null &&
204
+ baseline !== null &&
205
+ elapsed >= WINDOW_MIN_HOURS * HOUR &&
206
+ newC.requests >= required &&
207
+ rate(newC) <= baseline + ERROR_RATE_MARGIN;
208
+ if (closable) {
209
+ out.effects.push({ kind: 'stop', color: rel.from });
210
+ r = { ...r, window: { kind: 'passed', at: now.toISOString() }, release: null };
211
+ }
212
+ else if (elapsed >= WINDOW_CAP_DAYS * DAY) {
213
+ r = { ...r, window: { kind: 'cap', since: new Date(since).toISOString() } };
214
+ }
215
+ }
216
+ }
217
+ }
218
+ }
219
+ }
220
+ if (r.window.kind === 'cap')
221
+ blocked.push('release window reached the cap without enough data: pass or roll back in Archon');
222
+ return { recorded: r, signal, effects: out.effects, blocked, refused: out.refused };
223
+ }
224
+ function stepTo(r, rel, percent, now) {
225
+ const at = now.toISOString();
226
+ const fresh = { from: rel.from, to: rel.to, digest: rel.digest, fails: 0 };
227
+ if (percent >= 100) {
228
+ return {
229
+ ...r,
230
+ live: rel.to,
231
+ weights: weightsFor(rel.to, 100),
232
+ window: { kind: 'window', since: at },
233
+ release: fresh,
234
+ };
235
+ }
236
+ return {
237
+ ...r,
238
+ weights: weightsFor(rel.to, percent),
239
+ window: { kind: 'step', percent, since: at },
240
+ release: fresh,
241
+ };
242
+ }
@@ -0,0 +1,48 @@
1
+ import { z } from 'zod';
2
+ import type { HealthRead } from './decide.js';
3
+ /** The reporting health read has this long to answer. */
4
+ export declare const HEALTH_READ_TIMEOUT_MS = 10000;
5
+ /** The color's own address check has this long to answer. */
6
+ export declare const ADDRESS_CHECK_TIMEOUT_MS = 10000;
7
+ /** C1's 200 body. */
8
+ export declare const healthBody: z.ZodObject<{
9
+ colors: z.ZodObject<{
10
+ blue: z.ZodObject<{
11
+ requests: z.ZodNumber;
12
+ errors: z.ZodNumber;
13
+ }, z.core.$strip>;
14
+ green: z.ZodObject<{
15
+ requests: z.ZodNumber;
16
+ errors: z.ZodNumber;
17
+ }, z.core.$strip>;
18
+ }, z.core.$strip>;
19
+ daily: z.ZodObject<{
20
+ days: z.ZodNumber;
21
+ requestsPerDay: z.ZodNumber;
22
+ errorsPerDay: z.ZodNumber;
23
+ }, z.core.$strip>;
24
+ from: z.ZodString;
25
+ to: z.ZodString;
26
+ }, z.core.$strip>;
27
+ /**
28
+ * `GET <address>/api/reporting/release-health?from&to` with the bearer token.
29
+ * Returns null for any failure (network, timeout, status, shape): the caller
30
+ * counts that as inconclusive, never as a pass. Nothing about the response or
31
+ * the token reaches a log or an error.
32
+ */
33
+ export declare function readReleaseHealth(input: {
34
+ address: string;
35
+ token: string;
36
+ from: string;
37
+ to: string;
38
+ /** Extra request headers, e.g. the load balancer's color pin. */
39
+ headers?: Record<string, string>;
40
+ fetchImpl?: typeof fetch;
41
+ }): Promise<HealthRead | null>;
42
+ /** An HTTP check of the color's health path on its own address. */
43
+ export declare function checkColorAddress(input: {
44
+ address: string;
45
+ healthPath: string | null;
46
+ headers?: Record<string, string>;
47
+ fetchImpl?: typeof fetch;
48
+ }): Promise<'ok' | 'failed'>;
@@ -0,0 +1,61 @@
1
+ import { z } from 'zod';
2
+ /** The reporting health read has this long to answer. */
3
+ export const HEALTH_READ_TIMEOUT_MS = 10_000;
4
+ /** The color's own address check has this long to answer. */
5
+ export const ADDRESS_CHECK_TIMEOUT_MS = 10_000;
6
+ const counts = z.object({
7
+ requests: z.number().int().nonnegative(),
8
+ errors: z.number().int().nonnegative(),
9
+ });
10
+ /** C1's 200 body. */
11
+ export const healthBody = z.object({
12
+ colors: z.object({ blue: counts, green: counts }),
13
+ daily: z.object({
14
+ days: z.number().nonnegative(),
15
+ requestsPerDay: z.number().nonnegative(),
16
+ errorsPerDay: z.number().nonnegative(),
17
+ }),
18
+ from: z.string(),
19
+ to: z.string(),
20
+ });
21
+ /**
22
+ * `GET <address>/api/reporting/release-health?from&to` with the bearer token.
23
+ * Returns null for any failure (network, timeout, status, shape): the caller
24
+ * counts that as inconclusive, never as a pass. Nothing about the response or
25
+ * the token reaches a log or an error.
26
+ */
27
+ export async function readReleaseHealth(input) {
28
+ try {
29
+ const url = new URL('/api/reporting/release-health', input.address);
30
+ url.searchParams.set('from', input.from);
31
+ url.searchParams.set('to', input.to);
32
+ const res = await (input.fetchImpl ?? fetch)(url, {
33
+ headers: { ...input.headers, authorization: `Bearer ${input.token}` },
34
+ signal: AbortSignal.timeout(HEALTH_READ_TIMEOUT_MS),
35
+ });
36
+ if (!res.ok)
37
+ return null;
38
+ const parsed = healthBody.safeParse(await res.json());
39
+ if (!parsed.success)
40
+ return null;
41
+ return { colors: parsed.data.colors, daily: parsed.data.daily };
42
+ }
43
+ catch {
44
+ // Network error, timeout or invalid JSON: all the same to the caller.
45
+ return null;
46
+ }
47
+ }
48
+ /** An HTTP check of the color's health path on its own address. */
49
+ export async function checkColorAddress(input) {
50
+ try {
51
+ const res = await (input.fetchImpl ?? fetch)(new URL(input.healthPath ?? '/', input.address), {
52
+ ...(input.headers ? { headers: input.headers } : {}),
53
+ signal: AbortSignal.timeout(ADDRESS_CHECK_TIMEOUT_MS),
54
+ });
55
+ return res.ok ? 'ok' : 'failed';
56
+ }
57
+ catch {
58
+ // Unreachable counts as a failed check.
59
+ return 'failed';
60
+ }
61
+ }
@@ -0,0 +1,40 @@
1
+ import { type Decision, type Observation, type ReleaseEffect } from './decide.js';
2
+ import { type ReleaseStore } from './state.js';
3
+ import type { Color } from './types.js';
4
+ /** What the runner needs from the outside. Each call is bounded by its own timeout. */
5
+ export interface ReleaseDeps {
6
+ /** The reporting health read for the range, or null when it failed. */
7
+ readHealth(range: {
8
+ from: string;
9
+ to: string;
10
+ }): Promise<Observation['health']>;
11
+ /** The Coolify status and the own-address check of a color. */
12
+ observeColor(color: Color): Promise<Pick<Observation, 'status' | 'address'>>;
13
+ effect(effect: ReleaseEffect): Promise<void>;
14
+ }
15
+ export interface ReleaseRunResult {
16
+ /** Scope keys to record (null removes one). The caller persists them with the rest of the run. */
17
+ writes: Record<string, string | null>;
18
+ blocked: string[];
19
+ refused: string[];
20
+ decision: Decision | null;
21
+ /** The requests this run acted on. The caller consumes them with `store.consumeRequests` after it persists `writes`. */
22
+ requestIds: number[];
23
+ }
24
+ /**
25
+ * One run for one entry. Reads the recorded keys fresh and the open
26
+ * requests (it consumes none), decides and runs the effects. A failure, or a
27
+ * crash before the caller persists and consumes, leaves the requests open and
28
+ * the recorded keys as they were: the next run decides again from them.
29
+ */
30
+ export declare function runRelease(input: {
31
+ store: ReleaseStore;
32
+ orgId: string;
33
+ entryKey: string;
34
+ entry: {
35
+ steps?: number[];
36
+ stepHoldMinutes?: number;
37
+ };
38
+ now: Date;
39
+ deps: ReleaseDeps;
40
+ }): Promise<ReleaseRunResult>;
@@ -0,0 +1,56 @@
1
+ import { decide, healthRange, } from './decide.js';
2
+ import { formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
3
+ /**
4
+ * One run for one entry. Reads the recorded keys fresh and the open
5
+ * requests (it consumes none), decides and runs the effects. A failure, or a
6
+ * crash before the caller persists and consumes, leaves the requests open and
7
+ * the recorded keys as they were: the next run decides again from them.
8
+ */
9
+ export async function runRelease(input) {
10
+ const { store, orgId, entryKey, deps, now } = input;
11
+ const ids = await store.getScopeIds(orgId);
12
+ const recorded = recordedFrom(ids, entryKey);
13
+ if (!recorded)
14
+ return { writes: {}, blocked: [], refused: [], decision: null, requestIds: [] };
15
+ const open = await store.openRequests({ orgId, entryKey });
16
+ const actions = open.map((r) => r.action);
17
+ const range = healthRange(recorded, now);
18
+ const target = recorded.release?.to ?? null;
19
+ const health = range ? await deps.readHealth(range) : null;
20
+ const seen = target
21
+ ? await deps.observeColor(target)
22
+ : { status: 'other', address: 'unchecked' };
23
+ const decision = decide({
24
+ recorded,
25
+ actions,
26
+ steps: input.entry.steps,
27
+ stepHoldMinutes: input.entry.stepHoldMinutes,
28
+ now,
29
+ observation: { health, ...seen },
30
+ });
31
+ for (const effect of decision.effects)
32
+ await deps.effect(effect);
33
+ const k = releaseKeys(entryKey);
34
+ const next = decision.recorded;
35
+ const writes = {
36
+ [k.live]: next.live,
37
+ [k.weights]: formatWeights(next.weights),
38
+ [k.digest('blue')]: next.digests.blue,
39
+ [k.digest('green')]: next.digests.green,
40
+ [k.window]: formatWindow(next.window, next.release),
41
+ [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
42
+ };
43
+ for (const d of recorded.held)
44
+ if (!next.held.includes(d))
45
+ writes[k.held(d)] = null;
46
+ for (const d of next.held)
47
+ if (!recorded.held.includes(d))
48
+ writes[k.held(d)] = now.toISOString();
49
+ return {
50
+ writes,
51
+ blocked: decision.blocked,
52
+ refused: decision.refused,
53
+ decision,
54
+ requestIds: open.map((r) => r.id),
55
+ };
56
+ }
@@ -0,0 +1,64 @@
1
+ import { z } from 'zod';
2
+ import type { Recorded } from './decide.js';
3
+ import type { Color, ReleaseAction, ReleaseSignal, ReleaseState, WindowState } from './types.js';
4
+ /**
5
+ * The persistence the release runner and Archon share. `getScopeIds` is the
6
+ * recorded `bg.*` keys of an org's applied state (or of the platform, for the
7
+ * orgId `platform`). Requests live in their own table so a click never races
8
+ * the runner's snapshot write.
9
+ */
10
+ export interface ReleaseStore {
11
+ getScopeIds(orgId: string): Promise<Record<string, string>>;
12
+ addRequest(input: {
13
+ orgId: string;
14
+ entryKey: string;
15
+ action: ReleaseAction;
16
+ actor: string;
17
+ }): Promise<void>;
18
+ /** The open requests, oldest first. Reading consumes nothing: a run that fails leaves them open. */
19
+ openRequests(input: {
20
+ orgId: string;
21
+ entryKey: string;
22
+ }): Promise<{
23
+ id: number;
24
+ action: ReleaseAction;
25
+ actor: string;
26
+ }[]>;
27
+ /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
+ consumeRequests(ids: readonly number[]): Promise<void>;
29
+ }
30
+ /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
31
+ export declare function releaseKeys(entry: string): {
32
+ live: string;
33
+ digest: (c: Color) => string;
34
+ weights: string;
35
+ stage: string;
36
+ window: string;
37
+ signal: string;
38
+ heldPrefix: string;
39
+ held: (digest: string) => string;
40
+ };
41
+ export declare function formatWeights(w: Record<Color, number>): string;
42
+ export declare function parseWeights(value: string | undefined): Record<Color, number> | null;
43
+ /** The window JSON is the C2 state plus the active release's `release` field. */
44
+ export declare function parseWindow(value: string | undefined): {
45
+ window: WindowState;
46
+ release: Recorded['release'];
47
+ };
48
+ export declare function formatWindow(window: WindowState, release: Recorded['release']): string;
49
+ export declare function recordedFrom(ids: Record<string, string>, entryKey: string): Recorded | null;
50
+ export declare function readSignal(ids: Record<string, string>, entryKey: string): ReleaseSignal | null;
51
+ /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
52
+ export declare function readReleaseState(store: ReleaseStore, input: {
53
+ orgId: string;
54
+ entryKey: string;
55
+ }): Promise<ReleaseState | null>;
56
+ declare const actionSchema: z.ZodType<ReleaseAction>;
57
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. */
58
+ export declare function requestReleaseAction(store: ReleaseStore, input: {
59
+ orgId: string;
60
+ entryKey: string;
61
+ action: ReleaseAction;
62
+ actor: string;
63
+ }): Promise<void>;
64
+ export { actionSchema as releaseActionSchema };
@@ -0,0 +1,126 @@
1
+ import { z } from 'zod';
2
+ /** Scope keys (C3). `entry` is a hosting entry key: never `.` or `:`. */
3
+ export function releaseKeys(entry) {
4
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(entry))
5
+ throw new Error('invalid release entry key');
6
+ const p = `bg.${entry}`;
7
+ return {
8
+ live: `${p}.live`,
9
+ digest: (c) => `${p}.digest.${c}`,
10
+ weights: `${p}.weights`,
11
+ stage: `${p}.stage`,
12
+ window: `${p}.window`,
13
+ signal: `${p}.signal`,
14
+ heldPrefix: `${p}.held.`,
15
+ held: (digest) => `${p}.held.${digest}`,
16
+ };
17
+ }
18
+ export function formatWeights(w) {
19
+ return `blue:${w.blue},green:${w.green}`;
20
+ }
21
+ export function parseWeights(value) {
22
+ const m = /^blue:(\d{1,3}),green:(\d{1,3})$/.exec(value ?? '');
23
+ if (!m)
24
+ return null;
25
+ return { blue: Number(m[1]), green: Number(m[2]) };
26
+ }
27
+ const color = z.enum(['blue', 'green']);
28
+ const storedWindow = z.discriminatedUnion('kind', [
29
+ z.object({ kind: z.literal('idle') }),
30
+ z.object({ kind: z.literal('step'), percent: z.number(), since: z.string() }),
31
+ z.object({ kind: z.literal('inconclusive'), percent: z.number(), since: z.string() }),
32
+ z.object({ kind: z.literal('window'), since: z.string() }),
33
+ z.object({ kind: z.literal('passed'), at: z.string() }),
34
+ z.object({ kind: z.literal('rolled_back'), at: z.string(), reason: z.string() }),
35
+ z.object({ kind: z.literal('cap'), since: z.string() }),
36
+ ]);
37
+ const releaseField = z
38
+ .object({
39
+ from: color,
40
+ to: color,
41
+ digest: z.string(),
42
+ fails: z.number().int().nonnegative(),
43
+ checkedAt: z.string().optional(),
44
+ })
45
+ .nullable()
46
+ .optional();
47
+ const signalSchema = z.object({
48
+ requests: z.number(),
49
+ errors: z.number(),
50
+ baselineRate: z.number().nullable(),
51
+ verdict: z.enum(['pass', 'fail', 'hard_fail', 'inconclusive']),
52
+ });
53
+ function parseJson(value) {
54
+ if (!value)
55
+ return undefined;
56
+ try {
57
+ return JSON.parse(value);
58
+ }
59
+ catch {
60
+ // A damaged value reads as absent; the runner rewrites it.
61
+ return undefined;
62
+ }
63
+ }
64
+ /** The window JSON is the C2 state plus the active release's `release` field. */
65
+ export function parseWindow(value) {
66
+ const state = storedWindow.safeParse(parseJson(value));
67
+ if (!state.success)
68
+ return { window: { kind: 'idle' }, release: null };
69
+ const rel = releaseField.safeParse(parseJson(value).release);
70
+ return { window: state.data, release: rel.success ? (rel.data ?? null) : null };
71
+ }
72
+ export function formatWindow(window, release) {
73
+ return JSON.stringify(release ? { ...window, release } : window);
74
+ }
75
+ export function recordedFrom(ids, entryKey) {
76
+ const k = releaseKeys(entryKey);
77
+ const live = color.safeParse(ids[k.live]);
78
+ if (!live.success)
79
+ return null;
80
+ const { window, release } = parseWindow(ids[k.window]);
81
+ return {
82
+ live: live.data,
83
+ digests: { blue: ids[k.digest('blue')] ?? null, green: ids[k.digest('green')] ?? null },
84
+ stageDigest: ids[k.stage] ?? null,
85
+ weights: parseWeights(ids[k.weights]) ?? { ...{ blue: 0, green: 0 }, [live.data]: 100 },
86
+ window,
87
+ release,
88
+ held: Object.keys(ids)
89
+ .filter((key) => key.startsWith(k.heldPrefix))
90
+ .map((key) => key.slice(k.heldPrefix.length))
91
+ .sort(),
92
+ };
93
+ }
94
+ export function readSignal(ids, entryKey) {
95
+ const parsed = signalSchema.safeParse(parseJson(ids[releaseKeys(entryKey).signal]));
96
+ return parsed.success ? parsed.data : null;
97
+ }
98
+ /** C2: the release state of one hosting entry, or null before blue/green recorded anything. */
99
+ export async function readReleaseState(store, input) {
100
+ const ids = await store.getScopeIds(input.orgId);
101
+ const r = recordedFrom(ids, input.entryKey);
102
+ if (!r)
103
+ return null;
104
+ return {
105
+ entryKey: input.entryKey,
106
+ live: r.live,
107
+ weights: r.weights,
108
+ digests: r.digests,
109
+ stageDigest: r.stageDigest,
110
+ window: r.window,
111
+ signal: readSignal(ids, input.entryKey),
112
+ held: r.held,
113
+ };
114
+ }
115
+ const actionSchema = z.discriminatedUnion('kind', [
116
+ z.object({ kind: z.literal('promote') }),
117
+ z.object({ kind: z.literal('rollback') }),
118
+ z.object({ kind: z.literal('clear_held'), digest: z.string().min(1) }),
119
+ z.object({ kind: z.literal('decide_at_cap'), decision: z.enum(['pass', 'rollback']) }),
120
+ ]);
121
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. */
122
+ export async function requestReleaseAction(store, input) {
123
+ releaseKeys(input.entryKey);
124
+ await store.addRequest({ ...input, action: actionSchema.parse(input.action) });
125
+ }
126
+ export { actionSchema as releaseActionSchema };