@agent-native/core 0.187.0-nightly-20260924172231 → 0.187.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 (35) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/analytics/CHANGELOG.md +11 -0
  3. package/corpus/templates/analytics/app/i18n/en-US.ts +1 -0
  4. package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +101 -11
  5. package/corpus/templates/analytics/changelog/2026-09-24-session-list-hides-0m-recordings-by-default.md +6 -0
  6. package/corpus/templates/assets/CHANGELOG.md +6 -0
  7. package/corpus/templates/brain/actions/backfill-search-embeddings.ts +337 -0
  8. package/corpus/templates/brain/actions/get-brain-health.ts +1 -1
  9. package/corpus/templates/brain/app/lib/brain.ts +18 -0
  10. package/corpus/templates/brain/jobs/process-ingest-queue.ts +25 -3
  11. package/corpus/templates/brain/server/lib/brain-health.ts +197 -57
  12. package/corpus/templates/brain/server/lib/demo.ts +25 -4
  13. package/corpus/templates/brain/server/lib/hybrid-search.ts +4 -1
  14. package/corpus/templates/brain/server/lib/ingest-queue.ts +1 -0
  15. package/corpus/templates/brain/server/lib/search-index.ts +254 -17
  16. package/corpus/templates/brain/server/lib/search.ts +27 -0
  17. package/corpus/templates/calendar/CHANGELOG.md +6 -0
  18. package/corpus/templates/clips/CHANGELOG.md +10 -0
  19. package/corpus/templates/content/CHANGELOG.md +22 -0
  20. package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +559 -227
  21. package/corpus/templates/content/app/components/editor/VisualEditor.tsx +25 -1
  22. package/corpus/templates/design/CHANGELOG.md +36 -0
  23. package/corpus/templates/dispatch/CHANGELOG.md +7 -0
  24. package/corpus/templates/mail/CHANGELOG.md +17 -0
  25. package/corpus/templates/slides/CHANGELOG.md +7 -0
  26. package/dist/automation/index.d.ts +1 -1
  27. package/dist/collab/awareness.d.ts +2 -2
  28. package/dist/collab/struct-routes.d.ts +1 -1
  29. package/dist/embeddings/index.d.ts +5 -0
  30. package/dist/embeddings/index.js +44 -11
  31. package/dist/observability/routes.d.ts +5 -5
  32. package/dist/progress/routes.d.ts +1 -1
  33. package/dist/secrets/routes.d.ts +6 -6
  34. package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
  35. package/package.json +4 -4
package/corpus/README.md CHANGED
@@ -31,4 +31,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
31
31
 
32
32
  ## Generated Counts
33
33
 
34
- - template files: 9884
34
+ - template files: 9886
@@ -3,10 +3,21 @@
3
3
  All notable user-facing changes to Agent-Native Analytics are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-24
7
+
8
+ ### Improved
9
+
10
+ - The Sessions list hides 0m recordings by default, with a filter to include them.
11
+
12
+ ### Fixed
13
+
14
+ - Session recordings no longer cut off partway through when the daily recording budget runs low; new recordings wait for room instead.
15
+
6
16
  ## 2026-09-23
7
17
 
8
18
  ### Improved
9
19
 
20
+ - Transient HTTP 5xx checks now need confirmation before alerting
10
21
  - Analytics opens faster by loading translation catalogs and rarely used surfaces only when needed.
11
22
 
12
23
  ## 2026-09-22
@@ -1389,6 +1389,7 @@ export default {
1389
1389
  app: "App",
1390
1390
  unknownApp: "Unknown app",
1391
1391
  lastSeen: "Last seen",
1392
+ includeZeroMinuteSessions: "Include 0m sessions",
1392
1393
  duration: "Duration",
1393
1394
  events: "Events",
1394
1395
  chunks: "Chunks",
@@ -29,6 +29,7 @@ import { toast } from "sonner";
29
29
  import { Badge } from "@/components/ui/badge";
30
30
  import { Button } from "@/components/ui/button";
31
31
  import { Card, CardContent } from "@/components/ui/card";
32
+ import { Checkbox } from "@/components/ui/checkbox";
32
33
  import {
33
34
  Collapsible,
34
35
  CollapsibleContent,
@@ -170,8 +171,19 @@ type SessionRecordingIdentity = Pick<
170
171
  type SessionRecordingDevice = Pick<SessionRecordingSummary, "metadata">;
171
172
 
172
173
  const RANGE_OPTIONS: ReplayRange[] = ["24h", "7d", "30d", "90d", "all"];
174
+ const MIN_DURATION_FOR_ONE_MINUTE_LABEL_MS = 59_500;
173
175
  const SESSION_QUERY_DEBOUNCE_MS = 250;
174
176
 
177
+ export function shouldShowZeroMinuteRecoveryAction(
178
+ includeZeroMinuteSessions: boolean,
179
+ filteredCount: number,
180
+ unfilteredCount: number,
181
+ ): boolean {
182
+ return (
183
+ !includeZeroMinuteSessions && filteredCount === 0 && unfilteredCount > 0
184
+ );
185
+ }
186
+
175
187
  /**
176
188
  * Local input state for a URL-backed filter, debounced into the URL.
177
189
  *
@@ -212,6 +224,8 @@ export default function SessionsPage() {
212
224
  const range = readRange(searchParams.get("range"));
213
225
  const app = searchParams.get("app") ?? "";
214
226
  const query = searchParams.get("q") ?? "";
227
+ const includeZeroMinuteSessions =
228
+ searchParams.get("includeZeroMinuteSessions") === "true";
215
229
  const from = useMemo(() => rangeToFrom(range), [range]);
216
230
 
217
231
  const updateFilter = useCallback(
@@ -243,21 +257,55 @@ export default function SessionsPage() {
243
257
  );
244
258
  const [appInput, setAppInput] = useDebouncedUrlFilter(app, commitApp);
245
259
 
260
+ const sessionListFilters = {
261
+ from: from ?? undefined,
262
+ app: app || undefined,
263
+ query: query || undefined,
264
+ };
246
265
  const { data, isLoading, isFetching, refetch, error } = useActionQuery<
247
266
  SessionRecordingSummary[]
248
267
  >(
249
268
  "list-session-recordings",
250
269
  {
251
- from: from ?? undefined,
252
- app: app || undefined,
253
- query: query || undefined,
270
+ ...sessionListFilters,
271
+ minDurationMs: includeZeroMinuteSessions
272
+ ? undefined
273
+ : MIN_DURATION_FOR_ONE_MINUTE_LABEL_MS,
254
274
  limit: 100,
255
275
  },
256
276
  { staleTime: 30_000 },
257
277
  );
258
278
 
259
279
  const recordings = data ?? [];
260
- const popoverFiltered = range !== "30d" || app !== "";
280
+ const shouldCheckForHiddenSessions =
281
+ !includeZeroMinuteSessions &&
282
+ !isLoading &&
283
+ !error &&
284
+ recordings.length === 0;
285
+ const {
286
+ data: unfilteredRecordings,
287
+ isLoading: isCheckingForHiddenSessions,
288
+ isFetching: isFetchingHiddenSessions,
289
+ error: hiddenSessionsError,
290
+ refetch: refetchHiddenSessions,
291
+ } = useActionQuery<SessionRecordingSummary[]>(
292
+ "list-session-recordings",
293
+ { ...sessionListFilters, limit: 1 },
294
+ { enabled: shouldCheckForHiddenSessions, staleTime: 30_000 },
295
+ );
296
+ const showZeroMinuteRecoveryAction = shouldShowZeroMinuteRecoveryAction(
297
+ includeZeroMinuteSessions,
298
+ recordings.length,
299
+ unfilteredRecordings?.length ?? 0,
300
+ );
301
+ const loadError =
302
+ error ?? (shouldCheckForHiddenSessions ? hiddenSessionsError : null);
303
+ const isCheckingEmptyState =
304
+ shouldCheckForHiddenSessions && isCheckingForHiddenSessions;
305
+ const isRefreshing =
306
+ isFetching || (shouldCheckForHiddenSessions && isFetchingHiddenSessions);
307
+ const popoverFiltered =
308
+ range !== "30d" || app !== "" || includeZeroMinuteSessions;
261
309
 
262
310
  return (
263
311
  <div className="analytics-sessions-page mx-auto flex w-full max-w-7xl flex-col gap-4 px-4 py-5">
@@ -328,6 +376,18 @@ export default function SessionsPage() {
328
376
  </SelectContent>
329
377
  </Select>
330
378
  </div>
379
+ <label className="flex cursor-pointer items-center gap-2 border-t pt-3 text-sm">
380
+ <Checkbox
381
+ checked={includeZeroMinuteSessions}
382
+ onCheckedChange={(checked) =>
383
+ updateFilter(
384
+ "includeZeroMinuteSessions",
385
+ checked === true ? "true" : "",
386
+ )
387
+ }
388
+ />
389
+ {t("sessions.includeZeroMinuteSessions")}
390
+ </label>
331
391
  <div className="grid gap-1.5 border-t pt-3">
332
392
  <div className="text-xs font-medium text-muted-foreground">
333
393
  {t("sessions.userFilters")}
@@ -355,12 +415,17 @@ export default function SessionsPage() {
355
415
  variant="ghost"
356
416
  size="icon"
357
417
  className="h-9 w-9"
358
- onClick={() => void refetch()}
359
- disabled={isFetching}
418
+ onClick={() => {
419
+ void refetch();
420
+ if (shouldCheckForHiddenSessions) {
421
+ void refetchHiddenSessions();
422
+ }
423
+ }}
424
+ disabled={isRefreshing}
360
425
  aria-label={t("sessions.refresh")}
361
426
  >
362
427
  <IconRefresh
363
- className={cn("h-4 w-4", isFetching && "animate-spin")}
428
+ className={cn("h-4 w-4", isRefreshing && "animate-spin")}
364
429
  />
365
430
  </Button>
366
431
  </div>
@@ -369,14 +434,22 @@ export default function SessionsPage() {
369
434
 
370
435
  <Card>
371
436
  <CardContent className="p-0">
372
- {error ? (
437
+ {loadError ? (
373
438
  <div className="p-6 text-sm text-destructive">
374
- {t("sessions.loadFailed", { message: error.message })}
439
+ {t("sessions.loadFailed", { message: loadError.message })}
375
440
  </div>
376
- ) : isLoading ? (
441
+ ) : isLoading || isCheckingEmptyState ? (
377
442
  <SessionSkeleton />
378
443
  ) : recordings.length === 0 ? (
379
- <EmptySessionsState />
444
+ showZeroMinuteRecoveryAction ? (
445
+ <FilteredEmptySessionsState
446
+ onIncludeZeroMinuteSessions={() =>
447
+ updateFilter("includeZeroMinuteSessions", "true")
448
+ }
449
+ />
450
+ ) : (
451
+ <EmptySessionsState />
452
+ )
380
453
  ) : (
381
454
  <div>
382
455
  <div className="flex items-center justify-between border-b px-4 py-3">
@@ -525,6 +598,22 @@ function EmptySessionsState() {
525
598
  );
526
599
  }
527
600
 
601
+ function FilteredEmptySessionsState({
602
+ onIncludeZeroMinuteSessions,
603
+ }: {
604
+ onIncludeZeroMinuteSessions: () => void;
605
+ }) {
606
+ const t = useT();
607
+ return (
608
+ <div className="flex min-h-[380px] flex-col items-center justify-center gap-4 p-6">
609
+ <h2 className="text-lg font-semibold">{t("sessions.noSessions")}</h2>
610
+ <Button variant="outline" onClick={onIncludeZeroMinuteSessions}>
611
+ {t("sessions.includeZeroMinuteSessions")}
612
+ </Button>
613
+ </div>
614
+ );
615
+ }
616
+
528
617
  export function ReplayStorageHint({
529
618
  embedded = false,
530
619
  }: {
@@ -862,6 +951,7 @@ function formatDateTime(value: string): string {
862
951
 
863
952
  export function formatSessionDuration(ms: number | null): string {
864
953
  if (!ms || !Number.isFinite(ms) || ms <= 0) return "0m";
954
+ if (ms < MIN_DURATION_FOR_ONE_MINUTE_LABEL_MS) return "0m";
865
955
  const seconds = Math.round(ms / 1000);
866
956
  const minutes = Math.floor(seconds / 60);
867
957
  if (minutes < 60) return `${minutes}m`;
@@ -0,0 +1,6 @@
1
+ ---
2
+ type: improved
3
+ date: 2026-09-24
4
+ ---
5
+
6
+ The Sessions list hides 0m recordings by default, with a filter to include them.
@@ -3,6 +3,12 @@
3
3
  All notable user-facing changes to Assets are documented here. Open it any
4
4
  time from the command menu (Cmd+K → "What's new") or from Settings.
5
5
 
6
+ ## 2026-09-23
7
+
8
+ ### Fixed
9
+
10
+ - Stale image candidates stop showing as generating when their generation run is missing.
11
+
6
12
  ## 2026-09-01
7
13
 
8
14
  ### Improved
@@ -0,0 +1,337 @@
1
+ import { defineAction } from "@agent-native/core/action";
2
+ import { runWithRequestContext } from "@agent-native/core/server/request-context";
3
+ import { assertAccess } from "@agent-native/core/sharing";
4
+ import { and, asc, eq, gt, inArray, isNotNull, lte, or } from "drizzle-orm";
5
+ import { z } from "zod";
6
+
7
+ import { getDb, schema } from "../server/db/index.js";
8
+ import { nowIso } from "../server/lib/brain.js";
9
+ import { enqueueBrainOperation } from "../server/lib/ingest-queue.js";
10
+ import { BRAIN_SEARCH_INDEX_VERSION } from "../server/lib/search-index-contracts.js";
11
+ import {
12
+ readCaptureEmbeddingCoverage,
13
+ readEmbeddingReadiness,
14
+ } from "../server/lib/search-index.js";
15
+ import { redactSensitiveText } from "../server/lib/search.js";
16
+ import {
17
+ booleanishSchema,
18
+ idSchema,
19
+ stringArrayCliSchema,
20
+ } from "./_schemas.js";
21
+
22
+ export const backfillSearchEmbeddingsSchema = z
23
+ .object({
24
+ sourceId: idSchema,
25
+ dryRun: booleanishSchema.default(true),
26
+ force: booleanishSchema.default(false),
27
+ captureIds: stringArrayCliSchema({ min: 1, max: 50 }).optional(),
28
+ afterUpdatedAt: z.string().datetime().optional(),
29
+ afterCaptureId: idSchema.optional(),
30
+ throughUpdatedAt: z.string().datetime().optional(),
31
+ limit: z.coerce.number().int().min(1).max(50).default(25),
32
+ })
33
+ .refine(
34
+ (args) => Boolean(args.afterUpdatedAt) === Boolean(args.afterCaptureId),
35
+ {
36
+ message: "Provide afterUpdatedAt and afterCaptureId together.",
37
+ },
38
+ )
39
+ .refine(
40
+ (args) =>
41
+ !args.captureIds?.length ||
42
+ (!args.afterUpdatedAt && !args.afterCaptureId && !args.throughUpdatedAt),
43
+ {
44
+ message: "captureIds cannot be combined with pagination cursors.",
45
+ },
46
+ );
47
+
48
+ type CandidateRow = Awaited<
49
+ ReturnType<typeof findCandidatePage>
50
+ >["rows"][number];
51
+
52
+ async function findCandidatePage(args: {
53
+ sourceId: string;
54
+ embeddingSetId: string | null;
55
+ captureIds?: string[];
56
+ afterUpdatedAt?: string;
57
+ afterCaptureId?: string;
58
+ throughUpdatedAt: string;
59
+ limit: number;
60
+ }) {
61
+ const rows = await getDb()
62
+ .select({
63
+ id: schema.brainRawCaptures.id,
64
+ sourceId: schema.brainRawCaptures.sourceId,
65
+ title: schema.brainRawCaptures.title,
66
+ content: schema.brainRawCaptures.content,
67
+ contentHash: schema.brainRawCaptures.contentHash,
68
+ sensitivityDisposition: schema.brainRawCaptures.sensitivityDisposition,
69
+ sensitivityPolicyVersion:
70
+ schema.brainRawCaptures.sensitivityPolicyVersion,
71
+ audienceAclHash: schema.brainRawCaptures.audienceAclHash,
72
+ capturedAt: schema.brainRawCaptures.capturedAt,
73
+ updatedAt: schema.brainRawCaptures.updatedAt,
74
+ artifactId: schema.brainSearchArtifacts.id,
75
+ artifactAudienceId: schema.brainSearchArtifacts.audienceId,
76
+ artifactAclHash: schema.brainSearchArtifacts.aclHash,
77
+ artifactTitle: schema.brainSearchArtifacts.title,
78
+ artifactQuestion: schema.brainSearchArtifacts.question,
79
+ artifactSummary: schema.brainSearchArtifacts.summary,
80
+ artifactResolution: schema.brainSearchArtifacts.resolution,
81
+ artifactSystemsJson: schema.brainSearchArtifacts.systemsJson,
82
+ artifactCodeRefsJson: schema.brainSearchArtifacts.codeRefsJson,
83
+ embeddingId: schema.brainSearchEmbeddings.id,
84
+ })
85
+ .from(schema.brainRawCaptures)
86
+ .leftJoin(
87
+ schema.brainSearchArtifacts,
88
+ and(
89
+ eq(schema.brainSearchArtifacts.captureId, schema.brainRawCaptures.id),
90
+ eq(schema.brainSearchArtifacts.status, "active"),
91
+ eq(
92
+ schema.brainSearchArtifacts.contentHash,
93
+ schema.brainRawCaptures.contentHash,
94
+ ),
95
+ eq(
96
+ schema.brainSearchArtifacts.sensitivityPolicyVersion,
97
+ schema.brainRawCaptures.sensitivityPolicyVersion,
98
+ ),
99
+ eq(
100
+ schema.brainSearchArtifacts.aclHash,
101
+ schema.brainRawCaptures.audienceAclHash,
102
+ ),
103
+ eq(
104
+ schema.brainSearchArtifacts.indexVersion,
105
+ BRAIN_SEARCH_INDEX_VERSION,
106
+ ),
107
+ ),
108
+ )
109
+ .leftJoin(
110
+ schema.brainSearchEmbeddings,
111
+ and(
112
+ eq(schema.brainSearchEmbeddings.targetType, "artifact"),
113
+ eq(
114
+ schema.brainSearchEmbeddings.targetId,
115
+ schema.brainSearchArtifacts.id,
116
+ ),
117
+ eq(schema.brainSearchEmbeddings.status, "active"),
118
+ eq(
119
+ schema.brainSearchEmbeddings.embeddingSetId,
120
+ args.embeddingSetId ?? "__unconfigured__",
121
+ ),
122
+ eq(
123
+ schema.brainSearchEmbeddings.contentHash,
124
+ schema.brainRawCaptures.contentHash,
125
+ ),
126
+ eq(
127
+ schema.brainSearchEmbeddings.sensitivityPolicyVersion,
128
+ schema.brainRawCaptures.sensitivityPolicyVersion,
129
+ ),
130
+ eq(
131
+ schema.brainSearchEmbeddings.aclHash,
132
+ schema.brainRawCaptures.audienceAclHash,
133
+ ),
134
+ eq(
135
+ schema.brainSearchEmbeddings.indexVersion,
136
+ BRAIN_SEARCH_INDEX_VERSION,
137
+ ),
138
+ ),
139
+ )
140
+ .where(
141
+ and(
142
+ eq(schema.brainRawCaptures.sourceId, args.sourceId),
143
+ eq(schema.brainRawCaptures.sensitivityDisposition, "allowed"),
144
+ isNotNull(schema.brainRawCaptures.contentHash),
145
+ isNotNull(schema.brainRawCaptures.sensitivityPolicyVersion),
146
+ isNotNull(schema.brainRawCaptures.audienceAclHash),
147
+ args.captureIds?.length
148
+ ? inArray(schema.brainRawCaptures.id, args.captureIds)
149
+ : lte(schema.brainRawCaptures.updatedAt, args.throughUpdatedAt),
150
+ args.afterUpdatedAt && args.afterCaptureId
151
+ ? or(
152
+ gt(schema.brainRawCaptures.updatedAt, args.afterUpdatedAt),
153
+ and(
154
+ eq(schema.brainRawCaptures.updatedAt, args.afterUpdatedAt),
155
+ gt(schema.brainRawCaptures.id, args.afterCaptureId),
156
+ ),
157
+ )
158
+ : undefined,
159
+ ),
160
+ )
161
+ .orderBy(
162
+ asc(schema.brainRawCaptures.updatedAt),
163
+ asc(schema.brainRawCaptures.id),
164
+ )
165
+ .limit(
166
+ args.captureIds?.length
167
+ ? Math.min(50, args.captureIds.length)
168
+ : args.limit + 1,
169
+ );
170
+
171
+ return {
172
+ rows: rows.slice(0, args.captureIds?.length ?? args.limit),
173
+ hasMore: !args.captureIds?.length && rows.length > args.limit,
174
+ };
175
+ }
176
+
177
+ async function candidateReason(
178
+ row: CandidateRow,
179
+ force: boolean,
180
+ embeddingSetId: string | null,
181
+ ) {
182
+ if (force) return "forced";
183
+ if (!row.artifactId) return "missing-artifact";
184
+ if (!row.embeddingId || !embeddingSetId) return "missing-embedding";
185
+ const coverage = await readCaptureEmbeddingCoverage(row.id, embeddingSetId);
186
+ return coverage.complete ? null : "partial-embedding";
187
+ }
188
+
189
+ async function enqueueCandidate(row: CandidateRow, embeddingSetId: string) {
190
+ try {
191
+ const queue = await enqueueBrainOperation({
192
+ operation: "search-index",
193
+ dedupeKey: `search-index-backfill:${row.id}:${row.contentHash}:${embeddingSetId}`,
194
+ sourceId: row.sourceId,
195
+ captureId: row.id,
196
+ priority: 40,
197
+ payload: { requiredEmbeddingSetId: embeddingSetId },
198
+ });
199
+ return {
200
+ captureId: row.id,
201
+ outcome: "queued" as const,
202
+ queueId: queue?.id ?? null,
203
+ reason: null,
204
+ };
205
+ } catch (error) {
206
+ return {
207
+ captureId: row.id,
208
+ outcome: "failed" as const,
209
+ queueId: null,
210
+ reason: redactSensitiveText(
211
+ error instanceof Error ? error.message : String(error),
212
+ ),
213
+ };
214
+ }
215
+ }
216
+
217
+ async function readSourceEmbeddingReadiness(sourceId: string) {
218
+ const [source] = await getDb()
219
+ .select({
220
+ ownerEmail: schema.brainSources.ownerEmail,
221
+ orgId: schema.brainSources.orgId,
222
+ })
223
+ .from(schema.brainSources)
224
+ .where(eq(schema.brainSources.id, sourceId))
225
+ .limit(1);
226
+ if (!source) throw new Error("Brain source was not found.");
227
+ return runWithRequestContext(
228
+ {
229
+ userEmail: source.ownerEmail,
230
+ orgId: source.orgId ?? undefined,
231
+ },
232
+ readEmbeddingReadiness,
233
+ );
234
+ }
235
+
236
+ export function backfillSearchEmbeddingsNeedsApproval(args: {
237
+ dryRun?: unknown;
238
+ }) {
239
+ return !booleanishSchema.default(true).parse(args.dryRun);
240
+ }
241
+
242
+ export default defineAction({
243
+ description:
244
+ "Dry-run or durably queue semantic embedding backfills for a bounded page of allowed captures from one accessible Brain source.",
245
+ schema: backfillSearchEmbeddingsSchema,
246
+ needsApproval: backfillSearchEmbeddingsNeedsApproval,
247
+ toolCallable: false,
248
+ run: async (args) => {
249
+ await assertAccess("brain-source", args.sourceId, "admin");
250
+ const readiness = await readSourceEmbeddingReadiness(args.sourceId);
251
+ if (!args.dryRun && !readiness.ready) {
252
+ throw new Error(
253
+ readiness.warning ??
254
+ "Configure exactly one embedding provider before backfilling.",
255
+ );
256
+ }
257
+
258
+ const throughUpdatedAt = args.throughUpdatedAt ?? nowIso();
259
+ const page = await findCandidatePage({
260
+ sourceId: args.sourceId,
261
+ embeddingSetId: readiness.embeddingSetId,
262
+ captureIds: args.captureIds,
263
+ afterUpdatedAt: args.afterUpdatedAt,
264
+ afterCaptureId: args.afterCaptureId,
265
+ throughUpdatedAt,
266
+ limit: args.limit,
267
+ });
268
+ const candidateRows = await Promise.all(
269
+ page.rows.map(async (row) => ({
270
+ row,
271
+ reason: await candidateReason(
272
+ row,
273
+ args.force,
274
+ readiness.embeddingSetId,
275
+ ),
276
+ })),
277
+ );
278
+ const candidates = candidateRows.filter(
279
+ (candidate): candidate is typeof candidate & { reason: string } =>
280
+ candidate.reason !== null,
281
+ );
282
+ const lastRow = page.rows[page.rows.length - 1];
283
+ const nextCursor =
284
+ page.hasMore && lastRow
285
+ ? {
286
+ afterUpdatedAt: lastRow.updatedAt,
287
+ afterCaptureId: lastRow.id,
288
+ throughUpdatedAt,
289
+ }
290
+ : null;
291
+
292
+ if (args.dryRun) {
293
+ return {
294
+ dryRun: true,
295
+ sourceId: args.sourceId,
296
+ readiness,
297
+ scanned: page.rows.length,
298
+ matched: candidates.length,
299
+ queued: 0,
300
+ failed: 0,
301
+ failedCaptureIds: [],
302
+ hasMore: page.hasMore,
303
+ nextCursor,
304
+ candidates: candidates.map(({ row, reason }) => ({
305
+ captureId: row.id,
306
+ reason,
307
+ })),
308
+ results: [],
309
+ };
310
+ }
311
+
312
+ const results = await Promise.all(
313
+ candidates.map(({ row }) =>
314
+ enqueueCandidate(row, readiness.embeddingSetId!),
315
+ ),
316
+ );
317
+ return {
318
+ dryRun: false,
319
+ sourceId: args.sourceId,
320
+ readiness,
321
+ scanned: page.rows.length,
322
+ matched: candidates.length,
323
+ queued: results.filter((result) => result.outcome === "queued").length,
324
+ failed: results.filter((result) => result.outcome === "failed").length,
325
+ failedCaptureIds: results
326
+ .filter((result) => result.outcome === "failed")
327
+ .map((result) => result.captureId),
328
+ hasMore: page.hasMore,
329
+ nextCursor,
330
+ candidates: candidates.map(({ row, reason }) => ({
331
+ captureId: row.id,
332
+ reason,
333
+ })),
334
+ results,
335
+ };
336
+ },
337
+ });
@@ -7,7 +7,7 @@ export { readBrainHealth };
7
7
 
8
8
  export default defineAction({
9
9
  description:
10
- "Summarize Brain setup and source health, including configured sources, sync freshness, pending proposals, queue issues, last eval score, and suggested next setup steps.",
10
+ "Summarize Brain setup and source health, including embedding provider readiness and coverage, sync freshness, pending proposals, queue issues, last eval score, and suggested next setup steps.",
11
11
  schema: z.object({}),
12
12
  http: { method: "GET" },
13
13
  readOnly: true,
@@ -401,6 +401,24 @@ export interface BrainHealthResponse {
401
401
  counts?: Record<string, number>;
402
402
  };
403
403
  embeddings: {
404
+ readiness: {
405
+ status: "ready" | "not-configured" | "ambiguous" | "unavailable";
406
+ ready: boolean;
407
+ configuredProviders: string[];
408
+ unavailableProviders: string[];
409
+ configuredFamilies: number;
410
+ provider: string | null;
411
+ model: string | null;
412
+ embeddingSetId: string | null;
413
+ dimensions: number | null;
414
+ warning: string | null;
415
+ };
416
+ coverage: {
417
+ eligibleArtifacts: number;
418
+ embeddedArtifacts: number;
419
+ missingArtifacts: number;
420
+ percent: number;
421
+ };
404
422
  total: number;
405
423
  active: number;
406
424
  stale: number;
@@ -179,10 +179,32 @@ export async function claimForHeadlessRunner(row: QueueRow, payload: object) {
179
179
  async function runDeterministicOperation(
180
180
  row: QueueRow,
181
181
  context: NonNullable<Awaited<ReturnType<typeof loadCaptureAndSource>>>,
182
+ payload: Record<string, unknown>,
182
183
  ) {
183
184
  if (row.operation === "search-index") {
184
- const { indexBrainCapture } = await import("../server/lib/search-index.js");
185
- await indexBrainCapture(context.capture.id);
185
+ const { indexBrainCapture, readCaptureEmbeddingCoverage } =
186
+ await import("../server/lib/search-index.js");
187
+ const result = await indexBrainCapture(context.capture.id);
188
+ const requiredEmbeddingSetId =
189
+ typeof payload.requiredEmbeddingSetId === "string"
190
+ ? payload.requiredEmbeddingSetId
191
+ : null;
192
+ if (requiredEmbeddingSetId) {
193
+ if (!result.indexed) {
194
+ throw new Error(
195
+ `Embedding backfill indexing failed: ${result.reason ?? "unknown"}.`,
196
+ );
197
+ }
198
+ const coverage = await readCaptureEmbeddingCoverage(
199
+ context.capture.id,
200
+ requiredEmbeddingSetId,
201
+ );
202
+ if (!coverage.complete) {
203
+ throw new Error(
204
+ `Embedding backfill incomplete: artifact=${coverage.artifactEmbedded}, bursts=${coverage.embeddedBursts}/${coverage.expectedBursts}.`,
205
+ );
206
+ }
207
+ }
186
208
  return;
187
209
  }
188
210
  if (row.operation === "search-unindex") {
@@ -579,7 +601,7 @@ export async function processBrainIngestQueueOnce(
579
601
  userEmail: contextRows.source.ownerEmail,
580
602
  orgId: contextRows.source.orgId ?? undefined,
581
603
  },
582
- () => runDeterministicOperation(row, contextRows),
604
+ () => runDeterministicOperation(row, contextRows, payload),
583
605
  );
584
606
  await markOperationDone(row, claimToken);
585
607
  processed.push(row.id);