@wtfalch/foundry 0.73.0 → 0.75.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/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0071_release_request_schedule.sql +12 -0
- package/dist/plan/broker-mcp.d.ts +7 -0
- package/dist/plan/broker-mcp.js +16 -0
- package/dist/plan/diff.js +4 -2
- package/dist/plan/own-diff.js +2 -2
- package/dist/plan/plan-apply.js +3 -0
- package/dist/plan/platform-diff.d.ts +6 -1
- package/dist/plan/platform-diff.js +6 -1
- package/dist/plan/platform-providers.js +4 -3
- package/dist/plan/release-host.d.ts +5 -1
- package/dist/plan/release-host.js +99 -50
- package/dist/release/decide.d.ts +9 -0
- package/dist/release/decide.js +1 -1
- package/dist/release/golive.d.ts +77 -0
- package/dist/release/golive.js +151 -0
- package/dist/release/run.d.ts +7 -1
- package/dist/release/run.js +205 -14
- package/dist/release/state.d.ts +82 -4
- package/dist/release/state.js +114 -2
- package/dist/release/types.d.ts +48 -2
- package/dist/store/postgres.js +97 -6
- package/dist/store/schema.d.ts +34 -0
- package/dist/store/schema.js +11 -2
- package/package.json +1 -1
|
@@ -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
|
+
}
|
package/dist/release/run.d.ts
CHANGED
|
@@ -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;
|
package/dist/release/run.js
CHANGED
|
@@ -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 {
|
|
4
|
-
import {
|
|
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
|
-
|
|
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
|
|
39
|
-
const
|
|
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
|
|
53
|
+
const go = planReleaseRequests({
|
|
54
|
+
app: entryKey,
|
|
55
|
+
requests: releaseRequests,
|
|
47
56
|
recorded,
|
|
48
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
147
|
+
blocked,
|
|
98
148
|
refused,
|
|
99
149
|
decision,
|
|
100
|
-
requestIds: [
|
|
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
|
+
}
|
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, 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
|
-
/**
|
|
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;
|