@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 +0 -1
- package/package.json +3 -4
- package/src/callback-handler.js +1 -1
- package/src/callback-registry.js +1 -1
- package/src/dispatcher.js +18 -34
- package/src/index.js +11 -2
- package/src/resume-scheduler.js +58 -89
- package/src/token-resolver.js +41 -0
- package/src/discussion-context.js +0 -126
- package/src/origin-index.js +0 -49
package/README.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
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/
|
|
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/
|
|
48
|
+
"@forwardimpact/libmock": "^0.1.0"
|
|
50
49
|
},
|
|
51
50
|
"engines": {
|
|
52
51
|
"bun": ">=1.2.0",
|
package/src/callback-handler.js
CHANGED
|
@@ -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("./
|
|
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
|
package/src/callback-registry.js
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
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("./
|
|
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 {()
|
|
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
|
-
|
|
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 (
|
|
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.#
|
|
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
|
|
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:
|
|
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,
|
package/src/resume-scheduler.js
CHANGED
|
@@ -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("./
|
|
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
|
|
103
|
-
* `ctx.open_rfcs[correlationId]`. If the trigger has an
|
|
104
|
-
* component, schedules an in-memory timer
|
|
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
|
|
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
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
183
|
-
for (const
|
|
184
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
}
|
package/src/origin-index.js
DELETED
|
@@ -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
|
-
}
|