@wtfalch/foundry 0.70.0 → 0.71.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,105 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import { usageTailArtifactUrl } from '../platform.js';
3
+ import { PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY, PLATFORM_USAGE_TAIL_KEY_REF, PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, PLATFORM_USAGE_TAIL_SCRIPT_NAME, } from './platform-diff.js';
4
+ /** The date Cloudflare's Workers runtime compatibility is pinned to; no Workers feature here needs a later one. */
5
+ const USAGE_TAIL_COMPATIBILITY_DATE = '2026-10-01';
6
+ /**
7
+ * foundry#1024: makes the usage tail Worker true. Upload the pinned edge
8
+ * release (the host fetches and verifies it) and record the script at once,
9
+ * so a later failure is a half-made Worker foundry owns. Then hold the key
10
+ * (minted once, its guard recorded as soon as its value is held) and set it
11
+ * on the Worker. The applied values are recorded only when all of it
12
+ * succeeded.
13
+ */
14
+ export async function provisionPlatformUsageTail(context, deps) {
15
+ const { platform } = context;
16
+ const { usageTail } = platform;
17
+ const recorded = context.recordedIds();
18
+ const edgeUrl = recorded[PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY];
19
+ if (!edgeUrl) {
20
+ throw new Error(`platform:usage-tail: edge has not been applied yet (no recorded ${PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY}); the Worker is uploaded through it`);
21
+ }
22
+ const workers = await deps.workers(edgeUrl);
23
+ if (!usageTail.enabled) {
24
+ // Whatever of a (possibly half-made) deploy exists is removed; what does not exist is tolerated.
25
+ await workers.deleteScript(PLATFORM_USAGE_TAIL_SCRIPT_NAME);
26
+ await deps.deliverSecret(PLATFORM_USAGE_TAIL_KEY_REF, '');
27
+ for (const key of [
28
+ PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY,
29
+ PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY,
30
+ PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY,
31
+ PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY,
32
+ ]) {
33
+ context.recordScopeId(key, '');
34
+ }
35
+ context.recordScopeId(PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, '1');
36
+ return {};
37
+ }
38
+ if (!usageTail.tag || !usageTail.sha256) {
39
+ throw new Error('platform:usage-tail: no artifact is pinned (usageTail.tag, usageTail.sha256)');
40
+ }
41
+ // ---- the key, once ------------------------------------------------------
42
+ // The hash is never secret, so (the same pattern `provisionPlatformEdge`
43
+ // uses for `EDGE_PLATFORM_KEY_HASH`) it doubles as both the undelivered
44
+ // marker and the final recorded value: a marker with no guard means the
45
+ // mint may never have reached `deliverSecret`, so it is tried again with
46
+ // the same hash rather than minting a second key.
47
+ let keyHash = recorded[PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY] ?? '';
48
+ if (!keyHash) {
49
+ keyHash = recorded[PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY] ?? '';
50
+ let secret = keyHash ? await deps.heldKeys.resolve(PLATFORM_USAGE_TAIL_KEY_REF) : null;
51
+ if (!secret) {
52
+ secret = randomBytes(32).toString('base64url');
53
+ keyHash = createHash('sha256').update(secret).digest('hex');
54
+ context.recordScopeId(PLATFORM_USAGE_TAIL_KEY_UNDELIVERED_SCOPE_KEY, keyHash);
55
+ await context.persist?.();
56
+ }
57
+ await deliverKey(deps, secret);
58
+ context.recordScopeId(PLATFORM_USAGE_TAIL_KEY_SET_SCOPE_KEY, keyHash);
59
+ await context.persist?.();
60
+ }
61
+ // ---- the script ----------------------------------------------------------
62
+ await workers.uploadScript({
63
+ name: PLATFORM_USAGE_TAIL_SCRIPT_NAME,
64
+ artifactUrl: usageTailArtifactUrl(usageTail.tag),
65
+ artifactSha256: usageTail.sha256,
66
+ compatibilityDate: USAGE_TAIL_COMPATIBILITY_DATE,
67
+ bindings: [{ type: 'plain_text', name: 'EDGE_USAGE_URL', text: edgeUrl }],
68
+ });
69
+ // The script is foundry's from here on. Recorded now, not at the end: after
70
+ // a later failure the next plan must repair this Worker, not take it for a
71
+ // foreign one. "Fully applied" is the applied values recorded at the end.
72
+ context.recordScopeId(`cloudflare:worker:${PLATFORM_USAGE_TAIL_KEY}`, PLATFORM_USAGE_TAIL_SCRIPT_NAME);
73
+ context.recordScopeId(PLATFORM_USAGE_TAIL_REMOVED_SCOPE_KEY, '');
74
+ await context.persist?.();
75
+ // The key itself is read back only to hand it to the Worker (rule 6: it
76
+ // never reaches an error, a log, or a plan item); edge never returns it.
77
+ const secretValue = await deps.heldKeys.resolve(PLATFORM_USAGE_TAIL_KEY_REF);
78
+ if (!secretValue) {
79
+ throw new Error(`platform:usage-tail: the held key (${PLATFORM_USAGE_TAIL_KEY_REF}) is gone after it was minted; hold a new one to recover`);
80
+ }
81
+ await workers.setSecrets(PLATFORM_USAGE_TAIL_SCRIPT_NAME, [
82
+ { name: 'EDGE_USAGE_KEY', value: secretValue, version: keyHash },
83
+ ]);
84
+ // Last, after every write the applied values depend on.
85
+ context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_TAG_SCOPE_KEY, usageTail.tag);
86
+ context.recordScopeId(PLATFORM_USAGE_TAIL_APPLIED_SHA256_SCOPE_KEY, usageTail.sha256);
87
+ return { resourceId: PLATFORM_USAGE_TAIL_SCRIPT_NAME };
88
+ }
89
+ /** Rule 1: delivery is tried up to `deliverAttempts` times; the error names the ref only, never the cause. */
90
+ async function deliverKey(deps, secret) {
91
+ const attempts = deps.deliverAttempts ?? 3;
92
+ const sleep = deps.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms)));
93
+ for (let attempt = 1;; attempt += 1) {
94
+ try {
95
+ await deps.deliverSecret(PLATFORM_USAGE_TAIL_KEY_REF, secret);
96
+ return;
97
+ }
98
+ catch {
99
+ if (attempt >= attempts) {
100
+ throw new Error(`platform:usage-tail: the key (${PLATFORM_USAGE_TAIL_KEY_REF}) could not be delivered after ${attempts} tries; it went nowhere, so the next apply mints a new one`);
101
+ }
102
+ await sleep(1000 * attempt);
103
+ }
104
+ }
105
+ }
@@ -20,6 +20,9 @@ export interface PlatformArchonService extends PlatformService {
20
20
  blueGreen?: {
21
21
  steps?: number[];
22
22
  stepHoldMinutes?: number;
23
+ advance?: 'time' | 'signal' | 'approval';
24
+ /** Archon's switch only: `{ steps: [100], staffFirst: true }` deploys the idle copy and waits for `open`. */
25
+ staffFirst?: boolean;
23
26
  };
24
27
  }
25
28
  /**
@@ -66,6 +69,24 @@ export interface PlatformUptimeWorker {
66
69
  }
67
70
  /** Where Archon publishes the Worker script for `tag` (a private repo; the host's `fetchArtifact` fetches it). */
68
71
  export declare function uptimeArtifactUrl(tag: string): string;
72
+ /**
73
+ * foundry#1024: the usage tail Worker that meters every company Worker's
74
+ * use. `@wtfalch/edge`'s `packages/service/src/usage-tail-worker.ts` has no
75
+ * imports and builds to `usage-tail-worker.js`; foundry deploys the pinned
76
+ * build and never builds it, the same `tag`/`sha256` pin
77
+ * {@link PlatformUptimeWorker} uses. Both empty means nothing is pinned yet,
78
+ * and the planner reports that instead of planning the step. It has no
79
+ * hostname: it answers no request, only the tail events Cloudflare delivers.
80
+ */
81
+ export interface PlatformUsageTailWorker {
82
+ enabled: boolean;
83
+ /** An edge release tag, `v1.2.3`. */
84
+ tag: string;
85
+ /** Lower-case hex SHA-256 of `usage-tail-worker.js` at that tag. */
86
+ sha256: string;
87
+ }
88
+ /** Where edge publishes the Worker script for `tag` (the host's `fetchArtifact` fetches it). */
89
+ export declare function usageTailArtifactUrl(tag: string): string;
69
90
  /**
70
91
  * The shared services a declared platform names, decided in #165, plus
71
92
  * `edge` (#317, F7): the service that mints an entry's own edge key and
@@ -137,6 +158,8 @@ export interface DeclaredPlatform {
137
158
  coolify: PlatformService;
138
159
  /** foundry#542: the outside prober Worker; see {@link PlatformUptimeWorker}. */
139
160
  uptime: PlatformUptimeWorker;
161
+ /** foundry#1024: the usage tail Worker; see {@link PlatformUsageTailWorker}. */
162
+ usageTail: PlatformUsageTailWorker;
140
163
  /**
141
164
  * foundry#544: which Healthchecks instance manages the heartbeat checks
142
165
  * for every piece of scheduled work foundry sets up (the uptime Worker,
package/dist/platform.js CHANGED
@@ -4,6 +4,10 @@ import { validateBlueGreen, validateDomain } from './org.js';
4
4
  export function uptimeArtifactUrl(tag) {
5
5
  return `https://github.com/wtfalch/archon/releases/download/${tag}/archon-uptime-worker.js`;
6
6
  }
7
+ /** Where edge publishes the Worker script for `tag` (the host's `fetchArtifact` fetches it). */
8
+ export function usageTailArtifactUrl(tag) {
9
+ return `https://github.com/wtfalch/edge/releases/download/${tag}/usage-tail-worker.js`;
10
+ }
7
11
  export const PLATFORM_SERVICE_KEYS = [
8
12
  'auth',
9
13
  'mail',
@@ -87,6 +91,16 @@ export function validatePlatform(platform) {
87
91
  if (uptime.sha256 && !/^[0-9a-f]{64}$/.test(uptime.sha256)) {
88
92
  return 'uptime.sha256 must be 64 lower-case hex characters';
89
93
  }
94
+ const { usageTail } = platform;
95
+ if (Boolean(usageTail.tag.trim()) !== Boolean(usageTail.sha256.trim())) {
96
+ return 'usageTail.tag and usageTail.sha256 must be set together: a pin is both';
97
+ }
98
+ if (usageTail.tag && !/^v[0-9A-Za-z][0-9A-Za-z.+_-]*$/.test(usageTail.tag)) {
99
+ return `usageTail.tag '${usageTail.tag}' must be a release tag such as v1.2.3`;
100
+ }
101
+ if (usageTail.sha256 && !/^[0-9a-f]{64}$/.test(usageTail.sha256)) {
102
+ return 'usageTail.sha256 must be 64 lower-case hex characters';
103
+ }
90
104
  // #491: declared, never discovered -- a present-but-blank id is refused the
91
105
  // same way `serverIpv4`'s blank string is, rather than being read as unset.
92
106
  if (platform.mail.lettermintProjectId != null && !platform.mail.lettermintProjectId.trim()) {
@@ -0,0 +1,77 @@
1
+ import { type ReleaseStore } from './state.js';
2
+ /** The built-in apps Company Included releases from Archon and then copies to every customer org. */
3
+ export type FanOutApp = 'boule' | 'agora';
4
+ export type FanOutOrgState = 'waiting' | 'deploying' | 'done' | 'failed';
5
+ /** C1: what Archon shows for one app's fan-out. */
6
+ export interface BuiltinFanOut {
7
+ digest: string | null;
8
+ done: boolean;
9
+ orgs: Array<{
10
+ orgId: string;
11
+ slug: string;
12
+ state: FanOutOrgState;
13
+ at: string | null;
14
+ reason: string | null;
15
+ }>;
16
+ }
17
+ /** Applied-state scope keys of one app's fan-out (on the owner org). */
18
+ export declare function fanOutKeys(app: FanOutApp): {
19
+ /** The commit being copied. */
20
+ digest: string;
21
+ /** The commit whose copy finished on every org. */
22
+ done: string;
23
+ orgPrefix: string;
24
+ /** `<state>|<ISO time>|<org id>|<fixed reason>` */
25
+ org: (slug: string) => string;
26
+ };
27
+ /** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
28
+ export declare function readBuiltinFanOut(store: Pick<ReleaseStore, 'getScopeIds'>, ownerOrgId: string, app: FanOutApp): Promise<BuiltinFanOut>;
29
+ /**
30
+ * The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
31
+ * running and no open step window. Null otherwise.
32
+ */
33
+ export declare function fullyLiveDigest(ids: Readonly<Record<string, string>>, app: FanOutApp): string | null;
34
+ export type FanOutDeployResult = {
35
+ ok: true;
36
+ } | {
37
+ ok: false;
38
+ reason: string;
39
+ };
40
+ export interface FanOutDeps {
41
+ ownerOrgId: string;
42
+ app: FanOutApp;
43
+ /** Whether the owner's `org.<app>.blueGreen` is declared. */
44
+ ownerDeclares: boolean;
45
+ store: Pick<ReleaseStore, 'openRequests' | 'consumeRequests' | 'appendEvents'>;
46
+ /** The owner org's recorded ids now. */
47
+ readIds(): Promise<Record<string, string>>;
48
+ /** Persists scope keys on the owner org (null removes one), under a short lease. */
49
+ write(writes: Record<string, string | null>): Promise<void>;
50
+ /** Every other org that has the built-in app, in any order. Never the owner. */
51
+ targets(): Promise<Array<{
52
+ orgId: string;
53
+ slug: string;
54
+ }>>;
55
+ /** Puts the org's copy on `sha` in place and waits for the deployment. Fixed-text reasons only. */
56
+ deploy(target: {
57
+ orgId: string;
58
+ slug: string;
59
+ }, sha: string): Promise<FanOutDeployResult>;
60
+ now: () => Date;
61
+ /** Each deploy is abandoned after this long (default 12 minutes). */
62
+ deployTimeoutMs?: number;
63
+ }
64
+ export declare const FANOUT_DEPLOY_TIMEOUT_MS: number;
65
+ export interface FanOutRun {
66
+ /** Why nothing deployed, or `deployed`, `done`, `stopped`. Fixed text. */
67
+ outcome: string;
68
+ deployed: string[];
69
+ failed: string | null;
70
+ }
71
+ /**
72
+ * One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
73
+ * the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
74
+ * unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
75
+ * which the next pass deploys again (a deploy of the same commit is safe to repeat).
76
+ */
77
+ export declare function runBuiltinFanOut(deps: FanOutDeps): Promise<FanOutRun>;
@@ -0,0 +1,177 @@
1
+ import { recordedFrom } from './state.js';
2
+ const COMMIT = /^[0-9a-f]{40}$/;
3
+ const SLUG = /^[a-z0-9][a-z0-9-]*$/;
4
+ const STATES = ['waiting', 'deploying', 'done', 'failed'];
5
+ /** Applied-state scope keys of one app's fan-out (on the owner org). */
6
+ export function fanOutKeys(app) {
7
+ const p = `fanout.${app}`;
8
+ return {
9
+ /** The commit being copied. */
10
+ digest: `${p}.digest`,
11
+ /** The commit whose copy finished on every org. */
12
+ done: `${p}.done`,
13
+ orgPrefix: `${p}.org.`,
14
+ /** `<state>|<ISO time>|<org id>|<fixed reason>` */
15
+ org: (slug) => `${p}.org.${slug}`,
16
+ };
17
+ }
18
+ function assertApp(app) {
19
+ if (app !== 'boule' && app !== 'agora')
20
+ throw new Error('invalid fan-out app');
21
+ }
22
+ function parseOrg(ids, key, slug) {
23
+ if (!SLUG.test(slug))
24
+ return null;
25
+ const [state, at, orgId, ...rest] = (ids[key] ?? '').split('|');
26
+ if (!state || !STATES.includes(state) || !orgId)
27
+ return null;
28
+ return {
29
+ orgId,
30
+ slug,
31
+ state: state,
32
+ at: at || null,
33
+ reason: rest.join('|') || null,
34
+ };
35
+ }
36
+ function readFrom(ids, app) {
37
+ const k = fanOutKeys(app);
38
+ const orgs = Object.keys(ids)
39
+ .filter((key) => key.startsWith(k.orgPrefix))
40
+ .map((key) => parseOrg(ids, key, key.slice(k.orgPrefix.length)))
41
+ .filter((o) => o !== null)
42
+ .sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
43
+ const digest = ids[k.digest] ?? null;
44
+ return { digest, done: digest !== null && ids[k.done] === digest, orgs };
45
+ }
46
+ /** C1: where the fan-out of the owner's Boule or Agora stands, from the owner org's applied state. */
47
+ export async function readBuiltinFanOut(store, ownerOrgId, app) {
48
+ assertApp(app);
49
+ return readFrom(await store.getScopeIds(ownerOrgId), app);
50
+ }
51
+ /**
52
+ * The commit the owner's copy runs when it is fully live: one colour at 100 %, the other at 0, no release
53
+ * running and no open step window. Null otherwise.
54
+ */
55
+ export function fullyLiveDigest(ids, app) {
56
+ const r = recordedFrom({ ...ids }, app);
57
+ if (!r || r.release !== null)
58
+ return null;
59
+ const other = r.live === 'blue' ? 'green' : 'blue';
60
+ if (r.weights[r.live] !== 100 || r.weights[other] !== 0)
61
+ return null;
62
+ if (!['idle', 'passed', 'rolled_back'].includes(r.window.kind))
63
+ return null;
64
+ const digest = r.digests[r.live];
65
+ return digest && COMMIT.test(digest) ? digest : null;
66
+ }
67
+ export const FANOUT_DEPLOY_TIMEOUT_MS = 12 * 60_000;
68
+ async function bounded(run, ms) {
69
+ let timer;
70
+ const timeout = new Promise((resolve) => {
71
+ timer = setTimeout(() => resolve({ ok: false, reason: 'deployment timed out' }), ms);
72
+ });
73
+ try {
74
+ return await Promise.race([run, timeout]);
75
+ }
76
+ finally {
77
+ clearTimeout(timer);
78
+ }
79
+ }
80
+ /**
81
+ * One fan-out pass for one app (release tick, after the owner's release ran). Deploys each other org's copy at
82
+ * the owner's live commit, in slug order, and stops at the first failure. Every org is deployed in this pass
83
+ * unless one fails; a pass that dies halfway leaves the finished orgs `done` and the interrupted one `deploying`,
84
+ * which the next pass deploys again (a deploy of the same commit is safe to repeat).
85
+ */
86
+ export async function runBuiltinFanOut(deps) {
87
+ const { app } = deps;
88
+ assertApp(app);
89
+ const k = fanOutKeys(app);
90
+ const none = (outcome) => ({ outcome, deployed: [], failed: null });
91
+ const retries = (await deps.store.openRequests({ orgId: deps.ownerOrgId, entryKey: app })).filter((r) => r.action.kind === 'fanout_retry');
92
+ if (retries.length > 0) {
93
+ const failedNow = readFrom(await deps.readIds(), app).orgs.filter((o) => o.state === 'failed');
94
+ const writes = {};
95
+ for (const o of failedNow)
96
+ writes[k.org(o.slug)] = `waiting|${deps.now().toISOString()}|${o.orgId}|`;
97
+ if (Object.keys(writes).length > 0)
98
+ await deps.write(writes);
99
+ await deps.store.consumeRequests(retries.map((r) => r.id));
100
+ }
101
+ if (!deps.ownerDeclares)
102
+ return none('owner has no blue/green copy');
103
+ const ids = await deps.readIds();
104
+ const digest = fullyLiveDigest(ids, app);
105
+ if (!digest)
106
+ return none('owner copy is not fully live on one commit');
107
+ if (ids[k.done] === digest)
108
+ return none('commit already copied');
109
+ const targets = [...(await deps.targets())]
110
+ .filter((t) => t.orgId !== deps.ownerOrgId && SLUG.test(t.slug))
111
+ .sort((a, b) => (a.slug < b.slug ? -1 : a.slug > b.slug ? 1 : 0));
112
+ const stamp = deps.now().toISOString();
113
+ const current = readFrom(ids, app);
114
+ const fresh = current.digest !== digest;
115
+ const sync = {};
116
+ if (fresh) {
117
+ sync[k.digest] = digest;
118
+ sync[k.done] = null;
119
+ }
120
+ const states = new Map(current.orgs.map((o) => [o.slug, o]));
121
+ for (const o of current.orgs) {
122
+ if (!targets.some((t) => t.slug === o.slug))
123
+ sync[k.org(o.slug)] = null;
124
+ }
125
+ for (const t of targets) {
126
+ const have = states.get(t.slug);
127
+ if (fresh || !have || have.orgId !== t.orgId) {
128
+ sync[k.org(t.slug)] = `waiting|${stamp}|${t.orgId}|`;
129
+ states.set(t.slug, { ...t, state: 'waiting', at: stamp, reason: null });
130
+ }
131
+ }
132
+ if (Object.keys(sync).length > 0)
133
+ await deps.write(sync);
134
+ const run = { outcome: 'done', deployed: [], failed: null };
135
+ for (const t of targets) {
136
+ const state = states.get(t.slug)?.state;
137
+ if (state === 'done')
138
+ continue;
139
+ if (state === 'failed')
140
+ return { ...run, outcome: 'stopped', failed: t.slug };
141
+ await deps.write({ [k.org(t.slug)]: `deploying|${deps.now().toISOString()}|${t.orgId}|` });
142
+ let result;
143
+ try {
144
+ result = await bounded(deps.deploy(t, digest), deps.deployTimeoutMs ?? FANOUT_DEPLOY_TIMEOUT_MS);
145
+ }
146
+ catch (error) {
147
+ console.error(`fanout ${app}: deploy threw ${error instanceof Error ? error.name : 'Error'}`);
148
+ result = { ok: false, reason: 'deployment failed' };
149
+ }
150
+ const at = deps.now().toISOString();
151
+ if (result.ok) {
152
+ await deps.write({ [k.org(t.slug)]: `done|${at}|${t.orgId}|` });
153
+ run.deployed.push(t.slug);
154
+ await record(deps, { at, kind: 'fanout_done', reason: t.slug });
155
+ continue;
156
+ }
157
+ const reason = result.reason.replace(/\|/g, ' ').slice(0, 120);
158
+ await deps.write({ [k.org(t.slug)]: `failed|${at}|${t.orgId}|${reason}` });
159
+ await record(deps, { at, kind: 'fanout_failed', reason: `${t.slug}: ${reason}` });
160
+ return { ...run, outcome: 'stopped', failed: t.slug };
161
+ }
162
+ await deps.write({ [k.done]: digest });
163
+ return run;
164
+ }
165
+ async function record(deps, event) {
166
+ try {
167
+ await deps.store.appendEvents({
168
+ orgId: deps.ownerOrgId,
169
+ entryKey: deps.app,
170
+ events: [event],
171
+ });
172
+ }
173
+ catch {
174
+ // The history is a record, not a gate: the state above already says what happened.
175
+ console.error(`fanout ${deps.app}: recording a history line failed`);
176
+ }
177
+ }
@@ -28,7 +28,8 @@ export async function runRelease(input) {
28
28
  };
29
29
  }
30
30
  const k = releaseKeys(entryKey);
31
- const all = await store.openRequests({ orgId, entryKey });
31
+ // `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');
32
33
  // A stage request whose result was persisted before a crash is consumed, never run a second time.
33
34
  const handledId = ids[k.stageCopyHandled];
34
35
  const replayed = all.filter((r) => isStageCopyAction(r.action) && String(r.id) === handledId);
@@ -66,7 +66,7 @@ export declare function releaseKeys(entry: string): {
66
66
  };
67
67
  export declare function formatWeights(w: Record<Color, number>): string;
68
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"];
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"];
70
70
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
71
71
  export declare const releaseEventPayload: z.ZodObject<{
72
72
  percent: z.ZodOptional<z.ZodNumber>;
@@ -100,6 +100,8 @@ export declare const releaseEventKind: z.ZodEnum<{
100
100
  resume: "resume";
101
101
  step_now: "step_now";
102
102
  approve: "approve";
103
+ fanout_done: "fanout_done";
104
+ fanout_failed: "fanout_failed";
103
105
  }>;
104
106
  /** The window JSON is the C2 state plus the active release's `release` field. */
105
107
  export declare function parseWindow(value: string | undefined): {
@@ -78,6 +78,8 @@ export const RELEASE_EVENT_KINDS = [
78
78
  'rollback',
79
79
  'passed',
80
80
  'hard_fail',
81
+ 'fanout_done',
82
+ 'fanout_failed',
81
83
  ];
82
84
  /** What `foundry_release_events.payload` holds: a line's fields besides `at` and `kind`. */
83
85
  export const releaseEventPayload = z.object({
@@ -216,6 +218,7 @@ const actionSchema = z.discriminatedUnion('kind', [
216
218
  z.object({ kind: z.literal('deploy_stage'), ref: z.string().regex(STAGE_REF_PATTERN) }),
217
219
  z.object({ kind: z.literal('copy_prod_to_stage') }),
218
220
  z.object({ kind: z.literal('undo_stage_copy') }),
221
+ z.object({ kind: z.literal('fanout_retry') }),
219
222
  ]);
220
223
  /** C2: ask the runner to apply an action on its next run. Archon audits it. */
221
224
  export async function requestReleaseAction(store, input) {
@@ -107,8 +107,16 @@ export type ReleaseAction = {
107
107
  kind: 'copy_prod_to_stage';
108
108
  } | {
109
109
  kind: 'undo_stage_copy';
110
+ }
111
+ /** Owner org, entry key `boule` or `agora`: continue the built-in fan-out from the org that failed. */
112
+ | {
113
+ kind: 'fanout_retry';
110
114
  };
111
- export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail';
115
+ export type ReleaseEventKind = 'promote' | 'step' | 'check' | 'pause' | 'resume' | 'step_now' | 'approve' | 'open' | 'rollback' | 'passed' | 'hard_fail'
116
+ /** The built-in fan-out put an org's copy on the live commit (`reason` is the org slug). */
117
+ | 'fanout_done'
118
+ /** The built-in fan-out stopped at an org (`reason` is `<slug>: <fixed text>`). */
119
+ | 'fanout_failed';
112
120
  /** C1: one line of a release's history, written by the release run (`foundry_release_events`). */
113
121
  export interface ReleaseEvent {
114
122
  at: string;
@@ -815,6 +815,11 @@ function rowToPlatform(row) {
815
815
  gitRef: row.coolifyGitRef,
816
816
  },
817
817
  uptime: { enabled: row.uptimeEnabled, tag: row.uptimeTag, sha256: row.uptimeSha256 },
818
+ usageTail: {
819
+ enabled: row.usageTailEnabled,
820
+ tag: row.usageTailTag,
821
+ sha256: row.usageTailSha256,
822
+ },
818
823
  heartbeatsProvider: row.heartbeatsProvider,
819
824
  };
820
825
  }
@@ -867,6 +872,9 @@ function platformToRow(platform) {
867
872
  uptimeEnabled: platform.uptime.enabled,
868
873
  uptimeTag: platform.uptime.tag,
869
874
  uptimeSha256: platform.uptime.sha256,
875
+ usageTailEnabled: platform.usageTail.enabled,
876
+ usageTailTag: platform.usageTail.tag,
877
+ usageTailSha256: platform.usageTail.sha256,
870
878
  heartbeatsProvider: platform.heartbeatsProvider,
871
879
  };
872
880
  }
@@ -2173,6 +2173,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
2173
2173
  data: {
2174
2174
  steps?: number[];
2175
2175
  stepHoldMinutes?: number;
2176
+ advance?: "time" | "signal" | "approval";
2177
+ staffFirst?: boolean;
2176
2178
  } | null;
2177
2179
  driverParam: unknown;
2178
2180
  notNull: false;
@@ -2188,6 +2190,8 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
2188
2190
  $type: {
2189
2191
  steps?: number[];
2190
2192
  stepHoldMinutes?: number;
2193
+ advance?: "time" | "signal" | "approval";
2194
+ staffFirst?: boolean;
2191
2195
  } | null;
2192
2196
  }>;
2193
2197
  edgeEnabled: import("drizzle-orm/pg-core").PgColumn<{
@@ -2445,6 +2449,57 @@ export declare const foundryDeclaredPlatform: import("drizzle-orm/pg-core").PgTa
2445
2449
  identity: undefined;
2446
2450
  generated: undefined;
2447
2451
  }, {}, {}>;
2452
+ usageTailEnabled: import("drizzle-orm/pg-core").PgColumn<{
2453
+ name: "usage_tail_enabled";
2454
+ tableName: "foundry_declared_platform";
2455
+ dataType: "boolean";
2456
+ columnType: "PgBoolean";
2457
+ data: boolean;
2458
+ driverParam: boolean;
2459
+ notNull: true;
2460
+ hasDefault: true;
2461
+ isPrimaryKey: false;
2462
+ isAutoincrement: false;
2463
+ hasRuntimeDefault: false;
2464
+ enumValues: undefined;
2465
+ baseColumn: never;
2466
+ identity: undefined;
2467
+ generated: undefined;
2468
+ }, {}, {}>;
2469
+ usageTailTag: import("drizzle-orm/pg-core").PgColumn<{
2470
+ name: "usage_tail_tag";
2471
+ tableName: "foundry_declared_platform";
2472
+ dataType: "string";
2473
+ columnType: "PgText";
2474
+ data: string;
2475
+ driverParam: string;
2476
+ notNull: true;
2477
+ hasDefault: true;
2478
+ isPrimaryKey: false;
2479
+ isAutoincrement: false;
2480
+ hasRuntimeDefault: false;
2481
+ enumValues: [string, ...string[]];
2482
+ baseColumn: never;
2483
+ identity: undefined;
2484
+ generated: undefined;
2485
+ }, {}, {}>;
2486
+ usageTailSha256: import("drizzle-orm/pg-core").PgColumn<{
2487
+ name: "usage_tail_sha256";
2488
+ tableName: "foundry_declared_platform";
2489
+ dataType: "string";
2490
+ columnType: "PgText";
2491
+ data: string;
2492
+ driverParam: string;
2493
+ notNull: true;
2494
+ hasDefault: true;
2495
+ isPrimaryKey: false;
2496
+ isAutoincrement: false;
2497
+ hasRuntimeDefault: false;
2498
+ enumValues: [string, ...string[]];
2499
+ baseColumn: never;
2500
+ identity: undefined;
2501
+ generated: undefined;
2502
+ }, {}, {}>;
2448
2503
  heartbeatsProvider: import("drizzle-orm/pg-core").PgColumn<{
2449
2504
  name: "heartbeats_provider";
2450
2505
  tableName: "foundry_declared_platform";
@@ -231,6 +231,10 @@ export const foundryDeclaredPlatform = pgTable('foundry_declared_platform', {
231
231
  uptimeEnabled: boolean('uptime_enabled').notNull().default(true),
232
232
  uptimeTag: text('uptime_tag').notNull().default(''),
233
233
  uptimeSha256: text('uptime_sha256').notNull().default(''),
234
+ /** foundry#1024, `migrations/0070_platform_usage_tail.sql`: the usage tail Worker and its artifact pin. */
235
+ usageTailEnabled: boolean('usage_tail_enabled').notNull().default(false),
236
+ usageTailTag: text('usage_tail_tag').notNull().default(''),
237
+ usageTailSha256: text('usage_tail_sha256').notNull().default(''),
234
238
  /** foundry#544, `migrations/0053_heartbeats_provider.sql`: which Healthchecks instance manages the heartbeat checks, decoupled from `hcEnabled` above. */
235
239
  heartbeatsProvider: text('heartbeats_provider').notNull().default('self-hosted'),
236
240
  /** `migrations/0018_platform_apply.sql`: the platform's applied state, `system:kind:key` to provider id. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.70.0",
3
+ "version": "0.71.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",