Skip to content

Commit e9b12cd

Browse files
committed
refactor(canvas): delegate publish composition to the desktop-fs canvas action
canvas_publish now calls the desktop-fs canvas action, which owns version composition server-side, passing the checkout's version as expected_current_version_id so a stale publish is rejected atomically (409 version_conflict) instead of guarded by a non-atomic tool-side check-then-PATCH. Deletes the tool-side compose logic and the raw meta PATCH; backends predating the guard field ignore it and publish unguarded, so the tool degrades gracefully. Server side: PostHog/posthog#72365. Generated-By: PostHog Code Task-Id: 2b3d176e-3023-41d2-92e8-81eda7c12a8c
1 parent 327587d commit e9b12cd

4 files changed

Lines changed: 93 additions & 235 deletions

File tree

Lines changed: 4 additions & 122 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { describe, expect, it } from "vitest";
2-
import {
3-
canvasScratchDir,
4-
canvasScratchFile,
5-
composePublishedMeta,
6-
} from "./canvas";
2+
import { canvasScratchDir, canvasScratchFile } from "./canvas";
73

4+
// Version composition and the stale-base rejection live server-side in the
5+
// desktop-fs canvas action (and are tested there); the tool's own logic is
6+
// the scratch-file plumbing.
87
describe("canvas scratch paths", () => {
98
it("keys the scratch dir and file by canvas id, outside any workspace", () => {
109
expect(canvasScratchDir("dash-1")).toBe("/tmp/posthog-canvas/dash-1");
@@ -13,120 +12,3 @@ describe("canvas scratch paths", () => {
1312
);
1413
});
1514
});
16-
17-
describe("composePublishedMeta", () => {
18-
const v1 = { id: "v1", code: "one", createdAt: 1 };
19-
const v2 = { id: "v2", code: "two", createdAt: 2 };
20-
const v3 = { id: "v3", code: "three", createdAt: 3 };
21-
22-
it("appends a new head version when the base matches", () => {
23-
const result = composePublishedMeta({
24-
freshMeta: { code: "two", versions: [v1, v2], currentVersionId: "v2" },
25-
baseVersionId: "v2",
26-
code: "edited",
27-
prompt: "tweak the chart",
28-
now: 10,
29-
});
30-
expect(result.ok).toBe(true);
31-
if (!result.ok) return;
32-
expect(result.meta.code).toBe("edited");
33-
expect(result.meta.currentVersionId).toBe(result.versionId);
34-
expect(result.meta.versions).toHaveLength(3);
35-
expect(result.meta.versions?.at(-1)).toMatchObject({
36-
code: "edited",
37-
prompt: "tweak the chart",
38-
createdAt: 10,
39-
});
40-
expect(result.meta.updatedAt).toBe(10);
41-
});
42-
43-
it("preserves unrelated meta keys", () => {
44-
const result = composePublishedMeta({
45-
freshMeta: {
46-
code: "one",
47-
versions: [v1],
48-
currentVersionId: "v1",
49-
templateId: "freeform",
50-
pinnedAt: 123,
51-
},
52-
baseVersionId: "v1",
53-
code: "edited",
54-
now: 10,
55-
});
56-
expect(result.ok).toBe(true);
57-
if (!result.ok) return;
58-
expect(result.meta.templateId).toBe("freeform");
59-
expect(result.meta.pinnedAt).toBe(123);
60-
});
61-
62-
it("rejects when the canvas moved past the base (concurrent edit)", () => {
63-
const result = composePublishedMeta({
64-
freshMeta: {
65-
code: "three",
66-
versions: [v1, v2, v3],
67-
currentVersionId: "v3",
68-
},
69-
baseVersionId: "v2",
70-
code: "edited",
71-
now: 10,
72-
});
73-
expect(result.ok).toBe(false);
74-
});
75-
76-
it("rejects a based publish onto a canvas with no versions", () => {
77-
const result = composePublishedMeta({
78-
freshMeta: {},
79-
baseVersionId: "v1",
80-
code: "edited",
81-
now: 10,
82-
});
83-
expect(result.ok).toBe(false);
84-
});
85-
86-
it("truncates the redo tail when publishing from an undone version", () => {
87-
// The user undid to v1 (pointer mid-history), then the agent edited from
88-
// that checkout: the redo tail (v2, v3) is discarded — the same
89-
// linear-discard the client's undo/redo uses.
90-
const result = composePublishedMeta({
91-
freshMeta: {
92-
code: "one",
93-
versions: [v1, v2, v3],
94-
currentVersionId: "v1",
95-
},
96-
baseVersionId: "v1",
97-
code: "edited",
98-
now: 10,
99-
});
100-
expect(result.ok).toBe(true);
101-
if (!result.ok) return;
102-
expect(result.meta.versions?.map((v) => v.id)).toEqual([
103-
"v1",
104-
result.versionId,
105-
]);
106-
});
107-
108-
it("seeds an empty canvas as the first version (no base)", () => {
109-
const result = composePublishedMeta({
110-
freshMeta: { templateId: "freeform" },
111-
baseVersionId: undefined,
112-
code: "first build",
113-
now: 10,
114-
});
115-
expect(result.ok).toBe(true);
116-
if (!result.ok) return;
117-
expect(result.meta.versions).toHaveLength(1);
118-
expect(result.meta.currentVersionId).toBe(result.versionId);
119-
expect(result.meta.code).toBe("first build");
120-
});
121-
122-
it("rejects an un-based publish onto a canvas that gained versions", () => {
123-
// Checked out empty, but a concurrent first build published meanwhile.
124-
const result = composePublishedMeta({
125-
freshMeta: { code: "one", versions: [v1], currentVersionId: "v1" },
126-
baseVersionId: undefined,
127-
code: "edited",
128-
now: 10,
129-
});
130-
expect(result.ok).toBe(false);
131-
});
132-
});

packages/agent/src/adapters/local-tools/tools/canvas.ts

Lines changed: 29 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1-
import { randomUUID } from "node:crypto";
21
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
32
import * as path from "node:path";
43
import { z } from "zod";
5-
import { PostHogAPIClient } from "../../../posthog-api";
4+
import {
5+
DesktopCanvasVersionConflictError,
6+
PostHogAPIClient,
7+
} from "../../../posthog-api";
68
import { resolveSandboxPosthogApi } from "../../../signed-commit-artefacts";
79
import { 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-
5848
interface 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-
11895
function 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-
174110
function errorResult(text: string): LocalToolResult {
175111
return { content: [{ type: "text", text }], isError: true };
176112
}
@@ -221,8 +157,8 @@ export const canvasCheckoutTool = defineLocalTool({
221157
export 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
);

packages/agent/src/posthog-api.ts

Lines changed: 47 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,14 @@ export type TaskRunUpdate = Partial<
6363
state_remove_keys?: string[];
6464
};
6565

66+
/** A guarded canvas publish was based on a stale version (409 from the API). */
67+
export class DesktopCanvasVersionConflictError extends Error {
68+
constructor(readonly currentVersionId: string | null) {
69+
super("Canvas version conflict: the canvas changed since it was read.");
70+
this.name = "DesktopCanvasVersionConflictError";
71+
}
72+
}
73+
6674
export class PostHogAPIClient {
6775
private config: PostHogAPIConfig;
6876

@@ -357,22 +365,50 @@ export class PostHogAPIClient {
357365
}
358366

359367
/**
360-
* PATCH a desktop file system entry's meta blob (the endpoint stores the
361-
* sent meta verbatim, so callers merge into the previously-fetched meta —
362-
* the same read-modify-write contract as `dashboardsService` in core).
368+
* Publish a freeform canvas's source via the desktop-fs canvas action. The
369+
* server owns version composition (appends the full-file snapshot, moves
370+
* `currentVersionId`, truncates any redo tail) and rejects a publish whose
371+
* `expectedCurrentVersionId` no longer matches the live head with a 409 —
372+
* surfaced as DesktopCanvasVersionConflictError. Backends predating the
373+
* guard ignore the field and publish unguarded, so this degrades gracefully.
363374
*/
364-
async patchDesktopFsEntryMeta<T>(
375+
async publishDesktopCanvas<T>(
365376
entryId: string,
366-
meta: Record<string, unknown>,
377+
input: {
378+
code: string;
379+
prompt?: string;
380+
/** The head version the code was based on; null when it was empty. */
381+
expectedCurrentVersionId: string | null;
382+
},
367383
): Promise<T> {
368384
const teamId = this.getTeamId();
369-
return this.apiRequest<T>(
370-
`/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/`,
371-
{
372-
method: "PATCH",
373-
body: JSON.stringify({ meta }),
374-
},
385+
const body: Record<string, unknown> = {
386+
code: input.code,
387+
expected_current_version_id: input.expectedCurrentVersionId,
388+
};
389+
if (input.prompt) {
390+
body.prompt = input.prompt;
391+
}
392+
const response = await this.performRequestWithRetry(
393+
`/api/projects/${teamId}/desktop_file_system/${encodeURIComponent(entryId)}/canvas/`,
394+
{ method: "PATCH", body: JSON.stringify(body) },
375395
);
396+
if (response.status === 409) {
397+
let currentVersionId: string | null = null;
398+
try {
399+
const parsed = (await response.json()) as {
400+
current_version_id?: string | null;
401+
};
402+
currentVersionId = parsed.current_version_id ?? null;
403+
} catch {
404+
// Conflict body unavailable — the status alone carries the signal.
405+
}
406+
throw new DesktopCanvasVersionConflictError(currentVersionId);
407+
}
408+
if (!response.ok) {
409+
throw new Error(`Failed to publish canvas (${response.status})`);
410+
}
411+
return response.json() as Promise<T>;
376412
}
377413

378414
/** Signal reports the given task is associated with (via report task associations). */

0 commit comments

Comments
 (0)