@wtfalch/foundry 0.72.3 → 0.74.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.
@@ -0,0 +1,151 @@
1
+ import { formatReleased, releaseKeys, releasedKey } from './state.js';
2
+ /** A go-live tries each deploy step this many ticks before it is refused. */
3
+ export const GO_LIVE_TRIES = 3;
4
+ export function parseGoLive(value) {
5
+ const m = /^(\d+)\|(\d+)\|(\d*)(?:\|(\d+))?$/.exec(value ?? '');
6
+ if (!m)
7
+ return null;
8
+ return {
9
+ requestId: Number(m[1]),
10
+ tries: Number(m[2]),
11
+ release: m[3] ? Number(m[3]) : null,
12
+ idle: m[4] ? Number(m[4]) : 0,
13
+ };
14
+ }
15
+ export function formatGoLive(s) {
16
+ return `${s.requestId}|${s.tries}|${s.release ?? ''}|${s.idle}`;
17
+ }
18
+ /**
19
+ * Decides one `go_live`. Stage is deployed at the tag's commit when it differs, then the idle color is deployed
20
+ * with the tag's env (`promote`, also for a commit the live color already runs), then it is opened (the staff hold
21
+ * is passed here: stage was the review) only when the idle color runs and answers, and the request waits until
22
+ * the release completes, one color at 100 % with the window closed. Only then is the version recorded, and only
23
+ * when `bg.<entry>.golive` holds the release number this request's own idle deploy reserved: a tag no color
24
+ * carries is never recorded. Each deploy step is tried `GO_LIVE_TRIES` ticks, a history line for each retry.
25
+ */
26
+ export function planGoLive(input) {
27
+ const { action, recorded } = input;
28
+ const k = releaseKeys(input.app);
29
+ const state = parseGoLive(input.ids[k.goLive]);
30
+ const mine = state?.requestId === input.requestId ? state : null;
31
+ const reserved = mine?.release ?? null;
32
+ const refuse = (reason) => ({
33
+ actions: [],
34
+ record: false,
35
+ consume: true,
36
+ claim: false,
37
+ refused: reason,
38
+ state: null,
39
+ events: [{ kind: 'go_live_refused', reason }],
40
+ });
41
+ const same = input.released.find((v) => v.tag === action.tag);
42
+ if (same) {
43
+ if (same.sha === action.ref) {
44
+ return { actions: [], record: false, consume: true, claim: false, state: null, events: [] };
45
+ }
46
+ return refuse('go_live refused: that tag already went live on another commit');
47
+ }
48
+ const rel = recorded.release;
49
+ if (rel) {
50
+ // Only the release this request's own idle deploy started carries the tag's env.
51
+ if (rel.digest !== action.ref || reserved === null) {
52
+ return {
53
+ actions: [],
54
+ record: false,
55
+ consume: false,
56
+ claim: false,
57
+ blocked: 'go live waiting: another release is running',
58
+ events: [],
59
+ };
60
+ }
61
+ const ready = input.observation.status === 'running' && input.observation.address === 'ok';
62
+ if (recorded.window.kind === 'staff' && ready) {
63
+ return {
64
+ actions: [{ kind: 'open' }],
65
+ record: false,
66
+ consume: false,
67
+ claim: true,
68
+ events: [],
69
+ };
70
+ }
71
+ return { actions: [], record: false, consume: false, claim: true, events: [] };
72
+ }
73
+ // Held first: a rolled-back redeploy of a commit already live still finds that commit live.
74
+ if (recorded.held.includes(action.ref)) {
75
+ return refuse('go_live refused: that commit was rolled back and is held');
76
+ }
77
+ if (reserved !== null && recorded.digests[recorded.live] === action.ref) {
78
+ return { actions: [], record: true, consume: true, claim: true, state: null, events: [] };
79
+ }
80
+ const tries = mine?.tries ?? 0;
81
+ const idle = mine?.idle ?? 0;
82
+ if (recorded.stageDigest === action.ref) {
83
+ if (idle >= GO_LIVE_TRIES)
84
+ return refuse('go_live refused: the idle color could not be deployed');
85
+ const promote = {
86
+ kind: 'promote',
87
+ ...(recorded.digests[recorded.live] === action.ref ? { redeploy: true } : {}),
88
+ };
89
+ const release = releaseNumber(input.now, input.released);
90
+ const probe = {
91
+ app: input.app,
92
+ tag: action.tag,
93
+ sha: action.ref,
94
+ release,
95
+ required: action.required,
96
+ requires: action.requires,
97
+ at: input.now.toISOString(),
98
+ };
99
+ return {
100
+ actions: [promote],
101
+ promote,
102
+ record: false,
103
+ consume: false,
104
+ claim: true,
105
+ appEnv: {
106
+ tag: action.tag,
107
+ release,
108
+ minimum: minimumFor([probe, ...input.released], release),
109
+ },
110
+ carry: { tries, idle },
111
+ events: [],
112
+ };
113
+ }
114
+ if (tries >= GO_LIVE_TRIES)
115
+ return refuse('go_live refused: stage did not take that commit');
116
+ return {
117
+ actions: [{ kind: 'deploy_stage', ref: action.ref }],
118
+ record: false,
119
+ consume: false,
120
+ claim: true,
121
+ state: { requestId: input.requestId, tries: tries + 1, release: null, idle },
122
+ events: tries > 0
123
+ ? [
124
+ {
125
+ kind: 'go_live_failed',
126
+ reason: `stage did not take that commit (try ${tries} of ${GO_LIVE_TRIES})`,
127
+ },
128
+ ]
129
+ : [],
130
+ };
131
+ }
132
+ /** The highest release at or below `release` that is required; null when none. */
133
+ export function minimumFor(versions, release) {
134
+ let min = null;
135
+ for (const v of versions) {
136
+ if (v.required && v.release <= release && (min === null || v.release > min))
137
+ min = v.release;
138
+ }
139
+ return min;
140
+ }
141
+ /** A free release number: the unix second of `now`, or the next free one when a version already took it. */
142
+ export function releaseNumber(now, versions) {
143
+ let n = Math.floor(now.getTime() / 1000);
144
+ while (versions.some((v) => v.release === n))
145
+ n += 1;
146
+ return n;
147
+ }
148
+ /** The scope writes that record `v`. */
149
+ export function recordWrites(v) {
150
+ return { [releasedKey(v.app, v.release)]: formatReleased(v) };
151
+ }
@@ -49,6 +49,10 @@ export interface ReleaseRunResult {
49
49
  requestIds: number[];
50
50
  /** A stage copy request was left open because this run does not run copies. */
51
51
  deferredCopy: boolean;
52
+ /** The go-live requests this run started: the caller marks them claimed (`store.claimRequests`) after it persists. */
53
+ claimIds: number[];
54
+ /** A go-live request is still open and not waiting for another release: the next run may take its next step. */
55
+ goLiveOpen: boolean;
52
56
  /**
53
57
  * The stage copy this run decided on, not yet run. The caller runs it (`runStageCopy`) after it has persisted
54
58
  * `writes` and written the weights, so a rollback decided in the same run never waits for the copy. Its
@@ -76,8 +80,10 @@ export interface StageCopyJob {
76
80
  * the recorded keys as they were: the next run decides again from them.
77
81
  */
78
82
  export declare function runRelease(input: {
79
- store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'>;
83
+ store: Pick<ReleaseStore, 'getScopeIds' | 'openRequests'> & Partial<Pick<ReleaseStore, 'claimRequests'>>;
80
84
  orgId: string;
85
+ /** `DeclaredPlatform.ownerOrgId`: only the owner org's go_live and set_required requests run. */
86
+ ownerOrgId?: string;
81
87
  entryKey: string;
82
88
  entry: ReleaseEntryConfig;
83
89
  now: Date;
@@ -1,7 +1,9 @@
1
+ import { StaleLeaseError } from '../store.js';
1
2
  import { decide, healthRange, } from './decide.js';
2
3
  import { eventsOf } from './events.js';
3
- import { formatRefusals, formatWeights, formatWindow, recordedFrom, releaseKeys, } from './state.js';
4
- import { isStageCopyAction } from './types.js';
4
+ import { formatGoLive, parseGoLive, planGoLive, recordWrites, } from './golive.js';
5
+ import { formatRefusals, formatReleased, formatWeights, formatWindow, recordedFrom, releaseKeys, releasedFrom, releasedKey, } from './state.js';
6
+ import { isStageCopyAction, } from './types.js';
5
7
  const REFUSAL_TEXT = {
6
8
  'no-copy-port': 'copy refused: this host cannot restore',
7
9
  };
@@ -23,29 +25,44 @@ export async function runRelease(input) {
23
25
  decision: null,
24
26
  requestIds: [],
25
27
  deferredCopy: false,
28
+ goLiveOpen: false,
29
+ claimIds: [],
26
30
  copy: null,
27
31
  events: [],
28
32
  };
29
33
  }
30
34
  const k = releaseKeys(entryKey);
31
35
  // `fanout_retry` belongs to the built-in fan-out (`fanout.ts`), which consumes it; this run leaves it open.
32
- const all = (await store.openRequests({ orgId, entryKey })).filter((r) => r.action.kind !== 'fanout_retry');
36
+ // A request with a time that has not come is left alone whatever the store returned.
37
+ const all = (await store.openRequests({ orgId, entryKey, now })).filter((r) => r.action.kind !== 'fanout_retry' && !(r.notBefore && Date.parse(r.notBefore) > now.getTime()));
33
38
  // A stage request whose result was persisted before a crash is consumed, never run a second time.
34
39
  const handledId = ids[k.stageCopyHandled];
35
40
  const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
36
41
  const fresh = all.filter((r) => !replayed.includes(r));
37
42
  const deferred = input.deferCopies ? fresh.filter((r) => isStageCopyAction(r.action)) : [];
38
- const open = fresh.filter((r) => !deferred.includes(r));
39
- const actions = open.map((r) => r.action);
43
+ const due = fresh.filter((r) => !deferred.includes(r));
44
+ const isRelease = (a) => a.kind === 'go_live' || a.kind === 'set_required';
45
+ const open = due.filter((r) => !isRelease(r.action));
46
+ const releaseRequests = due.filter((r) => isRelease(r.action));
40
47
  const range = healthRange(recorded, now);
41
48
  const target = recorded.release?.to ?? null;
42
49
  const health = range && !input.hardOnly ? await deps.readHealth(range) : null;
43
50
  const seen = target
44
51
  ? await deps.observeColor(target)
45
52
  : { status: 'other', address: 'unchecked' };
46
- const decision = decide({
53
+ const go = planReleaseRequests({
54
+ app: entryKey,
55
+ requests: releaseRequests,
47
56
  recorded,
48
- actions,
57
+ ids,
58
+ now,
59
+ owner: input.ownerOrgId === orgId,
60
+ observation: seen,
61
+ });
62
+ const actions = [...open.map((r) => r.action), ...go.actions];
63
+ const decideWith = (acts) => decide({
64
+ recorded,
65
+ actions: acts,
49
66
  steps: input.entry.steps,
50
67
  stepHoldMinutes: input.entry.stepHoldMinutes,
51
68
  advance: input.entry.advance,
@@ -56,12 +73,41 @@ export async function runRelease(input) {
56
73
  observation: { health, ...seen },
57
74
  ...(input.hardOnly ? { hardOnly: true } : {}),
58
75
  });
59
- const extra = {};
60
- const refused = [...decision.refused];
76
+ let decision = decideWith(actions);
77
+ const refused = [...decision.refused, ...go.refused];
61
78
  const stamp = now.toISOString();
62
79
  let copy = null;
63
80
  const requestOf = (kind) => open.find((r) => r.action.kind === kind);
81
+ // A go-live's idle deploy goes first and may fail: the go-live then stays open and the run goes on without the
82
+ // promote, so a rollback or any other decision of this run is still persisted.
83
+ // Claimed before any effect, so a cancel can never race a deploy this run starts.
84
+ if (go.claim.length > 0) {
85
+ if (!store.claimRequests)
86
+ throw new Error('release store cannot claim a go-live');
87
+ await store.claimRequests(go.claim);
88
+ }
89
+ let deployFailed = false;
90
+ let ran = null;
91
+ const promoted = go.promote !== undefined && decision.applied.includes(go.promote);
92
+ const goDeploy = promoted ? decision.effects.find((e) => e.kind === 'deploy') : undefined;
93
+ if (goDeploy?.kind === 'deploy' && go.appEnv) {
94
+ const withEnv = { ...goDeploy, appEnv: go.appEnv };
95
+ try {
96
+ await deps.effect(withEnv);
97
+ ran = goDeploy;
98
+ }
99
+ catch (error) {
100
+ if (error instanceof StaleLeaseError)
101
+ throw error;
102
+ console.error(`release ${entryKey}: go-live request ${go.requestId}: the idle deploy failed`);
103
+ deployFailed = true;
104
+ decision = decideWith(actions.filter((a) => a !== go.promote));
105
+ refused.push(...decision.refused.filter((line) => !refused.includes(line)));
106
+ }
107
+ }
64
108
  for (const effect of decision.effects) {
109
+ if (effect === ran)
110
+ continue;
65
111
  if (effect.kind !== 'stage_copy') {
66
112
  await deps.effect(effect);
67
113
  continue;
@@ -70,6 +116,9 @@ export async function runRelease(input) {
70
116
  if (request)
71
117
  copy = { mode: effect.mode, requestId: request.id, actor: request.actor };
72
118
  }
119
+ const finished = go.finish(decision, deployFailed);
120
+ const extra = {};
121
+ Object.assign(extra, finished.writes);
73
122
  if (decision.stageRefusal)
74
123
  extra[k.stageCopy] = `${stamp}|refused|${decision.stageRefusal}`;
75
124
  const next = decision.recorded;
@@ -83,7 +132,8 @@ export async function runRelease(input) {
83
132
  [k.signal]: decision.signal ? JSON.stringify(decision.signal) : null,
84
133
  };
85
134
  // Part of the run's writes: persisted with them, so a failed persist leaves neither. A run with none keeps it.
86
- const lines = [...refused, ...decision.blocked];
135
+ const blocked = [...decision.blocked, ...go.blocked];
136
+ const lines = [...refused, ...blocked];
87
137
  if (lines.length > 0)
88
138
  writes[k.refused] = formatRefusals(stamp, lines);
89
139
  for (const d of recorded.held)
@@ -94,18 +144,24 @@ export async function runRelease(input) {
94
144
  writes[k.held(d)] = now.toISOString();
95
145
  return {
96
146
  writes,
97
- blocked: decision.blocked,
147
+ blocked,
98
148
  refused,
99
149
  decision,
100
- requestIds: [...replayed, ...open.filter((r) => r.id !== copy?.requestId)].map((r) => r.id),
150
+ requestIds: [
151
+ ...replayed,
152
+ ...open.filter((r) => r.id !== copy?.requestId),
153
+ ...finished.consumed,
154
+ ].map((r) => r.id),
101
155
  deferredCopy: deferred.length > 0,
156
+ goLiveOpen: finished.goLiveOpen,
157
+ claimIds: go.claim,
102
158
  copy,
103
159
  events: eventsOf({
104
160
  before: recorded,
105
161
  decision,
106
- actors: new Map(open.map((r) => [r.action, r.actor])),
162
+ actors: new Map([...open.map((r) => [r.action, r.actor]), ...go.actors]),
107
163
  now,
108
- }),
164
+ }).concat(finished.events),
109
165
  };
110
166
  }
111
167
  /**
@@ -157,3 +213,138 @@ export function settleStageCopy(input) {
157
213
  writes[k.stageCopyRetry] = String(job.requestId);
158
214
  return { writes, refused, consume: false };
159
215
  }
216
+ const NOT_BUILTIN = 'go_live and set_required refused: only the entries boule and agora are released this way';
217
+ /**
218
+ * The due `go_live` and `set_required` requests of one run. `set_required` takes effect and is consumed at once;
219
+ * only the due `go_live` with the earliest time is judged per run, the others wait their turn. Nothing here
220
+ * persists or consumes: the caller does both, in the order of the release runner's rules.
221
+ */
222
+ function planReleaseRequests(input) {
223
+ const none = {
224
+ actions: [],
225
+ actors: [],
226
+ claim: [],
227
+ refused: [],
228
+ blocked: [],
229
+ finish: () => ({ writes: {}, consumed: [], events: [], goLiveOpen: false }),
230
+ };
231
+ if (input.requests.length === 0 || !input.owner)
232
+ return none;
233
+ const { app, requests, recorded, now } = input;
234
+ if (app !== 'boule' && app !== 'agora') {
235
+ return {
236
+ ...none,
237
+ refused: [NOT_BUILTIN],
238
+ finish: () => ({
239
+ writes: {},
240
+ consumed: requests,
241
+ claimed: [],
242
+ events: [],
243
+ goLiveOpen: false,
244
+ }),
245
+ };
246
+ }
247
+ const k = releaseKeys(app);
248
+ const stamp = now.toISOString();
249
+ let versions = releasedFrom(input.ids, app);
250
+ const writes = {};
251
+ const consumed = [];
252
+ const refused = [];
253
+ const events = [];
254
+ for (const r of requests) {
255
+ if (r.action.kind !== 'set_required')
256
+ continue;
257
+ const a = r.action;
258
+ consumed.push(r);
259
+ const hit = versions.find((v) => v.tag === a.tag);
260
+ if (!hit) {
261
+ refused.push('set_required refused: that tag has not gone live');
262
+ continue;
263
+ }
264
+ const next = { ...hit, required: a.required };
265
+ versions = versions.map((v) => (v === hit ? next : v));
266
+ writes[releasedKey(app, hit.release)] = formatReleased(next);
267
+ }
268
+ const goRequest = requests
269
+ .filter((r) => r.action.kind === 'go_live')
270
+ .sort((a, b) => (a.notBefore ?? '').localeCompare(b.notBefore ?? '') || a.id - b.id)[0];
271
+ const goAction = goRequest?.action;
272
+ const plan = goRequest && goAction
273
+ ? planGoLive({
274
+ app,
275
+ action: goAction,
276
+ requestId: goRequest.id,
277
+ recorded,
278
+ ids: input.ids,
279
+ released: versions,
280
+ now,
281
+ observation: input.observation,
282
+ })
283
+ : null;
284
+ const syntheticActions = plan?.actions ?? [];
285
+ return {
286
+ actions: syntheticActions,
287
+ ...(plan?.promote ? { promote: plan.promote } : {}),
288
+ ...(plan?.appEnv ? { appEnv: plan.appEnv } : {}),
289
+ actors: goRequest ? syntheticActions.map((a) => [a, goRequest.actor]) : [],
290
+ claim: goRequest && plan?.claim ? [goRequest.id] : [],
291
+ ...(goRequest ? { requestId: goRequest.id } : {}),
292
+ refused: [...refused, ...(plan?.refused ? [plan.refused] : [])],
293
+ blocked: plan?.blocked ? [plan.blocked] : [],
294
+ finish(decision, deployFailed) {
295
+ if (!goRequest || !goAction || !plan)
296
+ return { writes, consumed, events, goLiveOpen: false };
297
+ const applied = (a) => a !== undefined && decision.applied.includes(a);
298
+ const keep = !plan.consume;
299
+ for (const e of plan.events)
300
+ events.push({ at: stamp, ...e, actor: goRequest.actor });
301
+ if (deployFailed) {
302
+ events.push({
303
+ at: stamp,
304
+ kind: 'go_live_failed',
305
+ reason: 'the idle color could not be deployed',
306
+ actor: goRequest.actor,
307
+ });
308
+ }
309
+ const promoted = plan.promote !== undefined && applied(plan.promote) && !deployFailed;
310
+ if (plan.state !== undefined && !plan.promote) {
311
+ writes[k.goLive] = plan.state ? formatGoLive(plan.state) : null;
312
+ }
313
+ if (plan.promote && plan.appEnv && plan.carry && (promoted || deployFailed)) {
314
+ // The idle deploy worked: its release number is reserved for this request. It failed: count the try.
315
+ writes[k.goLive] = formatGoLive({
316
+ requestId: goRequest.id,
317
+ tries: plan.carry.tries,
318
+ release: promoted ? plan.appEnv.release : null,
319
+ idle: plan.carry.idle + (promoted ? 0 : 1),
320
+ });
321
+ }
322
+ if (!keep)
323
+ consumed.push(goRequest);
324
+ if (plan.record) {
325
+ const held = parseGoLive(input.ids[k.goLive]);
326
+ // `planGoLive` records only a tag whose own idle deploy reserved a number.
327
+ const release = held?.requestId === goRequest.id ? held.release : null;
328
+ if (release === null)
329
+ throw new Error('go-live recorded without a reserved release');
330
+ const v = {
331
+ app,
332
+ tag: goAction.tag,
333
+ sha: goAction.ref,
334
+ release,
335
+ required: goAction.required,
336
+ requires: goAction.requires,
337
+ at: stamp,
338
+ };
339
+ Object.assign(writes, recordWrites(v));
340
+ events.push({ at: stamp, kind: 'released', reason: v.tag, actor: goRequest.actor });
341
+ }
342
+ return {
343
+ writes,
344
+ consumed,
345
+ events,
346
+ goLiveOpen: keep && plan.blocked === undefined,
347
+ };
348
+ },
349
+ };
350
+ }
@@ -1,6 +1,6 @@
1
1
  import { z } from 'zod';
2
2
  import { type Recorded, resolveReleaseConfig } from './decide.js';
3
- import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, StageCopyState, WindowState } from './types.js';
3
+ import type { Color, ReleaseAction, ReleaseEvent, ReleaseSignal, ReleaseState, ReleasedVersion, 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
@@ -14,16 +14,61 @@ export interface ReleaseStore {
14
14
  entryKey: string;
15
15
  action: ReleaseAction;
16
16
  actor: string;
17
+ /** ISO time. The runner sees the request only from then on. Absent: due at once. */
18
+ notBefore?: string;
17
19
  }): Promise<void>;
18
- /** The open requests, oldest first. Reading consumes nothing: a run that fails leaves them open. */
20
+ /**
21
+ * The open requests that are due, oldest first. Reading consumes nothing: a run that fails leaves them open.
22
+ * `now` defaults to the database's clock.
23
+ */
19
24
  openRequests(input: {
20
25
  orgId: string;
21
26
  entryKey: string;
27
+ now?: Date;
22
28
  }): Promise<{
23
29
  id: number;
24
30
  action: ReleaseAction;
25
31
  actor: string;
32
+ notBefore?: string | null;
26
33
  }[]>;
34
+ /** Marks go-live requests the runner has started. A claimed request cannot be cancelled or moved. */
35
+ claimRequests(ids: readonly number[]): Promise<void>;
36
+ /**
37
+ * Takes back a request that waits for a time; writes the `schedule_cancelled` line in the same transaction.
38
+ * False when there is no such request: it was consumed, cancelled, claimed, already due, or never had a time.
39
+ * `now` defaults to the database's clock.
40
+ */
41
+ cancelRequest(input: {
42
+ id: number;
43
+ orgId: string;
44
+ entryKey: string;
45
+ actor: string;
46
+ now?: Date;
47
+ }): Promise<boolean>;
48
+ /** The requests that wait for a time and are not consumed, soonest first. */
49
+ readScheduled(input: {
50
+ orgId: string;
51
+ entryKey: string;
52
+ }): Promise<{
53
+ id: number;
54
+ action: ReleaseAction;
55
+ notBefore: string;
56
+ actor: string;
57
+ claimed: boolean;
58
+ }[]>;
59
+ /**
60
+ * {@link cancelRequest} of `id` and {@link addRequest} of `action` at `notBefore` in one transaction, with the
61
+ * `schedule_cancelled` and `scheduled` lines. False, and nothing added, when `id` cannot be cancelled.
62
+ */
63
+ rescheduleRequest(input: {
64
+ id: number;
65
+ orgId: string;
66
+ entryKey: string;
67
+ action: ReleaseAction;
68
+ notBefore: string;
69
+ actor: string;
70
+ now?: Date;
71
+ }): Promise<boolean>;
27
72
  /** Marks requests consumed. The runner calls it only after its decision is persisted. */
28
73
  consumeRequests(ids: readonly number[]): Promise<void>;
29
74
  /** Appends history lines (`foundry_release_events`), oldest first. Nothing is ever updated or deleted. */
@@ -63,10 +108,12 @@ export declare function releaseKeys(entry: string): {
63
108
  stageCopyRetry: string;
64
109
  /** JSON `{ at, lines }`: the latest run that refused or blocked something (see {@link formatRefusals}). */
65
110
  refused: string;
111
+ /** `<request id>|<stage deploys tried>|<release number or empty>`: where the go-live being run stands. */
112
+ goLive: string;
66
113
  };
67
114
  export declare function formatWeights(w: Record<Color, number>): string;
68
115
  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", "fanout_done", "fanout_failed"];
116
+ export declare const RELEASE_EVENT_KINDS: readonly ["promote", "step", "check", "pause", "resume", "step_now", "approve", "open", "rollback", "passed", "hard_fail", "fanout_done", "fanout_failed", "scheduled", "schedule_cancelled", "released", "go_live_failed", "go_live_refused"];
70
117
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
71
118
  export declare const releaseEventPayload: z.ZodObject<{
72
119
  percent: z.ZodOptional<z.ZodNumber>;
@@ -102,6 +149,11 @@ export declare const releaseEventKind: z.ZodEnum<{
102
149
  approve: "approve";
103
150
  fanout_done: "fanout_done";
104
151
  fanout_failed: "fanout_failed";
152
+ scheduled: "scheduled";
153
+ schedule_cancelled: "schedule_cancelled";
154
+ released: "released";
155
+ go_live_failed: "go_live_failed";
156
+ go_live_refused: "go_live_refused";
105
157
  }>;
106
158
  /** The window JSON is the C2 state plus the active release's `release` field. */
107
159
  export declare function parseWindow(value: string | undefined): {
@@ -128,13 +180,39 @@ export declare function readReleaseState(store: ReleaseStore, input: {
128
180
  entry?: Parameters<typeof resolveReleaseConfig>[0];
129
181
  }): Promise<ReleaseState | null>;
130
182
  declare const actionSchema: z.ZodType<ReleaseAction>;
131
- /** C2: ask the runner to apply an action on its next run. Archon audits it. */
183
+ /** C2: ask the runner to apply an action on its next run. Archon audits it. `notBefore` (a `go_live` only) waits for that time. */
132
184
  export declare function requestReleaseAction(store: ReleaseStore, input: {
133
185
  orgId: string;
134
186
  entryKey: string;
135
187
  action: ReleaseAction;
136
188
  actor: string;
189
+ notBefore?: string;
190
+ /** `DeclaredPlatform.ownerOrgId`: required for `go_live` and `set_required`. */
191
+ ownerOrgId?: string;
192
+ /** The clock for the time check; default now. */
193
+ now?: Date;
137
194
  }): Promise<void>;
195
+ /** Moves a queued go-live: cancels `id` and queues `action` at `notBefore` in one transaction. False when `id` is consumed, claimed, cancelled or already due. */
196
+ export declare function rescheduleRelease(store: ReleaseStore, input: {
197
+ id: number;
198
+ orgId: string;
199
+ entryKey: string;
200
+ action: ReleaseAction;
201
+ notBefore: string;
202
+ actor: string;
203
+ ownerOrgId?: string;
204
+ now?: Date;
205
+ }): Promise<boolean>;
206
+ /** The applied-state key of one released version: `released.<app>.<release>`. */
207
+ export declare function releasedKey(app: 'boule' | 'agora', release: number): string;
208
+ /** `tag|sha|required|requires|at`; `requires` is comma separated. */
209
+ export declare function formatReleased(v: ReleasedVersion): string;
210
+ /** Reads one `released.*` value; a damaged value reads as absent. */
211
+ export declare function parseReleased(app: 'boule' | 'agora', release: number, value: string | undefined): ReleasedVersion | null;
212
+ /** The released versions of `app` in the owner's applied state, newest first. */
213
+ export declare function releasedFrom(ids: Readonly<Record<string, string>>, app: 'boule' | 'agora'): ReleasedVersion[];
214
+ /** The tags that went live on the owner's Boule or Agora, newest first. A stage-only build is not in it. */
215
+ export declare function readReleasedVersions(store: Pick<ReleaseStore, 'getScopeIds'>, ownerOrgId: string, app: 'boule' | 'agora'): Promise<ReleasedVersion[]>;
138
216
  export { actionSchema as releaseActionSchema };
139
217
  export declare const RELEASE_HISTORY_DEFAULT_LIMIT = 200;
140
218
  export declare const RELEASE_HISTORY_MAX_LIMIT = 1000;