@agent-native/core 0.173.1 → 0.173.2-nightly-20260825163448

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/templates/clips/desktop/src/app.tsx +77 -7
  3. package/corpus/templates/clips/desktop/src/lib/recorder.ts +91 -25
  4. package/corpus/templates/clips/desktop/src/lib/upload-recovery.ts +20 -2
  5. package/corpus/templates/clips/desktop/src-tauri/Cargo.toml +7 -1
  6. package/corpus/templates/clips/desktop/src-tauri/src/lib.rs +1 -0
  7. package/corpus/templates/clips/desktop/src-tauri/src/native_screen.rs +372 -70
  8. package/corpus/templates/clips/server/lib/resumable-session.ts +15 -0
  9. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +245 -118
  10. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +21 -11
  11. package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/resume.get.ts +132 -25
  12. package/corpus/templates/content/actions/_database-property-input.ts +211 -0
  13. package/corpus/templates/content/actions/_database-row-mutation.ts +151 -26
  14. package/corpus/templates/content/actions/add-database-item.ts +15 -5
  15. package/corpus/templates/content/actions/update-database-item.ts +15 -7
  16. package/corpus/templates/content/actions/upsert-database-item-by-key.ts +15 -5
  17. package/corpus/templates/content/package.json +1 -0
  18. package/corpus/templates/content/parity/README.md +11 -2
  19. package/corpus/templates/content/parity/eval-scenarios.ts +52 -0
  20. package/corpus/templates/content/parity/run-database-create-property-preservation.ts +140 -0
  21. package/corpus/templates/content/parity/scenario-to-eval.ts +390 -0
  22. package/dist/agent/run-store.js +0 -1
  23. package/dist/client/agent-engine-key.js +26 -4
  24. package/dist/client/settings/ProviderSetupForm.js +1 -1
  25. package/dist/collab/struct-routes.d.ts +1 -1
  26. package/dist/eval/agent-runner.js +23 -0
  27. package/dist/eval/types.d.ts +11 -0
  28. package/dist/observability/routes.d.ts +1 -1
  29. package/dist/resources/handlers.d.ts +1 -1
  30. package/dist/server/agent-engine-api-key-route.d.ts +7 -0
  31. package/dist/server/agent-engine-api-key-route.js +38 -0
  32. package/dist/server/realtime-token.d.ts +1 -1
  33. package/package.json +1 -1
@@ -11,6 +11,8 @@
11
11
  * Route: GET /api/uploads/:recordingId/resume
12
12
  */
13
13
 
14
+ import { randomUUID } from "node:crypto";
15
+
14
16
  import {
15
17
  compareAndSetAppState,
16
18
  readAppState,
@@ -18,7 +20,7 @@ import {
18
20
  } from "@agent-native/core/application-state";
19
21
  import { isFeatureFlagEnabled } from "@agent-native/core/feature-flags";
20
22
  import { runWithRequestContext } from "@agent-native/core/server";
21
- import { and, eq, isNull } from "drizzle-orm";
23
+ import { and, eq, isNull, lte } from "drizzle-orm";
22
24
  import {
23
25
  createError,
24
26
  defineEventHandler,
@@ -40,12 +42,32 @@ import {
40
42
  getEventOwnerContext,
41
43
  ownerEmailMatches,
42
44
  } from "../../../../lib/recordings.js";
43
- import { getResumableSession } from "../../../../lib/resumable-session.js";
44
45
  import {
45
- isRetryableUploadInterruption,
46
- RETRYABLE_UPLOAD_INTERRUPTION_REASON,
47
- } from "../../../../lib/upload-interruption.js";
48
- import { uploadLeaseExpiry } from "../../../../lib/upload-lease.js";
46
+ deleteResumableSession,
47
+ getResumableSession,
48
+ } from "../../../../lib/resumable-session.js";
49
+ import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
50
+ import { isRetryableUploadInterruption } from "../../../../lib/upload-interruption.js";
51
+ import {
52
+ UPLOAD_LEASE_MS,
53
+ uploadLeaseExpiry,
54
+ } from "../../../../lib/upload-lease.js";
55
+
56
+ const RETRY_CLAIM_LIVENESS_MS = 30 * 1000;
57
+ const RETRY_CLAIM_RETRY_AFTER_MIN_MS = 250;
58
+
59
+ function retryClaimRetryAfterMs(
60
+ lastHeartbeatMs: number,
61
+ nowMs: number,
62
+ ): number {
63
+ return Math.max(
64
+ RETRY_CLAIM_RETRY_AFTER_MIN_MS,
65
+ Math.min(
66
+ RETRY_CLAIM_LIVENESS_MS,
67
+ lastHeartbeatMs + RETRY_CLAIM_LIVENESS_MS - nowMs,
68
+ ),
69
+ );
70
+ }
49
71
 
50
72
  export default defineEventHandler(async (event: H3Event) => {
51
73
  setResponseHeader(event, "Cache-Control", "private, max-age=0, no-store");
@@ -85,13 +107,51 @@ export default defineEventHandler(async (event: H3Event) => {
85
107
  orgId,
86
108
  });
87
109
  if (!recoveryEnabled) {
88
- return {
89
- recoveryEnabled: false,
90
- resumable: false,
91
- recordingId,
92
- status: null,
93
- reason: "feature_disabled",
94
- };
110
+ return runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
111
+ const [recording] = await getDb()
112
+ .select({
113
+ status: schema.recordings.status,
114
+ uploadAttemptId: schema.recordings.uploadAttemptId,
115
+ uploadGenerationId: schema.recordings.uploadGenerationId,
116
+ })
117
+ .from(schema.recordings)
118
+ .where(
119
+ and(
120
+ eq(schema.recordings.id, recordingId),
121
+ ownerEmailMatches(schema.recordings.ownerEmail, ownerEmail),
122
+ ),
123
+ );
124
+ if (!recording) {
125
+ setResponseStatus(event, 404);
126
+ return { error: "Recording not found" };
127
+ }
128
+ if (
129
+ recording.uploadAttemptId &&
130
+ recording.uploadAttemptId !== requestedAttemptId
131
+ ) {
132
+ setResponseStatus(event, 409);
133
+ return {
134
+ recoveryEnabled: false,
135
+ resumable: false,
136
+ recordingId,
137
+ status: recording.status,
138
+ reason: "retry_already_active",
139
+ };
140
+ }
141
+ return {
142
+ recoveryEnabled: false,
143
+ resumable: false,
144
+ recordingId,
145
+ status: recording.status ?? null,
146
+ reason: "feature_disabled",
147
+ ...(recording.uploadAttemptId
148
+ ? { attemptId: recording.uploadAttemptId }
149
+ : {}),
150
+ ...(recording.uploadGenerationId
151
+ ? { uploadGenerationId: recording.uploadGenerationId }
152
+ : {}),
153
+ };
154
+ });
95
155
  }
96
156
 
97
157
  return runWithRequestContext({ userEmail: ownerEmail, orgId }, async () => {
@@ -104,6 +164,7 @@ export default defineEventHandler(async (event: H3Event) => {
104
164
  uploadProgress: schema.recordings.uploadProgress,
105
165
  uploadAttemptId: schema.recordings.uploadAttemptId,
106
166
  uploadGenerationId: schema.recordings.uploadGenerationId,
167
+ uploadLeaseExpiresAt: schema.recordings.uploadLeaseExpiresAt,
107
168
  })
108
169
  .from(schema.recordings)
109
170
  .where(
@@ -121,19 +182,38 @@ export default defineEventHandler(async (event: H3Event) => {
121
182
  // Legacy rows keep their null generation and unscoped scratch. A reset
122
183
  // upgrades them by installing a fresh generation before it deletes data.
123
184
  const existingGenerationId = recording.uploadGenerationId ?? null;
124
- const generationId = existingGenerationId;
125
- const session = generationId
185
+ let generationId = existingGenerationId;
186
+ let session = generationId
126
187
  ? await getResumableSession(recordingId, generationId)
127
188
  : await getResumableSession(recordingId);
128
189
  const retryableFailure =
129
190
  recording.status === "failed" &&
130
191
  isRetryableUploadInterruption(recording.failureReason);
131
192
  const existingAttemptId = recording.uploadAttemptId ?? null;
132
- if (
193
+ const nowMs = Date.now();
194
+ const now = new Date(nowMs).toISOString();
195
+ const staleLeaseThreshold = new Date(
196
+ nowMs + UPLOAD_LEASE_MS - RETRY_CLAIM_LIVENESS_MS,
197
+ ).toISOString();
198
+ const claimLeaseExpiryMs = Date.parse(recording.uploadLeaseExpiresAt ?? "");
199
+ const claimHeartbeatMs = claimLeaseExpiryMs - UPLOAD_LEASE_MS;
200
+ const differentRetryClaim =
133
201
  recording.status === "uploading" &&
134
202
  existingAttemptId !== null &&
135
- existingAttemptId !== requestedAttemptId
136
- ) {
203
+ existingAttemptId !== requestedAttemptId;
204
+ if (differentRetryClaim && !Number.isFinite(claimHeartbeatMs)) {
205
+ setResponseStatus(event, 409);
206
+ return {
207
+ resumable: false,
208
+ recoveryEnabled: true,
209
+ recordingId,
210
+ status: "uploading",
211
+ reason: "retry_claim_liveness_unavailable",
212
+ };
213
+ }
214
+ const differentLiveRetryClaim =
215
+ differentRetryClaim && claimHeartbeatMs > nowMs - RETRY_CLAIM_LIVENESS_MS;
216
+ if (differentLiveRetryClaim) {
137
217
  setResponseStatus(event, 409);
138
218
  return {
139
219
  resumable: false,
@@ -141,6 +221,7 @@ export default defineEventHandler(async (event: H3Event) => {
141
221
  recordingId,
142
222
  status: "uploading",
143
223
  reason: "retry_already_active",
224
+ retryAfterMs: retryClaimRetryAfterMs(claimHeartbeatMs, nowMs),
144
225
  };
145
226
  }
146
227
  if (recording.status !== "uploading" && !retryableFailure) {
@@ -158,15 +239,21 @@ export default defineEventHandler(async (event: H3Event) => {
158
239
  const uploadStateRaw = await readAppState(uploadStateKey);
159
240
  const uploadState = uploadStateRaw ?? {};
160
241
  const attemptId = requestedAttemptId;
161
- const now = new Date().toISOString();
242
+ const takingOverStaleRetryClaim = differentRetryClaim;
243
+ const claimedGenerationId = takingOverStaleRetryClaim
244
+ ? randomUUID()
245
+ : generationId;
246
+ const claimedLeaseExpiry = uploadLeaseExpiry(nowMs);
162
247
  const claimed = await getDb()
163
248
  .update(schema.recordings)
164
249
  .set({
165
250
  status: "uploading",
166
251
  failureReason: null,
167
252
  uploadAttemptId: attemptId,
168
- ...(generationId ? { uploadGenerationId: generationId } : {}),
169
- uploadLeaseExpiresAt: uploadLeaseExpiry(),
253
+ ...(claimedGenerationId
254
+ ? { uploadGenerationId: claimedGenerationId }
255
+ : {}),
256
+ uploadLeaseExpiresAt: claimedLeaseExpiry,
170
257
  updatedAt: now,
171
258
  })
172
259
  .where(
@@ -177,10 +264,7 @@ export default defineEventHandler(async (event: H3Event) => {
177
264
  ? eq(schema.recordings.status, "failed")
178
265
  : eq(schema.recordings.status, "uploading"),
179
266
  retryableFailure
180
- ? eq(
181
- schema.recordings.failureReason,
182
- RETRYABLE_UPLOAD_INTERRUPTION_REASON,
183
- )
267
+ ? eq(schema.recordings.failureReason, recording.failureReason!)
184
268
  : undefined,
185
269
  existingAttemptId === null
186
270
  ? isNull(schema.recordings.uploadAttemptId)
@@ -188,6 +272,9 @@ export default defineEventHandler(async (event: H3Event) => {
188
272
  existingGenerationId === null
189
273
  ? isNull(schema.recordings.uploadGenerationId)
190
274
  : eq(schema.recordings.uploadGenerationId, existingGenerationId),
275
+ takingOverStaleRetryClaim
276
+ ? lte(schema.recordings.uploadLeaseExpiresAt, staleLeaseThreshold)
277
+ : undefined,
191
278
  ),
192
279
  )
193
280
  .returning({ id: schema.recordings.id });
@@ -200,9 +287,29 @@ export default defineEventHandler(async (event: H3Event) => {
200
287
  recordingId,
201
288
  status: "uploading",
202
289
  reason: "retry_already_active",
290
+ retryAfterMs: 250,
203
291
  };
204
292
  }
205
293
 
294
+ if (takingOverStaleRetryClaim && session) {
295
+ const invalidated = await abortResumableUploadSession(session, {
296
+ label: `upload-resume-takeover-${recordingId}`,
297
+ });
298
+ if (!invalidated) {
299
+ setResponseStatus(event, 409);
300
+ return {
301
+ resumable: false,
302
+ recoveryEnabled: true,
303
+ recordingId,
304
+ status: "uploading",
305
+ reason: "stale_provider_session_invalidation_failed",
306
+ };
307
+ }
308
+ await deleteResumableSession(recordingId, generationId);
309
+ session = null;
310
+ }
311
+ generationId = claimedGenerationId;
312
+
206
313
  const uploadStateUpdated = await compareAndSetAppState(
207
314
  uploadStateKey,
208
315
  uploadStateRaw,
@@ -0,0 +1,211 @@
1
+ import { ActionContractError } from "@agent-native/core";
2
+ import { z } from "zod";
3
+
4
+ const nullable = <T extends z.ZodTypeAny>(schema: T) =>
5
+ z.union([schema, z.null()]);
6
+
7
+ const propertyIdSchema = z
8
+ .string()
9
+ .min(1)
10
+ .describe("Exact immutable property definition ID");
11
+
12
+ const stringPropertyEntry = (
13
+ propertyType: "text" | "place" | "phone" | "url" | "email",
14
+ valueDescription: string,
15
+ ) =>
16
+ z
17
+ .object({
18
+ propertyId: propertyIdSchema,
19
+ propertyType: z.literal(propertyType),
20
+ value: nullable(z.string()).describe(valueDescription),
21
+ })
22
+ .strict();
23
+
24
+ const optionPropertyEntry = (propertyType: "select" | "status") =>
25
+ z
26
+ .object({
27
+ propertyId: propertyIdSchema,
28
+ propertyType: z.literal(propertyType),
29
+ value: nullable(z.string()).describe(
30
+ "Exact option ID or exact option label from the discovered property contract; null explicitly clears the value",
31
+ ),
32
+ })
33
+ .strict();
34
+
35
+ export const databasePropertyEntrySchema = z.discriminatedUnion(
36
+ "propertyType",
37
+ [
38
+ stringPropertyEntry("text", "Text value; null explicitly clears the value"),
39
+ stringPropertyEntry(
40
+ "place",
41
+ "Place text; null explicitly clears the value",
42
+ ),
43
+ stringPropertyEntry(
44
+ "phone",
45
+ "Phone text; null explicitly clears the value",
46
+ ),
47
+ stringPropertyEntry(
48
+ "url",
49
+ "Absolute http/https URL; null explicitly clears the value",
50
+ ),
51
+ stringPropertyEntry(
52
+ "email",
53
+ "Email address; null explicitly clears the value",
54
+ ),
55
+ z
56
+ .object({
57
+ propertyId: propertyIdSchema,
58
+ propertyType: z.literal("number"),
59
+ value: nullable(z.number().finite()).describe(
60
+ "Finite number; use a JSON number rather than numeric text, or null to explicitly clear",
61
+ ),
62
+ })
63
+ .strict(),
64
+ z
65
+ .object({
66
+ propertyId: propertyIdSchema,
67
+ propertyType: z.literal("checkbox"),
68
+ value: nullable(z.boolean()).describe(
69
+ "Boolean; use true or false rather than text, or null to explicitly clear",
70
+ ),
71
+ })
72
+ .strict(),
73
+ optionPropertyEntry("select"),
74
+ optionPropertyEntry("status"),
75
+ z
76
+ .object({
77
+ propertyId: propertyIdSchema,
78
+ propertyType: z.literal("multi_select"),
79
+ value: nullable(z.array(z.string())).describe(
80
+ "Option IDs or exact option labels from the discovered property contract; null explicitly clears the value",
81
+ ),
82
+ })
83
+ .strict(),
84
+ z
85
+ .object({
86
+ propertyId: propertyIdSchema,
87
+ propertyType: z.literal("date"),
88
+ value: nullable(
89
+ z.union([
90
+ z.string(),
91
+ z
92
+ .object({
93
+ start: z.string(),
94
+ end: z.string().optional(),
95
+ includeTime: z.boolean().optional(),
96
+ })
97
+ .strict(),
98
+ ]),
99
+ ).describe(
100
+ "ISO date/date-time string or { start, end?, includeTime? }; null explicitly clears the value",
101
+ ),
102
+ })
103
+ .strict(),
104
+ z
105
+ .object({
106
+ propertyId: propertyIdSchema,
107
+ propertyType: z.literal("person"),
108
+ value: nullable(z.array(z.string())).describe(
109
+ "Person identifiers from the discovered property contract; null explicitly clears the value",
110
+ ),
111
+ })
112
+ .strict(),
113
+ z
114
+ .object({
115
+ propertyId: propertyIdSchema,
116
+ propertyType: z.literal("files_media"),
117
+ value: nullable(z.array(z.string())).describe(
118
+ "Absolute http/https file URLs; null explicitly clears the value",
119
+ ),
120
+ })
121
+ .strict(),
122
+ ],
123
+ );
124
+
125
+ export type DatabasePropertyEntry = z.infer<typeof databasePropertyEntrySchema>;
126
+
127
+ export const databasePropertyValuesSchema = z
128
+ .record(z.string(), z.unknown())
129
+ .optional()
130
+ .describe(
131
+ "Programmatic property values keyed by exact property definition ID.",
132
+ );
133
+
134
+ export const databasePropertyEntriesSchema = z
135
+ .array(databasePropertyEntrySchema)
136
+ .min(1)
137
+ .max(1_000)
138
+ .optional()
139
+ .describe(
140
+ "Typed property values as explicit entries. Copy each propertyType from the discovered mutation contract and include one entry for every writable property value the user requested, using the exact immutable property definition ID. When at least one value was requested, never pass an empty array. Do not invent or clear unmentioned properties.",
141
+ );
142
+
143
+ export function normalizeDatabasePropertyInput(input: {
144
+ propertyEntries?: DatabasePropertyEntry[];
145
+ propertyValues?: Record<string, unknown>;
146
+ }): {
147
+ propertyValues: Record<string, unknown> | undefined;
148
+ propertyTypeAssertions: Record<string, string> | undefined;
149
+ } {
150
+ if (input.propertyEntries && input.propertyValues) {
151
+ throw new ActionContractError(
152
+ "Provide propertyEntries or propertyValues, not both.",
153
+ { errorCode: "AMBIGUOUS_PROPERTY_INPUT" },
154
+ );
155
+ }
156
+ if (!input.propertyEntries) {
157
+ return {
158
+ propertyValues: input.propertyValues,
159
+ propertyTypeAssertions: undefined,
160
+ };
161
+ }
162
+
163
+ const values: Record<string, unknown> = Object.create(null) as Record<
164
+ string,
165
+ unknown
166
+ >;
167
+ const propertyTypes: Record<string, string> = Object.create(null) as Record<
168
+ string,
169
+ string
170
+ >;
171
+ for (const entry of input.propertyEntries) {
172
+ if (Object.prototype.hasOwnProperty.call(values, entry.propertyId)) {
173
+ throw new ActionContractError(
174
+ `Property entry ${entry.propertyId} was provided more than once.`,
175
+ {
176
+ errorCode: "DUPLICATE_PROPERTY_INPUT",
177
+ details: { propertyId: entry.propertyId },
178
+ },
179
+ );
180
+ }
181
+ values[entry.propertyId] = entry.value;
182
+ propertyTypes[entry.propertyId] = entry.propertyType;
183
+ }
184
+ return {
185
+ propertyValues: values,
186
+ propertyTypeAssertions: propertyTypes,
187
+ };
188
+ }
189
+
190
+ export function canonicalizeDatabasePropertyInput<
191
+ T extends {
192
+ propertyEntries?: DatabasePropertyEntry[];
193
+ propertyValues?: Record<string, unknown>;
194
+ },
195
+ >(
196
+ input: T,
197
+ ): Omit<T, "propertyEntries" | "propertyValues"> & {
198
+ propertyValues?: Record<string, unknown>;
199
+ propertyTypeAssertions?: Record<string, string>;
200
+ } {
201
+ const { propertyEntries, propertyValues, ...canonicalInput } = input;
202
+ const normalized = normalizeDatabasePropertyInput({
203
+ propertyEntries,
204
+ propertyValues,
205
+ });
206
+ return {
207
+ ...canonicalInput,
208
+ propertyValues: normalized.propertyValues,
209
+ propertyTypeAssertions: normalized.propertyTypeAssertions,
210
+ };
211
+ }