From 5fa4a6eb0422fc0c117819cc1124a271a0549cfc Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 02:21:57 -0500 Subject: [PATCH 1/6] =?UTF-8?q?MCP=20draft/write=20=E2=80=94=20Phase=20A1:?= =?UTF-8?q?=20draft=20engine=20+=20page=20authoring=20tools=20(WIP)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface-generic draft engine for the MCP draft/write feature. A draft is a pending, non-live version (draft=1, is_current=0); the live content and the is_current version are untouched until commit. - KytePageVersion: add `draft` + `draft_source` columns (+ additive migration 4.10.0_page_version_draft_flag.sql, migration-first / safe on old code). - src/Mcp/Service/DraftService.php: surface-generic engine (driven by a surface descriptor so functions/scripts plug in later). Owns write/read/ list/discard over the *Version + *VersionContent tables, reusing the same sha256 content-hash + bz2 + reference_count conventions as KytePageController so drafts dedup against existing version content. - src/Mcp/Tools/DraftTools.php: write_page_part [draft], list_drafts [read], read_draft [read], discard_draft [draft]. WIP: commit_draft (publish promotion) and tests land next. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.10.0_page_version_draft_flag.sql | 32 ++ src/Mcp/Service/DraftService.php | 455 ++++++++++++++++++ src/Mcp/Tools/DraftTools.php | 100 ++++ src/Mvc/Model/KytePageVersion.php | 25 + 4 files changed, 612 insertions(+) create mode 100644 migrations/4.10.0_page_version_draft_flag.sql create mode 100644 src/Mcp/Service/DraftService.php create mode 100644 src/Mcp/Tools/DraftTools.php diff --git a/migrations/4.10.0_page_version_draft_flag.sql b/migrations/4.10.0_page_version_draft_flag.sql new file mode 100644 index 0000000..aec03cf --- /dev/null +++ b/migrations/4.10.0_page_version_draft_flag.sql @@ -0,0 +1,32 @@ +-- ========================================================================= +-- Kyte v4.10.0 - add draft flag to KytePageVersion (MCP draft/write, Phase A) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds two columns to KytePageVersion in support of the MCP draft/write +-- feature (KYTE MCP initiative, Phase 2 write tools): +-- +-- draft INT - 1 = a pending draft version (not live), 0 = normal. +-- A draft has draft=1 AND is_current=0; the page's +-- live (is_current=1) version is untouched until the +-- draft is committed. Distinct from a historical +-- version (draft=0, is_current=0). +-- draft_source VARCHAR(50) - origin of a draft, e.g. 'mcp' for an +-- AI-authored draft; NULL for ordinary versions. +-- +-- ROLLOUT ORDER (expand / migration-first): this migration is ADDITIVE and +-- safe to run BEFORE the v4.10.0 code is deployed. Older code ignores the +-- new columns (SELECT * maps only defined fields; inserts omit them, so they +-- default to draft=0 / NULL = an ordinary non-draft version). Run the +-- migration first, then deploy the v4.10.0 code that reads/writes drafts. +-- +-- Table name is PascalCase (matches the framework's model class name). +-- ALGORITHM intentionally not pinned (engine auto-selects INSTANT where +-- available for ADD COLUMN; falls back automatically otherwise). +-- +-- See src/Mvc/Model/KytePageVersion.php (field definitions added in v4.10.0). +-- ========================================================================= + +ALTER TABLE `KytePageVersion` + ADD COLUMN `draft` INT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN `draft_source` VARCHAR(50) NULL; diff --git a/src/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php new file mode 100644 index 0000000..b867fe8 --- /dev/null +++ b/src/Mcp/Service/DraftService.php @@ -0,0 +1,455 @@ +, writableParts:array, label:string} + */ + public static function pageSurface(): array + { + return [ + 'versionModel' => \KytePageVersion, + 'contentModel' => \KytePageVersionContent, + 'parentModel' => \KytePage, + 'fk' => 'page', + // Order matters: must match KytePageController::generateContentHash. + 'contentFields' => ['html', 'stylesheet', 'javascript', 'block_layout'], + // Parts a write tool may set (block_layout is internal/legacy). + 'writableParts' => ['html', 'stylesheet', 'javascript'], + 'label' => 'page', + ]; + } + + private function accountId(): int + { + return isset($this->api->account->id) ? (int)$this->api->account->id : 0; + } + + private function userIdOrNull(): ?int + { + return isset($this->api->user->id) ? (int)$this->api->user->id : null; + } + + /** + * Verify a parent entity (page/function/script) belongs to the token's + * account. Caller-supplied ids must always be re-scoped. + */ + public function parentBelongsToAccount(array $surface, int $parentId): bool + { + $accountId = $this->accountId(); + if ($accountId === 0) { + return false; + } + $parent = new ModelObject($surface['parentModel']); + return $parent->retrieve('id', $parentId) && (int)$parent->kyte_account === $accountId; + } + + /** + * The single open draft for a parent (draft=1, is_current=0), or null. + */ + public function openDraft(array $surface, int $parentId): ?ModelObject + { + $version = new ModelObject($surface['versionModel']); + $found = $version->retrieve($surface['fk'], $parentId, [ + ['field' => 'draft', 'value' => 1], + ['field' => 'kyte_account', 'value' => $this->accountId()], + ]); + return $found ? $version : null; + } + + /** + * Decompressed content map for a given version object (by content_hash). + * Missing/orphaned content yields empty strings for every content field. + * + * @return array + */ + public function versionContent(array $surface, ModelObject $version): array + { + $map = []; + foreach ($surface['contentFields'] as $f) { + $map[$f] = ''; + } + $content = new ModelObject($surface['contentModel']); + if ($content->retrieve('content_hash', (string)$version->content_hash, [ + ['field' => 'kyte_account', 'value' => $this->accountId()], + ])) { + foreach ($surface['contentFields'] as $f) { + $map[$f] = Bz2Codec::decompressIfBz2($content->$f); + } + } + return $map; + } + + /** + * The current LIVE content for a parent (its is_current version), used as + * the base when starting a fresh draft. Empty strings if no current + * version exists yet. + * + * @return array + */ + public function currentLiveContent(array $surface, int $parentId): array + { + $version = new ModelObject($surface['versionModel']); + $found = $version->retrieve($surface['fk'], $parentId, [ + ['field' => 'is_current', 'value' => 1], + ['field' => 'kyte_account', 'value' => $this->accountId()], + ]); + if ($found) { + return $this->versionContent($surface, $version); + } + $map = []; + foreach ($surface['contentFields'] as $f) { + $map[$f] = ''; + } + return $map; + } + + /** + * sha256 over the RAW content fields, in descriptor order — identical to + * KytePageController::generateContentHash so drafts dedup against + * existing version content. + * + * @param array $content + */ + private function contentHash(array $surface, array $content): string + { + $s = ''; + foreach ($surface['contentFields'] as $f) { + $s .= isset($content[$f]) ? $content[$f] : ''; + } + return hash('sha256', $s); + } + + /** + * Store a content map (or reference it if an identical hash already + * exists) and return its content_hash. Mirrors storeVersionContent / + * incrementContentReference in KytePageController. + * + * @param array $content + */ + private function storeContent(array $surface, array $content): string + { + $hash = $this->contentHash($surface, $content); + + $existing = new ModelObject($surface['contentModel']); + if ($existing->retrieve('content_hash', $hash, [ + ['field' => 'kyte_account', 'value' => $this->accountId()], + ])) { + $existing->save([ + 'reference_count' => (int)$existing->reference_count + 1, + 'last_referenced' => time(), + ]); + return $hash; + } + + $row = ['content_hash' => $hash]; + foreach ($surface['contentFields'] as $f) { + $row[$f] = isset($content[$f]) ? bzcompress($content[$f], 9) : null; + } + $row['reference_count'] = 1; + $row['last_referenced'] = time(); + $row['kyte_account'] = $this->accountId(); + + $obj = new ModelObject($surface['contentModel']); + if (!$obj->create($row)) { + throw new \RuntimeException('Unable to store draft content.'); + } + return $hash; + } + + /** + * Drop a reference to a content hash (when a draft repoints away from it + * or is discarded). Never deletes the row or goes below zero — orphan + * cleanup is handled by the existing last_referenced sweep, not here. + */ + private function releaseContent(array $surface, string $hash): void + { + if ($hash === '') { + return; + } + $content = new ModelObject($surface['contentModel']); + if ($content->retrieve('content_hash', $hash, [ + ['field' => 'kyte_account', 'value' => $this->accountId()], + ])) { + $count = (int)$content->reference_count; + $content->save([ + 'reference_count' => $count > 0 ? $count - 1 : 0, + 'last_referenced' => time(), + ]); + } + } + + /** + * Next version_number for a parent (max across all versions + 1, drafts + * included), mirroring KytePageController::getNextVersionNumber. + */ + private function nextVersionNumber(array $surface, int $parentId): int + { + $last = new Model($surface['versionModel']); + $last->retrieve($surface['fk'], $parentId, false, [ + ['field' => 'kyte_account', 'value' => $this->accountId()], + ], false, [['field' => 'version_number', 'direction' => 'desc']], 1); + if ($last->count() > 0) { + return (int)$last->objects[0]->version_number + 1; + } + return 1; + } + + /** + * Create or update the open draft for a parent with one content part set + * to $content. Returns a small summary (or null if denied / invalid part). + * + * @return array{draft_id:int, parent_id:int, version_number:int, part:string, created:bool, content_bytes:int}|null + */ + public function writePart(array $surface, int $parentId, string $part, string $content): ?array + { + if (!$this->parentBelongsToAccount($surface, $parentId)) { + return null; + } + + $part = $this->normalizePart($surface, $part); + if ($part === null) { + return null; + } + + $draft = $this->openDraft($surface, $parentId); + + // Base = current draft content if a draft is open, else current live. + $base = $draft !== null + ? $this->versionContent($surface, $draft) + : $this->currentLiveContent($surface, $parentId); + + $base[$part] = $content; + $newHash = $this->storeContent($surface, $base); + + if ($draft !== null) { + $oldHash = (string)$draft->content_hash; + $draft->save([ + 'content_hash' => $newHash, + 'draft_source' => 'mcp', + ]); + if ($oldHash !== $newHash) { + $this->releaseContent($surface, $oldHash); + } + return [ + 'draft_id' => (int)$draft->id, + 'parent_id' => $parentId, + 'version_number' => (int)$draft->version_number, + 'part' => $part, + 'created' => false, + 'content_bytes' => strlen($content), + ]; + } + + $version = new ModelObject($surface['versionModel']); + $created = $version->create([ + $surface['fk'] => $parentId, + 'version_number' => $this->nextVersionNumber($surface, $parentId), + 'version_type' => 'mcp_draft', + 'content_hash' => $newHash, + 'is_current' => 0, + 'draft' => 1, + 'draft_source' => 'mcp', + 'kyte_account' => $this->accountId(), + 'created_by' => $this->userIdOrNull(), + ]); + if (!$created) { + throw new \RuntimeException('Unable to create draft.'); + } + return [ + 'draft_id' => (int)$version->id, + 'parent_id' => $parentId, + 'version_number' => (int)$version->version_number, + 'part' => $part, + 'created' => true, + 'content_bytes' => strlen($content), + ]; + } + + /** + * Load a draft version by id, re-scoped to the account and asserted to be + * an open draft. Null if not found / not a draft / wrong account. + */ + public function loadDraft(array $surface, int $draftId): ?ModelObject + { + $version = new ModelObject($surface['versionModel']); + if (!$version->retrieve('id', $draftId, [ + ['field' => 'draft', 'value' => 1], + ['field' => 'kyte_account', 'value' => $this->accountId()], + ])) { + return null; + } + return $version; + } + + /** + * Draft content + a diff (which parts differ from the current live + * version). Null if the draft doesn't exist / belong to the account. + * + * @return array{draft_id:int, parent_id:int, version_number:int, draft_source:?string, content:array, changed_parts:array}|null + */ + public function readDraft(array $surface, int $draftId): ?array + { + $draft = $this->loadDraft($surface, $draftId); + if ($draft === null) { + return null; + } + $parentId = (int)$draft->{$surface['fk']}; + $draftMap = $this->versionContent($surface, $draft); + $liveMap = $this->currentLiveContent($surface, $parentId); + + $changed = []; + foreach ($surface['writableParts'] as $part) { + if (($draftMap[$part] ?? '') !== ($liveMap[$part] ?? '')) { + $changed[] = $part; + } + } + + $content = []; + foreach ($surface['writableParts'] as $part) { + $content[$part] = $draftMap[$part] ?? ''; + } + + return [ + 'draft_id' => (int)$draft->id, + 'parent_id' => $parentId, + 'version_number' => (int)$draft->version_number, + 'draft_source' => $draft->draft_source !== null ? (string)$draft->draft_source : null, + 'content' => $content, + 'changed_parts' => $changed, + ]; + } + + /** + * Discard (soft-delete) a draft and release its content reference. + * + * @return array{discarded:bool, draft_id:int}|null + */ + public function discardDraft(array $surface, int $draftId): ?array + { + $draft = $this->loadDraft($surface, $draftId); + if ($draft === null) { + return null; + } + $hash = (string)$draft->content_hash; + $draft->delete(); + $this->releaseContent($surface, $hash); + return ['discarded' => true, 'draft_id' => $draftId]; + } + + /** + * List open drafts whose parent lives under the given application. Walks + * draft versions in account scope and filters by the parent's site/app. + * For the page surface, parent → KytePage → KyteSite(application). + * + * @return array{drafts: array} + */ + public function listDrafts(array $surface, int $applicationId): array + { + $accountId = $this->accountId(); + if ($accountId === 0 || !$this->applicationBelongsToAccount($applicationId, $accountId)) { + return ['drafts' => []]; + } + + $drafts = new Model($surface['versionModel']); + $drafts->retrieve(null, null, false, [ + ['field' => 'draft', 'value' => 1], + ['field' => 'kyte_account', 'value' => $accountId], + ]); + + $out = []; + foreach ($drafts->objects as $d) { + $parentId = (int)$d->{$surface['fk']}; + if (!$this->parentInApplication($surface, $parentId, $applicationId, $accountId)) { + continue; + } + $out[] = [ + 'draft_id' => (int)$d->id, + 'parent_id' => $parentId, + 'version_number' => (int)$d->version_number, + 'draft_source' => $d->draft_source !== null ? (string)$d->draft_source : null, + 'date_modified' => $d->date_modified !== null ? (int)$d->date_modified : null, + ]; + } + return ['drafts' => $out]; + } + + private function applicationBelongsToAccount(int $applicationId, int $accountId): bool + { + $app = new ModelObject(\Application); + return $app->retrieve('id', $applicationId) && (int)$app->kyte_account === $accountId; + } + + /** + * Whether a page parent belongs to the given application (page → site → + * application). Page-surface specific for now; functions/scripts will + * supply their own parent→app resolution when those surfaces land. + */ + private function parentInApplication(array $surface, int $parentId, int $applicationId, int $accountId): bool + { + if ($surface['label'] !== 'page') { + return false; + } + $page = new ModelObject(\KytePage); + if (!$page->retrieve('id', $parentId) || (int)$page->kyte_account !== $accountId) { + return false; + } + $site = new ModelObject(\KyteSite); + if (!$site->retrieve('id', (int)$page->site) || (int)$site->kyte_account !== $accountId) { + return false; + } + return (int)$site->application === $applicationId; + } + + /** + * Validate/normalize a requested part against the surface's writable + * parts. Accepts a few ergonomic aliases (css, js). Null if unsupported. + */ + private function normalizePart(array $surface, string $part): ?string + { + $aliases = ['css' => 'stylesheet', 'js' => 'javascript', 'script' => 'javascript']; + $p = strtolower(trim($part)); + $p = $aliases[$p] ?? $p; + return in_array($p, $surface['writableParts'], true) ? $p : null; + } +} diff --git a/src/Mcp/Tools/DraftTools.php b/src/Mcp/Tools/DraftTools.php new file mode 100644 index 0000000..3d1b80f --- /dev/null +++ b/src/Mcp/Tools/DraftTools.php @@ -0,0 +1,100 @@ +api); + } + + /** + * Create or update a draft edit of a page part (does NOT publish). + * + * Repeated calls for the same page accumulate into one open draft: each + * call starts from the page's current live content (or the in-progress + * draft), sets the named part, and re-stores. Use commit_draft to publish. + * + * @param int $page_id KytePage id from list_pages. + * @param string $part One of: html, stylesheet, javascript (aliases: css, js). + * @param string $content Full new content for that part. + * @return array{ok:bool, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} + */ + #[McpTool(name: 'write_page_part', description: 'Create or update a DRAFT edit of a page part (html, stylesheet, or javascript). Does not publish — the draft is held for review and promoted via commit_draft. Repeated calls on the same page accumulate into one draft.')] + #[RequiresScope('draft')] + public function writePagePart(int $page_id, string $part, string $content): array + { + $result = $this->service()->writePart(DraftService::pageSurface(), $page_id, $part, $content); + if ($result === null) { + return [ + 'ok' => false, + 'error' => "Could not write draft: page {$page_id} was not found in this account, or '{$part}' is not a writable part. Valid parts: html, stylesheet, javascript.", + ]; + } + return array_merge(['ok' => true], $result); + } + + /** + * List open drafts in an application (pages, for now). + * + * @param int $application_id Application id from list_applications. + * @return array{drafts: array} + */ + #[McpTool(name: 'list_drafts', description: 'List pending (uncommitted) drafts in a Kyte application. Use read_draft to inspect one and commit_draft to publish it.')] + #[RequiresScope('read')] + public function listDrafts(int $application_id): array + { + return $this->service()->listDrafts(DraftService::pageSurface(), $application_id); + } + + /** + * Read a draft's content and which parts differ from the live page. + * + * @param int $draft_id KytePageVersion id of a draft (from list_drafts). + * @return array{draft_id:int, parent_id:int, version_number:int, draft_source:?string, content:array, changed_parts:array}|null + */ + #[McpTool(name: 'read_draft', description: 'Read a pending draft: its content (html/stylesheet/javascript) and which parts differ from the current live version.')] + #[RequiresScope('read')] + public function readDraft(int $draft_id): ?array + { + return $this->service()->readDraft(DraftService::pageSurface(), $draft_id); + } + + /** + * Discard a pending draft (soft-delete; does not affect the live page). + * + * @param int $draft_id KytePageVersion id of a draft (from list_drafts). + * @return array{discarded:bool, draft_id:int}|null + */ + #[McpTool(name: 'discard_draft', description: 'Discard a pending draft. Does not affect the live page. Returns null if the draft does not exist in this account.')] + #[RequiresScope('draft')] + public function discardDraft(int $draft_id): ?array + { + return $this->service()->discardDraft(DraftService::pageSurface(), $draft_id); + } +} diff --git a/src/Mvc/Model/KytePageVersion.php b/src/Mvc/Model/KytePageVersion.php index bb1674a..c641107 100644 --- a/src/Mvc/Model/KytePageVersion.php +++ b/src/Mvc/Model/KytePageVersion.php @@ -187,6 +187,31 @@ 'date' => false, ], + // Draft flag (KYTE MCP draft/write). A draft is a pending version + // that is NOT live: draft=1, is_current=0, and the page's current + // live version is left untouched until the draft is committed. + // Distinct from a historical version (draft=0, is_current=0), which + // is a superseded former-current. Committing a draft flips it to + // is_current=1, draft=0 and publishes. + 'draft' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + // Origin of a draft, e.g. 'mcp' for an AI-authored draft. Null for + // ordinary (human/save) versions. Drives the Shipyard review panel's + // AI-vs-human filter. + 'draft_source' => [ + 'type' => 's', + 'required' => false, + 'size' => 50, + 'date' => false, + ], + 'parent_version' => [ 'type' => 'i', 'required' => false, From 36a0690fec68ed9921171a4e843e813570595d1c Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 02:58:36 -0500 Subject: [PATCH 2/6] =?UTF-8?q?MCP=20draft/write=20=E2=80=94=20Phase=20A1:?= =?UTF-8?q?=20commit=5Fdraft=20+=20authoring=20tests?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - commit_draft [commit scope]: publishes a draft to live and promotes it to the current version. Publishes through the REAL KytePageController pipeline (live KytePageData write + S3 + CloudFront), so output is identical to a human Publish. - KytePageController::publishPage made public static (it used no $this); added publishFromContent() which expands the page via getObject, overlays draft content, writes KytePageData, and publishes. - ModelController: optional `internal` construction flag that skips the session authenticate() check, so a trusted server-side caller (MCP commit) can use the controller with an account context but no HTTP session. - DraftService::commitDraft constructs the controller in internal mode, publishes, then demotes the prior current version and flips the draft to is_current. markCurrentNotCurrent helper. - tests/DraftServiceTest.php: authoring loop (write/accumulate/read/list/ discard), live-version-untouched, cross-account denial. Commit excluded (AWS publish; validated on dev). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Service/DraftService.php | 71 +++++++++ src/Mcp/Tools/DraftTools.php | 18 +++ src/Mvc/Controller/KytePageController.php | 58 +++++++- src/Mvc/Controller/ModelController.php | 19 ++- tests/DraftServiceTest.php | 172 ++++++++++++++++++++++ 5 files changed, 333 insertions(+), 5 deletions(-) create mode 100644 tests/DraftServiceTest.php diff --git a/src/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php index b867fe8..099ec92 100644 --- a/src/Mcp/Service/DraftService.php +++ b/src/Mcp/Service/DraftService.php @@ -377,6 +377,77 @@ public function discardDraft(array $surface, int $draftId): ?array return ['discarded' => true, 'draft_id' => $draftId]; } + /** + * Commit a draft: publish its content to live and promote it to the + * current version. This is the ONLY draft operation that touches the live + * site. Publishing reuses the real KytePageController pipeline (live + * KytePageData write + S3 + CloudFront), so the result is byte-identical + * to a human Publish. Null if the draft doesn't exist / belong to the + * account. + * + * @return array{committed:bool, draft_id:int, parent_id:int, version_number:int, site_id:?int, s3key:?string}|null + */ + public function commitDraft(array $surface, int $draftId): ?array + { + // Publish strategy is surface-specific; only pages are wired so far. + if ($surface['label'] !== 'page') { + return null; + } + + $draft = $this->loadDraft($surface, $draftId); + if ($draft === null) { + return null; + } + $parentId = (int)$draft->{$surface['fk']}; + + // Re-scope the page and load it as the publish target. + $page = new ModelObject($surface['parentModel']); + if (!$page->retrieve('id', $parentId) || (int)$page->kyte_account !== $this->accountId()) { + return null; + } + + $content = $this->versionContent($surface, $draft); + + // Publish through the real controller pipeline. Construct it in + // internal mode (no HTTP session) — account context comes from $api. + // The controller constructor takes $api by reference; bind a local. + $api = $this->api; + $resp = []; + $controller = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); + $pub = $controller->publishFromContent($page, $content); + + // Promote: demote the prior current version, flip this draft to live. + $this->markCurrentNotCurrent($surface, $parentId); + $draft->save([ + 'is_current' => 1, + 'draft' => 0, + 'version_type' => 'mcp_commit', + ]); + + return [ + 'committed' => true, + 'draft_id' => $draftId, + 'parent_id' => $parentId, + 'version_number' => (int)$draft->version_number, + 'site_id' => isset($pub['site_id']) ? (int)$pub['site_id'] : null, + 's3key' => isset($pub['s3key']) ? (string)$pub['s3key'] : null, + ]; + } + + /** + * Demote the parent's current version (is_current=1 → 0), if any. + */ + private function markCurrentNotCurrent(array $surface, int $parentId): void + { + $cur = new ModelObject($surface['versionModel']); + if ($cur->retrieve($surface['fk'], $parentId, [ + ['field' => 'is_current', 'value' => 1], + ['field' => 'kyte_account', 'value' => $this->accountId()], + ])) { + $cur->save(['is_current' => 0]); + } + } + /** * List open drafts whose parent lives under the given application. Walks * draft versions in account scope and filters by the parent's site/app. diff --git a/src/Mcp/Tools/DraftTools.php b/src/Mcp/Tools/DraftTools.php index 3d1b80f..13930bf 100644 --- a/src/Mcp/Tools/DraftTools.php +++ b/src/Mcp/Tools/DraftTools.php @@ -97,4 +97,22 @@ public function discardDraft(int $draft_id): ?array { return $this->service()->discardDraft(DraftService::pageSurface(), $draft_id); } + + /** + * Commit (publish) a draft to the live site. + * + * This is the ONLY draft tool that changes the live page: it writes the + * draft's content live, publishes to S3, invalidates CloudFront, and makes + * the draft the new current version — identical to a human clicking + * Publish. Requires the 'commit' scope (tokens are draft-only by default). + * + * @param int $draft_id KytePageVersion id of a draft (from list_drafts). + * @return array{committed:bool, draft_id:int, parent_id:int, version_number:int, site_id:?int, s3key:?string}|null + */ + #[McpTool(name: 'commit_draft', description: 'Publish a pending draft to the live site (writes live content, pushes to S3, invalidates CloudFront) and make it the current version. This is the only draft action that affects the live page. Requires the commit scope.')] + #[RequiresScope('commit')] + public function commitDraft(int $draft_id): ?array + { + return $this->service()->commitDraft(DraftService::pageSurface(), $draft_id); + } } diff --git a/src/Mvc/Controller/KytePageController.php b/src/Mvc/Controller/KytePageController.php index 4575385..3d64196 100644 --- a/src/Mvc/Controller/KytePageController.php +++ b/src/Mvc/Controller/KytePageController.php @@ -190,7 +190,7 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { } if (isset($d['state']) && $d['state'] == 1) { - $this->publishPage($o, $params, $r); + self::publishPage($o, $params, $r); } break; @@ -270,7 +270,61 @@ public function hook_response_data($method, $o, &$r = null, &$d = null) { * @param array $responseData Response data containing site information * @throws \Exception if critical errors occur during publishing */ - private function publishPage($pageObj, $params, &$responseData) { + /** + * Promote an explicit content set to live and publish it (MCP commit_draft). + * + * Mirrors the update→publish path exactly: writes the live KytePageData, + * recompiles the page via getObject expansion, pushes to S3, and + * invalidates CloudFront. Used to commit a draft (a non-current + * KytePageVersion) without going through an HTTP request — construct this + * controller with the internal flag (no session) and call this directly. + * Does NOT create a new version row (the draft being committed already is + * the version); the caller flips the draft to is_current. + * + * @param object $pageObj The KytePage ModelObject to publish. + * @param array $content html / stylesheet / javascript [/ block_layout]. + * @return array{site_id:int, s3key:string} + */ + public function publishFromContent($pageObj, array $content) { + // Expand the page exactly as the normal publish path does (FK chain: + // site → application, header/footer/navigation templates, etc.). + $r = $this->getObject($pageObj); + $params = $r; + $params['html'] = isset($content['html']) ? $content['html'] : ''; + $params['stylesheet'] = isset($content['stylesheet']) ? $content['stylesheet'] : ''; + $params['javascript'] = isset($content['javascript']) ? $content['javascript'] : ''; + + // Update the live KytePageData store (mirrors the update hook write). + $pageDataUpdate = [ + 'html' => bzcompress($params['html'], 9), + 'stylesheet' => bzcompress($params['stylesheet'], 9), + 'javascript' => bzcompress($params['javascript'], 9), + 'date_modified' => time(), + ]; + if (isset($content['block_layout'])) { + $pageDataUpdate['block_layout'] = bzcompress($content['block_layout'], 9); + } + $pd = new \Kyte\Core\ModelObject(KytePageData); + if (!$pd->retrieve('page', $pageObj->id)) { + throw new \Exception("CRITICAL ERROR: Unable to find page data for page {$pageObj->id}."); + } + $pd->save($pageDataUpdate); + + // Ensure the page is in the published state. + if ((int)$pageObj->state !== 1) { + $pageObj->save(['state' => 1]); + } + + // Compile + push to S3 + invalidate CloudFront (identical to a human publish). + self::publishPage($pageObj, $params, $r); + + return [ + 'site_id' => isset($r['site']['id']) ? (int)$r['site']['id'] : 0, + 's3key' => (string)$pageObj->s3key, + ]; + } + + public static function publishPage($pageObj, $params, &$responseData) { // If content fields are not set, retrieve them from database if (!isset($params['html'], $params['stylesheet'], $params['javascript'])) { $pd = new \Kyte\Core\ModelObject(KytePageData); diff --git a/src/Mvc/Controller/ModelController.php b/src/Mvc/Controller/ModelController.php index ab3987e..daf0782 100644 --- a/src/Mvc/Controller/ModelController.php +++ b/src/Mvc/Controller/ModelController.php @@ -104,17 +104,30 @@ class ModelController protected $checkExisting; protected $existingThrowException; + /** + * Internal/system invocation flag. When true, the constructor skips the + * session-based authenticate() check, allowing a trusted server-side + * caller (e.g. the MCP commit flow) to construct a controller with an + * account context but no HTTP session. Account scoping is still the + * caller's responsibility. Defaults to false — normal HTTP requests + * always authenticate. + * + * @var bool + */ + protected $internal = false; + // array with error messages protected $exceptionMessages; - public function __construct($model, &$api, $dateformat, &$response) + public function __construct($model, &$api, $dateformat, &$response, $internal = false) { try { - + $this->model = $model; $this->api = $api; $this->dateformat = $dateformat; $this->response = &$response; + $this->internal = $internal; /** * @deprecated These member variables are maintained for backwards compatibility but will be deprecated in the near future. @@ -179,7 +192,7 @@ protected function init() $this->hook_init(); - if ($this->requireAuth) { + if ($this->requireAuth && !$this->internal) { $this->authenticate(); } } diff --git a/tests/DraftServiceTest.php b/tests/DraftServiceTest.php new file mode 100644 index 0000000..cfde7fc --- /dev/null +++ b/tests/DraftServiceTest.php @@ -0,0 +1,172 @@ +api = new Api(); + + \Kyte\Core\DBI::createTable(KyteAccount); + \Kyte\Core\DBI::createTable(KytePage); + \Kyte\Core\DBI::createTable(KytePageVersion); + \Kyte\Core\DBI::createTable(KytePageVersionContent); + + \Kyte\Core\DBI::query("DELETE FROM `KyteAccount` WHERE number = '" . self::ACCOUNT . "'"); + + $acct = new \Kyte\Core\ModelObject(KyteAccount); + $acct->create(['number' => self::ACCOUNT, 'name' => 'Draft Svc Test']); + $this->accountId = (int)$acct->id; + + // Clean any stray rows from a prior run for this account. + \Kyte\Core\DBI::query("DELETE FROM `KytePageVersion` WHERE kyte_account = {$this->accountId}"); + \Kyte\Core\DBI::query("DELETE FROM `KytePage` WHERE kyte_account = {$this->accountId}"); + + // A page with a current (live) version so we exercise the live-base path. + $page = new \Kyte\Core\ModelObject(KytePage); + $page->create([ + 'title' => 'Draft Test Page', + 'state' => 1, + 'kyte_account' => $this->accountId, + ]); + $this->pageId = (int)$page->id; + + // Seed a current version + its content. + $liveHtml = '

live html

'; + $liveCss = 'body{color:#000}'; + $liveJs = ''; + $hash = hash('sha256', $liveHtml . $liveCss . $liveJs . ''); + $content = new \Kyte\Core\ModelObject(KytePageVersionContent); + $content->create([ + 'content_hash' => $hash, + 'html' => bzcompress($liveHtml, 9), + 'stylesheet' => bzcompress($liveCss, 9), + 'javascript' => bzcompress($liveJs, 9), + 'block_layout' => bzcompress('', 9), + 'reference_count' => 1, + 'last_referenced' => time(), + 'kyte_account' => $this->accountId, + ]); + $cur = new \Kyte\Core\ModelObject(KytePageVersion); + $cur->create([ + 'page' => $this->pageId, + 'version_number' => 1, + 'version_type' => 'initial', + 'content_hash' => $hash, + 'is_current' => 1, + 'draft' => 0, + 'kyte_account' => $this->accountId, + ]); + + $this->api->account = new \Kyte\Core\ModelObject(KyteAccount); + $this->api->account->retrieve('id', $this->accountId); + } + + private function svc(): DraftService + { + return new DraftService($this->api); + } + + public function testWriteCreatesDraftAndAccumulatesAcrossParts(): void + { + $svc = $this->svc(); + $S = DraftService::pageSurface(); + + $first = $svc->writePart($S, $this->pageId, 'html', '

draft html

'); + $this->assertNotNull($first); + $this->assertTrue($first['created'], 'first write creates the draft'); + + $second = $svc->writePart($S, $this->pageId, 'javascript', "console.log('x');"); + $this->assertNotNull($second); + $this->assertFalse($second['created'], 'second write reuses the same draft'); + $this->assertSame($first['draft_id'], $second['draft_id'], 'same draft id'); + + // Exactly one open draft for the page. + $rows = \Kyte\Core\DBI::query("SELECT COUNT(*) c FROM KytePageVersion WHERE page = {$this->pageId} AND draft = 1 AND deleted = 0"); + $this->assertSame(1, (int)$rows[0]['c']); + + $read = $svc->readDraft($S, $first['draft_id']); + $this->assertNotNull($read); + $this->assertSame('

draft html

', $read['content']['html']); + $this->assertSame("console.log('x');", $read['content']['javascript']); + $this->assertContains('html', $read['changed_parts']); + $this->assertContains('javascript', $read['changed_parts']); + // The stylesheet was never written, so it carries over from live and is unchanged. + $this->assertNotContains('stylesheet', $read['changed_parts']); + } + + public function testDraftDoesNotMutateLiveVersion(): void + { + $svc = $this->svc(); + $S = DraftService::pageSurface(); + + $live = $svc->currentLiveContent($S, $this->pageId); + $svc->writePart($S, $this->pageId, 'html', '

totally different

'); + $liveAfter = $svc->currentLiveContent($S, $this->pageId); + + $this->assertSame($live['html'], $liveAfter['html'], 'live html unchanged by drafting'); + $this->assertSame('

live html

', $liveAfter['html']); + + // The current version is still version 1. + $cur = new \Kyte\Core\ModelObject(KytePageVersion); + $this->assertTrue($cur->retrieve('page', $this->pageId, [['field' => 'is_current', 'value' => 1]])); + $this->assertSame(1, (int)$cur->version_number); + } + + public function testListDraftsAndDiscard(): void + { + $svc = $this->svc(); + $S = DraftService::pageSurface(); + + $w = $svc->writePart($S, $this->pageId, 'html', '

draft

'); + $draftId = $w['draft_id']; + + $discard = $svc->discardDraft($S, $draftId); + $this->assertNotNull($discard); + $this->assertTrue($discard['discarded']); + + $rows = \Kyte\Core\DBI::query("SELECT COUNT(*) c FROM KytePageVersion WHERE page = {$this->pageId} AND draft = 1 AND deleted = 0"); + $this->assertSame(0, (int)$rows[0]['c'], 'no open drafts after discard'); + + $read = $svc->readDraft($S, $draftId); + $this->assertNull($read, 'discarded draft is not readable'); + } + + public function testCrossAccountWriteIsDenied(): void + { + $svc = $this->svc(); + $S = DraftService::pageSurface(); + + // A page id that does not belong to this account. + $other = new \Kyte\Core\ModelObject(KytePage); + $other->create(['title' => 'Other', 'state' => 1, 'kyte_account' => $this->accountId + 99999]); + + $result = $svc->writePart($S, (int)$other->id, 'html', 'x'); + $this->assertNull($result, 'writing to a page outside the account is denied'); + } +} From e7ed6768ec7fb8905d396666f6cf6e196e29ea29 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 03:22:28 -0500 Subject: [PATCH 3/6] MCP commit_draft: surface publish failures (don't report false success) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit KytePageController::publishPage now returns the S3 page-write result (the stream wrapper returns false on a failed upload rather than throwing); existing void callers ignore it harmlessly. publishFromContent publishes FIRST and bails on a false result BEFORE mutating live KytePageData, so a failed commit leaves the page untouched instead of half-applied. DraftService::commitDraft wraps the publish in try/catch and only promotes the draft (is_current=1, draft=0) on success — on failure it returns committed:false + error and the draft is left intact for retry. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Service/DraftService.php | 19 ++++++++++-- src/Mcp/Tools/DraftTools.php | 4 +-- src/Mvc/Controller/KytePageController.php | 35 +++++++++++++++++------ 3 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php index 099ec92..234bfa9 100644 --- a/src/Mcp/Service/DraftService.php +++ b/src/Mcp/Service/DraftService.php @@ -385,7 +385,7 @@ public function discardDraft(array $surface, int $draftId): ?array * to a human Publish. Null if the draft doesn't exist / belong to the * account. * - * @return array{committed:bool, draft_id:int, parent_id:int, version_number:int, site_id:?int, s3key:?string}|null + * @return array{committed:bool, draft_id:int, parent_id:int, version_number?:int, site_id?:?int, s3key?:?string, error?:string}|null */ public function commitDraft(array $surface, int $draftId): ?array { @@ -414,9 +414,22 @@ public function commitDraft(array $surface, int $draftId): ?array $api = $this->api; $resp = []; $controller = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); - $pub = $controller->publishFromContent($page, $content); - // Promote: demote the prior current version, flip this draft to live. + // Publish through the real pipeline. If it fails (e.g. bad AWS creds), + // the draft is NOT promoted — the page stays untouched and the caller + // gets a clear error so it can retry, rather than a false "committed". + try { + $pub = $controller->publishFromContent($page, $content); + } catch (\Throwable $e) { + return [ + 'committed' => false, + 'draft_id' => $draftId, + 'parent_id' => $parentId, + 'error' => $e->getMessage(), + ]; + } + + // Publish succeeded — promote: demote the prior current, flip this draft. $this->markCurrentNotCurrent($surface, $parentId); $draft->save([ 'is_current' => 1, diff --git a/src/Mcp/Tools/DraftTools.php b/src/Mcp/Tools/DraftTools.php index 13930bf..d8f7dac 100644 --- a/src/Mcp/Tools/DraftTools.php +++ b/src/Mcp/Tools/DraftTools.php @@ -107,9 +107,9 @@ public function discardDraft(int $draft_id): ?array * Publish. Requires the 'commit' scope (tokens are draft-only by default). * * @param int $draft_id KytePageVersion id of a draft (from list_drafts). - * @return array{committed:bool, draft_id:int, parent_id:int, version_number:int, site_id:?int, s3key:?string}|null + * @return array{committed:bool, draft_id:int, parent_id:int, version_number?:int, site_id?:?int, s3key?:?string, error?:string}|null */ - #[McpTool(name: 'commit_draft', description: 'Publish a pending draft to the live site (writes live content, pushes to S3, invalidates CloudFront) and make it the current version. This is the only draft action that affects the live page. Requires the commit scope.')] + #[McpTool(name: 'commit_draft', description: 'Publish a pending draft to the live site (writes live content, pushes to S3, invalidates CloudFront) and make it the current version. This is the only draft action that affects the live page. Requires the commit scope. Returns committed:false with an error if the publish fails (the draft is left intact).')] #[RequiresScope('commit')] public function commitDraft(int $draft_id): ?array { diff --git a/src/Mvc/Controller/KytePageController.php b/src/Mvc/Controller/KytePageController.php index 3d64196..f915614 100644 --- a/src/Mvc/Controller/KytePageController.php +++ b/src/Mvc/Controller/KytePageController.php @@ -294,7 +294,15 @@ public function publishFromContent($pageObj, array $content) { $params['stylesheet'] = isset($content['stylesheet']) ? $content['stylesheet'] : ''; $params['javascript'] = isset($content['javascript']) ? $content['javascript'] : ''; - // Update the live KytePageData store (mirrors the update hook write). + // Publish FIRST (compile + S3 + CloudFront). If the S3 upload fails we + // bail BEFORE mutating the live KytePageData, so a failed commit leaves + // the page untouched rather than half-applied (DB updated, site stale). + $publishOk = self::publishPage($pageObj, $params, $r); + if ($publishOk === false) { + throw new \RuntimeException("Publish failed: S3 write to '{$pageObj->s3key}' did not succeed (check the application's AWS credentials / bucket)."); + } + + // Publish landed — persist the live content store + published state. $pageDataUpdate = [ 'html' => bzcompress($params['html'], 9), 'stylesheet' => bzcompress($params['stylesheet'], 9), @@ -305,19 +313,21 @@ public function publishFromContent($pageObj, array $content) { $pageDataUpdate['block_layout'] = bzcompress($content['block_layout'], 9); } $pd = new \Kyte\Core\ModelObject(KytePageData); - if (!$pd->retrieve('page', $pageObj->id)) { - throw new \Exception("CRITICAL ERROR: Unable to find page data for page {$pageObj->id}."); + if ($pd->retrieve('page', $pageObj->id)) { + $pd->save($pageDataUpdate); + } else { + $pd->create(array_merge($pageDataUpdate, [ + 'page' => $pageObj->id, + 'kyte_account' => (int)$pageObj->kyte_account, + 'date_created' => time(), + ])); } - $pd->save($pageDataUpdate); // Ensure the page is in the published state. if ((int)$pageObj->state !== 1) { $pageObj->save(['state' => 1]); } - // Compile + push to S3 + invalidate CloudFront (identical to a human publish). - self::publishPage($pageObj, $params, $r); - return [ 'site_id' => isset($r['site']['id']) ? (int)$r['site']['id'] : 0, 's3key' => (string)$pageObj->s3key, @@ -349,8 +359,11 @@ public static function publishPage($pageObj, $params, &$responseData) { // Compile HTML file $compiledHtml = self::createHtml($params); - // Write compiled HTML to S3 - $s3->write($pageObj->s3key, $compiledHtml); + // Write compiled HTML to S3. Capture the result: the S3 stream wrapper + // returns false on a failed upload (e.g. invalid credentials) rather + // than throwing, so callers that need to know whether the publish + // actually landed (the MCP commit_draft flow) can check the return. + $publishOk = $s3->write($pageObj->s3key, $compiledHtml); // Update sitemap $siteDomain = $responseData['site']['aliasDomain'] ?: $responseData['site']['cfDomain']; @@ -386,6 +399,10 @@ public static function publishPage($pageObj, $params, &$responseData) { $cf = new \Kyte\Aws\CloudFront($credential); $cf->createInvalidation($responseData['site']['cfDistributionId'], $invalidationPaths); } + + // true on a successful page write, false if the S3 upload failed. + // Existing void callers (the update hook) ignore this harmlessly. + return $publishOk; } /** From 0673e1c6170db2a30286b033d66435c3535392a1 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 03:32:23 -0500 Subject: [PATCH 4/6] =?UTF-8?q?MCP=20draft/write=20=E2=80=94=20A2=20founda?= =?UTF-8?q?tion:=20draft=20flags=20on=20function=20+=20script=20versions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add `draft` + `draft_source` to KyteFunctionVersion and KyteScriptVersion (same pattern as KytePageVersion) so the draft engine can extend to controller functions and standalone scripts. Replaces the page-only migration with 4.10.0_version_draft_flags.sql covering all three version tables. Additive / migration-first / inert on older code. Engine + commit dispatch + tools for functions/scripts land next. Co-Authored-By: Claude Opus 4.8 (1M context) --- migrations/4.10.0_page_version_draft_flag.sql | 32 ---------------- migrations/4.10.0_version_draft_flags.sql | 38 +++++++++++++++++++ src/Mvc/Model/KyteFunctionVersion.php | 19 ++++++++++ src/Mvc/Model/KyteScriptVersion.php | 19 ++++++++++ 4 files changed, 76 insertions(+), 32 deletions(-) delete mode 100644 migrations/4.10.0_page_version_draft_flag.sql create mode 100644 migrations/4.10.0_version_draft_flags.sql diff --git a/migrations/4.10.0_page_version_draft_flag.sql b/migrations/4.10.0_page_version_draft_flag.sql deleted file mode 100644 index aec03cf..0000000 --- a/migrations/4.10.0_page_version_draft_flag.sql +++ /dev/null @@ -1,32 +0,0 @@ --- ========================================================================= --- Kyte v4.10.0 - add draft flag to KytePageVersion (MCP draft/write, Phase A) --- ========================================================================= --- IMPORTANT: Backup your database before running this migration. --- --- Adds two columns to KytePageVersion in support of the MCP draft/write --- feature (KYTE MCP initiative, Phase 2 write tools): --- --- draft INT - 1 = a pending draft version (not live), 0 = normal. --- A draft has draft=1 AND is_current=0; the page's --- live (is_current=1) version is untouched until the --- draft is committed. Distinct from a historical --- version (draft=0, is_current=0). --- draft_source VARCHAR(50) - origin of a draft, e.g. 'mcp' for an --- AI-authored draft; NULL for ordinary versions. --- --- ROLLOUT ORDER (expand / migration-first): this migration is ADDITIVE and --- safe to run BEFORE the v4.10.0 code is deployed. Older code ignores the --- new columns (SELECT * maps only defined fields; inserts omit them, so they --- default to draft=0 / NULL = an ordinary non-draft version). Run the --- migration first, then deploy the v4.10.0 code that reads/writes drafts. --- --- Table name is PascalCase (matches the framework's model class name). --- ALGORITHM intentionally not pinned (engine auto-selects INSTANT where --- available for ADD COLUMN; falls back automatically otherwise). --- --- See src/Mvc/Model/KytePageVersion.php (field definitions added in v4.10.0). --- ========================================================================= - -ALTER TABLE `KytePageVersion` - ADD COLUMN `draft` INT UNSIGNED NOT NULL DEFAULT 0, - ADD COLUMN `draft_source` VARCHAR(50) NULL; diff --git a/migrations/4.10.0_version_draft_flags.sql b/migrations/4.10.0_version_draft_flags.sql new file mode 100644 index 0000000..2cf5e7a --- /dev/null +++ b/migrations/4.10.0_version_draft_flags.sql @@ -0,0 +1,38 @@ +-- ========================================================================= +-- Kyte v4.10.0 - add draft flags to version tables (MCP draft/write) +-- ========================================================================= +-- IMPORTANT: Backup your database before running this migration. +-- +-- Adds `draft` + `draft_source` to the three content-version tables that the +-- MCP draft/write feature operates on — pages, controller functions, and +-- standalone scripts. A draft is a pending version: draft=1 AND is_current=0, +-- leaving the live content and the current version untouched until the draft +-- is committed (which flips it to is_current=1, draft=0). Distinct from a +-- historical version (draft=0, is_current=0). +-- +-- draft INT - 1 = a pending draft, 0 = normal version. +-- draft_source VARCHAR(50) - origin of a draft, e.g. 'mcp'; NULL otherwise. +-- +-- ROLLOUT ORDER (expand / migration-first): ADDITIVE and safe to run BEFORE +-- the v4.10.0 code is deployed. Older code ignores the new columns (SELECT * +-- maps only defined fields; inserts omit them → default draft=0 / NULL = an +-- ordinary non-draft version). Run the migration first, then deploy v4.10.0. +-- +-- Table names are PascalCase (framework model class names). ALGORITHM not +-- pinned (engine auto-selects INSTANT for ADD COLUMN where available). +-- +-- See src/Mvc/Model/{KytePageVersion,KyteFunctionVersion,KyteScriptVersion}.php +-- (field definitions added in v4.10.0). +-- ========================================================================= + +ALTER TABLE `KytePageVersion` + ADD COLUMN `draft` INT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN `draft_source` VARCHAR(50) NULL; + +ALTER TABLE `KyteFunctionVersion` + ADD COLUMN `draft` INT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN `draft_source` VARCHAR(50) NULL; + +ALTER TABLE `KyteScriptVersion` + ADD COLUMN `draft` INT UNSIGNED NOT NULL DEFAULT 0, + ADD COLUMN `draft_source` VARCHAR(50) NULL; diff --git a/src/Mvc/Model/KyteFunctionVersion.php b/src/Mvc/Model/KyteFunctionVersion.php index 0fd3226..72f12ca 100644 --- a/src/Mvc/Model/KyteFunctionVersion.php +++ b/src/Mvc/Model/KyteFunctionVersion.php @@ -92,6 +92,25 @@ 'date' => false, ], + // Draft flag (MCP draft/write). A draft is a pending version: draft=1, + // is_current=0; the live function code + current version are untouched + // until the draft is committed. See KytePageVersion for the model. + 'draft' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + 'draft_source' => [ + 'type' => 's', + 'required' => false, + 'size' => 50, + 'date' => false, + ], + 'parent_version' => [ 'type' => 'i', 'required' => false, diff --git a/src/Mvc/Model/KyteScriptVersion.php b/src/Mvc/Model/KyteScriptVersion.php index 83abea4..7cda80b 100644 --- a/src/Mvc/Model/KyteScriptVersion.php +++ b/src/Mvc/Model/KyteScriptVersion.php @@ -115,6 +115,25 @@ 'date' => false, ], + // Draft flag (MCP draft/write). A draft is a pending version: draft=1, + // is_current=0; the live script content + current version are untouched + // until the draft is committed. See KytePageVersion for the model. + 'draft' => [ + 'type' => 'i', + 'required' => false, + 'size' => 1, + 'unsigned' => true, + 'default' => 0, + 'date' => false, + ], + + 'draft_source' => [ + 'type' => 's', + 'required' => false, + 'size' => 50, + 'date' => false, + ], + 'parent_version' => [ 'type' => 'i', 'required' => false, From 91e761802798bd2ae54d64013e076c55591981e0 Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 03:48:00 -0500 Subject: [PATCH 5/6] =?UTF-8?q?MCP=20draft/write=20=E2=80=94=20A2=20engine?= =?UTF-8?q?:=20functions=20+=20scripts=20on=20the=20generic=20draft=20engi?= =?UTF-8?q?ne?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DraftService: functionSurface() + scriptSurface() descriptors, surfaceByLabel()/allSurfaces(); generalized parentInApplication (page/script via site→app, function via controller→app) and commitDraft via a per-surface publishForSurface(): * page → KytePageController::publishFromContent (S3 + CF) * script→ KyteScriptController::publishFromContent (S3 + CF) * function → write Function.code + ControllerController::generateCodeBase (DB-only, no S3) - KyteScriptController::publishFromContent (publish-first, captures S3 result, invalidates CF; skips the heavier include_all all-pages regeneration for now). - DraftTools restructured: write_page_part / write_function_code / write_script_content; read_draft/discard_draft/commit_draft take a `surface` arg; list_drafts spans all surfaces (each row tagged with surface). - tests: function + script authoring loops (write/read/discard, live untouched). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Mcp/Service/DraftService.php | 174 ++++++++++++++++---- src/Mcp/Tools/DraftTools.php | 164 ++++++++++++------ src/Mvc/Controller/KyteScriptController.php | 46 ++++++ tests/DraftServiceTest.php | 87 ++++++++++ 4 files changed, 386 insertions(+), 85 deletions(-) diff --git a/src/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php index 234bfa9..e8022d2 100644 --- a/src/Mcp/Service/DraftService.php +++ b/src/Mcp/Service/DraftService.php @@ -60,6 +60,69 @@ public static function pageSurface(): array ]; } + /** + * Descriptor for the controller-function surface (KyteFunctionVersion). + * Single content field `code`; commit is DB-only (regenerate code base). + * + * @return array{versionModel:array, contentModel:array, parentModel:array, fk:string, contentFields:array, writableParts:array, label:string} + */ + public static function functionSurface(): array + { + return [ + 'versionModel' => \KyteFunctionVersion, + 'contentModel' => \KyteFunctionVersionContent, + // 'Function' collides with the PHP keyword — resolve via constant(). + 'parentModel' => constant('Function'), + 'fk' => 'function', + 'contentFields' => ['code'], + 'writableParts' => ['code'], + 'label' => 'function', + ]; + } + + /** + * Descriptor for the standalone-script surface (KyteScriptVersion). + * Single content field `content`; commit publishes the asset to S3. + * + * @return array{versionModel:array, contentModel:array, parentModel:array, fk:string, contentFields:array, writableParts:array, label:string} + */ + public static function scriptSurface(): array + { + return [ + 'versionModel' => \KyteScriptVersion, + 'contentModel' => \KyteScriptVersionContent, + 'parentModel' => \KyteScript, + 'fk' => 'script', + 'contentFields' => ['content'], + 'writableParts' => ['content'], + 'label' => 'script', + ]; + } + + /** + * Resolve a surface descriptor by label ('page' | 'function' | 'script'). + * Null for an unknown label. + */ + public static function surfaceByLabel(string $label): ?array + { + switch (strtolower(trim($label))) { + case 'page': return self::pageSurface(); + case 'function': return self::functionSurface(); + case 'script': return self::scriptSurface(); + default: return null; + } + } + + /** + * All draftable surfaces, for cross-surface listing. + * + * @return array + */ + public static function allSurfaces(): array + { + return [self::pageSurface(), self::functionSurface(), self::scriptSurface()]; + } + private function accountId(): int { return isset($this->api->account->id) ? (int)$this->api->account->id : 0; @@ -385,41 +448,30 @@ public function discardDraft(array $surface, int $draftId): ?array * to a human Publish. Null if the draft doesn't exist / belong to the * account. * - * @return array{committed:bool, draft_id:int, parent_id:int, version_number?:int, site_id?:?int, s3key?:?string, error?:string}|null + * @return array{committed:bool, draft_id:int, parent_id:int, version_number?:int, site_id?:int, s3key?:string, controller_id?:int, error?:string}|null */ public function commitDraft(array $surface, int $draftId): ?array { - // Publish strategy is surface-specific; only pages are wired so far. - if ($surface['label'] !== 'page') { - return null; - } - $draft = $this->loadDraft($surface, $draftId); if ($draft === null) { return null; } $parentId = (int)$draft->{$surface['fk']}; - // Re-scope the page and load it as the publish target. - $page = new ModelObject($surface['parentModel']); - if (!$page->retrieve('id', $parentId) || (int)$page->kyte_account !== $this->accountId()) { + // Re-scope the parent and load it as the publish target. + $parent = new ModelObject($surface['parentModel']); + if (!$parent->retrieve('id', $parentId) || (int)$parent->kyte_account !== $this->accountId()) { return null; } $content = $this->versionContent($surface, $draft); - // Publish through the real controller pipeline. Construct it in - // internal mode (no HTTP session) — account context comes from $api. - // The controller constructor takes $api by reference; bind a local. - $api = $this->api; - $resp = []; - $controller = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); - - // Publish through the real pipeline. If it fails (e.g. bad AWS creds), - // the draft is NOT promoted — the page stays untouched and the caller - // gets a clear error so it can retry, rather than a false "committed". + // Publish through the real pipeline for this surface. If it fails (e.g. + // bad AWS creds), the draft is NOT promoted — the parent stays + // untouched and the caller gets a clear error to retry, rather than a + // false "committed". try { - $pub = $controller->publishFromContent($page, $content); + $pub = $this->publishForSurface($surface, $parent, $content); } catch (\Throwable $e) { return [ 'committed' => false, @@ -437,14 +489,53 @@ public function commitDraft(array $surface, int $draftId): ?array 'version_type' => 'mcp_commit', ]); - return [ + return array_merge([ 'committed' => true, 'draft_id' => $draftId, 'parent_id' => $parentId, 'version_number' => (int)$draft->version_number, - 'site_id' => isset($pub['site_id']) ? (int)$pub['site_id'] : null, - 's3key' => isset($pub['s3key']) ? (string)$pub['s3key'] : null, - ]; + ], $pub); + } + + /** + * Surface-specific publish for commit. Returns publish metadata (merged + * into the commit result) and throws on a failed publish. + * + * - page/script: publish through the real controller (S3 + CloudFront), + * constructed in internal mode (no HTTP session; account from $api). + * - function: DB-only — write the live Function.code and regenerate the + * controller's compiled code base (no S3). + * + * @param array $content + * @return array + */ + private function publishForSurface(array $surface, ModelObject $parent, array $content): array + { + $api = $this->api; + $resp = []; + + switch ($surface['label']) { + case 'page': + $c = new \Kyte\Mvc\Controller\KytePageController(\KytePage, $api, 'm/d/Y H:i:s', $resp, true); + return $c->publishFromContent($parent, $content); + + case 'script': + $c = new \Kyte\Mvc\Controller\KyteScriptController(\KyteScript, $api, 'm/d/Y H:i:s', $resp, true); + return $c->publishFromContent($parent, $content); + + case 'function': + // Write the live function code, then regenerate the owning + // controller's compiled code base (generateCodeBase is static). + $parent->save(['code' => bzcompress(isset($content['code']) ? $content['code'] : '', 9)]); + $ctrl = new ModelObject(constant('Controller')); + if ($ctrl->retrieve('id', (int)$parent->controller)) { + \Kyte\Mvc\Controller\ControllerController::generateCodeBase($ctrl); + } + return ['controller_id' => (int)$parent->controller]; + + default: + throw new \RuntimeException("Commit is not supported for surface '{$surface['label']}'."); + } } /** @@ -466,7 +557,7 @@ private function markCurrentNotCurrent(array $surface, int $parentId): void * draft versions in account scope and filters by the parent's site/app. * For the page surface, parent → KytePage → KyteSite(application). * - * @return array{drafts: array} + * @return array{drafts: array} */ public function listDrafts(array $surface, int $applicationId): array { @@ -488,6 +579,7 @@ public function listDrafts(array $surface, int $applicationId): array continue; } $out[] = [ + 'surface' => $surface['label'], 'draft_id' => (int)$d->id, 'parent_id' => $parentId, 'version_number' => (int)$d->version_number, @@ -511,18 +603,32 @@ private function applicationBelongsToAccount(int $applicationId, int $accountId) */ private function parentInApplication(array $surface, int $parentId, int $applicationId, int $accountId): bool { - if ($surface['label'] !== 'page') { - return false; - } - $page = new ModelObject(\KytePage); - if (!$page->retrieve('id', $parentId) || (int)$page->kyte_account !== $accountId) { + $parent = new ModelObject($surface['parentModel']); + if (!$parent->retrieve('id', $parentId) || (int)$parent->kyte_account !== $accountId) { return false; } - $site = new ModelObject(\KyteSite); - if (!$site->retrieve('id', (int)$page->site) || (int)$site->kyte_account !== $accountId) { - return false; + + switch ($surface['label']) { + // Pages and scripts both resolve their app via the site FK. + case 'page': + case 'script': + $site = new ModelObject(\KyteSite); + if (!$site->retrieve('id', (int)$parent->site) || (int)$site->kyte_account !== $accountId) { + return false; + } + return (int)$site->application === $applicationId; + + // Functions resolve their app via the controller FK. + case 'function': + $ctrl = new ModelObject(constant('Controller')); + if (!$ctrl->retrieve('id', (int)$parent->controller) || (int)$ctrl->kyte_account !== $accountId) { + return false; + } + return (int)$ctrl->application === $applicationId; + + default: + return false; } - return (int)$site->application === $applicationId; } /** diff --git a/src/Mcp/Tools/DraftTools.php b/src/Mcp/Tools/DraftTools.php index d8f7dac..2151c21 100644 --- a/src/Mcp/Tools/DraftTools.php +++ b/src/Mcp/Tools/DraftTools.php @@ -7,20 +7,21 @@ use Mcp\Capability\Attribute\McpTool; /** - * Draft authoring tools for the MCP draft/write feature. + * Draft authoring + commit tools for the MCP draft/write feature, across all + * three draftable surfaces: pages, controller functions, and standalone + * scripts. A draft is a pending, non-live edit (draft=1, is_current=0); the + * live resource is untouched until commit_draft publishes it. * - * A draft is a pending, non-live edit. `write_page_part` accumulates content - * changes into a single open draft per page (draft=1, is_current=0) WITHOUT - * touching the live page — a human (or a commit-scoped tool) promotes it - * later. `list_drafts`/`read_draft` support review; `discard_draft` drops a - * draft. Commit (promotion + publish) is a separate commit-scoped tool. + * Writes are surface-specific (a page has parts; functions/scripts have a + * single content field). The lifecycle tools — read/discard/commit — take a + * `surface` argument ('page' | 'function' | 'script') because version ids are + * per-table and collide across surfaces. list_drafts spans all surfaces and + * tags each row with its surface, so a caller knows what to pass back. * - * All work is delegated to the surface-generic DraftService; this class only - * supplies the page surface and shapes tool I/O. Functions and scripts will - * add sibling tools over the same service with their own surface descriptors. - * - * Scope mapping: write/discard require 'draft'; list/read require 'read'. - * Account scoping is enforced inside the service on every caller-supplied id. + * Scopes: write/discard require 'draft'; list/read require 'read'; commit + * requires 'commit' (tokens are draft-only by default). Account scoping is + * enforced inside the service on every caller-supplied id. All work is + * delegated to the surface-generic DraftService. */ final class DraftTools { @@ -33,86 +34,147 @@ private function service(): DraftService return new DraftService($this->api); } + // ----- Writes (draft scope) — one per surface ----- + /** - * Create or update a draft edit of a page part (does NOT publish). + * Create/update a DRAFT edit of a page part (html, stylesheet, javascript). + * Does not publish. Repeated calls on the same page accumulate into one draft. * - * Repeated calls for the same page accumulate into one open draft: each - * call starts from the page's current live content (or the in-progress - * draft), sets the named part, and re-stores. Use commit_draft to publish. - * - * @param int $page_id KytePage id from list_pages. - * @param string $part One of: html, stylesheet, javascript (aliases: css, js). - * @param string $content Full new content for that part. - * @return array{ok:bool, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} + * @return array{ok:bool, surface?:string, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} */ - #[McpTool(name: 'write_page_part', description: 'Create or update a DRAFT edit of a page part (html, stylesheet, or javascript). Does not publish — the draft is held for review and promoted via commit_draft. Repeated calls on the same page accumulate into one draft.')] + #[McpTool(name: 'write_page_part', description: 'Create or update a DRAFT edit of a page part (html, stylesheet, or javascript). Does not publish — held for review and promoted via commit_draft. Repeated calls on the same page accumulate into one draft.')] #[RequiresScope('draft')] public function writePagePart(int $page_id, string $part, string $content): array { - $result = $this->service()->writePart(DraftService::pageSurface(), $page_id, $part, $content); + return $this->writeResult(DraftService::pageSurface(), $page_id, $part, $content); + } + + /** + * Create/update a DRAFT edit of a controller function's code. Does not + * regenerate the controller until commit_draft. + * + * @return array{ok:bool, surface?:string, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} + */ + #[McpTool(name: 'write_function_code', description: 'Create or update a DRAFT edit of a controller function\'s PHP code. Does not regenerate the controller — held for review and promoted via commit_draft.')] + #[RequiresScope('draft')] + public function writeFunctionCode(int $function_id, string $code): array + { + return $this->writeResult(DraftService::functionSurface(), $function_id, 'code', $code); + } + + /** + * Create/update a DRAFT edit of a standalone script's content. Does not + * publish the script until commit_draft. + * + * @return array{ok:bool, surface?:string, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} + */ + #[McpTool(name: 'write_script_content', description: 'Create or update a DRAFT edit of a standalone script\'s content. Does not publish — held for review and promoted via commit_draft.')] + #[RequiresScope('draft')] + public function writeScriptContent(int $script_id, string $content): array + { + return $this->writeResult(DraftService::scriptSurface(), $script_id, 'content', $content); + } + + /** + * @param array $surface + * @return array{ok:bool, surface?:string, draft_id?:int, parent_id?:int, version_number?:int, part?:string, created?:bool, content_bytes?:int, error?:string} + */ + private function writeResult(array $surface, int $parentId, string $part, string $content): array + { + $result = $this->service()->writePart($surface, $parentId, $part, $content); if ($result === null) { return [ 'ok' => false, - 'error' => "Could not write draft: page {$page_id} was not found in this account, or '{$part}' is not a writable part. Valid parts: html, stylesheet, javascript.", + 'error' => "Could not write draft: {$surface['label']} {$parentId} was not found in this account, or '{$part}' is not a writable part.", ]; } - return array_merge(['ok' => true], $result); + return array_merge(['ok' => true, 'surface' => $surface['label']], $result); } + // ----- Review / lifecycle ----- + /** - * List open drafts in an application (pages, for now). + * List pending (uncommitted) drafts across ALL surfaces in an application. + * Each row is tagged with its `surface` — pass that to read/commit/discard. * - * @param int $application_id Application id from list_applications. - * @return array{drafts: array} + * @return array{drafts: array} */ - #[McpTool(name: 'list_drafts', description: 'List pending (uncommitted) drafts in a Kyte application. Use read_draft to inspect one and commit_draft to publish it.')] + #[McpTool(name: 'list_drafts', description: 'List pending (uncommitted) drafts across pages, functions, and scripts in a Kyte application. Each row is tagged with its surface (page/function/script); pass that surface to read_draft / commit_draft / discard_draft.')] #[RequiresScope('read')] public function listDrafts(int $application_id): array { - return $this->service()->listDrafts(DraftService::pageSurface(), $application_id); + $svc = $this->service(); + $all = []; + foreach (DraftService::allSurfaces() as $surface) { + $r = $svc->listDrafts($surface, $application_id); + foreach ($r['drafts'] as $d) { + $all[] = $d; + } + } + return ['drafts' => $all]; } /** - * Read a draft's content and which parts differ from the live page. + * Read a pending draft (its content + which parts differ from live). * - * @param int $draft_id KytePageVersion id of a draft (from list_drafts). - * @return array{draft_id:int, parent_id:int, version_number:int, draft_source:?string, content:array, changed_parts:array}|null + * @param string $surface page | function | script (from list_drafts). + * @param int $draft_id Version id of the draft (from list_drafts). + * @return array{surface:string, draft_id:int, parent_id:int, version_number:int, draft_source:?string, content:array, changed_parts:array}|null */ - #[McpTool(name: 'read_draft', description: 'Read a pending draft: its content (html/stylesheet/javascript) and which parts differ from the current live version.')] + #[McpTool(name: 'read_draft', description: 'Read a pending draft: its content and which parts differ from the current live version. Pass the surface (page/function/script) and draft_id from list_drafts.')] #[RequiresScope('read')] - public function readDraft(int $draft_id): ?array + public function readDraft(string $surface, int $draft_id): ?array { - return $this->service()->readDraft(DraftService::pageSurface(), $draft_id); + $s = DraftService::surfaceByLabel($surface); + if ($s === null) { + return null; + } + $result = $this->service()->readDraft($s, $draft_id); + if ($result === null) { + return null; + } + return array_merge(['surface' => $s['label']], $result); } /** - * Discard a pending draft (soft-delete; does not affect the live page). + * Discard a pending draft (soft-delete; does not affect the live resource). * - * @param int $draft_id KytePageVersion id of a draft (from list_drafts). + * @param string $surface page | function | script (from list_drafts). + * @param int $draft_id Version id of the draft. * @return array{discarded:bool, draft_id:int}|null */ - #[McpTool(name: 'discard_draft', description: 'Discard a pending draft. Does not affect the live page. Returns null if the draft does not exist in this account.')] + #[McpTool(name: 'discard_draft', description: 'Discard a pending draft (does not affect the live resource). Pass the surface (page/function/script) and draft_id. Returns null if the draft does not exist in this account.')] #[RequiresScope('draft')] - public function discardDraft(int $draft_id): ?array + public function discardDraft(string $surface, int $draft_id): ?array { - return $this->service()->discardDraft(DraftService::pageSurface(), $draft_id); + $s = DraftService::surfaceByLabel($surface); + if ($s === null) { + return null; + } + return $this->service()->discardDraft($s, $draft_id); } /** - * Commit (publish) a draft to the live site. + * Commit (publish) a draft live and make it the current version. * - * This is the ONLY draft tool that changes the live page: it writes the - * draft's content live, publishes to S3, invalidates CloudFront, and makes - * the draft the new current version — identical to a human clicking - * Publish. Requires the 'commit' scope (tokens are draft-only by default). + * The ONLY draft tool that changes the live resource. Pages/scripts publish + * to S3 + invalidate CloudFront; functions regenerate the controller's + * compiled code base. Requires the 'commit' scope (tokens are draft-only by + * default). On a failed publish, returns committed:false + error and leaves + * the draft intact. * - * @param int $draft_id KytePageVersion id of a draft (from list_drafts). - * @return array{committed:bool, draft_id:int, parent_id:int, version_number?:int, site_id?:?int, s3key?:?string, error?:string}|null + * @param string $surface page | function | script (from list_drafts). + * @param int $draft_id Version id of the draft. + * @return array{committed:bool, draft_id?:int, parent_id?:int, version_number?:int, site_id?:int, s3key?:string, controller_id?:int, error?:string}|null */ - #[McpTool(name: 'commit_draft', description: 'Publish a pending draft to the live site (writes live content, pushes to S3, invalidates CloudFront) and make it the current version. This is the only draft action that affects the live page. Requires the commit scope. Returns committed:false with an error if the publish fails (the draft is left intact).')] + #[McpTool(name: 'commit_draft', description: 'Publish a pending draft live and make it the current version. The only draft action that affects the live resource (pages/scripts publish to S3 + CloudFront; functions regenerate the controller code). Pass the surface (page/function/script) and draft_id. Requires the commit scope. Returns committed:false with an error if the publish fails (the draft is left intact).')] #[RequiresScope('commit')] - public function commitDraft(int $draft_id): ?array + public function commitDraft(string $surface, int $draft_id): ?array { - return $this->service()->commitDraft(DraftService::pageSurface(), $draft_id); + $s = DraftService::surfaceByLabel($surface); + if ($s === null) { + return ['committed' => false, 'error' => "Unknown surface '{$surface}'. Use page, function, or script."]; + } + return $this->service()->commitDraft($s, $draft_id); } } diff --git a/src/Mvc/Controller/KyteScriptController.php b/src/Mvc/Controller/KyteScriptController.php index 1934c8f..18501b0 100644 --- a/src/Mvc/Controller/KyteScriptController.php +++ b/src/Mvc/Controller/KyteScriptController.php @@ -497,6 +497,52 @@ private function handleScriptPublication($o, $r, $d): void { $this->originalIncludeAll = null; } + /** + * Publish an explicit script content to live (MCP commit_draft). + * + * Publishes the script asset itself to S3 (the common case — scripts are + * referenced by their s3key URL) and invalidates CloudFront, mirroring the + * page commit: publish FIRST and bail on a failed upload BEFORE mutating + * the live KyteScript.content, so a failed commit leaves the script + * untouched. Construct this controller in internal mode (no session) from + * a trusted server-side caller. + * + * NOTE: this does NOT regenerate pages that inline an `include_all` script + * (updatePagesForScript) — that heavier propagation is left to a normal + * Shipyard publish for now. Scripts referenced by URL update fully here. + * + * @param object $scriptObj The KyteScript ModelObject to publish. + * @param array $content ['content' => ]. + * @return array{site_id:int, s3key:string} + */ + public function publishFromContent($scriptObj, array $content) { + $plain = isset($content['content']) ? $content['content'] : ''; + $r = $this->getObject($scriptObj); + + $app = new \Kyte\Core\ModelObject(Application); + if (!$app->retrieve('id', $r['site']['application']['id'])) { + throw new \Exception("CRITICAL ERROR: Unable to find application."); + } + $credential = new \Kyte\Aws\Credentials($r['site']['region'], $app->aws_public_key, $app->aws_private_key); + $s3 = new \Kyte\Aws\S3($credential, $r['site']['s3BucketName']); + + // Publish FIRST; bail before mutating the live content if S3 fails. + $publishOk = $s3->write($scriptObj->s3key, $plain); + if ($publishOk === false) { + throw new \RuntimeException("Publish failed: S3 write to '{$scriptObj->s3key}' did not succeed (check the application's AWS credentials / bucket)."); + } + + // Publish landed — persist the live content + invalidate CloudFront. + $scriptObj->save(['content' => bzcompress($plain, 9)]); + $r['content'] = $plain; + $this->invalidateCloudFront($r); + + return [ + 'site_id' => isset($r['site']['id']) ? (int)$r['site']['id'] : 0, + 's3key' => (string)$scriptObj->s3key, + ]; + } + /** * Handle script deletion (extracted from original delete case) */ diff --git a/tests/DraftServiceTest.php b/tests/DraftServiceTest.php index cfde7fc..b9ed231 100644 --- a/tests/DraftServiceTest.php +++ b/tests/DraftServiceTest.php @@ -169,4 +169,91 @@ public function testCrossAccountWriteIsDenied(): void $result = $svc->writePart($S, (int)$other->id, 'html', 'x'); $this->assertNull($result, 'writing to a page outside the account is denied'); } + + public function testFunctionSurfaceAuthoring(): void + { + \Kyte\Core\DBI::createTable(constant('Function')); + \Kyte\Core\DBI::createTable(KyteFunctionVersion); + \Kyte\Core\DBI::createTable(KyteFunctionVersionContent); + + $fn = new \Kyte\Core\ModelObject(constant('Function')); + $fn->create([ + 'name' => 'al_draft_test_fn', + 'controller' => 1, + 'code' => bzcompress('echo 1;', 9), + 'kyte_account' => $this->accountId, + ]); + $fnId = (int)$fn->id; + + $hash = hash('sha256', 'echo 1;'); + (new \Kyte\Core\ModelObject(KyteFunctionVersionContent))->create([ + 'content_hash' => $hash, 'code' => bzcompress('echo 1;', 9), + 'reference_count' => 1, 'last_referenced' => time(), 'kyte_account' => $this->accountId, + ]); + (new \Kyte\Core\ModelObject(KyteFunctionVersion))->create([ + 'function' => $fnId, 'version_number' => 1, 'version_type' => 'initial', + 'content_hash' => $hash, 'is_current' => 1, 'draft' => 0, 'kyte_account' => $this->accountId, + ]); + + $svc = $this->svc(); + $S = DraftService::functionSurface(); + + $w = $svc->writePart($S, $fnId, 'code', 'echo 2;'); + $this->assertNotNull($w); + $this->assertTrue($w['created']); + + $read = $svc->readDraft($S, $w['draft_id']); + $this->assertSame('echo 2;', $read['content']['code']); + $this->assertContains('code', $read['changed_parts']); + + // Live function code unchanged by drafting. + $this->assertSame('echo 1;', $svc->currentLiveContent($S, $fnId)['code']); + + $svc->discardDraft($S, $w['draft_id']); + $this->assertNull($svc->readDraft($S, $w['draft_id'])); + } + + public function testScriptSurfaceAuthoring(): void + { + \Kyte\Core\DBI::createTable(KyteScript); + \Kyte\Core\DBI::createTable(KyteScriptVersion); + \Kyte\Core\DBI::createTable(KyteScriptVersionContent); + + $sc = new \Kyte\Core\ModelObject(KyteScript); + $sc->create([ + 'name' => 'al_draft_test_script', + 'site' => 1, + 's3key' => 'assets/js/al-draft-test.js', + 'content' => bzcompress('let a = 1;', 9), + 'kyte_account' => $this->accountId, + ]); + $scId = (int)$sc->id; + + $hash = hash('sha256', 'let a = 1;'); + (new \Kyte\Core\ModelObject(KyteScriptVersionContent))->create([ + 'content_hash' => $hash, 'content' => bzcompress('let a = 1;', 9), + 'reference_count' => 1, 'last_referenced' => time(), 'kyte_account' => $this->accountId, + ]); + (new \Kyte\Core\ModelObject(KyteScriptVersion))->create([ + 'script' => $scId, 'version_number' => 1, 'version_type' => 'initial', + 'content_hash' => $hash, 'is_current' => 1, 'draft' => 0, 'kyte_account' => $this->accountId, + ]); + + $svc = $this->svc(); + $S = DraftService::scriptSurface(); + + $w = $svc->writePart($S, $scId, 'content', 'let a = 2;'); + $this->assertNotNull($w); + $this->assertTrue($w['created']); + + $read = $svc->readDraft($S, $w['draft_id']); + $this->assertSame('let a = 2;', $read['content']['content']); + $this->assertContains('content', $read['changed_parts']); + + // Live script content unchanged by drafting. + $this->assertSame('let a = 1;', $svc->currentLiveContent($S, $scId)['content']); + + $svc->discardDraft($S, $w['draft_id']); + $this->assertNull($svc->readDraft($S, $w['draft_id'])); + } } From e844e2afc3b4ffd505ae8f7999518ff521eb805c Mon Sep 17 00:00:00 2001 From: Kenneth Hough Date: Mon, 1 Jun 2026 03:53:42 -0500 Subject: [PATCH 6/6] MCP draft/write: default created_by to system sentinel + v4.10.0 changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP requests carry an account but no KyteUser, so the draft version insert passed created_by=null — fine for KytePageVersion (nullable) but KyteFunctionVersion.created_by is NOT NULL, which broke function/script drafts. Default created_by to 0 (system) when there's no user. Also adds the v4.10.0 CHANGELOG entry for the MCP draft/write feature. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 19 +++++++++++++++++++ src/Mcp/Service/DraftService.php | 5 ++++- 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dfccb2a..8f03232 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## 4.10.0 + +### Feature: MCP draft/write — AI can draft and commit pages, controller functions, and scripts + +Phase 2 *write* tools for the MCP server. Until now the MCP tools were read-only; this release lets an AI client **propose changes as drafts** and **commit** them live, across all three content surfaces — without ever touching the live resource until an explicit commit. + +**Model.** A draft is a pending version row (`draft=1`, `is_current=0`) on the existing version tables — the live content and the current version are untouched until commit, which flips the draft to `is_current=1` and publishes. A new migration `migrations/4.10.0_version_draft_flags.sql` adds `draft` + `draft_source` to `KytePageVersion`, `KyteFunctionVersion`, and `KyteScriptVersion` (additive, migration-first, inert on older code). + +**Engine.** `Kyte\Mcp\Service\DraftService` is surface-generic, driven by a per-surface descriptor (version model, content model, parent model, content fields), reusing the same sha256 content-hash + bzip2 + `reference_count` dedup conventions as the existing controllers so drafts de-duplicate against existing version content. + +**Tools** (`Kyte\Mcp\Tools\DraftTools`): +- Writes (require `draft` scope): `write_page_part(page_id, part, content)`, `write_function_code(function_id, code)`, `write_script_content(script_id, content)`. Repeated writes on the same resource accumulate into one open draft. +- Review (require `read`): `list_drafts(application_id)` spans all surfaces (each row tagged with its `surface`); `read_draft(surface, draft_id)` returns content + which parts differ from live. +- Lifecycle: `discard_draft(surface, draft_id)` (`draft` scope); `commit_draft(surface, draft_id)` (`commit` scope) — the only action that changes the live resource. Pages/scripts publish to S3 + invalidate CloudFront; functions write the live code and regenerate the controller's compiled code base. On a failed publish, commit returns `committed:false` + an error and leaves the draft intact (publishes first, so a failure never half-applies). + +**Supporting changes.** `KytePageController::publishPage` is now `public static` and returns the S3 write result (existing void callers unaffected); it gains `publishFromContent()` for commit. `KyteScriptController` gains `publishFromContent()`. `ModelController` gains an optional `internal` constructor flag that skips the session `authenticate()` check, so a trusted server-side caller (the MCP commit flow) can use a controller with an account context but no HTTP session. + +Token scopes (`read` / `draft` / `commit`) on `KyteMCPToken` already existed; new tokens stay draft-only by default, so committing live is opt-in per token. Sequenced by risk: pages and scripts (fully versioned) and controller functions; model/schema drafting is intentionally out of scope (deferred to the data-model initiative). + ## 4.9.0 ### Fix: ActivityLogger no longer bloats KyteActivityLog, and the admin log list stops dragging blobs (KYTE-#182) diff --git a/src/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php index e8022d2..572823b 100644 --- a/src/Mcp/Service/DraftService.php +++ b/src/Mcp/Service/DraftService.php @@ -354,7 +354,10 @@ public function writePart(array $surface, int $parentId, string $part, string $c 'draft' => 1, 'draft_source' => 'mcp', 'kyte_account' => $this->accountId(), - 'created_by' => $this->userIdOrNull(), + // MCP requests carry an account but no KyteUser, so default to the + // 0 system sentinel — some version tables (KyteFunctionVersion) + // declare created_by NOT NULL, where a null would fail the insert. + 'created_by' => $this->userIdOrNull() ?? 0, ]); if (!$created) { throw new \RuntimeException('Unable to create draft.');