@checkstack/dependency-backend 1.1.6 → 1.2.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 CHANGED
@@ -1,5 +1,98 @@
1
1
  # @checkstack/dependency-backend
2
2
 
3
+ ## 1.2.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 41c77f4: feat(automation): type enum-able trigger/artifact fields as enums for editor value autocompletion
8
+
9
+ The automation editor's staged completion offers concrete values after a
10
+ comparator (`{{ trigger.payload.severity == "high" }}`) only when the
11
+ field's JSON Schema carries an `enum`. Several trigger payload + artifact
12
+ schemas declared closed-set fields as loose `z.string()`, so no values
13
+ were suggested. Tightened them to the canonical enums that already
14
+ existed in each plugin's `-common` package (and matched the hook payload
15
+ types in lockstep so the trigger's `payloadSchema` and `hook` keep the
16
+ same `TPayload`):
17
+
18
+ - **incident** — trigger payloads: `severity` → `IncidentSeverityEnum`,
19
+ `status` / `statusChange` → `IncidentStatusEnum`.
20
+ - **healthcheck** — trigger payloads: `previousStatus` / `newStatus` /
21
+ `status` → `HealthCheckStatusSchema` (across systemDegraded,
22
+ systemHealthy, systemHealthChanged, checkFailed; plus checkCompleted's
23
+ hook type).
24
+ - **dependency** — trigger + artifact: `impactType` → `ImpactTypeSchema`;
25
+ impactPropagated `previousState` / `newState` → `DerivedStateSchema`.
26
+ Also deduped the inline `impactTypeSchema` action-config enum to reuse
27
+ the canonical `ImpactTypeSchema`.
28
+ - **maintenance** — trigger + artifact: `status` →
29
+ `MaintenanceStatusEnum`; deduped the inline `maintenanceStatusEnum`
30
+ (used by `add_update.statusChange`) to the canonical one.
31
+ - **slo** — `achievement.unlocked` trigger + hook: `achievement` →
32
+ `AchievementTypeSchema`.
33
+
34
+ Runtime behaviour is unchanged — these fields always carried valid enum
35
+ values (the underlying records are enum-constrained); only the schema
36
+ types were loose. The hook payload generics are now precise too, which
37
+ caught one stale test fixture asserting an invalid `impactType: "soft"`.
38
+
39
+ Fields that look enum-ish but are genuinely free-form were intentionally
40
+ left as `z.string()`: satellite `region` (user-entered), Jira issue
41
+ `status` (per-instance workflow name), notification `strategyQualifiedId`
42
+ / `errorMessage`, healthcheck collector `result`, and script
43
+ `stdout` / `stderr`.
44
+
45
+ - 41c77f4: feat(dependency): Phase 9 — triggers + create/remove actions for the Automation Platform
46
+
47
+ - Triggers `dependency.created`, `dependency.updated`, `dependency.deleted`,
48
+ each carrying `contextKey: (p) => p.dependencyId` so `wait_for_trigger`
49
+ resumes on the same edge.
50
+ - New hook `dependencyHooks.impactPropagated` + matching trigger
51
+ `dependency.impact_propagated` — fires once per upstream event from
52
+ `evaluateAndNotifyDownstream` with the list of downstream systems
53
+ whose derived state actually moved. Carries previous/new state for
54
+ each affected system so subscribers don't have to re-query the
55
+ graph. Fires regardless of notification suppression, so an
56
+ automation can react even when the user-facing notification is
57
+ skipped. `contextKey: (p) => p.sourceSystemId`.
58
+ - Actions `dependency.create` (with cycle + duplicate-edge detection
59
+ surfaced via the action's `error`) and `dependency.remove`. Both emit
60
+ the matching `dependencyHooks.*` so downstream automations and caches
61
+ react identically to RPC-driven changes.
62
+ - Artifact type `dependency.edge` for source/target/impact pass-through
63
+ between steps.
64
+
65
+ ### Patch Changes
66
+
67
+ - Updated dependencies [e2d6f25]
68
+ - Updated dependencies [41c77f4]
69
+ - Updated dependencies [41c77f4]
70
+ - Updated dependencies [e1a2077]
71
+ - Updated dependencies [41c77f4]
72
+ - Updated dependencies [41c77f4]
73
+ - Updated dependencies [41c77f4]
74
+ - Updated dependencies [41c77f4]
75
+ - Updated dependencies [41c77f4]
76
+ - Updated dependencies [41c77f4]
77
+ - Updated dependencies [41c77f4]
78
+ - Updated dependencies [6d52276]
79
+ - Updated dependencies [6d52276]
80
+ - Updated dependencies [35bc682]
81
+ - @checkstack/automation-backend@0.2.0
82
+ - @checkstack/healthcheck-backend@1.3.0
83
+ - @checkstack/catalog-backend@1.2.0
84
+ - @checkstack/common@0.12.0
85
+ - @checkstack/backend-api@0.18.0
86
+ - @checkstack/healthcheck-common@1.3.0
87
+ - @checkstack/catalog-common@2.2.3
88
+ - @checkstack/dependency-common@1.1.3
89
+ - @checkstack/incident-common@1.3.1
90
+ - @checkstack/maintenance-common@1.2.3
91
+ - @checkstack/gitops-backend@0.3.7
92
+ - @checkstack/gitops-common@0.4.2
93
+ - @checkstack/notification-common@1.2.1
94
+ - @checkstack/signal-common@0.2.5
95
+
3
96
  ## 1.1.6
4
97
 
5
98
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/dependency-backend",
3
- "version": "1.1.6",
3
+ "version": "1.2.0",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -11,20 +11,22 @@
11
11
  "typecheck": "tsgo -b",
12
12
  "generate": "drizzle-kit generate",
13
13
  "lint": "bun run lint:code",
14
- "lint:code": "eslint . --max-warnings 0"
14
+ "lint:code": "eslint . --max-warnings 0",
15
+ "test": "bun test"
15
16
  },
16
17
  "dependencies": {
17
- "@checkstack/backend-api": "0.17.0",
18
+ "@checkstack/backend-api": "0.17.1",
19
+ "@checkstack/automation-backend": "0.1.0",
18
20
  "@checkstack/dependency-common": "1.1.2",
19
21
  "@checkstack/catalog-common": "2.2.2",
20
- "@checkstack/catalog-backend": "1.1.5",
21
- "@checkstack/healthcheck-common": "1.1.2",
22
- "@checkstack/healthcheck-backend": "1.1.4",
22
+ "@checkstack/catalog-backend": "1.1.6",
23
+ "@checkstack/healthcheck-common": "1.2.0",
24
+ "@checkstack/healthcheck-backend": "1.2.0",
23
25
  "@checkstack/maintenance-common": "1.2.2",
24
- "@checkstack/incident-common": "1.2.2",
26
+ "@checkstack/incident-common": "1.3.0",
25
27
  "@checkstack/notification-common": "1.2.0",
26
28
  "@checkstack/signal-common": "0.2.4",
27
- "@checkstack/gitops-backend": "0.3.5",
29
+ "@checkstack/gitops-backend": "0.3.6",
28
30
  "@checkstack/gitops-common": "0.4.1",
29
31
  "@checkstack/common": "0.11.0",
30
32
  "drizzle-orm": "^0.45.0",
@@ -34,7 +36,7 @@
34
36
  "devDependencies": {
35
37
  "@checkstack/drizzle-helper": "0.0.5",
36
38
  "@checkstack/scripts": "0.3.3",
37
- "@checkstack/test-utils-backend": "0.1.29",
39
+ "@checkstack/test-utils-backend": "0.1.30",
38
40
  "@checkstack/tsconfig": "0.0.7",
39
41
  "@types/bun": "^1.0.0",
40
42
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Behaviour tests for the dependency automation triggers + actions.
3
+ */
4
+ import { describe, expect, it, mock } from "bun:test";
5
+ import type { Logger } from "@checkstack/backend-api";
6
+ import { createMockLogger } from "@checkstack/test-utils-backend";
7
+
8
+ import {
9
+ createDependencyActions,
10
+ dependencyArtifactType,
11
+ dependencyCreatedTrigger,
12
+ dependencyDeletedTrigger,
13
+ dependencyImpactPropagatedTrigger,
14
+ dependencyTriggers,
15
+ dependencyUpdatedTrigger,
16
+ } from "./automations";
17
+ import { dependencyHooks } from "./hooks";
18
+ import type { DependencyService } from "./services/dependency-service";
19
+
20
+ const logger = createMockLogger() as Logger;
21
+
22
+ const ctxBase = {
23
+ runId: "run-1",
24
+ automationId: "auto-1",
25
+ contextKey: null,
26
+ logger,
27
+ getService: async <T,>(): Promise<T> => {
28
+ throw new Error("not used");
29
+ },
30
+ };
31
+
32
+ describe("dependency triggers", () => {
33
+ it("exposes four triggers in a stable order", () => {
34
+ expect(dependencyTriggers).toHaveLength(4);
35
+ expect(dependencyTriggers[0]).toBe(
36
+ dependencyCreatedTrigger as (typeof dependencyTriggers)[number],
37
+ );
38
+ expect(dependencyTriggers[1]).toBe(
39
+ dependencyUpdatedTrigger as (typeof dependencyTriggers)[number],
40
+ );
41
+ expect(dependencyTriggers[2]).toBe(
42
+ dependencyDeletedTrigger as (typeof dependencyTriggers)[number],
43
+ );
44
+ expect(dependencyTriggers[3]).toBe(
45
+ dependencyImpactPropagatedTrigger as (typeof dependencyTriggers)[number],
46
+ );
47
+ });
48
+
49
+ it("extracts dependencyId as the contextKey on the edge-lifecycle triggers", () => {
50
+ const payload = {
51
+ dependencyId: "dep-1",
52
+ sourceSystemId: "sys-a",
53
+ targetSystemId: "sys-b",
54
+ impactType: "critical",
55
+ } as const;
56
+ expect(dependencyCreatedTrigger.contextKey?.(payload)).toBe("dep-1");
57
+ expect(dependencyUpdatedTrigger.contextKey?.(payload)).toBe("dep-1");
58
+ expect(dependencyDeletedTrigger.contextKey?.(payload)).toBe("dep-1");
59
+ });
60
+
61
+ it("extracts sourceSystemId as the contextKey on impactPropagated", () => {
62
+ const payload = {
63
+ sourceSystemId: "sys-upstream",
64
+ affectedSystems: [
65
+ { systemId: "sys-a", previousState: null, newState: "degraded" as const },
66
+ ],
67
+ timestamp: "2026-05-29T12:00:00Z",
68
+ };
69
+ expect(dependencyImpactPropagatedTrigger.contextKey?.(payload)).toBe(
70
+ "sys-upstream",
71
+ );
72
+ });
73
+
74
+ it("requires affectedSystems on the impactPropagated payload", () => {
75
+ const ok = dependencyImpactPropagatedTrigger.payloadSchema.safeParse({
76
+ sourceSystemId: "sys-1",
77
+ affectedSystems: [],
78
+ timestamp: "2026-05-29T12:00:00Z",
79
+ });
80
+ expect(ok.success).toBe(true);
81
+
82
+ const bad = dependencyImpactPropagatedTrigger.payloadSchema.safeParse({
83
+ sourceSystemId: "sys-1",
84
+ timestamp: "2026-05-29T12:00:00Z",
85
+ });
86
+ expect(bad.success).toBe(false);
87
+ });
88
+
89
+ it("rejects edge-lifecycle payloads missing required fields", () => {
90
+ const bad = dependencyCreatedTrigger.payloadSchema.safeParse({
91
+ dependencyId: "dep-1",
92
+ sourceSystemId: "sys-a",
93
+ });
94
+ expect(bad.success).toBe(false);
95
+ });
96
+ });
97
+
98
+ describe("dependencyArtifactType", () => {
99
+ it("validates the canonical edge shape", () => {
100
+ const ok = dependencyArtifactType.schema.safeParse({
101
+ dependencyId: "dep-1",
102
+ sourceSystemId: "sys-a",
103
+ targetSystemId: "sys-b",
104
+ impactType: "critical",
105
+ });
106
+ expect(ok.success).toBe(true);
107
+
108
+ // The artifact schema now uses the canonical ImpactType enum, so a
109
+ // value outside it is rejected.
110
+ const bad = dependencyArtifactType.schema.safeParse({
111
+ dependencyId: "dep-1",
112
+ sourceSystemId: "sys-a",
113
+ targetSystemId: "sys-b",
114
+ impactType: "soft",
115
+ });
116
+ expect(bad.success).toBe(false);
117
+ });
118
+ });
119
+
120
+ interface FakeDependencyRow {
121
+ id: string;
122
+ sourceSystemId: string;
123
+ targetSystemId: string;
124
+ impactType: string;
125
+ transitive: boolean;
126
+ label: string | null;
127
+ }
128
+
129
+ function makeService(args: {
130
+ createBehaviour?:
131
+ | { ok: true; row: FakeDependencyRow }
132
+ | { ok: false; error: string };
133
+ existingForRemove?: FakeDependencyRow;
134
+ deleteResult?: boolean;
135
+ }): DependencyService & {
136
+ createMock: ReturnType<typeof mock>;
137
+ deleteMock: ReturnType<typeof mock>;
138
+ getByIdMock: ReturnType<typeof mock>;
139
+ } {
140
+ const createMock = mock(async (input: unknown) => {
141
+ if (args.createBehaviour && !args.createBehaviour.ok) {
142
+ throw new Error(args.createBehaviour.error);
143
+ }
144
+ if (args.createBehaviour?.ok) return args.createBehaviour.row;
145
+ const i = input as { sourceSystemId: string; targetSystemId: string; impactType: string };
146
+ return {
147
+ id: "generated",
148
+ sourceSystemId: i.sourceSystemId,
149
+ targetSystemId: i.targetSystemId,
150
+ impactType: i.impactType,
151
+ transitive: false,
152
+ label: null,
153
+ };
154
+ });
155
+ const deleteMock = mock(async (_id: string) => args.deleteResult ?? true);
156
+ const getByIdMock = mock(async (_id: string) => args.existingForRemove);
157
+ return {
158
+ createDependency: createMock,
159
+ deleteDependency: deleteMock,
160
+ getDependencyById: getByIdMock,
161
+ createMock,
162
+ deleteMock,
163
+ getByIdMock,
164
+ } as unknown as DependencyService & {
165
+ createMock: ReturnType<typeof mock>;
166
+ deleteMock: ReturnType<typeof mock>;
167
+ getByIdMock: ReturnType<typeof mock>;
168
+ };
169
+ }
170
+
171
+ describe("dependency.create", () => {
172
+ it("creates an edge, fires dependencyCreated, and emits an artifact", async () => {
173
+ const service = makeService({
174
+ createBehaviour: {
175
+ ok: true,
176
+ row: {
177
+ id: "dep-1",
178
+ sourceSystemId: "sys-a",
179
+ targetSystemId: "sys-b",
180
+ impactType: "critical",
181
+ transitive: false,
182
+ label: null,
183
+ },
184
+ },
185
+ });
186
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
187
+ const [create] = createDependencyActions({ service, emitHook: emitHook as never });
188
+
189
+ const result = await create!.execute({
190
+ ...ctxBase,
191
+ consumedArtifacts: {},
192
+ config: {
193
+ sourceSystemId: "sys-a",
194
+ targetSystemId: "sys-b",
195
+ impactType: "critical",
196
+ transitive: false,
197
+ } as never,
198
+ });
199
+
200
+ expect(result.success).toBe(true);
201
+ if (!result.success) return;
202
+ expect(result.externalId).toBe("dep-1");
203
+ expect((result.artifact as { dependencyId: string }).dependencyId).toBe("dep-1");
204
+ expect(emitHook).toHaveBeenCalledTimes(1);
205
+ const emitCall = emitHook.mock.calls[0]!;
206
+ expect(emitCall[0]).toBe(dependencyHooks.dependencyCreated);
207
+ });
208
+
209
+ it("returns a failure when service.createDependency throws (e.g. cycle detected)", async () => {
210
+ const service = makeService({
211
+ createBehaviour: {
212
+ ok: false,
213
+ error: "Cannot create dependency: would form a circular chain: a → b → a",
214
+ },
215
+ });
216
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
217
+ const [create] = createDependencyActions({ service, emitHook: emitHook as never });
218
+
219
+ const result = await create!.execute({
220
+ ...ctxBase,
221
+ consumedArtifacts: {},
222
+ config: {
223
+ sourceSystemId: "a",
224
+ targetSystemId: "b",
225
+ impactType: "soft",
226
+ } as never,
227
+ });
228
+
229
+ expect(result.success).toBe(false);
230
+ if (result.success) return;
231
+ expect(result.error).toMatch(/circular chain/);
232
+ expect(emitHook).not.toHaveBeenCalled();
233
+ });
234
+ });
235
+
236
+ describe("dependency.remove", () => {
237
+ it("removes an edge, fires dependencyDeleted, and emits an artifact reflecting the removed edge", async () => {
238
+ const service = makeService({
239
+ existingForRemove: {
240
+ id: "dep-1",
241
+ sourceSystemId: "sys-a",
242
+ targetSystemId: "sys-b",
243
+ impactType: "critical",
244
+ transitive: false,
245
+ label: null,
246
+ },
247
+ deleteResult: true,
248
+ });
249
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
250
+ const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
251
+
252
+ const result = await remove!.execute({
253
+ ...ctxBase,
254
+ consumedArtifacts: {},
255
+ config: { dependencyId: "dep-1" } as never,
256
+ });
257
+
258
+ expect(result.success).toBe(true);
259
+ if (!result.success) return;
260
+ expect(result.externalId).toBe("dep-1");
261
+ expect(emitHook).toHaveBeenCalledTimes(1);
262
+ expect(emitHook.mock.calls[0]![0]).toBe(dependencyHooks.dependencyDeleted);
263
+ });
264
+
265
+ it("returns failure if the dependency does not exist", async () => {
266
+ const service = makeService({ existingForRemove: undefined });
267
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
268
+ const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
269
+
270
+ const result = await remove!.execute({
271
+ ...ctxBase,
272
+ consumedArtifacts: {},
273
+ config: { dependencyId: "missing" } as never,
274
+ });
275
+
276
+ expect(result.success).toBe(false);
277
+ if (result.success) return;
278
+ expect(result.error).toMatch(/not found/i);
279
+ expect(emitHook).not.toHaveBeenCalled();
280
+ });
281
+
282
+ it("returns failure if delete returns false (race-deleted)", async () => {
283
+ const service = makeService({
284
+ existingForRemove: {
285
+ id: "dep-1",
286
+ sourceSystemId: "sys-a",
287
+ targetSystemId: "sys-b",
288
+ impactType: "critical",
289
+ transitive: false,
290
+ label: null,
291
+ },
292
+ deleteResult: false,
293
+ });
294
+ const emitHook = mock(async (_hook: unknown, _payload: unknown) => {});
295
+ const [, remove] = createDependencyActions({ service, emitHook: emitHook as never });
296
+
297
+ const result = await remove!.execute({
298
+ ...ctxBase,
299
+ consumedArtifacts: {},
300
+ config: { dependencyId: "dep-1" } as never,
301
+ });
302
+
303
+ expect(result.success).toBe(false);
304
+ if (result.success) return;
305
+ expect(result.error).toMatch(/disappeared mid-delete/);
306
+ expect(emitHook).not.toHaveBeenCalled();
307
+ });
308
+ });
@@ -0,0 +1,287 @@
1
+ /**
2
+ * Dependency triggers + actions registered with the Automation Platform.
3
+ *
4
+ * Triggers expose the existing `dependencyHooks` as automation entry
5
+ * points (`dependency.created`, `dependency.updated`, `dependency.deleted`).
6
+ * Actions wrap `DependencyService.createDependency` / `deleteDependency`
7
+ * so operators can build / remove edges from automation flows.
8
+ *
9
+ * The plan also mentions a `dependency.impact_propagated` trigger.
10
+ * That event isn't emitted today — propagation runs synchronously
11
+ * inside `evaluateAndNotifyDownstream`. Adding a hook there is a
12
+ * separate change because it requires deciding what payload to pass
13
+ * (which downstream systems were re-evaluated, what their new status
14
+ * is, whether to deduplicate). Tracked as a follow-up in the plan;
15
+ * not included in this chunk.
16
+ *
17
+ * Mutation actions emit their hook themselves (via the `emitHook`
18
+ * factory dep) so downstream automations / caches react the same way
19
+ * they do when the mutation comes in via RPC.
20
+ */
21
+ import { z } from "zod";
22
+ import { Versioned, type Hook } from "@checkstack/backend-api";
23
+ import type {
24
+ ActionDefinition,
25
+ TriggerDefinition,
26
+ } from "@checkstack/automation-backend";
27
+ import { extractErrorMessage } from "@checkstack/common";
28
+ import {
29
+ DerivedStateSchema,
30
+ ImpactTypeSchema,
31
+ } from "@checkstack/dependency-common";
32
+
33
+ import { dependencyHooks } from "./hooks";
34
+ import type { DependencyService } from "./services/dependency-service";
35
+
36
+ // ─── Payload schemas — match the hook payloads exactly ─────────────────
37
+
38
+ const dependencyCreatedPayloadSchema = z.object({
39
+ dependencyId: z.string(),
40
+ sourceSystemId: z.string(),
41
+ targetSystemId: z.string(),
42
+ impactType: ImpactTypeSchema,
43
+ });
44
+
45
+ const dependencyUpdatedPayloadSchema = z.object({
46
+ dependencyId: z.string(),
47
+ sourceSystemId: z.string(),
48
+ targetSystemId: z.string(),
49
+ impactType: ImpactTypeSchema,
50
+ });
51
+
52
+ const dependencyDeletedPayloadSchema = z.object({
53
+ dependencyId: z.string(),
54
+ sourceSystemId: z.string(),
55
+ targetSystemId: z.string(),
56
+ });
57
+
58
+ const dependencyImpactPropagatedPayloadSchema = z.object({
59
+ sourceSystemId: z.string(),
60
+ affectedSystems: z.array(
61
+ z.object({
62
+ systemId: z.string(),
63
+ previousState: DerivedStateSchema.nullable(),
64
+ newState: DerivedStateSchema.nullable(),
65
+ }),
66
+ ),
67
+ timestamp: z.string(),
68
+ });
69
+
70
+ // ─── Triggers ──────────────────────────────────────────────────────────
71
+
72
+ export const dependencyCreatedTrigger: TriggerDefinition<
73
+ z.infer<typeof dependencyCreatedPayloadSchema>
74
+ > = {
75
+ id: "created",
76
+ displayName: "Dependency Created",
77
+ description: "Fires when a new dependency edge is added between two systems",
78
+ category: "Dependencies",
79
+ icon: "Network",
80
+ payloadSchema: dependencyCreatedPayloadSchema,
81
+ hook: dependencyHooks.dependencyCreated,
82
+ contextKey: (p) => p.dependencyId,
83
+ };
84
+
85
+ export const dependencyUpdatedTrigger: TriggerDefinition<
86
+ z.infer<typeof dependencyUpdatedPayloadSchema>
87
+ > = {
88
+ id: "updated",
89
+ displayName: "Dependency Updated",
90
+ description: "Fires when an existing dependency's impact-type or label changes",
91
+ category: "Dependencies",
92
+ icon: "Network",
93
+ payloadSchema: dependencyUpdatedPayloadSchema,
94
+ hook: dependencyHooks.dependencyUpdated,
95
+ contextKey: (p) => p.dependencyId,
96
+ };
97
+
98
+ export const dependencyDeletedTrigger: TriggerDefinition<
99
+ z.infer<typeof dependencyDeletedPayloadSchema>
100
+ > = {
101
+ id: "deleted",
102
+ displayName: "Dependency Deleted",
103
+ description: "Fires when a dependency edge is removed",
104
+ category: "Dependencies",
105
+ icon: "Network",
106
+ payloadSchema: dependencyDeletedPayloadSchema,
107
+ hook: dependencyHooks.dependencyDeleted,
108
+ contextKey: (p) => p.dependencyId,
109
+ };
110
+
111
+ export const dependencyImpactPropagatedTrigger: TriggerDefinition<
112
+ z.infer<typeof dependencyImpactPropagatedPayloadSchema>
113
+ > = {
114
+ id: "impact_propagated",
115
+ displayName: "Dependency Impact Propagated",
116
+ description:
117
+ "Fires once per upstream health change with the list of downstream systems whose derived state actually moved",
118
+ category: "Dependencies",
119
+ icon: "Network",
120
+ payloadSchema: dependencyImpactPropagatedPayloadSchema,
121
+ hook: dependencyHooks.impactPropagated,
122
+ contextKey: (p) => p.sourceSystemId,
123
+ };
124
+
125
+ export const dependencyTriggers: TriggerDefinition<unknown>[] = [
126
+ dependencyCreatedTrigger as TriggerDefinition<unknown>,
127
+ dependencyUpdatedTrigger as TriggerDefinition<unknown>,
128
+ dependencyDeletedTrigger as TriggerDefinition<unknown>,
129
+ dependencyImpactPropagatedTrigger as TriggerDefinition<unknown>,
130
+ ];
131
+
132
+ // ─── Action configs ────────────────────────────────────────────────────
133
+
134
+ const impactTypeSchema = ImpactTypeSchema.describe(
135
+ "How the target is affected when the source is affected — `critical` propagates the upstream's status (degraded → degraded, down → down), `degraded` always pulls the downstream to degraded, `informational` warns only.",
136
+ );
137
+
138
+ const dependencyCreateConfigSchema = z.object({
139
+ sourceSystemId: z.string().min(1).describe("Source (upstream) system id"),
140
+ targetSystemId: z.string().min(1).describe("Target (downstream) system id"),
141
+ impactType: impactTypeSchema,
142
+ transitive: z.boolean().optional().default(false),
143
+ label: z.string().optional(),
144
+ });
145
+
146
+ export type DependencyCreateConfig = z.infer<
147
+ typeof dependencyCreateConfigSchema
148
+ >;
149
+
150
+ const dependencyRemoveConfigSchema = z.object({
151
+ dependencyId: z.string().min(1).describe("Id of the dependency to remove"),
152
+ });
153
+
154
+ export type DependencyRemoveConfig = z.infer<
155
+ typeof dependencyRemoveConfigSchema
156
+ >;
157
+
158
+ // ─── Artifact type ─────────────────────────────────────────────────────
159
+
160
+ const dependencyArtifactSchema = z.object({
161
+ dependencyId: z.string(),
162
+ sourceSystemId: z.string(),
163
+ targetSystemId: z.string(),
164
+ impactType: ImpactTypeSchema,
165
+ });
166
+
167
+ export type DependencyArtifact = z.infer<typeof dependencyArtifactSchema>;
168
+
169
+ export const dependencyArtifactType = {
170
+ id: "edge",
171
+ displayName: "Dependency Edge",
172
+ description: "Source → target edge produced or removed by an automation",
173
+ schema: dependencyArtifactSchema,
174
+ } as const;
175
+
176
+ // ─── Actions ───────────────────────────────────────────────────────────
177
+
178
+ export interface DependencyActionDeps {
179
+ service: DependencyService;
180
+ emitHook: <T>(hook: Hook<T>, payload: T) => Promise<void>;
181
+ }
182
+
183
+ export function createDependencyActions(
184
+ deps: DependencyActionDeps,
185
+ ): ActionDefinition<unknown, unknown>[] {
186
+ const createAction: ActionDefinition<
187
+ DependencyCreateConfig,
188
+ DependencyArtifact
189
+ > = {
190
+ id: "create",
191
+ displayName: "Create Dependency",
192
+ description: "Add a dependency edge between two systems",
193
+ category: "Dependencies",
194
+ icon: "Network",
195
+ config: new Versioned({
196
+ version: 1,
197
+ schema: dependencyCreateConfigSchema,
198
+ }),
199
+ produces: "dependency.edge",
200
+ execute: async ({ config, logger }) => {
201
+ try {
202
+ const created = await deps.service.createDependency({
203
+ sourceSystemId: config.sourceSystemId,
204
+ targetSystemId: config.targetSystemId,
205
+ impactType: config.impactType,
206
+ transitive: config.transitive,
207
+ label: config.label,
208
+ healthCheckRules: [],
209
+ });
210
+ await deps.emitHook(dependencyHooks.dependencyCreated, {
211
+ dependencyId: created.id,
212
+ sourceSystemId: created.sourceSystemId,
213
+ targetSystemId: created.targetSystemId,
214
+ impactType: created.impactType,
215
+ });
216
+ logger.info(`Automation created dependency ${created.id}`);
217
+ return {
218
+ success: true,
219
+ externalId: created.id,
220
+ artifact: {
221
+ dependencyId: created.id,
222
+ sourceSystemId: created.sourceSystemId,
223
+ targetSystemId: created.targetSystemId,
224
+ impactType: created.impactType,
225
+ },
226
+ };
227
+ } catch (error) {
228
+ // Both duplicate-edge and cycle detection throw — surface the
229
+ // user-facing message so the run-detail UI shows the reason.
230
+ return { success: false, error: extractErrorMessage(error) };
231
+ }
232
+ },
233
+ };
234
+
235
+ const removeAction: ActionDefinition<
236
+ DependencyRemoveConfig,
237
+ DependencyArtifact
238
+ > = {
239
+ id: "remove",
240
+ displayName: "Remove Dependency",
241
+ description: "Delete a dependency edge by id",
242
+ category: "Dependencies",
243
+ icon: "Network",
244
+ config: new Versioned({
245
+ version: 1,
246
+ schema: dependencyRemoveConfigSchema,
247
+ }),
248
+ produces: "dependency.edge",
249
+ execute: async ({ config, logger }) => {
250
+ const existing = await deps.service.getDependencyById(config.dependencyId);
251
+ if (!existing) {
252
+ return {
253
+ success: false,
254
+ error: `Dependency not found: ${config.dependencyId}`,
255
+ };
256
+ }
257
+ const removed = await deps.service.deleteDependency(config.dependencyId);
258
+ if (!removed) {
259
+ return {
260
+ success: false,
261
+ error: `Dependency ${config.dependencyId} disappeared mid-delete`,
262
+ };
263
+ }
264
+ await deps.emitHook(dependencyHooks.dependencyDeleted, {
265
+ dependencyId: existing.id,
266
+ sourceSystemId: existing.sourceSystemId,
267
+ targetSystemId: existing.targetSystemId,
268
+ });
269
+ logger.info(`Automation removed dependency ${existing.id}`);
270
+ return {
271
+ success: true,
272
+ externalId: existing.id,
273
+ artifact: {
274
+ dependencyId: existing.id,
275
+ sourceSystemId: existing.sourceSystemId,
276
+ targetSystemId: existing.targetSystemId,
277
+ impactType: existing.impactType,
278
+ },
279
+ };
280
+ },
281
+ };
282
+
283
+ return [
284
+ createAction as ActionDefinition<unknown, unknown>,
285
+ removeAction as ActionDefinition<unknown, unknown>,
286
+ ];
287
+ }
package/src/hooks.ts CHANGED
@@ -1,8 +1,17 @@
1
1
  import { createHook } from "@checkstack/backend-api";
2
+ import type {
3
+ DerivedState,
4
+ ImpactType,
5
+ } from "@checkstack/dependency-common";
2
6
 
3
7
  /**
4
8
  * Dependency hooks for cross-plugin communication.
5
9
  * Other plugins can subscribe to these hooks to react to dependency changes.
10
+ *
11
+ * `impactType` and the derived-state fields carry the canonical
12
+ * `ImpactType` / `DerivedState` enum values, so automation triggers
13
+ * built on these hooks can offer the known values for `==` comparisons
14
+ * in the editor.
6
15
  */
7
16
  export const dependencyHooks = {
8
17
  /**
@@ -12,7 +21,7 @@ export const dependencyHooks = {
12
21
  dependencyId: string;
13
22
  sourceSystemId: string;
14
23
  targetSystemId: string;
15
- impactType: string;
24
+ impactType: ImpactType;
16
25
  }>("dependency.created"),
17
26
 
18
27
  /**
@@ -22,7 +31,7 @@ export const dependencyHooks = {
22
31
  dependencyId: string;
23
32
  sourceSystemId: string;
24
33
  targetSystemId: string;
25
- impactType: string;
34
+ impactType: ImpactType;
26
35
  }>("dependency.updated"),
27
36
 
28
37
  /**
@@ -33,4 +42,27 @@ export const dependencyHooks = {
33
42
  sourceSystemId: string;
34
43
  targetSystemId: string;
35
44
  }>("dependency.deleted"),
45
+
46
+ /**
47
+ * Emitted when an upstream system's state change has propagated
48
+ * through the dependency graph and produced derived-state changes
49
+ * on one or more downstream systems. Carries the list of affected
50
+ * downstream systems with their previous and new derived states so
51
+ * subscribers don't have to re-query the graph.
52
+ *
53
+ * Fires only when at least one downstream state actually changed —
54
+ * upstream events that don't move any downstream's derived state
55
+ * are silent. Emitted from `evaluateAndNotifyDownstream` once per
56
+ * upstream event (deduplicated by `sourceSystemId`, not by
57
+ * downstream).
58
+ */
59
+ impactPropagated: createHook<{
60
+ sourceSystemId: string;
61
+ affectedSystems: Array<{
62
+ systemId: string;
63
+ previousState: DerivedState | null;
64
+ newState: DerivedState | null;
65
+ }>;
66
+ timestamp: string;
67
+ }>("dependency.impact_propagated"),
36
68
  } as const;
package/src/index.ts CHANGED
@@ -8,10 +8,21 @@ import {
8
8
  dependencyGroupSubscription,
9
9
  } from "@checkstack/dependency-common";
10
10
  import { createBackendPlugin, coreServices } from "@checkstack/backend-api";
11
+ import {
12
+ automationActionExtensionPoint,
13
+ automationArtifactTypeExtensionPoint,
14
+ automationTriggerExtensionPoint,
15
+ } from "@checkstack/automation-backend";
11
16
  import { DependencyService } from "./services/dependency-service";
12
17
  import { WarningEvaluationService } from "./services/warning-evaluation-service";
13
18
  import type { SystemStatus } from "./services/warning-evaluation-service";
14
19
  import { createRouter } from "./router";
20
+ import {
21
+ createDependencyActions,
22
+ dependencyArtifactType,
23
+ dependencyTriggers,
24
+ } from "./automations";
25
+ import { dependencyHooks } from "./hooks";
15
26
  import { CatalogApi } from "@checkstack/catalog-common";
16
27
  import { HealthCheckApi } from "@checkstack/healthcheck-common";
17
28
  import { MaintenanceApi } from "@checkstack/maintenance-common";
@@ -36,6 +47,17 @@ export default createBackendPlugin({
36
47
  dependencyGroupSubscription,
37
48
  ]);
38
49
 
50
+ // ─── Automation Platform: triggers + artifact type ─────────────────
51
+ const automationTriggers = env.getExtensionPoint(
52
+ automationTriggerExtensionPoint,
53
+ );
54
+ for (const trigger of dependencyTriggers) {
55
+ automationTriggers.registerTrigger(trigger, pluginMetadata);
56
+ }
57
+ env
58
+ .getExtensionPoint(automationArtifactTypeExtensionPoint)
59
+ .registerArtifactType(dependencyArtifactType, pluginMetadata);
60
+
39
61
  // ─── GitOps Entity Kind Registration ─────────────────────────────
40
62
  let gitopsService: DependencyService | undefined;
41
63
  const kindRegistry = env.getExtensionPoint(entityKindExtensionPoint);
@@ -85,12 +107,35 @@ export default createBackendPlugin({
85
107
  rpcClient,
86
108
  logger,
87
109
  onHook,
110
+ emitHook,
88
111
  signalService,
89
112
  }) => {
113
+ // Bound callback that fires `dependency.impact_propagated`
114
+ // when `evaluateAndNotifyDownstream` reports actual downstream
115
+ // state transitions. Local so we don't pass the full
116
+ // `emitHook` into helper modules that should only know about
117
+ // the one hook they fire.
118
+ const emitImpactPropagated = (event: {
119
+ sourceSystemId: string;
120
+ affectedSystems: Array<{
121
+ systemId: string;
122
+ previousState: string | null;
123
+ newState: string | null;
124
+ }>;
125
+ timestamp: string;
126
+ }) => emitHook(dependencyHooks.impactPropagated, event);
90
127
  const typedDb = database as SafeDatabase<typeof schema>;
91
128
  const service = new DependencyService(typedDb);
92
129
  const warningService = new WarningEvaluationService();
93
130
 
131
+ // Register automation actions now that `emitHook` is available.
132
+ const automationActions = env.getExtensionPoint(
133
+ automationActionExtensionPoint,
134
+ );
135
+ for (const action of createDependencyActions({ service, emitHook })) {
136
+ automationActions.registerAction(action, pluginMetadata);
137
+ }
138
+
94
139
  const catalogClient = rpcClient.forPlugin(CatalogApi);
95
140
  const healthCheckClient = rpcClient.forPlugin(HealthCheckApi);
96
141
  const maintenanceClient = rpcClient.forPlugin(MaintenanceApi);
@@ -189,6 +234,7 @@ export default createBackendPlugin({
189
234
  incidentClient,
190
235
  signalService,
191
236
  logger,
237
+ emitImpactPropagated,
192
238
  });
193
239
  },
194
240
  {
@@ -215,6 +261,7 @@ export default createBackendPlugin({
215
261
  incidentClient,
216
262
  signalService,
217
263
  logger,
264
+ emitImpactPropagated,
218
265
  });
219
266
  },
220
267
  {
@@ -190,6 +190,7 @@ export async function evaluateAndNotifyDownstream({
190
190
  incidentClient,
191
191
  signalService,
192
192
  logger,
193
+ emitImpactPropagated,
193
194
  }: {
194
195
  changedSystemId: string;
195
196
  db: Db;
@@ -204,6 +205,21 @@ export async function evaluateAndNotifyDownstream({
204
205
  incidentClient: InferClient<typeof IncidentApi>;
205
206
  signalService: SignalService;
206
207
  logger: Logger;
208
+ /**
209
+ * Optional callback fired when at least one downstream system's
210
+ * derived state actually changed. Wired in `afterPluginsReady` to
211
+ * emit `dependencyHooks.impactPropagated`; left undefined in tests
212
+ * + stripped-down harnesses to keep them allocation-free.
213
+ */
214
+ emitImpactPropagated?: (event: {
215
+ sourceSystemId: string;
216
+ affectedSystems: Array<{
217
+ systemId: string;
218
+ previousState: string | null;
219
+ newState: string | null;
220
+ }>;
221
+ timestamp: string;
222
+ }) => Promise<void>;
207
223
  }): Promise<void> {
208
224
  try {
209
225
  // 1. Find all downstream systems that depend on the changed system
@@ -309,6 +325,11 @@ export async function evaluateAndNotifyDownstream({
309
325
  // 6. Evaluate state changes and collect systems that need notification
310
326
  const changedSystemIds: string[] = [];
311
327
  const systemsToNotify: SystemNotificationEntry[] = [];
328
+ const impactPropagatedAffected: Array<{
329
+ systemId: string;
330
+ previousState: string | null;
331
+ newState: string | null;
332
+ }> = [];
312
333
 
313
334
  for (const systemId of downstreamIds) {
314
335
  const currentWarning = warningMap.get(systemId);
@@ -320,6 +341,16 @@ export async function evaluateAndNotifyDownstream({
320
341
  continue;
321
342
  }
322
343
 
344
+ // Always record every derived-state transition for the
345
+ // automation hook — including ones suppressed for end-user
346
+ // notifications, since an operator may want to react to
347
+ // propagation regardless of suppression.
348
+ impactPropagatedAffected.push({
349
+ systemId,
350
+ previousState: previousState ?? null,
351
+ newState: currentState ?? null,
352
+ });
353
+
323
354
  // State changed — update DB first
324
355
  await (currentState
325
356
  ? db
@@ -389,6 +420,26 @@ export async function evaluateAndNotifyDownstream({
389
420
  affectedSystemIds: changedSystemIds,
390
421
  });
391
422
  }
423
+
424
+ // 9. Fire the `dependency.impact_propagated` automation hook
425
+ // exactly once per upstream event, with the full set of
426
+ // downstream state transitions. Best-effort — failures are
427
+ // logged but never propagated up so a misbehaving subscriber
428
+ // can't disrupt notifications or signal broadcasts.
429
+ if (emitImpactPropagated && impactPropagatedAffected.length > 0) {
430
+ try {
431
+ await emitImpactPropagated({
432
+ sourceSystemId: changedSystemId,
433
+ affectedSystems: impactPropagatedAffected,
434
+ timestamp: new Date().toISOString(),
435
+ });
436
+ } catch (error) {
437
+ logger.error(
438
+ `Failed to emit dependency.impact_propagated hook for upstream ${changedSystemId}:`,
439
+ error,
440
+ );
441
+ }
442
+ }
392
443
  } catch (error) {
393
444
  // Don't crash the hook handler
394
445
  logger.error(
package/tsconfig.json CHANGED
@@ -4,6 +4,9 @@
4
4
  "src"
5
5
  ],
6
6
  "references": [
7
+ {
8
+ "path": "../automation-backend"
9
+ },
7
10
  {
8
11
  "path": "../backend-api"
9
12
  },