@checkstack/incident-backend 1.5.0 → 1.6.1

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,125 @@
1
1
  # @checkstack/incident-backend
2
2
 
3
+ ## 1.6.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [13373ce]
8
+ - @checkstack/common@0.14.0
9
+ - @checkstack/backend-api@0.21.1
10
+ - @checkstack/cache-api@0.3.10
11
+ - @checkstack/ai-backend@0.1.1
12
+ - @checkstack/ai-common@0.1.1
13
+ - @checkstack/auth-common@0.8.1
14
+ - @checkstack/automation-backend@0.5.1
15
+ - @checkstack/automation-common@0.4.1
16
+ - @checkstack/catalog-backend@1.4.1
17
+ - @checkstack/catalog-common@2.3.1
18
+ - @checkstack/command-backend@0.2.1
19
+ - @checkstack/incident-common@1.4.1
20
+ - @checkstack/integration-backend@0.4.1
21
+ - @checkstack/integration-common@0.7.1
22
+ - @checkstack/notification-common@1.3.1
23
+ - @checkstack/signal-common@0.2.7
24
+ - @checkstack/cache-utils@0.2.15
25
+
26
+ ## 1.6.0
27
+
28
+ ### Minor Changes
29
+
30
+ - 9dcc848: Plugin-owned AI tools: every domain plugin contributes its own AI tools (chat assistant + automation AI action), and `ai-backend` is platform-only.
31
+
32
+ Every plugin-specific AI tool is owned by the plugin whose domain it acts on, registered via that plugin's own `aiToolExtensionPoint` / `aiToolProjectionExtensionPoint` from its init - the same path an external plugin author uses. `ai-backend` no longer imports or depends on any capability plugin's `*-common`; the dependency direction is strictly plugin -> ai-platform. Pure helpers (`computeFieldDiff`, capability-summary, `ScriptContextKind`) live in `@checkstack/ai-common`.
33
+
34
+ Tools shipped:
35
+
36
+ - Health checks and automations: full CRUD - `healthcheck.propose` / `automation.propose` and `*.update` (`mutate`, deep-validated) and `*.delete` (`destructive`, always confirm-gated). `healthcheck.propose`'s dry-run calls the new deep `validateConfiguration` so propose-time validation matches apply-time. Assertions are validated against the collector's result schema and the canonical operator vocabulary. Capability-catalog tools (`ai.listCapabilities`, `ai.getCapabilitySchema`), script context tools (`ai.getScriptContext`, `ai.testScript`), and notify-subscriber tools (`healthcheck.notifySystemSubscribers` / `...GroupSubscribers`).
37
+ - Catalog: `catalog.createSystem` / `updateSystem` / `createGroup` / `updateGroup` (`mutate`), `catalog.deleteSystem` / `deleteGroup` (`destructive`), membership tools (`mutate`), plus `catalog.listSystems` / `listGroups` read projections.
38
+ - Incident: `incident.create` / `update` / `addUpdate` / `resolve` / `addLink` (`mutate`), `incident.delete` / `removeLink` (`destructive`), and `incident.get` / `incident.list` read projections.
39
+ - Maintenance: `maintenance.create` / `update` / `addUpdate` / `close` / `addLink` (`mutate`), `maintenance.delete` / `removeLink` (`destructive`), and `maintenance.list` / `get` read projections.
40
+ - Read projections for SLO (`slo.listObjectives`), dependency (`dependency.list`), incident (`incident.list`), healthcheck (`healthcheck.status`), and anomaly (`anomaly.explain`), each gated by the source procedure's own access rule and routed as the principal.
41
+ - Documentation grounding: `ai.searchDocs` / `ai.getDoc` over a build-time bundled docs index (BM25-ish ranking), so the assistant grounds how-to answers in Checkstack's own docs offline.
42
+ - URL introspection: `ai.probeUrl`, an SSRF-guarded read tool the assistant uses to inspect a real endpoint before drafting a health check. Update tools compute a before -> after field diff rendered on the confirm card (approve mode) or an "Applied" card (auto mode), so a change is never silent.
43
+
44
+ `ai_analyze` automation action (automation-backend, with an editor connection picker + audited tool calls): runs a bounded AI agent on the run context as the automation's `runAs` service account, so it can never exceed that identity's permissions; destructive tools are never offered; mutating tools auto-apply through the service account's client. Produces an `automation.analysis` artifact downstream actions can branch on. The agent loop is exposed as a headless `aiAgentRunnerRef` service so automation-backend can drive it without depending on ai-backend.
45
+
46
+ `notification.notifyForSubscription` is now callable by user / application principals holding `notification.send` (previously service-only). Every tool routes through the user-scoped client, so handler-side authorization is enforced exactly as a direct UI/RPC action; the resolver gate plus the propose/apply re-check at propose AND apply are the additional authority. A systemic authz regression test asserts every registered tool falls into exactly one safe authorization category.
47
+
48
+ A new `ai_transport` enum value `automation` records the AI action's tool calls in the `ai_tool_calls` audit log. No new durable state beyond that; each tool is a thin, deterministic wrapper over an existing RPC, so every pod behaves identically.
49
+
50
+ This is a beta minor.
51
+
52
+ - 9dcc848: Align workspace dependency versions and migrate React Router to v7.
53
+
54
+ BREAKING CHANGES (React Router v7): All frontend packages now depend on `react-router-dom@^7.16.0`. Previously the workspace declared four divergent ranges (`^6.20.0`, `^6.22.0`, `^7.1.1`, `^7.14.2`), which resolved both `react-router@6` and `react-router@7` into a single bundle. Everything is now unified on v7. The public imports the app uses (`BrowserRouter`, `Routes`, `Route`, `Link`, `NavLink`, `MemoryRouter`, `useNavigate`, `useParams`, `useSearchParams`, `useLocation`) are unchanged between v6 and v7, so no source rewrites were required - but any out-of-tree plugin still on react-router v6 should upgrade to v7 (see the React Router v6 -> v7 upgrade guide) to share the host's single router instance via the import map.
55
+
56
+ Other unified ranges (no API change): `react` -> `^18.3.1`, the `@orpc/*` family (`contract`, `server`, `client`, `tanstack-query`, `openapi`, `zod`) -> `^1.14.4`, and `better-auth` -> `^1.6.13`.
57
+
58
+ Removed the pre-rename `@orpc/react-query` leftover from `@checkstack/frontend-api`; its `createRouterUtils` / `RouterUtils` / `ProcedureUtils` now come from `@orpc/tanstack-query` (the package already in use).
59
+
60
+ Stale in-range runtime deps pulled up to current published versions: `hono` `^4.12.23`, `@tanstack/react-query` (+devtools) `^5.100.14`, `date-fns` `^4.4.0`, `jose` `^6.2.3`, `tar` `^7.5.16`, `semver` `^7.8.1`, `@xyflow/react` `^12.11.0`.
61
+
62
+ ### Patch Changes
63
+
64
+ - 9dcc848: Write-path hardening: post-commit side effects can no longer fail a committed write, multi-row mutations are now atomic, and retry-duplication is blocked at the database.
65
+
66
+ **Platform-level (automatic for all current and future plugins):**
67
+
68
+ - signal-backend: `SignalService` (broadcast / sendToUser / sendToUsers / sendToAuthorizedUsers) is now resilient by construction - a transient event-bus/queue failure is caught and logged instead of thrown. Real-time signals are best-effort UI nudges; the authoritative data is already committed by the time a mutation broadcasts, so a signal-transport blip must never turn a successful write into a client-visible error. Every plugin's broadcasts inherit this without per-call-site `try/catch` (which would inevitably be forgotten and regress). This mirrors `createCachedScope`, which already makes cache invalidation non-throwing - so the cache + signal halves of the "post-commit side effect fails the response" class are both closed at the platform seam. Durable side effects (events/hooks that drive automations, queue jobs) intentionally still surface failures. Documented in `developer-guide/backend/signals.md`.
69
+
70
+ **Atomic multi-write mutations (each previously committed row-by-row in autocommit, so a mid-sequence failure left partial/orphaned state):**
71
+
72
+ - slo-backend: `createObjective` now inserts the objective and its 1:1 streak row in one transaction; the post-create reconcile/status/notify steps are best-effort and can no longer fail the (committed) create.
73
+ - incident-backend: `createIncident`, `updateIncident`, `addUpdate`, and `resolveIncident` wrap their row + system-link + timeline writes in a transaction (no more wiped system associations on a failed re-insert, or status flips with no matching timeline entry).
74
+ - maintenance-backend: same for `createMaintenance`, `updateMaintenance`, `addUpdate`, `closeMaintenance`.
75
+ - automation-backend: `cancelRun` marks the run cancelled and tears down its wait locks + durable state in one transaction - previously a failure after the status update could leave a wait lock behind, letting a later trigger event resume an already-cancelled run.
76
+ - healthcheck-backend: `ingestSatelliteResult` commits the run row and its hourly-aggregate increment together (no orphaned run, no aggregate without a backing run). NOTE: this guarantees run/aggregate consistency but does not yet make a _duplicate satellite delivery_ idempotent - that needs a dedupe key on the high-volume runs table and is tracked as a follow-up.
77
+
78
+ **Retry-duplication blocked at the DB (paired with the SQLSTATE 23505 -> 409 mapping shipped separately):**
79
+
80
+ - catalog-backend: new unique indexes on `groups.name`, `environments.name` (consistent with `systems.name`), on `system_links (system_id, url)`, and on `system_contacts (system_id, user_id)` + `(system_id, email)` (NULLs are distinct, so user vs mailbox contacts don't interfere). Name uniqueness is CASE-INSENSITIVE: the three name indexes are functional `lower(name)` indexes (the existing `systems.name` index is rebuilt this way too), so "Api" and "api" collide while the stored value keeps its original casing. The systems pre-write name check (`getSystemByName`) is case-folded to match. Migration `0005` de-dupes any pre-existing rows first - names are preserved by suffixing later case-insensitive duplicates (" (2)", " (3)", ...), redundant contact/link rows are removed keeping the earliest. (Link URLs stay case-sensitive - URL paths are; contact emails are deduped exact-match.)
81
+ - incident-backend / maintenance-backend: unique index on `incident_links (incident_id, url)` / `maintenance_links (maintenance_id, url)`, with a de-dupe step in the migration.
82
+
83
+ **Behavior change:** creating a group/environment with a duplicate name, or attaching a duplicate contact/link, now returns `409 Conflict` instead of silently creating a duplicate. The migrations resolve existing duplicates on upgrade.
84
+
85
+ This is a beta patch.
86
+
87
+ - Updated dependencies [9dcc848]
88
+ - Updated dependencies [9dcc848]
89
+ - Updated dependencies [9dcc848]
90
+ - Updated dependencies [9dcc848]
91
+ - Updated dependencies [9dcc848]
92
+ - Updated dependencies [9dcc848]
93
+ - Updated dependencies [9dcc848]
94
+ - Updated dependencies [9dcc848]
95
+ - Updated dependencies [9dcc848]
96
+ - Updated dependencies [9dcc848]
97
+ - Updated dependencies [9dcc848]
98
+ - Updated dependencies [9dcc848]
99
+ - Updated dependencies [9dcc848]
100
+ - Updated dependencies [9dcc848]
101
+ - Updated dependencies [9dcc848]
102
+ - Updated dependencies [9dcc848]
103
+ - Updated dependencies [9dcc848]
104
+ - Updated dependencies [9dcc848]
105
+ - @checkstack/ai-backend@0.1.0
106
+ - @checkstack/ai-common@0.1.0
107
+ - @checkstack/auth-common@0.8.0
108
+ - @checkstack/backend-api@0.21.0
109
+ - @checkstack/automation-backend@0.5.0
110
+ - @checkstack/catalog-backend@1.4.0
111
+ - @checkstack/notification-common@1.3.0
112
+ - @checkstack/automation-common@0.4.0
113
+ - @checkstack/catalog-common@2.3.0
114
+ - @checkstack/integration-backend@0.4.0
115
+ - @checkstack/common@0.13.0
116
+ - @checkstack/command-backend@0.2.0
117
+ - @checkstack/incident-common@1.4.0
118
+ - @checkstack/integration-common@0.7.0
119
+ - @checkstack/cache-api@0.3.9
120
+ - @checkstack/signal-common@0.2.6
121
+ - @checkstack/cache-utils@0.2.14
122
+
3
123
  ## 1.5.0
4
124
 
5
125
  ### Minor Changes
@@ -0,0 +1,10 @@
1
+ -- Resolve any pre-existing duplicate (incident_id, url) links before enforcing
2
+ -- uniqueness: keep the earliest by created_at/id, remove the redundant rest.
3
+ DELETE FROM "incident_links" WHERE "id" IN (
4
+ SELECT "id" FROM (
5
+ SELECT "id", row_number() OVER (PARTITION BY "incident_id", "url" ORDER BY "created_at", "id") AS rn
6
+ FROM "incident_links"
7
+ ) AS r WHERE r.rn > 1
8
+ );
9
+ --> statement-breakpoint
10
+ CREATE UNIQUE INDEX "incident_links_incident_url_unique" ON "incident_links" USING btree ("incident_id","url");
@@ -0,0 +1,300 @@
1
+ {
2
+ "id": "1d8635bb-6fc2-4dfb-bc27-794bfb92b6fc",
3
+ "prevId": "0c166fdf-4881-4230-a0b3-50b6c4b89e16",
4
+ "version": "7",
5
+ "dialect": "postgresql",
6
+ "tables": {
7
+ "public.incident_links": {
8
+ "name": "incident_links",
9
+ "schema": "",
10
+ "columns": {
11
+ "id": {
12
+ "name": "id",
13
+ "type": "text",
14
+ "primaryKey": true,
15
+ "notNull": true
16
+ },
17
+ "incident_id": {
18
+ "name": "incident_id",
19
+ "type": "text",
20
+ "primaryKey": false,
21
+ "notNull": true
22
+ },
23
+ "label": {
24
+ "name": "label",
25
+ "type": "text",
26
+ "primaryKey": false,
27
+ "notNull": false
28
+ },
29
+ "url": {
30
+ "name": "url",
31
+ "type": "text",
32
+ "primaryKey": false,
33
+ "notNull": true
34
+ },
35
+ "created_at": {
36
+ "name": "created_at",
37
+ "type": "timestamp",
38
+ "primaryKey": false,
39
+ "notNull": true,
40
+ "default": "now()"
41
+ }
42
+ },
43
+ "indexes": {
44
+ "incident_links_incident_url_unique": {
45
+ "name": "incident_links_incident_url_unique",
46
+ "columns": [
47
+ {
48
+ "expression": "incident_id",
49
+ "isExpression": false,
50
+ "asc": true,
51
+ "nulls": "last"
52
+ },
53
+ {
54
+ "expression": "url",
55
+ "isExpression": false,
56
+ "asc": true,
57
+ "nulls": "last"
58
+ }
59
+ ],
60
+ "isUnique": true,
61
+ "concurrently": false,
62
+ "method": "btree",
63
+ "with": {}
64
+ }
65
+ },
66
+ "foreignKeys": {
67
+ "incident_links_incident_id_incidents_id_fk": {
68
+ "name": "incident_links_incident_id_incidents_id_fk",
69
+ "tableFrom": "incident_links",
70
+ "tableTo": "incidents",
71
+ "columnsFrom": [
72
+ "incident_id"
73
+ ],
74
+ "columnsTo": [
75
+ "id"
76
+ ],
77
+ "onDelete": "cascade",
78
+ "onUpdate": "no action"
79
+ }
80
+ },
81
+ "compositePrimaryKeys": {},
82
+ "uniqueConstraints": {},
83
+ "policies": {},
84
+ "checkConstraints": {},
85
+ "isRLSEnabled": false
86
+ },
87
+ "public.incident_systems": {
88
+ "name": "incident_systems",
89
+ "schema": "",
90
+ "columns": {
91
+ "incident_id": {
92
+ "name": "incident_id",
93
+ "type": "text",
94
+ "primaryKey": false,
95
+ "notNull": true
96
+ },
97
+ "system_id": {
98
+ "name": "system_id",
99
+ "type": "text",
100
+ "primaryKey": false,
101
+ "notNull": true
102
+ }
103
+ },
104
+ "indexes": {},
105
+ "foreignKeys": {
106
+ "incident_systems_incident_id_incidents_id_fk": {
107
+ "name": "incident_systems_incident_id_incidents_id_fk",
108
+ "tableFrom": "incident_systems",
109
+ "tableTo": "incidents",
110
+ "columnsFrom": [
111
+ "incident_id"
112
+ ],
113
+ "columnsTo": [
114
+ "id"
115
+ ],
116
+ "onDelete": "cascade",
117
+ "onUpdate": "no action"
118
+ }
119
+ },
120
+ "compositePrimaryKeys": {
121
+ "incident_systems_incident_id_system_id_pk": {
122
+ "name": "incident_systems_incident_id_system_id_pk",
123
+ "columns": [
124
+ "incident_id",
125
+ "system_id"
126
+ ]
127
+ }
128
+ },
129
+ "uniqueConstraints": {},
130
+ "policies": {},
131
+ "checkConstraints": {},
132
+ "isRLSEnabled": false
133
+ },
134
+ "public.incident_updates": {
135
+ "name": "incident_updates",
136
+ "schema": "",
137
+ "columns": {
138
+ "id": {
139
+ "name": "id",
140
+ "type": "text",
141
+ "primaryKey": true,
142
+ "notNull": true
143
+ },
144
+ "incident_id": {
145
+ "name": "incident_id",
146
+ "type": "text",
147
+ "primaryKey": false,
148
+ "notNull": true
149
+ },
150
+ "message": {
151
+ "name": "message",
152
+ "type": "text",
153
+ "primaryKey": false,
154
+ "notNull": true
155
+ },
156
+ "status_change": {
157
+ "name": "status_change",
158
+ "type": "incident_status",
159
+ "typeSchema": "public",
160
+ "primaryKey": false,
161
+ "notNull": false
162
+ },
163
+ "created_at": {
164
+ "name": "created_at",
165
+ "type": "timestamp",
166
+ "primaryKey": false,
167
+ "notNull": true,
168
+ "default": "now()"
169
+ },
170
+ "created_by": {
171
+ "name": "created_by",
172
+ "type": "text",
173
+ "primaryKey": false,
174
+ "notNull": false
175
+ }
176
+ },
177
+ "indexes": {},
178
+ "foreignKeys": {
179
+ "incident_updates_incident_id_incidents_id_fk": {
180
+ "name": "incident_updates_incident_id_incidents_id_fk",
181
+ "tableFrom": "incident_updates",
182
+ "tableTo": "incidents",
183
+ "columnsFrom": [
184
+ "incident_id"
185
+ ],
186
+ "columnsTo": [
187
+ "id"
188
+ ],
189
+ "onDelete": "cascade",
190
+ "onUpdate": "no action"
191
+ }
192
+ },
193
+ "compositePrimaryKeys": {},
194
+ "uniqueConstraints": {},
195
+ "policies": {},
196
+ "checkConstraints": {},
197
+ "isRLSEnabled": false
198
+ },
199
+ "public.incidents": {
200
+ "name": "incidents",
201
+ "schema": "",
202
+ "columns": {
203
+ "id": {
204
+ "name": "id",
205
+ "type": "text",
206
+ "primaryKey": true,
207
+ "notNull": true
208
+ },
209
+ "title": {
210
+ "name": "title",
211
+ "type": "text",
212
+ "primaryKey": false,
213
+ "notNull": true
214
+ },
215
+ "description": {
216
+ "name": "description",
217
+ "type": "text",
218
+ "primaryKey": false,
219
+ "notNull": false
220
+ },
221
+ "status": {
222
+ "name": "status",
223
+ "type": "incident_status",
224
+ "typeSchema": "public",
225
+ "primaryKey": false,
226
+ "notNull": true,
227
+ "default": "'investigating'"
228
+ },
229
+ "severity": {
230
+ "name": "severity",
231
+ "type": "incident_severity",
232
+ "typeSchema": "public",
233
+ "primaryKey": false,
234
+ "notNull": true,
235
+ "default": "'major'"
236
+ },
237
+ "suppress_notifications": {
238
+ "name": "suppress_notifications",
239
+ "type": "boolean",
240
+ "primaryKey": false,
241
+ "notNull": true,
242
+ "default": false
243
+ },
244
+ "created_at": {
245
+ "name": "created_at",
246
+ "type": "timestamp",
247
+ "primaryKey": false,
248
+ "notNull": true,
249
+ "default": "now()"
250
+ },
251
+ "updated_at": {
252
+ "name": "updated_at",
253
+ "type": "timestamp",
254
+ "primaryKey": false,
255
+ "notNull": true,
256
+ "default": "now()"
257
+ }
258
+ },
259
+ "indexes": {},
260
+ "foreignKeys": {},
261
+ "compositePrimaryKeys": {},
262
+ "uniqueConstraints": {},
263
+ "policies": {},
264
+ "checkConstraints": {},
265
+ "isRLSEnabled": false
266
+ }
267
+ },
268
+ "enums": {
269
+ "public.incident_severity": {
270
+ "name": "incident_severity",
271
+ "schema": "public",
272
+ "values": [
273
+ "minor",
274
+ "major",
275
+ "critical"
276
+ ]
277
+ },
278
+ "public.incident_status": {
279
+ "name": "incident_status",
280
+ "schema": "public",
281
+ "values": [
282
+ "investigating",
283
+ "identified",
284
+ "fixing",
285
+ "monitoring",
286
+ "resolved"
287
+ ]
288
+ }
289
+ },
290
+ "schemas": {},
291
+ "sequences": {},
292
+ "roles": {},
293
+ "policies": {},
294
+ "views": {},
295
+ "_meta": {
296
+ "columns": {},
297
+ "schemas": {},
298
+ "tables": {}
299
+ }
300
+ }
@@ -22,6 +22,13 @@
22
22
  "when": 1777907796517,
23
23
  "tag": "0002_brown_thena",
24
24
  "breakpoints": true
25
+ },
26
+ {
27
+ "idx": 3,
28
+ "version": "7",
29
+ "when": 1780649381812,
30
+ "tag": "0003_careful_ken_ellis",
31
+ "breakpoints": true
25
32
  }
26
33
  ]
27
34
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/incident-backend",
3
- "version": "1.5.0",
3
+ "version": "1.6.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -14,29 +14,32 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/backend-api": "0.18.0",
18
- "@checkstack/cache-api": "0.3.6",
19
- "@checkstack/cache-utils": "0.2.11",
20
- "@checkstack/incident-common": "1.3.1",
21
- "@checkstack/catalog-common": "2.2.3",
22
- "@checkstack/catalog-backend": "1.2.0",
23
- "@checkstack/notification-common": "1.2.1",
24
- "@checkstack/auth-common": "0.7.2",
25
- "@checkstack/command-backend": "0.1.31",
26
- "@checkstack/signal-common": "0.2.5",
27
- "@checkstack/integration-backend": "0.2.0",
28
- "@checkstack/integration-common": "0.6.0",
29
- "@checkstack/automation-backend": "0.2.0",
30
- "@checkstack/automation-common": "0.2.0",
31
- "@checkstack/common": "0.12.0",
17
+ "@checkstack/ai-backend": "0.1.0",
18
+ "@checkstack/ai-common": "0.1.0",
19
+ "@checkstack/backend-api": "0.21.0",
20
+ "@checkstack/cache-api": "0.3.9",
21
+ "@checkstack/cache-utils": "0.2.14",
22
+ "@checkstack/incident-common": "1.4.0",
23
+ "@checkstack/catalog-common": "2.3.0",
24
+ "@checkstack/catalog-backend": "1.4.0",
25
+ "@checkstack/notification-common": "1.3.0",
26
+ "@checkstack/auth-common": "0.8.0",
27
+ "@checkstack/command-backend": "0.2.0",
28
+ "@checkstack/signal-common": "0.2.6",
29
+ "@checkstack/integration-backend": "0.4.0",
30
+ "@checkstack/integration-common": "0.7.0",
31
+ "@checkstack/automation-backend": "0.5.0",
32
+ "@checkstack/automation-common": "0.4.0",
33
+ "@checkstack/common": "0.13.0",
32
34
  "drizzle-orm": "^0.45.0",
33
35
  "zod": "^4.2.1",
34
- "@orpc/server": "^1.13.2"
36
+ "@orpc/contract": "^1.14.4",
37
+ "@orpc/server": "^1.14.4"
35
38
  },
36
39
  "devDependencies": {
37
40
  "@checkstack/drizzle-helper": "0.0.5",
38
- "@checkstack/scripts": "0.3.4",
39
- "@checkstack/test-utils-backend": "0.1.31",
41
+ "@checkstack/scripts": "0.4.0",
42
+ "@checkstack/test-utils-backend": "0.1.34",
40
43
  "@checkstack/tsconfig": "0.0.7",
41
44
  "@types/bun": "^1.0.0",
42
45
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,62 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type { AddIncidentLinkInput } from "@checkstack/incident-common";
4
+ import { createIncidentAddLinkTool } from "./incident-add-link";
5
+
6
+ const principal: AuthUser = {
7
+ type: "user",
8
+ id: "u1",
9
+ accessRules: ["incident.incident.manage"],
10
+ };
11
+
12
+ const input: AddIncidentLinkInput = {
13
+ incidentId: "inc1",
14
+ label: "Runbook",
15
+ url: "https://example.com/runbook",
16
+ };
17
+
18
+ function fakeRpcClient({
19
+ addLink,
20
+ }: {
21
+ addLink: ReturnType<typeof mock>;
22
+ }): RpcClient {
23
+ return {
24
+ forPlugin: () => ({ addLink }),
25
+ } as unknown as RpcClient;
26
+ }
27
+
28
+ describe("incident.addLink tool", () => {
29
+ test("declares mutate effect + the manage rule", () => {
30
+ const tool = createIncidentAddLinkTool();
31
+ expect(tool.name).toBe("incident.addLink");
32
+ expect(tool.effect).toBe("mutate");
33
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
34
+ expect(typeof tool.dryRun).toBe("function");
35
+ });
36
+
37
+ test("dryRun returns a payload and NEVER adds the link", async () => {
38
+ const addLink = mock(() => Promise.resolve({}));
39
+ const rpcClient = fakeRpcClient({ addLink });
40
+ const tool = createIncidentAddLinkTool();
41
+ const preview = await tool.dryRun!({ input, principal, rpcClient });
42
+ expect(addLink).not.toHaveBeenCalled();
43
+ expect(preview.summary).toContain("Runbook");
44
+ expect(preview.payload).toEqual(input);
45
+ });
46
+
47
+ test("execute (apply) adds via addLink", async () => {
48
+ const created = {
49
+ id: "lnk1",
50
+ incidentId: input.incidentId,
51
+ label: input.label ?? null,
52
+ url: input.url,
53
+ createdAt: new Date(),
54
+ };
55
+ const addLink = mock(() => Promise.resolve(created));
56
+ const rpcClient = fakeRpcClient({ addLink });
57
+ const tool = createIncidentAddLinkTool();
58
+ const result = await tool.execute({ input, principal, rpcClient });
59
+ expect(addLink).toHaveBeenCalledWith(input);
60
+ expect(result.link).toEqual(created);
61
+ });
62
+ });
@@ -0,0 +1,63 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ IncidentApi,
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ AddIncidentLinkInputSchema,
8
+ type AddIncidentLinkInput,
9
+ type IncidentLink,
10
+ } from "@checkstack/incident-common";
11
+ import type { AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /** Output returned once a human applies the link add (the created link). */
15
+ export interface IncidentAddLinkApplyResult {
16
+ link: IncidentLink;
17
+ }
18
+
19
+ /**
20
+ * `incident.addLink` - attach a hotlink (e.g. Jira ticket, runbook) to an
21
+ * incident.
22
+ *
23
+ * `effect: "mutate"` - adding a link is a non-destructive change, so it
24
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
25
+ * returns the captured payload for human review WITHOUT mutating; `execute`
26
+ * (reached only via `apply`) attaches the link. The underlying RPC uses the
27
+ * USER-SCOPED client passed at call time, so handler-side authorization is
28
+ * enforced exactly as a direct UI/RPC call.
29
+ */
30
+ export function createIncidentAddLinkTool(): RegisteredAiTool<
31
+ AddIncidentLinkInput,
32
+ IncidentAddLinkApplyResult
33
+ > {
34
+ const dryRun = async ({
35
+ input,
36
+ }: {
37
+ input: AddIncidentLinkInput;
38
+ principal: AuthUser;
39
+ rpcClient: RpcClient;
40
+ }): Promise<AiProposalPreview<AddIncidentLinkInput>> => {
41
+ return {
42
+ summary: `Add link ${input.label ? `"${input.label}" ` : ""}(${input.url}) to incident ${input.incidentId}.`,
43
+ payload: input,
44
+ };
45
+ };
46
+
47
+ return {
48
+ name: "incident.addLink",
49
+ description:
50
+ "Attach a hotlink (e.g. Jira ticket, runbook, status page) to an incident with an optional label and a URL. Never adds directly; a person must approve unless the conversation is in auto mode. Find the incidentId with the incident read tools first.",
51
+ effect: "mutate",
52
+ input: AddIncidentLinkInputSchema,
53
+ requiredAccessRules: [
54
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
55
+ ],
56
+ dryRun,
57
+ async execute({ input, rpcClient }) {
58
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
59
+ const link = await incidentClient.addLink(input);
60
+ return { link };
61
+ },
62
+ };
63
+ }