@schlessera/brain-ui-server 0.34.0 → 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 +34 -0
- package/dist/activity/sql.d.ts.map +1 -0
- package/dist/activity/sql.js +114 -0
- package/dist/activity/sql.js.map +1 -0
- package/dist/activity/store.d.ts +7 -0
- package/dist/activity/store.d.ts.map +1 -1
- package/dist/activity/store.js +48 -113
- 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 +122 -0
- package/src/activity/store.ts +73 -155
- 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
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
|
+
import type { Database } from "bun:sqlite";
|
|
3
|
+
import { Hono } from "hono";
|
|
4
|
+
import type { Context } from "hono";
|
|
5
|
+
import { generateSignedCookie } from "hono/cookie";
|
|
6
|
+
|
|
7
|
+
import type { AppEnv } from "../app-env.js";
|
|
8
|
+
import {
|
|
9
|
+
createPrincipal,
|
|
10
|
+
listLivePrincipals,
|
|
11
|
+
PrincipalCreatorNotUsableError,
|
|
12
|
+
PrincipalLimitError,
|
|
13
|
+
revokePrincipal,
|
|
14
|
+
} from "../db/principals.js";
|
|
15
|
+
import { readJsonBody } from "./body-limit.js";
|
|
16
|
+
import {
|
|
17
|
+
applyPrincipalRevocation,
|
|
18
|
+
SESSION_TTL_SECONDS,
|
|
19
|
+
type AuthMode,
|
|
20
|
+
type AuthRuntime,
|
|
21
|
+
} from "./auth.js";
|
|
22
|
+
import { requireJson } from "./origin.js";
|
|
23
|
+
import type { WsHost } from "../ws/host.js";
|
|
24
|
+
import { requireOwner } from "./require-owner.js";
|
|
25
|
+
|
|
26
|
+
const COOKIE_NAME = "brain_ui_session";
|
|
27
|
+
const SECONDS_PER_DAY = 24 * 60 * 60;
|
|
28
|
+
const DEFAULT_AGENT_TTL_DAYS = 7;
|
|
29
|
+
const MAX_AGENT_TTL_DAYS = SESSION_TTL_SECONDS / SECONDS_PER_DAY;
|
|
30
|
+
const MAX_LABEL_LENGTH = 64;
|
|
31
|
+
const CONTROL_CHARACTERS = /[\u0000-\u001f\u007f-\u009f]/gu;
|
|
32
|
+
|
|
33
|
+
export interface PrincipalManagementContext {
|
|
34
|
+
db: Database;
|
|
35
|
+
revoker: Pick<WsHost, "revokePrincipals">;
|
|
36
|
+
log?: Logger;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function notEnabled(c: Context) {
|
|
40
|
+
return c.json({ error: "Principal management is not enabled" }, 400);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function sanitizeLabel(value: unknown): string {
|
|
44
|
+
return typeof value === "string"
|
|
45
|
+
? value
|
|
46
|
+
.replace(CONTROL_CHARACTERS, "")
|
|
47
|
+
.trim()
|
|
48
|
+
.slice(0, MAX_LABEL_LENGTH)
|
|
49
|
+
.trim()
|
|
50
|
+
: "";
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function cookieValue(serialized: string): string {
|
|
54
|
+
const valueStart = serialized.indexOf("=") + 1;
|
|
55
|
+
const valueEnd = serialized.indexOf(";", valueStart);
|
|
56
|
+
return decodeURIComponent(
|
|
57
|
+
serialized.slice(valueStart, valueEnd === -1 ? undefined : valueEnd)
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Owner-only delegated-principal routes. Mount AFTER the auth guard: the
|
|
63
|
+
* family middleware below authorizes the resolved principal, it does not
|
|
64
|
+
* authenticate the request itself.
|
|
65
|
+
*/
|
|
66
|
+
export function principalManagementRoutes(
|
|
67
|
+
mode: AuthMode,
|
|
68
|
+
auth: AuthRuntime,
|
|
69
|
+
ctx: PrincipalManagementContext
|
|
70
|
+
): Hono<AppEnv> {
|
|
71
|
+
const app = new Hono<AppEnv>();
|
|
72
|
+
|
|
73
|
+
app.use("/auth/principals/*", async (c, next) => {
|
|
74
|
+
if (mode !== "password") return notEnabled(c);
|
|
75
|
+
await next();
|
|
76
|
+
});
|
|
77
|
+
app.use("/auth/principals/*", requireOwner());
|
|
78
|
+
|
|
79
|
+
app.get("/auth/principals", (c) => {
|
|
80
|
+
const caller = c.get("principal")!;
|
|
81
|
+
const principals = listLivePrincipals(ctx.db, Date.now()).map((principal) => ({
|
|
82
|
+
id: principal.id,
|
|
83
|
+
kind: principal.kind,
|
|
84
|
+
auth_method: principal.authMethod,
|
|
85
|
+
label: principal.label,
|
|
86
|
+
created_at: principal.createdAt,
|
|
87
|
+
expires_at: principal.expiresAt,
|
|
88
|
+
last_seen_at: principal.lastSeenAt,
|
|
89
|
+
is_own: principal.id === caller.id,
|
|
90
|
+
}));
|
|
91
|
+
return c.json({ principals });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
app.post("/auth/principals", requireJson(), async (c) => {
|
|
95
|
+
let body: { label?: unknown; ttlDays?: unknown };
|
|
96
|
+
try {
|
|
97
|
+
const result = await readJsonBody<{ label?: unknown; ttlDays?: unknown }>(c);
|
|
98
|
+
if (result instanceof Response) return result;
|
|
99
|
+
if (!result || typeof result !== "object" || Array.isArray(result)) {
|
|
100
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
101
|
+
}
|
|
102
|
+
body = result;
|
|
103
|
+
} catch {
|
|
104
|
+
return c.json({ error: "Invalid request body" }, 400);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const label = sanitizeLabel(body.label);
|
|
108
|
+
if (!label) return c.json({ error: "label must be a non-empty string" }, 400);
|
|
109
|
+
|
|
110
|
+
const ttlDays =
|
|
111
|
+
body.ttlDays === undefined ? DEFAULT_AGENT_TTL_DAYS : body.ttlDays;
|
|
112
|
+
if (
|
|
113
|
+
typeof ttlDays !== "number" ||
|
|
114
|
+
!Number.isInteger(ttlDays) ||
|
|
115
|
+
ttlDays < 1 ||
|
|
116
|
+
ttlDays > MAX_AGENT_TTL_DAYS
|
|
117
|
+
) {
|
|
118
|
+
return c.json(
|
|
119
|
+
{ error: `ttlDays must be an integer between 1 and ${MAX_AGENT_TTL_DAYS}` },
|
|
120
|
+
400
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const caller = c.get("principal")!;
|
|
125
|
+
let principal;
|
|
126
|
+
try {
|
|
127
|
+
principal = createPrincipal(ctx.db, {
|
|
128
|
+
authMethod: "delegated",
|
|
129
|
+
label,
|
|
130
|
+
createdBy: caller.id,
|
|
131
|
+
ttlSeconds: ttlDays * SECONDS_PER_DAY,
|
|
132
|
+
});
|
|
133
|
+
} catch (error) {
|
|
134
|
+
if (error instanceof PrincipalCreatorNotUsableError) {
|
|
135
|
+
return c.json({ error: "Owner access required" }, 403);
|
|
136
|
+
}
|
|
137
|
+
if (!(error instanceof PrincipalLimitError)) throw error;
|
|
138
|
+
return c.json(
|
|
139
|
+
{ error: "Principal capacity reached. Revoke another principal and try again." },
|
|
140
|
+
503
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const serialized = await generateSignedCookie(
|
|
145
|
+
COOKIE_NAME,
|
|
146
|
+
principal.id,
|
|
147
|
+
auth.cookieSecret ?? ""
|
|
148
|
+
);
|
|
149
|
+
const cookie = cookieValue(serialized);
|
|
150
|
+
|
|
151
|
+
ctx.log?.emit({
|
|
152
|
+
severityText: "INFO",
|
|
153
|
+
body: "delegated principal minted",
|
|
154
|
+
attributes: {
|
|
155
|
+
"auth.principal.id": caller.id,
|
|
156
|
+
"auth.target_principal.id": principal.id,
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
c.header("Cache-Control", "no-store");
|
|
160
|
+
return c.json({
|
|
161
|
+
id: principal.id,
|
|
162
|
+
label: principal.label,
|
|
163
|
+
expiresAt: principal.expiresAt,
|
|
164
|
+
cookie,
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
app.delete("/auth/principals/:id", (c) => {
|
|
169
|
+
const caller = c.get("principal")!;
|
|
170
|
+
const targetId = c.req.param("id");
|
|
171
|
+
const revokedIds = revokePrincipal(ctx.db, targetId, Date.now());
|
|
172
|
+
if (revokedIds.length === 0) {
|
|
173
|
+
return c.json({ error: "Unknown principal" }, 404);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
applyPrincipalRevocation(ctx.revoker, revokedIds);
|
|
177
|
+
ctx.log?.emit({
|
|
178
|
+
severityText: "INFO",
|
|
179
|
+
body: "principal revoked",
|
|
180
|
+
attributes: {
|
|
181
|
+
"auth.principal.id": caller.id,
|
|
182
|
+
"auth.target_principal.id": targetId,
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
return c.json({ ok: true });
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
return app;
|
|
189
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { MiddlewareHandler } from "hono";
|
|
2
|
+
|
|
3
|
+
import type { AppEnv } from "../app-env.js";
|
|
4
|
+
|
|
5
|
+
/** Require the authenticated request principal to be the owner. */
|
|
6
|
+
export function requireOwner(): MiddlewareHandler<AppEnv> {
|
|
7
|
+
return async (c, next) => {
|
|
8
|
+
if (c.get("principal")?.kind !== "owner") {
|
|
9
|
+
return c.json({ error: "Owner access required" }, 403);
|
|
10
|
+
}
|
|
11
|
+
await next();
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -54,7 +54,7 @@ export interface ConsoleLoggerProviderOptions {
|
|
|
54
54
|
function formatLine(record: CapturedLog): string {
|
|
55
55
|
const attrs = Object.entries(record.attributes)
|
|
56
56
|
.filter(([, v]) => v !== undefined)
|
|
57
|
-
.map(([k, v]) => `${k}=${String(v)}`);
|
|
57
|
+
.map(([k, v]) => `${k}=${typeof v === "string" ? JSON.stringify(v) : String(v)}`);
|
|
58
58
|
return `[${record.scope}] ${record.body}${attrs.length ? ` ${attrs.join(" ")}` : ""}`;
|
|
59
59
|
}
|
|
60
60
|
|
package/src/routes/activity.ts
CHANGED
|
@@ -137,6 +137,9 @@ export function createActivityRoutes(deps: {
|
|
|
137
137
|
name: s.name,
|
|
138
138
|
sessionId: s.sessionId,
|
|
139
139
|
jobName: s.jobName,
|
|
140
|
+
principalId: s.principalId,
|
|
141
|
+
principalLabel: null,
|
|
142
|
+
principalKind: null,
|
|
140
143
|
startedAt: s.startedAt,
|
|
141
144
|
endedAt: null,
|
|
142
145
|
outcome: null,
|
package/src/routes/brain.ts
CHANGED
|
@@ -7,11 +7,14 @@ import {
|
|
|
7
7
|
writeCache,
|
|
8
8
|
type KeytermSettings,
|
|
9
9
|
} from "../voice/keyterm-builder.js";
|
|
10
|
-
import { existsSync } from "fs";
|
|
10
|
+
import { existsSync, realpathSync } from "fs";
|
|
11
11
|
import { join } from "path";
|
|
12
12
|
import { readJsonBody } from "../middleware/body-limit.js";
|
|
13
13
|
import { requireJson } from "../middleware/origin.js";
|
|
14
14
|
|
|
15
|
+
// Shared across route instances, keyed by the canonical brain root.
|
|
16
|
+
const syncingRoots = new Set<string>();
|
|
17
|
+
|
|
15
18
|
export interface BrainRoutesDeps {
|
|
16
19
|
brain: BrainClient;
|
|
17
20
|
brainPath: string;
|
|
@@ -39,6 +42,7 @@ function findWhatsupScript(brainPath: string): string | null {
|
|
|
39
42
|
|
|
40
43
|
export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
41
44
|
const { brain, brainPath, keyterms } = deps;
|
|
45
|
+
let indexing: Promise<void> | null = null;
|
|
42
46
|
|
|
43
47
|
return new Hono()
|
|
44
48
|
.get("/brain/search", async (c) => {
|
|
@@ -52,12 +56,13 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
|
52
56
|
tag: c.req.query("tag"),
|
|
53
57
|
limit: c.req.query("limit") ? Number(c.req.query("limit")) : undefined,
|
|
54
58
|
mode: c.req.query("mode"),
|
|
59
|
+
signal: c.req.raw.signal,
|
|
55
60
|
});
|
|
56
61
|
return c.json({ results, warnings });
|
|
57
62
|
} catch (err) {
|
|
58
63
|
return c.json(
|
|
59
64
|
{ error: err instanceof Error ? err.message : "Search failed" },
|
|
60
|
-
500
|
|
65
|
+
err instanceof Error && err.name === "TimeoutError" ? 504 : 500
|
|
61
66
|
);
|
|
62
67
|
}
|
|
63
68
|
})
|
|
@@ -205,91 +210,123 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
|
205
210
|
// Stream sync output via SSE so the client sees live progress.
|
|
206
211
|
// brain sync spawns a nested Claude Code process that can take minutes.
|
|
207
212
|
|
|
213
|
+
const syncRoot = realpathSync(brainPath);
|
|
214
|
+
if (syncingRoots.has(syncRoot)) {
|
|
215
|
+
return c.json({ error: "A sync is already running. Wait for it to finish before retrying." }, 409);
|
|
216
|
+
}
|
|
217
|
+
syncingRoots.add(syncRoot);
|
|
208
218
|
return streamSSE(c, async (stream) => {
|
|
219
|
+
let keepalive: ReturnType<typeof setInterval> | undefined;
|
|
220
|
+
let disconnected = false;
|
|
221
|
+
stream.onAbort(() => { disconnected = true; });
|
|
209
222
|
const send = (data: {
|
|
210
223
|
type: string;
|
|
211
224
|
text?: string;
|
|
212
225
|
success?: boolean;
|
|
213
|
-
}) => stream.writeSSE({ data: JSON.stringify(data) })
|
|
226
|
+
}) => disconnected ? Promise.resolve() : stream.writeSSE({ data: JSON.stringify(data) })
|
|
227
|
+
.catch(() => { disconnected = true; });
|
|
214
228
|
|
|
215
|
-
|
|
229
|
+
try {
|
|
230
|
+
await send({ type: "start", text: "Starting brain sync..." });
|
|
216
231
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
232
|
+
// The nested Claude process can be silent for minutes; without traffic
|
|
233
|
+
// the socket gets cut by idle timeouts (Bun's own, or a proxy's). SSE
|
|
234
|
+
// comment lines are ignored by EventSource and data-line parsers.
|
|
235
|
+
keepalive = setInterval(() => {
|
|
236
|
+
stream.write(": keepalive\n\n").catch(() => {});
|
|
237
|
+
}, 15_000);
|
|
223
238
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
+
// Merge stderr into stdout — brain sync spawns a nested Claude Code
|
|
240
|
+
// process whose output goes to stderr. The command comes from
|
|
241
|
+
// brain.cliCommand() (packaged bin or legacy vendored script), passed to
|
|
242
|
+
// bash as positional args rather than interpolated into the script, so a
|
|
243
|
+
// BRAIN_PATH containing spaces or shell metacharacters stays inert.
|
|
244
|
+
const [cliBin, ...cliArgs] = brain.cliCommand();
|
|
245
|
+
const proc = Bun.spawn(
|
|
246
|
+
["bash", "-c", '"$0" "$@" 2>&1', cliBin!, ...cliArgs, "sync"],
|
|
247
|
+
{
|
|
248
|
+
cwd: brainPath,
|
|
249
|
+
stdout: "pipe",
|
|
250
|
+
stderr: "pipe",
|
|
251
|
+
env: subprocessEnv("brainCli", { NO_COLOR: "1" }),
|
|
252
|
+
}
|
|
253
|
+
);
|
|
239
254
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
255
|
+
// Keep draining even when the SSE reader disconnects. The repository
|
|
256
|
+
// stays reserved until the actual sync process has exited.
|
|
257
|
+
const stderrDone = new Response(proc.stderr).text().catch(() => "");
|
|
258
|
+
const reader = proc.stdout.getReader();
|
|
259
|
+
const decoder = new TextDecoder();
|
|
260
|
+
let buffer = "";
|
|
261
|
+
try {
|
|
262
|
+
while (true) {
|
|
263
|
+
const { done, value } = await reader.read();
|
|
264
|
+
if (done) break;
|
|
265
|
+
buffer += decoder.decode(value, { stream: true });
|
|
266
|
+
const lines = buffer.split("\n");
|
|
267
|
+
buffer = lines.pop() || "";
|
|
268
|
+
for (const line of lines) {
|
|
269
|
+
if (line.trim()) {
|
|
270
|
+
await send({ type: "progress", text: line });
|
|
271
|
+
}
|
|
253
272
|
}
|
|
254
273
|
}
|
|
274
|
+
if (buffer.trim()) {
|
|
275
|
+
await send({ type: "progress", text: buffer });
|
|
276
|
+
}
|
|
277
|
+
} catch {} finally {
|
|
278
|
+
clearInterval(keepalive);
|
|
255
279
|
}
|
|
256
|
-
if (buffer.trim()) {
|
|
257
|
-
await send({ type: "progress", text: buffer });
|
|
258
|
-
}
|
|
259
|
-
} catch {} finally {
|
|
260
|
-
clearInterval(keepalive);
|
|
261
|
-
}
|
|
262
280
|
|
|
263
|
-
|
|
281
|
+
const exitCode = await proc.exited;
|
|
282
|
+
await stderrDone;
|
|
264
283
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
284
|
+
// Rebuild voice keyterms cache after a successful sync.
|
|
285
|
+
if (exitCode === 0) {
|
|
286
|
+
try {
|
|
287
|
+
const cache = buildKeyterms(keyterms);
|
|
288
|
+
writeCache(keyterms, cache);
|
|
289
|
+
await send({
|
|
290
|
+
type: "progress",
|
|
291
|
+
text: `[voice] Rebuilt keyterms cache (${cache.count} terms)`,
|
|
292
|
+
});
|
|
293
|
+
} catch (err) {
|
|
294
|
+
await send({
|
|
295
|
+
type: "progress",
|
|
296
|
+
text: `[voice] Keyterm rebuild failed: ${
|
|
297
|
+
err instanceof Error ? err.message : String(err)
|
|
298
|
+
}`,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
281
301
|
}
|
|
282
|
-
}
|
|
283
302
|
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
303
|
+
await send({
|
|
304
|
+
type: "done",
|
|
305
|
+
success: exitCode === 0,
|
|
306
|
+
text:
|
|
307
|
+
exitCode === 0 ? "Sync completed" : `Sync failed (exit ${exitCode})`,
|
|
308
|
+
});
|
|
309
|
+
} catch (error) {
|
|
310
|
+
await send({ type: "done", success: false, text: error instanceof Error ? error.message : "Sync failed" });
|
|
311
|
+
} finally {
|
|
312
|
+
clearInterval(keepalive);
|
|
313
|
+
syncingRoots.delete(syncRoot);
|
|
314
|
+
}
|
|
290
315
|
});
|
|
291
316
|
})
|
|
292
317
|
|
|
318
|
+
// Recovery after a successful capture whose indexing failed. Coalesce
|
|
319
|
+
// concurrent retries and never submit the captured content a second time.
|
|
320
|
+
.post("/brain/index", requireJson(), async (c) => {
|
|
321
|
+
try {
|
|
322
|
+
if (!indexing) indexing = brain.index().finally(() => { indexing = null; });
|
|
323
|
+
await indexing;
|
|
324
|
+
return c.json({ success: true });
|
|
325
|
+
} catch (err) {
|
|
326
|
+
return c.json({ error: err instanceof Error ? err.message : "Indexing failed" }, 500);
|
|
327
|
+
}
|
|
328
|
+
})
|
|
329
|
+
|
|
293
330
|
.post("/brain/add", requireJson(), async (c) => {
|
|
294
331
|
const result = await readJsonBody<{
|
|
295
332
|
content: string;
|
|
@@ -303,12 +340,12 @@ export function createBrainRoutes(deps: BrainRoutesDeps): Hono {
|
|
|
303
340
|
return c.json({ error: "Field 'content' is required" }, 400);
|
|
304
341
|
}
|
|
305
342
|
try {
|
|
306
|
-
await brain.add(body.content, {
|
|
343
|
+
const outcome = await brain.add(body.content, {
|
|
307
344
|
type: body.type,
|
|
308
345
|
title: body.title,
|
|
309
346
|
tags: body.tags,
|
|
310
347
|
});
|
|
311
|
-
return c.json({ success: true });
|
|
348
|
+
return c.json({ success: true, ...outcome });
|
|
312
349
|
} catch (err) {
|
|
313
350
|
return c.json(
|
|
314
351
|
{ error: err instanceof Error ? err.message : "Add failed" },
|
package/src/routes/files.ts
CHANGED
|
@@ -29,6 +29,7 @@ function errorResponse(err: unknown, log?: Logger): { body: { error: string; siz
|
|
|
29
29
|
|
|
30
30
|
export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Hono {
|
|
31
31
|
const { brainRoot, log } = deps;
|
|
32
|
+
let wikilinkBuild: Promise<void> | null = null;
|
|
32
33
|
let wikilinkCache: { generatedAt: number; map: Record<string, string> } | null = null;
|
|
33
34
|
return new Hono()
|
|
34
35
|
.get("/files/tree", async (c) => {
|
|
@@ -87,13 +88,17 @@ export function createFilesRoutes(deps: { brainRoot: string; log?: Logger }): Ho
|
|
|
87
88
|
const now = Date.now();
|
|
88
89
|
try {
|
|
89
90
|
if (refresh || !wikilinkCache || now - wikilinkCache.generatedAt > WIKILINK_TTL_MS) {
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
if (!wikilinkBuild) {
|
|
92
|
+
wikilinkBuild = buildWikilinkMap(brainRoot).then((map) => {
|
|
93
|
+
wikilinkCache = { generatedAt: Date.now(), map };
|
|
94
|
+
}).finally(() => { wikilinkBuild = null; });
|
|
95
|
+
}
|
|
96
|
+
await wikilinkBuild;
|
|
92
97
|
}
|
|
93
98
|
return c.json({
|
|
94
|
-
generatedAt: wikilinkCache
|
|
95
|
-
count: Object.keys(wikilinkCache
|
|
96
|
-
slugs: wikilinkCache
|
|
99
|
+
generatedAt: wikilinkCache!.generatedAt,
|
|
100
|
+
count: Object.keys(wikilinkCache!.map).length,
|
|
101
|
+
slugs: wikilinkCache!.map,
|
|
97
102
|
});
|
|
98
103
|
} catch (err) {
|
|
99
104
|
const { body, status } = errorResponse(err, log);
|
package/src/routes/push.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
4
|
import type { PushSender } from "../activity/push-sender.js";
|
|
5
|
+
import type { AppEnv } from "../app-env.js";
|
|
5
6
|
import { readJsonBody } from "../middleware/body-limit.js";
|
|
6
7
|
import { requireJson } from "../middleware/origin.js";
|
|
7
8
|
|
|
@@ -26,10 +27,10 @@ const unsubscribeSchema = z.object({
|
|
|
26
27
|
endpoint: z.url().max(2048),
|
|
27
28
|
});
|
|
28
29
|
|
|
29
|
-
export function createPushRoutes(deps: { sender: PushSender })
|
|
30
|
+
export function createPushRoutes(deps: { sender: PushSender }) {
|
|
30
31
|
const { sender } = deps;
|
|
31
32
|
|
|
32
|
-
return new Hono()
|
|
33
|
+
return new Hono<AppEnv>()
|
|
33
34
|
.get("/push/public-key", (c) => {
|
|
34
35
|
try {
|
|
35
36
|
return c.json({ publicKey: sender.publicKey() });
|
|
@@ -45,7 +46,7 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
|
|
|
45
46
|
try {
|
|
46
47
|
// Endpoints are capability URLs — list only metadata.
|
|
47
48
|
return c.json({
|
|
48
|
-
subscriptions: sender.subscriptions().map((s) => ({
|
|
49
|
+
subscriptions: sender.subscriptions(c.get("principal")!.id).map((s) => ({
|
|
49
50
|
label: s.label,
|
|
50
51
|
createdAt: s.createdAt,
|
|
51
52
|
lastUsedAt: s.lastUsedAt,
|
|
@@ -66,7 +67,10 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
|
|
|
66
67
|
const result = await readJsonBody(c);
|
|
67
68
|
if (result instanceof Response) return result;
|
|
68
69
|
const body = subscribeSchema.parse(result);
|
|
69
|
-
|
|
70
|
+
const principal = c.get("principal")!;
|
|
71
|
+
if (!sender.subscribe(body.subscription, principal.id, body.label)) {
|
|
72
|
+
return c.json({ error: "Authentication required" }, 401);
|
|
73
|
+
}
|
|
70
74
|
return c.json({ ok: true });
|
|
71
75
|
} catch (err) {
|
|
72
76
|
return c.json(
|
|
@@ -81,7 +85,9 @@ export function createPushRoutes(deps: { sender: PushSender }): Hono {
|
|
|
81
85
|
const result = await readJsonBody(c);
|
|
82
86
|
if (result instanceof Response) return result;
|
|
83
87
|
const body = unsubscribeSchema.parse(result);
|
|
84
|
-
return c.json({
|
|
88
|
+
return c.json({
|
|
89
|
+
removed: sender.unsubscribe(body.endpoint, c.get("principal")!.id),
|
|
90
|
+
});
|
|
85
91
|
} catch (err) {
|
|
86
92
|
return c.json(
|
|
87
93
|
{ error: err instanceof Error ? err.message : "Bad request" },
|
package/src/routes/sessions.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { Hono } from "hono";
|
|
2
2
|
import type { Database } from "bun:sqlite";
|
|
3
|
+
import type { AgentBackend } from "@schlessera/brain-ui-sdk/server";
|
|
3
4
|
import type { BackendRegistry } from "../agent/backend.js";
|
|
4
5
|
|
|
5
6
|
export function createSessionRoutes(deps: {
|
|
@@ -7,6 +8,30 @@ export function createSessionRoutes(deps: {
|
|
|
7
8
|
db: Database;
|
|
8
9
|
}): Hono {
|
|
9
10
|
const { registry, db } = deps;
|
|
11
|
+
const pending = new Map<AgentBackend, ReturnType<AgentBackend["listSessions"]>>();
|
|
12
|
+
|
|
13
|
+
async function listSessions(backend: AgentBackend) {
|
|
14
|
+
let work = pending.get(backend);
|
|
15
|
+
if (!work) {
|
|
16
|
+
work = Promise.resolve().then(() => backend.listSessions());
|
|
17
|
+
pending.set(backend, work);
|
|
18
|
+
const clear = () => { if (pending.get(backend) === work) pending.delete(backend); };
|
|
19
|
+
void work.then(clear, clear);
|
|
20
|
+
}
|
|
21
|
+
// Backends have no cancellation API. Reuse unfinished work so retries
|
|
22
|
+
// cannot pile up scans behind a stalled backend.
|
|
23
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
24
|
+
try {
|
|
25
|
+
return await Promise.race([
|
|
26
|
+
work,
|
|
27
|
+
new Promise<never>((_, reject) => {
|
|
28
|
+
timer = setTimeout(() => reject(new Error("Session listing timed out")), 3_000);
|
|
29
|
+
}),
|
|
30
|
+
]);
|
|
31
|
+
} finally {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
10
35
|
|
|
11
36
|
function getStoredBackendId(sessionId: string): string | null {
|
|
12
37
|
const row = db
|
|
@@ -30,28 +55,28 @@ export function createSessionRoutes(deps: {
|
|
|
30
55
|
.all() as Array<{ id: string; cost: number | null; turns: number | null }>
|
|
31
56
|
).map((r) => [r.id, r])
|
|
32
57
|
);
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
})
|
|
49
|
-
)
|
|
58
|
+
const results = await Promise.allSettled(
|
|
59
|
+
backends.map(async (backend) =>
|
|
60
|
+
(await listSessions(backend)).map((session) => {
|
|
61
|
+
const stored = accounting.get(session.id);
|
|
62
|
+
return {
|
|
63
|
+
...session,
|
|
64
|
+
...(stored?.cost && session.totalCostUsd === 0
|
|
65
|
+
? { totalCostUsd: stored.cost }
|
|
66
|
+
: {}),
|
|
67
|
+
...(stored?.turns && session.numTurns === 0
|
|
68
|
+
? { numTurns: stored.turns }
|
|
69
|
+
: {}),
|
|
70
|
+
backendId: backend.id,
|
|
71
|
+
};
|
|
72
|
+
})
|
|
50
73
|
)
|
|
51
|
-
)
|
|
52
|
-
|
|
74
|
+
);
|
|
75
|
+
const sessions = results.flatMap((result) => result.status === "fulfilled" ? result.value : [])
|
|
53
76
|
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
|
|
54
|
-
|
|
77
|
+
const unavailableBackends = results.flatMap((result, index) =>
|
|
78
|
+
result.status === "rejected" ? [backends[index]!.id] : []);
|
|
79
|
+
return c.json({ sessions, ...(unavailableBackends.length ? { unavailableBackends } : {}) });
|
|
55
80
|
} catch (err) {
|
|
56
81
|
return c.json(
|
|
57
82
|
{ error: err instanceof Error ? err.message : "Failed to list sessions" },
|
package/src/share/staging.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Logger } from "@opentelemetry/api-logs";
|
|
2
|
-
import { mkdir, readdir, rename, rm,
|
|
2
|
+
import { mkdir, readdir, rename, rm, writeFile, realpath, lstat } from "node:fs/promises";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import {
|
|
5
5
|
SHARE_MAX_FILES,
|
|
@@ -406,9 +406,15 @@ export async function pruneShareStaging(
|
|
|
406
406
|
now = Date.now(),
|
|
407
407
|
log?: Logger
|
|
408
408
|
): Promise<number> {
|
|
409
|
-
|
|
409
|
+
let root: string;
|
|
410
410
|
let entries;
|
|
411
411
|
try {
|
|
412
|
+
root = await safeResolve(SHARE_STAGING_DIR, brainPath);
|
|
413
|
+
// Even an internal symlink could point at unrelated content. Only prune
|
|
414
|
+
// the actual staging directory; the brain root itself may be a symlink.
|
|
415
|
+
if (await realpath(root) !== join(await realpath(brainPath), SHARE_STAGING_DIR)) {
|
|
416
|
+
throw new Error("Share staging directory must not traverse a symlink");
|
|
417
|
+
}
|
|
412
418
|
entries = await readdir(root, { withFileTypes: true });
|
|
413
419
|
} catch (err) {
|
|
414
420
|
// Nothing staged yet is the ordinary case. Anything else — a permission
|
|
@@ -430,8 +436,9 @@ export async function pruneShareStaging(
|
|
|
430
436
|
const path = join(root, entry.name);
|
|
431
437
|
const ttl = entry.name.startsWith(".") ? PARTIAL_TTL_MS : SHARE_STAGING_TTL_MS;
|
|
432
438
|
try {
|
|
433
|
-
|
|
434
|
-
|
|
439
|
+
await safeResolve(`${SHARE_STAGING_DIR}/${entry.name}`, brainPath);
|
|
440
|
+
const info = await lstat(path);
|
|
441
|
+
if (!info.isDirectory() || now - info.mtimeMs <= ttl) continue;
|
|
435
442
|
await rm(path, { recursive: true, force: true });
|
|
436
443
|
removed += 1;
|
|
437
444
|
} catch {
|