@wtfalch/foundry 0.20.0 → 0.21.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.
@@ -577,6 +577,12 @@ export declare const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = "ai_platform_migrat
577
577
  * that throws) is repaired by the next one.
578
578
  */
579
579
  export declare const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = "ai_platform_deploy_pending";
580
+ /**
581
+ * Set before the `coolify:jobs` step writes the scheduler's env and cleared
582
+ * once its deploy finished, so a run that dies in between is repaired by the
583
+ * next one. The planner plans the step while it is set.
584
+ */
585
+ export declare const JOBS_DEPLOY_PENDING_SCOPE_KEY = "jobs_deploy_pending";
580
586
  /**
581
587
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
582
588
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -674,6 +680,104 @@ export declare const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = "edge_applied_git
674
680
  * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
675
681
  */
676
682
  export declare const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = "edge_platform_internal_url";
683
+ /**
684
+ * #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
685
+ * dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
686
+ * heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
687
+ * the app's uuid under `coolify:app:platform:hc` once the step succeeds,
688
+ * which is what stops `planHc` planning it again.
689
+ */
690
+ export declare const PLATFORM_HC_KEY = "platform:hc";
691
+ /**
692
+ * #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
693
+ * the last successful `hc` run applied. `planHc` plans the step again when
694
+ * either differs from the declared value; a re-run patches the image tag
695
+ * and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
696
+ * read back, never regenerated -- see `ensureGeneratedEnv`).
697
+ */
698
+ export declare const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = "hc_applied_hostname";
699
+ export declare const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = "hc_applied_git_ref";
700
+ /**
701
+ * #540 round 2: the held-key ref a human puts hc's first project API key
702
+ * under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
703
+ * `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
704
+ * this package mints this key: `provisionPlatformHc` stands up an empty
705
+ * Healthchecks instance with no user, no project and so no key, and neither
706
+ * a `manage.py` command nor a documented API mints a project's own API key
707
+ * (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
708
+ * only its web UI does, after a first superuser signs in by hand. Until a
709
+ * human does that and holds the key here, `FoundryPlatformStepExecutor
710
+ * .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
711
+ * blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
712
+ * than let `planHeartbeats` plan a step that would call hc's real
713
+ * Management API with no account and no key behind it -- see issue #540's
714
+ * own "Human steps" note. This package never reads the value back: whoever
715
+ * implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
716
+ * and that implementation is where the key is actually used.
717
+ */
718
+ export declare const PLATFORM_HC_API_KEY_REF = "hc:platform:apiKey";
719
+ /**
720
+ * #540: the heartbeat check named after the uptime Worker (foundry#542,
721
+ * which is what actually pings it -- blocked today on edge#26, so nothing
722
+ * pings this check yet; it still exists so the Worker has a URL to deliver
723
+ * to the moment it can). `platformBackupHeartbeatCheckName` is the matching
724
+ * name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
725
+ * Both names are also the prefix `provisionPlatformHeartbeats`
726
+ * (`platform-providers.ts`) uses to recognise which live Healthchecks
727
+ * checks are its own to delete when they are no longer wanted -- it never
728
+ * touches a check outside this naming scheme.
729
+ */
730
+ export declare const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = "uptime-worker";
731
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
732
+ export declare function platformBackupHeartbeatCheckName(service: PlatformBackupServiceKey): string;
733
+ /**
734
+ * #540: the held-key ref each heartbeat check's own ping URL is recorded
735
+ * under, once created -- `deliverSecret`'s ref-naming convention
736
+ * (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
737
+ * scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
738
+ * counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
739
+ * a ping URL is not retried-and-guarded the same way: it is closer to a
740
+ * resource id, so `provisionPlatformHeartbeats` simply records it again
741
+ * every time the check it names is (re)created.
742
+ */
743
+ export declare const PLATFORM_UPTIME_HEARTBEAT_URL_REF = "uptime:heartbeatUrl";
744
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
745
+ export declare function platformBackupHeartbeatUrlRef(service: PlatformBackupServiceKey): string;
746
+ /**
747
+ * #540: every heartbeat check name foundry currently wants -- the uptime
748
+ * Worker's own, plus one per platform database backup that already has a
749
+ * recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
750
+ * report yet" gate {@link planBackups} itself uses for a service with no
751
+ * database). Shared by {@link planHeartbeats} (to tell whether anything
752
+ * declared has changed since the last successful reconcile) and
753
+ * `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
754
+ * create or delete live) so the two can never compute a different desired
755
+ * set from each other.
756
+ */
757
+ export declare function desiredHeartbeatCheckNames(recordedIds: Readonly<Record<string, string>>): readonly string[];
758
+ /**
759
+ * #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
760
+ * produced) records its ping URL under -- `platform-providers.ts`'s
761
+ * `provisionPlatformHeartbeats` looks this up rather than re-deriving it
762
+ * inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
763
+ * and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
764
+ * alone. Throws on a name this module did not itself produce -- a
765
+ * programmer error, never a live value.
766
+ */
767
+ export declare function heartbeatCheckUrlRef(name: string): string;
768
+ /**
769
+ * #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
770
+ * last successful reconcile -- recorded by `provisionPlatformHeartbeats`
771
+ * once it has created every missing check and deleted every stale one.
772
+ * {@link planHeartbeats} plans the reconcile step again only when the
773
+ * declared desired set has since changed (a backup service's database
774
+ * arrived, say), not on every apply: `driveApply`'s bootstrap loop
775
+ * (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
776
+ * a step planned unconditionally on every round -- as this one once was --
777
+ * never lets a platform with `hc` enabled (the default, #540) finish
778
+ * bootstrapping at all.
779
+ */
780
+ export declare const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = "heartbeats_applied_desired";
677
781
  /**
678
782
  * Options for {@link planPlatform}, replacing four positional arguments that
679
783
  * shifted on every merge that added a platform service (#186, #187, #188) --
@@ -721,6 +825,26 @@ export interface PlanPlatformOptions {
721
825
  * "executor missing" shape {@link authExecutorMissing} has for auth.
722
826
  */
723
827
  backupExecutorMissing?: boolean;
828
+ /** #540: why `hc` is blocked, or absent when it is not. */
829
+ hc?: string;
830
+ /**
831
+ * #540: true once `hc` is applied but the host built the platform
832
+ * executor with no heartbeats dependencies -- the heartbeat-checks step
833
+ * is blocked, not planned, the same "executor missing" shape
834
+ * {@link backupExecutorMissing} has for a backup.
835
+ */
836
+ heartbeatsExecutorMissing?: boolean;
837
+ /**
838
+ * #540 round 2: true while the heartbeats dependencies are wired but no
839
+ * value is held under {@link PLATFORM_HC_API_KEY_REF} -- there is no
840
+ * bootstrap that mints hc's first superuser and project API key yet
841
+ * (issue #540's own "Decide"), so a fresh platform's heartbeats step
842
+ * would otherwise call hc's real Management API with no account and no
843
+ * key behind it. Blocked, not planned, until a human creates both by
844
+ * hand and holds the key under that ref; see this flag's own "Human
845
+ * steps" note in `platform-providers.ts`'s `FoundryPlatformStepExecutor.blockedSteps`.
846
+ */
847
+ heartbeatsApiKeyMissing?: boolean;
724
848
  };
725
849
  /**
726
850
  * Steps whose live state differs from the declared one in a way the recorded
@@ -653,6 +653,12 @@ export const PLATFORM_AI_MIGRATE_BLOCKED_SCOPE_KEY = 'ai_platform_migrate_blocke
653
653
  * that throws) is repaired by the next one.
654
654
  */
655
655
  export const PLATFORM_AI_DEPLOY_PENDING_SCOPE_KEY = 'ai_platform_deploy_pending';
656
+ /**
657
+ * Set before the `coolify:jobs` step writes the scheduler's env and cleared
658
+ * once its deploy finished, so a run that dies in between is repaired by the
659
+ * next one. The planner plans the step while it is set.
660
+ */
661
+ export const JOBS_DEPLOY_PENDING_SCOPE_KEY = 'jobs_deploy_pending';
656
662
  /**
657
663
  * #171: the resource key `planFiles` plans under and `FoundryPlatformStepExecutor`
658
664
  * dispatches on, the {@link PLATFORM_AUTH_KEY} shape for the files host.
@@ -750,6 +756,124 @@ export const PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY = 'edge_applied_git_ref';
750
756
  * (the {@link PLATFORM_FILES_INTERNAL_URL_SCOPE_KEY} pattern). Not a secret.
751
757
  */
752
758
  export const PLATFORM_EDGE_INTERNAL_URL_SCOPE_KEY = 'edge_platform_internal_url';
759
+ /**
760
+ * #540: the resource key `planHc` plans under and `FoundryPlatformStepExecutor`
761
+ * dispatches on -- the {@link PLATFORM_EDGE_KEY} shape for the platform's own
762
+ * heartbeat server (self-hosted Healthchecks). `runPlatformSteps` records
763
+ * the app's uuid under `coolify:app:platform:hc` once the step succeeds,
764
+ * which is what stops `planHc` planning it again.
765
+ */
766
+ export const PLATFORM_HC_KEY = 'platform:hc';
767
+ /**
768
+ * #540: the hostname and `gitRef` (a Docker image tag, like `mail.gitRef`)
769
+ * the last successful `hc` run applied. `planHc` plans the step again when
770
+ * either differs from the declared value; a re-run patches the image tag
771
+ * and redeploys, and mints nothing new (`SECRET_KEY` is generated once and
772
+ * read back, never regenerated -- see `ensureGeneratedEnv`).
773
+ */
774
+ export const PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY = 'hc_applied_hostname';
775
+ export const PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY = 'hc_applied_git_ref';
776
+ /**
777
+ * #540 round 2: the held-key ref a human puts hc's first project API key
778
+ * under -- `deliverSecret`'s naming convention (`EDGE_URL`'s own pattern),
779
+ * `<service>:<scope>:<name>` like {@link PLATFORM_EDGE_KEY_REF}. Nothing in
780
+ * this package mints this key: `provisionPlatformHc` stands up an empty
781
+ * Healthchecks instance with no user, no project and so no key, and neither
782
+ * a `manage.py` command nor a documented API mints a project's own API key
783
+ * (confirmed against `healthchecks/healthchecks`'s own source, 2026-10) --
784
+ * only its web UI does, after a first superuser signs in by hand. Until a
785
+ * human does that and holds the key here, `FoundryPlatformStepExecutor
786
+ * .blockedSteps` (`platform-providers.ts`) reports the heartbeat checks
787
+ * blocked (`PlanPlatformOptions.blocked.heartbeatsApiKeyMissing`) rather
788
+ * than let `planHeartbeats` plan a step that would call hc's real
789
+ * Management API with no account and no key behind it -- see issue #540's
790
+ * own "Human steps" note. This package never reads the value back: whoever
791
+ * implements {@link HeartbeatsPort} against hc's real HTTP API is the host,
792
+ * and that implementation is where the key is actually used.
793
+ */
794
+ export const PLATFORM_HC_API_KEY_REF = 'hc:platform:apiKey';
795
+ /**
796
+ * #540: the heartbeat check named after the uptime Worker (foundry#542,
797
+ * which is what actually pings it -- blocked today on edge#26, so nothing
798
+ * pings this check yet; it still exists so the Worker has a URL to deliver
799
+ * to the moment it can). `platformBackupHeartbeatCheckName` is the matching
800
+ * name for each platform database backup (`PLATFORM_BACKUP_SERVICE_KEYS`).
801
+ * Both names are also the prefix `provisionPlatformHeartbeats`
802
+ * (`platform-providers.ts`) uses to recognise which live Healthchecks
803
+ * checks are its own to delete when they are no longer wanted -- it never
804
+ * touches a check outside this naming scheme.
805
+ */
806
+ export const PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME = 'uptime-worker';
807
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME}'s own doc comment. */
808
+ export function platformBackupHeartbeatCheckName(service) {
809
+ return `backup-${service}`;
810
+ }
811
+ /**
812
+ * #540: the held-key ref each heartbeat check's own ping URL is recorded
813
+ * under, once created -- `deliverSecret`'s ref-naming convention
814
+ * (`EDGE_URL`'s own pattern), read back by whoever owns that piece of
815
+ * scheduled work. `platformBackupHeartbeatUrlRef` is the per-service
816
+ * counterpart. Unlike a minted credential (CLAUDE.md platform-step rule 1),
817
+ * a ping URL is not retried-and-guarded the same way: it is closer to a
818
+ * resource id, so `provisionPlatformHeartbeats` simply records it again
819
+ * every time the check it names is (re)created.
820
+ */
821
+ export const PLATFORM_UPTIME_HEARTBEAT_URL_REF = 'uptime:heartbeatUrl';
822
+ /** #540: see {@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}'s own doc comment. */
823
+ export function platformBackupHeartbeatUrlRef(service) {
824
+ return `backup:${service}:heartbeatUrl`;
825
+ }
826
+ /**
827
+ * #540: every heartbeat check name foundry currently wants -- the uptime
828
+ * Worker's own, plus one per platform database backup that already has a
829
+ * recorded database id (`PLATFORM_BACKUP_SERVICE_KEYS`, the same "nothing to
830
+ * report yet" gate {@link planBackups} itself uses for a service with no
831
+ * database). Shared by {@link planHeartbeats} (to tell whether anything
832
+ * declared has changed since the last successful reconcile) and
833
+ * `provisionPlatformHeartbeats` (`platform-providers.ts`, to decide what to
834
+ * create or delete live) so the two can never compute a different desired
835
+ * set from each other.
836
+ */
837
+ export function desiredHeartbeatCheckNames(recordedIds) {
838
+ const names = [PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME];
839
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
840
+ if (recordedIds[PLATFORM_BACKUP_DATABASE_SCOPE_KEYS[service]]) {
841
+ names.push(platformBackupHeartbeatCheckName(service));
842
+ }
843
+ }
844
+ return names;
845
+ }
846
+ /**
847
+ * #540: the held-key ref a check named `name` (one {@link desiredHeartbeatCheckNames}
848
+ * produced) records its ping URL under -- `platform-providers.ts`'s
849
+ * `provisionPlatformHeartbeats` looks this up rather than re-deriving it
850
+ * inline, so the two name-to-ref mappings ({@link PLATFORM_UPTIME_HEARTBEAT_URL_REF}
851
+ * and {@link platformBackupHeartbeatUrlRef}) stay reachable from the name
852
+ * alone. Throws on a name this module did not itself produce -- a
853
+ * programmer error, never a live value.
854
+ */
855
+ export function heartbeatCheckUrlRef(name) {
856
+ if (name === PLATFORM_UPTIME_HEARTBEAT_CHECK_NAME)
857
+ return PLATFORM_UPTIME_HEARTBEAT_URL_REF;
858
+ for (const service of PLATFORM_BACKUP_SERVICE_KEYS) {
859
+ if (name === platformBackupHeartbeatCheckName(service))
860
+ return platformBackupHeartbeatUrlRef(service);
861
+ }
862
+ throw new Error(`heartbeatCheckUrlRef: unrecognised heartbeat check name '${name}'`);
863
+ }
864
+ /**
865
+ * #540: the sorted, comma-joined {@link desiredHeartbeatCheckNames} as of the
866
+ * last successful reconcile -- recorded by `provisionPlatformHeartbeats`
867
+ * once it has created every missing check and deleted every stale one.
868
+ * {@link planHeartbeats} plans the reconcile step again only when the
869
+ * declared desired set has since changed (a backup service's database
870
+ * arrived, say), not on every apply: `driveApply`'s bootstrap loop
871
+ * (`bootstrap/drive.ts`) only ever terminates once a plan has zero steps, so
872
+ * a step planned unconditionally on every round -- as this one once was --
873
+ * never lets a platform with `hc` enabled (the default, #540) finish
874
+ * bootstrapping at all.
875
+ */
876
+ export const PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY = 'heartbeats_applied_desired';
753
877
  /**
754
878
  * #167: the platform's own diff engine, mirroring `diff.ts`'s
755
879
  * `computePlanDetail` for the one shared {@link DeclaredPlatform} record
@@ -782,7 +906,7 @@ export function planPlatform(platform, live, options = {}) {
782
906
  plannedSteps.push(zone);
783
907
  // #173: the jobs scheduler, second of the eight services this frame leaves
784
908
  // open (this file's own doc comment above).
785
- const jobs = planJobs(platform, byKey, claimed, jobsObservable);
909
+ const jobs = planJobs(platform, byKey, claimed, jobsObservable, recordedIds);
786
910
  if (jobs.step)
787
911
  plannedSteps.push(jobs.step);
788
912
  // A blocked SFU is reported in `unmanaged`, not planned: a step the
@@ -808,6 +932,17 @@ export function planPlatform(platform, live, options = {}) {
808
932
  : (service) => `the platform's ${service} is blocked, not planned: every DNS record is written through edge, and edge is neither applied nor planned (${platform.edge.enabled
809
933
  ? (edge.blocked ?? 'its step is not planned')
810
934
  : 'the platform does not enable edge'})`;
935
+ // #540: hc needs only edge's DNS, the same as mail and archon -- no
936
+ // auth/ZITADEL client, so it is planned here rather than after `authReady`
937
+ // further down.
938
+ const hc = planHc(platform, recordedIds, blocked.hc ?? dnsBlocked?.('heartbeat server'));
939
+ if (hc.step)
940
+ plannedSteps.push(hc.step);
941
+ // #540: the `authReady` pattern, for hc -- true once hc is either already
942
+ // applied or about to run earlier in this very plan, so the heartbeats
943
+ // reconcile below can be planned in the *same* round hc first comes up,
944
+ // the same way files/mail/archon are planned in auth's own first round.
945
+ const hcReady = hc.step !== null || Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]);
811
946
  const auth = planAuth(platform, recordedIds, blocked.auth ?? dnsBlocked?.('sign-in'), blocked.authExecutorMissing ?? false, drifted.auth ?? false, blocked.authOtherInputsMissing ?? []);
812
947
  if (auth.step)
813
948
  plannedSteps.push(auth.step);
@@ -841,6 +976,12 @@ export function planPlatform(platform, live, options = {}) {
841
976
  // recorded is already visible to `recordedIds` on this same plan.
842
977
  const backups = planBackups(byKey, claimed, recordedIds, blocked.backupExecutorMissing ?? false, drifted.backupStorage ?? false, drifted.backupStorageBlocked);
843
978
  plannedSteps.push(...backups.steps);
979
+ // #540: last of all, so the backup databases `planBackups` just saw above
980
+ // are the same set this reconcile sees too -- one check per platform
981
+ // database backup, plus the uptime Worker's own.
982
+ const heartbeats = planHeartbeats(platform, recordedIds, hcReady, blocked.heartbeatsExecutorMissing ?? false, blocked.heartbeatsApiKeyMissing ?? false);
983
+ if (heartbeats.step)
984
+ plannedSteps.push(heartbeats.step);
844
985
  // #251: one line per service whose recorded applied ref no longer matches
845
986
  // what the next apply would use (or, for zitadel, what is actually live).
846
987
  // Never pushed into `plannedSteps` -- see `VersionChangeItem`'s own doc
@@ -867,6 +1008,9 @@ export function planPlatform(platform, live, options = {}) {
867
1008
  edge.blocked
868
1009
  ? null
869
1010
  : versionChangeLine('edge', recordedIds[PLATFORM_EDGE_APPLIED_GIT_REF_SCOPE_KEY], platform.edge.gitRef, 'git ref, redeploy'),
1011
+ hc.blocked
1012
+ ? null
1013
+ : versionChangeLine('hc', recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY], platform.hc.gitRef, 'image, redeploy'),
870
1014
  auth.blocked
871
1015
  ? null
872
1016
  : versionNotAppliedLine('zitadel', Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_AUTH_KEY)]), recordedIds[PLATFORM_AUTH_ZITADEL_APPLIED_IMAGE_TAG_SCOPE_KEY], VERSIONS.zitadel),
@@ -900,6 +1044,8 @@ export function planPlatform(platform, live, options = {}) {
900
1044
  unmanaged.push({ description: ai.blocked, key: 'ai' });
901
1045
  if (edge.blocked)
902
1046
  unmanaged.push({ description: edge.blocked, key: 'edge' });
1047
+ if (hc.blocked)
1048
+ unmanaged.push({ description: hc.blocked, key: 'hc' });
903
1049
  if (archon.blocked)
904
1050
  unmanaged.push({ description: archon.blocked, key: 'archon' });
905
1051
  // #398: the old integrations key stays pinned until Archon holds the new
@@ -914,6 +1060,8 @@ export function planPlatform(platform, live, options = {}) {
914
1060
  });
915
1061
  }
916
1062
  unmanaged.push(...backups.blocked);
1063
+ if (heartbeats.blocked)
1064
+ unmanaged.push({ description: heartbeats.blocked, key: 'heartbeats' });
917
1065
  return { plannedSteps: plannedSteps.map(withStepSystem), unmanaged, versionChanges };
918
1066
  }
919
1067
  /**
@@ -972,7 +1120,7 @@ export const JOBS_SCHEDULER_KEY = 'jobs';
972
1120
  * (`diff.ts`'s eventual `jobs:registration`) has nothing to register against
973
1121
  * before this exists.
974
1122
  */
975
- function planJobs(platform, byKey, claimed, observable) {
1123
+ function planJobs(platform, byKey, claimed, observable, recordedIds) {
976
1124
  const key = resourceKey('coolify', 'jobs', JOBS_SCHEDULER_KEY);
977
1125
  const live = byKey.get(key);
978
1126
  if (live)
@@ -983,8 +1131,14 @@ function planJobs(platform, byKey, claimed, observable) {
983
1131
  // The step also starts the scheduler's database (0.16.3), so a live app
984
1132
  // whose database the reader saw stopped plans it again. A status the
985
1133
  // reader could not read is absent, and plans nothing.
1134
+ // An app without `DATABASE_URL_OWNER` also plans it again: the step
1135
+ // sets the owner and runtime URLs the scheduler's migrate stage needs.
1136
+ // So does a recorded pending deploy: the step wrote its env and died
1137
+ // before the deploy finished.
986
1138
  const databaseStatus = live.config.databaseStatus;
987
- if (typeof databaseStatus !== 'string' || databaseStatus.startsWith('running')) {
1139
+ if (live.config.ownerUrlMissing !== true &&
1140
+ !recordedIds[JOBS_DEPLOY_PENDING_SCOPE_KEY] &&
1141
+ (typeof databaseStatus !== 'string' || databaseStatus.startsWith('running'))) {
988
1142
  return { step: null, blocked: null };
989
1143
  }
990
1144
  }
@@ -1826,6 +1980,136 @@ function planEdge(platform, recordedIds, executorBlocked) {
1826
1980
  blocked: null,
1827
1981
  };
1828
1982
  }
1983
+ /**
1984
+ * #540: a self-hosted heartbeat server (Healthchecks) for the platform, for
1985
+ * now -- a Coolify Postgres plus one Docker-image app
1986
+ * (`healthchecks/healthchecks`), reached through edge's tunnel and DNS at
1987
+ * `platform.hc.hostname`, the {@link planEdge} bundling shape minus any
1988
+ * auth/ZITADEL client: `hc` is an internal operational tool, not something
1989
+ * an organisation member signs into. Whether this stays the platform's
1990
+ * heartbeat server long-term, instead of or alongside the hand-set-up
1991
+ * `hc.wtfalch.dev` instance, and what happens to that instance's own data,
1992
+ * are both still open "Decide" items on issue #540's own thread -- not
1993
+ * settled by this code.
1994
+ *
1995
+ * Idempotent on the recorded app id plus the applied hostname and `gitRef`,
1996
+ * for {@link planAuth}'s own reason: no platform reader reports this
1997
+ * Coolify app. A changed hostname or `gitRef` plans the step again (a
1998
+ * redeploy, no new secret -- `SECRET_KEY` is generated once by
1999
+ * `provisionPlatformHc` and read back, never regenerated).
2000
+ *
2001
+ * Blocked, not planned: `executorBlocked`, from `dnsBlocked` (edge neither
2002
+ * applied nor planned) or the host building no `hc` dependencies.
2003
+ */
2004
+ function planHc(platform, recordedIds, executorBlocked) {
2005
+ if (!platform.hc.enabled)
2006
+ return { step: null, blocked: null };
2007
+ const { hostname, gitRef } = platform.hc;
2008
+ const applied = Boolean(recordedIds[resourceKey('coolify', 'app', PLATFORM_HC_KEY)]) &&
2009
+ recordedIds[PLATFORM_HC_APPLIED_HOSTNAME_SCOPE_KEY] === hostname &&
2010
+ recordedIds[PLATFORM_HC_APPLIED_GIT_REF_SCOPE_KEY] === gitRef;
2011
+ if (applied)
2012
+ return { step: null, blocked: null };
2013
+ if (executorBlocked)
2014
+ return { step: null, blocked: executorBlocked };
2015
+ return {
2016
+ step: {
2017
+ action: {
2018
+ system: 'coolify',
2019
+ kind: 'app',
2020
+ key: PLATFORM_HC_KEY,
2021
+ desired: { hostname, gitRef },
2022
+ },
2023
+ step: {
2024
+ description: `provision the heartbeat server (Healthchecks) at ${hostname}, with its own database`,
2025
+ destroys: false,
2026
+ migrates: false,
2027
+ },
2028
+ },
2029
+ blocked: null,
2030
+ };
2031
+ }
2032
+ /**
2033
+ * #540: one reconcile step for every heartbeat check foundry owns -- the
2034
+ * uptime Worker's own check plus one per platform database backup
2035
+ * (`PLATFORM_BACKUP_SERVICE_KEYS`, filtered the same way {@link planBackups}
2036
+ * is to "has a recorded database", so a service with no database yet gets
2037
+ * no standing check either). The actual create-missing/delete-stale
2038
+ * reconciling against the live Healthchecks list (`HeartbeatsPort.listChecks`,
2039
+ * `platform-providers.ts`) happens inside `provisionPlatformHeartbeats`
2040
+ * itself, not here -- this function only decides *whether* that step runs
2041
+ * at all: never before `hc` is ready (`hcReady`, the {@link planPlatform}
2042
+ * own `authReady` pattern -- already applied, or about to be, earlier in
2043
+ * this same plan), never with no heartbeats dependencies wired, and never
2044
+ * again once {@link desiredHeartbeatCheckNames} already matches
2045
+ * {@link PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY}'s recorded value.
2046
+ *
2047
+ * That last check is the point: an earlier version of this function planned
2048
+ * the step unconditionally every round, on the theory that
2049
+ * `provisionPlatformHeartbeats` listing live checks first was idempotency
2050
+ * enough. It is, for what it creates -- but `bootstrap/drive.ts`'s
2051
+ * `driveApply` (the loop `bootstrapPlatform` runs to actually stand a
2052
+ * platform up) only stops once a round's plan has zero steps, and `hc` is
2053
+ * enabled by default (`bootstrap/declare.ts`), so a platform that never
2054
+ * recorded "nothing left to reconcile" could never finish bootstrapping at
2055
+ * all -- it would hit `driveApply`'s round cap on every single run. Compare
2056
+ * against the planner's own recorded desired-set marker, declared-to-declared,
2057
+ * so a plan with nothing left to do actually has nothing left to do.
2058
+ *
2059
+ * #540 round 2: also withheld while `apiKeyMissing` -- no human has held a
2060
+ * project API key under {@link PLATFORM_HC_API_KEY_REF} yet, so there is
2061
+ * nothing for the host's own `HeartbeatsPort` to authenticate a real call
2062
+ * with. Without this, a fresh platform's heartbeats step is ready
2063
+ * (`hcReady`) the same round `hc` is merely *planned*, long before anyone
2064
+ * has signed in to it to create that key.
2065
+ */
2066
+ function planHeartbeats(platform, recordedIds, hcReady, executorMissing, apiKeyMissing) {
2067
+ if (!platform.hc.enabled) {
2068
+ // Removing checks is not planned. The recorded marker names the checks
2069
+ // foundry's last reconcile wanted, so they are foundry's own; report
2070
+ // them rather than leave them unseen.
2071
+ const left = recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY];
2072
+ return {
2073
+ step: null,
2074
+ blocked: left
2075
+ ? `the heartbeat server is not enabled, but checks foundry created remain on it (${left}); removing them is not planned, delete them in Healthchecks by hand`
2076
+ : null,
2077
+ };
2078
+ }
2079
+ if (!hcReady) {
2080
+ return {
2081
+ step: null,
2082
+ blocked: 'the heartbeat checks are blocked, not planned: platform:hc has not been applied yet',
2083
+ };
2084
+ }
2085
+ if (executorMissing) {
2086
+ return {
2087
+ step: null,
2088
+ blocked: 'the heartbeat checks are blocked, not planned: the host built the platform executor without its heartbeats dependencies',
2089
+ };
2090
+ }
2091
+ if (apiKeyMissing) {
2092
+ return {
2093
+ step: null,
2094
+ blocked: `the heartbeat checks are blocked, not planned: no project API key is held yet for hc (${PLATFORM_HC_API_KEY_REF}); a human must sign in to hc, create the first superuser account and a project, mint that project's API key, and hold it under that ref (issue #540 "Human steps") before this step can call hc's check-creation API`,
2095
+ };
2096
+ }
2097
+ const desired = [...desiredHeartbeatCheckNames(recordedIds)].sort().join(',');
2098
+ if (recordedIds[PLATFORM_HEARTBEATS_APPLIED_SCOPE_KEY] === desired) {
2099
+ return { step: null, blocked: null };
2100
+ }
2101
+ return {
2102
+ step: {
2103
+ action: { system: 'heartbeats', kind: 'reconcile', key: 'platform', desired: {} },
2104
+ step: {
2105
+ description: 'reconcile heartbeat checks for the uptime Worker and every platform backup service',
2106
+ destroys: false,
2107
+ migrates: false,
2108
+ },
2109
+ },
2110
+ blocked: null,
2111
+ };
2112
+ }
1829
2113
  /**
1830
2114
  * #172: the platform's AI host (https://github.com/wtfalch/ai) -- a Coolify
1831
2115
  * Postgres plus three apps built from that repo's root Dockerfile, one per