@forwardimpact/libbridge 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "Threaded-channel bridge primitives — relay messages between human channels (GitHub Discussions, Microsoft Teams) and the Kata agent team.",
5
5
  "keywords": [
6
6
  "bridge",
@@ -41,6 +41,7 @@
41
41
  },
42
42
  "dependencies": {
43
43
  "@forwardimpact/libtype": "^0.1.0",
44
+ "@forwardimpact/libutil": "^0.1.84",
44
45
  "@hono/node-server": "^2.0.4",
45
46
  "hono": "^4.12.23"
46
47
  },
@@ -1,3 +1,5 @@
1
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
2
+
1
3
  import { validateCallbackPayload } from "./callback-payload.js";
2
4
 
3
5
  /**
@@ -44,6 +46,7 @@ export class CallbackHandlerError extends Error {
44
46
  * @param {(meta: object) => string} options.loadDiscussionId
45
47
  * @param {(meta: object) => unknown} [options.ackFinishTarget]
46
48
  * @param {(ctx: object, payload: object, meta: object) => Promise<void>} options.handleReply
49
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
47
50
  * @returns {(c: object) => Promise<Response>}
48
51
  */
49
52
  export function createCallbackHandler({
@@ -57,6 +60,7 @@ export function createCallbackHandler({
57
60
  loadDiscussionId,
58
61
  ackFinishTarget,
59
62
  handleReply,
63
+ clock = createDefaultClock(),
60
64
  }) {
61
65
  if (!channel) throw new Error("channel is required");
62
66
  if (!callbacks) throw new Error("callbacks is required");
@@ -72,20 +76,64 @@ export function createCallbackHandler({
72
76
  throw new Error("handleReply is required");
73
77
  }
74
78
 
79
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: session-aware callback handler branches on kind
75
80
  return async (c) => {
76
- const prelude = await resolveContext(c, {
77
- callbacks,
78
- ack,
79
- store,
80
- channel,
81
- logger,
82
- loadDiscussionId,
83
- ackFinishTarget,
84
- });
85
- if (prelude.error) return c.json({ error: prelude.error }, prelude.status);
81
+ let body;
82
+ try {
83
+ body = await c.req.json();
84
+ } catch {
85
+ return c.json({ error: "Invalid JSON" }, 400);
86
+ }
87
+ const payload = validateCallbackPayload(body);
88
+ if (!payload) return c.json({ error: "Invalid payload" }, 400);
89
+
90
+ const token = c.req.param("token");
91
+ const isTerminal = payload.kind === "terminal";
92
+ const meta = isTerminal ? callbacks.consume(token) : callbacks.peek(token);
93
+ if (!meta) {
94
+ logger.debug?.("callback", "unknown token");
95
+ return c.json({ error: "Unknown callback token" }, 404);
96
+ }
97
+
98
+ if (isTerminal) {
99
+ await ack.finish(
100
+ token,
101
+ ackFinishTarget ? ackFinishTarget(meta) : undefined,
102
+ );
103
+ }
104
+ if (payload.correlation_id !== meta.correlationId) {
105
+ return c.json({ error: "Correlation ID mismatch" }, 400);
106
+ }
107
+
108
+ const discussionId = loadDiscussionId(meta);
109
+ const ctx = await store.loadByChannel(channel, discussionId);
110
+ if (!ctx) {
111
+ logger.error?.("callback", "context missing", {
112
+ discussion_id: discussionId,
113
+ });
114
+ return c.json({ error: "Discussion context missing" }, 410);
115
+ }
116
+
117
+ if (
118
+ !isTerminal &&
119
+ payload.seq >= 0 &&
120
+ payload.seq <= (ctx.last_posted_seq ?? -1)
121
+ ) {
122
+ return c.json({ ok: true, dedupe: true }, 200);
123
+ }
124
+
125
+ if (!isTerminal) {
126
+ payload.replies = payload.body
127
+ ? [{ body: payload.body, agent: payload.agent }]
128
+ : [];
129
+ payload.verdict = null;
130
+ }
131
+
132
+ if (isTerminal) {
133
+ delete ctx.pending_callbacks[token];
134
+ ctx.active_requester = null;
135
+ }
86
136
 
87
- const { token, ctx, meta, payload } = prelude;
88
- delete ctx.pending_callbacks[token];
89
137
  return runHandleReply(c, {
90
138
  ctx,
91
139
  meta,
@@ -95,45 +143,16 @@ export function createCallbackHandler({
95
143
  logger,
96
144
  tracer,
97
145
  spanName,
146
+ clock,
147
+ postReply() {
148
+ if (!isTerminal) {
149
+ ctx.last_posted_seq = payload.seq;
150
+ }
151
+ },
98
152
  });
99
153
  };
100
154
  }
101
155
 
102
- async function resolveContext(
103
- c,
104
- { callbacks, ack, store, channel, logger, loadDiscussionId, ackFinishTarget },
105
- ) {
106
- const token = c.req.param("token");
107
- const meta = callbacks.consume(token);
108
- if (!meta) {
109
- logger.debug?.("callback", "unknown token");
110
- return { status: 404, error: "Unknown callback token" };
111
- }
112
- await ack.finish(token, ackFinishTarget ? ackFinishTarget(meta) : undefined);
113
-
114
- let body;
115
- try {
116
- body = await c.req.json();
117
- } catch {
118
- return { status: 400, error: "Invalid JSON" };
119
- }
120
- const payload = validateCallbackPayload(body);
121
- if (!payload) return { status: 400, error: "Invalid payload" };
122
- if (payload.correlation_id !== meta.correlationId) {
123
- return { status: 400, error: "Correlation ID mismatch" };
124
- }
125
-
126
- const discussionId = loadDiscussionId(meta);
127
- const ctx = await store.loadByChannel(channel, discussionId);
128
- if (!ctx) {
129
- logger.error?.("callback", "context missing", {
130
- discussion_id: discussionId,
131
- });
132
- return { status: 410, error: "Discussion context missing" };
133
- }
134
- return { token, ctx, meta, payload };
135
- }
136
-
137
156
  const STATUS_MESSAGES = {
138
157
  400: "Bad request",
139
158
  404: "Not found",
@@ -149,7 +168,18 @@ function sanitizeErrorMessage(status) {
149
168
 
150
169
  async function runHandleReply(
151
170
  c,
152
- { ctx, meta, payload, handleReply, store, logger, tracer, spanName },
171
+ {
172
+ ctx,
173
+ meta,
174
+ payload,
175
+ handleReply,
176
+ store,
177
+ logger,
178
+ tracer,
179
+ spanName,
180
+ clock,
181
+ postReply,
182
+ },
153
183
  ) {
154
184
  const span = tracer.startSpan(spanName, {
155
185
  kind: "SERVER",
@@ -157,7 +187,8 @@ async function runHandleReply(
157
187
  });
158
188
  try {
159
189
  await handleReply(ctx, payload, meta);
160
- ctx.last_active_at = Date.now();
190
+ if (postReply) postReply();
191
+ ctx.last_active_at = clock.now();
161
192
  await store.add(ctx);
162
193
  await store.flush();
163
194
  span.addEvent("reply_delivered", { verdict: payload.verdict });
@@ -1,3 +1,5 @@
1
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
2
+
1
3
  export const MAX_FIELD_LENGTH = 2000;
2
4
  export const MAX_REPLY_COUNT = 50;
3
5
 
@@ -11,6 +13,7 @@ export const MAX_REPLY_COUNT = 50;
11
13
  * @param {unknown} body
12
14
  * @returns {object | null}
13
15
  */
16
+ // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: lenient validator with per-field type checks
14
17
  export function validateCallbackPayload(body) {
15
18
  if (!body || typeof body !== "object") return null;
16
19
  const cid = body.correlation_id;
@@ -37,8 +40,22 @@ export function validateCallbackPayload(body) {
37
40
  const trigger = validateTrigger(body.trigger);
38
41
  const runUrl = typeof body.run_url === "string" ? body.run_url : undefined;
39
42
 
43
+ const kind = typeof body.kind === "string" ? body.kind : "terminal";
44
+ const seq = typeof body.seq === "number" ? body.seq : -1;
45
+ const eventBody =
46
+ typeof body.body === "string" ? body.body.slice(0, MAX_FIELD_LENGTH) : "";
47
+ const agent =
48
+ typeof body.agent === "string" ? body.agent.slice(0, MAX_FIELD_LENGTH) : "";
49
+ const lastActedSeq =
50
+ typeof body.last_acted_seq === "number" ? body.last_acted_seq : -1;
51
+
40
52
  return {
41
53
  correlation_id: cid,
54
+ kind,
55
+ seq,
56
+ body: eventBody,
57
+ agent,
58
+ last_acted_seq: lastActedSeq,
42
59
  verdict,
43
60
  summary,
44
61
  replies,
@@ -105,9 +122,15 @@ export function normalizeBaseUrl(url) {
105
122
  * @param {string} args.channel
106
123
  * @param {string} args.discussionId
107
124
  * @param {object} args.participant
125
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [args.clock]
108
126
  * @returns {object}
109
127
  */
110
- export function newDiscussionContext({ channel, discussionId, participant }) {
128
+ export function newDiscussionContext({
129
+ channel,
130
+ discussionId,
131
+ participant,
132
+ clock = createDefaultClock(),
133
+ }) {
111
134
  return {
112
135
  id: `${channel}:${discussionId}`,
113
136
  channel,
@@ -118,6 +141,8 @@ export function newDiscussionContext({ channel, discussionId, participant }) {
118
141
  lead: "release-engineer",
119
142
  pending_callbacks: {},
120
143
  dispatches: [],
121
- last_active_at: Date.now(),
144
+ last_active_at: clock.now(),
145
+ active_requester: null,
146
+ last_posted_seq: -1,
122
147
  };
123
148
  }
@@ -1,5 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
4
+
3
5
  const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
4
6
 
5
7
  /**
@@ -10,14 +12,17 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
10
12
  */
11
13
  export class CallbackRegistry {
12
14
  #ttlMs;
15
+ #clock;
13
16
  #entries = new Map();
14
17
 
15
18
  /**
16
19
  * @param {object} [options]
17
20
  * @param {number} [options.ttlMs] - Time-to-live in ms (default: 2h)
21
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
18
22
  */
19
- constructor({ ttlMs = DEFAULT_TTL_MS } = {}) {
23
+ constructor({ ttlMs = DEFAULT_TTL_MS, clock = createDefaultClock() } = {}) {
20
24
  this.#ttlMs = ttlMs;
25
+ this.#clock = clock;
21
26
  }
22
27
 
23
28
  /** @returns {number} */
@@ -38,7 +43,7 @@ export class CallbackRegistry {
38
43
  this.#entries.set(token, {
39
44
  correlationId,
40
45
  meta,
41
- createdAt: Date.now(),
46
+ createdAt: this.#clock.now(),
42
47
  });
43
48
  return token;
44
49
  }
@@ -75,7 +80,7 @@ export class CallbackRegistry {
75
80
  * @param {number} [now]
76
81
  * @returns {number} Number of entries evicted
77
82
  */
78
- sweep(now = Date.now()) {
83
+ sweep(now = this.#clock.now()) {
79
84
  let evicted = 0;
80
85
  for (const [token, entry] of this.#entries) {
81
86
  if (now - entry.createdAt > this.#ttlMs) {
package/src/dispatch.js CHANGED
@@ -16,6 +16,7 @@
16
16
  * @param {string} params.prompt - The facilitator prompt
17
17
  * @param {string} params.callbackUrl - Where the workflow posts the reply
18
18
  * @param {string} params.correlationId - UUID linking dispatch → callback
19
+ * @param {string} [params.inboxUrl] - Long-poll URL for injecting messages into a live run
19
20
  * @param {string} [params.discussionId] - For trace linkage in libeval
20
21
  * @param {string} [params.resumeContext] - JSON string carried across resumes
21
22
  * @returns {Promise<void>}
@@ -28,6 +29,7 @@ export async function dispatchWorkflow({
28
29
  prompt,
29
30
  callbackUrl,
30
31
  correlationId,
32
+ inboxUrl,
31
33
  discussionId,
32
34
  resumeContext,
33
35
  }) {
@@ -40,6 +42,7 @@ export async function dispatchWorkflow({
40
42
  callback_url: callbackUrl,
41
43
  correlation_id: correlationId,
42
44
  };
45
+ if (inboxUrl !== undefined) inputs.inbox_url = inboxUrl;
43
46
  if (discussionId !== undefined) inputs.discussion_id = discussionId;
44
47
  if (resumeContext !== undefined) inputs.resume_context = resumeContext;
45
48
 
package/src/dispatcher.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
4
+
3
5
  import { dispatchWorkflow } from "./dispatch.js";
4
- import { appendHistory } from "./history.js";
5
6
 
6
7
  /** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
7
8
  export class Dispatcher {
@@ -12,6 +13,7 @@ export class Dispatcher {
12
13
  #workflowFile;
13
14
  #githubRepo;
14
15
  #tokenResolver;
16
+ #clock;
15
17
 
16
18
  /**
17
19
  * @param {object} options
@@ -22,6 +24,7 @@ export class Dispatcher {
22
24
  * @param {string} options.workflowFile
23
25
  * @param {string} options.githubRepo
24
26
  * @param {import("./token-resolver.js").TokenResolver} options.tokenResolver
27
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
25
28
  */
26
29
  constructor({
27
30
  callbacks,
@@ -31,6 +34,7 @@ export class Dispatcher {
31
34
  workflowFile,
32
35
  githubRepo,
33
36
  tokenResolver,
37
+ clock = createDefaultClock(),
34
38
  }) {
35
39
  if (!callbacks) throw new Error("callbacks is required");
36
40
  if (!ack) throw new Error("ack is required");
@@ -48,6 +52,7 @@ export class Dispatcher {
48
52
  this.#workflowFile = workflowFile;
49
53
  this.#githubRepo = githubRepo;
50
54
  this.#tokenResolver = tokenResolver;
55
+ this.#clock = clock;
51
56
  }
52
57
 
53
58
  /**
@@ -57,7 +62,6 @@ export class Dispatcher {
57
62
  * @param {string} args.requester - Surface user id of the triggering human
58
63
  * @param {object} args.callbackMeta - Stored on the callback token
59
64
  * @param {unknown} [args.ackTarget] - If omitted, no acknowledgement is started
60
- * @param {string} [args.historyText] - Appended to ctx.history as the user turn on success
61
65
  * @param {object} [args.workflowInputs] - Extra fields for `dispatchWorkflow`
62
66
  * @returns {Promise<{kind: "dispatched", token: string, correlationId: string} | {kind: "link_required", authorizeUrl: string} | {kind: "reauth_required"} | {kind: "transient", error: Error}>}
63
67
  */
@@ -67,7 +71,6 @@ export class Dispatcher {
67
71
  requester,
68
72
  callbackMeta,
69
73
  ackTarget,
70
- historyText,
71
74
  workflowInputs,
72
75
  }) {
73
76
  if (!ctx) throw new Error("ctx is required");
@@ -81,7 +84,9 @@ export class Dispatcher {
81
84
  const mergedMeta = { ...(callbackMeta ?? {}), requester };
82
85
  const token = this.#callbacks.register(correlationId, mergedMeta);
83
86
  ctx.pending_callbacks[token] = correlationId;
87
+ ctx.active_requester = requester;
84
88
  const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${token}`;
89
+ const inboxUrl = `${this.#callbackBaseUrl}/api/inbox/${correlationId}`;
85
90
 
86
91
  if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
87
92
  try {
@@ -92,13 +97,11 @@ export class Dispatcher {
92
97
  prompt,
93
98
  callbackUrl,
94
99
  correlationId,
100
+ inboxUrl,
95
101
  ...(workflowInputs ?? {}),
96
102
  });
97
- if (historyText !== undefined) {
98
- appendHistory(ctx.history, { role: "user", text: historyText });
99
- }
100
- ctx.dispatches.push(Date.now());
101
- ctx.last_active_at = Date.now();
103
+ ctx.dispatches.push(this.#clock.now());
104
+ ctx.last_active_at = this.#clock.now();
102
105
  await this.#store.add(ctx);
103
106
  await this.#store.flush();
104
107
  return { kind: "dispatched", token, correlationId };
@@ -106,6 +109,7 @@ export class Dispatcher {
106
109
  if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
107
110
  this.#callbacks.consume(token);
108
111
  delete ctx.pending_callbacks[token];
112
+ ctx.active_requester = null;
109
113
  throw err;
110
114
  }
111
115
  }
@@ -1,3 +1,5 @@
1
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
2
+
1
3
  const CHUNK_CAP_MS = 7 * 24 * 60 * 60 * 1000;
2
4
 
3
5
  /**
@@ -12,16 +14,19 @@ export class ElapsedScheduler {
12
14
  #timers = new Map();
13
15
  #onFire;
14
16
  #onError;
17
+ #clock;
15
18
 
16
19
  /**
17
20
  * @param {object} options
18
21
  * @param {(correlationId: string) => Promise<void>} options.onFire - Invoked when the deadline passes.
19
22
  * @param {(err: Error, correlationId: string) => void} [options.onError] - Invoked when `onFire` rejects.
23
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
20
24
  */
21
- constructor({ onFire, onError = () => {} }) {
25
+ constructor({ onFire, onError = () => {}, clock = createDefaultClock() }) {
22
26
  if (typeof onFire !== "function") throw new Error("onFire is required");
23
27
  this.#onFire = onFire;
24
28
  this.#onError = onError;
29
+ this.#clock = clock;
25
30
  }
26
31
 
27
32
  /** @returns {number} */
@@ -38,13 +43,13 @@ export class ElapsedScheduler {
38
43
  */
39
44
  schedule(correlationId, dueAt) {
40
45
  this.cancel(correlationId);
41
- const remaining = dueAt - Date.now();
46
+ const remaining = dueAt - this.#clock.now();
42
47
  if (remaining <= 0) {
43
48
  this.#fire(correlationId);
44
49
  return;
45
50
  }
46
51
  const delay = Math.min(remaining, CHUNK_CAP_MS);
47
- const timer = setTimeout(() => {
52
+ const timer = this.#clock.setTimeout(() => {
48
53
  this.#timers.delete(correlationId);
49
54
  if (remaining > CHUNK_CAP_MS) {
50
55
  this.schedule(correlationId, dueAt);
@@ -63,14 +68,14 @@ export class ElapsedScheduler {
63
68
  cancel(correlationId) {
64
69
  const timer = this.#timers.get(correlationId);
65
70
  if (timer) {
66
- clearTimeout(timer);
71
+ this.#clock.clearTimeout(timer);
67
72
  this.#timers.delete(correlationId);
68
73
  }
69
74
  }
70
75
 
71
76
  /** Cancel every scheduled timer. */
72
77
  clear() {
73
- for (const timer of this.#timers.values()) clearTimeout(timer);
78
+ for (const timer of this.#timers.values()) this.#clock.clearTimeout(timer);
74
79
  this.#timers.clear();
75
80
  }
76
81
 
package/src/history.js CHANGED
@@ -9,6 +9,8 @@
9
9
  * @param {number} [options.maxEntries] - Default 10
10
10
  */
11
11
  export function appendHistory(history, entry, { maxEntries = 10 } = {}) {
12
- history.push(entry);
12
+ const record = { role: entry.role, text: entry.text };
13
+ if (entry.author !== undefined) record.author = entry.author;
14
+ history.push(record);
13
15
  while (history.length > maxEntries) history.shift();
14
16
  }
@@ -0,0 +1,47 @@
1
+ import { bridge } from "@forwardimpact/libtype";
2
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
3
+
4
+ /**
5
+ * Long-poll handler for the per-correlation inbox. The run's InboxPoller
6
+ * fetches injected messages via this endpoint.
7
+ *
8
+ * @param {object} deps
9
+ * @param {object} deps.client - Bridge gRPC client with DrainInbox
10
+ * @param {object} deps.logger
11
+ * @param {number} [deps.pollTimeoutMs] - Max wait before returning empty (default 30s)
12
+ * @param {number} [deps.pollIntervalMs] - Poll interval (default 1s)
13
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [deps.clock]
14
+ * @returns {(c: import("hono").Context) => Promise<Response>}
15
+ */
16
+ export function createInboxHandler({
17
+ client,
18
+ logger,
19
+ pollTimeoutMs = 30_000,
20
+ pollIntervalMs = 1_000,
21
+ clock = createDefaultClock(),
22
+ }) {
23
+ return async (c) => {
24
+ const correlationId = c.req.param("correlationId");
25
+ const sinceSeq = parseInt(c.req.query("since") ?? "0", 10);
26
+ const deadline = clock.now() + pollTimeoutMs;
27
+
28
+ while (clock.now() < deadline) {
29
+ try {
30
+ const result = await client.DrainInbox(
31
+ bridge.DrainInboxRequest.fromObject({
32
+ correlation_id: correlationId,
33
+ since_seq: sinceSeq,
34
+ }),
35
+ );
36
+ if (result.messages?.length > 0) {
37
+ return c.json({ messages: result.messages });
38
+ }
39
+ } catch (err) {
40
+ logger.error?.("inbox", err);
41
+ return c.json({ error: "Inbox failure" }, 500);
42
+ }
43
+ await clock.sleep(pollIntervalMs);
44
+ }
45
+ return c.json({ messages: [] });
46
+ };
47
+ }
package/src/index.js CHANGED
@@ -18,6 +18,8 @@
18
18
  * @property {(ctx: object) => Promise<void>} add
19
19
  * @property {() => Promise<void>} flush
20
20
  * @property {() => Promise<void>} shutdown
21
+ * @property {(target: object) => Promise<void>} [putPendingDispatch]
22
+ * @property {(linkToken: string) => Promise<object|null>} [resolvePendingDispatch]
21
23
  */
22
24
 
23
25
  export { createBridgeServer } from "./server.js";
@@ -47,3 +49,5 @@ export {
47
49
  validateCallbackPayload,
48
50
  } from "./callback-payload.js";
49
51
  export { evaluateTrigger, parseIsoDuration } from "./triggers.js";
52
+ export { prepareLinkResume, createLinkCompleteHandler } from "./link-resume.js";
53
+ export { createInboxHandler } from "./inbox-handler.js";
@@ -0,0 +1,92 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { normalizeBaseUrl } from "./callback-payload.js";
3
+ import { buildPrompt } from "./prompt.js";
4
+
5
+ /**
6
+ * @param {string} authorizeUrl
7
+ * @param {string} callbackBaseUrl
8
+ * @returns {{ linkToken: string, augmentedUrl: string }}
9
+ */
10
+ export function prepareLinkResume(authorizeUrl, callbackBaseUrl) {
11
+ const linkToken = randomUUID();
12
+ const url = new URL(authorizeUrl);
13
+ url.searchParams.set(
14
+ "redirect_uri",
15
+ `${normalizeBaseUrl(callbackBaseUrl)}/api/link-complete`,
16
+ );
17
+ url.searchParams.set("client_state", linkToken);
18
+ return { linkToken, augmentedUrl: url.toString() };
19
+ }
20
+
21
+ /**
22
+ * @param {object} options
23
+ * @returns {(c: import("hono").Context) => Promise<Response>}
24
+ */
25
+ export function createLinkCompleteHandler({
26
+ channel,
27
+ store,
28
+ dispatcher,
29
+ buildCallbackMeta,
30
+ }) {
31
+ return async (c) => {
32
+ const linkToken = c.req.query("state");
33
+ if (!linkToken) {
34
+ return c.html(
35
+ "<!DOCTYPE html><html><body><h1>Error</h1>" +
36
+ "<p>Missing state parameter.</p></body></html>",
37
+ 400,
38
+ );
39
+ }
40
+
41
+ const target = await store.resolvePendingDispatch(linkToken);
42
+ if (!target) {
43
+ return c.html(
44
+ "<!DOCTYPE html><html><body><h1>Already processed</h1>" +
45
+ "<p>This link has already been used or has expired." +
46
+ "</p></body></html>",
47
+ );
48
+ }
49
+
50
+ const ctx = await store.loadByChannel(channel, target.discussion_id);
51
+ if (!ctx) {
52
+ return c.html(
53
+ "<!DOCTYPE html><html><body><h1>Error</h1>" +
54
+ "<p>Discussion not found.</p></body></html>",
55
+ 404,
56
+ );
57
+ }
58
+
59
+ const userTurn = [...ctx.history]
60
+ .reverse()
61
+ .find((e) => e.role === "user" && e.author === target.surface_user_id);
62
+ if (!userTurn) {
63
+ return c.html(
64
+ "<!DOCTYPE html><html><body><h1>Error</h1>" +
65
+ "<p>No message found to re-dispatch.</p></body></html>",
66
+ 404,
67
+ );
68
+ }
69
+
70
+ const result = await dispatcher.dispatch({
71
+ ctx,
72
+ prompt: buildPrompt(userTurn.text, ctx.history),
73
+ requester: target.surface_user_id,
74
+ callbackMeta: buildCallbackMeta(ctx),
75
+ workflowInputs: { discussionId: target.discussion_id },
76
+ });
77
+
78
+ if (result.kind === "dispatched") {
79
+ return c.html(
80
+ "<!DOCTYPE html><html><body><h1>Processing</h1>" +
81
+ "<p>Your message is being processed. " +
82
+ "You can close this window.</p></body></html>",
83
+ );
84
+ }
85
+
86
+ return c.html(
87
+ "<!DOCTYPE html><html><body><h1>Unable to dispatch</h1>" +
88
+ "<p>Your account could not be verified. Please try " +
89
+ "linking again from the conversation.</p></body></html>",
90
+ );
91
+ };
92
+ }
package/src/rate-limit.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
2
+
1
3
  /**
2
4
  * Sliding-window rate limiter. Operates on a caller-owned `dispatches: number[]`
3
5
  * array of timestamps and returns a structured result so callers can both
@@ -6,15 +8,22 @@
6
8
  export class RateLimiter {
7
9
  #windowMs;
8
10
  #max;
11
+ #clock;
9
12
 
10
13
  /**
11
14
  * @param {object} [options]
12
15
  * @param {number} [options.windowMs] - Sliding window length in ms (default: 60_000)
13
16
  * @param {number} [options.max] - Max dispatches allowed in the window (default: 5)
17
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
14
18
  */
15
- constructor({ windowMs = 60_000, max = 5 } = {}) {
19
+ constructor({
20
+ windowMs = 60_000,
21
+ max = 5,
22
+ clock = createDefaultClock(),
23
+ } = {}) {
16
24
  this.#windowMs = windowMs;
17
25
  this.#max = max;
26
+ this.#clock = clock;
18
27
  }
19
28
 
20
29
  /**
@@ -29,7 +38,7 @@ export class RateLimiter {
29
38
  if (!Array.isArray(dispatches)) {
30
39
  throw new Error("dispatches must be an array");
31
40
  }
32
- const now = Date.now();
41
+ const now = this.#clock.now();
33
42
  const cutoff = now - this.#windowMs;
34
43
  let i = 0;
35
44
  while (i < dispatches.length && dispatches[i] < cutoff) i++;
@@ -1,3 +1,5 @@
1
+ import { createDefaultClock } from "@forwardimpact/libutil/runtime";
2
+
1
3
  import { ElapsedScheduler } from "./elapsed-scheduler.js";
2
4
  import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
3
5
 
@@ -13,6 +15,7 @@ export class ResumeScheduler {
13
15
  #buildResumeInputs;
14
16
  #buildCallbackMeta;
15
17
  #onDeclined;
18
+ #clock;
16
19
 
17
20
  /**
18
21
  * @param {object} options
@@ -23,6 +26,7 @@ export class ResumeScheduler {
23
26
  * @param {(ctx: object) => object} [options.buildCallbackMeta]
24
27
  * @param {(ctx: object) => object} [options.buildResumeInputs]
25
28
  * @param {((ctx: object, outcome: object) => Promise<void>) | null} [options.onDeclined]
29
+ * @param {import("@forwardimpact/libutil/runtime").Runtime["clock"]} [options.clock]
26
30
  */
27
31
  constructor({
28
32
  dispatcher,
@@ -32,6 +36,7 @@ export class ResumeScheduler {
32
36
  buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
33
37
  buildResumeInputs = () => ({}),
34
38
  onDeclined = null,
39
+ clock = createDefaultClock(),
35
40
  }) {
36
41
  if (!dispatcher) throw new Error("dispatcher is required");
37
42
  if (!store) throw new Error("store is required");
@@ -51,12 +56,14 @@ export class ResumeScheduler {
51
56
  this.#buildCallbackMeta = buildCallbackMeta;
52
57
  this.#buildResumeInputs = buildResumeInputs;
53
58
  this.#onDeclined = onDeclined;
59
+ this.#clock = clock;
54
60
  this.#elapsed = new ElapsedScheduler({
55
61
  onFire: (cid) => this.#fireElapsed(cid),
56
62
  onError: (err, cid) =>
57
63
  this.#logger?.error?.("resume.elapsed", err, {
58
64
  correlation_id: cid,
59
65
  }),
66
+ clock,
60
67
  });
61
68
  }
62
69
 
@@ -81,7 +88,7 @@ export class ResumeScheduler {
81
88
  */
82
89
  enterRecess(ctx, correlationId, trigger, requester) {
83
90
  if (!trigger) return;
84
- const openedAt = Date.now();
91
+ const openedAt = this.#clock.now();
85
92
  ctx.open_rfcs[correlationId] = {
86
93
  trigger,
87
94
  opened_at: openedAt,
@@ -157,7 +164,7 @@ export class ResumeScheduler {
157
164
  replies: ctx.history.length - rfc.history_index_at_open,
158
165
  opened_at: rfc.opened_at,
159
166
  };
160
- if (evaluateTrigger(trigger, observed, Date.now()).fired) {
167
+ if (evaluateTrigger(trigger, observed, this.#clock.now()).fired) {
161
168
  fired.push({ correlationId, rfc });
162
169
  }
163
170
  }
@@ -207,7 +214,7 @@ export class ResumeScheduler {
207
214
  const result = await this.#redispatch(ctx, correlationId, historySince);
208
215
  if (result.kind === "dispatched") {
209
216
  this.cancelRecess(ctx, correlationId);
210
- ctx.last_active_at = Date.now();
217
+ ctx.last_active_at = this.#clock.now();
211
218
  await this.#store.add(ctx);
212
219
  await this.#store.flush();
213
220
  }
package/src/server.js CHANGED
@@ -23,6 +23,8 @@ import { serve } from "@hono/node-server";
23
23
  * @param {string} options.webhookPath - e.g. `/api/messages` or `/api/webhooks/github`
24
24
  * @param {(c: import("hono").Context) => Promise<Response> | Response} options.onWebhook
25
25
  * @param {(c: import("hono").Context) => Promise<Response> | Response} options.onCallback
26
+ * @param {((c: import("hono").Context) => Promise<Response> | Response)} [options.onLinkComplete]
27
+ * @param {(c: import("hono").Context) => Promise<Response> | Response} [options.onInbox] - Long-poll inbox handler
26
28
  * @returns {{ start: () => Promise<void>, stop: () => Promise<void>, app: import("hono").Hono, address: () => ({port: number} | null) }}
27
29
  */
28
30
  export function createBridgeServer({
@@ -32,6 +34,8 @@ export function createBridgeServer({
32
34
  webhookPath,
33
35
  onWebhook,
34
36
  onCallback,
37
+ onLinkComplete,
38
+ onInbox,
35
39
  }) {
36
40
  if (!config) throw new Error("config is required");
37
41
  if (!logger) throw new Error("logger is required");
@@ -86,6 +90,28 @@ export function createBridgeServer({
86
90
  }
87
91
  });
88
92
 
93
+ if (onLinkComplete) {
94
+ app.get("/api/link-complete", async (c) => {
95
+ try {
96
+ return await onLinkComplete(c);
97
+ } catch (err) {
98
+ logger.error("bridge.link-complete", err);
99
+ return c.json({ error: "Link completion failure" }, 500);
100
+ }
101
+ });
102
+ }
103
+
104
+ if (onInbox) {
105
+ app.get("/api/inbox/:correlationId", async (c) => {
106
+ try {
107
+ return await onInbox(c);
108
+ } catch (err) {
109
+ logger.error("bridge.inbox", err);
110
+ return c.json({ error: "Inbox failure" }, 500);
111
+ }
112
+ });
113
+ }
114
+
89
115
  let server = null;
90
116
 
91
117
  return {
@@ -1,19 +1,19 @@
1
- import { ghauth } from "@forwardimpact/libtype";
1
+ import { ghuser } from "@forwardimpact/libtype";
2
2
 
3
- /** Maps ghauth GetToken oneof + gRPC transport into a discriminated DispatchAuth result. */
3
+ /** Maps ghuser GetToken oneof + gRPC transport into a discriminated DispatchAuth result. */
4
4
  export class TokenResolver {
5
5
  #client;
6
6
 
7
- /** @param {object} client - ghauth gRPC client */
7
+ /** @param {object} client - ghuser gRPC client */
8
8
  constructor(client) {
9
- if (!client) throw new Error("ghauth client is required");
9
+ if (!client) throw new Error("ghuser client is required");
10
10
  this.#client = client;
11
11
  }
12
12
 
13
13
  /** @returns {Promise<{kind: string, token?: string, authorizeUrl?: string, error?: Error}>} */
14
14
  async resolve(surface, surfaceUserId) {
15
15
  try {
16
- const req = new ghauth.GetTokenRequest({
16
+ const req = new ghuser.GetTokenRequest({
17
17
  surface,
18
18
  surface_user_id: surfaceUserId,
19
19
  });