Add existing workspace migration telemetry (#2359)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
fb4069be52
commit
c856413d23
|
|
@ -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<void>,
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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<Repo | null>(null)
|
||||
const [existingWorkspaceSource, setExistingWorkspaceSource] =
|
||||
useState<AddRepoExistingWorkspaceSource | null>(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<Set<string>>(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 (
|
||||
<Dialog
|
||||
|
|
@ -340,7 +391,7 @@ const AddRepoDialog = React.memo(function AddRepoDialog() {
|
|||
// Skip is handled on its own renderer-side click handler. Implicit closes
|
||||
// on the Setup step are funnel-equivalent to Skip.
|
||||
if (step === 'setup') {
|
||||
track('add_repo_setup_step_action', { action: 'skip' })
|
||||
trackSetupAction('skip')
|
||||
void finishImportedRepoWithoutOpening()
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import { Button } from '@/components/ui/button'
|
|||
import { Input } from '@/components/ui/input'
|
||||
import { RemoteFileBrowser } from './RemoteFileBrowser'
|
||||
import { SshTargetRow } from './SshTargetRow'
|
||||
import type { AddRepoExistingWorkspaceSource } from '../../../../shared/telemetry-events'
|
||||
import type { Repo } from '../../../../shared/types'
|
||||
import type { SshTarget, SshConnectionState } from '../../../../shared/ssh-types'
|
||||
|
||||
|
|
@ -23,7 +24,8 @@ export function useRemoteRepo(
|
|||
fetchWorktrees: (repoId: string) => Promise<void>,
|
||||
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<string | null>(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,
|
||||
|
|
|
|||
|
|
@ -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>): 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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'
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
@ -101,6 +101,15 @@ export const addRepoSetupStepActionSchema = z.enum([
|
|||
])
|
||||
export type AddRepoSetupStepAction = z.infer<typeof addRepoSetupStepActionSchema>
|
||||
|
||||
export const addRepoExistingWorkspaceSourceSchema = z.enum([
|
||||
'local_folder_picker',
|
||||
'runtime_server_path',
|
||||
'ssh_remote_path',
|
||||
'clone_url',
|
||||
'create_project'
|
||||
])
|
||||
export type AddRepoExistingWorkspaceSource = z.infer<typeof addRepoExistingWorkspaceSourceSchema>
|
||||
|
||||
// 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'
|
||||
|
|
|
|||
Loading…
Reference in New Issue