@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
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) {
|
package/src/brain/types.ts
CHANGED
|
@@ -42,3 +42,13 @@ export interface BrainSyncResult {
|
|
|
42
42
|
conflicts: number;
|
|
43
43
|
message: string;
|
|
44
44
|
}
|
|
45
|
+
|
|
46
|
+
/** Outcome of saving content, including a recoverable indexing failure. */
|
|
47
|
+
export interface BrainAddResult {
|
|
48
|
+
action: "created" | "appended";
|
|
49
|
+
path: string;
|
|
50
|
+
title: string;
|
|
51
|
+
type: string;
|
|
52
|
+
indexed: boolean;
|
|
53
|
+
indexError?: string;
|
|
54
|
+
}
|
package/src/cron/run-job.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { unlinkSync } from "fs";
|
|
|
8
8
|
import { ingestSpanSink } from "../activity/span-sink.js";
|
|
9
9
|
import { createActivityStore } from "../activity/store.js";
|
|
10
10
|
import { createUiDb } from "../db/client.js";
|
|
11
|
+
import { resolveSystemPrincipal } from "../db/principals.js";
|
|
11
12
|
import { recordCronRun } from "./scheduler.js";
|
|
12
13
|
|
|
13
14
|
/** Tail of stderr retained for the cron_runs error row. */
|
|
@@ -70,7 +71,7 @@ function messageOf(error: unknown): string {
|
|
|
70
71
|
}
|
|
71
72
|
|
|
72
73
|
function writeLine(sink: TextSink, text: string): void {
|
|
73
|
-
sink.write(`${text}\n`);
|
|
74
|
+
try { sink.write(`${text}\n`); } catch { /* Logging cannot stop the job. */ }
|
|
74
75
|
}
|
|
75
76
|
|
|
76
77
|
/** Forward a stream verbatim while exposing decoded chunks for tail capture. */
|
|
@@ -80,11 +81,17 @@ export async function teeStream(
|
|
|
80
81
|
onText: (text: string) => void
|
|
81
82
|
): Promise<void> {
|
|
82
83
|
const decoder = new TextDecoder();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
sink.write(text);
|
|
84
|
+
let forwarding = true;
|
|
85
|
+
const forward = async (text: string) => {
|
|
86
86
|
onText(text);
|
|
87
|
+
if (!forwarding) return;
|
|
88
|
+
try { await sink.write(text); } catch { forwarding = false; }
|
|
89
|
+
};
|
|
90
|
+
for await (const chunk of stream) {
|
|
91
|
+
await forward(decoder.decode(chunk, { stream: true }));
|
|
87
92
|
}
|
|
93
|
+
const trailing = decoder.decode();
|
|
94
|
+
if (trailing) await forward(trailing);
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
/** Keep the last `cap` characters, never the head. */
|
|
@@ -99,10 +106,16 @@ async function startRecord(
|
|
|
99
106
|
dbPath: string,
|
|
100
107
|
stderr: TextSink
|
|
101
108
|
): Promise<RunRecorder | null> {
|
|
109
|
+
let db: ReturnType<typeof createUiDb> | undefined;
|
|
102
110
|
try {
|
|
103
|
-
|
|
111
|
+
db = createUiDb(dbPath);
|
|
112
|
+
const openedDb = db;
|
|
104
113
|
const record = recordCronRun(db, name);
|
|
105
114
|
const store = createActivityStore(db, { writer: `cron:${process.pid}` });
|
|
115
|
+
const principal = resolveSystemPrincipal(db, {
|
|
116
|
+
identity: "scheduled-jobs",
|
|
117
|
+
label: "Scheduled jobs",
|
|
118
|
+
});
|
|
106
119
|
const runId = `cron-${name}-${Date.now()}`;
|
|
107
120
|
const rootSpanId = `${runId}:root`;
|
|
108
121
|
store.startSpan({
|
|
@@ -112,6 +125,7 @@ async function startRecord(
|
|
|
112
125
|
kind: "cron",
|
|
113
126
|
origin: "cron",
|
|
114
127
|
jobName: name,
|
|
128
|
+
principalId: principal.id,
|
|
115
129
|
});
|
|
116
130
|
|
|
117
131
|
let sinkOffset = 0;
|
|
@@ -168,10 +182,11 @@ async function startRecord(
|
|
|
168
182
|
},
|
|
169
183
|
close() {
|
|
170
184
|
clearInterval(heartbeat);
|
|
171
|
-
|
|
185
|
+
openedDb.close();
|
|
172
186
|
},
|
|
173
187
|
};
|
|
174
188
|
} catch (error) {
|
|
189
|
+
try { db?.close(); } catch { /* Preserve fail-open tracking. */ }
|
|
175
190
|
writeLine(
|
|
176
191
|
stderr,
|
|
177
192
|
`[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`
|
|
@@ -191,13 +206,12 @@ export async function runJob(
|
|
|
191
206
|
dependencies.sinkPath ??
|
|
192
207
|
`/tmp/brain-activity-sink-${process.pid}-${Date.now()}.jsonl`;
|
|
193
208
|
const beginRecord = dependencies.startRecord ?? startRecord;
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
sinkPath,
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
);
|
|
209
|
+
let recorder: RunRecorder | null = null;
|
|
210
|
+
try {
|
|
211
|
+
recorder = await beginRecord(options.jobName, sinkPath, options.command, options.dbPath, stderr);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
writeLine(stderr, `[cron-run] tracking unavailable (job runs anyway): ${messageOf(error)}`);
|
|
214
|
+
}
|
|
201
215
|
|
|
202
216
|
let exitCode: number;
|
|
203
217
|
let errorMessage: string | undefined;
|
|
@@ -213,7 +227,7 @@ export async function runJob(
|
|
|
213
227
|
});
|
|
214
228
|
|
|
215
229
|
let stderrTail = "";
|
|
216
|
-
await Promise.
|
|
230
|
+
const outcomes = await Promise.allSettled([
|
|
217
231
|
teeStream(proc.stdout, stdout, (text) => {
|
|
218
232
|
outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
|
|
219
233
|
}),
|
|
@@ -221,9 +235,16 @@ export async function runJob(
|
|
|
221
235
|
stderrTail = appendTail(stderrTail, text, STDERR_TAIL_CHARS);
|
|
222
236
|
outputTail = appendTail(outputTail, text, OUTPUT_TAIL_CHARS);
|
|
223
237
|
}),
|
|
238
|
+
proc.exited,
|
|
224
239
|
]);
|
|
225
|
-
|
|
226
|
-
|
|
240
|
+
for (const outcome of outcomes.slice(0, 2)) {
|
|
241
|
+
if (outcome.status === "rejected") {
|
|
242
|
+
writeLine(stderr, `[cron-run] output capture failed: ${messageOf(outcome.reason)}`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const child = outcomes[2]!;
|
|
246
|
+
if (child.status === "rejected") throw child.reason;
|
|
247
|
+
exitCode = child.value;
|
|
227
248
|
if (exitCode !== 0) {
|
|
228
249
|
const cause = proc.signalCode ? `killed by ${proc.signalCode}` : `exit code ${exitCode}`;
|
|
229
250
|
const trimmedTail = stderrTail.trim();
|
|
@@ -238,9 +259,12 @@ export async function runJob(
|
|
|
238
259
|
if (recorder) {
|
|
239
260
|
try {
|
|
240
261
|
recorder.finish(errorMessage, outputTail.trim(), exitCode);
|
|
241
|
-
recorder.close();
|
|
242
262
|
} catch (error) {
|
|
243
263
|
writeLine(stderr, `[cron-run] failed to record run outcome: ${messageOf(error)}`);
|
|
264
|
+
} finally {
|
|
265
|
+
try { recorder.close(); } catch (error) {
|
|
266
|
+
writeLine(stderr, `[cron-run] failed to close tracking: ${messageOf(error)}`);
|
|
267
|
+
}
|
|
244
268
|
}
|
|
245
269
|
}
|
|
246
270
|
|