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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-loopengine",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Scaffold a new loopengine agent project — a runnable HTTP server with one example agent, in your own repo.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -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
- runWorkflowNow,
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
- // An agent's workflows (its Overview): list, run now, forget.
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 === 'run' && req.method === 'POST') return json(200, await runWorkflowNow(agentName, dir, id))
1450
- if (id && !action && req.method === 'DELETE') {
1451
- forgetWorkflow(dir, id)
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 scheduled run. Use your own best judgement and proceed without waiting for a response.'
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
- async function fireScheduledTask(task: ScheduledTask, rawSessionId: string): Promise<{ kind: 'done'; text: string } | { kind: 'pending'; pendingIds: string[] }> {
1749
- const entry = getEntry(task.agent)
1750
- if (!entry) throw new Error(`no agent named "${task.agent}" in this project`)
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}:${task.agent}:${rawSessionId}`, async (history) => {
1754
- const result = await runAgent(entry.config, entry.createModelCall(), task.message, history, {
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(task.agent, tenant, rawSessionId, result.pending)
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 a
1766
- // scheduled run nobody denied anything, so say what actually happened.
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 a scheduled run unless the Permissions tab sends approval requests somewhere (or a rule denies it outright).`)
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
- const scheduler = new Scheduler({ fire: fireScheduledTask, onSettled: announceScheduledRun })
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
- scheduleJson(res, 200, { tasks: await listScheduledTasks({ agent: agentName }), time_zone: workspaceTimeZone() })
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: String(body.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) settles here.
3215
- void scheduler
3216
- .settleFromResume(finalCheckpoint.sessionId, { text: responseBody.text, pendingIds: responseBody.pending?.map((p) => p.pendingId) })
3217
- .catch((err) => console.error('[loopengine] could not settle scheduled run:', err))
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
- const agentWorkflowsMatch = pathname.match(/^\/agents\/([^/]+)\/workflows(?:\/([a-z0-9-]+))?(?:\/(run))?$/)
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
- if (process.env.LOOPENGINE_SCHEDULER !== 'off') void scheduler.start().catch((err) => console.error('[loopengine] scheduler failed to start:', err))
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
@@ -9,7 +9,7 @@
9
9
  "cli": "tsx adapters/cli.ts"
10
10
  },
11
11
  "dependencies": {
12
- "loopengine": "^0.2.0",
12
+ "loopengine": "^0.3.0",
13
13
  "actauth": "^0.0.12",
14
14
  "tsx": "^4.19.0",
15
15
  "typescript": "^5.7.0"