Skip to content

Commit c1c466d

Browse files
committed
refactor(core): split event task tools
1 parent ac0ac2e commit c1c466d

7 files changed

Lines changed: 638 additions & 558 deletions

File tree

Lines changed: 299 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,299 @@
1+
import { createHash } from "node:crypto";
2+
import { z } from "zod";
3+
import { getDb } from "@/chat/db";
4+
import { getEventTask } from "@/chat/event-tasks/store";
5+
import {
6+
eventTaskPrincipalSchema,
7+
type EventTask,
8+
type EventTaskConversationAccess,
9+
type EventTaskPrincipal,
10+
} from "@/chat/event-tasks/types";
11+
import {
12+
eventNamespaceSchema,
13+
pluginSupportsEvent,
14+
registeredEventTypeSchema,
15+
registeredResourceTypeSchema,
16+
type ResourceEventCatalog,
17+
} from "@/chat/resource-events/catalog";
18+
import { juniorToolResultSchema } from "@/chat/tool-support/structured-result";
19+
import { ToolInputError } from "@/chat/tools/execution/tool-input-error";
20+
import type { ToolRuntimeContext } from "@/chat/tools/types";
21+
22+
export const MAX_LISTED_EVENT_TASKS = 50;
23+
24+
const compactEventTaskResultSchema = z
25+
.object({
26+
id: z.string().min(1),
27+
status: z.enum(["active", "deleted"]),
28+
task: z.string().min(1),
29+
namespace: z.string().min(1),
30+
identifier: z.string().min(1),
31+
resourceType: z.string().min(1),
32+
label: z.string().min(1),
33+
events: z.array(z.string().min(1)).min(1),
34+
credentialMode: z.enum(["system", "creator"]),
35+
createdBy: eventTaskPrincipalSchema,
36+
triggerAvailable: z.boolean(),
37+
})
38+
.strict();
39+
40+
const eventTaskResultDataSchema = z
41+
.object({ task: compactEventTaskResultSchema })
42+
.strict();
43+
44+
export const eventTaskToolResultSchema = juniorToolResultSchema
45+
.extend({
46+
ok: z.literal(true),
47+
status: z.literal("success"),
48+
data: eventTaskResultDataSchema,
49+
task: compactEventTaskResultSchema,
50+
})
51+
.strict();
52+
53+
const eventTaskListResultDataSchema = z
54+
.object({
55+
tasks: z.array(compactEventTaskResultSchema),
56+
truncated: z.boolean(),
57+
})
58+
.strict();
59+
60+
export const eventTaskListToolResultSchema = juniorToolResultSchema
61+
.extend({
62+
ok: z.literal(true),
63+
status: z.literal("success"),
64+
data: eventTaskListResultDataSchema,
65+
tasks: z.array(compactEventTaskResultSchema),
66+
truncated: z.boolean(),
67+
})
68+
.strict();
69+
70+
/** Build the validated resource-event trigger accepted by event-task tools. */
71+
export function eventTaskTriggerSchema(catalog: ResourceEventCatalog) {
72+
return z
73+
.object({
74+
namespace: eventNamespaceSchema(catalog),
75+
identifier: z.string().trim().min(1),
76+
resourceType: registeredResourceTypeSchema(catalog),
77+
label: z.string().trim().min(1).max(500),
78+
events: z.array(registeredEventTypeSchema(catalog)).min(1),
79+
})
80+
.strict()
81+
.superRefine((trigger, context) => {
82+
trigger.events.forEach((eventType, index) => {
83+
if (
84+
!pluginSupportsEvent(
85+
catalog,
86+
trigger.namespace,
87+
trigger.resourceType,
88+
eventType,
89+
)
90+
) {
91+
context.addIssue({
92+
code: "custom",
93+
message: `Resource type "${trigger.namespace}:${trigger.resourceType}" does not support event "${eventType}".`,
94+
path: ["events", index],
95+
});
96+
}
97+
});
98+
});
99+
}
100+
101+
/** Reject an event-task trigger that is no longer supported by the catalog. */
102+
export function requireSupportedEventTaskTrigger(
103+
catalog: ResourceEventCatalog,
104+
trigger: { events: string[]; namespace: string; resourceType: string },
105+
): void {
106+
for (const eventType of trigger.events) {
107+
if (
108+
!pluginSupportsEvent(
109+
catalog,
110+
trigger.namespace,
111+
trigger.resourceType,
112+
eventType,
113+
)
114+
) {
115+
throw new ToolInputError(
116+
`Resource type "${trigger.namespace}:${trigger.resourceType}" does not support event "${eventType}".`,
117+
);
118+
}
119+
}
120+
}
121+
122+
/** Require the active Slack authority used for event-task management. */
123+
export function requireEventTaskSlackContext(context: ToolRuntimeContext) {
124+
if (
125+
context.source.platform !== "slack" ||
126+
context.destination.platform !== "slack" ||
127+
context.actor?.platform !== "slack" ||
128+
context.actor.teamId !== context.destination.teamId
129+
) {
130+
throw new ToolInputError(
131+
"Event tasks require an active Slack channel or DM and actor.",
132+
);
133+
}
134+
return {
135+
actor: context.actor,
136+
destination: context.destination,
137+
source: context.source,
138+
};
139+
}
140+
141+
/** Project a Slack actor into the durable event-task creator identity. */
142+
export function eventTaskPrincipal(
143+
actor: Extract<
144+
NonNullable<ToolRuntimeContext["actor"]>,
145+
{ platform: "slack" }
146+
>,
147+
): EventTaskPrincipal {
148+
return {
149+
slackUserId: actor.userId,
150+
...(actor.fullName ? { fullName: actor.fullName } : {}),
151+
...(actor.userName ? { userName: actor.userName } : {}),
152+
};
153+
}
154+
155+
/** Capture the durable access classification of the active Slack destination. */
156+
export function eventTaskConversationAccess(
157+
channelId: string,
158+
sourceVisibility: "private" | "public",
159+
): EventTaskConversationAccess {
160+
if (channelId.startsWith("D")) {
161+
return { audience: "direct", visibility: "private" };
162+
}
163+
if (channelId.startsWith("G")) {
164+
return { audience: "group", visibility: "private" };
165+
}
166+
return {
167+
audience: "channel",
168+
visibility: sourceVisibility,
169+
};
170+
}
171+
172+
/** Return whether an event task belongs to the active Slack destination. */
173+
export function eventTaskMatchesDestination(
174+
task: EventTask,
175+
destination: { channelId: string; teamId: string },
176+
): boolean {
177+
return (
178+
task.destination.teamId === destination.teamId &&
179+
task.destination.channelId === destination.channelId
180+
);
181+
}
182+
183+
/** Load one active task only when it belongs to this Slack channel or DM. */
184+
export async function writableEventTask(
185+
context: ToolRuntimeContext,
186+
id: string,
187+
): Promise<EventTask> {
188+
const { destination } = requireEventTaskSlackContext(context);
189+
const task = await getEventTask(getDb(), id);
190+
if (
191+
!task ||
192+
task.status === "deleted" ||
193+
!eventTaskMatchesDestination(task, destination)
194+
) {
195+
throw new ToolInputError(
196+
"Event task was not found in this Slack channel or DM.",
197+
);
198+
}
199+
return task;
200+
}
201+
202+
/** Build a retry-stable task id scoped to actor, destination, and tool call. */
203+
export function buildEventTaskId(args: {
204+
channelId: string;
205+
teamId: string;
206+
toolCallId: string | undefined;
207+
userId: string;
208+
}): string {
209+
const toolCallId = args.toolCallId?.trim();
210+
if (!toolCallId) {
211+
throw new Error("Event task creation requires a tool-call identity.");
212+
}
213+
const digest = createHash("sha256")
214+
.update(
215+
JSON.stringify({
216+
actor: args.userId,
217+
channel: args.channelId,
218+
operation: toolCallId,
219+
team: args.teamId,
220+
}),
221+
)
222+
.digest("hex")
223+
.slice(0, 32);
224+
return `evt_${digest}`;
225+
}
226+
227+
/** Normalize and deduplicate the event names stored on an event task. */
228+
export function cleanEventTaskEvents(events: string[]): string[] {
229+
const clean = [
230+
...new Set(events.map((event) => event.trim()).filter(Boolean)),
231+
];
232+
if (clean.length === 0) {
233+
throw new ToolInputError("At least one event is required.");
234+
}
235+
return clean;
236+
}
237+
238+
/** Return whether an edit changes the task's executable event source. */
239+
export function changesEventTaskTrigger(
240+
current: EventTask["trigger"],
241+
next: EventTask["trigger"],
242+
): boolean {
243+
const currentEvents = [...current.events].sort();
244+
const nextEvents = [...next.events].sort();
245+
return (
246+
current.namespace !== next.namespace ||
247+
current.identifier !== next.identifier ||
248+
currentEvents.length !== nextEvents.length ||
249+
currentEvents.some((event, index) => event !== nextEvents[index])
250+
);
251+
}
252+
253+
function triggerAvailable(
254+
task: EventTask,
255+
catalog: ResourceEventCatalog,
256+
): boolean {
257+
return task.trigger.events.every((eventType) =>
258+
pluginSupportsEvent(
259+
catalog,
260+
task.trigger.namespace,
261+
task.trigger.resourceType,
262+
eventType,
263+
),
264+
);
265+
}
266+
267+
/** Project an event task into the bounded tool-result shape. */
268+
export function compactEventTask(
269+
task: EventTask,
270+
catalog: ResourceEventCatalog,
271+
) {
272+
return compactEventTaskResultSchema.parse({
273+
id: task.id,
274+
status: task.status,
275+
task: task.task.text,
276+
namespace: task.trigger.namespace,
277+
identifier: task.trigger.identifier,
278+
resourceType: task.trigger.resourceType,
279+
label: task.trigger.label,
280+
events: task.trigger.events,
281+
credentialMode: task.credentialMode,
282+
createdBy: task.createdBy,
283+
triggerAvailable: triggerAvailable(task, catalog),
284+
});
285+
}
286+
287+
/** Return the standard successful event-task tool result. */
288+
export function eventTaskSuccess(
289+
task: EventTask,
290+
catalog: ResourceEventCatalog,
291+
) {
292+
const details = { task: compactEventTask(task, catalog) };
293+
return {
294+
ok: true as const,
295+
status: "success" as const,
296+
data: details,
297+
...details,
298+
};
299+
}

0 commit comments

Comments
 (0)