loopengine 0.3.0 → 0.3.2
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/core/agent-config.d.ts +2 -0
- package/dist/core/http-notifier.js +14 -3
- package/dist/core/http-notify-triggers/telegram.d.ts +20 -1
- package/dist/core/http-notify-triggers/telegram.js +63 -17
- package/dist/core/messaging/connector.d.ts +7 -0
- package/dist/core/messaging/connector.js +1 -1
- package/dist/core/messaging/connectors/slack/events.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/events.js +6 -2
- package/dist/core/messaging/connectors/slack/setup.d.ts +2 -1
- package/dist/core/messaging/connectors/slack/setup.js +10 -5
- package/dist/core/messaging/connectors/telegram/format.d.ts +6 -0
- package/dist/core/messaging/connectors/telegram/format.js +57 -0
- package/dist/core/messaging/connectors/telegram/index.d.ts +39 -0
- package/dist/core/messaging/connectors/telegram/index.js +136 -0
- package/dist/core/messaging/connectors/telegram/store.d.ts +31 -0
- package/dist/core/messaging/connectors/telegram/store.js +69 -0
- package/dist/core/messaging/connectors/telegram/updates.d.ts +40 -0
- package/dist/core/messaging/connectors/telegram/updates.js +112 -0
- package/dist/core/messaging/jobs.d.ts +44 -0
- package/dist/core/messaging/jobs.js +100 -0
- package/dist/core/messaging/threads.d.ts +12 -0
- package/dist/core/messaging/threads.js +36 -0
- package/dist/core/notifier-file.d.ts +13 -5
- package/dist/core/notifier-file.js +41 -6
- package/dist/core/run-callbacks.d.ts +39 -0
- package/dist/core/run-callbacks.js +125 -0
- package/dist/core/system-agents/workflow-planner.d.ts +37 -4
- package/dist/core/system-agents/workflow-planner.js +79 -5
- package/dist/core/workflow-runs.d.ts +131 -0
- package/dist/core/workflow-runs.js +229 -0
- package/dist/core/workflow-triggers.d.ts +140 -0
- package/dist/core/workflow-triggers.js +378 -0
- package/dist/web/agents-config-page.js +168 -50
- package/dist/web/notifier-admin.d.ts +69 -13
- package/dist/web/notifier-admin.js +127 -31
- package/dist/web/workflow-admin.d.ts +10 -0
- package/dist/web/workflow-admin.js +42 -3
- package/dist/web/workflow-page.js +93 -2
- package/package.json +7 -1
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
// Backs the console's Messaging tab: where an agent's durable
|
|
2
|
+
// approvals/questions go (core/http-notifier.ts's describeNotifier plus
|
|
3
|
+
// the deployment-wide default webhook), whether it answers replies there,
|
|
4
|
+
// editing its agents/<name>/messaging.yml, a test send and recent
|
|
5
|
+
// deliveries — and the decisions still waiting, shown in Permissions. Plain functions over loopengine's own modules
|
|
6
|
+
// so adapters/http.ts (and the scaffolded copy create-loopengine ships)
|
|
7
|
+
// only needs thin route handlers.
|
|
8
|
+
import { randomBytes } from 'node:crypto';
|
|
1
9
|
import { agentDirFor, createAgentEnv } from '../core/agent-env.js';
|
|
2
10
|
import { decidePath } from '../core/approval-links.js';
|
|
3
11
|
import { describeNotifier } from '../core/http-notifier.js';
|
|
@@ -7,8 +15,9 @@ import { SlackNotifier } from '../core/http-notify-triggers/slack.js';
|
|
|
7
15
|
import { LarkNotifier } from '../core/http-notify-triggers/lark.js';
|
|
8
16
|
import { TelegramNotifier, sendTestTelegramMessage } from '../core/http-notify-triggers/telegram.js';
|
|
9
17
|
import { listDeliveries } from '../core/notifier-deliveries.js';
|
|
10
|
-
import { NOTIFIER_CHANNELS, NOTIFIER_SECRET_VARS, PRIMARY_CHANNELS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readActiveConnections, readMessagingConnections, readNotifierFile, removeMessagingConnection, removeNotifierFile, setPrimaryConnection, writeNotifierFile } from '../core/notifier-file.js';
|
|
18
|
+
import { NOTIFIER_CHANNELS, NOTIFIER_SECRET_VARS, PRIMARY_CHANNELS, NOTIFIER_SLACK_SIGNING_SECRET_VAR, NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR, NotifierFileError, notifierConfigFromFile, readActiveConnections, readMessagingConnections, readNotifierFile, removeMessagingConnection, removeNotifierFile, setPrimaryConnection, writeNotifierFile } from '../core/notifier-file.js';
|
|
11
19
|
import { listSlackChannelMembers, listSlackChannels, slackAppManifest } from '../core/messaging/connectors/slack/index.js';
|
|
20
|
+
import { telegramBotUsername, telegramPeople, setTelegramWebhook, telegramWebhookInfo } from '../core/messaging/connectors/telegram/index.js';
|
|
12
21
|
import { setAgentEnvVar } from './env-admin.js';
|
|
13
22
|
export function describeAgentNotifier(config, defaultWebhook) {
|
|
14
23
|
const described = describeNotifier(config);
|
|
@@ -49,6 +58,11 @@ export function describeAgentNotifier(config, defaultWebhook) {
|
|
|
49
58
|
// interactivityPath as you type.
|
|
50
59
|
manifestTemplate: slackAppManifest({ agentName: config.name, interactivityUrl: '__INTERACTIVITY_URL__' }),
|
|
51
60
|
},
|
|
61
|
+
telegram: {
|
|
62
|
+
// Set once the bot's webhook points here (setupAgentTelegramWebhook).
|
|
63
|
+
webhookSet: env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR) !== undefined,
|
|
64
|
+
webhookPath: `/hooks/telegram/${encodeURIComponent(config.name)}`,
|
|
65
|
+
},
|
|
52
66
|
deliveries: listDeliveries(config.name),
|
|
53
67
|
};
|
|
54
68
|
}
|
|
@@ -89,49 +103,131 @@ export function slackCredentialsFor(config) {
|
|
|
89
103
|
const botToken = config.httpNotifier?.channel === 'slack' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.slack);
|
|
90
104
|
return { botToken, signingSecret: env.get(NOTIFIER_SLACK_SIGNING_SECRET_VAR) };
|
|
91
105
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
*
|
|
106
|
+
const APP_NAMES = { slack: 'Slack', telegram: 'Telegram' };
|
|
107
|
+
/** Whether replies in an app's request threads are answered: on unless
|
|
108
|
+
* that app's section of the Messaging tab turned Conversations off. An
|
|
109
|
+
* agent whose notifier is set in code answers in that app only. */
|
|
110
|
+
export function conversationsOn(config, app) {
|
|
111
|
+
if (config.httpNotifier)
|
|
112
|
+
return config.httpNotifier.channel === app;
|
|
113
|
+
const file = savedConnection(config, app);
|
|
114
|
+
return Boolean(file) && file.conversations !== false;
|
|
115
|
+
}
|
|
95
116
|
export function slackConversationsOn(config) {
|
|
117
|
+
return conversationsOn(config, 'slack');
|
|
118
|
+
}
|
|
119
|
+
/** Whether a DM to the agent's bot in `app`, or a mention of it, starts a
|
|
120
|
+
* job (core/messaging/jobs.ts). Off unless turned on in that app's
|
|
121
|
+
* section; never for a notifier set in code. */
|
|
122
|
+
export function jobsOn(config, app) {
|
|
96
123
|
if (config.httpNotifier)
|
|
97
|
-
return config.httpNotifier.channel === 'slack';
|
|
98
|
-
try {
|
|
99
|
-
return readNotifierFile(agentDirFor(config))?.conversations !== false;
|
|
100
|
-
}
|
|
101
|
-
catch {
|
|
102
124
|
return false;
|
|
103
|
-
|
|
125
|
+
return savedConnection(config, app)?.jobs === true;
|
|
104
126
|
}
|
|
105
|
-
/** The people who can decide this agent's requests from
|
|
106
|
-
* messaging.yml; none when the
|
|
107
|
-
|
|
127
|
+
/** The people who can decide this agent's requests from `app`: from that
|
|
128
|
+
* app's connection in messaging.yml; none when the notifier is set in
|
|
129
|
+
* code. */
|
|
130
|
+
export function agentApprovers(config, app = 'slack') {
|
|
108
131
|
if (config.httpNotifier)
|
|
109
132
|
return [];
|
|
110
|
-
|
|
111
|
-
return readNotifierFile(agentDirFor(config))?.approvers ?? [];
|
|
112
|
-
}
|
|
113
|
-
catch {
|
|
114
|
-
return [];
|
|
115
|
-
}
|
|
133
|
+
return savedConnection(config, app)?.approvers ?? [];
|
|
116
134
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
135
|
+
function approverList(app, approvers) {
|
|
136
|
+
return approvers.map((a) => (app === 'slack' ? `<@${a.user}>` : a.name ?? a.user)).join(', ');
|
|
137
|
+
}
|
|
138
|
+
/** Whether a person's button click counts: anyone's does while the
|
|
139
|
+
* agent has no approvers in that app (as before approvers existed), only
|
|
140
|
+
* theirs once it has some. `message` says who can, for a refused click. */
|
|
141
|
+
export function clickAllowed(config, app, user) {
|
|
142
|
+
const approvers = agentApprovers(config, app);
|
|
122
143
|
if (!approvers.length || (user && approvers.some((a) => a.user === user)))
|
|
123
144
|
return { allowed: true };
|
|
124
|
-
return { allowed: false, message: `Only approvers can decide this: ${
|
|
145
|
+
return { allowed: false, message: `Only approvers can decide this: ${approverList(app, approvers)}.` };
|
|
125
146
|
}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
export function slackClickAllowed(config, user) {
|
|
148
|
+
return clickAllowed(config, 'slack', user);
|
|
149
|
+
}
|
|
150
|
+
/** Whether a person may start a job from a message, or continue one: an
|
|
151
|
+
* approver, once the app has any; with none, anyone in the agent's own
|
|
152
|
+
* channel or chat, but nobody by DM. */
|
|
153
|
+
export function jobAllowed(config, app, message) {
|
|
154
|
+
const approvers = agentApprovers(config, app);
|
|
155
|
+
if (approvers.length) {
|
|
156
|
+
if (approvers.some((a) => a.user === message.user))
|
|
157
|
+
return { allowed: true };
|
|
158
|
+
return { allowed: false, message: `Only approvers can give me jobs here: ${approverList(app, approvers)}.` };
|
|
159
|
+
}
|
|
160
|
+
const file = savedConnection(config, app);
|
|
161
|
+
const home = app === 'slack' ? file?.channelId : file?.chatId;
|
|
162
|
+
if (!message.direct && home && home === message.conversation)
|
|
163
|
+
return { allowed: true };
|
|
164
|
+
return { allowed: false, message: `Jobs by direct message need approvers: add yourself in this agent's Messaging tab in LoopEngine (${APP_NAMES[app]} section).` };
|
|
165
|
+
}
|
|
166
|
+
/** Replaces the approver list on the agent's saved connection for `app`,
|
|
167
|
+
* primary or not. Throws NotifierFileError when there's no connection
|
|
168
|
+
* for that app to add them to. */
|
|
169
|
+
export function setAgentApprovers(config, approvers, app = 'slack') {
|
|
170
|
+
const file = savedConnection(config, app);
|
|
131
171
|
if (!file)
|
|
132
|
-
throw new NotifierFileError(
|
|
172
|
+
throw new NotifierFileError(`Save a ${APP_NAMES[app]} connection first`);
|
|
133
173
|
return writeNotifierFile(agentDirFor(config), { ...file, approvers: Array.isArray(approvers) ? approvers : [] }).approvers ?? [];
|
|
134
174
|
}
|
|
175
|
+
/** The bot token behind an agent's Telegram notifier and the secret its
|
|
176
|
+
* webhook was set up with, like slackCredentialsFor. */
|
|
177
|
+
export function telegramCredentialsFor(config) {
|
|
178
|
+
const env = createAgentEnv(config.name, agentDirFor(config));
|
|
179
|
+
const botToken = config.httpNotifier?.channel === 'telegram' ? config.httpNotifier.config.botToken : env.get(NOTIFIER_SECRET_VARS.telegram);
|
|
180
|
+
return { botToken, webhookSecret: env.get(NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR) };
|
|
181
|
+
}
|
|
182
|
+
/** The address remembered for links when the app's connection was saved. */
|
|
183
|
+
export function connectionPublicUrl(config, app) {
|
|
184
|
+
return savedConnection(config, app)?.publicUrl;
|
|
185
|
+
}
|
|
186
|
+
/** The Telegram chat the agent posts requests in, if any. */
|
|
187
|
+
export function telegramHomeChat(config) {
|
|
188
|
+
return config.httpNotifier?.channel === 'telegram' ? config.httpNotifier.config.chatId : savedConnection(config, 'telegram')?.chatId;
|
|
189
|
+
}
|
|
190
|
+
/** People who have written to the agent's Telegram bot, newest first,
|
|
191
|
+
* for picking approvers: the Bot API can't list a chat's members. */
|
|
192
|
+
export function listAgentTelegramPeople(config) {
|
|
193
|
+
return telegramPeople(agentDirFor(config));
|
|
194
|
+
}
|
|
195
|
+
/** Points the agent's Telegram bot at POST /hooks/telegram/<agent> on
|
|
196
|
+
* `baseUrl` (LOOPENGINE_PUBLIC_URL wins), with a secret made once and
|
|
197
|
+
* kept in the agent's env. From then on requests get buttons and replies
|
|
198
|
+
* reach the agent. */
|
|
199
|
+
export async function setupAgentTelegramWebhook(config, baseUrl) {
|
|
200
|
+
const { botToken, webhookSecret } = telegramCredentialsFor(config);
|
|
201
|
+
if (!botToken)
|
|
202
|
+
throw new Error('Save the Telegram bot token first');
|
|
203
|
+
const base = (process.env.LOOPENGINE_PUBLIC_URL || baseUrl).replace(/\/+$/, '');
|
|
204
|
+
if (!/^https:\/\//.test(base))
|
|
205
|
+
throw new Error('Telegram only sends updates to an https address; set LOOPENGINE_PUBLIC_URL to this server\'s https address');
|
|
206
|
+
const secret = webhookSecret ?? randomBytes(24).toString('hex');
|
|
207
|
+
const url = `${base}/hooks/telegram/${encodeURIComponent(config.name)}`;
|
|
208
|
+
const set = await setTelegramWebhook(botToken, url, secret);
|
|
209
|
+
if (!set.ok)
|
|
210
|
+
throw new Error(set.error);
|
|
211
|
+
if (!webhookSecret)
|
|
212
|
+
await setAgentEnvVar(config.name, NOTIFIER_TELEGRAM_WEBHOOK_SECRET_VAR, secret);
|
|
213
|
+
return { url, ...(set.botUsername ? { botUsername: set.botUsername } : {}) };
|
|
214
|
+
}
|
|
215
|
+
/** Where the agent's Telegram bot sends its updates now. */
|
|
216
|
+
export async function agentTelegramWebhookInfo(config) {
|
|
217
|
+
const { botToken } = telegramCredentialsFor(config);
|
|
218
|
+
if (!botToken)
|
|
219
|
+
throw new Error('Save the Telegram bot token first');
|
|
220
|
+
const info = await telegramWebhookInfo(botToken);
|
|
221
|
+
if (!info.ok)
|
|
222
|
+
throw new Error(info.error);
|
|
223
|
+
const botUsername = await telegramBotUsername(botToken);
|
|
224
|
+
return {
|
|
225
|
+
...(info.url ? { url: info.url } : {}),
|
|
226
|
+
...(info.lastError ? { lastError: info.lastError } : {}),
|
|
227
|
+
pointsHere: Boolean(info.url?.endsWith(`/hooks/telegram/${encodeURIComponent(config.name)}`)),
|
|
228
|
+
...(botUsername ? { botUsername } : {}),
|
|
229
|
+
};
|
|
230
|
+
}
|
|
135
231
|
/** The people in the agent's Slack channel, for picking approvers. */
|
|
136
232
|
export async function listAgentSlackMembers(config) {
|
|
137
233
|
const { botToken } = slackCredentialsFor(config);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type CreatedTemplateAgent, type TemplateChecklistItem, type TemplateCreateDeps } from './templates-admin.js';
|
|
2
2
|
import { type WorkflowPlan, type WorkflowPlannerTools } from '../core/system-agents/workflow-planner.js';
|
|
3
|
+
import { type PublicTrigger } from '../core/workflow-triggers.js';
|
|
3
4
|
import { type GeneratedMcpAbility, type McpAbilityRequest } from '../core/mcp-ability-generator.js';
|
|
4
5
|
import type { McpDirectoryServer } from '../core/mcp-directory.js';
|
|
5
6
|
import { type Workflow } from '../core/workflows.js';
|
|
@@ -126,6 +127,15 @@ export interface CreatedWorkflow {
|
|
|
126
127
|
checklist: TemplateChecklistItem[];
|
|
127
128
|
schedule?: CreatedTemplateAgent['schedule'];
|
|
128
129
|
scheduleError?: string;
|
|
130
|
+
/** The plan's webhook, made turned off until its signing secret is
|
|
131
|
+
* added. `token` makes its URL, shown once (the server turns it into
|
|
132
|
+
* `url`). */
|
|
133
|
+
webhook?: {
|
|
134
|
+
trigger: PublicTrigger;
|
|
135
|
+
token: string;
|
|
136
|
+
setup?: string;
|
|
137
|
+
};
|
|
138
|
+
webhookError?: string;
|
|
129
139
|
loadError?: string;
|
|
130
140
|
}
|
|
131
141
|
export interface WorkflowCreateDeps extends TemplateCreateDeps {
|
|
@@ -10,7 +10,8 @@ import { addActauthRule, readActauthConfig } from './actauth-admin.js';
|
|
|
10
10
|
import { writeSkill } from './skills-admin.js';
|
|
11
11
|
import { createFromTemplateObject, installFailureReason, templateChecklist } from './templates-admin.js';
|
|
12
12
|
import { templateScheduleStart } from '../core/system-agents/templates.js';
|
|
13
|
-
import { planParameters, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
|
|
13
|
+
import { planParameters, planWebhook, planWorkflowWithModel, slugify, workflowTemplate } from '../core/system-agents/workflow-planner.js';
|
|
14
|
+
import { createTrigger, publicTrigger } from '../core/workflow-triggers.js';
|
|
14
15
|
import { mcpAbilityNames } from '../core/mcp-ability-generator.js';
|
|
15
16
|
import { clearWorkflowFields, migrateWorkflowsFile, readWorkflow, readWorkflows, setWorkflowFields, validateParams, workflowRunMessage } from '../core/workflows.js';
|
|
16
17
|
import { isUnderWay, listRuns } from '../core/workflow-runs.js';
|
|
@@ -291,6 +292,38 @@ export function getPlanJob(id) {
|
|
|
291
292
|
const { at: _at, ...view } = job;
|
|
292
293
|
return view;
|
|
293
294
|
}
|
|
295
|
+
const SENDER_NAMES = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'the sender', custom: 'your app' };
|
|
296
|
+
/** Makes the plan's webhook for the created workflow: turned off, since
|
|
297
|
+
* the sender's signing secret comes from the sender, after the URL is
|
|
298
|
+
* pasted there. */
|
|
299
|
+
async function makePlannedWebhook(plan, agent, agentDir) {
|
|
300
|
+
const workflow = readWorkflow(agentDir, plan.id);
|
|
301
|
+
const webhook = workflow && planWebhook(plan.webhook, workflow.parameters);
|
|
302
|
+
if (!plan.webhook)
|
|
303
|
+
return { checklist: [] };
|
|
304
|
+
if (!workflow || !webhook)
|
|
305
|
+
return { webhookError: 'The webhook could not be set up from the plan; add it on the workflow’s Webhooks.', checklist: [] };
|
|
306
|
+
try {
|
|
307
|
+
const { trigger, token } = await createTrigger(agent, workflow, {
|
|
308
|
+
name: webhook.name,
|
|
309
|
+
enabled: false,
|
|
310
|
+
verify: { sender: webhook.sender, ...(webhook.header ? { header: webhook.header } : {}) },
|
|
311
|
+
...(webhook.filter ? { filter: webhook.filter } : {}),
|
|
312
|
+
...(webhook.mapping ? { mapping: webhook.mapping } : {}),
|
|
313
|
+
...(plan.notify?.on && plan.trigger.kind === 'webhook' ? { notify: true } : {}),
|
|
314
|
+
});
|
|
315
|
+
return {
|
|
316
|
+
webhook: { trigger: publicTrigger(trigger), token, ...(webhook.setup ? { setup: webhook.setup } : {}) },
|
|
317
|
+
checklist: [{
|
|
318
|
+
label: `Turn on the ${webhook.name} webhook: paste its URL into ${SENDER_NAMES[webhook.sender]}'s webhook settings, then add the signing secret ${webhook.sender === 'custom' ? 'your app signs with' : 'it shows'}`,
|
|
319
|
+
href: `/agents/config?agent=${encodeURIComponent(agent)}&tab=overview`,
|
|
320
|
+
}],
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
return { webhookError: err instanceof Error ? err.message : String(err), checklist: [] };
|
|
325
|
+
}
|
|
326
|
+
}
|
|
294
327
|
/** A to-do for each step nothing could be found for, first in the list. */
|
|
295
328
|
function withMissingSteps(plan, agent, checklist) {
|
|
296
329
|
const href = `/agents/config?agent=${encodeURIComponent(agent)}&tab=abilities`;
|
|
@@ -321,13 +354,16 @@ export async function createWorkflow(input, deps) {
|
|
|
321
354
|
const connected = copyConnectedTools(plan, created.name, agents, deps.agentDir);
|
|
322
355
|
const loadError = created.loadError;
|
|
323
356
|
markWorkflow(deps.agentDir(created.name));
|
|
357
|
+
const hook = await makePlannedWebhook(plan, created.name, deps.agentDir(created.name));
|
|
324
358
|
return {
|
|
325
359
|
agent: created.name,
|
|
326
360
|
newAgent: true,
|
|
327
361
|
workflow: plan.name,
|
|
328
362
|
abilities: { installed: [...created.abilities.installed, ...connected.copied], failed: [...mcp.failed, ...created.abilities.failed, ...connected.failed] },
|
|
329
363
|
keptDecisions: [],
|
|
330
|
-
checklist: withMissingSteps(plan, created.name, created.checklist),
|
|
364
|
+
checklist: [...withMissingSteps(plan, created.name, created.checklist), ...hook.checklist],
|
|
365
|
+
...(hook.webhook ? { webhook: hook.webhook } : {}),
|
|
366
|
+
...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
|
|
331
367
|
...(created.schedule ? { schedule: created.schedule } : {}),
|
|
332
368
|
...(created.scheduleError ? { scheduleError: created.scheduleError } : {}),
|
|
333
369
|
...(loadError ? { loadError } : {}),
|
|
@@ -433,13 +469,16 @@ export async function createWorkflow(input, deps) {
|
|
|
433
469
|
}
|
|
434
470
|
}
|
|
435
471
|
markWorkflow(deps.agentDir(agent.name));
|
|
472
|
+
const hook = await makePlannedWebhook(plan, agent.name, deps.agentDir(agent.name));
|
|
436
473
|
return {
|
|
437
474
|
agent: agent.name,
|
|
438
475
|
newAgent: false,
|
|
439
476
|
workflow: plan.name,
|
|
440
477
|
abilities: { installed, failed },
|
|
441
478
|
keptDecisions: check.conflicts,
|
|
442
|
-
checklist: withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)),
|
|
479
|
+
checklist: [...withMissingSteps(plan, agent.name, templateChecklist(template, agent.name, deps, failed, appsToConnect)), ...hook.checklist],
|
|
480
|
+
...(hook.webhook ? { webhook: hook.webhook } : {}),
|
|
481
|
+
...(hook.webhookError ? { webhookError: hook.webhookError } : {}),
|
|
443
482
|
...(schedule ? { schedule } : {}),
|
|
444
483
|
...(scheduleError ? { scheduleError } : {}),
|
|
445
484
|
...(loadError ? { loadError } : {}),
|
|
@@ -30,6 +30,11 @@ export const workflowPageHtml = `<!doctype html>
|
|
|
30
30
|
.wf-target label { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
|
|
31
31
|
.wf-notify { display: flex; gap: 8px; align-items: center; font-size: 13.5px; }
|
|
32
32
|
.wf-notify input { margin: 0; }
|
|
33
|
+
.wf-hook { border: 1px solid var(--line); border-radius: var(--radius); padding: 10px 12px; display: grid; gap: 4px; background: var(--surface); font-size: 13px; }
|
|
34
|
+
.wf-hook ul { margin: 2px 0 0; padding-left: 18px; }
|
|
35
|
+
.wf-hook input[type="text"], .wf-hook input:not([type]) { font: inherit; padding: 6px 8px; border: 1px solid var(--line); border-radius: 6px; background: var(--bg); color: var(--ink); width: 100%; box-sizing: border-box; }
|
|
36
|
+
.wf-hook-row { display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
|
|
37
|
+
.wf-hook-row input { flex: 1; min-width: 220px; }
|
|
33
38
|
.wf-params { display: grid; gap: 6px; }
|
|
34
39
|
.wf-param { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; font-size: 13px; }
|
|
35
40
|
.wf-param .wf-param-name { width: 160px; font-family: var(--mono, monospace); }
|
|
@@ -73,8 +78,10 @@ ${consoleShellScript}
|
|
|
73
78
|
var EXAMPLES = [
|
|
74
79
|
['Blog post', 'Every Monday morning, browse the web for timely blog post ideas about our product area. Ask me on Slack to pick one of the ideas. Then draft the post and let me review it before it is created.'],
|
|
75
80
|
['Weekly digest', 'Every Friday afternoon, search the web for news about our competitors and summarise the important changes. Send me the summary for review before it goes to the team channel.'],
|
|
76
|
-
['Refund requests', 'When asked, look up the customer' + String.fromCharCode(8217) + 's order and check our refund policy. If a refund fits the policy, prepare it and ask me to approve it before it is issued.']
|
|
81
|
+
['Refund requests', 'When asked, look up the customer' + String.fromCharCode(8217) + 's order and check our refund policy. If a refund fits the policy, prepare it and ask me to approve it before it is issued.'],
|
|
82
|
+
['Stripe disputes', 'When a Stripe dispute comes in, look up the order and its delivery, write the evidence for Stripe, and ask me before submitting it.']
|
|
77
83
|
];
|
|
84
|
+
var SENDERS = { stripe: 'Stripe', github: 'GitHub', shopify: 'Shopify', standard: 'Standard Webhooks', custom: 'your app' };
|
|
78
85
|
var state = { description: '', planned: null, agents: [], ready: false, canInstall: false };
|
|
79
86
|
|
|
80
87
|
function esc(s) {
|
|
@@ -228,6 +235,24 @@ ${consoleShellScript}
|
|
|
228
235
|
return { parameters: parameters };
|
|
229
236
|
}
|
|
230
237
|
|
|
238
|
+
// The webhook the plan makes: whose events, which ones, and what each
|
|
239
|
+
// parameter is filled from. Made turned off; its signing secret comes
|
|
240
|
+
// from the sender, after Create.
|
|
241
|
+
function webhookPlanHtml(p) {
|
|
242
|
+
var w = p.webhook;
|
|
243
|
+
if (!w) return '';
|
|
244
|
+
var who = SENDERS[w.sender] || w.sender;
|
|
245
|
+
var lines = [];
|
|
246
|
+
if (w.filter) lines.push('Runs for ' + esc(w.filter.path) + ' ' + esc(w.filter.values.join(' or ')));
|
|
247
|
+
Object.keys(w.mapping || {}).forEach(function (param) { lines.push('<code>' + esc(param) + '</code> ' + String.fromCharCode(8592) + ' <code>' + esc(w.mapping[param]) + '</code> in the payload'); });
|
|
248
|
+
return '<div class="wf-hook">' +
|
|
249
|
+
'<label class="wf-notify"><input type="checkbox" id="wfWebhook" checked> <strong>' + (p.trigger.kind === 'schedule' ? 'Also start it from a webhook: ' : 'Webhook: ') + esc(w.name) + '</strong></label>' +
|
|
250
|
+
'<span class="hint">' + esc(who) + ' calls a private URL and each event starts a run. It' + String.fromCharCode(8217) + 's made turned off; after Create you paste the URL into ' + esc(w.sender === 'custom' ? 'your app' : who) + ' and add the signing secret.</span>' +
|
|
251
|
+
(lines.length ? '<ul class="hint">' + lines.map(function (l) { return '<li>' + l + '</li>'; }).join('') + '</ul>' : '') +
|
|
252
|
+
(w.setup ? '<span class="hint">' + esc(w.setup) + '</span>' : '') +
|
|
253
|
+
'</div>';
|
|
254
|
+
}
|
|
255
|
+
|
|
231
256
|
function renderPlan(error) {
|
|
232
257
|
var p = state.planned.plan;
|
|
233
258
|
var suggested = p.target.kind === 'existing' && p.target.agent ? p.target.agent : '';
|
|
@@ -236,7 +261,8 @@ ${consoleShellScript}
|
|
|
236
261
|
'<div class="wf-card">' +
|
|
237
262
|
'<h2>' + esc(p.name) + '</h2><p style="margin:0">' + esc(p.summary) + '</p>' +
|
|
238
263
|
'<p class="hint" style="margin:0"><strong>Starts:</strong> ' + esc(p.trigger.description) + (p.trigger.kind === 'schedule' ? ' (created paused; you turn it on)' : '') + '</p>' +
|
|
239
|
-
(p
|
|
264
|
+
webhookPlanHtml(p) +
|
|
265
|
+
(p.trigger.kind === 'schedule' || p.trigger.kind === 'webhook'
|
|
240
266
|
? '<label class="wf-notify"><input type="checkbox" id="wfNotify"' + (p.notify && p.notify.on ? ' checked' : '') + '> Send each run' + String.fromCharCode(8217) + 's result to you through the agent' + String.fromCharCode(8217) + 's messaging app</label>' +
|
|
241
267
|
(p.notify && p.notify.reason ? '<p class="hint" style="margin:0 0 0 24px">' + esc(p.notify.reason) + '</p>' : '')
|
|
242
268
|
: '') +
|
|
@@ -279,6 +305,11 @@ ${consoleShellScript}
|
|
|
279
305
|
p.parameters = edited.parameters;
|
|
280
306
|
var notifyBox = document.getElementById('wfNotify');
|
|
281
307
|
if (notifyBox) p.notify = { on: notifyBox.checked, reason: (p.notify && p.notify.reason) || '' };
|
|
308
|
+
var webhookBox = document.getElementById('wfWebhook');
|
|
309
|
+
if (webhookBox && !webhookBox.checked) {
|
|
310
|
+
delete p.webhook;
|
|
311
|
+
if (p.trigger.kind === 'webhook') p.trigger.kind = 'on_request';
|
|
312
|
+
}
|
|
282
313
|
var chosen = page.querySelector('input[name="wfTarget"]:checked');
|
|
283
314
|
var target = chosen.value === 'new' ? { kind: 'new' } : { kind: 'existing', agent: chosen.value === 'other' ? document.getElementById('wfOther').value : chosen.dataset.agent };
|
|
284
315
|
var btn = document.getElementById('wfCreate');
|
|
@@ -303,6 +334,7 @@ ${consoleShellScript}
|
|
|
303
334
|
'<p style="margin:0">' + (c.newAgent ? 'Created as a new agent, <code>' + esc(c.agent) + '</code>.' : 'Added to <code>' + esc(c.agent) + '</code>.') + '</p>' +
|
|
304
335
|
(c.schedule ? '<p class="hint" style="margin:0">Its schedule (' + esc(c.schedule.description) + ') is paused: turn it on in the agent' + String.fromCharCode(8217) + 's Schedules tab when you' + String.fromCharCode(8217) + 're ready.</p>' : '') +
|
|
305
336
|
(c.scheduleError ? '<p class="error">The schedule could not be created: ' + esc(c.scheduleError) + '</p>' : '') +
|
|
337
|
+
webhookDoneHtml(c) +
|
|
306
338
|
(c.loadError ? '<p class="error">The agent did not load: ' + esc(c.loadError) + '</p>' : '') +
|
|
307
339
|
(c.abilities.failed.length ? '<p class="error">Not installed: ' + c.abilities.failed.map(function (f) { return esc(f.name) + ' (' + esc(f.error) + ')'; }).join('; ') + '</p>' : '') +
|
|
308
340
|
(c.keptDecisions.length ? '<p class="hint" style="margin:0"><strong>Kept as they were:</strong> ' + c.keptDecisions.map(function (k) { return '<code>' + esc(k.tool) + '</code> stays ' + esc(LABELS[k.has]) + ' (the workflow wanted ' + esc(LABELS[k.wants]) + ')'; }).join('; ') + '. Change them in Permissions if you want.</p>' : '') +
|
|
@@ -311,6 +343,65 @@ ${consoleShellScript}
|
|
|
311
343
|
'<button type="button" class="wf-btn plain" id="wfAnother">Describe another</button></div>' +
|
|
312
344
|
'</div>';
|
|
313
345
|
document.getElementById('wfAnother').addEventListener('click', function () { state.description = ''; state.planned = null; renderDescribe(); });
|
|
346
|
+
wireWebhookDone(c);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// The webhook made with the workflow: its URL (shown this once), where
|
|
350
|
+
// to paste it, and the signing secret that turns it on.
|
|
351
|
+
function webhookDoneHtml(c) {
|
|
352
|
+
if (c.webhookError) return '<p class="error">The webhook could not be made: ' + esc(c.webhookError) + '</p>';
|
|
353
|
+
if (!c.webhook) return '';
|
|
354
|
+
var t = c.webhook.trigger;
|
|
355
|
+
var who = SENDERS[t.verify.sender] || t.verify.sender;
|
|
356
|
+
var custom = t.verify.sender === 'custom';
|
|
357
|
+
return '<div class="wf-hook" id="wfHookDone">' +
|
|
358
|
+
'<strong>Turn on the webhook: ' + esc(t.name) + '</strong>' +
|
|
359
|
+
'<span>1. Copy this URL into ' + esc(custom ? 'your app' : who + String.fromCharCode(8217) + 's webhook settings') + '. It' + String.fromCharCode(8217) + 's shown only this once; make a new one on the workflow if it' + String.fromCharCode(8217) + 's lost.</span>' +
|
|
360
|
+
'<div class="wf-hook-row"><input id="wfHookUrl" readonly value="' + esc(c.webhook.url) + '"><button type="button" class="wf-btn plain" id="wfHookCopy">Copy</button></div>' +
|
|
361
|
+
(c.webhook.setup ? '<span class="hint">' + esc(c.webhook.setup) + '</span>' : '') +
|
|
362
|
+
'<span>2. ' + (custom
|
|
363
|
+
? 'Choose a signing secret. Your app signs each request body with it (HMAC-SHA256, hex) in the <code>' + esc(t.verify.header || 'X-Signature') + '</code> header.'
|
|
364
|
+
: 'Paste the signing secret ' + esc(who) + ' shows for this webhook.') + '</span>' +
|
|
365
|
+
(custom && !t.verify.header ? '<div class="wf-hook-row"><input id="wfHookHeader" placeholder="Signature header, e.g. X-Signature" value="X-Signature"></div>' : '') +
|
|
366
|
+
'<div class="wf-hook-row"><input id="wfHookSecret" type="password" autocomplete="off" placeholder="Signing secret"><button type="button" class="wf-btn" id="wfHookOn">Turn it on</button></div>' +
|
|
367
|
+
'<span class="hint" id="wfHookStatus">Until then it' + String.fromCharCode(8217) + 's off: nothing that reaches the URL starts a run.</span>' +
|
|
368
|
+
'</div>';
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function wireWebhookDone(c) {
|
|
372
|
+
if (!c.webhook) return;
|
|
373
|
+
var t = c.webhook.trigger;
|
|
374
|
+
var status = document.getElementById('wfHookStatus');
|
|
375
|
+
document.getElementById('wfHookCopy').addEventListener('click', function () {
|
|
376
|
+
var input = document.getElementById('wfHookUrl');
|
|
377
|
+
input.select();
|
|
378
|
+
if (navigator.clipboard) navigator.clipboard.writeText(input.value);
|
|
379
|
+
status.textContent = 'Copied.';
|
|
380
|
+
});
|
|
381
|
+
document.getElementById('wfHookOn').addEventListener('click', function () {
|
|
382
|
+
var secret = document.getElementById('wfHookSecret').value.trim();
|
|
383
|
+
if (!secret) { status.textContent = 'Paste the signing secret first.'; return; }
|
|
384
|
+
var verify = { sender: t.verify.sender, secret: secret };
|
|
385
|
+
if (t.verify.sender === 'custom') {
|
|
386
|
+
var headerInput = document.getElementById('wfHookHeader');
|
|
387
|
+
verify.header = headerInput ? headerInput.value.trim() : t.verify.header;
|
|
388
|
+
verify.algorithm = t.verify.algorithm || 'sha256';
|
|
389
|
+
verify.encoding = t.verify.encoding || 'hex';
|
|
390
|
+
if (t.verify.prefix) verify.prefix = t.verify.prefix;
|
|
391
|
+
}
|
|
392
|
+
var btn = document.getElementById('wfHookOn');
|
|
393
|
+
btn.disabled = true;
|
|
394
|
+
status.textContent = 'Turning it on' + ELLIPSIS;
|
|
395
|
+
fetch('/agents/' + encodeURIComponent(c.agent) + '/workflows/' + encodeURIComponent(t.workflow_id) + '/triggers/' + encodeURIComponent(t.trigger_id), {
|
|
396
|
+
method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ verify: verify, enabled: true }),
|
|
397
|
+
}).then(function (r) { return r.json().then(function (b) { return { ok: r.ok, body: b }; }); }).then(function (r) {
|
|
398
|
+
btn.disabled = false;
|
|
399
|
+
if (!r.ok) { status.textContent = r.body.error || 'Could not turn it on.'; return; }
|
|
400
|
+
document.getElementById('wfHookSecret').value = '';
|
|
401
|
+
status.textContent = 'On. ' + (t.filter ? 'Each ' + t.filter.values.join(' or ') + ' event' : 'Each event') + ' that reaches the URL now starts a run; deliveries show on the workflow' + String.fromCharCode(8217) + 's Webhooks.';
|
|
402
|
+
btn.textContent = 'Update the secret';
|
|
403
|
+
}).catch(function (err) { btn.disabled = false; status.textContent = err.message; });
|
|
404
|
+
});
|
|
314
405
|
}
|
|
315
406
|
|
|
316
407
|
fetch('/workflows', { cache: 'no-store' })
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "loopengine",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -124,6 +124,12 @@
|
|
|
124
124
|
"dist/core/durable-approvals.d.ts",
|
|
125
125
|
"dist/core/workflows.js",
|
|
126
126
|
"dist/core/workflows.d.ts",
|
|
127
|
+
"dist/core/workflow-runs.js",
|
|
128
|
+
"dist/core/workflow-runs.d.ts",
|
|
129
|
+
"dist/core/run-callbacks.js",
|
|
130
|
+
"dist/core/run-callbacks.d.ts",
|
|
131
|
+
"dist/core/workflow-triggers.js",
|
|
132
|
+
"dist/core/workflow-triggers.d.ts",
|
|
127
133
|
"dist/web",
|
|
128
134
|
"protocol/loop-event.schema.json",
|
|
129
135
|
"PROTOCOL.md",
|