@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.
- package/corpus/README.md +1 -1
- package/corpus/templates/analytics/CHANGELOG.md +11 -0
- package/corpus/templates/analytics/app/i18n/en-US.ts +1 -0
- package/corpus/templates/analytics/app/pages/sessions/SessionsPage.tsx +101 -11
- package/corpus/templates/analytics/changelog/2026-09-24-session-list-hides-0m-recordings-by-default.md +6 -0
- package/corpus/templates/assets/CHANGELOG.md +6 -0
- package/corpus/templates/brain/actions/backfill-search-embeddings.ts +337 -0
- package/corpus/templates/brain/actions/get-brain-health.ts +1 -1
- package/corpus/templates/brain/app/lib/brain.ts +18 -0
- package/corpus/templates/brain/jobs/process-ingest-queue.ts +25 -3
- package/corpus/templates/brain/server/lib/brain-health.ts +197 -57
- package/corpus/templates/brain/server/lib/demo.ts +25 -4
- package/corpus/templates/brain/server/lib/hybrid-search.ts +4 -1
- package/corpus/templates/brain/server/lib/ingest-queue.ts +1 -0
- package/corpus/templates/brain/server/lib/search-index.ts +254 -17
- package/corpus/templates/brain/server/lib/search.ts +27 -0
- package/corpus/templates/calendar/CHANGELOG.md +6 -0
- package/corpus/templates/clips/CHANGELOG.md +10 -0
- package/corpus/templates/content/CHANGELOG.md +22 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +559 -227
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +25 -1
- package/corpus/templates/design/CHANGELOG.md +36 -0
- package/corpus/templates/dispatch/CHANGELOG.md +7 -0
- package/corpus/templates/mail/CHANGELOG.md +17 -0
- package/corpus/templates/slides/CHANGELOG.md +7 -0
- package/dist/automation/index.d.ts +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/embeddings/index.d.ts +5 -0
- package/dist/embeddings/index.js +44 -11
- package/dist/observability/routes.d.ts +5 -5
- package/dist/progress/routes.d.ts +1 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-engine-ollama-models-route.d.ts +1 -1
- package/package.json +4 -4
package/corpus/README.md
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
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={() =>
|
|
359
|
-
|
|
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",
|
|
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
|
-
{
|
|
437
|
+
{loadError ? (
|
|
373
438
|
<div className="p-6 text-sm text-destructive">
|
|
374
|
-
{t("sessions.loadFailed", { 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
|
-
|
|
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`;
|
|
@@ -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
|
|
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 } =
|
|
185
|
-
|
|
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);
|