@wtfalch/foundry 0.16.16 → 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/apply.d.ts +22 -1
- package/dist/bootstrap/bootstrap.d.ts +6 -1
- package/dist/bootstrap/bootstrap.js +26 -3
- package/dist/bootstrap/declare.d.ts +11 -1
- package/dist/bootstrap/declare.js +18 -6
- package/dist/index.d.ts +11 -6
- package/dist/index.js +8 -1
- package/dist/migrations/0038_calls_version_pin.sql +8 -0
- package/dist/migrations/0039_destroy_record.sql +7 -0
- package/dist/migrations/0040_company_included_uuid.sql +72 -0
- package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
- package/dist/migrations/0042_mutation_leases.sql +12 -0
- 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 +128 -3
- package/dist/org.js +186 -1
- package/dist/plan/adopt.d.ts +76 -0
- package/dist/plan/adopt.js +184 -0
- package/dist/plan/agora.d.ts +21 -0
- package/dist/plan/agora.js +37 -0
- package/dist/plan/broker-entries.d.ts +9 -1
- package/dist/plan/broker-entries.js +13 -5
- package/dist/plan/chat-matrix.d.ts +1 -2
- package/dist/plan/chat-matrix.js +15 -4
- package/dist/plan/core-files.d.ts +25 -0
- package/dist/plan/core-files.js +25 -0
- package/dist/plan/diff.d.ts +27 -3
- package/dist/plan/diff.js +264 -86
- package/dist/plan/edge-worker.d.ts +114 -0
- package/dist/plan/edge-worker.js +201 -0
- package/dist/plan/entry-ai-key.d.ts +3 -3
- package/dist/plan/entry-files-key.d.ts +3 -3
- 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 +292 -24
- package/dist/plan/executor.js +745 -77
- package/dist/plan/index.d.ts +6 -2
- package/dist/plan/index.js +3 -1
- package/dist/plan/live.d.ts +9 -2
- package/dist/plan/login-host.d.ts +13 -2
- package/dist/plan/login-host.js +26 -7
- package/dist/plan/plan-apply.d.ts +153 -6
- package/dist/plan/plan-apply.js +639 -78
- package/dist/plan/platform-diff.d.ts +106 -1
- package/dist/plan/platform-diff.js +182 -2
- package/dist/plan/platform-org.d.ts +45 -1
- package/dist/plan/platform-org.js +34 -3
- package/dist/plan/platform-providers.d.ts +45 -1
- package/dist/plan/platform-providers.js +585 -16
- package/dist/plan/providers-readers.d.ts +2 -0
- package/dist/plan/providers-readers.js +4 -0
- package/dist/plan/rollout.d.ts +52 -0
- package/dist/plan/rollout.js +37 -0
- package/dist/plan/step-system.d.ts +22 -0
- package/dist/plan/step-system.js +38 -0
- package/dist/plan.d.ts +3 -0
- package/dist/platform.d.ts +22 -3
- package/dist/platform.js +6 -0
- package/dist/state.d.ts +13 -0
- package/dist/store/audit.js +15 -1
- package/dist/store/postgres.d.ts +1 -1
- package/dist/store/postgres.js +142 -15
- package/dist/store/schema.d.ts +202 -5
- package/dist/store/schema.js +18 -1
- package/dist/store.d.ts +47 -4
- package/dist/store.js +9 -1
- package/package.json +1 -1
package/dist/plan/diff.js
CHANGED
|
@@ -1,17 +1,20 @@
|
|
|
1
1
|
import { VERSIONS, floatingTagBlocked } from '../bootstrap/versions.js';
|
|
2
2
|
import { APP_ENTRY_KEY, ownerMailboxRecorded, ownerMailboxes } from '../org.js';
|
|
3
|
-
import { CALLS_INTROSPECTION_SCOPE_KEY, CALLS_INTROSPECTION_UNDELIVERED_SCOPE_KEY, NO_AGORA_HOST_VALUES, ZITADEL_ORG_SCOPE_KEY, agoraEnvDrift, agoraHostMissing, agoraHostnameFor, callsServiceHasName, callsServiceHasTokenEnv, callsTokenEnvMovedMessage, callsTokenHostFrom, desiredAgoraEnv, withRecordedMessagingProject, } from './agora.js';
|
|
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';
|
|
17
|
+
import { withStepSystem } from './step-system.js';
|
|
15
18
|
/** The `Plan` (#19's public contract) a `PlanDetail` carries. */
|
|
16
19
|
export function toPlan(detail) {
|
|
17
20
|
return {
|
|
@@ -42,9 +45,8 @@ function resourceKey(system, kind, key) {
|
|
|
42
45
|
* `hosting.issuerApps[]` entry rather than a single singleton, since an org
|
|
43
46
|
* can declare more than one ZITADEL app. #80 adds two more singletons,
|
|
44
47
|
* modeled on {@link planIssuer}: `planFilesOrganisation` and
|
|
45
|
-
* `planAiOrganisation`,
|
|
46
|
-
*
|
|
47
|
-
* limit to land, and AI use is switched on, before either limit is ever set.
|
|
48
|
+
* `planAiOrganisation`, the second planned before {@link planAiBudget} so AI
|
|
49
|
+
* use is switched on before its own limit is ever set.
|
|
48
50
|
* `planOwners` below adds one more singleton, in a different shape from the
|
|
49
51
|
* rest: the org's owner, invited by ZITADEL email, idempotent on the
|
|
50
52
|
* *recorded* resource id (`recordedIds`, sourced from
|
|
@@ -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) {
|
|
@@ -142,9 +149,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
142
149
|
const filesOrganisation = planFilesOrganisation(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
143
150
|
if (filesOrganisation.step)
|
|
144
151
|
plannedSteps.push(filesOrganisation.step);
|
|
145
|
-
const storage = planStorage(org, byKey, claimed, platformReadiness.filesReady, platformReadiness.missingKeys.files, platformReadiness.noKeyReader);
|
|
146
|
-
if (storage.step)
|
|
147
|
-
plannedSteps.push(storage.step);
|
|
148
152
|
const aiOrganisation = planAiOrganisation(org, byKey, claimed, platformReadiness.aiReady, platformReadiness.missingKeys.ai, platformReadiness.noKeyReader);
|
|
149
153
|
if (aiOrganisation.step)
|
|
150
154
|
plannedSteps.push(aiOrganisation.step);
|
|
@@ -200,6 +204,16 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
200
204
|
// F6b: after the owner, the apps and the files organisation it needs.
|
|
201
205
|
const filesKeys = planFilesKeys(org, recordedIds, plannedSteps, platformReadiness, (options.now ?? Date.now)());
|
|
202
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);
|
|
203
217
|
const tunnel = planTunnel(org, byKey, claimed, recordedIds);
|
|
204
218
|
// #238: a tunnel that is being provisioned waits for an active zone; parking
|
|
205
219
|
// one never does.
|
|
@@ -257,6 +271,26 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
257
271
|
const jobsRegistration = planJobsRegistration(org, recordedIds, jobsAvailable || platformReadiness.jobsReady, platformReadiness.missingKeys.jobs, platformReadiness.noKeyReader);
|
|
258
272
|
if (jobsRegistration.step)
|
|
259
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
|
+
}
|
|
260
294
|
// #10 (resolved): "foundry manages only what the declared org names";
|
|
261
295
|
// anything left unclaimed is listed, never proposed for deletion.
|
|
262
296
|
const unmanaged = live
|
|
@@ -277,6 +311,9 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
277
311
|
unmanaged.push({ description: aiKey.blocked, key: 'ai:key' });
|
|
278
312
|
unmanaged.push(...filesKeys.blocked);
|
|
279
313
|
unmanaged.push(...entryAiKeys.blocked);
|
|
314
|
+
unmanaged.push(...edgeWorkers.blocked);
|
|
315
|
+
unmanaged.push(...entryNamesKeys.blocked);
|
|
316
|
+
unmanaged.push(...namesReservedLabels.blocked);
|
|
280
317
|
// Same for a jobs registration that cannot run (no scheduler wired) or must
|
|
281
318
|
// not run again (minted, never delivered).
|
|
282
319
|
if (jobsRegistration.blocked) {
|
|
@@ -313,8 +350,6 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
313
350
|
if (aiOrganisation.blocked) {
|
|
314
351
|
unmanaged.push({ description: aiOrganisation.blocked, key: 'ai:organisation' });
|
|
315
352
|
}
|
|
316
|
-
if (storage.blocked)
|
|
317
|
-
unmanaged.push({ description: storage.blocked, key: 'files:storage' });
|
|
318
353
|
if (aiBudget.blocked)
|
|
319
354
|
unmanaged.push({ description: aiBudget.blocked, key: 'ai:budget' });
|
|
320
355
|
// #238: a pending zone, reported here with its nameservers rather than
|
|
@@ -326,7 +361,12 @@ export function computePlanDetail(org, live, recordedIds = {}, agoraHost = NO_AG
|
|
|
326
361
|
unmanaged.push(...app.blocked);
|
|
327
362
|
unmanaged.push(...brokerEntries.blocked);
|
|
328
363
|
unmanaged.push(...bouleBroker.blocked);
|
|
329
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
orgId: org.id,
|
|
366
|
+
plannedSteps: plannedSteps.map(withStepSystem),
|
|
367
|
+
unmanaged,
|
|
368
|
+
versionChanges,
|
|
369
|
+
};
|
|
330
370
|
}
|
|
331
371
|
/**
|
|
332
372
|
* The org's own ZITADEL org (issuer), a per-org singleton like every other
|
|
@@ -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.
|
|
@@ -990,11 +1144,8 @@ function notReadyReason(service, keyName, missingKeyRef, noKeyReader) {
|
|
|
990
1144
|
: `the platform's ${service} host is off or not yet applied`;
|
|
991
1145
|
}
|
|
992
1146
|
/**
|
|
993
|
-
* #80: every org's own organisation on Files, a per-org singleton resource
|
|
994
|
-
*
|
|
995
|
-
* org Files has never provisioned (`files/packages/sdk/src/client.ts`'s
|
|
996
|
-
* tenant routes need the row `provisionOrganisation` creates). Modeled on
|
|
997
|
-
* {@link planIssuer}: nothing to diff once it exists -- Files' own
|
|
1147
|
+
* #80: every org's own organisation on Files, a per-org singleton resource.
|
|
1148
|
+
* Modeled on {@link planIssuer}: nothing to diff once it exists -- Files' own
|
|
998
1149
|
* `provisionOrganisation` is safely repeatable (`executor.ts`'s
|
|
999
1150
|
* `filesOrganisation` calls it every time regardless), so the only planned
|
|
1000
1151
|
* state is "not provisioned yet". The operator-driven `ceilingBytes`/
|
|
@@ -1076,9 +1227,14 @@ function planFilesOrganisation(org, byKey, claimed, filesReady, missingKeyRef, n
|
|
|
1076
1227
|
* regardless), so the only planned state is "not provisioned yet".
|
|
1077
1228
|
* `AI_DEFAULT_OFFERS` is this campaign's Decision for what "AI use is
|
|
1078
1229
|
* switched on" means by default; `limitMicros` seeds the organisation's
|
|
1079
|
-
* first budget row with
|
|
1080
|
-
*
|
|
1081
|
-
* {@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.
|
|
1082
1238
|
*
|
|
1083
1239
|
* #177: blocked, not planned, while `aiReady` is false -- the platform's own
|
|
1084
1240
|
* AI host (#172) is off or not yet applied.
|
|
@@ -1104,12 +1260,14 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
|
|
|
1104
1260
|
key: org.id,
|
|
1105
1261
|
desired: {
|
|
1106
1262
|
provision: true,
|
|
1107
|
-
limitMicros: org.ai.limitMicros,
|
|
1263
|
+
limitMicros: effectiveAiLimitMicros(org.ai.limitMicros),
|
|
1108
1264
|
offers: [...AI_DEFAULT_OFFERS],
|
|
1109
1265
|
},
|
|
1110
1266
|
},
|
|
1111
1267
|
step: {
|
|
1112
|
-
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`,
|
|
1113
1271
|
destroys: false,
|
|
1114
1272
|
migrates: false,
|
|
1115
1273
|
},
|
|
@@ -1117,56 +1275,20 @@ function planAiOrganisation(org, byKey, claimed, aiReady, missingKeyRef, noKeyRe
|
|
|
1117
1275
|
blocked: null,
|
|
1118
1276
|
};
|
|
1119
1277
|
}
|
|
1120
|
-
/**
|
|
1121
|
-
* #27: the org's own spend limit on Files, a per-org singleton resource in
|
|
1122
|
-
* the same shape as issuer/mail/chat above. Setting a limit is a config
|
|
1123
|
-
* write, never destructive or migrating (#27 "Notes": "safe, idempotent and
|
|
1124
|
-
* reversible"). A missing live resource reads as uncapped (`null`), the same
|
|
1125
|
-
* value a freshly-declared org's `storage` starts at, so a brand new org with
|
|
1126
|
-
* no limits declared produces no step.
|
|
1127
|
-
*
|
|
1128
|
-
* #177: silently plans nothing while there is no organisation to size a limit
|
|
1129
|
-
* on yet and `filesReady` is false -- {@link planFilesOrganisation} already
|
|
1130
|
-
* reports the platform's files host as missing once.
|
|
1131
|
-
*/
|
|
1132
|
-
function planStorage(org, byKey, claimed, filesReady, missingKeyRef, noKeyReader = false) {
|
|
1133
|
-
const key = resourceKey('files', 'storage', org.id);
|
|
1134
|
-
const live = byKey.get(key);
|
|
1135
|
-
if (live)
|
|
1136
|
-
claimed.add(key);
|
|
1137
|
-
if (!filesReady && !live)
|
|
1138
|
-
return { step: null, blocked: null };
|
|
1139
|
-
const desired = { limitBytes: org.storage.limitBytes, limitMicros: org.storage.limitMicros };
|
|
1140
|
-
const liveLimitBytes = live ? (live.config.limitBytes ?? null) : null;
|
|
1141
|
-
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
1142
|
-
if (liveLimitBytes === desired.limitBytes && liveLimitMicros === desired.limitMicros) {
|
|
1143
|
-
return { step: null, blocked: null };
|
|
1144
|
-
}
|
|
1145
|
-
// A live resource with drift needs the platform's key to change; blocked, not planned to throw at apply.
|
|
1146
|
-
if (!filesReady) {
|
|
1147
|
-
return {
|
|
1148
|
-
step: null,
|
|
1149
|
-
blocked: `${org.slug}'s storage limit change is blocked, not planned: ${notReadyReason('files', 'files management key', missingKeyRef, noKeyReader)}`,
|
|
1150
|
-
};
|
|
1151
|
-
}
|
|
1152
|
-
return {
|
|
1153
|
-
blocked: null,
|
|
1154
|
-
step: {
|
|
1155
|
-
action: { system: 'files', kind: 'storage', key: org.id, desired },
|
|
1156
|
-
step: {
|
|
1157
|
-
description: `set ${org.slug}'s storage limit to ${desired.limitBytes ?? 'uncapped'} bytes / ${desired.limitMicros ?? 'uncapped'} micros`,
|
|
1158
|
-
destroys: false,
|
|
1159
|
-
migrates: false,
|
|
1160
|
-
},
|
|
1161
|
-
},
|
|
1162
|
-
};
|
|
1163
|
-
}
|
|
1164
1278
|
/**
|
|
1165
1279
|
* #27: the org's own spend limit on AI, a per-org singleton resource in the
|
|
1166
|
-
* same shape as
|
|
1280
|
+
* same singleton shape as issuer/mail/chat above. Planned after {@link planAiOrganisation}
|
|
1167
1281
|
* (#80), which already seeds a brand new org's first budget row from this
|
|
1168
|
-
* same `org.ai.limitMicros`
|
|
1169
|
-
* 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.
|
|
1170
1292
|
*
|
|
1171
1293
|
* #177: silently plans nothing while there is no organisation to size a
|
|
1172
1294
|
* budget on yet and `aiReady` is false -- {@link planAiOrganisation} already
|
|
@@ -1179,7 +1301,8 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
|
|
|
1179
1301
|
claimed.add(key);
|
|
1180
1302
|
if (!aiReady && !live)
|
|
1181
1303
|
return { step: null, blocked: null };
|
|
1182
|
-
const
|
|
1304
|
+
const isDefault = org.ai.limitMicros === null;
|
|
1305
|
+
const desired = { limitMicros: effectiveAiLimitMicros(org.ai.limitMicros) };
|
|
1183
1306
|
const liveLimitMicros = live ? (live.config.limitMicros ?? null) : null;
|
|
1184
1307
|
if (liveLimitMicros === desired.limitMicros)
|
|
1185
1308
|
return { step: null, blocked: null };
|
|
@@ -1194,7 +1317,9 @@ function planAiBudget(org, byKey, claimed, aiReady, missingKeyRef, noKeyReader =
|
|
|
1194
1317
|
step: {
|
|
1195
1318
|
action: { system: 'ai', kind: 'budget', key: org.id, desired },
|
|
1196
1319
|
step: {
|
|
1197
|
-
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`,
|
|
1198
1323
|
destroys: false,
|
|
1199
1324
|
migrates: false,
|
|
1200
1325
|
},
|
|
@@ -1469,13 +1594,25 @@ function planAgora(org, byKey, claimed, recordedIds, reading) {
|
|
|
1469
1594
|
if (liveEnv.AGORA_CONFIG === undefined && !drift.includes('AGORA_CONFIG')) {
|
|
1470
1595
|
drift.push('AGORA_CONFIG');
|
|
1471
1596
|
}
|
|
1472
|
-
|
|
1597
|
+
// #442: `drift` alone, once this step has ever run, is comparing `env`
|
|
1598
|
+
// against Coolify's own env store -- which the executor's `bulkUpsertEnvs`
|
|
1599
|
+
// leaves matching `env` the moment the write succeeds, restarted or not.
|
|
1600
|
+
// So once applied, drift reads empty forever even if the running process
|
|
1601
|
+
// was never restarted onto it (a crash between the write and the restart,
|
|
1602
|
+
// or simply never restarting at all). `AGORA_ACTIVATED_ENV_SCOPE_KEY` is
|
|
1603
|
+
// the executor's own record that a restart confirmed the process is
|
|
1604
|
+
// actually serving this exact `env`; while it disagrees, the step stays
|
|
1605
|
+
// planned no matter what Coolify's env store already shows.
|
|
1606
|
+
const activated = recordedIds[AGORA_ACTIVATED_ENV_SCOPE_KEY] === agoraEnvRevision(env);
|
|
1607
|
+
if (drift.length === 0 && activated)
|
|
1473
1608
|
return { step: null, blocked: null };
|
|
1474
1609
|
return {
|
|
1475
1610
|
step: {
|
|
1476
1611
|
action,
|
|
1477
1612
|
step: {
|
|
1478
|
-
description:
|
|
1613
|
+
description: drift.length > 0
|
|
1614
|
+
? `update ${org.slug}'s Agora config (${drift.join(', ')} missing or different)`
|
|
1615
|
+
: `activate ${org.slug}'s Agora config -- written but not yet confirmed restarted`,
|
|
1479
1616
|
destroys: false,
|
|
1480
1617
|
migrates: false,
|
|
1481
1618
|
},
|
|
@@ -1692,6 +1829,32 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
|
|
|
1692
1829
|
key: blockedKey(`env:${name}`),
|
|
1693
1830
|
})))
|
|
1694
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
|
+
}
|
|
1695
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);
|
|
1696
1859
|
if (toggled)
|
|
1697
1860
|
return { step: toggled, blocked };
|
|
@@ -1894,6 +2057,21 @@ function planApps(org, byKey, claimed, recordedIds, plannedSteps, authIssuer, re
|
|
|
1894
2057
|
if (signsIn)
|
|
1895
2058
|
app.step.action.desired = { ...app.step.action.desired, authIssuer };
|
|
1896
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
|
+
}
|
|
1897
2075
|
if (app.step)
|
|
1898
2076
|
steps.push(app.step);
|
|
1899
2077
|
blocked.push(...app.blocked);
|