@forwardimpact/libbridge 0.1.2 → 0.1.4
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 +5 -4
- package/src/callback-payload.js +19 -9
- package/src/dispatcher.js +17 -33
- package/src/index.js +1 -0
- package/src/progress-ticker.js +4 -2
- package/src/resume-scheduler.js +56 -79
- package/src/token-resolver.js +41 -0
- package/src/triggers.js +20 -27
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@forwardimpact/libbridge",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
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",
|
|
@@ -42,11 +42,12 @@
|
|
|
42
42
|
"dependencies": {
|
|
43
43
|
"@forwardimpact/libindex": "^0.1.38",
|
|
44
44
|
"@forwardimpact/libstorage": "^0.1.78",
|
|
45
|
-
"@
|
|
46
|
-
"hono": "^
|
|
45
|
+
"@forwardimpact/libtype": "^0.1.0",
|
|
46
|
+
"@hono/node-server": "^2.0.4",
|
|
47
|
+
"hono": "^4.12.23"
|
|
47
48
|
},
|
|
48
49
|
"devDependencies": {
|
|
49
|
-
"@forwardimpact/
|
|
50
|
+
"@forwardimpact/libmock": "^0.1.0"
|
|
50
51
|
},
|
|
51
52
|
"engines": {
|
|
52
53
|
"bun": ">=1.2.0",
|
package/src/callback-payload.js
CHANGED
|
@@ -48,7 +48,20 @@ export function validateCallbackPayload(body) {
|
|
|
48
48
|
};
|
|
49
49
|
}
|
|
50
50
|
|
|
51
|
-
const ALLOWED_TRIGGER_KINDS = new Set([
|
|
51
|
+
const ALLOWED_TRIGGER_KINDS = new Set([
|
|
52
|
+
"missing_input",
|
|
53
|
+
"escalation_needed",
|
|
54
|
+
"elapsed",
|
|
55
|
+
]);
|
|
56
|
+
|
|
57
|
+
const TRIGGER_FIELD_VALIDATORS = {
|
|
58
|
+
replies: (raw) => {
|
|
59
|
+
const n = Number(raw);
|
|
60
|
+
return Number.isFinite(n) && n >= 0 ? n : undefined;
|
|
61
|
+
},
|
|
62
|
+
elapsed: (raw) => (typeof raw === "string" ? raw : undefined),
|
|
63
|
+
signal: (raw) => (typeof raw === "string" && raw ? raw : undefined),
|
|
64
|
+
};
|
|
52
65
|
|
|
53
66
|
/**
|
|
54
67
|
* Validate and sanitize a trigger object at the payload boundary.
|
|
@@ -63,14 +76,11 @@ function validateTrigger(raw) {
|
|
|
63
76
|
return undefined;
|
|
64
77
|
}
|
|
65
78
|
const trigger = { kind: raw.kind };
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (raw.elapsed !== undefined) {
|
|
72
|
-
if (typeof raw.elapsed !== "string") return undefined;
|
|
73
|
-
trigger.elapsed = raw.elapsed;
|
|
79
|
+
for (const [field, validate] of Object.entries(TRIGGER_FIELD_VALIDATORS)) {
|
|
80
|
+
if (raw[field] === undefined) continue;
|
|
81
|
+
const clean = validate(raw[field]);
|
|
82
|
+
if (clean === undefined) return undefined;
|
|
83
|
+
trigger[field] = clean;
|
|
74
84
|
}
|
|
75
85
|
return trigger;
|
|
76
86
|
}
|
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,7 +11,7 @@ export class Dispatcher {
|
|
|
31
11
|
#callbackBaseUrl;
|
|
32
12
|
#workflowFile;
|
|
33
13
|
#githubRepo;
|
|
34
|
-
#
|
|
14
|
+
#tokenResolver;
|
|
35
15
|
|
|
36
16
|
/**
|
|
37
17
|
* @param {object} options
|
|
@@ -41,7 +21,7 @@ export class Dispatcher {
|
|
|
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
package/src/progress-ticker.js
CHANGED
|
@@ -37,15 +37,17 @@ export class ProgressTicker {
|
|
|
37
37
|
throw new Error("tick must be a function");
|
|
38
38
|
}
|
|
39
39
|
this.stop(token);
|
|
40
|
-
const
|
|
40
|
+
const safeTick = async () => {
|
|
41
41
|
try {
|
|
42
42
|
await tick();
|
|
43
43
|
} catch {
|
|
44
44
|
this.stop(token);
|
|
45
45
|
}
|
|
46
|
-
}
|
|
46
|
+
};
|
|
47
|
+
const timer = setInterval(safeTick, this.#intervalMs);
|
|
47
48
|
timer.unref?.();
|
|
48
49
|
this.#timers.set(token, timer);
|
|
50
|
+
safeTick();
|
|
49
51
|
}
|
|
50
52
|
|
|
51
53
|
/**
|
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,6 +12,7 @@ export class ResumeScheduler {
|
|
|
51
12
|
#prompt;
|
|
52
13
|
#buildResumeInputs;
|
|
53
14
|
#buildCallbackMeta;
|
|
15
|
+
#onDeclined;
|
|
54
16
|
|
|
55
17
|
/**
|
|
56
18
|
* @param {object} options
|
|
@@ -60,6 +22,7 @@ export class ResumeScheduler {
|
|
|
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,28 +77,27 @@ 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
|
-
if (trigger.kind === "elapsed"
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
this.#elapsed.schedule(correlationId, dueAt);
|
|
127
|
-
}
|
|
91
|
+
if (trigger.kind === "elapsed" && typeof trigger.elapsed === "string") {
|
|
92
|
+
const dueAt = openedAt + parseIsoDuration(trigger.elapsed);
|
|
93
|
+
ctx.open_rfcs[correlationId].due_at = dueAt;
|
|
94
|
+
this.#elapsed.schedule(correlationId, dueAt);
|
|
128
95
|
}
|
|
129
96
|
}
|
|
130
97
|
|
|
131
98
|
/**
|
|
132
99
|
* Stop watching `correlationId`. Removes the rfc from `ctx.open_rfcs`
|
|
133
|
-
* and cancels any associated elapsed timer. Idempotent
|
|
134
|
-
* for verdicts that didn't actually recess.
|
|
100
|
+
* and cancels any associated elapsed timer. Idempotent.
|
|
135
101
|
*
|
|
136
102
|
* @param {object} ctx
|
|
137
103
|
* @param {string} correlationId
|
|
@@ -142,29 +108,21 @@ export class ResumeScheduler {
|
|
|
142
108
|
}
|
|
143
109
|
|
|
144
110
|
/**
|
|
145
|
-
* Walk `ctx.open_rfcs`. For each rfc whose trigger has fired
|
|
146
|
-
*
|
|
147
|
-
*
|
|
148
|
-
* cancel the rfc. Returns a summary so the host can decide whether to
|
|
149
|
-
* additionally fire a fresh lead session on this inbound activity.
|
|
150
|
-
*
|
|
151
|
-
* If a redispatch fails the rfc remains armed — the host's failure
|
|
152
|
-
* recovery (next inbound activity, or the next elapsed tick) will
|
|
153
|
-
* retry.
|
|
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.
|
|
154
114
|
*
|
|
155
115
|
* @param {object} ctx
|
|
156
|
-
* @returns {Promise<{
|
|
157
|
-
* fired: number,
|
|
158
|
-
* hasOpenRfc: boolean,
|
|
159
|
-
* freshDispatchAllowed: boolean,
|
|
160
|
-
* }>}
|
|
116
|
+
* @returns {Promise<{fired: number, hasOpenRfc: boolean, freshDispatchAllowed: boolean}>}
|
|
161
117
|
*/
|
|
162
118
|
async processInbound(ctx) {
|
|
163
119
|
const fired = this.#evaluate(ctx);
|
|
164
120
|
for (const { correlationId, rfc } of fired) {
|
|
165
121
|
const historySince = ctx.history.slice(rfc.history_index_at_open);
|
|
166
|
-
await this.#redispatch(ctx, correlationId, historySince);
|
|
167
|
-
|
|
122
|
+
const result = await this.#redispatch(ctx, correlationId, historySince);
|
|
123
|
+
if (result.kind === "dispatched") {
|
|
124
|
+
this.cancelRecess(ctx, correlationId);
|
|
125
|
+
}
|
|
168
126
|
}
|
|
169
127
|
const hasOpenRfc = Object.keys(ctx.open_rfcs ?? {}).length > 0;
|
|
170
128
|
return {
|
|
@@ -175,9 +133,7 @@ export class ResumeScheduler {
|
|
|
175
133
|
}
|
|
176
134
|
|
|
177
135
|
/**
|
|
178
|
-
* Rehydrate persistent elapsed timers from the store.
|
|
179
|
-
* the bridge server starts so deadlines set before a restart still
|
|
180
|
-
* fire.
|
|
136
|
+
* Rehydrate persistent elapsed timers from the store.
|
|
181
137
|
* @returns {Promise<void>}
|
|
182
138
|
*/
|
|
183
139
|
async rearm() {
|
|
@@ -204,7 +160,7 @@ export class ResumeScheduler {
|
|
|
204
160
|
const trigger = rfc.trigger;
|
|
205
161
|
if (!trigger) continue;
|
|
206
162
|
const observed = {
|
|
207
|
-
|
|
163
|
+
replies: ctx.history.length - rfc.history_index_at_open,
|
|
208
164
|
opened_at: rfc.opened_at,
|
|
209
165
|
};
|
|
210
166
|
if (evaluateTrigger(trigger, observed, Date.now()).fired) {
|
|
@@ -215,19 +171,38 @@ export class ResumeScheduler {
|
|
|
215
171
|
}
|
|
216
172
|
|
|
217
173
|
async #redispatch(ctx, correlationId, historySince) {
|
|
174
|
+
const rfc = ctx.open_rfcs[correlationId];
|
|
175
|
+
if (!rfc.requester) {
|
|
176
|
+
this.cancelRecess(ctx, correlationId);
|
|
177
|
+
this.#logger?.info?.("resume.skip", "rfc missing requester", {
|
|
178
|
+
correlation_id: correlationId,
|
|
179
|
+
});
|
|
180
|
+
return {
|
|
181
|
+
kind: "transient",
|
|
182
|
+
error: new Error("rfc missing requester"),
|
|
183
|
+
};
|
|
184
|
+
}
|
|
218
185
|
const resumeContext = JSON.stringify({
|
|
219
186
|
correlation_id: correlationId,
|
|
220
187
|
history_since: historySince,
|
|
221
188
|
});
|
|
222
|
-
await this.#dispatcher.dispatch({
|
|
189
|
+
const result = await this.#dispatcher.dispatch({
|
|
223
190
|
ctx,
|
|
224
191
|
prompt: this.#prompt,
|
|
192
|
+
requester: rfc.requester,
|
|
225
193
|
callbackMeta: this.#buildCallbackMeta(ctx),
|
|
226
194
|
workflowInputs: {
|
|
227
195
|
...this.#buildResumeInputs(ctx),
|
|
228
196
|
resumeContext,
|
|
229
197
|
},
|
|
230
198
|
});
|
|
199
|
+
if (result.kind !== "dispatched") {
|
|
200
|
+
this.cancelRecess(ctx, correlationId);
|
|
201
|
+
await this.#store.add(ctx);
|
|
202
|
+
await this.#store.flush();
|
|
203
|
+
if (this.#onDeclined) await this.#onDeclined(ctx, result);
|
|
204
|
+
}
|
|
205
|
+
return result;
|
|
231
206
|
}
|
|
232
207
|
|
|
233
208
|
async #fireElapsed(correlationId) {
|
|
@@ -235,11 +210,13 @@ export class ResumeScheduler {
|
|
|
235
210
|
if (!found) return;
|
|
236
211
|
const { ctx, rfc } = found;
|
|
237
212
|
const historySince = ctx.history.slice(rfc.history_index_at_open);
|
|
238
|
-
await this.#redispatch(ctx, correlationId, historySince);
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
213
|
+
const result = await this.#redispatch(ctx, correlationId, historySince);
|
|
214
|
+
if (result.kind === "dispatched") {
|
|
215
|
+
this.cancelRecess(ctx, correlationId);
|
|
216
|
+
ctx.last_active_at = Date.now();
|
|
217
|
+
await this.#store.add(ctx);
|
|
218
|
+
await this.#store.flush();
|
|
219
|
+
}
|
|
243
220
|
}
|
|
244
221
|
|
|
245
222
|
async #findContextWithRfc(correlationId) {
|
|
@@ -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
|
+
}
|
package/src/triggers.js
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @typedef {object} ResumeTrigger
|
|
3
|
-
* @property {"
|
|
4
|
-
* @property {number} [
|
|
5
|
-
*
|
|
6
|
-
* @property {string} [elapsed] -
|
|
7
|
-
* `"
|
|
3
|
+
* @property {"missing_input"|"escalation_needed"|"elapsed"} kind
|
|
4
|
+
* @property {number} [replies] - Required for `kind: "missing_input"`.
|
|
5
|
+
* Number of new replies on the dispatching thread needed to fire.
|
|
6
|
+
* @property {string} [elapsed] - Required for `kind: "elapsed"`.
|
|
7
|
+
* ISO-8601 duration, e.g. `"P14D"`, `"PT12H"`, `"P1DT6H"`.
|
|
8
|
+
* @property {string} [signal] - Required for `kind: "escalation_needed"`.
|
|
9
|
+
* Reserved for future use. The bridge throws when evaluating this kind
|
|
10
|
+
* until signal-based resume support lands.
|
|
8
11
|
*/
|
|
9
12
|
|
|
10
13
|
const ISO_8601_DURATION =
|
|
@@ -42,7 +45,7 @@ export function parseIsoDuration(duration) {
|
|
|
42
45
|
* Evaluate whether a resume trigger has fired.
|
|
43
46
|
*
|
|
44
47
|
* @param {ResumeTrigger} trigger
|
|
45
|
-
* @param {{
|
|
48
|
+
* @param {{replies?: number, opened_at?: number}} observed
|
|
46
49
|
* @param {number} now - ms epoch (caller-provided for testability)
|
|
47
50
|
* @returns {{fired: boolean, due_at?: number}}
|
|
48
51
|
*/
|
|
@@ -56,37 +59,27 @@ export function evaluateTrigger(trigger, observed, now) {
|
|
|
56
59
|
observed ??= {};
|
|
57
60
|
|
|
58
61
|
switch (trigger.kind) {
|
|
59
|
-
case "
|
|
60
|
-
return
|
|
62
|
+
case "missing_input":
|
|
63
|
+
return evaluateMissingInput(trigger, observed);
|
|
61
64
|
case "elapsed":
|
|
62
65
|
return evaluateElapsed(trigger, observed, now);
|
|
63
|
-
case "
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
: { fired: false };
|
|
68
|
-
const e =
|
|
69
|
-
trigger.elapsed !== undefined
|
|
70
|
-
? evaluateElapsed(trigger, observed, now)
|
|
71
|
-
: { fired: false };
|
|
72
|
-
if (r.fired || e.fired) return { fired: true };
|
|
73
|
-
return e.due_at !== undefined
|
|
74
|
-
? { fired: false, due_at: e.due_at }
|
|
75
|
-
: { fired: false };
|
|
76
|
-
}
|
|
66
|
+
case "escalation_needed":
|
|
67
|
+
throw new Error(
|
|
68
|
+
"escalation_needed is reserved for future use. See the follow-up spec for signal-based resume.",
|
|
69
|
+
);
|
|
77
70
|
default:
|
|
78
71
|
throw new Error(`Unsupported trigger kind: ${trigger.kind}`);
|
|
79
72
|
}
|
|
80
73
|
}
|
|
81
74
|
|
|
82
|
-
function
|
|
83
|
-
if (typeof trigger.
|
|
75
|
+
function evaluateMissingInput(trigger, observed) {
|
|
76
|
+
if (typeof trigger.replies !== "number" || trigger.replies < 1) {
|
|
84
77
|
throw new Error(
|
|
85
|
-
'trigger.
|
|
78
|
+
'trigger.replies must be a positive number for kind "missing_input"',
|
|
86
79
|
);
|
|
87
80
|
}
|
|
88
|
-
const seen = observed.
|
|
89
|
-
return { fired: seen >= trigger.
|
|
81
|
+
const seen = observed.replies ?? 0;
|
|
82
|
+
return { fired: seen >= trigger.replies };
|
|
90
83
|
}
|
|
91
84
|
|
|
92
85
|
function evaluateElapsed(trigger, observed, now) {
|