@checkstack/automation-backend 0.2.0 → 0.3.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 (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Shared driven-write/remove guard for PLUGIN-BACKED (Model B) entities.
3
+ *
4
+ * Every plugin-backed domain (incident, catalog, dependency, maintenance, slo,
5
+ * satellite) drives its reactive-state writes through the same tiny guard:
6
+ *
7
+ * - no handle wired (tests construct the router without one, or before the
8
+ * entity is defined) → run the plugin write (`apply`) directly; reactivity
9
+ * is layered on top and is never required for the underlying write to
10
+ * succeed, and
11
+ * - handle wired → route through `handle.mutate` / `handle.remove` so the
12
+ * framework snapshots `prev`, appends the transition log, and emits
13
+ * `ENTITY_CHANGED`.
14
+ *
15
+ * The only thing that varied between the per-domain copies was the id-key name
16
+ * (`incidentId`, `dependencyId`, …); the guard itself is identical. Domains
17
+ * keep their thin, well-named wrappers (`writeIncidentEntity`, …) but delegate
18
+ * the guard here so the branch lives in exactly one place.
19
+ *
20
+ * NOTE: `writeHealthEntity` (healthcheck-backend) is deliberately NOT built on
21
+ * this helper — it is genuinely bespoke (closure-captured durable state,
22
+ * distinct rethrow-vs-fail-soft branches, a per-system serializer, and it
23
+ * returns the computed state). Do not force-fit it here.
24
+ */
25
+ import type { EntityHandle, EntityMutationOpts } from "./define-entity";
26
+
27
+ /**
28
+ * Drive a reactive-state write through `handle.mutate`, falling back to a plain
29
+ * `apply()` when no handle is wired. `apply` performs the plugin's REAL write
30
+ * (its own db/tx) and returns the new reactive state; the framework snapshots
31
+ * `prev`, appends the transition log, and emits `ENTITY_CHANGED`.
32
+ */
33
+ export async function withEntityWrite<
34
+ TState extends Record<string, unknown>,
35
+ >(args: {
36
+ handle: EntityHandle<TState> | undefined;
37
+ id: string;
38
+ opts?: EntityMutationOpts;
39
+ apply: () => Promise<TState>;
40
+ }): Promise<void> {
41
+ const { handle, id, opts, apply } = args;
42
+ if (!handle) {
43
+ await apply();
44
+ return;
45
+ }
46
+ await handle.mutate({ id, opts, apply });
47
+ }
48
+
49
+ /**
50
+ * Drive a tombstone through `handle.remove`, falling back to a plain `apply()`
51
+ * when no handle is wired. `apply` performs the plugin's REAL delete (its own
52
+ * db/tx); the framework records the tombstone transition and emits a tombstone
53
+ * `ENTITY_CHANGED`.
54
+ */
55
+ export async function withEntityRemove<
56
+ TState extends Record<string, unknown>,
57
+ >(args: {
58
+ handle: EntityHandle<TState> | undefined;
59
+ id: string;
60
+ opts?: EntityMutationOpts;
61
+ apply: () => Promise<void>;
62
+ }): Promise<void> {
63
+ const { handle, id, opts, apply } = args;
64
+ if (!handle) {
65
+ await apply();
66
+ return;
67
+ }
68
+ await handle.remove({ id, opts, apply });
69
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Helper for ENTITY-DRIVEN triggers (reactive automation engine Phase 4).
3
+ *
4
+ * A domain that migrated its state to a `defineEntity` no longer emits a
5
+ * cross-plugin lifecycle hook — the entity's change event drives dispatch
6
+ * through Stage-1 routing (the registered `registerChangeDeriver` maps a
7
+ * change to the qualified trigger event id). The trigger is therefore fired
8
+ * neither by a `hook` subscription nor by a `setup` listener; it is fired by
9
+ * the deriver matching the automation definition's `trigger.event` string
10
+ * (via `findEnabledByTriggerEvent`).
11
+ *
12
+ * The trigger still needs to REGISTER (for the editor's trigger catalog +
13
+ * payload introspection), and the trigger registry requires exactly one of
14
+ * `hook` / `setup`. This provides a no-op `setup` so an entity-driven
15
+ * trigger registers cleanly without re-introducing a hook. The setup
16
+ * subscribes to nothing and tears down nothing — the deriver does the work.
17
+ */
18
+ import type { TriggerSetupFn, TriggerTeardown } from "./action-types";
19
+
20
+ /** No-op teardown — an entity-driven trigger has no listener to remove. */
21
+ const noopTeardown: TriggerTeardown = async () => {
22
+ // Fired by the entity change deriver; nothing to tear down.
23
+ };
24
+
25
+ /**
26
+ * The single, payload-agnostic entity-driven `setup`: subscribes to nothing
27
+ * and returns the no-op teardown. The trigger actually fires via its
28
+ * registered change deriver (Stage-1 routing), not this setup.
29
+ */
30
+ async function entityDrivenSetup(): Promise<TriggerTeardown> {
31
+ return noopTeardown;
32
+ }
33
+
34
+ /**
35
+ * Return the entity-driven no-op `setup`, typed for the specific trigger
36
+ * payload/config it is assigned to. `setup` is contravariant in its payload,
37
+ * so a single shared `unknown`-typed value won't assign to a concrete
38
+ * `TriggerDefinition<TPayload>.setup`; this factory re-types the one shared
39
+ * implementation for each call site without per-trigger allocation churn.
40
+ */
41
+ export function makeEntityDrivenTriggerSetup<
42
+ TPayload,
43
+ TConfig = void,
44
+ >(): TriggerSetupFn<TPayload, TConfig> {
45
+ return entityDrivenSetup;
46
+ }
@@ -3,6 +3,7 @@ import {
3
3
  createServiceRef,
4
4
  } from "@checkstack/backend-api";
5
5
  import type { PluginMetadata } from "@checkstack/common";
6
+ import type { Filter } from "@checkstack/template-engine";
6
7
  import type {
7
8
  ActionDefinition,
8
9
  ArtifactTypeDefinition,
@@ -61,6 +62,40 @@ export const automationArtifactTypeExtensionPoint =
61
62
  "automation.artifactTypeExtensionPoint",
62
63
  );
63
64
 
65
+ /**
66
+ * A plugin-contributed template filter. Filters MUST be pure and
67
+ * synchronous (no I/O, no async, no DB) — the template engine evaluates
68
+ * them inline during condition/value rendering. `description` and
69
+ * `signature` feed the editor's autocomplete catalogue.
70
+ */
71
+ export interface FilterDefinition {
72
+ /** Pipe name, e.g. `older_than`. Used as `{{ value | older_than(...) }}`. */
73
+ name: string;
74
+ /** The pure transform. First arg is the piped value. */
75
+ filter: Filter;
76
+ /** One-line description for the autocomplete catalogue. */
77
+ description?: string;
78
+ /** Human-readable call signature, e.g. `older_than(thresholdMs)`. */
79
+ signature?: string;
80
+ }
81
+
82
+ /**
83
+ * Extension point for registering pure template filters. Lets plugins
84
+ * contribute domain-specific transforms (e.g. a duration or unit helper)
85
+ * without forking the template engine's default registry.
86
+ */
87
+ export interface AutomationFilterExtensionPoint {
88
+ registerFilter(
89
+ definition: FilterDefinition,
90
+ pluginMetadata: PluginMetadata,
91
+ ): void;
92
+ }
93
+
94
+ export const automationFilterExtensionPoint =
95
+ createExtensionPoint<AutomationFilterExtensionPoint>(
96
+ "automation.filterExtensionPoint",
97
+ );
98
+
64
99
  // ─── Service refs ─────────────────────────────────────────────────────────
65
100
 
66
101
  /**
@@ -0,0 +1,215 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { z } from "zod";
3
+ import { createHook, Versioned } from "@checkstack/backend-api";
4
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
5
+ import type { SpecSchemaDocumentationProvider } from "@checkstack/gitops-common";
6
+ import { createTriggerRegistry } from "./trigger-registry";
7
+ import { createActionRegistry } from "./action-registry";
8
+ import {
9
+ buildAutomationSpecSchemaDocumentation,
10
+ registerAutomationGitOpsDocumentation,
11
+ } from "./gitops-docs";
12
+
13
+ const testPlugin = { pluginId: "test-plugin" } as const;
14
+
15
+ /**
16
+ * Stub kind registry capturing the registered doc PROVIDER. The eager
17
+ * registration methods throw if hit, proving the docs path only registers a
18
+ * lazy provider.
19
+ */
20
+ function createCapturingKindRegistry() {
21
+ const providers: SpecSchemaDocumentationProvider[] = [];
22
+ return {
23
+ providers,
24
+ registry: {
25
+ registerKind: () => {
26
+ throw new Error("registerKind should not be called by docs");
27
+ },
28
+ registerKindExtension: () => {
29
+ throw new Error("registerKindExtension should not be called by docs");
30
+ },
31
+ registerSpecSchemaDocumentation: () => {
32
+ throw new Error(
33
+ "registerSpecSchemaDocumentation (eager) should not be called by docs",
34
+ );
35
+ },
36
+ registerSpecSchemaDocumentationProvider: (
37
+ provider: SpecSchemaDocumentationProvider,
38
+ ) => {
39
+ providers.push(provider);
40
+ },
41
+ },
42
+ };
43
+ }
44
+
45
+ describe("buildAutomationSpecSchemaDocumentation", () => {
46
+ let triggerRegistry: ReturnType<typeof createTriggerRegistry>;
47
+ let actionRegistry: ReturnType<typeof createActionRegistry>;
48
+
49
+ beforeEach(() => {
50
+ triggerRegistry = createTriggerRegistry();
51
+ actionRegistry = createActionRegistry();
52
+ });
53
+
54
+ it("emits triggers[].config docs only for triggers with a configSchema", () => {
55
+ // Setup-backed trigger WITH config schema.
56
+ triggerRegistry.register(
57
+ {
58
+ id: "time.cron",
59
+ displayName: "Cron",
60
+ description: "Runs on a cron schedule.",
61
+ payloadSchema: z.object({ scheduledAt: z.string() }),
62
+ configSchema: z.object({ pattern: z.string() }),
63
+ setup: async () => async () => {},
64
+ },
65
+ testPlugin,
66
+ );
67
+ // Hook-backed trigger WITHOUT config schema — should be skipped.
68
+ triggerRegistry.register(
69
+ {
70
+ id: "incident.created",
71
+ displayName: "Incident Created",
72
+ payloadSchema: z.object({ incidentId: z.string() }),
73
+ hook: createHook<{ incidentId: string }>("incident.created"),
74
+ },
75
+ testPlugin,
76
+ );
77
+
78
+ const docs = buildAutomationSpecSchemaDocumentation({
79
+ triggerRegistry,
80
+ actionRegistry,
81
+ });
82
+
83
+ const triggerDocs = docs.filter((c) => c.fieldPath === "triggers[].config");
84
+ expect(triggerDocs.length).toBe(1);
85
+
86
+ const cron = triggerDocs[0];
87
+ expect(cron?.kind).toBe("Automation");
88
+ expect(cron?.apiVersion).toBe(CHECKSTACK_API_VERSION);
89
+ expect(cron?.variantId).toBe("test-plugin.time.cron");
90
+ expect(cron?.label).toBe("Cron");
91
+ expect(cron?.description).toContain("ID: test-plugin.time.cron");
92
+ expect(cron?.description).toContain("Runs on a cron schedule.");
93
+ expect(cron?.schema).toBe(
94
+ triggerRegistry.getTrigger("test-plugin.time.cron")!.configSchema!,
95
+ );
96
+ // No conditions: the trigger config is a standalone variant the kind
97
+ // browser surfaces directly (mirrors Healthcheck's primary `config`).
98
+ expect(cron?.conditions).toBeUndefined();
99
+ });
100
+
101
+ it("emits actions[].config docs for each registered provider action", () => {
102
+ const jiraConfig = z.object({ projectKey: z.string() });
103
+ actionRegistry.register(
104
+ {
105
+ id: "jira.create_issue",
106
+ displayName: "Create Jira issue",
107
+ description: "Creates a Jira issue.",
108
+ config: new Versioned({ version: 1, schema: jiraConfig }),
109
+ execute: async () => ({ success: true }),
110
+ },
111
+ testPlugin,
112
+ );
113
+
114
+ const docs = buildAutomationSpecSchemaDocumentation({
115
+ triggerRegistry,
116
+ actionRegistry,
117
+ });
118
+
119
+ const actionDocs = docs.filter((c) => c.fieldPath === "actions[].config");
120
+ expect(actionDocs.length).toBe(1);
121
+
122
+ const jira = actionDocs[0];
123
+ expect(jira?.kind).toBe("Automation");
124
+ expect(jira?.apiVersion).toBe(CHECKSTACK_API_VERSION);
125
+ expect(jira?.variantId).toBe("test-plugin.jira.create_issue");
126
+ expect(jira?.label).toBe("Create Jira issue");
127
+ expect(jira?.description).toContain("ID: test-plugin.jira.create_issue");
128
+ expect(jira?.description).toContain("Creates a Jira issue.");
129
+ expect(jira?.schema).toBe(jiraConfig);
130
+ // No conditions: each provider action is a standalone variant in the
131
+ // kind browser's single `actions[].config` dropdown.
132
+ expect(jira?.conditions).toBeUndefined();
133
+ });
134
+
135
+ it("falls back to just the ID when a registration has no description", () => {
136
+ actionRegistry.register(
137
+ {
138
+ id: "noop",
139
+ displayName: "No-op",
140
+ config: new Versioned({ version: 1, schema: z.object({}) }),
141
+ execute: async () => ({ success: true }),
142
+ },
143
+ testPlugin,
144
+ );
145
+
146
+ const docs = buildAutomationSpecSchemaDocumentation({
147
+ triggerRegistry,
148
+ actionRegistry,
149
+ });
150
+
151
+ const doc = docs.find((c) => c.variantId === "test-plugin.noop");
152
+ expect(doc?.description).toBe("ID: test-plugin.noop");
153
+ });
154
+
155
+ it("emits nothing when both registries are empty", () => {
156
+ const docs = buildAutomationSpecSchemaDocumentation({
157
+ triggerRegistry,
158
+ actionRegistry,
159
+ });
160
+ expect(docs.length).toBe(0);
161
+ });
162
+ });
163
+
164
+ describe("registerAutomationGitOpsDocumentation", () => {
165
+ let triggerRegistry: ReturnType<typeof createTriggerRegistry>;
166
+ let actionRegistry: ReturnType<typeof createActionRegistry>;
167
+
168
+ beforeEach(() => {
169
+ triggerRegistry = createTriggerRegistry();
170
+ actionRegistry = createActionRegistry();
171
+ });
172
+
173
+ it("registers a LAZY provider (not eager entries)", () => {
174
+ const { providers, registry } = createCapturingKindRegistry();
175
+ registerAutomationGitOpsDocumentation({
176
+ kindRegistry: registry,
177
+ triggerRegistry,
178
+ actionRegistry,
179
+ });
180
+ expect(providers.length).toBe(1);
181
+ });
182
+
183
+ it("provider reflects actions registered AFTER it was registered (order-independent)", () => {
184
+ const { providers, registry } = createCapturingKindRegistry();
185
+
186
+ // Register the provider while the registries are still empty — this is
187
+ // the real-world race: automation registers docs before other plugins
188
+ // contribute their provider actions.
189
+ registerAutomationGitOpsDocumentation({
190
+ kindRegistry: registry,
191
+ triggerRegistry,
192
+ actionRegistry,
193
+ });
194
+
195
+ // Provider invoked now (empty) returns nothing.
196
+ expect(providers[0]!().length).toBe(0);
197
+
198
+ // A later plugin registers its action.
199
+ actionRegistry.register(
200
+ {
201
+ id: "late.action",
202
+ displayName: "Late Action",
203
+ config: new Versioned({ version: 1, schema: z.object({}) }),
204
+ execute: async () => ({ success: true }),
205
+ },
206
+ testPlugin,
207
+ );
208
+
209
+ // Re-invoking the SAME provider now surfaces the late action.
210
+ const later = providers[0]!();
211
+ expect(later.length).toBe(1);
212
+ expect(later[0]?.variantId).toBe("test-plugin.late.action");
213
+ expect(later[0]?.fieldPath).toBe("actions[].config");
214
+ });
215
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * GitOps spec-schema documentation for the `Automation` kind.
3
+ *
4
+ * Mirrors `registerHealthcheckGitOpsDocumentation` in healthcheck-backend:
5
+ * the `Automation` spec (`AutomationDefinitionSchema`) has two polymorphic,
6
+ * user-facing field paths whose concrete shape depends on a sibling
7
+ * discriminator value:
8
+ *
9
+ * - `triggers[].config` is polymorphic on `triggers[].event` — each
10
+ * registered trigger declares its own `configSchema`.
11
+ * - `actions[].config` is polymorphic on `actions[].action` — each
12
+ * registered provider action declares its own config schema.
13
+ *
14
+ * For every registered trigger/action we emit one standalone documentation
15
+ * entry (a variant keyed by the trigger/action id, with NO `conditions`), so
16
+ * the kind browser renders one variant dropdown per field - exactly the way
17
+ * Healthcheck surfaces its primary `config` (strategy) field. We deliberately
18
+ * do NOT condition these on `triggers[].event` / `actions[].action`: those
19
+ * discriminators have no variant-selector group of their own in the kind
20
+ * browser, so a condition referencing them would never be satisfied and the
21
+ * whole "Additional Schemas" section would render empty.
22
+ *
23
+ * IMPORTANT — why this uses a LAZY provider, not eager registration:
24
+ * unlike Healthcheck (whose strategy/collector registries are CORE SERVICES
25
+ * fully populated before any plugin's `afterPluginsReady`), the automation
26
+ * trigger/action registries are filled by OTHER plugins across their
27
+ * `init` / `afterPluginsReady` phases, which have NO guaranteed ordering
28
+ * relative to automation's `afterPluginsReady`. Several plugins
29
+ * (catalog/maintenance/notification) register their provider actions in
30
+ * THEIR `afterPluginsReady`, so a one-shot eager registration at automation's
31
+ * `afterPluginsReady` would snapshot a half-populated (often empty) registry.
32
+ * Registering a provider thunk instead means the kind registry re-reads the
33
+ * registries when the kind-browser RPC is queried, so the docs always reflect
34
+ * the fully-populated registries regardless of registration order.
35
+ */
36
+ import type {
37
+ EntityKindRegistry,
38
+ SpecSchemaDocumentation,
39
+ } from "@checkstack/gitops-common";
40
+ import { CHECKSTACK_API_VERSION } from "@checkstack/gitops-common";
41
+ import type { TriggerRegistry } from "./trigger-registry";
42
+ import type { ActionRegistry } from "./action-registry";
43
+
44
+ /** A documentation entry as accepted by the kind registry. */
45
+ type AutomationDoc = {
46
+ apiVersion: string;
47
+ kind: string;
48
+ } & SpecSchemaDocumentation;
49
+
50
+ /**
51
+ * Compose a doc description for a registered variant: the fully-qualified id
52
+ * (so operators can copy the exact `event` / `action` value into YAML)
53
+ * followed by the registration's own description, when present.
54
+ */
55
+ function buildDescription({
56
+ qualifiedId,
57
+ description,
58
+ }: {
59
+ qualifiedId: string;
60
+ description?: string;
61
+ }): string {
62
+ return description
63
+ ? `ID: ${qualifiedId}\n\n${description}`
64
+ : `ID: ${qualifiedId}`;
65
+ }
66
+
67
+ /**
68
+ * Build the `Automation` spec-schema documentation entries from the CURRENT
69
+ * contents of the trigger/action registries. Pure: it only reads the
70
+ * registries it is handed and returns the entries — no side effects. The
71
+ * provider registered below calls this at describe time.
72
+ */
73
+ export function buildAutomationSpecSchemaDocumentation({
74
+ triggerRegistry,
75
+ actionRegistry,
76
+ }: {
77
+ triggerRegistry: TriggerRegistry;
78
+ actionRegistry: ActionRegistry;
79
+ }): AutomationDoc[] {
80
+ const docs: AutomationDoc[] = [];
81
+
82
+ // 1. Trigger config docs (fieldPath: "triggers[].config").
83
+ // A trigger's `config` block is only meaningful when the trigger
84
+ // declares a `configSchema` — config-less triggers (most hook-backed
85
+ // ones) have no documentable shape, so we skip them. Each entry is a
86
+ // standalone variant keyed by the trigger id (no `conditions`): the
87
+ // kind browser surfaces one dropdown of triggers, exactly like
88
+ // Healthcheck's primary `config` (strategy) field.
89
+ for (const trigger of triggerRegistry.getTriggers()) {
90
+ if (!trigger.configSchema) continue;
91
+
92
+ docs.push({
93
+ apiVersion: CHECKSTACK_API_VERSION,
94
+ kind: "Automation",
95
+ fieldPath: "triggers[].config",
96
+ variantId: trigger.qualifiedId,
97
+ label: trigger.displayName,
98
+ description: buildDescription({
99
+ qualifiedId: trigger.qualifiedId,
100
+ description: trigger.description,
101
+ }),
102
+ schema: trigger.configSchema,
103
+ });
104
+ }
105
+
106
+ // 2. Provider-action config docs (fieldPath: "actions[].config").
107
+ // Every registered provider action carries a config schema
108
+ // (`action.config.schema`). Block kinds (choose/parallel/repeat/…)
109
+ // are structural and documented by the base spec schema, not here.
110
+ // Each entry is a standalone variant keyed by the action id (no
111
+ // `conditions`) - one dropdown of provider actions in the kind browser.
112
+ for (const action of actionRegistry.getActions()) {
113
+ docs.push({
114
+ apiVersion: CHECKSTACK_API_VERSION,
115
+ kind: "Automation",
116
+ fieldPath: "actions[].config",
117
+ variantId: action.qualifiedId,
118
+ label: action.displayName,
119
+ description: buildDescription({
120
+ qualifiedId: action.qualifiedId,
121
+ description: action.description,
122
+ }),
123
+ schema: action.config.schema,
124
+ });
125
+ }
126
+
127
+ return docs;
128
+ }
129
+
130
+ /**
131
+ * Register a lazy spec-schema documentation provider for the `Automation`
132
+ * kind. The provider re-reads the trigger/action registries each time the
133
+ * kind registry is described, so the docs reflect the fully-populated
134
+ * registries regardless of cross-plugin registration ordering.
135
+ */
136
+ export function registerAutomationGitOpsDocumentation({
137
+ kindRegistry,
138
+ triggerRegistry,
139
+ actionRegistry,
140
+ }: {
141
+ kindRegistry: EntityKindRegistry;
142
+ triggerRegistry: TriggerRegistry;
143
+ actionRegistry: ActionRegistry;
144
+ }): void {
145
+ kindRegistry.registerSpecSchemaDocumentationProvider(() =>
146
+ buildAutomationSpecSchemaDocumentation({
147
+ triggerRegistry,
148
+ actionRegistry,
149
+ }),
150
+ );
151
+ }