@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.
@@ -0,0 +1,69 @@
1
+ import { z } from "zod";
2
+ import { qualifyAccessRuleId } from "@checkstack/common";
3
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
4
+ import {
5
+ IncidentApi,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ type IncidentWithSystems,
9
+ } from "@checkstack/incident-common";
10
+ import type { AiProposalPreview } from "@checkstack/ai-common";
11
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
12
+
13
+ /** Input for `incident.resolve`: the incident id plus an optional message. */
14
+ export const IncidentResolveInputSchema = z.object({
15
+ id: z.string(),
16
+ message: z.string().optional(),
17
+ });
18
+ export type IncidentResolveInput = z.infer<typeof IncidentResolveInputSchema>;
19
+
20
+ /** Output returned once a human applies the resolution (the resolved incident). */
21
+ export interface IncidentResolveApplyResult {
22
+ incident: IncidentWithSystems;
23
+ }
24
+
25
+ /**
26
+ * `incident.resolve` - mark an incident resolved by id, optionally with a final
27
+ * status-update message.
28
+ *
29
+ * `effect: "mutate"` - resolving is a non-destructive status change, so it
30
+ * auto-applies in AUTO mode and is confirm-gated in APPROVE mode. `dryRun`
31
+ * returns the captured payload for human review WITHOUT mutating; `execute`
32
+ * (reached only via `apply`) resolves the incident. The underlying RPC uses the
33
+ * USER-SCOPED client passed at call time, so handler-side authorization is
34
+ * enforced exactly as a direct UI/RPC call.
35
+ */
36
+ export function createIncidentResolveTool(): RegisteredAiTool<
37
+ IncidentResolveInput,
38
+ IncidentResolveApplyResult
39
+ > {
40
+ const dryRun = async ({
41
+ input,
42
+ }: {
43
+ input: IncidentResolveInput;
44
+ principal: AuthUser;
45
+ rpcClient: RpcClient;
46
+ }): Promise<AiProposalPreview<IncidentResolveInput>> => {
47
+ return {
48
+ summary: `Resolve incident ${input.id}${input.message ? ` with message "${input.message}"` : ""}.`,
49
+ payload: input,
50
+ };
51
+ };
52
+
53
+ return {
54
+ name: "incident.resolve",
55
+ description:
56
+ "Mark an incident resolved by id, optionally with a final status-update message. Never resolves directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
57
+ effect: "mutate",
58
+ input: IncidentResolveInputSchema,
59
+ requiredAccessRules: [
60
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
61
+ ],
62
+ dryRun,
63
+ async execute({ input, rpcClient }) {
64
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
65
+ const incident = await incidentClient.resolveIncident(input);
66
+ return { incident };
67
+ },
68
+ };
69
+ }
@@ -0,0 +1,87 @@
1
+ import { describe, expect, test, mock } from "bun:test";
2
+ import type { AuthUser, RpcClient } from "@checkstack/backend-api";
3
+ import type { IncidentDetail } from "@checkstack/incident-common";
4
+ import { createIncidentUpdateTool } from "./incident-update";
5
+
6
+ const principal: AuthUser = {
7
+ type: "user",
8
+ id: "u1",
9
+ accessRules: ["incident.incident.manage"],
10
+ };
11
+
12
+ const existing: IncidentDetail = {
13
+ id: "inc1",
14
+ title: "Old title",
15
+ description: "old desc",
16
+ status: "investigating",
17
+ severity: "minor",
18
+ suppressNotifications: false,
19
+ systemIds: ["sys1"],
20
+ createdAt: new Date(),
21
+ updatedAt: new Date(),
22
+ updates: [],
23
+ links: [],
24
+ };
25
+
26
+ function fakeRpcClient({
27
+ getIncident,
28
+ updateIncident,
29
+ }: {
30
+ getIncident: ReturnType<typeof mock>;
31
+ updateIncident: ReturnType<typeof mock>;
32
+ }): RpcClient {
33
+ return {
34
+ forPlugin: () => ({ getIncident, updateIncident }),
35
+ } as unknown as RpcClient;
36
+ }
37
+
38
+ describe("incident.update tool", () => {
39
+ test("declares mutate effect + the manage rule", () => {
40
+ const tool = createIncidentUpdateTool();
41
+ expect(tool.name).toBe("incident.update");
42
+ expect(tool.effect).toBe("mutate");
43
+ expect(tool.requiredAccessRules).toEqual(["incident.incident.manage"]);
44
+ expect(typeof tool.dryRun).toBe("function");
45
+ });
46
+
47
+ test("dryRun fetches existing, returns a diff, and NEVER updates", async () => {
48
+ const getIncident = mock(() => Promise.resolve(existing));
49
+ const updateIncident = mock(() => Promise.resolve(existing));
50
+ const rpcClient = fakeRpcClient({ getIncident, updateIncident });
51
+ const tool = createIncidentUpdateTool();
52
+ const preview = await tool.dryRun!({
53
+ input: { id: "inc1", severity: "critical" },
54
+ principal,
55
+ rpcClient,
56
+ });
57
+ expect(getIncident).toHaveBeenCalledWith({ id: "inc1" });
58
+ expect(updateIncident).not.toHaveBeenCalled();
59
+ expect(preview.diff).toBeDefined();
60
+ expect(preview.summary).toContain("critical");
61
+ });
62
+
63
+ test("dryRun throws a clear error when the id is unknown", async () => {
64
+ const rpcClient = fakeRpcClient({
65
+ getIncident: mock(() => Promise.resolve(null)),
66
+ updateIncident: mock(),
67
+ });
68
+ const tool = createIncidentUpdateTool();
69
+ await expect(
70
+ tool.dryRun!({ input: { id: "nope" }, principal, rpcClient }),
71
+ ).rejects.toThrow(/No incident found/);
72
+ });
73
+
74
+ test("execute (apply) updates via updateIncident", async () => {
75
+ const input = { id: "inc1", title: "New title" };
76
+ const updated = { ...existing, title: "New title" };
77
+ const updateIncident = mock(() => Promise.resolve(updated));
78
+ const rpcClient = fakeRpcClient({
79
+ getIncident: mock(() => Promise.resolve(existing)),
80
+ updateIncident,
81
+ });
82
+ const tool = createIncidentUpdateTool();
83
+ const result = await tool.execute({ input, principal, rpcClient });
84
+ expect(updateIncident).toHaveBeenCalledWith(input);
85
+ expect(result.incident).toEqual(updated);
86
+ });
87
+ });
@@ -0,0 +1,94 @@
1
+ import { qualifyAccessRuleId } from "@checkstack/common";
2
+ import type { RpcClient, AuthUser } from "@checkstack/backend-api";
3
+ import {
4
+ IncidentApi,
5
+ incidentAccess,
6
+ pluginMetadata,
7
+ UpdateIncidentInputSchema,
8
+ type UpdateIncidentInput,
9
+ type IncidentWithSystems,
10
+ } from "@checkstack/incident-common";
11
+ import { computeFieldDiff, type AiProposalPreview } from "@checkstack/ai-common";
12
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
13
+
14
+ /** Output returned once a human applies the update (the updated incident). */
15
+ export interface IncidentUpdateApplyResult {
16
+ incident: IncidentWithSystems;
17
+ }
18
+
19
+ /**
20
+ * `incident.update` - edit an existing incident's metadata (title, description,
21
+ * severity, suppressNotifications, affected systems) by id. Only the provided
22
+ * fields change.
23
+ *
24
+ * `effect: "mutate"` - a non-destructive change, so it auto-applies in AUTO mode
25
+ * and is confirm-gated in APPROVE mode. `dryRun` fetches the live incident,
26
+ * computes a before -> after diff over the updatable fields, and rejects an
27
+ * unknown id with a self-correcting error; `execute` performs the update. The
28
+ * underlying RPC uses the USER-SCOPED client passed at call time, so
29
+ * handler-side authorization is enforced exactly as a direct UI/RPC call.
30
+ */
31
+ export function createIncidentUpdateTool(): RegisteredAiTool<
32
+ UpdateIncidentInput,
33
+ IncidentUpdateApplyResult
34
+ > {
35
+ const dryRun = async ({
36
+ input,
37
+ rpcClient,
38
+ }: {
39
+ input: UpdateIncidentInput;
40
+ principal: AuthUser;
41
+ rpcClient: RpcClient;
42
+ }): Promise<AiProposalPreview<UpdateIncidentInput>> => {
43
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
44
+ const existing = await incidentClient.getIncident({ id: input.id });
45
+ if (!existing) {
46
+ throw new Error(
47
+ `No incident found with id "${input.id}". List incidents first to get a valid id.`,
48
+ );
49
+ }
50
+ // before -> after over the updatable subset only (the full incident also
51
+ // carries status/timestamps/updates/links that are not part of an update).
52
+ const before = {
53
+ title: existing.title,
54
+ description: existing.description,
55
+ severity: existing.severity,
56
+ suppressNotifications: existing.suppressNotifications,
57
+ systemIds: existing.systemIds,
58
+ };
59
+ const after = {
60
+ title: input.title ?? before.title,
61
+ description:
62
+ input.description === undefined
63
+ ? before.description
64
+ : (input.description ?? undefined),
65
+ severity: input.severity ?? before.severity,
66
+ suppressNotifications:
67
+ input.suppressNotifications ?? before.suppressNotifications,
68
+ systemIds: input.systemIds ?? before.systemIds,
69
+ };
70
+ const diff = computeFieldDiff({ before, after });
71
+ return {
72
+ summary: `Update incident "${after.title}" (severity ${after.severity}, ${after.systemIds.length} system(s)).`,
73
+ payload: input,
74
+ diff,
75
+ };
76
+ };
77
+
78
+ return {
79
+ name: "incident.update",
80
+ description:
81
+ "Update an existing incident by id with the provided fields (title, description, severity, suppressNotifications, systemIds). Only provided fields change. Never updates directly; a person must approve unless the conversation is in auto mode. Find the id with the incident read tools first.",
82
+ effect: "mutate",
83
+ input: UpdateIncidentInputSchema,
84
+ requiredAccessRules: [
85
+ qualifyAccessRuleId(pluginMetadata, incidentAccess.incident.manage),
86
+ ],
87
+ dryRun,
88
+ async execute({ input, rpcClient }) {
89
+ const incidentClient = rpcClient.forPlugin(IncidentApi);
90
+ const incident = await incidentClient.updateIncident(input);
91
+ return { incident };
92
+ },
93
+ };
94
+ }
@@ -0,0 +1,33 @@
1
+ import type { RegisteredAiTool } from "@checkstack/ai-backend";
2
+ import { createIncidentCreateTool } from "./incident-create";
3
+ import { createIncidentUpdateTool } from "./incident-update";
4
+ import { createIncidentDeleteTool } from "./incident-delete";
5
+ import { createIncidentAddUpdateTool } from "./incident-add-update";
6
+ import { createIncidentResolveTool } from "./incident-resolve";
7
+ import { createIncidentAddLinkTool } from "./incident-add-link";
8
+ import { createIncidentRemoveLinkTool } from "./incident-remove-link";
9
+
10
+ /**
11
+ * The incident plugin's AI tools, registered into the AI registry via
12
+ * `aiToolExtensionPoint` from this plugin's own init - NOT centralized in
13
+ * ai-backend. This is the canonical pattern any plugin (first- or third-party)
14
+ * uses to contribute AI tools without ai-backend depending on it.
15
+ *
16
+ * create/update/addUpdate/resolve/addLink are `mutate` (auto-applies in AUTO
17
+ * mode, confirm-gated in APPROVE mode); delete/removeLink are `destructive`, so
18
+ * always confirm-gated. They all go through the USER-SCOPED client passed at
19
+ * call time, so handler-side authorization is enforced exactly as a direct
20
+ * UI/RPC call; the resolver gate + the propose/apply re-check at propose AND
21
+ * apply time are the additional authorization authority.
22
+ */
23
+ export function buildIncidentAiTools(): RegisteredAiTool[] {
24
+ return [
25
+ createIncidentCreateTool(),
26
+ createIncidentUpdateTool(),
27
+ createIncidentDeleteTool(),
28
+ createIncidentAddUpdateTool(),
29
+ createIncidentResolveTool(),
30
+ createIncidentAddLinkTool(),
31
+ createIncidentRemoveLinkTool(),
32
+ ];
33
+ }
@@ -0,0 +1,38 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { buildProjectedTool, deferredProjectionExecute } from "@checkstack/ai-backend";
3
+ import { qualifyAccessRuleId } from "@checkstack/common";
4
+ import {
5
+ incidentContract,
6
+ incidentAccess,
7
+ pluginMetadata,
8
+ } from "@checkstack/incident-common";
9
+
10
+ describe("incident AI projection (incident.list)", () => {
11
+ const tool = buildProjectedTool({
12
+ procedure: incidentContract.listIncidents,
13
+ sourcePluginMetadata: pluginMetadata,
14
+ procedureKey: "listIncidents",
15
+ name: "incident.list",
16
+ description: "List incidents with optional status/system filters. Read-only.",
17
+ effect: "read",
18
+ execute: deferredProjectionExecute,
19
+ });
20
+
21
+ test("projects to a read-only tool named incident.list", () => {
22
+ expect(tool.name).toBe("incident.list");
23
+ expect(tool.effect).toBe("read");
24
+ });
25
+
26
+ test("carries the source procedure's own qualified access rules", () => {
27
+ // Derived from listIncidents' own access metadata, NOT the broad
28
+ // ai.chat.read rule.
29
+ const expected = qualifyAccessRuleId(
30
+ pluginMetadata,
31
+ incidentAccess.incident.read,
32
+ );
33
+
34
+ expect(tool.requiredAccessRules.length).toBeGreaterThan(0);
35
+ expect(tool.requiredAccessRules).toEqual([expected]);
36
+ expect(tool.requiredAccessRules).not.toEqual(["ai.chat.read"]);
37
+ });
38
+ });
@@ -5,6 +5,7 @@
5
5
  * `core/automation-backend` cover registration validity.
6
6
  */
7
7
  import { describe, it, expect, mock } from "bun:test";
8
+ import type { RpcClient } from "@checkstack/backend-api";
8
9
  import { SYSTEM_ACTOR } from "@checkstack/common";
9
10
  import { createMockLogger } from "@checkstack/test-utils-backend";
10
11
 
@@ -48,6 +49,7 @@ const actionContext = {
48
49
  getService: async <T,>(): Promise<T> => {
49
50
  throw new Error("not used");
50
51
  },
52
+ rpcClient: { forPlugin: () => ({}) } as unknown as RpcClient,
51
53
  };
52
54
 
53
55
  describe("incident automation actions", () => {
package/src/index.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import * as schema from "./schema";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
+ import {
4
+ aiToolExtensionPoint,
5
+ aiToolProjectionExtensionPoint,
6
+ deferredProjectionExecute,
7
+ } from "@checkstack/ai-backend";
3
8
  import {
4
9
  incidentAccessRules,
5
10
  incidentAccess,
@@ -42,6 +47,7 @@ import {
42
47
  incidentArtifactType,
43
48
  incidentTriggers,
44
49
  } from "./automations";
50
+ import { buildIncidentAiTools } from "./ai/register-ai-tools";
45
51
 
46
52
  // =============================================================================
47
53
  // Plugin Definition
@@ -180,6 +186,44 @@ export default createBackendPlugin({
180
186
  automationActions.registerAction(action, pluginMetadata);
181
187
  }
182
188
 
189
+ // Register this plugin's AI tools (create/update/delete/addUpdate/
190
+ // resolve/addLink/removeLink) into the AI registry via the extension
191
+ // point - owned here, not in ai-backend.
192
+ const aiToolExt = env.getExtensionPoint(aiToolExtensionPoint);
193
+ for (const tool of buildIncidentAiTools()) {
194
+ aiToolExt.registerTool(tool, pluginMetadata);
195
+ }
196
+
197
+ // Expose this plugin's read-only AI projection (`incident.list`) via
198
+ // the AI projection extension point. ai-backend collects its routing in
199
+ // afterPluginsReady and never imports incident-common.
200
+ const aiProjectionExt = env.getExtensionPoint(
201
+ aiToolProjectionExtensionPoint,
202
+ );
203
+ aiProjectionExt.expose({
204
+ procedure: incidentContract.listIncidents,
205
+ sourcePluginMetadata: pluginMetadata,
206
+ procedureKey: "listIncidents",
207
+ name: "incident.list",
208
+ description:
209
+ "List incidents with optional status/system filters. Read-only.",
210
+ effect: "read",
211
+ execute: deferredProjectionExecute,
212
+ });
213
+
214
+ // Expose a read-only projection of `getIncident` so the model can pull
215
+ // one incident's full timeline (updates) + links to ground its actions.
216
+ aiProjectionExt.expose({
217
+ procedure: incidentContract.getIncident,
218
+ sourcePluginMetadata: pluginMetadata,
219
+ procedureKey: "getIncident",
220
+ name: "incident.get",
221
+ description:
222
+ "Get one incident with its full timeline (updates) and links. Read-only.",
223
+ effect: "read",
224
+ execute: deferredProjectionExecute,
225
+ });
226
+
183
227
  // Register "Create Incident" command in the command palette
184
228
  registerSearchProvider({
185
229
  pluginMetadata,
package/src/schema.ts CHANGED
@@ -5,6 +5,7 @@ import {
5
5
  timestamp,
6
6
  primaryKey,
7
7
  boolean,
8
+ uniqueIndex,
8
9
  } from "drizzle-orm/pg-core";
9
10
 
10
11
  /**
@@ -77,12 +78,22 @@ export const incidentUpdates = pgTable("incident_updates", {
77
78
  * Hotlinks attached to an incident — e.g. a Jira ticket, runbook, or chat
78
79
  * thread. Free-form URL + optional human label.
79
80
  */
80
- export const incidentLinks = pgTable("incident_links", {
81
- id: text("id").primaryKey(),
82
- incidentId: text("incident_id")
83
- .notNull()
84
- .references(() => incidents.id, { onDelete: "cascade" }),
85
- label: text("label"),
86
- url: text("url").notNull(),
87
- createdAt: timestamp("created_at").defaultNow().notNull(),
88
- });
81
+ export const incidentLinks = pgTable(
82
+ "incident_links",
83
+ {
84
+ id: text("id").primaryKey(),
85
+ incidentId: text("incident_id")
86
+ .notNull()
87
+ .references(() => incidents.id, { onDelete: "cascade" }),
88
+ label: text("label"),
89
+ url: text("url").notNull(),
90
+ createdAt: timestamp("created_at").defaultNow().notNull(),
91
+ },
92
+ (t) => ({
93
+ // The same URL may be attached to an incident only once.
94
+ incidentUrlUnique: uniqueIndex("incident_links_incident_url_unique").on(
95
+ t.incidentId,
96
+ t.url,
97
+ ),
98
+ }),
99
+ );
@@ -301,6 +301,12 @@ function createDedupFakeDb() {
301
301
  // The lock key is embedded in the SQL the helper runs via tx.execute.
302
302
  let lockKey = "default";
303
303
  const tx = {
304
+ // A transaction handle exposes the same query surface as `db`, so a
305
+ // service method that wraps its writes in `db.transaction` can issue
306
+ // select/insert against the same backing store (e.g. createIncident,
307
+ // which now commits the incident + its system links atomically).
308
+ select: buildSelect(),
309
+ insert: buildInsert(),
304
310
  execute: async (sqlObj: unknown) => {
305
311
  // Drizzle sql`` carries the interpolated key in its params; the
306
312
  // helper interpolates exactly one param (the lock key).
package/src/service.ts CHANGED
@@ -238,33 +238,38 @@ export class IncidentService {
238
238
  id: string = generateId(),
239
239
  ): Promise<IncidentWithSystems> {
240
240
 
241
- await this.db.insert(incidents).values({
242
- id,
243
- title: input.title,
244
- description: input.description,
245
- status: "investigating",
246
- severity: input.severity,
247
- suppressNotifications: input.suppressNotifications ?? false,
248
- });
249
-
250
- // Insert system associations
251
- for (const systemId of input.systemIds) {
252
- await this.db.insert(incidentSystems).values({
253
- incidentId: id,
254
- systemId,
241
+ // Atomic: the incident row, its system associations, and any initial update
242
+ // must all commit together. Without the transaction a failure mid-loop left
243
+ // a committed incident with only some (or none) of its system links.
244
+ await this.db.transaction(async (tx) => {
245
+ await tx.insert(incidents).values({
246
+ id,
247
+ title: input.title,
248
+ description: input.description,
249
+ status: "investigating",
250
+ severity: input.severity,
251
+ suppressNotifications: input.suppressNotifications ?? false,
255
252
  });
256
- }
257
253
 
258
- // Add initial update if provided
259
- if (input.initialMessage) {
260
- await this.db.insert(incidentUpdates).values({
261
- id: generateId(),
262
- incidentId: id,
263
- message: input.initialMessage,
264
- statusChange: "investigating",
265
- createdBy: userId,
266
- });
267
- }
254
+ // Insert system associations
255
+ for (const systemId of input.systemIds) {
256
+ await tx.insert(incidentSystems).values({
257
+ incidentId: id,
258
+ systemId,
259
+ });
260
+ }
261
+
262
+ // Add initial update if provided
263
+ if (input.initialMessage) {
264
+ await tx.insert(incidentUpdates).values({
265
+ id: generateId(),
266
+ incidentId: id,
267
+ message: input.initialMessage,
268
+ statusChange: "investigating",
269
+ createdBy: userId,
270
+ });
271
+ }
272
+ });
268
273
 
269
274
  return (await this.getIncident(id))!;
270
275
  }
@@ -293,24 +298,29 @@ export class IncidentService {
293
298
  if (input.suppressNotifications !== undefined)
294
299
  updateData.suppressNotifications = input.suppressNotifications;
295
300
 
296
- await this.db
297
- .update(incidents)
298
- .set(updateData)
299
- .where(eq(incidents.id, input.id));
300
-
301
- // Update system associations if provided
302
- if (input.systemIds !== undefined) {
303
- await this.db
304
- .delete(incidentSystems)
305
- .where(eq(incidentSystems.incidentId, input.id));
306
-
307
- for (const systemId of input.systemIds) {
308
- await this.db.insert(incidentSystems).values({
309
- incidentId: input.id,
310
- systemId,
311
- });
301
+ // Atomic: the field update and the delete-then-reinsert of system links must
302
+ // commit together. Without the transaction a failure after the delete left
303
+ // the incident with ALL system associations wiped.
304
+ await this.db.transaction(async (tx) => {
305
+ await tx
306
+ .update(incidents)
307
+ .set(updateData)
308
+ .where(eq(incidents.id, input.id));
309
+
310
+ // Update system associations if provided
311
+ if (input.systemIds !== undefined) {
312
+ await tx
313
+ .delete(incidentSystems)
314
+ .where(eq(incidentSystems.incidentId, input.id));
315
+
316
+ for (const systemId of input.systemIds) {
317
+ await tx.insert(incidentSystems).values({
318
+ incidentId: input.id,
319
+ systemId,
320
+ });
321
+ }
312
322
  }
313
- }
323
+ });
314
324
 
315
325
  return (await this.getIncident(input.id))!;
316
326
  }
@@ -324,20 +334,25 @@ export class IncidentService {
324
334
  ): Promise<IncidentUpdate> {
325
335
  const id = generateId();
326
336
 
327
- // If status change is provided, update the incident status
328
- if (input.statusChange) {
329
- await this.db
330
- .update(incidents)
331
- .set({ status: input.statusChange, updatedAt: new Date() })
332
- .where(eq(incidents.id, input.incidentId));
333
- }
337
+ // Atomic: the status flip and the timeline entry that records it must commit
338
+ // together. Without the transaction a failed insert left the incident in a
339
+ // new status with no update row explaining it (status/timeline divergence).
340
+ await this.db.transaction(async (tx) => {
341
+ // If status change is provided, update the incident status
342
+ if (input.statusChange) {
343
+ await tx
344
+ .update(incidents)
345
+ .set({ status: input.statusChange, updatedAt: new Date() })
346
+ .where(eq(incidents.id, input.incidentId));
347
+ }
334
348
 
335
- await this.db.insert(incidentUpdates).values({
336
- id,
337
- incidentId: input.incidentId,
338
- message: input.message,
339
- statusChange: input.statusChange,
340
- createdBy: userId,
349
+ await tx.insert(incidentUpdates).values({
350
+ id,
351
+ incidentId: input.incidentId,
352
+ message: input.message,
353
+ statusChange: input.statusChange,
354
+ createdBy: userId,
355
+ });
341
356
  });
342
357
 
343
358
  const [update] = await this.db
@@ -367,18 +382,21 @@ export class IncidentService {
367
382
 
368
383
  if (!existing) return undefined;
369
384
 
370
- await this.db
371
- .update(incidents)
372
- .set({ status: "resolved", updatedAt: new Date() })
373
- .where(eq(incidents.id, id));
385
+ // Atomic: mark resolved + write the resolution timeline entry together.
386
+ await this.db.transaction(async (tx) => {
387
+ await tx
388
+ .update(incidents)
389
+ .set({ status: "resolved", updatedAt: new Date() })
390
+ .where(eq(incidents.id, id));
374
391
 
375
- // Add resolution update entry
376
- await this.db.insert(incidentUpdates).values({
377
- id: generateId(),
378
- incidentId: id,
379
- message: message ?? "Incident resolved",
380
- statusChange: "resolved",
381
- createdBy: userId,
392
+ // Add resolution update entry
393
+ await tx.insert(incidentUpdates).values({
394
+ id: generateId(),
395
+ incidentId: id,
396
+ message: message ?? "Incident resolved",
397
+ statusChange: "resolved",
398
+ createdBy: userId,
399
+ });
382
400
  });
383
401
 
384
402
  return (await this.getIncident(id))!;
package/tsconfig.json CHANGED
@@ -4,6 +4,12 @@
4
4
  "src"
5
5
  ],
6
6
  "references": [
7
+ {
8
+ "path": "../ai-backend"
9
+ },
10
+ {
11
+ "path": "../ai-common"
12
+ },
7
13
  {
8
14
  "path": "../auth-common"
9
15
  },