loopengine 0.1.92 → 0.1.94
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/README.md +14 -5
- package/dist/core/agent-config.d.ts +19 -7
- package/dist/core/email-senders.d.ts +26 -0
- package/dist/core/email-senders.js +108 -0
- package/dist/core/http-notifier.js +6 -6
- package/dist/core/http-notify-triggers/email.d.ts +14 -8
- package/dist/core/http-notify-triggers/email.js +70 -28
- package/dist/core/notifier-file.d.ts +11 -3
- package/dist/core/notifier-file.js +37 -5
- package/dist/core/scheduler.d.ts +22 -0
- package/dist/core/scheduler.js +111 -12
- package/dist/core/system-agents/templates.d.ts +43 -0
- package/dist/core/system-agents/templates.js +290 -48
- package/dist/core/system-tools/schedule.js +16 -6
- package/dist/core/time-zone.d.ts +40 -0
- package/dist/core/time-zone.js +138 -0
- package/dist/index.d.ts +6 -5
- package/dist/index.js +5 -4
- package/dist/web/agents-config-page.js +49 -11
- package/dist/web/global-config-page.js +65 -0
- package/dist/web/global-config.d.ts +24 -0
- package/dist/web/global-config.js +30 -0
- package/dist/web/notifier-admin.js +11 -2
- package/dist/web/templates-admin.d.ts +82 -7
- package/dist/web/templates-admin.js +207 -16
- package/dist/web/templates-page.js +31 -6
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ Six channels ship as real, working notifiers — configure one and the
|
|
|
157
157
|
| `webhook` | Signed HMAC-SHA256 POST to any URL you own |
|
|
158
158
|
| `slack` | An interactive message via `chat.postMessage`, with Approve/Deny buttons |
|
|
159
159
|
| `lark` | A Lark/Feishu interactive card |
|
|
160
|
-
| `email` |
|
|
160
|
+
| `email` | An email per decision, through your own `sendEmail`: with signed, expiring magic links to your endpoints, or a link to this server's decision page |
|
|
161
161
|
| `database` | Writes a row for your own worker/dashboard to poll |
|
|
162
162
|
| `redis` | Pushes a queue entry for your own worker to consume |
|
|
163
163
|
|
|
@@ -173,7 +173,9 @@ export const config: AgentConfig = {
|
|
|
173
173
|
```
|
|
174
174
|
|
|
175
175
|
Or skip the code: in the console, the agent's **Messaging** tab sets a
|
|
176
|
-
webhook, Slack or
|
|
176
|
+
webhook, Slack, Lark or email target for one agent (email sends through
|
|
177
|
+
Resend, SendGrid or Postmark with your API key, each email linking to the
|
|
178
|
+
decision page) (saved to
|
|
177
179
|
`agents/<name>/messaging.yml`, with its secret in the agent's own `.env`;
|
|
178
180
|
an older `notifier.yml` is still read), sends a test and shows recent
|
|
179
181
|
deliveries. With Slack, people can also reply in a request's thread to
|
|
@@ -207,8 +209,8 @@ agents, can't see your systems, and creates nothing until you click.
|
|
|
207
209
|
|
|
208
210
|
Or skip the interview: **Templates** (`/templates`, linked from the
|
|
209
211
|
sidebar and the Agents page) are ready-made agents: answer customer
|
|
210
|
-
emails, triage incidents, check expense claims,
|
|
211
|
-
digest. Preview one's instructions and setup checklist, then create it
|
|
212
|
+
emails, refund approvals, triage incidents, check expense claims, lead
|
|
213
|
+
follow-up, inbox to tasks, and a weekly research digest. Preview one's instructions and setup checklist, then create it
|
|
212
214
|
the same way, as many times as you like. Templates live in
|
|
213
215
|
`core/system-agents/templates.ts`, in the same shape the interview
|
|
214
216
|
suggests.
|
|
@@ -248,7 +250,14 @@ under the hood.
|
|
|
248
250
|
## Scheduled tasks
|
|
249
251
|
|
|
250
252
|
The server runs one scheduler (`core/scheduler.ts`) that sends a message
|
|
251
|
-
to an agent later — once (`run_at`) or on an interval (`every: "1h"`)
|
|
253
|
+
to an agent later — once (`run_at`) or on an interval (`every: "1h"`),
|
|
254
|
+
optionally from a set first run (`start_at`), which every later run keeps:
|
|
255
|
+
`every: "1d"` from a 9:00 start runs daily at 9:00, `every: "7d"` from a
|
|
256
|
+
Monday runs on Mondays. `at: "09:00"` (plus `weekday: "monday"`) picks
|
|
257
|
+
that start for you. Times of day are in the workspace's time zone, chosen
|
|
258
|
+
in the console's **Settings** (`LOOPENGINE_TIME_ZONE`; the server's own
|
|
259
|
+
when unset): a timestamp without an offset is read there, and daily and
|
|
260
|
+
weekly tasks keep their time of day when the clocks change. Schedule
|
|
252
261
|
from the console's **Schedules** tab, the `GET/POST
|
|
253
262
|
/agents/<name>/schedules` API, or the agent itself: every agent has
|
|
254
263
|
built-in `system_schedule_task`, `system_list_scheduled_tasks`,
|
|
@@ -91,7 +91,14 @@ export type ApprovalOnlyHttpNotifierEvent = 'approval';
|
|
|
91
91
|
* see `core/http-notify-triggers/email.ts`'s own doc comment for why
|
|
92
92
|
* (an SMTP client/provider SDK is a dependency this package deliberately
|
|
93
93
|
* doesn't carry). */
|
|
94
|
-
export type SendEmail = (to: string, subject: string, html: string) => Promise<void>;
|
|
94
|
+
export type SendEmail = (to: string, subject: string, html: string, meta?: SendEmailMeta) => Promise<void>;
|
|
95
|
+
/** What an email is about, passed as `SendEmail`'s optional 4th argument
|
|
96
|
+
* so a sender can log it (core/email-senders.ts records deliveries with
|
|
97
|
+
* it). A sender that doesn't care can ignore it. */
|
|
98
|
+
export interface SendEmailMeta {
|
|
99
|
+
kind: 'approval' | 'question' | 'run_start' | 'run_finish' | 'test';
|
|
100
|
+
pendingId?: string;
|
|
101
|
+
}
|
|
95
102
|
/** One row `channel: 'database'` (`core/http-notify-triggers/database.ts`)
|
|
96
103
|
* inserts per pending approval — implement `insert()` against whatever
|
|
97
104
|
* real database/ORM you already have (a single INSERT, a Prisma/Drizzle
|
|
@@ -159,9 +166,13 @@ export interface PendingApprovalsRepository {
|
|
|
159
166
|
* `http-notify-triggers/lark.ts`'s `LarkNotifier` (lower
|
|
160
167
|
* confidence on exact card-schema field names — see that file's own
|
|
161
168
|
* header comment).
|
|
162
|
-
* - `'email'` — a `sendEmail` callback
|
|
163
|
-
*
|
|
164
|
-
* `
|
|
169
|
+
* - `'email'` — a `sendEmail` callback, via
|
|
170
|
+
* `http-notify-triggers/email.ts`'s `EmailNotifier`. With
|
|
171
|
+
* `resolveBaseUrl`/`answerBaseUrl`/`signingSecret`, each email carries
|
|
172
|
+
* its own signed magic links to *your* endpoints; without them, it
|
|
173
|
+
* links to this server's own decision page (`/decide/<token>`, at
|
|
174
|
+
* `publicUrl`), the same page Slack and Lark messages link to — what
|
|
175
|
+
* the console's Messaging tab sets up.
|
|
165
176
|
* - `'database'`/`'redis'` — not a notification channel at all (see
|
|
166
177
|
* `ApprovalOnlyHttpNotifierEvent`'s own doc comment): a row insert or
|
|
167
178
|
* a queue push for a separate worker/dashboard to poll, via
|
|
@@ -213,10 +224,11 @@ export type HttpNotifierConfig = {
|
|
|
213
224
|
config: {
|
|
214
225
|
to: string;
|
|
215
226
|
sendEmail: SendEmail;
|
|
216
|
-
resolveBaseUrl
|
|
217
|
-
answerBaseUrl
|
|
218
|
-
signingSecret
|
|
227
|
+
resolveBaseUrl?: string;
|
|
228
|
+
answerBaseUrl?: string;
|
|
229
|
+
signingSecret?: string;
|
|
219
230
|
linkTtlMs?: number;
|
|
231
|
+
publicUrl?: string;
|
|
220
232
|
};
|
|
221
233
|
events: HttpNotifierEvent[];
|
|
222
234
|
} | {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SendEmail } from '#core/agent-config.js';
|
|
2
|
+
export type EmailProvider = 'resend' | 'sendgrid' | 'postmark';
|
|
3
|
+
export declare const EMAIL_PROVIDERS: EmailProvider[];
|
|
4
|
+
/** "Ops <ops@example.com>" or "ops@example.com". */
|
|
5
|
+
export declare function parseAddress(value: string): {
|
|
6
|
+
name?: string;
|
|
7
|
+
email: string;
|
|
8
|
+
} | undefined;
|
|
9
|
+
/** A comma- or semicolon-separated list of addresses; undefined when any
|
|
10
|
+
* of them isn't a valid address. */
|
|
11
|
+
export declare function parseRecipients(value: string): string[] | undefined;
|
|
12
|
+
/** A plain-text version of the HTML body, for mail clients (and spam
|
|
13
|
+
* filters) that prefer one. */
|
|
14
|
+
export declare function htmlToText(html: string): string;
|
|
15
|
+
export interface ProviderSenderOptions {
|
|
16
|
+
provider: EmailProvider;
|
|
17
|
+
apiKey: string;
|
|
18
|
+
/** The sender, e.g. "LoopEngine <approvals@example.com>"; the provider
|
|
19
|
+
* must allow sending from its domain. */
|
|
20
|
+
from: string;
|
|
21
|
+
/** For the deliveries list. */
|
|
22
|
+
agent: string;
|
|
23
|
+
}
|
|
24
|
+
/** A SendEmail that sends through `provider`. Throws when the provider
|
|
25
|
+
* refuses the email, after recording the failure. */
|
|
26
|
+
export declare function createProviderSendEmail(options: ProviderSenderOptions): SendEmail;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { recordDelivery } from '#core/notifier-deliveries.js';
|
|
2
|
+
export const EMAIL_PROVIDERS = ['resend', 'sendgrid', 'postmark'];
|
|
3
|
+
const ENDPOINTS = {
|
|
4
|
+
resend: 'https://api.resend.com/emails',
|
|
5
|
+
sendgrid: 'https://api.sendgrid.com/v3/mail/send',
|
|
6
|
+
postmark: 'https://api.postmarkapp.com/email',
|
|
7
|
+
};
|
|
8
|
+
const ADDRESS = /^[^\s@<>,"]+@[^\s@<>,"]+\.[^\s@<>,"]+$/;
|
|
9
|
+
/** "Ops <ops@example.com>" or "ops@example.com". */
|
|
10
|
+
export function parseAddress(value) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
const named = /^(.*?)\s*<([^<>]+)>$/.exec(trimmed);
|
|
13
|
+
const email = (named ? named[2] : trimmed).trim();
|
|
14
|
+
if (!ADDRESS.test(email))
|
|
15
|
+
return undefined;
|
|
16
|
+
const name = named?.[1].replace(/^"|"$/g, '').trim();
|
|
17
|
+
return name ? { name, email } : { email };
|
|
18
|
+
}
|
|
19
|
+
/** A comma- or semicolon-separated list of addresses; undefined when any
|
|
20
|
+
* of them isn't a valid address. */
|
|
21
|
+
export function parseRecipients(value) {
|
|
22
|
+
const parts = value.split(/[,;]/).map((p) => p.trim()).filter(Boolean);
|
|
23
|
+
if (!parts.length || parts.some((p) => !parseAddress(p)))
|
|
24
|
+
return undefined;
|
|
25
|
+
return parts;
|
|
26
|
+
}
|
|
27
|
+
/** A plain-text version of the HTML body, for mail clients (and spam
|
|
28
|
+
* filters) that prefer one. */
|
|
29
|
+
export function htmlToText(html) {
|
|
30
|
+
return html
|
|
31
|
+
.replace(/<br\s*\/?>/gi, '\n')
|
|
32
|
+
.replace(/<\/(p|div|li|tr|h[1-6])>/gi, '\n')
|
|
33
|
+
.replace(/<a [^>]*href="([^"]*)"[^>]*>([\s\S]*?)<\/a>/gi, '$2 ($1)')
|
|
34
|
+
.replace(/<[^>]+>/g, '')
|
|
35
|
+
.replace(/ /g, ' ')
|
|
36
|
+
.replace(/</g, '<')
|
|
37
|
+
.replace(/>/g, '>')
|
|
38
|
+
.replace(/"/g, '"')
|
|
39
|
+
.replace(/'/g, "'")
|
|
40
|
+
.replace(/&/g, '&')
|
|
41
|
+
.replace(/[ \t]+\n/g, '\n')
|
|
42
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
43
|
+
.trim();
|
|
44
|
+
}
|
|
45
|
+
function requestFor(provider, apiKey, from, to, subject, html) {
|
|
46
|
+
const text = htmlToText(html);
|
|
47
|
+
switch (provider) {
|
|
48
|
+
case 'resend':
|
|
49
|
+
return {
|
|
50
|
+
method: 'POST',
|
|
51
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
52
|
+
body: JSON.stringify({ from, to, subject, html, text }),
|
|
53
|
+
};
|
|
54
|
+
case 'sendgrid': {
|
|
55
|
+
const sender = parseAddress(from);
|
|
56
|
+
return {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { authorization: `Bearer ${apiKey}`, 'content-type': 'application/json' },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
personalizations: [{ to: to.map((t) => parseAddress(t)) }],
|
|
61
|
+
from: sender,
|
|
62
|
+
subject,
|
|
63
|
+
content: [
|
|
64
|
+
{ type: 'text/plain', value: text },
|
|
65
|
+
{ type: 'text/html', value: html },
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
case 'postmark':
|
|
71
|
+
return {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'x-postmark-server-token': apiKey, 'content-type': 'application/json', accept: 'application/json' },
|
|
74
|
+
body: JSON.stringify({ From: from, To: to.join(', '), Subject: subject, HtmlBody: html, TextBody: text, MessageStream: 'outbound' }),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/** The provider's own error message, when its response carries one. */
|
|
79
|
+
async function errorDetail(res) {
|
|
80
|
+
const body = (await res.json().catch(() => undefined));
|
|
81
|
+
return body?.message ?? body?.Message ?? body?.errors?.[0]?.message ?? body?.name ?? '';
|
|
82
|
+
}
|
|
83
|
+
/** A SendEmail that sends through `provider`. Throws when the provider
|
|
84
|
+
* refuses the email, after recording the failure. */
|
|
85
|
+
export function createProviderSendEmail(options) {
|
|
86
|
+
return async (to, subject, html, meta) => {
|
|
87
|
+
const recipients = parseRecipients(to);
|
|
88
|
+
if (!recipients)
|
|
89
|
+
throw new Error(`"${to}" isn't a valid list of email addresses`);
|
|
90
|
+
const delivery = { agent: options.agent, kind: meta?.kind ?? 'approval', url: `${options.provider}:${recipients.join(',')}`, attempt: 1, ...(meta?.pendingId ? { pendingId: meta.pendingId } : {}) };
|
|
91
|
+
let res;
|
|
92
|
+
try {
|
|
93
|
+
res = await fetch(ENDPOINTS[options.provider], requestFor(options.provider, options.apiKey, options.from, recipients, subject, html));
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
97
|
+
recordDelivery({ at: new Date().toISOString(), ...delivery, ok: false, error });
|
|
98
|
+
throw new Error(`${options.provider} couldn't be reached: ${error}`);
|
|
99
|
+
}
|
|
100
|
+
if (!res.ok) {
|
|
101
|
+
const detail = await errorDetail(res);
|
|
102
|
+
const error = `HTTP ${res.status}${detail ? `: ${detail}` : ''}`;
|
|
103
|
+
recordDelivery({ at: new Date().toISOString(), ...delivery, ok: false, status: res.status, error });
|
|
104
|
+
throw new Error(`${options.provider} refused the email (${error})`);
|
|
105
|
+
}
|
|
106
|
+
recordDelivery({ at: new Date().toISOString(), ...delivery, ok: true, status: res.status });
|
|
107
|
+
};
|
|
108
|
+
}
|
|
@@ -48,7 +48,7 @@ export function describeNotifier(config) {
|
|
|
48
48
|
}
|
|
49
49
|
if (!file)
|
|
50
50
|
return { source: 'none' };
|
|
51
|
-
const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
51
|
+
const { missingSecret } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
|
|
52
52
|
return { source: missingSecret ? 'none' : 'file', channel: file.channel, events: file.events, file, missingSecret };
|
|
53
53
|
}
|
|
54
54
|
/** AgentConfig.httpNotifier when the agent sets one in code; otherwise
|
|
@@ -77,7 +77,7 @@ function resolveFromFile(config) {
|
|
|
77
77
|
}
|
|
78
78
|
if (!file)
|
|
79
79
|
return EMPTY;
|
|
80
|
-
const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir));
|
|
80
|
+
const { config: httpNotifier } = notifierConfigFromFile(file, createAgentEnv(config.name, dir), config.name);
|
|
81
81
|
return httpNotifier ? buildResolved(httpNotifier, dir) : EMPTY;
|
|
82
82
|
}
|
|
83
83
|
/** `agentDir`: where a Slack request's thread is recorded, so replies in
|
|
@@ -129,16 +129,16 @@ function buildResolved(httpNotifier, agentDir) {
|
|
|
129
129
|
break;
|
|
130
130
|
}
|
|
131
131
|
case 'email': {
|
|
132
|
-
const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs } = httpNotifier.config;
|
|
133
|
-
const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs });
|
|
132
|
+
const { to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl } = httpNotifier.config;
|
|
133
|
+
const email = new EmailNotifier({ to, sendEmail, resolveBaseUrl, answerBaseUrl, signingSecret, linkTtlMs, publicUrl });
|
|
134
134
|
resolved = {
|
|
135
135
|
approver: events.has('approval') ? email : undefined,
|
|
136
136
|
questionHandler: events.has('question') ? email : undefined,
|
|
137
137
|
onRunStart: events.has('run_start')
|
|
138
|
-
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_start', context), formatChatLifecycleMessage('run_start', context))
|
|
138
|
+
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_start', context), formatChatLifecycleMessage('run_start', context), 'run_start')
|
|
139
139
|
: undefined,
|
|
140
140
|
onRunFinish: events.has('run_finish')
|
|
141
|
-
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_finish', context), formatChatLifecycleMessage('run_finish', context))
|
|
141
|
+
? (context) => email.sendAnnouncement(formatEmailLifecycleSubject('run_finish', context), formatChatLifecycleMessage('run_finish', context), 'run_finish')
|
|
142
142
|
: undefined,
|
|
143
143
|
};
|
|
144
144
|
break;
|
|
@@ -19,18 +19,24 @@ export declare function verifyMagicLink(token: string, signingSecret: string): E
|
|
|
19
19
|
export declare class EmailNotifier implements DurableApprover, DurableQuestionHandler {
|
|
20
20
|
private readonly to;
|
|
21
21
|
private readonly sendEmail;
|
|
22
|
-
|
|
23
|
-
private readonly
|
|
24
|
-
|
|
22
|
+
/** Magic-link mode: links to YOUR endpoints, signed with signingSecret. */
|
|
23
|
+
private readonly magic?;
|
|
24
|
+
/** Decision-page mode: where this server is reachable. */
|
|
25
|
+
private readonly publicUrl?;
|
|
25
26
|
private readonly linkTtlMs;
|
|
26
27
|
constructor(options: {
|
|
27
28
|
to: string;
|
|
28
29
|
sendEmail: SendEmail;
|
|
29
|
-
resolveBaseUrl
|
|
30
|
-
answerBaseUrl
|
|
31
|
-
signingSecret
|
|
30
|
+
resolveBaseUrl?: string;
|
|
31
|
+
answerBaseUrl?: string;
|
|
32
|
+
signingSecret?: string;
|
|
32
33
|
linkTtlMs?: number;
|
|
34
|
+
publicUrl?: string;
|
|
33
35
|
});
|
|
36
|
+
private expiryNote;
|
|
37
|
+
/** Decision-page mode: one link to decide (or answer) on this server,
|
|
38
|
+
* or where to find the request when the server's address isn't known. */
|
|
39
|
+
private decideHtml;
|
|
34
40
|
requestDurableApproval(tool: string, args: Record<string, unknown>, scope: Scope, reason: string): {
|
|
35
41
|
pendingId: string;
|
|
36
42
|
};
|
|
@@ -39,6 +45,6 @@ export declare class EmailNotifier implements DurableApprover, DurableQuestionHa
|
|
|
39
45
|
};
|
|
40
46
|
/** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
|
|
41
47
|
* sender for `channel: 'email'` calls this directly — a plain
|
|
42
|
-
* announcement, no
|
|
43
|
-
sendAnnouncement(subject: string, text: string): void;
|
|
48
|
+
* announcement, no link, since there's nothing to resolve. */
|
|
49
|
+
sendAnnouncement(subject: string, text: string, kind?: 'run_start' | 'run_finish' | 'test'): void;
|
|
44
50
|
}
|
|
@@ -31,7 +31,15 @@
|
|
|
31
31
|
// file builds one: a small HMAC-signed, expiring token embedded in the
|
|
32
32
|
// link itself, verified before ever touching loopengine's own
|
|
33
33
|
// resolve/answer routes.
|
|
34
|
+
//
|
|
35
|
+
// Without resolveBaseUrl/answerBaseUrl/signingSecret it needs none of
|
|
36
|
+
// that: each email links to this server's own decision page
|
|
37
|
+
// (core/approval-links.ts, /decide/<token>), the same page Slack and Lark
|
|
38
|
+
// messages link to, where the person approves, denies, edits or answers.
|
|
39
|
+
// That's what an email channel set from the console's Messaging tab uses.
|
|
34
40
|
import { createHmac, randomUUID, timingSafeEqual } from 'node:crypto';
|
|
41
|
+
import { decidePath, decisionLinkTtlMs } from '../approval-links.js';
|
|
42
|
+
import { publicBaseUrl } from './webhook.js';
|
|
35
43
|
function signMagicLink(payload, expiresInMs, signingSecret) {
|
|
36
44
|
const body = Buffer.from(JSON.stringify({ ...payload, exp: Date.now() + expiresInMs })).toString('base64url');
|
|
37
45
|
const signature = createHmac('sha256', signingSecret).update(body).digest('base64url');
|
|
@@ -67,22 +75,51 @@ function escapeHtml(s) {
|
|
|
67
75
|
export class EmailNotifier {
|
|
68
76
|
to;
|
|
69
77
|
sendEmail;
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
78
|
+
/** Magic-link mode: links to YOUR endpoints, signed with signingSecret. */
|
|
79
|
+
magic;
|
|
80
|
+
/** Decision-page mode: where this server is reachable. */
|
|
81
|
+
publicUrl;
|
|
73
82
|
linkTtlMs;
|
|
74
83
|
constructor(options) {
|
|
75
84
|
this.to = options.to;
|
|
76
85
|
this.sendEmail = options.sendEmail;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
86
|
+
const { resolveBaseUrl, answerBaseUrl, signingSecret } = options;
|
|
87
|
+
const given = [resolveBaseUrl, answerBaseUrl, signingSecret].filter(Boolean).length;
|
|
88
|
+
if (given > 0 && given < 3) {
|
|
89
|
+
throw new Error('EmailNotifier: give resolveBaseUrl, answerBaseUrl and signingSecret together (magic links), or none of them (decision-page links)');
|
|
90
|
+
}
|
|
91
|
+
if (resolveBaseUrl && answerBaseUrl && signingSecret)
|
|
92
|
+
this.magic = { resolveBaseUrl, answerBaseUrl, signingSecret };
|
|
93
|
+
this.publicUrl = options.publicUrl;
|
|
94
|
+
// Magic links: 3 days — long enough for someone to actually check
|
|
95
|
+
// email, short enough that a leaked link doesn't stay live forever.
|
|
96
|
+
// Decision-page links last as long as the decision page allows.
|
|
97
|
+
this.linkTtlMs = options.linkTtlMs ?? (this.magic ? 3 * 24 * 60 * 60_000 : decisionLinkTtlMs());
|
|
98
|
+
}
|
|
99
|
+
expiryNote() {
|
|
100
|
+
const hours = Math.round(this.linkTtlMs / 3_600_000);
|
|
101
|
+
return `<p style="color:#888;font-size:12px">This link expires in ${hours >= 48 ? `${Math.round(hours / 24)} days` : `${hours} hours`}.</p>`;
|
|
102
|
+
}
|
|
103
|
+
/** Decision-page mode: one link to decide (or answer) on this server,
|
|
104
|
+
* or where to find the request when the server's address isn't known. */
|
|
105
|
+
decideHtml(pendingId, kind) {
|
|
106
|
+
const base = publicBaseUrl(this.publicUrl);
|
|
107
|
+
if (!base)
|
|
108
|
+
return `<p>Open this agent's Messaging tab in the LoopEngine console to ${kind === 'approval' ? 'approve or deny it' : 'answer'}.</p>`;
|
|
109
|
+
return `<p><a href="${base}${decidePath(pendingId, kind)}">${kind === 'approval' ? 'Review and approve or deny' : 'Answer'}</a></p>` + this.expiryNote();
|
|
81
110
|
}
|
|
82
111
|
requestDurableApproval(tool, args, scope, reason) {
|
|
83
112
|
const pendingId = randomUUID();
|
|
84
|
-
|
|
85
|
-
|
|
113
|
+
let links;
|
|
114
|
+
if (this.magic) {
|
|
115
|
+
const { resolveBaseUrl, signingSecret } = this.magic;
|
|
116
|
+
const approveLink = `${resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'approve' }, this.linkTtlMs, signingSecret)}`;
|
|
117
|
+
const denyLink = `${resolveBaseUrl}?token=${signMagicLink({ pendingId, decision: 'deny' }, this.linkTtlMs, signingSecret)}`;
|
|
118
|
+
links = `<p><a href="${approveLink}">Approve</a> | <a href="${denyLink}">Deny</a></p>` + this.expiryNote();
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
links = this.decideHtml(pendingId, 'approval');
|
|
122
|
+
}
|
|
86
123
|
// Not awaited — requestDurableApproval returns immediately, by
|
|
87
124
|
// contract (see DurableApprover's own doc comment). Logged instead of
|
|
88
125
|
// thrown on failure.
|
|
@@ -90,37 +127,42 @@ export class EmailNotifier {
|
|
|
90
127
|
`<p><b>Args:</b> ${escapeHtml(JSON.stringify(args))}</p>` +
|
|
91
128
|
`<p><b>Scope:</b> ${escapeHtml(`${scope.tenant}/${scope.environment}/${scope.agent}`)}</p>` +
|
|
92
129
|
`<p><b>Reason:</b> ${escapeHtml(reason)}</p>` +
|
|
93
|
-
|
|
94
|
-
`<p style="color:#888;font-size:12px">This link expires in ${Math.round(this.linkTtlMs / 3_600_000)} hours.</p>`).catch((err) => {
|
|
130
|
+
links, { kind: 'approval', pendingId }).catch((err) => {
|
|
95
131
|
console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
|
|
96
132
|
});
|
|
97
133
|
return { pendingId };
|
|
98
134
|
}
|
|
99
135
|
notifyPendingQuestion(question, options, agent, sessionId) {
|
|
100
136
|
const pendingId = randomUUID();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
`<p><a href="${freeTextLink}">${optionLinks ? 'Or answer in your own words' : 'Answer'}</a></p>` +
|
|
113
|
-
|
|
137
|
+
let links;
|
|
138
|
+
if (this.magic) {
|
|
139
|
+
const { answerBaseUrl, signingSecret } = this.magic;
|
|
140
|
+
const optionLinks = (options ?? [])
|
|
141
|
+
.map((option) => {
|
|
142
|
+
const token = signMagicLink({ pendingId, answer: option }, this.linkTtlMs, signingSecret);
|
|
143
|
+
return `<a href="${answerBaseUrl}?token=${token}">${escapeHtml(option)}</a>`;
|
|
144
|
+
})
|
|
145
|
+
.join(' | ');
|
|
146
|
+
const freeTextToken = signMagicLink({ pendingId }, this.linkTtlMs, signingSecret);
|
|
147
|
+
const freeTextLink = `${answerBaseUrl}?token=${freeTextToken}`;
|
|
148
|
+
links = (optionLinks ? `<p>${optionLinks}</p>` : '') + `<p><a href="${freeTextLink}">${optionLinks ? 'Or answer in your own words' : 'Answer'}</a></p>` + this.expiryNote();
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
// The decision page offers the suggested answers itself.
|
|
152
|
+
const suggested = (options ?? []).length ? `<p>Suggested answers: ${(options ?? []).map(escapeHtml).join(', ')}</p>` : '';
|
|
153
|
+
links = suggested + this.decideHtml(pendingId, 'question');
|
|
154
|
+
}
|
|
155
|
+
this.sendEmail(this.to, `Question from ${agent}`, `<p>${escapeHtml(question)}</p>` + (sessionId ? `<p style="color:#888;font-size:12px">Session: ${escapeHtml(sessionId)}</p>` : '') + links, { kind: 'question', pendingId }).catch((err) => {
|
|
114
156
|
console.error(`[loopengine] EmailNotifier: sendEmail failed for pendingId '${pendingId}':`, err);
|
|
115
157
|
});
|
|
116
158
|
return { pendingId };
|
|
117
159
|
}
|
|
118
160
|
/** Public: core/http-notifier.ts's own onRunStart/onRunFinish lifecycle
|
|
119
161
|
* sender for `channel: 'email'` calls this directly — a plain
|
|
120
|
-
* announcement, no
|
|
121
|
-
sendAnnouncement(subject, text) {
|
|
122
|
-
this.sendEmail(this.to, subject, `<p>${escapeHtml(text)}</p
|
|
123
|
-
console.error('[loopengine] EmailNotifier: sendEmail failed for
|
|
162
|
+
* announcement, no link, since there's nothing to resolve. */
|
|
163
|
+
sendAnnouncement(subject, text, kind = 'run_finish') {
|
|
164
|
+
this.sendEmail(this.to, subject, `<p>${escapeHtml(text)}</p>`, { kind }).catch((err) => {
|
|
165
|
+
console.error('[loopengine] EmailNotifier: sendEmail failed for an announcement:', err);
|
|
124
166
|
});
|
|
125
167
|
}
|
|
126
168
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AgentEnv, HttpNotifierConfig, HttpNotifierEvent } from '#core/agent-config.js';
|
|
2
|
-
|
|
2
|
+
import { type EmailProvider } from '#core/email-senders.js';
|
|
3
|
+
export type NotifierFileChannel = 'webhook' | 'slack' | 'lark' | 'email';
|
|
3
4
|
export interface NotifierFile {
|
|
4
5
|
channel: NotifierFileChannel;
|
|
5
6
|
events: HttpNotifierEvent[];
|
|
@@ -10,6 +11,12 @@ export interface NotifierFile {
|
|
|
10
11
|
/** lark: app id and chat to post into. */
|
|
11
12
|
appId?: string;
|
|
12
13
|
chatId?: string;
|
|
14
|
+
/** email: who receives requests (comma-separated), who they come from
|
|
15
|
+
* ("LoopEngine <approvals@example.com>"), and the provider that sends
|
|
16
|
+
* them. */
|
|
17
|
+
to?: string;
|
|
18
|
+
from?: string;
|
|
19
|
+
provider?: EmailProvider;
|
|
13
20
|
/** Where this server is reachable, for the links in each message —
|
|
14
21
|
* remembered from the console's own address when saving.
|
|
15
22
|
* LOOPENGINE_PUBLIC_URL, when set, wins over it. */
|
|
@@ -48,8 +55,9 @@ export declare function writeNotifierFile(dir: string, input: unknown): Notifier
|
|
|
48
55
|
export declare function removeNotifierFile(dir: string): void;
|
|
49
56
|
/** The HttpNotifierConfig a connection describes, with its secret read
|
|
50
57
|
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
51
|
-
* or unauthenticated.
|
|
52
|
-
|
|
58
|
+
* or unauthenticated. `agentName` labels email deliveries in the
|
|
59
|
+
* console's list. */
|
|
60
|
+
export declare function notifierConfigFromFile(file: NotifierFile, env: AgentEnv, agentName?: string): {
|
|
53
61
|
config?: HttpNotifierConfig;
|
|
54
62
|
missingSecret?: string;
|
|
55
63
|
};
|
|
@@ -18,24 +18,26 @@
|
|
|
18
18
|
// messaging.yml.
|
|
19
19
|
//
|
|
20
20
|
// The file holds only non-secret settings. The one secret each channel
|
|
21
|
-
// needs (a webhook signing secret, a Slack bot token, a Lark app secret)
|
|
21
|
+
// needs (a webhook signing secret, a Slack bot token, a Lark app secret, an email provider's API key)
|
|
22
22
|
// lives in the agent's own env (agents/<name>/.env by default, through
|
|
23
23
|
// core/secret-store.ts) under a fixed name, so it's never written into a
|
|
24
24
|
// file that might get committed.
|
|
25
25
|
import { existsSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
26
26
|
import { join } from 'node:path';
|
|
27
27
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
28
|
+
import { EMAIL_PROVIDERS, createProviderSendEmail, parseAddress, parseRecipients } from '#core/email-senders.js';
|
|
28
29
|
/** The env var each channel's secret is read from, per agent. */
|
|
29
30
|
export const NOTIFIER_SECRET_VARS = {
|
|
30
31
|
webhook: 'LOOPENGINE_NOTIFIER_WEBHOOK_SECRET',
|
|
31
32
|
slack: 'LOOPENGINE_NOTIFIER_SLACK_BOT_TOKEN',
|
|
32
33
|
lark: 'LOOPENGINE_NOTIFIER_LARK_APP_SECRET',
|
|
34
|
+
email: 'LOOPENGINE_NOTIFIER_EMAIL_API_KEY',
|
|
33
35
|
};
|
|
34
36
|
/** Optional: a Slack app's signing secret, per agent. Set, it turns on
|
|
35
37
|
* Approve/Deny buttons in Slack, handled by the server itself at
|
|
36
38
|
* POST /hooks/slack/<agent>; unset, messages carry only the decision link. */
|
|
37
39
|
export const NOTIFIER_SLACK_SIGNING_SECRET_VAR = 'LOOPENGINE_NOTIFIER_SLACK_SIGNING_SECRET';
|
|
38
|
-
const CHANNELS = ['webhook', 'slack', 'lark'];
|
|
40
|
+
const CHANNELS = ['webhook', 'slack', 'lark', 'email'];
|
|
39
41
|
const EVENTS = ['approval', 'question', 'run_start', 'run_finish'];
|
|
40
42
|
export class NotifierFileError extends Error {
|
|
41
43
|
}
|
|
@@ -99,7 +101,7 @@ export function validateNotifierFile(input) {
|
|
|
99
101
|
throw new NotifierFileError('channelId is required for slack');
|
|
100
102
|
file.channelId = channelId;
|
|
101
103
|
}
|
|
102
|
-
else {
|
|
104
|
+
else if (channel === 'lark') {
|
|
103
105
|
const appId = str('appId');
|
|
104
106
|
const chatId = str('chatId');
|
|
105
107
|
if (!appId || !chatId)
|
|
@@ -107,6 +109,21 @@ export function validateNotifierFile(input) {
|
|
|
107
109
|
file.appId = appId;
|
|
108
110
|
file.chatId = chatId;
|
|
109
111
|
}
|
|
112
|
+
else {
|
|
113
|
+
const to = str('to');
|
|
114
|
+
const recipients = to ? parseRecipients(to) : undefined;
|
|
115
|
+
if (!recipients)
|
|
116
|
+
throw new NotifierFileError('to must be one or more email addresses, separated by commas');
|
|
117
|
+
const from = str('from');
|
|
118
|
+
if (!from || !parseAddress(from))
|
|
119
|
+
throw new NotifierFileError('from must be an email address, like "LoopEngine <approvals@example.com>"');
|
|
120
|
+
const provider = str('provider');
|
|
121
|
+
if (!provider || !EMAIL_PROVIDERS.includes(provider))
|
|
122
|
+
throw new NotifierFileError(`provider must be one of: ${EMAIL_PROVIDERS.join(', ')}`);
|
|
123
|
+
file.to = recipients.join(', ');
|
|
124
|
+
file.from = from;
|
|
125
|
+
file.provider = provider;
|
|
126
|
+
}
|
|
110
127
|
return file;
|
|
111
128
|
}
|
|
112
129
|
/** The agent's connection: messaging.yml's first, else the older
|
|
@@ -147,8 +164,9 @@ export function removeNotifierFile(dir) {
|
|
|
147
164
|
}
|
|
148
165
|
/** The HttpNotifierConfig a connection describes, with its secret read
|
|
149
166
|
* from `env` — or which secret is missing, so nothing is sent unsigned
|
|
150
|
-
* or unauthenticated.
|
|
151
|
-
|
|
167
|
+
* or unauthenticated. `agentName` labels email deliveries in the
|
|
168
|
+
* console's list. */
|
|
169
|
+
export function notifierConfigFromFile(file, env, agentName = '') {
|
|
152
170
|
const secretVar = NOTIFIER_SECRET_VARS[file.channel];
|
|
153
171
|
const secret = env.get(secretVar);
|
|
154
172
|
if (!secret)
|
|
@@ -178,5 +196,19 @@ export function notifierConfigFromFile(file, env) {
|
|
|
178
196
|
// No card buttons: nothing built in handles Lark's card callbacks yet,
|
|
179
197
|
// so a console-configured Lark notifier relies on the decision link.
|
|
180
198
|
return { config: { channel: 'lark', config: { appId: file.appId, appSecret: secret, chatId: file.chatId, publicUrl: file.publicUrl, buttons: false }, events: file.events } };
|
|
199
|
+
case 'email':
|
|
200
|
+
// Links to the decision page, like Lark without buttons; the secret
|
|
201
|
+
// is the provider's API key.
|
|
202
|
+
return {
|
|
203
|
+
config: {
|
|
204
|
+
channel: 'email',
|
|
205
|
+
config: {
|
|
206
|
+
to: file.to,
|
|
207
|
+
sendEmail: createProviderSendEmail({ provider: file.provider, apiKey: secret, from: file.from, agent: agentName }),
|
|
208
|
+
publicUrl: file.publicUrl,
|
|
209
|
+
},
|
|
210
|
+
events: file.events,
|
|
211
|
+
},
|
|
212
|
+
};
|
|
181
213
|
}
|
|
182
214
|
}
|
package/dist/core/scheduler.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
|
+
/** `time_zone` (repeating in whole days only): the zone whose calendar
|
|
2
|
+
* days it steps through, so its time of day survives daylight saving.
|
|
3
|
+
* Tasks made before it existed step in exact 24-hour days. */
|
|
1
4
|
export type Schedule = {
|
|
2
5
|
run_at: string;
|
|
3
6
|
} | {
|
|
4
7
|
every: string;
|
|
5
8
|
anchor?: string;
|
|
9
|
+
time_zone?: string;
|
|
6
10
|
};
|
|
7
11
|
export type ScheduledTaskStatus = 'scheduled' | 'running' | 'awaiting_approval' | 'paused' | 'done' | 'cancelled';
|
|
8
12
|
export interface ScheduledTaskRun {
|
|
@@ -73,9 +77,20 @@ export declare function setSchedulerStore(next: SchedulerStore): void;
|
|
|
73
77
|
export interface CreateScheduledTaskInput {
|
|
74
78
|
agent: string;
|
|
75
79
|
message: string;
|
|
80
|
+
/** `start_at` (with `every` only): when the first run is, and so the
|
|
81
|
+
* time of day / day of week every later run keeps, e.g. every "1d"
|
|
82
|
+
* from "2026-11-02T09:00:00+08:00" runs daily at 9:00. A start in the
|
|
83
|
+
* past is fine: the first run is its next slot after now. Without it,
|
|
84
|
+
* the first run is one interval from now. `at` ("09:00", with `every`
|
|
85
|
+
* only) instead starts it at the next 9:00 in the workspace's time
|
|
86
|
+
* zone, and `weekday` ("monday", or 0 for Sunday to 6) at the next one
|
|
87
|
+
* on that day. */
|
|
76
88
|
schedule: {
|
|
77
89
|
run_at?: unknown;
|
|
78
90
|
every?: unknown;
|
|
91
|
+
start_at?: unknown;
|
|
92
|
+
at?: unknown;
|
|
93
|
+
weekday?: unknown;
|
|
79
94
|
};
|
|
80
95
|
task_name?: string;
|
|
81
96
|
notify?: boolean;
|
|
@@ -95,6 +110,13 @@ export declare function resumeScheduledTask(taskId: string, nowMs?: number): Pro
|
|
|
95
110
|
* finished one-off task runs once more. */
|
|
96
111
|
export declare function runScheduledTaskNow(taskId: string, nowMs?: number): Promise<ScheduledTask>;
|
|
97
112
|
export declare function deleteScheduledTask(taskId: string): Promise<void>;
|
|
113
|
+
/** Moves every daily and weekly (whole-day) task to `toTimeZone`,
|
|
114
|
+
* keeping its time of day: a 9:00 task made while the workspace was on
|
|
115
|
+
* UTC runs at 9:00 in the new zone. A task from before tasks kept their
|
|
116
|
+
* zone is taken to be in `fromTimeZone`. One-off and shorter repeating
|
|
117
|
+
* tasks are left alone: they're a moment, not a time of day. Returns
|
|
118
|
+
* how many moved. */
|
|
119
|
+
export declare function moveScheduledTasksToTimeZone(fromTimeZone: string, toTimeZone: string, nowMs?: number): Promise<number>;
|
|
98
120
|
/** When the next task is due — for a host that stops idle servers and
|
|
99
121
|
* needs to wake this one in time (GET /internal/activity). */
|
|
100
122
|
export declare function nextScheduledWakeAt(): Promise<string | undefined>;
|