@wtfalch/foundry 0.17.0 → 0.17.1
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/bootstrap/declare.js +6 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/migrations/0043_platform_names.sql +25 -0
- package/dist/migrations/0044_platform_mail_lettermint_project.sql +11 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +143 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +16 -0
- package/dist/plan/diff.js +237 -24
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-names-key.d.ts +73 -0
- package/dist/plan/entry-names-key.js +236 -0
- package/dist/plan/executor.d.ts +235 -1
- package/dist/plan/executor.js +611 -27
- package/dist/plan/index.d.ts +2 -2
- package/dist/plan/index.js +1 -1
- package/dist/plan/live.d.ts +4 -2
- package/dist/plan/login-host.d.ts +12 -1
- package/dist/plan/login-host.js +20 -2
- package/dist/plan/plan-apply.d.ts +14 -0
- package/dist/plan/plan-apply.js +95 -4
- package/dist/plan/platform-diff.d.ts +92 -1
- package/dist/plan/platform-diff.js +166 -1
- package/dist/plan/platform-org.d.ts +29 -1
- package/dist/plan/platform-org.js +17 -2
- package/dist/plan/platform-providers.d.ts +44 -0
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/store/postgres.js +15 -1
- package/dist/store/schema.d.ts +70 -2
- package/dist/store/schema.js +6 -0
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -3,12 +3,14 @@ import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
|
3
3
|
import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraEnvRevision, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
4
4
|
import { BOULE_BROKER_ENTRY_KEY, bouleEnvDrift, desiredBouleEnv, wantedBouleBrokerEntry, } from './boule.js';
|
|
5
5
|
import { brokerEntryAppliedScopeKey, brokerEntryWarningsScopeKey, entryAuthEnv, entryAuthEnvAppliedScopeKey, wantedBrokerEntry, } from './broker-entries.js';
|
|
6
|
+
import { planEdgeWorkers } from './edge-worker.js';
|
|
6
7
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
7
8
|
import { planFilesKeys } from './entry-files-key.js';
|
|
9
|
+
import { planEntryNamesKeys, planNamesReservedLabels } from './entry-names-key.js';
|
|
8
10
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
9
11
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
10
12
|
import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, } from './mail-copy.js';
|
|
11
|
-
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, versionNotAppliedLine, } from './platform-diff.js';
|
|
13
|
+
import { CALLS_MAS_APPLIED_IMAGE_TAG_SCOPE_KEY, CALLS_SYNAPSE_APPLIED_IMAGE_TAG_SCOPE_KEY, ORG_MAIL_APPLIED_APEX_SCOPE_KEY, ORG_MAIL_APPLIED_HOST_SCOPE_KEY, appEnvAppliedScopeKey, appSettingsOf, appStepKey, appliedAppDomains, appliedAppSettings, dbEnvAppliedScopeKey, resolveAppEnv, serviceDbEnvAppliedScopeKey, versionNotAppliedLine, } from './platform-diff.js';
|
|
12
14
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
13
15
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
14
16
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
@@ -87,7 +89,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
87
89
|
// #267: after the ZITADEL org step, before any app's own issuer
|
|
88
90
|
// application. Apps created later in this run are not in the binding yet;
|
|
89
91
|
// the next run replans this step and repoints them.
|
|
90
|
-
const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false
|
|
92
|
+
const loginHost = planLoginHost(org, recordedIds, platformReadiness.authReady, issuer.step !== null, options.loginHostBindings ?? false,
|
|
93
|
+
// #488: `agoraHost.issuer` is the shared ZITADEL instance's own issuer
|
|
94
|
+
// URL (`https://<platform.auth.hostname>`), the same value every org's
|
|
95
|
+
// sign-in already reads through `planOwners` above -- no separate port
|
|
96
|
+
// or plumbing needed to know the platform's own auth hostname.
|
|
97
|
+
platformAuthHostnameFrom('error' in agoraHost ? null : agoraHost.issuer));
|
|
91
98
|
if (loginHost.step)
|
|
92
99
|
plannedSteps.push(loginHost.step);
|
|
93
100
|
// Planned ahead of everything that needs the zone live (#238); its own step
|
|
@@ -95,7 +102,7 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
95
102
|
const zone = planZone(org, byKey, claimed);
|
|
96
103
|
const zoneWaiting = zone.blocked !== null;
|
|
97
104
|
const zoneBlocked = [];
|
|
98
|
-
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost);
|
|
105
|
+
const mail = planMail(org, byKey, claimed, recordedIds, platformReadiness.mailHost, platformReadiness.platformApex, platformReadiness.platformLettermintProjectId);
|
|
99
106
|
// #238: own-domain mail publishes DNS records into the zone, so it waits for
|
|
100
107
|
// an active zone -- blocked, not planned (rule 2).
|
|
101
108
|
if (mail.step && zoneWaiting && org.mail.usesDomain && org.domain) {
|
|
@@ -197,6 +204,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
197
204
|
// F6b: after the owner, the apps and the files organisation it needs.
|
|
198
205
|
const filesKeys = planFilesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
199
206
|
plannedSteps.push(...filesKeys.steps);
|
|
207
|
+
// F7 (#317): reserved labels before any entry's own key, so a label is
|
|
208
|
+
// never claimable by a customer in the gap between an entry's key going
|
|
209
|
+
// live and its siblings' labels reaching names.
|
|
210
|
+
const namesReservedLabels = planNamesReservedLabels(org, recordedIds, platformReadiness);
|
|
211
|
+
if (namesReservedLabels.step)
|
|
212
|
+
plannedSteps.push(namesReservedLabels.step);
|
|
213
|
+
// After the apps -- a names key needs the entry's app recorded, the same
|
|
214
|
+
// dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
215
|
+
const entryNamesKeys = planEntryNamesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
216
|
+
plannedSteps.push(...entryNamesKeys.steps);
|
|
200
217
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
201
218
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
202
219
|
// one never does.
|
|
@@ -254,6 +271,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
254
271
|
const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
|
|
255
272
|
if (jobsRegistration.step)
|
|
256
273
|
plannedSteps.push(jobsRegistration.step);
|
|
274
|
+
// F8 (#318), after Agora and Boule, not right after the zone: a route
|
|
275
|
+
// already bound by a hand-made entry, another entry's route, a taken
|
|
276
|
+
// script name, or no Workers token all throw at apply, and `runSteps`
|
|
277
|
+
// stops at the first failure -- planned this early, a failing edge step
|
|
278
|
+
// stopped Agora (and everything after it) from provisioning at all
|
|
279
|
+
// (#318 re-review). `cloudflareWorker` still resolves the zone by apex
|
|
280
|
+
// domain the same way `planRateLimit` does, so a deploy (never a
|
|
281
|
+
// retire, which needs no new zone) still waits for it.
|
|
282
|
+
const edgeWorkers = planEdgeWorkers(org, recordedIds, options.edgeWorkers ?? false);
|
|
283
|
+
for (const step of edgeWorkers.steps) {
|
|
284
|
+
if (zoneWaiting && step.action.desired.mode === 'apply') {
|
|
285
|
+
zoneBlocked.push({
|
|
286
|
+
description: `${org.slug}'s ${step.action.key.slice(org.id.length + 1)} edge Worker is blocked, not planned: the zone for ${org.domain} is not active yet`,
|
|
287
|
+
key: step.action.key,
|
|
288
|
+
});
|
|
289
|
+
}
|
|
290
|
+
else {
|
|
291
|
+
plannedSteps.push(step);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
257
294
|
// #10 (resolved): "foundry manages only what the declared org names";
|
|
258
295
|
// anything left unclaimed is listed, never proposed for deletion.
|
|
259
296
|
const unmanaged = live
|
|
@@ -274,6 +311,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
274
311
|
unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
|
|
275
312
|
unmanaged.push(...filesKeys.blocked);
|
|
276
313
|
unmanaged.push(...entryAiKeys.blocked);
|
|
314
|
+
unmanaged.push(...edgeWorkers.blocked);
|
|
315
|
+
unmanaged.push(...entryNamesKeys.blocked);
|
|
316
|
+
unmanaged.push(...namesReservedLabels.blocked);
|
|
277
317
|
// Same for a jobs registration that cannot run (no scheduler wired) or must
|
|
278
318
|
// not run again (minted, never delivered).
|
|
279
319
|
if (jobsRegistration.blocked) {
|
|
@@ -471,6 +511,24 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
471
511
|
});
|
|
472
512
|
return steps;
|
|
473
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* #488: the bare hostname out of an issuer URL (`https://<hostname>`, the
|
|
516
|
+
* only shape {@link agoraHostValuesFromPlatform} or a host-supplied
|
|
517
|
+
* `AgoraHostPort` ever hands back), or `null` when there is no issuer to
|
|
518
|
+
* read one from. What {@link loginHostnameFor} compares the org's own
|
|
519
|
+
* computed `auth.<domain>` against, so the platform's own org never plans
|
|
520
|
+
* its login host equal to the shared ZITADEL instance's own.
|
|
521
|
+
*/
|
|
522
|
+
function platformAuthHostnameFrom(issuer) {
|
|
523
|
+
if (!issuer)
|
|
524
|
+
return null;
|
|
525
|
+
try {
|
|
526
|
+
return new URL(issuer).hostname;
|
|
527
|
+
}
|
|
528
|
+
catch {
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
474
532
|
/**
|
|
475
533
|
* #267: the org's own company login host (`login-host.ts`'s
|
|
476
534
|
* `loginHostnameFor`) -- `auth.<domain>` once the org has one,
|
|
@@ -501,11 +559,22 @@ function planOwners(org, recordedIds, authReady, authIssuer) {
|
|
|
501
559
|
* runs well before `planIssuerApps`/`planCmsClient`/`planAgoraClient`
|
|
502
560
|
* create any app under the org's project -- #267's own "before any app's
|
|
503
561
|
* issuer application".
|
|
562
|
+
*
|
|
563
|
+
* `platformAuthHostname` (#488) is threaded through to {@link
|
|
564
|
+
* loginHostnameFor} so the platform's own org, declared with-domain on the
|
|
565
|
+
* platform's own domain, never plans the shared ZITADEL instance's own login
|
|
566
|
+
* host as its own -- `auth.companyincluded.com` colliding with itself.
|
|
504
567
|
*/
|
|
505
|
-
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired) {
|
|
568
|
+
function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired, platformAuthHostname) {
|
|
506
569
|
const none = { step: null, blocked: null };
|
|
507
570
|
const key = resourceKey('zitadel', 'login-host', org.id);
|
|
508
|
-
const host = loginHostnameFor(org);
|
|
571
|
+
const host = loginHostnameFor(org, platformAuthHostname);
|
|
572
|
+
// #488: the guard in `loginHostnameFor` fired -- the org's own domain host
|
|
573
|
+
// would have been the platform's shared ZITADEL instance host.
|
|
574
|
+
const sharedInstanceHost = Boolean(org.domain) &&
|
|
575
|
+
org.issuer.usesDomain &&
|
|
576
|
+
`auth.${org.domain}` === platformAuthHostname &&
|
|
577
|
+
host !== `auth.${org.domain}`;
|
|
509
578
|
// Already applied -- a re-run plans nothing unless the host changed, an app
|
|
510
579
|
// client id appeared since the binding was last written (#267, rule 4), or
|
|
511
580
|
// an earlier host change still has a removal owed (#274 fix round 2):
|
|
@@ -549,7 +618,9 @@ function planLoginHost(org, recordedIds, authReady, issuerPlanned, bindingsWired
|
|
|
549
618
|
desired: { host },
|
|
550
619
|
},
|
|
551
620
|
step: {
|
|
552
|
-
description:
|
|
621
|
+
description: sharedInstanceHost
|
|
622
|
+
? `set ${org.slug}'s login host to ${host}: its domain host (auth.${org.domain}) is the platform's own shared ZITADEL instance host, so it stays on oikos.host`
|
|
623
|
+
: `set ${org.slug}'s login host to ${host}`,
|
|
553
624
|
destroys: false,
|
|
554
625
|
migrates: false,
|
|
555
626
|
},
|
|
@@ -577,9 +648,14 @@ export const JOBS_REGISTRATION_UNDELIVERED_SCOPE_KEY = 'jobs_registration_undeli
|
|
|
577
648
|
* off this step's own `desired`, the same "the executor writes exactly these
|
|
578
649
|
* host values, so what the plan showed is what gets applied" rule
|
|
579
650
|
* `planAgora` already follows for its own host values -- never rebuilding
|
|
580
|
-
* them itself from a literal or a second lookup.
|
|
651
|
+
* them itself from a literal or a second lookup. #491: `platformApex` and
|
|
652
|
+
* `platformLettermintProjectId` go into `desired` the same way, so the
|
|
653
|
+
* executor can recognise the platform's own org without needing the whole
|
|
654
|
+
* `DeclaredPlatform`.
|
|
581
655
|
*/
|
|
582
|
-
function planMail(org, byKey, claimed, recordedIds, mailHost
|
|
656
|
+
function planMail(org, byKey, claimed, recordedIds, mailHost,
|
|
657
|
+
/** #491 (review round 2): read off `platformReadiness` directly, not off `mailHost` -- see `OrgPlatformReadiness.platformApex`'s own doc comment for why. */
|
|
658
|
+
platformApex, platformLettermintProjectId) {
|
|
583
659
|
const key = resourceKey('coolify', 'mail', org.id);
|
|
584
660
|
const live = byKey.get(key);
|
|
585
661
|
// `off` means mail is not foundry's concern here (org.ts's MailMode doc);
|
|
@@ -599,11 +675,32 @@ function planMail(org, byKey, claimed, recordedIds, mailHost) {
|
|
|
599
675
|
mailboxBlocked: null,
|
|
600
676
|
};
|
|
601
677
|
}
|
|
678
|
+
// #491: this org's own declared domain is the platform's apex (Company
|
|
679
|
+
// Included on companyincluded.com, today) -- it shares the platform's
|
|
680
|
+
// existing Lettermint project rather than minting a `<slug>-app` one of
|
|
681
|
+
// its own, and `executor.ts` must never touch Lettermint for it until an
|
|
682
|
+
// operator has said which project that is. Blocked whole, not planned
|
|
683
|
+
// half-ready: "Blocked, not failing" (foundry's own CLAUDE.md).
|
|
684
|
+
const resolvedMailDomain = org.mail.usesDomain ? org.domain : null;
|
|
685
|
+
const isPlatformApexOrg = Boolean(resolvedMailDomain &&
|
|
686
|
+
platformApex &&
|
|
687
|
+
resolvedMailDomain.toLowerCase() === platformApex.toLowerCase());
|
|
688
|
+
if (isPlatformApexOrg && !platformLettermintProjectId) {
|
|
689
|
+
return {
|
|
690
|
+
step: null,
|
|
691
|
+
blocked: `${org.slug}'s mail is blocked, not planned: its domain ${resolvedMailDomain} is the platform's own apex, and platform.mail.lettermintProjectId is not declared yet (#491)`,
|
|
692
|
+
mailboxBlocked: null,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
602
695
|
const desired = {
|
|
603
696
|
mode: org.mail.mode,
|
|
604
697
|
usesDomain: org.mail.usesDomain,
|
|
605
698
|
apex: mailHost.apex,
|
|
606
699
|
mailHost: mailHost.mailHost,
|
|
700
|
+
// #491: non-null whenever `mailHost` is -- both require a declared
|
|
701
|
+
// platform (`orgPlatformReadinessFrom`'s own doc comment).
|
|
702
|
+
platformApex,
|
|
703
|
+
platformLettermintProjectId,
|
|
607
704
|
};
|
|
608
705
|
// #162 (decided 2026-09-29): an owner's mailbox is minted inside the same
|
|
609
706
|
// `coolify:mail` step, after `applyOrgMail` -- so as long as one is still
|
|
@@ -799,8 +896,35 @@ function planChat(org, byKey, claimed, recordedIds, reading, issuerPlanned) {
|
|
|
799
896
|
};
|
|
800
897
|
}
|
|
801
898
|
const liveUsesDomain = Boolean(live.config.usesDomain);
|
|
802
|
-
if (liveUsesDomain === desiredUsesDomain)
|
|
899
|
+
if (liveUsesDomain === desiredUsesDomain) {
|
|
900
|
+
// #456: a live chat app made before the owner/runtime database split
|
|
901
|
+
// (#239) otherwise never gets another `coolify:chat` step at all --
|
|
902
|
+
// nothing else here ever differs for it again -- and its service keeps
|
|
903
|
+
// running on the bare superuser `DATABASE_URL` its own startup script
|
|
904
|
+
// now refuses. `coolifyChat` throws on any other action against an
|
|
905
|
+
// already-live chat (re-keying is not built, #7 amendment 3), so this
|
|
906
|
+
// step carries a distinct flag telling the executor to push only the
|
|
907
|
+
// split, never to replay the full provision.
|
|
908
|
+
if (!recordedIds[serviceDbEnvAppliedScopeKey('chat')]) {
|
|
909
|
+
return {
|
|
910
|
+
step: {
|
|
911
|
+
action: {
|
|
912
|
+
system: 'coolify',
|
|
913
|
+
kind: 'chat',
|
|
914
|
+
key: org.id,
|
|
915
|
+
desired: { usesDomain: liveUsesDomain, refreshDatabaseSplit: true },
|
|
916
|
+
},
|
|
917
|
+
step: {
|
|
918
|
+
description: `give ${org.slug}'s chat service its owner and runtime database URLs and redeploy it (#239, #456)`,
|
|
919
|
+
destroys: false,
|
|
920
|
+
migrates: false,
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
blocked: null,
|
|
924
|
+
};
|
|
925
|
+
}
|
|
803
926
|
return none;
|
|
927
|
+
}
|
|
804
928
|
const domain = org.domain ?? 'its domain';
|
|
805
929
|
if (!liveUsesDomain && desiredUsesDomain) {
|
|
806
930
|
return {
|
|
@@ -933,17 +1057,19 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
|
|
|
933
1057
|
: step;
|
|
934
1058
|
if (toggle)
|
|
935
1059
|
return { step: withHost(toggle), blocked: null };
|
|
936
|
-
// A live, enabled app made before the internal name
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
// SFU ready and the Matrix stack not
|
|
1060
|
+
// A live, enabled app made before the internal name, the token env or the
|
|
1061
|
+
// owner/runtime database split (#239, #456) existed has no step from the
|
|
1062
|
+
// toggle. Run the step again until the executor has recorded every marker,
|
|
1063
|
+
// but only while it can succeed: the SFU ready and the Matrix stack not
|
|
1064
|
+
// blocked.
|
|
940
1065
|
const missingName = !callsServiceHasName(org, recordedIds);
|
|
941
1066
|
const missingTokenEnv = !callsServiceHasTokenEnv(tokenHost, recordedIds);
|
|
1067
|
+
const missingDbSplit = !recordedIds[serviceDbEnvAppliedScopeKey('calls-service')];
|
|
942
1068
|
if (org.calls.enabled &&
|
|
943
1069
|
callsReady &&
|
|
944
1070
|
!callsBlocked &&
|
|
945
1071
|
byKey.has(key) &&
|
|
946
|
-
(missingName || missingTokenEnv)) {
|
|
1072
|
+
(missingName || missingTokenEnv || missingDbSplit)) {
|
|
947
1073
|
return {
|
|
948
1074
|
step: withHost({
|
|
949
1075
|
action: {
|
|
@@ -955,7 +1081,9 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
|
|
|
955
1081
|
step: {
|
|
956
1082
|
description: missingName
|
|
957
1083
|
? `set the calls service's internal name for ${org.slug}`
|
|
958
|
-
:
|
|
1084
|
+
: missingTokenEnv
|
|
1085
|
+
? `set the calls service's ZITADEL token env for ${org.slug}`
|
|
1086
|
+
: `give the calls service's owner and runtime database URLs for ${org.slug} and redeploy it (#239, #456)`,
|
|
959
1087
|
destroys: false,
|
|
960
1088
|
migrates: false,
|
|
961
1089
|
},
|
|
@@ -976,6 +1104,32 @@ function planCallsService(org, byKey, claimed, callsReady, recordedIds, callsBlo
|
|
|
976
1104
|
* explicit opt-in this default deliberately leaves out.
|
|
977
1105
|
*/
|
|
978
1106
|
const AI_DEFAULT_OFFERS = ['ai.offerings:invoke', 'ai.runs:create'];
|
|
1107
|
+
/**
|
|
1108
|
+
* #484: the spend cap foundry applies when an org declares no AI limit
|
|
1109
|
+
* (`org.ai.limitMicros === null`) -- Archon creates every company that way,
|
|
1110
|
+
* and AI's own `updateBudget` has no uncapped representation
|
|
1111
|
+
* (`UpdateBudget.limitMicros` is a required string), so a null limit used to
|
|
1112
|
+
* throw `ai:budget` out of every apply. William's Decision, 2026-10-05: 100
|
|
1113
|
+
* EUR, in `ai`'s own USD-micros unit (`usdToMicros`,
|
|
1114
|
+
* `ai/packages/service/src/money.ts`), billing to revisit the number later.
|
|
1115
|
+
* 100 EUR at the ECB reference rate for 2 October 2026 (1.1225 USD/EUR, the
|
|
1116
|
+
* most recent published rate as of the decision) is 112.25 USD = 112,250,000
|
|
1117
|
+
* micros. A declared numeric limit always wins over this default --
|
|
1118
|
+
* {@link effectiveAiLimitMicros} is the one place that substitutes it.
|
|
1119
|
+
*/
|
|
1120
|
+
export const DEFAULT_AI_LIMIT_MICROS = '112250000';
|
|
1121
|
+
/**
|
|
1122
|
+
* #484: the limit foundry actually plans and applies for an org -- its own
|
|
1123
|
+
* declared `ai.limitMicros` when it declares one, else {@link DEFAULT_AI_LIMIT_MICROS}.
|
|
1124
|
+
* Both {@link planAiOrganisation} and {@link planAiBudget} fill
|
|
1125
|
+
* `action.desired.limitMicros` through this one function, so a company with
|
|
1126
|
+
* no declared limit gets the same 100 EUR cap whichever step sets it, and
|
|
1127
|
+
* never reaches `executor.ts`'s `aiBudget` with a null `desired.limitMicros`
|
|
1128
|
+
* for `updateBudget` to choke on.
|
|
1129
|
+
*/
|
|
1130
|
+
function effectiveAiLimitMicros(limitMicros) {
|
|
1131
|
+
return limitMicros ?? DEFAULT_AI_LIMIT_MICROS;
|
|
1132
|
+
}
|
|
979
1133
|
/**
|
|
980
1134
|
* Why an org step on a shared platform service is blocked: a key that is not
|
|
981
1135
|
* held, no held-keys reader given at all, or the service off or not applied.
|
|
@@ -1073,9 +1227,14 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1073
1227
|
* regardless), so the only planned state is "not provisioned yet".
|
|
1074
1228
|
* `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
|
|
1075
1229
|
* switched on" means by default; `limitMicros` seeds the organisation's
|
|
1076
|
-
* first budget row with
|
|
1077
|
-
*
|
|
1078
|
-
* {@link
|
|
1230
|
+
* first budget row with the org's own declared limit, or
|
|
1231
|
+
* {@link DEFAULT_AI_LIMIT_MICROS} when it declares none (#484) --
|
|
1232
|
+
* {@link effectiveAiLimitMicros} either way, the same value
|
|
1233
|
+
* {@link planAiBudget} independently plans from `live` -- so a brand new
|
|
1234
|
+
* org's first plan always carries both steps (same shape as any other
|
|
1235
|
+
* declared limit, `ai:organisation` before `ai:budget`), and once that one
|
|
1236
|
+
* apply run has pushed both, a later plan finds no further drift to
|
|
1237
|
+
* replan.
|
|
1079
1238
|
*
|
|
1080
1239
|
* #177: blocked, not planned, while `aiReady` is false -- the platform's own
|
|
1081
1240
|
* AI host (#172) is off or not yet applied.
|
|
@@ -1101,12 +1260,14 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
|
|
|
1101
1260
|
key: org.id,
|
|
1102
1261
|
desired: {
|
|
1103
1262
|
provision: true,
|
|
1104
|
-
limitMicros: org.ai.limitMicros,
|
|
1263
|
+
limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
|
|
1105
1264
|
offers: [...AI_DEFAULT_OFFERS],
|
|
1106
1265
|
},
|
|
1107
1266
|
},
|
|
1108
1267
|
step: {
|
|
1109
|
-
description:
|
|
1268
|
+
description: org.ai.limitMicros === null
|
|
1269
|
+
? `provision ${org.slug}'s own organisation on AI and switch AI use on, capped at the default of ${DEFAULT_AI_LIMIT_MICROS} micros (no limit declared)`
|
|
1270
|
+
: `provision ${org.slug}'s own organisation on AI and switch AI use on`,
|
|
1110
1271
|
destroys: false,
|
|
1111
1272
|
migrates: false,
|
|
1112
1273
|
},
|
|
@@ -1118,8 +1279,16 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
|
|
|
1118
1279
|
* #27: the org's own spend limit on AI, a per-org singleton resource in the
|
|
1119
1280
|
* same singleton shape as issuer/mail/chat above. Planned after {@link planAiOrganisation}
|
|
1120
1281
|
* (#80), which already seeds a brand new org's first budget row from this
|
|
1121
|
-
* same `org.ai.limitMicros`
|
|
1122
|
-
* live value has drifted from
|
|
1282
|
+
* same `org.ai.limitMicros` (or {@link DEFAULT_AI_LIMIT_MICROS}, #484) --
|
|
1283
|
+
* this planner only fires again once that row's live value has drifted from
|
|
1284
|
+
* what is declared (or defaulted).
|
|
1285
|
+
*
|
|
1286
|
+
* #484: `desired.limitMicros` goes through {@link effectiveAiLimitMicros},
|
|
1287
|
+
* never the bare `org.ai.limitMicros` -- `executor.ts`'s `aiBudget` calls
|
|
1288
|
+
* AI's `updateBudget`, whose `limitMicros` is a required string with no
|
|
1289
|
+
* uncapped representation, so a step that reached it with `null` always
|
|
1290
|
+
* threw. The description names the default explicitly when it is the one in
|
|
1291
|
+
* effect, so an operator reading the plan sees which cap applies.
|
|
1123
1292
|
*
|
|
1124
1293
|
* #177: silently plans nothing while there is no organisation to size a
|
|
1125
1294
|
* budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
|
|
@@ -1132,7 +1301,8 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
|
|
|
1132
1301
|
claimed.add(key);
|
|
1133
1302
|
if (!aiReady && !live)
|
|
1134
1303
|
return { step: null, blocked: null };
|
|
1135
|
-
const
|
|
1304
|
+
const isDefault = org.ai.limitMicros === null;
|
|
1305
|
+
const desired = { limitMicros: effectiveAiLimitMicros(org.ai.limitMicros) };
|
|
1136
1306
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
1137
1307
|
if (liveLimitMicros === desired.limitMicros)
|
|
1138
1308
|
return { step: null, blocked: null };
|
|
@@ -1147,7 +1317,9 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
|
|
|
1147
1317
|
step: {
|
|
1148
1318
|
action: { system: 'ai', kind: 'budget', key: org.id, desired },
|
|
1149
1319
|
step: {
|
|
1150
|
-
description:
|
|
1320
|
+
description: isDefault
|
|
1321
|
+
? `set ${org.slug}'s AI budget limit to the default of ${desired.limitMicros} micros (no limit declared)`
|
|
1322
|
+
: `set ${org.slug}'s AI budget limit to ${desired.limitMicros} micros`,
|
|
1151
1323
|
destroys: false,
|
|
1152
1324
|
migrates: false,
|
|
1153
1325
|
},
|
|
@@ -1657,6 +1829,32 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1657
1829
|
key: blockedKey(`env:${name}`),
|
|
1658
1830
|
})))
|
|
1659
1831
|
: [];
|
|
1832
|
+
// #491: the `app` entry's own Lettermint work (`executor.ts`'s
|
|
1833
|
+
// `ensureAppLettermintMail`) reads `MAIL_DOMAIN_SCOPE_KEY` directly, which
|
|
1834
|
+
// can already be recorded from before this org's domain became the
|
|
1835
|
+
// platform's apex -- so this is checked here too, independently of
|
|
1836
|
+
// whether `coolify:mail` itself is blocked this run (`planMail` above).
|
|
1837
|
+
// Reported, not fatal: the rest of this entry's own plan is unaffected.
|
|
1838
|
+
// Review round 2: reads `readiness.platformApex` directly, not
|
|
1839
|
+
// `readiness.mailHost?.platformApex` -- the platform's own apex is a fact
|
|
1840
|
+
// independent of whether its mail server happens to be enabled or applied
|
|
1841
|
+
// right now, and gating this on `mailHost` let a platform-apex org with an
|
|
1842
|
+
// already-recorded domain take the unprotected path the moment platform
|
|
1843
|
+
// mail went back to off.
|
|
1844
|
+
if (entryKey === APP_ENTRY_KEY) {
|
|
1845
|
+
const mailDomain = recordedIds[MAIL_DOMAIN_SCOPE_KEY];
|
|
1846
|
+
const platformApex = readiness.platformApex;
|
|
1847
|
+
const platformLettermintProjectId = readiness.platformLettermintProjectId;
|
|
1848
|
+
if (mailDomain &&
|
|
1849
|
+
platformApex &&
|
|
1850
|
+
mailDomain.toLowerCase() === platformApex.toLowerCase() &&
|
|
1851
|
+
!platformLettermintProjectId) {
|
|
1852
|
+
blocked.push({
|
|
1853
|
+
description: `${org.slug}'s app Lettermint mail is blocked, not planned: its mail domain is the platform's own apex, and platform.mail.lettermintProjectId is not declared yet (#491)`,
|
|
1854
|
+
key: blockedKey('lettermint-project'),
|
|
1855
|
+
});
|
|
1856
|
+
}
|
|
1857
|
+
}
|
|
1660
1858
|
const toggled = planHostingToggle('coolify', 'app', entry !== undefined, org, byKey, claimed, () => `provision ${org.slug}'s ${label}`, () => `park ${org.slug}'s ${label} (stops serving; app data is retained, not deleted -- #7 corrections 1)`, stepKey);
|
|
1661
1859
|
if (toggled)
|
|
1662
1860
|
return { step: toggled, blocked };
|
|
@@ -1859,6 +2057,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
1859
2057
|
if (signsIn)
|
|
1860
2058
|
app.step.action.desired = { ...app.step.action.desired, authIssuer };
|
|
1861
2059
|
}
|
|
2060
|
+
// #491: only the `app` entry's own step runs `ensureAppLettermintMail`
|
|
2061
|
+
// (`executor.ts`'s own `entry.key === APP_ENTRY_KEY` gate). Unlike
|
|
2062
|
+
// `secretSources`/`authIssuer` above, these two are set unconditionally
|
|
2063
|
+
// -- `null` when no platform is declared, never omitted -- because
|
|
2064
|
+
// `executor.ts` treats the key being *absent* as a stale plan predating
|
|
2065
|
+
// this field and refuses to run (review round 2, finding 3): an omitted
|
|
2066
|
+
// key here would read identically to a stale plan and silently fall
|
|
2067
|
+
// back to the unprotected pre-#491 path instead.
|
|
2068
|
+
if (key === APP_ENTRY_KEY && app.step && app.step.action.desired.enabled !== false) {
|
|
2069
|
+
app.step.action.desired = {
|
|
2070
|
+
...app.step.action.desired,
|
|
2071
|
+
platformApex: readiness.platformApex,
|
|
2072
|
+
platformLettermintProjectId: readiness.platformLettermintProjectId,
|
|
2073
|
+
};
|
|
2074
|
+
}
|
|
1862
2075
|
if (app.step)
|
|
1863
2076
|
steps.push(app.step);
|
|
1864
2077
|
blocked.push(...app.blocked);
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import type { DeclaredOrg, EdgeConfig, HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
/**
|
|
4
|
+
* F8 (#318): one plain Cloudflare Worker per hosting entry that declares
|
|
5
|
+
* `edge`, deployed from a built artifact that entry's own repo publishes
|
|
6
|
+
* (estate DECISIONS.md, 2026-10-04: foundry fetches and verifies it, never
|
|
7
|
+
* checks it out or bundles it). Unlike `planEntryAiKeys`/`planFilesKeys`,
|
|
8
|
+
* there is no renewal window -- a Worker script has no expiry -- so a step
|
|
9
|
+
* is planned again only while the entry's own declared config (`route`,
|
|
10
|
+
* `bindings` or, crucially, `artifactSha256`) differs from what was last
|
|
11
|
+
* applied (recorded as that config's JSON, `edgeAppliedScopeKey`):
|
|
12
|
+
* `artifactSha256` is itself the declared signal that the built bytes
|
|
13
|
+
* changed, so a rebuild under an unchanged URL still redeploys as soon as
|
|
14
|
+
* the entry declares the new digest. "Blocked, not failing" (CLAUDE.md): a
|
|
15
|
+
* host whose executor carries no `WorkersPort` reports every entry's step
|
|
16
|
+
* (apply or retire) as blocked, not planned to a step that would throw.
|
|
17
|
+
*/
|
|
18
|
+
export declare const entryEdgeAppliedScopeKey: (entryKey: string) => string;
|
|
19
|
+
export declare const entryEdgeRouteIdScopeKey: (entryKey: string) => string;
|
|
20
|
+
/** The route pattern the recorded route id above was created for -- read to find and delete the *old* route when the declared route pattern changes. */
|
|
21
|
+
export declare const entryEdgeRoutePatternScopeKey: (entryKey: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* The script name retired, once a retire step completes. `runSteps` only
|
|
24
|
+
* ever writes a step's main recorded id from a *truthy* `resourceId`
|
|
25
|
+
* (`plan-apply.ts`), and retire returns none -- so the main
|
|
26
|
+
* `cloudflare:worker:<key>` id is never cleared by returning from the step,
|
|
27
|
+
* and without this marker the "gone" scan below would plan the same retire
|
|
28
|
+
* forever. Compared against that still-live id, the same convergence
|
|
29
|
+
* `entryAiRetiredScopeKey`/`entryFilesRetiredScopeKey`/
|
|
30
|
+
* `entryNamesRetiredScopeKey` already give their own entry keys.
|
|
31
|
+
*/
|
|
32
|
+
export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
|
|
33
|
+
/** The script name being uploaded, recorded (and persisted) *before* the upload call -- so a crash mid-upload or a failed route create still leaves a trail to the script rather than orphaning it, and a retry reads the script as foundry's own instead of tripping the `scriptExists` overwrite guard. Cleared once the step records success. */
|
|
34
|
+
export declare const entryEdgeUploadedScopeKey: (entryKey: string) => string;
|
|
35
|
+
/** The zone the recorded route id lives in, as {@link EdgeRouteZone} JSON -- so retire and destroy delete it on *that* zone even after `org.domain` changes or goes null. */
|
|
36
|
+
export declare const entryEdgeRouteZoneScopeKey: (entryKey: string) => string;
|
|
37
|
+
/** A route about to be created, as {@link EdgeRoutePending} JSON, recorded and persisted before `createRoute` -- so a crash or failed persist right after Cloudflare creates it still leaves retire and destroy something to find it by (`findRoute`). Cleared once the route id is recorded. */
|
|
38
|
+
export declare const entryEdgeRoutePendingScopeKey: (entryKey: string) => string;
|
|
39
|
+
export interface EdgeRouteZone {
|
|
40
|
+
id: string;
|
|
41
|
+
apex: string;
|
|
42
|
+
}
|
|
43
|
+
export interface EdgeRoutePending extends EdgeRouteZone {
|
|
44
|
+
pattern: string;
|
|
45
|
+
script: string;
|
|
46
|
+
}
|
|
47
|
+
/** The per-entry scope keys that mark something deployed or half-deployed, or that a retire already handled it -- cleared together once org destroy has torn the Worker down (#318 re-review: the retired marker was left out, so it stayed recorded as a stale leftover after the org itself was gone). */
|
|
48
|
+
export declare const edgeWorkerTraceScopeKeys: (entryKey: string) => string[];
|
|
49
|
+
/**
|
|
50
|
+
* Every entry key of `orgId` with something foundry deployed still live,
|
|
51
|
+
* mapped to the script name recorded for it (`''` if only a route trace is
|
|
52
|
+
* left). Read by the planner's gone-scan and by org destroy alike, so the two
|
|
53
|
+
* agree on what is live: the main `cloudflare:worker:<org>:<entry>` id
|
|
54
|
+
* (unless the retired marker matches it), or any partial-deploy trace -- the
|
|
55
|
+
* uploaded name, a recorded route id, or a pending route -- regardless of
|
|
56
|
+
* the retired marker, since an apply clears that marker before it leaves any
|
|
57
|
+
* of these (#318 re-review).
|
|
58
|
+
*/
|
|
59
|
+
export declare function liveEdgeWorkers(orgId: string, recordedIds: Readonly<Record<string, string>>): Map<string, string>;
|
|
60
|
+
/** The first 8 hex characters of org.id's own sha256 -- see {@link edgeWorkerName}'s doc comment. */
|
|
61
|
+
export declare const EDGE_WORKER_ORG_HASH_LENGTH = 8;
|
|
62
|
+
/** Cloudflare's own limit on a Worker script name. */
|
|
63
|
+
export declare const EDGE_WORKER_NAME_MAX_LENGTH = 63;
|
|
64
|
+
/**
|
|
65
|
+
* `${8-hex-char hash of org.id}-${entry.key}-edge`, not `${org.slug}-...`
|
|
66
|
+
* (#318 re-review): a slug and an entry key both allow hyphens, so org
|
|
67
|
+
* `a-b` entry `c` and org `a` entry `b-c` produced the identical name
|
|
68
|
+
* `a-b-c-edge` on the one shared Cloudflare account -- `scriptExists` only
|
|
69
|
+
* caught the second of the two to apply, and two concurrent applies could
|
|
70
|
+
* both pass it. `org.id` is a UUID with no ambiguity of its own, but using
|
|
71
|
+
* it in full would make the fixed part of the name longer than most of the
|
|
72
|
+
* 63-character budget this port's `EDGE_WORKER_NAME_MAX_LENGTH` gives it,
|
|
73
|
+
* so only a short hash of it is used -- `validateEntryEdge` (`org.ts`)
|
|
74
|
+
* checks the resulting name never exceeds that limit.
|
|
75
|
+
*/
|
|
76
|
+
export declare const edgeWorkerName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
77
|
+
/**
|
|
78
|
+
* Foundry's own fixed compatibility date for every Worker it deploys -- not
|
|
79
|
+
* a per-entry declared value, so it is not part of {@link EdgeWorkerWant}
|
|
80
|
+
* and never triggers a re-plan on its own; bumping it is a foundry release,
|
|
81
|
+
* like {@link RATE_LIMIT_FREE_PLAN_PERIOD_SECONDS} is a fixed Cloudflare
|
|
82
|
+
* Free plan value rather than something an org declares.
|
|
83
|
+
*/
|
|
84
|
+
export declare const EDGE_WORKER_COMPATIBILITY_DATE = "2026-09-12";
|
|
85
|
+
export type EdgeWorkerMode = 'apply' | 'retire';
|
|
86
|
+
export interface EdgeWorkerWant {
|
|
87
|
+
name: string;
|
|
88
|
+
artifactUrl: string;
|
|
89
|
+
artifactSha256: string;
|
|
90
|
+
route: string;
|
|
91
|
+
bindings: EdgeConfig['bindings'];
|
|
92
|
+
}
|
|
93
|
+
export declare function wantedEdgeWorker(org: DeclaredOrg, entry: HostingEntry): EdgeWorkerWant;
|
|
94
|
+
type Blocked = Array<{
|
|
95
|
+
description: string;
|
|
96
|
+
key: string;
|
|
97
|
+
}>;
|
|
98
|
+
/**
|
|
99
|
+
* One step per entry whose `edge` config is new or changed, plus one retire
|
|
100
|
+
* step per entry whose Worker is recorded but no longer declared -- either
|
|
101
|
+
* because the entry dropped `edge` or because the entry itself is gone, the
|
|
102
|
+
* same "gone" scan `planEntryAiKeys`/`planFilesKeys`/`planEntryNamesKeys`
|
|
103
|
+
* run over their own recorded keys, so removing a whole entry still cleans
|
|
104
|
+
* up its Worker. `workersAvailable` is the host's own
|
|
105
|
+
* `FoundryPlanApplyOptions.edgeWorkers` (mirrors `jobsAvailable`): without
|
|
106
|
+
* it, every entry's step -- apply or retire -- is reported blocked rather
|
|
107
|
+
* than planned to fail at apply. The caller (`diff.ts`) gates the *apply*
|
|
108
|
+
* steps further, on an active zone, the same way it gates `planRateLimit`.
|
|
109
|
+
*/
|
|
110
|
+
export declare function planEdgeWorkers(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, workersAvailable: boolean): {
|
|
111
|
+
steps: DraftStep[];
|
|
112
|
+
blocked: Blocked;
|
|
113
|
+
};
|
|
114
|
+
export {};
|