From c856413d234b164e70bbdcc41194abe3487cbdc3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Tue, 19 May 2026 15:44:39 -0700 Subject: [PATCH] Add existing workspace migration telemetry (#2359) Co-authored-by: Orca --- .../components/sidebar/AddRepoCreateStep.tsx | 16 ++- .../src/components/sidebar/AddRepoDialog.tsx | 77 ++++++++++-- .../src/components/sidebar/AddRepoSteps.tsx | 15 ++- ...repo-existing-workspaces-telemetry.test.ts | 119 ++++++++++++++++++ .../add-repo-existing-workspaces-telemetry.ts | 79 ++++++++++++ ...repo-existing-workspaces-telemetry.test.ts | 48 +++++++ src/shared/telemetry-events.ts | 37 +++++- 7 files changed, 373 insertions(+), 18 deletions(-) create mode 100644 src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts create mode 100644 src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts create mode 100644 src/shared/add-repo-existing-workspaces-telemetry.test.ts diff --git a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx index d24aeda7e..109ffaa83 100644 --- a/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx +++ b/src/renderer/src/components/sidebar/AddRepoCreateStep.tsx @@ -15,6 +15,7 @@ import { Input } from '@/components/ui/input' import { activateAndRevealWorktree } from '@/lib/worktree-activation' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events' import type { Repo } from '../../../../shared/types' type DialogStep = 'add' | 'clone' | 'remote' | 'create' | 'setup' @@ -24,7 +25,8 @@ export function useCreateRepo( fetchWorktrees: (repoId: string) => Promise, setStep: (step: DialogStep) => void, setAddedRepo: (repo: Repo | null) => void, - closeModal: () => void + closeModal: () => void, + setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void ) { const [createName, setCreateName] = useState('') const [createParent, setCreateParent] = useState('') @@ -128,6 +130,7 @@ export function useCreateRepo( // Why: setAddedRepo only drives the git "setup" step; the folder // branch closes the dialog, which resets addedRepo to null anyway. setAddedRepo(repo) + setExistingWorkspaceSource?.('create_project') await fetchWorktrees(repo.id) if (gen !== createGenRef.current) { return @@ -159,7 +162,16 @@ export function useCreateRepo( setIsCreating(false) } } - }, [createName, createParent, createKind, fetchWorktrees, setStep, setAddedRepo, closeModal]) + }, [ + createName, + createParent, + createKind, + fetchWorktrees, + setStep, + setAddedRepo, + closeModal, + setExistingWorkspaceSource + ]) return { createName, diff --git a/src/renderer/src/components/sidebar/AddRepoDialog.tsx b/src/renderer/src/components/sidebar/AddRepoDialog.tsx index 600c1f722..7f4d6f6fb 100644 --- a/src/renderer/src/components/sidebar/AddRepoDialog.tsx +++ b/src/renderer/src/components/sidebar/AddRepoDialog.tsx @@ -20,8 +20,16 @@ import { SetupStep } from './AddRepoSetupStep' import { getDefaultCloneParent } from './clone-defaults' import { callRuntimeRpc, getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client' import { isGitRepoKind } from '../../../../shared/repo-kind' +import type { + AddRepoExistingWorkspaceSource, + AddRepoSetupStepAction +} from '../../../../shared/telemetry-events' import type { Repo, Worktree } from '../../../../shared/types' import { finalizeImportedRepoAfterSkip } from './add-repo-skip-finalization' +import { + buildAddRepoExistingWorkspacesTelemetry, + shouldTrackAddRepoExistingWorkspacesDetected +} from './add-repo-existing-workspaces-telemetry' const AddRepoDialog = React.memo(function AddRepoDialog() { const activeModal = useAppStore((s) => s.activeModal) @@ -38,6 +46,8 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const [step, setStep] = useState<'add' | 'clone' | 'remote' | 'create' | 'setup'>('add') const [addedRepo, setAddedRepo] = useState(null) + const [existingWorkspaceSource, setExistingWorkspaceSource] = + useState(null) const [isAdding, setIsAdding] = useState(false) const [serverPath, setServerPath] = useState('') const [isAddingServerPath, setIsAddingServerPath] = useState(false) @@ -68,7 +78,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { handleOpenRemoteStep, handleAddRemoteRepo, handleConnectTarget - } = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal) + } = useRemoteRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource) const { createName, @@ -83,7 +93,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { resetCreateState, handlePickParent, handleCreate - } = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal) + } = useCreateRepo(fetchWorktrees, setStep, setAddedRepo, closeModal, setExistingWorkspaceSource) useEffect(() => { if (!isCloning) { return @@ -137,6 +147,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { void window.api.repos.cloneAbort() setStep('add') setAddedRepo(null) + setExistingWorkspaceSource(null) setIsAdding(false) setServerPath('') setIsAddingServerPath(false) @@ -164,6 +175,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const repo = await addRepo() if (repo && isGitRepoKind(repo)) { setAddedRepo(repo) + setExistingWorkspaceSource('local_folder_picker') await fetchWorktrees(repo.id) setStep('setup') } else if (repo) { @@ -186,6 +198,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const repo = await addRepoPath(path, kind) if (repo && isGitRepoKind(repo)) { setAddedRepo(repo) + setExistingWorkspaceSource('runtime_server_path') await fetchWorktrees(repo.id) setStep('setup') } else if (repo) { @@ -257,6 +270,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { useAppStore.setState({ repos: updated }) } setAddedRepo(repo) + setExistingWorkspaceSource('clone_url') await fetchWorktrees(repo.id) setStep('setup') } catch (err) { @@ -272,18 +286,55 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { } }, [cloneUrl, cloneDestination, fetchWorktrees]) + const existingWorkspaceTelemetry = useMemo( + () => buildAddRepoExistingWorkspacesTelemetry(existingWorkspaceSource, sortedWorktrees), + [existingWorkspaceSource, sortedWorktrees] + ) + + const detectedTelemetryTrackedRef = useRef>(new Set()) + useEffect(() => { + if ( + step !== 'setup' || + !repoId || + !existingWorkspaceTelemetry || + !shouldTrackAddRepoExistingWorkspacesDetected(existingWorkspaceTelemetry) || + detectedTelemetryTrackedRef.current.has(repoId) + ) { + return + } + detectedTelemetryTrackedRef.current.add(repoId) + track('add_repo_existing_workspaces_detected', existingWorkspaceTelemetry) + }, [existingWorkspaceSource, existingWorkspaceTelemetry, repoId, step]) + + const trackSetupAction = useCallback( + (action: AddRepoSetupStepAction): void => { + track('add_repo_setup_step_action', { + action, + ...(existingWorkspaceTelemetry + ? { + source: existingWorkspaceTelemetry.source, + existing_workspace_count: existingWorkspaceTelemetry.existing_workspace_count, + existing_linked_workspace_count: + existingWorkspaceTelemetry.existing_linked_workspace_count + } + : {}) + }) + }, + [existingWorkspaceTelemetry] + ) + const handleOpenWorktree = useCallback( (worktree: Worktree) => { - track('add_repo_setup_step_action', { action: 'open_existing' }) + trackSetupAction('open_existing') activateAndRevealWorktree(worktree.id) closeModal() }, - [closeModal] + [closeModal, trackSetupAction] ) const handleCreateWorktree = useCallback(() => { // Why: Setup-step "Create" affordance — fires on click intent, not on IPC arrival, mirroring the other 4 actions in this dialog. - track('add_repo_setup_step_action', { action: 'create_worktree' }) + trackSetupAction('create_worktree') // Why: small delay so the Add Project dialog close animation finishes before // the composer modal takes focus; otherwise the dialog teardown can steal // the first focus frame from the composer's prompt textarea. @@ -291,14 +342,14 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { setTimeout(() => { openModal('new-workspace-composer', { initialRepoId: repoId, telemetrySource: 'sidebar' }) }, 150) - }, [closeModal, openModal, repoId]) + }, [closeModal, openModal, repoId, trackSetupAction]) const handleConfigureRepo = useCallback(() => { - track('add_repo_setup_step_action', { action: 'configure' }) + trackSetupAction('configure') closeModal() openSettingsTarget({ pane: 'repo', repoId }) openSettingsPage() - }, [closeModal, openSettingsTarget, openSettingsPage, repoId]) + }, [closeModal, openSettingsTarget, openSettingsPage, repoId, trackSetupAction]) const finishImportedRepoWithoutOpening = useCallback(async () => { const importedRepoId = repoId @@ -317,18 +368,18 @@ const AddRepoDialog = React.memo(function AddRepoDialog() { const handleBack = resetState const handleSkip = useCallback(() => { - track('add_repo_setup_step_action', { action: 'skip' }) + trackSetupAction('skip') void finishImportedRepoWithoutOpening() - }, [finishImportedRepoWithoutOpening]) + }, [finishImportedRepoWithoutOpening, trackSetupAction]) // Why: only the Setup step's "Add another project" back arrow counts as a // funnel event — the in-flight Back arrows on clone/remote/create are not // a Setup-step affordance. Keeping the emit scoped to this handler avoids // also tagging mid-clone backs. const handleSetupStepBack = useCallback(() => { - track('add_repo_setup_step_action', { action: 'back' }) + trackSetupAction('back') handleBack() - }, [handleBack]) + }, [handleBack, trackSetupAction]) return ( Promise, setStep: (step: 'add' | 'clone' | 'remote' | 'create' | 'setup') => void, setAddedRepo: (repo: Repo | null) => void, - closeModal: () => void + closeModal: () => void, + setExistingWorkspaceSource?: (source: AddRepoExistingWorkspaceSource) => void ) { const [sshTargets, setSshTargets] = useState<(SshTarget & { state?: SshConnectionState })[]>([]) const [selectedTargetId, setSelectedTargetId] = useState(null) @@ -126,6 +128,7 @@ export function useRemoteRepo( toast.success('Remote project added', { description: repo.displayName }) setAddedRepo(repo) + setExistingWorkspaceSource?.('ssh_remote_path') await fetchWorktrees(repo.id) setStep('setup') } catch (err) { @@ -145,7 +148,15 @@ export function useRemoteRepo( } finally { setIsAddingRemote(false) } - }, [selectedTargetId, remotePath, fetchWorktrees, setStep, setAddedRepo, closeModal]) + }, [ + selectedTargetId, + remotePath, + fetchWorktrees, + setStep, + setAddedRepo, + closeModal, + setExistingWorkspaceSource + ]) return { sshTargets, diff --git a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts new file mode 100644 index 000000000..5ade8cfa3 --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.test.ts @@ -0,0 +1,119 @@ +import { describe, expect, it } from 'vitest' +import type { Worktree } from '../../../../shared/types' +import { + buildAddRepoExistingWorkspacesTelemetry, + shouldTrackAddRepoExistingWorkspacesDetected +} from './add-repo-existing-workspaces-telemetry' + +function worktree(overrides: Partial): Worktree { + return { + id: 'repo::/repo', + repoId: 'repo', + path: '/repo', + head: 'abc', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true, + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + ...overrides + } +} + +describe('add repo existing workspace telemetry', () => { + it('builds count-only payloads without raw workspace names', () => { + const payload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [ + worktree({ path: '/repo', displayName: 'main', branch: 'refs/heads/main' }), + worktree({ + id: 'repo::/repo-feature', + path: '/repo-feature', + displayName: 'Feature With User Text', + branch: 'refs/heads/feature/private-task', + isMainWorktree: false, + isSparse: true + }), + worktree({ + id: 'repo::/detached', + path: 'C:\\workspaces\\detached', + displayName: 'detached', + branch: '', + isMainWorktree: false + }) + ]) + + expect(payload).toEqual({ + source: 'local_folder_picker', + existing_workspace_count: 3, + existing_linked_workspace_count: 2, + main_workspace_count: 1, + branch_named_workspace_count: 2, + detached_workspace_count: 1, + custom_named_workspace_count: 1, + sparse_workspace_count: 1 + }) + expect(JSON.stringify(payload)).not.toContain('private-task') + expect(JSON.stringify(payload)).not.toContain('Feature With User Text') + }) + + it('tracks detection only for imported linked workspaces', () => { + expect(buildAddRepoExistingWorkspacesTelemetry(null, [worktree({})])).toBeNull() + expect(buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [])).toBeNull() + + const mainOnlyPayload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [ + worktree({}) + ]) + expect(mainOnlyPayload?.existing_linked_workspace_count).toBe(0) + expect(shouldTrackAddRepoExistingWorkspacesDetected(mainOnlyPayload)).toBe(false) + + const importedLocalPayload = buildAddRepoExistingWorkspacesTelemetry('local_folder_picker', [ + worktree({}), + worktree({ id: 'repo::/repo-existing', path: '/repo-existing', isMainWorktree: false }) + ]) + const importedRemotePayload = buildAddRepoExistingWorkspacesTelemetry('ssh_remote_path', [ + worktree({}), + worktree({ id: 'repo::/remote-existing', path: '/remote-existing', isMainWorktree: false }) + ]) + const clonePayload = buildAddRepoExistingWorkspacesTelemetry('clone_url', [ + worktree({}), + worktree({ id: 'repo::/clone-existing', path: '/clone-existing', isMainWorktree: false }) + ]) + const createPayload = buildAddRepoExistingWorkspacesTelemetry('create_project', [ + worktree({}), + worktree({ id: 'repo::/create-existing', path: '/create-existing', isMainWorktree: false }) + ]) + + expect(shouldTrackAddRepoExistingWorkspacesDetected(importedLocalPayload)).toBe(true) + expect(shouldTrackAddRepoExistingWorkspacesDetected(importedRemotePayload)).toBe(true) + expect(shouldTrackAddRepoExistingWorkspacesDetected(clonePayload)).toBe(false) + expect(shouldTrackAddRepoExistingWorkspacesDetected(createPayload)).toBe(false) + }) + + it('derives linked and detached counts before clamping reported values', () => { + const mainOnlyPayload = buildAddRepoExistingWorkspacesTelemetry( + 'local_folder_picker', + Array.from({ length: 60 }, (_, index) => + worktree({ + id: `repo::/repo-main-${index}`, + path: `/repo-main-${index}`, + isMainWorktree: true + }) + ) + ) + + expect(mainOnlyPayload).toMatchObject({ + existing_workspace_count: 50, + existing_linked_workspace_count: 0, + main_workspace_count: 50, + detached_workspace_count: 0 + }) + expect(shouldTrackAddRepoExistingWorkspacesDetected(mainOnlyPayload)).toBe(false) + }) +}) diff --git a/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts new file mode 100644 index 000000000..f1511e2bf --- /dev/null +++ b/src/renderer/src/components/sidebar/add-repo-existing-workspaces-telemetry.ts @@ -0,0 +1,79 @@ +import type { + AddRepoExistingWorkspaceSource, + EventProps +} from '../../../../shared/telemetry-events' +import type { Worktree } from '../../../../shared/types' + +type ExistingWorkspacesDetectedProps = EventProps<'add_repo_existing_workspaces_detected'> + +const MAX_REPORTED_WORKSPACES = 50 + +function countWorkspaces(count: number): number { + return Math.min(MAX_REPORTED_WORKSPACES, Math.max(0, count)) +} + +function branchDisplayName(worktree: Worktree): string { + return worktree.branch.replace(/^refs\/heads\//, '') +} + +function pathBasename(pathValue: string): string { + return ( + pathValue + .replace(/[\\/]+$/, '') + .split(/[\\/]/) + .filter(Boolean) + .at(-1) ?? '' + ) +} + +function isCustomDisplayName(worktree: Worktree): boolean { + const branchName = branchDisplayName(worktree) + const pathName = pathBasename(worktree.path) + return Boolean( + worktree.displayName && worktree.displayName !== branchName && worktree.displayName !== pathName + ) +} + +export function buildAddRepoExistingWorkspacesTelemetry( + source: AddRepoExistingWorkspaceSource | null, + worktrees: readonly Worktree[] +): ExistingWorkspacesDetectedProps | null { + if (!source || worktrees.length === 0) { + return null + } + + const mainWorkspaceCount = worktrees.filter((worktree) => worktree.isMainWorktree).length + const branchNamedWorkspaceCount = worktrees.filter((worktree) => + Boolean(branchDisplayName(worktree)) + ).length + const sparseWorkspaceCount = worktrees.filter((worktree) => worktree.isSparse === true).length + + return { + source, + existing_workspace_count: countWorkspaces(worktrees.length), + existing_linked_workspace_count: countWorkspaces(worktrees.length - mainWorkspaceCount), + main_workspace_count: countWorkspaces(mainWorkspaceCount), + branch_named_workspace_count: countWorkspaces(branchNamedWorkspaceCount), + detached_workspace_count: countWorkspaces(worktrees.length - branchNamedWorkspaceCount), + custom_named_workspace_count: countWorkspaces(worktrees.filter(isCustomDisplayName).length), + sparse_workspace_count: countWorkspaces(sparseWorkspaceCount) + } +} + +export function shouldTrackAddRepoExistingWorkspacesDetected( + payload: ExistingWorkspacesDetectedProps | null +): boolean { + // Track the import/discovery signal, not mere setup-modal exposure: the main + // checkout is always a worktree, but only non-main worktrees imply migration. + if (!payload || payload.existing_linked_workspace_count === 0) { + return false + } + + // Clone/create produce a new project during this flow, so their setup step is + // not evidence of a pre-existing workspace migration opportunity. + return ( + payload.source === 'local_folder_picker' || + payload.source === 'runtime_server_path' || + payload.source === 'ssh_remote_path' + ) +} diff --git a/src/shared/add-repo-existing-workspaces-telemetry.test.ts b/src/shared/add-repo-existing-workspaces-telemetry.test.ts new file mode 100644 index 000000000..3804cec5a --- /dev/null +++ b/src/shared/add-repo-existing-workspaces-telemetry.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { eventSchemas } from './telemetry-events' + +describe('add_repo_existing_workspaces_detected schema', () => { + it('accepts count-only workspace migration context', () => { + const parsed = eventSchemas.add_repo_existing_workspaces_detected.safeParse({ + source: 'local_folder_picker', + existing_workspace_count: 3, + existing_linked_workspace_count: 2, + main_workspace_count: 1, + branch_named_workspace_count: 2, + detached_workspace_count: 1, + custom_named_workspace_count: 1, + sparse_workspace_count: 0, + nth_repo_added: 1 + }) + expect(parsed.success).toBe(true) + }) + + it('rejects raw workspace names via .strict()', () => { + const parsed = eventSchemas.add_repo_existing_workspaces_detected.safeParse({ + source: 'local_folder_picker', + existing_workspace_count: 1, + existing_linked_workspace_count: 0, + main_workspace_count: 1, + branch_named_workspace_count: 1, + detached_workspace_count: 0, + custom_named_workspace_count: 0, + sparse_workspace_count: 0, + nth_repo_added: 1, + workspace_names: ['secret-customer-branch'] + }) + expect(parsed.success).toBe(false) + }) + + it('accepts setup start choices with bounded existing-workspace context', () => { + for (const action of ['open_existing', 'create_worktree'] as const) { + const parsed = eventSchemas.add_repo_setup_step_action.safeParse({ + action, + source: 'ssh_remote_path', + existing_workspace_count: 4, + existing_linked_workspace_count: 3, + nth_repo_added: 1 + }) + expect(parsed.success).toBe(true) + } + }) +}) diff --git a/src/shared/telemetry-events.ts b/src/shared/telemetry-events.ts index 5925fdf71..4919e5aec 100644 --- a/src/shared/telemetry-events.ts +++ b/src/shared/telemetry-events.ts @@ -101,6 +101,15 @@ export const addRepoSetupStepActionSchema = z.enum([ ]) export type AddRepoSetupStepAction = z.infer +export const addRepoExistingWorkspaceSourceSchema = z.enum([ + 'local_folder_picker', + 'runtime_server_path', + 'ssh_remote_path', + 'clone_url', + 'create_project' +]) +export type AddRepoExistingWorkspaceSource = z.infer + // Deliberately a separate enum from `errorClassSchema` (PTY-spawn taxonomy): // different domain — this one buckets git/filesystem failures thrown by // `createLocalWorktree` / `createRemoteWorktree`. Merging the two would lock @@ -286,8 +295,32 @@ const featureWallTileClickedSchema = z }) .strict() +const existingWorkspaceCountSchema = z.number().int().min(1).max(50) +const addRepoExistingWorkspaceContextSchema = { + source: addRepoExistingWorkspaceSourceSchema, + existing_workspace_count: existingWorkspaceCountSchema, + existing_linked_workspace_count: z.number().int().min(0).max(50) +} as const + const addRepoSetupStepActionEventSchema = z - .object({ action: addRepoSetupStepActionSchema, nth_repo_added: nthRepoAddedSchema }) + .object({ + action: addRepoSetupStepActionSchema, + source: addRepoExistingWorkspaceSourceSchema.optional(), + existing_workspace_count: existingWorkspaceCountSchema.optional(), + existing_linked_workspace_count: z.number().int().min(0).max(50).optional(), + nth_repo_added: nthRepoAddedSchema + }) + .strict() +const addRepoExistingWorkspacesDetectedSchema = z + .object({ + ...addRepoExistingWorkspaceContextSchema, + main_workspace_count: z.number().int().min(0).max(50), + branch_named_workspace_count: z.number().int().min(0).max(50), + detached_workspace_count: z.number().int().min(0).max(50), + custom_named_workspace_count: z.number().int().min(0).max(50), + sparse_workspace_count: z.number().int().min(0).max(50), + nth_repo_added: nthRepoAddedSchema + }) .strict() // Why: same enum-only discipline as `agent_error` — `.strict()` rejects raw @@ -710,6 +743,7 @@ export const eventSchemas = { repo_added: repoAddedSchema, add_repo_setup_step_action: addRepoSetupStepActionEventSchema, + add_repo_existing_workspaces_detected: addRepoExistingWorkspacesDetectedSchema, workspace_created: workspaceCreatedSchema, workspace_create_failed: workspaceCreateFailedSchema, @@ -791,6 +825,7 @@ type _CohortExtendedRoster = | 'app_opened' | 'repo_added' | 'add_repo_setup_step_action' + | 'add_repo_existing_workspaces_detected' | 'workspace_created' | 'workspace_create_failed' | 'agent_started'