@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.
@@ -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
+ }
@@ -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
+ }