@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.
Files changed (169) hide show
  1. package/README.md +28 -0
  2. package/dist/activity/notify.d.ts.map +1 -1
  3. package/dist/activity/notify.js +18 -8
  4. package/dist/activity/notify.js.map +1 -1
  5. package/dist/activity/push-sender.d.ts +7 -3
  6. package/dist/activity/push-sender.d.ts.map +1 -1
  7. package/dist/activity/push-sender.js +45 -11
  8. package/dist/activity/push-sender.js.map +1 -1
  9. package/dist/activity/query.d.ts.map +1 -1
  10. package/dist/activity/query.js +8 -0
  11. package/dist/activity/query.js.map +1 -1
  12. package/dist/activity/recorder.d.ts +11 -1
  13. package/dist/activity/recorder.d.ts.map +1 -1
  14. package/dist/activity/recorder.js +52 -2
  15. package/dist/activity/recorder.js.map +1 -1
  16. package/dist/activity/runtime.d.ts.map +1 -1
  17. package/dist/activity/runtime.js +2 -5
  18. package/dist/activity/runtime.js.map +1 -1
  19. package/dist/activity/sql.d.ts +34 -0
  20. package/dist/activity/sql.d.ts.map +1 -0
  21. package/dist/activity/sql.js +114 -0
  22. package/dist/activity/sql.js.map +1 -0
  23. package/dist/activity/store.d.ts +7 -0
  24. package/dist/activity/store.d.ts.map +1 -1
  25. package/dist/activity/store.js +48 -113
  26. package/dist/activity/store.js.map +1 -1
  27. package/dist/activity/stream.d.ts +3 -1
  28. package/dist/activity/stream.d.ts.map +1 -1
  29. package/dist/activity/stream.js +32 -17
  30. package/dist/activity/stream.js.map +1 -1
  31. package/dist/app-env.d.ts +8 -0
  32. package/dist/app-env.d.ts.map +1 -0
  33. package/dist/app-env.js +2 -0
  34. package/dist/app-env.js.map +1 -0
  35. package/dist/app.d.ts.map +1 -1
  36. package/dist/app.js +28 -3
  37. package/dist/app.js.map +1 -1
  38. package/dist/brain/client.d.ts +4 -2
  39. package/dist/brain/client.d.ts.map +1 -1
  40. package/dist/brain/client.js +60 -20
  41. package/dist/brain/client.js.map +1 -1
  42. package/dist/brain/types.d.ts +9 -0
  43. package/dist/brain/types.d.ts.map +1 -1
  44. package/dist/cron/run-job.d.ts.map +1 -1
  45. package/dist/cron/run-job.js +60 -10
  46. package/dist/cron/run-job.js.map +1 -1
  47. package/dist/db/principals.d.ts +72 -0
  48. package/dist/db/principals.d.ts.map +1 -0
  49. package/dist/db/principals.js +294 -0
  50. package/dist/db/principals.js.map +1 -0
  51. package/dist/files/walker.d.ts.map +1 -1
  52. package/dist/files/walker.js +6 -1
  53. package/dist/files/walker.js.map +1 -1
  54. package/dist/index.d.ts +4 -1
  55. package/dist/index.d.ts.map +1 -1
  56. package/dist/index.js +1 -1
  57. package/dist/index.js.map +1 -1
  58. package/dist/middleware/auth.d.ts +37 -9
  59. package/dist/middleware/auth.d.ts.map +1 -1
  60. package/dist/middleware/auth.js +148 -74
  61. package/dist/middleware/auth.js.map +1 -1
  62. package/dist/middleware/passkeys.d.ts +9 -7
  63. package/dist/middleware/passkeys.d.ts.map +1 -1
  64. package/dist/middleware/passkeys.js +25 -7
  65. package/dist/middleware/passkeys.js.map +1 -1
  66. package/dist/middleware/principals.d.ts +18 -0
  67. package/dist/middleware/principals.d.ts.map +1 -0
  68. package/dist/middleware/principals.js +138 -0
  69. package/dist/middleware/principals.js.map +1 -0
  70. package/dist/middleware/require-owner.d.ts +5 -0
  71. package/dist/middleware/require-owner.d.ts.map +1 -0
  72. package/dist/middleware/require-owner.js +10 -0
  73. package/dist/middleware/require-owner.js.map +1 -0
  74. package/dist/observability/loggers.js +1 -1
  75. package/dist/observability/loggers.js.map +1 -1
  76. package/dist/routes/activity.d.ts.map +1 -1
  77. package/dist/routes/activity.js +3 -0
  78. package/dist/routes/activity.js.map +1 -1
  79. package/dist/routes/brain.d.ts.map +1 -1
  80. package/dist/routes/brain.js +102 -63
  81. package/dist/routes/brain.js.map +1 -1
  82. package/dist/routes/files.d.ts.map +1 -1
  83. package/dist/routes/files.js +7 -2
  84. package/dist/routes/files.js.map +1 -1
  85. package/dist/routes/push.d.ts +61 -2
  86. package/dist/routes/push.d.ts.map +1 -1
  87. package/dist/routes/push.js +8 -3
  88. package/dist/routes/push.js.map +1 -1
  89. package/dist/routes/sessions.d.ts.map +1 -1
  90. package/dist/routes/sessions.js +30 -4
  91. package/dist/routes/sessions.js.map +1 -1
  92. package/dist/share/staging.d.ts.map +1 -1
  93. package/dist/share/staging.js +11 -4
  94. package/dist/share/staging.js.map +1 -1
  95. package/dist/ws/bridge.d.ts.map +1 -1
  96. package/dist/ws/bridge.js +6 -2
  97. package/dist/ws/bridge.js.map +1 -1
  98. package/dist/ws/clients.d.ts +9 -2
  99. package/dist/ws/clients.d.ts.map +1 -1
  100. package/dist/ws/clients.js +44 -6
  101. package/dist/ws/clients.js.map +1 -1
  102. package/dist/ws/connection.d.ts +7 -4
  103. package/dist/ws/connection.d.ts.map +1 -1
  104. package/dist/ws/connection.js +83 -12
  105. package/dist/ws/connection.js.map +1 -1
  106. package/dist/ws/dispatch.d.ts +12 -1
  107. package/dist/ws/dispatch.d.ts.map +1 -1
  108. package/dist/ws/dispatch.js +46 -6
  109. package/dist/ws/dispatch.js.map +1 -1
  110. package/dist/ws/history.d.ts.map +1 -1
  111. package/dist/ws/history.js +7 -5
  112. package/dist/ws/history.js.map +1 -1
  113. package/dist/ws/host.d.ts +22 -0
  114. package/dist/ws/host.d.ts.map +1 -1
  115. package/dist/ws/host.js +39 -1
  116. package/dist/ws/host.js.map +1 -1
  117. package/dist/ws/run-session.d.ts +7 -7
  118. package/dist/ws/run-session.d.ts.map +1 -1
  119. package/dist/ws/run-session.js +208 -52
  120. package/dist/ws/run-session.js.map +1 -1
  121. package/dist/ws/shrink.d.ts +3 -1
  122. package/dist/ws/shrink.d.ts.map +1 -1
  123. package/dist/ws/shrink.js +46 -16
  124. package/dist/ws/shrink.js.map +1 -1
  125. package/dist/ws/turns.d.ts +58 -3
  126. package/dist/ws/turns.d.ts.map +1 -1
  127. package/dist/ws/turns.js +101 -2
  128. package/dist/ws/turns.js.map +1 -1
  129. package/migrations/011_principals.sql +13 -0
  130. package/migrations/012_activity_principals.sql +12 -0
  131. package/migrations/013_push_subscription_principals.sql +7 -0
  132. package/migrations/014_invalidate_legacy_sessions.sql +14 -0
  133. package/package.json +3 -3
  134. package/src/activity/notify.ts +16 -7
  135. package/src/activity/push-sender.ts +79 -16
  136. package/src/activity/query.ts +8 -0
  137. package/src/activity/recorder.ts +68 -3
  138. package/src/activity/runtime.ts +2 -5
  139. package/src/activity/sql.ts +122 -0
  140. package/src/activity/store.ts +73 -155
  141. package/src/activity/stream.ts +37 -17
  142. package/src/app-env.ts +8 -0
  143. package/src/app.ts +33 -4
  144. package/src/brain/client.ts +61 -20
  145. package/src/brain/types.ts +10 -0
  146. package/src/cron/run-job.ts +41 -17
  147. package/src/db/principals.ts +452 -0
  148. package/src/files/walker.ts +5 -1
  149. package/src/index.ts +6 -1
  150. package/src/middleware/auth.ts +241 -81
  151. package/src/middleware/passkeys.ts +35 -14
  152. package/src/middleware/principals.ts +189 -0
  153. package/src/middleware/require-owner.ts +13 -0
  154. package/src/observability/loggers.ts +1 -1
  155. package/src/routes/activity.ts +3 -0
  156. package/src/routes/brain.ts +107 -70
  157. package/src/routes/files.ts +10 -5
  158. package/src/routes/push.ts +11 -5
  159. package/src/routes/sessions.ts +45 -20
  160. package/src/share/staging.ts +11 -4
  161. package/src/ws/bridge.ts +14 -2
  162. package/src/ws/clients.ts +52 -7
  163. package/src/ws/connection.ts +98 -20
  164. package/src/ws/dispatch.ts +66 -6
  165. package/src/ws/history.ts +7 -5
  166. package/src/ws/host.ts +59 -1
  167. package/src/ws/run-session.ts +247 -74
  168. package/src/ws/shrink.ts +48 -14
  169. 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
- clients: host.clients,
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
- clients: host.clients,
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
- if (!(await isWsAuthorized(c, authMode, auth, db))) {
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
  },
@@ -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<void>;
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
- export function createBrainClient(opts: { brainPath: string }): BrainClient {
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
- const [stdout, stderr] = await Promise.all([
173
- new Response(proc.stdout).text(),
174
- new Response(proc.stderr).text(),
175
- ]);
176
- const exitCode = await proc.exited;
177
-
178
- return { stdout, stderr, exitCode };
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, opts) {
222
+ async search(query, searchOpts) {
201
223
  const args = ["search"];
202
- if (opts?.type) args.push("--type", opts.type);
203
- if (opts?.tag) args.push("--tag", opts.tag);
204
- if (opts?.limit) args.push("--limit", String(opts.limit));
205
- if (opts?.mode) args.push("--mode", opts.mode);
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 result = await execBrain(args);
209
- const parsed = parseJsonOutput<BrainSearchResponse | BrainSearchResult[]>(result);
210
- // brain returns {results, warnings} since 22e82e1; tolerate the old bare array
211
- return Array.isArray(parsed) ? { results: parsed, warnings: [] } : parsed;
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) {
@@ -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
+ }
@@ -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
- for await (const chunk of stream) {
84
- const text = decoder.decode(chunk, { stream: true });
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
- const db = createUiDb(dbPath);
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
- db.close();
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
- const recorder = await beginRecord(
195
- options.jobName,
196
- sinkPath,
197
- options.command,
198
- options.dbPath,
199
- stderr
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.all([
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
- exitCode = await proc.exited;
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