|
1 | 1 | import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; |
2 | 2 | import * as path from "node:path"; |
| 3 | +import { freeformSystemPromptFor } from "@posthog/shared/canvas-freeform-prompt"; |
| 4 | +import { FREEFORM_STARTER_CODE } from "@posthog/shared/canvas-freeform-starter"; |
3 | 5 | import { z } from "zod"; |
4 | 6 | import { |
5 | 7 | DesktopCanvasVersionConflictError, |
@@ -48,6 +50,7 @@ function baseVersionMarkerFile(canvasId: string): string { |
48 | 50 | interface CanvasMeta { |
49 | 51 | code?: string; |
50 | 52 | currentVersionId?: string; |
| 53 | + templateId?: string; |
51 | 54 | [key: string]: unknown; |
52 | 55 | } |
53 | 56 |
|
@@ -92,6 +95,43 @@ async function fetchCanvasEntry(canvasId: string): Promise<CanvasFsEntry> { |
92 | 95 | return entry; |
93 | 96 | } |
94 | 97 |
|
| 98 | +// Create-if-missing for `canvas_checkout`: an agent working from a normal task |
| 99 | +// (not the channel generate bar) can start a canvas in one call instead of |
| 100 | +// chaining the raw desktop-fs create tool first. |
| 101 | +async function createCanvasEntry( |
| 102 | + name: string | undefined, |
| 103 | + parentPath: string | undefined, |
| 104 | +): Promise<CanvasFsEntry> { |
| 105 | + if (!name?.trim()) { |
| 106 | + throw new Error( |
| 107 | + "pass `id` to edit an existing canvas, or `name` to create a new one.", |
| 108 | + ); |
| 109 | + } |
| 110 | + const client = createClient(); |
| 111 | + if (!client) { |
| 112 | + throw new Error("No PostHog credentials available in this session."); |
| 113 | + } |
| 114 | + return client.createDesktopCanvas<CanvasFsEntry>({ |
| 115 | + name: name.trim(), |
| 116 | + parentPath, |
| 117 | + }); |
| 118 | +} |
| 119 | + |
| 120 | +// The freeform authoring rules (allowed imports, the `ph` data shim, style |
| 121 | +// rules) the channel generate bar injects up front. Returned on checkout so an |
| 122 | +// agent editing a canvas from any task authors valid source; an empty canvas |
| 123 | +// also gets the known-good starter scaffold to build on. |
| 124 | +function authoringContract( |
| 125 | + templateId: string | undefined, |
| 126 | + isEmpty: boolean, |
| 127 | +): string { |
| 128 | + const contract = freeformSystemPromptFor(templateId); |
| 129 | + const starter = isEmpty |
| 130 | + ? `\n\nStarter scaffold — write this working baseline to the scratch file first, then build by editing it:\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\`` |
| 131 | + : ""; |
| 132 | + return `Authoring contract for this canvas (imports, the \`ph\` data shim, and style rules):\n\n${contract}${starter}`; |
| 133 | +} |
| 134 | + |
95 | 135 | function readMarker(canvasId: string): BaseVersionMarker | undefined { |
96 | 136 | try { |
97 | 137 | return JSON.parse( |
@@ -119,32 +159,56 @@ const CONFLICT_MESSAGE = (canvasId: string) => |
119 | 159 | export const canvasCheckoutTool = defineLocalTool({ |
120 | 160 | name: "canvas_checkout", |
121 | 161 | description: |
122 | | - "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing: fetches the live " + |
123 | | - "source, writes it to a local scratch file, and records the version your edits are based on. " + |
124 | | - "Returns the file path. Edit that file with your normal file-editing tools, then call " + |
125 | | - "canvas_publish to save. Always start canvas work with this tool.", |
| 162 | + "Check out a PostHog canvas (a freeform React desktop-fs dashboard) for editing. Pass `id` to edit " + |
| 163 | + "an existing canvas, or omit `id` and pass `name` to create a fresh one. Fetches (or creates) the " + |
| 164 | + "canvas, writes its source to a local scratch file, records the base version for the publish-time " + |
| 165 | + "concurrency guard, and returns the scratch path plus the authoring contract to follow. Edit that " + |
| 166 | + "file with your normal file-editing tools, then call canvas_publish. Always start canvas work with " + |
| 167 | + "this tool.", |
126 | 168 | schema: { |
127 | | - id: z.string().describe("The canvas (desktop-fs dashboard row) id."), |
| 169 | + id: z |
| 170 | + .string() |
| 171 | + .optional() |
| 172 | + .describe( |
| 173 | + "Existing canvas (desktop-fs dashboard row) id to edit. Omit to create a new canvas via `name`.", |
| 174 | + ), |
| 175 | + name: z |
| 176 | + .string() |
| 177 | + .optional() |
| 178 | + .describe( |
| 179 | + "Name for a NEW canvas when `id` is omitted — creates it, then checks it out.", |
| 180 | + ), |
| 181 | + parentPath: z |
| 182 | + .string() |
| 183 | + .optional() |
| 184 | + .describe( |
| 185 | + "Optional desktop-fs folder path to create the new canvas under (e.g. a channel folder). Defaults to the top level.", |
| 186 | + ), |
128 | 187 | }, |
129 | 188 | alwaysLoad: true, |
| 189 | + autoApprove: true, |
130 | 190 | isEnabled: () => resolveSandboxPosthogApi() !== undefined, |
131 | 191 | handler: async (_ctx, args): Promise<LocalToolResult> => { |
132 | 192 | try { |
133 | | - const entry = await fetchCanvasEntry(args.id); |
134 | | - const file = canvasScratchFile(args.id); |
| 193 | + const entry = args.id |
| 194 | + ? await fetchCanvasEntry(args.id) |
| 195 | + : await createCanvasEntry(args.name, args.parentPath); |
| 196 | + const canvasId = entry.id; |
| 197 | + const file = canvasScratchFile(canvasId); |
135 | 198 | const code = entry.meta?.code ?? ""; |
136 | | - mkdirSync(canvasScratchDir(args.id), { recursive: true }); |
| 199 | + mkdirSync(canvasScratchDir(canvasId), { recursive: true }); |
137 | 200 | writeFileSync(file, code); |
138 | | - writeMarker(args.id, { |
| 201 | + writeMarker(canvasId, { |
139 | 202 | versionId: entry.meta?.currentVersionId, |
140 | 203 | fetchedAt: Date.now(), |
141 | 204 | }); |
142 | 205 | const lines = code ? code.split("\n").length : 0; |
143 | | - const text = code |
144 | | - ? `Checked out canvas "${entry.path}" to ${file} (${lines} lines, base version ${ |
| 206 | + const header = code |
| 207 | + ? `Checked out canvas "${entry.path}" (id ${canvasId}) to ${file} (${lines} lines, base version ${ |
145 | 208 | entry.meta?.currentVersionId ?? "none" |
146 | | - }).\nApply your changes by editing that file, then call canvas_publish with id "${args.id}".` |
147 | | - : `Canvas "${entry.path}" is empty. Author the complete single-file React app at ${file}, then call canvas_publish with id "${args.id}".`; |
| 209 | + }). Edit that file, then call canvas_publish with id "${canvasId}".` |
| 210 | + : `Canvas "${entry.path}" (id ${canvasId}) is empty — author the complete single-file React app at ${file}, then call canvas_publish with id "${canvasId}".`; |
| 211 | + const text = `${header}\n\n${authoringContract(entry.meta?.templateId, !code)}`; |
148 | 212 | return { content: [{ type: "text", text }] }; |
149 | 213 | } catch (err) { |
150 | 214 | return errorResult( |
|
0 commit comments