loopengine 0.2.2 → 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.
- package/README.md +1 -1
- package/dist/core/run-agent.js +11 -2
- package/dist/core/run-callbacks.d.ts +39 -0
- package/dist/core/run-callbacks.js +125 -0
- package/dist/core/scheduler.d.ts +29 -1
- package/dist/core/scheduler.js +28 -4
- package/dist/core/skillgarden/loader.d.ts +5 -1
- package/dist/core/skillgarden/loader.js +10 -3
- package/dist/core/skillgarden/substitute.d.ts +15 -1
- package/dist/core/skillgarden/substitute.js +28 -6
- package/dist/core/system-agents/templates.d.ts +5 -0
- package/dist/core/system-agents/workflow-planner.d.ts +26 -1
- package/dist/core/system-agents/workflow-planner.js +57 -5
- package/dist/core/system-tools/schedule.d.ts +5 -1
- package/dist/core/system-tools/schedule.js +45 -10
- package/dist/core/workflow-runs.d.ts +131 -0
- package/dist/core/workflow-runs.js +229 -0
- package/dist/core/workflow-triggers.d.ts +130 -0
- package/dist/core/workflow-triggers.js +361 -0
- package/dist/core/workflows.d.ts +52 -23
- package/dist/core/workflows.js +218 -27
- package/dist/index.d.ts +7 -4
- package/dist/index.js +7 -4
- package/dist/web/agents-config-page.js +394 -22
- package/dist/web/skills-admin.d.ts +4 -8
- package/dist/web/skills-admin.js +8 -12
- package/dist/web/templates-admin.js +7 -2
- package/dist/web/workflow-admin.d.ts +43 -18
- package/dist/web/workflow-admin.js +117 -50
- package/dist/web/workflow-page.js +72 -0
- package/package.json +7 -1
|
@@ -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/dist/core/workflows.d.ts
CHANGED
|
@@ -1,26 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
export interface WorkflowParameter {
|
|
2
|
+
description: string;
|
|
3
|
+
required: boolean;
|
|
4
|
+
}
|
|
5
|
+
export interface Workflow {
|
|
6
|
+
/** The skill's id (its folder name). */
|
|
4
7
|
id: string;
|
|
5
|
-
name
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
kind: 'schedule' | 'on_request';
|
|
9
|
-
description: string;
|
|
10
|
-
};
|
|
11
|
-
steps: WorkflowStep[];
|
|
12
|
-
/** The message each run starts with. */
|
|
8
|
+
/** A name for people, e.g. "Refund check". */
|
|
9
|
+
title: string;
|
|
10
|
+
description: string;
|
|
13
11
|
startMessage: string;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
/** When it runs, in plain words: "Every Monday at 9", "When asked". */
|
|
13
|
+
trigger: string;
|
|
14
|
+
parameters: Record<string, WorkflowParameter>;
|
|
15
|
+
/** Its steps: the skill body, placeholders unfilled. */
|
|
16
|
+
steps: string;
|
|
17
|
+
}
|
|
18
|
+
/** The front matter that makes a skill a workflow. */
|
|
19
|
+
export interface WorkflowFields {
|
|
20
|
+
title?: string;
|
|
21
|
+
startMessage: string;
|
|
22
|
+
trigger?: string;
|
|
23
|
+
parameters?: Record<string, WorkflowParameter>;
|
|
19
24
|
}
|
|
20
|
-
export declare
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
export declare function
|
|
25
|
-
/**
|
|
26
|
-
export declare function
|
|
25
|
+
export declare class WorkflowParamsError extends Error {
|
|
26
|
+
}
|
|
27
|
+
/** Writes a skill file: its front matter (name and description first) and
|
|
28
|
+
* body. */
|
|
29
|
+
export declare function writeSkillFile(path: string, frontmatter: Record<string, unknown>, body: string): void;
|
|
30
|
+
/** The agent's workflows: its skills that have a start message. */
|
|
31
|
+
export declare function readWorkflows(agentDir: string): Workflow[];
|
|
32
|
+
export declare function readWorkflow(agentDir: string, id: string): Workflow | undefined;
|
|
33
|
+
/** Makes a skill a workflow, or changes how its runs start. The skill must
|
|
34
|
+
* exist; its body and other front matter are kept. */
|
|
35
|
+
export declare function setWorkflowFields(agentDir: string, id: string, fields: WorkflowFields): void;
|
|
36
|
+
/** Stops a skill being a workflow. The skill itself stays. */
|
|
37
|
+
export declare function clearWorkflowFields(agentDir: string, id: string): void;
|
|
38
|
+
/** Checks a run's parameters against what the workflow declares: every
|
|
39
|
+
* required one given, no unknown ones. Values are given as text (numbers
|
|
40
|
+
* and true/false are accepted and turned into text). */
|
|
41
|
+
export declare function validateParams(workflow: Workflow, input: unknown): Record<string, string>;
|
|
42
|
+
/** The message a run of the workflow starts with: its start message, then
|
|
43
|
+
* its steps, each $parameter filled in. Values no placeholder uses are
|
|
44
|
+
* listed as inputs, so the agent still gets them. */
|
|
45
|
+
export declare function workflowRunMessage(workflow: Workflow, params?: Record<string, string>): string;
|
|
46
|
+
export declare function legacyWorkflowsPath(agentDir: string): string;
|
|
47
|
+
/** Moves the workflows in an agent's workflows.yml into their skills'
|
|
48
|
+
* front matter, then renames the file to workflows.migrated.yml. Returns
|
|
49
|
+
* each moved workflow's id with the schedule name it was linked by, so
|
|
50
|
+
* the caller can point those schedules at the workflow. Does nothing
|
|
51
|
+
* when there is no workflows.yml. */
|
|
52
|
+
export declare function migrateWorkflowsFile(agentDir: string): {
|
|
53
|
+
id: string;
|
|
54
|
+
scheduleName?: string;
|
|
55
|
+
}[];
|