@forwardimpact/libbridge 0.1.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/LICENSE +201 -0
- package/README.md +24 -0
- package/package.json +58 -0
- package/src/acknowledgement.js +141 -0
- package/src/callback-handler.js +167 -0
- package/src/callback-payload.js +79 -0
- package/src/callback-registry.js +88 -0
- package/src/discussion-context.js +126 -0
- package/src/dispatch.js +66 -0
- package/src/dispatcher.js +128 -0
- package/src/elapsed-scheduler.js +82 -0
- package/src/history.js +14 -0
- package/src/index.js +38 -0
- package/src/progress-ticker.js +62 -0
- package/src/prompt.js +30 -0
- package/src/rate-limit.js +45 -0
- package/src/resume-scheduler.js +254 -0
- package/src/server.js +105 -0
- package/src/triggers.js +105 -0
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { ElapsedScheduler } from "./elapsed-scheduler.js";
|
|
2
|
+
import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROMPT = "Resume requested.";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Channel-agnostic suspend/resume lifecycle for the discuss-mode trace.
|
|
8
|
+
* When the workflow returns a `"recessed"` verdict, the bridge calls
|
|
9
|
+
* `enterRecess(...)` to persist the trigger on
|
|
10
|
+
* `ctx.open_rfcs[correlationId]`. The scheduler watches inbound activity
|
|
11
|
+
* via `processInbound(ctx)` and ticking elapsed timers via the embedded
|
|
12
|
+
* `ElapsedScheduler`; when a trigger fires, it redispatches the workflow
|
|
13
|
+
* through the shared `Dispatcher` with a `resume_context` payload
|
|
14
|
+
* linking back to the original correlation id.
|
|
15
|
+
*
|
|
16
|
+
* Channel-specific extras are supplied by two small constructor
|
|
17
|
+
* callbacks:
|
|
18
|
+
*
|
|
19
|
+
* buildCallbackMeta(ctx) -> { ... } // matches the bridge's loadDiscussionId
|
|
20
|
+
* buildResumeInputs(ctx) -> { ... } // extra workflow_dispatch inputs
|
|
21
|
+
*
|
|
22
|
+
* Both default to ghbridge's convention; msbridge overrides
|
|
23
|
+
* `buildCallbackMeta` to `{ threadId: ctx.discussion_id }` when it
|
|
24
|
+
* adopts resume.
|
|
25
|
+
*
|
|
26
|
+
* @example
|
|
27
|
+
* const scheduler = new ResumeScheduler({
|
|
28
|
+
* dispatcher,
|
|
29
|
+
* store,
|
|
30
|
+
* logger,
|
|
31
|
+
* buildCallbackMeta: (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
32
|
+
* buildResumeInputs: (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
33
|
+
* });
|
|
34
|
+
* // service start:
|
|
35
|
+
* await scheduler.rearm();
|
|
36
|
+
* // inbound activity:
|
|
37
|
+
* const { freshDispatchAllowed } = await scheduler.processInbound(ctx);
|
|
38
|
+
* if (freshDispatchAllowed) { ... dispatch fresh ... }
|
|
39
|
+
* // on "recessed" verdict:
|
|
40
|
+
* scheduler.enterRecess(ctx, correlationId, payload.trigger);
|
|
41
|
+
* // on "adjourned" / "failed":
|
|
42
|
+
* scheduler.cancelRecess(ctx, correlationId);
|
|
43
|
+
* // service stop:
|
|
44
|
+
* scheduler.clear();
|
|
45
|
+
*/
|
|
46
|
+
export class ResumeScheduler {
|
|
47
|
+
#dispatcher;
|
|
48
|
+
#store;
|
|
49
|
+
#logger;
|
|
50
|
+
#elapsed;
|
|
51
|
+
#prompt;
|
|
52
|
+
#buildResumeInputs;
|
|
53
|
+
#buildCallbackMeta;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {object} options
|
|
57
|
+
* @param {import("./dispatcher.js").Dispatcher} options.dispatcher
|
|
58
|
+
* @param {import("./discussion-context.js").DiscussionContextStore} options.store
|
|
59
|
+
* @param {{error?: Function, info?: Function}} [options.logger]
|
|
60
|
+
* @param {string} [options.prompt] - Default "Resume requested."
|
|
61
|
+
* @param {(ctx: object) => object} [options.buildCallbackMeta]
|
|
62
|
+
* @param {(ctx: object) => object} [options.buildResumeInputs]
|
|
63
|
+
*/
|
|
64
|
+
constructor({
|
|
65
|
+
dispatcher,
|
|
66
|
+
store,
|
|
67
|
+
logger,
|
|
68
|
+
prompt = DEFAULT_PROMPT,
|
|
69
|
+
buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
|
|
70
|
+
buildResumeInputs = () => ({}),
|
|
71
|
+
}) {
|
|
72
|
+
if (!dispatcher) throw new Error("dispatcher is required");
|
|
73
|
+
if (!store) throw new Error("store is required");
|
|
74
|
+
if (typeof buildCallbackMeta !== "function") {
|
|
75
|
+
throw new Error("buildCallbackMeta must be a function");
|
|
76
|
+
}
|
|
77
|
+
if (typeof buildResumeInputs !== "function") {
|
|
78
|
+
throw new Error("buildResumeInputs must be a function");
|
|
79
|
+
}
|
|
80
|
+
this.#dispatcher = dispatcher;
|
|
81
|
+
this.#store = store;
|
|
82
|
+
this.#logger = logger ?? null;
|
|
83
|
+
this.#prompt = prompt;
|
|
84
|
+
this.#buildCallbackMeta = buildCallbackMeta;
|
|
85
|
+
this.#buildResumeInputs = buildResumeInputs;
|
|
86
|
+
this.#elapsed = new ElapsedScheduler({
|
|
87
|
+
onFire: (cid) => this.#fireElapsed(cid),
|
|
88
|
+
onError: (err, cid) =>
|
|
89
|
+
this.#logger?.error?.("resume.elapsed", err, {
|
|
90
|
+
correlation_id: cid,
|
|
91
|
+
}),
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** @returns {number} Number of armed elapsed timers */
|
|
96
|
+
get size() {
|
|
97
|
+
return this.#elapsed.size;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Begin watching `correlationId` for trigger resolution. Persists the
|
|
102
|
+
* trigger and the history index at recess time onto
|
|
103
|
+
* `ctx.open_rfcs[correlationId]`. If the trigger has an elapsed
|
|
104
|
+
* component, schedules an in-memory timer that will fire even when no
|
|
105
|
+
* inbound activity arrives. No-op if `trigger` is falsy.
|
|
106
|
+
*
|
|
107
|
+
* The caller is responsible for flushing the store after this call —
|
|
108
|
+
* `enterRecess` mutates ctx but does not write.
|
|
109
|
+
*
|
|
110
|
+
* @param {object} ctx
|
|
111
|
+
* @param {string} correlationId
|
|
112
|
+
* @param {import("./triggers.js").ResumeTrigger} trigger
|
|
113
|
+
*/
|
|
114
|
+
enterRecess(ctx, correlationId, trigger) {
|
|
115
|
+
if (!trigger) return;
|
|
116
|
+
const openedAt = Date.now();
|
|
117
|
+
ctx.open_rfcs[correlationId] = {
|
|
118
|
+
trigger,
|
|
119
|
+
opened_at: openedAt,
|
|
120
|
+
history_index_at_open: ctx.history.length,
|
|
121
|
+
};
|
|
122
|
+
if (trigger.kind === "elapsed" || trigger.kind === "either") {
|
|
123
|
+
if (typeof trigger.elapsed === "string") {
|
|
124
|
+
const dueAt = openedAt + parseIsoDuration(trigger.elapsed);
|
|
125
|
+
ctx.open_rfcs[correlationId].due_at = dueAt;
|
|
126
|
+
this.#elapsed.schedule(correlationId, dueAt);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Stop watching `correlationId`. Removes the rfc from `ctx.open_rfcs`
|
|
133
|
+
* and cancels any associated elapsed timer. Idempotent — safe to call
|
|
134
|
+
* for verdicts that didn't actually recess.
|
|
135
|
+
*
|
|
136
|
+
* @param {object} ctx
|
|
137
|
+
* @param {string} correlationId
|
|
138
|
+
*/
|
|
139
|
+
cancelRecess(ctx, correlationId) {
|
|
140
|
+
delete ctx.open_rfcs[correlationId];
|
|
141
|
+
this.#elapsed.cancel(correlationId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Walk `ctx.open_rfcs`. For each rfc whose trigger has fired given the
|
|
146
|
+
* current history length and clock, redispatch the workflow with
|
|
147
|
+
* `resume_context` linking back to the original correlation id, then
|
|
148
|
+
* cancel the rfc. Returns a summary so the host can decide whether to
|
|
149
|
+
* additionally fire a fresh lead session on this inbound activity.
|
|
150
|
+
*
|
|
151
|
+
* If a redispatch fails the rfc remains armed — the host's failure
|
|
152
|
+
* recovery (next inbound activity, or the next elapsed tick) will
|
|
153
|
+
* retry.
|
|
154
|
+
*
|
|
155
|
+
* @param {object} ctx
|
|
156
|
+
* @returns {Promise<{
|
|
157
|
+
* fired: number,
|
|
158
|
+
* hasOpenRfc: boolean,
|
|
159
|
+
* freshDispatchAllowed: boolean,
|
|
160
|
+
* }>}
|
|
161
|
+
*/
|
|
162
|
+
async processInbound(ctx) {
|
|
163
|
+
const fired = this.#evaluate(ctx);
|
|
164
|
+
for (const { correlationId, rfc } of fired) {
|
|
165
|
+
const historySince = ctx.history.slice(rfc.history_index_at_open);
|
|
166
|
+
await this.#redispatch(ctx, correlationId, historySince);
|
|
167
|
+
this.cancelRecess(ctx, correlationId);
|
|
168
|
+
}
|
|
169
|
+
const hasOpenRfc = Object.keys(ctx.open_rfcs ?? {}).length > 0;
|
|
170
|
+
return {
|
|
171
|
+
fired: fired.length,
|
|
172
|
+
hasOpenRfc,
|
|
173
|
+
freshDispatchAllowed: fired.length === 0 && !hasOpenRfc,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Rehydrate persistent elapsed timers from the store. Call once after
|
|
179
|
+
* the bridge server starts so deadlines set before a restart still
|
|
180
|
+
* fire.
|
|
181
|
+
* @returns {Promise<void>}
|
|
182
|
+
*/
|
|
183
|
+
async rearm() {
|
|
184
|
+
if (!this.#store.loaded) await this.#store.loadData();
|
|
185
|
+
for (const record of this.#store.index.values()) {
|
|
186
|
+
const open = record?.open_rfcs;
|
|
187
|
+
if (!open) continue;
|
|
188
|
+
for (const [correlationId, rfc] of Object.entries(open)) {
|
|
189
|
+
if (typeof rfc.due_at === "number") {
|
|
190
|
+
this.#elapsed.schedule(correlationId, rfc.due_at);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Cancel every armed elapsed timer. Safe to call on shutdown. */
|
|
197
|
+
clear() {
|
|
198
|
+
this.#elapsed.clear();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
#evaluate(ctx) {
|
|
202
|
+
const fired = [];
|
|
203
|
+
for (const [correlationId, rfc] of Object.entries(ctx.open_rfcs ?? {})) {
|
|
204
|
+
const trigger = rfc.trigger;
|
|
205
|
+
if (!trigger) continue;
|
|
206
|
+
const observed = {
|
|
207
|
+
responses: ctx.history.length - rfc.history_index_at_open,
|
|
208
|
+
opened_at: rfc.opened_at,
|
|
209
|
+
};
|
|
210
|
+
if (evaluateTrigger(trigger, observed, Date.now()).fired) {
|
|
211
|
+
fired.push({ correlationId, rfc });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
return fired;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async #redispatch(ctx, correlationId, historySince) {
|
|
218
|
+
const resumeContext = JSON.stringify({
|
|
219
|
+
correlation_id: correlationId,
|
|
220
|
+
history_since: historySince,
|
|
221
|
+
});
|
|
222
|
+
await this.#dispatcher.dispatch({
|
|
223
|
+
ctx,
|
|
224
|
+
prompt: this.#prompt,
|
|
225
|
+
callbackMeta: this.#buildCallbackMeta(ctx),
|
|
226
|
+
workflowInputs: {
|
|
227
|
+
...this.#buildResumeInputs(ctx),
|
|
228
|
+
resumeContext,
|
|
229
|
+
},
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
async #fireElapsed(correlationId) {
|
|
234
|
+
const found = await this.#findContextWithRfc(correlationId);
|
|
235
|
+
if (!found) return;
|
|
236
|
+
const { ctx, rfc } = found;
|
|
237
|
+
const historySince = ctx.history.slice(rfc.history_index_at_open);
|
|
238
|
+
await this.#redispatch(ctx, correlationId, historySince);
|
|
239
|
+
this.cancelRecess(ctx, correlationId);
|
|
240
|
+
ctx.last_active_at = Date.now();
|
|
241
|
+
await this.#store.add(ctx);
|
|
242
|
+
await this.#store.flush();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
async #findContextWithRfc(correlationId) {
|
|
246
|
+
if (!this.#store.loaded) await this.#store.loadData();
|
|
247
|
+
for (const record of this.#store.index.values()) {
|
|
248
|
+
if (record?.open_rfcs?.[correlationId]) {
|
|
249
|
+
return { ctx: record, rfc: record.open_rfcs[correlationId] };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
return null;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/server.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { Hono } from "hono";
|
|
2
|
+
import { serve } from "@hono/node-server";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Create the channel-agnostic HTTP server that bridges (ghbridge, msbridge)
|
|
6
|
+
* share. The server mounts two routes:
|
|
7
|
+
* - `OPTIONS|POST <webhookPath>` — channel-specific intake. The raw POST
|
|
8
|
+
* body is captured on `c.get("rawBody")` for signature verification.
|
|
9
|
+
* - `POST /api/callback/:token` — workflow → bridge reply intake.
|
|
10
|
+
*
|
|
11
|
+
* Handlers receive Hono's context `c` (matching the monorepo standard) and
|
|
12
|
+
* return a `Response` (or use `c.json` / `c.text` / `c.body`). The caller
|
|
13
|
+
* owns lifecycle (start/stop). Returning the `app` exposes the underlying
|
|
14
|
+
* Hono instance so adapters can mount additional health or diagnostic
|
|
15
|
+
* routes. `address()` returns the bound `{ port }` once started (useful for
|
|
16
|
+
* tests that bind to port 0).
|
|
17
|
+
*
|
|
18
|
+
* @param {object} options
|
|
19
|
+
* @param {{host?: string, port: number}} options.config - host/port
|
|
20
|
+
* @param {object} options.logger
|
|
21
|
+
* @param {object} [options.tracer]
|
|
22
|
+
* @param {string} options.webhookPath - e.g. `/api/messages` or `/api/webhooks/github`
|
|
23
|
+
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onWebhook
|
|
24
|
+
* @param {(c: import("hono").Context) => Promise<Response> | Response} options.onCallback
|
|
25
|
+
* @returns {{ start: () => Promise<void>, stop: () => Promise<void>, app: import("hono").Hono, address: () => ({port: number} | null) }}
|
|
26
|
+
*/
|
|
27
|
+
export function createBridgeServer({
|
|
28
|
+
config,
|
|
29
|
+
logger,
|
|
30
|
+
tracer: _tracer,
|
|
31
|
+
webhookPath,
|
|
32
|
+
onWebhook,
|
|
33
|
+
onCallback,
|
|
34
|
+
}) {
|
|
35
|
+
if (!config) throw new Error("config is required");
|
|
36
|
+
if (!logger) throw new Error("logger is required");
|
|
37
|
+
if (!webhookPath) throw new Error("webhookPath is required");
|
|
38
|
+
if (typeof onWebhook !== "function") {
|
|
39
|
+
throw new Error("onWebhook is required");
|
|
40
|
+
}
|
|
41
|
+
if (typeof onCallback !== "function") {
|
|
42
|
+
throw new Error("onCallback is required");
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const app = new Hono();
|
|
46
|
+
|
|
47
|
+
// Capture the raw POST body once, before downstream handlers parse it.
|
|
48
|
+
// Channel adapters use this buffer to verify HMAC signatures.
|
|
49
|
+
app.use("*", async (c, next) => {
|
|
50
|
+
if (c.req.method === "POST") {
|
|
51
|
+
const buf = Buffer.from(await c.req.raw.clone().arrayBuffer());
|
|
52
|
+
c.set("rawBody", buf);
|
|
53
|
+
}
|
|
54
|
+
await next();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
app.options(webhookPath, (c) => c.body(null, 200));
|
|
58
|
+
|
|
59
|
+
app.post(webhookPath, async (c) => {
|
|
60
|
+
try {
|
|
61
|
+
return await onWebhook(c);
|
|
62
|
+
} catch (err) {
|
|
63
|
+
logger.error("bridge.webhook", err);
|
|
64
|
+
return c.json({ error: "Webhook failure" }, 500);
|
|
65
|
+
}
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
app.post("/api/callback/:token", async (c) => {
|
|
69
|
+
try {
|
|
70
|
+
return await onCallback(c);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
logger.error("bridge.callback", err);
|
|
73
|
+
return c.json({ error: "Callback failure" }, 500);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
let server = null;
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
app,
|
|
81
|
+
address() {
|
|
82
|
+
if (!server || typeof server.address !== "function") return null;
|
|
83
|
+
const addr = server.address();
|
|
84
|
+
if (!addr || typeof addr === "string") return null;
|
|
85
|
+
return { port: addr.port };
|
|
86
|
+
},
|
|
87
|
+
async start() {
|
|
88
|
+
const { host, port } = config;
|
|
89
|
+
await new Promise((resolve) => {
|
|
90
|
+
server = serve({ fetch: app.fetch, port, hostname: host }, (info) => {
|
|
91
|
+
logger.info("bridge.server", "listening", {
|
|
92
|
+
host,
|
|
93
|
+
port: info?.port ?? port,
|
|
94
|
+
});
|
|
95
|
+
resolve();
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
},
|
|
99
|
+
async stop() {
|
|
100
|
+
if (!server) return;
|
|
101
|
+
await new Promise((resolve) => server.close(() => resolve()));
|
|
102
|
+
server = null;
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/triggers.js
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @typedef {object} ResumeTrigger
|
|
3
|
+
* @property {"responses"|"elapsed"|"either"} kind
|
|
4
|
+
* @property {number} [responses] - Number of new responses needed.
|
|
5
|
+
* For `kind: "either"`, optional alongside `elapsed`.
|
|
6
|
+
* @property {string} [elapsed] - ISO-8601 duration, e.g. `"P14D"`, `"PT12H"`,
|
|
7
|
+
* `"P1DT6H"`. Required for `kind: "elapsed"`; optional for `"either"`.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const ISO_8601_DURATION =
|
|
11
|
+
/^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)?$/;
|
|
12
|
+
const MS_IN_SECOND = 1000;
|
|
13
|
+
const MS_IN_MINUTE = 60 * MS_IN_SECOND;
|
|
14
|
+
const MS_IN_HOUR = 60 * MS_IN_MINUTE;
|
|
15
|
+
const MS_IN_DAY = 24 * MS_IN_HOUR;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse an ISO-8601 duration into milliseconds. Supports the day/hour/
|
|
19
|
+
* minute/second subset used by the resume-trigger contract.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} duration - e.g. `"P14D"`, `"PT12H"`, `"P1DT6H"`, `"PT30M"`
|
|
22
|
+
* @returns {number} Duration in milliseconds
|
|
23
|
+
*/
|
|
24
|
+
export function parseIsoDuration(duration) {
|
|
25
|
+
if (typeof duration !== "string" || !duration) {
|
|
26
|
+
throw new Error("duration must be a non-empty ISO-8601 string");
|
|
27
|
+
}
|
|
28
|
+
const match = ISO_8601_DURATION.exec(duration);
|
|
29
|
+
if (!match || duration === "P" || duration === "PT") {
|
|
30
|
+
throw new Error(`Unsupported ISO-8601 duration: ${duration}`);
|
|
31
|
+
}
|
|
32
|
+
const [, days, hours, minutes, seconds] = match;
|
|
33
|
+
return (
|
|
34
|
+
(Number(days) || 0) * MS_IN_DAY +
|
|
35
|
+
(Number(hours) || 0) * MS_IN_HOUR +
|
|
36
|
+
(Number(minutes) || 0) * MS_IN_MINUTE +
|
|
37
|
+
(Number(seconds) || 0) * MS_IN_SECOND
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Evaluate whether a resume trigger has fired.
|
|
43
|
+
*
|
|
44
|
+
* @param {ResumeTrigger} trigger
|
|
45
|
+
* @param {{responses?: number, opened_at?: number}} observed
|
|
46
|
+
* @param {number} now - ms epoch (caller-provided for testability)
|
|
47
|
+
* @returns {{fired: boolean, due_at?: number}}
|
|
48
|
+
*/
|
|
49
|
+
export function evaluateTrigger(trigger, observed, now) {
|
|
50
|
+
if (!trigger || typeof trigger !== "object") {
|
|
51
|
+
throw new Error("trigger is required");
|
|
52
|
+
}
|
|
53
|
+
if (typeof now !== "number" || Number.isNaN(now)) {
|
|
54
|
+
throw new Error("now must be a number (ms epoch)");
|
|
55
|
+
}
|
|
56
|
+
observed ??= {};
|
|
57
|
+
|
|
58
|
+
switch (trigger.kind) {
|
|
59
|
+
case "responses":
|
|
60
|
+
return evaluateResponses(trigger, observed);
|
|
61
|
+
case "elapsed":
|
|
62
|
+
return evaluateElapsed(trigger, observed, now);
|
|
63
|
+
case "either": {
|
|
64
|
+
const r =
|
|
65
|
+
trigger.responses !== undefined
|
|
66
|
+
? evaluateResponses(trigger, observed)
|
|
67
|
+
: { fired: false };
|
|
68
|
+
const e =
|
|
69
|
+
trigger.elapsed !== undefined
|
|
70
|
+
? evaluateElapsed(trigger, observed, now)
|
|
71
|
+
: { fired: false };
|
|
72
|
+
if (r.fired || e.fired) return { fired: true };
|
|
73
|
+
return e.due_at !== undefined
|
|
74
|
+
? { fired: false, due_at: e.due_at }
|
|
75
|
+
: { fired: false };
|
|
76
|
+
}
|
|
77
|
+
default:
|
|
78
|
+
throw new Error(`Unsupported trigger kind: ${trigger.kind}`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function evaluateResponses(trigger, observed) {
|
|
83
|
+
if (typeof trigger.responses !== "number" || trigger.responses < 1) {
|
|
84
|
+
throw new Error(
|
|
85
|
+
'trigger.responses must be a positive number for kind "responses"',
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
const seen = observed.responses ?? 0;
|
|
89
|
+
return { fired: seen >= trigger.responses };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function evaluateElapsed(trigger, observed, now) {
|
|
93
|
+
if (typeof trigger.elapsed !== "string") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
'trigger.elapsed must be an ISO-8601 string for kind "elapsed"',
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
if (typeof observed.opened_at !== "number") {
|
|
99
|
+
return { fired: false };
|
|
100
|
+
}
|
|
101
|
+
const dueAt = observed.opened_at + parseIsoDuration(trigger.elapsed);
|
|
102
|
+
return now >= dueAt
|
|
103
|
+
? { fired: true, due_at: dueAt }
|
|
104
|
+
: { fired: false, due_at: dueAt };
|
|
105
|
+
}
|