loopengine 0.1.68 → 0.1.70
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 +33 -3
- package/dist/core/agent-config.d.ts +8 -0
- package/dist/core/http-notifier.js +2 -2
- package/dist/core/http-notify-triggers/lark.d.ts +9 -0
- package/dist/core/http-notify-triggers/lark.js +39 -19
- package/dist/core/notifier-file.d.ts +4 -0
- package/dist/core/notifier-file.js +17 -4
- package/dist/core/run-agent.js +7 -2
- package/dist/core/scheduler.d.ts +143 -0
- package/dist/core/scheduler.js +359 -0
- package/dist/core/slack-interactions.d.ts +59 -0
- package/dist/core/slack-interactions.js +168 -0
- package/dist/core/system-tools/index.d.ts +1 -0
- package/dist/core/system-tools/index.js +1 -0
- package/dist/core/system-tools/schedule.d.ts +2 -0
- package/dist/core/system-tools/schedule.js +111 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.js +5 -2
- package/dist/web/agents-config-page.js +225 -4
- package/dist/web/console-shell.js +1 -1
- package/dist/web/notifier-admin.d.ts +25 -1
- package/dist/web/notifier-admin.js +77 -2
- package/package.json +6 -2
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
// Scheduled tasks: "send this message to that agent later — once, or
|
|
2
|
+
// every N minutes". One scheduler per server, started at boot by
|
|
3
|
+
// adapters/http.ts, which also supplies how a task actually fires (the
|
|
4
|
+
// same turn path as POST /agents/:name/messages, so approvals, notifiers
|
|
5
|
+
// and decision links behave exactly as they do for any HTTP call).
|
|
6
|
+
//
|
|
7
|
+
// Everything a tool or the console needs — create, list, pause, cancel —
|
|
8
|
+
// works on the store directly (below), so it works whether or not this
|
|
9
|
+
// process is the one running the loop; the loop picks changes up on its
|
|
10
|
+
// next tick.
|
|
11
|
+
//
|
|
12
|
+
// `every` is anchored: an hourly task created at 10:07 keeps firing at
|
|
13
|
+
// :07, however long each run takes. A run missed while the server was
|
|
14
|
+
// down fires once when it's back, then the schedule moves to the next
|
|
15
|
+
// future slot (no burst of catch-up runs).
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { KeyedMutex } from './session-store.js';
|
|
20
|
+
const HISTORY_LIMIT = 20;
|
|
21
|
+
const EVERY_PATTERN = /^(\d+)\s*(s|m|h|d)$/;
|
|
22
|
+
const EVERY_UNIT_MS = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 };
|
|
23
|
+
/** Shortest allowed interval, so a typo can't hammer the model. */
|
|
24
|
+
const MIN_EVERY_MS = 30_000;
|
|
25
|
+
export class ScheduleError extends Error {
|
|
26
|
+
}
|
|
27
|
+
export function parseEvery(every) {
|
|
28
|
+
const match = EVERY_PATTERN.exec(every.trim());
|
|
29
|
+
if (!match)
|
|
30
|
+
throw new ScheduleError(`"${every}" is not a valid interval — use a number followed by s/m/h/d, e.g. "30m", "1h", "1d"`);
|
|
31
|
+
const ms = Number(match[1]) * EVERY_UNIT_MS[match[2]];
|
|
32
|
+
if (ms < MIN_EVERY_MS)
|
|
33
|
+
throw new ScheduleError(`"${every}" is too short — the shortest interval is 30s`);
|
|
34
|
+
return ms;
|
|
35
|
+
}
|
|
36
|
+
/** The first anchored slot strictly after `nowMs`. */
|
|
37
|
+
function nextAnchoredSlot(anchorMs, intervalMs, nowMs) {
|
|
38
|
+
if (nowMs < anchorMs)
|
|
39
|
+
return anchorMs;
|
|
40
|
+
return anchorMs + (Math.floor((nowMs - anchorMs) / intervalMs) + 1) * intervalMs;
|
|
41
|
+
}
|
|
42
|
+
/** Human-readable schedule, for lists and messages. */
|
|
43
|
+
export function describeSchedule(schedule) {
|
|
44
|
+
return 'run_at' in schedule ? `once at ${schedule.run_at}` : `every ${schedule.every}`;
|
|
45
|
+
}
|
|
46
|
+
const fileMutex = new KeyedMutex();
|
|
47
|
+
/** One JSON file per task under `<dir>/tasks/` — one server process. */
|
|
48
|
+
export class FileSchedulerStore {
|
|
49
|
+
dir;
|
|
50
|
+
constructor(dir) {
|
|
51
|
+
this.dir = path.join(dir, 'tasks');
|
|
52
|
+
}
|
|
53
|
+
file(taskId) {
|
|
54
|
+
if (!/^[A-Za-z0-9_-]+$/.test(taskId))
|
|
55
|
+
throw new ScheduleError(`invalid task id "${taskId}"`);
|
|
56
|
+
return path.join(this.dir, `${taskId}.json`);
|
|
57
|
+
}
|
|
58
|
+
async list() {
|
|
59
|
+
if (!existsSync(this.dir))
|
|
60
|
+
return [];
|
|
61
|
+
const tasks = [];
|
|
62
|
+
for (const name of readdirSync(this.dir)) {
|
|
63
|
+
if (!name.endsWith('.json'))
|
|
64
|
+
continue;
|
|
65
|
+
try {
|
|
66
|
+
tasks.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// mid-write or removed between readdir and read
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return tasks.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
73
|
+
}
|
|
74
|
+
async get(taskId) {
|
|
75
|
+
const file = this.file(taskId);
|
|
76
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
|
|
77
|
+
}
|
|
78
|
+
async put(task) {
|
|
79
|
+
mkdirSync(this.dir, { recursive: true });
|
|
80
|
+
const file = this.file(task.task_id);
|
|
81
|
+
writeFileSync(file + '.tmp', JSON.stringify(task, null, 2));
|
|
82
|
+
renameSync(file + '.tmp', file);
|
|
83
|
+
}
|
|
84
|
+
async remove(taskId) {
|
|
85
|
+
rmSync(this.file(taskId), { force: true });
|
|
86
|
+
}
|
|
87
|
+
async update(taskId, fn) {
|
|
88
|
+
return fileMutex.run(taskId, async () => {
|
|
89
|
+
const next = fn(await this.get(taskId));
|
|
90
|
+
if (next)
|
|
91
|
+
await this.put(next);
|
|
92
|
+
return next;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
let store;
|
|
97
|
+
/** The process-wide store: `.scheduler/` in the project, or
|
|
98
|
+
* LOOPENGINE_SCHEDULER_DIR. */
|
|
99
|
+
export function getSchedulerStore() {
|
|
100
|
+
store ??= new FileSchedulerStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
|
|
101
|
+
return store;
|
|
102
|
+
}
|
|
103
|
+
export function setSchedulerStore(next) {
|
|
104
|
+
store = next;
|
|
105
|
+
}
|
|
106
|
+
export async function createScheduledTask(input, nowMs = Date.now()) {
|
|
107
|
+
if (!input.agent)
|
|
108
|
+
throw new ScheduleError('agent is required');
|
|
109
|
+
if (!input.message?.trim())
|
|
110
|
+
throw new ScheduleError('message is required');
|
|
111
|
+
let schedule;
|
|
112
|
+
let nextRunMs;
|
|
113
|
+
if (typeof input.schedule?.run_at === 'string' && input.schedule.run_at) {
|
|
114
|
+
const runAtMs = Date.parse(input.schedule.run_at);
|
|
115
|
+
if (Number.isNaN(runAtMs))
|
|
116
|
+
throw new ScheduleError(`"${input.schedule.run_at}" is not a valid ISO 8601 timestamp`);
|
|
117
|
+
schedule = { run_at: new Date(runAtMs).toISOString() };
|
|
118
|
+
nextRunMs = runAtMs;
|
|
119
|
+
}
|
|
120
|
+
else if (typeof input.schedule?.every === 'string' && input.schedule.every) {
|
|
121
|
+
const intervalMs = parseEvery(input.schedule.every);
|
|
122
|
+
nextRunMs = nowMs + intervalMs;
|
|
123
|
+
schedule = { every: input.schedule.every.trim(), anchor: new Date(nextRunMs).toISOString() };
|
|
124
|
+
}
|
|
125
|
+
else {
|
|
126
|
+
throw new ScheduleError('schedule must be { "run_at": "<ISO timestamp>" } or { "every": "<interval, e.g. 1h>" }');
|
|
127
|
+
}
|
|
128
|
+
const task = {
|
|
129
|
+
task_id: randomUUID(),
|
|
130
|
+
task_name: input.task_name?.trim() || undefined,
|
|
131
|
+
agent: input.agent,
|
|
132
|
+
message: input.message,
|
|
133
|
+
schedule,
|
|
134
|
+
status: 'scheduled',
|
|
135
|
+
created_at: new Date(nowMs).toISOString(),
|
|
136
|
+
next_run_at: new Date(nextRunMs).toISOString(),
|
|
137
|
+
run_count: 0,
|
|
138
|
+
notify: input.notify || undefined,
|
|
139
|
+
history: [],
|
|
140
|
+
};
|
|
141
|
+
await getSchedulerStore().put(task);
|
|
142
|
+
return task;
|
|
143
|
+
}
|
|
144
|
+
export async function listScheduledTasks(filter = {}) {
|
|
145
|
+
return (await getSchedulerStore().list()).filter((t) => (!filter.agent || t.agent === filter.agent) && (!filter.status || t.status === filter.status));
|
|
146
|
+
}
|
|
147
|
+
export async function getScheduledTask(taskId) {
|
|
148
|
+
return getSchedulerStore().get(taskId);
|
|
149
|
+
}
|
|
150
|
+
function mustExist(task, taskId) {
|
|
151
|
+
if (!task)
|
|
152
|
+
throw new ScheduleError(`no scheduled task "${taskId}"`);
|
|
153
|
+
return task;
|
|
154
|
+
}
|
|
155
|
+
/** Stops future runs; history stays. A run already waiting on an
|
|
156
|
+
* approval isn't retracted. */
|
|
157
|
+
export async function cancelScheduledTask(taskId) {
|
|
158
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => !t ? t : t.status === 'awaiting_approval' ? { ...t, cancel_after_run: true } : { ...t, status: 'cancelled', next_run_at: undefined }), taskId);
|
|
159
|
+
}
|
|
160
|
+
export async function pauseScheduledTask(taskId) {
|
|
161
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'paused', next_run_at: undefined } : t)), taskId);
|
|
162
|
+
}
|
|
163
|
+
export async function resumeScheduledTask(taskId, nowMs = Date.now()) {
|
|
164
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
165
|
+
if (!t || t.status !== 'paused')
|
|
166
|
+
return t;
|
|
167
|
+
return { ...t, status: 'scheduled', next_run_at: new Date(nextRunAfter(t, nowMs) ?? nowMs).toISOString() };
|
|
168
|
+
}), taskId);
|
|
169
|
+
}
|
|
170
|
+
/** Fires the task on the next tick, without changing its schedule. A
|
|
171
|
+
* finished one-off task runs once more. */
|
|
172
|
+
export async function runScheduledTaskNow(taskId, nowMs = Date.now()) {
|
|
173
|
+
return mustExist(await getSchedulerStore().update(taskId, (t) => {
|
|
174
|
+
if (!t || t.status === 'running' || t.status === 'awaiting_approval' || t.status === 'cancelled')
|
|
175
|
+
return t;
|
|
176
|
+
return { ...t, status: 'scheduled', next_run_at: new Date(nowMs).toISOString(), run_now_from: t.status === 'paused' || t.status === 'done' ? t.status : undefined };
|
|
177
|
+
}), taskId);
|
|
178
|
+
}
|
|
179
|
+
export async function deleteScheduledTask(taskId) {
|
|
180
|
+
await getSchedulerStore().remove(taskId);
|
|
181
|
+
}
|
|
182
|
+
/** When the next task is due — for a host that stops idle servers and
|
|
183
|
+
* needs to wake this one in time (GET /internal/activity). */
|
|
184
|
+
export async function nextScheduledWakeAt() {
|
|
185
|
+
const due = (await getSchedulerStore().list()).filter((t) => t.status === 'scheduled' && t.next_run_at).map((t) => t.next_run_at);
|
|
186
|
+
return due.sort()[0];
|
|
187
|
+
}
|
|
188
|
+
function nextRunAfter(task, nowMs) {
|
|
189
|
+
if ('run_at' in task.schedule)
|
|
190
|
+
return undefined;
|
|
191
|
+
const intervalMs = parseEvery(task.schedule.every);
|
|
192
|
+
const anchorMs = task.schedule.anchor ? Date.parse(task.schedule.anchor) : Date.parse(task.created_at) + intervalMs;
|
|
193
|
+
return nextAnchoredSlot(anchorMs, intervalMs, nowMs);
|
|
194
|
+
}
|
|
195
|
+
/** Moves the old lp-task-scheduler's task files (`<dir>/.tasks/*.json`)
|
|
196
|
+
* into this store once, renaming each to `.json.migrated`. Returns how
|
|
197
|
+
* many it imported. */
|
|
198
|
+
export async function importLegacyScheduledTasks(dir) {
|
|
199
|
+
const legacyDir = path.join(dir, '.tasks');
|
|
200
|
+
if (!existsSync(legacyDir))
|
|
201
|
+
return 0;
|
|
202
|
+
let imported = 0;
|
|
203
|
+
for (const name of readdirSync(legacyDir)) {
|
|
204
|
+
if (!name.endsWith('.json'))
|
|
205
|
+
continue;
|
|
206
|
+
const file = path.join(legacyDir, name);
|
|
207
|
+
try {
|
|
208
|
+
const old = JSON.parse(readFileSync(file, 'utf8'));
|
|
209
|
+
if (!(await getSchedulerStore().get(old.task_id))) {
|
|
210
|
+
const task = { ...old, history: old.history ?? [], run_count: old.run_count ?? 0 };
|
|
211
|
+
if ('every' in task.schedule && !task.schedule.anchor && task.next_run_at)
|
|
212
|
+
task.schedule = { ...task.schedule, anchor: task.next_run_at };
|
|
213
|
+
await getSchedulerStore().put(task);
|
|
214
|
+
imported++;
|
|
215
|
+
}
|
|
216
|
+
renameSync(file, file + '.migrated');
|
|
217
|
+
}
|
|
218
|
+
catch (err) {
|
|
219
|
+
console.error(`[loopengine] could not import legacy scheduled task ${file}:`, err);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return imported;
|
|
223
|
+
}
|
|
224
|
+
/** lp-task-scheduler 1.x kept tasks under
|
|
225
|
+
* generated/scheduled-tasks/<agent>/.tasks/ (or SCHEDULER_STORE_DIR) and
|
|
226
|
+
* ran its own timer; scheduling is built in now, so the server imports
|
|
227
|
+
* those once at start. */
|
|
228
|
+
async function importLegacyAbilityTasks() {
|
|
229
|
+
const dirs = new Set();
|
|
230
|
+
const root = path.join(process.cwd(), 'generated', 'scheduled-tasks');
|
|
231
|
+
if (existsSync(root)) {
|
|
232
|
+
dirs.add(root);
|
|
233
|
+
for (const name of readdirSync(root))
|
|
234
|
+
if (name !== '.tasks')
|
|
235
|
+
dirs.add(path.join(root, name));
|
|
236
|
+
}
|
|
237
|
+
if (process.env.SCHEDULER_STORE_DIR)
|
|
238
|
+
dirs.add(path.resolve(process.env.SCHEDULER_STORE_DIR));
|
|
239
|
+
for (const dir of dirs) {
|
|
240
|
+
const n = await importLegacyScheduledTasks(dir);
|
|
241
|
+
if (n)
|
|
242
|
+
console.log(`[loopengine] imported ${n} scheduled task(s) from ${dir}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
export class Scheduler {
|
|
246
|
+
fire;
|
|
247
|
+
tickMs;
|
|
248
|
+
onSettled;
|
|
249
|
+
inFlight = new Set();
|
|
250
|
+
timer;
|
|
251
|
+
constructor(options) {
|
|
252
|
+
this.fire = options.fire;
|
|
253
|
+
this.tickMs = options.tickMs ?? (Number(process.env.LOOPENGINE_SCHEDULER_TICK_MS) || 10_000);
|
|
254
|
+
this.onSettled = options.onSettled;
|
|
255
|
+
}
|
|
256
|
+
/** Imports tasks left by the old lp-task-scheduler ability, recovers
|
|
257
|
+
* runs a restart cut off, then ticks every `tickMs`. */
|
|
258
|
+
async start() {
|
|
259
|
+
await importLegacyAbilityTasks();
|
|
260
|
+
for (const task of await getSchedulerStore().list()) {
|
|
261
|
+
if (task.status === 'running')
|
|
262
|
+
await this.settle(task.task_id, new Date().toISOString(), { status: 'error', error: 'The server restarted while this run was in progress.' });
|
|
263
|
+
}
|
|
264
|
+
await this.tick();
|
|
265
|
+
this.timer = setInterval(() => void this.tick().catch((err) => console.error('[loopengine] scheduler tick failed:', err)), this.tickMs);
|
|
266
|
+
this.timer.unref?.();
|
|
267
|
+
}
|
|
268
|
+
stop() {
|
|
269
|
+
if (this.timer)
|
|
270
|
+
clearInterval(this.timer);
|
|
271
|
+
}
|
|
272
|
+
/** How many runs are executing right now (for idle-shutdown checks). */
|
|
273
|
+
get running() {
|
|
274
|
+
return this.inFlight.size;
|
|
275
|
+
}
|
|
276
|
+
/** Fires every task that's due. Safe to call any time (a host's wake-up
|
|
277
|
+
* request calls it directly). */
|
|
278
|
+
async tick(nowMs = Date.now()) {
|
|
279
|
+
let fired = 0;
|
|
280
|
+
for (const task of await getSchedulerStore().list()) {
|
|
281
|
+
if (task.status !== 'scheduled' || !task.next_run_at || Date.parse(task.next_run_at) > nowMs || this.inFlight.has(task.task_id))
|
|
282
|
+
continue;
|
|
283
|
+
fired++;
|
|
284
|
+
this.inFlight.add(task.task_id);
|
|
285
|
+
void this.run(task.task_id).finally(() => this.inFlight.delete(task.task_id));
|
|
286
|
+
}
|
|
287
|
+
return fired;
|
|
288
|
+
}
|
|
289
|
+
async run(taskId) {
|
|
290
|
+
const ranAt = new Date().toISOString();
|
|
291
|
+
const claimed = await getSchedulerStore().update(taskId, (t) => (t && t.status === 'scheduled' ? { ...t, status: 'running' } : t));
|
|
292
|
+
if (!claimed || claimed.status !== 'running')
|
|
293
|
+
return;
|
|
294
|
+
const sessionId = `sched-${taskId.slice(0, 8)}-${claimed.run_count + 1}-${Date.now().toString(36)}`;
|
|
295
|
+
try {
|
|
296
|
+
const outcome = await this.fire(claimed, sessionId);
|
|
297
|
+
if (outcome.kind === 'pending') {
|
|
298
|
+
await getSchedulerStore().update(taskId, (t) => t && {
|
|
299
|
+
...t,
|
|
300
|
+
status: 'awaiting_approval',
|
|
301
|
+
next_run_at: undefined,
|
|
302
|
+
pending: { ran_at: ranAt, session_id: sessionId, pending_ids: outcome.pendingIds },
|
|
303
|
+
// Cancelled while this run was starting: finish this run, then stop.
|
|
304
|
+
cancel_after_run: t.cancel_after_run || t.status === 'cancelled' || undefined,
|
|
305
|
+
});
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
await this.settle(taskId, ranAt, { status: 'ok', result: outcome.text, session_id: sessionId });
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
await this.settle(taskId, ranAt, { status: 'error', error: err instanceof Error ? err.message : String(err), session_id: sessionId });
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
/** A paused run resumed after a decision (adapters/http.ts calls this
|
|
315
|
+
* from the resolve path): settle it, or record the next decision it's
|
|
316
|
+
* now waiting on. */
|
|
317
|
+
async settleFromResume(sessionId, outcome) {
|
|
318
|
+
if (!sessionId.startsWith('sched-'))
|
|
319
|
+
return;
|
|
320
|
+
const task = (await getSchedulerStore().list()).find((t) => t.pending?.session_id === sessionId);
|
|
321
|
+
if (!task)
|
|
322
|
+
return;
|
|
323
|
+
if (outcome.pendingIds?.length) {
|
|
324
|
+
await getSchedulerStore().update(task.task_id, (t) => t && t.pending && { ...t, pending: { ...t.pending, pending_ids: outcome.pendingIds } });
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await this.settle(task.task_id, task.pending.ran_at, { status: 'ok', result: outcome.text, session_id: sessionId });
|
|
328
|
+
}
|
|
329
|
+
async settle(taskId, ranAt, run) {
|
|
330
|
+
const nowMs = Date.now();
|
|
331
|
+
const entry = { ran_at: ranAt, ...run };
|
|
332
|
+
const settled = await getSchedulerStore().update(taskId, (t) => {
|
|
333
|
+
if (!t)
|
|
334
|
+
return t;
|
|
335
|
+
const history = [...t.history, entry].slice(-HISTORY_LIMIT);
|
|
336
|
+
const base = {
|
|
337
|
+
...t,
|
|
338
|
+
run_count: t.run_count + 1,
|
|
339
|
+
last_run_at: ranAt,
|
|
340
|
+
last_status: run.status,
|
|
341
|
+
last_result: run.result,
|
|
342
|
+
last_error: run.error,
|
|
343
|
+
pending: undefined,
|
|
344
|
+
history,
|
|
345
|
+
run_now_from: undefined,
|
|
346
|
+
cancel_after_run: undefined,
|
|
347
|
+
};
|
|
348
|
+
if (t.cancel_after_run || t.status === 'cancelled')
|
|
349
|
+
return { ...base, status: 'cancelled', next_run_at: undefined };
|
|
350
|
+
// A "run now" of a paused or finished task goes back to how it was.
|
|
351
|
+
if (t.run_now_from)
|
|
352
|
+
return { ...base, status: t.run_now_from, next_run_at: undefined };
|
|
353
|
+
const next = nextRunAfter(t, nowMs);
|
|
354
|
+
return next === undefined ? { ...base, status: 'done', next_run_at: undefined } : { ...base, status: 'scheduled', next_run_at: new Date(next).toISOString() };
|
|
355
|
+
});
|
|
356
|
+
if (settled)
|
|
357
|
+
this.onSettled?.(settled, entry);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export declare const SLACK_ANSWER_BLOCK_ID = "answer_block";
|
|
2
|
+
export declare const SLACK_ANSWER_ACTION_ID = "answer_input";
|
|
3
|
+
/** https://api.slack.com/authentication/verifying-requests-from-slack —
|
|
4
|
+
* rejects anything older than five minutes, so a captured request can't
|
|
5
|
+
* be replayed later. */
|
|
6
|
+
export declare function verifySlackRequest(rawBody: string, timestamp: string | undefined, signature: string | undefined, signingSecret: string, nowMs?: number): boolean;
|
|
7
|
+
export type SlackInteraction = {
|
|
8
|
+
type: 'decide';
|
|
9
|
+
pendingId: string;
|
|
10
|
+
decision: 'approve' | 'deny';
|
|
11
|
+
responseUrl?: string;
|
|
12
|
+
user?: string;
|
|
13
|
+
} | {
|
|
14
|
+
type: 'answer';
|
|
15
|
+
pendingId: string;
|
|
16
|
+
answer: string;
|
|
17
|
+
responseUrl?: string;
|
|
18
|
+
user?: string;
|
|
19
|
+
} | {
|
|
20
|
+
type: 'open_answer';
|
|
21
|
+
pendingId: string;
|
|
22
|
+
triggerId: string;
|
|
23
|
+
} | {
|
|
24
|
+
type: 'ignore';
|
|
25
|
+
};
|
|
26
|
+
/** Reads Slack's form-encoded `payload=<json>` body into the one thing
|
|
27
|
+
* it asks for. Anything unrecognized is `ignore`. */
|
|
28
|
+
export declare function parseSlackInteraction(rawBody: string): SlackInteraction;
|
|
29
|
+
/** Opens the free-text answer modal for "Other…". Slack's trigger_id
|
|
30
|
+
* expires three seconds after the click, so call this before anything slow. */
|
|
31
|
+
export declare function openSlackAnswerModal(botToken: string, triggerId: string, pendingId: string): Promise<void>;
|
|
32
|
+
/** Replaces the original message (its buttons included) with what
|
|
33
|
+
* happened, so nobody clicks a decision that's already made. */
|
|
34
|
+
export declare function replyInSlack(responseUrl: string, text: string): Promise<void>;
|
|
35
|
+
/** What to say in Slack after a decision, from the resolve route's own
|
|
36
|
+
* response body. */
|
|
37
|
+
export declare function slackOutcomeText(kind: 'approve' | 'deny' | 'answer', status: number, body: {
|
|
38
|
+
alreadyResolved?: boolean;
|
|
39
|
+
resolved?: boolean;
|
|
40
|
+
text?: string;
|
|
41
|
+
error?: string;
|
|
42
|
+
pending?: unknown[];
|
|
43
|
+
}, user?: string): string;
|
|
44
|
+
export interface SlackChannel {
|
|
45
|
+
id: string;
|
|
46
|
+
name: string;
|
|
47
|
+
isPrivate: boolean;
|
|
48
|
+
isMember: boolean;
|
|
49
|
+
}
|
|
50
|
+
/** Channels the bot can see — needs the channels:read (and, for private
|
|
51
|
+
* channels, groups:read) scope. */
|
|
52
|
+
export declare function listSlackChannels(botToken: string): Promise<SlackChannel[]>;
|
|
53
|
+
/** A Slack app manifest for one agent's notifier: a bot that can post and
|
|
54
|
+
* read the channel list, with interactivity pointed at this server.
|
|
55
|
+
* Paste it into api.slack.com/apps → Create New App → From a manifest. */
|
|
56
|
+
export declare function slackAppManifest(options: {
|
|
57
|
+
agentName: string;
|
|
58
|
+
interactivityUrl: string;
|
|
59
|
+
}): Record<string, unknown>;
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
// What the server needs to handle Slack button clicks itself (POST
|
|
2
|
+
// /hooks/slack/<agent> in adapters/http.ts), so a Slack notifier's
|
|
3
|
+
// Approve/Deny and answer buttons work with no handler of your own to
|
|
4
|
+
// deploy: request verification, reading the interaction payload, the
|
|
5
|
+
// "Other…" answer modal, replying in the original message, listing
|
|
6
|
+
// channels for the console's channel picker, and the app manifest the
|
|
7
|
+
// console shows for creating the Slack app.
|
|
8
|
+
//
|
|
9
|
+
// Same button contract SlackNotifier (core/http-notify-triggers/slack.ts)
|
|
10
|
+
// sends: action_id approve|deny with the pendingId as value,
|
|
11
|
+
// answer_option with {pendingId, answer}, open_answer_modal with the
|
|
12
|
+
// pendingId, and the modal's answer under ANSWER_BLOCK_ID/ANSWER_ACTION_ID.
|
|
13
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
14
|
+
export const SLACK_ANSWER_BLOCK_ID = 'answer_block';
|
|
15
|
+
export const SLACK_ANSWER_ACTION_ID = 'answer_input';
|
|
16
|
+
/** https://api.slack.com/authentication/verifying-requests-from-slack —
|
|
17
|
+
* rejects anything older than five minutes, so a captured request can't
|
|
18
|
+
* be replayed later. */
|
|
19
|
+
export function verifySlackRequest(rawBody, timestamp, signature, signingSecret, nowMs = Date.now()) {
|
|
20
|
+
if (!timestamp || !signature)
|
|
21
|
+
return false;
|
|
22
|
+
const age = Math.abs(nowMs / 1000 - Number(timestamp));
|
|
23
|
+
if (!Number.isFinite(age) || age > 60 * 5)
|
|
24
|
+
return false;
|
|
25
|
+
const expected = Buffer.from(`v0=${createHmac('sha256', signingSecret).update(`v0:${timestamp}:${rawBody}`).digest('hex')}`);
|
|
26
|
+
const actual = Buffer.from(signature);
|
|
27
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
28
|
+
}
|
|
29
|
+
/** Reads Slack's form-encoded `payload=<json>` body into the one thing
|
|
30
|
+
* it asks for. Anything unrecognized is `ignore`. */
|
|
31
|
+
export function parseSlackInteraction(rawBody) {
|
|
32
|
+
const raw = new URLSearchParams(rawBody).get('payload');
|
|
33
|
+
if (!raw)
|
|
34
|
+
return { type: 'ignore' };
|
|
35
|
+
let payload;
|
|
36
|
+
try {
|
|
37
|
+
payload = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return { type: 'ignore' };
|
|
41
|
+
}
|
|
42
|
+
const user = payload.user?.id;
|
|
43
|
+
if (payload.type === 'view_submission' && payload.view?.private_metadata) {
|
|
44
|
+
const answer = payload.view.state?.values?.[SLACK_ANSWER_BLOCK_ID]?.[SLACK_ANSWER_ACTION_ID]?.value ?? '';
|
|
45
|
+
return { type: 'answer', pendingId: payload.view.private_metadata, answer, user };
|
|
46
|
+
}
|
|
47
|
+
if (payload.type !== 'block_actions')
|
|
48
|
+
return { type: 'ignore' };
|
|
49
|
+
const action = payload.actions?.[0];
|
|
50
|
+
if (!action?.value)
|
|
51
|
+
return { type: 'ignore' };
|
|
52
|
+
switch (action.action_id) {
|
|
53
|
+
case 'approve':
|
|
54
|
+
case 'deny':
|
|
55
|
+
return { type: 'decide', pendingId: action.value, decision: action.action_id, responseUrl: payload.response_url, user };
|
|
56
|
+
case 'answer_option': {
|
|
57
|
+
try {
|
|
58
|
+
const { pendingId, answer } = JSON.parse(action.value);
|
|
59
|
+
return pendingId && typeof answer === 'string' ? { type: 'answer', pendingId, answer, responseUrl: payload.response_url, user } : { type: 'ignore' };
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return { type: 'ignore' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
case 'open_answer_modal':
|
|
66
|
+
return payload.trigger_id ? { type: 'open_answer', pendingId: action.value, triggerId: payload.trigger_id } : { type: 'ignore' };
|
|
67
|
+
default:
|
|
68
|
+
return { type: 'ignore' };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
/** Opens the free-text answer modal for "Other…". Slack's trigger_id
|
|
72
|
+
* expires three seconds after the click, so call this before anything slow. */
|
|
73
|
+
export async function openSlackAnswerModal(botToken, triggerId, pendingId) {
|
|
74
|
+
const res = await fetch('https://slack.com/api/views.open', {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { Authorization: `Bearer ${botToken}`, 'content-type': 'application/json; charset=utf-8' },
|
|
77
|
+
body: JSON.stringify({
|
|
78
|
+
trigger_id: triggerId,
|
|
79
|
+
view: {
|
|
80
|
+
type: 'modal',
|
|
81
|
+
callback_id: 'answer_modal',
|
|
82
|
+
private_metadata: pendingId,
|
|
83
|
+
title: { type: 'plain_text', text: 'Your answer' },
|
|
84
|
+
submit: { type: 'plain_text', text: 'Send' },
|
|
85
|
+
blocks: [
|
|
86
|
+
{
|
|
87
|
+
type: 'input',
|
|
88
|
+
block_id: SLACK_ANSWER_BLOCK_ID,
|
|
89
|
+
label: { type: 'plain_text', text: 'Answer' },
|
|
90
|
+
element: { type: 'plain_text_input', action_id: SLACK_ANSWER_ACTION_ID, multiline: true },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
},
|
|
94
|
+
}),
|
|
95
|
+
});
|
|
96
|
+
const body = (await res.json());
|
|
97
|
+
if (!body.ok)
|
|
98
|
+
throw new Error(`views.open failed: ${body.error}`);
|
|
99
|
+
}
|
|
100
|
+
/** Replaces the original message (its buttons included) with what
|
|
101
|
+
* happened, so nobody clicks a decision that's already made. */
|
|
102
|
+
export async function replyInSlack(responseUrl, text) {
|
|
103
|
+
await fetch(responseUrl, {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
headers: { 'content-type': 'application/json' },
|
|
106
|
+
body: JSON.stringify({ replace_original: true, text }),
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
/** What to say in Slack after a decision, from the resolve route's own
|
|
110
|
+
* response body. */
|
|
111
|
+
export function slackOutcomeText(kind, status, body, user) {
|
|
112
|
+
const who = user ? ` by <@${user}>` : '';
|
|
113
|
+
if (status >= 400)
|
|
114
|
+
return `:warning: Couldn't record that: ${body.error ?? `HTTP ${status}`}`;
|
|
115
|
+
if (body.alreadyResolved)
|
|
116
|
+
return 'Already decided elsewhere.';
|
|
117
|
+
const what = kind === 'approve' ? ':white_check_mark: Approved' : kind === 'deny' ? ':no_entry: Denied' : ':speech_balloon: Answered';
|
|
118
|
+
if (body.resolved)
|
|
119
|
+
return `${what}${who}. Still waiting on another decision in the same turn.`;
|
|
120
|
+
if (body.pending?.length)
|
|
121
|
+
return `${what}${who}. The agent needs another decision; a new message is on its way.`;
|
|
122
|
+
return `${what}${who}.${body.text ? `\n>${body.text.replace(/\n/g, '\n>')}` : ''}`;
|
|
123
|
+
}
|
|
124
|
+
/** Channels the bot can see — needs the channels:read (and, for private
|
|
125
|
+
* channels, groups:read) scope. */
|
|
126
|
+
export async function listSlackChannels(botToken) {
|
|
127
|
+
const channels = [];
|
|
128
|
+
let cursor = '';
|
|
129
|
+
for (let page = 0; page < 10; page++) {
|
|
130
|
+
const params = new URLSearchParams({ types: 'public_channel,private_channel', exclude_archived: 'true', limit: '200' });
|
|
131
|
+
if (cursor)
|
|
132
|
+
params.set('cursor', cursor);
|
|
133
|
+
const res = await fetch(`https://slack.com/api/conversations.list?${params}`, { headers: { Authorization: `Bearer ${botToken}` } });
|
|
134
|
+
const body = (await res.json());
|
|
135
|
+
if (!body.ok) {
|
|
136
|
+
const reasons = {
|
|
137
|
+
missing_scope: 'The bot token is missing the channels:read scope; reinstall the app from the manifest',
|
|
138
|
+
invalid_auth: 'Slack rejected the bot token; copy the Bot User OAuth Token (xoxb-...) again',
|
|
139
|
+
not_authed: 'Enter the bot token first',
|
|
140
|
+
token_revoked: 'This bot token was revoked; reinstall the app and copy the new one',
|
|
141
|
+
};
|
|
142
|
+
throw new Error(reasons[body.error ?? ''] ?? `Slack said: ${body.error}`);
|
|
143
|
+
}
|
|
144
|
+
for (const c of body.channels ?? [])
|
|
145
|
+
channels.push({ id: c.id, name: c.name, isPrivate: Boolean(c.is_private), isMember: Boolean(c.is_member) });
|
|
146
|
+
cursor = body.response_metadata?.next_cursor ?? '';
|
|
147
|
+
if (!cursor)
|
|
148
|
+
break;
|
|
149
|
+
}
|
|
150
|
+
return channels.sort((a, b) => Number(b.isMember) - Number(a.isMember) || a.name.localeCompare(b.name));
|
|
151
|
+
}
|
|
152
|
+
/** A Slack app manifest for one agent's notifier: a bot that can post and
|
|
153
|
+
* read the channel list, with interactivity pointed at this server.
|
|
154
|
+
* Paste it into api.slack.com/apps → Create New App → From a manifest. */
|
|
155
|
+
export function slackAppManifest(options) {
|
|
156
|
+
const name = `LoopEngine ${options.agentName}`.slice(0, 35);
|
|
157
|
+
return {
|
|
158
|
+
display_information: { name, description: `Approval requests and questions from the ${options.agentName} agent` },
|
|
159
|
+
features: { bot_user: { display_name: name, always_online: true } },
|
|
160
|
+
oauth_config: { scopes: { bot: ['chat:write', 'channels:read', 'groups:read'] } },
|
|
161
|
+
settings: {
|
|
162
|
+
interactivity: { is_enabled: true, request_url: options.interactivityUrl },
|
|
163
|
+
org_deploy_enabled: false,
|
|
164
|
+
socket_mode_enabled: false,
|
|
165
|
+
token_rotation_enabled: false,
|
|
166
|
+
},
|
|
167
|
+
};
|
|
168
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../agent-config.js';
|
|
2
2
|
export declare const systemTools: ToolDefinition[];
|
|
3
|
+
export { createScheduleTools } from './schedule.js';
|
|
3
4
|
export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, type PendingQuestion, } from './ask_user.js';
|
|
@@ -9,4 +9,5 @@
|
|
|
9
9
|
// re-exported here instead of an instance.
|
|
10
10
|
import { readFile } from './read_file.js';
|
|
11
11
|
export const systemTools = [readFile];
|
|
12
|
+
export { createScheduleTools } from './schedule.js';
|
|
12
13
|
export { createAskUserTool, listQuestions, answerQuestion, findQuestion, CliQuestionHandler, WebchatQuestionHandler, isDurableQuestionHandler, } from './ask_user.js';
|