diff --git a/packages/core/docs/content/getting-started.mdx b/packages/core/docs/content/getting-started.mdx index 0d7c851472..4610a5bddb 100644 --- a/packages/core/docs/content/getting-started.mdx +++ b/packages/core/docs/content/getting-started.mdx @@ -72,13 +72,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "Response chart" + chatUI: { + renderer: "responses.response-chart", + title: "Response chart" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/ar-SA/getting-started.mdx b/packages/core/docs/content/locales/ar-SA/getting-started.mdx index e6ba369861..545c75dcb2 100644 --- a/packages/core/docs/content/locales/ar-SA/getting-started.mdx +++ b/packages/core/docs/content/locales/ar-SA/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "مخطط الردود" + chatUI: { + renderer: "responses.response-chart", + title: "مخطط الردود" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/de-DE/getting-started.mdx b/packages/core/docs/content/locales/de-DE/getting-started.mdx index ce419c538c..7ac514ee34 100644 --- a/packages/core/docs/content/locales/de-DE/getting-started.mdx +++ b/packages/core/docs/content/locales/de-DE/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "Antwortdiagramm" + chatUI: { + renderer: "responses.response-chart", + title: "Antwortdiagramm" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/es-ES/getting-started.mdx b/packages/core/docs/content/locales/es-ES/getting-started.mdx index f24955492e..62bbf37efd 100644 --- a/packages/core/docs/content/locales/es-ES/getting-started.mdx +++ b/packages/core/docs/content/locales/es-ES/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "Gráfico de respuestas" + chatUI: { + renderer: "responses.response-chart", + title: "Gráfico de respuestas" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/fr-FR/getting-started.mdx b/packages/core/docs/content/locales/fr-FR/getting-started.mdx index 98c8530dcd..f50c683a31 100644 --- a/packages/core/docs/content/locales/fr-FR/getting-started.mdx +++ b/packages/core/docs/content/locales/fr-FR/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "Graphique des réponses" + chatUI: { + renderer: "responses.response-chart", + title: "Graphique des réponses" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/hi-IN/getting-started.mdx b/packages/core/docs/content/locales/hi-IN/getting-started.mdx index a1e1287dfc..c5c293bd5c 100644 --- a/packages/core/docs/content/locales/hi-IN/getting-started.mdx +++ b/packages/core/docs/content/locales/hi-IN/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "प्रतिक्रिया चार्ट" + chatUI: { + renderer: "responses.response-chart", + title: "प्रतिक्रिया चार्ट" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/ja-JP/getting-started.mdx b/packages/core/docs/content/locales/ja-JP/getting-started.mdx index dd2925f781..798945a8d1 100644 --- a/packages/core/docs/content/locales/ja-JP/getting-started.mdx +++ b/packages/core/docs/content/locales/ja-JP/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "回答チャート" + chatUI: { + renderer: "responses.response-chart", + title: "回答チャート" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/ko-KR/getting-started.mdx b/packages/core/docs/content/locales/ko-KR/getting-started.mdx index 0e77c01d66..1e45f246f9 100644 --- a/packages/core/docs/content/locales/ko-KR/getting-started.mdx +++ b/packages/core/docs/content/locales/ko-KR/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "응답 차트" + chatUI: { + renderer: "responses.response-chart", + title: "응답 차트" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/pt-BR/getting-started.mdx b/packages/core/docs/content/locales/pt-BR/getting-started.mdx index b9b36869a8..9c577e4615 100644 --- a/packages/core/docs/content/locales/pt-BR/getting-started.mdx +++ b/packages/core/docs/content/locales/pt-BR/getting-started.mdx @@ -45,13 +45,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "Gráfico de respostas" + chatUI: { + renderer: "responses.response-chart", + title: "Gráfico de respostas" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/zh-CN/getting-started.mdx b/packages/core/docs/content/locales/zh-CN/getting-started.mdx index a2a72212be..389da1b1e0 100644 --- a/packages/core/docs/content/locales/zh-CN/getting-started.mdx +++ b/packages/core/docs/content/locales/zh-CN/getting-started.mdx @@ -55,13 +55,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "回复图表" + chatUI: { + renderer: "responses.response-chart", + title: "回复图表" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/packages/core/docs/content/locales/zh-TW/getting-started.mdx b/packages/core/docs/content/locales/zh-TW/getting-started.mdx index 1c9fbc3545..a986356e40 100644 --- a/packages/core/docs/content/locales/zh-TW/getting-started.mdx +++ b/packages/core/docs/content/locales/zh-TW/getting-started.mdx @@ -53,13 +53,13 @@ const responseChartResultSchema = z.object({ export default defineAction({ description: "Analyze recent form responses and render a custom chart.", - schema: z.object({ - formId: z.string().default("demo") + schema: z.object({ + formId: z.string().default("demo") }), outputSchema: responseChartResultSchema, - chatUI: { - renderer: "responses.response-chart", - title: "回覆圖表" + chatUI: { + renderer: "responses.response-chart", + title: "回覆圖表" }, readOnly: true, run: async ({ formId }) => ({ diff --git a/templates/content/AGENTS.md b/templates/content/AGENTS.md index 51b42ce2c8..dced32589f 100644 --- a/templates/content/AGENTS.md +++ b/templates/content/AGENTS.md @@ -113,6 +113,7 @@ cd templates/content && pnpm action [args] | `list-local-component-files` | | List registered local MDX component source files | | `write-local-component-file` | `--workspaceId --path --content ` | Create or update a file in a registered local `components/` folder | | `create-content-database` | `[--documentId ] [--parentId ] [--title ]` | Create a database page or convert an existing page into a database | +| `configure-docs-content-dogfood-workspace` | `[--databaseId ] [--documentId ] [--title ] [--updateRowStatusFields true\|false]` | Create or upgrade the internal Builder docs/blog dogfood workspace with operations fields, source status fields, saved views, and row status backfill | | `create-inline-content-database` | `--hostDocumentId [--title ]` | Create a database owned by an inline database block in the host document | | `get-content-database` | `--databaseId ` or `--documentId ` | Get a database table with property schema and item pages | | `list-trashed-content-databases` | | List soft-deleted databases visible in the sidebar Trash surface | @@ -587,6 +588,7 @@ multi-select, users can add a new board group from the board itself; this appends a new option to the grouped property definition. Use `create-content-database`, `create-inline-content-database`, +`configure-docs-content-dogfood-workspace`, `get-content-database`, `list-trashed-content-databases`, `restore-content-database`, `add-database-item`, `duplicate-database-item`, `duplicate-database-items`, `delete-database-items`, `move-database-item`, @@ -599,6 +601,18 @@ When targeting more than one database row, call `duplicate-database-items` or `documentIds`. Do not loop `duplicate-database-item` or `delete-document` for multi-row duplicate/delete requests. +For Builder docs/blog internal dogfooding, prefer +`configure-docs-content-dogfood-workspace` over manually creating the same +fields and views. It creates or upgrades a practical content-operations +workspace with persona, topic, use case, SEO angle, owner, source status, +safe-to-edit, needs-review, and unsupported-block fields. The action can +backfill row status fields from the current Builder source snapshot and saved +change-set/body hydration signals. Treat source status, safe-to-edit, +needs-review, unsupported blocks, and unsupported block notes as derived fields +when `updateRowStatusFields` is enabled; rerunning the action refreshes them +from source state. The action does not imply the separate scale, bulk-update, or +source-component mapping lanes have landed. + ## UI Components **Always use shadcn/ui components** from `app/components/ui/` for all standard UI patterns (dialogs, popovers, dropdowns, tooltips, buttons, etc). Never build custom modals or dropdowns with absolute/fixed positioning — use the shadcn primitives instead. diff --git a/templates/content/actions/configure-docs-content-dogfood-workspace.test.ts b/templates/content/actions/configure-docs-content-dogfood-workspace.test.ts new file mode 100644 index 0000000000..50431293ad --- /dev/null +++ b/templates/content/actions/configure-docs-content-dogfood-workspace.test.ts @@ -0,0 +1,350 @@ +import { describe, expect, it } from "vitest"; + +import { + buildDocsContentDogfoodViewConfig, + deriveDogfoodRowStatus, + dogfoodPropertyNameForTypeConflict, + mergeDogfoodViews, +} from "./configure-docs-content-dogfood-workspace"; + +const propertyIds = { + persona: "persona", + topic: "topic", + useCase: "use-case", + seoAngle: "seo-angle", + owner: "owner", + sourceStatus: "source-status", + safeToEdit: "safe-to-edit", + needsReview: "needs-review", + unsupportedBlocks: "unsupported-blocks", + unsupportedBlockNotes: "unsupported-block-notes", +}; + +describe("configure docs/content dogfood workspace", () => { + it("keeps existing same-name properties when their type conflicts", () => { + expect( + dogfoodPropertyNameForTypeConflict( + { name: "Owner", type: "person" }, + new Map([["Owner", { type: "text" }]]), + ), + ).toBe("Owner (dogfood)"); + expect( + dogfoodPropertyNameForTypeConflict( + { name: "Owner", type: "person" }, + new Map([ + ["Owner", { type: "text" }], + ["Owner (dogfood)", { type: "text" }], + ]), + ), + ).toBe("Owner (dogfood) 2"); + expect( + dogfoodPropertyNameForTypeConflict( + { name: "Owner", type: "person" }, + new Map([ + ["Owner", { type: "text" }], + ["Owner (dogfood)", { type: "person" }], + ]), + ), + ).toBe("Owner (dogfood)"); + }); + + it("builds practical views for docs/blog operations", () => { + const config = buildDocsContentDogfoodViewConfig(propertyIds); + + expect(config.activeViewId).toBe("docs-content-ops"); + expect(config.views.map((view) => view.name)).toEqual([ + "Docs/blog ops", + "Needs review", + "Safe local drafts", + "Unsupported blocks", + "Safe-to-edit board", + ]); + expect(config.views[0]).toMatchObject({ + rowDensity: "compact", + wrapCells: true, + calculations: { + "source-status": "count_values", + "safe-to-edit": "count_values", + "needs-review": "count_checked", + "unsupported-blocks": "count_checked", + }, + }); + expect(config.views[4]).toMatchObject({ + type: "board", + groupByPropertyId: "safe-to-edit", + hideEmptyGroups: true, + }); + }); + + it("merges dogfood views without removing existing custom views", () => { + const dogfood = buildDocsContentDogfoodViewConfig(propertyIds); + const merged = mergeDogfoodViews( + { + activeViewId: "custom-seo", + views: [ + { + id: "custom-seo", + name: "SEO copy", + type: "table", + sorts: [], + filters: [], + filterMode: "and", + columnWidths: { name: 360 }, + propertyOrderIds: ["name"], + hiddenPropertyIds: [], + collapsedGroupIds: [], + hideEmptyGroups: false, + calculations: {}, + wrapCells: true, + rowDensity: "comfortable", + openPagesIn: "full", + }, + { + ...dogfood.views[0], + name: "Old dogfood view", + rowDensity: "comfortable", + }, + ], + sorts: [], + filters: [], + columnWidths: { name: 360 }, + }, + dogfood, + ); + + expect(merged.activeViewId).toBe("custom-seo"); + expect(merged.views.map((view) => view.id)).toEqual([ + "custom-seo", + "docs-content-ops", + "docs-content-needs-review", + "docs-content-safe", + "docs-content-unsupported", + "docs-content-safe-board", + ]); + expect(merged.views.find((view) => view.id === "custom-seo")).toMatchObject( + { + name: "SEO copy", + rowDensity: "comfortable", + }, + ); + expect( + merged.views.find((view) => view.id === "docs-content-ops"), + ).toMatchObject({ + name: "Docs/blog ops", + rowDensity: "compact", + }); + }); + + it("marks fresh Builder rows as safe local drafts", () => { + expect( + deriveDogfoodRowStatus({ + item: { + document: { id: "doc-1" }, + bodyHydration: { status: "hydrated", error: null }, + }, + source: { + id: "source-1", + databaseId: "database", + sourceType: "builder-cms", + sourceName: "Builder CMS", + sourceTable: "blog-article", + syncState: "linked", + freshness: "fresh", + lastRefreshedAt: null, + lastSourceUpdatedAt: null, + lastError: null, + capabilities: { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: false, + canWriteBody: false, + canPush: false, + canPull: false, + canPublish: false, + canDelete: false, + canStageLocalRevision: false, + liveWritesEnabled: false, + readOnlyRefresh: true, + }, + metadata: { + primaryKey: "id", + titleField: "name", + }, + fields: [], + rows: [ + { + id: "row-1", + databaseItemId: "item-1", + documentId: "doc-1", + sourceRowId: "entry-1", + sourceQualifiedId: "blog-article:entry-1", + sourceDisplayKey: "Fresh post", + provenance: "source", + syncState: "linked", + freshness: "fresh", + lastSyncedAt: null, + lastSourceUpdatedAt: null, + }, + ], + changeSets: [], + }, + }), + ).toMatchObject({ + sourceStatus: "fresh", + safeToEdit: "safe-local-draft", + needsReview: false, + unsupportedBlocks: false, + unsupportedBlockNotes: "", + }); + }); + + it("blocks rows with unsupported Builder body warnings", () => { + expect( + deriveDogfoodRowStatus({ + item: { + document: { id: "doc-2" }, + bodyHydration: { status: "hydrated", error: null }, + }, + source: { + id: "source-1", + databaseId: "database", + sourceType: "builder-cms", + sourceName: "Builder CMS", + sourceTable: "docs-content", + syncState: "linked", + freshness: "fresh", + lastRefreshedAt: null, + lastSourceUpdatedAt: null, + lastError: null, + capabilities: { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: false, + canWriteBody: false, + canPush: false, + canPull: false, + canPublish: false, + canDelete: false, + canStageLocalRevision: false, + liveWritesEnabled: false, + readOnlyRefresh: true, + }, + metadata: { + primaryKey: "id", + titleField: "name", + }, + fields: [], + rows: [ + { + id: "row-2", + databaseItemId: "item-2", + documentId: "doc-2", + sourceRowId: "entry-2", + sourceQualifiedId: "docs-content:entry-2", + sourceDisplayKey: "Needs mapping", + provenance: "source", + syncState: "linked", + freshness: "fresh", + lastSyncedAt: null, + lastSourceUpdatedAt: null, + }, + ], + changeSets: [ + { + id: "change-1", + databaseItemId: "item-2", + documentId: "doc-2", + kind: "body_update", + direction: "outbound", + state: "proposed", + pushMode: null, + localOnly: true, + summary: "Builder body blocks changed.", + fieldChanges: [], + bodyChange: { + summary: "Builder body blocks changed.", + currentExcerpt: null, + proposedExcerpt: null, + warnings: ["Unsupported Builder MDX component: ."], + }, + riskLevel: "medium", + riskReasons: ["body diff"], + conflictState: "none", + reviewEvents: [], + executions: [], + createdAt: "2026-07-01T00:00:00.000Z", + updatedAt: "2026-07-01T00:00:00.000Z", + }, + ], + }, + }), + ).toMatchObject({ + sourceStatus: "fresh", + safeToEdit: "blocked", + needsReview: true, + unsupportedBlocks: true, + unsupportedBlockNotes: "Unsupported Builder MDX component: .", + }); + }); + + it("requires row-level freshness before marking source rows safe", () => { + expect( + deriveDogfoodRowStatus({ + item: { + document: { id: "doc-3" }, + bodyHydration: { status: "hydrated", error: null }, + }, + source: { + id: "source-1", + databaseId: "database", + sourceType: "builder-cms", + sourceName: "Builder CMS", + sourceTable: "blog-article", + syncState: "linked", + freshness: "fresh", + lastRefreshedAt: null, + lastSourceUpdatedAt: null, + lastError: null, + capabilities: { + canRefresh: true, + canCreateChangeSets: true, + canWriteFields: false, + canWriteBody: false, + canPush: false, + canPull: false, + canPublish: false, + canDelete: false, + canStageLocalRevision: false, + liveWritesEnabled: false, + readOnlyRefresh: true, + }, + metadata: { + primaryKey: "id", + titleField: "name", + }, + fields: [], + rows: [ + { + id: "row-3", + databaseItemId: "item-3", + documentId: "doc-3", + sourceRowId: "entry-3", + sourceQualifiedId: "blog-article:entry-3", + sourceDisplayKey: "Unknown freshness", + provenance: "source", + syncState: "linked", + freshness: "unknown", + lastSyncedAt: null, + lastSourceUpdatedAt: null, + }, + ], + changeSets: [], + }, + }), + ).toMatchObject({ + sourceStatus: "unknown", + safeToEdit: "review-first", + needsReview: false, + }); + }); +}); diff --git a/templates/content/actions/configure-docs-content-dogfood-workspace.ts b/templates/content/actions/configure-docs-content-dogfood-workspace.ts new file mode 100644 index 0000000000..42ee25c0d0 --- /dev/null +++ b/templates/content/actions/configure-docs-content-dogfood-workspace.ts @@ -0,0 +1,914 @@ +import { defineAction, embedApp } from "@agent-native/core"; +import { writeAppState } from "@agent-native/core/application-state"; +import { buildDeepLink } from "@agent-native/core/server"; +import { getRequestUserEmail } from "@agent-native/core/server/request-context"; +import { assertAccess } from "@agent-native/core/sharing"; +import { and, asc, eq, inArray, isNull, sql } from "drizzle-orm"; +import { z } from "zod"; + +import { getDb, schema } from "../server/db/index.js"; +import type { + ContentDatabaseFilter, + ContentDatabaseItem, + ContentDatabaseResponse, + ContentDatabaseSource, + ContentDatabaseView, + ContentDatabaseViewConfig, +} from "../shared/api.js"; +import { + parsePropertyOptions, + type DocumentPropertyOption, + type DocumentPropertyOptions, + type DocumentPropertyOptionColor, + type DocumentPropertyType, + type DocumentPropertyValue, +} from "../shared/properties.js"; +import { getAllContentDatabaseSourceSnapshots } from "./_database-source-utils.js"; +import { + getContentDatabaseResponse, + getDatabaseByDocumentId, +} from "./_database-utils.js"; +import { + getDatabaseById, + nanoid, + normalizedValueJson, + optionsForNewProperty, + parseDatabaseViewConfig, + serializeDatabaseViewConfig, +} from "./_property-utils.js"; +import { createContentDatabaseRecord } from "./create-content-database.js"; + +const WORKSPACE_TITLE = "Docs/content dogfood workspace"; +type ContentDatabaseRow = typeof schema.contentDatabases.$inferSelect; + +const dogfoodWorkspaceSchema = z + .object({ + databaseId: z + .string() + .optional() + .describe("Existing content database to configure"), + documentId: z + .string() + .optional() + .describe("Existing database page, or document to convert to a database"), + title: z + .string() + .optional() + .describe("Title to use when a new database is created"), + updateRowStatusFields: z + .boolean() + .default(true) + .describe( + "Refresh source status, safe-to-edit, needs-review, and unsupported-block fields from current source data", + ), + }) + .refine((args) => !(args.databaseId && args.documentId), { + message: "Pass databaseId or documentId, not both.", + }); + +type DogfoodPropertySpec = { + key: DogfoodPropertyKey; + name: string; + type: DocumentPropertyType; + visibility?: "always_show" | "hide_when_empty" | "always_hide"; + options?: DocumentPropertyOptions; +}; + +type DogfoodPropertyKey = + | "persona" + | "topic" + | "useCase" + | "seoAngle" + | "owner" + | "sourceStatus" + | "safeToEdit" + | "needsReview" + | "unsupportedBlocks" + | "unsupportedBlockNotes"; + +type PropertyIdMap = Record; +type DogfoodPropertyConflict = { + requestedName: string; + requestedType: DocumentPropertyType; + existingPropertyId: string; + existingType: string; + dogfoodName: string; + dogfoodPropertyId: string; +}; +type DogfoodRowStatusSummary = { + updatedRows: number; + skippedRows: number; + sourceId: string | null; + sourceTable: string | null; + refreshed: boolean; + skipped?: boolean; +}; + +const sourceStatusOptions = [ + option("fresh", "Fresh", "green"), + option("stale", "Stale", "yellow"), + option("error", "Error", "red"), + option("unknown", "Unknown", "gray"), + option("no-source", "No source", "gray"), +]; + +const safeToEditOptions = [ + option("safe-local-draft", "Safe local draft", "green"), + option("review-first", "Review first", "yellow"), + option("blocked", "Blocked", "red"), + option("read-only", "Read only", "gray"), +]; + +export const docsContentDogfoodPropertySpecs: DogfoodPropertySpec[] = [ + { + key: "persona", + name: "Persona", + type: "multi_select", + options: { + options: [ + option("developer", "Developer", "blue"), + option("docs-author", "Docs author", "green"), + option("product", "Product", "purple"), + option("sales", "Sales", "orange"), + option("admin", "Admin", "gray"), + ], + }, + }, + { + key: "topic", + name: "Topic", + type: "multi_select", + options: { + options: [ + option("content", "Content", "blue"), + option("builder-cms", "Builder CMS", "purple"), + option("agents", "Agents", "green"), + option("templates", "Templates", "yellow"), + option("workflow", "Workflow", "orange"), + ], + }, + }, + { + key: "useCase", + name: "Use case", + type: "multi_select", + options: { + options: [ + option("docs-review", "Docs review", "blue"), + option("content-ops", "Content ops", "green"), + option("enablement", "Enablement", "orange"), + option("seo", "SEO", "yellow"), + option("migration", "Migration", "purple"), + ], + }, + }, + { key: "seoAngle", name: "SEO angle", type: "text" }, + { key: "owner", name: "Owner", type: "person" }, + { + key: "sourceStatus", + name: "Source status", + type: "status", + options: { options: sourceStatusOptions }, + }, + { + key: "safeToEdit", + name: "Safe to edit", + type: "status", + options: { options: safeToEditOptions }, + }, + { key: "needsReview", name: "Needs review", type: "checkbox" }, + { key: "unsupportedBlocks", name: "Unsupported blocks", type: "checkbox" }, + { + key: "unsupportedBlockNotes", + name: "Unsupported block notes", + type: "text", + visibility: "hide_when_empty", + }, +]; + +export default defineAction({ + description: + "Create or upgrade an internal Builder docs/blog dogfood workspace with practical content-operations fields, source status fields, and saved views.", + schema: dogfoodWorkspaceSchema, + mcpApp: { + compactCatalog: true, + resource: embedApp({ + title: "Open dogfood workspace", + description: "Open the docs/content dogfood workspace in Content.", + iframeTitle: "Agent-Native Content", + openLabel: "Open in Content", + height: 900, + }), + }, + run: async (args) => { + const result = await configureDocsContentDogfoodWorkspace(args); + await writeAppState("refresh-signal", { ts: Date.now() }); + return result; + }, + link: ({ result }) => { + const documentId = (result as { database?: { documentId?: string } } | null) + ?.database?.documentId; + if (!documentId) return null; + return { + url: buildDeepLink({ + app: "content", + view: "editor", + params: { documentId }, + }), + label: "Open dogfood workspace", + view: "editor", + }; + }, +}); + +export async function configureDocsContentDogfoodWorkspace(args: { + databaseId?: string; + documentId?: string; + title?: string; + updateRowStatusFields?: boolean; +}): Promise { + const db = getDb(); + const database = await resolveDogfoodDatabase(args); + await assertAccess("document", database.documentId, "editor"); + + const now = new Date().toISOString(); + const propertySetup = await upsertDogfoodProperties({ + database, + now, + }); + const propertyIds = propertySetup.ids; + + const dogfoodViewConfig = buildDocsContentDogfoodViewConfig(propertyIds); + const viewConfig = mergeDogfoodViews( + parseDatabaseViewConfig(database.viewConfigJson), + dogfoodViewConfig, + ); + await db + .update(schema.contentDatabases) + .set({ + viewConfigJson: serializeDatabaseViewConfig(viewConfig), + updatedAt: now, + }) + .where(eq(schema.contentDatabases.id, database.id)); + + const rowStatusSummary = + args.updateRowStatusFields === false + ? { + updatedRows: 0, + skippedRows: 0, + sourceId: null, + sourceTable: null, + refreshed: false, + skipped: true, + } + : await refreshDogfoodRowStatuses({ + database, + propertyIds, + now, + }); + + return { + ...(await getContentDatabaseResponse(database.id)), + dogfoodWorkspace: { + propertyIds, + propertyConflicts: propertySetup.conflicts, + views: viewConfig.views.map((view) => ({ + id: view.id, + name: view.name, + type: view.type, + })), + rowStatusSummary, + caveat: + "This configures the dogfood workspace fields and views; scale, bulk-update, and component-mapping guarantees remain in their own PR lanes.", + }, + }; +} + +async function resolveDogfoodDatabase(args: { + databaseId?: string; + documentId?: string; + title?: string; +}) { + if (args.databaseId) { + const database = await getDatabaseById(args.databaseId); + if (!database) throw new Error(`Database "${args.databaseId}" not found`); + return database; + } + + if (args.documentId) { + const existing = await getDatabaseByDocumentId(args.documentId); + if (existing) return existing; + const databaseId = await createContentDatabaseRecord({ + documentId: args.documentId, + title: args.title ?? WORKSPACE_TITLE, + }); + const database = await getDatabaseById(databaseId); + if (!database) throw new Error(`Database "${databaseId}" not found`); + return database; + } + + const existing = await findExistingDogfoodDatabase( + args.title ?? WORKSPACE_TITLE, + ); + if (existing) return existing; + + const databaseId = await createContentDatabaseRecord({ + title: args.title ?? WORKSPACE_TITLE, + }); + const database = await getDatabaseById(databaseId); + if (!database) throw new Error(`Database "${databaseId}" not found`); + return database; +} + +async function upsertDogfoodProperties(args: { + database: ContentDatabaseRow; + now: string; +}): Promise<{ ids: PropertyIdMap; conflicts: DogfoodPropertyConflict[] }> { + const db = getDb(); + const existing = await db + .select() + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.database.id)) + .orderBy(asc(schema.documentPropertyDefinitions.position)); + const byName = new Map(existing.map((property) => [property.name, property])); + const [maxPosition] = await db + .select({ max: sql`COALESCE(MAX(position), -1)` }) + .from(schema.documentPropertyDefinitions) + .where(eq(schema.documentPropertyDefinitions.databaseId, args.database.id)); + let nextPosition = (maxPosition?.max ?? -1) + 1; + const ids = {} as PropertyIdMap; + const conflicts: DogfoodPropertyConflict[] = []; + + for (const spec of docsContentDogfoodPropertySpecs) { + const requestedNameMatch = byName.get(spec.name); + const propertyName = + requestedNameMatch && requestedNameMatch.type !== spec.type + ? dogfoodPropertyNameForTypeConflict(spec, byName) + : spec.name; + const existingProperty = byName.get(propertyName); + const optionsJson = optionsForSpec(spec, existingProperty?.optionsJson); + if (existingProperty) { + ids[spec.key] = existingProperty.id; + await db + .update(schema.documentPropertyDefinitions) + .set({ + type: spec.type, + visibility: spec.visibility ?? "always_show", + optionsJson, + updatedAt: args.now, + }) + .where(eq(schema.documentPropertyDefinitions.id, existingProperty.id)); + if ( + requestedNameMatch && + requestedNameMatch.id !== existingProperty.id && + requestedNameMatch.type !== spec.type + ) { + conflicts.push({ + requestedName: spec.name, + requestedType: spec.type, + existingPropertyId: requestedNameMatch.id, + existingType: requestedNameMatch.type, + dogfoodName: propertyName, + dogfoodPropertyId: existingProperty.id, + }); + } + continue; + } + + const id = nanoid(); + ids[spec.key] = id; + await db.insert(schema.documentPropertyDefinitions).values({ + id, + ownerEmail: args.database.ownerEmail, + orgId: args.database.orgId ?? null, + databaseId: args.database.id, + name: propertyName, + type: spec.type, + visibility: spec.visibility ?? "always_show", + optionsJson, + position: nextPosition, + createdAt: args.now, + updatedAt: args.now, + }); + if (requestedNameMatch && requestedNameMatch.type !== spec.type) { + conflicts.push({ + requestedName: spec.name, + requestedType: spec.type, + existingPropertyId: requestedNameMatch.id, + existingType: requestedNameMatch.type, + dogfoodName: propertyName, + dogfoodPropertyId: id, + }); + } + nextPosition += 1; + } + + return { ids, conflicts }; +} + +export function dogfoodPropertyNameForTypeConflict( + spec: Pick, + byName: Map, +) { + const baseName = `${spec.name} (dogfood)`; + let candidate = baseName; + let suffix = 2; + while (true) { + const existing = byName.get(candidate); + if (!existing || existing.type === spec.type) return candidate; + candidate = `${baseName} ${suffix}`; + suffix += 1; + } +} + +async function findExistingDogfoodDatabase(title: string) { + const ownerEmail = getRequestUserEmail(); + if (!ownerEmail) return null; + const db = getDb(); + const [database] = await db + .select() + .from(schema.contentDatabases) + .where( + and( + eq(schema.contentDatabases.ownerEmail, ownerEmail), + eq(schema.contentDatabases.title, title), + isNull(schema.contentDatabases.deletedAt), + ), + ) + .orderBy( + asc(schema.contentDatabases.createdAt), + asc(schema.contentDatabases.id), + ); + if (!database) return null; + + try { + await assertAccess("document", database.documentId, "editor"); + return database; + } catch { + return null; + } +} + +function optionsForSpec( + spec: DogfoodPropertySpec, + existingOptionsJson?: string | null, +) { + const defaultJson = optionsForNewProperty(spec.type, spec.options); + const existing = parsePropertyOptions(existingOptionsJson); + if (!spec.options?.options?.length || !existing.options?.length) { + return defaultJson; + } + return JSON.stringify({ + ...spec.options, + options: mergeOptions(spec.options.options, existing.options), + }); +} + +function mergeOptions( + defaults: DocumentPropertyOption[], + existing: DocumentPropertyOption[], +) { + const seen = new Set(defaults.map((item) => item.id)); + return [ + ...defaults, + ...existing.filter((item) => { + if (seen.has(item.id)) return false; + seen.add(item.id); + return true; + }), + ]; +} + +export function buildDocsContentDogfoodViewConfig( + propertyIds: PropertyIdMap, +): ContentDatabaseViewConfig { + const propertyOrderIds = [ + "name", + propertyIds.sourceStatus, + propertyIds.safeToEdit, + propertyIds.needsReview, + propertyIds.unsupportedBlocks, + propertyIds.persona, + propertyIds.topic, + propertyIds.useCase, + propertyIds.seoAngle, + propertyIds.owner, + propertyIds.unsupportedBlockNotes, + ]; + const widths = { + name: 280, + [propertyIds.sourceStatus]: 150, + [propertyIds.safeToEdit]: 170, + [propertyIds.needsReview]: 120, + [propertyIds.unsupportedBlocks]: 150, + [propertyIds.persona]: 180, + [propertyIds.topic]: 200, + [propertyIds.useCase]: 200, + [propertyIds.seoAngle]: 260, + [propertyIds.owner]: 160, + [propertyIds.unsupportedBlockNotes]: 320, + }; + const views: ContentDatabaseView[] = [ + { + id: "docs-content-ops", + name: "Docs/blog ops", + type: "table", + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filters: [], + filterMode: "and", + columnWidths: widths, + propertyOrderIds, + hiddenPropertyIds: [], + collapsedGroupIds: [], + hideEmptyGroups: false, + calculations: { + [propertyIds.sourceStatus]: "count_values", + [propertyIds.safeToEdit]: "count_values", + [propertyIds.needsReview]: "count_checked", + [propertyIds.unsupportedBlocks]: "count_checked", + }, + wrapCells: true, + rowDensity: "compact", + openPagesIn: "preview", + }, + tableView("Needs review", "docs-content-needs-review", propertyOrderIds, [ + checkboxFilter(propertyIds.needsReview, "Needs review"), + checkboxFilter(propertyIds.unsupportedBlocks, "Unsupported blocks"), + statusFilter(propertyIds.safeToEdit, "Safe to edit", "review-first"), + statusFilter(propertyIds.safeToEdit, "Safe to edit", "blocked"), + ]), + tableView("Safe local drafts", "docs-content-safe", propertyOrderIds, [ + statusFilter(propertyIds.safeToEdit, "Safe to edit", "safe-local-draft"), + ]), + tableView( + "Unsupported blocks", + "docs-content-unsupported", + propertyOrderIds, + [checkboxFilter(propertyIds.unsupportedBlocks, "Unsupported blocks")], + ), + { + ...tableView( + "Safe-to-edit board", + "docs-content-safe-board", + propertyOrderIds, + ), + type: "board", + groupByPropertyId: propertyIds.safeToEdit, + hideEmptyGroups: true, + }, + ]; + + return { + activeViewId: views[0].id, + views, + sorts: views[0].sorts, + filters: views[0].filters, + columnWidths: views[0].columnWidths, + }; +} + +export function mergeDogfoodViews( + existing: ContentDatabaseViewConfig, + dogfood: ContentDatabaseViewConfig, +): ContentDatabaseViewConfig { + const dogfoodById = new Map(dogfood.views.map((view) => [view.id, view])); + const mergedViews = existing.views.map( + (view) => dogfoodById.get(view.id) ?? view, + ); + const existingIds = new Set(mergedViews.map((view) => view.id)); + for (const view of dogfood.views) { + if (!existingIds.has(view.id)) mergedViews.push(view); + } + const activeViewId = mergedViews.some( + (view) => view.id === existing.activeViewId, + ) + ? existing.activeViewId + : dogfood.activeViewId; + const activeView = + mergedViews.find((view) => view.id === activeViewId) ?? + dogfood.views[0] ?? + mergedViews[0]; + + return { + activeViewId: activeView.id, + views: mergedViews, + sorts: activeView.sorts, + filters: activeView.filters, + columnWidths: activeView.columnWidths, + }; +} + +function tableView( + name: string, + id: string, + propertyOrderIds: string[], + filters: ContentDatabaseFilter[] = [], +): ContentDatabaseView { + return { + id, + name, + type: "table", + sorts: [{ key: "name", label: "Name", direction: "asc" }], + filters, + filterMode: filters.length > 1 ? "or" : "and", + columnWidths: {}, + propertyOrderIds, + hiddenPropertyIds: [], + collapsedGroupIds: [], + hideEmptyGroups: false, + calculations: {}, + wrapCells: true, + rowDensity: "compact", + openPagesIn: "preview", + }; +} + +function checkboxFilter(key: string, label: string): ContentDatabaseFilter { + return { key, label, operator: "is_checked", value: "" }; +} + +function statusFilter( + key: string, + label: string, + value: string, +): ContentDatabaseFilter { + return { key, label, operator: "equals", value }; +} + +async function refreshDogfoodRowStatuses(args: { + database: ContentDatabaseRow; + propertyIds: PropertyIdMap; + now: string; +}): Promise { + const db = getDb(); + const items = await db + .select() + .from(schema.contentDatabaseItems) + .where(eq(schema.contentDatabaseItems.databaseId, args.database.id)) + .orderBy(asc(schema.contentDatabaseItems.position)); + const sources = await getAllContentDatabaseSourceSnapshots(args.database); + const source = + sources.find((item) => item.sourceType === "builder-cms") ?? null; + let updatedRows = 0; + let skippedRows = 0; + const statusValues: Array<{ + documentId: string; + ownerEmail: string; + propertyId: string; + type: DocumentPropertyType; + value: DocumentPropertyValue; + }> = []; + + for (const item of items) { + const access = await assertAccess("document", item.documentId, "editor") + .then((result) => result) + .catch(() => null); + if (!access) { + skippedRows += 1; + continue; + } + + const status = deriveDogfoodRowStatus({ + item: { + document: { id: item.documentId }, + bodyHydration: { + status: + item.bodyHydrationStatus === "pending" || + item.bodyHydrationStatus === "hydrating" || + item.bodyHydrationStatus === "error" + ? item.bodyHydrationStatus + : "hydrated", + error: item.bodyHydrationError, + }, + }, + source, + }); + + statusValues.push({ + documentId: item.documentId, + ownerEmail: access.resource.ownerEmail, + propertyId: args.propertyIds.sourceStatus, + type: "status", + value: status.sourceStatus, + }); + statusValues.push({ + documentId: item.documentId, + ownerEmail: access.resource.ownerEmail, + propertyId: args.propertyIds.safeToEdit, + type: "status", + value: status.safeToEdit, + }); + statusValues.push({ + documentId: item.documentId, + ownerEmail: access.resource.ownerEmail, + propertyId: args.propertyIds.needsReview, + type: "checkbox", + value: status.needsReview, + }); + statusValues.push({ + documentId: item.documentId, + ownerEmail: access.resource.ownerEmail, + propertyId: args.propertyIds.unsupportedBlocks, + type: "checkbox", + value: status.unsupportedBlocks, + }); + statusValues.push({ + documentId: item.documentId, + ownerEmail: access.resource.ownerEmail, + propertyId: args.propertyIds.unsupportedBlockNotes, + type: "text", + value: status.unsupportedBlockNotes, + }); + updatedRows += 1; + } + + await setPropertyValues({ + values: statusValues, + propertyIds: [ + args.propertyIds.sourceStatus, + args.propertyIds.safeToEdit, + args.propertyIds.needsReview, + args.propertyIds.unsupportedBlocks, + args.propertyIds.unsupportedBlockNotes, + ], + now: args.now, + }); + + return { + updatedRows, + skippedRows, + sourceId: source?.id ?? null, + sourceTable: source?.sourceTable ?? null, + refreshed: true, + }; +} + +type DogfoodStatusInput = { + item: { + document: Pick; + bodyHydration?: { + status?: string | null; + error?: string | null; + }; + }; + source: ContentDatabaseSource | null; +}; + +export function deriveDogfoodRowStatus(args: DogfoodStatusInput) { + const row = args.source?.rows.find( + (candidate) => candidate.documentId === args.item.document.id, + ); + const openChangeSets = + args.source?.changeSets.filter( + (changeSet) => + changeSet.documentId === args.item.document.id && + changeSet.state !== "applied" && + changeSet.state !== "rejected", + ) ?? []; + const unsupportedReasons = unsupportedBlockReasons(args, openChangeSets); + const hasHydrationError = args.item.bodyHydration?.status === "error"; + const hasSourceError = + row?.syncState === "error" || args.source?.syncState === "error"; + const hasConflict = openChangeSets.some( + (changeSet) => changeSet.conflictState === "source_changed", + ); + const needsReview = + openChangeSets.length > 0 || + hasHydrationError || + hasSourceError || + hasConflict || + unsupportedReasons.length > 0; + const sourceStatus = sourceStatusForRow(args.source, row, hasHydrationError); + const unsupportedBlocks = unsupportedReasons.length > 0; + const safeToEdit = safeToEditForRow({ + source: args.source, + sourceStatus, + needsReview, + unsupportedBlocks, + hasHydrationError, + hasConflict, + }); + + return { + sourceStatus, + safeToEdit, + needsReview, + unsupportedBlocks, + unsupportedBlockNotes: unsupportedReasons.join("; "), + }; +} + +function sourceStatusForRow( + source: ContentDatabaseSource | null, + row: ContentDatabaseSource["rows"][number] | undefined, + hasHydrationError: boolean, +) { + if (!source || !row) return "no-source"; + if ( + hasHydrationError || + row.syncState === "error" || + source.syncState === "error" + ) { + return "error"; + } + if (row.freshness === "stale" || source.freshness === "stale") return "stale"; + if (row.freshness === "fresh") return "fresh"; + return "unknown"; +} + +function safeToEditForRow(args: { + source: ContentDatabaseSource | null; + sourceStatus: string; + needsReview: boolean; + unsupportedBlocks: boolean; + hasHydrationError: boolean; + hasConflict: boolean; +}) { + if (!args.source) return "read-only"; + if ( + args.hasHydrationError || + args.hasConflict || + args.unsupportedBlocks || + args.sourceStatus === "error" + ) { + return "blocked"; + } + if ( + args.needsReview || + args.sourceStatus === "stale" || + args.sourceStatus === "unknown" + ) { + return "review-first"; + } + return "safe-local-draft"; +} + +function unsupportedBlockReasons( + args: DogfoodStatusInput, + openChangeSets: ContentDatabaseSource["changeSets"], +) { + const reasons = new Set(); + const hydrationError = args.item.bodyHydration?.error ?? ""; + if (mentionsUnsupportedBlock(hydrationError)) reasons.add(hydrationError); + + for (const changeSet of openChangeSets) { + for (const warning of changeSet.bodyChange?.warnings ?? []) { + if (mentionsUnsupportedBlock(warning)) reasons.add(warning); + } + } + + return Array.from(reasons); +} + +function mentionsUnsupportedBlock(value: string) { + return /unsupported|unmodeled|raw sidecar/i.test(value); +} + +async function setPropertyValues(args: { + values: Array<{ + documentId: string; + ownerEmail: string; + propertyId: string; + type: DocumentPropertyType; + value: DocumentPropertyValue; + }>; + propertyIds: string[]; + now: string; +}) { + if (!args.values.length) return; + const db = getDb(); + const documentIds = Array.from( + new Set(args.values.map((value) => value.documentId)), + ); + + await db.transaction(async (tx) => { + await tx + .delete(schema.documentPropertyValues) + .where( + and( + inArray(schema.documentPropertyValues.documentId, documentIds), + inArray(schema.documentPropertyValues.propertyId, args.propertyIds), + ), + ); + + await tx.insert(schema.documentPropertyValues).values( + args.values.map((value) => ({ + id: nanoid(), + ownerEmail: value.ownerEmail, + documentId: value.documentId, + propertyId: value.propertyId, + valueJson: normalizedValueJson(value.type, value.value), + createdAt: args.now, + updatedAt: args.now, + })), + ); + }); +} + +function option( + id: string, + name: string, + color: DocumentPropertyOptionColor, +): DocumentPropertyOption { + return { id, name, color }; +} diff --git a/templates/content/changelog/2026-07-01-builder-docs-blog-rows-can-be-organized-into-an-internal.md b/templates/content/changelog/2026-07-01-builder-docs-blog-rows-can-be-organized-into-an-internal.md new file mode 100644 index 0000000000..7c40cb1824 --- /dev/null +++ b/templates/content/changelog/2026-07-01-builder-docs-blog-rows-can-be-organized-into-an-internal.md @@ -0,0 +1,6 @@ +--- +type: added +date: 2026-07-01 +--- + +Builder docs/blog rows can be organized into an internal dogfood workspace with content-operations fields, review views, and source safety indicators. diff --git a/templates/content/parity/matrix.md b/templates/content/parity/matrix.md index a549fa1d53..6704cd75f2 100644 --- a/templates/content/parity/matrix.md +++ b/templates/content/parity/matrix.md @@ -2,27 +2,28 @@ This generated matrix tracks whether high-value Content UI operations use the same action surface agents can call, or have an explicit exception. Edit `matrix.ts`, then regenerate this file. -| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | -| ---------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | -| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | -| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - | -| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| database.rows | database | Add, duplicate, move, open, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | -| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | -| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | -| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | -| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | -| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `export-content-source`, `import-content-source`, `remove-local-file-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | -| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | -| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | -| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | -| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | -| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `delete-document`, `get-document`, `list-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | -| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | -| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | -| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, and execute Builder CMS source writes | action-backed | `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | -| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | -| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | -| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | -| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | +| ID | Surface | User-visible action | Status | Actions | UI entrypoints | Durable effect | Exception / gap | Reliability risk | Spine priority | Test coverage | Coverage refs | Eval scenarios | Follow-up | +| ---------------------------------------------- | ----------- | ---------------------------------------------------------------------------------------------- | --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------ | -------------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | ------------------------------ | +| comments.threads | comments | List, add, reply, resolve, reopen, and delete comment threads | action-backed | `add-comment`, `delete-comment`, `list-comments`, `update-comment` | `app/components/editor/CommentsSidebar.tsx`, `app/hooks/use-comments.ts` | Comment threads, replies, anchors, mentions, resolution state, and deletion are stored through comment actions. | - | - | P0 | seeded | - | - | - | +| database.lifecycle-and-trash | database | Create, soft-delete, restore, list, and inspect content databases | action-backed | `create-content-database`, `create-inline-content-database`, `delete-content-database`, `get-content-database`, `list-content-databases`, `list-trashed-content-databases`, `restore-content-database` | `app/components/editor/SlashCommandMenu.tsx`, `app/hooks/use-content-database.ts`, `app/hooks/use-documents.ts` | Database pages and database records are created, read, soft-deleted, restored, and listed. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts` | `database-source-scope` | - | +| database.properties-and-view-config | database | Configure properties, values, ordering, and saved views | action-backed | `configure-document-property`, `delete-document-property`, `duplicate-document-property`, `get-content-database-personal-view`, `list-document-properties`, `reorder-document-property`, `set-document-property`, `update-content-database-personal-view`, `update-content-database-view` | `app/components/editor/DocumentProperties.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-document-properties.ts` | Property schemas, property values, property order, filters, sorts, grouping, hidden columns, view type, and view settings are stored. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| database.rows | database | Add, duplicate, move, open, and delete database rows | action-backed | `add-database-item`, `delete-database-items`, `delete-document`, `duplicate-database-items`, `duplicate-database-item`, `move-database-item`, `set-document-property` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx` | Database row backing documents and row ordering are created, duplicated, moved, edited, and deleted. | - | - | P0 | covered | `actions/database-row-batch-actions.db.test.ts`, `parity/__tests__/database-row-batch-reliability.test.ts` | `database-bulk-row-reliability` | - | +| editor.agent-assist-prompts | editor | Ask AI from slash generation or comment context | client-assist | - | `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/CommentsSidebar.tsx` | No direct durable mutation; the prompt asks the agent to use document actions when it decides to write. | - | - | P1 | none | - | - | - | +| editor.client-formatting-and-insertions | editor | Rich text formatting, selection state, slash block insertion, and copy actions | client-only-ephemeral | - | `app/components/editor/BubbleToolbar.tsx`, `app/components/editor/SlashCommandMenu.tsx`, `app/components/editor/DocumentToolbar.tsx` | - | - | - | P1 | none | - | - | - | +| editor.document-body-and-title | editor | Edit document title, body, icon, image alt text, and precise text | action-backed | `edit-document`, `pull-document`, `set-image-alt-text`, `transcribe-media`, `update-document` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/extensions/ImageBlock.tsx`, `app/components/editor/SlashCommandMenu.tsx` | Document content, title, icon, image metadata, and text replacements are saved to the same document source. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| local-files.components-workspace | local-files | Register, list, and write local MDX component workspaces | host-only | `list-local-component-files`, `register-local-component-workspace`, `write-local-component-file` | `app/routes/_app.local-files.tsx`, `actions/register-local-component-workspace.ts`, `actions/list-local-component-files.ts`, `actions/write-local-component-file.ts` | Trusted local component workspace registration and component file reads/writes support local MDX previews. | Workspace registration depends on a trusted Desktop folder path and is intentionally hidden with agentTool: false. | - | P1 | seeded | - | - | Local folder exception/docs PR | +| local-files.host-folder-handles | local-files | Choose, persist, remove, and write trusted local folder handles | host-only | - | `app/routes/_app.local-files.tsx` | Host directory handles and browser/Desktop write permissions are managed outside SQL action state. | Mounted local folders require browser/Desktop host handles that agents cannot safely or portably hold as normal tools. | - | P0 | none | - | - | Local folder exception/docs PR | +| local-files.import-export-mounted-folder | local-files | Import, check, export, push, and remove local folder source files | action-backed | `export-content-source`, `import-content-source`, `remove-local-file-source` | `app/routes/_app.local-files.tsx`, `actions/import-content-source.ts`, `actions/export-content-source.ts` | Local Markdown/MDX source files are imported into Content documents, editable Content documents are exported back to source-friendly files, and imported source entries can be removed without deleting files on disk. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | +| notion.route-backed-document-sync | source-sync | Notion document sync status, link, unlink, pull, push, resolve, create, search, and disconnect | action-backed | `connect-notion-status`, `create-and-link-notion-page`, `disconnect-notion`, `link-notion-page`, `list-notion-links`, `pull-notion-page`, `push-notion-page`, `refresh-notion-sync-status`, `resolve-notion-sync-conflict`, `search-notion-pages`, `sync-notion-comments`, `unlink-notion-page` | `app/hooks/use-notion.ts`, `app/components/editor/DocumentToolbar.tsx`, `app/components/editor/NotionSyncBar.tsx`, `app/components/editor/DocumentEditor.tsx` | Notion connection state, page search, link metadata, and local/remote document body sync state are read or mutated through Content actions. | Notion OAuth auth-url and callback routes remain route-shaped because they initiate and receive browser redirects rather than normal app data mutations. | - | P0 | covered | `parity/__tests__/matrix-route-gap-classify.test.ts` | - | - | +| sharing.document-discoverability-and-export | sharing | Share, hide from search, export, and reveal documents | action-backed | `export-document`, `reveal-local-source-file`, `set-document-discoverability`, `share-local-file-document` | `app/components/editor/DocumentToolbar.tsx`, `app/hooks/use-documents.ts` | Search discoverability, shareable copies, exports, and OS reveal requests are managed through Content actions. | - | - | P0 | covered | `actions/_local-file-documents.test.ts` | `local-file-source-truth` | - | +| sharing.os-reveal-local-source | sharing | Reveal a local source file in the system file manager | host-only | `reveal-local-source-file` | `app/components/editor/DocumentToolbar.tsx`, `actions/reveal-local-source-file.ts` | - | OS reveal depends on trusted local host capabilities and should not spend agent tool surface or imply portable hosted behavior. | - | P2 | seeded | - | - | Local folder exception/docs PR | +| sidebar.chrome-state | sidebar | Collapse sections and resize the sidebar | client-only-ephemeral | - | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/layout/Layout.tsx` | - | - | - | P2 | none | - | - | - | +| sidebar.document-tree-crud | sidebar | Create, delete, move, favorite, list, search, and open pages | action-backed | `create-document`, `delete-document`, `get-document`, `list-documents`, `move-document`, `search-documents`, `update-document` | `app/components/sidebar/DocumentSidebar.tsx`, `app/components/sidebar/DocumentTreeItem.tsx`, `app/hooks/use-documents.ts` | Document tree rows and document metadata are created, updated, deleted, moved, searched, or read. | - | - | P0 | covered | `actions/content-database-lifecycle.db.test.ts`, `actions/_local-file-documents.test.ts` | `document-search-edit` | - | +| sidebar.navigation-and-screen-context | sidebar | Navigate between documents and expose current screen context | action-equivalent | `navigate`, `view-screen` | `app/components/sidebar/DocumentSidebar.tsx`, `actions/navigate.ts`, `actions/view-screen.ts` | Application navigation state is updated or read so the agent can reason about the user's current page/view. | Human navigation is router-local, while agent navigation/screen inspection uses application-state actions to produce the same workspace orientation effect. | - | P1 | seeded | - | - | - | +| source-sync.builder-body-hydration-worker | source-sync | Process queued Builder CMS body hydration work | action-backed | `process-builder-body-hydration` | `app/components/editor/DocumentEditor.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Queued Builder body hydration entries are processed into readable Content document/database body state. | This action is intentionally hidden from the model with agentTool: false because it is an internal bounded queue worker; agents should use source refresh, review, and execution actions rather than manually driving hydration internals. | - | P0 | covered | `actions/_database-source-utils.test.ts` | - | - | +| source-sync.builder-cms-review-and-write-gates | source-sync | Review, stage, validate, and execute Builder CMS source writes | action-backed | `execute-builder-source-batch`, `execute-builder-source-execution`, `prepare-builder-source-execution`, `prepare-builder-source-review`, `review-content-database-source-change-set`, `set-content-database-source-write-mode`, `stage-builder-source-bulk-update`, `stage-builder-revision`, `validate-builder-source-execution` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/components/editor/database-sources/BuilderSourceReviewDialog.tsx` | Builder source write mode, staged reviews, validation records, and bounded execution records are created through guarded actions. | - | - | P0 | covered | `actions/builder-source-review-gates.db.test.ts`, `actions/execute-builder-source-execution.test.ts`, `actions/stage-builder-source-bulk-update.db.test.ts` | `builder-source-review-readonly` | - | +| source-sync.builder-documents | source-sync | List, pull, check, and push Builder docs/blog MDX documents | action-backed | `check-builder-doc`, `list-builder-docs`, `pull-builder-doc`, `push-builder-doc` | `actions/list-builder-docs.ts`, `actions/pull-builder-doc.ts`, `actions/check-builder-doc.ts`, `actions/push-builder-doc.ts` | Builder docs/blog entries can be read into Content, checked locally, and pushed through guarded Builder document actions. | - | - | P1 | seeded | - | - | - | +| source-sync.database-source-bindings | source-sync | Attach, inspect, refresh, disconnect, join, and bind database sources | action-backed | `add-content-database-source-field-property`, `attach-content-database-source`, `bind-content-database-source-field`, `change-content-database-source-role`, `disconnect-content-database-source`, `get-content-database-source`, `list-builder-cms-models`, `refresh-content-database-source`, `suggest-source-join-key` | `app/components/editor/DocumentDatabase.tsx`, `app/components/editor/DocumentProperties.tsx`, `app/components/editor/database/DatabaseView.tsx`, `app/hooks/use-content-database.ts` | Mounted database source metadata, fields, source role, join keys, and source-field/property bindings are stored and refreshed. | - | - | P0 | covered | `actions/bind-content-database-source-field.db.test.ts`, `actions/content-database-source-actions.test.ts`, `actions/resync-content-database-source.db.test.ts` | `database-source-scope` | - | +| source-sync.docs-content-dogfood-workspace | source-sync | Create or upgrade an internal Builder docs/blog dogfood workspace | action-backed | `configure-docs-content-dogfood-workspace` | `actions/configure-docs-content-dogfood-workspace.ts` | The workspace database gets content-operations fields, saved views, and row-level source safety indicators where source data exists. | - | Depends on separate scale, bulk-update, and source-component mapping lanes for broader docs/blog operational guarantees. | P1 | seeded | `actions/configure-docs-content-dogfood-workspace.test.ts` | - | - | +| source-sync.provider-api-and-staged-datasets | source-sync | Inspect provider APIs and stage/query/delete large provider datasets | action-backed | `delete-staged-dataset`, `list-staged-datasets`, `provider-api-catalog`, `provider-api-docs`, `provider-api-request`, `query-staged-dataset` | `actions/provider-api-catalog.ts`, `actions/provider-api-docs.ts`, `actions/provider-api-request.ts`, `actions/query-staged-dataset.ts` | Provider API metadata and staged dataset scratch storage support scoped agent/source analysis. | - | - | P1 | seeded | - | - | - | +| versions.history-and-restore | versions | Open version history and restore a previous document version | action-backed | `list-document-versions`, `restore-document-version` | `app/components/editor/VersionHistoryPanel.tsx`, `app/hooks/use-document-versions.ts` | Document versions are listed and selected versions can restore the document while snapshotting current state. | - | - | P0 | seeded | - | - | - | diff --git a/templates/content/parity/matrix.ts b/templates/content/parity/matrix.ts index 9a34ad3392..3114d8570e 100644 --- a/templates/content/parity/matrix.ts +++ b/templates/content/parity/matrix.ts @@ -425,6 +425,25 @@ export const parityMatrix: ParityRow[] = [ testCoverage: "seeded", followUpPR: null, }, + { + id: "source-sync.docs-content-dogfood-workspace", + surface: "source-sync", + label: "Create or upgrade an internal Builder docs/blog dogfood workspace", + uiEntrypoints: ["actions/configure-docs-content-dogfood-workspace.ts"], + durableEffect: + "The workspace database gets content-operations fields, saved views, and row-level source safety indicators where source data exists.", + uiImplementation: + "Currently agent/action-facing for internal dogfood setup rather than a primary visible UI wizard.", + status: "action-backed", + actions: ["configure-docs-content-dogfood-workspace"], + exception: null, + reliabilityRisk: + "Depends on separate scale, bulk-update, and source-component mapping lanes for broader docs/blog operational guarantees.", + spinePriority: "P1", + testCoverage: "seeded", + followUpPR: null, + coverageRefs: ["actions/configure-docs-content-dogfood-workspace.test.ts"], + }, { id: "source-sync.provider-api-and-staged-datasets", surface: "source-sync",