From 2675c187ce1f461cec50237eb8de63f4ee119beb Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Fri, 17 Jul 2026 10:56:54 +0100 Subject: [PATCH 1/5] feat(canvas): workflows attached to canvases A workflow canvas = a canvas artifact + a PostHog workflow (HogFlow) + the link between them. The unified canvas generation prompt gains a workflow intent branch: describing an ongoing action ("Send an email after signup") makes the build agent draft a workflow over the PostHog MCP workflows-* tools, author email templates with neutral branding, test every branch, and publish an observability canvas from a baked-in starter board (health or engagement) instead of authoring React from scratch. - Workflow link primitive: the agent-side hooks pair a workflows-create/get result with the canvas publish and emit _posthog/workflow_built; the app writes the link into the dashboard row meta (setWorkflow) and stamps templateId "workflow" (lightning icon in Artifacts, workflow badge in the canvas header, edit-time prompt resolution). - Go-live gating: workflows-enable / run-batch / schedule-create / update-schedule always raise an "Approve & publish" card - even in auto mode, with no always-allow, never persisted - and the cloud agent-server parks (never auto-approves) such requests when no client is reachable. - Generating state now mirrors the agent's live to-do list (StepList) and flips optimistically on submit; a terminal cloud run now clears "Generating" even while the chat session lingers connected. - Hero suggestions gain workflow ideas; repo-less tasks hide the diff chip. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da --- packages/agent/src/acp-extensions.ts | 18 + .../agent/src/adapters/claude/claude-agent.ts | 16 +- .../agent/src/adapters/claude/hooks.test.ts | 104 +++++ packages/agent/src/adapters/claude/hooks.ts | 126 ++++- .../permissions/permission-handlers.test.ts | 110 +++++ .../claude/permissions/permission-handlers.ts | 71 ++- .../permissions/posthog-exec-gate.test.ts | 37 ++ .../claude/permissions/posthog-exec-gate.ts | 17 + .../src/adapters/claude/session/options.ts | 7 + .../agent/src/server/agent-server.test.ts | 68 +++ packages/agent/src/server/agent-server.ts | 28 ++ packages/core/src/canvas/canvasTemplates.ts | 31 +- packages/core/src/canvas/dashboardSchemas.ts | 34 ++ .../core/src/canvas/dashboardsService.test.ts | 50 ++ packages/core/src/canvas/dashboardsService.ts | 37 +- packages/core/src/canvas/freeformSchemas.ts | 7 + packages/core/src/canvas/services.ts | 11 +- packages/core/src/canvas/workflowStarters.ts | 437 ++++++++++++++++++ .../src/sessions/acpNotifications.test.ts | 47 ++ .../core/src/sessions/acpNotifications.ts | 35 ++ .../src/routers/dashboards.router.ts | 9 + .../canvas/components/WebsiteDashboard.tsx | 11 +- .../canvas/components/canvasTemplateIcon.tsx | 11 +- .../canvas/freeform/CanvasGenerateHero.tsx | 8 + .../canvas/freeform/FreeformCanvasView.tsx | 108 ++++- .../canvas/freeform/FreeformGenerateBar.tsx | 10 + .../freeform/canvasGenerateSuggestions.ts | 32 +- .../freeform/canvasGenerationStatus.test.ts | 17 + .../canvas/freeform/canvasGenerationStatus.ts | 9 + .../freeform/useCanvasGenerationToasts.ts | 74 ++- .../features/canvas/freeformPrompt.test.ts | 109 +++++ .../ui/src/features/canvas/freeformPrompt.ts | 140 +++++- .../features/canvas/hooks/useDashboards.ts | 8 + .../sessions/components/DiffStatsChip.tsx | 3 + 34 files changed, 1784 insertions(+), 56 deletions(-) create mode 100644 packages/core/src/canvas/workflowStarters.ts create mode 100644 packages/core/src/sessions/acpNotifications.test.ts diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index c700c32bb7..20d4c20093 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -75,6 +75,11 @@ export const POSTHOG_NOTIFICATIONS = { /** PostHog products used during a turn (derived from MCP exec calls) */ RESOURCES_USED: "_posthog/resources_used", + /** A workflow build linked a PostHog workflow to its canvas (derived from the + * workflows-create result + the canvas publish call). The host writes the + * link onto the dashboard row (the agent can't persist it itself). */ + WORKFLOW_BUILT: "_posthog/workflow_built", + /** Response to a relayed permission request (plan approval, question) */ PERMISSION_RESPONSE: "_posthog/permission_response", @@ -93,6 +98,19 @@ export const POSTHOG_NOTIFICATIONS = { MCP_RESPONSE: "_posthog/mcp_response", } as const; +/** + * Payload of a `_posthog/workflow_built` notification: the PostHog workflow a + * build attached to its canvas. `dashboardId` is the canvas the workflow + * tracks; the host writes the rest onto that row's meta. + */ +export interface WorkflowBuiltPayload { + dashboardId: string; + workflowId: string; + workflowStatus?: string; + workflowName?: string; + workflowType?: string; +} + export type NativeGoalState = { objective: string; status: diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 402d8dbdd4..705569b0e0 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -96,7 +96,7 @@ import { type TaskState, taskStateToPlanEntries, } from "./conversion/task-state"; -import type { EnrichedReadCache } from "./hooks"; +import type { EnrichedReadCache, WorkflowBuiltSignal } from "./hooks"; import { createLocalToolsMcpServer } from "./mcp/local-tools"; import { clearMcpToolMetadataCache, @@ -1975,6 +1975,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { settingsManager, onModeChange: this.createOnModeChange(), onPostHogResourceUsed: this.createOnPostHogResourceUsed(), + onWorkflowBuilt: this.createOnWorkflowBuilt(), onProcessSpawned: this.options?.onProcessSpawned, onProcessExited: this.options?.onProcessExited, effort, @@ -2277,6 +2278,19 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; } + /** Emits the workflow link (workflow ↔ canvas) the moment a build forms it, + * so the host can persist it onto the dashboard row (the agent has no MCP + * tool to write it itself). Deterministic - derived from the build's tool + * calls, no model cooperation required. */ + private createOnWorkflowBuilt() { + return (signal: WorkflowBuiltSignal) => { + void this.client.extNotification(POSTHOG_NOTIFICATIONS.WORKFLOW_BUILT, { + sessionId: this.sessionId, + ...signal, + }); + }; + } + /** Adds products to the session-wide set and emits any newly-seen ones. * Session-wide dedup: only the first use of a product emits, so the client's * persistent list shows each chip once across all turns. */ diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index f12669dffc..53e3108ac7 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -10,12 +10,14 @@ vi.mock("../../enrichment/file-enricher", () => ({ import { Logger } from "../../utils/logger"; import type { TaskState } from "./conversion/task-state"; import { + createPostToolUseHook, createPreToolUseHook, createReadEnrichmentHook, createReadImageGuardHook, createSignedCommitGuardHook, createTaskHook, type EnrichedReadCache, + type WorkflowBuiltSignal, } from "./hooks"; import type { PermissionCheckResult, @@ -694,3 +696,105 @@ describe("createTaskHook", () => { expect(state.get("t1")?.subject).toBe("Fix bug"); }); }); + +describe("createPostToolUseHook workflow link", () => { + function execInput(command: string, tool_response?: unknown): HookInput { + return { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { command }, + tool_response, + } as unknown as HookInput; + } + + test("pairs a workflows-create result with the canvas publish it precedes", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + await hook( + execInput( + "call --json workflows-create {}", + JSON.stringify({ + id: "wf-42", + status: "draft", + name: "Welcome sequence", + }), + ), + "tu1", + { signal: new AbortController().signal }, + ); + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"export default () => null;"}', + ), + "tu2", + { signal: new AbortController().signal }, + ); + + expect(signals).toEqual([ + { + dashboardId: "dash-7", + workflowId: "wf-42", + workflowStatus: "draft", + workflowName: "Welcome sequence", + }, + ]); + }); + + test("links an existing workflow fetched with workflows-get", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + // Attach-existing flow: no workflows-create, just a workflows-get on the + // target, then the canvas publish. + await hook( + execInput( + "call --json workflows-get {}", + JSON.stringify({ + id: "wf-existing", + status: "active", + name: "Welcome sequence", + }), + ), + "tu1", + { signal: new AbortController().signal }, + ); + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-9","code":"x"}', + ), + "tu2", + { signal: new AbortController().signal }, + ); + + expect(signals).toEqual([ + { + dashboardId: "dash-9", + workflowId: "wf-existing", + workflowStatus: "active", + workflowName: "Welcome sequence", + }, + ]); + }); + + test("does not fire on a canvas publish with no workflow created first", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-7","code":"x"}', + ), + "tu1", + { signal: new AbortController().signal }, + ); + + expect(signals).toEqual([]); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 467df4bcfb..897d3f1e93 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -11,6 +11,7 @@ import { gitSubcommand } from "./git-command"; import { neutralizeUnprocessableImages } from "./image-sanitization"; import { extractPostHogSubTool, + isPostHogAlwaysGatedSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, } from "./permissions/posthog-exec-gate"; @@ -55,6 +56,66 @@ function extractTextFromToolResponse(response: unknown): string | null { return null; } +// Parse the first JSON object embedded in a string (e.g. the `{...}` arg of a +// PostHog `call --json {...}` command, or a JSON tool result). Returns +// null when there's no parseable object. +function parseEmbeddedJsonObject(text: string): Record | null { + const start = text.indexOf("{"); + if (start === -1) return null; + try { + const parsed = JSON.parse(text.slice(start)); + return parsed && typeof parsed === "object" + ? (parsed as Record) + : null; + } catch { + return null; + } +} + +// The `id` (dashboard id) a workflow build's canvas publish targets, read from +// the `desktop-file-system-canvas-partial-update` command's JSON arg. +function parseCanvasPublishDashboardId(toolInput: unknown): string | null { + const command = (toolInput as { command?: unknown })?.command; + if (typeof command !== "string") return null; + const arg = parseEmbeddedJsonObject(command); + return arg && typeof arg.id === "string" ? arg.id : null; +} + +// The workflow id + status from a `workflows-create` result. The result JSON may +// be the workflow itself or wrapped, so look under common envelopes too. Best +// effort - returns null if no workflow id is present. +function parseWorkflowFromResponse(response: unknown): { + workflowId: string; + workflowStatus?: string; + workflowName?: string; +} | null { + const text = extractTextFromToolResponse(response); + if (!text) return null; + const root = parseEmbeddedJsonObject(text); + if (!root) return null; + for (const candidate of [ + root, + root.workflow, + root.result, + root.data, + ] as Record[]) { + if (candidate && typeof candidate === "object") { + const id = (candidate as { id?: unknown }).id; + if (typeof id === "string" && id) { + const status = (candidate as { status?: unknown }).status; + const name = (candidate as { name?: unknown }).name; + return { + workflowId: id, + workflowStatus: typeof status === "string" ? status : undefined, + workflowName: + typeof name === "string" && name.trim() ? name : undefined, + }; + } + } + } + return null; +} + /** * Per-toolUseId handoff from the PostToolUse hook to `toolUpdateFromToolResult`. * Can't emit a standalone `tool_call_update` because the SDK emits its own @@ -198,19 +259,39 @@ export const createTaskHook = export type OnModeChange = (mode: CodeExecutionMode) => Promise; +// The link a workflow build forms: the workflow (from a workflows-create +// result) tracked by the canvas it publishes (dashboardId from the publish call). +export interface WorkflowBuiltSignal { + dashboardId: string; + workflowId: string; + workflowStatus?: string; + workflowName?: string; +} + interface CreatePostToolUseHookParams { onModeChange?: OnModeChange; /** Called after a PostHog MCP `call` exec executes, with the sub-tool name * and the raw command (the command embeds the SQL for execute-sql). */ onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; + /** Called once a workflow build has both created a workflow and published + * its canvas - the host then writes the link onto the dashboard row. */ + onWorkflowBuilt?: (signal: WorkflowBuiltSignal) => void; } -export const createPostToolUseHook = - ({ - onModeChange, - onPostHogResourceUsed, - }: CreatePostToolUseHookParams): HookCallback => - async ( +export const createPostToolUseHook = ({ + onModeChange, + onPostHogResourceUsed, + onWorkflowBuilt, +}: CreatePostToolUseHookParams): HookCallback => { + // Session-scoped accumulation: the workflow is created before the canvas is + // published, so remember the last workflow this run created and fire the + // link the moment the canvas publish (which carries the dashboardId) lands. + let pendingWorkflow: { + workflowId: string; + workflowStatus?: string; + workflowName?: string; + } | null = null; + return async ( input: HookInput, toolUseID: string | undefined, ): Promise<{ continue: boolean }> => { @@ -235,6 +316,28 @@ export const createPostToolUseHook = } } + // Observe a workflow build (deterministic, no model cooperation): the + // workflow this run last created OR fetched is the one the canvas tracks; + // the subsequent `desktop-file-system-canvas-partial-update` carries the + // dashboard it publishes to. Pair them into the link the host persists. + // Capturing `workflows-get` (not just `workflows-create`) is what lets a + // build attach a canvas to an EXISTING workflow, not only a new one. + if (onWorkflowBuilt && isPostHogExecTool(toolName)) { + const subTool = extractPostHogSubTool(input.tool_input); + if (subTool === "workflows-create" || subTool === "workflows-get") { + const workflow = parseWorkflowFromResponse(input.tool_response); + if (workflow) pendingWorkflow = workflow; + } else if ( + subTool === "desktop-file-system-canvas-partial-update" && + pendingWorkflow + ) { + const dashboardId = parseCanvasPublishDashboardId(input.tool_input); + if (dashboardId) { + onWorkflowBuilt({ dashboardId, ...pendingWorkflow }); + } + } + } + if (toolUseID) { const onPostToolUseHook = toolUseCallbacks[toolUseID]?.onPostToolUseHook; @@ -250,6 +353,7 @@ export const createPostToolUseHook = } return { continue: true }; }; +}; /** * Rewrites Agent tool calls targeting built-in subagent types to use our custom @@ -407,13 +511,19 @@ export const createPreToolUseHook = // so the SDK invokes canUseTool. if (permissionCheck.decision === "allow" && isPostHogExecTool(toolName)) { const subTool = extractPostHogSubTool(toolInput); - if (subTool && isPostHogDestructiveSubTool(subTool)) { + // Force "ask" (→ canUseTool) for destructive sub-tools AND for go-live + // workflow tools, so a settings allow-rule can't skip either gate. + if ( + subTool && + (isPostHogDestructiveSubTool(subTool) || + isPostHogAlwaysGatedSubTool(subTool)) + ) { return { continue: true, hookSpecificOutput: { hookEventName: "PreToolUse" as const, permissionDecision: "ask" as const, - permissionDecisionReason: `Destructive PostHog sub-tool '${subTool}' requires explicit approval`, + permissionDecisionReason: `PostHog sub-tool '${subTool}' requires explicit approval`, }, }; } diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index 60a200126c..01b2a16872 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -455,3 +455,113 @@ describe("AskUserQuestion cancelled outcomes", () => { await expect(canUseTool(context)).rejects.toThrow("Tool use aborted"); }); }); + +describe("canUseTool workflow go-live gate", () => { + function createGoLiveContext( + command: string, + options: { + permissionMode?: string; + hasPersistedApproval?: boolean; + responseOptionId?: string; + } = {}, + ) { + const addPostHogExecApproval = vi.fn().mockResolvedValue(undefined); + const context = createContext("mcp__posthog__exec", { + toolInput: { command }, + client: createClient({ + outcome: { + outcome: "selected", + optionId: options.responseOptionId ?? "allow", + }, + }), + session: { + permissionMode: options.permissionMode ?? "auto", + settingsManager: { + getRepoRoot: vi.fn().mockReturnValue("/repo"), + hasPostHogExecApproval: vi + .fn() + .mockReturnValue(options.hasPersistedApproval ?? false), + addPostHogExecApproval, + }, + }, + }); + return { context, addPostHogExecApproval }; + } + + it.each([ + "workflows-enable", + "workflows-run-batch", + "workflows-schedule-create", + "workflows-update-schedule", + ])( + "raises the approve-&-publish card for %s even in auto mode", + async (subTool) => { + const { context } = createGoLiveContext(`call --json ${subTool} {}`); + + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).toHaveBeenCalledTimes(1); + const request = ( + context.client.requestPermission as ReturnType + ).mock.calls[0][0]; + expect(request.toolCall.title).toContain("take this workflow live"); + expect(request.toolCall._meta.posthog.alwaysGated).toBe(true); + expect(request.options.map((o: { kind: string }) => o.kind)).toEqual([ + "allow_once", + "reject_once", + ]); + expect( + request.options.find((o: { kind: string }) => o.kind === "allow_once") + ?.name, + ).toBe("Approve & publish"); + }, + ); + + it("prompts even when a persisted always-allow covers the sub-tool", async () => { + // workflows-update-schedule matches the destructive regex too; a previously + // persisted "always allow" for it must not bypass the go-live gate. + const { context } = createGoLiveContext( + "call --json workflows-update-schedule {}", + { hasPersistedApproval: true }, + ); + + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).toHaveBeenCalledTimes(1); + }); + + it("never persists a go-live approval", async () => { + // Even if a client answered with allow_always (the go-live card doesn't + // offer it, but a stale/hostile client could), nothing may be persisted. + const { context, addPostHogExecApproval } = createGoLiveContext( + "call --json workflows-enable {}", + { responseOptionId: "allow_always" }, + ); + + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(addPostHogExecApproval).not.toHaveBeenCalled(); + }); + + it("still auto-allows plain destructive sub-tools in auto mode (contrast)", async () => { + const { context } = createGoLiveContext("call --json experiment-update {}"); + + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + }); + + it("denies when the user rejects going live", async () => { + const { context } = createGoLiveContext("call --json workflows-enable {}", { + responseOptionId: "reject", + }); + + const result = await canUseTool(context); + + expect(result.behavior).toBe("deny"); + }); +}); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 979f62140e..41d04ea62a 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -36,6 +36,7 @@ import { } from "./permission-options"; import { extractPostHogSubTool, + isPostHogAlwaysGatedSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, } from "./posthog-exec-gate"; @@ -585,39 +586,63 @@ async function handleMcpApprovalFlow( async function handlePostHogExecApprovalFlow( context: ToolHandlerContext, subTool: string, + // Go-live tools (enabling a workflow, dispatching a batch, scheduling) send to + // real people, so the card omits "always allow" - going live is an explicit, + // per-run human decision that must never be persisted away. + opts?: { goLive?: boolean }, ): Promise { const { toolName, toolInput, toolUseID, sessionId, session } = context; + const goLive = opts?.goLive ?? false; const response = await requestPermissionFromClient(context, { - options: [ - { kind: "allow_once", name: "Yes", optionId: "allow" }, - { - kind: "allow_always", - name: "Yes, always allow", - optionId: "allow_always", - }, - { - kind: "reject_once", - name: "Type here to tell the agent what to do differently", - optionId: "reject", - _meta: { customInput: true }, - }, - ], + options: goLive + ? [ + { kind: "allow_once", name: "Approve & publish", optionId: "allow" }, + { + kind: "reject_once", + name: "Type here to tell the agent what to do differently", + optionId: "reject", + _meta: { customInput: true }, + }, + ] + : [ + { kind: "allow_once", name: "Yes", optionId: "allow" }, + { + kind: "allow_always", + name: "Yes, always allow", + optionId: "allow_always", + }, + { + kind: "reject_once", + name: "Type here to tell the agent what to do differently", + optionId: "reject", + _meta: { customInput: true }, + }, + ], sessionId, toolCall: { toolCallId: toolUseID, - title: `The agent wants to run \`${subTool}\` on PostHog`, + title: goLive + ? `The agent wants to take this workflow live (\`${subTool}\`)` + : `The agent wants to run \`${subTool}\` on PostHog`, kind: "other", content: [ { type: "content" as const, content: text( - "This will modify live PostHog data. Approve to run this sub-tool.", + goLive + ? "This will make the workflow live and can send to real people. Approve only if you're ready to publish." + : "This will modify live PostHog data. Approve to run this sub-tool.", ), }, ], rawInput: { ...(toolInput as Record), toolName }, - _meta: { claudeCode: { toolName } }, + // `posthog.alwaysGated` marks the request as a go-live approval so the + // cloud relay can park it (never auto-approve) when no client is + // reachable - see the agent-server cloud client's requestPermission. + _meta: goLive + ? { claudeCode: { toolName }, posthog: { alwaysGated: true } } + : { claudeCode: { toolName } }, }, }); @@ -630,7 +655,7 @@ async function handlePostHogExecApprovalFlow( (response.outcome.optionId === "allow" || response.outcome.optionId === "allow_always") ) { - if (response.outcome.optionId === "allow_always") { + if (response.outcome.optionId === "allow_always" && !goLive) { try { await session.settingsManager.addPostHogExecApproval(subTool); } catch (error) { @@ -777,6 +802,16 @@ export async function canUseTool( if (isPostHogExecTool(toolName)) { const subTool = extractPostHogSubTool(toolInput); + // Go-live tools (enable / run-batch / schedule) always raise the + // approve-&-publish card - BEFORE the auto-mode short-circuit and the + // persisted-approval check below, so neither auto mode nor a previously + // saved "always allow" can ever take a workflow live without an explicit + // human OK. + if (subTool && isPostHogAlwaysGatedSubTool(subTool)) { + return handlePostHogExecApprovalFlow(context, subTool, { + goLive: true, + }); + } if (subTool && isPostHogDestructiveSubTool(subTool)) { if ( session.permissionMode === "auto" || diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts index 6c2e82a930..8162f0fd7a 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { extractPostHogSubTool, + isPostHogAlwaysGatedSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, } from "./posthog-exec-gate"; @@ -82,3 +83,39 @@ describe("isPostHogDestructiveSubTool", () => { expect(isPostHogDestructiveSubTool("deleter-test")).toBe(false); }); }); + +describe("isPostHogAlwaysGatedSubTool", () => { + it.each([ + "workflows-enable", + "workflows-run-batch", + "workflows-schedule-create", + "workflows-update-schedule", + "WORKFLOWS-ENABLE", + ])("gates the go-live tool %s", (subTool) => { + expect(isPostHogAlwaysGatedSubTool(subTool)).toBe(true); + }); + + it("does not gate other workflow or unrelated tools", () => { + expect(isPostHogAlwaysGatedSubTool("workflows-create")).toBe(false); + expect(isPostHogAlwaysGatedSubTool("workflows-test-run")).toBe(false); + expect(isPostHogAlwaysGatedSubTool("workflows-patch-graph")).toBe(false); + expect(isPostHogAlwaysGatedSubTool("workflows-blast-radius")).toBe(false); + expect(isPostHogAlwaysGatedSubTool("experiment-update")).toBe(false); + }); + + // The whole reason this list exists: go-live tools carry no update/delete + // token, so the destructive regex would let them through ungated. + it("covers go-live tools the destructive regex misses", () => { + for (const subTool of [ + "workflows-enable", + "workflows-run-batch", + "workflows-schedule-create", + ]) { + expect(isPostHogDestructiveSubTool(subTool)).toBe(false); + expect(isPostHogAlwaysGatedSubTool(subTool)).toBe(true); + } + // workflows-update-schedule DOES contain "update", so it's caught by both - + // that's fine, the go-live gate takes precedence in canUseTool. + expect(isPostHogDestructiveSubTool("workflows-update-schedule")).toBe(true); + }); +}); diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts index 2ecdb8c6ec..c9258dda48 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts @@ -28,3 +28,20 @@ export function extractPostHogSubTool(toolInput: unknown): string | null { export function isPostHogDestructiveSubTool(subTool: string): boolean { return POSTHOG_DESTRUCTIVE_SUBTOOL_RE.test(subTool); } + +// Workflow "go live" tools: enabling a workflow or dispatching to a real +// audience. These must ALWAYS raise an approval card - even in auto mode, and +// even when a prior "always allow" was persisted - because sending to real +// people is the human's explicit call (the approve-&-publish gate). Kept as an +// explicit set (not folded into the destructive regex) because these names +// carry no update/delete token and the gate must not be persistable. +const POSTHOG_ALWAYS_GATED_SUBTOOLS = new Set([ + "workflows-enable", + "workflows-run-batch", + "workflows-schedule-create", + "workflows-update-schedule", +]); + +export function isPostHogAlwaysGatedSubTool(subTool: string): boolean { + return POSTHOG_ALWAYS_GATED_SUBTOOLS.has(subTool.toLowerCase()); +} diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index 220d96b90f..766a8e7477 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -25,6 +25,7 @@ import { createTaskHook, type EnrichedReadCache, type OnModeChange, + type WorkflowBuiltSignal, } from "../hooks"; import { type CodeExecutionMode, toSdkPermissionMode } from "../tools"; import type { EffortLevel } from "../types"; @@ -86,6 +87,9 @@ export interface BuildOptionsParams { enrichedReadCache?: EnrichedReadCache; /** Records PostHog product usage from MCP exec calls (deduped, session-wide). */ onPostHogResourceUsed?: (subTool: string, commandText?: string) => void; + /** Fires when a workflow build links a PostHog workflow to its canvas, so + * the host can persist the link onto the dashboard row. */ + onWorkflowBuilt?: (signal: WorkflowBuiltSignal) => void; /** Cloud task session — enables the signed-commit guard. */ cloudMode?: boolean; /** Reactive self-heal invoked when the guard blocks a raw git commit/push. @@ -217,6 +221,7 @@ function buildHooks( onPostHogResourceUsed: | ((subTool: string, commandText?: string) => void) | undefined, + onWorkflowBuilt: ((signal: WorkflowBuiltSignal) => void) | undefined, settingsManager: SettingsManager, logger: Logger, enrichmentDeps: FileEnrichmentDeps | undefined, @@ -233,6 +238,7 @@ function buildHooks( createPostToolUseHook({ onModeChange, onPostHogResourceUsed, + onWorkflowBuilt, }), ]; if (enrichmentDeps && enrichedReadCache) { @@ -470,6 +476,7 @@ export function buildSessionOptions(params: BuildOptionsParams): Options { params.userProvidedOptions?.hooks, params.onModeChange, params.onPostHogResourceUsed, + params.onWorkflowBuilt, params.settingsManager, params.logger, params.enrichmentDeps, diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index fd0d106dec..4c5fe49795 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1478,6 +1478,74 @@ describe("AgentServer HTTP Mode", () => { expect(relaySpy).not.toHaveBeenCalled(); expect(result.outcome).toEqual({ outcome: "cancelled" }); }); + + // Workflow go-live approvals (`_meta.posthog.alwaysGated`, raised for + // workflows-enable / run-batch / schedule) must reach a human: relay when a + // client can answer, park (cancel) otherwise - never fall through to the + // auto-approve path, which would silently take the workflow live. + function goLivePermissionRequest() { + return { + options: [ + { optionId: "allow", kind: "allow_once", name: "Approve & publish" }, + { optionId: "reject", kind: "reject_once", name: "No" }, + ], + toolCall: { + kind: "other", + _meta: { + claudeCode: { toolName: "mcp__posthog__exec" }, + posthog: { alwaysGated: true }, + }, + rawInput: { command: "call workflows-enable {}" }, + }, + }; + } + + it("relays a go-live approval when a client is reachable", async () => { + const testServer = exposeCloudClient(createServer()); + testServer.session = { hasDesktopConnected: true }; + const relaySpy = vi + .spyOn(testServer, "relayPermissionToClient") + .mockResolvedValue({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + + const { requestPermission } = testServer.createCloudClient(basePayload); + const result = await requestPermission(goLivePermissionRequest()); + + expect(relaySpy).toHaveBeenCalledOnce(); + expect(result).toEqual({ + outcome: { outcome: "selected", optionId: "allow" }, + }); + }); + + it("parks a go-live approval instead of auto-approving when no client is reachable", async () => { + const testServer = exposeCloudClient(createServer()); + testServer.session = null; + testServer.eventStreamSender = null; + const relaySpy = vi.spyOn(testServer, "relayPermissionToClient"); + + const { requestPermission } = testServer.createCloudClient(basePayload); + const result = await requestPermission(goLivePermissionRequest()); + + expect(relaySpy).not.toHaveBeenCalled(); + expect(result.outcome).toEqual({ outcome: "cancelled" }); + expect(result._meta?.message).toContain("explicit human approval"); + }); + + it("parks a go-live approval in background mode even when a client is reachable", async () => { + const testServer = exposeCloudClient(createServer()); + testServer.session = { hasDesktopConnected: true }; + const relaySpy = vi.spyOn(testServer, "relayPermissionToClient"); + + const { requestPermission } = testServer.createCloudClient({ + ...basePayload, + mode: "background", + }); + const result = await requestPermission(goLivePermissionRequest()); + + expect(relaySpy).not.toHaveBeenCalled(); + expect(result.outcome).toEqual({ outcome: "cancelled" }); + }); }); describe("refresh_session relay re-append", () => { diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index 140863f0da..c36fc75467 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -3694,6 +3694,34 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} } } + // Always-gated approvals (workflow go-live: enable / run-batch / + // schedule) make an agent-built workflow live and can send to real + // people. The permission handler marks them with + // `_meta.posthog.alwaysGated`; they must reach a human - relay when a + // client can answer, otherwise PARK (cancel with guidance) rather than + // fall through to the auto-approve below, which would silently take + // the workflow live with nobody watching. + { + const alwaysGated = + (params.toolCall?._meta as { posthog?: { alwaysGated?: unknown } }) + ?.posthog?.alwaysGated === true; + if (alwaysGated) { + if (mode !== "background" && this.hasReachableClient()) { + return this.relayPermissionToClient(params); + } + return { + outcome: { outcome: "cancelled" as const }, + _meta: { + message: + "Taking a workflow live requires explicit human approval, " + + "but no user is available to approve it. Do NOT retry and do " + + "NOT enable or dispatch the workflow; summarise what is ready " + + "and end your turn so the user can approve when they are back.", + }, + }; + } + } + // Relay permission requests to the connected client when: // - Plan approvals: always relay because they gate autonomy changes // that require human confirmation (buffered until desktop connects) diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index d75cfba72b..5ba8af6212 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -1,4 +1,4 @@ -import { FREEFORM_TEMPLATE_ID } from "./freeformSchemas"; +import { FREEFORM_TEMPLATE_ID, WORKFLOW_TEMPLATE_ID } from "./freeformSchemas"; import { FREEFORM_WHITELIST } from "./freeformWhitelist"; import type { CanvasSuggestion } from "./templateSchemas"; @@ -149,6 +149,32 @@ const FREEFORM_WEB_ANALYTICS_RULES = [ ...FREEFORM_DATE_CONTROL_RULES, ]; +// The metrics-canvas contract for a WORKFLOW canvas. The build agent (see the +// workflow branch of buildFreeformGenerationPrompt) builds + tests a PostHog +// workflow, then publishes this canvas to track it. The canvas ADAPTS to what +// the workflow does: an alert shows deliverability & health; a marketing flow +// shows email engagement. Same freeform React contract underneath - these are +// the extra layout/metric rules layered on top. +const FREEFORM_WORKFLOW_RULES = [ + "This canvas TRACKS a PostHog workflow that the agent has just built. It is a LIVE, DATA-DRIVEN board of that workflow's real delivery + outcome metrics - not a static mockup.", + "ADAPT THE BOARD TO WHAT THE WORKFLOW DOES - pick the layout from the workflow's type:", + "- ALERT / NOTIFICATION / SYNC / DATA-HYGIENE (Slack/Discord/webhook/CRM/enrichment) → DELIVERABILITY & HEALTH: successful vs failed deliveries over time, overall success rate, an error-reason breakdown, delivery latency, and last-fired / total volume. Frame FAILURES prominently - a broken alert is the thing the user most needs to see.", + "- MARKETING / LIFECYCLE EMAIL (welcome, onboarding, re-engagement, drip) → ENGAGEMENT: sends / deliveries / opens / clicks / bounces / unsubscribes over time, a send→open→click funnel, and open-rate / click-rate / bounce-rate KPIs.", + "- BATCH / SCHEDULED (either of the above, run on a schedule or over an audience) → ALSO show audience reach per run (how many people each run matched / dispatched to).", + "DISCOVER THE REAL DATA - do NOT hardcode event or table names for workflow deliveries or email outcomes. Use the PostHog MCP to find the actual events/tables this project emits for workflow invocations and email engagement (e.g. via read-data-schema and the workflows MCP tools), then back each metric with a SAVED insight loaded by `short_id`.", + "NOT-YET-FIRED - a just-published workflow has NO data yet. Detect the empty case and say so explicitly (an inviting 'This workflow hasn't run yet - metrics will appear here once it goes live' state). NEVER render zeros as if they were failures.", + ...FREEFORM_QUILL_RULES, + ...FREEFORM_DATE_CONTROL_RULES, +]; + +// The workflow metrics-canvas rules as one block, for embedding into the +// unified generation prompt's workflow branch (which already carries the +// generic freeform contract - embedding the full workflow system prompt there +// would duplicate the OUTPUT/IMPORTS/DATA sections). +export const WORKFLOW_CANVAS_RULES_TEXT = FREEFORM_WORKFLOW_RULES.map( + (rule) => `- ${rule}`, +).join("\n"); + const FREEFORM_SYSTEM_PROMPT = buildFreeformPrompt(); // System prompts keyed by templateId for the canvas gen path; the generic @@ -157,6 +183,9 @@ const FREEFORM_SYSTEM_PROMPT = buildFreeformPrompt(); // templateIds still resolve their richer layout prompts here. const FREEFORM_SYSTEM_PROMPTS: Record = { [FREEFORM_TEMPLATE_ID]: FREEFORM_SYSTEM_PROMPT, + // Workflow canvases are stamped with this templateId when their build links a + // workflow, so EDIT generations resolve the workflow board contract. + [WORKFLOW_TEMPLATE_ID]: buildFreeformPrompt(FREEFORM_WORKFLOW_RULES), dashboard: buildFreeformPrompt(FREEFORM_DASHBOARD_RULES), "web-analytics": buildFreeformPrompt(FREEFORM_WEB_ANALYTICS_RULES), }; diff --git a/packages/core/src/canvas/dashboardSchemas.ts b/packages/core/src/canvas/dashboardSchemas.ts index db70307cb9..94cd95c0ff 100644 --- a/packages/core/src/canvas/dashboardSchemas.ts +++ b/packages/core/src/canvas/dashboardSchemas.ts @@ -1,6 +1,23 @@ import { z } from "zod"; import { freeformVersionSchema } from "./freeformSchemas"; +// The workflow attached to a WORKFLOW canvas. A workflow canvas is a canvas +// (dashboard row) with this link in its meta; the workflow itself lives in +// PostHog cloud and is only referenced here (PRD §3). Written host-side after a +// successful workflow build (the agent can't persist it - see the workflow +// link primitive), and read to render the status badge + link-out. +export const workflowLinkSchema = z.object({ + // The PostHog HogFlow id this canvas tracks. + workflowId: z.string(), + // What the workflow does (alert / marketing / batch …). Optional - used for + // display only; absent when it couldn't be classified deterministically. + workflowType: z.string().optional(), + // The workflow's status at link time (e.g. "draft"). Optional; drives the + // badge text. Not kept live - refreshed only when a new build runs. + workflowStatus: z.string().optional(), +}); +export type WorkflowLink = z.infer; + export const dashboardRecordSchema = z.object({ id: z.string(), // The channel (desktop file-system folder) this dashboard belongs to. @@ -28,6 +45,9 @@ export const dashboardRecordSchema = z.object({ // Epoch ms the canvas was pinned to its channel; absent = not pinned. Stored // in the row's meta, so a pin is shared across all users of the channel. pinnedAt: z.number().optional(), + // The attached workflow (workflow canvases only). Absent for regular + // canvases and for a workflow canvas whose build hasn't linked one yet. + workflow: workflowLinkSchema.optional(), }); export type DashboardRecord = z.infer; @@ -64,6 +84,9 @@ export const dashboardFileMetaSchema = z.object({ // Epoch ms the canvas was pinned to its channel; absent = not pinned. Lives in // meta (the shared backend row) so a pin is visible to every channel member. pinnedAt: z.number().optional(), + // The attached workflow for a workflow canvas (PRD §3). Lives in meta so + // the link is shared across every client, like the other canvas metadata. + workflow: workflowLinkSchema.optional(), }); export type DashboardFileMeta = z.infer; @@ -84,6 +107,9 @@ export const dashboardSummarySchema = z.object({ // Epoch ms the canvas was pinned to its channel; absent = not pinned. On the // summary so the Pinned menu can filter/sort without a per-canvas get(). pinnedAt: z.number().optional(), + // The attached workflow (workflow canvases only). On the summary so the + // Artifacts list can show the workflow's status without a per-canvas get(). + workflow: workflowLinkSchema.optional(), }); export type DashboardSummary = z.infer; @@ -131,3 +157,11 @@ export const setPinnedInput = z.object({ id: z.string().min(1), pinned: z.boolean(), }); + +// Attach (link) a workflow to a canvas, making it a workflow canvas. Written +// host-side after a successful workflow build; merged into the row's meta like +// the other writers. +export const setWorkflowInput = z.object({ + id: z.string().min(1), + workflow: workflowLinkSchema, +}); diff --git a/packages/core/src/canvas/dashboardsService.test.ts b/packages/core/src/canvas/dashboardsService.test.ts index 5d488ccdf5..65dfd263ef 100644 --- a/packages/core/src/canvas/dashboardsService.test.ts +++ b/packages/core/src/canvas/dashboardsService.test.ts @@ -285,3 +285,53 @@ describe("DashboardsService.resetHomeCanvas", () => { ).toBe("new-1"); }); }); + +describe("DashboardsService.setWorkflow", () => { + it("merges the workflow link into meta and stamps the workflow templateId", async () => { + const { fs, entries } = statefulFs({ + wf1: { + id: "wf1", + path: "marketing/My workflow canvas", + type: "dashboard", + meta: { + channelId: "chan-1", + templateId: "freeform", + code: "export default () => null;", + versions: [{ id: "v1", code: "x", createdAt: 1 }], + currentVersionId: "v1", + generationTaskId: "task-9", + }, + }, + }); + const service = new DashboardsService(fs, {} as never); + + const record = await service.setWorkflow({ + id: "wf1", + workflow: { + workflowId: "hf-123", + workflowType: "alert", + workflowStatus: "draft", + }, + }); + + // The link is written onto the record + meta. + expect(record.workflow).toEqual({ + workflowId: "hf-123", + workflowType: "alert", + workflowStatus: "draft", + }); + const meta = entries.wf1?.meta as Record; + expect(meta.workflow).toEqual({ + workflowId: "hf-123", + workflowType: "alert", + workflowStatus: "draft", + }); + // The row is retagged as a workflow canvas (drives the lightning icon and + // edit-time prompt resolution) ... + expect(meta.templateId).toBe("workflow"); + // ... while existing canvas state is preserved (merge, not clobber). + expect(meta.code).toBe("export default () => null;"); + expect(meta.currentVersionId).toBe("v1"); + expect(meta.generationTaskId).toBe("task-9"); + }); +}); diff --git a/packages/core/src/canvas/dashboardsService.ts b/packages/core/src/canvas/dashboardsService.ts index fcc22c1e6e..a681a5be20 100644 --- a/packages/core/src/canvas/dashboardsService.ts +++ b/packages/core/src/canvas/dashboardsService.ts @@ -5,13 +5,18 @@ import type { DashboardFileMeta, DashboardRecord, DashboardSummary, + WorkflowLink, } from "./dashboardSchemas"; import { DESKTOP_FS_CLIENT, type DesktopFsClient, type FsEntryBase, } from "./desktopFsClient"; -import { FREEFORM_TEMPLATE_ID, type FreeformVersion } from "./freeformSchemas"; +import { + FREEFORM_TEMPLATE_ID, + type FreeformVersion, + WORKFLOW_TEMPLATE_ID, +} from "./freeformSchemas"; import { fetchCurrentUser } from "./posthogApi"; // Desktop file-system "type" tag for a dashboard entry. Channels are `folder` @@ -92,6 +97,7 @@ export class DashboardsService { code, generationTaskId, pinnedAt, + workflow, }) => ({ id, channelId: cid, @@ -102,6 +108,7 @@ export class DashboardsService { code, generationTaskId, pinnedAt, + workflow, }), ); } @@ -202,6 +209,33 @@ export class DashboardsService { return toRecord((await res.json()) as FsEntry); } + // Attach a workflow to a canvas, making it a workflow canvas. Merges the link + // into the row's meta like the other writers so it never clobbers + // code/versions, and stamps `templateId` so the artifact list / icon / edit + // prompts recognize the canvas without inspecting the link. This is the host + // side of the workflow link primitive: the agent builds the workflow + + // publishes the canvas, and the host writes the link here (the agent has no + // MCP tool to persist it itself - PRD §6.3). + async setWorkflow(input: { + id: string; + workflow: WorkflowLink; + }): Promise { + const entry = await this.getEntry(input.id); + const prevMeta = entry?.meta ?? {}; + const meta: DashboardFileMeta = { + ...prevMeta, + templateId: WORKFLOW_TEMPLATE_ID, + workflow: input.workflow, + }; + const res = await this.fs.fetch(`${encodeURIComponent(input.id)}/`, { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ meta }), + }); + if (!res.ok) throw new Error(`Failed to set workflow (${res.status})`); + return toRecord((await res.json()) as FsEntry); + } + // Pin (or unpin) a canvas to its channel. Writes `pinnedAt` into the row's // meta — shared across users — merging like the other writers so it never // clobbers code/versions. Unpinning drops the key (the PATCH sends the merged @@ -796,6 +830,7 @@ function toRecord(entry: FsEntry): DashboardRecord { createdAt, updatedAt: meta.updatedAt ?? createdAt, pinnedAt: meta.pinnedAt, + workflow: meta.workflow, }; } diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index 8f683bfa33..0baaaee507 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -4,6 +4,13 @@ import { z } from "zod"; // generation path can resolve the right system prompt. export const FREEFORM_TEMPLATE_ID = "freeform"; +// A workflow canvas is a canvas artifact with a PostHog workflow (HogFlow) +// attached - same dashboard-typed FS row, tagged with this templateId in meta. +// It is NOT a new object type; the tag is stamped host-side when the workflow +// link is written, and distinguishes the artifact (lightning icon, edit-time +// generation prompt). See docs/plans/automations-prd.md. +export const WORKFLOW_TEMPLATE_ID = "workflow"; + // A single point in a freeform canvas's edit history. Every agent turn appends // one full-file snapshot (Q7: full-file rewrite); the user can revert to any of // them and the `currentVersionId` pointer is what publishes. We keep whole-file diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index abae8e389a..1788910d77 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -1,5 +1,9 @@ import type { ChannelTaskRecord } from "./channelTaskSchemas"; -import type { DashboardRecord, DashboardSummary } from "./dashboardSchemas"; +import type { + DashboardRecord, + DashboardSummary, + WorkflowLink, +} from "./dashboardSchemas"; import type { CanvasCaptureConfig, CanvasCaptureInput, @@ -45,6 +49,11 @@ export interface IDashboardsService { taskId: string | null; }): Promise; setPinned(input: { id: string; pinned: boolean }): Promise; + // Attach a workflow to a canvas (the workflow link primitive). + setWorkflow(input: { + id: string; + workflow: WorkflowLink; + }): Promise; rename(input: { id: string; name: string }): Promise; // Idempotently create + seed a channel's home canvas, returning it. ensureHomeCanvas(channelId: string): Promise; diff --git a/packages/core/src/canvas/workflowStarters.ts b/packages/core/src/canvas/workflowStarters.ts new file mode 100644 index 0000000000..8f8e883f06 --- /dev/null +++ b/packages/core/src/canvas/workflowStarters.ts @@ -0,0 +1,437 @@ +// Category STARTER dashboards for a workflow's metrics canvas. Instead of the +// agent authoring the whole React app from scratch (slow), the workflow +// generation prompt seeds the scaffold matching the workflow's category, and the +// agent just replaces the placeholder metrics with SAVED insights it discovers. +// +// The two categories mirror the adaptive rules in canvasTemplates +// (FREEFORM_WORKFLOW_RULES): +// - health: alert / notification / sync / data-hygiene → deliverability +// - engagement: marketing / lifecycle email → send→open→click +// +// Both reuse the EXACT proven wiring from FREEFORM_STARTER_CODE (self-sizing date +// picker, theme tokens, per-card skeletons, typed-node result reading) so they +// compile as-is. The sample query is "all events" (works on any project) — the +// agent swaps each metric for a real SAVED insight loaded with +// `ph.loadInsight(shortId, { dateRange })`, and renders the not-yet-fired empty +// state when there's no data (never zeros-as-failures). + +// Shared prelude (imports + date/refresh/loading state) so the two starters stay +// in lockstep with the base scaffold's wiring. +const STARTER_PRELUDE = `import React, { useEffect, useState } from "react"; +import { + Badge, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + DateTimePicker, + Heading, + Popover, + PopoverContent, + PopoverTrigger, + quickRanges, + SkeletonText, + Text, +} from "@posthog/quill"; +import { RefreshCw } from "lucide-react"; +import { + Bar, + BarChart, + CartesianGrid, + Line, + LineChart, + ResponsiveContainer, + Tooltip, + XAxis, + YAxis, +} from "recharts";`; + +// Deliverability & health board: successful vs failed deliveries, success rate, +// error/last-fired KPIs. For alert / notification / sync / data-hygiene flows. +export const WORKFLOW_HEALTH_STARTER = `${STARTER_PRELUDE} + +// STARTER: workflow deliverability & health board. KEEP the wiring; REPLACE +// the placeholder "all events" query with SAVED insights for this workflow's +// real delivery + failure metrics (discover the event/table names via MCP). +export default function Canvas() { + const def = + quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0]; + const [win, setWin] = useState({ + start: def.rangeSetter(new Date()), + end: new Date(), + range: def, + }); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [nonce, setNonce] = useState(0); + + // TODO(agent): replace with the workflow's real delivery metrics — one SAVED + // insight per KPI, loaded via ph.loadInsight(shortId, { dateRange }). Keep the + // shape: a per-day series for the chart and totals for the KPI cards. + const [delivered, setDelivered] = useState(0); + const [failed, setFailed] = useState(0); + const [series, setSeries] = useState([]); + + useEffect(() => { + let cancelled = false; + setLoading(true); + ph.query({ + kind: "TrendsQuery", + series: [ + { kind: "EventsNode", event: null, name: "All events", math: "total" }, + ], + dateRange: { + date_from: win.start.toISOString(), + date_to: win.end.toISOString(), + }, + }) + .then((res) => { + if (cancelled) return; + const s = res.results[0] ?? {}; + setDelivered(s.count ?? 0); + setFailed(0); // TODO(agent): a second insight for failed deliveries. + setSeries( + (s.days ?? []).map((day, i) => ({ + day, + delivered: s.data?.[i] ?? 0, + failed: 0, + })), + ); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [win, nonce]); + + const total = delivered + failed; + const successRate = total > 0 ? Math.round((delivered / total) * 100) : 0; + const hasData = loading || total > 0; + + return ( +
+
+ Deliverability & health +
+ + {win.range.name}} + /> + + { + setWin(v); + setOpen(false); + }} + onCancel={() => setOpen(false)} + /> + + + +
+
+ + {!hasData ? ( + // Not-yet-fired: no deliveries in this window — say so, don't show zeros + // as failures. + + +
+ This workflow hasn't fired yet + + Delivery volume, success rate, and history will appear here once + the workflow is live and starts running. + +
+
+
+ ) : ( + <> +
+ + + Delivered + + + {loading ? ( + + ) : ( + {delivered.toLocaleString()} + )} + + + + + Success rate + + + {loading ? ( + + ) : ( +
+ {successRate}% + 0 ? "warning" : "success"}> + {failed.toLocaleString()} failed + +
+ )} +
+
+ + + Failures + + + {loading ? ( + + ) : ( + {failed.toLocaleString()} + )} + + +
+ + + + Deliveries over time + + + {loading ? ( + + ) : ( +
+ + + + + + + + + + +
+ )} +
+
+ + )} +
+ ); +}`; + +// Engagement board: sends/opens/clicks/bounces + a send→open→click funnel. For +// marketing / lifecycle email flows. +export const WORKFLOW_ENGAGEMENT_STARTER = `${STARTER_PRELUDE} + +// STARTER: workflow email-engagement board. KEEP the wiring; REPLACE the +// placeholder "all events" query with SAVED insights for this workflow's real +// send / open / click / bounce metrics (discover the event names via MCP). +export default function Canvas() { + const def = + quickRanges.find((r) => r.name === "Last 30 days") ?? quickRanges[0]; + const [win, setWin] = useState({ + start: def.rangeSetter(new Date()), + end: new Date(), + range: def, + }); + const [open, setOpen] = useState(false); + const [loading, setLoading] = useState(true); + const [nonce, setNonce] = useState(0); + + // TODO(agent): replace with the workflow's real email metrics — one SAVED + // insight per stage, loaded via ph.loadInsight(shortId, { dateRange }). + const [sends, setSends] = useState(0); + const [opens, setOpens] = useState(0); + const [clicks, setClicks] = useState(0); + + useEffect(() => { + let cancelled = false; + setLoading(true); + ph.query({ + kind: "TrendsQuery", + series: [ + { kind: "EventsNode", event: null, name: "All events", math: "total" }, + ], + dateRange: { + date_from: win.start.toISOString(), + date_to: win.end.toISOString(), + }, + }) + .then((res) => { + if (cancelled) return; + const s = res.results[0] ?? {}; + const n = s.count ?? 0; + // TODO(agent): these are placeholders derived from one series so the + // funnel renders — replace each with its own saved insight. + setSends(n); + setOpens(Math.round(n * 0.4)); + setClicks(Math.round(n * 0.1)); + setLoading(false); + }) + .catch(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [win, nonce]); + + const openRate = sends > 0 ? Math.round((opens / sends) * 100) : 0; + const clickRate = sends > 0 ? Math.round((clicks / sends) * 100) : 0; + const funnel = [ + { stage: "Sent", value: sends }, + { stage: "Opened", value: opens }, + { stage: "Clicked", value: clicks }, + ]; + const hasData = loading || sends > 0; + + return ( +
+
+ Email engagement +
+ + {win.range.name}} + /> + + { + setWin(v); + setOpen(false); + }} + onCancel={() => setOpen(false)} + /> + + + +
+
+ + {!hasData ? ( + + +
+ This workflow hasn't sent yet + + Sends, opens, clicks, and the engagement funnel will appear here + once the workflow is live. + +
+
+
+ ) : ( + <> +
+ + + Sent + + + {loading ? ( + + ) : ( + {sends.toLocaleString()} + )} + + + + + Open rate + + + {loading ? ( + + ) : ( + {openRate}% + )} + + + + + Click rate + + + {loading ? ( + + ) : ( + {clickRate}% + )} + + +
+ + + + Send → open → click + + + {loading ? ( + + ) : ( +
+ + + + + + + + + +
+ )} +
+
+ + )} +
+ ); +}`; diff --git a/packages/core/src/sessions/acpNotifications.test.ts b/packages/core/src/sessions/acpNotifications.test.ts new file mode 100644 index 0000000000..3b259d89f8 --- /dev/null +++ b/packages/core/src/sessions/acpNotifications.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from "vitest"; +import { parseWorkflowBuiltParams } from "./acpNotifications"; + +describe("parseWorkflowBuiltParams", () => { + it("parses a full payload", () => { + expect( + parseWorkflowBuiltParams({ + sessionId: "s1", + dashboardId: "d1", + workflowId: "wf1", + workflowStatus: "draft", + workflowName: "Welcome sequence", + workflowType: "alert", + }), + ).toEqual({ + dashboardId: "d1", + workflowId: "wf1", + workflowStatus: "draft", + workflowName: "Welcome sequence", + workflowType: "alert", + }); + }); + + it("keeps optional fields undefined when absent or wrong-typed", () => { + expect( + parseWorkflowBuiltParams({ + dashboardId: "d1", + workflowId: "wf1", + workflowStatus: 42, + }), + ).toEqual({ + dashboardId: "d1", + workflowId: "wf1", + workflowStatus: undefined, + workflowName: undefined, + workflowType: undefined, + }); + }); + + it("returns null without the required ids", () => { + expect(parseWorkflowBuiltParams({ workflowId: "wf1" })).toBeNull(); + expect(parseWorkflowBuiltParams({ dashboardId: "d1" })).toBeNull(); + expect(parseWorkflowBuiltParams(undefined)).toBeNull(); + expect(parseWorkflowBuiltParams(null)).toBeNull(); + expect(parseWorkflowBuiltParams("nope")).toBeNull(); + }); +}); diff --git a/packages/core/src/sessions/acpNotifications.ts b/packages/core/src/sessions/acpNotifications.ts index 242e69ad14..2cef27cf80 100644 --- a/packages/core/src/sessions/acpNotifications.ts +++ b/packages/core/src/sessions/acpNotifications.ts @@ -21,6 +21,7 @@ export const POSTHOG_NOTIFICATIONS = { PERMISSION_RESPONSE: "_posthog/permission_response", PERMISSION_REQUEST: "_posthog/permission_request", PERMISSION_RESOLVED: "_posthog/permission_resolved", + WORKFLOW_BUILT: "_posthog/workflow_built", } as const; // Qualified id of the agent's `speak` narration tool, as it appears on the @@ -42,3 +43,37 @@ export function isNotification( ): boolean { return matchesExt(method, expected); } + +// The PostHog workflow a build attached to its canvas (payload of a +// `_posthog/workflow_built` notification). Mirrors WorkflowBuiltPayload in +// @posthog/agent; duplicated here so core/ui parse it without importing agent. +export interface WorkflowBuiltPayload { + dashboardId: string; + workflowId: string; + workflowStatus?: string; + workflowName?: string; + workflowType?: string; +} + +// Parse a `_posthog/workflow_built` notification's params, returning the link +// payload only when the required ids are present. Tolerates the loosely-typed +// JSON-RPC params object that arrives off the session stream. +export function parseWorkflowBuiltParams( + params: unknown, +): WorkflowBuiltPayload | null { + if (!params || typeof params !== "object") return null; + const p = params as Record; + if (typeof p.dashboardId !== "string" || typeof p.workflowId !== "string") { + return null; + } + return { + dashboardId: p.dashboardId, + workflowId: p.workflowId, + workflowStatus: + typeof p.workflowStatus === "string" ? p.workflowStatus : undefined, + workflowName: + typeof p.workflowName === "string" ? p.workflowName : undefined, + workflowType: + typeof p.workflowType === "string" ? p.workflowType : undefined, + }; +} diff --git a/packages/host-router/src/routers/dashboards.router.ts b/packages/host-router/src/routers/dashboards.router.ts index 2f5996794b..91a61c8348 100644 --- a/packages/host-router/src/routers/dashboards.router.ts +++ b/packages/host-router/src/routers/dashboards.router.ts @@ -9,6 +9,7 @@ import { saveFreeformInput, setGenerationTaskInput, setPinnedInput, + setWorkflowInput, } from "@posthog/core/canvas/dashboardSchemas"; import { DASHBOARDS_SERVICE } from "@posthog/core/canvas/identifiers"; import type { IDashboardsService } from "@posthog/core/canvas/services"; @@ -60,6 +61,14 @@ export const dashboardsRouter = router({ .get(DASHBOARDS_SERVICE) .setPinned(input), ), + setWorkflow: publicProcedure + .input(setWorkflowInput) + .output(dashboardRecordSchema) + .mutation(({ ctx, input }) => + ctx.container + .get(DASHBOARDS_SERVICE) + .setWorkflow(input), + ), rename: publicProcedure .input(renameDashboardInput) .output(dashboardRecordSchema) diff --git a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx index 0af43d10cf..9ead288310 100644 --- a/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteDashboard.tsx @@ -1,3 +1,4 @@ +import { WORKFLOW_TEMPLATE_ID } from "@posthog/core/canvas/freeformSchemas"; import { FreeformCanvasView } from "@posthog/ui/features/canvas/freeform/FreeformCanvasView"; import { useDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useIsDashboardEditing } from "@posthog/ui/features/canvas/stores/dashboardEditStore"; @@ -27,5 +28,13 @@ export function WebsiteDashboard({ dashboardId }: { dashboardId: string }) { }); }, [dashboard, threadId, syncFromRecord]); - return ; + // Workflow canvases always open with the agent chat on the right so users + // can ask about the workflow or request changes — and so a build whose + // metrics canvas hasn't published yet lands on the composer, not a dead-end + // "this canvas is empty" view. Regular canvases keep the view-first default. + const isWorkflowCanvas = + !!dashboard?.workflow || dashboard?.templateId === WORKFLOW_TEMPLATE_ID; + const interactive = editing || isWorkflowCanvas; + + return ; } diff --git a/packages/ui/src/features/canvas/components/canvasTemplateIcon.tsx b/packages/ui/src/features/canvas/components/canvasTemplateIcon.tsx index 693dc20150..69e6816947 100644 --- a/packages/ui/src/features/canvas/components/canvasTemplateIcon.tsx +++ b/packages/ui/src/features/canvas/components/canvasTemplateIcon.tsx @@ -2,13 +2,18 @@ import { ChartBarIcon, ChartLineIcon, FileIcon, + LightningIcon, ShapesIcon, } from "@phosphor-icons/react"; -import { FREEFORM_TEMPLATE_ID } from "@posthog/core/canvas/freeformSchemas"; +import { + FREEFORM_TEMPLATE_ID, + WORKFLOW_TEMPLATE_ID, +} from "@posthog/core/canvas/freeformSchemas"; import type { ReactNode } from "react"; // A canvas's leading icon, chosen from its template so the tree and header read -// at a glance: bar chart for json-render dashboards, line chart for web +// at a glance: lightning for a workflow canvas (a canvas with a PostHog +// workflow attached), bar chart for json-render dashboards, line chart for web // analytics, shapes for the generic freeform canvas (until it's classified as // something more specific), plain file for blank canvases. export function iconForTemplate( @@ -18,6 +23,8 @@ export function iconForTemplate( const size = opts?.size ?? 16; const className = opts?.className ?? "text-gray-9"; switch (templateId) { + case WORKFLOW_TEMPLATE_ID: + return ; case "web-analytics": return ; case "blank": diff --git a/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx b/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx index 862a2b536e..bb10b0c4b0 100644 --- a/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx +++ b/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx @@ -18,6 +18,8 @@ export function CanvasGenerateHero({ name, templateId, onStarted, + onSubmitStart, + onSubmitError, }: { dashboardId: string; channelId: string; @@ -25,6 +27,10 @@ export function CanvasGenerateHero({ name: string; templateId?: string; onStarted?: (taskId: string) => void; + // Passed through to the generate bar: fired the instant a submit begins / + // when it fails to create a task, so the view can flip optimistically. + onSubmitStart?: () => void; + onSubmitError?: () => void; }) { // Lets a suggestion card drop its prompt straight into the editor. const editorRef = useRef(null); @@ -63,6 +69,8 @@ export function CanvasGenerateHero({ name={name} templateId={templateId} onStarted={onStarted} + onSubmitStart={onSubmitStart} + onSubmitError={onSubmitError} /> diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx index d59ac286a4..6924740e93 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvasView.tsx @@ -2,12 +2,14 @@ import { ArrowCounterClockwiseIcon, ArrowUUpLeftIcon, ArrowUUpRightIcon, + LightningIcon, ShapesIcon, SidebarSimpleIcon, SpinnerGapIcon, WarningIcon, } from "@phosphor-icons/react"; import type { CanvasAnalyticsConfig } from "@posthog/core/canvas/freeformSchemas"; +import { createLatestPlanTracker } from "@posthog/core/sessions/sessionService"; import { useHostTRPC } from "@posthog/host-router/react"; import { Button, @@ -29,9 +31,11 @@ import { useFreeformThread, } from "@posthog/ui/features/canvas/stores/freeformChatStore"; import type { EditorHandle } from "@posthog/ui/features/message-editor/types"; +import type { Plan } from "@posthog/ui/features/sessions/types"; import { useSessionForTask } from "@posthog/ui/features/sessions/useSession"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; import { ResizableSidebar } from "@posthog/ui/primitives/ResizableSidebar"; +import { StepList, type StepStatus } from "@posthog/ui/primitives/StepList"; import { Box, Flex, @@ -79,6 +83,11 @@ export function FreeformCanvasView({ // local bridge so the composer floats to the side immediately on submit, // before the canvas record's polled generationTaskId catches up. const [startedTaskId, setStartedTaskId] = useState(null); + // Optimistic bridge for the submit → task-created gap: creating a cloud task + // (model resolution + createTask) takes a few seconds, so flip to the + // generating screen the instant the user submits instead of leaving them on + // the hero. Cleared once the real task attaches, or if the submit failed. + const [pendingStart, setPendingStart] = useState(false); const collapsed = useCanvasChatPanelStore((s) => s.collapsed); const setCollapsed = useCanvasChatPanelStore((s) => s.setCollapsed); const panelWidth = useCanvasChatPanelStore((s) => s.width); @@ -140,6 +149,21 @@ export function FreeformCanvasView({ refetchInterval: effectiveTaskId ? 5000 : false, }); const genSession = useSessionForTask(effectiveTaskId ?? undefined); + // The agent's live to-do list, folded from the run's session events the same + // way the chat's PlanStatusBar does. Surfaced in the generating screen so the + // wait shows real, high-level progress (discover → build → test → publish). + const planTrackerRef = useRef | null>(null); + if (!planTrackerRef.current) { + planTrackerRef.current = createLatestPlanTracker(); + } + const genEvents = genSession?.events; + const genPlan = useMemo( + () => + (planTrackerRef.current?.update(genEvents ?? []) ?? null) as Plan | null, + [genEvents], + ); // Whether the run's session is still alive. Drives record polling so a freshly // published canvas gets picked up. A local ACP session stays "connected" after // its generation prompt finishes, so this keeps syncing until it disconnects. @@ -240,16 +264,21 @@ export function FreeformCanvasView({ // `isGenerating` keys off the effective task (the optimistic bridge right after // submit, then the polled record) and short-circuits on a terminal run — so a // failed/cancelled run can't strand the canvas body on the spinner. - const showGeneratingState = !renderCode && isGenerating; + const showGeneratingState = !renderCode && (isGenerating || pendingStart); // While the record is still being fetched we don't yet know whether the canvas // has content, so show a spinner instead of the empty state / hero. Bounded by // the query, so it resolves once the fetch settles. - const showLoadingState = !renderCode && !isGenerating && dashboardLoading; + const showLoadingState = + !renderCode && !isGenerating && !pendingStart && dashboardLoading; // The empty-canvas landing: a centered composer with suggestions. Held back // until the record settles (so it doesn't flash over a canvas that has content) // and only when no run is in flight. After submit it floats into the panel. const showHero = - interactive && !renderCode && !effectiveTaskId && !dashboardLoading; + interactive && + !renderCode && + !effectiveTaskId && + !pendingStart && + !dashboardLoading; // The side panel only exists once there's a canvas or an active run. const showPanel = interactive && (showCanvas || !!effectiveTaskId); @@ -342,6 +371,24 @@ export function FreeformCanvasView({ ) )} + {!isGenerating && dashboard?.workflow && ( + // Workflow link badge: proof the workflow is attached + its + // status. No deep-link to the workflow yet - the PostHog + // workflows URL path isn't confirmed; add the link-out once it + // is, rather than shipping a broken link. + + + + + {dashboard.workflow.workflowStatus ?? "Workflow"} + + + + )} {showPanel && collapsed && ( ) : showLoadingState ? ( @@ -458,9 +506,23 @@ export function FreeformCanvasView({ name={dashboard?.name ?? "Canvas"} templateId={dashboard?.templateId} onStarted={(id) => { - // Hold the panel shut until the hero finishes sliding down. - setWaitingForHeroExit(true); setStartedTaskId(id); + // The real task is attached now; drop the optimistic bridge. + setPendingStart(false); + }} + onSubmitStart={() => { + // Flip to the generating screen immediately (pendingStart) AND + // hold the panel shut until the hero finishes sliding down. Both + // must happen now, on submit: the hero exits the moment + // pendingStart hides it, so setting waitingForHeroExit later (in + // onStarted, seconds after the task is created) would land after + // onExitComplete has already fired — stranding the panel closed. + setPendingStart(true); + setWaitingForHeroExit(true); + }} + onSubmitError={() => { + setPendingStart(false); + setWaitingForHeroExit(false); }} /> @@ -485,15 +547,25 @@ function LoadingState() { ); } -// Centered status shown while a generation task runs on an empty canvas, with a -// button to jump to the task doing the work. +// Centered status shown while a generation task runs on an empty canvas. Mirrors +// the agent's live to-do list (the same plan the chat's PlanStatusBar shows) so +// the wait reflects real high-level progress, with a button to jump to the task. function GeneratingState({ channelId, taskId, + plan, }: { channelId: string; taskId: string; + plan: Plan | null; }) { + const steps = plan?.entries ?? []; + const inProgress = steps.find((e) => e.status === "in_progress"); + // Prefer the agent's current step; fall back to a generic line before the + // plan exists (e.g. during the submit → task-created gap). + const description = + inProgress?.content ?? "An agent is building this canvas."; + return ( @@ -501,10 +573,22 @@ function GeneratingState({ Generating - An agent is building this canvas. + {description} - {taskId && ( - + + {steps.length > 0 && ( + + ({ + key: e.content, + label: e.content, + status: e.status as StepStatus, + }))} + size="1" + /> + + )} + {taskId && ( - - )} + )} + ); } diff --git a/packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx b/packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx index c56b124bcb..f8402c5dd5 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformGenerateBar.tsx @@ -31,6 +31,12 @@ export const FreeformGenerateBar = forwardRef< // Keys the editor's draft/command state; distinct per canvas. sessionId: string; onStarted?: (taskId: string) => void; + // Fired the instant the user submits, before the task is created (model + // resolution + createTask take a few seconds). Lets the view flip to the + // generating screen immediately instead of sitting on the composer. + onSubmitStart?: () => void; + // Fired if that submit failed to create a task, so the view can revert. + onSubmitError?: () => void; } >(function FreeformGenerateBar( { @@ -42,6 +48,8 @@ export const FreeformGenerateBar = forwardRef< currentCode, sessionId, onStarted, + onSubmitStart, + onSubmitError, }, ref, ) { @@ -64,6 +72,7 @@ export const FreeformGenerateBar = forwardRef< const run = async (text: string) => { const instruction = text.trim(); if (!instruction) return; + onSubmitStart?.(); const taskId = await generate({ dashboardId, name, @@ -74,6 +83,7 @@ export const FreeformGenerateBar = forwardRef< workspaceMode, }); if (taskId) onStarted?.(taskId); + else onSubmitError?.(); }; return ( diff --git a/packages/ui/src/features/canvas/freeform/canvasGenerateSuggestions.ts b/packages/ui/src/features/canvas/freeform/canvasGenerateSuggestions.ts index cb3aa971e7..b1cc79745a 100644 --- a/packages/ui/src/features/canvas/freeform/canvasGenerateSuggestions.ts +++ b/packages/ui/src/features/canvas/freeform/canvasGenerateSuggestions.ts @@ -1,16 +1,20 @@ import { ArrowsClockwise, - ChartBar, + BellRinging, ChartLine, CurrencyDollar, + EnvelopeSimple, FunnelSimple, + Lightning, UsersThree, } from "@phosphor-icons/react"; import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; // Starter prompts shown below the centered composer on an empty freeform // canvas. Clicking a card drops its `prompt` into the composer, ready to -// edit/send. No `mode` — canvas generation always runs the canvas-build flow. +// edit/send. No `mode` — the unified generation prompt decides whether a +// request is a plain canvas or a workflow build, so workflow ideas are just +// prompts like the rest. export const CANVAS_GENERATE_SUGGESTIONS: SuggestedPrompt[] = [ { label: "Weekly active users", @@ -37,12 +41,28 @@ export const CANVAS_GENERATE_SUGGESTIONS: SuggestedPrompt[] = [ "Build a canvas showing revenue trends over time broken down by plan, calling out the fastest-growing and shrinking segments.", }, { - label: "Top events", - description: "The most common events over the last 30 days", - icon: ChartBar, + label: "Slack alert on an event", + description: "Post to Slack whenever a chosen event fires", + icon: BellRinging, color: "amber", prompt: - "Build a breakdown of the most common events over the last 30 days, ranked by volume, with a short note on what stands out.", + "Build a workflow that posts a Slack message whenever a specific event fires, including the key event properties in the message.\n\nEvent to watch: \nSlack channel: ", + }, + { + label: "Welcome email after signup", + description: "Send new signups a welcome email sequence", + icon: EnvelopeSimple, + color: "blue", + prompt: + "Build a workflow that sends a welcome email to new signups, with a follow-up a few days later if they haven't come back.", + }, + { + label: "Track an existing workflow", + description: "A metrics board for a workflow you already have", + icon: Lightning, + color: "violet", + prompt: + "Build a metrics dashboard tracking my existing workflow. Find it and link this canvas to it - don't create a new one.\n\nWorkflow name: ", }, { label: "Retention cohorts", diff --git a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts index 53655410bc..da3c888fcf 100644 --- a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts +++ b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.test.ts @@ -62,6 +62,15 @@ describe("isCanvasGenerationRunning", () => { false, ], ["run record terminal", run("cloud", "failed"), undefined, false], + // Regression: a terminal cloud run must win over a still-connected session + // whose cloudStatus lags non-terminal (a workflow canvas's chat panel keeps + // one open), else the canvas is stranded on "Generating". + [ + "terminal run wins over lingering session cloudStatus", + run("cloud", "completed"), + session("connected", "in_progress"), + false, + ], ])("cloud: %s", (_label, latestRun, sess, expected) => { expect( isCanvasGenerationRunning({ @@ -161,6 +170,14 @@ describe("isCanvasGenerating", () => { genSession("connected", { cloudStatus: "completed" }), false, ], + // Regression: a terminal cloud run clears "Generating" even while a + // workflow canvas's chat session stays connected with a lagging cloudStatus. + [ + "cloud terminal run wins over lingering session", + run("cloud", "completed"), + genSession("connected", { cloudStatus: "in_progress" }), + false, + ], // Local: keys off the pending prompt, NOT the connection — a session that // lingers connected after the prompt finishes is no longer generating. [ diff --git a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts index f28cdd53d2..ff5b81c994 100644 --- a/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts +++ b/packages/ui/src/features/canvas/freeform/canvasGenerationStatus.ts @@ -43,6 +43,11 @@ export function isCanvasGenerationRunning({ if (genTaskLoading) return true; if (latestRun?.environment === "cloud") { + // A terminal run record means the build finished — even if a live session is + // still connected (e.g. a workflow canvas's chat panel keeps one open), + // whose non-terminal cloudStatus would otherwise pin the canvas on + // "Generating". + if (isTerminalStatus(latestRun?.status)) return false; const cloudStatus = session?.cloudStatus ?? latestRun.status; return !isTerminalStatus(cloudStatus); } @@ -69,6 +74,10 @@ export function isCanvasGenerating({ if (genTaskLoading) return true; if (latestRun?.environment === "cloud") { + // A terminal run record wins over a lingering live session (see + // isCanvasGenerationRunning) so a connected chat panel can't keep the + // "Generating" indicator spinning after the build has finished. + if (isTerminalStatus(latestRun?.status)) return false; const cloudStatus = session?.cloudStatus ?? latestRun.status ?? null; return !isTerminalStatus(cloudStatus); } diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts index dc2abbe844..e16d6e0a52 100644 --- a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts @@ -1,10 +1,18 @@ +import { + isNotification, + POSTHOG_NOTIFICATIONS, + parseWorkflowBuiltParams, + type WorkflowBuiltPayload, +} from "@posthog/core/sessions/acpNotifications"; import { useServiceOptional } from "@posthog/di/react"; +import { type AcpMessage, isJsonRpcNotification } from "@posthog/shared"; import { type CanvasTerminalStatus, hasCanvasGenerationStarted, isCanvasGenerating, resolveCanvasGenerationStatus, } from "@posthog/ui/features/canvas/freeform/canvasGenerationStatus"; +import { useDashboardMutations } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; import { useSessionStore } from "@posthog/ui/features/sessions/sessionStore"; @@ -52,6 +60,27 @@ function emitCanvasGenerationNotification( // "cancelled" is user-initiated — stay silent. } +// The workflow link, if this run emitted one. A workflow build fires a +// `_posthog/workflow_built` notification (workflow ↔ canvas) once it has both +// created a workflow and published the canvas; we read it off the session's +// event stream - the same source SessionResourcesBar folds for resource chips - +// and persist it onto the dashboard row. Absent for regular canvas generations. +function findWorkflowBuilt( + events: readonly AcpMessage[] | undefined, +): WorkflowBuiltPayload | null { + if (!events) return null; + for (const event of events) { + const msg = event.message; + if (!isJsonRpcNotification(msg)) continue; + if (!isNotification(msg.method, POSTHOG_NOTIFICATIONS.WORKFLOW_BUILT)) { + continue; + } + const parsed = parseWorkflowBuiltParams(msg.params); + if (parsed) return parsed; + } + return null; +} + // Watches every canvas generation started in this client (registered in the // tracker store) and fires a toast — with a link to the canvas — the moment each // one stops generating. Mounted on the persistent channel layout so it keeps @@ -64,6 +93,17 @@ function emitCanvasGenerationNotification( export function useCanvasGenerationToasts(): void { const tracked = useCanvasGenerationTrackerStore((s) => s.tracked); const untrack = useCanvasGenerationTrackerStore((s) => s.untrack); + // The host side of the workflow link primitive: on a build that emitted one we + // write the workflow link onto the dashboard row (the agent can't persist it + // itself). Captured in a ref so the status-keyed effect isn't re-created by it. + const { setWorkflow, renameDashboard } = useDashboardMutations(); + const setWorkflowRef = useRef(setWorkflow); + setWorkflowRef.current = setWorkflow; + // Rename the canvas to match the workflow the build created, so the breadcrumb + // reflects the real workflow name instead of the placeholder. Kept in a ref + // for the same reason as setWorkflow above. + const renameDashboardRef = useRef(renameDashboard); + renameDashboardRef.current = renameDashboard; // The bus is a container singleton (stable identity); capture in a ref so the // status-keyed effect reads it without listing it as a dependency. Optional so // hosts that don't bind it (web) simply no-op instead of throwing. @@ -99,11 +139,13 @@ export function useCanvasGenerationToasts(): void { return { id, generating, latestRun, session }; }); - // A stable signature so the transition effect only runs on real changes. + // A stable signature so the transition effect only runs on real changes. The + // event count is included so the effect also re-runs as notifications stream + // in — that's what lets the workflow link land mid-run (below). const sig = states .map( (s) => - `${s.id}:${s.generating ? 1 : 0}:${s.latestRun?.status ?? ""}:${s.session?.status ?? ""}:${s.session?.cloudStatus ?? ""}:${s.session?.isPromptPending ? 1 : 0}`, + `${s.id}:${s.generating ? 1 : 0}:${s.latestRun?.status ?? ""}:${s.session?.status ?? ""}:${s.session?.cloudStatus ?? ""}:${s.session?.isPromptPending ? 1 : 0}:${s.session?.events.length ?? 0}`, ) .join("|"); @@ -114,10 +156,38 @@ export function useCanvasGenerationToasts(): void { const armedRef = useRef>(new Set()); // Tasks already toasted, so a re-run can never double-fire. const toastedRef = useRef>(new Set()); + // Tasks whose workflow link we've already written, so the mid-run write + // below fires exactly once per task. + const linkedRef = useRef>(new Set()); // biome-ignore lint/correctness/useExhaustiveDependencies: sig is the trigger; states/store are read fresh (states via ref) when it changes. useEffect(() => { for (const st of statesRef.current) { + // Persist the workflow link + name the moment the build emits it — do + // NOT wait for the run to finish. A workflow-build agent keeps running + // after it publishes the canvas (it summarises, and the run can linger), + // so gating this on completion would leave the canvas untagged and + // placeholder-named until the whole run ends. + if (!linkedRef.current.has(st.id)) { + const link = findWorkflowBuilt(st.session?.events); + if (link) { + linkedRef.current.add(st.id); + void setWorkflowRef + .current(link.dashboardId, { + workflowId: link.workflowId, + workflowStatus: link.workflowStatus, + workflowType: link.workflowType, + }) + .catch(() => {}); + // Name the canvas after the workflow so the breadcrumb reflects it. + if (link.workflowName?.trim()) { + void renameDashboardRef + .current(link.dashboardId, link.workflowName.trim()) + .catch(() => {}); + } + } + } + if ( hasCanvasGenerationStarted({ latestRun: st.latestRun, diff --git a/packages/ui/src/features/canvas/freeformPrompt.test.ts b/packages/ui/src/features/canvas/freeformPrompt.test.ts index 647f4cb465..c4eea35f7c 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.test.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.test.ts @@ -37,3 +37,112 @@ describe("buildFreeformGenerationPrompt", () => { expect(extracted?.body).toContain("Edit the freeform React canvas"); }); }); + +describe("buildFreeformGenerationPrompt workflow branch", () => { + const base = { + dashboardId: "dash-wf-1", + name: "Untitled canvas", + channelName: "growth", + instruction: "Send a welcome email after signup.", + }; + const firstBuild = () => buildFreeformGenerationPrompt(base); + + it("gates the workflow flow behind an explicit mode decision", () => { + const prompt = firstBuild(); + expect(prompt).toContain("MODE"); + expect(prompt).toContain("PLAIN CANVAS (default)"); + expect(prompt).toContain("NEVER call any `workflows-*` MCP tool"); + expect(prompt).toContain(""); + expect(prompt).toContain(""); + }); + + it("drives the workflow lifecycle: discover, draft, test every branch", () => { + const prompt = firstBuild(); + expect(prompt).toContain("cdp-function-templates-list"); + expect(prompt).toContain("workflows-create"); + expect(prompt).toContain("workflows-patch-graph"); + expect(prompt).toContain("workflows-test-run"); + expect(prompt.toUpperCase()).toContain("EVERY BRANCH"); + expect(prompt).toContain("DRAFT"); + }); + + it("supports tracking an existing workflow without building one", () => { + const prompt = firstBuild(); + expect(prompt).toContain("TRACK AN EXISTING WORKFLOW"); + expect(prompt).toContain("workflows-list"); + expect(prompt).toContain("workflows-get"); + }); + + it("requires blast-radius before any batch dispatch", () => { + const prompt = firstBuild(); + expect(prompt).toContain("workflows-blast-radius"); + }); + + it("builds email templates with neutral branding and never asks mid-build", () => { + const prompt = firstBuild(); + expect(prompt).toContain("workflows-create-email-template"); + expect(prompt).toContain("neutral defaults"); + expect(prompt).toContain("do NOT ask the user about branding"); + expect(prompt.toLowerCase()).toContain("branding can be edited later"); + }); + + it("forbids going live on the agent's own initiative - the human approves & publishes", () => { + const prompt = firstBuild(); + expect(prompt).toContain("NEVER take the workflow live yourself"); + expect(prompt).toContain("Do NOT call `workflows-enable`"); + expect(prompt).toContain("workflows-run-batch"); + expect(prompt).toContain("workflows-schedule-create"); + expect(prompt.toLowerCase()).toContain("approve"); + }); + + it("forbids the agent persisting the workflow link itself", () => { + const prompt = firstBuild(); + expect(prompt.replace(/\s+/g, " ")).toContain("recorded AUTOMATICALLY"); + expect(prompt).toContain( + "Do NOT try to write it onto the canvas/dashboard yourself", + ); + }); + + it("publishes the metrics canvas via the canvas MCP tool, keyed to this canvas", () => { + const prompt = firstBuild(); + expect(prompt).toContain("desktop-file-system-canvas-partial-update"); + expect(prompt).toContain("dash-wf-1"); + }); + + it("embeds both starter boards and the adaptive canvas rules", () => { + const prompt = firstBuild(); + expect(prompt).toContain("[Starter dashboard — HEALTH (deliverability)]"); + expect(prompt).toContain("[Starter dashboard — ENGAGEMENT (email)]"); + // Starter bodies (not just the labels) ride along. + expect(prompt).toContain("This workflow hasn't fired yet"); + expect(prompt).toContain("This workflow hasn't sent yet"); + // Adaptive rules: health vs engagement, discover-not-hardcode, empty state. + expect(prompt).toContain("DELIVERABILITY & HEALTH"); + expect(prompt.toLowerCase()).toContain("open-rate"); + expect(prompt).toContain("ph.loadInsight"); + expect(prompt).toContain("NOT-YET-FIRED"); + }); + + it("asks for a human-reviewable summary before stopping", () => { + const prompt = firstBuild(); + expect(prompt.toLowerCase()).toContain("summarise"); + expect(prompt.toLowerCase()).toContain("trigger"); + expect(prompt).toContain("STOP"); + }); + + it("still collapses cleanly into the canvas-instructions tag", () => { + const extracted = extractCanvasInstructions(firstBuild()); + expect(extracted?.stripped).toBe("Send a welcome email after signup."); + expect(extracted?.body).toContain(""); + }); + + it("omits the workflow flow entirely when editing an existing canvas", () => { + const prompt = buildFreeformGenerationPrompt({ + ...base, + currentCode: "export const App = () => null;", + }); + expect(prompt).not.toContain(""); + expect(prompt).not.toContain("workflows-create"); + expect(prompt).not.toContain("MODE"); + }); +}); diff --git a/packages/ui/src/features/canvas/freeformPrompt.ts b/packages/ui/src/features/canvas/freeformPrompt.ts index 32631fc0c7..d82290059b 100644 --- a/packages/ui/src/features/canvas/freeformPrompt.ts +++ b/packages/ui/src/features/canvas/freeformPrompt.ts @@ -1,5 +1,12 @@ -import { freeformSystemPromptFor } from "@posthog/core/canvas/canvasTemplates"; +import { + freeformSystemPromptFor, + WORKFLOW_CANVAS_RULES_TEXT, +} from "@posthog/core/canvas/canvasTemplates"; import { FREEFORM_STARTER_CODE } from "@posthog/core/canvas/freeformStarter"; +import { + WORKFLOW_ENGAGEMENT_STARTER, + WORKFLOW_HEALTH_STARTER, +} from "@posthog/core/canvas/workflowStarters"; // Builds the prompt for the task that generates a freeform (React) canvas. Like // CONTEXT.md generation, this runs as a normal repo-less agent task (no repo @@ -55,6 +62,28 @@ export function buildFreeformGenerationPrompt(input: { ? `\n[Starter scaffold] — begin from this WORKING baseline instead of authoring from scratch. It already wires the things that are easy to get wrong: the date picker, theme-aware tokens, per-card loading skeletons, and reading a typed-node result correctly. KEEP that wiring; replace the sample "total events" metric and the layout with what the user asked for, and output the COMPLETE rewritten file.\n\n\`\`\`tsx\n${FREEFORM_STARTER_CODE}\n\`\`\`\n` : ""; + // First builds can turn out to be a WORKFLOW build (the user asked to DO + // something ongoing, not just visualize) — the mode gate + the full workflow + // build flow ride along so free-typed requests like "Send an email after + // signup" just work. Edits never re-run the workflow flow: an existing + // workflow canvas resolves its board contract via its stamped templateId. + const modeBlock = isEdit + ? "" + : ` +MODE — before anything else, decide which of three modes the user's request is: +- PLAIN CANVAS (default): a dashboard, report, or interactive tool — even one + ABOUT workflows or emails. Ignore the block below + entirely and NEVER call any \`workflows-*\` MCP tool. +- BUILD A WORKFLOW: the request asks to DO something on an ongoing basis — send + an email after signup, alert Slack/Discord on an event, run a drip/onboarding + sequence, sync or webhook on a trigger. Follow below. +- TRACK AN EXISTING WORKFLOW: the request asks for a board "for", "around", or + "of" a workflow they already have. Follow the TRACK-EXISTING part of + . +Only pick a workflow mode when the request clearly asks to automate or track an +automation; when in doubt, build the plain canvas. +`; + // The standing authoring contract + publishing/data rules are the same // boilerplate on every canvas generation — the user never typed them. Wrap // them in a `` element so the conversation UI @@ -62,7 +91,7 @@ export function buildFreeformGenerationPrompt(input: { // inline (see extractCanvasInstructions). Kept after the user's instruction so // the request leads, mirroring how channel CONTEXT.md is appended. const instructions = `${header} -${currentBlock}${starterBlock} +${currentBlock}${starterBlock}${modeBlock} Follow this authoring contract for the canvas (imports, the \`ph\` data shim, and style rules): @@ -83,7 +112,7 @@ DATA — for each metric, first SAVE an insight via the PostHog MCP insight tool (prefer an insight query type — Trends, Funnels, Retention, web-analytics kinds — over raw SQL), record the \`short_id\` it returns, and load it in the canvas with \`ph.loadInsight(short_id, { dateRange })\`. Fall back to inline \`ph.query(...)\`/HogQL -only when no insight can express the metric.`; +only when no insight can express the metric.${isEdit ? "" : buildWorkflowInstructionsBlock(dashboardId)}`; return `${instruction} @@ -91,3 +120,108 @@ only when no insight can express the metric.`; ${instructions} `; } + +// The workflow build flow, appended to FIRST-build prompts only (see the MODE +// gate above). A workflow canvas is a PostHog WORKFLOW plus the metrics canvas +// that tracks it: the agent builds + tests the workflow over the PostHog MCP +// \`workflows-*\` tools, then publishes the metrics canvas via the same publish +// path as a plain canvas, starting from a proven starter board. The workflow +// LINK is recorded automatically host-side by observing the build (see the +// workflow link primitive) - the agent does NOT persist it and MUST NOT try. +// The agent NEVER takes the workflow live; going live is the human's explicit +// call (the go-live tools raise an approval card even in auto mode). +function buildWorkflowInstructionsBlock(dashboardId: string): string { + return ` + + +Applies ONLY in the BUILD A WORKFLOW / TRACK AN EXISTING WORKFLOW modes. Operate +ONLY on this project via the PostHog MCP tools, and verify every +event/property/template name via MCP before using it - never hardcode ids. + +TRACK AN EXISTING WORKFLOW - find it with \`workflows-list\`, then read it with +\`workflows-get\` (always \`workflows-get\` the target before publishing - this is +what links the canvas to it). Do NOT create, edit, or test a workflow. If more +than one workflow could match, list the candidates and ask the user which one +before continuing. Then SKIP straight to step 6 (publish the metrics canvas) and +step 7. + +BUILD FLOW - follow in order: + +1. PARSE INTENT + DISCOVER DESTINATIONS. Work out the trigger, audience, steps, +and outputs. List the live destination catalog with \`cdp-function-templates-list\` +and read required inputs with \`cdp-function-templates-retrieve\`. Never hardcode +template ids. + +2. CREATE THE WORKFLOW AS A DRAFT with \`workflows-create\` (drafts never +execute). ALWAYS pass a clear, specific \`name\` that describes what the workflow +does (e.g. "Slack alert on failed checkout", "Welcome email sequence") - NEVER +leave it unnamed or generic. Shape the graph with \`workflows-patch-graph\` - +surgical edits, not full rewrites. + +3. EMAIL TEMPLATES - BUILD THEM IN PARALLEL. Authoring an email template is +slow, so do NOT block the graph on it: first lay down the graph with PLACEHOLDER +email actions, then delegate EACH email template to its OWN sub-agent so they +build concurrently. Use the Task tool with subagent_type "general-purpose" (NOT +the read-only explorer) so the sub-agent can call the PostHog MCP; instruct each +sub-agent to author one template via \`workflows-create-email-template\` and +return ONLY the new template id. As each id comes back, wire it into its email +action with \`workflows-patch-graph\`. BRANDING: use clean, neutral defaults +(simple layout, no invented logos or brand colors) unless the user's request +specifies branding - do NOT ask the user about branding mid-build; note in your +summary that the template's branding can be edited later. Because a sub-agent's +own work is not streamed to the chat, post a short visible line first (e.g. +"Building N email templates in parallel…") so the build doesn't look stalled. + +4. TEST EVERY BRANCH with \`workflows-test-run\` (it runs one step at a time - +walk the graph via \`nextActionId\`, and set \`current_action_id\` to exercise each +branch). Read \`workflows-logs\`. Re-test after ANY edit. + +5. BATCH / SCHEDULED ONLY - before any dispatch, call \`workflows-blast-radius\` +and surface the matched count to the user for explicit confirmation. + +6. PUBLISH THE METRICS CANVAS. When the workflow is built + tested, PUBLISH the +canvas by calling \`desktop-file-system-canvas-partial-update\` exactly once with: +- id: "${dashboardId}" +- code: the COMPLETE single-file React source for the metrics canvas. +START FROM the STARTER DASHBOARD below that matches this workflow's category, and +EDIT it - do NOT author a dashboard from scratch, and IGNORE the generic +[Starter scaffold] above (these starters replace it): + - alert / notification / sync / data-hygiene (Slack/Discord/webhook/CRM/enrichment) → the HEALTH starter. + - marketing / lifecycle email (welcome/onboarding/re-engagement/drip) → the ENGAGEMENT starter. +Replace each placeholder metric (the "all events" query + the TODO(agent) spots) +with a SAVED insight you created for THIS workflow's real deliveries / email +outcomes, loaded via \`ph.loadInsight(shortId, { dateRange })\`. KEEP the wiring +(date picker, skeletons, the not-yet-fired empty state). For a BATCH / SCHEDULED +workflow, also add an "audience reach per run" card/row. Only depart from the +starter if the workflow genuinely needs something it doesn't cover. + +Extra rules for the metrics canvas, on top of the authoring contract above: +${WORKFLOW_CANVAS_RULES_TEXT} + +[Starter dashboard — HEALTH (deliverability)] + +\`\`\`tsx +${WORKFLOW_HEALTH_STARTER} +\`\`\` + +[Starter dashboard — ENGAGEMENT (email)] + +\`\`\`tsx +${WORKFLOW_ENGAGEMENT_STARTER} +\`\`\` + +7. SUMMARISE for the human - the trigger, inputs/audience, steps/outputs, and +the branch-test results - then STOP. + +HARD RULES: +- NEVER take the workflow live yourself. Do NOT call \`workflows-enable\`, +\`workflows-run-batch\`, \`workflows-schedule-create\`, or +\`workflows-update-schedule\`. Going live is the human's explicit "approve & +publish" step (those tools raise an approval card). Your job ends at a tested +draft + published canvas. +- The workflow LINK (which workflow this canvas tracks) is recorded +AUTOMATICALLY once you create (or \`workflows-get\`) the workflow and publish the +canvas. Do NOT try to write it onto the canvas/dashboard yourself - there is no +MCP tool for that and attempting it will loop. +`; +} diff --git a/packages/ui/src/features/canvas/hooks/useDashboards.ts b/packages/ui/src/features/canvas/hooks/useDashboards.ts index af8267c501..18b3ab2cc2 100644 --- a/packages/ui/src/features/canvas/hooks/useDashboards.ts +++ b/packages/ui/src/features/canvas/hooks/useDashboards.ts @@ -1,6 +1,7 @@ import type { DashboardRecord, DashboardSummary, + WorkflowLink, } from "@posthog/core/canvas/dashboardSchemas"; import type { FreeformVersion } from "@posthog/core/canvas/freeformSchemas"; import { useHostTRPC } from "@posthog/host-router/react"; @@ -103,6 +104,9 @@ export function useDashboardMutations() { const setPinned = useMutation( trpc.dashboards.setPinned.mutationOptions({ onSuccess: invalidate }), ); + const setWorkflow = useMutation( + trpc.dashboards.setWorkflow.mutationOptions({ onSuccess: invalidate }), + ); const ensureHome = useMutation( trpc.dashboards.ensureHomeCanvas.mutationOptions({ onSuccess: () => { @@ -130,6 +134,10 @@ export function useDashboardMutations() { // pin shows in the channel's Pinned menu for every member. setPinned: (id: string, pinned: boolean) => setPinned.mutateAsync({ id, pinned }), + // Attach a workflow to a canvas (the workflow link primitive). Written when + // a build links one so the lightning icon + status badge light up. + setWorkflow: (id: string, workflow: WorkflowLink) => + setWorkflow.mutateAsync({ id, workflow }), // Ensure a channel has its home canvas (creating + seeding it if absent). // Idempotent server-side; returns the home canvas record. ensureHomeCanvas: (channelId: string) => diff --git a/packages/ui/src/features/sessions/components/DiffStatsChip.tsx b/packages/ui/src/features/sessions/components/DiffStatsChip.tsx index 5f0200f261..c78bab3cfa 100644 --- a/packages/ui/src/features/sessions/components/DiffStatsChip.tsx +++ b/packages/ui/src/features/sessions/components/DiffStatsChip.tsx @@ -16,6 +16,9 @@ export function DiffStatsChip({ task }: DiffStatsChipProps) { const { filesChanged, linesAdded, linesRemoved, isOpen, toggle } = useDiffStatsToggle(task, "expanded"); + // Repo-less runs (e.g. canvas / workflow builds that work over MCP) have no + // repository to review, so any sandbox file churn isn't a diff worth opening. + if (!task.repository) return null; if (filesChanged === 0) return null; return ( From 320d31061c1c377b85a3101c56a01a280271eecc Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Fri, 17 Jul 2026 11:56:01 +0100 Subject: [PATCH 2/5] fix(canvas): workflow-link fixes from live dogfooding - Parse the PostHog MCP's YAML tool results in the workflow-built pairing: workflows-create/get return a YAML document, not JSON, so the link notification never fired. Add a top-level-line YAML fallback with a regression test against a captured real payload. - Resolve the backend feed channel in useGenerateFreeformCanvas when the caller doesn't pass one (the canvas hero didn't), so hero-initiated builds land in the channel feed like composer submits. - Never demote an auto/bypass session to acceptEdits when the user answers a permission card with "always allow" - the upgrade helper previously preferred acceptEdits unconditionally, which turned one "always allow" click into a prompt storm for the rest of an auto canvas build. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da --- .../agent/src/adapters/claude/hooks.test.ts | 97 +++++++++++++++++++ packages/agent/src/adapters/claude/hooks.ts | 65 +++++++++---- packages/core/src/sessions/sessionService.ts | 6 ++ .../canvas/hooks/useGenerateFreeformCanvas.ts | 36 ++++++- 4 files changed, 182 insertions(+), 22 deletions(-) diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 53e3108ac7..599f2ce2a2 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -798,3 +798,100 @@ describe("createPostToolUseHook workflow link", () => { expect(signals).toEqual([]); }); }); + +describe("createPostToolUseHook workflow link - YAML MCP results", () => { + // The PostHog MCP renders tool results as YAML documents, not JSON — this is + // the real shape a workflows-create returns (captured from a live session). + const YAML_WORKFLOW_RESULT = [ + "id: 019f6f90-0d70-0000-5171-4622c6016e1e", + "name: Welcome email after signup", + 'description: "Sends a welcome email to every new user immediately after they sign up."', + "version: 1", + "status: draft", + 'created_at: "2026-07-17T10:12:19.441125Z"', + "created_by:", + " id: 546751", + " first_name: Harley", + "trigger:", + " type: event", + " filters:", + " source: events", + " events[1]{id,name,type,order}:", + " user signed up,user signed up,events,0", + ].join("\n"); + + test("pairs a YAML workflows-create result with the canvas publish", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { command: "call --json workflows-create {}" }, + tool_response: { + content: [{ type: "text", text: YAML_WORKFLOW_RESULT }], + }, + } as unknown as HookInput, + "tu1", + { signal: new AbortController().signal }, + ); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { + command: + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}', + }, + } as unknown as HookInput, + "tu2", + { signal: new AbortController().signal }, + ); + + expect(signals).toEqual([ + { + dashboardId: "dash-1", + workflowId: "019f6f90-0d70-0000-5171-4622c6016e1e", + workflowStatus: "draft", + workflowName: "Welcome email after signup", + }, + ]); + }); + + test("does not read a nested block's id as the workflow id", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + // No top-level id line at all — only the nested created_by.id. + const nestedOnly = ["name: X", "created_by:", " id: 546751"].join("\n"); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { command: "call --json workflows-create {}" }, + tool_response: { content: [{ type: "text", text: nestedOnly }] }, + } as unknown as HookInput, + "tu1", + { signal: new AbortController().signal }, + ); + await hook( + { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { + command: + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"x"}', + }, + } as unknown as HookInput, + "tu2", + { signal: new AbortController().signal }, + ); + + expect(signals).toEqual([]); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 897d3f1e93..6844e2f6d0 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -81,9 +81,20 @@ function parseCanvasPublishDashboardId(toolInput: unknown): string | null { return arg && typeof arg.id === "string" ? arg.id : null; } -// The workflow id + status from a `workflows-create` result. The result JSON may -// be the workflow itself or wrapped, so look under common envelopes too. Best -// effort - returns null if no workflow id is present. +// A top-level `key: value` line from a YAML-ish blob (no leading indentation, +// so nested blocks like `created_by:` don't shadow the workflow's own fields). +// The PostHog MCP renders results as YAML, not JSON. +function parseTopLevelYamlValue(text: string, key: string): string | undefined { + const match = text.match(new RegExp(`^${key}:[ \\t]*(.+)$`, "m")); + if (!match) return undefined; + const value = match[1].trim().replace(/^"(.*)"$/, "$1"); + return value || undefined; +} + +// The workflow id + status from a `workflows-create` / `workflows-get` result. +// The PostHog MCP returns the workflow as a YAML document (top-level `id:` / +// `name:` / `status:` lines); older/other shapes may be JSON, possibly wrapped. +// Best effort - returns null if no workflow id is present. function parseWorkflowFromResponse(response: unknown): { workflowId: string; workflowStatus?: string; @@ -91,28 +102,40 @@ function parseWorkflowFromResponse(response: unknown): { } | null { const text = extractTextFromToolResponse(response); if (!text) return null; + const root = parseEmbeddedJsonObject(text); - if (!root) return null; - for (const candidate of [ - root, - root.workflow, - root.result, - root.data, - ] as Record[]) { - if (candidate && typeof candidate === "object") { - const id = (candidate as { id?: unknown }).id; - if (typeof id === "string" && id) { - const status = (candidate as { status?: unknown }).status; - const name = (candidate as { name?: unknown }).name; - return { - workflowId: id, - workflowStatus: typeof status === "string" ? status : undefined, - workflowName: - typeof name === "string" && name.trim() ? name : undefined, - }; + if (root) { + for (const candidate of [ + root, + root.workflow, + root.result, + root.data, + ] as Record[]) { + if (candidate && typeof candidate === "object") { + const id = (candidate as { id?: unknown }).id; + if (typeof id === "string" && id) { + const status = (candidate as { status?: unknown }).status; + const name = (candidate as { name?: unknown }).name; + return { + workflowId: id, + workflowStatus: typeof status === "string" ? status : undefined, + workflowName: + typeof name === "string" && name.trim() ? name : undefined, + }; + } } } } + + // YAML document (what the MCP actually emits): top-level key: value lines. + const id = parseTopLevelYamlValue(text, "id"); + if (id && /^[0-9a-f][0-9a-f-]{10,}$/i.test(id)) { + return { + workflowId: id, + workflowStatus: parseTopLevelYamlValue(text, "status"), + workflowName: parseTopLevelYamlValue(text, "name"), + }; + } return null; } diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 096826e4fb..90b6997e5d 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -5549,6 +5549,12 @@ export class SessionService { modeOption: SessionConfigOption | undefined, ): string | undefined { if (modeOption?.type !== "select") return undefined; + // Never DEMOTE a session that already runs unattended: an "always allow" + // answer in an auto/bypass session must not drop it to acceptEdits, which + // would make every subsequent gated tool prompt (the opposite of what the + // user just asked for). + const current = modeOption.currentValue; + if (current === "auto" || current === "bypassPermissions") return undefined; const availableIds = new Set( flattenSelectOptions(modeOption.options).map((opt) => opt.value), ); diff --git a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts index 3ae5404d7b..ba300974a5 100644 --- a/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts +++ b/packages/ui/src/features/canvas/hooks/useGenerateFreeformCanvas.ts @@ -15,14 +15,20 @@ import { getCloudUrlFromRegion, type WorkspaceMode, } from "@posthog/shared"; +import { useOptionalAuthenticatedClient } from "@posthog/ui/features/auth/authClient"; import { useAuthStateValue } from "@posthog/ui/features/auth/store"; import { buildFreeformGenerationPrompt } from "@posthog/ui/features/canvas/freeformPrompt"; +import { channelFeedQueryKey } from "@posthog/ui/features/canvas/hooks/useChannelFeed"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; import { isPlaceholderCanvasName, useDashboardMutations, } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; +import { + normalizeChannelName, + PERSONAL_CHANNEL_NAME, +} from "@posthog/ui/features/canvas/hooks/useTaskChannels"; import { useCanvasGenerationTrackerStore } from "@posthog/ui/features/canvas/stores/canvasGenerationTrackerStore"; import { toastError } from "@posthog/ui/features/notifications/errorDetails"; import { useCreateTask } from "@posthog/ui/features/tasks/useTaskCrudMutations"; @@ -58,6 +64,7 @@ export function useGenerateFreeformCanvas(args: { ); const trpc = useHostTRPC(); const queryClient = useQueryClient(); + const apiClient = useOptionalAuthenticatedClient(); const { invalidateTasks } = useCreateTask(); const { fileTask } = useChannelTaskMutations(); const { setGenerationTask, renameDashboard } = useDashboardMutations(); @@ -103,7 +110,6 @@ export function useGenerateFreeformCanvas(args: { templateId, instruction, currentCode, - backendChannelId, adapter = "claude", reasoningLevel, useStarter, @@ -115,6 +121,26 @@ export function useGenerateFreeformCanvas(args: { } = opts; setIsStarting(true); try { + // Resolve the backend channel that owns the task so the run shows as a + // card in the channel feed — the same mapping the channel composer + // resolves. Callers that already know it (the composer) pass it; the + // canvas hero doesn't, so fall back to resolving by channel name here. + // Best-effort: an unresolved feed channel shouldn't block generation. + let backendChannelId = opts.backendChannelId; + const normalizedName = channelName + ? normalizeChannelName(channelName) + : ""; + if ( + !backendChannelId && + apiClient && + normalizedName && + normalizedName !== PERSONAL_CHANNEL_NAME + ) { + backendChannelId = await apiClient + .resolveTaskChannel(normalizedName) + .then((c) => c.id) + .catch(() => undefined); + } // A cloud run requires an explicit adapter + model (the API rejects a // cloud runtime without a model). Resolve the caller's pick — or the // adapter's server default when none — the same way the inbox one-click @@ -186,6 +212,13 @@ export function useGenerateFreeformCanvas(args: { void queryClient.invalidateQueries({ queryKey: trpc.workspace.getAll.queryKey(), }); + // Surface the run's card in the channel feed without waiting for the + // feed's next poll. + if (backendChannelId) { + void queryClient.invalidateQueries({ + queryKey: channelFeedQueryKey(backendChannelId), + }); + } // Auto-name a still-unnamed canvas from its generation prompt, using the // same helper model that names tasks. Best-effort: a failure (or a user // who already named the canvas) leaves the existing title untouched. @@ -217,6 +250,7 @@ export function useGenerateFreeformCanvas(args: { titleGenerator, trpc, queryClient, + apiClient, invalidateTasks, fileTask, setGenerationTask, From 4288c7f4f1df3f8cd877e98c830eba6119a35258 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Fri, 17 Jul 2026 12:13:29 +0100 Subject: [PATCH 3/5] test(canvas): cover the workflow-built session-event scan Export findWorkflowBuilt and test it directly: live-stream and replay method variants, request/response and foreign notifications ignored, malformed payloads skipped. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da --- .../useCanvasGenerationToasts.test.ts | 78 +++++++++++++++++++ .../freeform/useCanvasGenerationToasts.ts | 3 +- 2 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.test.ts diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.test.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.test.ts new file mode 100644 index 0000000000..ef888e4f41 --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.test.ts @@ -0,0 +1,78 @@ +import type { AcpMessage } from "@posthog/shared"; +import { describe, expect, it } from "vitest"; +import { findWorkflowBuilt } from "./useCanvasGenerationToasts"; + +function acp(message: AcpMessage["message"]): AcpMessage { + return { type: "acp_message", ts: 1, message }; +} + +describe("findWorkflowBuilt", () => { + const builtNotification = acp({ + jsonrpc: "2.0", + method: "_posthog/workflow_built", + params: { + sessionId: "s1", + dashboardId: "dash-1", + workflowId: "wf-1", + workflowStatus: "draft", + workflowName: "Welcome email after signup", + }, + }); + + it("finds the workflow link in a session's event stream", () => { + const events: AcpMessage[] = [ + acp({ jsonrpc: "2.0", method: "session/update", params: {} }), + builtNotification, + ]; + expect(findWorkflowBuilt(events)).toEqual({ + dashboardId: "dash-1", + workflowId: "wf-1", + workflowStatus: "draft", + workflowName: "Welcome email after signup", + workflowType: undefined, + }); + }); + + it("matches the underscore-prefixed replay variant too", () => { + // Stored-log replay can surface ext notifications with a leading + // underscore on the method; isNotification matches both. + const events: AcpMessage[] = [ + acp({ + jsonrpc: "2.0", + method: "__posthog/workflow_built", + params: { dashboardId: "dash-2", workflowId: "wf-2" }, + }), + ]; + expect(findWorkflowBuilt(events)?.dashboardId).toBe("dash-2"); + }); + + it("ignores requests, responses, and other notifications", () => { + const events: AcpMessage[] = [ + // A request (has an id) with the right method must not match. + acp({ + jsonrpc: "2.0", + id: 1, + method: "_posthog/workflow_built", + params: { dashboardId: "d", workflowId: "w" }, + }), + acp({ jsonrpc: "2.0", method: "_posthog/resources_used", params: {} }), + ]; + expect(findWorkflowBuilt(events)).toBeNull(); + }); + + it("skips a malformed payload rather than returning a partial link", () => { + const events: AcpMessage[] = [ + acp({ + jsonrpc: "2.0", + method: "_posthog/workflow_built", + params: { workflowId: "wf-only" }, + }), + ]; + expect(findWorkflowBuilt(events)).toBeNull(); + }); + + it("returns null for empty or missing events", () => { + expect(findWorkflowBuilt(undefined)).toBeNull(); + expect(findWorkflowBuilt([])).toBeNull(); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts index e16d6e0a52..1035281d04 100644 --- a/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts +++ b/packages/ui/src/features/canvas/freeform/useCanvasGenerationToasts.ts @@ -65,7 +65,8 @@ function emitCanvasGenerationNotification( // created a workflow and published the canvas; we read it off the session's // event stream - the same source SessionResourcesBar folds for resource chips - // and persist it onto the dashboard row. Absent for regular canvas generations. -function findWorkflowBuilt( +// Exported for tests. +export function findWorkflowBuilt( events: readonly AcpMessage[] | undefined, ): WorkflowBuiltPayload | null { if (!events) return null; From 656de2c367ed52cb9b218f0c55b76f402b5c6e43 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Fri, 17 Jul 2026 12:40:15 +0100 Subject: [PATCH 4/5] feat(canvas): "Set up a workflow" suggestion on the new-task surfaces Workflow builds were only discoverable via + > New canvas. Add a canvas-kind starter card to the channel task suggestions, shown on both new-task surfaces: - Channel home: selecting it prefills the composer AND arms canvas mode, so the submit runs canvas generation (whose unified prompt handles workflow intent) instead of creating a plain task. - New-task screen: TaskInput hands canvas suggestions to the surface, which creates a canvas in the channel and opens it with the starter prompt dropped into the hero composer (one-shot prefill handoff), ready to edit. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da --- packages/shared/src/analytics-events.ts | 1 + .../ui/src/features/canvas/canvasPrefill.ts | 21 +++++++++++++++++++ .../features/canvas/channelTaskSuggestions.ts | 13 ++++++++++++ .../canvas/components/ChannelHomeComposer.tsx | 19 ++++++++++++++--- .../canvas/components/WebsiteChannelHome.tsx | 8 ++++--- .../canvas/components/WebsiteNewTask.test.tsx | 4 ++++ .../canvas/components/WebsiteNewTask.tsx | 19 +++++++++++++++++ .../canvas/freeform/CanvasGenerateHero.tsx | 14 ++++++++++++- .../features/canvas/hooks/useDashboards.ts | 7 +++++++ .../components/SuggestedPromptCard.tsx | 3 +++ .../task-detail/components/TaskInput.tsx | 19 +++++++++++++++++ 11 files changed, 121 insertions(+), 7 deletions(-) create mode 100644 packages/ui/src/features/canvas/canvasPrefill.ts diff --git a/packages/shared/src/analytics-events.ts b/packages/shared/src/analytics-events.ts index efe590fb7f..9288e56c7f 100644 --- a/packages/shared/src/analytics-events.ts +++ b/packages/shared/src/analytics-events.ts @@ -866,6 +866,7 @@ export type ChannelActionType = | "edit_context_open" | "new_task_open" | "new_task_suggestion" + | "new_task_canvas_suggestion" | "view_context" | "view_history" | "view_artifacts" diff --git a/packages/ui/src/features/canvas/canvasPrefill.ts b/packages/ui/src/features/canvas/canvasPrefill.ts new file mode 100644 index 0000000000..8b181dcd03 --- /dev/null +++ b/packages/ui/src/features/canvas/canvasPrefill.ts @@ -0,0 +1,21 @@ +// One-shot handoff of a starter instruction into a just-created canvas's hero +// composer. A surface that creates a canvas on behalf of a suggestion (e.g. the +// new-task screen's "Set up a workflow" card) stashes the prompt here BEFORE +// navigating; the hero takes (and clears) it on mount. Ephemeral by design — +// nothing is persisted, and an entry survives at most one navigation. +const pending = new Map(); + +export function setPendingCanvasPrefill( + dashboardId: string, + instruction: string, +): void { + pending.set(dashboardId, instruction); +} + +// Returns the stashed instruction for this canvas and removes it, so a +// remount (or another canvas) can never replay it. +export function takePendingCanvasPrefill(dashboardId: string): string | null { + const instruction = pending.get(dashboardId) ?? null; + pending.delete(dashboardId); + return instruction; +} diff --git a/packages/ui/src/features/canvas/channelTaskSuggestions.ts b/packages/ui/src/features/canvas/channelTaskSuggestions.ts index 5529b1447e..c9101093be 100644 --- a/packages/ui/src/features/canvas/channelTaskSuggestions.ts +++ b/packages/ui/src/features/canvas/channelTaskSuggestions.ts @@ -6,6 +6,7 @@ import { Cube, CurrencyDollar, Flask, + Lightning, Wrench, } from "@phosphor-icons/react"; import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; @@ -18,6 +19,18 @@ import type { SuggestedPrompt } from "@posthog/ui/features/task-detail/component // (icon badge + title + description); the icon/color follow the same // `var(---N)` token scheme. export const CHANNEL_TASK_SUGGESTIONS: SuggestedPrompt[] = [ + { + label: "Set up a workflow", + description: "Automate an action, with a live board tracking it", + icon: Lightning, + color: "amber", + // Workflows build through canvas generation (the canvas is the workflow's + // observability board), so this suggestion arms the canvas path rather + // than creating a plain task. + canvas: true, + prompt: + "Set up a workflow that runs automatically — send an email, post to Slack, or fire a webhook when something happens — and build a live dashboard tracking it.\n\n\nUser input:\n- What should happen, and when (e.g. send a welcome email after signup):", + }, { label: "Debug a user issue", description: "Trace a specific user's events, replays, and errors", diff --git a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx index 1233aff0db..61a53f855e 100644 --- a/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx +++ b/packages/ui/src/features/canvas/components/ChannelHomeComposer.tsx @@ -48,8 +48,14 @@ import { import type { PendingKickoff } from "./ChannelFeedView"; export interface ChannelHomeComposerHandle { - /** Drop a starter prompt into the editor and apply its mode, if any. */ - applySuggestion: (prompt: string, mode?: string) => void; + /** Drop a starter prompt into the editor and apply its mode, if any. A + * `canvas` suggestion also arms canvas mode, so the submit runs canvas + * generation (canvas + workflow builds) instead of creating a plain task. */ + applySuggestion: ( + prompt: string, + mode?: string, + opts?: { canvas?: boolean }, + ) => void; } interface ChannelHomeComposerProps { @@ -351,7 +357,11 @@ export const ChannelHomeComposer = forwardRef< useImperativeHandle( ref, () => ({ - applySuggestion: (prompt: string, mode?: string) => { + applySuggestion: ( + prompt: string, + mode?: string, + opts?: { canvas?: boolean }, + ) => { // Pending content (not setContent) preserves the multi-line template's // line breaks and focuses at the end; mirrors the new-task screen. useDraftStore.getState().actions.setPendingContent(sessionId, { @@ -360,6 +370,9 @@ export const ChannelHomeComposer = forwardRef< if (mode && isValidConfigValue(modeOption, mode)) { setConfigOption(modeOption.id, mode); } + // Canvas suggestions (canvas + workflow builds) flip the composer into + // canvas mode so the submit runs canvas generation for this prompt. + setCanvasArmed(opts?.canvas ?? false); }, }), [sessionId, modeOption, setConfigOption], diff --git a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx index 0159ab0aa0..85d7f3431b 100644 --- a/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteChannelHome.tsx @@ -123,8 +123,8 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { const closeThread = useThreadPanelStore((s) => s.closeThread); const handleSuggestionSelect = useCallback( - (prompt: string, mode?: string) => { - composerRef.current?.applySuggestion(prompt, mode); + (prompt: string, mode?: string, opts?: { canvas?: boolean }) => { + composerRef.current?.applySuggestion(prompt, mode, opts); }, [], ); @@ -256,7 +256,9 @@ export function WebsiteChannelHome({ channelId }: { channelId: string }) { key={suggestion.label} suggestion={suggestion} onSelect={() => - handleSuggestionSelect(suggestion.prompt, suggestion.mode) + handleSuggestionSelect(suggestion.prompt, suggestion.mode, { + canvas: suggestion.canvas, + }) } /> ))} diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx index 0ee1e45e2e..9831daa387 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.test.tsx @@ -39,6 +39,10 @@ vi.mock("@posthog/ui/features/canvas/hooks/useChannels", () => ({ vi.mock("@posthog/ui/features/canvas/hooks/useChannelTasks", () => ({ useChannelTaskMutations: () => ({ fileTask: vi.fn() }), })); +// Needs a TRPCProvider at render; the canvas-suggestion path isn't under test. +vi.mock("@posthog/ui/features/canvas/hooks/useDashboards", () => ({ + useCreateAndOpenDashboard: () => vi.fn(), +})); vi.mock("@posthog/ui/features/canvas/hooks/useFolderInstructions", () => ({ useFolderInstructions, })); diff --git a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx index 786d6412eb..be400e68c5 100644 --- a/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx +++ b/packages/ui/src/features/canvas/components/WebsiteNewTask.tsx @@ -5,6 +5,7 @@ import { ChannelBreadcrumb } from "@posthog/ui/features/canvas/components/Channe import { ChannelContextPanel } from "@posthog/ui/features/canvas/components/ChannelContextPanel"; import { useChannels } from "@posthog/ui/features/canvas/hooks/useChannels"; import { useChannelTaskMutations } from "@posthog/ui/features/canvas/hooks/useChannelTasks"; +import { useCreateAndOpenDashboard } from "@posthog/ui/features/canvas/hooks/useDashboards"; import { useFolderInstructions } from "@posthog/ui/features/canvas/hooks/useFolderInstructions"; import { TaskInput } from "@posthog/ui/features/task-detail/components/TaskInput"; import { taskDetailQuery } from "@posthog/ui/features/tasks/queries"; @@ -68,6 +69,23 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { } }, [channelId, contextPanelOpen]); + // Canvas suggestions (e.g. "Set up a workflow") don't fill this composer — + // it creates plain tasks — they open a fresh canvas in the channel with the + // starter prompt dropped into its hero composer, ready to edit/send. + const createAndOpenCanvas = useCreateAndOpenDashboard(channelId); + const onCanvasSuggestionSelect = useCallback( + (suggestion: { label: string; prompt: string }) => { + track(ANALYTICS_EVENTS.CHANNEL_ACTION, { + action_type: "new_task_canvas_suggestion", + surface: "new_task", + channel_id: channelId, + suggestion_label: suggestion.label, + }); + void createAndOpenCanvas({ prefillInstruction: suggestion.prompt }); + }, + [channelId, createAndOpenCanvas], + ); + const onTaskCreated = useCallback( (task: Task) => { // Seed the detail cache so the destination route resolves instantly @@ -120,6 +138,7 @@ export function WebsiteNewTask({ channelId }: { channelId: string }) { suggestion_label: label, }) } + onCanvasSuggestionSelect={onCanvasSuggestionSelect} onContextChipClick={ channelContext ? handleContextChipClick : undefined } diff --git a/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx b/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx index bb10b0c4b0..3385036373 100644 --- a/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx +++ b/packages/ui/src/features/canvas/freeform/CanvasGenerateHero.tsx @@ -1,11 +1,12 @@ import { ShapesIcon } from "@phosphor-icons/react"; +import { takePendingCanvasPrefill } from "@posthog/ui/features/canvas/canvasPrefill"; import { CANVAS_GENERATE_SUGGESTIONS } from "@posthog/ui/features/canvas/freeform/canvasGenerateSuggestions"; import { FreeformGenerateBar } from "@posthog/ui/features/canvas/freeform/FreeformGenerateBar"; import type { EditorHandle } from "@posthog/ui/features/message-editor/types"; import { SuggestedPromptCard } from "@posthog/ui/features/task-detail/components/SuggestedPromptCard"; import { DotPatternBackground } from "@posthog/ui/primitives/DotPatternBackground"; import { Flex, Text } from "@radix-ui/themes"; -import { useRef } from "react"; +import { useEffect, useRef } from "react"; // The empty-canvas landing state: a centered composer with starter-prompt // suggestions below it. Once the user submits, the canvas record records a @@ -35,6 +36,17 @@ export function CanvasGenerateHero({ // Lets a suggestion card drop its prompt straight into the editor. const editorRef = useRef(null); + // A surface that created this canvas on behalf of a suggestion (e.g. the + // new-task screen's "Set up a workflow" card) stashes the starter prompt + // before navigating here; drop it into the composer ready to edit/send. + useEffect(() => { + const prefill = takePendingCanvasPrefill(dashboardId); + if (prefill) { + editorRef.current?.setContent(prefill); + editorRef.current?.focus(); + } + }, [dashboardId]); + return ( Promise { const navigate = useNavigate(); const { createDashboard } = useDashboardMutations(); @@ -235,6 +239,9 @@ export function useCreateAndOpenDashboard( try { const record = await createDashboard(targetChannelId, name, templateId); setEditing(record.id, true); + if (opts?.prefillInstruction) { + setPendingCanvasPrefill(record.id, opts.prefillInstruction); + } await navigate({ to: "/website/$channelId/dashboards/$dashboardId", params: { channelId: targetChannelId, dashboardId: record.id }, diff --git a/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx b/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx index 7ed80ab144..5f916db44b 100644 --- a/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx +++ b/packages/ui/src/features/task-detail/components/SuggestedPromptCard.tsx @@ -10,6 +10,9 @@ export interface SuggestedPrompt { color: string; /** Task mode to apply when this suggestion is selected, if it implies one. */ mode?: ExecutionMode; + /** Routes into canvas generation (canvas + workflow builds) instead of a + * plain task — the surface arms its canvas path when selecting this. */ + canvas?: boolean; } export interface SuggestedPromptCardProps { diff --git a/packages/ui/src/features/task-detail/components/TaskInput.tsx b/packages/ui/src/features/task-detail/components/TaskInput.tsx index a9f7a5881e..23fd88d180 100644 --- a/packages/ui/src/features/task-detail/components/TaskInput.tsx +++ b/packages/ui/src/features/task-detail/components/TaskInput.tsx @@ -126,6 +126,13 @@ interface TaskInputProps { * effect on the fill behaviour. */ onSuggestionSelect?: (label: string) => void; + /** + * Called INSTEAD of the default fill behaviour when a `canvas` suggestion + * (canvas + workflow builds) is clicked — this composer only creates plain + * tasks, so the surface routes those into its canvas flow. Without it, + * canvas suggestions fill the composer like any other. + */ + onCanvasSuggestionSelect?: (suggestion: SuggestedPrompt) => void; /** * Called when the channel CONTEXT.md chip is clicked (not its dismiss × ). * When provided, the chip's icon+label becomes a button — the channels @@ -149,6 +156,7 @@ export function TaskInput({ allowNoRepo, suggestions, onSuggestionSelect, + onCanvasSuggestionSelect, onContextChipClick, }: TaskInputProps = {}) { const cloudRegion = useAuthStateValue((s) => s.cloudRegion); @@ -1414,6 +1422,17 @@ export function TaskInput({ suggestion={suggestion} onSelect={() => { onSuggestionSelect?.(suggestion.label); + // Canvas suggestions (canvas + workflow builds) + // don't belong in this composer — it creates + // plain tasks — so hand them to the surface's + // canvas flow instead of filling the input. + if ( + suggestion.canvas && + onCanvasSuggestionSelect + ) { + onCanvasSuggestionSelect(suggestion); + return; + } // Use pending content (not setContent) so the // multi-line template — intro + "User input:" fill-in // lines — keeps its line breaks; focuses at the end. From 9cbdd7a26751f008093efd08e8b149c6c1ecd655 Mon Sep 17 00:00:00 2001 From: Harley Alexander Date: Fri, 17 Jul 2026 12:57:53 +0100 Subject: [PATCH 5/5] fix(agent): harden the workflow go-live gate per review - Classify go-live calls adapter-neutrally at the cloud relay: Codex forwards PostHog exec approvals without Claude's alwaysGated marker, so the park rule now also inspects the tool call itself (isPostHogGoLiveToolCall: posthog server exec + always-gated sub-tool). - Recognize plugin-installed PostHog servers (mcp__plugin_posthog_*__exec) in the exec-tool matcher, mirroring the renderer's server-name pattern, so both the destructive and go-live gates cover them. - Consume the pendingWorkflow slot after a link is emitted, so a later canvas publish in the same session can't re-emit a stale workflow link. Generated-By: PostHog Code Task-Id: 3b50883f-cab5-443a-8b92-3d0c948688da --- .../agent/src/adapters/claude/hooks.test.ts | 96 +++++++++++++++++++ packages/agent/src/adapters/claude/hooks.ts | 4 + .../permissions/posthog-exec-gate.test.ts | 80 ++++++++++++++++ .../claude/permissions/posthog-exec-gate.ts | 34 ++++++- .../agent/src/server/agent-server.test.ts | 23 +++++ packages/agent/src/server/agent-server.ts | 16 ++-- 6 files changed, 246 insertions(+), 7 deletions(-) diff --git a/packages/agent/src/adapters/claude/hooks.test.ts b/packages/agent/src/adapters/claude/hooks.test.ts index 599f2ce2a2..c54b52ec90 100644 --- a/packages/agent/src/adapters/claude/hooks.test.ts +++ b/packages/agent/src/adapters/claude/hooks.test.ts @@ -895,3 +895,99 @@ describe("createPostToolUseHook workflow link - YAML MCP results", () => { expect(signals).toEqual([]); }); }); + +describe("createPostToolUseHook workflow link - slot consumption", () => { + function execInput(command: string, tool_response?: unknown): HookInput { + return { + hook_event_name: "PostToolUse", + tool_name: "mcp__posthog__exec", + tool_input: { command }, + tool_response, + } as unknown as HookInput; + } + const opts = { signal: new AbortController().signal }; + + test("a second publish without a fresh lookup does not re-emit the link", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + await hook( + execInput( + "call --json workflows-create {}", + JSON.stringify({ id: "wf-1", status: "draft", name: "Alert" }), + ), + "tu1", + opts, + ); + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"a"}', + ), + "tu2", + opts, + ); + // An edit-turn republish of the same (or another) canvas: no new + // workflows-create/get happened, so no link may be emitted. + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-2","code":"b"}', + ), + "tu3", + opts, + ); + + expect(signals).toEqual([ + { + dashboardId: "dash-1", + workflowId: "wf-1", + workflowStatus: "draft", + workflowName: "Alert", + }, + ]); + }); + + test("a fresh workflows-get re-arms the pairing after a publish", async () => { + const signals: WorkflowBuiltSignal[] = []; + const hook = createPostToolUseHook({ + onWorkflowBuilt: (s) => signals.push(s), + }); + + await hook( + execInput( + "call --json workflows-create {}", + JSON.stringify({ id: "wf-1", status: "draft", name: "First" }), + ), + "tu1", + opts, + ); + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-1","code":"a"}', + ), + "tu2", + opts, + ); + await hook( + execInput( + "call --json workflows-get {}", + JSON.stringify({ id: "wf-2", status: "active", name: "Second" }), + ), + "tu3", + opts, + ); + await hook( + execInput( + 'call --json desktop-file-system-canvas-partial-update {"id":"dash-2","code":"b"}', + ), + "tu4", + opts, + ); + + expect(signals.map((s) => [s.dashboardId, s.workflowId])).toEqual([ + ["dash-1", "wf-1"], + ["dash-2", "wf-2"], + ]); + }); +}); diff --git a/packages/agent/src/adapters/claude/hooks.ts b/packages/agent/src/adapters/claude/hooks.ts index 6844e2f6d0..eb89fccf26 100644 --- a/packages/agent/src/adapters/claude/hooks.ts +++ b/packages/agent/src/adapters/claude/hooks.ts @@ -357,6 +357,10 @@ export const createPostToolUseHook = ({ const dashboardId = parseCanvasPublishDashboardId(input.tool_input); if (dashboardId) { onWorkflowBuilt({ dashboardId, ...pendingWorkflow }); + // Consume the slot: a later canvas publish in the same session + // (an edit turn, a second board) must not re-emit a stale link - + // only a fresh workflows-create/get re-arms the pairing. + pendingWorkflow = null; } } } diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts index 8162f0fd7a..99eda6492d 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.test.ts @@ -4,6 +4,7 @@ import { isPostHogAlwaysGatedSubTool, isPostHogDestructiveSubTool, isPostHogExecTool, + isPostHogGoLiveToolCall, } from "./posthog-exec-gate"; describe("isPostHogExecTool", () => { @@ -119,3 +120,82 @@ describe("isPostHogAlwaysGatedSubTool", () => { expect(isPostHogDestructiveSubTool("workflows-update-schedule")).toBe(true); }); }); + +describe("plugin-prefixed PostHog exec variants", () => { + it("matches the plugin-installed server name", () => { + // The renderer's POSTHOG_SERVER_RE recognizes plugin_posthog_* servers; + // the gate must gate the same names or a plugin install skips it. + expect(isPostHogExecTool("mcp__plugin_posthog_posthog__exec")).toBe(true); + expect(isPostHogExecTool("mcp__plugin_posthog__exec")).toBe(true); + }); + + it("still rejects non-PostHog plugin servers", () => { + expect(isPostHogExecTool("mcp__plugin_linear_linear__exec")).toBe(false); + expect(isPostHogExecTool("mcp__pluginposthog__exec")).toBe(false); + }); +}); + +describe("isPostHogGoLiveToolCall", () => { + it("classifies a Claude-shaped go-live call (legacy toolName meta)", () => { + expect( + isPostHogGoLiveToolCall({ + _meta: { claudeCode: { toolName: "mcp__posthog__exec" } }, + rawInput: { command: "call workflows-enable {}" }, + }), + ).toBe(true); + }); + + it("classifies a Codex-shaped go-live call (structured mcp descriptor)", () => { + expect( + isPostHogGoLiveToolCall({ + _meta: { posthog: { mcp: { server: "posthog", tool: "exec" } } }, + rawInput: { command: "call --json workflows-run-batch {}" }, + }), + ).toBe(true); + }); + + it("classifies a plugin-installed server's go-live call", () => { + expect( + isPostHogGoLiveToolCall({ + _meta: { + posthog: { mcp: { server: "plugin_posthog_posthog", tool: "exec" } }, + }, + rawInput: { command: "call workflows-schedule-create {}" }, + }), + ).toBe(true); + }); + + it("falls back to rawInput.toolName when meta carries no descriptor", () => { + expect( + isPostHogGoLiveToolCall({ + rawInput: { + toolName: "mcp__posthog__exec", + command: "call workflows-enable {}", + }, + }), + ).toBe(true); + }); + + it("rejects non-go-live sub-tools, other servers, and non-exec tools", () => { + expect( + isPostHogGoLiveToolCall({ + _meta: { posthog: { mcp: { server: "posthog", tool: "exec" } } }, + rawInput: { command: "call workflows-create {}" }, + }), + ).toBe(false); + expect( + isPostHogGoLiveToolCall({ + _meta: { posthog: { mcp: { server: "linear", tool: "exec" } } }, + rawInput: { command: "call workflows-enable {}" }, + }), + ).toBe(false); + expect( + isPostHogGoLiveToolCall({ + _meta: { posthog: { mcp: { server: "posthog", tool: "query" } } }, + rawInput: { command: "call workflows-enable {}" }, + }), + ).toBe(false); + expect(isPostHogGoLiveToolCall(undefined)).toBe(false); + expect(isPostHogGoLiveToolCall({})).toBe(false); + }); +}); diff --git a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts index c9258dda48..efed8f51c9 100644 --- a/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts +++ b/packages/agent/src/adapters/claude/permissions/posthog-exec-gate.ts @@ -1,3 +1,5 @@ +import { readMcpToolDescriptor } from "@posthog/shared"; + /** * The PostHog MCP exposes a single `exec` dispatcher tool that runs * subcommands like `call [--json] [json]`. Once the user approves @@ -6,7 +8,13 @@ * destructive subset (update/delete family) at sub-tool granularity. */ -const POSTHOG_EXEC_TOOL_RE = /^mcp__posthog(?:_[^_]+)*__exec$/; +// Optional `plugin_` prefix + `posthog` + optional installation suffixes, +// mirroring the renderer's POSTHOG_SERVER_RE (posthog-exec-display.ts) so a +// plugin-installed server (`mcp__plugin_posthog_posthog__exec`) is gated the +// same as the built-in one. +const POSTHOG_SERVER_NAME_RE = /^(?:plugin_)?posthog(?:_[^_]+)*$/; + +const POSTHOG_EXEC_TOOL_RE = /^mcp__(?:plugin_)?posthog(?:_[^_]+)*__exec$/; const POSTHOG_CALL_COMMAND_RE = /^\s*call\s+(?:--json\s+)?([a-zA-Z0-9_-]+)/; @@ -45,3 +53,27 @@ const POSTHOG_ALWAYS_GATED_SUBTOOLS = new Set([ export function isPostHogAlwaysGatedSubTool(subTool: string): boolean { return POSTHOG_ALWAYS_GATED_SUBTOOLS.has(subTool.toLowerCase()); } + +/** + * Adapter-neutral: whether a permission request's tool call is a PostHog + * go-live exec call. Claude marks these with `_meta.posthog.alwaysGated`, but + * Codex forwards PostHog exec approvals without it — so the cloud relay also + * classifies the call itself: the MCP descriptor (or legacy tool name) must be + * a PostHog server's `exec`, and the command's sub-tool must be always-gated. + */ +export function isPostHogGoLiveToolCall( + toolCall: { _meta?: unknown; rawInput?: unknown } | null | undefined, +): boolean { + if (!toolCall) return false; + const descriptor = readMcpToolDescriptor(toolCall._meta); + const rawInput = toolCall.rawInput as { toolName?: unknown } | undefined; + const rawName = + typeof rawInput?.toolName === "string" ? rawInput.toolName : undefined; + const isExec = descriptor + ? descriptor.tool === "exec" && + POSTHOG_SERVER_NAME_RE.test(descriptor.server) + : !!rawName && isPostHogExecTool(rawName); + if (!isExec) return false; + const subTool = extractPostHogSubTool(toolCall.rawInput); + return !!subTool && isPostHogAlwaysGatedSubTool(subTool); +} diff --git a/packages/agent/src/server/agent-server.test.ts b/packages/agent/src/server/agent-server.test.ts index 4c5fe49795..48bffc2e87 100644 --- a/packages/agent/src/server/agent-server.test.ts +++ b/packages/agent/src/server/agent-server.test.ts @@ -1532,6 +1532,29 @@ describe("AgentServer HTTP Mode", () => { expect(result._meta?.message).toContain("explicit human approval"); }); + it("parks a Codex-shaped go-live approval that carries no alwaysGated marker", async () => { + // Codex forwards PostHog exec approvals without Claude's + // `_meta.posthog.alwaysGated` marker — the relay must classify the call + // itself (posthog exec + go-live sub-tool) and still park it. + const testServer = exposeCloudClient(createServer()); + testServer.session = null; + testServer.eventStreamSender = null; + const relaySpy = vi.spyOn(testServer, "relayPermissionToClient"); + + const { requestPermission } = testServer.createCloudClient(basePayload); + const result = await requestPermission({ + options: [{ optionId: "allow", kind: "allow_once", name: "Yes" }], + toolCall: { + kind: "other", + _meta: { posthog: { mcp: { server: "posthog", tool: "exec" } } }, + rawInput: { command: "call workflows-enable {}" }, + }, + }); + + expect(relaySpy).not.toHaveBeenCalled(); + expect(result.outcome).toEqual({ outcome: "cancelled" }); + }); + it("parks a go-live approval in background mode even when a client is reachable", async () => { const testServer = exposeCloudClient(createServer()); testServer.session = { hasDesktopConnected: true }; diff --git a/packages/agent/src/server/agent-server.ts b/packages/agent/src/server/agent-server.ts index c36fc75467..2bf5db685b 100644 --- a/packages/agent/src/server/agent-server.ts +++ b/packages/agent/src/server/agent-server.ts @@ -34,6 +34,7 @@ import { type InProcessAcpConnection, } from "../adapters/acp-connection"; import { setAlwaysAskMcpServers } from "../adapters/claude/mcp/tool-metadata"; +import { isPostHogGoLiveToolCall } from "../adapters/claude/permissions/posthog-exec-gate"; import { getSessionJsonlPath, hydrateSessionJsonl, @@ -3696,15 +3697,18 @@ ${signedCommitInstructions}${prLinkInstructions}${shellEfficiencyInstructions} // Always-gated approvals (workflow go-live: enable / run-batch / // schedule) make an agent-built workflow live and can send to real - // people. The permission handler marks them with - // `_meta.posthog.alwaysGated`; they must reach a human - relay when a - // client can answer, otherwise PARK (cancel with guidance) rather than - // fall through to the auto-approve below, which would silently take - // the workflow live with nobody watching. + // people. Claude's permission handler marks them with + // `_meta.posthog.alwaysGated`; Codex forwards PostHog exec approvals + // without the marker, so ALSO classify the call itself at this + // adapter-neutral boundary. Either way they must reach a human - + // relay when a client can answer, otherwise PARK (cancel with + // guidance) rather than fall through to the auto-approve below, + // which would silently take the workflow live with nobody watching. { const alwaysGated = (params.toolCall?._meta as { posthog?: { alwaysGated?: unknown } }) - ?.posthog?.alwaysGated === true; + ?.posthog?.alwaysGated === true || + isPostHogGoLiveToolCall(params.toolCall); if (alwaysGated) { if (mode !== "background" && this.hasReachableClient()) { return this.relayPermissionToClient(params);