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/gate.ts
ADDED
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The execution gate.
|
|
3
|
+
*
|
|
4
|
+
* One place decides whether a call runs. Every path into an application's
|
|
5
|
+
* operations — a click in the browser, a model's tool call, an external agent
|
|
6
|
+
* over MCP, a workflow step — arrives here with an envelope saying who is
|
|
7
|
+
* asking, and leaves with a record saying what happened. There is no second
|
|
8
|
+
* door: an adapter that does not call `guard` is a bug, not a shortcut.
|
|
9
|
+
*
|
|
10
|
+
* The policy is deliberately three rows and no configuration language. What
|
|
11
|
+
* makes it trustworthy is not its expressiveness but where the inputs come
|
|
12
|
+
* from: the `channel` is set by the trusted adapter that received the request
|
|
13
|
+
* and never read out of model output, tool arguments or anything a browser
|
|
14
|
+
* sent. A model cannot claim to be the user, because nothing it can write is
|
|
15
|
+
* consulted when the channel is chosen.
|
|
16
|
+
*/
|
|
17
|
+
import { createHash } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
import type { Effect } from '../shared/contract.ts';
|
|
20
|
+
import { INTERNAL_ERROR_MESSAGE, isPublicError, publicError } from '../shared/errors.ts';
|
|
21
|
+
|
|
22
|
+
import type { HostLogger } from './app.ts';
|
|
23
|
+
|
|
24
|
+
/** Who is asking. Set by an adapter, never by the thing being adapted. */
|
|
25
|
+
export type Channel = 'user' | 'ai' | 'mcp' | 'workflow';
|
|
26
|
+
/** `preview` runs against a copy of the data, so nothing may leave the machine. */
|
|
27
|
+
export type ExecutionMode = 'live' | 'preview';
|
|
28
|
+
/** How the gate answered. */
|
|
29
|
+
export type Decision = 'allowed' | 'confirmed' | 'denied' | 'refused';
|
|
30
|
+
/** How the call itself ended, once it was allowed to start. */
|
|
31
|
+
export type Outcome = 'succeeded' | 'failed' | 'cancelled';
|
|
32
|
+
/** What the policy says about one (channel, effect, mode). */
|
|
33
|
+
export type PolicyVerdict = 'allow' | 'confirm' | 'refuse';
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Who is asking, on behalf of what, and how the answer may be obtained.
|
|
37
|
+
*
|
|
38
|
+
* Built by the trusted adapter that received the request — the bridge
|
|
39
|
+
* handler, the AI runner, the MCP adapter, the workflow runner — and never
|
|
40
|
+
* from anything a model, a browser or an MCP client sent. That is the whole
|
|
41
|
+
* basis of the policy: a model cannot claim to be the user.
|
|
42
|
+
*/
|
|
43
|
+
export interface Envelope {
|
|
44
|
+
/** Unique per request. Correlates the question, the answer and the record. */
|
|
45
|
+
readonly requestId: string;
|
|
46
|
+
readonly channel: Channel;
|
|
47
|
+
/** Free text for records: 'tab', 'ai:<runId>', 'mcp:<client>', 'workflow:<id>'. */
|
|
48
|
+
readonly caller: string;
|
|
49
|
+
/** May only tighten the gate's default: `preview` wins over `live`. */
|
|
50
|
+
readonly mode?: ExecutionMode;
|
|
51
|
+
readonly signal?: AbortSignal;
|
|
52
|
+
/** Who to ask when the policy says `confirm`. Absent means nobody, so denied. */
|
|
53
|
+
readonly approver?: Approver;
|
|
54
|
+
/**
|
|
55
|
+
* The effect an adapter resolved for a route the contract leaves silent.
|
|
56
|
+
*
|
|
57
|
+
* It fills a gap and never widens one: a route that declares an effect keeps
|
|
58
|
+
* it, whatever an adapter says. The AI layer's allow list is the reason this
|
|
59
|
+
* exists — an operation named under `read` there is a read tool even though
|
|
60
|
+
* an undeclared route is `write` everywhere else, and the gate has to be told
|
|
61
|
+
* the same thing the model was.
|
|
62
|
+
*/
|
|
63
|
+
readonly effectHint?: Effect;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** One decision to make: an envelope plus the route it is about. */
|
|
67
|
+
export interface GuardRequest extends Envelope {
|
|
68
|
+
readonly route: string;
|
|
69
|
+
readonly effect: Effect;
|
|
70
|
+
/** Already validated by the contract. Shown to the approver and hashed. */
|
|
71
|
+
readonly input: unknown;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** What a person is asked, and what their answer has to name to count. */
|
|
75
|
+
export interface ApprovalQuestion {
|
|
76
|
+
readonly requestId: string;
|
|
77
|
+
readonly channel: Channel;
|
|
78
|
+
readonly caller: string;
|
|
79
|
+
readonly appId: string;
|
|
80
|
+
readonly releaseId: string;
|
|
81
|
+
readonly route: string;
|
|
82
|
+
readonly effect: Effect;
|
|
83
|
+
readonly input: unknown;
|
|
84
|
+
readonly argumentsHash: string;
|
|
85
|
+
/** When the gate started considering this call. */
|
|
86
|
+
readonly askedAt: number;
|
|
87
|
+
/**
|
|
88
|
+
* When an unanswered question stops waiting.
|
|
89
|
+
*
|
|
90
|
+
* Carried on the question rather than left to the asker so that every place
|
|
91
|
+
* a person is shown one — a strip in a tab, a card in a chat, an MCP
|
|
92
|
+
* client's own dialogue — can say how long they have without knowing which
|
|
93
|
+
* gate asked or how it was configured. A question with no visible deadline
|
|
94
|
+
* is one people answer after it has already been refused.
|
|
95
|
+
*/
|
|
96
|
+
readonly expiresAt: number;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Somewhere a question can be put to a person. */
|
|
100
|
+
export interface Approver {
|
|
101
|
+
/**
|
|
102
|
+
* Ask a person. Resolves `true` only for an approval that names this
|
|
103
|
+
* question. Must resolve `false` when `signal` aborts.
|
|
104
|
+
*/
|
|
105
|
+
ask(question: ApprovalQuestion, signal: AbortSignal): Promise<boolean>;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** What the gate writes down about one decision, whatever it was. */
|
|
109
|
+
export interface ExecutionRecord extends ApprovalQuestion {
|
|
110
|
+
readonly mode: ExecutionMode;
|
|
111
|
+
readonly decision: Decision;
|
|
112
|
+
readonly outcome?: Outcome;
|
|
113
|
+
/**
|
|
114
|
+
* What the call returned, present only when it succeeded.
|
|
115
|
+
*
|
|
116
|
+
* A recorder that keeps this can show a person what actually happened, and
|
|
117
|
+
* can draft a workflow from a run that worked. It is host-controlled output,
|
|
118
|
+
* not something a caller supplied — but a recorder that persists it is
|
|
119
|
+
* storing application data and owns whatever redaction that needs.
|
|
120
|
+
*/
|
|
121
|
+
readonly output?: unknown;
|
|
122
|
+
/** A sentence safe to show. Never a stack, never a secret. */
|
|
123
|
+
readonly error?: string;
|
|
124
|
+
readonly startedAt: number;
|
|
125
|
+
readonly endedAt: number;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Where records go. A run store, a log, or nothing. */
|
|
129
|
+
export interface Recorder {
|
|
130
|
+
record(record: ExecutionRecord): void;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** Options for {@link createGate}. */
|
|
134
|
+
export interface GateOptions {
|
|
135
|
+
readonly appId: string;
|
|
136
|
+
readonly releaseId: string;
|
|
137
|
+
readonly recorder?: Recorder;
|
|
138
|
+
/** Default 120_000. */
|
|
139
|
+
readonly confirmTimeoutMs?: number;
|
|
140
|
+
/** Default 'live'. A preview child passes 'preview'. */
|
|
141
|
+
readonly mode?: ExecutionMode;
|
|
142
|
+
readonly logger?: HostLogger;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** The one door. */
|
|
146
|
+
export interface Gate {
|
|
147
|
+
readonly appId: string;
|
|
148
|
+
readonly releaseId: string;
|
|
149
|
+
readonly mode: ExecutionMode;
|
|
150
|
+
/** True while the gate is holding back everything that changes anything. */
|
|
151
|
+
readonly paused: boolean;
|
|
152
|
+
/**
|
|
153
|
+
* Stop admitting work that changes anything.
|
|
154
|
+
*
|
|
155
|
+
* For draining before an update: the application goes on answering questions
|
|
156
|
+
* while it is being replaced, and stops accepting new writes. `reason` is
|
|
157
|
+
* shown to whoever asked, so it should say what is happening rather than
|
|
158
|
+
* that something went wrong.
|
|
159
|
+
*/
|
|
160
|
+
pause(reason: string): void;
|
|
161
|
+
/** Admit everything again. */
|
|
162
|
+
resume(): void;
|
|
163
|
+
/**
|
|
164
|
+
* Decide, ask if needed, record, run.
|
|
165
|
+
*
|
|
166
|
+
* Throws `PublicError` with code `rejected` when the policy refuses or a
|
|
167
|
+
* person declines, times out or the request is cancelled while waiting.
|
|
168
|
+
* `run` is called at most once and only after an allow or a confirmed
|
|
169
|
+
* approval.
|
|
170
|
+
*/
|
|
171
|
+
guard<T>(request: GuardRequest, run: (signal: AbortSignal) => Promise<T>): Promise<T>;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** How long a question waits when the gate is not told otherwise. */
|
|
175
|
+
const DEFAULT_CONFIRM_TIMEOUT_MS = 120_000;
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The whole v1 policy. Pure, exported so the table can be tested row by row.
|
|
179
|
+
*
|
|
180
|
+
* Read for anybody, always: nothing changes, so there is nothing to approve.
|
|
181
|
+
* The owner's own click is allowed to write, because the owner is who the
|
|
182
|
+
* application belongs to. Every other channel is an agent acting on their
|
|
183
|
+
* behalf and asks first. `preview` refuses `external` outright for everyone:
|
|
184
|
+
* a preview runs against a copy of the data, and a copy of the data is not a
|
|
185
|
+
* copy of the world — a message sent from a preview is sent for real.
|
|
186
|
+
*/
|
|
187
|
+
export function decide(channel: Channel, effect: Effect, mode: ExecutionMode): PolicyVerdict {
|
|
188
|
+
if (mode === 'preview' && effect === 'external') return 'refuse';
|
|
189
|
+
if (effect === 'read') return 'allow';
|
|
190
|
+
if (channel === 'user') return 'allow';
|
|
191
|
+
return 'confirm';
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Canonical JSON (object keys sorted at every depth) hashed with sha256, hex,
|
|
196
|
+
* first 32 characters.
|
|
197
|
+
*
|
|
198
|
+
* Sorting is what makes the hash an identity rather than a formatting
|
|
199
|
+
* accident: the question a person was shown and the answer they gave have to
|
|
200
|
+
* be about the same arguments, and two encoders of the same object must not
|
|
201
|
+
* disagree about that.
|
|
202
|
+
*/
|
|
203
|
+
export function argumentsHash(input: unknown): string {
|
|
204
|
+
return createHash('sha256').update(canonicalJson(input)).digest('hex').slice(0, 32);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* JSON with every object's keys in sorted order, at every depth.
|
|
209
|
+
*
|
|
210
|
+
* Exported because more than one thing needs the same answer to "are these two
|
|
211
|
+
* values the same value": the gate, for an approval, and Autoapp's release
|
|
212
|
+
* identity, for a contract. Two sorters would eventually disagree.
|
|
213
|
+
*/
|
|
214
|
+
export function canonicalJson(value: unknown): string {
|
|
215
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value) ?? 'null';
|
|
216
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
217
|
+
const entries = Object.entries(value as Record<string, unknown>)
|
|
218
|
+
.filter(([, member]) => member !== undefined)
|
|
219
|
+
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
220
|
+
return `{${entries.map(([key, member]) => `${JSON.stringify(key)}:${canonicalJson(member)}`).join(',')}}`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** A signal that never aborts, for a request that brought none. */
|
|
224
|
+
function neverAborts(): AbortSignal {
|
|
225
|
+
return new AbortController().signal;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Build the gate one application runs behind. */
|
|
229
|
+
export function createGate(options: GateOptions): Gate {
|
|
230
|
+
const logger: HostLogger = options.logger ?? console;
|
|
231
|
+
const confirmTimeoutMs = options.confirmTimeoutMs ?? DEFAULT_CONFIRM_TIMEOUT_MS;
|
|
232
|
+
const gateMode: ExecutionMode = options.mode ?? 'live';
|
|
233
|
+
const recorder = options.recorder;
|
|
234
|
+
/** The reason writes are being held back, or `null` when they are not. */
|
|
235
|
+
let pausedReason: string | null = null;
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Write one record.
|
|
239
|
+
*
|
|
240
|
+
* A recorder that throws is a broken diagnostic, not a reason to fail the
|
|
241
|
+
* user's call, so the throw stops here and is logged instead.
|
|
242
|
+
*/
|
|
243
|
+
function write(record: ExecutionRecord): void {
|
|
244
|
+
if (recorder === undefined) return;
|
|
245
|
+
try {
|
|
246
|
+
recorder.record(record);
|
|
247
|
+
} catch (cause) {
|
|
248
|
+
logger.error(
|
|
249
|
+
`[broapp] the execution recorder failed for ${record.route}: ${String(cause instanceof Error ? (cause.stack ?? cause.message) : cause)}`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const gate: Gate = {
|
|
255
|
+
appId: options.appId,
|
|
256
|
+
releaseId: options.releaseId,
|
|
257
|
+
mode: gateMode,
|
|
258
|
+
|
|
259
|
+
get paused() {
|
|
260
|
+
return pausedReason !== null;
|
|
261
|
+
},
|
|
262
|
+
|
|
263
|
+
pause(reason: string) {
|
|
264
|
+
pausedReason = reason;
|
|
265
|
+
},
|
|
266
|
+
|
|
267
|
+
resume() {
|
|
268
|
+
pausedReason = null;
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
async guard<T>(request: GuardRequest, run: (signal: AbortSignal) => Promise<T>): Promise<T> {
|
|
272
|
+
// A pause is not a decision about this call — it is the application
|
|
273
|
+
// declining to be asked at all for a moment — so nothing is recorded and
|
|
274
|
+
// the code is `unavailable` rather than `rejected`. Reads still run: a
|
|
275
|
+
// draining application that stopped answering questions would look
|
|
276
|
+
// broken to whoever is still looking at it.
|
|
277
|
+
if (pausedReason !== null && request.effect !== 'read') {
|
|
278
|
+
throw publicError.unavailable(pausedReason);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// Either side may tighten to `preview`; neither may loosen back. A live
|
|
282
|
+
// gate asked to preview obeys, and a preview gate handed `live` in an
|
|
283
|
+
// envelope stays a preview.
|
|
284
|
+
const mode: ExecutionMode =
|
|
285
|
+
gateMode === 'preview' || request.mode === 'preview' ? 'preview' : 'live';
|
|
286
|
+
const at = Date.now();
|
|
287
|
+
const question: ApprovalQuestion = {
|
|
288
|
+
requestId: request.requestId,
|
|
289
|
+
channel: request.channel,
|
|
290
|
+
caller: request.caller,
|
|
291
|
+
appId: gate.appId,
|
|
292
|
+
releaseId: gate.releaseId,
|
|
293
|
+
route: request.route,
|
|
294
|
+
effect: request.effect,
|
|
295
|
+
input: request.input,
|
|
296
|
+
argumentsHash: argumentsHash(request.input),
|
|
297
|
+
// Filled in for every call, not only the ones that ask. A record of an
|
|
298
|
+
// allowed call carries the window it would have had, which costs two
|
|
299
|
+
// numbers and saves a reader working out whether one was even offered.
|
|
300
|
+
askedAt: at,
|
|
301
|
+
expiresAt: at + confirmTimeoutMs,
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
const verdict = decide(request.channel, request.effect, mode);
|
|
305
|
+
if (verdict === 'refuse') {
|
|
306
|
+
write({ ...question, mode, decision: 'refused', startedAt: at, endedAt: Date.now() });
|
|
307
|
+
throw publicError.rejected(`${request.route} is not allowed in preview`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let decision: Decision = 'allowed';
|
|
311
|
+
if (verdict === 'confirm') {
|
|
312
|
+
const approver = request.approver;
|
|
313
|
+
if (approver === undefined) {
|
|
314
|
+
write({ ...question, mode, decision: 'denied', startedAt: at, endedAt: Date.now() });
|
|
315
|
+
throw publicError.rejected(`${request.route} needs approval and nobody can give it`);
|
|
316
|
+
}
|
|
317
|
+
// The question's own deadline is separate from the request's: a person
|
|
318
|
+
// who never answers must not hold a tool call open forever, and a
|
|
319
|
+
// cancelled request must stop asking.
|
|
320
|
+
const asking = new AbortController();
|
|
321
|
+
const timer = setTimeout(() => asking.abort(new Error('the question timed out')), confirmTimeoutMs);
|
|
322
|
+
const relay = (): void => asking.abort(new Error('the request was cancelled'));
|
|
323
|
+
request.signal?.addEventListener('abort', relay, { once: true });
|
|
324
|
+
if (request.signal?.aborted === true) relay();
|
|
325
|
+
let approved: boolean;
|
|
326
|
+
try {
|
|
327
|
+
approved = await approver.ask(question, asking.signal);
|
|
328
|
+
} finally {
|
|
329
|
+
clearTimeout(timer);
|
|
330
|
+
request.signal?.removeEventListener('abort', relay);
|
|
331
|
+
}
|
|
332
|
+
if (!approved) {
|
|
333
|
+
const cancelled = request.signal?.aborted === true;
|
|
334
|
+
write({
|
|
335
|
+
...question,
|
|
336
|
+
mode,
|
|
337
|
+
decision: 'denied',
|
|
338
|
+
...(cancelled ? { outcome: 'cancelled' as const } : {}),
|
|
339
|
+
startedAt: at,
|
|
340
|
+
endedAt: Date.now(),
|
|
341
|
+
});
|
|
342
|
+
throw publicError.rejected(`${request.route} was not approved`);
|
|
343
|
+
}
|
|
344
|
+
decision = 'confirmed';
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const signal = request.signal ?? neverAborts();
|
|
348
|
+
const startedAt = Date.now();
|
|
349
|
+
try {
|
|
350
|
+
const value = await run(signal);
|
|
351
|
+
write({
|
|
352
|
+
...question,
|
|
353
|
+
mode,
|
|
354
|
+
decision,
|
|
355
|
+
outcome: 'succeeded',
|
|
356
|
+
output: value,
|
|
357
|
+
startedAt,
|
|
358
|
+
endedAt: Date.now(),
|
|
359
|
+
});
|
|
360
|
+
return value;
|
|
361
|
+
} catch (cause) {
|
|
362
|
+
// A `PublicError` was written for whoever is watching and keeps its
|
|
363
|
+
// words. Anything else may name a path, a query or a token, so the
|
|
364
|
+
// record gets the same fixed sentence the browser would have seen.
|
|
365
|
+
write({
|
|
366
|
+
...question,
|
|
367
|
+
mode,
|
|
368
|
+
decision,
|
|
369
|
+
outcome: signal.aborted ? 'cancelled' : 'failed',
|
|
370
|
+
error: isPublicError(cause) ? cause.message : INTERNAL_ERROR_MESSAGE,
|
|
371
|
+
startedAt,
|
|
372
|
+
endedAt: Date.now(),
|
|
373
|
+
});
|
|
374
|
+
throw cause;
|
|
375
|
+
}
|
|
376
|
+
},
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
return gate;
|
|
380
|
+
}
|
package/src/host/index.ts
CHANGED
|
@@ -6,6 +6,11 @@
|
|
|
6
6
|
* the import would fail loudly — which is the intended outcome.
|
|
7
7
|
*/
|
|
8
8
|
export { createHostApp } from './app.ts';
|
|
9
|
+
/**
|
|
10
|
+
* Not for applications: it skips the check that refuses Broapp's reserved
|
|
11
|
+
* route groups. Only `broapp/ai/host` and `broapp-autoapp/host` should use it.
|
|
12
|
+
*/
|
|
13
|
+
export { createReservedHostApp } from './app.ts';
|
|
9
14
|
export type {
|
|
10
15
|
CallContext,
|
|
11
16
|
HostApp,
|
|
@@ -16,6 +21,26 @@ export type {
|
|
|
16
21
|
StreamSink,
|
|
17
22
|
} from './app.ts';
|
|
18
23
|
|
|
24
|
+
export { argumentsHash, canonicalJson, createGate, decide } from './gate.ts';
|
|
25
|
+
export type {
|
|
26
|
+
Approver,
|
|
27
|
+
ApprovalQuestion,
|
|
28
|
+
Channel,
|
|
29
|
+
Decision,
|
|
30
|
+
Envelope,
|
|
31
|
+
ExecutionMode,
|
|
32
|
+
ExecutionRecord,
|
|
33
|
+
Gate,
|
|
34
|
+
GateOptions,
|
|
35
|
+
GuardRequest,
|
|
36
|
+
Outcome,
|
|
37
|
+
PolicyVerdict,
|
|
38
|
+
Recorder,
|
|
39
|
+
} from './gate.ts';
|
|
40
|
+
|
|
41
|
+
export { createPendingApprovals } from './approvals.ts';
|
|
42
|
+
export type { AnswerResult, ApprovalAnswer, PendingApprovals } from './approvals.ts';
|
|
43
|
+
|
|
19
44
|
export { startApp } from './runtime.ts';
|
|
20
45
|
export type { LifecycleMode, RunningApp, ShutdownReason, StartAppOptions } from './runtime.ts';
|
|
21
46
|
|
package/src/host/paths.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* it to the application.
|
|
10
10
|
*/
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
|
-
import { join } from 'node:path';
|
|
12
|
+
import { join, resolve } from 'node:path';
|
|
13
13
|
import { mkdirSync } from 'node:fs';
|
|
14
14
|
|
|
15
15
|
/** The environment variable that overrides the resolved directory. */
|
|
@@ -31,7 +31,11 @@ export const DATA_DIR_ENV = 'BROAPP_DATA_DIR';
|
|
|
31
31
|
*/
|
|
32
32
|
export function dataDir(appName: string, env: NodeJS.ProcessEnv = process.env): string {
|
|
33
33
|
const override = env[DATA_DIR_ENV];
|
|
34
|
-
|
|
34
|
+
// Absolute, whatever was given. A relative override is convenient at a shell
|
|
35
|
+
// (`BROAPP_DATA_DIR=./run`), but paths under it are handed to child
|
|
36
|
+
// processes and to `import()`, and a relative path in an import specifier is
|
|
37
|
+
// a package name, not a file.
|
|
38
|
+
if (override !== undefined && override !== '') return resolve(override);
|
|
35
39
|
|
|
36
40
|
const platform = process.platform;
|
|
37
41
|
if (platform === 'win32') {
|
package/src/host/runtime.ts
CHANGED
|
@@ -66,6 +66,16 @@ export interface RunningApp {
|
|
|
66
66
|
readonly done: Promise<number>;
|
|
67
67
|
/** Stop it. Safe to call more than once. */
|
|
68
68
|
stop(reason?: ShutdownReason): Promise<void>;
|
|
69
|
+
/**
|
|
70
|
+
* True while at least one tab is actually connected.
|
|
71
|
+
*
|
|
72
|
+
* The same question the idle logic asks, exposed for a supervisor that has to
|
|
73
|
+
* report whether anybody is looking at this application. Deliberately not
|
|
74
|
+
* "a session exists": Brobridge retains a session for a minute after its
|
|
75
|
+
* socket drops so a reconnecting tab can resume, and a closed tab would go on
|
|
76
|
+
* looking attached for that whole minute.
|
|
77
|
+
*/
|
|
78
|
+
readonly attached: boolean;
|
|
69
79
|
}
|
|
70
80
|
|
|
71
81
|
const POLL_INTERVAL_MS = 1_000;
|
|
@@ -159,12 +169,16 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
|
|
|
159
169
|
});
|
|
160
170
|
}
|
|
161
171
|
|
|
172
|
+
/** Whether any endpoint is open right now. One definition, two readers. */
|
|
173
|
+
const isAttached = (): boolean =>
|
|
174
|
+
bridge.sessions.some((session) => session.endpoint.state === 'open');
|
|
175
|
+
|
|
162
176
|
if (mode === 'interactive') {
|
|
163
177
|
const startedAt = Date.now();
|
|
164
178
|
let idleSince: number | null = null;
|
|
165
179
|
|
|
166
180
|
poll = setInterval(() => {
|
|
167
|
-
const attached =
|
|
181
|
+
const attached = isAttached();
|
|
168
182
|
const now = Date.now();
|
|
169
183
|
|
|
170
184
|
if (attached) {
|
|
@@ -194,5 +208,12 @@ export async function startApp(options: StartAppOptions): Promise<RunningApp> {
|
|
|
194
208
|
poll.unref?.();
|
|
195
209
|
}
|
|
196
210
|
|
|
197
|
-
return {
|
|
211
|
+
return {
|
|
212
|
+
bridge,
|
|
213
|
+
done,
|
|
214
|
+
stop,
|
|
215
|
+
get attached() {
|
|
216
|
+
return isAttached();
|
|
217
|
+
},
|
|
218
|
+
};
|
|
198
219
|
}
|
package/src/react/hooks.tsx
CHANGED
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
StreamName,
|
|
24
24
|
StreamParams,
|
|
25
25
|
} from '../shared/contract.ts';
|
|
26
|
+
import { mergeContracts } from '../shared/contract.ts';
|
|
26
27
|
import { BroappError } from '../shared/errors.ts';
|
|
27
28
|
import type { BridgeState, BroappClient, CreateClientOptions, Subscription } from '../client/client.ts';
|
|
28
29
|
import { createClient } from '../client/client.ts';
|
|
@@ -72,6 +73,14 @@ interface ContextValue<C extends AnyContract> {
|
|
|
72
73
|
*/
|
|
73
74
|
readonly ready: Promise<BroappClient<C>>;
|
|
74
75
|
readonly status: ConnectionStatus;
|
|
76
|
+
/**
|
|
77
|
+
* The contract actually spoken, application plus extensions.
|
|
78
|
+
*
|
|
79
|
+
* Components below the provider read routes from here rather than from the
|
|
80
|
+
* contract they were handed, because an extension's routes exist only after
|
|
81
|
+
* the merge.
|
|
82
|
+
*/
|
|
83
|
+
readonly contract: AnyContract;
|
|
75
84
|
}
|
|
76
85
|
|
|
77
86
|
const BroappContext = React.createContext<ContextValue<AnyContract> | null>(null);
|
|
@@ -79,6 +88,8 @@ const BroappContext = React.createContext<ContextValue<AnyContract> | null>(null
|
|
|
79
88
|
/** Props for {@link BroappProvider}. */
|
|
80
89
|
export interface BroappProviderProps<C extends AnyContract> {
|
|
81
90
|
readonly contract: C;
|
|
91
|
+
/** Extra contracts to speak over the same connection, e.g. Broapp's `aiContract`. */
|
|
92
|
+
readonly extensions?: readonly AnyContract[];
|
|
82
93
|
readonly options?: CreateClientOptions;
|
|
83
94
|
/**
|
|
84
95
|
* How long the host retains a protocol session after a disconnect. Must
|
|
@@ -106,10 +117,22 @@ function toStatus(state: BridgeState, since: number, now: number, ttlMs: number)
|
|
|
106
117
|
/** Owns the connection for everything below it. */
|
|
107
118
|
export function BroappProvider<C extends AnyContract>({
|
|
108
119
|
contract,
|
|
120
|
+
extensions,
|
|
109
121
|
options,
|
|
110
122
|
sessionTtlMs = 60_000,
|
|
111
123
|
children,
|
|
112
124
|
}: BroappProviderProps<C>): React.ReactElement {
|
|
125
|
+
// Merged once and kept. Both contracts are module-level constants, and a
|
|
126
|
+
// second merge would build a second client and drop the connection.
|
|
127
|
+
const mergedRef = React.useRef<AnyContract | null>(null);
|
|
128
|
+
if (mergedRef.current === null) {
|
|
129
|
+
mergedRef.current = (extensions ?? []).reduce<AnyContract>(
|
|
130
|
+
(left, right) => mergeContracts(left, right),
|
|
131
|
+
contract,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const merged = mergedRef.current as C;
|
|
135
|
+
|
|
113
136
|
const [client, setClient] = React.useState<BroappClient<C> | null>(null);
|
|
114
137
|
const [status, setStatus] = React.useState<ConnectionStatus>({ phase: 'connecting' });
|
|
115
138
|
// Created before the effect runs, so the very first render already has
|
|
@@ -143,7 +166,7 @@ export function BroappProvider<C extends AnyContract>({
|
|
|
143
166
|
// transport event.
|
|
144
167
|
let tick: ReturnType<typeof setInterval> | undefined;
|
|
145
168
|
|
|
146
|
-
void createClient(
|
|
169
|
+
void createClient(merged, options).then(
|
|
147
170
|
(next) => {
|
|
148
171
|
if (!live) {
|
|
149
172
|
void next.close();
|
|
@@ -191,8 +214,8 @@ export function BroappProvider<C extends AnyContract>({
|
|
|
191
214
|
}, []);
|
|
192
215
|
|
|
193
216
|
const value = React.useMemo<ContextValue<C>>(
|
|
194
|
-
() => ({ client, ready, status }),
|
|
195
|
-
[client, ready, status],
|
|
217
|
+
() => ({ client, ready, status, contract: merged }),
|
|
218
|
+
[client, ready, status, merged],
|
|
196
219
|
);
|
|
197
220
|
return (
|
|
198
221
|
<BroappContext.Provider value={value as ContextValue<AnyContract>}>
|
|
@@ -212,6 +235,17 @@ export function useConnection(): ConnectionStatus {
|
|
|
212
235
|
return useContextValue().status;
|
|
213
236
|
}
|
|
214
237
|
|
|
238
|
+
/**
|
|
239
|
+
* The contract actually spoken over this connection, extensions included.
|
|
240
|
+
*
|
|
241
|
+
* An extension's own hooks use it to check that they were installed — asking
|
|
242
|
+
* for a route that is not there fails at the first call otherwise, which is
|
|
243
|
+
* later and further from the mistake.
|
|
244
|
+
*/
|
|
245
|
+
export function useBroappContract(): AnyContract {
|
|
246
|
+
return useContextValue().contract;
|
|
247
|
+
}
|
|
248
|
+
|
|
215
249
|
/** The client, or `null` until the first connection settles. */
|
|
216
250
|
export function useBroapp<C extends AnyContract>(): BroappClient<C> | null {
|
|
217
251
|
return useContextValue<C>().client;
|