@wtfalch/foundry 0.20.0 → 0.21.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.
@@ -21,6 +21,19 @@ export declare const DEFAULT_GIT_REF = "main";
21
21
  * file's loader follows.
22
22
  */
23
23
  export declare const DEFAULT_MAIL_IMAGE_TAG: string;
24
+ /**
25
+ * #540: the Healthchecks image tag the platform's `hc` step runs.
26
+ * `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
27
+ * carries. #540 shipped this floating at `'latest'`, with no
28
+ * `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
29
+ * reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
30
+ * `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
31
+ * real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
32
+ * rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
33
+ * refuses a floating one the way `mailImageTagBlocked` already does for
34
+ * Stalwart.
35
+ */
36
+ export declare const DEFAULT_HC_IMAGE_TAG: string;
24
37
  export interface BootstrapCompany {
25
38
  name: string;
26
39
  slug: string;
@@ -22,6 +22,19 @@ export const DEFAULT_GIT_REF = 'main';
22
22
  * file's loader follows.
23
23
  */
24
24
  export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
25
+ /**
26
+ * #540: the Healthchecks image tag the platform's `hc` step runs.
27
+ * `platform.hc.gitRef` is an image tag, the same overload `mail.gitRef`
28
+ * carries. #540 shipped this floating at `'latest'`, with no
29
+ * `versions.json` pin and no `floatingTagBlocked` guard at all -- a real
30
+ * reviewed gap, closed round 2 the same way Stalwart's own tag is pinned:
31
+ * `versions.json`'s `hc` entry, checked against `healthchecks/healthchecks`'s
32
+ * real GitHub releases and Docker Hub tags (2026-10, latest stable `v4.4`)
33
+ * rather than guessed, and `hcImageTagBlocked` (`platform-providers.ts`)
34
+ * refuses a floating one the way `mailImageTagBlocked` already does for
35
+ * Stalwart.
36
+ */
37
+ export const DEFAULT_HC_IMAGE_TAG = VERSIONS.hc;
25
38
  /**
26
39
  * Every platform service except `sfu` is enabled, like the other steps: a
27
40
  * service whose dependencies the host did not supply is reported blocked,
@@ -30,6 +43,8 @@ export const DEFAULT_MAIL_IMAGE_TAG = VERSIONS.stalwart;
30
43
  * platform DNS record is written through it (F4.dns); its own Cloudflare API
31
44
  * token is a held key (`cloudflare:edge:apiToken`), and until a human holds
32
45
  * it the planner reports edge blocked, and auth, mail and archon with it.
46
+ * `hc` (#540) is on too: it needs no held key of its own, only edge's DNS,
47
+ * the same as mail and archon.
33
48
  */
34
49
  const SERVICES_ENABLED_BY_DEFAULT = new Set([
35
50
  'auth',
@@ -40,6 +55,7 @@ const SERVICES_ENABLED_BY_DEFAULT = new Set([
40
55
  'integrations',
41
56
  'archon',
42
57
  'edge',
58
+ 'hc',
43
59
  ]);
44
60
  /**
45
61
  * The owner as `OwnerIdentity`, trimmed. Throws a plain error naming the
@@ -115,7 +131,13 @@ export function declaredPlatformFor(apex, serverIpv4) {
115
131
  services[key] = {
116
132
  enabled,
117
133
  hostname: defaultPlatformHostname(key, apex),
118
- gitRef: !enabled ? '' : key === 'mail' ? DEFAULT_MAIL_IMAGE_TAG : DEFAULT_GIT_REF,
134
+ gitRef: !enabled
135
+ ? ''
136
+ : key === 'mail'
137
+ ? DEFAULT_MAIL_IMAGE_TAG
138
+ : key === 'hc'
139
+ ? DEFAULT_HC_IMAGE_TAG
140
+ : DEFAULT_GIT_REF,
119
141
  };
120
142
  }
121
143
  return {
@@ -12,6 +12,7 @@ export interface PinnedVersions {
12
12
  livekit: string;
13
13
  cloudflared: string;
14
14
  stalwart: string;
15
+ hc: string;
15
16
  }
16
17
  export declare function loadVersions(): PinnedVersions;
17
18
  export declare const VERSIONS: PinnedVersions;
package/dist/index.d.ts CHANGED
@@ -30,6 +30,8 @@ export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
30
30
  export type { CoolifyPort, CloudflarePort, ZitadelPort, CallsSfuPort, JobsSchedulerPort, FilesOrganisationPort, AiOrganisationPort, FoundryStepExecutorPorts, PlatformOrgPortSource, AgoraHostPort, AgoraHostValues, LoginHostBinding, LoginHostBindingsPort, } from './plan/index.js';
31
31
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
32
32
  export { HttpJobsSchedulerClient } from './plan/index.js';
33
+ export type { HealthTarget, HealthTargetDepth, HealthTargetExpect } from './plan/index.js';
34
+ export { healthTargets, platformHealthTargets } from './plan/index.js';
33
35
  export type { ScopeResolver, RecordedIds } from './plan/index.js';
34
36
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
35
37
  export type { ZitadelListAppsRawApp } from './plan/index.js';
package/dist/index.js CHANGED
@@ -18,6 +18,7 @@ export { DEFAULT_ORG_PLATFORM_READINESS, ORG_PLATFORM_KEY_REFS, agoraHostValuesF
18
18
  export { PlatformOrgBlockedError, PlatformOrgPorts } from './plan/index.js';
19
19
  export { FoundryStepExecutor, NotBuiltError, healthUrls, loginHostnameFor } from './plan/index.js';
20
20
  export { HttpJobsSchedulerClient } from './plan/index.js';
21
+ export { healthTargets, platformHealthTargets } from './plan/index.js';
21
22
  export { ProvidersAiReader, ProvidersCloudflareReader, ProvidersCmsReader, ProvidersCoolifyReader, ProvidersZitadelReader, storeScopeResolver, storeRecordedIds, } from './plan/index.js';
22
23
  export { adaptZitadelListApps } from './plan/index.js';
23
24
  export { checkMailIdentityAudience } from './plan/index.js';
@@ -0,0 +1,19 @@
1
+ -- @wtfalch/foundry: #540 -- the platform's own heartbeat server (self-hosted
2
+ -- Healthchecks), added to the declared platform the same way every other
3
+ -- shared service here was (#165, #317). Mirrors `src/store/schema.ts`'s
4
+ -- `foundryDeclaredPlatform` table additions. Backfills `hc_hostname` the
5
+ -- same way `0043_platform_names.sql` backfilled `names_hostname`: the
6
+ -- column must not be empty (`src/platform.ts`'s `validatePlatform`), and
7
+ -- the table already carries its one live row by the time this runs.
8
+
9
+ alter table foundry_declared_platform
10
+ add column if not exists hc_enabled boolean not null default false,
11
+ add column if not exists hc_hostname text,
12
+ add column if not exists hc_git_ref text not null default '';
13
+
14
+ update foundry_declared_platform
15
+ set hc_hostname = 'hc.' || apex
16
+ where hc_hostname is null;
17
+
18
+ alter table foundry_declared_platform
19
+ alter column hc_hostname set not null;
@@ -62,6 +62,8 @@
62
62
  */
63
63
  const DEFAULT_READY_TIMEOUT_MS = 300_000;
64
64
  const DEFAULT_READY_INTERVAL_MS = 5_000;
65
+ /** One health request's own limit: a connection that accepts and never answers counts as not healthy yet, instead of blocking the whole wait. */
66
+ const HEALTH_REQUEST_TIMEOUT_MS = 5_000;
65
67
  /** The one terminal `CoolifyDeploymentStatus` that means the new version landed. */
66
68
  const DEPLOYMENT_SUCCEEDED = 'finished';
67
69
  /**
@@ -135,7 +137,9 @@ export async function waitForNewDeployment(coolify, appId, health, runtime, slee
135
137
  }
136
138
  async function matchesHealth(fetchImpl, health) {
137
139
  try {
138
- const response = await fetchImpl(health.url);
140
+ const response = await fetchImpl(health.url, {
141
+ signal: AbortSignal.timeout(HEALTH_REQUEST_TIMEOUT_MS),
142
+ });
139
143
  if (!response.ok)
140
144
  return false;
141
145
  if (health.kind === 'ready')
package/dist/plan/diff.js CHANGED
@@ -1955,6 +1955,29 @@ function planApp(org, entryKey, byKey, claimed, recordedIds, plannedSteps, authI
1955
1955
  blocked,
1956
1956
  };
1957
1957
  }
1958
+ // #543: a human turning the health check off in the Coolify UI (or an app
1959
+ // applied before this patch existed) is noticed the same way `hasLogVolume`
1960
+ // just above is -- `coolifyApp` already sends the quartet on every run, so
1961
+ // replanning this one step (the same action, nothing extra) is the whole
1962
+ // repair. A reader that did not check (`healthCheckEnabled` absent) reads
1963
+ // as compliant, like `hasLogVolume`.
1964
+ // Only for an entry `coolifyApp` would set the check on (a static entry,
1965
+ // or one with a declared port): otherwise the repair could never land and
1966
+ // every apply would replan it.
1967
+ if (live.config.healthCheckEnabled === false &&
1968
+ (entry.buildPack === 'static' || entry.port !== null)) {
1969
+ return {
1970
+ step: {
1971
+ action,
1972
+ step: {
1973
+ description: `turn Coolify's health check back on for ${org.slug}'s ${label} (#543)`,
1974
+ destroys: false,
1975
+ migrates: false,
1976
+ },
1977
+ },
1978
+ blocked,
1979
+ };
1980
+ }
1958
1981
  // #239 (rule 4), from live state: an app with a database but no
1959
1982
  // `DATABASE_URL_OWNER` env still has the pre-split superuser URL, or never
1960
1983
  // got the push. Names only, never values. A reader that did not list env
@@ -2,6 +2,7 @@ import type { OrgMailOptions, OrgMailOrg, OrgMailResult } from '@wtfalch/email/p
2
2
  import type { DeclaredOrg, HostingBuildPack } from '../org.js';
3
3
  import type { DeclaredOrgStore } from '../store.js';
4
4
  import { type BrokerPort } from './broker-entries.js';
5
+ import { type DeployHealthRuntime } from './deploy-health.js';
5
6
  import { type StepAction } from './diff.js';
6
7
  import { type AccessPort, type EdgePlatformAccess } from './edge-access.js';
7
8
  import { type DnsPort, type EdgePlatformDns } from './edge-dns.js';
@@ -384,6 +385,8 @@ export interface CoolifyPort {
384
385
  enabled: boolean;
385
386
  save_s3: boolean;
386
387
  s3_storage_uuid?: string;
388
+ /** The databases this schedule dumps, comma separated. Empty means the resource's first database. */
389
+ databases_to_backup?: string | null;
387
390
  }>>;
388
391
  /**
389
392
  * #257: schedules dumps of a database to the registered S3 storage --
@@ -1451,6 +1454,17 @@ export interface FoundryStepExecutorPorts {
1451
1454
  * passes the same store instance it constructs `FoundryPlanApply` with.
1452
1455
  */
1453
1456
  declaredOrgStore: Pick<DeclaredOrgStore, 'findOrgIdForZone'>;
1457
+ /**
1458
+ * #543: clock, sleep and `fetch` for the health-check wait every company
1459
+ * app's own redeploy now goes through (`deploy-health.ts`'s
1460
+ * `waitForNewDeployment`) -- the same override shape `platform-providers.ts`'s
1461
+ * own `PlatformMailDeps.runtime`/`PlatformArchonDeps.runtime` already take,
1462
+ * here on one shared port instead of one per platform step since this
1463
+ * executor is the one class every company-app step runs through. Optional:
1464
+ * without it, {@link defaultSleep} (plain `setTimeout`) and the real
1465
+ * `fetch`/`Date.now` are used.
1466
+ */
1467
+ runtime?: DeployHealthRuntime;
1454
1468
  }
1455
1469
  /**
1456
1470
  * Thrown by name for a step this executor deliberately does not implement:
@@ -1462,6 +1476,26 @@ export interface FoundryStepExecutorPorts {
1462
1476
  export declare class NotBuiltError extends Error {
1463
1477
  constructor(step: string, detail: string);
1464
1478
  }
1479
+ /**
1480
+ * `cms.<domain>` -- the cms studio's own hostname (`cmsSite` below), apart
1481
+ * from the public site itself at `org.domain` (used directly there). Called
1482
+ * from `cmsSite` and from `zitadelApp` for the fixed `cms` client (#139),
1483
+ * both of which only run once `org.site.enabled` is true, so `org.domain`
1484
+ * is non-null by the same `validateSite` guarantee
1485
+ * {@link tunnelIngressHostnames}'s own doc comment cites.
1486
+ */
1487
+ export declare function cmsHostnameFor(org: DeclaredOrg): string;
1488
+ /**
1489
+ * #145's "Decided" (2026-09-27): the calls service's own public hostname.
1490
+ * `calls.usesDomain: false` (or no `domain`) is served flat, like
1491
+ * `<slug>-stage.oikos.host` and `<slug>-pr-<n>.oikos.host` (`../slug.ts`) --
1492
+ * the shared tunnel's wildcard `*.oikos.host` DNS record already reaches it,
1493
+ * so nothing changes in `scripts/shared-ingress.mjs`. `calls.usesDomain: true`
1494
+ * gets `calls.<domain>` instead, on its own per-org tunnel
1495
+ * ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
1496
+ * shape {@link cmsHostnameFor} uses for `cms.<domain>`.
1497
+ */
1498
+ export declare function callsHostnameFor(org: DeclaredOrg): string;
1465
1499
  /**
1466
1500
  * The health URLs an outside prober should call for this org (#43): one per
1467
1501
  * app foundry runs for it. A hosting org's app answers at its own hostname,
@@ -9,6 +9,7 @@ import { MAS_IMAGE, MAS_TAG, SYNAPSE_IMAGE, SYNAPSE_TAG, callsMasConfigYaml, cal
9
9
  import { CHAT_REPO, CHAT_SERVICE_DOCKERFILE, CHAT_SERVICE_PORT, CHAT_SYNAPSE_DOCKERFILE, CHAT_SYNAPSE_PORT, chatInternalNames, chatMasConfigYaml, chatServiceEnvs, chatSynapseHomeserverYaml, chatUpstreams, } from './chat-matrix.js';
10
10
  import { ensureCloudflaredConnector } from './cloudflared-connector.js';
11
11
  import { CORE_FILES_COLLECTION_NAME, FILES_CORE_COLLECTION_SCOPE_KEY, ZITADEL_FILES_PROJECT_SCOPE_KEY, } from './core-files.js';
12
+ import { waitForNewDeployment, } from './deploy-health.js';
12
13
  import { DEFAULT_AI_LIMIT_MICROS } from './diff.js';
13
14
  import { createEdgeAccess, } from './edge-access.js';
14
15
  import { EDGE_DNS_WRITE_CONCURRENCY, createEdgeDns, limiter, } from './edge-dns.js';
@@ -18,6 +19,7 @@ import { createEdgeZones } from './edge-zones.js';
18
19
  import { AI_ENTRY_RENEW_WINDOW_DAYS, entryAiAppliedScopeKey, entryAiClampedScopeKey, entryAiExpiresScopeKey, entryAiPendingExpiresScopeKey, entryAiRetiredScopeKey, entryAiUndeliveredScopeKey, entryAiUnrevokedScopeKey, entryAiWebhookPendingScopeKey, entryAiWrittenScopeKey, parseIdList, } from './entry-ai-key.js';
19
20
  import { BOULE_EDGE_ENTRY_KEY, EDGE_ENTRY_KEY_DAYS, EDGE_RESERVED_LABELS_APPLIED_SCOPE_KEY, ENTRY_EDGE_KEY_DELIVERY_ATTEMPTS, edgeKeyAppRecordKey, entryEdgeMintedAtScopeKey, entryEdgeRetiredScopeKey, entryEdgeUndeliveredScopeKey, } from './entry-edge-key.js';
20
21
  import { FILES_AGORA_KEY_SCOPE_KEY, FILES_AGORA_UNDELIVERED_SCOPE_KEY, FILES_KEY_DAYS, FILES_KEY_RENEW_WINDOW_DAYS, FILES_ORG_OWNER_SCOPE_KEY, FILES_ROOT_EXPIRES_SCOPE_KEY, FILES_ROOT_PREVIOUS_SCOPE_KEY, FILES_ROOT_UNDELIVERED_SCOPE_KEY, FILES_ROOT_UNREVOKED_SCOPE_KEY, entryFilesAppliedScopeKey, entryFilesExpiresScopeKey, entryFilesMinterScopeKey, entryFilesPendingExpiresScopeKey, entryFilesRetiredScopeKey, entryFilesUndeliveredScopeKey, entryFilesUnrevokedScopeKey, entryFilesWrittenScopeKey, filesOwnerUserId, parseIdList as parseFilesIdList, } from './entry-files-key.js';
22
+ import { healthCheckSettings } from './health-check-settings.js';
21
23
  import { appHostname, coolifyDomains, entryHostnames, tunnelDnsHostnames, tunnelIngressHostnames, } from './hostnames.js';
22
24
  import { LOGIN_BINDING_SCOPE_KEY, LOGIN_HOST_PENDING_REMOVAL_SCOPE_KEY, LOGIN_HOST_SHARED_SCOPE_KEY, loginBindingState, loginHostClientIds, pendingRemovalHosts, pendingRemovalState, } from './login-host.js';
23
25
  import { MAIL_COPY_COMPLETED_SCOPE_KEY, MAIL_COPY_DONE_SCOPE_KEY, parseMailCopyCompleted, runMailCopyStep, } from './mail-copy.js';
@@ -84,7 +86,9 @@ function normalizedLoginBaseUri(uri) {
84
86
  * is non-null by the same `validateSite` guarantee
85
87
  * {@link tunnelIngressHostnames}'s own doc comment cites.
86
88
  */
87
- function cmsHostnameFor(org) {
89
+ // #541: exported so `health-targets.ts` reuses this hostname rule rather
90
+ // than restating it; nothing else about this function changes.
91
+ export function cmsHostnameFor(org) {
88
92
  return `cms.${org.domain}`;
89
93
  }
90
94
  /**
@@ -97,7 +101,8 @@ function cmsHostnameFor(org) {
97
101
  * ({@link tunnelIngressHostnames}), the same subdomain-of-the-org's-own-domain
98
102
  * shape {@link cmsHostnameFor} uses for `cms.<domain>`.
99
103
  */
100
- function callsHostnameFor(org) {
104
+ // #541: exported for the same reason `cmsHostnameFor` just above now is.
105
+ export function callsHostnameFor(org) {
101
106
  return org.calls.usesDomain && org.domain
102
107
  ? `calls.${org.domain}`
103
108
  : `${org.slug}-calls.oikos.host`;
@@ -233,6 +238,14 @@ const DEFAULT_APP_DOCKERFILE = '/apps/hestia/Dockerfile';
233
238
  const CMS_REPO = 'wtfalch/cms';
234
239
  const CMS_APP_DOCKERFILE = '/Dockerfile';
235
240
  const CMS_SITE_APP_DOCKERFILE = '/apps/site/Dockerfile';
241
+ /**
242
+ * #543: both of `wtfalch/cms`'s apps are Next.js hosts on the same
243
+ * app-template convention as Archon/edge/ai's runner/Boule -- port 3000,
244
+ * `/api/health` (`healthUrls`'s own default for a non-static entry). Neither
245
+ * create call below passes `ports_exposes` (unlike Agora's #506 fix), so
246
+ * this is the port Coolify's own default build already listens its proxy on.
247
+ */
248
+ const CMS_APP_PORT = 3000;
236
249
  /**
237
250
  * #164: the repo `coolifyAgora` builds the org's Agora from
238
251
  * (https://github.com/wtfalch/agora) -- the issue's own "How" names this
@@ -266,6 +279,8 @@ const AGORA_APP_DOCKERFILE = '/apps/agora/Dockerfile';
266
279
  */
267
280
  const BOULE_REPO = 'wtfalch/boule';
268
281
  const BOULE_APP_DOCKERFILE = '/Dockerfile';
282
+ /** Boule's own `EXPOSE 3000` (its doc comment just above) -- the port its health check (#543) probes. */
283
+ const BOULE_PORT = 3000;
269
284
  /**
270
285
  * Every app foundry deploys keeps its own logs across a redeploy, so a
271
286
  * database outage does not also erase the only other copy of the evidence
@@ -597,6 +612,21 @@ const CALLS_SERVICE_DB_SCOPE_KEY = 'coolify_calls_service_database';
597
612
  const CALLS_SERVICE_REPO = 'wtfalch/calls';
598
613
  /** `packages/service/Dockerfile`, built from the repo root (that Dockerfile's own top comment). */
599
614
  const CALLS_SERVICE_DOCKERFILE = '/packages/service/Dockerfile';
615
+ /**
616
+ * #543: the calls service's own health route. Restated, not imported, from
617
+ * `health-targets.ts`'s private `CALLS_HEALTH_PATH` of the same value --
618
+ * issue #543's own text keeps that issue's (#541) diff and this one
619
+ * independent of each other.
620
+ */
621
+ const CALLS_HEALTH_CHECK_PATH = '/healthz';
622
+ /**
623
+ * #543: the chat service's own health route, reached through Agora's proxy
624
+ * (`chat-matrix.ts`'s "NO PUBLIC HOSTNAME OF ITS OWN": Synapse and MAS have
625
+ * none, only this). Restated from `health-targets.ts`'s private
626
+ * `CHAT_SERVICE_HEALTH_PATH` of the same value, for the same reason as
627
+ * {@link CALLS_HEALTH_CHECK_PATH} above.
628
+ */
629
+ const CHAT_SERVICE_HEALTH_CHECK_PATH = '/_chat/health';
600
630
  /**
601
631
  * Whether an application's own Coolify `status` (`` `running:healthy`,
602
632
  * `exited:unhealthy` `` -- `@wtfalch/providers`' own doc comment) means it is
@@ -642,6 +672,43 @@ export async function getDatabaseStarted(coolify, dbId) {
642
672
  await sleep(graceMs);
643
673
  return db;
644
674
  }
675
+ /**
676
+ * #543: the plain `setTimeout`-based sleep every company-app deploy wait
677
+ * falls back to when `FoundryStepExecutorPorts.runtime.sleep` is not given --
678
+ * the same default `platform-providers.ts`'s own per-step `defaultSleep`
679
+ * constants use.
680
+ */
681
+ const defaultSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
682
+ /**
683
+ * #543 review: a deploy waits with `'ready'` on a public url only when
684
+ * nothing still has to create or change the route to it. Otherwise it is
685
+ * `'deployment-only'`, so a fresh company is not failed for a tunnel, DNS
686
+ * record or Agora that a later step in the same plan creates. From
687
+ * `recorded`: a hostname under `org.domain` has a route once the tunnel step
688
+ * published its DNS record ({@link CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY});
689
+ * a hostname elsewhere is on the shared wildcard tunnel and already routed.
690
+ * `viaAgora` (the chat service, reached through Agora's proxy) also needs
691
+ * Agora deployed ({@link AGORA_DEPLOYED_SCOPE_KEY}). A hostname behind
692
+ * Cloudflare Access is never probed: with no credentials every answer is a
693
+ * redirect or 403.
694
+ */
695
+ function publicHealthCheck(org, recorded, hostname, path, options = {}) {
696
+ const app = appEntry(org.hosting);
697
+ const behindAccess = (org.hosting.access ?? []).some((rule) => rule.hostname === hostname) ||
698
+ (app !== undefined &&
699
+ app.accessEmails.length > 0 &&
700
+ entryHostnames(org, app).includes(hostname));
701
+ if (behindAccess)
702
+ return { kind: 'deployment-only' };
703
+ const routed = (host) => !org.domain ||
704
+ !(host === org.domain || host.endsWith(`.${org.domain}`)) ||
705
+ (recorded[CLOUDFLARE_TUNNEL_DNS_HOSTNAMES_SCOPE_KEY] ?? '').split(',').includes(host);
706
+ const agoraReady = !options.viaAgora ||
707
+ (Boolean(recorded[AGORA_DEPLOYED_SCOPE_KEY]) && routed(agoraHostnameFor(org)));
708
+ if (!agoraReady || !routed(hostname))
709
+ return { kind: 'deployment-only' };
710
+ return { kind: 'ready', url: `https://${hostname}${path}` };
711
+ }
645
712
  /** Timing for the wait in {@link getDatabaseStarted}; a test swaps `sleep` and `now` for a fake clock. */
646
713
  export const databaseStartWait = {
647
714
  sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
@@ -1682,6 +1749,20 @@ export class FoundryStepExecutor {
1682
1749
  }
1683
1750
  if (want.autoDeploy !== have.autoDeploy)
1684
1751
  patch.is_auto_deploy_enabled = want.autoDeploy;
1752
+ // #543: the health-check quartet too, every run -- the same reason
1753
+ // Archon's own doc comment gives (without one, Coolify's rolling update
1754
+ // has nothing to wait on and stops the old container immediately). The
1755
+ // port Coolify's own health probe reaches is whatever `ports_exposes`
1756
+ // names: 80 for a static entry (Coolify's own web server), or the
1757
+ // entry's declared `port`. An entry with neither (an arbitrary buildpack,
1758
+ // no declared port) gets none here -- there is no port this executor
1759
+ // could honestly name for Coolify to probe, the same "nothing to check
1760
+ // instead" reasoning `platform:ai`'s worker and `platform:files`'
1761
+ // migrate job are left out of #543's own quartet for.
1762
+ const healthCheckPort = isStatic ? 80 : want.port;
1763
+ if (healthCheckPort !== null) {
1764
+ Object.assign(patch, healthCheckSettings(entry.healthPath ?? (isStatic ? '/' : '/api/health'), healthCheckPort));
1765
+ }
1685
1766
  // Create names the app after `domains`; a changed name reaches a live app
1686
1767
  // here (rule 3). The internal name is set after create, as the calls
1687
1768
  // stack's apps do.
@@ -1759,10 +1840,23 @@ export class FoundryStepExecutor {
1759
1840
  // declared appEnv changed.
1760
1841
  // A new folder needs a build, which a restart does not do, so a settings
1761
1842
  // change deploys instead (rule 3), and that carries the env too.
1762
- if (settingsNeedDeploy)
1763
- await this.ports.coolify.deploy(appId);
1764
- else if ((appEnv.changed || dbNeedsRestart) && existing)
1843
+ if (settingsNeedDeploy) {
1844
+ // #543: every deploy waits for health through the shared helper, never
1845
+ // a fire-and-forget `deploy` call -- `names[0]` is the same hostname
1846
+ // `healthUrls` probes for this entry; an internal entry (no hostname)
1847
+ // has nothing this executor could reach from outside the app's own
1848
+ // Docker network, so `'deployment-only'` is the honest fallback there
1849
+ // (Coolify's own deployment record is still the whole check).
1850
+ const [hostname] = names;
1851
+ const healthPath = entry.healthPath ?? (isStatic ? '/' : '/api/health');
1852
+ const runtime = this.ports.runtime ?? {};
1853
+ await waitForNewDeployment(this.ports.coolify, appId, hostname
1854
+ ? publicHealthCheck(org, context.recordedIds(), hostname, healthPath)
1855
+ : { kind: 'deployment-only' }, runtime, runtime.sleep ?? defaultSleep, `coolify:app:${entry.key}`, `${org.slug}'s ${entry.key === APP_ENTRY_KEY ? 'app' : `${entry.key} app`} stays at its previous deploy`);
1856
+ }
1857
+ else if ((appEnv.changed || dbNeedsRestart) && existing) {
1765
1858
  await this.ports.coolify.restart(appId);
1859
+ }
1766
1860
  // Recorded only after the redeploy succeeded: a failed one leaves the old
1767
1861
  // value, so the next plan replans (rule 3). The db marker is not a URL.
1768
1862
  // A port taken back to null was not patched, so the marker keeps the old
@@ -2602,6 +2696,21 @@ export class FoundryStepExecutor {
2602
2696
  dockerfileLocation: CMS_APP_DOCKERFILE,
2603
2697
  domain: cmsHostnameFor(org),
2604
2698
  });
2699
+ // #543: the health-check quartet, every run, on both apps -- their own
2700
+ // `/api/health` (`healthUrls`'s own default for site/cms), on the port
2701
+ // {@link CMS_APP_PORT}'s own doc comment explains. Unlike every other
2702
+ // company app in this file, neither app here is ever deployed through an
2703
+ // explicit `coolify.deploy`/`waitForNewDeployment` call: `findOrCreateCmsApp`
2704
+ // (below) never sets `instant_deploy` either, the same gap #504 found and
2705
+ // fixed for Agora and Boule -- fixing that for cms/site is its own,
2706
+ // separate issue (out of #543's scope: this issue adds waits to existing
2707
+ // deploys, it does not add a deploy where none exists).
2708
+ await this.ports.coolify.updateApplication(site.appId, {
2709
+ ...healthCheckSettings('/api/health', CMS_APP_PORT),
2710
+ });
2711
+ await this.ports.coolify.updateApplication(cms.appId, {
2712
+ ...healthCheckSettings('/api/health', CMS_APP_PORT),
2713
+ });
2605
2714
  // Every app foundry deploys keeps its own logs (#75) -- reused as-is for
2606
2715
  // both of these two, the same unconditional call `coolifyApp` makes for
2607
2716
  // its own one app.
@@ -2979,10 +3088,21 @@ export class FoundryStepExecutor {
2979
3088
  // converges. A brand new app or a freshly minted secret set are the only
2980
3089
  // two reasons the file just written could differ from what the container
2981
3090
  // already has -- reusing existing secrets means byte-identical content.
2982
- if (!synapse.existed || secrets.changed)
2983
- await this.ports.coolify.deploy(synapse.appId);
2984
- if (!mas.existed || secrets.changed)
2985
- await this.ports.coolify.deploy(mas.appId);
3091
+ //
3092
+ // #543: both waits are `'deployment-only'` -- Synapse and MAS have their
3093
+ // own health check off (just above: "Neither Synapse's nor MAS's health
3094
+ // check can pass before the other is up"), so there is no url either
3095
+ // could honestly be probed at; `coolify.deployAndWait` reaching Coolify's
3096
+ // own deployment record is still the whole check, same as `platform:ai`'s
3097
+ // worker.
3098
+ const runtime = this.ports.runtime ?? {};
3099
+ const sleep = runtime.sleep ?? defaultSleep;
3100
+ if (!synapse.existed || secrets.changed) {
3101
+ await waitForNewDeployment(this.ports.coolify, synapse.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:calls', `${org.slug}'s calls Synapse stays at its previous deploy`);
3102
+ }
3103
+ if (!mas.existed || secrets.changed) {
3104
+ await waitForNewDeployment(this.ports.coolify, mas.appId, { kind: 'deployment-only' }, runtime, sleep, 'coolify:calls', `${org.slug}'s calls MAS stays at its previous deploy`);
3105
+ }
2986
3106
  return { resourceId: mas.appId };
2987
3107
  }
2988
3108
  /**
@@ -3198,6 +3318,15 @@ export class FoundryStepExecutor {
3198
3318
  */
3199
3319
  async coolifyChat(action, org, context) {
3200
3320
  const recorded = context.recordedIds();
3321
+ // #543: every deploy below waits for health. The chat service answers
3322
+ // `CHAT_SERVICE_HEALTH_CHECK_PATH` only through Agora's own proxy
3323
+ // (`chat-matrix.ts`'s "NO PUBLIC HOSTNAME OF ITS OWN" -- the service has
3324
+ // no domain of its own), so that is the url every `'ready'` check below
3325
+ // probes; Synapse and MAS stay `'deployment-only'`, the same reason
3326
+ // `coolifyCalls`'s own comment gives for its Synapse/MAS.
3327
+ const runtime = this.ports.runtime ?? {};
3328
+ const sleep = runtime.sleep ?? defaultSleep;
3329
+ const chatServiceHealth = () => publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), CHAT_SERVICE_HEALTH_CHECK_PATH, { viaAgora: true });
3201
3330
  if (recorded[`coolify:chat:${action.key}`]) {
3202
3331
  // #456: `planChat` plans this distinct, non-re-key action for a live
3203
3332
  // chat app that predates the owner/runtime database split (#239) --
@@ -3222,7 +3351,7 @@ export class FoundryStepExecutor {
3222
3351
  throw new Error(`coolify:chat database split for ${org.slug}: the chat service's database (${serviceDbId}) has no internal_db_url -- it may have been deleted or is unreachable; this is not expected for an already-live chat`);
3223
3352
  }
3224
3353
  if (push.changed || !recorded[markerKey]) {
3225
- await this.ports.coolify.deploy(serviceAppId);
3354
+ await waitForNewDeployment(this.ports.coolify, serviceAppId, chatServiceHealth(), runtime, sleep, 'coolify:chat', `${org.slug}'s chat service stays at its previous deploy`);
3226
3355
  }
3227
3356
  context.recordScopeId(markerKey, '1');
3228
3357
  return { resourceId: serviceAppId };
@@ -3245,8 +3374,9 @@ export class FoundryStepExecutor {
3245
3374
  ]);
3246
3375
  // Deploy first, marker last: a failed deploy leaves the marker unset,
3247
3376
  // so the next plan still carries this step and the container gets the env.
3248
- if (client.changed || !hadMarker)
3249
- await this.ports.coolify.deploy(serviceAppId);
3377
+ if (client.changed || !hadMarker) {
3378
+ await waitForNewDeployment(this.ports.coolify, serviceAppId, chatServiceHealth(), runtime, sleep, 'coolify:chat', `${org.slug}'s chat service stays at its previous deploy`);
3379
+ }
3250
3380
  context.recordScopeId(CHAT_INTROSPECTION_SCOPE_KEY, client.appId);
3251
3381
  return { resourceId: serviceAppId };
3252
3382
  }
@@ -3348,6 +3478,8 @@ export class FoundryStepExecutor {
3348
3478
  });
3349
3479
  await this.ports.coolify.updateApplication(service.appId, {
3350
3480
  custom_internal_name: names.service,
3481
+ // No Coolify health check on the chat service (#543 review): its image
3482
+ // is node:22-alpine with no curl, which Coolify's container check runs.
3351
3483
  });
3352
3484
  context.recordScopeId(CHAT_SYNAPSE_SCOPE_KEY, synapse.appId);
3353
3485
  context.recordScopeId(CHAT_SERVICE_SCOPE_KEY, service.appId);
@@ -3414,7 +3546,11 @@ export class FoundryStepExecutor {
3414
3546
  secrets.changed ||
3415
3547
  (app === service && (serviceDbPush.changed || introspection.changed)) ||
3416
3548
  !isRunningOrStarting((await this.ports.coolify.getApplication(app.appId)).status)) {
3417
- await this.ports.coolify.deploy(app.appId);
3549
+ // #543: `'ready'` for the service (it has a real route, probed
3550
+ // through Agora); `'deployment-only'` for Synapse/MAS, same as this
3551
+ // function's two re-key paths above and `coolifyCalls`'s own
3552
+ // Synapse/MAS.
3553
+ await waitForNewDeployment(this.ports.coolify, app.appId, app === service ? chatServiceHealth() : { kind: 'deployment-only' }, runtime, sleep, 'coolify:chat', `${org.slug}'s chat ${app === service ? 'service' : app === synapse ? 'Synapse' : 'MAS'} stays at its previous deploy`);
3418
3554
  }
3419
3555
  }
3420
3556
  // After the write and the deploys, so a failure before them leaves the
@@ -3502,6 +3638,8 @@ export class FoundryStepExecutor {
3502
3638
  // step again for a live app whose marker is missing or stale, so an app
3503
3639
  // made before this existed gets its name here. The marker, recorded last,
3504
3640
  // tells `desiredAgoraEnv` that the name and the env are both in place.
3641
+ // No Coolify health check on the calls service (#543 review): its image
3642
+ // is node:24-alpine with no curl, which Coolify's container check runs.
3505
3643
  await this.ports.coolify.updateApplication(app.appId, { custom_internal_name: name });
3506
3644
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3507
3645
  await this.ensureLogVolume(app.appId, org, app.existed);
@@ -3569,7 +3707,11 @@ export class FoundryStepExecutor {
3569
3707
  // existed, still pointed at a role nothing has created.
3570
3708
  const givesTokenEnv = token !== null && context.recordedIds()[CALLS_SERVICE_TOKEN_ENV_SCOPE_KEY] !== tokenMarker;
3571
3709
  if (app.existed && dbSplitNeedsRefresh) {
3572
- await this.ports.coolify.deploy(app.appId);
3710
+ // #543: every deploy waits for health -- the calls service's own
3711
+ // public hostname and `/healthz`, the same path the quartet above
3712
+ // just set on the app.
3713
+ const runtime = this.ports.runtime ?? {};
3714
+ await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), callsHostnameFor(org), CALLS_HEALTH_CHECK_PATH), runtime, runtime.sleep ?? defaultSleep, 'coolify:calls-service', `${org.slug}'s calls service stays at its previous deploy`);
3573
3715
  }
3574
3716
  else if (app.existed && (!callsServiceHasName(org, context.recordedIds()) || givesTokenEnv)) {
3575
3717
  await this.ports.coolify.restart(app.appId);
@@ -3783,11 +3925,14 @@ export class FoundryStepExecutor {
3783
3925
  const portDiffers = app.existed &&
3784
3926
  (await this.ports.coolify.getApplication(app.appId)).ports_exposes !==
3785
3927
  AGORA_APP_PORTS_EXPOSES;
3786
- if (portDiffers) {
3787
- await this.ports.coolify.updateApplication(app.appId, {
3788
- ports_exposes: AGORA_APP_PORTS_EXPOSES,
3789
- });
3790
- }
3928
+ // #543: the health-check quartet, every run, like Archon's own patch --
3929
+ // Agora is static (Caddy on {@link AGORA_APP_PORTS_EXPOSES}), so the only
3930
+ // health route available is a plain page load at `/` (the same depth
3931
+ // `health-targets.ts` gives it: `'page'`, not an API).
3932
+ await this.ports.coolify.updateApplication(app.appId, {
3933
+ ...(portDiffers ? { ports_exposes: AGORA_APP_PORTS_EXPOSES } : {}),
3934
+ ...healthCheckSettings('/', Number(AGORA_APP_PORTS_EXPOSES)),
3935
+ });
3791
3936
  // #442: read before overwriting -- `bulkUpsertEnvs` below always leaves
3792
3937
  // Coolify's own env store matching `env`, which is why that store alone
3793
3938
  // can no longer tell a changed value from one already live.
@@ -3806,16 +3951,16 @@ export class FoundryStepExecutor {
3806
3951
  // confirmed (the marker never changes value once set), never again -- a
3807
3952
  // live app is only ever restarted from here on, unless #506's port fix
3808
3953
  // below needs its own deploy.
3809
- if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY]) {
3810
- await this.ports.coolify.deploy(app.appId);
3954
+ if (!context.recordedIds()[AGORA_DEPLOYED_SCOPE_KEY] || portDiffers) {
3955
+ // #543/#506: every deploy waits for health -- Agora's own page at `/`.
3956
+ // A first deploy and a port fix share this one wait (a port fix needs
3957
+ // its own full deploy; env drift alone only needs the cheaper restart
3958
+ // in the branch below, and this deploy already carries this run's env,
3959
+ // pushed above, so the two never both run).
3960
+ const runtime = this.ports.runtime ?? {};
3961
+ await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), agoraHostnameFor(org), '/'), runtime, runtime.sleep ?? defaultSleep, 'coolify:agora', `${org.slug}'s Agora stays at its previous deploy`);
3811
3962
  context.recordScopeId(AGORA_DEPLOYED_SCOPE_KEY, '1');
3812
3963
  }
3813
- else if (portDiffers) {
3814
- // #506: a port fix needs its own full deploy -- env drift alone only
3815
- // needs the cheaper restart, and this deploy already carries this
3816
- // run's env (pushed above), so the two never both run.
3817
- await this.ports.coolify.deploy(app.appId);
3818
- }
3819
3964
  else if (app.existed && (envChanged || !alreadyActivated)) {
3820
3965
  await this.ports.coolify.restart(app.appId);
3821
3966
  }
@@ -3882,6 +4027,12 @@ export class FoundryStepExecutor {
3882
4027
  }));
3883
4028
  // Every app foundry deploys keeps its own logs (#75), reused as-is here.
3884
4029
  await this.ensureLogVolume(app.appId, org, app.existed);
4030
+ // #543: the health-check quartet, every run, like Archon's own patch --
4031
+ // Boule's own `/api/health` (`healthUrls`'s default) on its Dockerfile's
4032
+ // `EXPOSE 3000` ({@link BOULE_PORT}'s own doc comment).
4033
+ await this.ports.coolify.updateApplication(app.appId, {
4034
+ ...healthCheckSettings('/api/health', BOULE_PORT),
4035
+ });
3885
4036
  const liveEnv = new Map((await this.ports.coolify.listEnvs(app.appId))
3886
4037
  .filter((e) => !e.is_preview)
3887
4038
  .map((e) => [e.key, e.value]));
@@ -3906,7 +4057,9 @@ export class FoundryStepExecutor {
3906
4057
  // but never started. Deployed once this executor has confirmed, never
3907
4058
  // again -- a live app is only ever restarted from here on.
3908
4059
  if (!context.recordedIds()[BOULE_DEPLOYED_SCOPE_KEY]) {
3909
- await this.ports.coolify.deploy(app.appId);
4060
+ // #543: every deploy waits for health -- Boule's own public hostname.
4061
+ const runtime = this.ports.runtime ?? {};
4062
+ await waitForNewDeployment(this.ports.coolify, app.appId, publicHealthCheck(org, context.recordedIds(), bouleHostnameFor(org), '/api/health'), runtime, runtime.sleep ?? defaultSleep, 'coolify:boule', `${org.slug}'s Boule stays at its previous deploy`);
3910
4063
  context.recordScopeId(BOULE_DEPLOYED_SCOPE_KEY, '1');
3911
4064
  }
3912
4065
  else if (app.existed &&