@agent-native/core 0.81.3 → 0.83.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/core/CHANGELOG.md +12 -0
- package/corpus/core/package.json +1 -1
- package/corpus/core/src/client/CommandMenu.tsx +34 -12
- package/corpus/core/src/file-upload/builder.ts +199 -37
- package/corpus/core/src/file-upload/index.ts +2 -0
- package/corpus/core/src/file-upload/types.ts +38 -0
- package/corpus/templates/analytics/actions/hubspot-deals.ts +64 -4
- package/corpus/templates/clips/actions/create-recording.ts +44 -0
- package/corpus/templates/clips/actions/finalize-recording.ts +198 -88
- package/corpus/templates/clips/actions/lib/create-recording-schema.ts +12 -0
- package/corpus/templates/clips/app/components/recorder/recorder-engine.ts +83 -29
- package/corpus/templates/clips/app/routes/record.tsx +12 -1
- package/corpus/templates/clips/server/lib/resumable-session.ts +39 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/abort.post.ts +2 -0
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/chunk.post.ts +186 -29
- package/corpus/templates/clips/server/routes/api/uploads/[recordingId]/reset-chunks.post.ts +4 -0
- package/corpus/templates/clips/shared/recording-core.ts +5 -0
- package/corpus/templates/content/.env.local.example +6 -0
- package/corpus/templates/content/AGENTS.md +10 -1
- package/corpus/templates/content/actions/_builder-cms-read-client.ts +14 -1
- package/corpus/templates/content/actions/_builder-cms-source-adapter.ts +43 -0
- package/corpus/templates/content/actions/_builder-cms-write-adapter.ts +79 -7
- package/corpus/templates/content/actions/_database-source-utils.ts +987 -22
- package/corpus/templates/content/actions/_database-utils.ts +41 -0
- package/corpus/templates/content/actions/_local-file-documents.ts +74 -1
- package/corpus/templates/content/actions/attach-content-database-source.ts +33 -8
- package/corpus/templates/content/actions/change-content-database-source-role.ts +14 -0
- package/corpus/templates/content/actions/disconnect-content-database-source.ts +3 -0
- package/corpus/templates/content/actions/execute-builder-source-execution.ts +71 -3
- package/corpus/templates/content/actions/list-content-databases.ts +41 -33
- package/corpus/templates/content/actions/process-builder-body-hydration.ts +47 -0
- package/corpus/templates/content/actions/update-document.ts +93 -0
- package/corpus/templates/content/app/blocks/SourceComponentBlock.tsx +277 -0
- package/corpus/templates/content/app/blocks/contentBlockRegistry.tsx +2 -0
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +105 -17
- package/corpus/templates/content/app/components/editor/VisualEditor.tsx +5 -1
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +92 -0
- package/corpus/templates/content/app/components/editor/database-sources/BuilderSourceReviewDialog.tsx +5 -3
- package/corpus/templates/content/app/components/editor/extensions/registryBlocks.ts +41 -0
- package/corpus/templates/content/app/components/editor/registrySlashItems.ts +4 -0
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +28 -33
- package/corpus/templates/content/app/components/sidebar/document-sidebar-sections.ts +47 -0
- package/corpus/templates/content/app/global.css +10 -3
- package/corpus/templates/content/app/hooks/use-content-database.ts +24 -0
- package/corpus/templates/content/app/hooks/use-documents.ts +37 -0
- package/corpus/templates/content/app/i18n/zh-TW.ts +24 -0
- package/corpus/templates/content/app/i18n-data.ts +212 -0
- package/corpus/templates/content/app/lib/content-command-search.ts +64 -0
- package/corpus/templates/content/app/root.tsx +236 -8
- package/corpus/templates/content/changelog/2026-06-29-builder-cms-sources-now-sync-article-bodies-through-content.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-article-media-now-render-as-images-and-embeds-when-r.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-builder-source-components-now-render-as-preserved-preview-bl.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-cmd-k-search-now-opens-from-the-editor-and-finds-real-content.md +6 -0
- package/corpus/templates/content/changelog/2026-06-30-sidebar-favorites-now-keep-long-titles-tidy-and-stay-in-sync.md +6 -0
- package/corpus/templates/content/package.json +3 -1
- package/corpus/templates/content/scripts/check-native-deps.mjs +57 -0
- package/corpus/templates/content/scripts/dev-database.mjs +83 -0
- package/corpus/templates/content/scripts/seed-demo-user.mjs +107 -0
- package/corpus/templates/content/server/db/schema.ts +27 -0
- package/corpus/templates/content/server/plugins/db.ts +33 -0
- package/corpus/templates/content/shared/api.ts +45 -0
- package/corpus/templates/content/shared/builder-mdx.ts +905 -5
- package/corpus/templates/content/shared/nfm-registry.ts +2 -0
- package/corpus/templates/content/shared/source-component-block.ts +141 -0
- package/corpus/templates/design/app/components/design/DesignCanvas.tsx +5 -1
- package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +229 -131
- package/corpus/templates/design/app/components/design/bridge/editor-chrome.bridge.ts +103 -6
- package/corpus/templates/design/app/hooks/useDesignHotkeys.ts +6 -3
- package/corpus/templates/design/app/pages/DesignEditor.tsx +382 -72
- package/corpus/templates/design/changelog/2026-06-30-canvas-editing-now-keeps-undo-redo-and-cross-screen-layer-mo.md +6 -0
- package/dist/client/CommandMenu.d.ts +6 -2
- package/dist/client/CommandMenu.d.ts.map +1 -1
- package/dist/client/CommandMenu.js +25 -14
- package/dist/client/CommandMenu.js.map +1 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/awareness.d.ts.map +1 -1
- package/dist/collab/routes.d.ts +1 -1
- package/dist/file-upload/builder.d.ts.map +1 -1
- package/dist/file-upload/builder.js +137 -25
- package/dist/file-upload/builder.js.map +1 -1
- package/dist/file-upload/index.d.ts +1 -1
- package/dist/file-upload/index.d.ts.map +1 -1
- package/dist/file-upload/index.js.map +1 -1
- package/dist/file-upload/types.d.ts +26 -0
- package/dist/file-upload/types.d.ts.map +1 -1
- package/dist/file-upload/types.js.map +1 -1
- package/dist/notifications/routes.d.ts +3 -3
- package/dist/progress/routes.d.ts +1 -1
- package/dist/resources/handlers.d.ts +3 -3
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/package.json +1 -1
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
readAppState,
|
|
18
18
|
writeAppState,
|
|
19
19
|
} from "@agent-native/core/application-state";
|
|
20
|
+
import { getActiveFileUploadProvider } from "@agent-native/core/file-upload";
|
|
20
21
|
import { runWithRequestContext } from "@agent-native/core/server";
|
|
21
22
|
import { normalizeChunkUploadNumber } from "@shared/recording-core.js";
|
|
22
23
|
import { MAX_UPLOAD_BYTES as MAX_RECORDING_UPLOAD_BYTES } from "@shared/upload-limits.js";
|
|
@@ -40,12 +41,23 @@ import {
|
|
|
40
41
|
getEventOwnerContext,
|
|
41
42
|
ownerEmailMatches,
|
|
42
43
|
} from "../../../../lib/recordings.js";
|
|
44
|
+
import {
|
|
45
|
+
getResumableSession,
|
|
46
|
+
setResumableSession,
|
|
47
|
+
type StoredResumableSession,
|
|
48
|
+
} from "../../../../lib/resumable-session.js";
|
|
43
49
|
import {
|
|
44
50
|
shouldRejectVideoUploadWithoutStorage,
|
|
45
51
|
STORAGE_SETUP_REQUIRED_REASON,
|
|
46
52
|
} from "../../../../lib/video-storage.js";
|
|
53
|
+
|
|
47
54
|
const RECORDING_TOO_LARGE_REASON = `Recording exceeds the ${Math.round(MAX_RECORDING_UPLOAD_BYTES / (1024 * 1024))} MB size limit. Please record a shorter clip.`;
|
|
48
55
|
|
|
56
|
+
// Netlify functions have a 6 MB buffered request cap, but binary requests
|
|
57
|
+
// are base64 encoded by the gateway and effectively cap out around 4.5 MB.
|
|
58
|
+
// Keep our own cap lower so dev/local failures match production.
|
|
59
|
+
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
60
|
+
|
|
49
61
|
const ALLOWED_RECORDING_MIME_TYPES = new Set([
|
|
50
62
|
"video/webm",
|
|
51
63
|
"video/mp4",
|
|
@@ -115,10 +127,6 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
115
127
|
throw createError({ statusCode: 400, message: "Invalid chunk index" });
|
|
116
128
|
}
|
|
117
129
|
|
|
118
|
-
// Netlify functions have a 6 MB buffered request cap, but binary requests
|
|
119
|
-
// are base64 encoded by the gateway and effectively cap out around 4.5 MB.
|
|
120
|
-
// Keep our own cap lower so dev/local failures match production.
|
|
121
|
-
const MAX_CHUNK_BYTES = 4 * 1024 * 1024;
|
|
122
130
|
const contentLength = Number(getHeader(event, "content-length") || 0);
|
|
123
131
|
if (contentLength > MAX_CHUNK_BYTES) {
|
|
124
132
|
setResponseStatus(event, 413);
|
|
@@ -164,6 +172,20 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
164
172
|
throw createError({ statusCode: 404, message: "Recording not found" });
|
|
165
173
|
}
|
|
166
174
|
|
|
175
|
+
// Resumable streaming path — forward chunks directly to the provider.
|
|
176
|
+
const resumableSession = await getResumableSession(recordingId);
|
|
177
|
+
if (resumableSession) {
|
|
178
|
+
return handleResumableChunk(
|
|
179
|
+
event,
|
|
180
|
+
resumableSession,
|
|
181
|
+
recordingId,
|
|
182
|
+
index,
|
|
183
|
+
isFinal,
|
|
184
|
+
mimeType,
|
|
185
|
+
query,
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
167
189
|
const failedUploadResponse = (reason: string, bytes?: number) => {
|
|
168
190
|
setResponseStatus(
|
|
169
191
|
event,
|
|
@@ -183,6 +205,13 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
183
205
|
);
|
|
184
206
|
}
|
|
185
207
|
|
|
208
|
+
// Already finalized — retried final chunk after session was deleted. Skip
|
|
209
|
+
// buffered path writes so recording-upload-* state stays correct.
|
|
210
|
+
if (existing.status === "ready") {
|
|
211
|
+
return { ok: true, finalized: true };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Store chunks in application_state, assemble on finalize.
|
|
186
215
|
if (await shouldRejectVideoUploadWithoutStorage()) {
|
|
187
216
|
const now = new Date().toISOString();
|
|
188
217
|
await db
|
|
@@ -362,10 +391,7 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
362
391
|
|
|
363
392
|
await db
|
|
364
393
|
.update(schema.recordings)
|
|
365
|
-
.set({
|
|
366
|
-
uploadProgress: progress,
|
|
367
|
-
updatedAt: new Date().toISOString(),
|
|
368
|
-
})
|
|
394
|
+
.set({ uploadProgress: progress, updatedAt: new Date().toISOString() })
|
|
369
395
|
.where(
|
|
370
396
|
and(
|
|
371
397
|
eq(schema.recordings.id, recordingId),
|
|
@@ -399,21 +425,9 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
399
425
|
if (failedResponse) return failedResponse;
|
|
400
426
|
debugLog("[chunk] isFinal — invoking finalize", { recordingId });
|
|
401
427
|
try {
|
|
402
|
-
const result = await finalizeRecording.run(
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
width: normalizeChunkUploadNumber(query.width),
|
|
406
|
-
height: normalizeChunkUploadNumber(query.height),
|
|
407
|
-
hasAudio:
|
|
408
|
-
query.hasAudio === undefined
|
|
409
|
-
? undefined
|
|
410
|
-
: query.hasAudio === "1" || query.hasAudio === "true",
|
|
411
|
-
hasCamera:
|
|
412
|
-
query.hasCamera === undefined
|
|
413
|
-
? undefined
|
|
414
|
-
: query.hasCamera === "1" || query.hasCamera === "true",
|
|
415
|
-
mimeType,
|
|
416
|
-
});
|
|
428
|
+
const result = await finalizeRecording.run(
|
|
429
|
+
buildFinalizeArgs(recordingId, mimeType, query),
|
|
430
|
+
);
|
|
417
431
|
debugLog("[chunk] finalize ok", {
|
|
418
432
|
recordingId,
|
|
419
433
|
videoUrl: (result as any)?.videoUrl,
|
|
@@ -510,11 +524,154 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
510
524
|
}
|
|
511
525
|
}
|
|
512
526
|
|
|
513
|
-
return {
|
|
514
|
-
ok: true,
|
|
515
|
-
finalized: false,
|
|
516
|
-
index,
|
|
517
|
-
bytes: bytes.byteLength,
|
|
518
|
-
};
|
|
527
|
+
return { ok: true, finalized: false, index, bytes: bytes.byteLength };
|
|
519
528
|
});
|
|
520
529
|
});
|
|
530
|
+
|
|
531
|
+
function buildFinalizeArgs(
|
|
532
|
+
recordingId: string,
|
|
533
|
+
mimeType: string,
|
|
534
|
+
query: Record<string, unknown>,
|
|
535
|
+
) {
|
|
536
|
+
return {
|
|
537
|
+
id: recordingId,
|
|
538
|
+
durationMs: normalizeChunkUploadNumber(query.durationMs),
|
|
539
|
+
width: normalizeChunkUploadNumber(query.width),
|
|
540
|
+
height: normalizeChunkUploadNumber(query.height),
|
|
541
|
+
hasAudio:
|
|
542
|
+
query.hasAudio === undefined
|
|
543
|
+
? undefined
|
|
544
|
+
: query.hasAudio === "1" || query.hasAudio === "true",
|
|
545
|
+
hasCamera:
|
|
546
|
+
query.hasCamera === undefined
|
|
547
|
+
? undefined
|
|
548
|
+
: query.hasCamera === "1" || query.hasCamera === "true",
|
|
549
|
+
mimeType,
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Resumable streaming path: each chunk is forwarded directly to the upload
|
|
554
|
+
// provider. Always returns a response — never falls through to the buffered path.
|
|
555
|
+
async function handleResumableChunk(
|
|
556
|
+
event: H3Event,
|
|
557
|
+
session: StoredResumableSession,
|
|
558
|
+
recordingId: string,
|
|
559
|
+
index: number,
|
|
560
|
+
isFinal: boolean,
|
|
561
|
+
mimeType: string,
|
|
562
|
+
query: Record<string, unknown>,
|
|
563
|
+
) {
|
|
564
|
+
const uploadProvider = getActiveFileUploadProvider();
|
|
565
|
+
console.log(
|
|
566
|
+
`[resumable-chunk-${recordingId}] resumable session exists - bytesUploaded=${session.bytesUploaded} index=${index} isFinal=${isFinal}`,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
const raw = await readRawBody(event, false);
|
|
570
|
+
const bytes: Uint8Array = raw ?? new Uint8Array(0);
|
|
571
|
+
|
|
572
|
+
if (!isFinal && bytes.byteLength === 0) {
|
|
573
|
+
throw createError({ statusCode: 400, message: "Empty chunk body" });
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
if (isFinal && bytes.byteLength === 0) {
|
|
577
|
+
// 0-byte sentinel from the recorder after stop(). All data chunks have
|
|
578
|
+
// already been PUT to the provider; send Content-Range: bytes */<total>
|
|
579
|
+
// to close the session before handing off to finalize-recording.
|
|
580
|
+
const closeRes = await uploadProvider!.resumable!.relayChunk(
|
|
581
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
582
|
+
`bytes */${session.bytesUploaded}`,
|
|
583
|
+
new Uint8Array(0),
|
|
584
|
+
);
|
|
585
|
+
if (!closeRes.ok) {
|
|
586
|
+
console.error(
|
|
587
|
+
`[resumable-chunk-${recordingId}] session close failed (${closeRes.status})`,
|
|
588
|
+
);
|
|
589
|
+
setResponseStatus(event, 502);
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
error: `Resumable session close failed (${closeRes.status})`,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
} else {
|
|
596
|
+
// Idempotent replay guard: a client retry (after a lost response) can
|
|
597
|
+
// re-send a chunk we already committed. Re-PUTing it at the new offset
|
|
598
|
+
// would corrupt the file — detect the duplicate by index and skip the PUT.
|
|
599
|
+
// Chunks are strictly sequential, so any index <= last committed is a replay.
|
|
600
|
+
// A replayed non-final is acked here; a replayed final falls through to
|
|
601
|
+
// finalize, which is idempotent.
|
|
602
|
+
const isReplay = index <= (session.lastCommittedIndex ?? -1);
|
|
603
|
+
if (isReplay) {
|
|
604
|
+
console.warn(
|
|
605
|
+
`[resumable-chunk-${recordingId}] duplicate chunk ${index}, acking without re-upload`,
|
|
606
|
+
);
|
|
607
|
+
if (!isFinal) {
|
|
608
|
+
return {
|
|
609
|
+
ok: true,
|
|
610
|
+
finalized: false,
|
|
611
|
+
index,
|
|
612
|
+
bytes: bytes.byteLength,
|
|
613
|
+
duplicate: true,
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
} else {
|
|
617
|
+
// Forward the data chunk to the provider and advance offsets only after
|
|
618
|
+
// the provider confirms receipt (308 Resume Incomplete for non-final, 2xx for final).
|
|
619
|
+
const start = session.bytesUploaded;
|
|
620
|
+
const end = start + bytes.byteLength - 1;
|
|
621
|
+
const contentRange = isFinal
|
|
622
|
+
? `bytes ${start}-${end}/${start + bytes.byteLength}`
|
|
623
|
+
: `bytes ${start}-${end}/*`;
|
|
624
|
+
|
|
625
|
+
const putT0 = Date.now();
|
|
626
|
+
const putResult = await uploadProvider!.resumable!.relayChunk(
|
|
627
|
+
{ sessionId: session.sessionId, meta: session.meta },
|
|
628
|
+
contentRange,
|
|
629
|
+
bytes,
|
|
630
|
+
{ mimeType: mimeType.split(";")[0].trim() },
|
|
631
|
+
);
|
|
632
|
+
console.log(
|
|
633
|
+
`[resumable-chunk-${recordingId}] PUT ${Date.now() - putT0}ms status=${putResult.status} range="${contentRange}"`,
|
|
634
|
+
);
|
|
635
|
+
|
|
636
|
+
const resultOk = isFinal
|
|
637
|
+
? putResult.ok && putResult.status !== 308
|
|
638
|
+
: putResult.ok;
|
|
639
|
+
if (!resultOk) {
|
|
640
|
+
setResponseStatus(event, 502);
|
|
641
|
+
return {
|
|
642
|
+
ok: false,
|
|
643
|
+
error: `Chunk upload failed (${putResult.status})`,
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
await setResumableSession(recordingId, {
|
|
648
|
+
...session,
|
|
649
|
+
...(putResult.updatedMeta
|
|
650
|
+
? { meta: { ...session.meta, ...putResult.updatedMeta } }
|
|
651
|
+
: {}),
|
|
652
|
+
bytesUploaded: start + bytes.byteLength,
|
|
653
|
+
lastCommittedIndex: index,
|
|
654
|
+
});
|
|
655
|
+
|
|
656
|
+
if (!isFinal) {
|
|
657
|
+
return { ok: true, finalized: false, index, bytes: bytes.byteLength };
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// isFinal — delegate to finalize-recording, which reads the resumable
|
|
663
|
+
// session and calls provider.resumable.completeSession.
|
|
664
|
+
try {
|
|
665
|
+
const result = await finalizeRecording.run(
|
|
666
|
+
buildFinalizeArgs(recordingId, mimeType, query),
|
|
667
|
+
);
|
|
668
|
+
return { ok: true, finalized: true, ...result };
|
|
669
|
+
} catch (err) {
|
|
670
|
+
console.error(`[resumable-chunk-${recordingId}] finalize failed:`, err);
|
|
671
|
+
setResponseStatus(event, 500);
|
|
672
|
+
return {
|
|
673
|
+
ok: false,
|
|
674
|
+
error: err instanceof Error ? err.message : "Finalize failed",
|
|
675
|
+
};
|
|
676
|
+
}
|
|
677
|
+
}
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
getEventOwnerContext,
|
|
45
45
|
ownerEmailMatches,
|
|
46
46
|
} from "../../../../lib/recordings.js";
|
|
47
|
+
import { deleteResumableSession } from "../../../../lib/resumable-session.js";
|
|
47
48
|
|
|
48
49
|
interface CompressionMeta {
|
|
49
50
|
originalBytes?: number;
|
|
@@ -112,6 +113,9 @@ export default defineEventHandler(async (event: H3Event) => {
|
|
|
112
113
|
const cleared = await deleteAppStateByPrefix(
|
|
113
114
|
`recording-chunks-${recordingId}-`,
|
|
114
115
|
);
|
|
116
|
+
// Clear any stale resumable session so a buffered retry does not
|
|
117
|
+
// accidentally route through handleResumableChunk with stale offsets.
|
|
118
|
+
await deleteResumableSession(recordingId).catch(() => {});
|
|
115
119
|
|
|
116
120
|
// Reset the per-recording upload progress so the UI poller sees the
|
|
117
121
|
// re-upload restart from 0 and doesn't briefly show "100% then
|
|
@@ -46,6 +46,11 @@ export function pickMimeType(): string {
|
|
|
46
46
|
return "";
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/** How the client delivers recorded data to the server.
|
|
50
|
+
* - `"streaming"` — chunks are flushed to GCS during recording via a resumable session
|
|
51
|
+
* - `"buffered"` — full blob assembled after stop() and uploaded in slices */
|
|
52
|
+
export type UploadMode = "streaming" | "buffered";
|
|
53
|
+
|
|
49
54
|
/** Query params understood by the chunk-upload route
|
|
50
55
|
* (`/api/uploads/:id/chunk`). This is the on-the-wire contract — the route in
|
|
51
56
|
* `server/routes/api/uploads/[recordingId]/chunk.post.ts` reads exactly these. */
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Local database-mode development for Builder/source testing.
|
|
2
|
+
# Copy to .env.local or run `pnpm dev:database` to generate one.
|
|
3
|
+
# These values are local-only. Do not use them for production deploys.
|
|
4
|
+
|
|
5
|
+
AGENT_NATIVE_MODE=database
|
|
6
|
+
BETTER_AUTH_SECRET=<generated-local-development-secret>
|
|
@@ -113,10 +113,11 @@ cd templates/content && pnpm action <name> [args]
|
|
|
113
113
|
| `attach-content-database-source` | `--databaseId <id>` or `--documentId <id> [--sourceType mock\|builder-cms] [--sourceName] [--sourceTable] [--relationshipMode items\|details] [--join <json>]` | Attach a source binding; use `items` to add more rows and `details` to match a source onto existing rows |
|
|
114
114
|
| `change-content-database-source-role` | `--databaseId <id>` or `--documentId <id> --sourceId <id> --relationshipMode items\|details [--join <json>]` | Change an attached source between adding rows and adding matched detail columns without removing the source |
|
|
115
115
|
| `refresh-content-database-source` | `--databaseId <id>` or `--documentId <id>` | Refresh the read-only source status envelope; Builder CMS reads live entries only when configured |
|
|
116
|
+
| `process-builder-body-hydration` | `--sourceId <id> [--documentId <id>] [--limit <n>]` | Hydrate queued Builder article bodies into readable Content markdown with preserved source-component markers |
|
|
116
117
|
| `set-content-database-source-write-mode` | `--databaseId <id>` or `--documentId <id> --liveWritesEnabled true\|false [--allowedWriteModes <json>]` | Enable/disable per-source Builder live writes; enabling is allowed only for `agent-native-blog-article-test` with explicit modes |
|
|
117
118
|
| `stage-builder-revision` | `--databaseId <id>` or `--documentId <id>` | Stage pending local Builder CMS changes as a local-only save-revision record; never calls Builder |
|
|
118
119
|
| `review-content-database-source-change-set` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> --decision approve\|reject [--note]` | Approve or reject a local source change-set review record without provider writes |
|
|
119
|
-
| `prepare-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate with request semantics/idempotency key; never calls Builder
|
|
120
|
+
| `prepare-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--pushModeConfirmation autosave\|draft\|publish]` | Prepare a dry-run Builder execution gate for approved field/body changes with request semantics/idempotency key; never calls Builder |
|
|
120
121
|
| `validate-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>]` | Validate/replay a prepared Builder execution gate locally as a dry run; never calls Builder |
|
|
121
122
|
| `execute-builder-source-execution` | `--databaseId <id>` or `--documentId <id> --changeSetId <id> [--idempotencyKey <key>] [--pushModeConfirmation autosave\|draft\|publish]` | Execute a guarded live Builder write only when approved, validated, enabled, idempotent, and targeting `agent-native-blog-article-test` |
|
|
122
123
|
| `add-database-item` | `--databaseId <id> [--title <text>] [--propertyValues <json>]` | Add a page row to a database, optionally seeding property values |
|
|
@@ -208,6 +209,14 @@ Content has two file workflows:
|
|
|
208
209
|
Edit reusable symbol content in that emitted source file; do not retarget
|
|
209
210
|
`entry`, `model`, or `source` in the parent MDX unless a dedicated Builder
|
|
210
211
|
retargeting workflow is added.
|
|
212
|
+
- **Builder source components:** Builder CMS database body hydration renders
|
|
213
|
+
unsupported provider-native body blocks as `<SourceComponent ... />` markers.
|
|
214
|
+
Treat these as read-only preservation anchors, not editable local blocks.
|
|
215
|
+
Agents may edit surrounding prose, but must not delete, duplicate, move, or
|
|
216
|
+
rewrite `rawRef`, `rawHash`, or marker ids unless a dedicated provider
|
|
217
|
+
conversion workflow exists. Guarded Builder write-back refuses missing,
|
|
218
|
+
tampered, or structurally moved markers so source-native components are not
|
|
219
|
+
lost.
|
|
211
220
|
- **Picked folders and components:** browser-picked folders can be the
|
|
212
221
|
source of truth for `.md`/`.mdx` files, but the browser does not expose an
|
|
213
222
|
absolute path that Vite can compile. Component previews from a picked
|
|
@@ -5,6 +5,11 @@ import type {
|
|
|
5
5
|
BuilderCmsModelSummary,
|
|
6
6
|
BuilderCmsModelsResponse,
|
|
7
7
|
} from "../shared/api.js";
|
|
8
|
+
import {
|
|
9
|
+
builderBlocksHash,
|
|
10
|
+
builderEntryBlocks,
|
|
11
|
+
type BuilderContentEntry,
|
|
12
|
+
} from "../shared/builder-mdx.js";
|
|
8
13
|
import {
|
|
9
14
|
normalizeBuilderCmsApiEntry,
|
|
10
15
|
type BuilderCmsSourceEntry,
|
|
@@ -23,6 +28,7 @@ export interface BuilderCmsEntryLiveState {
|
|
|
23
28
|
exists: boolean;
|
|
24
29
|
published: "published" | "draft" | string | null;
|
|
25
30
|
lastUpdated: number | string | null;
|
|
31
|
+
blocksHash: string | null;
|
|
26
32
|
id: string | null;
|
|
27
33
|
}
|
|
28
34
|
|
|
@@ -42,7 +48,7 @@ const BUILDER_CMS_MAX_READ_LIMIT = 1000;
|
|
|
42
48
|
const BUILDER_CMS_PAGE_SIZE = 100;
|
|
43
49
|
const BUILDER_CMS_READ_RETRIES = 2;
|
|
44
50
|
const BUILDER_CMS_ENTRY_FIELDS =
|
|
45
|
-
"id,name,published,lastUpdated,createdDate,data.title,data.handle,data.url,data.slug,data.date,data.description,data.status,data.author,data.image";
|
|
51
|
+
"id,name,published,lastUpdated,createdDate,data.title,data.handle,data.url,data.slug,data.date,data.description,data.status,data.author,data.image,data.blocks,data.blocksString";
|
|
46
52
|
|
|
47
53
|
function builderContentApiHost() {
|
|
48
54
|
return (
|
|
@@ -76,6 +82,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
|
|
|
76
82
|
exists: false,
|
|
77
83
|
published: null,
|
|
78
84
|
lastUpdated: null,
|
|
85
|
+
blocksHash: null,
|
|
79
86
|
id: null,
|
|
80
87
|
};
|
|
81
88
|
}
|
|
@@ -84,6 +91,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
|
|
|
84
91
|
exists: false,
|
|
85
92
|
published: null,
|
|
86
93
|
lastUpdated: null,
|
|
94
|
+
blocksHash: null,
|
|
87
95
|
id: null,
|
|
88
96
|
};
|
|
89
97
|
}
|
|
@@ -97,6 +105,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
|
|
|
97
105
|
exists: false,
|
|
98
106
|
published: null,
|
|
99
107
|
lastUpdated: null,
|
|
108
|
+
blocksHash: null,
|
|
100
109
|
id: null,
|
|
101
110
|
};
|
|
102
111
|
}
|
|
@@ -116,10 +125,12 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
|
|
|
116
125
|
exists: false,
|
|
117
126
|
published: null,
|
|
118
127
|
lastUpdated: null,
|
|
128
|
+
blocksHash: null,
|
|
119
129
|
id: null,
|
|
120
130
|
};
|
|
121
131
|
}
|
|
122
132
|
|
|
133
|
+
const blocks = builderEntryBlocks(record as BuilderContentEntry);
|
|
123
134
|
return {
|
|
124
135
|
exists: true,
|
|
125
136
|
published:
|
|
@@ -129,6 +140,7 @@ function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
|
|
|
129
140
|
stringOrNumberFromUnknown(record.updatedDate) ??
|
|
130
141
|
stringOrNumberFromUnknown(record.updatedAt) ??
|
|
131
142
|
stringOrNumberFromUnknown(data.updatedAt),
|
|
143
|
+
blocksHash: blocks.length > 0 ? builderBlocksHash(blocks) : null,
|
|
132
144
|
id,
|
|
133
145
|
};
|
|
134
146
|
}
|
|
@@ -618,6 +630,7 @@ export async function readBuilderCmsEntryLiveState(args: {
|
|
|
618
630
|
exists: false,
|
|
619
631
|
published: null,
|
|
620
632
|
lastUpdated: null,
|
|
633
|
+
blocksHash: null,
|
|
621
634
|
id: null,
|
|
622
635
|
};
|
|
623
636
|
}
|
|
@@ -3,6 +3,21 @@ import type {
|
|
|
3
3
|
ContentDatabaseSourceFieldMapping,
|
|
4
4
|
DocumentPropertyValue,
|
|
5
5
|
} from "../shared/api.js";
|
|
6
|
+
import {
|
|
7
|
+
builderBlocksHash,
|
|
8
|
+
builderEntryBlocks,
|
|
9
|
+
normalizeRemoteUpdatedAt,
|
|
10
|
+
type BuilderContentEntry,
|
|
11
|
+
} from "../shared/builder-mdx.js";
|
|
12
|
+
|
|
13
|
+
export const BUILDER_CMS_BODY_CONTENT_KEY = "__builder.body.content";
|
|
14
|
+
export const BUILDER_CMS_BODY_BLOCKS_HASH_KEY = "__builder.body.blocksHash";
|
|
15
|
+
export const BUILDER_CMS_BODY_SIDECARS_KEY = "__builder.body.sidecarsJson";
|
|
16
|
+
export const BUILDER_CMS_BODY_LOSSLESS_CONTENT_KEY =
|
|
17
|
+
"__builder.body.losslessContent";
|
|
18
|
+
export const BUILDER_CMS_BODY_READABLE_MAP_KEY =
|
|
19
|
+
"__builder.body.readableMapJson";
|
|
20
|
+
export const BUILDER_CMS_BODY_BLOCK_COUNT_KEY = "__builder.body.blockCount";
|
|
6
21
|
|
|
7
22
|
export interface BuilderCmsSourceEntry {
|
|
8
23
|
id: string;
|
|
@@ -11,6 +26,7 @@ export interface BuilderCmsSourceEntry {
|
|
|
11
26
|
urlPath: string;
|
|
12
27
|
updatedAt: string;
|
|
13
28
|
sourceValues: Record<string, DocumentPropertyValue>;
|
|
29
|
+
rawEntry?: BuilderContentEntry;
|
|
14
30
|
}
|
|
15
31
|
|
|
16
32
|
export interface ExistingBuilderSourceRowIdentity {
|
|
@@ -291,6 +307,7 @@ function builderSourceValuesFromRecord(args: {
|
|
|
291
307
|
title: string;
|
|
292
308
|
urlPath: string;
|
|
293
309
|
updatedAt: string;
|
|
310
|
+
rawEntry: BuilderContentEntry;
|
|
294
311
|
}) {
|
|
295
312
|
const values: Record<string, DocumentPropertyValue> = {
|
|
296
313
|
"data.title": args.title,
|
|
@@ -298,6 +315,7 @@ function builderSourceValuesFromRecord(args: {
|
|
|
298
315
|
lastUpdated: args.updatedAt,
|
|
299
316
|
};
|
|
300
317
|
for (const [key, value] of Object.entries(args.data)) {
|
|
318
|
+
if (key === "blocks" || key === "blocksString") continue;
|
|
301
319
|
values[`data.${key}`] = sourceValueFromUnknown(value);
|
|
302
320
|
}
|
|
303
321
|
for (const key of ["published", "createdDate", "updatedDate", "updatedAt"]) {
|
|
@@ -305,6 +323,11 @@ function builderSourceValuesFromRecord(args: {
|
|
|
305
323
|
values[key] = sourceValueFromUnknown(args.record[key]);
|
|
306
324
|
}
|
|
307
325
|
}
|
|
326
|
+
const blocks = builderEntryBlocks(args.rawEntry);
|
|
327
|
+
if (blocks.length > 0) {
|
|
328
|
+
values[BUILDER_CMS_BODY_BLOCKS_HASH_KEY] = builderBlocksHash(blocks);
|
|
329
|
+
values[BUILDER_CMS_BODY_BLOCK_COUNT_KEY] = blocks.length;
|
|
330
|
+
}
|
|
308
331
|
return values;
|
|
309
332
|
}
|
|
310
333
|
|
|
@@ -342,6 +365,24 @@ export function normalizeBuilderCmsApiEntry(
|
|
|
342
365
|
]) ??
|
|
343
366
|
timestampStringFromRecord(data, ["updatedAt"]) ??
|
|
344
367
|
new Date().toISOString();
|
|
368
|
+
const rawEntry: BuilderContentEntry = {
|
|
369
|
+
...record,
|
|
370
|
+
id,
|
|
371
|
+
model,
|
|
372
|
+
name:
|
|
373
|
+
typeof record.name === "string" && record.name.trim()
|
|
374
|
+
? record.name.trim()
|
|
375
|
+
: title,
|
|
376
|
+
published:
|
|
377
|
+
typeof record.published === "string" ? record.published : undefined,
|
|
378
|
+
lastUpdated: normalizeRemoteUpdatedAt({
|
|
379
|
+
...record,
|
|
380
|
+
id,
|
|
381
|
+
model,
|
|
382
|
+
data,
|
|
383
|
+
} as BuilderContentEntry),
|
|
384
|
+
data,
|
|
385
|
+
};
|
|
345
386
|
|
|
346
387
|
return {
|
|
347
388
|
id,
|
|
@@ -355,6 +396,8 @@ export function normalizeBuilderCmsApiEntry(
|
|
|
355
396
|
title,
|
|
356
397
|
urlPath,
|
|
357
398
|
updatedAt,
|
|
399
|
+
rawEntry,
|
|
358
400
|
}),
|
|
401
|
+
rawEntry,
|
|
359
402
|
};
|
|
360
403
|
}
|
|
@@ -194,6 +194,70 @@ function nestedBuilderPatch(
|
|
|
194
194
|
return body;
|
|
195
195
|
}
|
|
196
196
|
|
|
197
|
+
function mergeBuilderPatch(
|
|
198
|
+
base: Record<string, unknown>,
|
|
199
|
+
patch: Record<string, unknown>,
|
|
200
|
+
) {
|
|
201
|
+
const merged = { ...base };
|
|
202
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
203
|
+
if (
|
|
204
|
+
key === "data" &&
|
|
205
|
+
value &&
|
|
206
|
+
typeof value === "object" &&
|
|
207
|
+
!Array.isArray(value)
|
|
208
|
+
) {
|
|
209
|
+
merged.data = {
|
|
210
|
+
...((merged.data && typeof merged.data === "object"
|
|
211
|
+
? merged.data
|
|
212
|
+
: {}) as Record<string, unknown>),
|
|
213
|
+
...(value as Record<string, unknown>),
|
|
214
|
+
};
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
merged[key] = value;
|
|
218
|
+
}
|
|
219
|
+
return merged;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function builderBodyPatch(changeSet: ContentDatabaseSourceChangeSet) {
|
|
223
|
+
const bodyChange = changeSet.bodyChange;
|
|
224
|
+
const checks: string[] = [];
|
|
225
|
+
const blockers: string[] = [];
|
|
226
|
+
if (!bodyChange) {
|
|
227
|
+
return { patch: {}, checks, blockers, hasBodyOperation: false };
|
|
228
|
+
}
|
|
229
|
+
if (!bodyChange.proposedBlocksJson) {
|
|
230
|
+
blockers.push(
|
|
231
|
+
"Builder body diff could not be converted to Builder blocks.",
|
|
232
|
+
);
|
|
233
|
+
return { patch: {}, checks, blockers, hasBodyOperation: false };
|
|
234
|
+
}
|
|
235
|
+
try {
|
|
236
|
+
const blocks = JSON.parse(bodyChange.proposedBlocksJson) as unknown;
|
|
237
|
+
if (!Array.isArray(blocks)) {
|
|
238
|
+
blockers.push("Builder body diff did not produce a blocks array.");
|
|
239
|
+
return { patch: {}, checks, blockers, hasBodyOperation: false };
|
|
240
|
+
}
|
|
241
|
+
checks.push("Includes converted Builder body blocks.");
|
|
242
|
+
for (const warning of bodyChange.warnings ?? []) {
|
|
243
|
+
checks.push(`Body conversion warning: ${warning}`);
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
patch: {
|
|
247
|
+
data: {
|
|
248
|
+
blocks,
|
|
249
|
+
},
|
|
250
|
+
},
|
|
251
|
+
checks,
|
|
252
|
+
blockers,
|
|
253
|
+
hasBodyOperation: true,
|
|
254
|
+
};
|
|
255
|
+
} catch {
|
|
256
|
+
blockers.push("Builder body diff contains invalid converted blocks JSON.");
|
|
257
|
+
return { patch: {}, checks, blockers, hasBodyOperation: false };
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
197
261
|
function builderRequestForEffect(args: {
|
|
198
262
|
effect: BuilderCmsWriteEffect;
|
|
199
263
|
model: string;
|
|
@@ -272,18 +336,19 @@ function builderSafetyChecks(args: {
|
|
|
272
336
|
entryId: string | null;
|
|
273
337
|
syntheticFixtureTarget: boolean;
|
|
274
338
|
operations: BuilderCmsExecutionOperation[];
|
|
339
|
+
hasBodyOperation: boolean;
|
|
340
|
+
bodyChecks: string[];
|
|
341
|
+
bodyBlockers: string[];
|
|
275
342
|
}) {
|
|
276
343
|
const checks = [
|
|
277
344
|
"Requires explicit approval before execution.",
|
|
278
345
|
"Uses the stored execution idempotency key.",
|
|
346
|
+
...args.bodyChecks,
|
|
279
347
|
];
|
|
280
|
-
const blockers: string[] = [];
|
|
348
|
+
const blockers: string[] = [...args.bodyBlockers];
|
|
281
349
|
|
|
282
|
-
if (args.operations.length === 0) {
|
|
283
|
-
blockers.push("No
|
|
284
|
-
}
|
|
285
|
-
if (args.changeSet.bodyChange) {
|
|
286
|
-
blockers.push("Builder body diffs are not executable in this slice.");
|
|
350
|
+
if (args.operations.length === 0 && !args.hasBodyOperation) {
|
|
351
|
+
blockers.push("No Builder operations are available for this change.");
|
|
287
352
|
}
|
|
288
353
|
if (args.effect === "autosave" || args.effect === "update_in_place") {
|
|
289
354
|
const label = args.effect === "autosave" ? "Autosave" : "Update in place";
|
|
@@ -418,7 +483,11 @@ export function buildBuilderCmsExecutionPlan(args: {
|
|
|
418
483
|
localFieldKey: field.localFieldKey,
|
|
419
484
|
value: field.proposedValue,
|
|
420
485
|
}));
|
|
421
|
-
const
|
|
486
|
+
const bodyDiffPatch = builderBodyPatch(args.changeSet);
|
|
487
|
+
const bodyPatch = mergeBuilderPatch(
|
|
488
|
+
nestedBuilderPatch(operations),
|
|
489
|
+
bodyDiffPatch.patch,
|
|
490
|
+
);
|
|
422
491
|
// State-preserving effects must not include `published` in the body. Builder
|
|
423
492
|
// PATCH preserves omitted publication state, so only transition/create effects
|
|
424
493
|
// are allowed to set it.
|
|
@@ -441,6 +510,9 @@ export function buildBuilderCmsExecutionPlan(args: {
|
|
|
441
510
|
args.source.sourceTable === SAFE_WRITE_MODEL &&
|
|
442
511
|
target?.isSyntheticFixture === true,
|
|
443
512
|
operations,
|
|
513
|
+
hasBodyOperation: bodyDiffPatch.hasBodyOperation,
|
|
514
|
+
bodyChecks: bodyDiffPatch.checks,
|
|
515
|
+
bodyBlockers: bodyDiffPatch.blockers,
|
|
444
516
|
});
|
|
445
517
|
const state: ContentDatabaseSourceExecutionState =
|
|
446
518
|
safety.blockers.length > 0
|