@wtfalch/foundry 0.16.15 → 0.17.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.
Files changed (64) hide show
  1. package/dist/apply.d.ts +22 -1
  2. package/dist/bootstrap/bootstrap.d.ts +6 -1
  3. package/dist/bootstrap/bootstrap.js +26 -3
  4. package/dist/bootstrap/declare.d.ts +11 -1
  5. package/dist/bootstrap/declare.js +12 -2
  6. package/dist/index.d.ts +9 -4
  7. package/dist/index.js +7 -0
  8. package/dist/migrations/0038_calls_version_pin.sql +8 -0
  9. package/dist/migrations/0039_destroy_record.sql +7 -0
  10. package/dist/migrations/0040_company_included_uuid.sql +72 -0
  11. package/dist/migrations/0041_company_included_uuid_keys.sql +132 -0
  12. package/dist/migrations/0042_mutation_leases.sql +12 -0
  13. package/dist/org.d.ts +82 -5
  14. package/dist/org.js +62 -1
  15. package/dist/plan/adopt.d.ts +76 -0
  16. package/dist/plan/adopt.js +184 -0
  17. package/dist/plan/agora.d.ts +21 -0
  18. package/dist/plan/agora.js +37 -0
  19. package/dist/plan/broker-entries.d.ts +9 -1
  20. package/dist/plan/broker-entries.js +13 -5
  21. package/dist/plan/chat-matrix.d.ts +1 -1
  22. package/dist/plan/chat-matrix.js +11 -3
  23. package/dist/plan/core-files.d.ts +25 -0
  24. package/dist/plan/core-files.js +25 -0
  25. package/dist/plan/deploy-health.d.ts +136 -0
  26. package/dist/plan/deploy-health.js +153 -0
  27. package/dist/plan/diff.d.ts +11 -3
  28. package/dist/plan/diff.js +77 -71
  29. package/dist/plan/entry-ai-key.d.ts +3 -3
  30. package/dist/plan/entry-files-key.d.ts +6 -4
  31. package/dist/plan/entry-files-key.js +7 -10
  32. package/dist/plan/executor.d.ts +109 -28
  33. package/dist/plan/executor.js +213 -56
  34. package/dist/plan/hostnames.d.ts +6 -0
  35. package/dist/plan/hostnames.js +13 -0
  36. package/dist/plan/index.d.ts +5 -1
  37. package/dist/plan/index.js +3 -1
  38. package/dist/plan/live.d.ts +5 -0
  39. package/dist/plan/login-host.d.ts +1 -1
  40. package/dist/plan/login-host.js +6 -5
  41. package/dist/plan/plan-apply.d.ts +153 -3
  42. package/dist/plan/plan-apply.js +584 -69
  43. package/dist/plan/platform-diff.d.ts +103 -9
  44. package/dist/plan/platform-diff.js +192 -14
  45. package/dist/plan/platform-org.d.ts +25 -0
  46. package/dist/plan/platform-org.js +17 -1
  47. package/dist/plan/platform-providers.d.ts +38 -5
  48. package/dist/plan/platform-providers.js +592 -109
  49. package/dist/plan/providers-readers.d.ts +2 -0
  50. package/dist/plan/providers-readers.js +4 -0
  51. package/dist/plan/rollout.d.ts +52 -0
  52. package/dist/plan/rollout.js +37 -0
  53. package/dist/plan/step-system.d.ts +22 -0
  54. package/dist/plan/step-system.js +37 -0
  55. package/dist/plan.d.ts +3 -0
  56. package/dist/state.d.ts +13 -0
  57. package/dist/store/audit.js +15 -1
  58. package/dist/store/postgres.d.ts +1 -1
  59. package/dist/store/postgres.js +127 -14
  60. package/dist/store/schema.d.ts +134 -5
  61. package/dist/store/schema.js +12 -1
  62. package/dist/store.d.ts +47 -4
  63. package/dist/store.js +9 -1
  64. package/package.json +2 -2
@@ -48,6 +48,11 @@ export interface ZitadelReader {
48
48
  }
49
49
  export interface CoolifyReader {
50
50
  listResources(orgId: OrgId): Promise<LiveResource[]>;
51
+ /**
52
+ * Reads one Coolify project by its uuid, for an org that records none yet
53
+ * (adopt, archon#271). Optional: without it nothing can be adopted.
54
+ */
55
+ listProject?(projectUuid: string): Promise<LiveResource[]>;
51
56
  }
52
57
  export interface CloudflareReader {
53
58
  listResources(orgId: OrgId): Promise<LiveResource[]>;
@@ -66,7 +66,7 @@ export interface LoginHostBindingsPort {
66
66
  * on the next run.
67
67
  */
68
68
  export declare const LOGIN_BINDING_SCOPE_KEY = "login_host_binding";
69
- /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
69
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora, boule), deduplicated and sorted. */
70
70
  export declare function loginHostClientIds(recorded: Readonly<Record<string, string>>): string[];
71
71
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
72
72
  export declare function loginBindingState(host: string, clientIds: readonly string[]): string;
@@ -1,4 +1,5 @@
1
1
  import { AGORA_CLIENT_ID_SCOPE_KEY, APP_CLIENT_ID_SCOPE_KEY, CMS_CLIENT_ID_SCOPE_KEY, ENTRY_CLIENT_ID_SCOPE_PREFIX, } from './agora.js';
2
+ import { BOULE_CLIENT_ID_SCOPE_KEY } from './boule.js';
2
3
  /**
3
4
  * #267: what both the planner (`diff.ts`) and the executor (`executor.ts`)
4
5
  * need to agree on for an org's own company login host -- the hostname and
@@ -36,18 +37,18 @@ export function loginHostnameFor(org) {
36
37
  * on the next run.
37
38
  */
38
39
  export const LOGIN_BINDING_SCOPE_KEY = 'login_host_binding';
39
- /** The org's recorded app client ids (hosted app, each entry's own, cms, agora), sorted. */
40
+ /** The org's recorded app client ids (hosted app, each entry's own, cms, agora, boule), deduplicated and sorted. */
40
41
  export function loginHostClientIds(recorded) {
41
- return [
42
+ const ids = [
42
43
  recorded[APP_CLIENT_ID_SCOPE_KEY],
43
44
  recorded[CMS_CLIENT_ID_SCOPE_KEY],
44
45
  recorded[AGORA_CLIENT_ID_SCOPE_KEY],
46
+ recorded[BOULE_CLIENT_ID_SCOPE_KEY],
45
47
  ...Object.entries(recorded)
46
48
  .filter(([key]) => key.startsWith(ENTRY_CLIENT_ID_SCOPE_PREFIX))
47
49
  .map(([, id]) => id),
48
- ]
49
- .filter((id) => Boolean(id))
50
- .sort();
50
+ ].filter((id) => Boolean(id));
51
+ return [...new Set(ids)].sort();
51
52
  }
52
53
  /** The host plus its sorted client ids, as recorded under {@link LOGIN_BINDING_SCOPE_KEY}. */
53
54
  export function loginBindingState(host, clientIds) {
@@ -1,9 +1,11 @@
1
- import type { PlanApply } from '../apply.js';
1
+ import type { DestroyProgress, PlanApply } from '../apply.js';
2
2
  import type { DeclaredOrg, OrgId } from '../org.js';
3
3
  import type { Plan, PlanStep } from '../plan.js';
4
4
  import type { DeclaredPlatform } from '../platform.js';
5
+ import type { JobRunner } from '../runner/types.js';
5
6
  import type { AppliedState } from '../state.js';
6
- import type { AppliedVersionsStore, DeclaredOrgStore, PlatformStore } from '../store.js';
7
+ import { type AppliedVersionsStore, type DeclaredOrgStore, type PlatformStore } from '../store.js';
8
+ import { type AdoptCandidate, type AdoptInput, type AdoptPlan } from './adopt.js';
7
9
  import { type AgoraHostPort } from './agora.js';
8
10
  import { type StepAction } from './diff.js';
9
11
  import { type LiveReaders, type LiveResource, type PlatformLiveReaders } from './live.js';
@@ -50,6 +52,16 @@ export interface AuditLog {
50
52
  target?: 'platform';
51
53
  at: Date;
52
54
  }): Promise<void>;
55
+ /** `FoundryPlanApply.adopt` and `release` (archon#271): no `Plan` is applied, so this is its own entry. `entryKey` names the hosting entry. */
56
+ recordAdoption(entry: {
57
+ actor: AuditActor;
58
+ orgId: OrgId;
59
+ action: 'adopt' | 'release';
60
+ entryKey: string;
61
+ outcome: 'adopted' | 'released' | 'refused';
62
+ reason?: string;
63
+ at: Date;
64
+ }): Promise<void>;
53
65
  /** `FoundryPlanApply.destroy`'s own record -- no `Plan` involved, so this is its own entry shape rather than a reuse of `recordApply`'s. */
54
66
  recordDestroy(entry: {
55
67
  actor: AuditActor;
@@ -210,6 +222,12 @@ export interface PlatformStepExecutor {
210
222
  /** #257 fix round 4: see `PlanPlatformOptions.drifted.backupStorageBlocked` (`platform-diff.ts`). */
211
223
  backupStorageBlocked?: string;
212
224
  }>;
225
+ /**
226
+ * Gives the platform files runner a changed share-domain set and touches
227
+ * nothing else. Returns the scope ids to record (only the share-domains
228
+ * one), or `null` when nothing changed.
229
+ */
230
+ refreshFilesShare?(platform: DeclaredPlatform, recordedIds: Readonly<Record<string, string>>): Promise<Record<string, string> | null>;
213
231
  execute(action: StepAction, context: PlatformStepExecutionContext): Promise<{
214
232
  resourceId?: string;
215
233
  } | undefined>;
@@ -243,6 +261,13 @@ export interface StepDestroyer {
243
261
  readonly recordedIds: Readonly<Record<string, string>>;
244
262
  }): Promise<void>;
245
263
  }
264
+ /** #441: another apply, destroy, adopt or release holds the lease for this company or the platform. Nothing ran; try again when it finishes. */
265
+ export declare class MutationInProgressError extends Error {
266
+ readonly scope: string;
267
+ readonly heldUntil: Date;
268
+ /** @param heldUntil when the holder's lease runs out if it is never released. */
269
+ constructor(scope: string, heldUntil: Date);
270
+ }
246
271
  export declare class DeclaredOrgNotFoundError extends Error {
247
272
  constructor(orgId: OrgId);
248
273
  }
@@ -317,6 +342,27 @@ export interface FoundryPlanApplyOptions {
317
342
  aiOrganisation?: boolean;
318
343
  /** Overridable for tests; defaults to `() => new Date()`. */
319
344
  now?: () => Date;
345
+ /** #441: how long a mutation lease lasts before another caller may take it over. Defaults to 30 minutes. */
346
+ leaseTtlMs?: number;
347
+ /**
348
+ * #441 (part b): a durable, DB-row-backed record of `apply`, `destroy`,
349
+ * `adopt` and `release` for one org, held under the same mutation lease
350
+ * above rather than instead of it -- the lease is what actually keeps two
351
+ * holders from running `fn` at once (`withRunnerSerialization`'s own doc
352
+ * comment explains why the runner cannot safely take that job on its
353
+ * own). Optional, and a no-op without it -- every existing caller of this
354
+ * constructor is unaffected, and the lease alone still serializes every
355
+ * mutation the way it already did (#441, part a).
356
+ *
357
+ * What it adds: a row that never expires on a timer the way the lease
358
+ * does, so a crashed holder leaves something a host can see and query
359
+ * (`JobRunner.currentRun`/`getRun`) rather than nothing but a scope that
360
+ * quietly frees itself. Resuming after a crash reuses that row's id
361
+ * rather than starting a fresh one. Pass the same host-supplied
362
+ * `JobRunner` (`createJobRunner`, migrated with `runner/schema.ts`) that a
363
+ * host may also poll directly for progress.
364
+ */
365
+ runner?: JobRunner;
320
366
  /**
321
367
  * #253: appends a history row every time `runSteps` records a new
322
368
  * version as applied, beside the single-snapshot `store.putAppliedState`
@@ -373,11 +419,62 @@ export declare class FoundryPlanApply implements PlanApply {
373
419
  private readonly hostFiles;
374
420
  private readonly hostAi;
375
421
  private readonly now;
422
+ private readonly leaseTtlMs;
376
423
  private readonly appliedVersions;
377
424
  private readonly platformDeps;
425
+ private readonly runner;
378
426
  constructor(options: FoundryPlanApplyOptions);
379
427
  plan(id: OrgId): Promise<Plan>;
428
+ /**
429
+ * Archon#271: what adopting a live app and database into entry
430
+ * `input.entryKey` would do. A read, like {@link plan}: it reads the project
431
+ * through the reader and changes nothing. A mismatch is reported in the
432
+ * result, with no steps. Needs the apply permission, since it reads a
433
+ * project the caller names.
434
+ */
435
+ planAdopt(id: OrgId, input: AdoptInput): Promise<AdoptPlan>;
436
+ /**
437
+ * Archon#271: the live apps and databases in `projectUuid` this org has not
438
+ * recorded, for Changes' "Live, not theirs". A read, but it needs the apply
439
+ * permission: it reads a project the caller names.
440
+ */
441
+ adoptCandidates(id: OrgId, entryKey: string, projectUuid?: string): Promise<AdoptCandidate[]>;
442
+ /**
443
+ * Archon#271: records an app and database that already run in Coolify as
444
+ * entry `input.entryKey`'s resources, so the next plan finds them and plans
445
+ * no create. Verifies first and refuses on a mismatch or an entry that is
446
+ * already applied or held by another org. Same permission as {@link apply}.
447
+ * It never calls an executor: nothing in Coolify changes. Nothing redeploys
448
+ * here, but the next normal plan may propose updates to the adopted app
449
+ * that do (see `adopt.ts`); show that plan before applying it. Two
450
+ * concurrent adopts of one resource are not locked against each other.
451
+ */
452
+ adopt(id: OrgId, input: AdoptInput): Promise<AppliedState>;
453
+ /**
454
+ * Archon#271: the undo of {@link adopt}. Drops the entry's recorded Coolify
455
+ * ids and its `adopted.<entryKey>` snapshot (and `coolify_project` when
456
+ * adopt recorded it and no other Coolify id remains). It leaves the
457
+ * declaration alone: the entry goes back to declared and not applied, so the
458
+ * next plan creates it, exactly as before adopt. The host then adopts it
459
+ * again or removes the entry from the declaration. Deletes and stops nothing
460
+ * in Coolify. Only an adopted entry can be released. Same permission as
461
+ * {@link apply}. Every path writes an audit row.
462
+ */
463
+ release(id: OrgId, entryKey: string): Promise<AppliedState>;
464
+ private computeAdopt;
380
465
  apply(plan: Plan, confirmedOrgName: string): Promise<AppliedState>;
466
+ /**
467
+ * After an org apply: gives the platform files runner a changed share-domain
468
+ * set (its Coolify `domains` and `FILES_PUBLIC_HOSTS`, derived from the
469
+ * declared orgs) without a platform pass. Touches only the share hosts and
470
+ * redeploys the runner; the platform files step itself never runs here.
471
+ * Skips quietly for an actor who may not apply the platform, and when the
472
+ * platform files step was never applied. It holds the platform lease for
473
+ * the refresh and fences its write with it; while another platform change
474
+ * holds the lease it skips (this class has no log), and the next platform
475
+ * apply does the refresh. Only the share-domains key is recorded. A failure is rethrown naming that the org part succeeded.
476
+ */
477
+ private refreshPlatformFilesShare;
381
478
  /**
382
479
  * #167: {@link plan}, for the shared platform (#166) rather than an org --
383
480
  * there is no id to pass, since `DeclaredPlatform` is one singleton record.
@@ -404,7 +501,60 @@ export declare class FoundryPlanApply implements PlanApply {
404
501
  * declared org in place with whatever remains recorded in its applied
405
502
  * state, so a retried `destroy` only tears down what's left.
406
503
  */
407
- destroy(id: OrgId, confirmedOrgName: string): Promise<void>;
504
+ destroy(id: OrgId, confirmedOrgName: string, options?: {
505
+ onProgress?: (event: DestroyProgress) => void;
506
+ }): Promise<void>;
507
+ /** The steps `destroy` runs for `id`, in order. Refuses a non-test org like `destroy` does; writes no audit row since it changes nothing. */
508
+ planDestroy(id: OrgId): Promise<{
509
+ steps: string[];
510
+ kept: string[];
511
+ }>;
512
+ /**
513
+ * #441 (part b): records `fn`'s run in the durable runner, when one is
514
+ * configured, and calls it. Must run with the mutation lease already
515
+ * held -- every one of this method's four callers (`apply`, `destroy`,
516
+ * `adopt`, `release`) calls it from inside its own `withMutationLease`
517
+ * callback, never around it. The reason is `JobRunner.runSteps` itself:
518
+ * it checkpoints a run's one step (the literal id `'mutate'`, the same
519
+ * for all four callers so they share one row per org) by a plain read of
520
+ * the row followed by a later blind write, with nothing to stop two
521
+ * concurrent calls for the same `runId` from both reading "not yet
522
+ * checkpointed" and both calling `fn` -- the runner sequences and
523
+ * checkpoints (its own doc comment), it does not itself exclude a second
524
+ * caller the way the lease's atomic acquire does. Holding the lease first
525
+ * is what makes it safe to reuse an existing, not-yet-succeeded run
526
+ * ({@link JobRunner.currentRun}) rather than refuse it outright: by the
527
+ * time this method runs, this caller is already the lease's sole holder,
528
+ * so any row `currentRun` finds can only be a previous, now-abandoned
529
+ * attempt (the lease's own atomic acquire already refused a second live
530
+ * caller before either of them got here) -- reusing it is the resume #9
531
+ * calls "a human decides when to call it again", not a race.
532
+ *
533
+ * Two *first* attempts for the same org can still race the insert itself
534
+ * when neither has an existing row yet -- `JobRunner.enqueueApply`'s own
535
+ * unique constraint decides it at the database level, a real race rather
536
+ * than one decided by this process's await order, and the loser's
537
+ * {@link AlreadyRunningError} is refused and audited through `refuse`,
538
+ * the same as a lost lease. In practice this needs two lease holders at
539
+ * once to reach it, which the lease itself already prevents; it is this
540
+ * method's defence against a bug in the lease's own acquire path, not
541
+ * the primary lock.
542
+ *
543
+ * `fn`'s result is threaded out through a closure, since
544
+ * {@link RunnerStep.run} returns nothing.
545
+ */
546
+ private withRunnerSerialization;
547
+ /**
548
+ * #441: runs `fn` while holding the one mutation lease for `scope`, so two
549
+ * `FoundryPlanApply` instances on one store never run provider steps for the
550
+ * same company or platform at once. A second caller is refused with
551
+ * {@link MutationInProgressError}; `fn` gets the lease as the fence its
552
+ * store writes carry, so a worker that lost the lease cannot overwrite the
553
+ * newer holder's state. Both refusals are audited through `refuse`.
554
+ */
555
+ private withMutationLease;
556
+ /** #441: before each provider step, extends the lease; a holder that lost it stops here, before the step runs. */
557
+ private renewLease;
408
558
  private refuse;
409
559
  private refusePlatform;
410
560
  private refuseDestroy;