1- import { randomUUID } from "node:crypto" ;
21import { mkdirSync , readFileSync , writeFileSync } from "node:fs" ;
32import * as path from "node:path" ;
43import { z } from "zod" ;
5- import { PostHogAPIClient } from "../../../posthog-api" ;
4+ import {
5+ DesktopCanvasVersionConflictError ,
6+ PostHogAPIClient ,
7+ } from "../../../posthog-api" ;
68import { resolveSandboxPosthogApi } from "../../../signed-commit-artefacts" ;
79import { defineLocalTool , type LocalToolResult } from "../registry" ;
810
@@ -15,15 +17,11 @@ import { defineLocalTool, type LocalToolResult } from "../registry";
1517 * deterministic scratch path tool-side (no model transcription), and stashes
1618 * the fetched `currentVersionId` as the publish-time concurrency base.
1719 * - `canvas_publish` reads the scratch file from disk (again, no
18- * transcription), verifies the canvas hasn't moved past the stashed base
19- * (a concurrent edit or user undo), appends a full-file version snapshot,
20- * and PATCHes the merged meta — mirroring `dashboardsService.saveFreeform`
21- * in `@posthog/core`, including the linear-discard of any redo tail.
22- *
23- * The check-then-PATCH guard is tool-side and therefore not atomic; it shrinks
24- * the clobber window from "the whole generation turn" to milliseconds. A
25- * server-side `base_version` check on the desktop-fs PATCH remains the
26- * follow-up that closes it completely.
20+ * transcription) and publishes through the desktop-fs canvas action, which
21+ * owns version composition server-side. The stashed base rides along as
22+ * `expected_current_version_id`, so a publish based on a stale read (a
23+ * concurrent edit, or a user's undo) is rejected atomically with a version
24+ * conflict instead of clobbering the newer head.
2725 *
2826 * Credentials come from the sandbox environment (see
2927 * `resolveSandboxPosthogApi`), so this works identically from the Claude
@@ -47,19 +45,9 @@ function baseVersionMarkerFile(canvasId: string): string {
4745 return path . join ( canvasScratchDir ( canvasId ) , ".base-version.json" ) ;
4846}
4947
50- interface CanvasVersion {
51- id : string ;
52- code : string ;
53- context ?: string ;
54- prompt ?: string ;
55- createdAt : number ;
56- }
57-
5848interface CanvasMeta {
5949 code ?: string ;
60- versions ?: CanvasVersion [ ] ;
6150 currentVersionId ?: string ;
62- updatedAt ?: number ;
6351 [ key : string ] : unknown ;
6452}
6553
@@ -104,17 +92,6 @@ async function fetchCanvasEntry(canvasId: string): Promise<CanvasFsEntry> {
10492 return entry ;
10593}
10694
107- async function patchCanvasMeta (
108- canvasId : string ,
109- meta : CanvasMeta ,
110- ) : Promise < void > {
111- const client = createClient ( ) ;
112- if ( ! client ) {
113- throw new Error ( "No PostHog credentials available in this session." ) ;
114- }
115- await client . patchDesktopFsEntryMeta ( canvasId , meta ) ;
116- }
117-
11895function readMarker ( canvasId : string ) : BaseVersionMarker | undefined {
11996 try {
12097 return JSON . parse (
@@ -130,47 +107,6 @@ function writeMarker(canvasId: string, marker: BaseVersionMarker): void {
130107 writeFileSync ( baseVersionMarkerFile ( canvasId ) , JSON . stringify ( marker ) ) ;
131108}
132109
133- /**
134- * Compose the published meta from a freshly-fetched entry: verify the base,
135- * truncate any redo tail past the current pointer (linear-discard, matching
136- * the client's undo semantics in `freeformSchemas.ts`), and append the new
137- * full-file snapshot. Pure — exported for tests.
138- */
139- export function composePublishedMeta ( input : {
140- freshMeta : CanvasMeta ;
141- baseVersionId : string | undefined ;
142- code : string ;
143- prompt ?: string ;
144- now : number ;
145- } ) : { ok : true ; meta : CanvasMeta ; versionId : string } | { ok : false } {
146- const { freshMeta, baseVersionId, code, prompt, now } = input ;
147- if ( ( freshMeta . currentVersionId ?? undefined ) !== baseVersionId ) {
148- return { ok : false } ;
149- }
150- const versions = freshMeta . versions ?? [ ] ;
151- const pointer = baseVersionId
152- ? versions . findIndex ( ( v ) => v . id === baseVersionId )
153- : - 1 ;
154- const kept = pointer >= 0 ? versions . slice ( 0 , pointer + 1 ) : [ ] ;
155- const version : CanvasVersion = {
156- id : randomUUID ( ) ,
157- code,
158- ...( prompt ? { prompt } : { } ) ,
159- createdAt : now ,
160- } ;
161- return {
162- ok : true ,
163- versionId : version . id ,
164- meta : {
165- ...freshMeta ,
166- code,
167- versions : [ ...kept , version ] ,
168- currentVersionId : version . id ,
169- updatedAt : now ,
170- } ,
171- } ;
172- }
173-
174110function errorResult ( text : string ) : LocalToolResult {
175111 return { content : [ { type : "text" , text } ] , isError : true } ;
176112}
@@ -221,8 +157,8 @@ export const canvasCheckoutTool = defineLocalTool({
221157export const canvasPublishTool = defineLocalTool ( {
222158 name : "canvas_publish" ,
223159 description :
224- "Publish the checked-out canvas: reads the scratch file written by canvas_checkout, verifies the " +
225- "canvas hasn't changed since checkout, and saves the file as the canvas's new live version . Call " +
160+ "Publish the checked-out canvas: reads the scratch file written by canvas_checkout and saves it as " +
161+ "the canvas's new live version, guarded against the canvas having changed since checkout . Call " +
226162 "exactly once when the edit is complete. On a version-conflict error, re-run canvas_checkout, " +
227163 "re-apply your edits, and publish again." ,
228164 schema : {
@@ -256,36 +192,38 @@ export const canvasPublishTool = defineLocalTool({
256192 `canvas_publish failed: no checkout record for canvas "${ args . id } ". Call canvas_checkout first.` ,
257193 ) ;
258194 }
195+ const client = createClient ( ) ;
196+ if ( ! client ) {
197+ return errorResult (
198+ "canvas_publish failed: no PostHog credentials available in this session." ,
199+ ) ;
200+ }
259201 try {
260- const fresh = await fetchCanvasEntry ( args . id ) ;
261- const composed = composePublishedMeta ( {
262- freshMeta : fresh . meta ?? { } ,
263- baseVersionId : marker . versionId ,
202+ const entry = await client . publishDesktopCanvas < CanvasFsEntry > ( args . id , {
264203 code,
265204 prompt : args . prompt ,
266- now : Date . now ( ) ,
205+ expectedCurrentVersionId : marker . versionId ?? null ,
267206 } ) ;
268- if ( ! composed . ok ) {
269- return errorResult (
270- `canvas_publish failed: ${ CONFLICT_MESSAGE ( args . id ) } ` ,
271- ) ;
272- }
273- await patchCanvasMeta ( args . id , composed . meta ) ;
274207 // Advance the base so a follow-up publish in the same session works
275208 // without a re-checkout.
276- writeMarker ( args . id , {
277- versionId : composed . versionId ,
278- fetchedAt : Date . now ( ) ,
279- } ) ;
209+ const newVersionId = entry . meta ?. currentVersionId ;
210+ writeMarker ( args . id , { versionId : newVersionId , fetchedAt : Date . now ( ) } ) ;
280211 return {
281212 content : [
282213 {
283214 type : "text" ,
284- text : `Published canvas "${ args . id } " (new version ${ composed . versionId } ). The canvas is live; do not paste the code into chat.` ,
215+ text : `Published canvas "${ args . id } "${
216+ newVersionId ? ` (new version ${ newVersionId } )` : ""
217+ } . The canvas is live; do not paste the code into chat.`,
285218 } ,
286219 ] ,
287220 } ;
288221 } catch ( err ) {
222+ if ( err instanceof DesktopCanvasVersionConflictError ) {
223+ return errorResult (
224+ `canvas_publish failed: ${ CONFLICT_MESSAGE ( args . id ) } ` ,
225+ ) ;
226+ }
289227 return errorResult (
290228 `canvas_publish failed: ${ err instanceof Error ? err . message : String ( err ) } ` ,
291229 ) ;
0 commit comments