@wtfalch/foundry 0.21.0 → 0.22.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.
@@ -8,7 +8,7 @@ import { waitForDeploymentFinished, waitForHealthyVersion, waitForNewDeployment,
8
8
  import { ACCESS_POLICY_NAME, getDatabaseStarted, isNotFoundError, isRunningOrStarting, } from './executor.js';
9
9
  import { healthCheckSettings } from './health-check-settings.js';
10
10
  import { coolifyDomains, filesShareHostname } from './hostnames.js';
11
- import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
11
+ import { AUTH_ZITADEL_RUN_OPTIONS, JOBS_DEPLOY_PENDING_SCOPE_KEY, JOBS_SCHEDULER_KEY, PLATFORM_AI_APPLIED_GITREF_SCOPE_KEY, PLATFORM_AI_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY, PLATFORM_AI_INTERNAL_URL_SCOPE_KEY, PLATFORM_AI_KEY, PLATFORM_AI_MANAGEMENT_KEY_CONFLICT_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_AI_MANAGEMENT_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY, PLATFORM_AI_ORGANISATION_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_SET_SCOPE_KEY, PLATFORM_AI_SIGNING_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY, PLATFORM_ARCHON_CONNECTOR_APP_SCOPE_KEY, PLATFORM_ARCHON_COOLIFY_READ_TOKEN_REF, PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, PLATFORM_ARCHON_ENVS_DEPLOYED_SCOPE_KEY, PLATFORM_ARCHON_GATE_CLOSED_SCOPE_KEY, PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, PLATFORM_ARCHON_KEY, PLATFORM_ARCHON_MASTER_CLOUDFLARE_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_TOKEN_REF, PLATFORM_ARCHON_MASTER_COOLIFY_URL_REF, PLATFORM_AUTH_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_ADMIN_USER_SCOPE_KEY, PLATFORM_AUTH_BROKER_DEPLOYED_SCOPE_KEY, PLATFORM_AUTH_BROKER_SCOPE_KEY, PLATFORM_AUTH_CONNECTOR_APP_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_SCOPE_KEY, PLATFORM_AUTH_INSTANCE_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_KEY, PLATFORM_AUTH_LOGIN_CLIENT_UNDELIVERED_SCOPE_KEY, PLATFORM_AUTH_LOGIN_CLIENT_USER_SCOPE_KEY, PLATFORM_AUTH_OWNER_INVITE_SCOPE_KEY, PLATFORM_AUTH_SMTP_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY, PLATFORM_AUTH_ZITADEL_RUN_OPTIONS_SCOPE_KEY, PLATFORM_BACKUP_DATABASE_SCOPE_KEYS, PLATFORM_BACKUP_SERVICE_KEYS, PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_EDGE_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_EDGE_CLOUDFLARE_TOKEN_REF, PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY, PLATFORM_EDGE_KEY, PLATFORM_EDGE_KEY_REF, PLATFORM_EDGE_PLATFORM_KEY_SET_SCOPE_KEY, PLATFORM_EDGE_PLATFORM_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_FILES_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_SCOPE_KEY, PLATFORM_FILES_INTROSPECTION_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_KEY, PLATFORM_FILES_MANAGEMENT_KEY_SCOPE_KEY, PLATFORM_FILES_MANAGEMENT_UNDELIVERED_SCOPE_KEY, PLATFORM_FILES_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_HC_API_KEY_REF, PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_HC_KEY, PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_GITREF_SCOPE_KEY, PLATFORM_INTEGRATIONS_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY, PLATFORM_INTEGRATIONS_KEY_ID_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_PREVIOUS_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_REQUESTED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_ROTATION_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_SET_SCOPE_KEY, PLATFORM_INTEGRATIONS_KEY_UNDELIVERED_SCOPE_KEY, PLATFORM_INTEGRATIONS_RUNNER_KEYRING_DEPLOYED_SCOPE_KEY, PLATFORM_MAIL_ADMIN_KEY_SCOPE_KEY, PLATFORM_MAIL_ADMIN_UNDELIVERED_SCOPE_KEY, PLATFORM_MAIL_APPLIED_GIT_REF_SCOPE_KEY, PLATFORM_MAIL_APPLIED_HOSTNAME_SCOPE_KEY, PLATFORM_MAIL_CONNECTOR_APP_SCOPE_KEY, PLATFORM_MAIL_INTERNAL_URL_SCOPE_KEY, PLATFORM_MAIL_KEY, PLATFORM_MAIL_ZITADEL_PROJECT_SCOPE_KEY, PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME, archonBootstrapOperator, desiredHeartbeatCheckNames, heartbeatCheckUrlRef, parseBrokerDeployRecord, parseLoginClientRecord, platformBackupHeartbeatCheckName, platformMailMxHost, } from './platform-diff.js';
12
12
  import { applyCallsSfuPlatformStep } from './platform-sfu.js';
13
13
  import { CreateInstanceError, createZitadelInstance, generateSystemApiKeypair, instanceWithoutBearerMessage, systemApiUsersEnv, } from './zitadel-system-api.js';
14
14
  /** The key `runPlatformSteps` records the apex zone's id under (`system:kind:key`). */
@@ -318,6 +318,18 @@ export class FoundryPlatformStepExecutor {
318
318
  if (!apiKey)
319
319
  blocked.heartbeatsApiKeyMissing = true;
320
320
  }
321
+ // Not a block: tells the planner to order Archon's step last (`PlanPlatformOptions.blocked.archonRunsInsideApp`).
322
+ if (this.archon?.runsInsideApp) {
323
+ blocked.archonRunsInsideApp = true;
324
+ // A deploy the step triggered and did not wait for: while it runs, a
325
+ // second deploy must not be planned.
326
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
327
+ if (platform.archon.enabled &&
328
+ deployment &&
329
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'running') {
330
+ blocked.archonDeployRunning = `Archon is blocked, not planned: its deployment ${deployment} is still running; plan again when it has finished`;
331
+ }
332
+ }
321
333
  return blocked;
322
334
  }
323
335
  /**
@@ -347,7 +359,18 @@ export class FoundryPlatformStepExecutor {
347
359
  !brokerDrifted &&
348
360
  !bootstrapOperatorDrifted &&
349
361
  (await archonRollingUpdateDrifted(this.archon.coolify, recordedIds[`coolify:app:${PLATFORM_ARCHON_KEY}`]));
350
- if (accessDrifted || brokerDrifted || bootstrapOperatorDrifted || rollingUpdateDrifted)
362
+ // The deployment the step triggered failed or Coolify does not know it:
363
+ // plan the step again, which deploys again. A finished one is confirmed
364
+ // by the next run of the step (keys-synced), see `provisionPlatformArchon`.
365
+ const deployment = recordedIds[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY];
366
+ const redeployFailed = this.archon.runsInsideApp === true &&
367
+ Boolean(deployment) &&
368
+ (await archonDeploymentState(this.archon.coolify, deployment)) === 'failed';
369
+ if (accessDrifted ||
370
+ brokerDrifted ||
371
+ bootstrapOperatorDrifted ||
372
+ rollingUpdateDrifted ||
373
+ redeployFailed)
351
374
  result.archon = true;
352
375
  }
353
376
  if (platform.auth.enabled && this.auth) {
@@ -596,7 +619,25 @@ export class FoundryPlatformStepExecutor {
596
619
  if (!db.internal_db_url) {
597
620
  throw new Error(`platform step coolify:jobs: database ${dbId} has no internal_db_url yet; not deploying the scheduler without a database`);
598
621
  }
599
- const envs = [{ key: 'DATABASE_URL', value: db.internal_db_url }];
622
+ // The scheduler migrates as `DATABASE_URL_OWNER` and creates its own
623
+ // `jobs_rt` role from `DATABASE_URL` (jobs README, "Deploying"). An app
624
+ // with no owner URL still holds the owner's URL in `DATABASE_URL`, so
625
+ // that is never reused: a runtime URL is minted, once, and kept after.
626
+ const hasOwnerUrl = (await coolify.listEnvs(appId)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
627
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, hasOwnerUrl ? [appId] : [], db.internal_db_url, 'platform step coolify:jobs');
628
+ const envs = [
629
+ { key: 'DATABASE_URL_OWNER', value: db.internal_db_url },
630
+ { key: 'DATABASE_URL', value: runtimeUrl },
631
+ ];
632
+ // Whether the scheduler runs on envs other than the ones written below.
633
+ // Marked and persisted before the first write, and cleared only after the
634
+ // deploy, so a run that dies in between leaves the mark and the next run
635
+ // deploys again (`CLAUDE.md` rule 4).
636
+ const pending = Boolean(recorded[JOBS_DEPLOY_PENDING_SCOPE_KEY]) || (await envsDiffer(coolify, appId, envs));
637
+ if (pending) {
638
+ context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '1');
639
+ await context.persist?.();
640
+ }
600
641
  // SCHEDULER_ADMIN_TOKEN gates the admin API the org registrations use
601
642
  // (jobs README, "The admin API"); unset, every admin route answers 401.
602
643
  // Delivered to the host before it is written to the app, so a failed
@@ -629,8 +670,12 @@ export class FoundryPlatformStepExecutor {
629
670
  // a redeploy, so deploy after a create or a new token. Also when it is not
630
671
  // running, so a deploy that failed on an earlier run is retried.
631
672
  const running = (await coolify.getApplication(appId)).status?.startsWith('running') ?? false;
632
- if (!existingApp || tokenJustSet || !running)
673
+ if (!existingApp || tokenJustSet || pending || !running)
633
674
  await coolify.deploy(appId);
675
+ if (pending) {
676
+ context.recordScopeId(JOBS_DEPLOY_PENDING_SCOPE_KEY, '');
677
+ await context.persist?.();
678
+ }
634
679
  return { resourceId: appId };
635
680
  }
636
681
  }
@@ -2578,6 +2623,7 @@ async function provisionPlatformArchon(context, deps) {
2578
2623
  const appliedGitRef = recorded[PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY];
2579
2624
  const runtime = deps.runtime ?? {};
2580
2625
  const sleep = runtime.sleep ?? defaultSleep;
2626
+ const selfRedeploy = deps.runsInsideApp === true;
2581
2627
  if (deps.accessEmails.length === 0) {
2582
2628
  throw new Error('platform:archon: no Access emails given; refusing to put Archon behind an Access app nobody can pass');
2583
2629
  }
@@ -3008,7 +3054,17 @@ async function provisionPlatformArchon(context, deps) {
3008
3054
  // patch above actually takes effect on the running container, not just on
3009
3055
  // Coolify's stored config.
3010
3056
  const rollingUpdateApplied = recorded[PLATFORM_ARCHON_APPLIED_ROLLING_UPDATE_SCOPE_KEY] === '1';
3011
- const archonDeployedNow = keyUnsynced ||
3057
+ // The deployment an earlier run triggered without waiting: read before
3058
+ // deciding whether to deploy again.
3059
+ const priorDeployment = selfRedeploy ? recorded[PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY] : undefined;
3060
+ const deployState = priorDeployment
3061
+ ? await archonDeploymentState(deps.coolify, priorDeployment)
3062
+ : 'none';
3063
+ if (deployState === 'running' || deployState === 'unreadable') {
3064
+ throw new Error(`platform:archon: the deployment ${priorDeployment} cannot be confirmed yet (${deployState === 'running' ? 'still running' : 'status unreadable'}); plan again later`);
3065
+ }
3066
+ const archonDeployedNow = (keyUnsynced && deployState !== 'finished') ||
3067
+ deployState === 'failed' ||
3012
3068
  app.justCreated ||
3013
3069
  !app.running ||
3014
3070
  changed ||
@@ -3017,7 +3073,7 @@ async function provisionPlatformArchon(context, deps) {
3017
3073
  appliedGitRef !== gitRef ||
3018
3074
  !rollingUpdateApplied ||
3019
3075
  recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] !== hostname;
3020
- if (archonDeployedNow) {
3076
+ if (archonDeployedNow && !selfRedeploy) {
3021
3077
  // #467: cleared before the deploy below is even attempted, not after --
3022
3078
  // a deploy that then fails, or a crash before `waitForHealthyVersion`
3023
3079
  // passes, must leave this run's attempt unconfirmed. Recording it only
@@ -3089,13 +3145,14 @@ async function provisionPlatformArchon(context, deps) {
3089
3145
  // Coolify's own deployment record finished, which is what actually closes
3090
3146
  // the loophole named in #252 (an old container staying healthy while the
3091
3147
  // deployment itself fails).
3092
- if (archonDeployedNow) {
3148
+ if (archonDeployedNow && !selfRedeploy) {
3093
3149
  await waitForHealthyVersion(appId, { kind: 'ready', url: `https://${hostname}/api/health` }, runtime, sleep, 'platform:archon', `archon stays at ${appliedGitRef ?? 'no recorded ref'} on ${recorded[PLATFORM_ARCHON_APPLIED_HOSTNAME_SCOPE_KEY] ?? 'no recorded hostname'}`);
3094
3150
  }
3095
3151
  // #398: the deployment finished and answers health, so Archon now runs with
3096
3152
  // the key id its env carries. `provisionPlatformIntegrations` drops the
3097
3153
  // previous public key only on this marker.
3098
- if (keyUnsynced && integrationsKeyId) {
3154
+ // With `runsInsideApp` only once Coolify's deployment record says it finished.
3155
+ if (keyUnsynced && integrationsKeyId && (!selfRedeploy || deployState === 'finished')) {
3099
3156
  await record(PLATFORM_ARCHON_INTEGRATIONS_KEY_SYNCED_SCOPE_KEY, integrationsKeyId);
3100
3157
  }
3101
3158
  // #475: confirmed only now, after the health check above has passed on a
@@ -3118,6 +3175,36 @@ async function provisionPlatformArchon(context, deps) {
3118
3175
  // it.
3119
3176
  await record(PLATFORM_ARCHON_APPLIED_BROKER_SCOPE_KEY, brokerAppId);
3120
3177
  await record(PLATFORM_ARCHON_APPLIED_OPERATOR_SCOPE_KEY, bootstrapOperator ?? 'none');
3178
+ // The host runs inside this app: every marker is recorded above, so the
3179
+ // deploy goes last and is not waited for -- the old container stops during
3180
+ // it, and the response, the lease and the audit row must be done first.
3181
+ if (selfRedeploy && !archonDeployedNow && deployState === 'finished') {
3182
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, ''); // confirmed above
3183
+ }
3184
+ if (selfRedeploy && archonDeployedNow) {
3185
+ await record(`coolify:app:${PLATFORM_ARCHON_KEY}`, appId);
3186
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, 'pending');
3187
+ let deploymentUuid;
3188
+ try {
3189
+ // `force` takes the branch that always creates a real deployment record
3190
+ // (a skipped one answers with a uuid Coolify never stored).
3191
+ const started = (await deps.coolify.deploy(appId, true));
3192
+ const uuid = Array.isArray(started) ? started[0]?.deployment_uuid : undefined;
3193
+ if (typeof uuid !== 'string' || !uuid) {
3194
+ throw new Error('platform:archon: Coolify answered the deploy with no deployment_uuid');
3195
+ }
3196
+ deploymentUuid = uuid;
3197
+ }
3198
+ catch (error) {
3199
+ // Not triggered: forget the applied ref so the next plan runs the step again.
3200
+ await record(PLATFORM_ARCHON_APPLIED_GIT_REF_SCOPE_KEY, '');
3201
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, '');
3202
+ throw error;
3203
+ }
3204
+ // The next plan reads this deployment's status; a kill before this write
3205
+ // leaves `pending`, which that plan reads as unconfirmed and plans again.
3206
+ await record(PLATFORM_ARCHON_DEPLOYMENT_SCOPE_KEY, deploymentUuid);
3207
+ }
3121
3208
  return { resourceId: appId };
3122
3209
  }
3123
3210
  // #211 round 6: mirrors `wtfalch/auth/broker/src/apps.ts`'s own `isValidFrom`
@@ -3538,6 +3625,28 @@ async function archonBootstrapOperatorDrifted(coolify, appId, recordedIds) {
3538
3625
  * version of this check never actually fired. `custom_network_aliases` and
3539
3626
  * `health_check_enabled` are both top-level there.
3540
3627
  */
3628
+ /**
3629
+ * The state of the deployment Archon's step triggered without waiting
3630
+ * (`PlatformArchonDeps.runsInsideApp`), from Coolify's own deployment record:
3631
+ * `running` (queued or in progress), `finished`, `failed` (failed, cancelled,
3632
+ * its uuid never recorded, or unknown to Coolify -- a 404), or `unreadable`
3633
+ * for any other error. Status and a served request cannot tell a failed build
3634
+ * from a success (the old container keeps serving), so this record is the
3635
+ * only honest answer.
3636
+ */
3637
+ async function archonDeploymentState(coolify, deploymentUuid) {
3638
+ if (deploymentUuid === 'pending')
3639
+ return 'failed';
3640
+ try {
3641
+ const { status } = await coolify.getDeployment(deploymentUuid);
3642
+ if (status === 'queued' || status === 'in_progress')
3643
+ return 'running';
3644
+ return status === 'finished' ? 'finished' : 'failed';
3645
+ }
3646
+ catch (error) {
3647
+ return isNotFoundError(error) ? 'failed' : 'unreadable';
3648
+ }
3649
+ }
3541
3650
  async function archonRollingUpdateDrifted(coolify, appId) {
3542
3651
  if (!appId)
3543
3652
  return false;
@@ -4185,7 +4294,7 @@ async function provisionPlatformAi(context, deps) {
4185
4294
  // ---- env -----------------------------------------------------------------
4186
4295
  // Both database URLs are live credentials: handed to `bulkUpsertEnvs` and
4187
4296
  // nowhere else, never returned, recorded, logged or put in an error.
4188
- const runtimeUrl = await ensureAiRuntimeUrl(coolify, [runner.appId, worker.appId, migrate.appId], ownerUrl);
4297
+ const runtimeUrl = await ensureAiRuntimeUrl(coolify, [runner.appId, worker.appId, migrate.appId], ownerUrl, 'platform:ai');
4189
4298
  const migrateEnvs = [
4190
4299
  { key: 'DATABASE_URL_OWNER', value: ownerUrl },
4191
4300
  { key: 'DATABASE_URL', value: runtimeUrl },
@@ -4556,7 +4665,7 @@ async function waitForAiMigration(coolify, appId, runtime, sleep) {
4556
4665
  * inside a try: Node's parse error carries the whole input, credential
4557
4666
  * included, so it is replaced with a fixed message.
4558
4667
  */
4559
- async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
4668
+ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl, step) {
4560
4669
  for (const appId of appIds) {
4561
4670
  const existing = (await coolify.listEnvs(appId)).find((e) => e.key === 'DATABASE_URL' && !e.is_preview && e.value);
4562
4671
  if (existing)
@@ -4569,11 +4678,11 @@ async function ensureAiRuntimeUrl(coolify, appIds, ownerUrl) {
4569
4678
  ({ host, pathname, search } = new URL(ownerUrl));
4570
4679
  }
4571
4680
  catch {
4572
- throw new Error("platform:ai: the database's internal_db_url could not be parsed");
4681
+ throw new Error(`${step}: the database's internal_db_url could not be parsed`);
4573
4682
  }
4574
4683
  const database = pathname.replace(/^\//, '');
4575
4684
  if (!/^[A-Za-z0-9_]+$/.test(database)) {
4576
- throw new Error("platform:ai: the database's name cannot form a runtime role name (letters, digits and _ only)");
4685
+ throw new Error(`${step}: the database's name cannot form a runtime role name (letters, digits and _ only)`);
4577
4686
  }
4578
4687
  const password = randomBytes(24).toString('base64url');
4579
4688
  return `postgres://${database}_rt:${password}@${host}${pathname}${search}`;
@@ -7098,6 +7207,16 @@ export class ProvidersPlatformCoolifyReader {
7098
7207
  databaseStatus = undefined;
7099
7208
  }
7100
7209
  }
7210
+ // An app with no `DATABASE_URL_OWNER` predates the owner/runtime
7211
+ // split, so `planJobs` plans the step again to set both URLs. Left
7212
+ // out when the envs cannot be read: unknown is not "missing".
7213
+ let ownerUrlMissing;
7214
+ try {
7215
+ ownerUrlMissing = !(await this.client.listEnvs(app.uuid)).some((e) => e.key === 'DATABASE_URL_OWNER' && !e.is_preview && e.value);
7216
+ }
7217
+ catch {
7218
+ ownerUrlMissing = undefined;
7219
+ }
7101
7220
  resources.push({
7102
7221
  system: 'coolify',
7103
7222
  kind: 'jobs',
@@ -7106,6 +7225,7 @@ export class ProvidersPlatformCoolifyReader {
7106
7225
  uuid: app.uuid,
7107
7226
  status: app.status,
7108
7227
  ...(databaseStatus !== undefined && { databaseStatus }),
7228
+ ...(ownerUrlMissing && { ownerUrlMissing }),
7109
7229
  },
7110
7230
  });
7111
7231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wtfalch/foundry",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "description": "Foundry's SDK: the declared org's types, the plan and apply contracts, and the state store's interface.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -37,6 +37,7 @@
37
37
  "calls-sfu:deploy": "node scripts/calls-sfu-deploy.mjs"
38
38
  },
39
39
  "peerDependencies": {
40
+ "@wtfalch/providers": ">=0.7.4",
40
41
  "drizzle-orm": ">=0.45.2"
41
42
  },
42
43
  "dependencies": {
@@ -44,7 +45,7 @@
44
45
  "@wtfalch/auth": "0.16.0",
45
46
  "@wtfalch/edge": "0.9.0",
46
47
  "@wtfalch/email": "^0.17.0",
47
- "@wtfalch/providers": "0.7.3",
48
+ "@wtfalch/providers": "0.7.5",
48
49
  "ssh2": "1.17.0",
49
50
  "zod": "4.6.5"
50
51
  },