Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
8f58d5a
test(content): gated live e2e for Builder CMS write orchestration
3mdistal Jun 24, 2026
881802a
feat(content): discoverable enable-live-writes toggle on Builder source
3mdistal Jun 24, 2026
9f334c2
docs(content): document enabling Builder live writes
3mdistal Jun 24, 2026
2eb3616
refine(content): declutter live-writes control into its own labeled row
3mdistal Jun 24, 2026
b856993
feat(content): publication-state effect model for Builder writes
3mdistal Jun 24, 2026
59b6824
feat(content): live preflight read + stale/transition guard before Bu…
3mdistal Jun 24, 2026
a58e731
fix(content): capture numeric Builder lastUpdated so the stale guard …
3mdistal Jun 24, 2026
2eaba91
feat(content): tiered Builder write enablement (read-only / stage / p…
3mdistal Jun 24, 2026
4780c7e
feat(content): bulk Builder write runner (concurrency, continue-on-er…
3mdistal Jun 24, 2026
23725c1
feat(content): per-item publication effect + transition controls in t…
3mdistal Jun 24, 2026
da7ccd1
docs(content): update live-writes README for tiers, transitions, bulk
3mdistal Jun 25, 2026
f7cf5ed
feat(content): detect new local rows as Builder create_draft change-sets
3mdistal Jun 25, 2026
469feb5
feat(content): detect all mapped field edits (not just title) on Buil…
3mdistal Jun 25, 2026
e163a6f
fix(content): make Builder create_draft push work end-to-end
3mdistal Jun 25, 2026
e0648c2
refactor(content): declutter Builder live-writes review UI
3mdistal Jun 25, 2026
d7cf92b
refactor(content): collapse applied Builder changes to a one-liner
3mdistal Jun 25, 2026
46cb3b8
refactor(content): badge + direct nav on the connected Builder collec…
3mdistal Jun 25, 2026
f69284d
refactor(content): split pending vs pushed, drop redundant inline diff
3mdistal Jun 25, 2026
3180885
refactor(content): merge Builder changes into one card, action at bottom
3mdistal Jun 25, 2026
2611ecc
refactor(content): Sources IA — root is connected + add only; integra…
3mdistal Jun 25, 2026
1ab87e4
feat(content): thread sourceId through the Builder write path (multi-…
3mdistal Jun 26, 2026
3d18d08
fix(content): address Codex adversarial review findings (multi-source…
3mdistal Jun 26, 2026
46c9336
feat(content): attach additional writable Builder source + auto Sourc…
3mdistal Jun 26, 2026
b5332b5
fix(content): guard against attaching the same Builder collection twi…
3mdistal Jun 26, 2026
4941c62
fix(content): address Codex slice-5 review (row-identity, Source valu…
3mdistal Jun 26, 2026
c2ef569
feat(content): writable leaf for any Builder source (slice 6a)
3mdistal Jun 26, 2026
462bf26
fix(content): row-union change-detection fidelity (slice 6a follow-ups)
3mdistal Jun 26, 2026
15095c2
test(content): cover row-union change-detection scoping
3mdistal Jun 26, 2026
d3f7317
fix(content): address Codex adversarial review of slice 6a
3mdistal Jun 26, 2026
101bc86
feat(content): adopt new rows by their Source tag (slice 6b backend)
3mdistal Jun 26, 2026
1e816d1
fix(content): resync only links a source's own rows (row-union)
3mdistal Jun 26, 2026
3c047a7
feat(content): new-row source picker for multi-source databases (slic…
3mdistal Jun 26, 2026
c35dbda
feat(content): column field-binding editor (slice 6c)
3mdistal Jun 26, 2026
533211a
fix(content): address Codex review of slices 6b + 6c
3mdistal Jun 26, 2026
edfb104
test(content): integration tests for row-union bind + resync (slices …
3mdistal Jun 26, 2026
1b9914b
Merge remote-tracking branch 'origin/main' into slice/builder-live-wr…
3mdistal Jun 26, 2026
6cb2d92
i18n(content): internationalize row-union panel, picker, binding edit…
3mdistal Jun 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 0 additions & 24 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

16 changes: 16 additions & 0 deletions templates/content/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,3 +28,19 @@ Open http://localhost:8080 and create your first page.
## Data

Documents are stored in the app's SQL database. Local development defaults to SQLite at `data/app.db`; deployed apps should set `DATABASE_URL` to a persistent SQL database. The agent should use content actions for normal document operations and reserve `db-query` / `db-exec` for inspection or maintenance.

## Enable Builder live writes

Connect Builder through the existing Builder Connect flow, the same connection used by the AI assistant. Once connected, Content resolves the key automatically for the user, or for org owners/admins through the org connection. There is no separate key entry. In local development, `BUILDER_PRIVATE_KEY` and `BUILDER_PUBLIC_KEY` in `.env.local` also work; see `DEVELOPING.md` for local env opt-in details.

Live writes are only allowed for the safe write model `agent-native-blog-article-test` (`BUILDER_CMS_SAFE_WRITE_MODEL`). Other Builder models stay read-only by design.

Write access is an intentional per-source choice from the source panel selector. Sources start at `read_only`, can move to `stage_only` to push approved changes as Builder autosave revisions, and can move to `publish_updates` for state-preserving live writes. Autosave staging stays quiet; update-in-place and publish writes trigger Builder webhooks so downstream rebuilds can run.

In `publish_updates`, Content PATCHes the Builder entry in place and preserves its current publication state: published entries stay published and drafts stay draft. Content never sends a `published` field, so a normal content push cannot publish or unpublish. Builder merges the field changes and preserves the entry envelope, including scheduling and targeting.

Publication transitions are explicit per-row choices in the review/diff, only when the source enables **Allow publish/unpublish per item**. Publishing a draft or unpublishing a published entry is never a bulk default, and unpublish requires confirmation because it takes live content down. New Builder entries are created as drafts.

**Push all approved (N)** runs approved rows in a concurrency-capped batch. It continues after individual errors, reports per-item `succeeded`, `blocked`, or `failed` status, and can be resumed because already-succeeded items are skipped.

Before each write, Content reads the target's current state from Builder. A write blocks if the entry changed or was deleted since the diff, or if a requested publish/unpublish transition no longer matches the real Builder state. Body diffs remain non-executable for now; they are planned for a later slice.
125 changes: 125 additions & 0 deletions templates/content/actions/_builder-cms-read-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@ export interface BuilderCmsReadResult {
message: string | null;
}

export interface BuilderCmsEntryLiveState {
exists: boolean;
published: "published" | "draft" | string | null;
lastUpdated: number | string | null;
id: string | null;
}

type FetchLike = typeof fetch;

type BuilderMcpContentPart = {
Expand Down Expand Up @@ -52,6 +59,80 @@ function entryArrayFromResponse(value: unknown) {
return Array.isArray(record.results) ? record.results : [];
}

function stringFromUnknown(value: unknown) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

function stringOrNumberFromUnknown(value: unknown) {
if (typeof value === "number" && Number.isFinite(value)) return value;
return stringFromUnknown(value);
}

function liveStateFromBuilderEntry(value: unknown): BuilderCmsEntryLiveState {
if (Array.isArray(value)) {
return value.length > 0
? liveStateFromBuilderEntry(value[0])
: {
exists: false,
published: null,
lastUpdated: null,
id: null,
};
}
if (!value || typeof value !== "object") {
return {
exists: false,
published: null,
lastUpdated: null,
id: null,
};
}

const record = value as Record<string, unknown>;
if (Array.isArray(record.results)) {
return liveStateFromBuilderEntry(record.results);
}
if (Object.keys(record).length === 0) {
return {
exists: false,
published: null,
lastUpdated: null,
id: null,
};
}

const data =
record.data &&
typeof record.data === "object" &&
!Array.isArray(record.data)
? (record.data as Record<string, unknown>)
: {};
const id =
stringFromUnknown(record.id) ??
stringFromUnknown(record["@id"]) ??
stringFromUnknown(record.uuid);
if (!id) {
return {
exists: false,
published: null,
lastUpdated: null,
id: null,
};
}

return {
exists: true,
published:
stringFromUnknown(record.published) ?? stringFromUnknown(data.published),
lastUpdated:
stringOrNumberFromUnknown(record.lastUpdated) ??
stringOrNumberFromUnknown(record.updatedDate) ??
stringOrNumberFromUnknown(record.updatedAt) ??
stringOrNumberFromUnknown(data.updatedAt),
id,
};
}

function readLimit(limit: number | undefined) {
if (typeof limit === "number" && Number.isFinite(limit) && limit > 0) {
return Math.min(Math.floor(limit), BUILDER_CMS_MAX_READ_LIMIT);
Expand Down Expand Up @@ -506,6 +587,50 @@ async function readBuilderCmsContentEntriesViaContentApi(args: {
};
}

export async function readBuilderCmsEntryLiveState(args: {
model: string;
entryId: string;
fetchImpl?: FetchLike;
}): Promise<BuilderCmsEntryLiveState> {
const publicKey = await resolveBuilderCredential("BUILDER_PUBLIC_KEY");
if (!publicKey) {
throw new Error(
"Builder CMS live entry read skipped because BUILDER_PUBLIC_KEY is not configured.",
);
}

const url = new URL(
`/api/v3/content/${encodeURIComponent(args.model)}/${encodeURIComponent(
args.entryId,
)}`,
builderContentApiHost(),
);
url.searchParams.set("apiKey", publicKey);
url.searchParams.set("includeUnpublished", "true");
url.searchParams.set("cachebust", String(Date.now()));

const response = await fetchBuilderContentPage({
fetchImpl: args.fetchImpl ?? fetch,
url,
});
if (response.status === 404) {
return {
exists: false,
published: null,
lastUpdated: null,
id: null,
};
}
if (!response.ok) {
throw new Error(
`Builder CMS live entry read failed with HTTP ${response.status}.`,
);
}

const json = (await response.json()) as unknown;
return liveStateFromBuilderEntry(json);
}

export async function listBuilderCmsModels(
args: {
fetchImpl?: FetchLike;
Expand Down
37 changes: 35 additions & 2 deletions templates/content/actions/_builder-cms-source-adapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,12 +66,15 @@ describe("Builder CMS source adapter", () => {
);
});

it("records Builder metadata with natural key and autosave push mode", () => {
it("records Builder metadata with natural key and read-only write mode", () => {
expect(builderCmsSourceMetadata("blog_article")).toMatchObject({
primaryKey: "id",
titleField: "data.title",
naturalKeyField: "/blog/[slug]",
pushMode: "autosave",
pushMode: "none",
writeMode: "read_only",
allowPublicationTransitions: false,
allowedWriteModes: [],
label: "builder.cms.blog_article",
});
});
Expand Down Expand Up @@ -206,6 +209,36 @@ describe("Builder CMS source adapter", () => {
});
});

it("uses numeric Builder lastUpdated as the row source baseline", () => {
const lastUpdated = 1782328870774;
const entry = normalizeBuilderCmsApiEntry(
{
id: "entry-numeric-last-updated",
lastUpdated,
data: {
title: "Numeric timestamp entry",
url: "/blog/numeric-timestamp-entry",
},
},
"blog_article",
);

if (!entry) throw new Error("Expected Builder entry to normalize.");
expect(entry.updatedAt).toBe(String(lastUpdated));
expect(entry.sourceValues.lastUpdated).toBe(String(lastUpdated));
expect(
builderCmsSourceRowIdentity({
item: item("Local title"),
sourceTable: "blog_article",
now: "2026-06-08T12:30:00.000Z",
entry,
}),
).toMatchObject({
sourceRowId: "entry-numeric-last-updated",
lastSourceUpdatedAt: String(lastUpdated),
});
});

it("renders Builder reference fields as readable labels, not raw JSON", () => {
const result = normalizeBuilderCmsApiEntry(
{
Expand Down
34 changes: 28 additions & 6 deletions templates/content/actions/_builder-cms-source-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,13 @@ export function builderCmsSourceMetadata(sourceTable: string) {
primaryKey: "id",
titleField: "data.title",
naturalKeyField: "/blog/[slug]",
pushMode: "autosave" as const,
pushModeLabel: "Save revision / autosave",
pushMode: "none" as const,
pushModeLabel: "No writes",
pushModeDescription:
"Local-only Builder revision staging. No Builder API write runs in this slice.",
allowedWriteModes: ["autosave"],
"Read-only Builder source. Choose a write tier before any Builder API write can run.",
writeMode: "read_only" as const,
allowPublicationTransitions: false,
allowedWriteModes: [],
allowDraftWrites: false,
allowPublishWrites: false,
notes:
Expand Down Expand Up @@ -196,6 +198,22 @@ function stringFromRecord(
return null;
}

function timestampStringFromRecord(
value: Record<string, unknown>,
keys: string[],
): string | null {
for (const key of keys) {
const candidate = value[key];
if (typeof candidate === "number" && Number.isFinite(candidate)) {
return String(candidate);
}
if (typeof candidate === "string" && candidate.trim()) {
return candidate.trim();
}
}
return null;
}

function pickStringField(
obj: Record<string, unknown>,
keys: string[],
Expand Down Expand Up @@ -317,8 +335,12 @@ export function normalizeBuilderCmsApiEntry(
stringFromRecord(data, ["url", "urlPath", "path"]) ??
(slug ? `/blog/${slug.replace(/^\/+/, "")}` : `/blog/${id}`);
const updatedAt =
stringFromRecord(record, ["lastUpdated", "updatedDate", "updatedAt"]) ??
stringFromRecord(data, ["updatedAt"]) ??
timestampStringFromRecord(record, [
"lastUpdated",
"updatedDate",
"updatedAt",
]) ??
timestampStringFromRecord(data, ["updatedAt"]) ??
new Date().toISOString();

return {
Expand Down
Loading
Loading