loopengine 0.3.0 → 0.3.1

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.
@@ -0,0 +1,39 @@
1
+ export declare class CallbackUrlError extends Error {
2
+ }
3
+ export type RunCallbackEvent = 'run.done' | 'run.failed' | 'run.awaiting_approval';
4
+ /** Whether an IP address is private, loopback, link-local or otherwise
5
+ * not on the public internet. */
6
+ export declare function isPrivateAddress(address: string): boolean;
7
+ /** Checks a callback URL as given; returns it normalised. Throws
8
+ * CallbackUrlError saying what's wrong. */
9
+ export declare function checkCallbackUrl(raw: unknown): string;
10
+ /** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
11
+ * made once and kept in the scheduler's folder. */
12
+ export declare function callbackSecret(): string;
13
+ /** The signature header for a body sent at `timestamp` (unix seconds). */
14
+ export declare function signCallback(body: string, timestamp: number, secret?: string): string;
15
+ export interface CallbackDelivery {
16
+ event: RunCallbackEvent;
17
+ delivered: boolean;
18
+ attempts: number;
19
+ status_code?: number;
20
+ error?: string;
21
+ at: string;
22
+ }
23
+ export interface DeliverOptions {
24
+ /** Waits between attempts, in ms; its length + 1 is the attempts made. */
25
+ backoffMs?: number[];
26
+ timeoutMs?: number;
27
+ fetch?: typeof fetch;
28
+ }
29
+ /** Posts `payload` to `url` for `event`, retrying a failed attempt (an
30
+ * error, or a status other than 2xx) after each wait in backoffMs. The
31
+ * host is looked up first, and a private address is refused (unless
32
+ * local callbacks are allowed), so a public name can't lead inward. */
33
+ export declare function deliverCallback(url: string, event: RunCallbackEvent, payload: unknown, options?: DeliverOptions): Promise<CallbackDelivery>;
34
+ /** What a run's callback carries: the event and the run's record, without
35
+ * its session id, its first message or past callback deliveries. */
36
+ export declare function runCallbackPayload(event: RunCallbackEvent, run: object): {
37
+ event: RunCallbackEvent;
38
+ run: Record<string, unknown>;
39
+ };
@@ -0,0 +1,125 @@
1
+ // Callbacks for workflow runs started over the API: a run started with
2
+ // "callback_url" has its record posted there when it finishes, fails, or
3
+ // stops for an approval or a question (core/workflow-runs.ts calls
4
+ // deliverRunCallback for each of those).
5
+ //
6
+ // Each POST carries
7
+ // X-LoopEngine-Event: run.done | run.failed | run.awaiting_approval
8
+ // X-LoopEngine-Signature: t=<unix seconds>,v1=<hex HMAC-SHA256 of "<t>.<body>">
9
+ // signed with the workspace's callback secret: LOOPENGINE_CALLBACK_SECRET,
10
+ // or one made once and kept in the scheduler's folder
11
+ // (GET /runs/callback-secret shows it). A receiver checks the signature
12
+ // and that t is recent.
13
+ //
14
+ // Only https URLs on public addresses are accepted, so a run can't be
15
+ // pointed at the workspace's own network. LOOPENGINE_ALLOW_LOCAL_CALLBACKS=1
16
+ // also allows http and local addresses, for development.
17
+ import { createHmac, randomBytes } from 'node:crypto';
18
+ import { lookup } from 'node:dns/promises';
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { isIP } from 'node:net';
21
+ import path from 'node:path';
22
+ export class CallbackUrlError extends Error {
23
+ }
24
+ function allowLocal() {
25
+ return process.env.LOOPENGINE_ALLOW_LOCAL_CALLBACKS === '1';
26
+ }
27
+ /** Whether an IP address is private, loopback, link-local or otherwise
28
+ * not on the public internet. */
29
+ export function isPrivateAddress(address) {
30
+ const ip = address.toLowerCase().replace(/^\[|\]$/g, '');
31
+ const v4 = ip.startsWith('::ffff:') ? ip.slice(7) : ip;
32
+ if (isIP(v4) === 4) {
33
+ const [a, b] = v4.split('.').map(Number);
34
+ return a === 0 || a === 10 || a === 127 || (a === 100 && b >= 64 && b <= 127) || (a === 169 && b === 254) ||
35
+ (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a >= 224;
36
+ }
37
+ if (isIP(ip) === 6) {
38
+ return ip === '::' || ip === '::1' || /^f[cd]/.test(ip) || /^fe[89ab]/.test(ip) || ip.startsWith('ff');
39
+ }
40
+ return false;
41
+ }
42
+ /** Checks a callback URL as given; returns it normalised. Throws
43
+ * CallbackUrlError saying what's wrong. */
44
+ export function checkCallbackUrl(raw) {
45
+ if (typeof raw !== 'string' || !raw.trim())
46
+ throw new CallbackUrlError('callback_url must be a URL, e.g. "https://example.com/hooks/loopengine"');
47
+ let url;
48
+ try {
49
+ url = new URL(raw.trim());
50
+ }
51
+ catch {
52
+ throw new CallbackUrlError(`callback_url "${raw}" isn't a URL`);
53
+ }
54
+ const host = url.hostname.toLowerCase();
55
+ const local = host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.internal') || host.endsWith('.local') || isPrivateAddress(host);
56
+ if (url.protocol !== 'https:' && !(allowLocal() && url.protocol === 'http:'))
57
+ throw new CallbackUrlError('callback_url must use https');
58
+ if (local && !allowLocal())
59
+ throw new CallbackUrlError('callback_url must be a public address, not a local or private one');
60
+ if (url.username || url.password)
61
+ throw new CallbackUrlError('callback_url must not carry a username or password; check the signature instead');
62
+ return url.toString();
63
+ }
64
+ /** The workspace's callback secret: LOOPENGINE_CALLBACK_SECRET, or one
65
+ * made once and kept in the scheduler's folder. */
66
+ export function callbackSecret() {
67
+ if (process.env.LOOPENGINE_CALLBACK_SECRET)
68
+ return process.env.LOOPENGINE_CALLBACK_SECRET;
69
+ const dir = process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler');
70
+ const file = path.join(dir, 'callback-secret');
71
+ if (existsSync(file))
72
+ return readFileSync(file, 'utf8').trim();
73
+ mkdirSync(dir, { recursive: true });
74
+ const secret = randomBytes(32).toString('hex');
75
+ writeFileSync(file, secret + '\n', { mode: 0o600 });
76
+ return secret;
77
+ }
78
+ /** The signature header for a body sent at `timestamp` (unix seconds). */
79
+ export function signCallback(body, timestamp, secret = callbackSecret()) {
80
+ return `t=${timestamp},v1=${createHmac('sha256', secret).update(`${timestamp}.${body}`).digest('hex')}`;
81
+ }
82
+ /** Posts `payload` to `url` for `event`, retrying a failed attempt (an
83
+ * error, or a status other than 2xx) after each wait in backoffMs. The
84
+ * host is looked up first, and a private address is refused (unless
85
+ * local callbacks are allowed), so a public name can't lead inward. */
86
+ export async function deliverCallback(url, event, payload, options = {}) {
87
+ const backoff = options.backoffMs ?? [2_000, 10_000, 30_000];
88
+ const doFetch = options.fetch ?? fetch;
89
+ const body = JSON.stringify(payload);
90
+ let last = { event, delivered: false, attempts: 0, at: new Date().toISOString() };
91
+ for (let attempt = 0; attempt <= backoff.length; attempt++) {
92
+ if (attempt > 0)
93
+ await new Promise((r) => setTimeout(r, backoff[attempt - 1]));
94
+ const at = new Date().toISOString();
95
+ try {
96
+ if (!allowLocal()) {
97
+ const addresses = await lookup(new URL(url).hostname, { all: true });
98
+ if (addresses.some((a) => isPrivateAddress(a.address))) {
99
+ return { event, delivered: false, attempts: attempt + 1, error: 'The callback address leads to a private network.', at };
100
+ }
101
+ }
102
+ const timestamp = Math.floor(Date.now() / 1000);
103
+ const response = await doFetch(url, {
104
+ method: 'POST',
105
+ headers: { 'content-type': 'application/json', 'x-loopengine-event': event, 'x-loopengine-signature': signCallback(body, timestamp), 'user-agent': 'LoopEngine-Callback/1' },
106
+ body,
107
+ signal: AbortSignal.timeout(options.timeoutMs ?? 10_000),
108
+ redirect: 'manual',
109
+ });
110
+ last = { event, delivered: response.ok, attempts: attempt + 1, status_code: response.status, at, ...(response.ok ? {} : { error: `It answered ${response.status}.` }) };
111
+ if (response.ok)
112
+ return last;
113
+ }
114
+ catch (err) {
115
+ last = { event, delivered: false, attempts: attempt + 1, error: err instanceof Error ? err.message : String(err), at };
116
+ }
117
+ }
118
+ return last;
119
+ }
120
+ /** What a run's callback carries: the event and the run's record, without
121
+ * its session id, its first message or past callback deliveries. */
122
+ export function runCallbackPayload(event, run) {
123
+ const { session_id: _session, message: _message, callbacks: _callbacks, ...rest } = run;
124
+ return { event, run: rest };
125
+ }
@@ -0,0 +1,131 @@
1
+ import type { CallbackDelivery } from './run-callbacks.js';
2
+ export type WorkflowRunStatus = 'running' | 'awaiting_approval' | 'done' | 'error';
3
+ export interface WorkflowRun {
4
+ run_id: string;
5
+ agent: string;
6
+ workflow_id: string;
7
+ params?: Record<string, string>;
8
+ /** Who started it: the console's Run now, an app over the API, a
9
+ * schedule, or an outside service through a webhook trigger. */
10
+ source: 'console' | 'api' | 'schedule' | 'webhook';
11
+ /** The webhook trigger that started it, for source "webhook"
12
+ * (core/workflow-triggers.ts). */
13
+ trigger_id?: string;
14
+ /** The schedule that started it, for source "schedule". */
15
+ task_id?: string;
16
+ task_name?: string;
17
+ status: WorkflowRunStatus;
18
+ started_at: string;
19
+ finished_at?: string;
20
+ /** The message it started with: the workflow's start message and steps. */
21
+ message: string;
22
+ session_id: string;
23
+ /** The agent's final reply, when done. */
24
+ result?: string;
25
+ error?: string;
26
+ /** The approvals or questions it is waiting on. */
27
+ pending_ids?: string[];
28
+ /** Send its result through the agent's notifier when it ends. */
29
+ notify?: boolean;
30
+ /** Where its record is posted when it ends or stops for a decision
31
+ * (core/run-callbacks.ts). */
32
+ callback_url?: string;
33
+ /** Each callback delivery, oldest first (the latest 10). */
34
+ callbacks?: CallbackDelivery[];
35
+ }
36
+ export type RunOutcome = {
37
+ kind: 'done';
38
+ text: string;
39
+ } | {
40
+ kind: 'pending';
41
+ pendingIds: string[];
42
+ };
43
+ /** Runs the agent once: what a scheduled task's fire does. */
44
+ export type ExecuteRun = (agent: string, message: string, sessionId: string) => Promise<RunOutcome>;
45
+ export declare class RunError extends Error {
46
+ }
47
+ export interface RunStore {
48
+ list(): Promise<WorkflowRun[]>;
49
+ get(runId: string): Promise<WorkflowRun | undefined>;
50
+ put(run: WorkflowRun): Promise<void>;
51
+ remove(runId: string): Promise<void>;
52
+ update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
53
+ }
54
+ /** One JSON file per run under `<dir>/runs/` — one server process. */
55
+ export declare class FileRunStore implements RunStore {
56
+ private readonly dir;
57
+ constructor(dir: string);
58
+ private file;
59
+ list(): Promise<WorkflowRun[]>;
60
+ get(runId: string): Promise<WorkflowRun | undefined>;
61
+ put(run: WorkflowRun): Promise<void>;
62
+ remove(runId: string): Promise<void>;
63
+ update(runId: string, fn: (run: WorkflowRun | undefined) => WorkflowRun | undefined): Promise<WorkflowRun | undefined>;
64
+ }
65
+ /** The process-wide store: `runs/` in the scheduler's folder. */
66
+ export declare function getRunStore(): RunStore;
67
+ export declare function setRunStore(next: RunStore): void;
68
+ /** An agent's runs, newest first; of one workflow when `workflowId` is given. */
69
+ export declare function listRuns(agent: string, filter?: {
70
+ workflowId?: string;
71
+ limit?: number;
72
+ }): Promise<WorkflowRun[]>;
73
+ /** One of an agent's runs; undefined for another agent's. */
74
+ export declare function getRun(agent: string, runId: string): Promise<WorkflowRun | undefined>;
75
+ export declare function isUnderWay(run: WorkflowRun): boolean;
76
+ export interface StartRunInput {
77
+ agent: string;
78
+ workflow_id: string;
79
+ params?: Record<string, string>;
80
+ source: WorkflowRun['source'];
81
+ message: string;
82
+ notify?: boolean;
83
+ callback_url?: string;
84
+ trigger_id?: string;
85
+ }
86
+ export declare class WorkflowRunner {
87
+ private readonly execute;
88
+ private readonly maxFinishedPerAgent;
89
+ private readonly onChange?;
90
+ private readonly inFlight;
91
+ /** `onChange`: called with the run each time it finishes, fails or
92
+ * stops for a decision (the server sends its notify and callback). */
93
+ constructor(options: {
94
+ execute: ExecuteRun;
95
+ maxFinishedPerAgent?: number;
96
+ onChange?: (run: WorkflowRun) => void | Promise<void>;
97
+ });
98
+ private changed;
99
+ /** Records a run and starts it; returns at once with the record. */
100
+ start(input: StartRunInput): Promise<WorkflowRun>;
101
+ /** A schedule firing one of its agent's workflows (the scheduler's fire,
102
+ * for a workflow task): recorded as a run of the workflow, then run the
103
+ * usual way. Returns the outcome for the scheduler, and throws what the
104
+ * run threw, so the schedule's own history records it too. */
105
+ runForSchedule(input: {
106
+ agent: string;
107
+ workflow_id: string;
108
+ params?: Record<string, string>;
109
+ task_id: string;
110
+ task_name?: string;
111
+ message: string;
112
+ session_id: string;
113
+ }): Promise<RunOutcome>;
114
+ /** Waits for the runs this process has started (tests, shutdown). */
115
+ idle(): Promise<void>;
116
+ /** How many runs are executing right now (for idle-shutdown checks). */
117
+ get running(): number;
118
+ private run;
119
+ private finish;
120
+ /** A paused run resumed after a decision (the server calls this from
121
+ * its resolve path, for every resumed session): settle it, or record
122
+ * what it's waiting on now. */
123
+ settleFromResume(sessionId: string, outcome: {
124
+ text: string;
125
+ pendingIds?: string[];
126
+ }): Promise<void>;
127
+ /** On startup: a run a restart cut off can't continue, so it's an
128
+ * error. One waiting on a decision is kept: its checkpoint survives. */
129
+ recover(): Promise<void>;
130
+ private prune;
131
+ }
@@ -0,0 +1,229 @@
1
+ // Workflow runs: started by hand or by an app (Run now in the console, or
2
+ // POST /agents/:name/workflows/:id/run), or by a schedule that runs the
3
+ // workflow. Each is a record of its own here, so a workflow has one
4
+ // history of every run whatever started it, the Schedules tab only lists
5
+ // what is set to run, and each run can be looked up by its id afterwards
6
+ // (GET /agents/:name/runs/:id).
7
+ //
8
+ // A run executes the same way a scheduled task fires (supplied by the
9
+ // server, adapters/http.ts): an unattended agent turn whose approvals and
10
+ // questions go to the agent's messaging app and wait durably. A run that
11
+ // pauses there is settled by settleFromResume once someone decides.
12
+ //
13
+ // Stored like scheduled tasks: one JSON file per run under
14
+ // `<LOOPENGINE_SCHEDULER_DIR or .scheduler>/runs/`. Each agent keeps its
15
+ // latest MAX_FINISHED_PER_AGENT finished runs; older ones are removed as
16
+ // new runs start.
17
+ import { randomUUID } from 'node:crypto';
18
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { KeyedMutex } from './session-store.js';
21
+ const MAX_FINISHED_PER_AGENT = 500;
22
+ const SESSION_PREFIX = 'run-';
23
+ /** A scheduled task's sessions (core/scheduler.ts). */
24
+ const SCHEDULE_SESSION_PREFIX = 'sched-';
25
+ export class RunError extends Error {
26
+ }
27
+ const fileMutex = new KeyedMutex();
28
+ /** One JSON file per run under `<dir>/runs/` — one server process. */
29
+ export class FileRunStore {
30
+ dir;
31
+ constructor(dir) {
32
+ this.dir = path.join(dir, 'runs');
33
+ }
34
+ file(runId) {
35
+ if (!/^[A-Za-z0-9_-]+$/.test(runId))
36
+ throw new RunError(`invalid run id "${runId}"`);
37
+ return path.join(this.dir, `${runId}.json`);
38
+ }
39
+ async list() {
40
+ if (!existsSync(this.dir))
41
+ return [];
42
+ const runs = [];
43
+ for (const name of readdirSync(this.dir)) {
44
+ if (!name.endsWith('.json'))
45
+ continue;
46
+ try {
47
+ runs.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
48
+ }
49
+ catch {
50
+ // A file being written, or not a run: skip it.
51
+ }
52
+ }
53
+ return runs.sort((a, b) => b.started_at.localeCompare(a.started_at));
54
+ }
55
+ async get(runId) {
56
+ const file = this.file(runId);
57
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
58
+ }
59
+ async put(run) {
60
+ mkdirSync(this.dir, { recursive: true });
61
+ const file = this.file(run.run_id);
62
+ writeFileSync(file + '.tmp', JSON.stringify(run, null, 2));
63
+ renameSync(file + '.tmp', file);
64
+ }
65
+ async remove(runId) {
66
+ rmSync(this.file(runId), { force: true });
67
+ }
68
+ async update(runId, fn) {
69
+ return fileMutex.run(runId, async () => {
70
+ const next = fn(await this.get(runId));
71
+ if (next)
72
+ await this.put(next);
73
+ return next;
74
+ });
75
+ }
76
+ }
77
+ let store;
78
+ /** The process-wide store: `runs/` in the scheduler's folder. */
79
+ export function getRunStore() {
80
+ store ??= new FileRunStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
81
+ return store;
82
+ }
83
+ export function setRunStore(next) {
84
+ store = next;
85
+ }
86
+ // ---------- reading ----------
87
+ /** An agent's runs, newest first; of one workflow when `workflowId` is given. */
88
+ export async function listRuns(agent, filter = {}) {
89
+ const runs = (await getRunStore().list()).filter((r) => r.agent === agent && (!filter.workflowId || r.workflow_id === filter.workflowId));
90
+ return filter.limit ? runs.slice(0, filter.limit) : runs;
91
+ }
92
+ /** One of an agent's runs; undefined for another agent's. */
93
+ export async function getRun(agent, runId) {
94
+ if (!/^[A-Za-z0-9_-]+$/.test(runId))
95
+ return undefined;
96
+ const run = await getRunStore().get(runId);
97
+ return run && run.agent === agent ? run : undefined;
98
+ }
99
+ export function isUnderWay(run) {
100
+ return run.status === 'running' || run.status === 'awaiting_approval';
101
+ }
102
+ export class WorkflowRunner {
103
+ execute;
104
+ maxFinishedPerAgent;
105
+ onChange;
106
+ inFlight = new Set();
107
+ /** `onChange`: called with the run each time it finishes, fails or
108
+ * stops for a decision (the server sends its notify and callback). */
109
+ constructor(options) {
110
+ this.execute = options.execute;
111
+ this.maxFinishedPerAgent = options.maxFinishedPerAgent ?? MAX_FINISHED_PER_AGENT;
112
+ this.onChange = options.onChange;
113
+ }
114
+ changed(run) {
115
+ if (!run || !this.onChange)
116
+ return;
117
+ void Promise.resolve()
118
+ .then(() => this.onChange(run))
119
+ .catch((err) => console.error(`[loopengine] reporting run ${run.run_id} failed:`, err));
120
+ }
121
+ /** Records a run and starts it; returns at once with the record. */
122
+ async start(input) {
123
+ const runId = randomUUID();
124
+ const run = {
125
+ run_id: runId,
126
+ agent: input.agent,
127
+ workflow_id: input.workflow_id,
128
+ ...(input.params && Object.keys(input.params).length ? { params: input.params } : {}),
129
+ source: input.source,
130
+ ...(input.notify ? { notify: true } : {}),
131
+ ...(input.callback_url ? { callback_url: input.callback_url } : {}),
132
+ ...(input.trigger_id ? { trigger_id: input.trigger_id } : {}),
133
+ status: 'running',
134
+ started_at: new Date().toISOString(),
135
+ message: input.message,
136
+ session_id: `${SESSION_PREFIX}${runId.slice(0, 8)}-${Date.now().toString(36)}`,
137
+ };
138
+ await getRunStore().put(run);
139
+ await this.prune(input.agent);
140
+ const job = this.run(run).finally(() => this.inFlight.delete(job));
141
+ this.inFlight.add(job);
142
+ return run;
143
+ }
144
+ /** A schedule firing one of its agent's workflows (the scheduler's fire,
145
+ * for a workflow task): recorded as a run of the workflow, then run the
146
+ * usual way. Returns the outcome for the scheduler, and throws what the
147
+ * run threw, so the schedule's own history records it too. */
148
+ async runForSchedule(input) {
149
+ const run = {
150
+ run_id: randomUUID(),
151
+ agent: input.agent,
152
+ workflow_id: input.workflow_id,
153
+ ...(input.params && Object.keys(input.params).length ? { params: input.params } : {}),
154
+ source: 'schedule',
155
+ task_id: input.task_id,
156
+ ...(input.task_name ? { task_name: input.task_name } : {}),
157
+ status: 'running',
158
+ started_at: new Date().toISOString(),
159
+ message: input.message,
160
+ session_id: input.session_id,
161
+ };
162
+ await getRunStore().put(run);
163
+ await this.prune(input.agent);
164
+ try {
165
+ const outcome = await this.execute(run.agent, run.message, run.session_id);
166
+ if (outcome.kind === 'pending')
167
+ this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
168
+ else
169
+ await this.finish(run.run_id, { status: 'done', result: outcome.text });
170
+ return outcome;
171
+ }
172
+ catch (err) {
173
+ await this.finish(run.run_id, { status: 'error', error: err instanceof Error ? err.message : String(err) });
174
+ throw err;
175
+ }
176
+ }
177
+ /** Waits for the runs this process has started (tests, shutdown). */
178
+ async idle() {
179
+ await Promise.all([...this.inFlight]);
180
+ }
181
+ /** How many runs are executing right now (for idle-shutdown checks). */
182
+ get running() {
183
+ return this.inFlight.size;
184
+ }
185
+ async run(run) {
186
+ try {
187
+ const outcome = await this.execute(run.agent, run.message, run.session_id);
188
+ if (outcome.kind === 'pending') {
189
+ this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
190
+ return;
191
+ }
192
+ await this.finish(run.run_id, { status: 'done', result: outcome.text });
193
+ }
194
+ catch (err) {
195
+ await this.finish(run.run_id, { status: 'error', error: err instanceof Error ? err.message : String(err) });
196
+ }
197
+ }
198
+ async finish(runId, end) {
199
+ this.changed(await getRunStore().update(runId, (r) => r && { ...r, ...end, pending_ids: undefined, finished_at: new Date().toISOString() }));
200
+ }
201
+ /** A paused run resumed after a decision (the server calls this from
202
+ * its resolve path, for every resumed session): settle it, or record
203
+ * what it's waiting on now. */
204
+ async settleFromResume(sessionId, outcome) {
205
+ if (!sessionId.startsWith(SESSION_PREFIX) && !sessionId.startsWith(SCHEDULE_SESSION_PREFIX))
206
+ return;
207
+ const run = (await getRunStore().list()).find((r) => r.session_id === sessionId);
208
+ if (!run)
209
+ return;
210
+ if (outcome.pendingIds?.length) {
211
+ this.changed(await getRunStore().update(run.run_id, (r) => r && { ...r, status: 'awaiting_approval', pending_ids: outcome.pendingIds }));
212
+ return;
213
+ }
214
+ await this.finish(run.run_id, { status: 'done', result: outcome.text });
215
+ }
216
+ /** On startup: a run a restart cut off can't continue, so it's an
217
+ * error. One waiting on a decision is kept: its checkpoint survives. */
218
+ async recover() {
219
+ for (const run of await getRunStore().list()) {
220
+ if (run.status === 'running')
221
+ await this.finish(run.run_id, { status: 'error', error: 'The server restarted while this run was in progress.' });
222
+ }
223
+ }
224
+ async prune(agent) {
225
+ const finished = (await listRuns(agent)).filter((r) => !isUnderWay(r));
226
+ for (const old of finished.slice(this.maxFinishedPerAgent))
227
+ await getRunStore().remove(old.run_id);
228
+ }
229
+ }
@@ -0,0 +1,130 @@
1
+ import type { Workflow } from './workflows.js';
2
+ /** Whose signature scheme a trigger checks. "standard" is Standard
3
+ * Webhooks (Svix: used by Clerk, Resend, OpenAI and others); "custom" is
4
+ * an HMAC of the raw body in a header the sender names (Typeform, Linear,
5
+ * Sentry, Intercom and most others that sign the body). */
6
+ export type TriggerSender = 'stripe' | 'github' | 'shopify' | 'standard' | 'custom';
7
+ export type SignatureAlgorithm = 'sha256' | 'sha1' | 'sha512';
8
+ export interface TriggerVerify {
9
+ sender: TriggerSender;
10
+ /** The signing secret the sender gives (Stripe's whsec_…, GitHub's,
11
+ * Shopify's, a Standard Webhooks whsec_…, or a custom one). */
12
+ secret: string;
13
+ /** custom only: the header the signature is in, e.g. "Linear-Signature". */
14
+ header?: string;
15
+ /** custom only: the HMAC's hash (default sha256). */
16
+ algorithm?: SignatureAlgorithm;
17
+ /** custom only: how the signature is written (default hex). */
18
+ encoding?: 'hex' | 'base64';
19
+ /** custom only: text before the signature, e.g. "sha256=". */
20
+ prefix?: string;
21
+ }
22
+ export interface TriggerFilter {
23
+ /** Where in the payload, as a dot path: "type", "action", "data.object.status". */
24
+ path: string;
25
+ /** Run only when the value there is one of these. */
26
+ values: string[];
27
+ }
28
+ export type TriggerOutcome = 'started' | 'filtered' | 'duplicate' | 'busy' | 'rejected' | 'invalid' | 'error';
29
+ export interface TriggerDelivery {
30
+ at: string;
31
+ outcome: TriggerOutcome;
32
+ delivery_id?: string;
33
+ run_id?: string;
34
+ detail?: string;
35
+ }
36
+ export interface WorkflowTrigger {
37
+ trigger_id: string;
38
+ agent: string;
39
+ workflow_id: string;
40
+ name: string;
41
+ /** SHA-256 of the URL's token. */
42
+ token_hash: string;
43
+ /** The token's start, to tell triggers apart: "whk_8f3k…". */
44
+ token_hint: string;
45
+ enabled: boolean;
46
+ created_at: string;
47
+ verify?: TriggerVerify;
48
+ filter?: TriggerFilter;
49
+ /** Workflow parameter → dot path in the payload. */
50
+ mapping?: Record<string, string>;
51
+ /** A parameter that gets the whole payload, as JSON. */
52
+ payload_param?: string;
53
+ notify?: boolean;
54
+ callback_url?: string;
55
+ /** The latest deliveries, newest first (20). */
56
+ deliveries: TriggerDelivery[];
57
+ /** Delivery ids already handled (the latest 200), for retries. */
58
+ seen_ids: string[];
59
+ }
60
+ /** A trigger as the console and API show it: no token hash, no secret. */
61
+ export type PublicTrigger = Omit<WorkflowTrigger, 'token_hash' | 'verify' | 'seen_ids'> & {
62
+ verify?: Omit<TriggerVerify, 'secret'>;
63
+ };
64
+ export declare class TriggerError extends Error {
65
+ }
66
+ export interface TriggerStore {
67
+ list(): Promise<WorkflowTrigger[]>;
68
+ get(id: string): Promise<WorkflowTrigger | undefined>;
69
+ put(trigger: WorkflowTrigger): Promise<void>;
70
+ remove(id: string): Promise<void>;
71
+ update(id: string, fn: (t: WorkflowTrigger | undefined) => WorkflowTrigger | undefined): Promise<WorkflowTrigger | undefined>;
72
+ }
73
+ /** One JSON file per trigger under `<dir>/triggers/` — one server process. */
74
+ export declare class FileTriggerStore implements TriggerStore {
75
+ private readonly dir;
76
+ constructor(dir: string);
77
+ private file;
78
+ list(): Promise<WorkflowTrigger[]>;
79
+ get(id: string): Promise<WorkflowTrigger | undefined>;
80
+ put(trigger: WorkflowTrigger): Promise<void>;
81
+ remove(id: string): Promise<void>;
82
+ update(id: string, fn: (t: WorkflowTrigger | undefined) => WorkflowTrigger | undefined): Promise<WorkflowTrigger | undefined>;
83
+ }
84
+ export declare function getTriggerStore(): TriggerStore;
85
+ export declare function setTriggerStore(next: TriggerStore): void;
86
+ export declare function publicTrigger(t: WorkflowTrigger): PublicTrigger;
87
+ /** The settings a trigger can have, checked against its workflow. */
88
+ export interface TriggerSettings {
89
+ name?: unknown;
90
+ enabled?: unknown;
91
+ verify?: unknown;
92
+ filter?: unknown;
93
+ mapping?: unknown;
94
+ payload_param?: unknown;
95
+ notify?: unknown;
96
+ callback_url?: unknown;
97
+ }
98
+ /** Checks settings for a trigger of `workflow`; throws TriggerError saying
99
+ * what's wrong. `existing` keeps a sender's secret when only the sender
100
+ * is given again (the console never sees the secret). */
101
+ export declare function checkTriggerSettings(input: TriggerSettings, workflow: Workflow, existing?: WorkflowTrigger): Pick<WorkflowTrigger, 'name' | 'enabled' | 'verify' | 'filter' | 'mapping' | 'payload_param' | 'notify' | 'callback_url'>;
102
+ /** Makes a trigger for a workflow; returns it with its token (shown once). */
103
+ export declare function createTrigger(agent: string, workflow: Workflow, input: TriggerSettings): Promise<{
104
+ trigger: WorkflowTrigger;
105
+ token: string;
106
+ }>;
107
+ export declare function updateTrigger(id: string, workflow: Workflow, input: TriggerSettings): Promise<WorkflowTrigger>;
108
+ /** A new URL for a trigger; the old one stops working. */
109
+ export declare function rotateTrigger(id: string): Promise<{
110
+ trigger: WorkflowTrigger;
111
+ token: string;
112
+ }>;
113
+ export declare function listTriggers(agent: string, workflowId?: string): Promise<WorkflowTrigger[]>;
114
+ /** The trigger a URL's token belongs to. */
115
+ export declare function findTriggerByToken(token: string): Promise<WorkflowTrigger | undefined>;
116
+ /** Whether the sender's own signature on the raw body is right. */
117
+ export declare function verifySenderSignature(verify: TriggerVerify, headers: Record<string, string | string[] | undefined>, raw: Buffer, nowS?: number): boolean;
118
+ /** The payload: JSON, a form, or plain text (as { body }). */
119
+ export declare function parsePayload(raw: Buffer, contentType: string | undefined): unknown;
120
+ /** The value at a dot path: "data.object.id", "items.0.sku". */
121
+ export declare function valueAt(payload: unknown, dotPath: string): unknown;
122
+ /** The sender's id for this delivery, if it gives one: what a retry
123
+ * repeats. */
124
+ export declare function deliveryId(headers: Record<string, string | string[] | undefined>, payload: unknown): string | undefined;
125
+ /** Whether the filter keeps this payload. */
126
+ export declare function passesFilter(filter: TriggerFilter | undefined, payload: unknown): boolean;
127
+ /** The workflow's parameters from the payload. */
128
+ export declare function paramsFromPayload(trigger: Pick<WorkflowTrigger, 'mapping' | 'payload_param'>, payload: unknown): Record<string, string>;
129
+ /** Notes a delivery on its trigger, and its id as handled. */
130
+ export declare function recordDelivery(id: string, delivery: TriggerDelivery, seenId?: string): Promise<void>;
@@ -0,0 +1,361 @@
1
+ // Webhook triggers: a secret URL per workflow that an outside service
2
+ // (Stripe, GitHub, Shopify, a form or monitoring tool) can post its own
3
+ // events to, to start a run, with no API key and no body format of ours:
4
+ //
5
+ // POST /hooks/workflows/<token>
6
+ //
7
+ // The token in the URL is the credential (it's kept here only as a
8
+ // SHA-256 hash, so the full URL is shown once, when it's made or
9
+ // rotated). A trigger can also check the sender's own signature, keep
10
+ // only some events (filter), and fill the workflow's parameters from the
11
+ // payload (mapping, or the whole payload as one parameter). A delivery a
12
+ // sender retries is recognised by its id and starts no second run.
13
+ //
14
+ // Stored like runs and schedules: one JSON file per trigger under
15
+ // `<LOOPENGINE_SCHEDULER_DIR or .scheduler>/triggers/`.
16
+ import { createHash, createHmac, randomBytes, randomUUID, timingSafeEqual } 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
+ import { checkCallbackUrl } from './run-callbacks.js';
21
+ export class TriggerError extends Error {
22
+ }
23
+ const MAX_DELIVERIES = 20;
24
+ const MAX_SEEN = 200;
25
+ const MAX_PAYLOAD_PARAM = 20_000;
26
+ const SENDERS = ['stripe', 'github', 'shopify', 'standard', 'custom'];
27
+ const ALGORITHMS = ['sha256', 'sha1', 'sha512'];
28
+ /** How old a signed timestamp (Stripe, Standard Webhooks) may be. */
29
+ const SIGNATURE_TOLERANCE_S = 300;
30
+ const fileMutex = new KeyedMutex();
31
+ /** One JSON file per trigger under `<dir>/triggers/` — one server process. */
32
+ export class FileTriggerStore {
33
+ dir;
34
+ constructor(dir) {
35
+ this.dir = path.join(dir, 'triggers');
36
+ }
37
+ file(id) {
38
+ if (!/^[A-Za-z0-9_-]+$/.test(id))
39
+ throw new TriggerError(`invalid trigger id "${id}"`);
40
+ return path.join(this.dir, `${id}.json`);
41
+ }
42
+ async list() {
43
+ if (!existsSync(this.dir))
44
+ return [];
45
+ const triggers = [];
46
+ for (const name of readdirSync(this.dir)) {
47
+ if (!name.endsWith('.json'))
48
+ continue;
49
+ try {
50
+ triggers.push(JSON.parse(readFileSync(path.join(this.dir, name), 'utf8')));
51
+ }
52
+ catch {
53
+ // A file being written, or not a trigger: skip it.
54
+ }
55
+ }
56
+ return triggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
57
+ }
58
+ async get(id) {
59
+ const file = this.file(id);
60
+ return existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : undefined;
61
+ }
62
+ async put(trigger) {
63
+ mkdirSync(this.dir, { recursive: true });
64
+ const file = this.file(trigger.trigger_id);
65
+ writeFileSync(file + '.tmp', JSON.stringify(trigger, null, 2), { mode: 0o600 });
66
+ renameSync(file + '.tmp', file);
67
+ }
68
+ async remove(id) {
69
+ rmSync(this.file(id), { force: true });
70
+ }
71
+ async update(id, fn) {
72
+ return fileMutex.run(id, async () => {
73
+ const next = fn(await this.get(id));
74
+ if (next)
75
+ await this.put(next);
76
+ return next;
77
+ });
78
+ }
79
+ }
80
+ let store;
81
+ export function getTriggerStore() {
82
+ store ??= new FileTriggerStore(process.env.LOOPENGINE_SCHEDULER_DIR || path.join(process.cwd(), '.scheduler'));
83
+ return store;
84
+ }
85
+ export function setTriggerStore(next) {
86
+ store = next;
87
+ }
88
+ // ---------- making and finding ----------
89
+ function hashToken(token) {
90
+ return createHash('sha256').update(token).digest('hex');
91
+ }
92
+ function newToken() {
93
+ return `whk_${randomBytes(24).toString('base64url')}`;
94
+ }
95
+ export function publicTrigger(t) {
96
+ const { token_hash: _hash, verify, seen_ids: _seen, ...rest } = t;
97
+ if (!verify)
98
+ return rest;
99
+ const { secret: _secret, ...shown } = verify;
100
+ return { ...rest, verify: shown };
101
+ }
102
+ /** Checks settings for a trigger of `workflow`; throws TriggerError saying
103
+ * what's wrong. `existing` keeps a sender's secret when only the sender
104
+ * is given again (the console never sees the secret). */
105
+ export function checkTriggerSettings(input, workflow, existing) {
106
+ const name = typeof input.name === 'string' && input.name.trim() ? input.name.trim().slice(0, 80) : existing?.name ?? 'Webhook';
107
+ if (input.enabled !== undefined && typeof input.enabled !== 'boolean')
108
+ throw new TriggerError('enabled must be true or false');
109
+ let verify;
110
+ if (input.verify !== undefined && input.verify !== null) {
111
+ const v = input.verify;
112
+ if (typeof v !== 'object' || !SENDERS.includes(v.sender))
113
+ throw new TriggerError(`verify.sender must be one of ${SENDERS.join(', ')}`);
114
+ const kept = existing?.verify && existing.verify.sender === v.sender ? existing.verify.secret : '';
115
+ const secret = typeof v.secret === 'string' && v.secret.trim() ? v.secret.trim() : kept;
116
+ if (!secret)
117
+ throw new TriggerError(`Give the signing secret ${v.sender === 'custom' ? 'the sender' : v.sender} shows for this webhook, so its signature can be checked.`);
118
+ verify = { sender: v.sender, secret };
119
+ if (v.sender === 'standard' && !/^(whsec_)?[A-Za-z0-9+/]+=*$/.test(secret))
120
+ throw new TriggerError('A Standard Webhooks secret looks like "whsec_" followed by base64 text.');
121
+ if (v.sender === 'custom') {
122
+ const headerName = typeof v.header === 'string' ? v.header.trim() : '';
123
+ if (!/^[A-Za-z0-9-]{1,80}$/.test(headerName))
124
+ throw new TriggerError('Give the header the signature arrives in, e.g. "Linear-Signature" (letters, digits and dashes).');
125
+ const algorithm = (v.algorithm ?? 'sha256');
126
+ if (!ALGORITHMS.includes(algorithm))
127
+ throw new TriggerError(`algorithm must be one of ${ALGORITHMS.join(', ')}`);
128
+ const encoding = v.encoding ?? 'hex';
129
+ if (encoding !== 'hex' && encoding !== 'base64')
130
+ throw new TriggerError('encoding must be hex or base64');
131
+ const prefix = typeof v.prefix === 'string' ? v.prefix.trim() : '';
132
+ if (prefix.length > 20)
133
+ throw new TriggerError('prefix is too long: keep it under 20 characters, e.g. "sha256="');
134
+ verify = { ...verify, header: headerName.toLowerCase(), algorithm, encoding, ...(prefix ? { prefix } : {}) };
135
+ }
136
+ }
137
+ let filter;
138
+ if (input.filter !== undefined && input.filter !== null) {
139
+ const f = input.filter;
140
+ const values = Array.isArray(f.values) ? f.values.filter((x) => typeof x === 'string' && x.trim()).map((x) => x.trim()) : [];
141
+ if (typeof f.path !== 'string' || !f.path.trim() || !values.length)
142
+ throw new TriggerError('filter needs a path, e.g. "type", and at least one value, e.g. ["charge.dispute.created"]');
143
+ filter = { path: f.path.trim(), values };
144
+ }
145
+ const declared = Object.keys(workflow.parameters);
146
+ let mapping;
147
+ if (input.mapping !== undefined && input.mapping !== null) {
148
+ if (typeof input.mapping !== 'object' || Array.isArray(input.mapping))
149
+ throw new TriggerError('mapping must be an object of parameter names to payload paths');
150
+ mapping = {};
151
+ for (const [param, p] of Object.entries(input.mapping)) {
152
+ if (typeof p !== 'string' || !p.trim())
153
+ continue;
154
+ if (!declared.includes(param))
155
+ throw new TriggerError(`mapping: ${workflow.title} has no parameter "${param}"${declared.length ? ` (its parameters: ${declared.join(', ')})` : ''}`);
156
+ mapping[param] = p.trim();
157
+ }
158
+ if (!Object.keys(mapping).length)
159
+ mapping = undefined;
160
+ }
161
+ let payloadParam;
162
+ if (typeof input.payload_param === 'string' && input.payload_param) {
163
+ if (!declared.includes(input.payload_param))
164
+ throw new TriggerError(`payload_param: ${workflow.title} has no parameter "${input.payload_param}"`);
165
+ payloadParam = input.payload_param;
166
+ }
167
+ if (input.notify !== undefined && typeof input.notify !== 'boolean')
168
+ throw new TriggerError('notify must be true or false');
169
+ const callbackUrl = input.callback_url ? checkCallbackUrl(input.callback_url) : undefined;
170
+ return {
171
+ name,
172
+ enabled: input.enabled === undefined ? existing?.enabled ?? true : input.enabled,
173
+ ...(verify ? { verify } : {}),
174
+ ...(filter ? { filter } : {}),
175
+ ...(mapping ? { mapping } : {}),
176
+ ...(payloadParam ? { payload_param: payloadParam } : {}),
177
+ ...(input.notify === true ? { notify: true } : {}),
178
+ ...(callbackUrl ? { callback_url: callbackUrl } : {}),
179
+ };
180
+ }
181
+ /** Makes a trigger for a workflow; returns it with its token (shown once). */
182
+ export async function createTrigger(agent, workflow, input) {
183
+ const settings = checkTriggerSettings(input, workflow);
184
+ const token = newToken();
185
+ const trigger = {
186
+ trigger_id: randomUUID(),
187
+ agent,
188
+ workflow_id: workflow.id,
189
+ ...settings,
190
+ token_hash: hashToken(token),
191
+ token_hint: `${token.slice(0, 8)}…`,
192
+ created_at: new Date().toISOString(),
193
+ deliveries: [],
194
+ seen_ids: [],
195
+ };
196
+ await getTriggerStore().put(trigger);
197
+ return { trigger, token };
198
+ }
199
+ export async function updateTrigger(id, workflow, input) {
200
+ const existing = await getTriggerStore().get(id);
201
+ if (!existing)
202
+ throw new TriggerError(`no trigger ${id}`);
203
+ const settings = checkTriggerSettings(input, workflow, existing);
204
+ const { verify: _v, filter: _f, mapping: _m, payload_param: _p, notify: _n, callback_url: _c, ...kept } = existing;
205
+ const next = { ...kept, ...settings };
206
+ await getTriggerStore().put(next);
207
+ return next;
208
+ }
209
+ /** A new URL for a trigger; the old one stops working. */
210
+ export async function rotateTrigger(id) {
211
+ const token = newToken();
212
+ const trigger = await getTriggerStore().update(id, (t) => t && { ...t, token_hash: hashToken(token), token_hint: `${token.slice(0, 8)}…` });
213
+ if (!trigger)
214
+ throw new TriggerError(`no trigger ${id}`);
215
+ return { trigger, token };
216
+ }
217
+ export async function listTriggers(agent, workflowId) {
218
+ return (await getTriggerStore().list()).filter((t) => t.agent === agent && (!workflowId || t.workflow_id === workflowId));
219
+ }
220
+ /** The trigger a URL's token belongs to. */
221
+ export async function findTriggerByToken(token) {
222
+ if (!/^whk_[A-Za-z0-9_-]{16,}$/.test(token))
223
+ return undefined;
224
+ const hash = Buffer.from(hashToken(token), 'hex');
225
+ return (await getTriggerStore().list()).find((t) => {
226
+ const other = Buffer.from(t.token_hash, 'hex');
227
+ return other.length === hash.length && timingSafeEqual(other, hash);
228
+ });
229
+ }
230
+ // ---------- a delivery ----------
231
+ const hmacHex = (secret, data) => createHmac('sha256', secret).update(data).digest('hex');
232
+ const sameText = (a, b) => {
233
+ const x = Buffer.from(a);
234
+ const y = Buffer.from(b);
235
+ return x.length === y.length && timingSafeEqual(x, y);
236
+ };
237
+ const header = (headers, name) => {
238
+ const v = headers[name];
239
+ return Array.isArray(v) ? v[0] : v;
240
+ };
241
+ /** Whether the sender's own signature on the raw body is right. */
242
+ export function verifySenderSignature(verify, headers, raw, nowS = Math.floor(Date.now() / 1000)) {
243
+ if (verify.sender === 'stripe') {
244
+ const parts = (header(headers, 'stripe-signature') ?? '').split(',').map((p) => p.split('='));
245
+ const t = Number(parts.find(([k]) => k === 't')?.[1]);
246
+ if (!Number.isFinite(t) || Math.abs(nowS - t) > SIGNATURE_TOLERANCE_S)
247
+ return false;
248
+ const expected = hmacHex(verify.secret, Buffer.concat([Buffer.from(`${t}.`), raw]));
249
+ return parts.some(([k, v]) => k === 'v1' && typeof v === 'string' && sameText(v, expected));
250
+ }
251
+ if (verify.sender === 'github') {
252
+ const sig = header(headers, 'x-hub-signature-256') ?? '';
253
+ return sig.startsWith('sha256=') && sameText(sig.slice(7), hmacHex(verify.secret, raw));
254
+ }
255
+ if (verify.sender === 'shopify') {
256
+ const sig = header(headers, 'x-shopify-hmac-sha256') ?? '';
257
+ return Boolean(sig) && sameText(sig, createHmac('sha256', verify.secret).update(raw).digest('base64'));
258
+ }
259
+ if (verify.sender === 'standard') {
260
+ // webhook-* headers, or Svix's own svix-* names for the same thing.
261
+ const id = header(headers, 'webhook-id') ?? header(headers, 'svix-id');
262
+ const t = Number(header(headers, 'webhook-timestamp') ?? header(headers, 'svix-timestamp'));
263
+ const sigs = header(headers, 'webhook-signature') ?? header(headers, 'svix-signature') ?? '';
264
+ if (!id || !Number.isFinite(t) || Math.abs(nowS - t) > SIGNATURE_TOLERANCE_S)
265
+ return false;
266
+ const key = Buffer.from(verify.secret.replace(/^whsec_/, ''), 'base64');
267
+ const expected = createHmac('sha256', key).update(Buffer.concat([Buffer.from(`${id}.${t}.`), raw])).digest('base64');
268
+ return sigs.split(' ').some((part) => {
269
+ const [version, sig] = part.split(',');
270
+ return version === 'v1' && typeof sig === 'string' && sameText(sig, expected);
271
+ });
272
+ }
273
+ // custom: an HMAC of the raw body, in the header the sender names.
274
+ const got = (header(headers, verify.header ?? '') ?? '').trim();
275
+ const prefix = verify.prefix ?? '';
276
+ if (!got || (prefix && !got.startsWith(prefix)))
277
+ return false;
278
+ const expected = createHmac(verify.algorithm ?? 'sha256', verify.secret).update(raw).digest(verify.encoding ?? 'hex');
279
+ const value = got.slice(prefix.length);
280
+ return verify.encoding === 'base64' ? sameText(value, expected) : sameText(value.toLowerCase(), expected);
281
+ }
282
+ /** The payload: JSON, a form, or plain text (as { body }). */
283
+ export function parsePayload(raw, contentType) {
284
+ const text = raw.toString('utf8');
285
+ if (/application\/x-www-form-urlencoded/i.test(contentType ?? '')) {
286
+ const form = Object.fromEntries(new URLSearchParams(text));
287
+ // Slack-style and some tools put JSON in a "payload" field.
288
+ if (typeof form.payload === 'string') {
289
+ try {
290
+ return JSON.parse(form.payload);
291
+ }
292
+ catch {
293
+ // Leave it as a form.
294
+ }
295
+ }
296
+ return form;
297
+ }
298
+ try {
299
+ return text.trim() ? JSON.parse(text) : {};
300
+ }
301
+ catch {
302
+ return { body: text };
303
+ }
304
+ }
305
+ /** The value at a dot path: "data.object.id", "items.0.sku". */
306
+ export function valueAt(payload, dotPath) {
307
+ let value = payload;
308
+ for (const key of dotPath.split('.')) {
309
+ if (value === null || value === undefined || typeof value !== 'object')
310
+ return undefined;
311
+ value = value[key];
312
+ }
313
+ return value;
314
+ }
315
+ function asText(value) {
316
+ if (value === undefined || value === null)
317
+ return undefined;
318
+ if (typeof value === 'string')
319
+ return value;
320
+ if (typeof value === 'number' || typeof value === 'boolean')
321
+ return String(value);
322
+ return JSON.stringify(value);
323
+ }
324
+ /** The sender's id for this delivery, if it gives one: what a retry
325
+ * repeats. */
326
+ export function deliveryId(headers, payload) {
327
+ for (const name of ['x-github-delivery', 'x-shopify-webhook-id', 'idempotency-key', 'x-delivery-id', 'x-request-id']) {
328
+ const v = header(headers, name);
329
+ if (v)
330
+ return v.slice(0, 200);
331
+ }
332
+ const id = valueAt(payload, 'id');
333
+ return typeof id === 'string' && id ? id.slice(0, 200) : undefined;
334
+ }
335
+ /** Whether the filter keeps this payload. */
336
+ export function passesFilter(filter, payload) {
337
+ if (!filter)
338
+ return true;
339
+ const value = asText(valueAt(payload, filter.path));
340
+ return value !== undefined && filter.values.includes(value);
341
+ }
342
+ /** The workflow's parameters from the payload. */
343
+ export function paramsFromPayload(trigger, payload) {
344
+ const params = {};
345
+ for (const [param, dotPath] of Object.entries(trigger.mapping ?? {})) {
346
+ const value = asText(valueAt(payload, dotPath));
347
+ if (value !== undefined && value !== '')
348
+ params[param] = value.slice(0, MAX_PAYLOAD_PARAM);
349
+ }
350
+ if (trigger.payload_param)
351
+ params[trigger.payload_param] = JSON.stringify(payload).slice(0, MAX_PAYLOAD_PARAM);
352
+ return params;
353
+ }
354
+ /** Notes a delivery on its trigger, and its id as handled. */
355
+ export async function recordDelivery(id, delivery, seenId) {
356
+ await getTriggerStore().update(id, (t) => t && {
357
+ ...t,
358
+ deliveries: [delivery, ...t.deliveries].slice(0, MAX_DELIVERIES),
359
+ seen_ids: seenId ? [...t.seen_ids.filter((s) => s !== seenId), seenId].slice(-MAX_SEEN) : t.seen_ids,
360
+ });
361
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loopengine",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "type": "module",
5
5
  "description": "Runtime for tool-using AI agents with a transparent ReAct loop.",
6
6
  "license": "MIT",
@@ -124,6 +124,12 @@
124
124
  "dist/core/durable-approvals.d.ts",
125
125
  "dist/core/workflows.js",
126
126
  "dist/core/workflows.d.ts",
127
+ "dist/core/workflow-runs.js",
128
+ "dist/core/workflow-runs.d.ts",
129
+ "dist/core/run-callbacks.js",
130
+ "dist/core/run-callbacks.d.ts",
131
+ "dist/core/workflow-triggers.js",
132
+ "dist/core/workflow-triggers.d.ts",
127
133
  "dist/web",
128
134
  "protocol/loop-event.schema.json",
129
135
  "PROTOCOL.md",