@forwardimpact/libbridge 0.1.3 → 0.1.5

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.3",
3
+ "version": "0.1.5",
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,13 +40,12 @@
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",
43
+ "@forwardimpact/libtype": "^0.1.0",
45
44
  "@hono/node-server": "^2.0.4",
46
45
  "hono": "^4.12.23"
47
46
  },
48
47
  "devDependencies": {
49
- "@forwardimpact/libharness": "^0.1.21"
48
+ "@forwardimpact/libmock": "^0.1.0"
50
49
  },
51
50
  "engines": {
52
51
  "bun": ">=1.2.0",
@@ -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
@@ -3,27 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import { dispatchWorkflow } from "./dispatch.js";
4
4
  import { appendHistory } from "./history.js";
5
5
 
6
- /**
7
- * The standard "dispatch dance" both bridges perform: generate a
8
- * correlation ID, register the callback token, start the acknowledgement
9
- * (if `ackTarget` is supplied), fire the kata-dispatch workflow, append
10
- * history, push the dispatch timestamp, and flush the store. On failure
11
- * the acknowledgement is finished and the callback registration is rolled
12
- * back before the error rethrows.
13
- *
14
- * The caller still owns: loading/creating the context, checking the rate
15
- * limiter, and deciding the user-facing action when `dispatch()` throws.
16
- *
17
- * @example
18
- * const { token, correlationId } = await dispatcher.dispatch({
19
- * ctx,
20
- * prompt,
21
- * ackTarget: { subjectId: nodeId },
22
- * historyText: text,
23
- * callbackMeta: { discussionId },
24
- * workflowInputs: { discussionId },
25
- * });
26
- */
6
+ /** Dispatch dance: resolve per-user token, register callback, ack, fire workflow, flush. */
27
7
  export class Dispatcher {
28
8
  #callbacks;
29
9
  #ack;
@@ -31,17 +11,17 @@ export class Dispatcher {
31
11
  #callbackBaseUrl;
32
12
  #workflowFile;
33
13
  #githubRepo;
34
- #getGithubToken;
14
+ #tokenResolver;
35
15
 
36
16
  /**
37
17
  * @param {object} options
38
18
  * @param {import("./callback-registry.js").CallbackRegistry} options.callbacks
39
19
  * @param {import("./acknowledgement.js").Acknowledgement} options.ack
40
- * @param {import("./discussion-context.js").DiscussionContextStore} options.store
20
+ * @param {import("./index.js").DiscussionAdapter} options.store
41
21
  * @param {string} options.callbackBaseUrl - Already normalised
42
22
  * @param {string} options.workflowFile
43
23
  * @param {string} options.githubRepo
44
- * @param {() => Promise<string> | string} options.getGithubToken
24
+ * @param {import("./token-resolver.js").TokenResolver} options.tokenResolver
45
25
  */
46
26
  constructor({
47
27
  callbacks,
@@ -50,7 +30,7 @@ export class Dispatcher {
50
30
  callbackBaseUrl,
51
31
  workflowFile,
52
32
  githubRepo,
53
- getGithubToken,
33
+ tokenResolver,
54
34
  }) {
55
35
  if (!callbacks) throw new Error("callbacks is required");
56
36
  if (!ack) throw new Error("ack is required");
@@ -60,31 +40,31 @@ export class Dispatcher {
60
40
  }
61
41
  if (!workflowFile) throw new Error("workflowFile is required");
62
42
  if (!githubRepo) throw new Error("githubRepo is required");
63
- if (typeof getGithubToken !== "function") {
64
- throw new Error("getGithubToken is required");
65
- }
43
+ if (!tokenResolver) throw new Error("tokenResolver is required");
66
44
  this.#callbacks = callbacks;
67
45
  this.#ack = ack;
68
46
  this.#store = store;
69
47
  this.#callbackBaseUrl = callbackBaseUrl;
70
48
  this.#workflowFile = workflowFile;
71
49
  this.#githubRepo = githubRepo;
72
- this.#getGithubToken = getGithubToken;
50
+ this.#tokenResolver = tokenResolver;
73
51
  }
74
52
 
75
53
  /**
76
54
  * @param {object} args
77
55
  * @param {object} args.ctx - Discussion context record (mutated)
78
56
  * @param {string} args.prompt
57
+ * @param {string} args.requester - Surface user id of the triggering human
79
58
  * @param {object} args.callbackMeta - Stored on the callback token
80
59
  * @param {unknown} [args.ackTarget] - If omitted, no acknowledgement is started
81
60
  * @param {string} [args.historyText] - Appended to ctx.history as the user turn on success
82
61
  * @param {object} [args.workflowInputs] - Extra fields for `dispatchWorkflow`
83
- * @returns {Promise<{token: string, correlationId: string}>}
62
+ * @returns {Promise<{kind: "dispatched", token: string, correlationId: string} | {kind: "link_required", authorizeUrl: string} | {kind: "reauth_required"} | {kind: "transient", error: Error}>}
84
63
  */
85
64
  async dispatch({
86
65
  ctx,
87
66
  prompt,
67
+ requester,
88
68
  callbackMeta,
89
69
  ackTarget,
90
70
  historyText,
@@ -92,19 +72,23 @@ export class Dispatcher {
92
72
  }) {
93
73
  if (!ctx) throw new Error("ctx is required");
94
74
  if (typeof prompt !== "string") throw new Error("prompt is required");
75
+ if (typeof requester !== "string") throw new Error("requester is required");
76
+
77
+ const auth = await this.#tokenResolver.resolve(ctx.channel, requester);
78
+ if (auth.kind !== "token") return auth;
95
79
 
96
80
  const correlationId = randomUUID();
97
- const token = this.#callbacks.register(correlationId, callbackMeta ?? {});
81
+ const mergedMeta = { ...(callbackMeta ?? {}), requester };
82
+ const token = this.#callbacks.register(correlationId, mergedMeta);
98
83
  ctx.pending_callbacks[token] = correlationId;
99
84
  const callbackUrl = `${this.#callbackBaseUrl}/api/callback/${token}`;
100
85
 
101
86
  if (ackTarget !== undefined) await this.#ack.start(token, ackTarget);
102
87
  try {
103
- const ghToken = await this.#getGithubToken();
104
88
  await dispatchWorkflow({
105
89
  workflowFile: this.#workflowFile,
106
90
  repo: this.#githubRepo,
107
- token: ghToken,
91
+ token: auth.token,
108
92
  prompt,
109
93
  callbackUrl,
110
94
  correlationId,
@@ -117,7 +101,7 @@ export class Dispatcher {
117
101
  ctx.last_active_at = Date.now();
118
102
  await this.#store.add(ctx);
119
103
  await this.#store.flush();
120
- return { token, correlationId };
104
+ return { kind: "dispatched", token, correlationId };
121
105
  } catch (err) {
122
106
  if (ackTarget !== undefined) await this.#ack.finish(token, ackTarget);
123
107
  this.#callbacks.consume(token);
package/src/index.js CHANGED
@@ -10,20 +10,29 @@
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
+ */
22
+
13
23
  export { createBridgeServer } from "./server.js";
14
24
  export { CallbackRegistry } from "./callback-registry.js";
15
25
  export { buildPrompt } from "./prompt.js";
16
26
  export { appendHistory } from "./history.js";
17
27
  export { RateLimiter } from "./rate-limit.js";
18
28
  export { dispatchWorkflow } from "./dispatch.js";
19
- export { DiscussionContextStore } from "./discussion-context.js";
20
- export { OriginIndex } from "./origin-index.js";
21
29
  export { ProgressTicker } from "./progress-ticker.js";
22
30
  export {
23
31
  Acknowledgement,
24
32
  DEFAULT_TYPING_VERBS,
25
33
  } from "./acknowledgement.js";
26
34
  export { Dispatcher } from "./dispatcher.js";
35
+ export { TokenResolver } from "./token-resolver.js";
27
36
  export {
28
37
  CallbackHandlerError,
29
38
  createCallbackHandler,
@@ -3,46 +3,7 @@ import { evaluateTrigger, parseIsoDuration } from "./triggers.js";
3
3
 
4
4
  const DEFAULT_PROMPT = "Resume requested.";
5
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
- */
6
+ /** Channel-agnostic suspend/resume lifecycle for the discuss-mode trace. */
46
7
  export class ResumeScheduler {
47
8
  #dispatcher;
48
9
  #store;
@@ -51,15 +12,17 @@ export class ResumeScheduler {
51
12
  #prompt;
52
13
  #buildResumeInputs;
53
14
  #buildCallbackMeta;
15
+ #onDeclined;
54
16
 
55
17
  /**
56
18
  * @param {object} options
57
19
  * @param {import("./dispatcher.js").Dispatcher} options.dispatcher
58
- * @param {import("./discussion-context.js").DiscussionContextStore} options.store
20
+ * @param {import("./index.js").DiscussionAdapter} options.store
59
21
  * @param {{error?: Function, info?: Function}} [options.logger]
60
22
  * @param {string} [options.prompt] - Default "Resume requested."
61
23
  * @param {(ctx: object) => object} [options.buildCallbackMeta]
62
24
  * @param {(ctx: object) => object} [options.buildResumeInputs]
25
+ * @param {((ctx: object, outcome: object) => Promise<void>) | null} [options.onDeclined]
63
26
  */
64
27
  constructor({
65
28
  dispatcher,
@@ -68,6 +31,7 @@ export class ResumeScheduler {
68
31
  prompt = DEFAULT_PROMPT,
69
32
  buildCallbackMeta = (ctx) => ({ discussionId: ctx.discussion_id }),
70
33
  buildResumeInputs = () => ({}),
34
+ onDeclined = null,
71
35
  }) {
72
36
  if (!dispatcher) throw new Error("dispatcher is required");
73
37
  if (!store) throw new Error("store is required");
@@ -77,12 +41,16 @@ export class ResumeScheduler {
77
41
  if (typeof buildResumeInputs !== "function") {
78
42
  throw new Error("buildResumeInputs must be a function");
79
43
  }
44
+ if (onDeclined != null && typeof onDeclined !== "function") {
45
+ throw new Error("onDeclined must be a function");
46
+ }
80
47
  this.#dispatcher = dispatcher;
81
48
  this.#store = store;
82
49
  this.#logger = logger ?? null;
83
50
  this.#prompt = prompt;
84
51
  this.#buildCallbackMeta = buildCallbackMeta;
85
52
  this.#buildResumeInputs = buildResumeInputs;
53
+ this.#onDeclined = onDeclined;
86
54
  this.#elapsed = new ElapsedScheduler({
87
55
  onFire: (cid) => this.#fireElapsed(cid),
88
56
  onError: (err, cid) =>
@@ -99,10 +67,9 @@ export class ResumeScheduler {
99
67
 
100
68
  /**
101
69
  * 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.
70
+ * trigger, the history index at recess time, and the triggering
71
+ * requester onto `ctx.open_rfcs[correlationId]`. If the trigger has an
72
+ * elapsed component, schedules an in-memory timer.
106
73
  *
107
74
  * The caller is responsible for flushing the store after this call —
108
75
  * `enterRecess` mutates ctx but does not write.
@@ -110,14 +77,16 @@ export class ResumeScheduler {
110
77
  * @param {object} ctx
111
78
  * @param {string} correlationId
112
79
  * @param {import("./triggers.js").ResumeTrigger} trigger
80
+ * @param {string} requester - Surface user id of the triggering human
113
81
  */
114
- enterRecess(ctx, correlationId, trigger) {
82
+ enterRecess(ctx, correlationId, trigger, requester) {
115
83
  if (!trigger) return;
116
84
  const openedAt = Date.now();
117
85
  ctx.open_rfcs[correlationId] = {
118
86
  trigger,
119
87
  opened_at: openedAt,
120
88
  history_index_at_open: ctx.history.length,
89
+ requester,
121
90
  };
122
91
  if (trigger.kind === "elapsed" && typeof trigger.elapsed === "string") {
123
92
  const dueAt = openedAt + parseIsoDuration(trigger.elapsed);
@@ -128,8 +97,7 @@ export class ResumeScheduler {
128
97
 
129
98
  /**
130
99
  * Stop watching `correlationId`. Removes the rfc from `ctx.open_rfcs`
131
- * and cancels any associated elapsed timer. Idempotent — safe to call
132
- * for verdicts that didn't actually recess.
100
+ * and cancels any associated elapsed timer. Idempotent.
133
101
  *
134
102
  * @param {object} ctx
135
103
  * @param {string} correlationId
@@ -140,29 +108,21 @@ export class ResumeScheduler {
140
108
  }
141
109
 
142
110
  /**
143
- * Walk `ctx.open_rfcs`. For each rfc whose trigger has fired given the
144
- * current history length and clock, redispatch the workflow with
145
- * `resume_context` linking back to the original correlation id, then
146
- * cancel the rfc. Returns a summary so the host can decide whether to
147
- * additionally fire a fresh lead session on this inbound activity.
148
- *
149
- * If a redispatch fails the rfc remains armed — the host's failure
150
- * recovery (next inbound activity, or the next elapsed tick) will
151
- * retry.
111
+ * Walk `ctx.open_rfcs`. For each rfc whose trigger has fired,
112
+ * redispatch the workflow. Returns a summary so the host can decide
113
+ * whether to additionally fire a fresh lead session.
152
114
  *
153
115
  * @param {object} ctx
154
- * @returns {Promise<{
155
- * fired: number,
156
- * hasOpenRfc: boolean,
157
- * freshDispatchAllowed: boolean,
158
- * }>}
116
+ * @returns {Promise<{fired: number, hasOpenRfc: boolean, freshDispatchAllowed: boolean}>}
159
117
  */
160
118
  async processInbound(ctx) {
161
119
  const fired = this.#evaluate(ctx);
162
120
  for (const { correlationId, rfc } of fired) {
163
121
  const historySince = ctx.history.slice(rfc.history_index_at_open);
164
- await this.#redispatch(ctx, correlationId, historySince);
165
- this.cancelRecess(ctx, correlationId);
122
+ const result = await this.#redispatch(ctx, correlationId, historySince);
123
+ if (result.kind === "dispatched") {
124
+ this.cancelRecess(ctx, correlationId);
125
+ }
166
126
  }
167
127
  const hasOpenRfc = Object.keys(ctx.open_rfcs ?? {}).length > 0;
168
128
  return {
@@ -173,21 +133,13 @@ export class ResumeScheduler {
173
133
  }
174
134
 
175
135
  /**
176
- * Rehydrate persistent elapsed timers from the store. Call once after
177
- * the bridge server starts so deadlines set before a restart still
178
- * fire.
136
+ * Rehydrate persistent elapsed timers from the store.
179
137
  * @returns {Promise<void>}
180
138
  */
181
139
  async rearm() {
182
- if (!this.#store.loaded) await this.#store.loadData();
183
- for (const record of this.#store.index.values()) {
184
- const open = record?.open_rfcs;
185
- if (!open) continue;
186
- for (const [correlationId, rfc] of Object.entries(open)) {
187
- if (typeof rfc.due_at === "number") {
188
- this.#elapsed.schedule(correlationId, rfc.due_at);
189
- }
190
- }
140
+ const refs = await this.#store.listOpenRecesses();
141
+ for (const { correlationId, dueAt } of refs) {
142
+ this.#elapsed.schedule(correlationId, dueAt);
191
143
  }
192
144
  }
193
145
 
@@ -213,19 +165,38 @@ export class ResumeScheduler {
213
165
  }
214
166
 
215
167
  async #redispatch(ctx, correlationId, historySince) {
168
+ const rfc = ctx.open_rfcs[correlationId];
169
+ if (!rfc.requester) {
170
+ this.cancelRecess(ctx, correlationId);
171
+ this.#logger?.info?.("resume.skip", "rfc missing requester", {
172
+ correlation_id: correlationId,
173
+ });
174
+ return {
175
+ kind: "transient",
176
+ error: new Error("rfc missing requester"),
177
+ };
178
+ }
216
179
  const resumeContext = JSON.stringify({
217
180
  correlation_id: correlationId,
218
181
  history_since: historySince,
219
182
  });
220
- await this.#dispatcher.dispatch({
183
+ const result = await this.#dispatcher.dispatch({
221
184
  ctx,
222
185
  prompt: this.#prompt,
186
+ requester: rfc.requester,
223
187
  callbackMeta: this.#buildCallbackMeta(ctx),
224
188
  workflowInputs: {
225
189
  ...this.#buildResumeInputs(ctx),
226
190
  resumeContext,
227
191
  },
228
192
  });
193
+ if (result.kind !== "dispatched") {
194
+ this.cancelRecess(ctx, correlationId);
195
+ await this.#store.add(ctx);
196
+ await this.#store.flush();
197
+ if (this.#onDeclined) await this.#onDeclined(ctx, result);
198
+ }
199
+ return result;
229
200
  }
230
201
 
231
202
  async #fireElapsed(correlationId) {
@@ -233,20 +204,18 @@ export class ResumeScheduler {
233
204
  if (!found) return;
234
205
  const { ctx, rfc } = found;
235
206
  const historySince = ctx.history.slice(rfc.history_index_at_open);
236
- await this.#redispatch(ctx, correlationId, historySince);
237
- this.cancelRecess(ctx, correlationId);
238
- ctx.last_active_at = Date.now();
239
- await this.#store.add(ctx);
240
- await this.#store.flush();
207
+ const result = await this.#redispatch(ctx, correlationId, historySince);
208
+ if (result.kind === "dispatched") {
209
+ this.cancelRecess(ctx, correlationId);
210
+ ctx.last_active_at = Date.now();
211
+ await this.#store.add(ctx);
212
+ await this.#store.flush();
213
+ }
241
214
  }
242
215
 
243
216
  async #findContextWithRfc(correlationId) {
244
- if (!this.#store.loaded) await this.#store.loadData();
245
- for (const record of this.#store.index.values()) {
246
- if (record?.open_rfcs?.[correlationId]) {
247
- return { ctx: record, rfc: record.open_rfcs[correlationId] };
248
- }
249
- }
250
- return null;
217
+ const ctx = await this.#store.loadByCorrelation(correlationId);
218
+ if (!ctx) return null;
219
+ return { ctx, rfc: ctx.open_rfcs[correlationId] };
251
220
  }
252
221
  }
@@ -0,0 +1,41 @@
1
+ import { ghauth } from "@forwardimpact/libtype";
2
+
3
+ /** Maps ghauth GetToken oneof + gRPC transport into a discriminated DispatchAuth result. */
4
+ export class TokenResolver {
5
+ #client;
6
+
7
+ /** @param {object} client - ghauth gRPC client */
8
+ constructor(client) {
9
+ if (!client) throw new Error("ghauth client is required");
10
+ this.#client = client;
11
+ }
12
+
13
+ /** @returns {Promise<{kind: string, token?: string, authorizeUrl?: string, error?: Error}>} */
14
+ async resolve(surface, surfaceUserId) {
15
+ try {
16
+ const req = new ghauth.GetTokenRequest({
17
+ surface,
18
+ surface_user_id: surfaceUserId,
19
+ });
20
+ const res = await this.#client.GetToken(req);
21
+ switch (res.result) {
22
+ case "token":
23
+ return { kind: "token", token: res.token };
24
+ case "link_required":
25
+ return {
26
+ kind: "link_required",
27
+ authorizeUrl: res.link_required.authorize_url,
28
+ };
29
+ case "re_auth_required":
30
+ return { kind: "reauth_required" };
31
+ default:
32
+ return {
33
+ kind: "transient",
34
+ error: new Error("unexpected GetToken result"),
35
+ };
36
+ }
37
+ } catch (err) {
38
+ return { kind: "transient", error: err };
39
+ }
40
+ }
41
+ }
@@ -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
- }