@notionhq/workers 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.
- package/dist/capabilities/ai_connector.d.ts +97 -0
- package/dist/capabilities/ai_connector.d.ts.map +1 -0
- package/dist/capabilities/ai_connector.js +54 -0
- package/dist/capabilities/ai_connector.test.d.ts +2 -0
- package/dist/capabilities/ai_connector.test.d.ts.map +1 -0
- package/dist/capabilities/automation.d.ts.map +1 -1
- package/dist/capabilities/automation.js +10 -10
- package/dist/capabilities/output.d.ts +5 -0
- package/dist/capabilities/output.d.ts.map +1 -0
- package/dist/capabilities/output.js +10 -0
- package/dist/capabilities/stateful-capability.d.ts +30 -0
- package/dist/capabilities/stateful-capability.d.ts.map +1 -0
- package/dist/capabilities/stateful-capability.js +50 -0
- package/dist/capabilities/stateful-capability.test.d.ts +2 -0
- package/dist/capabilities/stateful-capability.test.d.ts.map +1 -0
- package/dist/capabilities/sync.d.ts +9 -19
- package/dist/capabilities/sync.d.ts.map +1 -1
- package/dist/capabilities/sync.js +13 -60
- package/dist/capabilities/tool.d.ts.map +1 -1
- package/dist/capabilities/tool.js +5 -20
- package/dist/capabilities/webhook.d.ts +101 -0
- package/dist/capabilities/webhook.d.ts.map +1 -0
- package/dist/capabilities/webhook.js +47 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/pacer_internal.d.ts +7 -0
- package/dist/pacer_internal.d.ts.map +1 -1
- package/dist/pacer_internal.js +17 -0
- package/dist/schema.d.ts +28 -11
- package/dist/schema.d.ts.map +1 -1
- package/dist/schema.js +2 -2
- package/dist/worker.d.ts +91 -9
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +111 -1
- package/package.json +1 -1
- package/src/capabilities/ai_connector.test.ts +341 -0
- package/src/capabilities/ai_connector.ts +194 -0
- package/src/capabilities/automation.ts +10 -6
- package/src/capabilities/output.ts +8 -0
- package/src/capabilities/stateful-capability.test.ts +25 -0
- package/src/capabilities/stateful-capability.ts +87 -0
- package/src/capabilities/sync.test.ts +89 -3
- package/src/capabilities/sync.ts +22 -86
- package/src/capabilities/tool.ts +5 -12
- package/src/capabilities/webhook.ts +148 -0
- package/src/index.ts +22 -0
- package/src/pacer_internal.ts +34 -0
- package/src/schema.ts +29 -12
- package/src/worker.ts +137 -10
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
afterEach,
|
|
3
|
+
beforeEach,
|
|
4
|
+
describe,
|
|
5
|
+
expect,
|
|
6
|
+
it,
|
|
7
|
+
type Mock,
|
|
8
|
+
vi,
|
|
9
|
+
} from "vitest";
|
|
10
|
+
import { ExecutionError } from "../error.js";
|
|
11
|
+
import { getPacerState } from "../pacer_internal.js";
|
|
12
|
+
import { Worker } from "../worker.js";
|
|
13
|
+
import type { AiConnectorExecutionResult } from "./ai_connector.js";
|
|
14
|
+
import { createAiConnectorCapability } from "./ai_connector.js";
|
|
15
|
+
|
|
16
|
+
describe("Worker.aiConnector", () => {
|
|
17
|
+
let stdoutSpy: Mock<typeof process.stdout.write>;
|
|
18
|
+
|
|
19
|
+
beforeEach(() => {
|
|
20
|
+
stdoutSpy = vi
|
|
21
|
+
.spyOn(process.stdout, "write")
|
|
22
|
+
.mockImplementation(() => true);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
afterEach(() => {
|
|
26
|
+
vi.restoreAllMocks();
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("registers an ai_connector capability with normalized config", () => {
|
|
30
|
+
const capability = createAiConnectorCapability("supportChats", {
|
|
31
|
+
aiConnectorId: "connector-record-id",
|
|
32
|
+
archetype: "chat",
|
|
33
|
+
mode: "incremental",
|
|
34
|
+
schedule: "15m",
|
|
35
|
+
execute: async () => ({
|
|
36
|
+
changes: [],
|
|
37
|
+
hasMore: false,
|
|
38
|
+
}),
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
expect(capability._tag).toBe("ai_connector");
|
|
42
|
+
expect(capability.config).toEqual({
|
|
43
|
+
aiConnectorId: "connector-record-id",
|
|
44
|
+
archetype: "chat",
|
|
45
|
+
mode: "incremental",
|
|
46
|
+
schedule: {
|
|
47
|
+
type: "interval",
|
|
48
|
+
intervalMs: 15 * 60 * 1000,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("wraps successful handler output in the success envelope", async () => {
|
|
54
|
+
const capability = createAiConnectorCapability("supportChats", {
|
|
55
|
+
aiConnectorId: "connector-record-id",
|
|
56
|
+
archetype: "chat",
|
|
57
|
+
execute: async () => ({
|
|
58
|
+
changes: [
|
|
59
|
+
{
|
|
60
|
+
type: "upsert",
|
|
61
|
+
key: "thread-1",
|
|
62
|
+
record: {
|
|
63
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
64
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
65
|
+
channel: {
|
|
66
|
+
id: "channel-1",
|
|
67
|
+
name: "Support",
|
|
68
|
+
},
|
|
69
|
+
messages: [
|
|
70
|
+
{
|
|
71
|
+
id: "message-1",
|
|
72
|
+
content: "Hello",
|
|
73
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
74
|
+
author: {
|
|
75
|
+
id: "user-1",
|
|
76
|
+
username: "alice",
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
],
|
|
83
|
+
hasMore: false,
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
await capability.handler();
|
|
88
|
+
|
|
89
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
90
|
+
expect.stringContaining(`"_tag":"success"`),
|
|
91
|
+
);
|
|
92
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
93
|
+
expect.stringContaining(`"thread-1"`),
|
|
94
|
+
);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("passes state through from legacy runtime userContext", async () => {
|
|
98
|
+
const execute = vi.fn(async (state?: { cursor: string }) => ({
|
|
99
|
+
changes: [],
|
|
100
|
+
hasMore: false,
|
|
101
|
+
...(state === undefined ? {} : { nextState: state }),
|
|
102
|
+
}));
|
|
103
|
+
const capability = createAiConnectorCapability("supportChats", {
|
|
104
|
+
aiConnectorId: "connector-record-id",
|
|
105
|
+
archetype: "chat",
|
|
106
|
+
execute,
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
await capability.handler(
|
|
110
|
+
{ userContext: { cursor: "cursor-1" } },
|
|
111
|
+
{ concreteOutput: true },
|
|
112
|
+
);
|
|
113
|
+
|
|
114
|
+
expect(execute).toHaveBeenCalledWith(
|
|
115
|
+
{ cursor: "cursor-1" },
|
|
116
|
+
expect.anything(),
|
|
117
|
+
);
|
|
118
|
+
expect(stdoutSpy).not.toHaveBeenCalled();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("hydrates and persists pacer state", async () => {
|
|
122
|
+
const worker = new Worker();
|
|
123
|
+
const pacer = worker.pacer("api", {
|
|
124
|
+
allowedRequests: 1,
|
|
125
|
+
intervalMs: 1000,
|
|
126
|
+
});
|
|
127
|
+
const capability = worker.aiConnector("supportChats", {
|
|
128
|
+
aiConnectorId: "connector-record-id",
|
|
129
|
+
archetype: "chat",
|
|
130
|
+
execute: async () => {
|
|
131
|
+
await pacer.wait();
|
|
132
|
+
return {
|
|
133
|
+
changes: [],
|
|
134
|
+
hasMore: false,
|
|
135
|
+
};
|
|
136
|
+
},
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const before = Date.now() - 1000;
|
|
140
|
+
const result = await capability.handler(
|
|
141
|
+
{
|
|
142
|
+
pacers: {
|
|
143
|
+
api: {
|
|
144
|
+
lastScheduledAtMs: before,
|
|
145
|
+
allowedRequests: 1,
|
|
146
|
+
intervalMs: 1000,
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
{ concreteOutput: true },
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
expect(result).toEqual({
|
|
154
|
+
changes: [],
|
|
155
|
+
hasMore: false,
|
|
156
|
+
nextPacerStates: {
|
|
157
|
+
api: {
|
|
158
|
+
lastScheduledAtMs: expect.any(Number),
|
|
159
|
+
allowedRequests: 1,
|
|
160
|
+
intervalMs: 1000,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
164
|
+
expect(
|
|
165
|
+
result.nextPacerStates.api?.lastScheduledAtMs,
|
|
166
|
+
).toBeGreaterThanOrEqual(before);
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
describe("pacer local fallback", () => {
|
|
170
|
+
it("seeds pacer state from declarations when runtime context has no pacers", async () => {
|
|
171
|
+
const worker = new Worker();
|
|
172
|
+
const pacer = worker.pacer("myApi", {
|
|
173
|
+
allowedRequests: 10,
|
|
174
|
+
intervalMs: 1000,
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
let pacerWaitCalled = false;
|
|
178
|
+
const capability = worker.aiConnector("supportChats", {
|
|
179
|
+
aiConnectorId: "connector-record-id",
|
|
180
|
+
archetype: "chat",
|
|
181
|
+
execute: async () => {
|
|
182
|
+
await pacer.wait();
|
|
183
|
+
pacerWaitCalled = true;
|
|
184
|
+
return { changes: [], hasMore: false };
|
|
185
|
+
},
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// No runtimeContext → no pacers from server → should fall back to declarations
|
|
189
|
+
await capability.handler(undefined, { concreteOutput: true });
|
|
190
|
+
|
|
191
|
+
expect(pacerWaitCalled).toBe(true);
|
|
192
|
+
const state = getPacerState();
|
|
193
|
+
expect(state.pacers.myApi).toBeDefined();
|
|
194
|
+
expect(state.pacers.myApi?.allowedRequests).toBe(10);
|
|
195
|
+
expect(state.pacers.myApi?.intervalMs).toBe(1000);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("uses server-provided pacers when available", async () => {
|
|
199
|
+
const worker = new Worker();
|
|
200
|
+
const pacer = worker.pacer("myApi", {
|
|
201
|
+
allowedRequests: 10,
|
|
202
|
+
intervalMs: 1000,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
let pacerWaitCalled = false;
|
|
206
|
+
const capability = worker.aiConnector("supportChats", {
|
|
207
|
+
aiConnectorId: "connector-record-id",
|
|
208
|
+
archetype: "chat",
|
|
209
|
+
execute: async () => {
|
|
210
|
+
await pacer.wait();
|
|
211
|
+
pacerWaitCalled = true;
|
|
212
|
+
return { changes: [], hasMore: false };
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Server provides apportioned budget (e.g., 5 instead of 10)
|
|
217
|
+
await capability.handler(
|
|
218
|
+
{
|
|
219
|
+
pacers: {
|
|
220
|
+
myApi: {
|
|
221
|
+
lastScheduledAtMs: 0,
|
|
222
|
+
allowedRequests: 5,
|
|
223
|
+
intervalMs: 1000,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
{ concreteOutput: true },
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
expect(pacerWaitCalled).toBe(true);
|
|
231
|
+
// Should use server-provided value, not the declaration
|
|
232
|
+
expect(getPacerState().pacers.myApi?.allowedRequests).toBe(5);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("wraps handler errors in the shared error envelope", async () => {
|
|
237
|
+
const capability = createAiConnectorCapability("supportChats", {
|
|
238
|
+
aiConnectorId: "connector-record-id",
|
|
239
|
+
archetype: "chat",
|
|
240
|
+
execute: async () => {
|
|
241
|
+
throw new Error("failed");
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
await expect(capability.handler()).rejects.toThrow(ExecutionError);
|
|
246
|
+
expect(stdoutSpy).toHaveBeenCalledWith(
|
|
247
|
+
expect.stringContaining(`"_tag":"error"`),
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("accepts chat records at compile time", () => {
|
|
252
|
+
const result = {
|
|
253
|
+
changes: [
|
|
254
|
+
{
|
|
255
|
+
type: "upsert",
|
|
256
|
+
key: "thread-1",
|
|
257
|
+
record: {
|
|
258
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
259
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
260
|
+
channel: {
|
|
261
|
+
id: "channel-1",
|
|
262
|
+
name: "Support",
|
|
263
|
+
},
|
|
264
|
+
messages: [
|
|
265
|
+
{
|
|
266
|
+
id: "message-1",
|
|
267
|
+
content: "Hello",
|
|
268
|
+
timestamp: "2026-01-01T00:00:00Z",
|
|
269
|
+
author: {
|
|
270
|
+
id: "user-1",
|
|
271
|
+
username: "alice",
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
],
|
|
275
|
+
},
|
|
276
|
+
},
|
|
277
|
+
],
|
|
278
|
+
hasMore: false,
|
|
279
|
+
} satisfies AiConnectorExecutionResult<"chat">;
|
|
280
|
+
|
|
281
|
+
expect(result.hasMore).toBe(false);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("accepts project records with required blocks at compile time", () => {
|
|
285
|
+
const result = {
|
|
286
|
+
changes: [
|
|
287
|
+
{
|
|
288
|
+
type: "upsert",
|
|
289
|
+
key: "project-1",
|
|
290
|
+
record: {
|
|
291
|
+
name: "Roadmap",
|
|
292
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
293
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
294
|
+
blocks: [
|
|
295
|
+
{
|
|
296
|
+
id: "block-1",
|
|
297
|
+
type: "paragraph",
|
|
298
|
+
content: "Ship the roadmap",
|
|
299
|
+
},
|
|
300
|
+
],
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
],
|
|
304
|
+
hasMore: false,
|
|
305
|
+
} satisfies AiConnectorExecutionResult<"project">;
|
|
306
|
+
|
|
307
|
+
expect(result.changes).toHaveLength(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("rejects project records without blocks at compile time", () => {
|
|
311
|
+
const changes = [
|
|
312
|
+
{
|
|
313
|
+
type: "upsert",
|
|
314
|
+
key: "project-1",
|
|
315
|
+
// @ts-expect-error Project records must include blocks.
|
|
316
|
+
record: {
|
|
317
|
+
name: "Roadmap",
|
|
318
|
+
createdAt: "2026-01-01T00:00:00Z",
|
|
319
|
+
updatedAt: "2026-01-01T00:00:00Z",
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
] satisfies AiConnectorExecutionResult<"project">["changes"];
|
|
323
|
+
|
|
324
|
+
expect(changes).toHaveLength(1);
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
it("rejects replace mode at compile time", () => {
|
|
328
|
+
createAiConnectorCapability("supportChats", {
|
|
329
|
+
aiConnectorId: "connector-record-id",
|
|
330
|
+
archetype: "chat",
|
|
331
|
+
// @ts-expect-error AI connector capabilities are incremental-only for now.
|
|
332
|
+
mode: "replace",
|
|
333
|
+
execute: async () => ({
|
|
334
|
+
changes: [],
|
|
335
|
+
hasMore: false,
|
|
336
|
+
}),
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
expect(true).toBe(true);
|
|
340
|
+
});
|
|
341
|
+
});
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { ExecutionError } from "../error.js";
|
|
2
|
+
import {
|
|
3
|
+
getPacerState,
|
|
4
|
+
initPacerState,
|
|
5
|
+
type PacerDeclaration,
|
|
6
|
+
type PacerEntry,
|
|
7
|
+
} from "../pacer_internal.js";
|
|
8
|
+
import type { HandlerOptions, Schedule } from "../types.js";
|
|
9
|
+
import type { CapabilityContext } from "./context.js";
|
|
10
|
+
import { createCapabilityContext } from "./context.js";
|
|
11
|
+
import { writeOutput } from "./output.js";
|
|
12
|
+
import {
|
|
13
|
+
parseSchedule,
|
|
14
|
+
type StatefulCapabilityMode,
|
|
15
|
+
type StatefulExecutionResult,
|
|
16
|
+
type RuntimeContext as StatefulRuntimeContext,
|
|
17
|
+
} from "./stateful-capability.js";
|
|
18
|
+
|
|
19
|
+
export type AiConnectorArchetype = "chat" | "project";
|
|
20
|
+
/**
|
|
21
|
+
* AI connector syncs are incremental-only for now.
|
|
22
|
+
*
|
|
23
|
+
* Unlike collection syncs, AI connector ingestion does not yet support
|
|
24
|
+
* replace-style reconciliation semantics, so exposing `replace` here would
|
|
25
|
+
* encourage a misleading reset-and-replay model.
|
|
26
|
+
*/
|
|
27
|
+
export type AiConnectorMode = Exclude<StatefulCapabilityMode, "replace">;
|
|
28
|
+
|
|
29
|
+
export type AiConnectorChatAuthor = {
|
|
30
|
+
id: string;
|
|
31
|
+
username: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type AiConnectorChatMessage = {
|
|
35
|
+
id: string;
|
|
36
|
+
content: string;
|
|
37
|
+
timestamp: string;
|
|
38
|
+
editedTimestamp?: string;
|
|
39
|
+
author: AiConnectorChatAuthor;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export type AiConnectorChatChannel = {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type AiConnectorChatRecord = {
|
|
48
|
+
createdAt: string;
|
|
49
|
+
updatedAt: string;
|
|
50
|
+
externalUrl?: string;
|
|
51
|
+
channel: AiConnectorChatChannel;
|
|
52
|
+
threadId?: string;
|
|
53
|
+
messages: AiConnectorChatMessage[];
|
|
54
|
+
chatType?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type AiConnectorProjectBlock = {
|
|
58
|
+
id: string;
|
|
59
|
+
type: string;
|
|
60
|
+
content: string;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type AiConnectorProjectRecord = {
|
|
64
|
+
name: string;
|
|
65
|
+
createdAt: string;
|
|
66
|
+
updatedAt: string;
|
|
67
|
+
externalUrl?: string;
|
|
68
|
+
priority?: string;
|
|
69
|
+
teams?: string[];
|
|
70
|
+
targetEnd?: string;
|
|
71
|
+
createdBy?: {
|
|
72
|
+
id: string;
|
|
73
|
+
};
|
|
74
|
+
blocks: AiConnectorProjectBlock[];
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export type AiConnectorRecordByArchetype = {
|
|
78
|
+
chat: AiConnectorChatRecord;
|
|
79
|
+
project: AiConnectorProjectRecord;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export type AiConnectorChangeUpsert<A extends AiConnectorArchetype> = {
|
|
83
|
+
type: "upsert";
|
|
84
|
+
key: string;
|
|
85
|
+
record: AiConnectorRecordByArchetype[A];
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
export type AiConnectorChange<A extends AiConnectorArchetype> =
|
|
89
|
+
AiConnectorChangeUpsert<A>;
|
|
90
|
+
|
|
91
|
+
export type AiConnectorExecutionResult<
|
|
92
|
+
A extends AiConnectorArchetype,
|
|
93
|
+
State = unknown,
|
|
94
|
+
> = StatefulExecutionResult<AiConnectorChange<A>, State>;
|
|
95
|
+
|
|
96
|
+
export type AiConnectorConfiguration<
|
|
97
|
+
A extends AiConnectorArchetype,
|
|
98
|
+
State = unknown,
|
|
99
|
+
> = {
|
|
100
|
+
/**
|
|
101
|
+
* The Notion record ID of an existing custom connector in the target workspace.
|
|
102
|
+
*
|
|
103
|
+
* This must be the connector record UUID that Notion created, not an external
|
|
104
|
+
* source identifier.
|
|
105
|
+
*/
|
|
106
|
+
aiConnectorId: string;
|
|
107
|
+
archetype: A;
|
|
108
|
+
mode?: AiConnectorMode;
|
|
109
|
+
schedule?: Schedule;
|
|
110
|
+
execute: (
|
|
111
|
+
state: State | undefined,
|
|
112
|
+
context: CapabilityContext,
|
|
113
|
+
) => Promise<AiConnectorExecutionResult<A, State>>;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
export type AiConnectorCapability = ReturnType<
|
|
117
|
+
typeof createAiConnectorCapability
|
|
118
|
+
>;
|
|
119
|
+
|
|
120
|
+
type NormalizedAiConnectorConfiguration<A extends AiConnectorArchetype> = {
|
|
121
|
+
aiConnectorId: string;
|
|
122
|
+
archetype: A;
|
|
123
|
+
mode?: AiConnectorMode;
|
|
124
|
+
schedule?: ReturnType<typeof parseSchedule>;
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
type AiConnectorHandlerResult<A extends AiConnectorArchetype, State> = {
|
|
128
|
+
changes: AiConnectorChange<A>[];
|
|
129
|
+
hasMore: boolean;
|
|
130
|
+
nextUserContext?: State;
|
|
131
|
+
nextPacerStates: Record<string, PacerEntry>;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
export function createAiConnectorCapability<
|
|
135
|
+
A extends AiConnectorArchetype,
|
|
136
|
+
State = unknown,
|
|
137
|
+
>(
|
|
138
|
+
key: string,
|
|
139
|
+
configuration: AiConnectorConfiguration<A, State>,
|
|
140
|
+
pacerDeclarations?: readonly PacerDeclaration[],
|
|
141
|
+
) {
|
|
142
|
+
return {
|
|
143
|
+
_tag: "ai_connector" as const,
|
|
144
|
+
key,
|
|
145
|
+
config: {
|
|
146
|
+
aiConnectorId: configuration.aiConnectorId,
|
|
147
|
+
archetype: configuration.archetype,
|
|
148
|
+
...(configuration.mode === undefined ? {} : { mode: configuration.mode }),
|
|
149
|
+
...(configuration.schedule === undefined
|
|
150
|
+
? {}
|
|
151
|
+
: { schedule: parseSchedule(configuration.schedule) }),
|
|
152
|
+
} satisfies NormalizedAiConnectorConfiguration<A>,
|
|
153
|
+
async handler(
|
|
154
|
+
runtimeContext?: StatefulRuntimeContext<State>,
|
|
155
|
+
options?: HandlerOptions,
|
|
156
|
+
) {
|
|
157
|
+
const capabilityContext = createCapabilityContext();
|
|
158
|
+
const state = runtimeContext?.state ?? runtimeContext?.userContext;
|
|
159
|
+
|
|
160
|
+
initPacerState(runtimeContext?.pacers, pacerDeclarations);
|
|
161
|
+
|
|
162
|
+
let executionResult: AiConnectorExecutionResult<A, State>;
|
|
163
|
+
try {
|
|
164
|
+
executionResult = await configuration.execute(state, capabilityContext);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
const error = new ExecutionError(err);
|
|
167
|
+
if (!options?.concreteOutput) {
|
|
168
|
+
writeOutput({
|
|
169
|
+
_tag: "error",
|
|
170
|
+
error: { name: error.name, message: error.message },
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
throw error;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const result = {
|
|
177
|
+
changes: executionResult.changes,
|
|
178
|
+
hasMore: executionResult.hasMore,
|
|
179
|
+
...(executionResult.nextState === undefined
|
|
180
|
+
? {}
|
|
181
|
+
: { nextUserContext: executionResult.nextState }),
|
|
182
|
+
nextPacerStates: getPacerState().pacers,
|
|
183
|
+
} satisfies AiConnectorHandlerResult<A, State>;
|
|
184
|
+
|
|
185
|
+
if (options?.concreteOutput) {
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
writeOutput({ _tag: "success", value: result });
|
|
190
|
+
|
|
191
|
+
return result;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -2,6 +2,7 @@ import { ExecutionError } from "../error.js";
|
|
|
2
2
|
import type { HandlerOptions } from "../types.js";
|
|
3
3
|
import type { CapabilityContext } from "./context.js";
|
|
4
4
|
import { createCapabilityContext } from "./context.js";
|
|
5
|
+
import { writeOutput } from "./output.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Event provided to automation execute functions
|
|
@@ -110,17 +111,20 @@ export function createAutomationCapability(
|
|
|
110
111
|
if (options?.concreteOutput) {
|
|
111
112
|
return { status: "success" };
|
|
112
113
|
} else {
|
|
113
|
-
|
|
114
|
-
`\n<__notion_output__>${JSON.stringify({ _tag: "success", value: { status: "success" } })}</__notion_output__>\n`,
|
|
115
|
-
);
|
|
114
|
+
writeOutput({ _tag: "success", value: { status: "success" } });
|
|
116
115
|
}
|
|
117
116
|
} catch (err) {
|
|
118
117
|
const error = new ExecutionError(err);
|
|
119
118
|
|
|
120
119
|
if (!options?.concreteOutput) {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
120
|
+
writeOutput({
|
|
121
|
+
_tag: "error",
|
|
122
|
+
error: {
|
|
123
|
+
name: error.name,
|
|
124
|
+
message: error.message,
|
|
125
|
+
trace: error.stack,
|
|
126
|
+
},
|
|
127
|
+
});
|
|
124
128
|
}
|
|
125
129
|
|
|
126
130
|
throw error;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { parseSchedule } from "./stateful-capability.js";
|
|
3
|
+
|
|
4
|
+
describe("stateful capability shared primitives", () => {
|
|
5
|
+
it("parses interval schedules into normalized backend config", () => {
|
|
6
|
+
expect(parseSchedule("15m")).toEqual({
|
|
7
|
+
type: "interval",
|
|
8
|
+
intervalMs: 15 * 60 * 1000,
|
|
9
|
+
});
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("parses continuous schedules directly", () => {
|
|
13
|
+
expect(parseSchedule("continuous")).toEqual({ type: "continuous" });
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("parses manual schedules directly", () => {
|
|
17
|
+
expect(parseSchedule("manual")).toEqual({ type: "manual" });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("rejects intervals longer than the supported maximum", () => {
|
|
21
|
+
expect(() => parseSchedule("8d")).toThrow(
|
|
22
|
+
'Schedule interval must be at most 7 days. Got: "8d"',
|
|
23
|
+
);
|
|
24
|
+
});
|
|
25
|
+
});
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { unreachable } from "../error.js";
|
|
2
|
+
import type { PacerEntry } from "../pacer_internal.js";
|
|
3
|
+
import type { Schedule, SyncSchedule, TimeUnit } from "../types.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Shared lifecycle mode used by sync-like capabilities.
|
|
7
|
+
*/
|
|
8
|
+
export type StatefulCapabilityMode = "replace" | "incremental";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Base runtime context shared by sync-like capabilities.
|
|
12
|
+
*/
|
|
13
|
+
export type RuntimeContext<UserContext = unknown> = {
|
|
14
|
+
/** The user-defined/-controlled state (cursor, pagination state, etc.) */
|
|
15
|
+
state?: UserContext;
|
|
16
|
+
/** Legacy field for user-defined/-controlled state. */
|
|
17
|
+
userContext?: UserContext;
|
|
18
|
+
/** Pacer state from the server for rate limiting. */
|
|
19
|
+
pacers?: Record<string, PacerEntry>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Shared execution result returned from stateful capability execute functions.
|
|
24
|
+
*/
|
|
25
|
+
export type StatefulExecutionResult<Change, State = unknown> = {
|
|
26
|
+
changes: Change[];
|
|
27
|
+
hasMore: boolean;
|
|
28
|
+
nextState?: State;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const MS_PER_MINUTE = 60 * 1000;
|
|
32
|
+
const MS_PER_HOUR = 60 * MS_PER_MINUTE;
|
|
33
|
+
const MS_PER_DAY = 24 * MS_PER_HOUR;
|
|
34
|
+
|
|
35
|
+
const MIN_INTERVAL_MS = MS_PER_MINUTE; // 1m
|
|
36
|
+
const MAX_INTERVAL_MS = 7 * MS_PER_DAY; // 7d
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Parses a user-friendly schedule string into the normalized backend format.
|
|
40
|
+
*/
|
|
41
|
+
export function parseSchedule(schedule: Schedule): SyncSchedule {
|
|
42
|
+
if (schedule === "continuous") {
|
|
43
|
+
return { type: "continuous" };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (schedule === "manual") {
|
|
47
|
+
return { type: "manual" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const match = schedule.match(/^(\d+)(m|h|d)$/);
|
|
51
|
+
if (!match || !match[1] || !match[2]) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`Invalid schedule format: "${schedule}". Use "continuous" or an interval like "30m", "1h", "1d".`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const value = parseInt(match[1], 10);
|
|
58
|
+
const unit = match[2] as TimeUnit;
|
|
59
|
+
|
|
60
|
+
let intervalMs: number;
|
|
61
|
+
switch (unit) {
|
|
62
|
+
case "m":
|
|
63
|
+
intervalMs = value * MS_PER_MINUTE;
|
|
64
|
+
break;
|
|
65
|
+
case "h":
|
|
66
|
+
intervalMs = value * MS_PER_HOUR;
|
|
67
|
+
break;
|
|
68
|
+
case "d":
|
|
69
|
+
intervalMs = value * MS_PER_DAY;
|
|
70
|
+
break;
|
|
71
|
+
default:
|
|
72
|
+
unreachable(unit);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (intervalMs < MIN_INTERVAL_MS) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Schedule interval must be at least 1 minute. Got: "${schedule}"`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
if (intervalMs > MAX_INTERVAL_MS) {
|
|
81
|
+
throw new Error(
|
|
82
|
+
`Schedule interval must be at most 7 days. Got: "${schedule}"`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return { type: "interval", intervalMs };
|
|
87
|
+
}
|