@forwardimpact/libbridge 0.1.4 → 0.1.6

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 CHANGED
@@ -13,7 +13,6 @@ Threaded-channel bridge primitives — relay messages between human channels
13
13
  import {
14
14
  createBridgeServer,
15
15
  CallbackRegistry,
16
- DiscussionContextStore,
17
16
  RateLimiter,
18
17
  ProgressTicker,
19
18
  appendHistory,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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",
@@ -40,8 +40,6 @@
40
40
  "test": "bun test test/*.test.js"
41
41
  },
42
42
  "dependencies": {
43
- "@forwardimpact/libindex": "^0.1.38",
44
- "@forwardimpact/libstorage": "^0.1.78",
45
43
  "@forwardimpact/libtype": "^0.1.0",
46
44
  "@hono/node-server": "^2.0.4",
47
45
  "hono": "^4.12.23"
@@ -37,7 +37,7 @@ export class CallbackHandlerError extends Error {
37
37
  * @param {string} options.channel
38
38
  * @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
39
39
  * @param {import("./acknowledgement.js").Acknowledgement} options.ack
40
- * @param {import("./discussion-context.js").DiscussionContextStore} options.store
40
+ * @param {import("./index.js").DiscussionAdapter} options.store
41
41
  * @param {{debug?: Function, error?: Function}} options.logger
42
42
  * @param {{startSpan: Function}} options.tracer
43
43
  * @param {string} options.spanName
@@ -4,7 +4,7 @@ const DEFAULT_TTL_MS = 2 * 60 * 60 * 1000;
4
4
 
5
5
  /**
6
6
  * In-memory registry of pending bridge → workflow callbacks. Hosts persist
7
- * the (token, correlationId) pairs via DiscussionContextStore so the registry
7
+ * the (token, correlationId) pairs via the discussion store so the registry
8
8
  * can be rehydrated after a restart; this class only owns the live token →
9
9
  * metadata mapping and TTL sweep.
10
10
  */
package/src/dispatcher.js CHANGED
@@ -1,7 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
 
3
3
  import { dispatchWorkflow } from "./dispatch.js";
4
- import { appendHistory } from "./history.js";
5
4
 
6
5
  /** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
7
6
  export class Dispatcher {
@@ -17,7 +16,7 @@ export class Dispatcher {
17
16
  * @param {object} options
18
17
  * @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
19
18
  * @param {import("./acknowledgement.js").Acknowledgement} options.ack
20
- * @param {import("./discussion-context.js").DiscussionContextStore} options.store
19
+ * @param {import("./index.js").DiscussionAdapter} options.store
21
20
  * @param {string} options.callbackBaseUrl - Already normalised
22
21
  * @param {string} options.workflowFile
23
22
  * @param {string} options.githubRepo
@@ -57,7 +56,6 @@ export class Dispatcher {
57
56
  * @param {string} args.requester - Surface user id of the triggering human
58
57
  * @param {object} args.callbackMeta - Stored on the callback token
59
58
  * @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
59
  * @param {object} [args.workflowInputs] - Extra fields for `dispatchWorkflow`
62
60
  * @returns {Promise<{kind: "dispatched", token: string, correlationId: string} | {kind: "link_required", authorizeUrl: string} | {kind: "reauth_required"} | {kind: "transient", error: Error}>}
63
61
  */
@@ -67,7 +65,6 @@ export class Dispatcher {
67
65
  requester,
68
66
  callbackMeta,
69
67
  ackTarget,
70
- historyText,
71
68
  workflowInputs,
72
69
  }) {
73
70
  if (!ctx) throw new Error("ctx is required");
@@ -94,9 +91,6 @@ export class Dispatcher {
94
91
  correlationId,
95
92
  ...(workflowInputs ?? {}),
96
93
  });
97
- if (historyText !== undefined) {
98
- appendHistory(ctx.history, { role: "user", text: historyText });
99
- }
100
94
  ctx.dispatches.push(Date.now());
101
95
  ctx.last_active_at = Date.now();
102
96
  await this.#store.add(ctx);
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
  }
package/src/index.js CHANGED
@@ -10,14 +10,24 @@
10
10
  * @property {string} github_repo - "owner/repo" hosting the kata-dispatch workflow
11
11
  */
12
12
 
13
+ /**
14
+ * @typedef {object} DiscussionAdapter
15
+ * @property {(channel: string, discussionId: string) => Promise<object|null>} loadByChannel
16
+ * @property {(correlationId: string) => Promise<object|null>} loadByCorrelation
17
+ * @property {() => Promise<Array<{correlationId: string, dueAt: number}>>} listOpenRecesses
18
+ * @property {(ctx: object) => Promise<void>} add
19
+ * @property {() => Promise<void>} flush
20
+ * @property {() => Promise<void>} shutdown
21
+ * @property {(target: object) => Promise<void>} [putPendingDispatch]
22
+ * @property {(linkToken: string) => Promise<object|null>} [resolvePendingDispatch]
23
+ */
24
+
13
25
  export { createBridgeServer } from "./server.js";
14
26
  export { CallbackRegistry } from "./callback-registry.js";
15
27
  export { buildPrompt } from "./prompt.js";
16
28
  export { appendHistory } from "./history.js";
17
29
  export { RateLimiter } from "./rate-limit.js";
18
30
  export { dispatchWorkflow } from "./dispatch.js";
19
- export { DiscussionContextStore } from "./discussion-context.js";
20
- export { OriginIndex } from "./origin-index.js";
21
31
  export { ProgressTicker } from "./progress-ticker.js";
22
32
  export {
23
33
  Acknowledgement,
@@ -39,3 +49,4 @@ export {
39
49
  validateCallbackPayload,
40
50
  } from "./callback-payload.js";
41
51
  export { evaluateTrigger, parseIsoDuration } from "./triggers.js";
52
+ export { prepareLinkResume, createLinkCompleteHandler } from "./link-resume.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
+ }
@@ -17,7 +17,7 @@ export class ResumeScheduler {
17
17
  /**
18
18
  * @param {object} options
19
19
  * @param {import("./dispatcher.js").Dispatcher} options.dispatcher
20
- * @param {import("./discussion-context.js").DiscussionContextStore} options.store
20
+ * @param {import("./index.js").DiscussionAdapter} options.store
21
21
  * @param {{error?: Function, info?: Function}} [options.logger]
22
22
  * @param {string} [options.prompt] - Default "Resume requested."
23
23
  * @param {(ctx: object) => object} [options.buildCallbackMeta]
@@ -137,15 +137,9 @@ export class ResumeScheduler {
137
137
  * @returns {Promise<void>}
138
138
  */
139
139
  async rearm() {
140
- if (!this.#store.loaded) await this.#store.loadData();
141
- for (const record of this.#store.index.values()) {
142
- const open = record?.open_rfcs;
143
- if (!open) continue;
144
- for (const [correlationId, rfc] of Object.entries(open)) {
145
- if (typeof rfc.due_at === "number") {
146
- this.#elapsed.schedule(correlationId, rfc.due_at);
147
- }
148
- }
140
+ const refs = await this.#store.listOpenRecesses();
141
+ for (const { correlationId, dueAt } of refs) {
142
+ this.#elapsed.schedule(correlationId, dueAt);
149
143
  }
150
144
  }
151
145
 
@@ -220,12 +214,8 @@ export class ResumeScheduler {
220
214
  }
221
215
 
222
216
  async #findContextWithRfc(correlationId) {
223
- if (!this.#store.loaded) await this.#store.loadData();
224
- for (const record of this.#store.index.values()) {
225
- if (record?.open_rfcs?.[correlationId]) {
226
- return { ctx: record, rfc: record.open_rfcs[correlationId] };
227
- }
228
- }
229
- return null;
217
+ const ctx = await this.#store.loadByCorrelation(correlationId);
218
+ if (!ctx) return null;
219
+ return { ctx, rfc: ctx.open_rfcs[correlationId] };
230
220
  }
231
221
  }
package/src/server.js CHANGED
@@ -23,6 +23,7 @@ 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]
26
27
  * @returns {{ start: () => Promise<void>, stop: () => Promise<void>, app: import("hono").Hono, address: () => ({port: number} | null) }}
27
28
  */
28
29
  export function createBridgeServer({
@@ -32,6 +33,7 @@ export function createBridgeServer({
32
33
  webhookPath,
33
34
  onWebhook,
34
35
  onCallback,
36
+ onLinkComplete,
35
37
  }) {
36
38
  if (!config) throw new Error("config is required");
37
39
  if (!logger) throw new Error("logger is required");
@@ -86,6 +88,17 @@ export function createBridgeServer({
86
88
  }
87
89
  });
88
90
 
91
+ if (onLinkComplete) {
92
+ app.get("/api/link-complete", async (c) => {
93
+ try {
94
+ return await onLinkComplete(c);
95
+ } catch (err) {
96
+ logger.error("bridge.link-complete", err);
97
+ return c.json({ error: "Link completion failure" }, 500);
98
+ }
99
+ });
100
+ }
101
+
89
102
  let server = null;
90
103
 
91
104
  return {
@@ -1,126 +0,0 @@
1
- import { BufferedIndex } from "@forwardimpact/libindex";
2
-
3
- const DEFAULT_FLUSH_INTERVAL_MS = 5_000;
4
- const DEFAULT_MAX_BUFFER_SIZE = 1_000;
5
- const DEFAULT_CONVERSATION_TTL_MS = 24 * 60 * 60 * 1000;
6
- const DEFAULT_SWEEP_INTERVAL_MS = 60_000;
7
-
8
- /**
9
- * Persisted thread state keyed by `(channel, discussion_id)`. Both
10
- * `services/ghbridge` and `services/msbridge` write into the same store so
11
- * the channel-agnostic `kata-dispatch.yml` workflow can resume conversations
12
- * from either side.
13
- *
14
- * Record shape:
15
- * {
16
- * id: "<channel>:<discussion_id>",
17
- * channel: "github-discussions" | "msteams",
18
- * discussion_id: string,
19
- * history: Array<{role: "user"|"assistant", text: string}>,
20
- * participants: Array<{name, kind: "agent"|"human", external_id?, metadata?}>,
21
- * open_rfcs: Record<correlationId, {trigger, opened_at, history_index_at_open}>,
22
- * lead: string,
23
- * pending_callbacks: Record<token, correlationId>,
24
- * last_active_at: number,
25
- * }
26
- *
27
- * @augments BufferedIndex
28
- */
29
- export class DiscussionContextStore extends BufferedIndex {
30
- #conversationTtlMs;
31
- #sweepTimer;
32
-
33
- /**
34
- * @param {import("@forwardimpact/libstorage").StorageInterface} storage
35
- * @param {object} [options]
36
- * @param {string} [options.indexKey] - JSONL file name (default `discussions.jsonl`)
37
- * @param {number} [options.flushIntervalMs]
38
- * @param {number} [options.maxBufferSize]
39
- * @param {number} [options.conversationTtlMs] - Eviction window (default 24h)
40
- * @param {number} [options.sweepIntervalMs] - Sweep cadence (default 60s)
41
- */
42
- constructor(
43
- storage,
44
- {
45
- indexKey = "discussions.jsonl",
46
- flushIntervalMs = DEFAULT_FLUSH_INTERVAL_MS,
47
- maxBufferSize = DEFAULT_MAX_BUFFER_SIZE,
48
- conversationTtlMs = DEFAULT_CONVERSATION_TTL_MS,
49
- sweepIntervalMs = DEFAULT_SWEEP_INTERVAL_MS,
50
- } = {},
51
- ) {
52
- super(storage, indexKey, {
53
- flush_interval: flushIntervalMs,
54
- max_buffer_size: maxBufferSize,
55
- });
56
- this.#conversationTtlMs = conversationTtlMs;
57
- this.#sweepTimer = setInterval(
58
- () => this.#sweep(Date.now()),
59
- sweepIntervalMs,
60
- );
61
- this.#sweepTimer.unref();
62
- }
63
-
64
- /**
65
- * Compose the `id` field for a `(channel, discussion_id)` pair.
66
- * @param {string} channel
67
- * @param {string} discussionId
68
- * @returns {string}
69
- */
70
- static keyOf(channel, discussionId) {
71
- return `${channel}:${discussionId}`;
72
- }
73
-
74
- /**
75
- * @param {string} channel
76
- * @param {string} discussionId
77
- * @returns {Promise<object | null>}
78
- */
79
- async loadByChannel(channel, discussionId) {
80
- if (!this.loaded) await this.loadData();
81
- const id = DiscussionContextStore.keyOf(channel, discussionId);
82
- return this.index.get(id) ?? null;
83
- }
84
-
85
- /**
86
- * Stop the periodic sweep timer. Called on host shutdown alongside
87
- * `shutdown()` to release the interval.
88
- */
89
- stopSweep() {
90
- if (this.#sweepTimer) {
91
- clearInterval(this.#sweepTimer);
92
- this.#sweepTimer = null;
93
- }
94
- }
95
-
96
- /**
97
- * Flush buffered writes and stop the sweep timer.
98
- * @returns {Promise<void>}
99
- */
100
- async shutdown() {
101
- this.stopSweep();
102
- await super.shutdown();
103
- }
104
-
105
- /**
106
- * Evict records whose `last_active_at` is older than `conversationTtlMs`.
107
- * Caller-driven `now` keeps unit tests deterministic.
108
- * @param {number} now
109
- * @returns {number}
110
- */
111
- sweepNow(now) {
112
- return this.#sweep(now);
113
- }
114
-
115
- #sweep(now) {
116
- let evicted = 0;
117
- for (const [id, record] of this.index) {
118
- const lastActive = record?.last_active_at ?? 0;
119
- if (now - lastActive > this.#conversationTtlMs) {
120
- this.index.delete(id);
121
- evicted++;
122
- }
123
- }
124
- return evicted;
125
- }
126
- }
@@ -1,49 +0,0 @@
1
- import { BufferedIndex } from "@forwardimpact/libindex";
2
-
3
- const DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
4
-
5
- /**
6
- * Tracks comment IDs posted by the bridge so webhook handlers can skip
7
- * dispatching for self-originated events. Wraps `BufferedIndex` with
8
- * caller-injected `StorageInterface` per the libbridge invariant.
9
- *
10
- * Record shape: `{ id: "<comment_node_id>", discussion_id, posted_at }`
11
- *
12
- * @augments BufferedIndex
13
- */
14
- export class OriginIndex extends BufferedIndex {
15
- #ttlMs;
16
-
17
- /**
18
- * @param {import("@forwardimpact/libstorage").StorageInterface} storage
19
- * @param {object} [options]
20
- * @param {string} [options.indexKey]
21
- * @param {number} [options.ttlMs] - Eviction window (default 24h)
22
- */
23
- constructor(
24
- storage,
25
- { indexKey = "origins.jsonl", ttlMs = DEFAULT_TTL_MS } = {},
26
- ) {
27
- super(storage, indexKey, {
28
- flush_interval: 1_000,
29
- max_buffer_size: 100,
30
- });
31
- this.#ttlMs = ttlMs;
32
- }
33
-
34
- /**
35
- * Evict records older than `ttlMs`.
36
- * @param {number} now
37
- * @returns {number} count evicted
38
- */
39
- sweep(now) {
40
- let evicted = 0;
41
- for (const [id, record] of this.index) {
42
- if (now - (record?.posted_at ?? 0) > this.#ttlMs) {
43
- this.index.delete(id);
44
- evicted++;
45
- }
46
- }
47
- return evicted;
48
- }
49
- }