@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 +120 -0
- package/drizzle/0003_careful_ken_ellis.sql +10 -0
- package/drizzle/meta/0003_snapshot.json +300 -0
- package/drizzle/meta/_journal.json +7 -0
- package/package.json +22 -19
- package/src/ai/incident-add-link.test.ts +62 -0
- package/src/ai/incident-add-link.ts +63 -0
- package/src/ai/incident-add-update.test.ts +56 -0
- package/src/ai/incident-add-update.ts +66 -0
- package/src/ai/incident-create.test.ts +70 -0
- package/src/ai/incident-create.ts +65 -0
- package/src/ai/incident-delete.test.ts +88 -0
- package/src/ai/incident-delete.ts +76 -0
- package/src/ai/incident-get.projection.test.ts +43 -0
- package/src/ai/incident-remove-link.test.ts +56 -0
- package/src/ai/incident-remove-link.ts +69 -0
- package/src/ai/incident-resolve.test.ts +61 -0
- package/src/ai/incident-resolve.ts +69 -0
- package/src/ai/incident-update.test.ts +87 -0
- package/src/ai/incident-update.ts +94 -0
- package/src/ai/register-ai-tools.ts +33 -0
- package/src/ai-projection.test.ts +38 -0
- package/src/automations.test.ts +2 -0
- package/src/index.ts +44 -0
- package/src/schema.ts +20 -9
- package/src/service.test.ts +6 -0
- package/src/service.ts +84 -66
- package/tsconfig.json +6 -0
|
@@ -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
|
+
});
|
package/src/automations.test.ts
CHANGED
|
@@ -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(
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
+
);
|
package/src/service.test.ts
CHANGED
|
@@ -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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
incidentId
|
|
310
|
-
|
|
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
|
-
//
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
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))!;
|