@agent-native/core 0.157.1 → 0.157.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/calendar/server/handlers/bookings.ts +6 -1
- package/corpus/templates/clips/actions/request-recording-access.ts +47 -14
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +125 -15
- package/corpus/templates/content/actions/add-database-item.ts +9 -0
- package/corpus/templates/content/actions/describe-content-database.ts +92 -0
- package/corpus/templates/content/actions/list-content-databases.ts +213 -72
- package/corpus/templates/content/actions/set-document-property.ts +9 -0
- package/corpus/templates/content/actions/submit-content-database-form.ts +9 -0
- package/corpus/templates/content/actions/update-document.ts +9 -0
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +7 -0
- package/corpus/templates/content/app/components/editor/database/settings.tsx +7 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +91 -11
- package/corpus/templates/content/parity/matrix.md +1 -1
- package/corpus/templates/content/parity/matrix.ts +7 -1
- package/corpus/templates/content/server/plugins/agent-chat.ts +3 -0
- package/corpus/templates/content/shared/api.ts +9 -0
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/observability/routes.d.ts +5 -5
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/docs/content/cloneable-saas.mdx +24 -26
- package/package.json +1 -1
package/corpus/README.md
CHANGED
|
@@ -483,7 +483,12 @@ async function resolveAvailabilityContext({
|
|
|
483
483
|
draft
|
|
484
484
|
? and(
|
|
485
485
|
eq(schema.bookingLinks.slug, slug),
|
|
486
|
-
accessFilter(
|
|
486
|
+
accessFilter(
|
|
487
|
+
schema.bookingLinks,
|
|
488
|
+
schema.bookingLinkShares,
|
|
489
|
+
undefined,
|
|
490
|
+
"editor",
|
|
491
|
+
),
|
|
487
492
|
)
|
|
488
493
|
: eq(schema.bookingLinks.slug, slug),
|
|
489
494
|
)
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
1
3
|
import { defineAction } from "@agent-native/core";
|
|
2
4
|
import { notify } from "@agent-native/core/notifications";
|
|
3
5
|
import {
|
|
@@ -19,7 +21,7 @@ import { and, eq } from "drizzle-orm";
|
|
|
19
21
|
import { z } from "zod";
|
|
20
22
|
|
|
21
23
|
import { getDb, schema } from "../server/db/index.js";
|
|
22
|
-
import {
|
|
24
|
+
import { normalizeOwnerEmail } from "../server/lib/recordings.js";
|
|
23
25
|
import {
|
|
24
26
|
CLIPS_ACCESS_REQUEST_TOKEN_PREFIX,
|
|
25
27
|
recordingSharePath,
|
|
@@ -27,6 +29,20 @@ import {
|
|
|
27
29
|
|
|
28
30
|
export const CLIPS_ACCESS_REQUEST_EMAIL_ID = "clips.access-request";
|
|
29
31
|
|
|
32
|
+
function accessRequestEventId(
|
|
33
|
+
recordingId: string,
|
|
34
|
+
requesterEmail: string,
|
|
35
|
+
): string {
|
|
36
|
+
return (
|
|
37
|
+
"access-request-" +
|
|
38
|
+
createHash("sha256")
|
|
39
|
+
.update(recordingId)
|
|
40
|
+
.update("\0")
|
|
41
|
+
.update(requesterEmail)
|
|
42
|
+
.digest("hex")
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
30
46
|
function httpError(message: string, statusCode: number): Error {
|
|
31
47
|
return Object.assign(new Error(message), { statusCode });
|
|
32
48
|
}
|
|
@@ -211,19 +227,36 @@ export default defineAction({
|
|
|
211
227
|
getRequestUserName()?.trim() ||
|
|
212
228
|
displayNameForEmail(normalizedRequesterEmail);
|
|
213
229
|
const requestedAt = new Date().toISOString();
|
|
214
|
-
await db
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
230
|
+
const [insertedRequest] = await db
|
|
231
|
+
.insert(schema.recordingEvents)
|
|
232
|
+
.values({
|
|
233
|
+
id: accessRequestEventId(recordingId, normalizedRequesterEmail),
|
|
234
|
+
recordingId,
|
|
235
|
+
viewerId: null,
|
|
236
|
+
kind: "access-request",
|
|
237
|
+
timestampMs: 0,
|
|
238
|
+
payload: JSON.stringify({
|
|
239
|
+
requesterEmail: normalizedRequesterEmail,
|
|
240
|
+
requesterName,
|
|
241
|
+
requestedAt,
|
|
242
|
+
}),
|
|
243
|
+
createdAt: requestedAt,
|
|
244
|
+
})
|
|
245
|
+
.onConflictDoNothing()
|
|
246
|
+
.returning({ id: schema.recordingEvents.id });
|
|
247
|
+
|
|
248
|
+
// Historical requests use random IDs, so the read above still handles
|
|
249
|
+
// them. New requests use a deterministic primary key so concurrent
|
|
250
|
+
// callers have one database-backed winner before notifications are sent.
|
|
251
|
+
if (!insertedRequest) {
|
|
252
|
+
return {
|
|
253
|
+
ok: true as const,
|
|
254
|
+
alreadyHasAccess: false,
|
|
255
|
+
alreadyRequested: true,
|
|
256
|
+
notifiedOwner: false,
|
|
257
|
+
message: "Your access request is already with the clip owner.",
|
|
258
|
+
};
|
|
259
|
+
}
|
|
227
260
|
|
|
228
261
|
const ownerEmail = recording.ownerEmail
|
|
229
262
|
? normalizeOwnerEmail(recording.ownerEmail)
|
|
@@ -57,6 +57,7 @@ import {
|
|
|
57
57
|
deleteResumableSession,
|
|
58
58
|
getResumableSession,
|
|
59
59
|
setResumableSession,
|
|
60
|
+
type StoredResumableSession,
|
|
60
61
|
} from "../../../../lib/resumable-session.js";
|
|
61
62
|
import { abortResumableUploadSession } from "../../../../lib/resumable-upload-cleanup.js";
|
|
62
63
|
import { shouldEnableStreamingUpload } from "../../../../lib/streaming-upload-mode.js";
|
|
@@ -74,6 +75,66 @@ interface CompressionMeta {
|
|
|
74
75
|
outputMimeType?: string;
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
interface PendingResumableCleanup {
|
|
79
|
+
recordingId: string;
|
|
80
|
+
generationId: string | null;
|
|
81
|
+
ownerGenerationId: string | null;
|
|
82
|
+
claimId: string | null;
|
|
83
|
+
session: StoredResumableSession;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function parsePendingResumableCleanup(
|
|
87
|
+
raw: Record<string, unknown> | null,
|
|
88
|
+
recordingId: string,
|
|
89
|
+
): PendingResumableCleanup | null {
|
|
90
|
+
if (!raw) return null;
|
|
91
|
+
const session = raw.session;
|
|
92
|
+
const generationId = raw.generationId;
|
|
93
|
+
const ownerGenerationId = raw.ownerGenerationId;
|
|
94
|
+
const claimId = raw.claimId;
|
|
95
|
+
if (
|
|
96
|
+
raw.recordingId !== recordingId ||
|
|
97
|
+
!session ||
|
|
98
|
+
typeof session !== "object" ||
|
|
99
|
+
Array.isArray(session) ||
|
|
100
|
+
!(generationId === null || typeof generationId === "string") ||
|
|
101
|
+
(ownerGenerationId !== undefined &&
|
|
102
|
+
ownerGenerationId !== null &&
|
|
103
|
+
typeof ownerGenerationId !== "string") ||
|
|
104
|
+
(claimId !== undefined && claimId !== null && typeof claimId !== "string")
|
|
105
|
+
) {
|
|
106
|
+
throw new Error(
|
|
107
|
+
`Invalid resumable cleanup state for recording ${recordingId}`,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const candidate = session as Record<string, unknown>;
|
|
112
|
+
if (
|
|
113
|
+
typeof candidate.providerId !== "string" ||
|
|
114
|
+
typeof candidate.sessionId !== "string" ||
|
|
115
|
+
!candidate.meta ||
|
|
116
|
+
typeof candidate.meta !== "object" ||
|
|
117
|
+
Array.isArray(candidate.meta) ||
|
|
118
|
+
typeof candidate.bytesUploaded !== "number" ||
|
|
119
|
+
!Number.isFinite(candidate.bytesUploaded) ||
|
|
120
|
+
(candidate.lastCommittedIndex !== undefined &&
|
|
121
|
+
typeof candidate.lastCommittedIndex !== "number")
|
|
122
|
+
) {
|
|
123
|
+
throw new Error(
|
|
124
|
+
`Invalid resumable session in cleanup state for recording ${recordingId}`,
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
recordingId,
|
|
130
|
+
generationId,
|
|
131
|
+
ownerGenerationId:
|
|
132
|
+
ownerGenerationId === undefined ? null : ownerGenerationId,
|
|
133
|
+
claimId: claimId === undefined ? null : claimId,
|
|
134
|
+
session: candidate as unknown as StoredResumableSession,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
|
|
77
138
|
function normalizeVideoMimeType(value: unknown): string | null {
|
|
78
139
|
if (typeof value !== "string") return null;
|
|
79
140
|
const mimeType = value.split(";")[0]?.trim().toLowerCase();
|
|
@@ -219,10 +280,25 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
219
280
|
const nextGenerationId = useGenerationFence ? randomUUID() : null;
|
|
220
281
|
const uploadStateKey = `recording-upload-${recordingId}`;
|
|
221
282
|
const uploadStateSnapshot = await readAppState(uploadStateKey);
|
|
222
|
-
const
|
|
283
|
+
const cleanupStateKey = `recording-resumable-cleanup-${recordingId}`;
|
|
284
|
+
const cleanupStateSnapshot = await readAppState(cleanupStateKey);
|
|
285
|
+
const parsedCleanup = parsePendingResumableCleanup(
|
|
286
|
+
cleanupStateSnapshot,
|
|
223
287
|
recordingId,
|
|
224
|
-
existingGenerationId,
|
|
225
288
|
);
|
|
289
|
+
const pendingCleanup =
|
|
290
|
+
parsedCleanup &&
|
|
291
|
+
(parsedCleanup.ownerGenerationId === null
|
|
292
|
+
? !useGenerationFence
|
|
293
|
+
: parsedCleanup.ownerGenerationId === existingGenerationId)
|
|
294
|
+
? parsedCleanup
|
|
295
|
+
: null;
|
|
296
|
+
const discardedGenerationId = pendingCleanup
|
|
297
|
+
? pendingCleanup.generationId
|
|
298
|
+
: existingGenerationId;
|
|
299
|
+
const discardedResumableSession =
|
|
300
|
+
pendingCleanup?.session ??
|
|
301
|
+
(await getResumableSession(recordingId, existingGenerationId));
|
|
226
302
|
const reset = await db
|
|
227
303
|
.update(schema.recordings)
|
|
228
304
|
.set({
|
|
@@ -257,29 +333,63 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
257
333
|
};
|
|
258
334
|
}
|
|
259
335
|
|
|
336
|
+
let cleanupClaim: PendingResumableCleanup | null = null;
|
|
260
337
|
if (discardedResumableSession) {
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
338
|
+
// A cleanup claim belongs to the generation that won this fence. A
|
|
339
|
+
// losing reset must never resurrect a claim after the winner releases
|
|
340
|
+
// it, and a later winner must be able to transfer an unfinished claim.
|
|
341
|
+
const candidate: PendingResumableCleanup = {
|
|
342
|
+
recordingId,
|
|
343
|
+
generationId: discardedGenerationId,
|
|
344
|
+
ownerGenerationId: nextGenerationId,
|
|
345
|
+
claimId: randomUUID(),
|
|
346
|
+
session: discardedResumableSession,
|
|
347
|
+
};
|
|
348
|
+
const claimed = await compareAndSetAppState(
|
|
349
|
+
cleanupStateKey,
|
|
350
|
+
cleanupStateSnapshot,
|
|
351
|
+
candidate as unknown as Record<string, unknown>,
|
|
264
352
|
);
|
|
265
|
-
if (
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
353
|
+
if (claimed) cleanupClaim = candidate;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
if (discardedResumableSession) {
|
|
357
|
+
if (cleanupClaim) {
|
|
358
|
+
const cleaned = await abortResumableUploadSession(
|
|
359
|
+
discardedResumableSession,
|
|
360
|
+
{ label: `reset-${recordingId}` },
|
|
361
|
+
);
|
|
362
|
+
if (!cleaned) {
|
|
363
|
+
setResponseStatus(event, 502);
|
|
364
|
+
return {
|
|
365
|
+
error:
|
|
366
|
+
"The previous recording upload could not be cleaned up. Retry the upload restart.",
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
const released = await compareAndSetAppState(
|
|
370
|
+
cleanupStateKey,
|
|
371
|
+
cleanupClaim as unknown as Record<string, unknown>,
|
|
372
|
+
null,
|
|
373
|
+
);
|
|
374
|
+
if (!released) {
|
|
375
|
+
console.warn(
|
|
376
|
+
`[reset-chunks-${recordingId}] cleanup claim changed while releasing it`,
|
|
377
|
+
);
|
|
378
|
+
}
|
|
271
379
|
}
|
|
272
380
|
}
|
|
273
381
|
const cleared = await deleteRecordingChunks(
|
|
274
382
|
ownerEmail,
|
|
275
383
|
recordingId,
|
|
276
|
-
|
|
384
|
+
discardedGenerationId,
|
|
277
385
|
);
|
|
278
386
|
// Clear any stale resumable session so a buffered retry does not
|
|
279
387
|
// accidentally route through handleResumableChunk with stale offsets.
|
|
280
|
-
|
|
281
|
-
()
|
|
282
|
-
|
|
388
|
+
if (!discardedResumableSession || cleanupClaim) {
|
|
389
|
+
await deleteResumableSession(recordingId, discardedGenerationId).catch(
|
|
390
|
+
() => {},
|
|
391
|
+
);
|
|
392
|
+
}
|
|
283
393
|
|
|
284
394
|
let uploadMode: UploadMode = "buffered";
|
|
285
395
|
let compensateStartedSession: (() => Promise<void>) | null = null;
|
|
@@ -26,6 +26,15 @@ const schema = databaseMutationEnvelopeSchema.extend({
|
|
|
26
26
|
export default defineAction({
|
|
27
27
|
description:
|
|
28
28
|
"Create one row in an exact ordinary Content database using its discovered schema revision. Strictly validates every non-Blocks property, applies the side effect once per idempotency key, and returns a verified receipt with stable row identity.",
|
|
29
|
+
publicAgent: {
|
|
30
|
+
expose: true,
|
|
31
|
+
readOnly: false,
|
|
32
|
+
requiresAuth: true,
|
|
33
|
+
isConsequential: true,
|
|
34
|
+
title: "Add Content Database Item",
|
|
35
|
+
description:
|
|
36
|
+
"Delegate creation of one page item in an existing Content database.",
|
|
37
|
+
},
|
|
29
38
|
schema,
|
|
30
39
|
audit: {
|
|
31
40
|
recordInputs: false,
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { defineAction } from "@agent-native/core";
|
|
2
|
+
import { accessFilter } from "@agent-native/core/sharing";
|
|
3
|
+
import { and, eq } from "drizzle-orm";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
|
|
6
|
+
import { getDb, schema } from "../server/db/index.js";
|
|
7
|
+
import { getDocumentContextPath } from "../server/lib/document-context.js";
|
|
8
|
+
import type {
|
|
9
|
+
ContentDatabaseDescriptionResponse,
|
|
10
|
+
ContentDatabaseUnavailableResponse,
|
|
11
|
+
} from "../shared/api.js";
|
|
12
|
+
import { resolveContentDatabaseRead } from "./_database-utils.js";
|
|
13
|
+
import {
|
|
14
|
+
listPropertiesForDatabase,
|
|
15
|
+
serializeDatabase,
|
|
16
|
+
} from "./_property-utils.js";
|
|
17
|
+
import listContentDatabases, {
|
|
18
|
+
ContentDatabaseResolutionError,
|
|
19
|
+
} from "./list-content-databases.js";
|
|
20
|
+
|
|
21
|
+
export default defineAction({
|
|
22
|
+
description:
|
|
23
|
+
"Describe one exact ordinary Content database, including its live metadata, views, and property schema but not its rows. Resolve the stable database or document ID with list-content-databases first.",
|
|
24
|
+
schema: z
|
|
25
|
+
.object({
|
|
26
|
+
databaseId: z.string().min(1).optional().describe("Exact database ID"),
|
|
27
|
+
documentId: z
|
|
28
|
+
.string()
|
|
29
|
+
.min(1)
|
|
30
|
+
.optional()
|
|
31
|
+
.describe("Exact database document/page ID"),
|
|
32
|
+
})
|
|
33
|
+
.refine(
|
|
34
|
+
(input) => Boolean(input.databaseId) !== Boolean(input.documentId),
|
|
35
|
+
"Provide exactly one of databaseId or documentId.",
|
|
36
|
+
),
|
|
37
|
+
http: { method: "GET" },
|
|
38
|
+
readOnly: true,
|
|
39
|
+
publicAgent: { expose: true, readOnly: true, requiresAuth: true },
|
|
40
|
+
run: async ({
|
|
41
|
+
databaseId,
|
|
42
|
+
documentId,
|
|
43
|
+
}): Promise<
|
|
44
|
+
ContentDatabaseDescriptionResponse | ContentDatabaseUnavailableResponse
|
|
45
|
+
> => {
|
|
46
|
+
let selection: Awaited<ReturnType<typeof listContentDatabases.run>>;
|
|
47
|
+
try {
|
|
48
|
+
selection = await listContentDatabases.run({ databaseId, documentId });
|
|
49
|
+
} catch (error) {
|
|
50
|
+
if (!(error instanceof ContentDatabaseResolutionError)) throw error;
|
|
51
|
+
throw new Error("Content database not found.");
|
|
52
|
+
}
|
|
53
|
+
const selected = selection.databases[0];
|
|
54
|
+
if (!selected) throw new Error("Content database not found.");
|
|
55
|
+
|
|
56
|
+
const resolved = await resolveContentDatabaseRead({
|
|
57
|
+
databaseId: selected.databaseId,
|
|
58
|
+
});
|
|
59
|
+
if (!resolved.available) return resolved;
|
|
60
|
+
if (resolved.database.systemRole) {
|
|
61
|
+
throw new Error("Content database not found.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const db = getDb();
|
|
65
|
+
const [databaseDocument] = await db
|
|
66
|
+
.select({
|
|
67
|
+
id: schema.documents.id,
|
|
68
|
+
parentId: schema.documents.parentId,
|
|
69
|
+
})
|
|
70
|
+
.from(schema.documents)
|
|
71
|
+
.where(
|
|
72
|
+
and(
|
|
73
|
+
eq(schema.documents.id, selected.documentId),
|
|
74
|
+
accessFilter(schema.documents, schema.documentShares),
|
|
75
|
+
),
|
|
76
|
+
);
|
|
77
|
+
if (!databaseDocument) throw new Error("Content database not found.");
|
|
78
|
+
|
|
79
|
+
const [properties, contextPath] = await Promise.all([
|
|
80
|
+
listPropertiesForDatabase(resolved.database.id),
|
|
81
|
+
getDocumentContextPath(databaseDocument),
|
|
82
|
+
]);
|
|
83
|
+
return {
|
|
84
|
+
database: serializeDatabase(
|
|
85
|
+
{ ...resolved.database, title: selected.title },
|
|
86
|
+
selected.description,
|
|
87
|
+
),
|
|
88
|
+
contextPath,
|
|
89
|
+
properties,
|
|
90
|
+
};
|
|
91
|
+
},
|
|
92
|
+
});
|