@poncho-ai/harness 0.37.2 → 0.39.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/.turbo/turbo-build.log +5 -5
- package/CHANGELOG.md +216 -0
- package/dist/index.d.ts +397 -34
- package/dist/index.js +2228 -130
- package/package.json +2 -2
- package/src/harness.ts +48 -72
- package/src/index.ts +1 -0
- package/src/local-tools.ts +4 -3
- package/src/mcp.ts +9 -6
- package/src/orchestrator/continuation.ts +13 -0
- package/src/orchestrator/history.ts +69 -0
- package/src/orchestrator/index.ts +54 -0
- package/src/orchestrator/orchestrator.ts +1535 -0
- package/src/orchestrator/subagents.ts +27 -0
- package/src/orchestrator/turn.ts +367 -0
- package/src/reminder-store.ts +183 -16
- package/src/reminder-tools.ts +102 -6
- package/src/state.ts +127 -3
- package/src/storage/engine.ts +29 -10
- package/src/storage/memory-engine.ts +74 -10
- package/src/storage/postgres-engine.ts +1 -0
- package/src/storage/schema.ts +21 -0
- package/src/storage/sql-dialect.ts +294 -23
- package/src/storage/sqlite-engine.ts +1 -0
- package/src/storage/store-adapters.ts +17 -11
- package/src/telemetry.ts +10 -7
- package/src/upload-store.ts +7 -4
- package/src/vfs/bash-manager.ts +16 -2
- package/src/vfs/edit-file-tool.ts +9 -8
- package/src/vfs/read-file-tool.ts +14 -15
- package/src/vfs/write-file-tool.ts +6 -5
- package/test/orchestrator.test.ts +176 -0
- package/test/reminder-store.test.ts +193 -4
- package/test/state.test.ts +21 -0
- package/test/storage-engine.test.ts +80 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { AgentHarness } from "../harness.js";
|
|
2
|
+
import type { Conversation } from "../state.js";
|
|
3
|
+
|
|
4
|
+
// ── Types ──
|
|
5
|
+
|
|
6
|
+
export type ActiveSubagentRun = {
|
|
7
|
+
abortController: AbortController;
|
|
8
|
+
harness: AgentHarness;
|
|
9
|
+
parentConversationId: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type PendingSubagentApproval = {
|
|
13
|
+
resolve: (decidedApprovals: NonNullable<Conversation["pendingApprovals"]>) => void;
|
|
14
|
+
childHarness: AgentHarness;
|
|
15
|
+
checkpoint: NonNullable<Conversation["pendingApprovals"]>[number];
|
|
16
|
+
childConversationId: string;
|
|
17
|
+
parentConversationId: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// ── Constants ──
|
|
21
|
+
|
|
22
|
+
/** root -> L1 -> L2 = 3 levels; L2 cannot spawn further */
|
|
23
|
+
export const MAX_SUBAGENT_NESTING = 3;
|
|
24
|
+
export const MAX_CONCURRENT_SUBAGENTS = 2;
|
|
25
|
+
export const MAX_SUBAGENT_CALLBACK_COUNT = 20;
|
|
26
|
+
export const CALLBACK_LOCK_STALE_MS = 5 * 60 * 1000;
|
|
27
|
+
export const STALE_SUBAGENT_THRESHOLD_MS = 5 * 60 * 1000;
|
|
@@ -0,0 +1,367 @@
|
|
|
1
|
+
import type { AgentEvent, Message } from "@poncho-ai/sdk";
|
|
2
|
+
import type { Conversation } from "../state.js";
|
|
3
|
+
import type { AgentHarness } from "../harness.js";
|
|
4
|
+
import { isMessageArray } from "./history.js";
|
|
5
|
+
|
|
6
|
+
// ── Types ──
|
|
7
|
+
|
|
8
|
+
export type StoredApproval = NonNullable<Conversation["pendingApprovals"]>[number];
|
|
9
|
+
export type PendingToolCall = { id: string; name: string; input: Record<string, unknown> };
|
|
10
|
+
export type ApprovalEventItem = {
|
|
11
|
+
approvalId: string;
|
|
12
|
+
tool: string;
|
|
13
|
+
toolCallId?: string;
|
|
14
|
+
input: Record<string, unknown>;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type TurnSection = { type: "text" | "tools"; content: string | string[] };
|
|
18
|
+
|
|
19
|
+
export type TurnDraftState = {
|
|
20
|
+
assistantResponse: string;
|
|
21
|
+
toolTimeline: string[];
|
|
22
|
+
sections: TurnSection[];
|
|
23
|
+
currentTools: string[];
|
|
24
|
+
currentText: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ExecuteTurnResult = {
|
|
28
|
+
latestRunId: string;
|
|
29
|
+
runCancelled: boolean;
|
|
30
|
+
runContinuation: boolean;
|
|
31
|
+
runContinuationMessages?: Message[];
|
|
32
|
+
runHarnessMessages?: Message[];
|
|
33
|
+
runContextTokens: number;
|
|
34
|
+
runContextWindow: number;
|
|
35
|
+
runSteps: number;
|
|
36
|
+
runMaxSteps?: number;
|
|
37
|
+
draft: TurnDraftState;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type TurnResultMetadata = {
|
|
41
|
+
latestRunId: string;
|
|
42
|
+
contextTokens: number;
|
|
43
|
+
contextWindow: number;
|
|
44
|
+
continuation?: boolean;
|
|
45
|
+
continuationMessages?: Message[];
|
|
46
|
+
harnessMessages?: Message[];
|
|
47
|
+
toolResultArchive?: Conversation["_toolResultArchive"];
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// ── Draft helpers ──
|
|
51
|
+
|
|
52
|
+
export const createTurnDraftState = (): TurnDraftState => ({
|
|
53
|
+
assistantResponse: "",
|
|
54
|
+
toolTimeline: [],
|
|
55
|
+
sections: [],
|
|
56
|
+
currentTools: [],
|
|
57
|
+
currentText: "",
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
export const cloneSections = (sections: TurnSection[]): TurnSection[] =>
|
|
61
|
+
sections.map((section) => ({
|
|
62
|
+
type: section.type,
|
|
63
|
+
content: Array.isArray(section.content) ? [...section.content] : section.content,
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
export const flushTurnDraft = (draft: TurnDraftState): void => {
|
|
67
|
+
if (draft.currentTools.length > 0) {
|
|
68
|
+
draft.sections.push({ type: "tools", content: draft.currentTools });
|
|
69
|
+
draft.currentTools = [];
|
|
70
|
+
}
|
|
71
|
+
if (draft.currentText.length > 0) {
|
|
72
|
+
draft.sections.push({ type: "text", content: draft.currentText });
|
|
73
|
+
draft.currentText = "";
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// ── Event processing ──
|
|
78
|
+
|
|
79
|
+
/** Build enriched tool:completed text with input details (bash command, URL, etc.) */
|
|
80
|
+
export const buildToolCompletedText = (event: AgentEvent & { type: "tool:completed" }): string => {
|
|
81
|
+
const input = event.input as Record<string, unknown> | undefined;
|
|
82
|
+
const output = event.output as Record<string, unknown> | undefined;
|
|
83
|
+
|
|
84
|
+
const meta: string[] = [`${event.duration}ms`];
|
|
85
|
+
let detail = "";
|
|
86
|
+
|
|
87
|
+
if (event.tool === "bash" && input && typeof input.command === "string") {
|
|
88
|
+
detail = input.command;
|
|
89
|
+
} else if (event.tool === "web_search") {
|
|
90
|
+
const q = (input?.query as string) || (output?.query as string) || "";
|
|
91
|
+
if (q) detail = `"${q.length > 60 ? q.slice(0, 57) + "..." : q}"`;
|
|
92
|
+
} else if (event.tool === "web_fetch") {
|
|
93
|
+
const u = (input?.url as string) || (output?.url as string) || "";
|
|
94
|
+
if (u) detail = u;
|
|
95
|
+
} else if (event.tool === "spawn_subagent") {
|
|
96
|
+
if (input && typeof input.task === "string") detail = input.task;
|
|
97
|
+
} else if (input) {
|
|
98
|
+
// Generic: pick the first short string value from input
|
|
99
|
+
for (const [, v] of Object.entries(input)) {
|
|
100
|
+
if (typeof v === "string" && v.length > 0) {
|
|
101
|
+
detail = v.length > 80 ? v.slice(0, 77) + "..." : v;
|
|
102
|
+
break;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (detail) {
|
|
107
|
+
detail = detail.replace(/\n/g, " ");
|
|
108
|
+
meta.push(detail);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
let text = `- done \`${event.tool}\`` + (meta.length > 0 ? ` (${meta.join(", ")})` : "");
|
|
112
|
+
|
|
113
|
+
if (event.tool === "spawn_subagent" && output?.subagentId) {
|
|
114
|
+
text += ` [subagent:${output.subagentId}]`;
|
|
115
|
+
}
|
|
116
|
+
if (event.tool === "bash" && typeof output?.exitCode === "number" && output.exitCode !== 0) {
|
|
117
|
+
text += ` \u2014 exit ${output.exitCode}`;
|
|
118
|
+
}
|
|
119
|
+
if (event.tool === "web_search" && Array.isArray(output?.results)) {
|
|
120
|
+
text += ` \u2014 ${(output.results as unknown[]).length} result${(output.results as unknown[]).length !== 1 ? "s" : ""}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return text;
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
export const recordStandardTurnEvent = (draft: TurnDraftState, event: AgentEvent): void => {
|
|
127
|
+
if (event.type === "model:chunk") {
|
|
128
|
+
if (draft.currentTools.length > 0) {
|
|
129
|
+
draft.sections.push({ type: "tools", content: draft.currentTools });
|
|
130
|
+
draft.currentTools = [];
|
|
131
|
+
if (draft.assistantResponse.length > 0 && !/\s$/.test(draft.assistantResponse)) {
|
|
132
|
+
draft.assistantResponse += " ";
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
draft.assistantResponse += event.content;
|
|
136
|
+
draft.currentText += event.content;
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (event.type === "tool:started") {
|
|
140
|
+
if (draft.currentText.length > 0) {
|
|
141
|
+
draft.sections.push({ type: "text", content: draft.currentText });
|
|
142
|
+
draft.currentText = "";
|
|
143
|
+
}
|
|
144
|
+
const input = event.input as Record<string, unknown> | undefined;
|
|
145
|
+
let startDetail = "";
|
|
146
|
+
if (event.tool === "bash" && input && typeof input.command === "string") {
|
|
147
|
+
startDetail = input.command;
|
|
148
|
+
} else if (event.tool === "web_fetch" && input && typeof input.url === "string") {
|
|
149
|
+
startDetail = input.url;
|
|
150
|
+
} else if (event.tool === "web_search" && input && typeof input.query === "string") {
|
|
151
|
+
startDetail = `"${input.query}"`;
|
|
152
|
+
} else if (input) {
|
|
153
|
+
for (const [, v] of Object.entries(input)) {
|
|
154
|
+
if (typeof v === "string" && v.length > 0) {
|
|
155
|
+
startDetail = v.length > 80 ? v.slice(0, 77) + "..." : v;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
if (startDetail) startDetail = startDetail.replace(/\n/g, " ");
|
|
161
|
+
const toolText = `- start \`${event.tool}\`` + (startDetail ? ` (${startDetail})` : "");
|
|
162
|
+
draft.toolTimeline.push(toolText);
|
|
163
|
+
draft.currentTools.push(toolText);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (event.type === "tool:completed") {
|
|
167
|
+
const toolText = buildToolCompletedText(event);
|
|
168
|
+
draft.toolTimeline.push(toolText);
|
|
169
|
+
draft.currentTools.push(toolText);
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
if (event.type === "tool:error") {
|
|
173
|
+
const toolText = `- error \`${event.tool}\`: ${event.error}`;
|
|
174
|
+
draft.toolTimeline.push(toolText);
|
|
175
|
+
draft.currentTools.push(toolText);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
export const buildAssistantMetadata = (
|
|
180
|
+
draft: TurnDraftState,
|
|
181
|
+
sectionsOverride?: TurnSection[],
|
|
182
|
+
opts?: { id?: string; timestamp?: number },
|
|
183
|
+
): Message["metadata"] | undefined => {
|
|
184
|
+
const sections = sectionsOverride ?? cloneSections(draft.sections);
|
|
185
|
+
const hasContent = draft.toolTimeline.length > 0 || sections.length > 0;
|
|
186
|
+
if (!hasContent && !opts?.id) return undefined;
|
|
187
|
+
const meta: Message["metadata"] = {};
|
|
188
|
+
if (opts?.id) meta.id = opts.id;
|
|
189
|
+
if (opts?.timestamp) meta.timestamp = opts.timestamp;
|
|
190
|
+
if (draft.toolTimeline.length > 0) meta.toolActivity = [...draft.toolTimeline];
|
|
191
|
+
if (sections.length > 0) meta.sections = sections;
|
|
192
|
+
return meta;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// ── Turn executor ──
|
|
196
|
+
|
|
197
|
+
export const executeConversationTurn = async ({
|
|
198
|
+
harness,
|
|
199
|
+
runInput,
|
|
200
|
+
events,
|
|
201
|
+
initialContextTokens = 0,
|
|
202
|
+
initialContextWindow = 0,
|
|
203
|
+
onEvent,
|
|
204
|
+
}: {
|
|
205
|
+
harness: AgentHarness;
|
|
206
|
+
runInput?: Parameters<AgentHarness["runWithTelemetry"]>[0];
|
|
207
|
+
events?: AsyncIterable<AgentEvent>;
|
|
208
|
+
initialContextTokens?: number;
|
|
209
|
+
initialContextWindow?: number;
|
|
210
|
+
onEvent?: (event: AgentEvent, draft: TurnDraftState) => void | Promise<void>;
|
|
211
|
+
}): Promise<ExecuteTurnResult> => {
|
|
212
|
+
const draft = createTurnDraftState();
|
|
213
|
+
let latestRunId = "";
|
|
214
|
+
let runCancelled = false;
|
|
215
|
+
let runContinuation = false;
|
|
216
|
+
let runContinuationMessages: Message[] | undefined;
|
|
217
|
+
let runHarnessMessages: Message[] | undefined;
|
|
218
|
+
let runContextTokens = initialContextTokens;
|
|
219
|
+
let runContextWindow = initialContextWindow;
|
|
220
|
+
let runSteps = 0;
|
|
221
|
+
let runMaxSteps: number | undefined;
|
|
222
|
+
|
|
223
|
+
const source = events ?? harness.runWithTelemetry(runInput!);
|
|
224
|
+
for await (const event of source) {
|
|
225
|
+
recordStandardTurnEvent(draft, event);
|
|
226
|
+
if (event.type === "run:started") {
|
|
227
|
+
latestRunId = event.runId;
|
|
228
|
+
}
|
|
229
|
+
if (event.type === "run:cancelled") {
|
|
230
|
+
runCancelled = true;
|
|
231
|
+
}
|
|
232
|
+
if (event.type === "run:completed") {
|
|
233
|
+
runContinuation = event.result.continuation === true;
|
|
234
|
+
runContinuationMessages = event.result.continuationMessages;
|
|
235
|
+
runHarnessMessages = event.result.continuationMessages;
|
|
236
|
+
runContextTokens = event.result.contextTokens ?? runContextTokens;
|
|
237
|
+
runContextWindow = event.result.contextWindow ?? runContextWindow;
|
|
238
|
+
runSteps = event.result.steps;
|
|
239
|
+
if (typeof event.result.maxSteps === "number") {
|
|
240
|
+
runMaxSteps = event.result.maxSteps;
|
|
241
|
+
}
|
|
242
|
+
if (draft.assistantResponse.length === 0 && event.result.response) {
|
|
243
|
+
draft.assistantResponse = event.result.response;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
if (event.type === "run:error") {
|
|
247
|
+
draft.assistantResponse = draft.assistantResponse || `[Error: ${event.error.message}]`;
|
|
248
|
+
}
|
|
249
|
+
if (onEvent) {
|
|
250
|
+
await onEvent(event, draft);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
latestRunId,
|
|
256
|
+
runCancelled,
|
|
257
|
+
runContinuation,
|
|
258
|
+
runContinuationMessages,
|
|
259
|
+
runHarnessMessages,
|
|
260
|
+
runContextTokens,
|
|
261
|
+
runContextWindow,
|
|
262
|
+
runSteps,
|
|
263
|
+
runMaxSteps,
|
|
264
|
+
draft,
|
|
265
|
+
};
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
// ── Approval checkpoint helpers ──
|
|
269
|
+
|
|
270
|
+
const normalizePendingToolCalls = (value: unknown): PendingToolCall[] => {
|
|
271
|
+
if (!Array.isArray(value)) return [];
|
|
272
|
+
return value
|
|
273
|
+
.filter((entry): entry is PendingToolCall => {
|
|
274
|
+
if (!entry || typeof entry !== "object") return false;
|
|
275
|
+
const row = entry as Record<string, unknown>;
|
|
276
|
+
return (
|
|
277
|
+
typeof row.id === "string" &&
|
|
278
|
+
typeof row.name === "string" &&
|
|
279
|
+
typeof row.input === "object" &&
|
|
280
|
+
row.input !== null
|
|
281
|
+
);
|
|
282
|
+
})
|
|
283
|
+
.map((entry) => ({ id: entry.id, name: entry.name, input: entry.input }));
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
export const normalizeApprovalCheckpoint = (
|
|
287
|
+
approval: StoredApproval,
|
|
288
|
+
fallbackMessages: Message[],
|
|
289
|
+
): StoredApproval => ({
|
|
290
|
+
...approval,
|
|
291
|
+
checkpointMessages: isMessageArray(approval.checkpointMessages) ? approval.checkpointMessages : [...fallbackMessages],
|
|
292
|
+
baseMessageCount: typeof approval.baseMessageCount === "number" && approval.baseMessageCount >= 0
|
|
293
|
+
? approval.baseMessageCount
|
|
294
|
+
: 0,
|
|
295
|
+
pendingToolCalls: normalizePendingToolCalls(approval.pendingToolCalls),
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
export const buildApprovalCheckpoints = ({
|
|
299
|
+
approvals,
|
|
300
|
+
runId,
|
|
301
|
+
checkpointMessages,
|
|
302
|
+
baseMessageCount,
|
|
303
|
+
pendingToolCalls,
|
|
304
|
+
}: {
|
|
305
|
+
approvals: ApprovalEventItem[];
|
|
306
|
+
runId: string;
|
|
307
|
+
checkpointMessages: Message[];
|
|
308
|
+
baseMessageCount: number;
|
|
309
|
+
pendingToolCalls: PendingToolCall[];
|
|
310
|
+
}): NonNullable<Conversation["pendingApprovals"]> =>
|
|
311
|
+
approvals.map((approval) => ({
|
|
312
|
+
approvalId: approval.approvalId,
|
|
313
|
+
runId,
|
|
314
|
+
tool: approval.tool,
|
|
315
|
+
toolCallId: approval.toolCallId,
|
|
316
|
+
input: approval.input,
|
|
317
|
+
checkpointMessages,
|
|
318
|
+
baseMessageCount,
|
|
319
|
+
pendingToolCalls,
|
|
320
|
+
}));
|
|
321
|
+
|
|
322
|
+
// ── Turn metadata persistence ──
|
|
323
|
+
|
|
324
|
+
export const applyTurnMetadata = (
|
|
325
|
+
conv: Conversation,
|
|
326
|
+
meta: TurnResultMetadata,
|
|
327
|
+
opts: {
|
|
328
|
+
clearContinuation?: boolean;
|
|
329
|
+
clearApprovals?: boolean;
|
|
330
|
+
setIdle?: boolean;
|
|
331
|
+
shouldRebuildCanonical?: boolean;
|
|
332
|
+
} = {},
|
|
333
|
+
): void => {
|
|
334
|
+
const {
|
|
335
|
+
clearContinuation = true,
|
|
336
|
+
clearApprovals = true,
|
|
337
|
+
setIdle = true,
|
|
338
|
+
shouldRebuildCanonical = false,
|
|
339
|
+
} = opts;
|
|
340
|
+
|
|
341
|
+
if (meta.continuation && meta.continuationMessages) {
|
|
342
|
+
conv._continuationMessages = meta.continuationMessages;
|
|
343
|
+
} else if (clearContinuation) {
|
|
344
|
+
conv._continuationMessages = undefined;
|
|
345
|
+
conv._continuationCount = undefined;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (meta.harnessMessages) {
|
|
349
|
+
conv._harnessMessages = meta.harnessMessages;
|
|
350
|
+
} else if (shouldRebuildCanonical) {
|
|
351
|
+
conv._harnessMessages = conv.messages;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (meta.toolResultArchive !== undefined) {
|
|
355
|
+
conv._toolResultArchive = meta.toolResultArchive;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
conv.runtimeRunId = meta.latestRunId || conv.runtimeRunId;
|
|
359
|
+
|
|
360
|
+
if (clearApprovals) conv.pendingApprovals = [];
|
|
361
|
+
if (setIdle) conv.runStatus = "idle";
|
|
362
|
+
|
|
363
|
+
if (meta.contextTokens > 0) conv.contextTokens = meta.contextTokens;
|
|
364
|
+
if (meta.contextWindow > 0) conv.contextWindow = meta.contextWindow;
|
|
365
|
+
|
|
366
|
+
conv.updatedAt = Date.now();
|
|
367
|
+
};
|
package/src/reminder-store.ts
CHANGED
|
@@ -6,6 +6,22 @@ import type { StateConfig } from "./state.js";
|
|
|
6
6
|
|
|
7
7
|
export type ReminderStatus = "pending" | "fired" | "cancelled";
|
|
8
8
|
|
|
9
|
+
export type RecurrenceType = "daily" | "weekly" | "monthly" | "cron";
|
|
10
|
+
|
|
11
|
+
export interface Recurrence {
|
|
12
|
+
type: RecurrenceType;
|
|
13
|
+
/** Repeat every N units (e.g. every 2 days). Defaults to 1. */
|
|
14
|
+
interval?: number;
|
|
15
|
+
/** For weekly: which days (0=Sun … 6=Sat). */
|
|
16
|
+
daysOfWeek?: number[];
|
|
17
|
+
/** For type "cron": a 5-field cron expression. */
|
|
18
|
+
expression?: string;
|
|
19
|
+
/** Stop recurring after this epoch-ms timestamp. */
|
|
20
|
+
endsAt?: number;
|
|
21
|
+
/** Stop recurring after this many total firings. */
|
|
22
|
+
maxOccurrences?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
9
25
|
export interface Reminder {
|
|
10
26
|
id: string;
|
|
11
27
|
task: string;
|
|
@@ -16,18 +32,24 @@ export interface Reminder {
|
|
|
16
32
|
conversationId: string;
|
|
17
33
|
ownerId?: string;
|
|
18
34
|
tenantId?: string | null;
|
|
35
|
+
recurrence?: Recurrence | null;
|
|
36
|
+
occurrenceCount?: number;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface ReminderCreateInput {
|
|
40
|
+
task: string;
|
|
41
|
+
scheduledAt: number;
|
|
42
|
+
timezone?: string;
|
|
43
|
+
conversationId: string;
|
|
44
|
+
ownerId?: string;
|
|
45
|
+
tenantId?: string | null;
|
|
46
|
+
recurrence?: Recurrence | null;
|
|
19
47
|
}
|
|
20
48
|
|
|
21
49
|
export interface ReminderStore {
|
|
22
50
|
list(): Promise<Reminder[]>;
|
|
23
|
-
create(input:
|
|
24
|
-
|
|
25
|
-
scheduledAt: number;
|
|
26
|
-
timezone?: string;
|
|
27
|
-
conversationId: string;
|
|
28
|
-
ownerId?: string;
|
|
29
|
-
tenantId?: string | null;
|
|
30
|
-
}): Promise<Reminder>;
|
|
51
|
+
create(input: ReminderCreateInput): Promise<Reminder>;
|
|
52
|
+
update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder>;
|
|
31
53
|
cancel(id: string): Promise<Reminder>;
|
|
32
54
|
delete(id: string): Promise<void>;
|
|
33
55
|
}
|
|
@@ -63,14 +85,7 @@ class InMemoryReminderStore implements ReminderStore {
|
|
|
63
85
|
return [...this.reminders];
|
|
64
86
|
}
|
|
65
87
|
|
|
66
|
-
async create(input: {
|
|
67
|
-
task: string;
|
|
68
|
-
scheduledAt: number;
|
|
69
|
-
timezone?: string;
|
|
70
|
-
conversationId: string;
|
|
71
|
-
ownerId?: string;
|
|
72
|
-
tenantId?: string | null;
|
|
73
|
-
}): Promise<Reminder> {
|
|
88
|
+
async create(input: ReminderCreateInput): Promise<Reminder> {
|
|
74
89
|
const reminder: Reminder = {
|
|
75
90
|
id: generateId(),
|
|
76
91
|
task: input.task,
|
|
@@ -81,12 +96,23 @@ class InMemoryReminderStore implements ReminderStore {
|
|
|
81
96
|
conversationId: input.conversationId,
|
|
82
97
|
ownerId: input.ownerId,
|
|
83
98
|
tenantId: input.tenantId,
|
|
99
|
+
recurrence: input.recurrence ?? null,
|
|
100
|
+
occurrenceCount: 0,
|
|
84
101
|
};
|
|
85
102
|
this.reminders = pruneStale(this.reminders);
|
|
86
103
|
this.reminders.push(reminder);
|
|
87
104
|
return reminder;
|
|
88
105
|
}
|
|
89
106
|
|
|
107
|
+
async update(id: string, fields: { scheduledAt?: number; occurrenceCount?: number; status?: ReminderStatus }): Promise<Reminder> {
|
|
108
|
+
const reminder = this.reminders.find((r) => r.id === id);
|
|
109
|
+
if (!reminder) throw new Error(`Reminder "${id}" not found`);
|
|
110
|
+
if (fields.scheduledAt !== undefined) reminder.scheduledAt = fields.scheduledAt;
|
|
111
|
+
if (fields.occurrenceCount !== undefined) reminder.occurrenceCount = fields.occurrenceCount;
|
|
112
|
+
if (fields.status !== undefined) reminder.status = fields.status;
|
|
113
|
+
return reminder;
|
|
114
|
+
}
|
|
115
|
+
|
|
90
116
|
async cancel(id: string): Promise<Reminder> {
|
|
91
117
|
const reminder = this.reminders.find((r) => r.id === id);
|
|
92
118
|
if (!reminder) throw new Error(`Reminder "${id}" not found`);
|
|
@@ -102,6 +128,147 @@ class InMemoryReminderStore implements ReminderStore {
|
|
|
102
128
|
}
|
|
103
129
|
}
|
|
104
130
|
|
|
131
|
+
// ---------------------------------------------------------------------------
|
|
132
|
+
// Recurrence helpers
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Given a reminder's current scheduledAt and its recurrence config, compute the
|
|
137
|
+
* next fire time. Returns null if the recurrence is exhausted (maxOccurrences
|
|
138
|
+
* reached or endsAt passed).
|
|
139
|
+
*/
|
|
140
|
+
export const computeNextOccurrence = (reminder: Reminder): number | null => {
|
|
141
|
+
const rec = reminder.recurrence;
|
|
142
|
+
if (!rec) return null;
|
|
143
|
+
|
|
144
|
+
const fired = (reminder.occurrenceCount ?? 0) + 1;
|
|
145
|
+
if (rec.maxOccurrences && fired >= rec.maxOccurrences) return null;
|
|
146
|
+
|
|
147
|
+
const interval = rec.interval ?? 1;
|
|
148
|
+
const prev = reminder.scheduledAt;
|
|
149
|
+
let next: number;
|
|
150
|
+
|
|
151
|
+
switch (rec.type) {
|
|
152
|
+
case "daily": {
|
|
153
|
+
next = prev + interval * 24 * 60 * 60 * 1000;
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
case "weekly": {
|
|
157
|
+
if (rec.daysOfWeek && rec.daysOfWeek.length > 0) {
|
|
158
|
+
// Advance to next matching day-of-week
|
|
159
|
+
const d = new Date(prev);
|
|
160
|
+
const days = [...rec.daysOfWeek].sort((a, b) => a - b);
|
|
161
|
+
const currentDay = d.getUTCDay();
|
|
162
|
+
// Find the next day in the list that is strictly after currentDay
|
|
163
|
+
let nextDay = days.find((day) => day > currentDay);
|
|
164
|
+
if (nextDay !== undefined) {
|
|
165
|
+
// Same week
|
|
166
|
+
const delta = nextDay - currentDay;
|
|
167
|
+
next = prev + delta * 24 * 60 * 60 * 1000;
|
|
168
|
+
} else {
|
|
169
|
+
// Wrap to next week (+ interval weeks if interval > 1)
|
|
170
|
+
const delta = (7 * interval) - currentDay + days[0];
|
|
171
|
+
next = prev + delta * 24 * 60 * 60 * 1000;
|
|
172
|
+
}
|
|
173
|
+
} else {
|
|
174
|
+
// No specific days — just advance by N weeks
|
|
175
|
+
next = prev + interval * 7 * 24 * 60 * 60 * 1000;
|
|
176
|
+
}
|
|
177
|
+
break;
|
|
178
|
+
}
|
|
179
|
+
case "monthly": {
|
|
180
|
+
const d = new Date(prev);
|
|
181
|
+
d.setUTCMonth(d.getUTCMonth() + interval);
|
|
182
|
+
next = d.getTime();
|
|
183
|
+
break;
|
|
184
|
+
}
|
|
185
|
+
case "cron": {
|
|
186
|
+
// Minimal cron: parse 5-field expression and find the next matching
|
|
187
|
+
// minute after `prev`. We support basic values, ranges, steps and *.
|
|
188
|
+
if (!rec.expression) return null;
|
|
189
|
+
const parsed = parseCronExpression(rec.expression);
|
|
190
|
+
if (!parsed) return null;
|
|
191
|
+
next = nextCronOccurrence(prev, parsed);
|
|
192
|
+
if (next <= prev) return null; // safety
|
|
193
|
+
break;
|
|
194
|
+
}
|
|
195
|
+
default:
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (rec.endsAt && next > rec.endsAt) return null;
|
|
200
|
+
return next;
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// Minimal cron parser (5-field: min hour dom month dow)
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
interface CronFields {
|
|
208
|
+
minutes: Set<number>;
|
|
209
|
+
hours: Set<number>;
|
|
210
|
+
daysOfMonth: Set<number>;
|
|
211
|
+
months: Set<number>;
|
|
212
|
+
daysOfWeek: Set<number>;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
const expandField = (field: string, min: number, max: number): Set<number> | null => {
|
|
216
|
+
const values = new Set<number>();
|
|
217
|
+
for (const part of field.split(",")) {
|
|
218
|
+
const stepMatch = part.match(/^(.+)\/(\d+)$/);
|
|
219
|
+
const step = stepMatch ? parseInt(stepMatch[2], 10) : 1;
|
|
220
|
+
const range = stepMatch ? stepMatch[1] : part;
|
|
221
|
+
|
|
222
|
+
if (range === "*") {
|
|
223
|
+
for (let i = min; i <= max; i += step) values.add(i);
|
|
224
|
+
} else if (range.includes("-")) {
|
|
225
|
+
const [lo, hi] = range.split("-").map(Number);
|
|
226
|
+
if (isNaN(lo) || isNaN(hi)) return null;
|
|
227
|
+
for (let i = lo; i <= hi; i += step) values.add(i);
|
|
228
|
+
} else {
|
|
229
|
+
const n = parseInt(range, 10);
|
|
230
|
+
if (isNaN(n)) return null;
|
|
231
|
+
values.add(n);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return values;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const parseCronExpression = (expr: string): CronFields | null => {
|
|
238
|
+
const parts = expr.trim().split(/\s+/);
|
|
239
|
+
if (parts.length !== 5) return null;
|
|
240
|
+
const minutes = expandField(parts[0], 0, 59);
|
|
241
|
+
const hours = expandField(parts[1], 0, 23);
|
|
242
|
+
const daysOfMonth = expandField(parts[2], 1, 31);
|
|
243
|
+
const months = expandField(parts[3], 1, 12);
|
|
244
|
+
const daysOfWeek = expandField(parts[4], 0, 6);
|
|
245
|
+
if (!minutes || !hours || !daysOfMonth || !months || !daysOfWeek) return null;
|
|
246
|
+
return { minutes, hours, daysOfMonth, months, daysOfWeek };
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const nextCronOccurrence = (afterMs: number, fields: CronFields): number => {
|
|
250
|
+
// Start from the minute after `afterMs`
|
|
251
|
+
const d = new Date(afterMs);
|
|
252
|
+
d.setUTCSeconds(0, 0);
|
|
253
|
+
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
|
254
|
+
|
|
255
|
+
// Cap search to ~1 year to avoid infinite loops
|
|
256
|
+
const limit = afterMs + 366 * 24 * 60 * 60 * 1000;
|
|
257
|
+
while (d.getTime() < limit) {
|
|
258
|
+
if (
|
|
259
|
+
fields.months.has(d.getUTCMonth() + 1) &&
|
|
260
|
+
fields.daysOfMonth.has(d.getUTCDate()) &&
|
|
261
|
+
fields.daysOfWeek.has(d.getUTCDay()) &&
|
|
262
|
+
fields.hours.has(d.getUTCHours()) &&
|
|
263
|
+
fields.minutes.has(d.getUTCMinutes())
|
|
264
|
+
) {
|
|
265
|
+
return d.getTime();
|
|
266
|
+
}
|
|
267
|
+
d.setUTCMinutes(d.getUTCMinutes() + 1);
|
|
268
|
+
}
|
|
269
|
+
return afterMs; // no match within a year — treat as exhausted
|
|
270
|
+
};
|
|
271
|
+
|
|
105
272
|
// ---------------------------------------------------------------------------
|
|
106
273
|
// Factory
|
|
107
274
|
// ---------------------------------------------------------------------------
|