@schlessera/brain-ui-server 0.34.1 → 0.35.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 +28 -0
- package/dist/activity/notify.d.ts.map +1 -1
- package/dist/activity/notify.js +18 -8
- package/dist/activity/notify.js.map +1 -1
- package/dist/activity/push-sender.d.ts +7 -3
- package/dist/activity/push-sender.d.ts.map +1 -1
- package/dist/activity/push-sender.js +45 -11
- package/dist/activity/push-sender.js.map +1 -1
- package/dist/activity/query.d.ts.map +1 -1
- package/dist/activity/query.js +8 -0
- package/dist/activity/query.js.map +1 -1
- package/dist/activity/recorder.d.ts +11 -1
- package/dist/activity/recorder.d.ts.map +1 -1
- package/dist/activity/recorder.js +52 -2
- package/dist/activity/recorder.js.map +1 -1
- package/dist/activity/runtime.d.ts.map +1 -1
- package/dist/activity/runtime.js +2 -5
- package/dist/activity/runtime.js.map +1 -1
- package/dist/activity/sql.d.ts +3 -3
- package/dist/activity/sql.d.ts.map +1 -1
- package/dist/activity/sql.js +7 -5
- package/dist/activity/sql.js.map +1 -1
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +15 -3
- package/dist/activity/store.js.map +1 -1
- package/dist/activity/stream.d.ts +3 -1
- package/dist/activity/stream.d.ts.map +1 -1
- package/dist/activity/stream.js +32 -17
- package/dist/activity/stream.js.map +1 -1
- package/dist/app-env.d.ts +8 -0
- package/dist/app-env.d.ts.map +1 -0
- package/dist/app-env.js +2 -0
- package/dist/app-env.js.map +1 -0
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +28 -3
- package/dist/app.js.map +1 -1
- package/dist/brain/client.d.ts +4 -2
- package/dist/brain/client.d.ts.map +1 -1
- package/dist/brain/client.js +60 -20
- package/dist/brain/client.js.map +1 -1
- package/dist/brain/types.d.ts +9 -0
- package/dist/brain/types.d.ts.map +1 -1
- package/dist/cron/run-job.d.ts.map +1 -1
- package/dist/cron/run-job.js +60 -10
- package/dist/cron/run-job.js.map +1 -1
- package/dist/db/principals.d.ts +72 -0
- package/dist/db/principals.d.ts.map +1 -0
- package/dist/db/principals.js +294 -0
- package/dist/db/principals.js.map +1 -0
- package/dist/files/walker.d.ts.map +1 -1
- package/dist/files/walker.js +6 -1
- package/dist/files/walker.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware/auth.d.ts +37 -9
- package/dist/middleware/auth.d.ts.map +1 -1
- package/dist/middleware/auth.js +148 -74
- package/dist/middleware/auth.js.map +1 -1
- package/dist/middleware/passkeys.d.ts +9 -7
- package/dist/middleware/passkeys.d.ts.map +1 -1
- package/dist/middleware/passkeys.js +25 -7
- package/dist/middleware/passkeys.js.map +1 -1
- package/dist/middleware/principals.d.ts +18 -0
- package/dist/middleware/principals.d.ts.map +1 -0
- package/dist/middleware/principals.js +138 -0
- package/dist/middleware/principals.js.map +1 -0
- package/dist/middleware/require-owner.d.ts +5 -0
- package/dist/middleware/require-owner.d.ts.map +1 -0
- package/dist/middleware/require-owner.js +10 -0
- package/dist/middleware/require-owner.js.map +1 -0
- package/dist/observability/loggers.js +1 -1
- package/dist/observability/loggers.js.map +1 -1
- package/dist/routes/activity.d.ts.map +1 -1
- package/dist/routes/activity.js +3 -0
- package/dist/routes/activity.js.map +1 -1
- package/dist/routes/brain.d.ts.map +1 -1
- package/dist/routes/brain.js +102 -63
- package/dist/routes/brain.js.map +1 -1
- package/dist/routes/files.d.ts.map +1 -1
- package/dist/routes/files.js +7 -2
- package/dist/routes/files.js.map +1 -1
- package/dist/routes/push.d.ts +61 -2
- package/dist/routes/push.d.ts.map +1 -1
- package/dist/routes/push.js +8 -3
- package/dist/routes/push.js.map +1 -1
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +30 -4
- package/dist/routes/sessions.js.map +1 -1
- package/dist/share/staging.d.ts.map +1 -1
- package/dist/share/staging.js +11 -4
- package/dist/share/staging.js.map +1 -1
- package/dist/ws/bridge.d.ts.map +1 -1
- package/dist/ws/bridge.js +6 -2
- package/dist/ws/bridge.js.map +1 -1
- package/dist/ws/clients.d.ts +9 -2
- package/dist/ws/clients.d.ts.map +1 -1
- package/dist/ws/clients.js +44 -6
- package/dist/ws/clients.js.map +1 -1
- package/dist/ws/connection.d.ts +7 -4
- package/dist/ws/connection.d.ts.map +1 -1
- package/dist/ws/connection.js +83 -12
- package/dist/ws/connection.js.map +1 -1
- package/dist/ws/dispatch.d.ts +12 -1
- package/dist/ws/dispatch.d.ts.map +1 -1
- package/dist/ws/dispatch.js +46 -6
- package/dist/ws/dispatch.js.map +1 -1
- package/dist/ws/history.d.ts.map +1 -1
- package/dist/ws/history.js +7 -5
- package/dist/ws/history.js.map +1 -1
- package/dist/ws/host.d.ts +22 -0
- package/dist/ws/host.d.ts.map +1 -1
- package/dist/ws/host.js +39 -1
- package/dist/ws/host.js.map +1 -1
- package/dist/ws/run-session.d.ts +7 -7
- package/dist/ws/run-session.d.ts.map +1 -1
- package/dist/ws/run-session.js +208 -52
- package/dist/ws/run-session.js.map +1 -1
- package/dist/ws/shrink.d.ts +3 -1
- package/dist/ws/shrink.d.ts.map +1 -1
- package/dist/ws/shrink.js +46 -16
- package/dist/ws/shrink.js.map +1 -1
- package/dist/ws/turns.d.ts +58 -3
- package/dist/ws/turns.d.ts.map +1 -1
- package/dist/ws/turns.js +101 -2
- package/dist/ws/turns.js.map +1 -1
- package/migrations/011_principals.sql +13 -0
- package/migrations/012_activity_principals.sql +12 -0
- package/migrations/013_push_subscription_principals.sql +7 -0
- package/migrations/014_invalidate_legacy_sessions.sql +14 -0
- package/package.json +3 -3
- package/src/activity/notify.ts +16 -7
- package/src/activity/push-sender.ts +79 -16
- package/src/activity/query.ts +8 -0
- package/src/activity/recorder.ts +68 -3
- package/src/activity/runtime.ts +2 -5
- package/src/activity/sql.ts +7 -5
- package/src/activity/store.ts +32 -1
- package/src/activity/stream.ts +37 -17
- package/src/app-env.ts +8 -0
- package/src/app.ts +33 -4
- package/src/brain/client.ts +61 -20
- package/src/brain/types.ts +10 -0
- package/src/cron/run-job.ts +41 -17
- package/src/db/principals.ts +452 -0
- package/src/files/walker.ts +5 -1
- package/src/index.ts +6 -1
- package/src/middleware/auth.ts +241 -81
- package/src/middleware/passkeys.ts +35 -14
- package/src/middleware/principals.ts +189 -0
- package/src/middleware/require-owner.ts +13 -0
- package/src/observability/loggers.ts +1 -1
- package/src/routes/activity.ts +3 -0
- package/src/routes/brain.ts +107 -70
- package/src/routes/files.ts +10 -5
- package/src/routes/push.ts +11 -5
- package/src/routes/sessions.ts +45 -20
- package/src/share/staging.ts +11 -4
- package/src/ws/bridge.ts +14 -2
- package/src/ws/clients.ts +52 -7
- package/src/ws/connection.ts +98 -20
- package/src/ws/dispatch.ts +66 -6
- package/src/ws/history.ts +7 -5
- package/src/ws/host.ts +59 -1
- package/src/ws/run-session.ts +247 -74
- package/src/ws/shrink.ts +48 -14
- package/src/ws/turns.ts +136 -5
package/src/activity/recorder.ts
CHANGED
|
@@ -46,8 +46,21 @@ export interface TurnRecorder {
|
|
|
46
46
|
readonly runId: string;
|
|
47
47
|
observeFrame(msg: ServerMessage): void;
|
|
48
48
|
observeActivity(event: BackendActivityEvent): void;
|
|
49
|
+
/** Record that this turn's principal was revoked while work kept running. */
|
|
50
|
+
recordPrincipalRevocation(principalId: string): void;
|
|
51
|
+
/** Record a live follow-up that joined this already-running turn. */
|
|
52
|
+
recordFollowUp(principalId: string): void;
|
|
53
|
+
/** Record who cancelled this turn or an interactive prompt within it. */
|
|
54
|
+
recordCancellation(principalId: string, kind?: "turn" | "ask_user"): void;
|
|
55
|
+
/** Record who answered an ask-user interaction. */
|
|
56
|
+
recordAskUserResponse(principalId: string): void;
|
|
49
57
|
/** The user's approval decision arrived for a gated tool call. */
|
|
50
|
-
onApprovalDecision(
|
|
58
|
+
onApprovalDecision(
|
|
59
|
+
toolUseId: string,
|
|
60
|
+
decision: "allow" | "always_allow" | "deny",
|
|
61
|
+
requestKind: "tool" | "command",
|
|
62
|
+
principalId?: string
|
|
63
|
+
): void;
|
|
51
64
|
/**
|
|
52
65
|
* Close the turn: merge the buffered result enrichment into the root's
|
|
53
66
|
* single terminal write, cascade-cancel anything left open, roll up.
|
|
@@ -64,6 +77,8 @@ export function createTurnRecorder(
|
|
|
64
77
|
profileId?: string;
|
|
65
78
|
/** Billing classification of that profile, resolved at run start (U3). */
|
|
66
79
|
billingMode?: BillingMode;
|
|
80
|
+
/** Principal that initiated this turn; absent means unattributed. */
|
|
81
|
+
principalId?: string;
|
|
67
82
|
}
|
|
68
83
|
): TurnRecorder {
|
|
69
84
|
const { store, onWrite, log } = deps;
|
|
@@ -76,6 +91,7 @@ export function createTurnRecorder(
|
|
|
76
91
|
let resultOutcome: SpanOutcome | null = null;
|
|
77
92
|
let resultUsage: SpanUsage | undefined;
|
|
78
93
|
let resultAttrs: Record<string, unknown> | undefined;
|
|
94
|
+
let principalRevocationRecorded = false;
|
|
79
95
|
|
|
80
96
|
const guard = (fn: () => void) => {
|
|
81
97
|
try {
|
|
@@ -99,6 +115,7 @@ export function createTurnRecorder(
|
|
|
99
115
|
kind: "turn",
|
|
100
116
|
origin: "session",
|
|
101
117
|
sessionId,
|
|
118
|
+
principalId: turn.principalId,
|
|
102
119
|
// Profile + billing ride the ROOT span so the rollup can price the run
|
|
103
120
|
// without any registry or env lookup of its own (a root missing the
|
|
104
121
|
// billing attr falls back to env classification at rollup time).
|
|
@@ -253,9 +270,57 @@ export function createTurnRecorder(
|
|
|
253
270
|
});
|
|
254
271
|
},
|
|
255
272
|
|
|
256
|
-
|
|
273
|
+
recordPrincipalRevocation(principalId) {
|
|
257
274
|
guard(() => {
|
|
258
|
-
if (
|
|
275
|
+
if (finished || principalRevocationRecorded) return;
|
|
276
|
+
ensureRoot();
|
|
277
|
+
store.appendEvent(rootSpanId, "principal_revoked", { principalId });
|
|
278
|
+
principalRevocationRecorded = true;
|
|
279
|
+
onWrite?.();
|
|
280
|
+
});
|
|
281
|
+
},
|
|
282
|
+
|
|
283
|
+
recordFollowUp(principalId) {
|
|
284
|
+
guard(() => {
|
|
285
|
+
if (finished) return;
|
|
286
|
+
ensureRoot();
|
|
287
|
+
store.appendEvent(rootSpanId, "follow_up", { principalId });
|
|
288
|
+
onWrite?.();
|
|
289
|
+
});
|
|
290
|
+
},
|
|
291
|
+
|
|
292
|
+
recordCancellation(principalId, kind = "turn") {
|
|
293
|
+
guard(() => {
|
|
294
|
+
if (finished) return;
|
|
295
|
+
ensureRoot();
|
|
296
|
+
store.appendEvent(rootSpanId, `${kind}_cancelled`, { principalId });
|
|
297
|
+
onWrite?.();
|
|
298
|
+
});
|
|
299
|
+
},
|
|
300
|
+
|
|
301
|
+
recordAskUserResponse(principalId) {
|
|
302
|
+
guard(() => {
|
|
303
|
+
if (finished) return;
|
|
304
|
+
ensureRoot();
|
|
305
|
+
store.appendEvent(rootSpanId, "ask_user_response", { principalId });
|
|
306
|
+
onWrite?.();
|
|
307
|
+
});
|
|
308
|
+
},
|
|
309
|
+
|
|
310
|
+
onApprovalDecision(toolUseId, decision, requestKind, principalId) {
|
|
311
|
+
guard(() => {
|
|
312
|
+
if (principalId) {
|
|
313
|
+
// One tool can raise several approval requests. The span-level actor
|
|
314
|
+
// remains the latest responder for compact views, while this
|
|
315
|
+
// append-only event preserves every decision and actor in order.
|
|
316
|
+
store.appendEvent(toolUseId, "approval_decision", {
|
|
317
|
+
principalId,
|
|
318
|
+
decision,
|
|
319
|
+
requestKind,
|
|
320
|
+
});
|
|
321
|
+
store.patchSpan(toolUseId, { principalId });
|
|
322
|
+
}
|
|
323
|
+
if (decision !== "deny") {
|
|
259
324
|
// Everything before this moment was approval wait, not execution.
|
|
260
325
|
store.patchSpan(toolUseId, { waitUntil: Date.now() });
|
|
261
326
|
} else {
|
package/src/activity/runtime.ts
CHANGED
|
@@ -58,11 +58,8 @@ export function createActivityRuntime(
|
|
|
58
58
|
const store = createActivityStore(db, deps.pricing ? { pricing: deps.pricing } : {});
|
|
59
59
|
const stream = createActivityStream(store, log);
|
|
60
60
|
|
|
61
|
-
// The notifier
|
|
62
|
-
//
|
|
63
|
-
// it and the first tick turns restart-interrupted runs into failure
|
|
64
|
-
// intents. Created after, the sweep's changes would sit below the cursor
|
|
65
|
-
// and a turn killed by a restart would never be notified.
|
|
61
|
+
// The notifier resumes its persisted cursor, including terminal changes
|
|
62
|
+
// committed while this server was down and interruptions from the boot sweep.
|
|
66
63
|
const notifier = createActivityNotifier({
|
|
67
64
|
db,
|
|
68
65
|
store,
|
package/src/activity/sql.ts
CHANGED
|
@@ -17,11 +17,12 @@ export const ACTIVITY_SQL = {
|
|
|
17
17
|
"SELECT span_id FROM activity_spans WHERE run_id = ? AND outcome IS NULL ORDER BY started_at DESC",
|
|
18
18
|
spansByRun: "SELECT * FROM activity_spans WHERE run_id = ? ORDER BY started_at",
|
|
19
19
|
upsertRollup: `INSERT INTO activity_run_rollups
|
|
20
|
-
(run_id, origin, name, session_id, job_name,
|
|
20
|
+
(run_id, origin, name, session_id, job_name, principal_id, principal_label,
|
|
21
|
+
principal_kind, started_at, ended_at, outcome,
|
|
21
22
|
duration_ms, span_count, input_tokens, output_tokens, cache_read_tokens,
|
|
22
23
|
cache_creation_tokens, cost_usd, effective_cost_usd, billing_mode,
|
|
23
24
|
pricing_estimate, failure_reason, detail_pruned)
|
|
24
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
25
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0)
|
|
25
26
|
ON CONFLICT(run_id) DO UPDATE SET
|
|
26
27
|
ended_at = excluded.ended_at, outcome = excluded.outcome,
|
|
27
28
|
duration_ms = excluded.duration_ms, span_count = excluded.span_count,
|
|
@@ -48,10 +49,11 @@ export const ACTIVITY_SQL = {
|
|
|
48
49
|
END,
|
|
49
50
|
failure_reason = excluded.failure_reason`,
|
|
50
51
|
insertSpan: `INSERT INTO activity_spans
|
|
51
|
-
(span_id, run_id, parent_span_id, name, kind, origin, session_id, job_name,
|
|
52
|
+
(span_id, run_id, parent_span_id, name, kind, origin, session_id, job_name, principal_id,
|
|
52
53
|
attrs, started_at, writer, last_heartbeat_at)
|
|
53
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
54
|
-
patchSpan: `UPDATE activity_spans SET
|
|
54
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
55
|
+
patchSpan: `UPDATE activity_spans SET principal_id = COALESCE(?, principal_id),
|
|
56
|
+
attrs = ?, wait_until = COALESCE(?, wait_until),
|
|
55
57
|
input_tokens = COALESCE(?, input_tokens),
|
|
56
58
|
output_tokens = COALESCE(?, output_tokens),
|
|
57
59
|
cache_read_tokens = COALESCE(?, cache_read_tokens),
|
package/src/activity/store.ts
CHANGED
|
@@ -38,6 +38,7 @@ import {
|
|
|
38
38
|
resolveStandalonePricingConfig,
|
|
39
39
|
} from "../config/env.js";
|
|
40
40
|
import { createModelPricing, type PricingRates } from "../pricing/model-pricing.js";
|
|
41
|
+
import type { PrincipalKind } from "../db/principals.js";
|
|
41
42
|
import { ACTIVITY_SQL } from "./sql.js";
|
|
42
43
|
|
|
43
44
|
export const SPAN_OUTCOMES = [
|
|
@@ -53,6 +54,12 @@ export type SpanOutcome = (typeof SPAN_OUTCOMES)[number];
|
|
|
53
54
|
export type SpanKind = "turn" | "tool" | "subagent" | "cron";
|
|
54
55
|
export type SpanOrigin = "session" | "cron";
|
|
55
56
|
|
|
57
|
+
function isPrincipalKind(value: unknown): value is PrincipalKind {
|
|
58
|
+
return (
|
|
59
|
+
value === "owner" || value === "agent" || value === "ambient" || value === "system"
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
|
|
56
63
|
/** Token usage as spans carry it — OTel GenAI attribute semantics. */
|
|
57
64
|
export interface SpanUsage {
|
|
58
65
|
inputTokens?: number;
|
|
@@ -72,6 +79,7 @@ export interface SpanRow {
|
|
|
72
79
|
origin: SpanOrigin;
|
|
73
80
|
sessionId: string | null;
|
|
74
81
|
jobName: string | null;
|
|
82
|
+
principalId: string | null;
|
|
75
83
|
attrs: Record<string, unknown>;
|
|
76
84
|
startedAt: number;
|
|
77
85
|
waitUntil: number | null;
|
|
@@ -99,6 +107,9 @@ export interface RunRollupRow {
|
|
|
99
107
|
name: string;
|
|
100
108
|
sessionId: string | null;
|
|
101
109
|
jobName: string | null;
|
|
110
|
+
principalId: string | null;
|
|
111
|
+
principalLabel: string | null;
|
|
112
|
+
principalKind: PrincipalKind | null;
|
|
102
113
|
startedAt: number;
|
|
103
114
|
endedAt: number | null;
|
|
104
115
|
outcome: SpanOutcome | null;
|
|
@@ -131,6 +142,9 @@ export function rowToRunRollup(r: any): RunRollupRow {
|
|
|
131
142
|
name: r.name,
|
|
132
143
|
sessionId: r.session_id,
|
|
133
144
|
jobName: r.job_name,
|
|
145
|
+
principalId: r.principal_id ?? null,
|
|
146
|
+
principalLabel: r.principal_label ?? null,
|
|
147
|
+
principalKind: isPrincipalKind(r.principal_kind) ? r.principal_kind : null,
|
|
134
148
|
startedAt: r.started_at,
|
|
135
149
|
endedAt: r.ended_at,
|
|
136
150
|
outcome: r.outcome,
|
|
@@ -183,6 +197,7 @@ export interface StartSpanInput {
|
|
|
183
197
|
origin: SpanOrigin;
|
|
184
198
|
sessionId?: string;
|
|
185
199
|
jobName?: string;
|
|
200
|
+
principalId?: string;
|
|
186
201
|
attrs?: Record<string, unknown>;
|
|
187
202
|
startedAt?: number;
|
|
188
203
|
}
|
|
@@ -237,7 +252,12 @@ export interface ActivityStore {
|
|
|
237
252
|
/** Non-terminal field updates (usage enrichment, wait boundary, attrs). */
|
|
238
253
|
patchSpan(
|
|
239
254
|
spanId: string,
|
|
240
|
-
patch: {
|
|
255
|
+
patch: {
|
|
256
|
+
principalId?: string;
|
|
257
|
+
attrs?: Record<string, unknown>;
|
|
258
|
+
usage?: SpanUsage;
|
|
259
|
+
waitUntil?: number;
|
|
260
|
+
}
|
|
241
261
|
): boolean;
|
|
242
262
|
/**
|
|
243
263
|
* Append one event; null (nothing written) for an unknown span. `cap`
|
|
@@ -347,6 +367,7 @@ export function createActivityStore(
|
|
|
347
367
|
origin: r.origin,
|
|
348
368
|
sessionId: r.session_id,
|
|
349
369
|
jobName: r.job_name,
|
|
370
|
+
principalId: r.principal_id ?? null,
|
|
350
371
|
attrs: safeParse(r.attrs) ?? {},
|
|
351
372
|
startedAt: r.started_at,
|
|
352
373
|
waitUntil: r.wait_until,
|
|
@@ -492,6 +513,11 @@ export function createActivityStore(
|
|
|
492
513
|
? (priced.estimate ? 1 : 0)
|
|
493
514
|
: null;
|
|
494
515
|
const costUsd = root.usage.costUsd ?? priced?.costUsd ?? null;
|
|
516
|
+
const principal = root.principalId
|
|
517
|
+
? (db
|
|
518
|
+
.query("SELECT label, kind FROM principals WHERE id = ?")
|
|
519
|
+
.get(root.principalId) as { label: string; kind: PrincipalKind } | null)
|
|
520
|
+
: null;
|
|
495
521
|
|
|
496
522
|
db.query(
|
|
497
523
|
ACTIVITY_SQL.upsertRollup
|
|
@@ -511,6 +537,9 @@ export function createActivityStore(
|
|
|
511
537
|
root.name,
|
|
512
538
|
root.sessionId,
|
|
513
539
|
root.jobName,
|
|
540
|
+
root.principalId,
|
|
541
|
+
principal?.label ?? null,
|
|
542
|
+
principal?.kind ?? null,
|
|
514
543
|
root.startedAt,
|
|
515
544
|
root.endedAt,
|
|
516
545
|
root.outcome,
|
|
@@ -543,6 +572,7 @@ export function createActivityStore(
|
|
|
543
572
|
input.origin,
|
|
544
573
|
input.sessionId ?? null,
|
|
545
574
|
input.jobName ?? null,
|
|
575
|
+
input.principalId ?? null,
|
|
546
576
|
input.attrs ? JSON.stringify(input.attrs) : null,
|
|
547
577
|
startedAt,
|
|
548
578
|
writer,
|
|
@@ -568,6 +598,7 @@ export function createActivityStore(
|
|
|
568
598
|
: existing.attrs;
|
|
569
599
|
const u = patch.usage ?? {};
|
|
570
600
|
db.query(ACTIVITY_SQL.patchSpan).run(
|
|
601
|
+
patch.principalId ?? null,
|
|
571
602
|
attrs,
|
|
572
603
|
patch.waitUntil ?? null,
|
|
573
604
|
u.inputTokens ?? null,
|
package/src/activity/stream.ts
CHANGED
|
@@ -77,6 +77,7 @@ export function toWireSpan(span: SpanRow): ActivitySpan {
|
|
|
77
77
|
origin: span.origin,
|
|
78
78
|
sessionId: span.sessionId ?? undefined,
|
|
79
79
|
jobName: span.jobName ?? undefined,
|
|
80
|
+
principalId: span.principalId ?? undefined,
|
|
80
81
|
startedAt: span.startedAt,
|
|
81
82
|
waitUntil: span.waitUntil ?? undefined,
|
|
82
83
|
endedAt: span.endedAt ?? undefined,
|
|
@@ -122,6 +123,9 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
|
|
|
122
123
|
name: r.name,
|
|
123
124
|
sessionId: r.sessionId,
|
|
124
125
|
jobName: r.jobName,
|
|
126
|
+
principalId: r.principalId,
|
|
127
|
+
principalLabel: r.principalLabel,
|
|
128
|
+
principalKind: r.principalKind,
|
|
125
129
|
startedAt: r.startedAt,
|
|
126
130
|
endedAt: r.endedAt,
|
|
127
131
|
outcome: r.outcome,
|
|
@@ -136,10 +140,12 @@ export function toWireRollup(r: RunRollupRow): ActivityRunRollup {
|
|
|
136
140
|
}
|
|
137
141
|
|
|
138
142
|
export interface ActivityStream {
|
|
139
|
-
handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe): void;
|
|
143
|
+
handleSubscribe(ws: WSContext, msg: ClientActivitySubscribe, principalId: string): void;
|
|
140
144
|
handleUnsubscribe(ws: WSContext, msg: ClientActivityUnsubscribe): void;
|
|
141
145
|
/** Forget a closed socket. */
|
|
142
146
|
dropConnection(ws: WSContext): void;
|
|
147
|
+
/** Forget every subscription attached by one revoked principal. */
|
|
148
|
+
dropFor(principalId: string): void;
|
|
143
149
|
/** Drain new committed changes to subscribers. Reentrancy-safe. */
|
|
144
150
|
pump(): void;
|
|
145
151
|
/** Whether any connection currently watches the given session's activity. */
|
|
@@ -150,7 +156,10 @@ export interface ActivityStream {
|
|
|
150
156
|
}
|
|
151
157
|
|
|
152
158
|
export function createActivityStream(store: ActivityStore, log?: Logger): ActivityStream {
|
|
153
|
-
const subscriptions = new Map<
|
|
159
|
+
const subscriptions = new Map<
|
|
160
|
+
unknown,
|
|
161
|
+
{ ws: WSContext; principalId: string; subscriptions: Subscription[] }
|
|
162
|
+
>();
|
|
154
163
|
let cursor = store.latestChangeCursor();
|
|
155
164
|
let pumping = false;
|
|
156
165
|
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
|
@@ -222,9 +231,9 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
|
|
|
222
231
|
const span = change.kind === "span" ? change.span : undefined;
|
|
223
232
|
if (span) noteSpan(span);
|
|
224
233
|
const frame = deltaFrame(change);
|
|
225
|
-
for (const
|
|
226
|
-
if (
|
|
227
|
-
sendTo(ws, frame);
|
|
234
|
+
for (const entry of subscriptions.values()) {
|
|
235
|
+
if (entry.subscriptions.some((s) => matches(s, change.runId, span))) {
|
|
236
|
+
sendTo(entry.ws, frame);
|
|
228
237
|
}
|
|
229
238
|
}
|
|
230
239
|
}
|
|
@@ -310,7 +319,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
|
|
|
310
319
|
}
|
|
311
320
|
|
|
312
321
|
return {
|
|
313
|
-
handleSubscribe(ws, msg) {
|
|
322
|
+
handleSubscribe(ws, msg, principalId) {
|
|
314
323
|
// A scoped view without its scope id can never match anything and
|
|
315
324
|
// would only leak an all-runs snapshot shape — ignore it up front.
|
|
316
325
|
if ((msg.view === "session" && !msg.sessionId) || (msg.view === "run" && !msg.runId)) {
|
|
@@ -321,9 +330,11 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
|
|
|
321
330
|
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
322
331
|
...(msg.runId ? { runId: msg.runId } : {}),
|
|
323
332
|
};
|
|
324
|
-
const
|
|
333
|
+
const identity = ws.raw ?? ws;
|
|
334
|
+
const existing = subscriptions.get(identity);
|
|
335
|
+
const list = existing?.subscriptions ?? [];
|
|
325
336
|
if (!list.some((s) => subKey(s) === subKey(sub))) list.push(sub);
|
|
326
|
-
subscriptions.set(ws, list);
|
|
337
|
+
subscriptions.set(identity, { ws, principalId, subscriptions: list });
|
|
327
338
|
ensurePolling();
|
|
328
339
|
try {
|
|
329
340
|
sendSnapshot(ws, sub);
|
|
@@ -337,29 +348,38 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
|
|
|
337
348
|
},
|
|
338
349
|
|
|
339
350
|
handleUnsubscribe(ws, msg) {
|
|
340
|
-
const
|
|
341
|
-
|
|
351
|
+
const identity = ws.raw ?? ws;
|
|
352
|
+
const entry = subscriptions.get(identity);
|
|
353
|
+
if (!entry) return;
|
|
342
354
|
const key = subKey({
|
|
343
355
|
view: msg.view,
|
|
344
356
|
...(msg.sessionId ? { sessionId: msg.sessionId } : {}),
|
|
345
357
|
...(msg.runId ? { runId: msg.runId } : {}),
|
|
346
358
|
});
|
|
347
|
-
const remaining =
|
|
348
|
-
if (remaining.length > 0)
|
|
349
|
-
|
|
359
|
+
const remaining = entry.subscriptions.filter((s) => subKey(s) !== key);
|
|
360
|
+
if (remaining.length > 0) {
|
|
361
|
+
subscriptions.set(identity, { ...entry, ws, subscriptions: remaining });
|
|
362
|
+
} else subscriptions.delete(identity);
|
|
350
363
|
stopPollingIfIdle();
|
|
351
364
|
},
|
|
352
365
|
|
|
353
366
|
dropConnection(ws) {
|
|
354
|
-
subscriptions.delete(ws);
|
|
367
|
+
subscriptions.delete(ws.raw ?? ws);
|
|
368
|
+
stopPollingIfIdle();
|
|
369
|
+
},
|
|
370
|
+
|
|
371
|
+
dropFor(principalId) {
|
|
372
|
+
for (const [identity, entry] of subscriptions) {
|
|
373
|
+
if (entry.principalId === principalId) subscriptions.delete(identity);
|
|
374
|
+
}
|
|
355
375
|
stopPollingIfIdle();
|
|
356
376
|
},
|
|
357
377
|
|
|
358
378
|
pump,
|
|
359
379
|
|
|
360
380
|
isWatched(scope) {
|
|
361
|
-
for (const
|
|
362
|
-
for (const s of
|
|
381
|
+
for (const entry of subscriptions.values()) {
|
|
382
|
+
for (const s of entry.subscriptions) {
|
|
363
383
|
if (s.view === "index") return true;
|
|
364
384
|
if (scope.runId && s.view === "run" && s.runId === scope.runId) return true;
|
|
365
385
|
if (scope.sessionId && s.view === "session" && s.sessionId === scope.sessionId)
|
|
@@ -371,7 +391,7 @@ export function createActivityStream(store: ActivityStore, log?: Logger): Activi
|
|
|
371
391
|
|
|
372
392
|
subscriptionCount() {
|
|
373
393
|
let n = 0;
|
|
374
|
-
for (const
|
|
394
|
+
for (const entry of subscriptions.values()) n += entry.subscriptions.length;
|
|
375
395
|
return n;
|
|
376
396
|
},
|
|
377
397
|
|
package/src/app-env.ts
ADDED
package/src/app.ts
CHANGED
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
assertPasskeyConfig,
|
|
36
36
|
type PasskeyContext,
|
|
37
37
|
} from "./middleware/passkeys.js";
|
|
38
|
+
import { principalManagementRoutes } from "./middleware/principals.js";
|
|
38
39
|
import { createUiDb } from "./db/client.js";
|
|
40
|
+
import { isUsablePrincipal, prunePrincipals, resolvePrincipal } from "./db/principals.js";
|
|
39
41
|
import {
|
|
40
42
|
getAutoAllowedTools,
|
|
41
43
|
getBillingOverrides,
|
|
@@ -61,6 +63,7 @@ import { createSessionCatalog } from "./ws/session-catalog.js";
|
|
|
61
63
|
import type { KeytermSettings } from "./voice/keyterm-builder.js";
|
|
62
64
|
import { createObservability, type Observability } from "./observability/index.js";
|
|
63
65
|
import { isSameOriginRequest, originPolicy } from "./middleware/origin.js";
|
|
66
|
+
import type { AppEnv } from "./app-env.js";
|
|
64
67
|
|
|
65
68
|
export type { AppRenderer };
|
|
66
69
|
|
|
@@ -160,7 +163,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
160
163
|
options.observability ?? createObservability({ minSeverity: config.logLevel });
|
|
161
164
|
const auth: AuthRuntime = { ...config.auth, host: config.host };
|
|
162
165
|
|
|
163
|
-
const app = new Hono();
|
|
166
|
+
const app = new Hono<AppEnv>();
|
|
164
167
|
const authLog = observability.logger("auth");
|
|
165
168
|
const authMode = resolveAuthMode(auth, authLog);
|
|
166
169
|
// Validate inside the factory, not the bin entry: every consumer of the app
|
|
@@ -181,6 +184,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
181
184
|
probeBrainCliVersion(config.brainPath, observability.logger("brain"));
|
|
182
185
|
const dbLog = observability.logger("db");
|
|
183
186
|
const db = createUiDb(config.dbPath, { log: dbLog });
|
|
187
|
+
prunePrincipals(db, Date.now());
|
|
184
188
|
const brain = createBrainClient({ brainPath: config.brainPath });
|
|
185
189
|
const cron = createCronScheduler({ db, brain, log: observability.logger("cron") });
|
|
186
190
|
|
|
@@ -225,6 +229,10 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
225
229
|
maxConcurrentSessions: () => config.maxConcurrentSessions,
|
|
226
230
|
wsRate: config.wsRate,
|
|
227
231
|
wsMaxConnections: config.wsMaxConnections,
|
|
232
|
+
isPrincipalValid: (principal) => {
|
|
233
|
+
const current = resolvePrincipal(db, principal.id);
|
|
234
|
+
return current !== null && isUsablePrincipal(current, Date.now());
|
|
235
|
+
},
|
|
228
236
|
toolPermissions: {
|
|
229
237
|
isAutoAllowed: (toolName) => getAutoAllowedTools(db, dbLog).includes(toolName),
|
|
230
238
|
add: (toolName) =>
|
|
@@ -233,6 +241,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
233
241
|
activity: {
|
|
234
242
|
store: activity.store,
|
|
235
243
|
stream: activity.stream,
|
|
244
|
+
pushSender: activity.pushSender,
|
|
236
245
|
query: activity.query,
|
|
237
246
|
},
|
|
238
247
|
});
|
|
@@ -244,7 +253,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
244
253
|
});
|
|
245
254
|
const passkeyCtx: PasskeyContext = {
|
|
246
255
|
db,
|
|
247
|
-
|
|
256
|
+
revoker: host,
|
|
248
257
|
webauthn: config.webauthn,
|
|
249
258
|
auth,
|
|
250
259
|
allowedOrigins: config.allowedOrigins,
|
|
@@ -268,6 +277,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
268
277
|
if (c.req.path === "/api/health") return next();
|
|
269
278
|
const startedAt = Date.now();
|
|
270
279
|
await next();
|
|
280
|
+
const principal = c.get("principal");
|
|
271
281
|
httpLog.emit({
|
|
272
282
|
severityText: "INFO",
|
|
273
283
|
body: "request",
|
|
@@ -276,6 +286,12 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
276
286
|
path: c.req.path,
|
|
277
287
|
status: c.res.status,
|
|
278
288
|
"duration.ms": Date.now() - startedAt,
|
|
289
|
+
...(principal
|
|
290
|
+
? {
|
|
291
|
+
"auth.principal.id": principal.id,
|
|
292
|
+
"auth.principal.label": principal.label,
|
|
293
|
+
}
|
|
294
|
+
: {}),
|
|
279
295
|
},
|
|
280
296
|
});
|
|
281
297
|
});
|
|
@@ -335,7 +351,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
335
351
|
"/api",
|
|
336
352
|
authRoutes(authMode, auth, {
|
|
337
353
|
db,
|
|
338
|
-
|
|
354
|
+
revoker: host,
|
|
339
355
|
passwordDisabled: (c) => passwordLoginDisabled(c, passkeyCtx),
|
|
340
356
|
log: authLog,
|
|
341
357
|
failures: authFailures,
|
|
@@ -351,6 +367,16 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
351
367
|
// Passkey registration/management: after the guard, so a session is required
|
|
352
368
|
// by mount position (the public assertion routes are registered above).
|
|
353
369
|
app.route("/api", passkeyManagementRoutes(authMode, passkeyCtx));
|
|
370
|
+
// Principal management: also after the guard. Its router adds the narrower
|
|
371
|
+
// owner-only check after authentication has resolved the caller principal.
|
|
372
|
+
app.route(
|
|
373
|
+
"/api",
|
|
374
|
+
principalManagementRoutes(authMode, auth, {
|
|
375
|
+
db,
|
|
376
|
+
revoker: host,
|
|
377
|
+
log: authLog,
|
|
378
|
+
})
|
|
379
|
+
);
|
|
354
380
|
app.route(
|
|
355
381
|
"/api",
|
|
356
382
|
createStatusRoutes({
|
|
@@ -414,9 +440,11 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
414
440
|
if (!isSameOriginRequest(c, allowedOrigins, auth.trustProxy)) {
|
|
415
441
|
return c.json({ error: "Cross-origin WebSocket rejected" }, 403);
|
|
416
442
|
}
|
|
417
|
-
|
|
443
|
+
const principal = await isWsAuthorized(c, authMode, auth, db);
|
|
444
|
+
if (!principal) {
|
|
418
445
|
return c.json({ error: "Authentication required" }, 401);
|
|
419
446
|
}
|
|
447
|
+
c.set("principal", principal);
|
|
420
448
|
if (!host.clients.hasCapacity()) {
|
|
421
449
|
host.reportRefusedConnection();
|
|
422
450
|
return c.text("WebSocket connection limit reached", 503);
|
|
@@ -462,6 +490,7 @@ export function createApp(options: CreateAppOptions = {}): BrainUiApp {
|
|
|
462
490
|
isTurnActive: () => host.coordinator.isTurnActive(),
|
|
463
491
|
cancelActiveTurns: () => host.coordinator.cancelAll("Server shutting down"),
|
|
464
492
|
close: () => {
|
|
493
|
+
host.close();
|
|
465
494
|
activity.close();
|
|
466
495
|
db.close();
|
|
467
496
|
},
|
package/src/brain/client.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type {
|
|
|
8
8
|
BrainDocument,
|
|
9
9
|
BrainStats,
|
|
10
10
|
BrainSyncResult,
|
|
11
|
+
BrainAddResult,
|
|
11
12
|
} from "./types.js";
|
|
12
13
|
|
|
13
14
|
/**
|
|
@@ -20,7 +21,7 @@ export interface BrainClient {
|
|
|
20
21
|
cliCommand(): string[];
|
|
21
22
|
search(
|
|
22
23
|
query: string,
|
|
23
|
-
opts?: { type?: string; tag?: string; limit?: number; mode?: string }
|
|
24
|
+
opts?: { type?: string; tag?: string; limit?: number; mode?: string; signal?: AbortSignal }
|
|
24
25
|
): Promise<BrainSearchResponse>;
|
|
25
26
|
briefing(): Promise<string>;
|
|
26
27
|
stats(): Promise<BrainStats>;
|
|
@@ -36,7 +37,7 @@ export interface BrainClient {
|
|
|
36
37
|
add(
|
|
37
38
|
content: string,
|
|
38
39
|
opts?: { type?: string; title?: string; tags?: string[] }
|
|
39
|
-
): Promise<
|
|
40
|
+
): Promise<BrainAddResult>;
|
|
40
41
|
index(opts?: { force?: boolean }): Promise<void>;
|
|
41
42
|
validate(): Promise<string>;
|
|
42
43
|
/** Run `brain skills sync` — re-materialize every agent's skill links. */
|
|
@@ -157,25 +158,46 @@ export function probeBrainCliVersion(brainPath: string, log: Logger): void {
|
|
|
157
158
|
}
|
|
158
159
|
}
|
|
159
160
|
|
|
160
|
-
|
|
161
|
+
/** Interactive search must finish or fail within a bounded time. */
|
|
162
|
+
const SEARCH_TIMEOUT_MS = 15_000;
|
|
163
|
+
|
|
164
|
+
export function createBrainClient(opts: { brainPath: string; searchTimeoutMs?: number }): BrainClient {
|
|
161
165
|
const { brainPath } = opts;
|
|
166
|
+
const searchTimeoutMs = opts.searchTimeoutMs ?? SEARCH_TIMEOUT_MS;
|
|
167
|
+
if (!Number.isFinite(searchTimeoutMs) || searchTimeoutMs <= 0) {
|
|
168
|
+
throw new Error("searchTimeoutMs must be a positive finite number");
|
|
169
|
+
}
|
|
162
170
|
|
|
163
|
-
async function execBrain(args: string[]): Promise<ExecResult> {
|
|
171
|
+
async function execBrain(args: string[], signal?: AbortSignal): Promise<ExecResult> {
|
|
172
|
+
signal?.throwIfAborted();
|
|
164
173
|
const proc = Bun.spawn([...brainCliCommand(brainPath), ...args], {
|
|
165
174
|
cwd: brainPath,
|
|
166
175
|
stdout: "pipe",
|
|
167
176
|
stderr: "pipe",
|
|
168
177
|
// Force JSON output when not a TTY
|
|
169
178
|
env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
|
|
179
|
+
// Only read-only search passes a signal. Kill even a CLI that ignores
|
|
180
|
+
// SIGTERM; abandoning a request must not leave the local process alive.
|
|
181
|
+
...(signal ? { signal, killSignal: "SIGKILL" } : {}),
|
|
170
182
|
});
|
|
171
183
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
184
|
+
try {
|
|
185
|
+
const [stdout, stderr, exitCode] = await Promise.all([
|
|
186
|
+
new Response(proc.stdout).text(),
|
|
187
|
+
new Response(proc.stderr).text(),
|
|
188
|
+
proc.exited,
|
|
189
|
+
]);
|
|
190
|
+
signal?.throwIfAborted();
|
|
191
|
+
return { stdout, stderr, exitCode };
|
|
192
|
+
} catch (error) {
|
|
193
|
+
// A pipe-read failure also abandons search. Reap before its caller
|
|
194
|
+
// clears the deadline; otherwise that failure could orphan the child.
|
|
195
|
+
if (signal) {
|
|
196
|
+
proc.kill("SIGKILL");
|
|
197
|
+
await proc.exited;
|
|
198
|
+
}
|
|
199
|
+
throw error;
|
|
200
|
+
}
|
|
179
201
|
}
|
|
180
202
|
|
|
181
203
|
function parseJsonOutput<T>(result: ExecResult): T {
|
|
@@ -197,18 +219,29 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
197
219
|
return brainCliCommand(brainPath);
|
|
198
220
|
},
|
|
199
221
|
|
|
200
|
-
async search(query,
|
|
222
|
+
async search(query, searchOpts) {
|
|
201
223
|
const args = ["search"];
|
|
202
|
-
if (
|
|
203
|
-
if (
|
|
204
|
-
if (
|
|
205
|
-
if (
|
|
224
|
+
if (searchOpts?.type) args.push("--type", searchOpts.type);
|
|
225
|
+
if (searchOpts?.tag) args.push("--tag", searchOpts.tag);
|
|
226
|
+
if (searchOpts?.limit) args.push("--limit", String(searchOpts.limit));
|
|
227
|
+
if (searchOpts?.mode) args.push("--mode", searchOpts.mode);
|
|
206
228
|
args.push("--", query);
|
|
207
229
|
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
230
|
+
const deadline = new AbortController();
|
|
231
|
+
const timer = setTimeout(() => deadline.abort(
|
|
232
|
+
new DOMException("Search timed out. Please try again.", "TimeoutError")
|
|
233
|
+
), searchTimeoutMs);
|
|
234
|
+
const signal = searchOpts?.signal
|
|
235
|
+
? AbortSignal.any([searchOpts.signal, deadline.signal])
|
|
236
|
+
: deadline.signal;
|
|
237
|
+
try {
|
|
238
|
+
const result = await execBrain(args, signal);
|
|
239
|
+
const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
|
|
240
|
+
// brain returns {results, warnings} since 22e82e1; tolerate the old bare array
|
|
241
|
+
return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
|
|
242
|
+
} finally {
|
|
243
|
+
clearTimeout(timer);
|
|
244
|
+
}
|
|
212
245
|
},
|
|
213
246
|
|
|
214
247
|
async briefing() {
|
|
@@ -279,6 +312,14 @@ export function createBrainClient(opts: { brainPath: string }): BrainClient {
|
|
|
279
312
|
if (result.exitCode !== 0) {
|
|
280
313
|
throw new Error(`brain add failed: ${result.stderr}`);
|
|
281
314
|
}
|
|
315
|
+
const parsed = parseJsonOutput<BrainAddResult>(result);
|
|
316
|
+
if (!parsed || typeof parsed !== "object" || typeof parsed.path !== "string" ||
|
|
317
|
+
typeof parsed.indexed !== "boolean" || typeof parsed.title !== "string" ||
|
|
318
|
+
typeof parsed.type !== "string" || (parsed.indexError !== undefined && typeof parsed.indexError !== "string") ||
|
|
319
|
+
!["created", "appended"].includes(parsed.action)) {
|
|
320
|
+
throw new Error("Capture completed but its outcome could not be read. Check your files before adding it again.");
|
|
321
|
+
}
|
|
322
|
+
return parsed;
|
|
282
323
|
},
|
|
283
324
|
|
|
284
325
|
async index(opts) {
|