@agent-native/core 0.136.0 → 0.136.3
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/.agents/skills/analysis-workspace/SKILL.md +31 -5
- package/corpus/templates/analytics/.agents/skills/dashboard-management/SKILL.md +7 -0
- package/corpus/templates/analytics/.agents/skills/gong/SKILL.md +52 -19
- package/corpus/templates/analytics/.agents/skills/provider-api/SKILL.md +25 -3
- package/corpus/templates/analytics/AGENTS.md +6 -7
- package/corpus/templates/analytics/actions/gong-calls.ts +3 -3
- package/corpus/templates/analytics/actions/provider-api-catalog.ts +1 -1
- package/corpus/templates/analytics/actions/query-agent-native-analytics.ts +1 -1
- package/corpus/templates/analytics/app/i18n-data.ts +46 -0
- package/corpus/templates/analytics/app/pages/Settings.tsx +67 -1
- package/corpus/templates/analytics/app/pages/settings/settings-search.ts +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-dashboard-builds-now-finish-without-an-extra-confirmation.md +6 -0
- package/corpus/templates/analytics/changelog/2026-08-03-error-alert-emails-are-opt-in.md +6 -0
- package/corpus/templates/analytics/server/lib/agent-chat-plan-mode.ts +10 -9
- package/corpus/templates/analytics/server/lib/error-capture.ts +38 -10
- package/corpus/templates/analytics/server/lib/gong.ts +27 -4
- package/corpus/templates/analytics/server/plugins/agent-chat.ts +64 -4
- package/corpus/templates/analytics/shared/analytics-user-prefs.ts +7 -0
- package/corpus/templates/clips/.agents/skills/video-sharing/SKILL.md +15 -6
- package/corpus/templates/clips/actions/apply-rewind-extension.ts +9 -0
- package/corpus/templates/clips/actions/cleanup-dictation.ts +2 -2
- package/corpus/templates/clips/actions/cleanup-transcript.ts +8 -11
- package/corpus/templates/clips/actions/clear-edits.ts +15 -2
- package/corpus/templates/clips/actions/create-recording-agent-link.ts +10 -0
- package/corpus/templates/clips/actions/finalize-meeting.ts +6 -6
- package/corpus/templates/clips/actions/finalize-recording.ts +33 -8
- package/corpus/templates/clips/actions/lib/ensure-seekable-video.ts +1 -3
- package/corpus/templates/clips/actions/list-recordings.ts +39 -24
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +4 -1
- package/corpus/templates/clips/actions/regenerate-title.ts +2 -2
- package/corpus/templates/clips/actions/stitch-recordings.ts +36 -7
- package/corpus/templates/clips/app/components/editor/rewind-extension-dialog.tsx +1 -4
- package/corpus/templates/clips/app/components/editor/stitch-manager.tsx +3 -1
- package/corpus/templates/clips/app/components/library/recording-card.tsx +9 -0
- package/corpus/templates/clips/app/components/player/recording-views-badge.tsx +52 -7
- package/corpus/templates/clips/app/hooks/use-library.ts +1 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +17 -10
- package/corpus/templates/clips/app/i18n/en-US.ts +3 -0
- package/corpus/templates/clips/app/lib/timestamp-mapping.ts +5 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +1 -0
- package/corpus/templates/clips/app/routes/share.$shareId.tsx +2 -0
- package/corpus/templates/clips/changelog/2026-07-31-agent-views-now-show-the-agent-s-name-from-the-link-it-was-g.md +6 -0
- package/corpus/templates/clips/changelog/2026-07-31-clip-cards-and-the-watch-share-page-header-now-show-agent-vi.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-01-recordings-stored-in-private-s3-compatible-buckets-finalize-.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-clip-cleanup-titles-and-meeting-summaries-now-use-luna-by-de.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-get-a-monthly-recap-email-showing-how-many-people-and-ai-age.md +6 -0
- package/corpus/templates/clips/changelog/2026-08-03-get-an-email-the-first-time-an-ai-agent-reads-one-of-your-cl.md +6 -0
- package/corpus/templates/clips/learnings.defaults.md +2 -2
- package/corpus/templates/clips/package.json +1 -1
- package/corpus/templates/clips/scripts/send-real-recap.ts +165 -0
- package/corpus/templates/clips/scripts/send-test-emails.ts +60 -0
- package/corpus/templates/clips/server/db/schema.ts +4 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +256 -0
- package/corpus/templates/clips/server/lib/agent-views.ts +38 -11
- package/corpus/templates/clips/server/lib/media-storage-provenance.ts +16 -0
- package/corpus/templates/clips/server/lib/public-agent-context.ts +3 -1
- package/corpus/templates/clips/server/lib/recap-metrics.ts +373 -0
- package/corpus/templates/clips/server/lib/s3-upload-provider.ts +84 -4
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +41 -2
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +245 -3
- package/corpus/templates/clips/server/plugins/db.ts +16 -0
- package/corpus/templates/clips/server/register-secrets.ts +2 -2
- package/corpus/templates/clips/server/routes/api/public-recording.get.ts +7 -2
- package/corpus/templates/clips/server/routes/api/video/[recordingId].get.ts +42 -1
- package/corpus/templates/design/actions/take-design-screenshot.ts +5 -3
- package/corpus/templates/design/package.json +1 -1
- package/corpus/templates/design/server/lib/playwright-runtime.ts +13 -5
- package/corpus/templates/mail/actions/get-automation-settings.ts +4 -12
- package/corpus/templates/mail/app/pages/SettingsPage.tsx +1 -1
- package/corpus/templates/mail/changelog/2026-08-03-inbox-automations-now-prefer-the-lowest-cost-luna-model-when.md +6 -0
- package/corpus/templates/mail/server/lib/automation-engine.ts +19 -14
- package/corpus/templates/mail/server/lib/automation-model.ts +110 -0
- package/corpus/templates/mail/server/plugins/agent-chat.ts +1 -1
- package/corpus/templates/slides/actions/generate-slides-ai.ts +92 -24
- package/corpus/templates/slides/changelog/2026-08-03-legacy-slide-outlines-now-prefer-the-lowest-cost-luna-model-.md +6 -0
- package/corpus/templates/tasks/package.json +2 -2
- package/dist/agent/production-agent.d.ts +2 -2
- package/dist/agent/production-agent.js +1 -1
- package/dist/catalog.json +2 -0
- package/dist/client/AgentTaskCard.js +4 -2
- package/dist/client/i18n.js +3 -0
- package/dist/client/settings/VoiceTranscriptionSection.js +2 -2
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/routes.d.ts +1 -1
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/localization/default-messages.d.ts +5 -0
- package/dist/localization/default-messages.js +5 -0
- package/dist/mcp/oauth-client-metadata.d.ts +7 -0
- package/dist/mcp/oauth-client-metadata.js +47 -0
- package/dist/mcp/oauth-route.js +7 -3
- package/dist/mcp/oauth-store.d.ts +2 -0
- package/dist/mcp/oauth-store.js +25 -3
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/traces.d.ts +1 -1
- package/dist/observability/traces.js +2 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/index.js +29 -1
- package/dist/secrets/routes.d.ts +6 -6
- package/dist/server/agent-access.d.ts +3 -1
- package/dist/server/agent-access.js +2 -1
- package/dist/server/agent-chat/browser-team-tools.d.ts +1 -0
- package/dist/server/agent-chat/browser-team-tools.js +5 -4
- package/dist/server/agent-chat/context-tools.js +2 -2
- package/dist/server/agent-chat-plugin.js +13 -6
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/agent-teams-run-queue.d.ts +2 -0
- package/dist/server/agent-teams.d.ts +2 -0
- package/dist/server/agent-teams.js +45 -2
- package/dist/server/request-context.d.ts +2 -0
- package/dist/server/short-lived-token.d.ts +7 -0
- package/dist/server/short-lived-token.js +7 -1
- package/dist/server/ssr-handler.js +19 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/server/transcribe-voice.js +24 -16
- package/dist/vite/client.d.ts +37 -1
- package/dist/vite/client.js +110 -1
- package/docs/content/template-assets.mdx +14 -4
- package/package.json +3 -4
|
@@ -20,6 +20,7 @@ import { fileURLToPath } from "node:url";
|
|
|
20
20
|
*/
|
|
21
21
|
import { notifyWithDelivery } from "@agent-native/core/notifications";
|
|
22
22
|
import { recordChange } from "@agent-native/core/server";
|
|
23
|
+
import { getUserSetting } from "@agent-native/core/settings";
|
|
23
24
|
import { accessFilter } from "@agent-native/core/sharing";
|
|
24
25
|
import {
|
|
25
26
|
and,
|
|
@@ -33,6 +34,7 @@ import {
|
|
|
33
34
|
sql,
|
|
34
35
|
} from "drizzle-orm";
|
|
35
36
|
|
|
37
|
+
import { ANALYTICS_USER_PREFS_KEY } from "../../shared/analytics-user-prefs";
|
|
36
38
|
import { getDb, schema } from "../db/index.js";
|
|
37
39
|
|
|
38
40
|
export type ExceptionLevel = "fatal" | "error" | "warning" | "info" | "debug";
|
|
@@ -952,11 +954,14 @@ export async function ingestException(
|
|
|
952
954
|
});
|
|
953
955
|
|
|
954
956
|
if (isNewIssue) {
|
|
955
|
-
await
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
},
|
|
959
|
-
|
|
957
|
+
const emailEnabled = await errorEmailNotificationsEnabled(scope);
|
|
958
|
+
await notifyNewIssue(
|
|
959
|
+
scope,
|
|
960
|
+
{ issueId, title, level: raw.level },
|
|
961
|
+
emailEnabled,
|
|
962
|
+
).catch(() => {
|
|
963
|
+
// New-issue alerts are best-effort; never fail ingest on delivery.
|
|
964
|
+
});
|
|
960
965
|
}
|
|
961
966
|
|
|
962
967
|
return { issueId, eventId, isNewIssue, sessionRecordingId };
|
|
@@ -990,22 +995,25 @@ export async function ingestAnalyticsExceptionEvents(
|
|
|
990
995
|
async function notifyNewIssue(
|
|
991
996
|
scope: IngestScope,
|
|
992
997
|
issue: { issueId: string; title: string; level: ExceptionLevel },
|
|
998
|
+
emailEnabled: boolean,
|
|
993
999
|
): Promise<void> {
|
|
994
1000
|
await notifyWithDelivery(
|
|
995
1001
|
{
|
|
996
1002
|
severity: issue.level === "fatal" ? "critical" : "warning",
|
|
997
1003
|
title: `New error: ${issue.title}`,
|
|
998
1004
|
body: "A new JavaScript error was captured in your app.",
|
|
999
|
-
channels: ["inbox", "email"],
|
|
1005
|
+
channels: emailEnabled ? ["inbox", "email"] : ["inbox"],
|
|
1000
1006
|
metadata: {
|
|
1001
1007
|
kind: "error_issue",
|
|
1002
1008
|
issueId: issue.issueId,
|
|
1003
1009
|
level: issue.level,
|
|
1004
1010
|
path: `/monitoring?view=errors&issue=${issue.issueId}`,
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1011
|
+
...(emailEnabled
|
|
1012
|
+
? {
|
|
1013
|
+
emailRecipients: [scope.ownerEmail],
|
|
1014
|
+
emailSubject: `New error in your app: ${issue.title}`,
|
|
1015
|
+
}
|
|
1016
|
+
: {}),
|
|
1009
1017
|
},
|
|
1010
1018
|
},
|
|
1011
1019
|
// The notification inbox is owner-scoped; the issue's owner is the analytics
|
|
@@ -1015,6 +1023,26 @@ async function notifyNewIssue(
|
|
|
1015
1023
|
);
|
|
1016
1024
|
}
|
|
1017
1025
|
|
|
1026
|
+
async function errorEmailNotificationsEnabled(
|
|
1027
|
+
scope: IngestScope,
|
|
1028
|
+
): Promise<boolean> {
|
|
1029
|
+
try {
|
|
1030
|
+
const prefs = await getUserSetting(
|
|
1031
|
+
scope.ownerEmail,
|
|
1032
|
+
ANALYTICS_USER_PREFS_KEY,
|
|
1033
|
+
);
|
|
1034
|
+
return prefs?.errorEmailNotifications === true;
|
|
1035
|
+
} catch (error) {
|
|
1036
|
+
// Error email delivery must fail closed when the owner preference cannot be
|
|
1037
|
+
// read; the in-app issue notification still remains available.
|
|
1038
|
+
console.warn(
|
|
1039
|
+
"[error-capture] Could not read error email preference; skipping email delivery:",
|
|
1040
|
+
error,
|
|
1041
|
+
);
|
|
1042
|
+
return false;
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1018
1046
|
// ---------------------------------------------------------------------------
|
|
1019
1047
|
// Reads + triage
|
|
1020
1048
|
// ---------------------------------------------------------------------------
|
|
@@ -14,6 +14,8 @@ import {
|
|
|
14
14
|
import { resolveAnalyticsGongCredentials } from "./provider-credentials";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_API_BASE = "https://api.gong.io/v2";
|
|
17
|
+
const MAX_GONG_SEARCH_PAGES = 50;
|
|
18
|
+
const MAX_GONG_EXHAUSTIVE_RECORDS = 500;
|
|
17
19
|
|
|
18
20
|
const cache = new Map<string, { data: unknown; ts: number }>();
|
|
19
21
|
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
@@ -541,6 +543,7 @@ export async function searchCallsForQueries(
|
|
|
541
543
|
const matches = new Map<string, GongCall & { matchedQueries?: string[] }>();
|
|
542
544
|
let searchedCallCount = 0;
|
|
543
545
|
let cursor: string | undefined;
|
|
546
|
+
let pages = 0;
|
|
544
547
|
do {
|
|
545
548
|
const data = await apiPost<{
|
|
546
549
|
calls?: unknown[];
|
|
@@ -553,12 +556,23 @@ export async function searchCallsForQueries(
|
|
|
553
556
|
contentSelector: { exposedFields: { parties: true } },
|
|
554
557
|
...(cursor ? { cursor } : {}),
|
|
555
558
|
});
|
|
559
|
+
pages += 1;
|
|
560
|
+
if (
|
|
561
|
+
options.exhaustive &&
|
|
562
|
+
typeof data.records?.totalRecords === "number" &&
|
|
563
|
+
data.records.totalRecords > MAX_GONG_EXHAUSTIVE_RECORDS
|
|
564
|
+
) {
|
|
565
|
+
throw new Error(
|
|
566
|
+
`Gong exhaustive search found ${data.records.totalRecords.toLocaleString()} records in the requested window. ` +
|
|
567
|
+
"Use provider-corpus-job with staged call IDs for searches larger than 500 records so progress is checkpointed between batches.",
|
|
568
|
+
);
|
|
569
|
+
}
|
|
556
570
|
const calls = (data.calls ?? [])
|
|
557
571
|
.map(normalizeExtensiveCall)
|
|
558
572
|
.filter((call): call is GongCall => Boolean(call))
|
|
559
573
|
.filter(isExternalCall);
|
|
560
574
|
searchedCallCount += calls.length;
|
|
561
|
-
|
|
575
|
+
const nextCursor = data.records?.cursor;
|
|
562
576
|
|
|
563
577
|
for (const call of calls) {
|
|
564
578
|
const parties = call.parties ?? [];
|
|
@@ -573,7 +587,16 @@ export async function searchCallsForQueries(
|
|
|
573
587
|
}
|
|
574
588
|
if (!options.exhaustive && matches.size >= normalizedLimit) break;
|
|
575
589
|
}
|
|
576
|
-
|
|
590
|
+
if (!nextCursor || nextCursor === cursor) {
|
|
591
|
+
cursor = nextCursor;
|
|
592
|
+
break;
|
|
593
|
+
}
|
|
594
|
+
cursor = nextCursor;
|
|
595
|
+
} while (
|
|
596
|
+
cursor &&
|
|
597
|
+
pages < MAX_GONG_SEARCH_PAGES &&
|
|
598
|
+
(options.exhaustive || matches.size < normalizedLimit)
|
|
599
|
+
);
|
|
577
600
|
|
|
578
601
|
const matchedCalls = Array.from(matches.values());
|
|
579
602
|
return buildGongSearchResult(matchedCalls, normalizedLimit, {
|
|
@@ -611,11 +634,11 @@ export function buildGongSearchResult(
|
|
|
611
634
|
return {
|
|
612
635
|
calls: sorted,
|
|
613
636
|
limit: sorted.length,
|
|
614
|
-
truncated:
|
|
637
|
+
truncated: Boolean(meta.cursor),
|
|
615
638
|
searchedCallCount: meta.searchedCallCount,
|
|
616
639
|
matchedCallCount: matchedCalls.length,
|
|
617
640
|
queryCount: meta.queryCount,
|
|
618
|
-
coverageTruncated:
|
|
641
|
+
coverageTruncated: Boolean(meta.cursor),
|
|
619
642
|
};
|
|
620
643
|
}
|
|
621
644
|
const limited = limitGongCalls(matchedCalls, normalizedLimit);
|
|
@@ -43,6 +43,45 @@ const ANALYTICS_DATA_SOURCES_LINK = buildDeepLink({
|
|
|
43
43
|
to: "/data-sources",
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
const DASHBOARD_BUILD_PAUSE_PATTERN =
|
|
47
|
+
/\b(?:want me to|would you like me to|shall i|should i|can i|may i|do you want me to)\b[\s\S]{0,160}\b(?:proceed|continue|seed|populate|save|embed|finish|run|apply|create|build)\b/i;
|
|
48
|
+
|
|
49
|
+
function hasSuccessfulDashboardSave(
|
|
50
|
+
toolResults: AgentLoopFinalResponseGuardContext["toolResults"],
|
|
51
|
+
): boolean {
|
|
52
|
+
const saveActions = new Set([
|
|
53
|
+
"update-dashboard",
|
|
54
|
+
"mutate-dashboard",
|
|
55
|
+
"compose-dashboard",
|
|
56
|
+
"install-dashboard-template",
|
|
57
|
+
]);
|
|
58
|
+
return (toolResults ?? []).some((result) => {
|
|
59
|
+
if (result.isError) return false;
|
|
60
|
+
const name = String(result.name ?? "")
|
|
61
|
+
.trim()
|
|
62
|
+
.toLowerCase()
|
|
63
|
+
.replace(/[\s_]+/g, "-");
|
|
64
|
+
return saveActions.has(name);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function hasPartialDashboardBuild(
|
|
69
|
+
toolResults: AgentLoopFinalResponseGuardContext["toolResults"],
|
|
70
|
+
): boolean {
|
|
71
|
+
const partialBuildActions = new Set([
|
|
72
|
+
"create-extension",
|
|
73
|
+
"extension-data-set",
|
|
74
|
+
]);
|
|
75
|
+
return (toolResults ?? []).some((result) => {
|
|
76
|
+
if (result.isError) return false;
|
|
77
|
+
const name = String(result.name ?? "")
|
|
78
|
+
.trim()
|
|
79
|
+
.toLowerCase()
|
|
80
|
+
.replace(/[\s_]+/g, "-");
|
|
81
|
+
return partialBuildActions.has(name);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
46
85
|
export const BOUNDED_STRUCTURED_LOOKUP_GUIDANCE =
|
|
47
86
|
"BOUNDED STRUCTURED LOOKUP FAST PATH — Treat existing analytics work like an engineer treats existing code: grep before writing. For an ordinary count, aggregate, grouped metric, trend, or record lookup, first call `search-analytics-query-catalog` once with focused metric/entity terms. It searches accessible dashboard names, chart titles/descriptions/saved queries, shipped dashboard patterns, and data-dictionary definitions together. Prefer the strongest approved dictionary or saved-chart match, preserve its source and business logic, adapt only the requested filters and explicit time window, then run one bounded query against that source. A user-named source wins, but still use a matching saved definition when it supplies the source's proven query shape. If there is no useful match, inspect only the most likely source schema or ask one clarification; do not fan out across providers. Do not separately list every dashboard, call data-source status, browse the whole dictionary, load provider catalogs/corpus tools, or query a second source after a strong match. Once the query succeeds, answer immediately with its source, time window, filters, row count, and only necessary caveats. Do not enrich, cross-check, retry, or add breakdowns unless the user requested them, the first query failed, or its result conflicts with the known definition. The words `all`, `total`, or `exact` in a structured aggregate do not by themselves make it a corpus investigation. Never repeat an identical invalid or failed tool call; correct its arguments once or surface the error. This does not waive the real-data requirement: never answer from a guess, stale value, or unverified result. ";
|
|
48
87
|
|
|
@@ -541,7 +580,12 @@ export function realDataFinalGuard(
|
|
|
541
580
|
context as AgentLoopFinalResponseGuardContext & { requestText?: string }
|
|
542
581
|
).requestText;
|
|
543
582
|
const userText = stableRequestText ?? latestUserText(context.messages ?? []);
|
|
544
|
-
|
|
583
|
+
const dashboardConstructionRequest =
|
|
584
|
+
looksLikeDashboardConstructionRequest(userText);
|
|
585
|
+
if (
|
|
586
|
+
!looksLikeAnalyticsDataRequest(userText) &&
|
|
587
|
+
!dashboardConstructionRequest
|
|
588
|
+
) {
|
|
545
589
|
// Deterministic backstop: the soft NON_ANALYTICS_REQUEST_GUIDANCE prompt
|
|
546
590
|
// sentence is not always enough, and a model occasionally parrots the
|
|
547
591
|
// canned no-grounded-data fallback even for ordinary conversation. Catch
|
|
@@ -612,7 +656,7 @@ export function realDataFinalGuard(
|
|
|
612
656
|
) {
|
|
613
657
|
return {
|
|
614
658
|
retryMessage:
|
|
615
|
-
"The user asked a coverage-sensitive provider question, but the draft only used bounded convenience data actions. Do not finalize an exhaustive, all-records, or absence-sensitive answer from shortcut actions alone. Use the broad provider API/MCP surface and a
|
|
659
|
+
"The user asked a coverage-sensitive provider question, but the draft only used bounded convenience data actions. Do not finalize an exhaustive, all-records, or absence-sensitive answer from shortcut actions alone. Use the broad provider API/MCP surface and a staged analysis workflow now: provider-api-catalog/provider-api-docs when needed; for Gong, use configured tracker results from /calls/extensive when they cover the term, otherwise use provider-api-request as raw ingestion with stageAs/saveToFile followed by query-staged-dataset or a Data Program; use provider-corpus-job for durable batched raw-transcript scans. Never loop per call from run-code or a delegated agent. For more than 500 Gong records, gong-calls is not the broad-search path. If full coverage is not possible in this turn, finalize with explicit partial-coverage wording, inspected counts, filters, and remaining gaps.",
|
|
616
660
|
fallbackMessage:
|
|
617
661
|
"I can't make a confident coverage-sensitive provider claim from bounded shortcut actions alone. I need a provider API/corpus workflow, or I need to label the answer as partial with exact inspected counts and gaps.",
|
|
618
662
|
};
|
|
@@ -626,7 +670,7 @@ export function realDataFinalGuard(
|
|
|
626
670
|
) {
|
|
627
671
|
return {
|
|
628
672
|
retryMessage:
|
|
629
|
-
"The user asked to search source-record body text such as transcripts, messages, tickets, issues, notes, documents, or conversation logs, but the draft's corpus evidence does not show that the requested body records were actually searched. A parent/container metadata scan, title search, summary search, or call/ticket/message list is not enough for an absence-sensitive body-text claim. Retry with the provider's
|
|
673
|
+
"The user asked to search source-record body text such as transcripts, messages, tickets, issues, notes, documents, or conversation logs, but the draft's corpus evidence does not show that the requested body records were actually searched. A parent/container metadata scan, title search, summary search, or call/ticket/message list is not enough for an absence-sensitive body-text claim. Retry with the provider's native search, indexed tracker result, or raw body endpoint for the requested record type, using provider-corpus-job batch-search/paginated-search, provider-api-request with staging, or a Data Program/query over staged raw records. Then report source path/body field, inspected record count, hit count, and gaps.",
|
|
630
674
|
fallbackMessage:
|
|
631
675
|
"I can't make a confident source-record body-text claim because the corpus evidence does not show that the requested raw records were searched.",
|
|
632
676
|
};
|
|
@@ -652,6 +696,21 @@ export function realDataFinalGuard(
|
|
|
652
696
|
// on tool evidence, not user wording, and still block any draft that states
|
|
653
697
|
// invented numbers via draftClaimsAnalyticsMetrics. A saved SQL panel the
|
|
654
698
|
// user runs themselves is not a fabricated metric.
|
|
699
|
+
if (
|
|
700
|
+
dashboardConstructionRequest &&
|
|
701
|
+
hasPartialDashboardBuild(context.toolResults) &&
|
|
702
|
+
!hasSuccessfulDashboardSave(context.toolResults) &&
|
|
703
|
+
DASHBOARD_BUILD_PAUSE_PATTERN.test(context.text)
|
|
704
|
+
) {
|
|
705
|
+
return {
|
|
706
|
+
retryMessage:
|
|
707
|
+
"The user explicitly requested this dashboard or Custom Block. Continue the non-destructive build in this same turn: seed or refresh extension data when needed, save and embed the dashboard, and navigate to the result. Do not ask whether to proceed. Ask only about an ambiguous metric scope, a destructive change, or an external side effect such as sending email or outreach.",
|
|
708
|
+
fallbackMessage:
|
|
709
|
+
"I couldn't finish the requested dashboard build in this turn. Please retry and I'll continue from the saved artifact.",
|
|
710
|
+
maxRetries: 2,
|
|
711
|
+
expandToolSurface: true,
|
|
712
|
+
};
|
|
713
|
+
}
|
|
655
714
|
if (
|
|
656
715
|
hasDashboardMutationAttempt(context.toolResults) &&
|
|
657
716
|
!draftClaimsAnalyticsMetrics(context.text)
|
|
@@ -664,7 +723,7 @@ export function realDataFinalGuard(
|
|
|
664
723
|
// "no data query ran" fallback so a template-based extension clone is not
|
|
665
724
|
// treated the same as an unanswerable analytics-result question.
|
|
666
725
|
if (
|
|
667
|
-
|
|
726
|
+
dashboardConstructionRequest &&
|
|
668
727
|
!draftClaimsAnalyticsMetrics(context.text)
|
|
669
728
|
) {
|
|
670
729
|
if (
|
|
@@ -865,6 +924,7 @@ export default createAgentChatPlugin({
|
|
|
865
924
|
const sourceGuidance =
|
|
866
925
|
analyticsSourceGuidanceOpening() +
|
|
867
926
|
"DASHBOARD CREATION RULE — You may create dashboard artifacts, SQL panels, or other resources only when the user explicitly asks you to (e.g. 'build me a dashboard for...', 'save this analysis', 'add a chart for...'). Treat a requested saved analysis or deep-dive report as a dashboard request. Never create any resource proactively during research, trend analysis, or answering questions. If you think a dashboard would be useful, suggest it and wait for explicit confirmation before creating anything. Never add new items to the sidebar or modify existing dashboards without an explicit user directive. " +
|
|
927
|
+
"EXECUTION CONTINUITY — An explicit request to build, create, save, or adapt a dashboard or one-off Custom Block authorizes all non-destructive in-app steps required to finish it in the same turn. After querying or scaffolding, continue through extension-data seeding/refresh, dashboard save/embed, and navigation. Do not ask 'want me to proceed?' or stop at an empty shell. Ask one clarification only when metric scope or grain materially changes the result, and pause for destructive changes or external side effects such as sending email or outreach. " +
|
|
868
928
|
"DASHBOARD MUTATION RULE — For dashboard edits, default to `mutate-dashboard` with the typed `dashboard.*` script API so the main payload is a string and avoids native-array serialization traps. It can move panels by id, edit titles/SQL/config, insert, duplicate, remove, and patch dashboard fields in one atomic save. The script API is constrained: no variables/imports/loops/functions, only JSON-compatible arguments on documented dashboard methods. Do not count shifting `/panels/<index>` positions for ordinary dashboard edits unless the user specifically asks for low-level JSON-pointer operations. " +
|
|
869
929
|
'CUSTOM BLOCK RULE — Analytics can embed sandboxed extensions as dashboard-scoped Custom Blocks, but native panels and Data Programs come first. Do not create one for an ordinary "put X in this dashboard" request. Use `config.extensionId` only for an explicitly requested one-off or bespoke visualization that the native dashboard model cannot represent faithfully. For each new block, set `config.customBlock` with `authoredBy: "agent"`, `intent: "one-off"`, `scope: "dashboard"`, and a categorical `nativeGapReason` of `custom-visualization`, `custom-interaction`, `custom-layout`, or `other`; never store prompt or customer text there. The embed is shared with the dashboard, appears in scheduled reports, and receives dashboard/panel/current-filter context. Use `config.extensionSlotId` only when the user explicitly asks for a personal/per-viewer slot. Slot ids use `analytics.dashboard.<dashboard-id>.panel.<panel-id>` and require `add-extension-slot-target` plus `install-extension`; installs are per-user, so viewers can see different content and report identities may see an empty slot. Use `get-sql-dashboard` panel summaries to inspect an existing Custom Block. ' +
|
|
870
930
|
'EXTENSION DATA-REPAIR RULE — When fixing data in an existing extension-backed dashboard or migrated surface such as Risk Meeting, inspect the current dashboard and extension first, then call `update-extension` with exactly `id`, `operation="edit"`, and a `payloadJson` string containing focused patches/edits that change only the data-loading seam. Never send empty placeholder fields. Preserve the existing layout, CSS, copy, and interactions; never reconstruct the full HTML body for a data-only fix. A request that combines a visual rewrite such as compacting, removing sections, renaming, or changing padding with a data repair is a broad rewrite; after inspecting the current extension, use `operation="replace"` with the complete replacement in `payloadJson`. If a focused edit fails, change the target instead of retrying identical arguments. ' +
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/** Per-user Analytics preferences shared by Settings and notification senders. */
|
|
2
|
+
export const ANALYTICS_USER_PREFS_KEY = "analytics-user-prefs";
|
|
3
|
+
|
|
4
|
+
export type AnalyticsUserPrefs = {
|
|
5
|
+
/** New JavaScript error emails are opt-in. */
|
|
6
|
+
errorEmailNotifications?: boolean;
|
|
7
|
+
};
|
|
@@ -289,9 +289,15 @@ clip never hits them), so a request on one is the signal.
|
|
|
289
289
|
|
|
290
290
|
- **Table:** `recording_agent_views` — one row per `(recordingId, agentKey, viewSessionId)`.
|
|
291
291
|
`agentKey` is a sha256 of user-agent + request IP, so an agent is countable
|
|
292
|
-
across polls without ever storing its IP. `agentLabel`
|
|
293
|
-
|
|
294
|
-
`
|
|
292
|
+
across polls without ever storing its IP. `agentLabel` resolves in that order:
|
|
293
|
+
the label the agent link was minted with (a signed `agentLabel` claim on the
|
|
294
|
+
`agent_access` token, set via `create-recording-agent-link --agentLabel`), then
|
|
295
|
+
the product name parsed from the user-agent (Claude, ChatGPT, Perplexity, …),
|
|
296
|
+
then NULL. NULL means unnamed, not a name — render it as "Unknown agent" and
|
|
297
|
+
never write a placeholder string, or an agent we could not identify becomes
|
|
298
|
+
indistinguishable from one that identified itself. `userAgent` keeps the raw
|
|
299
|
+
(truncated) string so unnamed agents stay identifiable and new `AGENT_LABELS`
|
|
300
|
+
patterns come from real traffic.
|
|
295
301
|
- **Where it's written:** `recordAgentView` in `server/lib/agent-views.ts`,
|
|
296
302
|
called from `loadPublicAgentAccess` — the one choke point all three agent
|
|
297
303
|
routes share. Owner requests are skipped (they're previews, not views), and the
|
|
@@ -300,9 +306,12 @@ clip never hits them), so a request on one is the signal.
|
|
|
300
306
|
into a single view via a 30-minute window (`AGENT_VIEW_SESSION_MS`), with
|
|
301
307
|
`requestCount` recording how many polls that view covered.
|
|
302
308
|
- **Reads:** `countRecordingAgentViews` and `listRecordingAgentViewers`. Surfaced
|
|
303
|
-
as `agentViews` / `agentViewers` on `get-recording-insights`
|
|
304
|
-
|
|
305
|
-
|
|
309
|
+
as `agentViews` / `agentViewers` on `get-recording-insights`, `agentViewCount`
|
|
310
|
+
on `get-recording-player-data`, `list-recordings`, and the public
|
|
311
|
+
`/api/public-recording` payload. The count renders inline — on the views pill
|
|
312
|
+
itself (`RecordingViewsBadge`, watch and share headers) and on library cards —
|
|
313
|
+
so agent reads are visible without opening the popover. It is always a
|
|
314
|
+
separate icon-prefixed number, never summed into the human view total.
|
|
306
315
|
|
|
307
316
|
Keeping this in its own table is deliberate: no human-view query can pick agents
|
|
308
317
|
up by forgetting a filter. Do not add agent rows to `recording_viewers` or
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
getCurrentOwnerEmail,
|
|
14
14
|
ownerEmailMatches,
|
|
15
15
|
} from "../server/lib/recordings.js";
|
|
16
|
+
import { isS3ObjectUrlBoundToRecording } from "../server/lib/s3-upload-provider.js";
|
|
16
17
|
import { parseTranscriptSegments } from "../shared/transcript-segments.js";
|
|
17
18
|
import { assertNoDirectRecordingShares } from "./make-recording-private-for-rewind.js";
|
|
18
19
|
import {
|
|
@@ -112,6 +113,13 @@ export default defineAction({
|
|
|
112
113
|
if (Math.abs(args.durationMs - expectedDuration) > 2_000) {
|
|
113
114
|
throw new Error("The combined Clip duration does not match its sources.");
|
|
114
115
|
}
|
|
116
|
+
const rewindMediaIsBound = await isS3ObjectUrlBoundToRecording(
|
|
117
|
+
args.videoUrl,
|
|
118
|
+
args.recordingId,
|
|
119
|
+
);
|
|
120
|
+
if (rewindMediaIsBound === false) {
|
|
121
|
+
throw new Error("The Rewind upload is not bound to its recording.");
|
|
122
|
+
}
|
|
115
123
|
|
|
116
124
|
const edits = parseEdits(recording.editsJson);
|
|
117
125
|
edits.trims = edits.trims.map((trim) => ({
|
|
@@ -125,6 +133,7 @@ export default defineAction({
|
|
|
125
133
|
endMs: blur.endMs + args.addedMs,
|
|
126
134
|
}));
|
|
127
135
|
edits.rewindOriginalStartMs = args.addedMs;
|
|
136
|
+
edits.mediaStorageLayout = "external";
|
|
128
137
|
|
|
129
138
|
const [transcript] = await db
|
|
130
139
|
.select()
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Run the
|
|
2
|
+
* Run the text-model cleanup pass on a dictation's raw transcript text.
|
|
3
3
|
* Persists `cleanedText` on the dictation row.
|
|
4
4
|
*/
|
|
5
5
|
|
|
@@ -15,7 +15,7 @@ import { loadAgentsMdContext } from "./lib/agents-md-context.js";
|
|
|
15
15
|
|
|
16
16
|
export default defineAction({
|
|
17
17
|
description:
|
|
18
|
-
"Clean up a dictation's raw text via the
|
|
18
|
+
"Clean up a dictation's raw text via the low-cost text-model cleanup pass. Stores the result on `dictations.cleanedText`. Editor access required.",
|
|
19
19
|
schema: z.object({
|
|
20
20
|
id: z.string().describe("Dictation id"),
|
|
21
21
|
}),
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared
|
|
2
|
+
* Shared text-model cleanup pass for raw native transcripts.
|
|
3
3
|
*
|
|
4
4
|
* This action is the ONE narrow exception to the "all AI through agent chat"
|
|
5
5
|
* rule (see CLAUDE.md rule 1 + 2). It is a media-pipeline path — input is a
|
|
@@ -13,11 +13,9 @@
|
|
|
13
13
|
* - Meetings finalize (task='summary' → summary + bullets + action items)
|
|
14
14
|
*
|
|
15
15
|
* Provider routing:
|
|
16
|
-
* 1. Builder.io Connect credentials → Builder engine with
|
|
17
|
-
* `gemini-3-1-flash-lite` (matches existing convention in
|
|
18
|
-
* `transcribe-voice.ts`).
|
|
16
|
+
* 1. Builder.io Connect credentials → Builder engine with GPT-5.6 Luna.
|
|
19
17
|
* 2. Fallback: user GEMINI_API_KEY direct to Google's generativelanguage
|
|
20
|
-
* API.
|
|
18
|
+
* API with Gemini Flash-Lite.
|
|
21
19
|
* 3. Otherwise → throw FeatureNotConfiguredError.
|
|
22
20
|
*
|
|
23
21
|
* Usage:
|
|
@@ -44,11 +42,10 @@ import {
|
|
|
44
42
|
noteBuilderCreditsExhausted,
|
|
45
43
|
} from "./lib/builder-credits-state.js";
|
|
46
44
|
|
|
47
|
-
|
|
48
|
-
const BUILDER_MODEL = "gemini-3-1-flash-lite";
|
|
45
|
+
const BUILDER_MODEL = "gpt-5-6-luna";
|
|
49
46
|
|
|
50
|
-
// BYOK direct-Google fallback
|
|
51
|
-
// managed
|
|
47
|
+
// BYOK direct-Google fallback keeps an explicit public model id; Builder's
|
|
48
|
+
// managed path can use its own model catalog.
|
|
52
49
|
const GEMINI_BYOK_MODEL = "gemini-2.0-flash-lite";
|
|
53
50
|
const GEMINI_BYOK_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_BYOK_MODEL}:generateContent`;
|
|
54
51
|
|
|
@@ -71,7 +68,7 @@ const CLIPS_TRANSCRIPT_AGENT_INSTRUCTIONS = [
|
|
|
71
68
|
"Relevant Clips AGENTS.md rules:",
|
|
72
69
|
"- User-facing language calls a recording a Clip.",
|
|
73
70
|
"- Generated titles should be concise, specific, and human-editable.",
|
|
74
|
-
"- Native Web Speech/macOS Speech text is the source transcript;
|
|
71
|
+
"- Native Web Speech/macOS Speech text is the source transcript; the text model only cleans or titles it.",
|
|
75
72
|
"- Cleanup preserves the speaker's meaning and voice, fixes recognition errors, and must not invent facts.",
|
|
76
73
|
].join("\n");
|
|
77
74
|
|
|
@@ -95,7 +92,7 @@ export interface CleanupResult {
|
|
|
95
92
|
|
|
96
93
|
export default defineAction({
|
|
97
94
|
description:
|
|
98
|
-
"Run a
|
|
95
|
+
"Run a low-cost text-model cleanup pass on a raw transcript, preferring GPT-5.6 Luna through Builder when available and falling back to Gemini BYOK. task='cleanup' returns a cleaned transcript; task='title' returns a short title; task='summary' returns a markdown summary plus structured bullets and action items. This is a server-side media-pipeline path — it does NOT delegate to the agent chat.",
|
|
99
96
|
schema: z.object({
|
|
100
97
|
transcript: z.string().min(1).describe("Raw transcript text to process"),
|
|
101
98
|
task: z
|
|
@@ -16,7 +16,11 @@ import { assertAccess } from "@agent-native/core/sharing";
|
|
|
16
16
|
import { and, eq, isNull } from "drizzle-orm";
|
|
17
17
|
import { z } from "zod";
|
|
18
18
|
|
|
19
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
DEFAULT_EDITS,
|
|
21
|
+
parseEdits,
|
|
22
|
+
serializeEdits,
|
|
23
|
+
} from "../app/lib/timestamp-mapping.js";
|
|
20
24
|
import { getDb, schema } from "../server/db/index.js";
|
|
21
25
|
import { assertNativeRecordingMedia } from "./lib/native-media.js";
|
|
22
26
|
|
|
@@ -44,11 +48,20 @@ export default defineAction({
|
|
|
44
48
|
assertNativeRecordingMedia(existing);
|
|
45
49
|
|
|
46
50
|
const previousEditsJson = existing.editsJson;
|
|
51
|
+
const existingEdits = parseEdits(previousEditsJson);
|
|
47
52
|
|
|
48
53
|
const result = await db
|
|
49
54
|
.update(schema.recordings)
|
|
50
55
|
.set({
|
|
51
|
-
editsJson: serializeEdits({
|
|
56
|
+
editsJson: serializeEdits({
|
|
57
|
+
...DEFAULT_EDITS,
|
|
58
|
+
...(existingEdits.stitchedFrom
|
|
59
|
+
? { stitchedFrom: existingEdits.stitchedFrom }
|
|
60
|
+
: {}),
|
|
61
|
+
...(existingEdits.mediaStorageLayout
|
|
62
|
+
? { mediaStorageLayout: existingEdits.mediaStorageLayout }
|
|
63
|
+
: {}),
|
|
64
|
+
}),
|
|
52
65
|
updatedAt: new Date().toISOString(),
|
|
53
66
|
})
|
|
54
67
|
.where(
|
|
@@ -36,6 +36,15 @@ export default defineAction({
|
|
|
36
36
|
"Create a temporary private agent-readable link for one Clips recording. The URL is scoped to that recording and expires after two hours.",
|
|
37
37
|
schema: z.object({
|
|
38
38
|
recordingId: z.string().describe("Recording ID"),
|
|
39
|
+
agentLabel: z
|
|
40
|
+
.string()
|
|
41
|
+
.trim()
|
|
42
|
+
.min(1)
|
|
43
|
+
.max(60)
|
|
44
|
+
.optional()
|
|
45
|
+
.describe(
|
|
46
|
+
"Name of the agent that will read this link (e.g. 'Fusion', 'Claude Code'). Recorded against every read the link produces, so the owner sees a name instead of an unidentified agent.",
|
|
47
|
+
),
|
|
39
48
|
ttlSeconds: z
|
|
40
49
|
.number()
|
|
41
50
|
.int()
|
|
@@ -68,6 +77,7 @@ export default defineAction({
|
|
|
68
77
|
resourceKind: CLIP_AGENT_ACCESS_TOKEN_PREFIX,
|
|
69
78
|
resourceId: recording.id,
|
|
70
79
|
viewerEmail: getRequestUserEmail() || undefined,
|
|
80
|
+
agentLabel: args.agentLabel,
|
|
71
81
|
ttlSeconds: args.ttlSeconds ?? CLIPS_AGENT_ACCESS_TTL_SECONDS,
|
|
72
82
|
});
|
|
73
83
|
const origin = appOrigin();
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Finalize a meeting — runs the
|
|
2
|
+
* Finalize a meeting — runs the text-model cleanup pass on its transcript and
|
|
3
3
|
* persists summary, bullets, and action items.
|
|
4
4
|
*
|
|
5
5
|
* Reads the linked recording's transcript (`recording_transcripts`) and
|
|
@@ -24,7 +24,7 @@ import { loadAgentsMdContext } from "./lib/agents-md-context.js";
|
|
|
24
24
|
|
|
25
25
|
// A forced claim still needs the same compare-and-set protection as the normal
|
|
26
26
|
// path. It can recover a pending row only after that row has gone stale, which
|
|
27
|
-
// handles crashed processes without stealing an active
|
|
27
|
+
// handles crashed processes without stealing an active text-model run.
|
|
28
28
|
const PENDING_STALE_MS = 2 * 60 * 1000;
|
|
29
29
|
|
|
30
30
|
function trimContextValue(value: string, maxChars: number): string {
|
|
@@ -35,7 +35,7 @@ function trimContextValue(value: string, maxChars: number): string {
|
|
|
35
35
|
|
|
36
36
|
export default defineAction({
|
|
37
37
|
description:
|
|
38
|
-
"Finalize a meeting: run the
|
|
38
|
+
"Finalize a meeting: run the low-cost text-model cleanup pass on its transcript, persist summary + bullets + action items, and flip transcriptStatus to 'ready'. Editor access required.",
|
|
39
39
|
schema: z.object({
|
|
40
40
|
meetingId: z.string().describe("Meeting id"),
|
|
41
41
|
overrideTranscript: z
|
|
@@ -88,12 +88,12 @@ export default defineAction({
|
|
|
88
88
|
// Mark pending so the UI shows a spinner during the LLM call. This is
|
|
89
89
|
// also the compare-and-swap that prevents two concurrent finalize calls
|
|
90
90
|
// (e.g. the desktop stop-path and the web route's auto-finalize effect)
|
|
91
|
-
// from both running
|
|
91
|
+
// from both running the text model and clobbering meeting_action_items: only a
|
|
92
92
|
// call that observes 'ready' or 'failed' gets to flip the row to
|
|
93
93
|
// 'pending' and proceed. `force` (the manual "Regenerate notes" flow)
|
|
94
94
|
// additionally allows claiming a 'pending' row only when that claim is
|
|
95
95
|
// stale (see PENDING_STALE_MS). A fresh 'pending' row may still be an
|
|
96
|
-
// active
|
|
96
|
+
// active text-model run, so it is left alone.
|
|
97
97
|
const staleBefore = new Date(Date.now() - PENDING_STALE_MS).toISOString();
|
|
98
98
|
const claimPredicate = args.force
|
|
99
99
|
? or(
|
|
@@ -112,7 +112,7 @@ export default defineAction({
|
|
|
112
112
|
|
|
113
113
|
if (!claimed.length) {
|
|
114
114
|
// Another finalize is already in flight (status is 'pending') — no-op
|
|
115
|
-
// quietly instead of re-running
|
|
115
|
+
// quietly instead of re-running the text model and re-writing action items.
|
|
116
116
|
const [current] = await db
|
|
117
117
|
.select()
|
|
118
118
|
.from(schema.meetings)
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
applyFaststart,
|
|
31
31
|
hasPlayableMp4Metadata,
|
|
32
32
|
} from "../server/lib/faststart.js";
|
|
33
|
+
import { allowsLegacyS3ObjectForPersistedMedia } from "../server/lib/media-storage-provenance.js";
|
|
33
34
|
import {
|
|
34
35
|
mediaVerificationStateKey,
|
|
35
36
|
parseMediaVerificationMarker,
|
|
@@ -48,6 +49,7 @@ import {
|
|
|
48
49
|
getResumableSession,
|
|
49
50
|
} from "../server/lib/resumable-session.js";
|
|
50
51
|
import { resolveResumableUploadProvider } from "../server/lib/resumable-upload-provider.js";
|
|
52
|
+
import { fetchS3ObjectByUrl } from "../server/lib/s3-upload-provider.js";
|
|
51
53
|
import { isStreamingUploadDisabled } from "../server/lib/streaming-upload-mode.js";
|
|
52
54
|
import {
|
|
53
55
|
probeHasAudioStream,
|
|
@@ -181,7 +183,11 @@ function servedMediaSizeBytes(response: Response): number | null {
|
|
|
181
183
|
return null;
|
|
182
184
|
}
|
|
183
185
|
|
|
184
|
-
async function verifyServedMediaUrl(
|
|
186
|
+
async function verifyServedMediaUrl(
|
|
187
|
+
recordingId: string,
|
|
188
|
+
videoUrl: string,
|
|
189
|
+
allowLegacyObjectKey = false,
|
|
190
|
+
): Promise<number | null> {
|
|
185
191
|
if (!shouldVerifyServedMediaUrl(videoUrl)) return null;
|
|
186
192
|
|
|
187
193
|
let lastFailure = "media URL did not serve readable bytes";
|
|
@@ -196,11 +202,21 @@ async function verifyServedMediaUrl(videoUrl: string): Promise<number | null> {
|
|
|
196
202
|
MEDIA_SERVE_VERIFICATION_TIMEOUT_MS,
|
|
197
203
|
);
|
|
198
204
|
try {
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
205
|
+
const signedS3Response = await fetchS3ObjectByUrl(videoUrl, {
|
|
206
|
+
range: "bytes=0-1023",
|
|
207
|
+
timeoutMs: MEDIA_SERVE_VERIFICATION_TIMEOUT_MS,
|
|
208
|
+
recordingId,
|
|
209
|
+
...(allowLegacyObjectKey ? { allowLegacyObjectKey } : {}),
|
|
203
210
|
});
|
|
211
|
+
let response = signedS3Response;
|
|
212
|
+
if (response?.status !== 200 && response?.status !== 206) {
|
|
213
|
+
await response?.body?.cancel().catch(() => undefined);
|
|
214
|
+
response = await fetch(videoUrl, {
|
|
215
|
+
method: "GET",
|
|
216
|
+
headers: { Range: "bytes=0-1023" },
|
|
217
|
+
signal: controller.signal,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
204
220
|
const statusOk = response.status === 200 || response.status === 206;
|
|
205
221
|
if (statusOk) {
|
|
206
222
|
const servedBytes = servedMediaSizeBytes(response);
|
|
@@ -727,6 +743,7 @@ async function retryPendingMediaVerification(params: {
|
|
|
727
743
|
.select({
|
|
728
744
|
status: schema.recordings.status,
|
|
729
745
|
videoUrl: schema.recordings.videoUrl,
|
|
746
|
+
editsJson: schema.recordings.editsJson,
|
|
730
747
|
})
|
|
731
748
|
.from(schema.recordings)
|
|
732
749
|
.where(
|
|
@@ -754,7 +771,15 @@ async function retryPendingMediaVerification(params: {
|
|
|
754
771
|
videoUrl: recording.videoUrl || media.videoUrl,
|
|
755
772
|
};
|
|
756
773
|
try {
|
|
757
|
-
const servedBytes = await verifyServedMediaUrl(
|
|
774
|
+
const servedBytes = await verifyServedMediaUrl(
|
|
775
|
+
id,
|
|
776
|
+
candidate.videoUrl,
|
|
777
|
+
allowsLegacyS3ObjectForPersistedMedia({
|
|
778
|
+
requestedUrl: candidate.videoUrl,
|
|
779
|
+
persistedUrl: recording.videoUrl,
|
|
780
|
+
editsJson: recording.editsJson,
|
|
781
|
+
}),
|
|
782
|
+
);
|
|
758
783
|
const result = await markRecordingReady({
|
|
759
784
|
id,
|
|
760
785
|
ownerEmail,
|
|
@@ -1155,7 +1180,7 @@ export default defineAction({
|
|
|
1155
1180
|
debugLog("[finalize] resumable upload completed", { id, videoUrl });
|
|
1156
1181
|
let servedBytes: number | null;
|
|
1157
1182
|
try {
|
|
1158
|
-
servedBytes = await verifyServedMediaUrl(videoUrl);
|
|
1183
|
+
servedBytes = await verifyServedMediaUrl(id, videoUrl, true);
|
|
1159
1184
|
} catch (err) {
|
|
1160
1185
|
const failureReason =
|
|
1161
1186
|
err instanceof Error ? err.message : String(err);
|
|
@@ -1703,7 +1728,7 @@ export default defineAction({
|
|
|
1703
1728
|
});
|
|
1704
1729
|
let servedBytes: number | null;
|
|
1705
1730
|
try {
|
|
1706
|
-
servedBytes = await verifyServedMediaUrl(upload.url);
|
|
1731
|
+
servedBytes = await verifyServedMediaUrl(id, upload.url, true);
|
|
1707
1732
|
} catch (err) {
|
|
1708
1733
|
const failureReason = err instanceof Error ? err.message : String(err);
|
|
1709
1734
|
return await leaveRecordingProcessingForMediaVerification({
|