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/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/Mcp/Service/DraftService.php b/src/Mcp/Service/DraftService.php new file mode 100644 index 0000000..572823b --- /dev/null +++ b/src/Mcp/Service/DraftService.php @@ -0,0 +1,648 @@ +, 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', + ]; + } + + /** + * 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; + } + + 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(), + // 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.'); + } + 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]; + } + + /** + * 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, controller_id?:int, error?:string}|null + */ + public function commitDraft(array $surface, int $draftId): ?array + { + $draft = $this->loadDraft($surface, $draftId); + if ($draft === null) { + return null; + } + $parentId = (int)$draft->{$surface['fk']}; + + // 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 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 = $this->publishForSurface($surface, $parent, $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, + 'draft' => 0, + 'version_type' => 'mcp_commit', + ]); + + return array_merge([ + 'committed' => true, + 'draft_id' => $draftId, + 'parent_id' => $parentId, + 'version_number' => (int)$draft->version_number, + ], $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']}'."); + } + } + + /** + * 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. + * 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[] = [ + 'surface' => $surface['label'], + '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 + { + $parent = new ModelObject($surface['parentModel']); + if (!$parent->retrieve('id', $parentId) || (int)$parent->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; + } + } + + /** + * 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..2151c21 --- /dev/null +++ b/src/Mcp/Tools/DraftTools.php @@ -0,0 +1,180 @@ +api); + } + + // ----- Writes (draft scope) — one per surface ----- + + /** + * 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. + * + * @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 — 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 + { + 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: {$surface['label']} {$parentId} was not found in this account, or '{$part}' is not a writable part.", + ]; + } + return array_merge(['ok' => true, 'surface' => $surface['label']], $result); + } + + // ----- Review / lifecycle ----- + + /** + * List pending (uncommitted) drafts across ALL surfaces in an application. + * Each row is tagged with its `surface` — pass that to read/commit/discard. + * + * @return array{drafts: array} + */ + #[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 + { + $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 pending draft (its content + which parts differ from live). + * + * @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 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(string $surface, int $draft_id): ?array + { + $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 resource). + * + * @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 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(string $surface, int $draft_id): ?array + { + $s = DraftService::surfaceByLabel($surface); + if ($s === null) { + return null; + } + return $this->service()->discardDraft($s, $draft_id); + } + + /** + * Commit (publish) a draft live and make it the current version. + * + * 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 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 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(string $surface, int $draft_id): ?array + { + $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/KytePageController.php b/src/Mvc/Controller/KytePageController.php index 4575385..f915614 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,71 @@ 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'] : ''; + + // 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), + '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)) { + $pd->save($pageDataUpdate); + } else { + $pd->create(array_merge($pageDataUpdate, [ + 'page' => $pageObj->id, + 'kyte_account' => (int)$pageObj->kyte_account, + 'date_created' => time(), + ])); + } + + // Ensure the page is in the published state. + if ((int)$pageObj->state !== 1) { + $pageObj->save(['state' => 1]); + } + + 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); @@ -295,8 +359,11 @@ private 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']; @@ -332,6 +399,10 @@ private 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; } /** 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/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/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/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, 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, diff --git a/tests/DraftServiceTest.php b/tests/DraftServiceTest.php new file mode 100644 index 0000000..b9ed231 --- /dev/null +++ b/tests/DraftServiceTest.php @@ -0,0 +1,259 @@ +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'); + } + + 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'])); + } +}