@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@forwardimpact/libbridge",
3
- "version": "0.1.2",
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
- "@hono/node-server": "^2.0.3",
46
- "hono": "^4.12.22"
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/libharness": "^0.1.21"
50
+ "@forwardimpact/libmock": "^0.1.0"
50
51
  },
51
52
  "engines": {
52
53
  "bun": ">=1.2.0",
@@ -48,7 +48,20 @@ export function validateCallbackPayload(body) {
48
48
  };
49
49
  }
50
50
 
51
- const ALLOWED_TRIGGER_KINDS = new Set(["responses", "elapsed", "any"]);
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
- if (raw.responses !== undefined) {
67
- const n = Number(raw.responses);
68
- if (!Number.isFinite(n) || n < 0) return undefined;
69
- trigger.responses = n;
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
- #getGithubToken;
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 {() => 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
@@ -24,6 +24,7 @@ export {
24
24
  DEFAULT_TYPING_VERBS,
25
25
  } from "./acknowledgement.js";
26
26
  export { Dispatcher } from "./dispatcher.js";
27
+ export { TokenResolver } from "./token-resolver.js";
27
28
  export {
28
29
  CallbackHandlerError,
29
30
  createCallbackHandler,
@@ -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 timer = setInterval(async () => {
40
+ const safeTick = async () => {
41
41
  try {
42
42
  await tick();
43
43
  } catch {
44
44
  this.stop(token);
45
45
  }
46
- }, this.#intervalMs);
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
  /**
@@ -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 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,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" || trigger.kind === "either") {
123
- if (typeof trigger.elapsed === "string") {
124
- const dueAt = openedAt + parseIsoDuration(trigger.elapsed);
125
- ctx.open_rfcs[correlationId].due_at = dueAt;
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 — safe to call
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 given the
146
- * current history length and clock, redispatch the workflow with
147
- * `resume_context` linking back to the original correlation id, then
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
- this.cancelRecess(ctx, correlationId);
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. Call once after
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
- responses: ctx.history.length - rfc.history_index_at_open,
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
- this.cancelRecess(ctx, correlationId);
240
- ctx.last_active_at = Date.now();
241
- await this.#store.add(ctx);
242
- await this.#store.flush();
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 {"responses"|"elapsed"|"either"} kind
4
- * @property {number} [responses] - Number of new responses needed.
5
- * For `kind: "either"`, optional alongside `elapsed`.
6
- * @property {string} [elapsed] - ISO-8601 duration, e.g. `"P14D"`, `"PT12H"`,
7
- * `"P1DT6H"`. Required for `kind: "elapsed"`; optional for `"either"`.
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 {{responses?: number, opened_at?: number}} observed
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 "responses":
60
- return evaluateResponses(trigger, observed);
62
+ case "missing_input":
63
+ return evaluateMissingInput(trigger, observed);
61
64
  case "elapsed":
62
65
  return evaluateElapsed(trigger, observed, now);
63
- case "either": {
64
- const r =
65
- trigger.responses !== undefined
66
- ? evaluateResponses(trigger, observed)
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 evaluateResponses(trigger, observed) {
83
- if (typeof trigger.responses !== "number" || trigger.responses < 1) {
75
+ function evaluateMissingInput(trigger, observed) {
76
+ if (typeof trigger.replies !== "number" || trigger.replies < 1) {
84
77
  throw new Error(
85
- 'trigger.responses must be a positive number for kind "responses"',
78
+ 'trigger.replies must be a positive number for kind "missing_input"',
86
79
  );
87
80
  }
88
- const seen = observed.responses ?? 0;
89
- return { fired: seen >= trigger.responses };
81
+ const seen = observed.replies ?? 0;
82
+ return { fired: seen >= trigger.replies };
90
83
  }
91
84
 
92
85
  function evaluateElapsed(trigger, observed, now) {