broapp 0.1.0 → 0.3.0
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 +13 -4
- package/package.json +10 -2
- package/src/ai/host/adapter.ts +109 -0
- package/src/ai/host/create-ai.ts +266 -0
- package/src/ai/host/fake.ts +230 -0
- package/src/ai/host/from-contract.ts +105 -0
- package/src/ai/host/index.ts +37 -0
- package/src/ai/host/registry.ts +232 -0
- package/src/ai/host/run-types.ts +14 -0
- package/src/ai/host/run.ts +540 -0
- package/src/ai/host/secrets.ts +118 -0
- package/src/ai/host/settings.ts +83 -0
- package/src/ai/host/threads.ts +366 -0
- package/src/ai/host/tool.ts +95 -0
- package/src/ai/react/AiChat.tsx +242 -0
- package/src/ai/react/AiSettings.tsx +228 -0
- package/src/ai/react/ai.css +166 -0
- package/src/ai/react/index.tsx +38 -0
- package/src/ai/react/provider.tsx +97 -0
- package/src/ai/react/use-ai-chat.ts +317 -0
- package/src/ai/react/use-ai-models.ts +70 -0
- package/src/ai/react/use-ai-settings.ts +105 -0
- package/src/ai/shared/contract.ts +247 -0
- package/src/ai/shared/index.ts +19 -0
- package/src/ai/shared/types.check.ts +71 -0
- package/src/ai/shared/types.ts +147 -0
- package/src/host/app.ts +183 -36
- package/src/host/approvals.ts +115 -0
- package/src/host/gate.ts +380 -0
- package/src/host/index.ts +25 -0
- package/src/host/paths.ts +6 -2
- package/src/host/runtime.ts +23 -2
- package/src/react/hooks.tsx +37 -3
- package/src/react/index.ts +1 -0
- package/src/shared/contract.ts +99 -2
- package/src/shared/countdown.ts +36 -0
- package/src/shared/errors.ts +66 -2
- package/src/shared/index.ts +14 -3
- package/src/shared/schema.ts +141 -28
package/src/host/app.ts
CHANGED
|
@@ -16,6 +16,7 @@ import type { BridgeStream } from '@brobridgejs/core';
|
|
|
16
16
|
|
|
17
17
|
import type {
|
|
18
18
|
AnyContract,
|
|
19
|
+
Effect,
|
|
19
20
|
OperationInput,
|
|
20
21
|
OperationName,
|
|
21
22
|
OperationOutput,
|
|
@@ -23,10 +24,18 @@ import type {
|
|
|
23
24
|
StreamName,
|
|
24
25
|
StreamParams,
|
|
25
26
|
} from '../shared/contract.ts';
|
|
26
|
-
import { splitRoute } from '../shared/contract.ts';
|
|
27
|
-
import {
|
|
27
|
+
import { assertNoReservedRoutes, effectOf, splitRoute } from '../shared/contract.ts';
|
|
28
|
+
import {
|
|
29
|
+
INTERNAL_ERROR_MESSAGE,
|
|
30
|
+
isPublicBridgeError,
|
|
31
|
+
isPublicError,
|
|
32
|
+
PublicError,
|
|
33
|
+
} from '../shared/errors.ts';
|
|
28
34
|
import { encodeEvent } from '../shared/ndjson.ts';
|
|
29
|
-
import {
|
|
35
|
+
import { isValidationError } from '../shared/schema.ts';
|
|
36
|
+
|
|
37
|
+
import { createGate } from './gate.ts';
|
|
38
|
+
import type { Channel, Envelope, ExecutionMode, Gate } from './gate.ts';
|
|
30
39
|
|
|
31
40
|
/**
|
|
32
41
|
* What an operation handler is told about its caller.
|
|
@@ -41,6 +50,14 @@ import { ValidationError } from '../shared/schema.ts';
|
|
|
41
50
|
export interface CallContext {
|
|
42
51
|
/** The route name, for logging. */
|
|
43
52
|
readonly route: string;
|
|
53
|
+
/** Correlates this call with the gate's record and any approval it needed. */
|
|
54
|
+
readonly requestId: string;
|
|
55
|
+
/** Who asked, as the adapter that received the request said so. */
|
|
56
|
+
readonly channel: Channel;
|
|
57
|
+
/** Free text naming the caller: 'tab', 'ai:<runId>', 'mcp:<client>'. */
|
|
58
|
+
readonly caller: string;
|
|
59
|
+
/** `preview` means the data directory is a copy and nothing may leave the machine. */
|
|
60
|
+
readonly mode: ExecutionMode;
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
/** A unary operation implementation. */
|
|
@@ -81,6 +98,12 @@ export interface HostLogger {
|
|
|
81
98
|
/** Options for {@link createHostApp}. */
|
|
82
99
|
export interface HostAppOptions {
|
|
83
100
|
readonly logger?: HostLogger;
|
|
101
|
+
/**
|
|
102
|
+
* The gate every call passes. An application that does not supply one gets a
|
|
103
|
+
* live gate for an unreleased application: the policy still applies, there is
|
|
104
|
+
* simply nothing recording it and no release to bind an approval to.
|
|
105
|
+
*/
|
|
106
|
+
readonly gate?: Gate;
|
|
84
107
|
}
|
|
85
108
|
|
|
86
109
|
/** A contract with implementations attached, ready to mount on a bridge. */
|
|
@@ -97,18 +120,66 @@ export interface HostApp<C extends AnyContract> {
|
|
|
97
120
|
* a developer is present to see it.
|
|
98
121
|
*/
|
|
99
122
|
mount(bridge: Bridge): void;
|
|
123
|
+
/**
|
|
124
|
+
* Run one operation directly, without the bridge. Input is validated and
|
|
125
|
+
* output is checked exactly as for a call from the browser, and the same
|
|
126
|
+
* error boundary applies. This is how the AI layer lets a model call an
|
|
127
|
+
* application's operations as tools.
|
|
128
|
+
*
|
|
129
|
+
* A route that is a stream, or one no handler implements, is a programming
|
|
130
|
+
* error rather than a call failure, so it throws `TypeError` at the call
|
|
131
|
+
* site instead of rejecting.
|
|
132
|
+
*
|
|
133
|
+
* The envelope is required, and required to be built by the adapter making
|
|
134
|
+
* the call rather than passed through from whatever asked it to. That is
|
|
135
|
+
* what stops a model from invoking an operation as the user.
|
|
136
|
+
*/
|
|
137
|
+
invoke<K extends OperationName<C>>(
|
|
138
|
+
name: K,
|
|
139
|
+
input: unknown,
|
|
140
|
+
envelope: Envelope,
|
|
141
|
+
): Promise<OperationOutput<C, K>>;
|
|
100
142
|
/** Abort every stream this app currently has open. Called during shutdown. */
|
|
101
143
|
abortAll(reason: string): void;
|
|
102
144
|
/** How many streams are running right now. */
|
|
103
145
|
readonly activeStreams: number;
|
|
146
|
+
/** The gate this application's calls pass through. */
|
|
147
|
+
readonly gate: Gate;
|
|
104
148
|
}
|
|
105
149
|
|
|
106
|
-
/**
|
|
150
|
+
/**
|
|
151
|
+
* Build the host side of an application's contract.
|
|
152
|
+
*
|
|
153
|
+
* The reserved-group check lives here rather than in `defineContract` because
|
|
154
|
+
* Broapp's own AI contract is built with `defineContract` and legitimately
|
|
155
|
+
* uses the group. An application that declares `ai.*` would collide with the
|
|
156
|
+
* AI layer on the same bridge, so it is refused while a developer is watching.
|
|
157
|
+
*/
|
|
107
158
|
export function createHostApp<C extends AnyContract>(
|
|
108
159
|
contract: C,
|
|
109
160
|
options: HostAppOptions = {},
|
|
161
|
+
): HostApp<C> {
|
|
162
|
+
assertNoReservedRoutes(contract);
|
|
163
|
+
return createReservedHostApp(contract, options);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Build a host app without the reserved-group check.
|
|
168
|
+
*
|
|
169
|
+
* Not for applications. `broapp/ai/host` uses it to mount the AI contract,
|
|
170
|
+
* which is the one contract allowed to own the `ai` group.
|
|
171
|
+
*/
|
|
172
|
+
export function createReservedHostApp<C extends AnyContract>(
|
|
173
|
+
contract: C,
|
|
174
|
+
options: HostAppOptions = {},
|
|
110
175
|
): HostApp<C> {
|
|
111
176
|
const logger: HostLogger = options.logger ?? console;
|
|
177
|
+
// An application that was not given a gate still has one. There is no
|
|
178
|
+
// ungated path into a handler, so the only question a missing option answers
|
|
179
|
+
// is what the records say, not whether the policy applies.
|
|
180
|
+
const gate: Gate =
|
|
181
|
+
options.gate ??
|
|
182
|
+
createGate({ appId: 'app', releaseId: 'unreleased', mode: 'live', logger });
|
|
112
183
|
const operations = new Map<string, OperationHandler<C, never>>();
|
|
113
184
|
const streams = new Map<string, StreamHandlerFor<C, never>>();
|
|
114
185
|
const running = new Set<AbortController>();
|
|
@@ -120,6 +191,53 @@ export function createHostApp<C extends AnyContract>(
|
|
|
120
191
|
}
|
|
121
192
|
}
|
|
122
193
|
|
|
194
|
+
/**
|
|
195
|
+
* One operation call, from the bridge or from {@link HostApp.invoke}.
|
|
196
|
+
*
|
|
197
|
+
* Both paths must validate the same way and fail the same way — an AI tool
|
|
198
|
+
* call is not more trusted than a browser call just because it originates
|
|
199
|
+
* inside the host — so there is one implementation and two callers.
|
|
200
|
+
*/
|
|
201
|
+
async function runOperation(route: string, raw: unknown, envelope: Envelope): Promise<unknown> {
|
|
202
|
+
const spec = contract.operations[route];
|
|
203
|
+
const handler = operations.get(route);
|
|
204
|
+
if (spec === undefined || handler === undefined) {
|
|
205
|
+
throw new TypeError(`operation ${JSON.stringify(route)} has no implementation`);
|
|
206
|
+
}
|
|
207
|
+
let input: unknown;
|
|
208
|
+
try {
|
|
209
|
+
input = spec.input.parse(raw);
|
|
210
|
+
} catch (cause) {
|
|
211
|
+
// A validation message names a field and a constraint from the contract
|
|
212
|
+
// the browser already has. It carries nothing the caller did not send,
|
|
213
|
+
// so it is safe to return and useful to see.
|
|
214
|
+
//
|
|
215
|
+
// This is deliberately before the gate. A call that never had valid
|
|
216
|
+
// arguments asked nobody for anything, so there is nothing to approve and
|
|
217
|
+
// nothing worth recording.
|
|
218
|
+
throw new PublicError(
|
|
219
|
+
'invalid_input',
|
|
220
|
+
isValidationError(cause) ? cause.message : 'invalid input',
|
|
221
|
+
).toBridgeError();
|
|
222
|
+
}
|
|
223
|
+
const context: CallContext = {
|
|
224
|
+
route,
|
|
225
|
+
requestId: envelope.requestId,
|
|
226
|
+
channel: envelope.channel,
|
|
227
|
+
caller: envelope.caller,
|
|
228
|
+
mode: gate.mode === 'preview' || envelope.mode === 'preview' ? 'preview' : 'live',
|
|
229
|
+
};
|
|
230
|
+
try {
|
|
231
|
+
const output = await gate.guard(
|
|
232
|
+
{ ...envelope, route, effect: spec.effect ?? envelope.effectHint ?? 'write', input },
|
|
233
|
+
() => Promise.resolve(handler(input as never, context)),
|
|
234
|
+
);
|
|
235
|
+
return spec.output.parse(output);
|
|
236
|
+
} catch (cause) {
|
|
237
|
+
throw wrap(cause, route, logger);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
123
241
|
const app: HostApp<C> = {
|
|
124
242
|
operation(name, handler) {
|
|
125
243
|
known('operation', name);
|
|
@@ -135,10 +253,34 @@ export function createHostApp<C extends AnyContract>(
|
|
|
135
253
|
return app;
|
|
136
254
|
},
|
|
137
255
|
|
|
256
|
+
invoke(name, input, envelope) {
|
|
257
|
+
// Structural mistakes surface synchronously: a stream is not invokable
|
|
258
|
+
// and a missing handler is a bug, and neither should look like a failed
|
|
259
|
+
// call to whatever is awaiting the result.
|
|
260
|
+
if (Object.prototype.hasOwnProperty.call(contract.streams, name)) {
|
|
261
|
+
throw new TypeError(`route ${JSON.stringify(name)} is a stream, which cannot be invoked`);
|
|
262
|
+
}
|
|
263
|
+
if (!Object.prototype.hasOwnProperty.call(contract.operations, name)) {
|
|
264
|
+
throw new TypeError(`operation ${JSON.stringify(name)} is not declared in the contract`);
|
|
265
|
+
}
|
|
266
|
+
if (!operations.has(name)) {
|
|
267
|
+
throw new TypeError(`operation ${JSON.stringify(name)} has no implementation`);
|
|
268
|
+
}
|
|
269
|
+
return runOperation(name, input, envelope).catch((cause: unknown) => {
|
|
270
|
+
// On the bridge, Brobridge reduces an unexpected failure to a fixed
|
|
271
|
+
// sentence on the way out. `invoke` has no transport to do that, and
|
|
272
|
+
// its caller is the AI layer, which may put what it is given into a
|
|
273
|
+
// transcript — so the same reduction is applied here.
|
|
274
|
+
throw isPublicBridgeError(cause) ? cause : new Error(INTERNAL_ERROR_MESSAGE);
|
|
275
|
+
}) as Promise<never>;
|
|
276
|
+
},
|
|
277
|
+
|
|
138
278
|
get activeStreams() {
|
|
139
279
|
return running.size;
|
|
140
280
|
},
|
|
141
281
|
|
|
282
|
+
gate,
|
|
283
|
+
|
|
142
284
|
abortAll(reason) {
|
|
143
285
|
for (const controller of running) controller.abort(new Error(reason));
|
|
144
286
|
},
|
|
@@ -156,35 +298,19 @@ export function createHostApp<C extends AnyContract>(
|
|
|
156
298
|
// dotted routes are collected back into groups here. This is the only
|
|
157
299
|
// place the two namings meet.
|
|
158
300
|
const groups = new Map<string, Record<string, unknown>>();
|
|
159
|
-
for (const
|
|
301
|
+
for (const route of operations.keys()) {
|
|
160
302
|
const { group, member } = splitRoute(route);
|
|
161
|
-
|
|
162
|
-
if (spec === undefined) continue;
|
|
303
|
+
if (contract.operations[route] === undefined) continue;
|
|
163
304
|
const service = groups.get(group) ?? {};
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
throw new PublicError(
|
|
174
|
-
'invalid_input',
|
|
175
|
-
cause instanceof ValidationError ? cause.message : 'invalid input',
|
|
176
|
-
).toBridgeError();
|
|
177
|
-
}
|
|
178
|
-
try {
|
|
179
|
-
const output = await (handler as OperationHandler<C, never>)(
|
|
180
|
-
input as never,
|
|
181
|
-
context,
|
|
182
|
-
);
|
|
183
|
-
return spec.output.parse(output);
|
|
184
|
-
} catch (cause) {
|
|
185
|
-
throw wrap(cause, route, logger);
|
|
186
|
-
}
|
|
187
|
-
};
|
|
305
|
+
// The envelope is built here and nowhere else on this path. A tab has
|
|
306
|
+
// no way to describe itself as anything other than the user, because
|
|
307
|
+
// nothing it sends is read when this is filled in.
|
|
308
|
+
service[member] = (raw: unknown): Promise<unknown> =>
|
|
309
|
+
runOperation(route, raw, {
|
|
310
|
+
requestId: crypto.randomUUID(),
|
|
311
|
+
channel: 'user',
|
|
312
|
+
caller: 'tab',
|
|
313
|
+
});
|
|
188
314
|
groups.set(group, service);
|
|
189
315
|
}
|
|
190
316
|
for (const [group, service] of groups) bridge.expose(group, service);
|
|
@@ -193,7 +319,7 @@ export function createHostApp<C extends AnyContract>(
|
|
|
193
319
|
const spec = contract.streams[route];
|
|
194
320
|
if (spec === undefined) continue;
|
|
195
321
|
bridge.stream(route, (stream: BridgeStream, streamContext: StreamContext) =>
|
|
196
|
-
runStream(stream, streamContext, route, spec, handler, running, logger),
|
|
322
|
+
runStream(stream, streamContext, route, spec, handler, running, logger, gate),
|
|
197
323
|
);
|
|
198
324
|
}
|
|
199
325
|
},
|
|
@@ -212,7 +338,7 @@ export function createHostApp<C extends AnyContract>(
|
|
|
212
338
|
* accidentally undo the reduction by wrapping the message.
|
|
213
339
|
*/
|
|
214
340
|
function wrap(cause: unknown, route: string, logger: HostLogger): unknown {
|
|
215
|
-
if (cause
|
|
341
|
+
if (isPublicError(cause)) return cause.toBridgeError();
|
|
216
342
|
logger.error(`[broapp] ${route} failed: ${String(cause instanceof Error ? cause.stack ?? cause.message : cause)}`);
|
|
217
343
|
return cause;
|
|
218
344
|
}
|
|
@@ -233,10 +359,15 @@ async function runStream<E>(
|
|
|
233
359
|
stream: BridgeStream,
|
|
234
360
|
streamContext: StreamContext,
|
|
235
361
|
route: string,
|
|
236
|
-
spec: {
|
|
362
|
+
spec: {
|
|
363
|
+
params: { parse(value: unknown): unknown };
|
|
364
|
+
event: { parse(value: unknown): unknown };
|
|
365
|
+
effect?: Effect;
|
|
366
|
+
},
|
|
237
367
|
handler: (params: never, sink: StreamSink<E>) => void | Promise<void>,
|
|
238
368
|
running: Set<AbortController>,
|
|
239
369
|
logger: HostLogger,
|
|
370
|
+
gate: Gate,
|
|
240
371
|
): Promise<void> {
|
|
241
372
|
const controller = new AbortController();
|
|
242
373
|
running.add(controller);
|
|
@@ -255,7 +386,7 @@ async function runStream<E>(
|
|
|
255
386
|
running.delete(controller);
|
|
256
387
|
throw new PublicError(
|
|
257
388
|
'invalid_input',
|
|
258
|
-
cause
|
|
389
|
+
isValidationError(cause) ? cause.message : 'invalid stream parameters',
|
|
259
390
|
).toBridgeError();
|
|
260
391
|
}
|
|
261
392
|
|
|
@@ -269,7 +400,23 @@ async function runStream<E>(
|
|
|
269
400
|
};
|
|
270
401
|
|
|
271
402
|
try {
|
|
272
|
-
|
|
403
|
+
// A stream is guarded once, when it starts. Its events are the one call's
|
|
404
|
+
// output, not a series of calls, so there is nothing further to decide
|
|
405
|
+
// once the handler is running.
|
|
406
|
+
await gate.guard(
|
|
407
|
+
{
|
|
408
|
+
requestId: crypto.randomUUID(),
|
|
409
|
+
channel: 'user',
|
|
410
|
+
caller: 'tab',
|
|
411
|
+
signal: controller.signal,
|
|
412
|
+
route,
|
|
413
|
+
effect: effectOf(spec),
|
|
414
|
+
input: params,
|
|
415
|
+
},
|
|
416
|
+
async () => {
|
|
417
|
+
await handler(params as never, sink);
|
|
418
|
+
},
|
|
419
|
+
);
|
|
273
420
|
if (!controller.signal.aborted) await stream.end();
|
|
274
421
|
} catch (cause) {
|
|
275
422
|
// A cancelled stream is not a fault: the browser asked for it, the stream
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The table a waiting question and a later answer meet in.
|
|
3
|
+
*
|
|
4
|
+
* An approver has to ask somebody, and the somebody is at the other end of a
|
|
5
|
+
* route: a chat stream, a launcher tab, an MCP client's own UI. So the ask and
|
|
6
|
+
* the answer are two separate calls, and something has to hold the question in
|
|
7
|
+
* between. That is all this is — with one property that matters more than the
|
|
8
|
+
* bookkeeping.
|
|
9
|
+
*
|
|
10
|
+
* An answer must name the question it is answering. A `requestId` alone would
|
|
11
|
+
* let an answer meant for one call approve a different one that happened to be
|
|
12
|
+
* pending under the same identifier after a rebuild, or after a model changed
|
|
13
|
+
* its arguments between the question and the click. So an answer may carry the
|
|
14
|
+
* release and the arguments hash it believes it is approving, and a value that
|
|
15
|
+
* does not match the pending question is a denial, not a near miss.
|
|
16
|
+
*/
|
|
17
|
+
import type { HostLogger } from './app.ts';
|
|
18
|
+
import type { ApprovalQuestion, Approver } from './gate.ts';
|
|
19
|
+
|
|
20
|
+
/** What the route that received a person's answer passes back in. */
|
|
21
|
+
export interface ApprovalAnswer {
|
|
22
|
+
readonly requestId: string;
|
|
23
|
+
readonly approved: boolean;
|
|
24
|
+
/** When given, must equal the pending question's value or the answer is a mismatch. */
|
|
25
|
+
readonly releaseId?: string;
|
|
26
|
+
readonly argumentsHash?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** What happened to an answer. */
|
|
30
|
+
export type AnswerResult = 'accepted' | 'unknown' | 'mismatch';
|
|
31
|
+
|
|
32
|
+
/** An {@link Approver} whose answers arrive later, over some other route. */
|
|
33
|
+
export interface PendingApprovals extends Approver {
|
|
34
|
+
/** Called by the route that receives the person's answer. */
|
|
35
|
+
answer(answer: ApprovalAnswer): AnswerResult;
|
|
36
|
+
/**
|
|
37
|
+
* Questions currently waiting, for a UI to show. `input` is shown as-is;
|
|
38
|
+
* adapters must not put secrets in inputs.
|
|
39
|
+
*/
|
|
40
|
+
readonly pending: readonly ApprovalQuestion[];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** One question, and the callback that settles it. */
|
|
44
|
+
interface Waiting {
|
|
45
|
+
readonly question: ApprovalQuestion;
|
|
46
|
+
settle(approved: boolean): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build a pending-approval table. */
|
|
50
|
+
export function createPendingApprovals(logger?: HostLogger): PendingApprovals {
|
|
51
|
+
const log: HostLogger = logger ?? console;
|
|
52
|
+
const waiting = new Map<string, Waiting>();
|
|
53
|
+
|
|
54
|
+
return {
|
|
55
|
+
ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean> {
|
|
56
|
+
// Two questions under one identifier would make an answer ambiguous, and
|
|
57
|
+
// an ambiguous approval is the one thing this table exists to prevent.
|
|
58
|
+
// The caller chose the identifier, so a collision is its bug.
|
|
59
|
+
if (waiting.has(question.requestId)) {
|
|
60
|
+
throw new TypeError(
|
|
61
|
+
`an approval for request ${JSON.stringify(question.requestId)} is already pending`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
return new Promise<boolean>((resolve) => {
|
|
65
|
+
let settled = false;
|
|
66
|
+
const finish = (approved: boolean): void => {
|
|
67
|
+
if (settled) return;
|
|
68
|
+
settled = true;
|
|
69
|
+
waiting.delete(question.requestId);
|
|
70
|
+
signal.removeEventListener('abort', onAbort);
|
|
71
|
+
resolve(approved);
|
|
72
|
+
};
|
|
73
|
+
// A question nobody answers is a denial, not a hung call: the person
|
|
74
|
+
// may have closed the tab, and nothing may run unattended.
|
|
75
|
+
const onAbort = (): void => finish(false);
|
|
76
|
+
signal.addEventListener('abort', onAbort, { once: true });
|
|
77
|
+
if (signal.aborted) finish(false);
|
|
78
|
+
else waiting.set(question.requestId, { question, settle: finish });
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
|
|
82
|
+
answer(answer: ApprovalAnswer): AnswerResult {
|
|
83
|
+
const entry = waiting.get(answer.requestId);
|
|
84
|
+
// Nobody waiting covers both a stale answer and a second answer for a
|
|
85
|
+
// question that has already been settled and removed.
|
|
86
|
+
if (entry === undefined) return 'unknown';
|
|
87
|
+
|
|
88
|
+
const wrong =
|
|
89
|
+
answer.releaseId !== undefined && answer.releaseId !== entry.question.releaseId
|
|
90
|
+
? 'releaseId'
|
|
91
|
+
: answer.argumentsHash !== undefined &&
|
|
92
|
+
answer.argumentsHash !== entry.question.argumentsHash
|
|
93
|
+
? 'argumentsHash'
|
|
94
|
+
: null;
|
|
95
|
+
if (wrong !== null) {
|
|
96
|
+
// An answer about a different release or different arguments is not
|
|
97
|
+
// this question's answer. It is treated as a denial rather than
|
|
98
|
+
// ignored, so the caller stops waiting instead of hanging until the
|
|
99
|
+
// deadline for a question that will never be answered correctly.
|
|
100
|
+
log.warn(
|
|
101
|
+
`[broapp] an approval for ${entry.question.route} named a different ${wrong} and was refused`,
|
|
102
|
+
);
|
|
103
|
+
entry.settle(false);
|
|
104
|
+
return 'mismatch';
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
entry.settle(answer.approved);
|
|
108
|
+
return 'accepted';
|
|
109
|
+
},
|
|
110
|
+
|
|
111
|
+
get pending(): readonly ApprovalQuestion[] {
|
|
112
|
+
return [...waiting.values()].map((entry) => entry.question);
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|