@agent-native/core 0.84.13 → 0.84.16
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/core/CHANGELOG.md +25 -0
- package/corpus/core/package.json +2 -2
- package/corpus/core/src/agent/durable-background.ts +18 -5
- package/corpus/core/src/agent/engine/builder-engine.ts +7 -3
- package/corpus/core/src/agent/production-agent.ts +48 -7
- package/corpus/core/src/agent/run-manager.ts +39 -2
- package/corpus/core/src/agent/run-store.ts +55 -11
- package/corpus/core/src/client/AssistantChat.tsx +30 -4
- package/corpus/core/src/client/MultiTabAssistantChat.tsx +11 -3
- package/corpus/core/src/client/agent-chat-adapter.ts +21 -3
- package/corpus/core/src/client/agent-chat.ts +18 -3
- package/corpus/core/src/client/blocks/library/diagram.tsx +3 -3
- package/corpus/core/src/client/chat/message-components.tsx +17 -0
- package/corpus/core/src/client/use-agent-engine-configured.ts +7 -4
- package/corpus/core/src/db/client.ts +10 -1
- package/corpus/core/src/observability/traces.ts +17 -3
- package/corpus/core/src/server/agent-chat-plugin.ts +80 -44
- package/corpus/core/src/styles/blocks.css +44 -1
- package/corpus/templates/analytics/app/pages/adhoc/sql-dashboard/DashboardFilterBar.tsx +18 -24
- package/corpus/templates/analytics/changelog/2026-07-01-dashboard-charts-no-longer-show-future-dates-when-clients-se.md +6 -0
- package/corpus/templates/analytics/changelog/2026-07-01-dashboard-filters-take-less-space-and-keep-view-actions-tucked.md +6 -0
- package/corpus/templates/analytics/server/lib/first-party-analytics.ts +34 -9
- package/corpus/templates/analytics/server/lib/first-party-metric-catalog.ts +6 -2
- package/corpus/templates/analytics/server/lib/session-replay.ts +109 -50
- package/corpus/templates/analytics/server/plugins/db.ts +21 -0
- package/corpus/templates/clips/changelog/2026-07-01-chrome-extension-recordings-stream-uploads-while-recording.md +6 -0
- package/corpus/templates/clips/chrome-extension/public/manifest.json +1 -1
- package/corpus/templates/clips/chrome-extension/src/background.ts +8 -0
- package/corpus/templates/clips/chrome-extension/src/offscreen.ts +61 -16
- package/corpus/templates/design/app/pages/DesignEditor.tsx +176 -13
- package/corpus/templates/design/changelog/2026-07-01-exporting-a-design-as-png-or-svg-now-downloads-just-the-sele.md +6 -0
- package/corpus/templates/plan/app/components/plan/wireframe/html-artboard.css +44 -1
- package/corpus/templates/plan/changelog/2026-07-01-visual-plan-and-recap-diagram-labels-now-wrap-inside-their-b.md +6 -0
- package/dist/agent/durable-background.d.ts +1 -0
- package/dist/agent/durable-background.d.ts.map +1 -1
- package/dist/agent/durable-background.js +17 -3
- package/dist/agent/durable-background.js.map +1 -1
- package/dist/agent/engine/builder-engine.d.ts.map +1 -1
- package/dist/agent/engine/builder-engine.js +7 -1
- package/dist/agent/engine/builder-engine.js.map +1 -1
- package/dist/agent/production-agent.d.ts.map +1 -1
- package/dist/agent/production-agent.js +32 -7
- package/dist/agent/production-agent.js.map +1 -1
- package/dist/agent/run-manager.d.ts +2 -0
- package/dist/agent/run-manager.d.ts.map +1 -1
- package/dist/agent/run-manager.js +28 -3
- package/dist/agent/run-manager.js.map +1 -1
- package/dist/agent/run-store.d.ts +9 -0
- package/dist/agent/run-store.d.ts.map +1 -1
- package/dist/agent/run-store.js +47 -11
- package/dist/agent/run-store.js.map +1 -1
- package/dist/client/AssistantChat.d.ts +8 -2
- package/dist/client/AssistantChat.d.ts.map +1 -1
- package/dist/client/AssistantChat.js +18 -2
- package/dist/client/AssistantChat.js.map +1 -1
- package/dist/client/MultiTabAssistantChat.d.ts.map +1 -1
- package/dist/client/MultiTabAssistantChat.js +7 -3
- package/dist/client/MultiTabAssistantChat.js.map +1 -1
- package/dist/client/agent-chat-adapter.d.ts.map +1 -1
- package/dist/client/agent-chat-adapter.js +19 -3
- package/dist/client/agent-chat-adapter.js.map +1 -1
- package/dist/client/agent-chat.d.ts +7 -0
- package/dist/client/agent-chat.d.ts.map +1 -1
- package/dist/client/agent-chat.js +5 -1
- package/dist/client/agent-chat.js.map +1 -1
- package/dist/client/blocks/library/diagram.js +3 -3
- package/dist/client/blocks/library/diagram.js.map +1 -1
- package/dist/client/chat/message-components.d.ts +3 -1
- package/dist/client/chat/message-components.d.ts.map +1 -1
- package/dist/client/chat/message-components.js +16 -2
- package/dist/client/chat/message-components.js.map +1 -1
- package/dist/client/use-agent-engine-configured.d.ts.map +1 -1
- package/dist/client/use-agent-engine-configured.js +7 -1
- package/dist/client/use-agent-engine-configured.js.map +1 -1
- package/dist/collab/routes.d.ts +2 -2
- package/dist/db/client.d.ts +6 -0
- package/dist/db/client.d.ts.map +1 -1
- package/dist/db/client.js +8 -1
- package/dist/db/client.js.map +1 -1
- package/dist/notifications/routes.d.ts +2 -2
- package/dist/observability/routes.d.ts +3 -3
- package/dist/observability/traces.d.ts +5 -0
- package/dist/observability/traces.d.ts.map +1 -1
- package/dist/observability/traces.js +9 -3
- package/dist/observability/traces.js.map +1 -1
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/agent-chat-plugin.d.ts +2 -1
- package/dist/server/agent-chat-plugin.d.ts.map +1 -1
- package/dist/server/agent-chat-plugin.js +64 -42
- package/dist/server/agent-chat-plugin.js.map +1 -1
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/styles/blocks.css +44 -1
- package/package.json +2 -2
|
@@ -50,6 +50,10 @@ function nowIso(): string {
|
|
|
50
50
|
return new Date().toISOString();
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
function todayIsoDate(): string {
|
|
54
|
+
return nowIso().slice(0, 10);
|
|
55
|
+
}
|
|
56
|
+
|
|
53
57
|
function randomHex(bytes: number): string {
|
|
54
58
|
const arr = new Uint8Array(bytes);
|
|
55
59
|
if (!globalThis.crypto?.getRandomValues) {
|
|
@@ -204,17 +208,30 @@ function asString(value: unknown): string | null {
|
|
|
204
208
|
return null;
|
|
205
209
|
}
|
|
206
210
|
|
|
207
|
-
function
|
|
208
|
-
|
|
211
|
+
export function normalizeAnalyticsTimestamp(
|
|
212
|
+
value: unknown,
|
|
213
|
+
receivedAt = nowIso(),
|
|
214
|
+
): string {
|
|
215
|
+
const fallback = (() => {
|
|
216
|
+
const date = new Date(receivedAt);
|
|
217
|
+
return Number.isNaN(date.getTime()) ? nowIso() : date.toISOString();
|
|
218
|
+
})();
|
|
219
|
+
const fallbackTime = new Date(fallback).getTime();
|
|
220
|
+
const normalize = (date: Date) => {
|
|
221
|
+
if (Number.isNaN(date.getTime())) return fallback;
|
|
222
|
+
return date.getTime() > fallbackTime ? fallback : date.toISOString();
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
if (value instanceof Date) return normalize(value);
|
|
209
226
|
if (typeof value === "number") {
|
|
210
227
|
const d = new Date(value);
|
|
211
|
-
return
|
|
228
|
+
return normalize(d);
|
|
212
229
|
}
|
|
213
230
|
if (typeof value === "string" && value.trim()) {
|
|
214
231
|
const d = new Date(value);
|
|
215
|
-
return
|
|
232
|
+
return normalize(d);
|
|
216
233
|
}
|
|
217
|
-
return
|
|
234
|
+
return fallback;
|
|
218
235
|
}
|
|
219
236
|
|
|
220
237
|
function eventDateFromTimestamp(timestamp: string): string {
|
|
@@ -362,7 +379,7 @@ export async function recordAnalyticsEvents(
|
|
|
362
379
|
asString((properties as any).anonymousId) ??
|
|
363
380
|
asString((properties as any).distinctId);
|
|
364
381
|
const userKey = userId || anonymousId;
|
|
365
|
-
const timestamp =
|
|
382
|
+
const timestamp = normalizeAnalyticsTimestamp(event.timestamp, receivedAt);
|
|
366
383
|
|
|
367
384
|
return {
|
|
368
385
|
id: id("evt"),
|
|
@@ -516,9 +533,17 @@ function scopeClause(scope: AnalyticsScope): {
|
|
|
516
533
|
};
|
|
517
534
|
}
|
|
518
535
|
|
|
519
|
-
function
|
|
536
|
+
function freshnessClause(tableName: string): string {
|
|
537
|
+
if (tableName === "analytics_events") {
|
|
538
|
+
return "(COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) <= ?)";
|
|
539
|
+
}
|
|
540
|
+
return "(substr(started_at, 1, 10) <= ?)";
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
export function scopedAnalyticsSql(
|
|
520
544
|
sql: string,
|
|
521
545
|
scope: AnalyticsScope,
|
|
546
|
+
today = todayIsoDate(),
|
|
522
547
|
): { sql: string; args: Array<string | null> } {
|
|
523
548
|
const args: Array<string | null> = [];
|
|
524
549
|
const aliasRe =
|
|
@@ -536,8 +561,8 @@ function scopedAnalyticsSql(
|
|
|
536
561
|
? aliasPart
|
|
537
562
|
: ` AS ${normalizedTable}`;
|
|
538
563
|
const scopeDef = scopeClause(scope);
|
|
539
|
-
args.push(...scopeDef.args);
|
|
540
|
-
return `${keyword} (SELECT * FROM ${normalizedTable} WHERE ${scopeDef.sql})${usableAlias}`;
|
|
564
|
+
args.push(...scopeDef.args, today);
|
|
565
|
+
return `${keyword} (SELECT * FROM ${normalizedTable} WHERE ${scopeDef.sql} AND ${freshnessClause(normalizedTable)})${usableAlias}`;
|
|
541
566
|
},
|
|
542
567
|
);
|
|
543
568
|
return { sql: rewritten, args };
|
|
@@ -128,6 +128,10 @@ function daysAgoSql(days: number): string {
|
|
|
128
128
|
return `to_char(CURRENT_DATE - INTERVAL '${days} ${unit}', 'YYYY-MM-DD')`;
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
+
function todaySql(): string {
|
|
132
|
+
return "to_char(CURRENT_DATE, 'YYYY-MM-DD')";
|
|
133
|
+
}
|
|
134
|
+
|
|
131
135
|
function windowStartFilter(days: number): string {
|
|
132
136
|
return `${EVENT_DATE_SQL} >= ${daysAgoSql(days)}`;
|
|
133
137
|
}
|
|
@@ -140,14 +144,14 @@ function rollingWindowStartSql(
|
|
|
140
144
|
}
|
|
141
145
|
|
|
142
146
|
function dashboardTimeRangeFilter(dateExpr = EVENT_DATE_FILTER_SQL): string {
|
|
143
|
-
return `('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365)}))`;
|
|
147
|
+
return `(${dateExpr} <= ${todaySql()} AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365)})))`;
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
function dashboardLookbackTimeRangeFilter(
|
|
147
151
|
dateExpr = EVENT_DATE_FILTER_SQL,
|
|
148
152
|
lookbackDays = 0,
|
|
149
153
|
): string {
|
|
150
|
-
return `('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7 + lookbackDays)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30 + lookbackDays)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90 + lookbackDays)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180 + lookbackDays)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365 + lookbackDays)}))`;
|
|
154
|
+
return `(${dateExpr} <= ${todaySql()} AND ('{{timeRange}}' IN ('', 'all') OR ('{{timeRange}}' = '7d' AND ${dateExpr} >= ${daysAgoSql(7 + lookbackDays)}) OR ('{{timeRange}}' = '30d' AND ${dateExpr} >= ${daysAgoSql(30 + lookbackDays)}) OR ('{{timeRange}}' = '90d' AND ${dateExpr} >= ${daysAgoSql(90 + lookbackDays)}) OR ('{{timeRange}}' = '180d' AND ${dateExpr} >= ${daysAgoSql(180 + lookbackDays)}) OR ('{{timeRange}}' = '365d' AND ${dateExpr} >= ${daysAgoSql(365 + lookbackDays)})))`;
|
|
151
155
|
}
|
|
152
156
|
|
|
153
157
|
const DASHBOARD_TIME_RANGE_FILTER = dashboardTimeRangeFilter();
|
|
@@ -275,6 +275,53 @@ function replayMaxIso(values: Array<string | null | undefined>): string | null {
|
|
|
275
275
|
return present.reduce((max, value) => (value > max ? value : max));
|
|
276
276
|
}
|
|
277
277
|
|
|
278
|
+
function replayClampIso(
|
|
279
|
+
value: string | null | undefined,
|
|
280
|
+
latestIso: string,
|
|
281
|
+
): string | null {
|
|
282
|
+
if (!value) return null;
|
|
283
|
+
const parsed = Date.parse(value);
|
|
284
|
+
const latest = Date.parse(latestIso);
|
|
285
|
+
if (!Number.isFinite(parsed)) return null;
|
|
286
|
+
if (!Number.isFinite(latest)) return value;
|
|
287
|
+
return parsed > latest ? latestIso : value;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function clampReplayChunkTiming(
|
|
291
|
+
chunk: NormalizedSessionReplayChunk,
|
|
292
|
+
latestIso: string,
|
|
293
|
+
): NormalizedSessionReplayChunk {
|
|
294
|
+
const startedAt = replayClampIso(chunk.startedAt, latestIso);
|
|
295
|
+
let endedAt = replayClampIso(chunk.endedAt, latestIso);
|
|
296
|
+
if (startedAt && endedAt && endedAt < startedAt) {
|
|
297
|
+
endedAt = startedAt;
|
|
298
|
+
}
|
|
299
|
+
return { ...chunk, startedAt, endedAt };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function clampReplayIngestTiming(
|
|
303
|
+
input: ParsedSessionReplayIngest,
|
|
304
|
+
latestIso: string,
|
|
305
|
+
): ParsedSessionReplayIngest {
|
|
306
|
+
const chunks = input.chunks.map((chunk) =>
|
|
307
|
+
clampReplayChunkTiming(chunk, latestIso),
|
|
308
|
+
);
|
|
309
|
+
const startedAt =
|
|
310
|
+
replayMinIso([
|
|
311
|
+
replayClampIso(input.startedAt, latestIso),
|
|
312
|
+
...chunks.map((chunk) => chunk.startedAt),
|
|
313
|
+
]) ?? latestIso;
|
|
314
|
+
let endedAt = replayMaxIso([
|
|
315
|
+
replayClampIso(input.endedAt, latestIso),
|
|
316
|
+
...chunks.map((chunk) => chunk.endedAt),
|
|
317
|
+
]);
|
|
318
|
+
if (endedAt && endedAt < startedAt) endedAt = startedAt;
|
|
319
|
+
const durationMs = endedAt
|
|
320
|
+
? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
|
|
321
|
+
: input.durationMs;
|
|
322
|
+
return { ...input, startedAt, endedAt, durationMs, chunks };
|
|
323
|
+
}
|
|
324
|
+
|
|
278
325
|
function normalizeReplayUrl(url: string | null): {
|
|
279
326
|
url: string | null;
|
|
280
327
|
path: string | null;
|
|
@@ -1101,7 +1148,8 @@ export async function recordSessionReplayChunks(
|
|
|
1101
1148
|
}> {
|
|
1102
1149
|
const key = await resolveReplayPublicKey(input.publicKey, context);
|
|
1103
1150
|
const db = getDb() as any;
|
|
1104
|
-
const ingestedAt = replayNowIso();
|
|
1151
|
+
const ingestedAt = replayTimestamp(context.now) ?? replayNowIso();
|
|
1152
|
+
const clampedInput = clampReplayIngestTiming(input, ingestedAt);
|
|
1105
1153
|
|
|
1106
1154
|
let [recording] = await db
|
|
1107
1155
|
.select()
|
|
@@ -1109,7 +1157,10 @@ export async function recordSessionReplayChunks(
|
|
|
1109
1157
|
.where(
|
|
1110
1158
|
and(
|
|
1111
1159
|
eq(schema.sessionRecordings.publicKeyId, key.id),
|
|
1112
|
-
eq(
|
|
1160
|
+
eq(
|
|
1161
|
+
schema.sessionRecordings.clientRecordingId,
|
|
1162
|
+
clampedInput.clientRecordingId,
|
|
1163
|
+
),
|
|
1113
1164
|
),
|
|
1114
1165
|
)
|
|
1115
1166
|
.limit(1);
|
|
@@ -1121,27 +1172,27 @@ export async function recordSessionReplayChunks(
|
|
|
1121
1172
|
.values({
|
|
1122
1173
|
id: newRecordingId,
|
|
1123
1174
|
publicKeyId: key.id,
|
|
1124
|
-
clientRecordingId:
|
|
1125
|
-
sessionId:
|
|
1126
|
-
userId:
|
|
1127
|
-
anonymousId:
|
|
1128
|
-
userKey:
|
|
1129
|
-
startedAt:
|
|
1130
|
-
endedAt:
|
|
1131
|
-
durationMs:
|
|
1132
|
-
pageCount:
|
|
1133
|
-
errorCount:
|
|
1134
|
-
rageClickCount:
|
|
1135
|
-
privacyMode:
|
|
1136
|
-
firstUrl:
|
|
1137
|
-
lastUrl:
|
|
1138
|
-
path:
|
|
1139
|
-
hostname:
|
|
1140
|
-
referrer:
|
|
1141
|
-
app:
|
|
1142
|
-
template:
|
|
1143
|
-
status:
|
|
1144
|
-
metadata: JSON.stringify(
|
|
1175
|
+
clientRecordingId: clampedInput.clientRecordingId,
|
|
1176
|
+
sessionId: clampedInput.sessionId,
|
|
1177
|
+
userId: clampedInput.userId,
|
|
1178
|
+
anonymousId: clampedInput.anonymousId,
|
|
1179
|
+
userKey: clampedInput.userKey,
|
|
1180
|
+
startedAt: clampedInput.startedAt,
|
|
1181
|
+
endedAt: clampedInput.endedAt,
|
|
1182
|
+
durationMs: clampedInput.durationMs,
|
|
1183
|
+
pageCount: clampedInput.pageCount,
|
|
1184
|
+
errorCount: clampedInput.errorCount,
|
|
1185
|
+
rageClickCount: clampedInput.rageClickCount,
|
|
1186
|
+
privacyMode: clampedInput.privacyMode,
|
|
1187
|
+
firstUrl: clampedInput.url,
|
|
1188
|
+
lastUrl: clampedInput.url,
|
|
1189
|
+
path: clampedInput.path,
|
|
1190
|
+
hostname: clampedInput.hostname,
|
|
1191
|
+
referrer: clampedInput.referrer,
|
|
1192
|
+
app: clampedInput.app,
|
|
1193
|
+
template: clampedInput.template,
|
|
1194
|
+
status: clampedInput.status,
|
|
1195
|
+
metadata: JSON.stringify(clampedInput.metadata),
|
|
1145
1196
|
lastIngestedAt: ingestedAt,
|
|
1146
1197
|
ownerEmail: key.ownerEmail,
|
|
1147
1198
|
orgId: key.orgId,
|
|
@@ -1162,7 +1213,7 @@ export async function recordSessionReplayChunks(
|
|
|
1162
1213
|
eq(schema.sessionRecordings.publicKeyId, key.id),
|
|
1163
1214
|
eq(
|
|
1164
1215
|
schema.sessionRecordings.clientRecordingId,
|
|
1165
|
-
|
|
1216
|
+
clampedInput.clientRecordingId,
|
|
1166
1217
|
),
|
|
1167
1218
|
),
|
|
1168
1219
|
)
|
|
@@ -1193,7 +1244,7 @@ export async function recordSessionReplayChunks(
|
|
|
1193
1244
|
existingChunks.length === 0;
|
|
1194
1245
|
|
|
1195
1246
|
try {
|
|
1196
|
-
for (const rawChunk of
|
|
1247
|
+
for (const rawChunk of clampedInput.chunks) {
|
|
1197
1248
|
const existing = existingBySeq.get(rawChunk.seq);
|
|
1198
1249
|
if (existing) {
|
|
1199
1250
|
if (existing.checksum !== rawChunk.checksum) {
|
|
@@ -1272,13 +1323,15 @@ export async function recordSessionReplayChunks(
|
|
|
1272
1323
|
id: replayId("sri"),
|
|
1273
1324
|
publicKeyId: key.id,
|
|
1274
1325
|
recordingId: recording.id,
|
|
1275
|
-
byteLength: replayIngestByteLength(
|
|
1326
|
+
byteLength: replayIngestByteLength(clampedInput, context),
|
|
1276
1327
|
createdAt: ingestedAt,
|
|
1277
1328
|
ownerEmail: key.ownerEmail,
|
|
1278
1329
|
orgId: key.orgId,
|
|
1279
1330
|
});
|
|
1280
1331
|
|
|
1281
|
-
const allChunks = [...existingChunks, ...rowsToInsert]
|
|
1332
|
+
const allChunks = [...existingChunks, ...rowsToInsert].map((chunk: any) =>
|
|
1333
|
+
clampReplayChunkTiming(chunk, ingestedAt),
|
|
1334
|
+
);
|
|
1282
1335
|
const chunkCount = allChunks.length;
|
|
1283
1336
|
const eventCount = allChunks.reduce(
|
|
1284
1337
|
(sum, chunk: any) => sum + Number(chunk.eventCount ?? 0),
|
|
@@ -1291,57 +1344,63 @@ export async function recordSessionReplayChunks(
|
|
|
1291
1344
|
const startedAt =
|
|
1292
1345
|
replayMinIso([
|
|
1293
1346
|
recording.startedAt,
|
|
1294
|
-
|
|
1347
|
+
clampedInput.startedAt,
|
|
1295
1348
|
...allChunks.map((chunk: any) => chunk.startedAt),
|
|
1296
|
-
]) ??
|
|
1349
|
+
]) ?? clampedInput.startedAt;
|
|
1297
1350
|
const endedAt =
|
|
1298
1351
|
replayMaxIso([
|
|
1299
1352
|
recording.endedAt,
|
|
1300
|
-
|
|
1353
|
+
clampedInput.endedAt,
|
|
1301
1354
|
...allChunks.map((chunk: any) => chunk.endedAt),
|
|
1302
1355
|
]) ?? null;
|
|
1303
1356
|
const durationMs =
|
|
1304
|
-
|
|
1357
|
+
clampedInput.durationMs ??
|
|
1305
1358
|
(endedAt
|
|
1306
1359
|
? Math.max(0, Date.parse(endedAt) - Date.parse(startedAt))
|
|
1307
1360
|
: (recording.durationMs ?? null));
|
|
1308
1361
|
const metadata = mergeReplayMetadata(
|
|
1309
1362
|
parseRecordingMetadata(recording),
|
|
1310
|
-
|
|
1363
|
+
clampedInput.metadata,
|
|
1311
1364
|
);
|
|
1312
1365
|
|
|
1313
1366
|
await db
|
|
1314
1367
|
.update(schema.sessionRecordings)
|
|
1315
1368
|
.set({
|
|
1316
|
-
sessionId:
|
|
1317
|
-
userId:
|
|
1318
|
-
anonymousId:
|
|
1319
|
-
userKey:
|
|
1369
|
+
sessionId: clampedInput.sessionId,
|
|
1370
|
+
userId: clampedInput.userId ?? recording.userId ?? null,
|
|
1371
|
+
anonymousId: clampedInput.anonymousId ?? recording.anonymousId ?? null,
|
|
1372
|
+
userKey: clampedInput.userKey ?? recording.userKey ?? null,
|
|
1320
1373
|
startedAt,
|
|
1321
1374
|
endedAt,
|
|
1322
1375
|
durationMs,
|
|
1323
1376
|
chunkCount,
|
|
1324
1377
|
eventCount,
|
|
1325
1378
|
totalBytes,
|
|
1326
|
-
pageCount: Math.max(
|
|
1327
|
-
|
|
1379
|
+
pageCount: Math.max(
|
|
1380
|
+
Number(recording.pageCount ?? 0),
|
|
1381
|
+
clampedInput.pageCount,
|
|
1382
|
+
),
|
|
1383
|
+
errorCount: Math.max(
|
|
1384
|
+
Number(recording.errorCount ?? 0),
|
|
1385
|
+
clampedInput.errorCount,
|
|
1386
|
+
),
|
|
1328
1387
|
rageClickCount: Math.max(
|
|
1329
1388
|
Number(recording.rageClickCount ?? 0),
|
|
1330
|
-
|
|
1389
|
+
clampedInput.rageClickCount,
|
|
1331
1390
|
),
|
|
1332
1391
|
privacyMode:
|
|
1333
|
-
|
|
1334
|
-
?
|
|
1392
|
+
clampedInput.privacyMode !== "unknown"
|
|
1393
|
+
? clampedInput.privacyMode
|
|
1335
1394
|
: (recording.privacyMode ?? "unknown"),
|
|
1336
|
-
firstUrl: recording.firstUrl ??
|
|
1337
|
-
lastUrl:
|
|
1338
|
-
path:
|
|
1339
|
-
hostname:
|
|
1340
|
-
referrer:
|
|
1341
|
-
app:
|
|
1342
|
-
template:
|
|
1395
|
+
firstUrl: recording.firstUrl ?? clampedInput.url,
|
|
1396
|
+
lastUrl: clampedInput.url ?? recording.lastUrl ?? null,
|
|
1397
|
+
path: clampedInput.path ?? recording.path ?? null,
|
|
1398
|
+
hostname: clampedInput.hostname ?? recording.hostname ?? null,
|
|
1399
|
+
referrer: clampedInput.referrer ?? recording.referrer ?? null,
|
|
1400
|
+
app: clampedInput.app ?? recording.app ?? null,
|
|
1401
|
+
template: clampedInput.template ?? recording.template ?? null,
|
|
1343
1402
|
status:
|
|
1344
|
-
|
|
1403
|
+
clampedInput.status === "completed" || recording.status === "completed"
|
|
1345
1404
|
? "completed"
|
|
1346
1405
|
: "active",
|
|
1347
1406
|
metadata: JSON.stringify(metadata),
|
|
@@ -1364,7 +1423,7 @@ export async function recordSessionReplayChunks(
|
|
|
1364
1423
|
|
|
1365
1424
|
return {
|
|
1366
1425
|
recordingId: recording.id,
|
|
1367
|
-
sessionId:
|
|
1426
|
+
sessionId: clampedInput.sessionId,
|
|
1368
1427
|
acceptedChunks: rowsToInsert.length,
|
|
1369
1428
|
duplicateChunks,
|
|
1370
1429
|
chunkCount,
|
|
@@ -492,6 +492,27 @@ export default runMigrations(
|
|
|
492
492
|
version: 71,
|
|
493
493
|
sql: `CREATE INDEX IF NOT EXISTS session_replay_ingests_recording_idx ON session_replay_ingests (recording_id)`,
|
|
494
494
|
},
|
|
495
|
+
{
|
|
496
|
+
version: 72,
|
|
497
|
+
sql: {
|
|
498
|
+
postgres: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), event_date = substr(COALESCE(NULLIF(received_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > to_char(CURRENT_DATE, 'YYYY-MM-DD')`,
|
|
499
|
+
sqlite: `UPDATE analytics_events SET timestamp = COALESCE(NULLIF(received_at, ''), datetime('now')), event_date = substr(COALESCE(NULLIF(received_at, ''), date('now')), 1, 10) WHERE COALESCE(NULLIF(event_date, ''), substr(timestamp, 1, 10)) > date('now')`,
|
|
500
|
+
},
|
|
501
|
+
},
|
|
502
|
+
{
|
|
503
|
+
version: 73,
|
|
504
|
+
sql: {
|
|
505
|
+
postgres: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')))`,
|
|
506
|
+
sqlite: `UPDATE session_recordings SET started_at = CASE WHEN substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(last_ingested_at, ''), NULLIF(updated_at, ''), NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (owner_email IS NOT NULL OR org_id IS NOT NULL) AND (substr(started_at, 1, 10) > date('now') OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now')))`,
|
|
507
|
+
},
|
|
508
|
+
},
|
|
509
|
+
{
|
|
510
|
+
version: 74,
|
|
511
|
+
sql: {
|
|
512
|
+
postgres: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD') THEN LEAST(COALESCE(NULLIF(created_at, ''), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')), to_char(CURRENT_TIMESTAMP AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.MS"Z"')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > to_char(CURRENT_DATE, 'YYYY-MM-DD'))`,
|
|
513
|
+
sqlite: `UPDATE session_replay_chunks SET started_at = CASE WHEN started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE started_at END, ended_at = CASE WHEN ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now') THEN min(COALESCE(NULLIF(created_at, ''), datetime('now')), datetime('now')) ELSE ended_at END WHERE (started_at IS NOT NULL AND substr(started_at, 1, 10) > date('now')) OR (ended_at IS NOT NULL AND substr(ended_at, 1, 10) > date('now'))`,
|
|
514
|
+
},
|
|
515
|
+
},
|
|
495
516
|
],
|
|
496
517
|
{ table: "analytics_migrations" },
|
|
497
518
|
);
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "Agent-Native Clips",
|
|
4
4
|
"short_name": "Clips",
|
|
5
|
-
"version": "0.1.
|
|
5
|
+
"version": "0.1.8",
|
|
6
6
|
"description": "Start Clips recordings from Chrome, capture optional diagnostics, and preview Clips links on GitHub.",
|
|
7
7
|
"minimum_chrome_version": "116",
|
|
8
8
|
"action": {
|
|
@@ -32,6 +32,7 @@ const UNQUOTED_SECRET_VALUE_RE = new RegExp(
|
|
|
32
32
|
type CaptureSurface = "browser" | "window" | "monitor" | "camera";
|
|
33
33
|
type ConsoleLevel = "debug" | "log" | "info" | "warn" | "error";
|
|
34
34
|
type NetworkType = "fetch" | "xhr";
|
|
35
|
+
type UploadMode = "streaming" | "buffered";
|
|
35
36
|
|
|
36
37
|
type ExtensionSettings = {
|
|
37
38
|
clipsBaseUrl: string;
|
|
@@ -134,6 +135,7 @@ type NativeRecording = {
|
|
|
134
135
|
recordingId: string;
|
|
135
136
|
clipsBaseUrl: string;
|
|
136
137
|
uploadUrl: string;
|
|
138
|
+
uploadMode: UploadMode;
|
|
137
139
|
targetTabId: number;
|
|
138
140
|
targetTitle: string | null;
|
|
139
141
|
targetUrl: string | null;
|
|
@@ -1049,6 +1051,7 @@ async function armRecording(args: {
|
|
|
1049
1051
|
id?: string;
|
|
1050
1052
|
uploadChunkUrl?: string;
|
|
1051
1053
|
abortUrl?: string;
|
|
1054
|
+
uploadMode?: UploadMode;
|
|
1052
1055
|
};
|
|
1053
1056
|
let created: CreatedRecording;
|
|
1054
1057
|
try {
|
|
@@ -1061,6 +1064,8 @@ async function armRecording(args: {
|
|
|
1061
1064
|
hasAudio:
|
|
1062
1065
|
settings.includeMicrophone || settings.captureSurface !== "camera",
|
|
1063
1066
|
visibility: "public",
|
|
1067
|
+
mimeType: "video/webm",
|
|
1068
|
+
requestStreaming: true,
|
|
1064
1069
|
});
|
|
1065
1070
|
} catch (err) {
|
|
1066
1071
|
captureExtensionError(err, {
|
|
@@ -1091,6 +1096,7 @@ async function armRecording(args: {
|
|
|
1091
1096
|
recordingId: created.id,
|
|
1092
1097
|
clipsBaseUrl: settings.clipsBaseUrl,
|
|
1093
1098
|
uploadUrl,
|
|
1099
|
+
uploadMode: created.uploadMode ?? "buffered",
|
|
1094
1100
|
targetTabId: tab.id as number,
|
|
1095
1101
|
targetTitle: tab.title?.trim() || null,
|
|
1096
1102
|
targetUrl: tab.url?.trim() || null,
|
|
@@ -1122,6 +1128,7 @@ async function armRecording(args: {
|
|
|
1122
1128
|
sessionId,
|
|
1123
1129
|
recordingId: created.id,
|
|
1124
1130
|
uploadUrl,
|
|
1131
|
+
uploadMode: created.uploadMode ?? "buffered",
|
|
1125
1132
|
hasCamera: cameraInvolved,
|
|
1126
1133
|
startDelayMs,
|
|
1127
1134
|
authToken,
|
|
@@ -1274,6 +1281,7 @@ async function handleOverlayRestart() {
|
|
|
1274
1281
|
sessionId: recording.sessionId,
|
|
1275
1282
|
recordingId: recording.recordingId,
|
|
1276
1283
|
uploadUrl: recording.uploadUrl,
|
|
1284
|
+
uploadMode: recording.uploadMode ?? "buffered",
|
|
1277
1285
|
hasCamera:
|
|
1278
1286
|
recording.captureSurface === "camera" || recording.includeCamera,
|
|
1279
1287
|
startDelayMs: COUNTDOWN_SECONDS * 1000,
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
// app recorder via @shared/recording-core so the server contract can't drift.
|
|
18
18
|
|
|
19
19
|
import { scheduleReadyChime } from "@shared/recording-audio";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
chunkUploadUrl,
|
|
22
|
+
pickMimeType,
|
|
23
|
+
type UploadMode,
|
|
24
|
+
} from "@shared/recording-core";
|
|
21
25
|
import { MAX_UPLOAD_BYTES } from "@shared/upload-limits";
|
|
22
26
|
|
|
23
27
|
import { waitForReadyRecordingAfterFinalizeError } from "./finalize-recovery";
|
|
@@ -63,6 +67,7 @@ type BeginMessage = {
|
|
|
63
67
|
sessionId: string;
|
|
64
68
|
recordingId: string;
|
|
65
69
|
uploadUrl: string;
|
|
70
|
+
uploadMode?: UploadMode;
|
|
66
71
|
hasCamera?: boolean;
|
|
67
72
|
// Pre-roll countdown delay, owned here in the offscreen document (a reliable
|
|
68
73
|
// context) rather than the service worker (which can suspend and drop timers).
|
|
@@ -114,6 +119,7 @@ type ActiveRecording = {
|
|
|
114
119
|
sessionId: string;
|
|
115
120
|
recordingId: string;
|
|
116
121
|
uploadUrl: string;
|
|
122
|
+
uploadMode: UploadMode;
|
|
117
123
|
authToken: string | null;
|
|
118
124
|
mode: CaptureMode;
|
|
119
125
|
startedAtMs: number;
|
|
@@ -134,6 +140,8 @@ type ActiveRecording = {
|
|
|
134
140
|
// Set if the recording grew past the buffer ceiling and we stopped retaining
|
|
135
141
|
// — at that point a local save can't be guaranteed, so we don't promise one.
|
|
136
142
|
localBufferOverflow: boolean;
|
|
143
|
+
pendingStreamBlobs: Blob[];
|
|
144
|
+
pendingStreamBytes: number;
|
|
137
145
|
cancelled: boolean;
|
|
138
146
|
// Set when the recorder is being torn down to start over on the same source
|
|
139
147
|
// streams, so the stop handler skips the usual track cleanup.
|
|
@@ -155,6 +163,8 @@ type ActiveRecording = {
|
|
|
155
163
|
rejectStopped: (error: Error) => void;
|
|
156
164
|
};
|
|
157
165
|
|
|
166
|
+
const GCS_CHUNK_ALIGN_BYTES = 256 * 1024;
|
|
167
|
+
const STREAM_CHUNK_BYTES = 15 * GCS_CHUNK_ALIGN_BYTES; // 3.75 MiB
|
|
158
168
|
const UPLOAD_SLICE_BYTES = 3 * 1024 * 1024;
|
|
159
169
|
|
|
160
170
|
// Don't retain more than the upload ceiling — past it the server rejects the
|
|
@@ -578,6 +588,32 @@ async function uploadBlobInSlices(
|
|
|
578
588
|
}
|
|
579
589
|
}
|
|
580
590
|
|
|
591
|
+
async function uploadStreamingBlob(
|
|
592
|
+
recording: ActiveRecording,
|
|
593
|
+
blob: Blob,
|
|
594
|
+
): Promise<void> {
|
|
595
|
+
if (blob.size === 0) return;
|
|
596
|
+
recording.pendingStreamBlobs.push(blob);
|
|
597
|
+
recording.pendingStreamBytes += blob.size;
|
|
598
|
+
|
|
599
|
+
while (recording.pendingStreamBytes >= STREAM_CHUNK_BYTES) {
|
|
600
|
+
if (recording.cancelled || recording.uploadFailure) return;
|
|
601
|
+
const combined = new Blob(recording.pendingStreamBlobs, {
|
|
602
|
+
type: recording.mimeType,
|
|
603
|
+
});
|
|
604
|
+
const head = combined.slice(0, STREAM_CHUNK_BYTES, recording.mimeType);
|
|
605
|
+
const tail = combined.slice(
|
|
606
|
+
STREAM_CHUNK_BYTES,
|
|
607
|
+
undefined,
|
|
608
|
+
recording.mimeType,
|
|
609
|
+
);
|
|
610
|
+
recording.pendingStreamBlobs = tail.size > 0 ? [tail] : [];
|
|
611
|
+
recording.pendingStreamBytes = tail.size;
|
|
612
|
+
const index = recording.chunkIndex++;
|
|
613
|
+
await uploadChunk(recording, head, index);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
581
617
|
function stopStreams(streams: (MediaStream | null)[]): void {
|
|
582
618
|
for (const stream of streams) {
|
|
583
619
|
if (!stream) continue;
|
|
@@ -744,6 +780,7 @@ async function begin(message: BeginMessage): Promise<{
|
|
|
744
780
|
sessionId: ready.sessionId,
|
|
745
781
|
recordingId: message.recordingId,
|
|
746
782
|
uploadUrl: message.uploadUrl,
|
|
783
|
+
uploadMode: message.uploadMode ?? "buffered",
|
|
747
784
|
authToken: message.authToken ?? null,
|
|
748
785
|
mode: ready.mode,
|
|
749
786
|
startedAtMs: 0,
|
|
@@ -763,6 +800,8 @@ async function begin(message: BeginMessage): Promise<{
|
|
|
763
800
|
recordedBlobs: [],
|
|
764
801
|
recordedBytes: 0,
|
|
765
802
|
localBufferOverflow: false,
|
|
803
|
+
pendingStreamBlobs: [],
|
|
804
|
+
pendingStreamBytes: 0,
|
|
766
805
|
cancelled: false,
|
|
767
806
|
restarting: false,
|
|
768
807
|
startTimer: null,
|
|
@@ -798,7 +837,11 @@ async function begin(message: BeginMessage): Promise<{
|
|
|
798
837
|
// rejected promise that surfaces as an "Uncaught (in promise)" error (bad
|
|
799
838
|
// look in a Chrome Web Store review). finalizeStop reads recording.upload-
|
|
800
839
|
// Failure and surfaces it through the normal error path instead.
|
|
801
|
-
const upload =
|
|
840
|
+
const upload = (
|
|
841
|
+
recording.uploadMode === "streaming"
|
|
842
|
+
? uploadStreamingBlob(recording, event.data)
|
|
843
|
+
: uploadBlobInSlices(recording, event.data)
|
|
844
|
+
).catch((err) => {
|
|
802
845
|
recording.uploadFailure =
|
|
803
846
|
err instanceof Error ? err : new Error(String(err));
|
|
804
847
|
captureExtensionError(recording.uploadFailure, {
|
|
@@ -1001,20 +1044,22 @@ async function finalizeStop(recording: ActiveRecording): Promise<void> {
|
|
|
1001
1044
|
}
|
|
1002
1045
|
let result: UploadResult;
|
|
1003
1046
|
try {
|
|
1004
|
-
|
|
1005
|
-
recording
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1047
|
+
const finalBlob =
|
|
1048
|
+
recording.uploadMode === "streaming"
|
|
1049
|
+
? new Blob(recording.pendingStreamBlobs, { type: recording.mimeType })
|
|
1050
|
+
: new Blob([], { type: recording.mimeType });
|
|
1051
|
+
recording.pendingStreamBlobs = [];
|
|
1052
|
+
recording.pendingStreamBytes = 0;
|
|
1053
|
+
const finalIndex = recording.chunkIndex;
|
|
1054
|
+
result = await uploadChunk(recording, finalBlob, finalIndex, {
|
|
1055
|
+
isFinal: true,
|
|
1056
|
+
total: finalIndex + (finalBlob.size > 0 ? 1 : 0),
|
|
1057
|
+
durationMs,
|
|
1058
|
+
width: recording.dimensions.width,
|
|
1059
|
+
height: recording.dimensions.height,
|
|
1060
|
+
hasAudio: recording.hasAudio,
|
|
1061
|
+
hasCamera: recording.hasCamera,
|
|
1062
|
+
});
|
|
1018
1063
|
} catch (err) {
|
|
1019
1064
|
const error = err instanceof Error ? err : new Error(String(err));
|
|
1020
1065
|
(error as { finalUpload?: boolean }).finalUpload = true;
|