@wtfalch/foundry 0.17.0 → 0.18.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/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/migrations/0045_names_to_edge.sql +107 -0
- package/dist/org.d.ts +74 -3
- package/dist/org.js +133 -0
- package/dist/plan/chat-matrix.d.ts +0 -1
- package/dist/plan/chat-matrix.js +4 -1
- package/dist/plan/diff.d.ts +18 -0
- package/dist/plan/diff.js +239 -26
- package/dist/plan/entry-edge-key.d.ts +79 -0
- package/dist/plan/entry-edge-key.js +260 -0
- package/dist/plan/executor.d.ts +246 -1
- package/dist/plan/executor.js +684 -32
- 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 +15 -0
- package/dist/plan/plan-apply.js +102 -9
- 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 +45 -1
- package/dist/plan/platform-providers.js +514 -7
- package/dist/plan/step-system.js +1 -0
- package/dist/plan/worker.d.ts +134 -0
- package/dist/plan/worker.js +239 -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
|
@@ -4,15 +4,17 @@ import { AGORA_ACTIVATED_ENV_SCOPE_KEY, CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INT
|
|
|
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
6
|
import { planEntryAiKeys } from './entry-ai-key.js';
|
|
7
|
+
import { planEdgeReservedLabels, planEntryEdgeKeys } from './entry-edge-key.js';
|
|
7
8
|
import { planFilesKeys } from './entry-files-key.js';
|
|
8
9
|
import { appHostname, entryHostnames, tunnelDnsHostnames } from './hostnames.js';
|
|
9
10
|
import { LOGIN_BINDING_SCOPE_KEY, loginBindingState, loginHostClientIds, loginHostnameFor, pendingRemovalHosts, } from './login-host.js';
|
|
10
11
|
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';
|
|
12
|
+
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
13
|
import { DEFAULT_ORG_PLATFORM_READINESS } from './platform-org.js';
|
|
13
14
|
import { PLATFORM_AUTH_BROKER_MAIL_KEY_REF } from './platform-providers.js';
|
|
14
15
|
import { appLabelsAppliedScopeKey, entryLabelHash, entryLabelSpec } from './proxy-labels.js';
|
|
15
16
|
import { withStepSystem } from './step-system.js';
|
|
17
|
+
import { planWorkers } from './worker.js';
|
|
16
18
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
17
19
|
export function toPlan(detail) {
|
|
18
20
|
return {
|
|
@@ -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 edge.
|
|
210
|
+
const edgeReservedLabels = planEdgeReservedLabels(org, recordedIds, platformReadiness, options.platformHostnames ?? []);
|
|
211
|
+
if (edgeReservedLabels.step)
|
|
212
|
+
plannedSteps.push(edgeReservedLabels.step);
|
|
213
|
+
// After the apps -- a edge key needs the entry's app recorded, the same
|
|
214
|
+
// dependency `planEntryAiKeys`/`planFilesKeys` have.
|
|
215
|
+
const entryEdgeKeys = planEntryEdgeKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
216
|
+
plannedSteps.push(...entryEdgeKeys.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 worker 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 = planWorkers(org, recordedIds, options.edgeWorkers ?? false, options.platformHostnames ?? []);
|
|
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)} 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(...entryEdgeKeys.blocked);
|
|
316
|
+
unmanaged.push(...edgeReservedLabels.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);
|
|
@@ -2001,10 +2214,10 @@ function planTunnel(org, byKey, claimed, recordedIds) {
|
|
|
2001
2214
|
// differ from the names now declared. An org that never recorded a list
|
|
2002
2215
|
// has nothing to compare, so only the domain check above applies to it.
|
|
2003
2216
|
const published = recordedIds[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY];
|
|
2004
|
-
const
|
|
2217
|
+
const edgeChanged = published !== undefined &&
|
|
2005
2218
|
org.domain !== null &&
|
|
2006
2219
|
sortedNames(published.split(',')) !== sortedNames(tunnelDnsHostnames(org, org.domain));
|
|
2007
|
-
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain ||
|
|
2220
|
+
const dnsStale = recordedIds[CLOUDFLARE_TUNNEL_DNS_DOMAIN_SCOPE_KEY] !== org.domain || edgeChanged;
|
|
2008
2221
|
if (!connectorMissing && !dnsStale)
|
|
2009
2222
|
return null;
|
|
2010
2223
|
return {
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DeclaredOrg, HostingEntry } from '../org.js';
|
|
2
|
+
import type { DraftStep } from './diff.js';
|
|
3
|
+
import type { OrgPlatformReadiness } from './platform-org.js';
|
|
4
|
+
/**
|
|
5
|
+
* F7 (#317): one edge key per entry with `uses.edge`, minted directly
|
|
6
|
+
* below the org with the edge service (`EdgePort.mintKey({
|
|
7
|
+
* organisationId, ... })` -- no org-level root key the way files/ai have
|
|
8
|
+
* one, since the edge service takes the org id itself). `EDGE_URL` and
|
|
9
|
+
* `EDGE_API_KEY` go to the entry's env and nowhere else. A edge key has no
|
|
10
|
+
* declared renewal window in the issue, so unlike `planEntryAiKeys`/
|
|
11
|
+
* `planFilesKeys` this mints once and never renews.
|
|
12
|
+
*
|
|
13
|
+
* `EDGE_*` is written in this step, after the app's own `coolify:app`
|
|
14
|
+
* create (which never knows the key yet), so the app's first boot has
|
|
15
|
+
* neither and its first deploy restarts once this step's env write lands --
|
|
16
|
+
* the same one-extra-restart shape `planEntryAiKeys`'s `AI_*`/
|
|
17
|
+
* `planFilesKeys`'s `FILES_*` already have, not a names-specific gap.
|
|
18
|
+
*
|
|
19
|
+
* A domain change after mint is not detected (#317 re-review): the key is
|
|
20
|
+
* bound to the domain passed at mint time, but nothing here records that
|
|
21
|
+
* domain to compare against a later `org.domain`, so an org that changes
|
|
22
|
+
* domains keeps a edge key still bound to the old one. Not built --
|
|
23
|
+
* resolving it needs a manual revoke-and-re-mint (drop `uses.edge`, apply,
|
|
24
|
+
* re-add it) until this is.
|
|
25
|
+
*/
|
|
26
|
+
export declare const entryEdgeUndeliveredScopeKey: (entryKey: string) => string;
|
|
27
|
+
/** The id of a key that was live when its entry stopped declaring `uses.edge`, once handled. */
|
|
28
|
+
export declare const entryEdgeRetiredScopeKey: (entryKey: string) => string;
|
|
29
|
+
export declare const ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS = 3;
|
|
30
|
+
/** How long an entry's edge key lives. The issue names no renewal window, so this mints once and never renews. */
|
|
31
|
+
export declare const EDGE_ENTRY_KEY_DAYS = 365;
|
|
32
|
+
/** The ISO time a delivered key's mint succeeded -- the only trace of its age, since there is no renewal to track against. */
|
|
33
|
+
export declare const entryEdgeMintedAtScopeKey: (entryKey: string) => string;
|
|
34
|
+
/** A key with fewer days left than this is surfaced as a blocked warning row (#317 review): still live, but nothing here renews it. */
|
|
35
|
+
export declare const EDGE_ENTRY_KEY_WARN_WINDOW_DAYS = 30;
|
|
36
|
+
/** The reserved labels last sent to the edge service, as that sorted list's own JSON. */
|
|
37
|
+
export declare const EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY = "edge_reserved_labels_applied";
|
|
38
|
+
export declare const entryEdgeKeyName: (org: DeclaredOrg, entry: HostingEntry) => string;
|
|
39
|
+
/**
|
|
40
|
+
* Every label under `org.domain` that foundry provisions or the platform
|
|
41
|
+
* uses, so edge never lets a company claim one: the hostnames foundry routes
|
|
42
|
+
* (`tunnelIngressHostnames`: entries, site, cms, calls, Agora, Boule, share),
|
|
43
|
+
* the names it locks (`lockedHostnames`: also the sign-in host and the
|
|
44
|
+
* platform's service hostnames), each entry's own labels, the mail labels
|
|
45
|
+
* and `www`. The entry's `*` is the space users claim in, so it reserves
|
|
46
|
+
* nothing, and neither does the bare domain.
|
|
47
|
+
*
|
|
48
|
+
* An org without a domain has no hostnames edge sees (`uses.edge` needs a
|
|
49
|
+
* domain), so it reserves `www` only.
|
|
50
|
+
*/
|
|
51
|
+
export declare function edgeReservedLabels(org: DeclaredOrg, platformHostnames?: readonly string[]): string[];
|
|
52
|
+
type Blocked = Array<{
|
|
53
|
+
description: string;
|
|
54
|
+
key: string;
|
|
55
|
+
}>;
|
|
56
|
+
/**
|
|
57
|
+
* One step per entry whose edge key is missing, plus one retire step per
|
|
58
|
+
* entry whose `uses.edge` was removed but still has a recorded key.
|
|
59
|
+
* Blocked, not planned (rule 2), while the platform has no edge service
|
|
60
|
+
* wired, the entry's app is not recorded, or a previous mint's delivery
|
|
61
|
+
* failed.
|
|
62
|
+
*/
|
|
63
|
+
export declare function planEntryEdgeKeys(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, plannedSteps: readonly DraftStep[], readiness: OrgPlatformReadiness, now?: number): {
|
|
64
|
+
steps: DraftStep[];
|
|
65
|
+
blocked: Blocked;
|
|
66
|
+
};
|
|
67
|
+
/**
|
|
68
|
+
* The reserved-labels step: resent whenever the org's own label set
|
|
69
|
+
* (`edgeReservedLabels`) differs from what was last sent. While the org
|
|
70
|
+
* uses edge at all (at least one entry with `uses.edge`) that set is
|
|
71
|
+
* `edgeReservedLabels(org)`; once the last entry drops `uses.edge`, this
|
|
72
|
+
* clears whatever was last sent (an empty list) rather than leaving it
|
|
73
|
+
* stuck in names forever -- a one-time step, same as any other change.
|
|
74
|
+
*/
|
|
75
|
+
export declare function planEdgeReservedLabels(org: DeclaredOrg, recordedIds: Readonly<Record<string, string>>, readiness: OrgPlatformReadiness, platformHostnames?: readonly string[]): {
|
|
76
|
+
step: DraftStep | null;
|
|
77
|
+
blocked: Blocked;
|
|
78
|
+
};
|
|
79
|
+
export {};
|