@checkstack/automation-backend 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +544 -0
  2. package/drizzle/0003_sparkling_xorn.sql +17 -0
  3. package/drizzle/0004_cultured_spyke.sql +2 -0
  4. package/drizzle/0005_classy_the_hand.sql +19 -0
  5. package/drizzle/0006_burly_wallop.sql +10 -0
  6. package/drizzle/0007_nappy_jackal.sql +1 -0
  7. package/drizzle/0008_remove_seeded_auto_incident_automations.sql +13 -0
  8. package/drizzle/0009_steady_liz_osborn.sql +12 -0
  9. package/drizzle/0010_chunky_changeling.sql +2 -0
  10. package/drizzle/meta/0003_snapshot.json +1007 -0
  11. package/drizzle/meta/0004_snapshot.json +1028 -0
  12. package/drizzle/meta/0005_snapshot.json +1164 -0
  13. package/drizzle/meta/0006_snapshot.json +1261 -0
  14. package/drizzle/meta/0007_snapshot.json +1215 -0
  15. package/drizzle/meta/0008_snapshot.json +1215 -0
  16. package/drizzle/meta/0009_snapshot.json +1328 -0
  17. package/drizzle/meta/0010_snapshot.json +1349 -0
  18. package/drizzle/meta/_journal.json +56 -0
  19. package/package.json +23 -12
  20. package/src/action-types.ts +23 -0
  21. package/src/artifact-store.ts +16 -1
  22. package/src/automation-store.test.ts +143 -0
  23. package/src/automation-store.ts +30 -8
  24. package/src/builtin-triggers.test.ts +77 -74
  25. package/src/builtin-triggers.ts +105 -108
  26. package/src/dispatch/action-kind.ts +2 -0
  27. package/src/dispatch/assemble-get-service.ts +31 -0
  28. package/src/dispatch/cancel-resurrect.test.ts +147 -0
  29. package/src/dispatch/concurrency-race.test.ts +255 -0
  30. package/src/dispatch/concurrency-scope.test.ts +166 -0
  31. package/src/dispatch/condition.ts +24 -5
  32. package/src/dispatch/dwell-queue.ts +65 -0
  33. package/src/dispatch/dwell-store.ts +154 -0
  34. package/src/dispatch/dwell.it.test.ts +142 -0
  35. package/src/dispatch/dwell.test.ts +799 -0
  36. package/src/dispatch/dwell.ts +257 -0
  37. package/src/dispatch/engine.test.ts +189 -2
  38. package/src/dispatch/engine.ts +555 -9
  39. package/src/dispatch/entity-scope.test.ts +176 -0
  40. package/src/dispatch/get-service-wiring.test.ts +318 -0
  41. package/src/dispatch/numeric.test.ts +71 -0
  42. package/src/dispatch/numeric.ts +96 -0
  43. package/src/dispatch/render.test.ts +34 -0
  44. package/src/dispatch/render.ts +31 -11
  45. package/src/dispatch/reseed-run-secrets.ts +230 -0
  46. package/src/dispatch/run-secret-registry.test.ts +189 -0
  47. package/src/dispatch/run-secret-registry.ts +247 -0
  48. package/src/dispatch/run-state-masking.test.ts +376 -0
  49. package/src/dispatch/run-state-store.ts +95 -38
  50. package/src/dispatch/run-state.ts +226 -59
  51. package/src/dispatch/scope-artifact-masking.test.ts +138 -0
  52. package/src/dispatch/secret-ref-ids.test.ts +19 -0
  53. package/src/dispatch/secret-ref-ids.ts +17 -0
  54. package/src/dispatch/snapshots.test.ts +86 -0
  55. package/src/dispatch/snapshots.ts +79 -0
  56. package/src/dispatch/stage1-router.test.ts +324 -0
  57. package/src/dispatch/stage1-router.ts +152 -0
  58. package/src/dispatch/stage1.it.test.ts +84 -0
  59. package/src/dispatch/stage2-dispatch.test.ts +285 -0
  60. package/src/dispatch/stage2-dispatch.ts +207 -0
  61. package/src/dispatch/stage2-stalled.it.test.ts +132 -0
  62. package/src/dispatch/stalled-sweeper.test.ts +197 -0
  63. package/src/dispatch/stalled-sweeper.ts +112 -5
  64. package/src/dispatch/state-scope.test.ts +234 -0
  65. package/src/dispatch/state-scope.ts +322 -0
  66. package/src/dispatch/structured-conditions.test.ts +246 -0
  67. package/src/dispatch/structured-conditions.ts +146 -0
  68. package/src/dispatch/test-fixtures.ts +306 -38
  69. package/src/dispatch/trigger-fanin.test.ts +111 -0
  70. package/src/dispatch/trigger-subscriber.ts +316 -14
  71. package/src/dispatch/types.ts +263 -8
  72. package/src/dispatch/wait-timeout-queue.ts +89 -0
  73. package/src/dispatch/wait-until-entity-wake.test.ts +544 -0
  74. package/src/dispatch/wait-until.test.ts +540 -0
  75. package/src/dispatch/wake-refs.test.ts +158 -0
  76. package/src/dispatch/wake-refs.ts +348 -0
  77. package/src/dispatch/window-gate.test.ts +513 -0
  78. package/src/dispatch/window-store.test.ts +162 -0
  79. package/src/dispatch/window-store.ts +102 -0
  80. package/src/entity/change-derivers.test.ts +148 -0
  81. package/src/entity/change-derivers.ts +143 -0
  82. package/src/entity/change-emitter.test.ts +66 -0
  83. package/src/entity/change-emitter.ts +76 -0
  84. package/src/entity/create-handle.ts +344 -0
  85. package/src/entity/cross-pod-read-consistency.it.test.ts +281 -0
  86. package/src/entity/define-entity.ts +157 -0
  87. package/src/entity/diff.test.ts +57 -0
  88. package/src/entity/diff.ts +54 -0
  89. package/src/entity/entity-store.test.ts +30 -0
  90. package/src/entity/entity-store.ts +171 -0
  91. package/src/entity/extension-point.ts +56 -0
  92. package/src/entity/fake-entity-store.ts +130 -0
  93. package/src/entity/hook.ts +19 -0
  94. package/src/entity/index.ts +50 -0
  95. package/src/entity/mutate-handle.test.ts +517 -0
  96. package/src/entity/on-entity-changed.test.ts +189 -0
  97. package/src/entity/on-entity-changed.ts +214 -0
  98. package/src/entity/registry.test.ts +181 -0
  99. package/src/entity/registry.ts +200 -0
  100. package/src/entity/stable-stringify.test.ts +55 -0
  101. package/src/entity/stable-stringify.ts +49 -0
  102. package/src/entity/wake-index.it.test.ts +251 -0
  103. package/src/entity/with-entity-write.test.ts +100 -0
  104. package/src/entity/with-entity-write.ts +69 -0
  105. package/src/entity-driven-trigger.ts +46 -0
  106. package/src/extension-points.ts +35 -0
  107. package/src/gitops-docs.test.ts +215 -0
  108. package/src/gitops-docs.ts +151 -0
  109. package/src/gitops-kinds.test.ts +174 -0
  110. package/src/gitops-kinds.ts +137 -0
  111. package/src/index.ts +355 -11
  112. package/src/migration/flapping-to-window.test.ts +123 -0
  113. package/src/migration/flapping-to-window.ts +205 -0
  114. package/src/router.test.ts +182 -1
  115. package/src/router.ts +73 -2
  116. package/src/schema.ts +236 -3
  117. package/src/script-test-replay.test.ts +88 -0
  118. package/src/script-test-replay.ts +100 -0
  119. package/src/script-test-shell-env.test.ts +41 -0
  120. package/src/script-test-shell-env.ts +89 -0
  121. package/src/script-test.test.ts +386 -0
  122. package/src/script-test.ts +258 -0
  123. package/src/trigger-registry.ts +2 -0
  124. package/src/validate-definition.test.ts +1 -0
  125. package/tsconfig.json +24 -0
@@ -0,0 +1,174 @@
1
+ import { describe, it, expect, mock } from "bun:test";
2
+ import {
3
+ deleteAutomationEntity,
4
+ GITOPS_MANAGED_BY,
5
+ reconcileAutomation,
6
+ } from "./gitops-kinds";
7
+
8
+ const logger = { info: () => {} };
9
+
10
+ const spec = {
11
+ name: "Notify on incident",
12
+ triggers: [{ event: "incident.created" }],
13
+ conditions: [],
14
+ actions: [{ action: "automation.log", config: { message: "hi" } }],
15
+ mode: "single",
16
+ max_runs: 10,
17
+ };
18
+
19
+ function insertMockDb(newId: string) {
20
+ const values = mock((_input: Record<string, unknown>) => ({
21
+ returning: mock(() => Promise.resolve([{ id: newId }])),
22
+ }));
23
+ return { db: { insert: mock(() => ({ values })) }, values };
24
+ }
25
+
26
+ function updateMockDb(rows: Array<{ id: string }>) {
27
+ const set = mock((_input: Record<string, unknown>) => ({
28
+ where: mock(() => ({ returning: mock(() => Promise.resolve(rows)) })),
29
+ }));
30
+ return { db: { update: mock(() => ({ set })) }, set };
31
+ }
32
+
33
+ describe("reconcileAutomation", () => {
34
+ it("creates a new automation on first reconcile, tagging managed_by=gitops", async () => {
35
+ const { db, values } = insertMockDb("auto-1");
36
+ const result = await reconcileAutomation(db as never, {
37
+ entity: { metadata: { name: "notify" }, spec: spec as never },
38
+ logger,
39
+ });
40
+ expect(result.entityId).toBe("auto-1");
41
+ const inserted = values.mock.calls[0]?.[0] as unknown as {
42
+ managedBy: string;
43
+ name: string;
44
+ status: string;
45
+ };
46
+ expect(inserted.managedBy).toBe(GITOPS_MANAGED_BY);
47
+ expect(inserted.status).toBe("enabled");
48
+ });
49
+
50
+ it("uses metadata.title for the name when present", async () => {
51
+ const { db, values } = insertMockDb("auto-1");
52
+ await reconcileAutomation(db as never, {
53
+ entity: {
54
+ metadata: { name: "notify", title: "Notify on incident" },
55
+ spec: spec as never,
56
+ },
57
+ logger,
58
+ });
59
+ const inserted = values.mock.calls[0]?.[0] as unknown as { name: string };
60
+ expect(inserted.name).toBe("Notify on incident");
61
+ });
62
+
63
+ it("updates in place when an existingEntityId is given", async () => {
64
+ const { db, set } = updateMockDb([{ id: "auto-1" }]);
65
+ const result = await reconcileAutomation(db as never, {
66
+ entity: { metadata: { name: "notify" }, spec: spec as never },
67
+ existingEntityId: "auto-1",
68
+ logger,
69
+ });
70
+ expect(result.entityId).toBe("auto-1");
71
+ const updated = set.mock.calls[0]?.[0] as unknown as { managedBy: string };
72
+ expect(updated.managedBy).toBe(GITOPS_MANAGED_BY);
73
+ });
74
+
75
+ it("falls back to insert when the existing row has vanished", async () => {
76
+ // update returns no rows → re-create
77
+ const set = mock(() => ({
78
+ where: mock(() => ({ returning: mock(() => Promise.resolve([])) })),
79
+ }));
80
+ const values = mock(() => ({
81
+ returning: mock(() => Promise.resolve([{ id: "auto-new" }])),
82
+ }));
83
+ const db = {
84
+ update: mock(() => ({ set })),
85
+ insert: mock(() => ({ values })),
86
+ };
87
+ const result = await reconcileAutomation(db as never, {
88
+ entity: { metadata: { name: "notify" }, spec: spec as never },
89
+ existingEntityId: "gone",
90
+ logger,
91
+ });
92
+ expect(result.entityId).toBe("auto-new");
93
+ expect(db.insert).toHaveBeenCalledTimes(1);
94
+ });
95
+
96
+ it("carries the group from metadata.labels.group on insert", async () => {
97
+ const { db, values } = insertMockDb("auto-1");
98
+ await reconcileAutomation(db as never, {
99
+ entity: {
100
+ metadata: { name: "notify", labels: { group: "Alerting" } },
101
+ spec: spec as never,
102
+ },
103
+ logger,
104
+ });
105
+ const inserted = values.mock.calls[0]?.[0] as unknown as { group: string };
106
+ expect(inserted.group).toBe("Alerting");
107
+ });
108
+
109
+ it("carries the group from metadata.labels.group on update", async () => {
110
+ const { db, set } = updateMockDb([{ id: "auto-1" }]);
111
+ await reconcileAutomation(db as never, {
112
+ entity: {
113
+ metadata: { name: "notify", labels: { group: "Networking" } },
114
+ spec: spec as never,
115
+ },
116
+ existingEntityId: "auto-1",
117
+ logger,
118
+ });
119
+ const updated = set.mock.calls[0]?.[0] as unknown as { group: string };
120
+ expect(updated.group).toBe("Networking");
121
+ });
122
+
123
+ it("sets group to null when the label is absent or blank", async () => {
124
+ const { db, values } = insertMockDb("auto-1");
125
+ await reconcileAutomation(db as never, {
126
+ entity: {
127
+ metadata: { name: "notify", labels: { group: " " } },
128
+ spec: spec as never,
129
+ },
130
+ logger,
131
+ });
132
+ const inserted = values.mock.calls[0]?.[0] as unknown as {
133
+ group: string | null;
134
+ };
135
+ expect(inserted.group).toBeNull();
136
+ });
137
+
138
+ it("fills schema defaults (mode / concurrency_scope) from the spec", async () => {
139
+ const { db, values } = insertMockDb("auto-1");
140
+ await reconcileAutomation(db as never, {
141
+ // spec omits mode/concurrency_scope → defaults applied on parse.
142
+ entity: {
143
+ metadata: { name: "notify" },
144
+ spec: {
145
+ name: "x",
146
+ triggers: [{ event: "incident.created" }],
147
+ actions: [],
148
+ } as never,
149
+ },
150
+ logger,
151
+ });
152
+ const inserted = values.mock.calls[0]?.[0] as unknown as {
153
+ definition: { mode: string; concurrency_scope: string };
154
+ };
155
+ expect(inserted.definition.mode).toBe("single");
156
+ expect(inserted.definition.concurrency_scope).toBe("automation");
157
+ });
158
+ });
159
+
160
+ describe("deleteAutomationEntity", () => {
161
+ it("deletes the row (guarded to managed_by=gitops)", async () => {
162
+ const where = mock(() => Promise.resolve());
163
+ const db = { delete: mock(() => ({ where })) };
164
+ await deleteAutomationEntity(db as never, { entityId: "auto-1", logger });
165
+ expect(db.delete).toHaveBeenCalledTimes(1);
166
+ expect(where).toHaveBeenCalledTimes(1);
167
+ });
168
+
169
+ it("no-ops when no entityId is given", async () => {
170
+ const db = { delete: mock(() => ({ where: mock() })) };
171
+ await deleteAutomationEntity(db as never, { logger });
172
+ expect(db.delete).not.toHaveBeenCalled();
173
+ });
174
+ });
@@ -0,0 +1,137 @@
1
+ /**
2
+ * GitOps `Automation` entity kind (Phase 21).
3
+ *
4
+ * Registers `Automation` with the GitOps entity-kind registry so an
5
+ * automation's full `AutomationDefinitionSchema` can be declared in YAML
6
+ * and reconciled into the `automations` table. Mirrors the System kind
7
+ * registration in `catalog-backend`.
8
+ *
9
+ * Reconcile is upsert-by-name (identity tracked by the GitOps engine via
10
+ * the returned `entityId` + provenance): first sight creates, later
11
+ * sights update in place. Reconciled rows carry `managed_by = "gitops"`
12
+ * so the UI can show them as declaratively managed and lock the editor
13
+ * (the provenance lock is what actually gates the UI; `managed_by` is the
14
+ * origin marker).
15
+ */
16
+ import { and, eq } from "drizzle-orm";
17
+ import type { SafeDatabase } from "@checkstack/backend-api";
18
+ import {
19
+ AutomationDefinitionSchema,
20
+ type AutomationDefinition,
21
+ } from "@checkstack/automation-common";
22
+
23
+ import { automations } from "./schema";
24
+ import * as schema from "./schema";
25
+
26
+ type Db = SafeDatabase<typeof schema>;
27
+
28
+ /** Origin marker stamped on GitOps-managed automations. */
29
+ export const GITOPS_MANAGED_BY = "gitops";
30
+
31
+ /**
32
+ * Metadata label key carrying the automation's grouping label. GitOps has no
33
+ * dedicated `group` metadata field, so a declaratively-managed automation
34
+ * expresses its group via `metadata.labels.group`. Mirrors how `name` /
35
+ * `description` come from `metadata`, keeping `group` out of the spec
36
+ * (definition) tier — consistent with it being a row column, not part of the
37
+ * definition YAML.
38
+ */
39
+ export const GITOPS_GROUP_LABEL = "group";
40
+
41
+ interface ReconcileArgs {
42
+ entity: {
43
+ metadata: {
44
+ name: string;
45
+ title?: string;
46
+ description?: string;
47
+ labels?: Record<string, string>;
48
+ };
49
+ spec: AutomationDefinition;
50
+ };
51
+ existingEntityId?: string;
52
+ logger: { info: (msg: string) => void };
53
+ }
54
+
55
+ /**
56
+ * Resolve the grouping label from GitOps metadata. Trims and treats a
57
+ * blank/whitespace value as "no group".
58
+ */
59
+ function resolveGroup(labels?: Record<string, string>): string | null {
60
+ const raw = labels?.[GITOPS_GROUP_LABEL]?.trim();
61
+ return raw && raw.length > 0 ? raw : null;
62
+ }
63
+
64
+ /**
65
+ * Reconcile an `Automation` descriptor into the `automations` table.
66
+ * Pure of the GitOps registry shape so it can be unit-tested directly.
67
+ * Returns the persisted automation id.
68
+ */
69
+ export async function reconcileAutomation(
70
+ db: Db,
71
+ args: ReconcileArgs,
72
+ ): Promise<{ entityId: string }> {
73
+ const { entity, existingEntityId, logger } = args;
74
+ // The spec IS the automation definition; re-validate defensively (the
75
+ // GitOps engine already validated against specSchema, but parsing here
76
+ // fills defaults like `mode` / `concurrency_scope`).
77
+ const definition = AutomationDefinitionSchema.parse(entity.spec);
78
+ const name = entity.metadata.title ?? entity.metadata.name;
79
+ const description = entity.metadata.description ?? definition.description;
80
+ const group = resolveGroup(entity.metadata.labels);
81
+
82
+ if (existingEntityId) {
83
+ const [row] = await db
84
+ .update(automations)
85
+ .set({
86
+ name,
87
+ description: description ?? null,
88
+ group,
89
+ definition: definition as unknown as Record<string, unknown>,
90
+ managedBy: GITOPS_MANAGED_BY,
91
+ updatedAt: new Date(),
92
+ })
93
+ .where(eq(automations.id, existingEntityId))
94
+ .returning({ id: automations.id });
95
+ if (row) {
96
+ logger.info(`GitOps: updated Automation "${name}" (id: ${row.id})`);
97
+ return { entityId: row.id };
98
+ }
99
+ // Row vanished (manual delete between syncs) - fall through to insert.
100
+ }
101
+
102
+ const [created] = await db
103
+ .insert(automations)
104
+ .values({
105
+ name,
106
+ description: description ?? null,
107
+ group,
108
+ status: "enabled",
109
+ definition: definition as unknown as Record<string, unknown>,
110
+ managedBy: GITOPS_MANAGED_BY,
111
+ })
112
+ .returning({ id: automations.id });
113
+ if (!created) throw new Error("reconcileAutomation: insert returned no rows");
114
+ logger.info(`GitOps: created Automation "${name}" (id: ${created.id})`);
115
+ return { entityId: created.id };
116
+ }
117
+
118
+ /**
119
+ * Delete a GitOps-managed automation. Idempotent + guarded: only deletes
120
+ * rows still tagged `managed_by = "gitops"` so a row an operator took
121
+ * over manually (clearing the marker) is left alone.
122
+ */
123
+ export async function deleteAutomationEntity(
124
+ db: Db,
125
+ args: { entityId?: string; logger: { info: (msg: string) => void } },
126
+ ): Promise<void> {
127
+ if (!args.entityId) return;
128
+ await db
129
+ .delete(automations)
130
+ .where(
131
+ and(
132
+ eq(automations.id, args.entityId),
133
+ eq(automations.managedBy, GITOPS_MANAGED_BY),
134
+ ),
135
+ );
136
+ args.logger.info(`GitOps: deleted Automation (id: ${args.entityId})`);
137
+ }