create-loopengine 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/template/adapters/http.ts +369 -28
- package/template/package.json +1 -1
package/package.json
CHANGED
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
pauseScheduledTask,
|
|
140
140
|
resumeScheduledTask,
|
|
141
141
|
runScheduledTaskNow,
|
|
142
|
+
setScheduledTaskNotify,
|
|
142
143
|
type ScheduledTask,
|
|
143
144
|
type ScheduledTaskRun,
|
|
144
145
|
listAgentPending,
|
|
@@ -201,9 +202,11 @@ import {
|
|
|
201
202
|
forgetWorkflow,
|
|
202
203
|
getPlanJob,
|
|
203
204
|
listAgentWorkflows,
|
|
205
|
+
migrateAgentWorkflows,
|
|
204
206
|
planWorkflow,
|
|
205
|
-
|
|
207
|
+
prepareWorkflowRun,
|
|
206
208
|
startPlanJob,
|
|
209
|
+
updateWorkflow,
|
|
207
210
|
type WorkflowAgentInfo,
|
|
208
211
|
type WorkflowMcpDeps,
|
|
209
212
|
workflowPageHtml,
|
|
@@ -273,6 +276,38 @@ import {
|
|
|
273
276
|
createScheduleTools,
|
|
274
277
|
type PendingQuestion,
|
|
275
278
|
WebhookNotifier,
|
|
279
|
+
// Workflows (skills with a start message), their runs, run callbacks
|
|
280
|
+
// and webhook triggers.
|
|
281
|
+
WorkflowParamsError,
|
|
282
|
+
readWorkflow,
|
|
283
|
+
readWorkflows,
|
|
284
|
+
validateParams,
|
|
285
|
+
workflowRunMessage,
|
|
286
|
+
WorkflowRunner,
|
|
287
|
+
getRun,
|
|
288
|
+
getRunStore,
|
|
289
|
+
listRuns,
|
|
290
|
+
type WorkflowRun,
|
|
291
|
+
CallbackUrlError,
|
|
292
|
+
callbackSecret,
|
|
293
|
+
checkCallbackUrl,
|
|
294
|
+
deliverCallback,
|
|
295
|
+
runCallbackPayload,
|
|
296
|
+
type RunCallbackEvent,
|
|
297
|
+
TriggerError,
|
|
298
|
+
createTrigger,
|
|
299
|
+
deliveryId,
|
|
300
|
+
findTriggerByToken,
|
|
301
|
+
getTriggerStore,
|
|
302
|
+
listTriggers,
|
|
303
|
+
paramsFromPayload,
|
|
304
|
+
parsePayload,
|
|
305
|
+
passesFilter,
|
|
306
|
+
publicTrigger,
|
|
307
|
+
recordTriggerDelivery as recordDelivery,
|
|
308
|
+
rotateTrigger,
|
|
309
|
+
updateTrigger,
|
|
310
|
+
verifySenderSignature,
|
|
276
311
|
} from 'loopengine'
|
|
277
312
|
import { SkillGarden } from 'loopengine'
|
|
278
313
|
import type { Decision, PendingApproval } from 'actauth'
|
|
@@ -369,6 +404,27 @@ function readRawBody(req: IncomingMessage): Promise<string> {
|
|
|
369
404
|
})
|
|
370
405
|
}
|
|
371
406
|
|
|
407
|
+
/** The raw request body, refused past `maxBytes` (null then). */
|
|
408
|
+
function readLimitedRawBuffer(req: IncomingMessage, maxBytes: number): Promise<Buffer | null> {
|
|
409
|
+
return new Promise((resolve, reject) => {
|
|
410
|
+
const chunks: Buffer[] = []
|
|
411
|
+
let size = 0
|
|
412
|
+
let over = false
|
|
413
|
+
req.on('data', (chunk: Buffer) => {
|
|
414
|
+
if (over) return
|
|
415
|
+
size += chunk.length
|
|
416
|
+
if (size > maxBytes) {
|
|
417
|
+
over = true
|
|
418
|
+
resolve(null)
|
|
419
|
+
return
|
|
420
|
+
}
|
|
421
|
+
chunks.push(chunk)
|
|
422
|
+
})
|
|
423
|
+
req.on('end', () => { if (!over) resolve(Buffer.concat(chunks)) })
|
|
424
|
+
req.on('error', reject)
|
|
425
|
+
})
|
|
426
|
+
}
|
|
427
|
+
|
|
372
428
|
/** readJsonBody with a size cap, for uploads: a body past `maxBytes` is
|
|
373
429
|
* refused before it's all read. */
|
|
374
430
|
function readLimitedJsonBody(req: IncomingMessage, maxBytes: number): Promise<Record<string, unknown>> {
|
|
@@ -563,7 +619,7 @@ async function describeAgent(entry: RegistryEntry): Promise<Record<string, unkno
|
|
|
563
619
|
// createAskUserTool() with no onPending, purely to describe its
|
|
564
620
|
// schema here — never executed, so the console-prompt fallback its
|
|
565
621
|
// own doc comment mentions never applies to this call.
|
|
566
|
-
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name))].map(describeTool),
|
|
622
|
+
systemTools: [...systemTools, createAskUserTool({ agent: config.name }), ...(config.selfScheduling === false ? [] : createScheduleTools(config.name, { agentDir: agentDirFor(config) }))].map(describeTool),
|
|
567
623
|
localTools: localTools.map((t) => ({ ...describeTool(t), httpToolEditable: editableHttpToolNames.has(t.name) })),
|
|
568
624
|
agentAsTools: agentAsTools.map(describeTool),
|
|
569
625
|
gatewayTools: gatewayTools.map(describeTool),
|
|
@@ -1437,7 +1493,28 @@ async function handleWorkflows(req: IncomingMessage, res: ServerResponse, pathna
|
|
|
1437
1493
|
}
|
|
1438
1494
|
}
|
|
1439
1495
|
|
|
1440
|
-
//
|
|
1496
|
+
// How long POST .../workflows/:id/run with "wait": true waits for the run
|
|
1497
|
+
// before answering 202 with its runId (look it up later with GET
|
|
1498
|
+
// /agents/:name/runs/:runId); under the 90 s an ingress allows.
|
|
1499
|
+
const WORKFLOW_WAIT_MS = Number(process.env.LOOPENGINE_WORKFLOW_WAIT_MS) || 85_000
|
|
1500
|
+
|
|
1501
|
+
// An agent's workflows (its Overview, and apps over the API): list, run
|
|
1502
|
+
// now with parameters, edit how runs start (PUT), forget.
|
|
1503
|
+
//
|
|
1504
|
+
// POST /agents/:name/workflows/:id/run
|
|
1505
|
+
// { "params": {…}, "wait": true, "notify": true, "callback_url": "https://…" }
|
|
1506
|
+
//
|
|
1507
|
+
// "notify" sends the result through the agent's messaging connection when
|
|
1508
|
+
// the run ends; "callback_url" gets the run's record posted to it when it
|
|
1509
|
+
// ends or stops for a decision, signed (core/run-callbacks.ts). Both are
|
|
1510
|
+
// optional, and work with or without "wait".
|
|
1511
|
+
//
|
|
1512
|
+
// starts a one-off run. Without "wait" it answers 202 { runId } straight
|
|
1513
|
+
// away. With it, 200 { runId, text } once the run finishes, 202 { runId,
|
|
1514
|
+
// pending: true } if it stops for an approval or a question, or 202
|
|
1515
|
+
// { runId, running: true } if it's still going after WORKFLOW_WAIT_MS.
|
|
1516
|
+
// A failed run answers 502 { runId, error }. Wrong or missing params are
|
|
1517
|
+
// a 400 before anything runs.
|
|
1441
1518
|
async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, agentName: string, id: string | undefined, action: string | undefined): Promise<void> {
|
|
1442
1519
|
const json = (status: number, body: unknown): void => {
|
|
1443
1520
|
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
@@ -1446,15 +1523,46 @@ async function handleAgentWorkflows(req: IncomingMessage, res: ServerResponse, a
|
|
|
1446
1523
|
const dir = agentDir(agentName)
|
|
1447
1524
|
try {
|
|
1448
1525
|
if (!id && req.method === 'GET') return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1449
|
-
if (id && action === '
|
|
1450
|
-
if (id &&
|
|
1451
|
-
|
|
1526
|
+
if (id && action === 'runs' && req.method === 'GET') return json(200, { runs: await listRuns(agentName, { workflowId: id, limit: 50 }) })
|
|
1527
|
+
if (id && action === 'run' && req.method === 'POST') {
|
|
1528
|
+
const body = await readJsonBody(req)
|
|
1529
|
+
if (body.notify !== undefined && typeof body.notify !== 'boolean') return json(400, { error: 'notify must be true or false' })
|
|
1530
|
+
let callbackUrl: string | undefined
|
|
1531
|
+
if (body.callback_url !== undefined && body.callback_url !== null && body.callback_url !== '') {
|
|
1532
|
+
try {
|
|
1533
|
+
callbackUrl = checkCallbackUrl(body.callback_url)
|
|
1534
|
+
} catch (err) {
|
|
1535
|
+
return json(400, { error: err instanceof CallbackUrlError ? err.message : String(err) })
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const prepared = await prepareWorkflowRun(agentName, dir, id, body.params)
|
|
1539
|
+
const run = await workflowRunner.start({
|
|
1540
|
+
agent: agentName, workflow_id: id, params: prepared.params, source: body.source === 'console' ? 'console' : 'api', message: prepared.message,
|
|
1541
|
+
...(body.notify === true ? { notify: true } : {}), ...(callbackUrl ? { callback_url: callbackUrl } : {}),
|
|
1542
|
+
})
|
|
1543
|
+
if (body.wait !== true) return json(202, { runId: run.run_id })
|
|
1544
|
+
const deadline = Date.now() + WORKFLOW_WAIT_MS
|
|
1545
|
+
for (;;) {
|
|
1546
|
+
const current = await getRun(agentName, run.run_id)
|
|
1547
|
+
if (current?.status === 'awaiting_approval') return json(202, { runId: run.run_id, pending: true })
|
|
1548
|
+
if (current?.status === 'done') return json(200, { runId: run.run_id, text: current.result ?? '' })
|
|
1549
|
+
if (current?.status === 'error') return json(502, { runId: run.run_id, error: current.error ?? 'The run failed.' })
|
|
1550
|
+
if (Date.now() >= deadline) return json(202, { runId: run.run_id, running: true })
|
|
1551
|
+
await new Promise((r) => setTimeout(r, 300))
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
if (id && !action && req.method === 'PUT') {
|
|
1555
|
+
updateWorkflow(dir, id, await readJsonBody(req))
|
|
1452
1556
|
return json(200, { workflows: await listAgentWorkflows(agentName, dir) })
|
|
1453
1557
|
}
|
|
1558
|
+
if (id && !action && req.method === 'DELETE') {
|
|
1559
|
+
const { cancelled } = await forgetWorkflow(agentName, dir, id)
|
|
1560
|
+
return json(200, { cancelledSchedules: cancelled, workflows: await listAgentWorkflows(agentName, dir) })
|
|
1561
|
+
}
|
|
1454
1562
|
return json(404, { error: 'not found' })
|
|
1455
1563
|
} catch (err) {
|
|
1456
1564
|
const message = err instanceof Error ? err.message : String(err)
|
|
1457
|
-
return json(err instanceof WorkflowError ? 400 : 500, { error: message })
|
|
1565
|
+
return json(err instanceof WorkflowError || err instanceof WorkflowParamsError ? 400 : 500, { error: message })
|
|
1458
1566
|
}
|
|
1459
1567
|
}
|
|
1460
1568
|
|
|
@@ -1743,34 +1851,71 @@ function handleEnvGet(res: ServerResponse, agentName: string): void {
|
|
|
1743
1851
|
// durable approval, and with no notifier at all it's denied rather than
|
|
1744
1852
|
// left waiting on nobody — a scheduled run has no one live in it. ----
|
|
1745
1853
|
|
|
1746
|
-
const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended
|
|
1854
|
+
const NO_HUMAN_ANSWER = 'No human is available to answer — this is an unattended run. Use your own best judgement and proceed without waiting for a response.'
|
|
1747
1855
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1856
|
+
// One unattended agent turn: a scheduled task firing, or a workflow run
|
|
1857
|
+
// started by hand or by an app (core/workflow-runs.ts).
|
|
1858
|
+
async function executeUnattendedRun(agent: string, message: string, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
|
|
1859
|
+
const entry = getEntry(agent)
|
|
1860
|
+
if (!entry) throw new Error(`no agent named "${agent}" in this project`)
|
|
1751
1861
|
const tenant = 'default'
|
|
1752
1862
|
const environment = process.env.LOOPENGINE_ENV ?? 'production'
|
|
1753
|
-
const outcome = await sessions.withSession(`${tenant}:${environment}:${
|
|
1754
|
-
const result = await runAgent(entry.config, entry.createModelCall(),
|
|
1863
|
+
const outcome = await sessions.withSession(`${tenant}:${environment}:${agent}:${rawSessionId}`, async (history) => {
|
|
1864
|
+
const result = await runAgent(entry.config, entry.createModelCall(), message, history, {
|
|
1755
1865
|
tenant,
|
|
1756
1866
|
sessionId: rawSessionId,
|
|
1757
1867
|
channel: 'http',
|
|
1758
1868
|
approver: defaultHttpWebhookNotifier ?? { requestApproval: async () => false },
|
|
1759
1869
|
questionHandler: defaultHttpWebhookNotifier ?? { requestQuestion: async () => NO_HUMAN_ANSWER },
|
|
1760
1870
|
})
|
|
1761
|
-
if (result.pending) await createCheckpointFromPending(
|
|
1871
|
+
if (result.pending) await createCheckpointFromPending(agent, tenant, rawSessionId, result.pending)
|
|
1762
1872
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pendingIds: result.pending?.outstanding.map((o) => o.pendingId) } }
|
|
1763
1873
|
})
|
|
1764
1874
|
if (outcome.pendingIds?.length) return { kind: 'pending', pendingIds: outcome.pendingIds }
|
|
1765
|
-
// run-agent's "you denied" wording is for someone live in a chat; in
|
|
1766
|
-
//
|
|
1875
|
+
// run-agent's "you denied" wording is for someone live in a chat; in an
|
|
1876
|
+
// unattended run nobody denied anything, so say what actually happened.
|
|
1767
1877
|
if (outcome.stopReason === 'denied') {
|
|
1768
1878
|
const tools = /denied: (.+?)\. Send/.exec(outcome.text)?.[1] ?? 'a tool call'
|
|
1769
|
-
throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in
|
|
1879
|
+
throw new Error(`Not allowed: ${tools}. A call that needs approval is denied in an unattended run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
|
|
1770
1880
|
}
|
|
1771
1881
|
return { kind: 'done', text: outcome.text }
|
|
1772
1882
|
}
|
|
1773
1883
|
|
|
1884
|
+
// A run started over the API with "notify" or "callback_url" reports
|
|
1885
|
+
// when it ends (and, to its callback, when it stops for a decision): its
|
|
1886
|
+
// result through the agent's notifier, and its record to the URL.
|
|
1887
|
+
async function reportWorkflowRun(run: WorkflowRun): Promise<void> {
|
|
1888
|
+
const event: RunCallbackEvent | undefined = run.status === 'done' ? 'run.done' : run.status === 'error' ? 'run.failed' : run.status === 'awaiting_approval' ? 'run.awaiting_approval' : undefined
|
|
1889
|
+
if (!event) return
|
|
1890
|
+
const entry = getEntry(run.agent)
|
|
1891
|
+
if (run.notify && entry && event !== 'run.awaiting_approval') {
|
|
1892
|
+
const text = run.status === 'done' ? `Workflow ${run.workflow_id} for ${run.agent} ran:\n${run.result ?? ''}` : `Workflow ${run.workflow_id} for ${run.agent} failed: ${run.error ?? 'unknown error'}`
|
|
1893
|
+
announceToNotifier(entry.config, text, { runId: run.run_id, workflowId: run.workflow_id, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
|
|
1894
|
+
}
|
|
1895
|
+
if (run.callback_url) {
|
|
1896
|
+
const delivery = await deliverCallback(run.callback_url, event, runCallbackPayload(event, run))
|
|
1897
|
+
await getRunStore().update(run.run_id, (r) => r && { ...r, callbacks: [...(r.callbacks ?? []), delivery].slice(-10) })
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// Workflow runs, whatever starts them, each recorded apart from schedules.
|
|
1902
|
+
const workflowRunner = new WorkflowRunner({ execute: executeUnattendedRun, onChange: reportWorkflowRun })
|
|
1903
|
+
|
|
1904
|
+
// A task that runs a workflow is also recorded as a run of that workflow,
|
|
1905
|
+
// so the workflow's Recent runs shows its scheduled runs too.
|
|
1906
|
+
function fireScheduledTask(task: ScheduledTask, rawSessionId: string): ReturnType<typeof executeUnattendedRun> {
|
|
1907
|
+
if (!task.workflow_id) return executeUnattendedRun(task.agent, task.message, rawSessionId)
|
|
1908
|
+
return workflowRunner.runForSchedule({
|
|
1909
|
+
agent: task.agent,
|
|
1910
|
+
workflow_id: task.workflow_id,
|
|
1911
|
+
params: task.params,
|
|
1912
|
+
task_id: task.task_id,
|
|
1913
|
+
task_name: task.task_name,
|
|
1914
|
+
message: task.message,
|
|
1915
|
+
session_id: rawSessionId,
|
|
1916
|
+
})
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1774
1919
|
// A task with `notify` on reports each run through its agent's notifier.
|
|
1775
1920
|
function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void {
|
|
1776
1921
|
if (!task.notify) return
|
|
@@ -1781,24 +1926,71 @@ function announceScheduledRun(task: ScheduledTask, run: ScheduledTaskRun): void
|
|
|
1781
1926
|
announceToNotifier(entry.config, text, { scheduledTaskId: task.task_id, taskName: task.task_name ?? null, status: run.status, result: run.result ?? null, error: run.error ?? null }, defaultWebhook)
|
|
1782
1927
|
}
|
|
1783
1928
|
|
|
1784
|
-
|
|
1929
|
+
// A workflow task's message is worked out at run time from the workflow
|
|
1930
|
+
// as it is then: its start message and steps, with the task's params
|
|
1931
|
+
// (which must still fit its parameters) filled in.
|
|
1932
|
+
const scheduler = new Scheduler({
|
|
1933
|
+
fire: fireScheduledTask,
|
|
1934
|
+
resolveWorkflowMessage: (agent, workflowId, params) => {
|
|
1935
|
+
const workflow = getEntry(agent) ? readWorkflow(agentDir(agent), workflowId) : undefined
|
|
1936
|
+
return workflow && workflowRunMessage(workflow, validateParams(workflow, params))
|
|
1937
|
+
},
|
|
1938
|
+
onSettled: announceScheduledRun,
|
|
1939
|
+
})
|
|
1785
1940
|
|
|
1786
1941
|
function scheduleJson(res: ServerResponse, status: number, body: unknown): void {
|
|
1787
1942
|
res.writeHead(status, { 'content-type': 'application/json' }).end(JSON.stringify(body))
|
|
1788
1943
|
}
|
|
1789
1944
|
|
|
1945
|
+
// A workflow's start message with a task's params in its $name
|
|
1946
|
+
// placeholders (a declared one not given becomes empty), for the
|
|
1947
|
+
// Schedules tab; runs themselves use workflowRunMessage.
|
|
1948
|
+
function fillStartMessage(text: string, params: Record<string, string>, declared: string[]): string {
|
|
1949
|
+
return text.replace(/\$([a-z_][a-z0-9_]*)/g, (match, name: string) =>
|
|
1950
|
+
Object.prototype.hasOwnProperty.call(params, name) ? params[name]! : declared.includes(name) ? '' : match)
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
// The agent's tasks, a workflow task with its workflow's name and current
|
|
1954
|
+
// start message (or `workflow_missing` once the workflow is gone), and
|
|
1955
|
+
// the agent's workflows, for the New schedule form's choice.
|
|
1790
1956
|
async function handleSchedulesGet(res: ServerResponse, agentName: string): Promise<void> {
|
|
1791
1957
|
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1792
|
-
|
|
1958
|
+
await migrateAgentWorkflows(agentName, agentDir(agentName))
|
|
1959
|
+
const workflows = readWorkflows(agentDir(agentName))
|
|
1960
|
+
const tasks = (await listScheduledTasks({ agent: agentName })).map((t) => {
|
|
1961
|
+
if (!t.workflow_id) return t
|
|
1962
|
+
const workflow = workflows.find((w) => w.id === t.workflow_id)
|
|
1963
|
+
// Its start message as this task's runs see it, parameters filled in.
|
|
1964
|
+
const startMessage = workflow && fillStartMessage(workflow.startMessage, t.params ?? {}, Object.keys(workflow.parameters))
|
|
1965
|
+
return workflow ? { ...t, workflow: { id: workflow.id, title: workflow.title, start_message: startMessage } } : { ...t, workflow_missing: true }
|
|
1966
|
+
})
|
|
1967
|
+
scheduleJson(res, 200, {
|
|
1968
|
+
tasks,
|
|
1969
|
+
workflows: workflows.map((w) => ({ id: w.id, title: w.title, description: w.description, start_message: w.startMessage, parameters: w.parameters })),
|
|
1970
|
+
time_zone: workspaceTimeZone(),
|
|
1971
|
+
})
|
|
1793
1972
|
}
|
|
1794
1973
|
|
|
1795
1974
|
async function handleSchedulesPost(req: IncomingMessage, res: ServerResponse, agentName: string): Promise<void> {
|
|
1796
1975
|
if (!getEntry(agentName)) return scheduleJson(res, 404, { error: `unknown agent '${agentName}'` })
|
|
1797
1976
|
const body = await readJsonBody(req)
|
|
1977
|
+
const workflowId = typeof body.workflow_id === 'string' && body.workflow_id ? body.workflow_id : undefined
|
|
1978
|
+
let params: Record<string, string> | undefined
|
|
1979
|
+
if (workflowId) {
|
|
1980
|
+
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
1981
|
+
if (!workflow) return scheduleJson(res, 400, { error: `${agentName} has no workflow "${workflowId}"` })
|
|
1982
|
+
try {
|
|
1983
|
+
params = validateParams(workflow, body.params)
|
|
1984
|
+
} catch (err) {
|
|
1985
|
+
return scheduleJson(res, 400, { error: err instanceof Error ? err.message : String(err) })
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1798
1988
|
try {
|
|
1799
1989
|
const task = await createScheduledTask({
|
|
1800
1990
|
agent: agentName,
|
|
1801
|
-
message:
|
|
1991
|
+
message: typeof body.message === 'string' ? body.message : undefined,
|
|
1992
|
+
workflow_id: workflowId,
|
|
1993
|
+
...(params && Object.keys(params).length ? { params } : {}),
|
|
1802
1994
|
schedule: (body.schedule ?? {}) as { run_at?: unknown; every?: unknown; start_at?: unknown; at?: unknown; weekday?: unknown },
|
|
1803
1995
|
task_name: typeof body.task_name === 'string' ? body.task_name : undefined,
|
|
1804
1996
|
notify: body.notify === true,
|
|
@@ -3211,10 +3403,11 @@ async function respondAfterResolutionNow(res: ServerResponse, finalCheckpoint: T
|
|
|
3211
3403
|
return { newMessages: result.newMessages, result: { text: result.text, stopReason: result.stopReason, pending: pendingSummary(result.pending) } }
|
|
3212
3404
|
})
|
|
3213
3405
|
|
|
3214
|
-
// A scheduled task's paused run (core/scheduler.ts)
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3406
|
+
// A scheduled task's paused run (core/scheduler.ts), or a workflow run's
|
|
3407
|
+
// (core/workflow-runs.ts), settles here.
|
|
3408
|
+
const resumed = { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) }
|
|
3409
|
+
void scheduler.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
|
|
3410
|
+
void workflowRunner.settleFromResume(finalCheckpoint.sessionId, resumed).catch((err) => console.error('[loopengine] could not settle workflow run:', err))
|
|
3218
3411
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3219
3412
|
JSON.stringify({ text: responseBody.text, sessionId: finalCheckpoint.sessionId, ...(responseBody.stopReason ? { stopReason: responseBody.stopReason } : {}), ...(responseBody.pending ? { pending: responseBody.pending } : {}) }),
|
|
3220
3413
|
)
|
|
@@ -3407,7 +3600,7 @@ async function tracked<T>(fn: () => Promise<T>): Promise<T> {
|
|
|
3407
3600
|
async function handleActivityGet(res: ServerResponse): Promise<void> {
|
|
3408
3601
|
res.writeHead(200, { 'content-type': 'application/json' }).end(
|
|
3409
3602
|
JSON.stringify({
|
|
3410
|
-
inFlightTurns: sessionTurns.size + scheduler.running + backgroundWork,
|
|
3603
|
+
inFlightTurns: sessionTurns.size + scheduler.running + workflowRunner.running + backgroundWork,
|
|
3411
3604
|
scheduledRuns: scheduler.running,
|
|
3412
3605
|
// When the next scheduled task is due — wake this server by then
|
|
3413
3606
|
// (POST /internal/scheduler/tick) if it's been stopped while idle.
|
|
@@ -3641,6 +3834,100 @@ async function decideFromMessaging(pendingId: string, decision: MessagingDecisio
|
|
|
3641
3834
|
return { text: typeof body.text === 'string' ? body.text : undefined }
|
|
3642
3835
|
}
|
|
3643
3836
|
|
|
3837
|
+
// ---- Webhook triggers (core/workflow-triggers.ts): an outside service
|
|
3838
|
+
// posts its own events to a workflow's secret URL. Answered 202 as soon as
|
|
3839
|
+
// the run starts (senders retry a slow or failed answer); a retry of a
|
|
3840
|
+
// delivery already handled, or an event the filter skips, is answered 202
|
|
3841
|
+
// too, so it isn't sent again. ----
|
|
3842
|
+
|
|
3843
|
+
const WEBHOOK_MAX_BYTES = 1024 * 1024
|
|
3844
|
+
|
|
3845
|
+
async function handleWorkflowHook(req: IncomingMessage, res: ServerResponse, token: string): Promise<void> {
|
|
3846
|
+
const json = (status: number, body: unknown): void => {
|
|
3847
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
3848
|
+
}
|
|
3849
|
+
const raw = await readLimitedRawBuffer(req, WEBHOOK_MAX_BYTES)
|
|
3850
|
+
if (!raw) return json(413, { error: 'The payload is larger than 1 MB.' })
|
|
3851
|
+
// Unknown and turned-off triggers read the same, so a URL can't be probed.
|
|
3852
|
+
const trigger = await findTriggerByToken(token)
|
|
3853
|
+
if (!trigger || !trigger.enabled) return json(404, { error: 'not found' })
|
|
3854
|
+
const at = new Date().toISOString()
|
|
3855
|
+
const record = (outcome: Parameters<typeof recordDelivery>[1]['outcome'], extra: { delivery_id?: string; run_id?: string; detail?: string } = {}, seen?: string) =>
|
|
3856
|
+
recordDelivery(trigger.trigger_id, { at, outcome, ...extra }, seen)
|
|
3857
|
+
|
|
3858
|
+
if (trigger.verify && !verifySenderSignature(trigger.verify, req.headers, raw)) {
|
|
3859
|
+
await record('rejected', { detail: `The ${trigger.verify.sender} signature didn't match.` })
|
|
3860
|
+
return json(401, { error: 'signature check failed' })
|
|
3861
|
+
}
|
|
3862
|
+
const payload = parsePayload(raw, req.headers['content-type'])
|
|
3863
|
+
const id = deliveryId(req.headers, payload)
|
|
3864
|
+
if (id && trigger.seen_ids.includes(id)) {
|
|
3865
|
+
await record('duplicate', { delivery_id: id })
|
|
3866
|
+
return json(202, { ignored: 'duplicate' })
|
|
3867
|
+
}
|
|
3868
|
+
if (!passesFilter(trigger.filter, payload)) {
|
|
3869
|
+
await record('filtered', { ...(id ? { delivery_id: id } : {}) }, id)
|
|
3870
|
+
return json(202, { ignored: 'filtered' })
|
|
3871
|
+
}
|
|
3872
|
+
if (!getEntry(trigger.agent)) {
|
|
3873
|
+
await record('error', { detail: `The agent ${trigger.agent} is gone.` })
|
|
3874
|
+
return json(404, { error: 'not found' })
|
|
3875
|
+
}
|
|
3876
|
+
try {
|
|
3877
|
+
const prepared = await prepareWorkflowRun(trigger.agent, agentDir(trigger.agent), trigger.workflow_id, paramsFromPayload(trigger, payload))
|
|
3878
|
+
const run = await workflowRunner.start({
|
|
3879
|
+
agent: trigger.agent, workflow_id: trigger.workflow_id, params: prepared.params, source: 'webhook', trigger_id: trigger.trigger_id, message: prepared.message,
|
|
3880
|
+
...(trigger.notify ? { notify: true } : {}), ...(trigger.callback_url ? { callback_url: trigger.callback_url } : {}),
|
|
3881
|
+
})
|
|
3882
|
+
await record('started', { run_id: run.run_id, ...(id ? { delivery_id: id } : {}) }, id)
|
|
3883
|
+
return json(202, { runId: run.run_id })
|
|
3884
|
+
} catch (err) {
|
|
3885
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
3886
|
+
if (err instanceof WorkflowError && /already running/.test(message)) {
|
|
3887
|
+
await record('busy', { detail: message, ...(id ? { delivery_id: id } : {}) }, id)
|
|
3888
|
+
return json(202, { ignored: 'already running' })
|
|
3889
|
+
}
|
|
3890
|
+
await record(err instanceof WorkflowError ? 'invalid' : 'error', { detail: message, ...(id ? { delivery_id: id } : {}) })
|
|
3891
|
+
return json(err instanceof WorkflowError ? 422 : 500, { error: message })
|
|
3892
|
+
}
|
|
3893
|
+
}
|
|
3894
|
+
|
|
3895
|
+
// A workflow's webhook triggers, for the console: list, make (its URL is
|
|
3896
|
+
// shown once), change, rotate (a new URL), delete.
|
|
3897
|
+
async function handleWorkflowTriggers(req: IncomingMessage, res: ServerResponse, agentName: string, workflowId: string, triggerId: string | undefined, action: string | undefined): Promise<void> {
|
|
3898
|
+
const json = (status: number, body: unknown): void => {
|
|
3899
|
+
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify(body))
|
|
3900
|
+
}
|
|
3901
|
+
if (!getEntry(agentName)) return json(404, { error: `unknown agent '${agentName}'` })
|
|
3902
|
+
const workflow = readWorkflow(agentDir(agentName), workflowId)
|
|
3903
|
+
if (!workflow) return json(404, { error: `no workflow ${workflowId} in ${agentName}` })
|
|
3904
|
+
const forwardedProto = String(req.headers['x-forwarded-proto'] ?? '').split(',')[0]
|
|
3905
|
+
const base = `${forwardedProto || ((req.socket as { encrypted?: boolean }).encrypted ? 'https' : 'http')}://${String(req.headers['x-forwarded-host'] ?? req.headers.host ?? 'localhost')}`
|
|
3906
|
+
const urlFor = (token: string) => `${base}/hooks/workflows/${token}`
|
|
3907
|
+
try {
|
|
3908
|
+
if (!triggerId && req.method === 'GET') return json(200, { triggers: (await listTriggers(agentName, workflowId)).map(publicTrigger) })
|
|
3909
|
+
if (!triggerId && req.method === 'POST') {
|
|
3910
|
+
const { trigger, token } = await createTrigger(agentName, workflow, await readJsonBody(req))
|
|
3911
|
+
return json(201, { trigger: publicTrigger(trigger), url: urlFor(token) })
|
|
3912
|
+
}
|
|
3913
|
+
const existing = triggerId ? await getTriggerStore().get(triggerId) : undefined
|
|
3914
|
+
if (!existing || existing.agent !== agentName || existing.workflow_id !== workflowId) return json(404, { error: 'no such trigger' })
|
|
3915
|
+
if (action === 'rotate' && req.method === 'POST') {
|
|
3916
|
+
const { trigger, token } = await rotateTrigger(existing.trigger_id)
|
|
3917
|
+
return json(200, { trigger: publicTrigger(trigger), url: urlFor(token) })
|
|
3918
|
+
}
|
|
3919
|
+
if (!action && req.method === 'PUT') return json(200, { trigger: publicTrigger(await updateTrigger(existing.trigger_id, workflow, await readJsonBody(req))) })
|
|
3920
|
+
if (!action && req.method === 'DELETE') {
|
|
3921
|
+
await getTriggerStore().remove(existing.trigger_id)
|
|
3922
|
+
return json(200, { removed: true })
|
|
3923
|
+
}
|
|
3924
|
+
return json(404, { error: 'not found' })
|
|
3925
|
+
} catch (err) {
|
|
3926
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
3927
|
+
return json(err instanceof TriggerError || err instanceof CallbackUrlError ? 400 : 500, { error: message })
|
|
3928
|
+
}
|
|
3929
|
+
}
|
|
3930
|
+
|
|
3644
3931
|
// Set once shutdown() starts, so /healthz turns 503 while the server
|
|
3645
3932
|
// drains and a router or load balancer stops sending it new requests.
|
|
3646
3933
|
let shuttingDown = false
|
|
@@ -3679,6 +3966,19 @@ const server = createServer(async (req, res) => {
|
|
|
3679
3966
|
return
|
|
3680
3967
|
}
|
|
3681
3968
|
|
|
3969
|
+
// A workflow's webhook trigger: its token is the credential (no login).
|
|
3970
|
+
const workflowHookMatch = req.method === 'POST' && (req.url ?? '/').split('?')[0].match(/^\/hooks\/workflows\/([A-Za-z0-9_-]+)$/)
|
|
3971
|
+
if (workflowHookMatch) {
|
|
3972
|
+
try {
|
|
3973
|
+
lastRequestAt = Date.now()
|
|
3974
|
+
await tracked(() => handleWorkflowHook(req, res, workflowHookMatch[1]))
|
|
3975
|
+
} catch (err) {
|
|
3976
|
+
console.error('[loopengine] workflow webhook failed:', err)
|
|
3977
|
+
if (!res.headersSent) res.writeHead(500, { 'content-type': 'application/json' }).end(JSON.stringify({ error: 'Something went wrong.' }))
|
|
3978
|
+
}
|
|
3979
|
+
return
|
|
3980
|
+
}
|
|
3981
|
+
|
|
3682
3982
|
const decideMatch = (req.method === 'GET' || req.method === 'POST') && (req.url ?? '/').split('?')[0].match(/^\/decide\/([A-Za-z0-9_.-]+)$/)
|
|
3683
3983
|
if (decideMatch) {
|
|
3684
3984
|
try {
|
|
@@ -3949,7 +4249,26 @@ const server = createServer(async (req, res) => {
|
|
|
3949
4249
|
await handleAgentApp(req, res, decodeURIComponent(agentAppMatch[1]), decodeURIComponent(agentAppMatch[2]))
|
|
3950
4250
|
return
|
|
3951
4251
|
}
|
|
3952
|
-
|
|
4252
|
+
// The secret run callbacks are signed with, for whoever checks them.
|
|
4253
|
+
if (req.method === 'GET' && pathname === '/runs/callback-secret') {
|
|
4254
|
+
res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store' }).end(JSON.stringify({ secret: callbackSecret() }))
|
|
4255
|
+
return
|
|
4256
|
+
}
|
|
4257
|
+
// One workflow run, by the runId its POST .../run returned.
|
|
4258
|
+
const agentRunMatch = req.method === 'GET' && pathname.match(/^\/agents\/([^/]+)\/runs\/([A-Za-z0-9_-]+)$/)
|
|
4259
|
+
if (agentRunMatch) {
|
|
4260
|
+
const agentName = decodeURIComponent(agentRunMatch[1])
|
|
4261
|
+
const run = getEntry(agentName) ? await getRun(agentName, agentRunMatch[2]) : undefined
|
|
4262
|
+
res.writeHead(run ? 200 : 404, { 'content-type': 'application/json', 'cache-control': 'no-store' })
|
|
4263
|
+
.end(JSON.stringify(run ?? { error: `no run ${agentRunMatch[2]} for ${agentName}` }))
|
|
4264
|
+
return
|
|
4265
|
+
}
|
|
4266
|
+
const triggersMatch = pathname.match(/^\/agents\/([^/]+)\/workflows\/([a-z0-9-]+)\/triggers(?:\/([A-Za-z0-9_-]+))?(?:\/(rotate))?$/)
|
|
4267
|
+
if (triggersMatch) {
|
|
4268
|
+
await handleWorkflowTriggers(req, res, decodeURIComponent(triggersMatch[1]), triggersMatch[2], triggersMatch[3], triggersMatch[4])
|
|
4269
|
+
return
|
|
4270
|
+
}
|
|
4271
|
+
const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run|runs))?$/)
|
|
3953
4272
|
if (agentWorkflowsMatch) {
|
|
3954
4273
|
await handleAgentWorkflows(req, res, decodeURIComponent(agentWorkflowsMatch[1]), agentWorkflowsMatch[2], agentWorkflowsMatch[3])
|
|
3955
4274
|
return
|
|
@@ -4020,7 +4339,7 @@ const server = createServer(async (req, res) => {
|
|
|
4020
4339
|
return
|
|
4021
4340
|
}
|
|
4022
4341
|
try {
|
|
4023
|
-
const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running > 0 })
|
|
4342
|
+
const outcome = await runUpgrade({ busy: () => sessionTurns.size + scheduler.running + workflowRunner.running > 0 })
|
|
4024
4343
|
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, outcome, restarting: exitOnCodeChange }))
|
|
4025
4344
|
} catch (err) {
|
|
4026
4345
|
const status = err instanceof UpgradeError ? 409 : 500
|
|
@@ -4264,6 +4583,19 @@ const server = createServer(async (req, res) => {
|
|
|
4264
4583
|
await handleSchedulesPost(req, res, decodeURIComponent(schedulesMatch[1]))
|
|
4265
4584
|
return
|
|
4266
4585
|
}
|
|
4586
|
+
// POST /schedules/:id/notify { "notify": true|false }: whether each
|
|
4587
|
+
// run's result goes through the agent's notifier.
|
|
4588
|
+
const scheduleNotifyMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/notify$/)
|
|
4589
|
+
if (scheduleNotifyMatch) {
|
|
4590
|
+
const body = await readJsonBody(req)
|
|
4591
|
+
if (typeof body.notify !== 'boolean') return scheduleJson(res, 400, { error: 'notify must be true or false' })
|
|
4592
|
+
try {
|
|
4593
|
+
scheduleJson(res, 200, await setScheduledTaskNotify(scheduleNotifyMatch[1], body.notify))
|
|
4594
|
+
} catch (err) {
|
|
4595
|
+
scheduleJson(res, err instanceof ScheduleError ? 404 : 500, { error: err instanceof Error ? err.message : String(err) })
|
|
4596
|
+
}
|
|
4597
|
+
return
|
|
4598
|
+
}
|
|
4267
4599
|
const scheduleActionMatch = req.method === 'POST' && pathname.match(/^\/schedules\/([A-Za-z0-9_-]+)\/(run|pause|resume|cancel)$/)
|
|
4268
4600
|
if (scheduleActionMatch) {
|
|
4269
4601
|
await handleScheduleAction(res, scheduleActionMatch[1], scheduleActionMatch[2])
|
|
@@ -4540,7 +4872,16 @@ server.listen(port, () => {
|
|
|
4540
4872
|
console.log(`agent API listening on :${port}`)
|
|
4541
4873
|
void warmAgentTools()
|
|
4542
4874
|
// LOOPENGINE_SCHEDULER=off leaves scheduling to another process.
|
|
4543
|
-
|
|
4875
|
+
// Workflows from before they were skills move into their skills first,
|
|
4876
|
+
// so their schedules run them.
|
|
4877
|
+
const startScheduling = async () => {
|
|
4878
|
+
for (const name of listAgents()) await migrateAgentWorkflows(name, agentDir(name)).catch((err) => console.error(`[loopengine] moving ${name}'s workflows.yml failed:`, err))
|
|
4879
|
+
if (process.env.LOOPENGINE_SCHEDULER !== 'off') {
|
|
4880
|
+
await workflowRunner.recover()
|
|
4881
|
+
await scheduler.start()
|
|
4882
|
+
}
|
|
4883
|
+
}
|
|
4884
|
+
void startScheduling().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
|
|
4544
4885
|
})
|
|
4545
4886
|
|
|
4546
4887
|
// An agent that leaves `tools` unset gets its tools/ folder imported
|