@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.
- package/CHANGELOG.md +544 -0
- package/drizzle/0003_sparkling_xorn.sql +17 -0
- package/drizzle/0004_cultured_spyke.sql +2 -0
- package/drizzle/0005_classy_the_hand.sql +19 -0
- package/drizzle/0006_burly_wallop.sql +10 -0
- package/drizzle/0007_nappy_jackal.sql +1 -0
- package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
- package/drizzle/0009_steady_liz_osborn.sql +12 -0
- package/drizzle/0010_chunky_changeling.sql +2 -0
- package/drizzle/meta/0003_snapshot.json +1007 -0
- package/drizzle/meta/0004_snapshot.json +1028 -0
- package/drizzle/meta/0005_snapshot.json +1164 -0
- package/drizzle/meta/0006_snapshot.json +1261 -0
- package/drizzle/meta/0007_snapshot.json +1215 -0
- package/drizzle/meta/0008_snapshot.json +1215 -0
- package/drizzle/meta/0009_snapshot.json +1328 -0
- package/drizzle/meta/0010_snapshot.json +1349 -0
- package/drizzle/meta/_journal.json +56 -0
- package/package.json +23 -12
- package/src/action-types.ts +23 -0
- package/src/artifact-store.ts +16 -1
- package/src/automation-store.test.ts +143 -0
- package/src/automation-store.ts +30 -8
- package/src/builtin-triggers.test.ts +77 -74
- package/src/builtin-triggers.ts +105 -108
- package/src/dispatch/action-kind.ts +2 -0
- package/src/dispatch/assemble-get-service.ts +31 -0
- package/src/dispatch/cancel-resurrect.test.ts +147 -0
- package/src/dispatch/concurrency-race.test.ts +255 -0
- package/src/dispatch/concurrency-scope.test.ts +166 -0
- package/src/dispatch/condition.ts +24 -5
- package/src/dispatch/dwell-queue.ts +65 -0
- package/src/dispatch/dwell-store.ts +154 -0
- package/src/dispatch/dwell.it.test.ts +142 -0
- package/src/dispatch/dwell.test.ts +799 -0
- package/src/dispatch/dwell.ts +257 -0
- package/src/dispatch/engine.test.ts +189 -2
- package/src/dispatch/engine.ts +555 -9
- package/src/dispatch/entity-scope.test.ts +176 -0
- package/src/dispatch/get-service-wiring.test.ts +318 -0
- package/src/dispatch/numeric.test.ts +71 -0
- package/src/dispatch/numeric.ts +96 -0
- package/src/dispatch/render.test.ts +34 -0
- package/src/dispatch/render.ts +31 -11
- package/src/dispatch/reseed-run-secrets.ts +230 -0
- package/src/dispatch/run-secret-registry.test.ts +189 -0
- package/src/dispatch/run-secret-registry.ts +247 -0
- package/src/dispatch/run-state-masking.test.ts +376 -0
- package/src/dispatch/run-state-store.ts +95 -38
- package/src/dispatch/run-state.ts +226 -59
- package/src/dispatch/scope-artifact-masking.test.ts +138 -0
- package/src/dispatch/secret-ref-ids.test.ts +19 -0
- package/src/dispatch/secret-ref-ids.ts +17 -0
- package/src/dispatch/snapshots.test.ts +86 -0
- package/src/dispatch/snapshots.ts +79 -0
- package/src/dispatch/stage1-router.test.ts +324 -0
- package/src/dispatch/stage1-router.ts +152 -0
- package/src/dispatch/stage1.it.test.ts +84 -0
- package/src/dispatch/stage2-dispatch.test.ts +285 -0
- package/src/dispatch/stage2-dispatch.ts +207 -0
- package/src/dispatch/stage2-stalled.it.test.ts +132 -0
- package/src/dispatch/stalled-sweeper.test.ts +197 -0
- package/src/dispatch/stalled-sweeper.ts +112 -5
- package/src/dispatch/state-scope.test.ts +234 -0
- package/src/dispatch/state-scope.ts +322 -0
- package/src/dispatch/structured-conditions.test.ts +246 -0
- package/src/dispatch/structured-conditions.ts +146 -0
- package/src/dispatch/test-fixtures.ts +306 -38
- package/src/dispatch/trigger-fanin.test.ts +111 -0
- package/src/dispatch/trigger-subscriber.ts +316 -14
- package/src/dispatch/types.ts +263 -8
- package/src/dispatch/wait-timeout-queue.ts +89 -0
- package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
- package/src/dispatch/wait-until.test.ts +540 -0
- package/src/dispatch/wake-refs.test.ts +158 -0
- package/src/dispatch/wake-refs.ts +348 -0
- package/src/dispatch/window-gate.test.ts +513 -0
- package/src/dispatch/window-store.test.ts +162 -0
- package/src/dispatch/window-store.ts +102 -0
- package/src/entity/change-derivers.test.ts +148 -0
- package/src/entity/change-derivers.ts +143 -0
- package/src/entity/change-emitter.test.ts +66 -0
- package/src/entity/change-emitter.ts +76 -0
- package/src/entity/create-handle.ts +344 -0
- package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
- package/src/entity/define-entity.ts +157 -0
- package/src/entity/diff.test.ts +57 -0
- package/src/entity/diff.ts +54 -0
- package/src/entity/entity-store.test.ts +30 -0
- package/src/entity/entity-store.ts +171 -0
- package/src/entity/extension-point.ts +56 -0
- package/src/entity/fake-entity-store.ts +130 -0
- package/src/entity/hook.ts +19 -0
- package/src/entity/index.ts +50 -0
- package/src/entity/mutate-handle.test.ts +517 -0
- package/src/entity/on-entity-changed.test.ts +189 -0
- package/src/entity/on-entity-changed.ts +214 -0
- package/src/entity/registry.test.ts +181 -0
- package/src/entity/registry.ts +200 -0
- package/src/entity/stable-stringify.test.ts +55 -0
- package/src/entity/stable-stringify.ts +49 -0
- package/src/entity/wake-index.it.test.ts +251 -0
- package/src/entity/with-entity-write.test.ts +100 -0
- package/src/entity/with-entity-write.ts +69 -0
- package/src/entity-driven-trigger.ts +46 -0
- package/src/extension-points.ts +35 -0
- package/src/gitops-docs.test.ts +215 -0
- package/src/gitops-docs.ts +151 -0
- package/src/gitops-kinds.test.ts +174 -0
- package/src/gitops-kinds.ts +137 -0
- package/src/index.ts +355 -11
- package/src/migration/flapping-to-window.test.ts +123 -0
- package/src/migration/flapping-to-window.ts +205 -0
- package/src/router.test.ts +182 -1
- package/src/router.ts +73 -2
- package/src/schema.ts +236 -3
- package/src/script-test-replay.test.ts +88 -0
- package/src/script-test-replay.ts +100 -0
- package/src/script-test-shell-env.test.ts +41 -0
- package/src/script-test-shell-env.ts +89 -0
- package/src/script-test.test.ts +386 -0
- package/src/script-test.ts +258 -0
- package/src/trigger-registry.ts +2 -0
- package/src/validate-definition.test.ts +1 -0
- 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
|
+
}
|
package/src/extension-points.ts
CHANGED
|
@@ -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
|
+
}
|