@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 +93 -0
- package/package.json +11 -9
- package/src/automations.test.ts +308 -0
- package/src/automations.ts +287 -0
- package/src/hooks.ts +34 -2
- package/src/index.ts +47 -0
- package/src/notifications.ts +51 -0
- package/tsconfig.json +3 -0
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.
|
|
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.
|
|
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.
|
|
21
|
-
"@checkstack/healthcheck-common": "1.
|
|
22
|
-
"@checkstack/healthcheck-backend": "1.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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:
|
|
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
|
{
|
package/src/notifications.ts
CHANGED
|
@@ -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(
|