mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
@@ -66,6 +66,7 @@ tmp/
|
||||
design-docs/
|
||||
.context/
|
||||
.atl/
|
||||
/agent-hooks/
|
||||
|
||||
# Local-only design/planning docs (not checked in)
|
||||
docs/*.md
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
# Design Review Context
|
||||
|
||||
## Document Info
|
||||
- Path: docs/orchestration-workspace-lineage.md
|
||||
- Type: Technical Spec / Product Design
|
||||
- Review started: 2026-05-14 15:36:41 PDT
|
||||
|
||||
## Design Direction
|
||||
- Direction confirmed: confirmed
|
||||
- Chosen approach: first-class persisted workspace lineage with runtime-validated capture source/confidence, followed by opt-in sidebar Parent grouping
|
||||
- Alternatives considered: session-scoped lineage only; orchestration-only lineage; storing parent fields directly on WorktreeMeta
|
||||
- Key UX decisions: Parent grouping remains opt-in; pinned workspaces keep precedence with parent badges; inferred relationships are visible in CLI output and JSON; hidden/missing parent states remain recoverable
|
||||
|
||||
## Iteration State
|
||||
Current iteration: 3
|
||||
Last completed phase: Iteration 3 Verification
|
||||
Issues addressed this iteration: none; verification found no remaining P0/P1 findings
|
||||
|
||||
## Addressed Issues (Do Not Re-report)
|
||||
<!-- Issues that were fixed in the design doc. Reviewers should not re-report these. -->
|
||||
<!-- Format: [iteration] | [severity] | [category] | [issue summary] | [how addressed] -->
|
||||
|
||||
Phase 0.5 | P1 | UX | Hidden confidence state for inferred parent | Added capture source/confidence to lineage and CLI output
|
||||
Phase 0.5 | P1 | Architecture | Runtime capture source of truth unclear | Added Capture Source of Truth section and runtime validation rules for caller context
|
||||
Phase 0.5 | P2 | UX | Parent grouping interactions under-specified | Added interaction state matrix for grouping, reveal, collapse, and repair flows
|
||||
Phase 0.5 | P2 | Architecture | Row model/reveal risk under-specified | Added flat row model contract and sidebar grouping test requirements
|
||||
Phase 0.5 | P2 | Architecture | Orchestration enrichment bridge incomplete | Added narrow runtime orchestration bridge and centralized validation guidance
|
||||
Iteration 1 | P1 | CLI | CLI JSON shape/stdout contract ambiguity | Specified existing RPC envelope, single JSON stdout object, and stderr-only human diagnostics
|
||||
Iteration 1 | P1 | CLI | Missing JSON warning/error contracts | Added `result.warnings[]`, stable lineage warning codes, and structured `ok: false` error example with `nextSteps`
|
||||
Iteration 1 | P2 | Data integrity | Path-derived worktree ID reuse can stale-attach lineage | Added immutable workspace instance IDs and stale-instance handling
|
||||
Iteration 1 | P2 | UX | Hidden-parent rendering was ambiguous | Made Parent grouping use `Hidden parent` deterministically and non-Parent modes use badges
|
||||
Iteration 1 | P2 | Operational | Lineage failure observability under-specified | Added structured diagnostics with reason code, request ID, runtime ID, and candidate source types
|
||||
Iteration 1 | P2 | UX | Repair action semantics under-specified | Renamed action to `Group under active workspace` and defined selection, disabled, multi-select, and SSH validation behavior
|
||||
Iteration 2 | P1 | UX | Hidden parent behavior conflicted with filter contract | Made Parent grouping always render the real parent group when the parent exists and any child matches filters
|
||||
Iteration 2 | P2 | API contract | Stale-instance warning placement under-specified | Clarified `LINEAGE_PARENT_INSTANCE_STALE` is a hydration/read-path diagnostic, not a create warning
|
||||
|
||||
## Skipped Issues (Accepted Risks)
|
||||
<!-- Issues reviewed but deemed acceptable for this context. Do not re-report. -->
|
||||
<!-- Format: [iteration] | [severity] | [category] | [reason skipped] | [issue summary] -->
|
||||
<!-- NOTE: Only P2/P3 issues may be skipped. P0/P1 must always be addressed. -->
|
||||
|
||||
Phase 0.5 | P3 | UX | Below fix threshold after adding CLI/JSON confidence signals | Cognitive load from four grouping modes plus pinning exception
|
||||
|
||||
## Invalidated Findings (Do Not Re-report)
|
||||
<!-- Findings that were challenged and determined to be false positives or noise. -->
|
||||
<!-- Reviewers in future iterations must NOT resurface these. -->
|
||||
<!-- Format: [iteration] | [original severity] | [finding summary] | [reason invalidated] -->
|
||||
|
||||
[Initially empty - populated after each validation phase]
|
||||
|
||||
## Findings History
|
||||
<!-- Running log of findings across iterations for convergence tracking -->
|
||||
|
||||
### Phase 0.5
|
||||
- Direction challenge confirmed the proposed direction with refinement.
|
||||
- P0: 0 | P1: 2 | P2: 3 | P3: 1
|
||||
- Addressed: 5 | Skipped: 1
|
||||
- Key changes: capture confidence/source, runtime validation source-of-truth, interaction states, row model contract, orchestration bridge, capture-quality rollout gate
|
||||
|
||||
### Iteration 1
|
||||
- P0: 0 | P1: 2 | P2: 4 | P3: 0
|
||||
- Addressed: 6 | Skipped: 0
|
||||
- Key changes: JSON envelope compatibility, structured warning/error contracts, instance IDs for stale lineage protection, deterministic hidden-parent rendering, diagnostics, repair-action semantics
|
||||
|
||||
### Iteration 2
|
||||
- P0: 0 | P1: 1 | P2: 1 | P3: 0
|
||||
- Addressed: 2 | Skipped: 0
|
||||
- Key changes: parent group/filter precedence clarified; stale-instance diagnostics scoped to hydration/read path
|
||||
|
||||
### Iteration 3
|
||||
- P0: 0 | P1: 0 | P2: 0 | P3: 0
|
||||
- Addressed: 0 | Skipped: 0
|
||||
- Key changes: final verification only; primary and secondary reviewers both reported no P0/P1 findings
|
||||
@@ -60,6 +60,18 @@ export function formatCliError(error: unknown): string {
|
||||
) {
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
const data = error.response.error.data
|
||||
const nextSteps =
|
||||
data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps)
|
||||
? (data as { nextSteps: unknown[] }).nextSteps.filter(
|
||||
(step): step is string => typeof step === 'string'
|
||||
)
|
||||
: []
|
||||
if (nextSteps.length > 0) {
|
||||
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
||||
}
|
||||
}
|
||||
return message
|
||||
}
|
||||
|
||||
|
||||
@@ -207,12 +207,18 @@ export const ORCHESTRATION_HANDLERS: Record<string, CommandHandler> = {
|
||||
},
|
||||
|
||||
'orchestration task-create': async ({ flags, client, json }) => {
|
||||
const callerTerminalHandle =
|
||||
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
|
||||
process.env.ORCA_TERMINAL_HANDLE.length > 0
|
||||
? process.env.ORCA_TERMINAL_HANDLE
|
||||
: undefined
|
||||
const result = await client.call<{ task: { id: string; status: string } }>(
|
||||
'orchestration.taskCreate',
|
||||
{
|
||||
spec: getRequiredStringFlag(flags, 'spec'),
|
||||
deps: getOptionalStringFlag(flags, 'deps'),
|
||||
parent: getOptionalStringFlag(flags, 'parent')
|
||||
parent: getOptionalStringFlag(flags, 'parent'),
|
||||
callerTerminalHandle
|
||||
}
|
||||
)
|
||||
printResult(result, json, (r) => `Created ${r.task.id} [${r.task.status}]`)
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type {
|
||||
RuntimeWorktreeListResult,
|
||||
RuntimeWorktreePsResult,
|
||||
RuntimeWorktreeRecord
|
||||
RuntimeWorktreeRecord,
|
||||
RuntimeWorktreeCreateResult
|
||||
} from '../../shared/runtime-types'
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { formatWorktreeList, formatWorktreePs, formatWorktreeShow, printResult } from '../format'
|
||||
@@ -24,6 +25,30 @@ function printHookWarning(result: HookWarningResult, json: boolean): void {
|
||||
}
|
||||
}
|
||||
|
||||
function printLineageSummary(result: RuntimeWorktreeCreateResult, json: boolean): void {
|
||||
if (json) {
|
||||
return
|
||||
}
|
||||
for (const warning of result.warnings ?? []) {
|
||||
console.error(`warning: ${warning.message}`)
|
||||
}
|
||||
if (result.lineage) {
|
||||
const source =
|
||||
result.lineage.capture.source === 'terminal-context'
|
||||
? 'terminal'
|
||||
: result.lineage.capture.source === 'orchestration-context'
|
||||
? 'orchestration'
|
||||
: result.lineage.capture.source === 'explicit-cli-flag'
|
||||
? 'explicit flag'
|
||||
: 'manual action'
|
||||
console.error(
|
||||
`parent: ${result.lineage.parentWorktreeId} (${result.lineage.capture.confidence} from ${source})`
|
||||
)
|
||||
} else {
|
||||
console.error('parent: none')
|
||||
}
|
||||
}
|
||||
|
||||
export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
'worktree ps': async ({ flags, client, json }) => {
|
||||
const result = await client.call<RuntimeWorktreePsResult>('worktree.ps', {
|
||||
@@ -50,20 +75,38 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
})
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
},
|
||||
'worktree create': async ({ flags, client, json }) => {
|
||||
const result = await client.call<{ worktree: RuntimeWorktreeRecord; warning?: string }>(
|
||||
'worktree.create',
|
||||
{
|
||||
repo: getRequiredStringFlag(flags, 'repo'),
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
activate: flags.get('activate') === true || flags.get('run-hooks') === true
|
||||
'worktree create': async ({ flags, client, cwd, json }) => {
|
||||
const callerTerminalHandle =
|
||||
typeof process.env.ORCA_TERMINAL_HANDLE === 'string' &&
|
||||
process.env.ORCA_TERMINAL_HANDLE.length > 0
|
||||
? process.env.ORCA_TERMINAL_HANDLE
|
||||
: undefined
|
||||
const explicitParentWorktree = getOptionalStringFlag(flags, 'parent-worktree')
|
||||
const noParent = flags.get('no-parent') === true
|
||||
let parentWorktree = explicitParentWorktree
|
||||
if (!parentWorktree && !noParent && !callerTerminalHandle) {
|
||||
try {
|
||||
// Why: agent shells can lose ORCA_TERMINAL_HANDLE while still running
|
||||
// inside an Orca worktree. Cwd keeps CLI-created children nestable.
|
||||
parentWorktree = await resolveCurrentWorktreeSelector(cwd, client)
|
||||
} catch {
|
||||
parentWorktree = undefined
|
||||
}
|
||||
)
|
||||
}
|
||||
const result = await client.call<RuntimeWorktreeCreateResult>('worktree.create', {
|
||||
repo: getRequiredStringFlag(flags, 'repo'),
|
||||
name: getRequiredStringFlag(flags, 'name'),
|
||||
baseBranch: getOptionalStringFlag(flags, 'base-branch'),
|
||||
linkedIssue: getOptionalNumberFlag(flags, 'issue'),
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
runHooks: flags.get('run-hooks') === true,
|
||||
activate: flags.get('activate') === true || flags.get('run-hooks') === true,
|
||||
parentWorktree,
|
||||
noParent,
|
||||
callerTerminalHandle
|
||||
})
|
||||
printHookWarning(result.result, json)
|
||||
printLineageSummary(result.result, json)
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
},
|
||||
'worktree set': async ({ flags, client, cwd, json }) => {
|
||||
@@ -71,7 +114,9 @@ export const WORKTREE_HANDLERS: Record<string, CommandHandler> = {
|
||||
worktree: await getRequiredWorktreeSelector(flags, 'worktree', cwd, client),
|
||||
displayName: getOptionalStringFlag(flags, 'display-name'),
|
||||
linkedIssue: getOptionalNullableNumberFlag(flags, 'issue'),
|
||||
comment: getOptionalStringFlag(flags, 'comment')
|
||||
comment: getOptionalStringFlag(flags, 'comment'),
|
||||
parentWorktree: getOptionalStringFlag(flags, 'parent-worktree'),
|
||||
noParent: flags.get('no-parent') === true
|
||||
})
|
||||
printResult(result, json, formatWorktreeShow)
|
||||
},
|
||||
|
||||
+2
-2
@@ -140,10 +140,10 @@ Common Commands:
|
||||
orca open [--json]
|
||||
orca status [--json]
|
||||
orca worktree list [--repo <selector>] [--limit <n>] [--json]
|
||||
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]
|
||||
orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]
|
||||
orca worktree show --worktree <selector> [--json]
|
||||
orca worktree current [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]
|
||||
orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]
|
||||
orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]
|
||||
orca worktree ps [--limit <n>] [--json]
|
||||
orca terminal list [--worktree <selector>] [--limit <n>] [--json]
|
||||
|
||||
+40
-5
@@ -61,6 +61,7 @@ describe('orca cli worktree awareness', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
delete process.env.ORCA_TERMINAL_HANDLE
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -142,13 +143,16 @@ describe('orca cli worktree awareness', () => {
|
||||
worktree: `path:${path.resolve('/tmp/repo/feature')}`,
|
||||
displayName: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: 'hello'
|
||||
comment: 'hello',
|
||||
parentWorktree: undefined,
|
||||
noParent: false
|
||||
})
|
||||
})
|
||||
|
||||
it('passes explicit activation through worktree.create', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
okFixture('req_create', {
|
||||
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
|
||||
})
|
||||
@@ -160,20 +164,24 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
runHooks: false,
|
||||
activate: true
|
||||
activate: true,
|
||||
parentWorktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
noParent: false,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
})
|
||||
|
||||
it('opts into setup and activation when worktree.create runs hooks', async () => {
|
||||
queueFixtures(
|
||||
callMock,
|
||||
worktreeListFixture([buildWorktree('/tmp/repo', 'main', 'abc', 'repo-1')]),
|
||||
okFixture('req_create', {
|
||||
worktree: buildWorktree('/tmp/repo/feature', 'feature', 'abc', 'repo-1')
|
||||
})
|
||||
@@ -185,14 +193,17 @@ describe('orca cli worktree awareness', () => {
|
||||
'/tmp/repo'
|
||||
)
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('worktree.create', {
|
||||
expect(callMock).toHaveBeenNthCalledWith(2, 'worktree.create', {
|
||||
repo: 'id:repo-1',
|
||||
name: 'feature',
|
||||
baseBranch: undefined,
|
||||
linkedIssue: undefined,
|
||||
comment: undefined,
|
||||
runHooks: true,
|
||||
activate: true
|
||||
activate: true,
|
||||
parentWorktree: `path:${path.resolve('/tmp/repo')}`,
|
||||
noParent: false,
|
||||
callerTerminalHandle: undefined
|
||||
})
|
||||
})
|
||||
|
||||
@@ -308,6 +319,30 @@ describe('orca cli worktree awareness', () => {
|
||||
errSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('passes the caller terminal handle through orchestration task-create', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_creator'
|
||||
callMock.mockResolvedValueOnce({
|
||||
id: 'req_task_create',
|
||||
ok: true,
|
||||
result: {
|
||||
task: { id: 'task_1', status: 'ready' }
|
||||
},
|
||||
_meta: {
|
||||
runtimeId: 'runtime-1'
|
||||
}
|
||||
})
|
||||
vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['orchestration', 'task-create', '--spec', 'spawn child workspace'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('orchestration.taskCreate', {
|
||||
spec: 'spawn child workspace',
|
||||
deps: undefined,
|
||||
parent: undefined,
|
||||
callerTerminalHandle: 'term_creator'
|
||||
})
|
||||
})
|
||||
|
||||
it('passes dev mode to injected orchestration dispatches', async () => {
|
||||
process.env.ORCA_TERMINAL_HANDLE = 'term_sender'
|
||||
process.env.ORCA_USER_DATA_PATH = '/tmp/orca-dev'
|
||||
|
||||
+13
-3
@@ -72,7 +72,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'create'],
|
||||
summary: 'Create a new Orca-managed worktree',
|
||||
usage:
|
||||
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--run-hooks] [--activate] [--json]',
|
||||
'orca worktree create --repo <selector> --name <name> [--base-branch <ref>] [--issue <number>] [--comment <text>] [--parent-worktree <selector>] [--no-parent] [--run-hooks] [--activate] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'repo',
|
||||
@@ -80,6 +80,8 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
'base-branch',
|
||||
'issue',
|
||||
'comment',
|
||||
'parent-worktree',
|
||||
'no-parent',
|
||||
'run-hooks',
|
||||
'activate'
|
||||
],
|
||||
@@ -94,8 +96,16 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
path: ['worktree', 'set'],
|
||||
summary: 'Update Orca metadata for a worktree',
|
||||
usage:
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'display-name', 'issue', 'comment']
|
||||
'orca worktree set --worktree <selector> [--display-name <name>] [--issue <number|null>] [--comment <text>] [--parent-worktree <selector>|--no-parent] [--json]',
|
||||
allowedFlags: [
|
||||
...GLOBAL_FLAGS,
|
||||
'worktree',
|
||||
'display-name',
|
||||
'issue',
|
||||
'comment',
|
||||
'parent-worktree',
|
||||
'no-parent'
|
||||
]
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
|
||||
@@ -175,6 +175,7 @@ export function mergeWorktree(
|
||||
const branchShort = git.branch.replace(/^refs\/heads\//, '')
|
||||
return {
|
||||
id: `${repoId}::${git.path}`,
|
||||
...(meta?.instanceId !== undefined ? { instanceId: meta.instanceId } : {}),
|
||||
repoId,
|
||||
path: git.path,
|
||||
head: git.head,
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { join } from 'path'
|
||||
import { existsSync } from 'fs'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
CreateWorktreeArgs,
|
||||
@@ -311,6 +312,10 @@ export async function createRemoteWorktree(
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
// Why: path-derived worktree IDs can be reused after external deletion.
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
// attach to the new occupant of the same path.
|
||||
instanceId: randomUUID(),
|
||||
lastActivityAt: now,
|
||||
// Why: grants the new worktree a short grace window at the top of the
|
||||
// Recent sort. During worktree creation (git fetch + add can take several
|
||||
@@ -594,6 +599,10 @@ export async function createLocalWorktree(
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const metaUpdates: Partial<WorktreeMeta> = {
|
||||
// Why: path-derived worktree IDs can be reused after external deletion.
|
||||
// Fresh creations must rotate instance identity so stale lineage cannot
|
||||
// attach to the new occupant of the same path.
|
||||
instanceId: randomUUID(),
|
||||
// Stamp activity so the worktree sorts into its final position
|
||||
// immediately — prevents scroll-to-reveal racing with a later
|
||||
// bumpWorktreeActivity that would re-sort the list.
|
||||
|
||||
@@ -154,7 +154,9 @@ describe('registerWorktreeHandlers', () => {
|
||||
getSettings: vi.fn(),
|
||||
getWorktreeMeta: vi.fn(),
|
||||
setWorktreeMeta: vi.fn(),
|
||||
removeWorktreeMeta: vi.fn()
|
||||
removeWorktreeMeta: vi.fn(),
|
||||
getAllWorktreeLineage: vi.fn(),
|
||||
removeWorktreeLineage: vi.fn()
|
||||
}
|
||||
let runtimeStub: {
|
||||
resolveRemoteTrackingBase: ReturnType<typeof vi.fn>
|
||||
@@ -204,6 +206,8 @@ describe('registerWorktreeHandlers', () => {
|
||||
store.getWorktreeMeta,
|
||||
store.setWorktreeMeta,
|
||||
store.removeWorktreeMeta,
|
||||
store.getAllWorktreeLineage,
|
||||
store.removeWorktreeLineage,
|
||||
killAllProcessesForWorktreeMock,
|
||||
getLocalPtyProviderMock
|
||||
]) {
|
||||
@@ -242,6 +246,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
store.getAllWorktreeLineage.mockReturnValue({})
|
||||
getGitUsernameMock.mockReturnValue('')
|
||||
getDefaultBaseRefMock.mockReturnValue('origin/main')
|
||||
getDefaultRemoteMock.mockResolvedValue('origin')
|
||||
@@ -586,6 +591,104 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/live',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/live',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/remote/live-child',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/live-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
'repo-ssh::/remote/missing-child': {
|
||||
parentWorktreeId: 'repo-ssh::/remote/live'
|
||||
},
|
||||
'repo-ssh::/remote/live-child': {
|
||||
parentWorktreeId: 'repo-ssh::/remote/missing-parent',
|
||||
parentWorktreeInstanceId: 'old-parent-instance'
|
||||
},
|
||||
'repo-ssh::/remote/live': {
|
||||
parentWorktreeId: 'other-repo::/elsewhere'
|
||||
}
|
||||
})
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) =>
|
||||
worktreeId === 'repo-ssh::/remote/missing-parent'
|
||||
? { instanceId: 'old-parent-instance' }
|
||||
: undefined
|
||||
)
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||||
|
||||
expect(store.removeWorktreeLineage).toHaveBeenCalledWith('repo-ssh::/remote/missing-child')
|
||||
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live-child')
|
||||
expect(store.removeWorktreeLineage).not.toHaveBeenCalledWith('repo-ssh::/remote/live')
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-ssh::/remote/missing-parent',
|
||||
expect.objectContaining({ instanceId: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not repeatedly rotate already-invalid missing parent metadata', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1'
|
||||
}
|
||||
const provider = {
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/remote/live-child',
|
||||
head: 'def456',
|
||||
branch: 'refs/heads/live-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
store.getAllWorktreeLineage.mockReturnValue({
|
||||
'repo-ssh::/remote/live-child': {
|
||||
parentWorktreeId: 'repo-ssh::/remote/missing-parent',
|
||||
parentWorktreeInstanceId: 'old-parent-instance'
|
||||
}
|
||||
})
|
||||
store.getWorktreeMeta.mockReturnValue({ instanceId: 'rotated-parent-instance' })
|
||||
|
||||
await handlers['worktrees:list'](null, { repoId: 'repo-ssh' })
|
||||
|
||||
expect(store.setWorktreeMeta).not.toHaveBeenCalledWith(
|
||||
'repo-ssh::/remote/missing-parent',
|
||||
expect.objectContaining({ instanceId: expect.any(String) })
|
||||
)
|
||||
})
|
||||
|
||||
it('does not await a cold fetch when the remote-tracking base exists locally', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
@@ -777,6 +880,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
instanceId: 'existing-instance',
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
@@ -793,6 +897,44 @@ describe('registerWorktreeHandlers', () => {
|
||||
expect(listed[0].lastActivityAt).toBe(42)
|
||||
})
|
||||
|
||||
it('backfills instanceId on discovery for persisted metadata from older profiles', async () => {
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/existing-wt',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/feature',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
store.getWorktreeMeta.mockReturnValue({
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 42
|
||||
})
|
||||
store.setWorktreeMeta.mockReturnValue({
|
||||
instanceId: 'new-instance',
|
||||
lastActivityAt: 42
|
||||
})
|
||||
|
||||
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
|
||||
id: string
|
||||
instanceId?: string
|
||||
}[]
|
||||
|
||||
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
|
||||
'repo-1::/workspace/existing-wt',
|
||||
expect.objectContaining({ instanceId: expect.any(String) })
|
||||
)
|
||||
expect(listed[0].instanceId).toBe('new-instance')
|
||||
})
|
||||
|
||||
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
|
||||
// Why: folder repos produce a synthetic worktree that flows through the
|
||||
// same list path. Without the stamp, adding a folder puts its card at the
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { BrowserWindow } from 'electron'
|
||||
import { ipcMain } from 'electron'
|
||||
import { rm } from 'fs/promises'
|
||||
import { randomUUID } from 'crypto'
|
||||
import type { Store } from '../persistence'
|
||||
import { isFolderRepo } from '../../shared/repo-kind'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
@@ -61,6 +62,12 @@ import { classifyWorkspaceCreateError } from './workspace-create-error-classifie
|
||||
function resolveWorktreeMetaWithDiscoveryStamp(store: Store, worktreeId: string): WorktreeMeta {
|
||||
const existing = store.getWorktreeMeta(worktreeId)
|
||||
if (existing) {
|
||||
if (!existing.instanceId) {
|
||||
// Why: profiles created before lineage shipped already have WorktreeMeta
|
||||
// rows. Backfill on authoritative discovery so upgraded workspaces can
|
||||
// immediately participate in instance-validated lineage.
|
||||
return store.setWorktreeMeta(worktreeId, { instanceId: randomUUID() })
|
||||
}
|
||||
return existing
|
||||
}
|
||||
return store.setWorktreeMeta(worktreeId, { lastActivityAt: Date.now() })
|
||||
@@ -98,6 +105,40 @@ function rememberLocalWorktreeRoots(
|
||||
])
|
||||
}
|
||||
|
||||
function pruneLineageForMissingRepoWorktrees(
|
||||
store: Store,
|
||||
repo: Repo,
|
||||
gitWorktrees: GitWorktreeInfo[]
|
||||
): void {
|
||||
if (
|
||||
typeof store.getAllWorktreeLineage !== 'function' ||
|
||||
typeof store.removeWorktreeLineage !== 'function'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`))
|
||||
const repoPrefix = `${repo.id}::`
|
||||
for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
|
||||
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
|
||||
// Why: path-derived IDs can disappear and later be reused by a different
|
||||
// checkout. Once a successful scan proves the child is gone, drop its
|
||||
// lineage so a future same-path worktree cannot inherit it. Missing
|
||||
// parents stay readable so the UI can show the repairable "Missing
|
||||
// parent" state.
|
||||
store.removeWorktreeLineage(childId)
|
||||
}
|
||||
if (lineage.parentWorktreeId.startsWith(repoPrefix) && !liveIds.has(lineage.parentWorktreeId)) {
|
||||
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
|
||||
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
|
||||
// Why: keep the child lineage so the UI can show "Missing parent", but
|
||||
// rotate the absent parent's stale identity once. If a different
|
||||
// checkout later reuses that path, the old lineage stays invalid.
|
||||
store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function registerWorktreeHandlers(
|
||||
mainWindow: BrowserWindow,
|
||||
store: Store,
|
||||
@@ -111,6 +152,8 @@ export function registerWorktreeHandlers(
|
||||
ipcMain.removeHandler('worktrees:resolvePrBase')
|
||||
ipcMain.removeHandler('worktrees:remove')
|
||||
ipcMain.removeHandler('worktrees:updateMeta')
|
||||
ipcMain.removeHandler('worktrees:listLineage')
|
||||
ipcMain.removeHandler('worktrees:updateLineage')
|
||||
ipcMain.removeHandler('worktrees:persistSortOrder')
|
||||
ipcMain.removeHandler('hooks:check')
|
||||
ipcMain.removeHandler('hooks:createIssueCommandRunner')
|
||||
@@ -144,6 +187,7 @@ export function registerWorktreeHandlers(
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
@@ -204,6 +248,7 @@ export function registerWorktreeHandlers(
|
||||
gitWorktrees = await listRepoWorktrees(repo)
|
||||
}
|
||||
rememberLocalWorktreeRoots(store, repo, gitWorktrees)
|
||||
pruneLineageForMissingRepoWorktrees(store, repo, gitWorktrees)
|
||||
loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`)
|
||||
return gitWorktrees.map((gw) => {
|
||||
const worktreeId = `${repo.id}::${gw.path}`
|
||||
@@ -620,6 +665,27 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle('worktrees:listLineage', async () => {
|
||||
await runtime.hydrateInferredWorktreeLineage()
|
||||
return store.getAllWorktreeLineage()
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:updateLineage',
|
||||
async (_event, args: { worktreeId: string; parentWorktreeId?: string; noParent?: boolean }) => {
|
||||
await runtime.updateManagedWorktreeMeta(args.worktreeId, {
|
||||
lineage:
|
||||
args.noParent === true
|
||||
? { noParent: true }
|
||||
: args.parentWorktreeId
|
||||
? { parentWorktree: `id:${args.parentWorktreeId}` }
|
||||
: undefined
|
||||
})
|
||||
notifyWorktreesChanged(mainWindow, parseWorktreeId(args.worktreeId).repoId)
|
||||
return store.getWorktreeLineage(args.worktreeId) ?? null
|
||||
}
|
||||
)
|
||||
|
||||
// Why: the renderer continuously snapshots the computed sidebar order into
|
||||
// sortOrder so that it can be restored on cold start (when ephemeral signals
|
||||
// like running jobs and live terminals are gone). A single batch call avoids
|
||||
|
||||
@@ -33,6 +33,7 @@ import type {
|
||||
Repo,
|
||||
SparsePreset,
|
||||
WorktreeMeta,
|
||||
WorktreeLineage,
|
||||
GlobalSettings,
|
||||
OnboardingChecklistState,
|
||||
OnboardingOutcome,
|
||||
@@ -411,6 +412,7 @@ export class Store {
|
||||
result = {
|
||||
...defaults,
|
||||
...parsed,
|
||||
worktreeLineageById: parsed.worktreeLineageById ?? {},
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
...parsed.settings,
|
||||
@@ -863,6 +865,11 @@ export class Store {
|
||||
delete this.state.worktreeMeta[key]
|
||||
}
|
||||
}
|
||||
for (const [childId, lineage] of Object.entries(this.state.worktreeLineageById)) {
|
||||
if (childId.startsWith(prefix) || lineage.parentWorktreeId.startsWith(prefix)) {
|
||||
delete this.state.worktreeLineageById[childId]
|
||||
}
|
||||
}
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
@@ -1143,6 +1150,9 @@ export class Store {
|
||||
setWorktreeMeta(worktreeId: string, meta: Partial<WorktreeMeta>): WorktreeMeta {
|
||||
const existing = this.state.worktreeMeta[worktreeId] || getDefaultWorktreeMeta()
|
||||
const updated = { ...existing, ...meta }
|
||||
if (!updated.instanceId) {
|
||||
updated.instanceId = randomUUID()
|
||||
}
|
||||
this.state.worktreeMeta[worktreeId] = updated
|
||||
this.scheduleSave()
|
||||
return updated
|
||||
@@ -1150,6 +1160,26 @@ export class Store {
|
||||
|
||||
removeWorktreeMeta(worktreeId: string): void {
|
||||
delete this.state.worktreeMeta[worktreeId]
|
||||
delete this.state.worktreeLineageById[worktreeId]
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
getWorktreeLineage(worktreeId: string): WorktreeLineage | undefined {
|
||||
return this.state.worktreeLineageById[worktreeId]
|
||||
}
|
||||
|
||||
getAllWorktreeLineage(): Record<string, WorktreeLineage> {
|
||||
return this.state.worktreeLineageById
|
||||
}
|
||||
|
||||
setWorktreeLineage(worktreeId: string, lineage: WorktreeLineage): WorktreeLineage {
|
||||
this.state.worktreeLineageById[worktreeId] = lineage
|
||||
this.scheduleSave()
|
||||
return lineage
|
||||
}
|
||||
|
||||
removeWorktreeLineage(worktreeId: string): void {
|
||||
delete this.state.worktreeLineageById[worktreeId]
|
||||
this.scheduleSave()
|
||||
}
|
||||
|
||||
@@ -1695,6 +1725,7 @@ export class Store {
|
||||
|
||||
function getDefaultWorktreeMeta(): WorktreeMeta {
|
||||
return {
|
||||
instanceId: randomUUID(),
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/* eslint-disable max-lines -- Why: runtime behavior is stateful and cross-cutting, so these tests stay in one file to preserve the end-to-end invariants around handles, waits, and graph sync. */
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { WorktreeMeta } from '../../shared/types'
|
||||
import type { WorktreeLineage, WorktreeMeta } from '../../shared/types'
|
||||
import { addWorktree, listWorktrees, removeWorktree } from '../git/worktree'
|
||||
import {
|
||||
createSetupRunnerScript,
|
||||
@@ -30,6 +30,7 @@ const {
|
||||
removeWorktreeMock,
|
||||
computeWorktreePathMock,
|
||||
ensurePathWithinWorkspaceMock,
|
||||
getSshGitProviderMock,
|
||||
invalidateAuthorizedRootsCacheMock
|
||||
} = vi.hoisted(() => ({
|
||||
MOCK_GIT_WORKTREES: [
|
||||
@@ -45,6 +46,7 @@ const {
|
||||
removeWorktreeMock: vi.fn(),
|
||||
computeWorktreePathMock: vi.fn(),
|
||||
ensurePathWithinWorkspaceMock: vi.fn(),
|
||||
getSshGitProviderMock: vi.fn(),
|
||||
invalidateAuthorizedRootsCacheMock: vi.fn()
|
||||
}))
|
||||
|
||||
@@ -54,6 +56,10 @@ vi.mock('../git/worktree', () => ({
|
||||
removeWorktree: removeWorktreeMock
|
||||
}))
|
||||
|
||||
vi.mock('../providers/ssh-git-dispatch', () => ({
|
||||
getSshGitProvider: getSshGitProviderMock
|
||||
}))
|
||||
|
||||
vi.mock('../hooks', () => ({
|
||||
createSetupRunnerScript: vi.fn(),
|
||||
getEffectiveHooks: vi.fn().mockReturnValue(null),
|
||||
@@ -97,6 +103,7 @@ afterEach(() => {
|
||||
vi.mocked(listWorktrees).mockResolvedValue(MOCK_GIT_WORKTREES)
|
||||
vi.mocked(addWorktree).mockReset()
|
||||
vi.mocked(removeWorktree).mockReset()
|
||||
getSshGitProviderMock.mockReset()
|
||||
vi.mocked(createSetupRunnerScript).mockReset()
|
||||
vi.mocked(getEffectiveHooks).mockReset()
|
||||
vi.mocked(runHook).mockReset()
|
||||
@@ -143,6 +150,24 @@ function createRuntime(): OrcaRuntimeService {
|
||||
return new OrcaRuntimeService(store)
|
||||
}
|
||||
|
||||
function makeWorktreeMeta(overrides: Partial<WorktreeMeta> = {}): WorktreeMeta {
|
||||
return {
|
||||
displayName: '',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
const store = {
|
||||
getRepo: (id: string) => store.getRepos().find((repo) => repo.id === id),
|
||||
getRepos: () => [
|
||||
@@ -1492,6 +1517,772 @@ describe('OrcaRuntimeService', () => {
|
||||
await expect(runtime.searchRepoRefs('id:repo-1', 'main', -5)).rejects.toThrow('invalid_limit')
|
||||
})
|
||||
|
||||
it('resolves SSH worktrees when manually updating lineage', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'remote-repo',
|
||||
path: '/home/user/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const childId = `${remoteRepo.id}::/home/user/repo-child`
|
||||
const parentId = `${remoteRepo.id}::/home/user/repo-parent`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' }),
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' })
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage)
|
||||
const listSshWorktrees = vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: '/home/user/repo-child',
|
||||
head: 'abc',
|
||||
branch: 'feature/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: '/home/user/repo-parent',
|
||||
head: 'def',
|
||||
branch: 'feature/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
getSshGitProviderMock.mockReturnValue({ listWorktrees: listSshWorktrees })
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
|
||||
getRepos: () => [remoteRepo],
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
|
||||
expect(listSshWorktrees).toHaveBeenCalledWith(remoteRepo.path)
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('ignores stale instance-mismatched lineage when validating manual cycle repairs', async () => {
|
||||
const parentPath = '/tmp/worktree-a'
|
||||
const childPath = '/tmp/worktree-b'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'new-parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId, lineage) => lineage)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: (worktreeId: string) =>
|
||||
worktreeId === parentId
|
||||
? {
|
||||
worktreeId: parentId,
|
||||
worktreeInstanceId: 'old-parent-instance',
|
||||
parentWorktreeId: childId,
|
||||
parentWorktreeInstanceId: 'child-instance',
|
||||
origin: 'manual' as const,
|
||||
capture: { source: 'manual-action' as const, confidence: 'explicit' as const },
|
||||
createdAt: 1
|
||||
}
|
||||
: undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'abc',
|
||||
branch: 'feature/a',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'feature/b',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'new-parent-instance'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('backfills instanceId during runtime selector resolution for upgraded metadata', async () => {
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta(),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = {
|
||||
...metaById[worktreeId],
|
||||
...meta,
|
||||
instanceId: meta.instanceId ?? metaById[worktreeId]?.instanceId ?? 'backfilled-instance'
|
||||
}
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage: vi.fn((_worktreeId: string, lineage) => lineage)
|
||||
}
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'abc',
|
||||
branch: 'feature/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'feature/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
|
||||
expect(runtimeStore.setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeInstanceId: 'backfilled-instance'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('rotates a missing parent instance during runtime selector scans before same-path reuse', async () => {
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'old-parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'old-parent-instance',
|
||||
origin: 'manual' as const,
|
||||
capture: { source: 'manual-action' as const, confidence: 'explicit' as const },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: (worktreeId: string) => lineageById[worktreeId],
|
||||
getAllWorktreeLineage: () => lineageById,
|
||||
removeWorktreeLineage: vi.fn((worktreeId: string) => {
|
||||
delete lineageById[worktreeId]
|
||||
}),
|
||||
setWorktreeLineage
|
||||
}
|
||||
vi.mocked(listWorktrees)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'feature/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValue([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'feature/child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
},
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'abc',
|
||||
branch: 'feature/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await runtime.showManagedWorktree(`id:${childId}`)
|
||||
const rotatedParentInstance = metaById[parentId].instanceId
|
||||
expect(rotatedParentInstance).toBeTruthy()
|
||||
expect(rotatedParentInstance).not.toBe('old-parent-instance')
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'rescanned' })
|
||||
expect(metaById[parentId].instanceId).toBe(rotatedParentInstance)
|
||||
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, { comment: 'touch' })
|
||||
await runtime.updateManagedWorktreeMeta(`id:${childId}`, {
|
||||
lineage: { parentWorktree: `id:${parentId}` }
|
||||
})
|
||||
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeInstanceId: rotatedParentInstance
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not prune lineage when a runtime local worktree scan fails', async () => {
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const childPath = '/tmp/worktree-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const removeWorktreeLineage = vi.fn((worktreeId: string) => {
|
||||
delete lineageById[worktreeId]
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
}),
|
||||
getAllWorktreeLineage: () => lineageById,
|
||||
removeWorktreeLineage
|
||||
}
|
||||
vi.mocked(listWorktrees).mockRejectedValueOnce(new Error('git unavailable'))
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found')
|
||||
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(lineageById[childId]).toBeTruthy()
|
||||
expect(metaById[parentId].instanceId).toBe('parent-instance')
|
||||
})
|
||||
|
||||
it('does not prune lineage when an SSH runtime provider is unavailable', async () => {
|
||||
const remoteRepo = {
|
||||
id: 'remote-repo',
|
||||
path: '/home/user/repo',
|
||||
displayName: 'remote',
|
||||
badgeColor: 'blue',
|
||||
addedAt: 1,
|
||||
connectionId: 'ssh-1'
|
||||
}
|
||||
const parentId = `${remoteRepo.id}::/home/user/repo-parent`
|
||||
const childId = `${remoteRepo.id}::/home/user/repo-child`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({ instanceId: 'parent-instance' }),
|
||||
[childId]: makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
}
|
||||
const lineageById: Record<string, WorktreeLineage> = {
|
||||
[childId]: {
|
||||
worktreeId: childId,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parentId,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: 1
|
||||
}
|
||||
}
|
||||
const removeWorktreeLineage = vi.fn((worktreeId: string) => {
|
||||
delete lineageById[worktreeId]
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getRepo: (id: string) => (id === remoteRepo.id ? remoteRepo : undefined),
|
||||
getRepos: () => [remoteRepo],
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: vi.fn((worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
metaById[worktreeId] = { ...metaById[worktreeId], ...meta }
|
||||
return metaById[worktreeId]
|
||||
}),
|
||||
getAllWorktreeLineage: () => lineageById,
|
||||
removeWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
|
||||
await expect(runtime.showManagedWorktree(`id:${childId}`)).rejects.toThrow('selector_not_found')
|
||||
|
||||
expect(removeWorktreeLineage).not.toHaveBeenCalled()
|
||||
expect(runtimeStore.setWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(lineageById[childId]).toBeTruthy()
|
||||
expect(metaById[parentId].instanceId).toBe('parent-instance')
|
||||
})
|
||||
|
||||
it('keeps valid orchestration lineage when caller terminal context is stale', async () => {
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const childPath = '/tmp/workspaces/worker-child'
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
displayName: 'coordinator'
|
||||
})
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((worktreeId: string, lineage) => {
|
||||
metaById[worktreeId] = metaById[worktreeId] ?? makeWorktreeMeta()
|
||||
return lineage
|
||||
})
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
const existing = metaById[worktreeId] ?? makeWorktreeMeta({ instanceId: 'child-instance' })
|
||||
metaById[worktreeId] = { ...existing, ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
computeWorktreePathMock.mockReturnValue(childPath)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(childPath)
|
||||
vi.mocked(listWorktrees)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'abc',
|
||||
branch: 'feature/coordinator',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'worker-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'worker-child',
|
||||
lineage: {
|
||||
callerTerminalHandle: 'term_stale',
|
||||
orchestrationContext: {
|
||||
parentWorktreeId: parentId,
|
||||
orchestrationRunId: 'run-1',
|
||||
taskId: 'task-1',
|
||||
coordinatorHandle: 'term_coord'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
expect(result.lineage).toMatchObject({
|
||||
worktreeId: childId,
|
||||
parentWorktreeId: parentId,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||||
orchestrationRunId: 'run-1',
|
||||
taskId: 'task-1',
|
||||
coordinatorHandle: 'term_coord'
|
||||
})
|
||||
expect(result.lineage).not.toHaveProperty('createdByTerminalHandle')
|
||||
expect(result.warnings).toEqual([])
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(childId, expect.any(Object))
|
||||
})
|
||||
|
||||
it('enriches caller-terminal lineage with active orchestration dispatch context', async () => {
|
||||
const workerPath = '/tmp/worktree-worker'
|
||||
const childPath = '/tmp/workspaces/worker-child'
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const workerId = `${TEST_REPO_ID}::${workerPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[TEST_WORKTREE_ID]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
displayName: 'coordinator'
|
||||
}),
|
||||
[workerId]: makeWorktreeMeta({
|
||||
instanceId: 'worker-instance',
|
||||
displayName: 'worker'
|
||||
})
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
const existing = metaById[worktreeId] ?? makeWorktreeMeta()
|
||||
metaById[worktreeId] = { ...existing, ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
|
||||
const coordinatorHandle = runtime.preAllocateHandleForPty('pty-coordinator')
|
||||
runtime.setOrchestrationDb({
|
||||
getActiveDispatchForTerminal: vi.fn(() => ({
|
||||
task_id: 'task-1'
|
||||
})),
|
||||
getActiveCoordinatorRun: vi.fn(() => ({
|
||||
id: 'run-1',
|
||||
coordinator_handle: coordinatorHandle
|
||||
}))
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: workerId,
|
||||
title: 'Worker',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
},
|
||||
{
|
||||
tabId: 'tab-coordinator',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Coordinator',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: workerId,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-worker',
|
||||
paneTitle: null
|
||||
},
|
||||
{
|
||||
tabId: 'tab-coordinator',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 2,
|
||||
ptyId: 'pty-coordinator',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
computeWorktreePathMock.mockReturnValue(childPath)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(childPath)
|
||||
vi.mocked(listWorktrees)
|
||||
.mockResolvedValueOnce([
|
||||
...MOCK_GIT_WORKTREES,
|
||||
{
|
||||
path: workerPath,
|
||||
head: 'fed',
|
||||
branch: 'feature/worker',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'worker-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'worker-child',
|
||||
lineage: { callerTerminalHandle: workerHandle }
|
||||
})
|
||||
|
||||
expect(result.lineage).toMatchObject({
|
||||
worktreeId: childId,
|
||||
parentWorktreeId: workerId,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||||
orchestrationRunId: 'run-1',
|
||||
taskId: 'task-1',
|
||||
coordinatorHandle,
|
||||
createdByTerminalHandle: workerHandle
|
||||
})
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
worktreeInstanceId: expect.not.stringMatching(/^old-/),
|
||||
parentWorktreeInstanceId: 'worker-instance'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('infers orchestration lineage from task-id comments when dispatch is completed', async () => {
|
||||
const workerPath = '/tmp/worktree-worker'
|
||||
const childPath = '/tmp/workspaces/worker-child'
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const workerId = `${TEST_REPO_ID}::${workerPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[workerId]: makeWorktreeMeta({
|
||||
instanceId: 'worker-instance',
|
||||
displayName: 'worker'
|
||||
})
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
const existing = metaById[worktreeId] ?? makeWorktreeMeta()
|
||||
metaById[worktreeId] = { ...existing, ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const workerHandle = runtime.preAllocateHandleForPty('pty-worker')
|
||||
runtime.setOrchestrationDb({
|
||||
getDispatchContext: vi.fn(() => ({
|
||||
task_id: 'task_abc123',
|
||||
assignee_handle: workerHandle,
|
||||
status: 'completed'
|
||||
}))
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: workerId,
|
||||
title: 'Worker',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-worker',
|
||||
worktreeId: workerId,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-worker',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
computeWorktreePathMock.mockReturnValue(childPath)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(childPath)
|
||||
vi.mocked(listWorktrees)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: workerPath,
|
||||
head: 'fed',
|
||||
branch: 'feature/worker',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'worker-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'worker-child',
|
||||
comment: 'Created via orchestration task task_abc123'
|
||||
})
|
||||
|
||||
expect(result.lineage).toMatchObject({
|
||||
worktreeId: childId,
|
||||
parentWorktreeId: workerId,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||||
taskId: 'task_abc123'
|
||||
})
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
parentWorktreeInstanceId: 'worker-instance'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('infers orchestration lineage from task creator when no dispatch context exists', async () => {
|
||||
const parentPath = '/tmp/worktree-parent'
|
||||
const childPath = '/tmp/workspaces/parent-child'
|
||||
const childId = `${TEST_REPO_ID}::${childPath}`
|
||||
const parentId = `${TEST_REPO_ID}::${parentPath}`
|
||||
const metaById: Record<string, WorktreeMeta> = {
|
||||
[parentId]: makeWorktreeMeta({
|
||||
instanceId: 'parent-instance',
|
||||
displayName: 'parent'
|
||||
})
|
||||
}
|
||||
const setWorktreeLineage = vi.fn((_worktreeId: string, lineage) => lineage)
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getAllWorktreeMeta: () => metaById,
|
||||
getWorktreeMeta: (worktreeId: string) => metaById[worktreeId],
|
||||
setWorktreeMeta: (worktreeId: string, meta: Partial<WorktreeMeta>) => {
|
||||
const existing = metaById[worktreeId] ?? makeWorktreeMeta()
|
||||
metaById[worktreeId] = { ...existing, ...meta }
|
||||
return metaById[worktreeId]
|
||||
},
|
||||
getWorktreeLineage: () => undefined,
|
||||
setWorktreeLineage
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const parentHandle = runtime.preAllocateHandleForPty('pty-parent')
|
||||
runtime.setOrchestrationDb({
|
||||
getDispatchContext: vi.fn(() => undefined),
|
||||
getTask: vi.fn(() => ({
|
||||
id: 'task_creator123',
|
||||
created_by_terminal_handle: parentHandle
|
||||
}))
|
||||
} as never)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-parent',
|
||||
worktreeId: parentId,
|
||||
title: 'Parent',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-parent',
|
||||
worktreeId: parentId,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-parent',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
computeWorktreePathMock.mockReturnValue(childPath)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(childPath)
|
||||
vi.mocked(listWorktrees)
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: parentPath,
|
||||
head: 'fed',
|
||||
branch: 'feature/parent',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
path: childPath,
|
||||
head: 'def',
|
||||
branch: 'parent-child',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'parent-child',
|
||||
comment: 'Created via orchestration task task_creator123'
|
||||
})
|
||||
|
||||
expect(result.lineage).toMatchObject({
|
||||
worktreeId: childId,
|
||||
parentWorktreeId: parentId,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||||
taskId: 'task_creator123'
|
||||
})
|
||||
expect(setWorktreeLineage).toHaveBeenCalledWith(
|
||||
childId,
|
||||
expect.objectContaining({
|
||||
parentWorktreeInstanceId: 'parent-instance'
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('returns a setup launch payload for CLI-created worktrees when hooks are explicitly enabled', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
|
||||
@@ -16,9 +16,12 @@ import { OrchestrationDb } from './orchestration/db'
|
||||
import { formatMessagesForInjection } from './orchestration/formatter'
|
||||
import type {
|
||||
CreateWorktreeResult,
|
||||
GitWorktreeInfo,
|
||||
GlobalSettings,
|
||||
Repo,
|
||||
StatsSummary,
|
||||
WorktreeLineage,
|
||||
WorktreeLineageWarning,
|
||||
WorktreeBaseStatusEvent,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeStartupLaunch
|
||||
@@ -138,6 +141,7 @@ import {
|
||||
} from '../hooks'
|
||||
import { REPO_COLORS, getDefaultVoiceSettings } from '../../shared/constants'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
import type { Store } from '../persistence'
|
||||
import type { StatsCollector } from '../stats/collector'
|
||||
import { AgentDetector } from '../stats/agent-detector'
|
||||
@@ -214,6 +218,10 @@ type RuntimeStore = {
|
||||
getWorktreeMeta: Store['getWorktreeMeta']
|
||||
setWorktreeMeta: Store['setWorktreeMeta']
|
||||
removeWorktreeMeta: Store['removeWorktreeMeta']
|
||||
getWorktreeLineage?: Store['getWorktreeLineage']
|
||||
getAllWorktreeLineage?: Store['getAllWorktreeLineage']
|
||||
setWorktreeLineage?: Store['setWorktreeLineage']
|
||||
removeWorktreeLineage?: Store['removeWorktreeLineage']
|
||||
getGitHubCache: Store['getGitHubCache']
|
||||
getWorkspaceSession?: Store['getWorkspaceSession']
|
||||
getSettings(): {
|
||||
@@ -405,6 +413,7 @@ type MessageWaiter = {
|
||||
|
||||
type ResolvedWorktree = {
|
||||
id: string
|
||||
instanceId?: string
|
||||
repoId: string
|
||||
path: string
|
||||
branch: string
|
||||
@@ -420,6 +429,62 @@ type ResolvedWorktree = {
|
||||
comment: string
|
||||
}
|
||||
|
||||
type WorktreeLineageInput = {
|
||||
parentWorktree?: string
|
||||
noParent?: boolean
|
||||
callerTerminalHandle?: string
|
||||
comment?: string
|
||||
orchestrationContext?: {
|
||||
parentWorktreeId?: string
|
||||
orchestrationRunId?: string
|
||||
taskId?: string
|
||||
coordinatorHandle?: string
|
||||
}
|
||||
}
|
||||
|
||||
type WorktreeLineageResolution =
|
||||
| {
|
||||
kind: 'lineage'
|
||||
parent: ResolvedWorktree
|
||||
origin: WorktreeLineage['origin']
|
||||
capture: WorktreeLineage['capture']
|
||||
orchestrationRunId?: string
|
||||
taskId?: string
|
||||
coordinatorHandle?: string
|
||||
createdByTerminalHandle?: string
|
||||
}
|
||||
| {
|
||||
kind: 'none'
|
||||
warnings: WorktreeLineageWarning[]
|
||||
}
|
||||
|
||||
type RuntimeWorktreeScanResult =
|
||||
| { ok: true; worktrees: GitWorktreeInfo[] }
|
||||
| { ok: false; worktrees: [] }
|
||||
|
||||
type WorktreeLineageCandidate = {
|
||||
source: 'terminal-context' | 'orchestration-context'
|
||||
parent: ResolvedWorktree
|
||||
orchestrationRunId?: string
|
||||
taskId?: string
|
||||
coordinatorHandle?: string
|
||||
}
|
||||
|
||||
function extractOrchestrationTaskId(text?: string): string | undefined {
|
||||
return text?.match(/\btask_[A-Za-z0-9]+\b/)?.[0]
|
||||
}
|
||||
|
||||
class RuntimeLineageError extends Error {
|
||||
code: string
|
||||
data?: unknown
|
||||
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
type BrowserCommandTargetParams = {
|
||||
worktree?: string
|
||||
page?: string
|
||||
@@ -4132,6 +4197,7 @@ export class OrcaRuntimeService {
|
||||
activate?: boolean
|
||||
setupDecision?: 'run' | 'skip' | 'inherit'
|
||||
startup?: WorktreeStartupLaunch
|
||||
lineage?: WorktreeLineageInput
|
||||
}): Promise<CreateWorktreeResult> {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
@@ -4141,6 +4207,9 @@ export class OrcaRuntimeService {
|
||||
if (isFolderRepo(repo)) {
|
||||
throw new Error('Folder mode does not support creating worktrees.')
|
||||
}
|
||||
const lineageInput =
|
||||
args.lineage || args.comment ? { ...args.lineage, comment: args.comment } : undefined
|
||||
const lineageResolution = await this.resolveLineageForWorktreeCreate(lineageInput)
|
||||
const settings = this.store.getSettings()
|
||||
const requestedName = args.name
|
||||
const sanitizedName = sanitizeWorktreeName(args.name)
|
||||
@@ -4216,6 +4285,10 @@ export class OrcaRuntimeService {
|
||||
const worktreeId = `${repo.id}::${created.path}`
|
||||
const now = Date.now()
|
||||
const meta = this.store.setWorktreeMeta(worktreeId, {
|
||||
// Why: worktree IDs are path-derived. If a path is deleted outside Orca
|
||||
// and later recreated, creation must mint a fresh instance identity so
|
||||
// stale lineage records tied to the old occupant fail validation.
|
||||
instanceId: randomUUID(),
|
||||
lastActivityAt: now,
|
||||
// See createRemoteWorktree: createdAt grants the new worktree a grace
|
||||
// window in Recent sort so ambient PTY bumps in OTHER worktrees can't
|
||||
@@ -4230,6 +4303,44 @@ export class OrcaRuntimeService {
|
||||
...(args.comment !== undefined ? { comment: args.comment } : {})
|
||||
})
|
||||
const worktree = mergeWorktree(repo.id, created, meta)
|
||||
let lineage: WorktreeLineage | null = null
|
||||
const lineageWarnings = lineageResolution.kind === 'none' ? [...lineageResolution.warnings] : []
|
||||
if (lineageResolution.kind === 'lineage') {
|
||||
const childInstanceId = meta.instanceId
|
||||
const parentInstanceId = lineageResolution.parent.instanceId
|
||||
if (childInstanceId && parentInstanceId && this.store.setWorktreeLineage) {
|
||||
lineage = this.store.setWorktreeLineage(worktreeId, {
|
||||
worktreeId,
|
||||
worktreeInstanceId: childInstanceId,
|
||||
parentWorktreeId: lineageResolution.parent.id,
|
||||
parentWorktreeInstanceId: parentInstanceId,
|
||||
origin: lineageResolution.origin,
|
||||
capture: lineageResolution.capture,
|
||||
...(lineageResolution.orchestrationRunId
|
||||
? { orchestrationRunId: lineageResolution.orchestrationRunId }
|
||||
: {}),
|
||||
...(lineageResolution.taskId ? { taskId: lineageResolution.taskId } : {}),
|
||||
...(lineageResolution.coordinatorHandle
|
||||
? { coordinatorHandle: lineageResolution.coordinatorHandle }
|
||||
: {}),
|
||||
...(lineageResolution.createdByTerminalHandle
|
||||
? { createdByTerminalHandle: lineageResolution.createdByTerminalHandle }
|
||||
: {}),
|
||||
createdAt: now
|
||||
})
|
||||
} else {
|
||||
lineageWarnings.push({
|
||||
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
|
||||
message:
|
||||
'Worktree created, but Orca could not record lineage because instance identity was unavailable.',
|
||||
details: {
|
||||
childHasInstanceId: Boolean(childInstanceId),
|
||||
parentHasInstanceId: Boolean(parentInstanceId),
|
||||
storeSupportsLineage: Boolean(this.store.setWorktreeLineage)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
let warning: string | undefined
|
||||
@@ -4356,6 +4467,7 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
return {
|
||||
worktree,
|
||||
...(lineageInput ? { lineage, warnings: lineageWarnings } : {}),
|
||||
...(setup ? { setup } : {}),
|
||||
...(warning ? { warning } : {})
|
||||
}
|
||||
@@ -4701,12 +4813,43 @@ export class OrcaRuntimeService {
|
||||
linkedIssue?: number | null
|
||||
comment?: string
|
||||
isPinned?: boolean
|
||||
lineage?: {
|
||||
parentWorktree?: string
|
||||
noParent?: boolean
|
||||
}
|
||||
}
|
||||
) {
|
||||
if (!this.store) {
|
||||
throw new Error('runtime_unavailable')
|
||||
}
|
||||
const worktree = await this.resolveWorktreeSelector(worktreeSelector)
|
||||
if (updates.lineage?.noParent === true) {
|
||||
this.store.removeWorktreeLineage?.(worktree.id)
|
||||
} else if (updates.lineage?.parentWorktree) {
|
||||
const parent = await this.resolveWorktreeSelector(updates.lineage.parentWorktree)
|
||||
this.validateLineageParent(worktree, parent)
|
||||
if (!worktree.instanceId || !parent.instanceId) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CONTEXT_MISSING',
|
||||
'Workspace instance identity was unavailable.'
|
||||
)
|
||||
}
|
||||
if (!this.store.setWorktreeLineage) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CONTEXT_MISSING',
|
||||
'Workspace lineage storage was unavailable.'
|
||||
)
|
||||
}
|
||||
this.store.setWorktreeLineage(worktree.id, {
|
||||
worktreeId: worktree.id,
|
||||
worktreeInstanceId: worktree.instanceId,
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: parent.instanceId,
|
||||
origin: 'manual',
|
||||
capture: { source: 'manual-action', confidence: 'explicit' },
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
const meta = this.store.setWorktreeMeta(worktree.id, {
|
||||
...(updates.displayName !== undefined ? { displayName: updates.displayName } : {}),
|
||||
...(updates.linkedIssue !== undefined ? { linkedIssue: updates.linkedIssue } : {}),
|
||||
@@ -5421,6 +5564,279 @@ export class OrcaRuntimeService {
|
||||
throw new Error('selector_not_found')
|
||||
}
|
||||
|
||||
private validateLineageParent(child: ResolvedWorktree, parent: ResolvedWorktree): void {
|
||||
const childWorktreeId = child.id
|
||||
const parentWorktreeId = parent.id
|
||||
if (childWorktreeId === parentWorktreeId) {
|
||||
throw new RuntimeLineageError('LINEAGE_PARENT_CYCLE', 'A workspace cannot parent itself.')
|
||||
}
|
||||
const instanceByWorktreeId = new Map(
|
||||
this.resolvedWorktreeCache?.worktrees.map((worktree) => [
|
||||
worktree.id,
|
||||
worktree.instanceId
|
||||
]) ?? [
|
||||
[child.id, child.instanceId],
|
||||
[parent.id, parent.instanceId]
|
||||
]
|
||||
)
|
||||
let cursor: string | undefined = parentWorktreeId
|
||||
const visited = new Set<string>([childWorktreeId])
|
||||
while (cursor) {
|
||||
if (visited.has(cursor)) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CYCLE',
|
||||
'Parent workspace would create a lineage cycle.'
|
||||
)
|
||||
}
|
||||
visited.add(cursor)
|
||||
const lineage = this.store?.getWorktreeLineage?.(cursor)
|
||||
if (!lineage) {
|
||||
break
|
||||
}
|
||||
const cursorInstanceId = instanceByWorktreeId.get(cursor)
|
||||
const parentInstanceId = instanceByWorktreeId.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
cursorInstanceId !== lineage.worktreeInstanceId ||
|
||||
parentInstanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
cursor = lineage.parentWorktreeId
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLineageForWorktreeCreate(
|
||||
input?: WorktreeLineageInput
|
||||
): Promise<WorktreeLineageResolution> {
|
||||
if (!input) {
|
||||
return { kind: 'none', warnings: [] }
|
||||
}
|
||||
|
||||
if (input.noParent === true && input.parentWorktree) {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||||
'Choose either --parent-worktree or --no-parent, not both.'
|
||||
)
|
||||
}
|
||||
|
||||
if (input.noParent === true) {
|
||||
return { kind: 'none', warnings: [] }
|
||||
}
|
||||
|
||||
if (input.parentWorktree) {
|
||||
try {
|
||||
const parent = await this.resolveWorktreeSelector(input.parentWorktree)
|
||||
return {
|
||||
kind: 'lineage',
|
||||
parent,
|
||||
origin: 'cli',
|
||||
capture: { source: 'explicit-cli-flag', confidence: 'explicit' }
|
||||
}
|
||||
} catch {
|
||||
throw new RuntimeLineageError(
|
||||
'LINEAGE_PARENT_NOT_FOUND',
|
||||
'Parent workspace was not found.',
|
||||
{
|
||||
nextSteps: [
|
||||
'Run `orca worktree list` and pass a valid --parent-worktree selector.',
|
||||
'Retry with --no-parent to create without lineage.'
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const warnings: WorktreeLineageWarning[] = []
|
||||
const candidates: WorktreeLineageCandidate[] = []
|
||||
let terminalContextResolved = false
|
||||
|
||||
if (input.orchestrationContext?.parentWorktreeId) {
|
||||
try {
|
||||
candidates.push({
|
||||
source: 'orchestration-context',
|
||||
parent: await this.resolveWorktreeSelector(
|
||||
`id:${input.orchestrationContext.parentWorktreeId}`
|
||||
)
|
||||
})
|
||||
} catch {
|
||||
// Keep creation recoverable; the warning below covers missing inferred context.
|
||||
}
|
||||
}
|
||||
|
||||
const commentTaskId = extractOrchestrationTaskId(input.comment)
|
||||
if (commentTaskId) {
|
||||
const candidate = await this.resolveLineageCandidateForTaskId(commentTaskId)
|
||||
if (candidate) {
|
||||
candidates.push(candidate)
|
||||
}
|
||||
}
|
||||
|
||||
if (input.callerTerminalHandle) {
|
||||
try {
|
||||
const terminal = await this.showTerminal(input.callerTerminalHandle)
|
||||
const terminalParent = await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`)
|
||||
const activeDispatch = this._orchestrationDb?.getActiveDispatchForTerminal(
|
||||
input.callerTerminalHandle
|
||||
)
|
||||
const activeRun = this._orchestrationDb?.getActiveCoordinatorRun()
|
||||
if (activeDispatch) {
|
||||
candidates.push({
|
||||
source: 'orchestration-context',
|
||||
parent: terminalParent,
|
||||
taskId: activeDispatch.task_id,
|
||||
...(activeRun
|
||||
? {
|
||||
orchestrationRunId: activeRun.id,
|
||||
coordinatorHandle: activeRun.coordinator_handle
|
||||
}
|
||||
: {})
|
||||
})
|
||||
} else {
|
||||
candidates.push({
|
||||
source: 'terminal-context',
|
||||
parent: terminalParent
|
||||
})
|
||||
}
|
||||
terminalContextResolved = true
|
||||
} catch {
|
||||
// Why: terminal handles can go stale during reloads or SSH reconnects.
|
||||
// A valid orchestration parent is still authoritative, so keep resolving
|
||||
// other inferred candidates instead of dropping lineage completely.
|
||||
warnings.push({
|
||||
code: 'LINEAGE_PARENT_CONTEXT_MISSING',
|
||||
message:
|
||||
'Worktree created, but Orca could not validate the caller terminal as a parent workspace.',
|
||||
details: { callerTerminalHandle: input.callerTerminalHandle }
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.length === 0) {
|
||||
return { kind: 'none', warnings }
|
||||
}
|
||||
|
||||
const [first] = candidates
|
||||
const conflict = candidates.find((candidate) => candidate.parent.id !== first.parent.id)
|
||||
if (conflict) {
|
||||
return {
|
||||
kind: 'none',
|
||||
warnings: [
|
||||
{
|
||||
code: 'LINEAGE_PARENT_CONTEXT_CONFLICT',
|
||||
message: 'Worktree created, but Orca could not prove which parent workspace caused it.',
|
||||
details: {
|
||||
terminalParentWorktreeId: candidates.find((c) => c.source === 'terminal-context')
|
||||
?.parent.id,
|
||||
orchestrationParentWorktreeId: candidates.find(
|
||||
(c) => c.source === 'orchestration-context'
|
||||
)?.parent.id
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const preferred =
|
||||
candidates.find((candidate) => candidate.source === 'orchestration-context') ?? first
|
||||
return {
|
||||
kind: 'lineage',
|
||||
parent: preferred.parent,
|
||||
origin: preferred.source === 'orchestration-context' ? 'orchestration' : 'cli',
|
||||
capture: { source: preferred.source, confidence: 'inferred' },
|
||||
...((preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId)
|
||||
? {
|
||||
orchestrationRunId:
|
||||
preferred.orchestrationRunId ?? input.orchestrationContext?.orchestrationRunId
|
||||
}
|
||||
: {}),
|
||||
...((preferred.taskId ?? input.orchestrationContext?.taskId)
|
||||
? { taskId: preferred.taskId ?? input.orchestrationContext?.taskId }
|
||||
: {}),
|
||||
...((preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle)
|
||||
? {
|
||||
coordinatorHandle:
|
||||
preferred.coordinatorHandle ?? input.orchestrationContext?.coordinatorHandle
|
||||
}
|
||||
: {}),
|
||||
...(terminalContextResolved && input.callerTerminalHandle
|
||||
? { createdByTerminalHandle: input.callerTerminalHandle }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveLineageCandidateForTaskId(
|
||||
taskId: string
|
||||
): Promise<WorktreeLineageCandidate | null> {
|
||||
const db = this.getOrchestrationDbIfAvailable()
|
||||
const dispatch = db?.getDispatchContext(taskId)
|
||||
// Why: agent-created task records may never be dispatched, but the
|
||||
// creating terminal still identifies the parent workspace for descendants.
|
||||
const parentHandle =
|
||||
dispatch?.assignee_handle ?? db?.getTask(taskId)?.created_by_terminal_handle
|
||||
if (!parentHandle) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const terminal = await this.showTerminal(parentHandle)
|
||||
return {
|
||||
source: 'orchestration-context',
|
||||
parent: await this.resolveWorktreeSelector(`id:${terminal.worktreeId}`),
|
||||
taskId
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private getOrchestrationDbIfAvailable(): OrchestrationDb | null {
|
||||
try {
|
||||
return this._orchestrationDb ?? this.getOrchestrationDb()
|
||||
} catch {
|
||||
return this._orchestrationDb
|
||||
}
|
||||
}
|
||||
|
||||
async hydrateInferredWorktreeLineage(): Promise<void> {
|
||||
const store = this.store
|
||||
if (
|
||||
!store ||
|
||||
typeof store.getWorktreeLineage !== 'function' ||
|
||||
typeof store.setWorktreeLineage !== 'function'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
const worktrees = await this.listResolvedWorktrees()
|
||||
for (const worktree of worktrees) {
|
||||
if (store.getWorktreeLineage(worktree.id) || !worktree.instanceId) {
|
||||
continue
|
||||
}
|
||||
const taskId = extractOrchestrationTaskId(worktree.comment)
|
||||
if (!taskId) {
|
||||
continue
|
||||
}
|
||||
const candidate = await this.resolveLineageCandidateForTaskId(taskId)
|
||||
if (!candidate?.parent.instanceId || candidate.parent.id === worktree.id) {
|
||||
continue
|
||||
}
|
||||
try {
|
||||
this.validateLineageParent(worktree, candidate.parent)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
store.setWorktreeLineage(worktree.id, {
|
||||
worktreeId: worktree.id,
|
||||
worktreeInstanceId: worktree.instanceId,
|
||||
parentWorktreeId: candidate.parent.id,
|
||||
parentWorktreeInstanceId: candidate.parent.instanceId,
|
||||
origin: 'orchestration',
|
||||
capture: { source: 'orchestration-context', confidence: 'inferred' },
|
||||
taskId,
|
||||
createdAt: Date.now()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveNotesScope(worktreeSelector: string): Promise<{
|
||||
projectId: string
|
||||
worktreeId: string
|
||||
@@ -5510,25 +5926,37 @@ export class OrcaRuntimeService {
|
||||
return []
|
||||
}
|
||||
const now = Date.now()
|
||||
const metaById = this.store.getAllWorktreeMeta()
|
||||
const perRepoWorktrees = await Promise.all(
|
||||
this.store.getRepos().map(async (repo) => {
|
||||
// Why: mobile startup RPCs share this path. A slow repo scan should
|
||||
// degrade one repo's metadata, not block all terminal/session loading.
|
||||
const gitWorktrees = await withTimeout(
|
||||
listRepoWorktrees(repo),
|
||||
const scan = await withTimeout(
|
||||
this.listRepoWorktreesForResolution(repo),
|
||||
RESOLVED_WORKTREE_REPO_TIMEOUT_MS,
|
||||
[]
|
||||
{ ok: false, worktrees: [] }
|
||||
)
|
||||
const gitWorktrees = scan.worktrees
|
||||
if (scan.ok) {
|
||||
this.pruneLineageForMissingRepoWorktrees(repo, gitWorktrees)
|
||||
}
|
||||
const metaById = this.store?.getAllWorktreeMeta() ?? {}
|
||||
return gitWorktrees.map((gitWorktree) => {
|
||||
const worktreeId = `${repo.id}::${gitWorktree.path}`
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, metaById[worktreeId], repo.displayName)
|
||||
// Why: lineage validation needs a durable instance ID even when the
|
||||
// runtime sees a workspace before the renderer's discovery-stamp path.
|
||||
const existingMeta = metaById[worktreeId]
|
||||
const meta =
|
||||
existingMeta && existingMeta.instanceId
|
||||
? existingMeta
|
||||
: this.store?.setWorktreeMeta(worktreeId, {})
|
||||
const merged = mergeWorktree(repo.id, gitWorktree, meta, repo.displayName)
|
||||
return {
|
||||
id: merged.id,
|
||||
...(merged.instanceId !== undefined ? { instanceId: merged.instanceId } : {}),
|
||||
repoId: repo.id,
|
||||
path: merged.path,
|
||||
branch: merged.branch,
|
||||
linkedIssue: metaById[worktreeId]?.linkedIssue ?? null,
|
||||
linkedIssue: meta?.linkedIssue ?? null,
|
||||
git: {
|
||||
path: gitWorktree.path,
|
||||
head: gitWorktree.head,
|
||||
@@ -5553,6 +5981,50 @@ export class OrcaRuntimeService {
|
||||
return worktrees
|
||||
}
|
||||
|
||||
private pruneLineageForMissingRepoWorktrees(repo: Repo, gitWorktrees: GitWorktreeInfo[]): void {
|
||||
const store = this.store
|
||||
if (
|
||||
!store ||
|
||||
typeof store.getAllWorktreeLineage !== 'function' ||
|
||||
typeof store.removeWorktreeLineage !== 'function'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const liveIds = new Set(gitWorktrees.map((worktree) => `${repo.id}::${worktree.path}`))
|
||||
const repoPrefix = `${repo.id}::`
|
||||
for (const [childId, lineage] of Object.entries(store.getAllWorktreeLineage())) {
|
||||
if (childId.startsWith(repoPrefix) && !liveIds.has(childId)) {
|
||||
// Why: runtime selector scans can be the only scan before a path is
|
||||
// reused. Once a successful scan proves the child is gone, stale
|
||||
// lineage must not survive into the replacement checkout.
|
||||
store.removeWorktreeLineage(childId)
|
||||
}
|
||||
if (
|
||||
lineage.parentWorktreeId.startsWith(repoPrefix) &&
|
||||
!liveIds.has(lineage.parentWorktreeId)
|
||||
) {
|
||||
const parentMeta = store.getWorktreeMeta(lineage.parentWorktreeId)
|
||||
if (!parentMeta || parentMeta.instanceId === lineage.parentWorktreeInstanceId) {
|
||||
// Why: preserving child lineage powers the repair UI, but a missing
|
||||
// parent path only needs one fresh identity to keep same-path
|
||||
// replacement checkouts from validating old lineage.
|
||||
store.setWorktreeMeta(lineage.parentWorktreeId, { instanceId: randomUUID() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async listRepoWorktreesForResolution(repo: Repo): Promise<RuntimeWorktreeScanResult> {
|
||||
if (!repo.connectionId) {
|
||||
return { ok: true, worktrees: await listRepoWorktrees(repo) }
|
||||
}
|
||||
const provider = getSshGitProvider(repo.connectionId)
|
||||
if (!provider) {
|
||||
return { ok: false, worktrees: [] }
|
||||
}
|
||||
return { ok: true, worktrees: await provider.listWorktrees(repo.path) }
|
||||
}
|
||||
|
||||
private async getResolvedWorktreeMap(): Promise<Map<string, ResolvedWorktree>> {
|
||||
return new Map((await this.listResolvedWorktrees()).map((worktree) => [worktree.id, worktree]))
|
||||
}
|
||||
|
||||
@@ -132,6 +132,17 @@ describe('OrchestrationDb', () => {
|
||||
expect(task.deps).toBe('[]')
|
||||
})
|
||||
|
||||
it('persists the creating terminal handle for task-created worktrees', () => {
|
||||
const d = createDb()
|
||||
const task = d.createTask({
|
||||
spec: 'spawn related workspace',
|
||||
createdByTerminalHandle: 'term_creator'
|
||||
})
|
||||
|
||||
expect(task.created_by_terminal_handle).toBe('term_creator')
|
||||
expect(d.getTask(task.id)?.created_by_terminal_handle).toBe('term_creator')
|
||||
})
|
||||
|
||||
it('creates a task with deps as pending', () => {
|
||||
const d = createDb()
|
||||
const parent = d.createTask({ spec: 'parent' })
|
||||
|
||||
@@ -36,10 +36,10 @@ function generateId(prefix: string): string {
|
||||
// Why: v1 → v2 added `'heartbeat'` to messages.type CHECK + `last_heartbeat_at`
|
||||
// column (preamble-hardening PR). v2 → v3 adds `delivered_at` column so
|
||||
// push-on-idle can distinguish queued-but-undelivered from user-acknowledged
|
||||
// messages without touching the `read` bit (check-wait PR). Bumping together
|
||||
// since both PRs ship in the same release — existing on-disk DBs must clear
|
||||
// both gaps in one atomic migration.
|
||||
const SCHEMA_VERSION = 3
|
||||
// messages without touching the `read` bit (check-wait PR). v3 → v4 records
|
||||
// the terminal that created a task so task-record worktree creation can infer
|
||||
// the parent workspace even when no dispatch context exists.
|
||||
const SCHEMA_VERSION = 4
|
||||
|
||||
export class OrchestrationDb {
|
||||
private db: Database.Database
|
||||
@@ -83,6 +83,7 @@ export class OrchestrationDb {
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_id TEXT,
|
||||
created_by_terminal_handle TEXT,
|
||||
spec TEXT NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending'
|
||||
CHECK(status IN (
|
||||
@@ -227,6 +228,11 @@ export class OrchestrationDb {
|
||||
this.db.exec(`ALTER TABLE messages ADD COLUMN delivered_at TEXT`)
|
||||
}
|
||||
}
|
||||
if (current < 4) {
|
||||
if (!this.hasColumn('tasks', 'created_by_terminal_handle')) {
|
||||
this.db.exec(`ALTER TABLE tasks ADD COLUMN created_by_terminal_handle TEXT`)
|
||||
}
|
||||
}
|
||||
|
||||
this.db.pragma(`user_version = ${SCHEMA_VERSION}`)
|
||||
this.db.exec('COMMIT')
|
||||
@@ -367,14 +373,28 @@ export class OrchestrationDb {
|
||||
|
||||
// ── Tasks ──
|
||||
|
||||
createTask(task: { spec: string; deps?: string[]; parentId?: string }): TaskRow {
|
||||
createTask(task: {
|
||||
spec: string
|
||||
deps?: string[]
|
||||
parentId?: string
|
||||
createdByTerminalHandle?: string
|
||||
}): TaskRow {
|
||||
const id = generateId('task')
|
||||
const depsJson = JSON.stringify(task.deps ?? [])
|
||||
const hasDeps = (task.deps ?? []).length > 0
|
||||
const status: TaskStatus = hasDeps ? 'pending' : 'ready'
|
||||
this.db
|
||||
.prepare('INSERT INTO tasks (id, parent_id, spec, status, deps) VALUES (?, ?, ?, ?, ?)')
|
||||
.run(id, task.parentId ?? null, task.spec, status, depsJson)
|
||||
.prepare(
|
||||
'INSERT INTO tasks (id, parent_id, created_by_terminal_handle, spec, status, deps) VALUES (?, ?, ?, ?, ?, ?)'
|
||||
)
|
||||
.run(
|
||||
id,
|
||||
task.parentId ?? null,
|
||||
task.createdByTerminalHandle ?? null,
|
||||
task.spec,
|
||||
status,
|
||||
depsJson
|
||||
)
|
||||
return this.db.prepare('SELECT * FROM tasks WHERE id = ?').get(id) as TaskRow
|
||||
}
|
||||
|
||||
|
||||
@@ -37,6 +37,7 @@ export type MessageRow = {
|
||||
export type TaskRow = {
|
||||
id: string
|
||||
parent_id: string | null
|
||||
created_by_terminal_handle: string | null
|
||||
spec: string
|
||||
status: TaskStatus
|
||||
deps: string
|
||||
|
||||
@@ -62,6 +62,20 @@ export function mapRuntimeError(id: string, meta: RpcEnvelopeMeta, error: unknow
|
||||
) {
|
||||
return errorResponse(id, meta, (error as { code: string }).code, message)
|
||||
}
|
||||
if (
|
||||
error instanceof Error &&
|
||||
'code' in error &&
|
||||
typeof (error as { code: unknown }).code === 'string' &&
|
||||
(error as { code: string }).code.startsWith('LINEAGE_')
|
||||
) {
|
||||
return errorResponse(
|
||||
id,
|
||||
meta,
|
||||
(error as { code: string }).code,
|
||||
message,
|
||||
(error as { data?: unknown }).data
|
||||
)
|
||||
}
|
||||
if (RUNTIME_PASSTHROUGH_CODES.has(message)) {
|
||||
return errorResponse(id, meta, message, message)
|
||||
}
|
||||
|
||||
@@ -441,6 +441,16 @@ describe('orchestration RPC methods', () => {
|
||||
expect(result.task.status).toBe('pending')
|
||||
})
|
||||
|
||||
it('records the caller terminal handle when creating a task', async () => {
|
||||
setup()
|
||||
const result = (await call('orchestration.taskCreate', {
|
||||
spec: 'spawn related workspace',
|
||||
callerTerminalHandle: 'term_creator'
|
||||
})) as { task: { id: string } }
|
||||
|
||||
expect(db.getTask(result.task.id)?.created_by_terminal_handle).toBe('term_creator')
|
||||
})
|
||||
|
||||
it('rejects invalid deps JSON', async () => {
|
||||
setup()
|
||||
await expect(
|
||||
|
||||
@@ -81,7 +81,8 @@ const InboxParams = z.object({
|
||||
const TaskCreateParams = z.object({
|
||||
spec: requiredString('Missing --spec'),
|
||||
deps: OptionalString,
|
||||
parent: OptionalString
|
||||
parent: OptionalString,
|
||||
callerTerminalHandle: OptionalString
|
||||
})
|
||||
|
||||
const TaskListParams = z.object({
|
||||
@@ -322,7 +323,8 @@ export const ORCHESTRATION_METHODS: RpcMethod[] = [
|
||||
const task = db.createTask({
|
||||
spec: params.spec,
|
||||
deps,
|
||||
parentId: params.parent
|
||||
parentId: params.parent,
|
||||
createdByTerminalHandle: params.callerTerminalHandle
|
||||
})
|
||||
return { task }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { WORKTREE_METHODS } from './worktree'
|
||||
import type { RpcContext } from '../core'
|
||||
import { OrcaRuntimeService } from '../../orca-runtime'
|
||||
|
||||
describe('worktree RPC methods', () => {
|
||||
function findMethod(name: string) {
|
||||
const method = WORKTREE_METHODS.find((m) => m.name === name)
|
||||
if (!method) {
|
||||
throw new Error(`Method not found: ${name}`)
|
||||
}
|
||||
return method
|
||||
}
|
||||
|
||||
async function call(name: string, params: Record<string, unknown>, runtime: OrcaRuntimeService) {
|
||||
const method = findMethod(name)
|
||||
const parsed = method.params ? method.params.parse(params) : undefined
|
||||
return method.handler(parsed, { runtime } as RpcContext)
|
||||
}
|
||||
|
||||
it('rejects worktree.create when both parent and no-parent are supplied', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'createManagedWorktree')
|
||||
|
||||
await expect(
|
||||
call(
|
||||
'worktree.create',
|
||||
{
|
||||
repo: 'id:repo-1',
|
||||
name: 'child',
|
||||
parentWorktree: 'id:parent',
|
||||
noParent: true
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).rejects.toThrow('Choose either --parent-worktree or --no-parent, not both.')
|
||||
expect(runtime.createManagedWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects worktree.set when both parent and no-parent are supplied', async () => {
|
||||
const runtime = new OrcaRuntimeService()
|
||||
vi.spyOn(runtime, 'updateManagedWorktreeMeta')
|
||||
|
||||
await expect(
|
||||
call(
|
||||
'worktree.set',
|
||||
{
|
||||
worktree: 'id:child',
|
||||
parentWorktree: 'id:parent',
|
||||
noParent: true
|
||||
},
|
||||
runtime
|
||||
)
|
||||
).rejects.toThrow('Choose either --parent-worktree or --no-parent, not both.')
|
||||
expect(runtime.updateManagedWorktreeMeta).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -23,34 +23,63 @@ const WorktreeSelector = z.object({
|
||||
.pipe(z.string().min(1, 'Missing worktree selector'))
|
||||
})
|
||||
|
||||
const WorktreeCreate = z.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
runHooks: OptionalBoolean,
|
||||
activate: OptionalBoolean,
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
|
||||
.optional(),
|
||||
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
|
||||
// terminal pane launches the selected agent instead of an idle shell.
|
||||
startupCommand: OptionalString
|
||||
})
|
||||
const WorktreeCreate = z
|
||||
.object({
|
||||
repo: z
|
||||
.unknown()
|
||||
.transform((v) => (typeof v === 'string' ? v : ''))
|
||||
.pipe(z.string().min(1, 'Missing repo selector')),
|
||||
name: OptionalString,
|
||||
baseBranch: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
runHooks: OptionalBoolean,
|
||||
activate: OptionalBoolean,
|
||||
parentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean,
|
||||
callerTerminalHandle: OptionalString,
|
||||
orchestrationContext: z
|
||||
.object({
|
||||
parentWorktreeId: OptionalString,
|
||||
orchestrationRunId: OptionalString,
|
||||
taskId: OptionalString,
|
||||
coordinatorHandle: OptionalString
|
||||
})
|
||||
.optional(),
|
||||
setupDecision: z
|
||||
.unknown()
|
||||
.transform((v) =>
|
||||
typeof v === 'string' && (v === 'run' || v === 'skip' || v === 'inherit') ? v : undefined
|
||||
)
|
||||
.pipe(z.union([z.enum(['run', 'skip', 'inherit']), z.undefined()]))
|
||||
.optional(),
|
||||
// Why: mobile clients pass a startup command (e.g. 'claude') so the first
|
||||
// terminal pane launches the selected agent instead of an idle shell.
|
||||
startupCommand: OptionalString
|
||||
})
|
||||
.superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const WorktreeSet = WorktreeSelector.extend({
|
||||
displayName: OptionalString,
|
||||
linkedIssue: TriStateLinkedIssue,
|
||||
comment: OptionalString,
|
||||
isPinned: OptionalBoolean
|
||||
isPinned: OptionalBoolean,
|
||||
parentWorktree: OptionalString,
|
||||
noParent: OptionalBoolean
|
||||
}).superRefine((params, ctx) => {
|
||||
if (params.parentWorktree && params.noParent === true) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: 'Choose either --parent-worktree or --no-parent, not both.'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const WorktreeRemove = WorktreeSelector.extend({
|
||||
@@ -99,7 +128,13 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
runHooks: params.runHooks === true,
|
||||
activate: params.activate === true,
|
||||
setupDecision: params.setupDecision,
|
||||
startup: params.startupCommand ? { command: params.startupCommand } : undefined
|
||||
startup: params.startupCommand ? { command: params.startupCommand } : undefined,
|
||||
lineage: {
|
||||
parentWorktree: params.parentWorktree,
|
||||
noParent: params.noParent === true,
|
||||
callerTerminalHandle: params.callerTerminalHandle,
|
||||
orchestrationContext: params.orchestrationContext
|
||||
}
|
||||
})
|
||||
}),
|
||||
defineMethod({
|
||||
@@ -110,7 +145,14 @@ export const WORKTREE_METHODS: RpcMethod[] = [
|
||||
displayName: params.displayName,
|
||||
linkedIssue: params.linkedIssue,
|
||||
comment: params.comment,
|
||||
isPinned: params.isPinned
|
||||
isPinned: params.isPinned,
|
||||
lineage:
|
||||
params.parentWorktree || params.noParent === true
|
||||
? {
|
||||
parentWorktree: params.parentWorktree,
|
||||
noParent: params.noParent === true
|
||||
}
|
||||
: undefined
|
||||
})
|
||||
})
|
||||
}),
|
||||
|
||||
@@ -79,6 +79,7 @@ import type {
|
||||
UpdateStatus,
|
||||
Worktree,
|
||||
WorktreeBaseStatusEvent,
|
||||
WorktreeLineage,
|
||||
WorktreeMeta,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeSetupLaunch,
|
||||
@@ -519,6 +520,12 @@ export type PreloadApi = {
|
||||
}) => Promise<{ baseBranch: string } | { error: string }>
|
||||
remove: (args: { worktreeId: string; force?: boolean; skipArchive?: boolean }) => Promise<void>
|
||||
updateMeta: (args: { worktreeId: string; updates: Partial<WorktreeMeta> }) => Promise<Worktree>
|
||||
listLineage: () => Promise<Record<string, WorktreeLineage>>
|
||||
updateLineage: (args: {
|
||||
worktreeId: string
|
||||
parentWorktreeId?: string
|
||||
noParent?: boolean
|
||||
}) => Promise<WorktreeLineage | null>
|
||||
persistSortOrder: (args: { orderedIds: string[] }) => Promise<void>
|
||||
onChanged: (callback: (data: { repoId: string }) => void) => () => void
|
||||
onBaseStatus: (callback: (data: WorktreeBaseStatusEvent) => void) => () => void
|
||||
|
||||
@@ -460,6 +460,15 @@ const api = {
|
||||
updates: Record<string, unknown>
|
||||
}): Promise<unknown> => ipcRenderer.invoke('worktrees:updateMeta', args),
|
||||
|
||||
listLineage: (): Promise<Record<string, unknown>> =>
|
||||
ipcRenderer.invoke('worktrees:listLineage'),
|
||||
|
||||
updateLineage: (args: {
|
||||
worktreeId: string
|
||||
parentWorktreeId?: string
|
||||
noParent?: boolean
|
||||
}): Promise<unknown> => ipcRenderer.invoke('worktrees:updateLineage', args),
|
||||
|
||||
persistSortOrder: (args: { orderedIds: string[] }): Promise<void> =>
|
||||
ipcRenderer.invoke('worktrees:persistSortOrder', args),
|
||||
|
||||
|
||||
@@ -184,6 +184,7 @@ function App(): React.JSX.Element {
|
||||
toggleSidebar: s.toggleSidebar,
|
||||
fetchRepos: s.fetchRepos,
|
||||
fetchAllWorktrees: s.fetchAllWorktrees,
|
||||
fetchWorktreeLineage: s.fetchWorktreeLineage,
|
||||
fetchSettings: s.fetchSettings,
|
||||
initGitHubCache: s.initGitHubCache,
|
||||
refreshAllGitHub: s.refreshAllGitHub,
|
||||
@@ -284,6 +285,7 @@ function App(): React.JSX.Element {
|
||||
const sidebarWidth = useAppStore((s) => s.sidebarWidth)
|
||||
const sidebarOpen = useAppStore((s) => s.sidebarOpen)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
@@ -368,6 +370,7 @@ function App(): React.JSX.Element {
|
||||
try {
|
||||
await actions.fetchRepos()
|
||||
await actions.fetchAllWorktrees()
|
||||
await actions.fetchWorktreeLineage()
|
||||
const persistedUI = await window.api.ui.get()
|
||||
uiHydrated = hydratePersistedUIAfterStartupRead({
|
||||
persistedUI,
|
||||
@@ -722,6 +725,7 @@ function App(): React.JSX.Element {
|
||||
sidebarWidth,
|
||||
rightSidebarWidth,
|
||||
groupBy,
|
||||
showWorkspaceLineage,
|
||||
sortBy,
|
||||
showActiveOnly,
|
||||
hideDefaultBranchWorkspace,
|
||||
@@ -741,6 +745,7 @@ function App(): React.JSX.Element {
|
||||
sidebarWidth,
|
||||
rightSidebarWidth,
|
||||
groupBy,
|
||||
showWorkspaceLineage,
|
||||
sortBy,
|
||||
showActiveOnly,
|
||||
hideDefaultBranchWorkspace,
|
||||
|
||||
@@ -62,6 +62,8 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
const setSortBy = useAppStore((s) => s.setSortBy)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const setGroupBy = useAppStore((s) => s.setGroupBy)
|
||||
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
|
||||
const setShowWorkspaceLineage = useAppStore((s) => s.setShowWorkspaceLineage)
|
||||
return (
|
||||
<div className="flex h-8 items-center justify-between px-2 gap-2">
|
||||
<span className="px-2 text-[10.5px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/80 select-none">
|
||||
@@ -114,6 +116,15 @@ const SidebarHeader = React.memo(function SidebarHeader() {
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuCheckboxItem
|
||||
checked={showWorkspaceLineage}
|
||||
onCheckedChange={(checked) => setShowWorkspaceLineage(Boolean(checked))}
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
>
|
||||
Nest child workspaces
|
||||
</DropdownMenuCheckboxItem>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuLabel>Sort by</DropdownMenuLabel>
|
||||
<DropdownMenuRadioGroup
|
||||
|
||||
@@ -12,7 +12,8 @@ import {
|
||||
CircleCheck,
|
||||
CircleX,
|
||||
Server,
|
||||
ServerOff
|
||||
ServerOff,
|
||||
Workflow
|
||||
} from 'lucide-react'
|
||||
import StatusIndicator from './StatusIndicator'
|
||||
import CacheTimer from './CacheTimer'
|
||||
@@ -52,6 +53,8 @@ type WorktreeCardProps = {
|
||||
isMultiSelected?: boolean
|
||||
selectedWorktrees?: readonly Worktree[]
|
||||
hideRepoBadge?: boolean
|
||||
parentLabel?: string
|
||||
lineageState?: 'valid' | 'missing'
|
||||
onSelectionGesture?: (event: React.MouseEvent<HTMLDivElement>, worktreeId: string) => boolean
|
||||
onContextMenuSelect?: (event: React.MouseEvent<HTMLDivElement>) => readonly Worktree[]
|
||||
}
|
||||
@@ -69,7 +72,9 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
selectedWorktrees,
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
hideRepoBadge
|
||||
hideRepoBadge,
|
||||
parentLabel,
|
||||
lineageState
|
||||
}: WorktreeCardProps) {
|
||||
const openModal = useAppStore((s) => s.openModal)
|
||||
const updateWorktreeMeta = useAppStore((s) => s.updateWorktreeMeta)
|
||||
@@ -646,6 +651,23 @@ const WorktreeCard = React.memo(function WorktreeCard({
|
||||
)}
|
||||
|
||||
<CacheTimer worktreeId={worktree.id} />
|
||||
|
||||
{parentLabel && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 gap-1 leading-none',
|
||||
lineageState === 'missing'
|
||||
? 'text-muted-foreground border-border bg-muted/40'
|
||||
: 'text-muted-foreground border-border bg-accent/50'
|
||||
)}
|
||||
>
|
||||
<Workflow className="size-2.5" />
|
||||
<span className="max-w-[7rem] truncate">
|
||||
{lineageState === 'missing' ? 'Missing parent' : `from ${parentLabel}`}
|
||||
</span>
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Meta section: Issue / hosted review / Comment
|
||||
|
||||
@@ -18,15 +18,19 @@ import {
|
||||
Pencil,
|
||||
Pin,
|
||||
PinOff,
|
||||
Trash2
|
||||
Trash2,
|
||||
Unlink,
|
||||
Workflow
|
||||
} from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { useRepoById, useRepoMap } from '@/store/selectors'
|
||||
import { useRepoById, useRepoMap, useWorktreeMap } from '@/store/selectors'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree } from '../../../../shared/types'
|
||||
import { isFolderRepo } from '../../../../shared/repo-kind'
|
||||
import { runWorktreeBatchDelete, runWorktreeDelete } from './delete-worktree-flow'
|
||||
import { runSleepWorktrees } from './sleep-worktree-flow'
|
||||
import { activateAndRevealWorktree } from '@/lib/worktree-activation'
|
||||
import { getLineageRenderInfo } from './worktree-list-groups'
|
||||
|
||||
type Props = {
|
||||
worktree: Worktree
|
||||
@@ -56,6 +60,10 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
const isDeleting = deleteState?.isDeleting ?? false
|
||||
const isFolder = repo ? isFolderRepo(repo) : false
|
||||
const repoMap = useRepoMap()
|
||||
const worktreeMap = useWorktreeMap()
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
|
||||
const updateWorktreeLineage = useAppStore((s) => s.updateWorktreeLineage)
|
||||
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
|
||||
const ptyIdsByTabId = useAppStore((s) => s.ptyIdsByTabId)
|
||||
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
|
||||
@@ -92,6 +100,17 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
isMultiContext && batchDeleteWorktrees.length > 0
|
||||
? `Delete ${batchDeleteWorktrees.length} Workspace${batchDeleteWorktrees.length === 1 ? '' : 's'}`
|
||||
: 'Delete Selected'
|
||||
const lineage = worktreeLineageById[worktree.id]
|
||||
// Why: path-derived worktree IDs can be reused. The menu must honor the same
|
||||
// instance check as grouped rows before offering navigation to a parent.
|
||||
const lineageInfo = useMemo(
|
||||
() => getLineageRenderInfo(worktree, worktreeLineageById, worktreeMap),
|
||||
[worktree, worktreeLineageById, worktreeMap]
|
||||
)
|
||||
const validParentWorktreeId = lineageInfo.state === 'valid' ? lineageInfo.parent.id : null
|
||||
const hasAnyContextLineage = activeContextWorktrees.some((item) => worktreeLineageById[item.id])
|
||||
const canGroupUnderActive =
|
||||
activeWorktreeId != null && activeContextWorktrees.every((item) => item.id !== activeWorktreeId)
|
||||
|
||||
useEffect(() => {
|
||||
const closeMenu = (): void => setMenuOpen(false)
|
||||
@@ -206,6 +225,29 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
worktree.repoId
|
||||
])
|
||||
|
||||
const handleOpenParent = useCallback(() => {
|
||||
if (validParentWorktreeId) {
|
||||
activateAndRevealWorktree(validParentWorktreeId)
|
||||
}
|
||||
}, [validParentWorktreeId])
|
||||
|
||||
const handleGroupUnderActive = useCallback(() => {
|
||||
if (!activeWorktreeId) {
|
||||
return
|
||||
}
|
||||
void Promise.all(
|
||||
activeContextWorktrees.map((item) =>
|
||||
updateWorktreeLineage(item.id, { parentWorktreeId: activeWorktreeId })
|
||||
)
|
||||
)
|
||||
}, [activeContextWorktrees, activeWorktreeId, updateWorktreeLineage])
|
||||
|
||||
const handleRemoveParentLink = useCallback(() => {
|
||||
void Promise.all(
|
||||
activeContextWorktrees.map((item) => updateWorktreeLineage(item.id, { noParent: true }))
|
||||
)
|
||||
}, [activeContextWorktrees, updateWorktreeLineage])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
@@ -275,6 +317,45 @@ const WorktreeContextMenu = React.memo(function WorktreeContextMenu({
|
||||
{worktree.comment ? 'Edit Comment' : 'Add Comment'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onSelect={handleOpenParent}
|
||||
disabled={isDeleting || !validParentWorktreeId}
|
||||
>
|
||||
<Workflow className="size-3.5" />
|
||||
Open Parent Workspace
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleGroupUnderActive}
|
||||
disabled={isDeleting || !canGroupUnderActive}
|
||||
>
|
||||
<Workflow className="size-3.5" />
|
||||
Group under Active Workspace
|
||||
</DropdownMenuItem>
|
||||
{lineage && (
|
||||
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={isDeleting}>
|
||||
<Unlink className="size-3.5" />
|
||||
Remove Parent Link
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
{isMultiContext && (
|
||||
<>
|
||||
<DropdownMenuItem
|
||||
onSelect={handleGroupUnderActive}
|
||||
disabled={deletingContext || !canGroupUnderActive}
|
||||
>
|
||||
<Workflow className="size-3.5" />
|
||||
Group under Active Workspace
|
||||
</DropdownMenuItem>
|
||||
{hasAnyContextLineage && (
|
||||
<DropdownMenuItem onSelect={handleRemoveParentLink} disabled={deletingContext}>
|
||||
<Unlink className="size-3.5" />
|
||||
Remove Parent Links
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuSeparator />
|
||||
</>
|
||||
)}
|
||||
<Tooltip>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/* eslint-disable max-lines */
|
||||
import React, { useMemo, useCallback, useRef, useState, useEffect, useLayoutEffect } from 'react'
|
||||
import { useVirtualizer } from '@tanstack/react-virtual'
|
||||
import { ChevronDown, CircleX, Plus } from 'lucide-react'
|
||||
import { ChevronDown, ChevronRight, CircleX, Plus } from 'lucide-react'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getAllWorktreesFromState,
|
||||
@@ -13,7 +13,7 @@ import WorktreeCard from './WorktreeCard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { Worktree, Repo } from '../../../../shared/types'
|
||||
import type { Worktree, Repo, WorktreeLineage } from '../../../../shared/types'
|
||||
import { isGitRepoKind } from '../../../../shared/repo-kind'
|
||||
import { buildWorktreeComparator } from './smart-sort'
|
||||
import {
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
ALL_GROUP_KEY,
|
||||
PINNED_GROUP_KEY,
|
||||
buildRows,
|
||||
getGroupKeyForWorktree
|
||||
getGroupKeyForWorktree,
|
||||
getLineageGroupKey
|
||||
} from './worktree-list-groups'
|
||||
import {
|
||||
computeClearFilterActions,
|
||||
@@ -76,10 +77,91 @@ function getWorktreeOptionId(worktreeId: string): string {
|
||||
return `worktree-list-option-${encodeURIComponent(worktreeId)}`
|
||||
}
|
||||
|
||||
function LineageGuides({ row }: { row: Extract<Row, { type: 'item' }> }) {
|
||||
if (row.depth <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const step = 18
|
||||
const lineX = 10
|
||||
const currentX = (row.depth - 1) * step + lineX
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 text-sidebar-border" aria-hidden>
|
||||
{row.lineageTrail
|
||||
.slice(0, -1)
|
||||
.map((continues, index) =>
|
||||
continues ? (
|
||||
<span
|
||||
key={index}
|
||||
className="absolute top-0 bottom-0 w-px bg-current/80"
|
||||
style={{ left: `${index * step + lineX}px` }}
|
||||
/>
|
||||
) : null
|
||||
)}
|
||||
<span
|
||||
className="absolute top-0 w-px bg-current/80"
|
||||
style={{
|
||||
left: `${currentX}px`,
|
||||
height: row.isLastLineageChild ? '50%' : '100%'
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
className="absolute h-px bg-current/80"
|
||||
style={{
|
||||
left: `${currentX}px`,
|
||||
top: '50%',
|
||||
width: '12px'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function LineageDisclosure({
|
||||
row,
|
||||
onToggle
|
||||
}: {
|
||||
row: Extract<Row, { type: 'item' }>
|
||||
onToggle: (event: React.MouseEvent<HTMLButtonElement>) => void
|
||||
}) {
|
||||
if (!row.lineageGroupKey || row.lineageChildCount <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const label = `${row.lineageCollapsed ? 'Show' : 'Hide'} ${row.lineageChildCount} child ${
|
||||
row.lineageChildCount === 1 ? 'workspace' : 'workspaces'
|
||||
}`
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
className="absolute right-2 top-2 z-10 size-5 rounded-md bg-sidebar/80 text-muted-foreground opacity-80 shadow-sm ring-1 ring-sidebar-border/70 backdrop-blur-sm transition-colors hover:bg-sidebar-accent hover:text-foreground hover:opacity-100 focus-visible:opacity-100"
|
||||
aria-label={label}
|
||||
aria-expanded={!row.lineageCollapsed}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<ChevronRight
|
||||
className={cn('size-3 transition-transform', !row.lineageCollapsed && 'rotate-90')}
|
||||
/>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="right" sideOffset={8}>
|
||||
{row.lineageCollapsed ? 'Show child workspaces' : 'Hide child workspaces'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
type VirtualizedWorktreeViewportProps = {
|
||||
rows: Row[]
|
||||
activeWorktreeId: string | null
|
||||
groupBy: 'none' | 'repo' | 'pr-status'
|
||||
showWorkspaceLineage: boolean
|
||||
toggleGroup: (key: string) => void
|
||||
collapsedGroups: Set<string>
|
||||
handleCreateForRepo: (repoId: string) => void
|
||||
@@ -95,6 +177,8 @@ type VirtualizedWorktreeViewportProps = {
|
||||
worktree: Worktree
|
||||
) => readonly Worktree[]
|
||||
repoMap: Map<string, Repo>
|
||||
worktreeMap: Map<string, Worktree>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
repoOrder: Map<string, number>
|
||||
// The full canonical state.repos id ordering — the drag controller commits
|
||||
// permutations of this list, even when some repos aren't currently visible
|
||||
@@ -116,6 +200,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
rows,
|
||||
activeWorktreeId,
|
||||
groupBy,
|
||||
showWorkspaceLineage,
|
||||
toggleGroup,
|
||||
collapsedGroups,
|
||||
handleCreateForRepo,
|
||||
@@ -128,6 +213,8 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
onSelectionGesture,
|
||||
onContextMenuSelect,
|
||||
repoMap,
|
||||
worktreeMap,
|
||||
worktreeLineageById,
|
||||
repoOrder,
|
||||
allRepoIds,
|
||||
reorderRepos,
|
||||
@@ -222,6 +309,29 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
|
||||
{
|
||||
const targetWorktree = worktrees.find((w) => w.id === pendingRevealWorktreeId)
|
||||
if (targetWorktree && showWorkspaceLineage && !targetWorktree.isPinned) {
|
||||
const seen = new Set<string>()
|
||||
let current: Worktree | undefined = targetWorktree
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id)
|
||||
const lineage = worktreeLineageById[current.id]
|
||||
const parent = lineage ? worktreeMap.get(lineage.parentWorktreeId) : undefined
|
||||
if (
|
||||
!lineage ||
|
||||
!parent ||
|
||||
current.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
break
|
||||
}
|
||||
const lineageGroupKey = getLineageGroupKey(parent.id)
|
||||
if (collapsedGroups.has(lineageGroupKey)) {
|
||||
toggleGroup(lineageGroupKey)
|
||||
}
|
||||
current = parent
|
||||
}
|
||||
}
|
||||
|
||||
if (targetWorktree?.isPinned) {
|
||||
// Why: pinned worktrees live in the dedicated "Pinned" section regardless
|
||||
// of their PR-status / repo group. Only uncollapse the Pinned header
|
||||
@@ -267,6 +377,9 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
showWorkspaceLineage,
|
||||
rows,
|
||||
virtualizer,
|
||||
clearPendingRevealWorktreeId,
|
||||
@@ -300,7 +413,10 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
repoMap,
|
||||
prCache,
|
||||
new Set<string>(),
|
||||
repoOrder
|
||||
repoOrder,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
showWorkspaceLineage
|
||||
).filter((r): r is Extract<Row, { type: 'item' }> => r.type === 'item')
|
||||
if (worktreeRows.length === 0) {
|
||||
return
|
||||
@@ -333,7 +449,19 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
virtualizer.scrollToIndex(rowIndex, { align: 'auto' })
|
||||
}
|
||||
},
|
||||
[rows, activeWorktreeId, virtualizer, groupBy, worktrees, repoMap, prCache, repoOrder]
|
||||
[
|
||||
rows,
|
||||
activeWorktreeId,
|
||||
virtualizer,
|
||||
groupBy,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
repoOrder,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
showWorkspaceLineage
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -493,7 +621,7 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
/>
|
||||
</div>
|
||||
|
||||
{row.repo ? (
|
||||
{row.repo && groupBy === 'repo' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -538,16 +666,36 @@ const VirtualizedWorktreeViewport = React.memo(function VirtualizedWorktreeViewp
|
||||
className="absolute left-0 right-0"
|
||||
style={{ transform: `translateY(${vItem.start}px)` }}
|
||||
>
|
||||
<WorktreeCard
|
||||
worktree={row.worktree}
|
||||
repo={row.repo}
|
||||
isActive={activeWorktreeId === row.worktree.id}
|
||||
isMultiSelected={selectedWorktreeIds.has(row.worktree.id)}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={(event) => onContextMenuSelect(event, row.worktree)}
|
||||
hideRepoBadge={groupBy === 'repo'}
|
||||
/>
|
||||
<div
|
||||
className="group/lineage-row relative"
|
||||
style={{ paddingLeft: row.depth > 0 ? `${row.depth * 18}px` : undefined }}
|
||||
>
|
||||
<LineageGuides row={row} />
|
||||
<WorktreeCard
|
||||
worktree={row.worktree}
|
||||
repo={row.repo}
|
||||
isActive={activeWorktreeId === row.worktree.id}
|
||||
isMultiSelected={selectedWorktreeIds.has(row.worktree.id)}
|
||||
selectedWorktrees={selectedWorktrees}
|
||||
onSelectionGesture={onSelectionGesture}
|
||||
onContextMenuSelect={(event) => onContextMenuSelect(event, row.worktree)}
|
||||
hideRepoBadge={groupBy === 'repo'}
|
||||
parentLabel={
|
||||
row.depth > 0 && row.lineageState === 'valid' ? undefined : row.parentLabel
|
||||
}
|
||||
lineageState={row.lineageState}
|
||||
/>
|
||||
<LineageDisclosure
|
||||
row={row}
|
||||
onToggle={(event) => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (row.lineageGroupKey) {
|
||||
toggleGroup(row.lineageGroupKey)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
@@ -564,9 +712,11 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
const allWorktrees = useAllWorktrees()
|
||||
const repoMap = useRepoMap()
|
||||
const worktreeMap = useWorktreeMap()
|
||||
const worktreeLineageById = useAppStore((s) => s.worktreeLineageById)
|
||||
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
|
||||
const activeWorktreeId = useAppStore((s) => s.activeWorktreeId)
|
||||
const groupBy = useAppStore((s) => s.groupBy)
|
||||
const showWorkspaceLineage = useAppStore((s) => s.showWorkspaceLineage)
|
||||
const sortBy = useAppStore((s) => s.sortBy)
|
||||
const showActiveOnly = useAppStore((s) => s.showActiveOnly)
|
||||
const hideDefaultBranchWorkspace = useAppStore((s) => s.hideDefaultBranchWorkspace)
|
||||
@@ -872,8 +1022,29 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
|
||||
// Build flat row list for rendering
|
||||
const rows: Row[] = useMemo(
|
||||
() => buildRows(groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder),
|
||||
[groupBy, worktrees, repoMap, prCache, collapsedGroups, repoOrder]
|
||||
() =>
|
||||
buildRows(
|
||||
groupBy,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
collapsedGroups,
|
||||
repoOrder,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
showWorkspaceLineage
|
||||
),
|
||||
[
|
||||
groupBy,
|
||||
worktrees,
|
||||
repoMap,
|
||||
prCache,
|
||||
collapsedGroups,
|
||||
repoOrder,
|
||||
worktreeLineageById,
|
||||
worktreeMap,
|
||||
showWorkspaceLineage
|
||||
]
|
||||
)
|
||||
// Why: rows.length alone can stay the same when items migrate between
|
||||
// groups (e.g., PR cache loads on restart and a collapsed group absorbs
|
||||
@@ -1054,6 +1225,7 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
rows={rows}
|
||||
activeWorktreeId={selectedSidebarWorktreeId}
|
||||
groupBy={groupBy}
|
||||
showWorkspaceLineage={showWorkspaceLineage}
|
||||
toggleGroup={toggleGroup}
|
||||
collapsedGroups={collapsedGroups}
|
||||
handleCreateForRepo={handleCreateForRepo}
|
||||
@@ -1066,6 +1238,8 @@ const WorktreeList = React.memo(function WorktreeList() {
|
||||
onSelectionGesture={updateSelectionForGesture}
|
||||
onContextMenuSelect={selectForContextMenu}
|
||||
repoMap={repoMap}
|
||||
worktreeMap={worktreeMap}
|
||||
worktreeLineageById={worktreeLineageById}
|
||||
repoOrder={repoOrder}
|
||||
allRepoIds={allRepoIds}
|
||||
reorderRepos={(orderedIds) => {
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
/* eslint-disable max-lines -- Why: row-builder tests keep grouping, pinning, and lineage ordering cases together so expected row contracts stay comparable. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildRows, getPRGroupKey } from './worktree-list-groups'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import {
|
||||
buildRows,
|
||||
getLineageGroupKey,
|
||||
getLineageRenderInfo,
|
||||
getPRGroupKey
|
||||
} from './worktree-list-groups'
|
||||
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
|
||||
const repo: Repo = {
|
||||
id: 'repo-1',
|
||||
@@ -171,6 +177,224 @@ describe('buildRows repo grouping order', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildRows workspace lineage nesting', () => {
|
||||
const parent: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-parent',
|
||||
instanceId: 'parent-instance',
|
||||
displayName: 'coordinator'
|
||||
}
|
||||
const child: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-child',
|
||||
instanceId: 'child-instance',
|
||||
displayName: 'worker'
|
||||
}
|
||||
const grandchild: Worktree = {
|
||||
...worktree,
|
||||
id: 'wt-grandchild',
|
||||
instanceId: 'grandchild-instance',
|
||||
displayName: 'nested-worker'
|
||||
}
|
||||
const lineage: WorktreeLineage = {
|
||||
worktreeId: child.id,
|
||||
worktreeInstanceId: 'child-instance',
|
||||
parentWorktreeId: parent.id,
|
||||
parentWorktreeInstanceId: 'parent-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'terminal-context', confidence: 'inferred' },
|
||||
createdAt: 1
|
||||
}
|
||||
const grandchildLineage: WorktreeLineage = {
|
||||
worktreeId: grandchild.id,
|
||||
worktreeInstanceId: 'grandchild-instance',
|
||||
parentWorktreeId: child.id,
|
||||
parentWorktreeInstanceId: 'child-instance',
|
||||
origin: 'cli',
|
||||
capture: { source: 'terminal-context', confidence: 'inferred' },
|
||||
createdAt: 1
|
||||
}
|
||||
|
||||
it('keeps lineage flat when nesting is off', () => {
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[child, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
{ [child.id]: lineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child]
|
||||
])
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: child.id } })
|
||||
expect(rows[0]).not.toHaveProperty('parentLabel')
|
||||
expect(rows[1]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: parent.id }
|
||||
})
|
||||
})
|
||||
|
||||
it('places children directly under their parent when nesting is on', () => {
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[child, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
{ [child.id]: lineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({ type: 'item', worktree: { id: parent.id } })
|
||||
expect(rows[1]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: child.id },
|
||||
depth: 1,
|
||||
parentLabel: 'coordinator'
|
||||
})
|
||||
})
|
||||
|
||||
it('supports nested lineage chains beyond one level', () => {
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[grandchild, child, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
[grandchild.id, grandchild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows.map((row) => (row.type === 'item' ? row.worktree.id : row.key))).toEqual([
|
||||
parent.id,
|
||||
child.id,
|
||||
grandchild.id
|
||||
])
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: 'item',
|
||||
depth: 0,
|
||||
lineageChildCount: 1,
|
||||
lineageCollapsed: false
|
||||
})
|
||||
expect(rows[1]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: child.id },
|
||||
depth: 1,
|
||||
lineageChildCount: 1
|
||||
})
|
||||
expect(rows[2]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: grandchild.id },
|
||||
depth: 2,
|
||||
lineageChildCount: 0
|
||||
})
|
||||
})
|
||||
|
||||
it('collapses descendants under lineage parents', () => {
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[grandchild, child, parent],
|
||||
repoMap,
|
||||
null,
|
||||
new Set([getLineageGroupKey(parent.id)]),
|
||||
undefined,
|
||||
{ [child.id]: lineage, [grandchild.id]: grandchildLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child],
|
||||
[grandchild.id, grandchild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: parent.id },
|
||||
lineageChildCount: 1,
|
||||
lineageCollapsed: true
|
||||
})
|
||||
})
|
||||
|
||||
it('marks stale instance links as missing without creating a parent group', () => {
|
||||
const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' }
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[child],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
{ [child.id]: staleLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: child.id },
|
||||
lineageState: 'missing'
|
||||
})
|
||||
})
|
||||
|
||||
it('marks stale instance links as missing for shared context-menu validation', () => {
|
||||
const staleLineage = { ...lineage, parentWorktreeInstanceId: 'old-parent-instance' }
|
||||
const info = getLineageRenderInfo(
|
||||
child,
|
||||
{ [child.id]: staleLineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, child]
|
||||
])
|
||||
)
|
||||
|
||||
expect(info).toMatchObject({ state: 'missing' })
|
||||
})
|
||||
|
||||
it('keeps pinned children in Pinned with parent context', () => {
|
||||
const pinnedChild = { ...child, isPinned: true }
|
||||
const rows = buildRows(
|
||||
'none',
|
||||
[parent, pinnedChild],
|
||||
repoMap,
|
||||
null,
|
||||
new Set(),
|
||||
undefined,
|
||||
{ [child.id]: lineage },
|
||||
new Map([
|
||||
[parent.id, parent],
|
||||
[child.id, pinnedChild]
|
||||
]),
|
||||
true
|
||||
)
|
||||
|
||||
expect(rows[0]).toMatchObject({ type: 'header', key: 'pinned' })
|
||||
expect(rows[1]).toMatchObject({
|
||||
type: 'item',
|
||||
worktree: { id: child.id },
|
||||
parentLabel: 'coordinator'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('WorktreeList header styles', () => {
|
||||
it('does not title-case workspace group labels', () => {
|
||||
const source = readFileSync(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable max-lines -- Why: sidebar row construction keeps every grouping mode in one pure module so reveal, virtualized rendering, and tests share the same flat row contract. */
|
||||
import {
|
||||
CircleCheckBig,
|
||||
CircleDot,
|
||||
@@ -8,7 +9,7 @@ import {
|
||||
Pin
|
||||
} from 'lucide-react'
|
||||
import type React from 'react'
|
||||
import type { Repo, Worktree } from '../../../../shared/types'
|
||||
import type { Repo, Worktree, WorktreeLineage } from '../../../../shared/types'
|
||||
import { branchName } from '@/lib/git-utils'
|
||||
|
||||
export { branchName }
|
||||
@@ -23,7 +24,19 @@ export type GroupHeaderRow = {
|
||||
repo?: Repo
|
||||
}
|
||||
|
||||
export type WorktreeRow = { type: 'item'; worktree: Worktree; repo: Repo | undefined }
|
||||
export type WorktreeRow = {
|
||||
type: 'item'
|
||||
worktree: Worktree
|
||||
repo: Repo | undefined
|
||||
depth: number
|
||||
lineageTrail: boolean[]
|
||||
isLastLineageChild: boolean
|
||||
lineageChildCount: number
|
||||
lineageGroupKey?: string
|
||||
lineageCollapsed?: boolean
|
||||
parentLabel?: string
|
||||
lineageState?: 'valid' | 'missing'
|
||||
}
|
||||
export type Row = GroupHeaderRow | WorktreeRow
|
||||
|
||||
export type PRGroupKey = 'done' | 'in-review' | 'in-progress' | 'closed'
|
||||
@@ -81,6 +94,41 @@ export const ALL_GROUP_META = {
|
||||
icon: LayoutList
|
||||
} as const
|
||||
|
||||
export const MISSING_PARENT_GROUP_META = {
|
||||
label: 'Missing parent'
|
||||
} as const
|
||||
|
||||
export const LINEAGE_GROUP_PREFIX = 'lineage:'
|
||||
|
||||
export function getLineageGroupKey(worktreeId: string): string {
|
||||
return `${LINEAGE_GROUP_PREFIX}${worktreeId}`
|
||||
}
|
||||
|
||||
export type LineageRenderInfo =
|
||||
| { state: 'none' }
|
||||
| { state: 'valid'; lineage: WorktreeLineage; parent: Worktree }
|
||||
| { state: 'missing'; lineage: WorktreeLineage }
|
||||
|
||||
export function getLineageRenderInfo(
|
||||
worktree: Worktree,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>
|
||||
): LineageRenderInfo {
|
||||
const lineage = lineageById[worktree.id]
|
||||
if (!lineage) {
|
||||
return { state: 'none' }
|
||||
}
|
||||
const parent = worktreeMap.get(lineage.parentWorktreeId)
|
||||
if (
|
||||
!parent ||
|
||||
worktree.instanceId !== lineage.worktreeInstanceId ||
|
||||
parent.instanceId !== lineage.parentWorktreeInstanceId
|
||||
) {
|
||||
return { state: 'missing', lineage }
|
||||
}
|
||||
return { state: 'valid', lineage, parent }
|
||||
}
|
||||
|
||||
export function getPRGroupKey(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
@@ -117,8 +165,11 @@ export function getPRGroupKey(
|
||||
function emitPinnedGroup(
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>,
|
||||
collapsedGroups: Set<string>,
|
||||
result: Row[]
|
||||
result: Row[],
|
||||
showLineageContext: boolean
|
||||
): Set<string> {
|
||||
const pinned = worktrees.filter((w) => w.isPinned)
|
||||
if (pinned.length === 0) {
|
||||
@@ -134,13 +185,151 @@ function emitPinnedGroup(
|
||||
icon: PINNED_GROUP_META.icon
|
||||
})
|
||||
if (!collapsedGroups.has(PINNED_GROUP_KEY)) {
|
||||
for (const w of pinned) {
|
||||
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
|
||||
}
|
||||
appendWorktreeRows(result, pinned, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage: false,
|
||||
showLineageContext,
|
||||
collapsedGroups
|
||||
})
|
||||
}
|
||||
return new Set(pinned.map((w) => w.id))
|
||||
}
|
||||
|
||||
function buildWorktreeRow(
|
||||
worktree: Worktree,
|
||||
repoMap: Map<string, Repo>,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>,
|
||||
showLineageContext: boolean,
|
||||
depth: number,
|
||||
lineageTrail: boolean[],
|
||||
isLastLineageChild: boolean,
|
||||
lineageChildCount: number,
|
||||
lineageCollapsed: boolean
|
||||
): WorktreeRow {
|
||||
const lineage = showLineageContext
|
||||
? getLineageRenderInfo(worktree, lineageById, worktreeMap)
|
||||
: { state: 'none' as const }
|
||||
return {
|
||||
type: 'item',
|
||||
worktree,
|
||||
repo: repoMap.get(worktree.repoId),
|
||||
depth,
|
||||
lineageTrail,
|
||||
isLastLineageChild,
|
||||
lineageChildCount,
|
||||
...(lineageChildCount > 0 ? { lineageGroupKey: getLineageGroupKey(worktree.id) } : {}),
|
||||
...(lineageChildCount > 0 ? { lineageCollapsed } : {}),
|
||||
...(lineage.state === 'valid'
|
||||
? { parentLabel: lineage.parent.displayName, lineageState: 'valid' as const }
|
||||
: lineage.state === 'missing'
|
||||
? { parentLabel: MISSING_PARENT_GROUP_META.label, lineageState: 'missing' as const }
|
||||
: {})
|
||||
}
|
||||
}
|
||||
|
||||
function appendWorktreeRows(
|
||||
result: Row[],
|
||||
worktrees: Worktree[],
|
||||
repoMap: Map<string, Repo>,
|
||||
lineageById: Record<string, WorktreeLineage>,
|
||||
worktreeMap: Map<string, Worktree>,
|
||||
options: {
|
||||
nestLineage: boolean
|
||||
showLineageContext: boolean
|
||||
collapsedGroups: Set<string>
|
||||
}
|
||||
): void {
|
||||
const { nestLineage, showLineageContext, collapsedGroups } = options
|
||||
if (!nestLineage) {
|
||||
for (const worktree of worktrees) {
|
||||
result.push(
|
||||
buildWorktreeRow(
|
||||
worktree,
|
||||
repoMap,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
showLineageContext,
|
||||
0,
|
||||
[],
|
||||
false,
|
||||
0,
|
||||
false
|
||||
)
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIds = new Set(worktrees.map((worktree) => worktree.id))
|
||||
const childrenByParentId = new Map<string, Worktree[]>()
|
||||
const childIds = new Set<string>()
|
||||
for (const worktree of worktrees) {
|
||||
const lineage = getLineageRenderInfo(worktree, lineageById, worktreeMap)
|
||||
if (lineage.state !== 'valid' || !visibleIds.has(lineage.parent.id)) {
|
||||
continue
|
||||
}
|
||||
childIds.add(worktree.id)
|
||||
const children = childrenByParentId.get(lineage.parent.id) ?? []
|
||||
children.push(worktree)
|
||||
childrenByParentId.set(lineage.parent.id, children)
|
||||
}
|
||||
|
||||
const emitted = new Set<string>()
|
||||
const emit = (
|
||||
worktree: Worktree,
|
||||
depth: number,
|
||||
lineageTrail: boolean[],
|
||||
isLastChild: boolean
|
||||
): void => {
|
||||
if (emitted.has(worktree.id)) {
|
||||
return
|
||||
}
|
||||
const children = childrenByParentId.get(worktree.id) ?? []
|
||||
const lineageGroupKey = getLineageGroupKey(worktree.id)
|
||||
const lineageCollapsed = collapsedGroups.has(lineageGroupKey)
|
||||
emitted.add(worktree.id)
|
||||
result.push(
|
||||
buildWorktreeRow(
|
||||
worktree,
|
||||
repoMap,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
showLineageContext,
|
||||
depth,
|
||||
lineageTrail,
|
||||
isLastChild,
|
||||
children.length,
|
||||
lineageCollapsed
|
||||
)
|
||||
)
|
||||
if (lineageCollapsed) {
|
||||
return
|
||||
}
|
||||
children.forEach((child, index) => {
|
||||
emit(
|
||||
child,
|
||||
depth + 1,
|
||||
[...lineageTrail, index < children.length - 1],
|
||||
index === children.length - 1
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
const roots = worktrees.filter((worktree) => !childIds.has(worktree.id))
|
||||
for (const [index, worktree] of roots.entries()) {
|
||||
emit(worktree, 0, [], index === roots.length - 1)
|
||||
}
|
||||
if (roots.length === 0) {
|
||||
for (const worktree of worktrees) {
|
||||
if (!emitted.has(worktree.id)) {
|
||||
// Why: malformed cyclic lineage should not hide every participant.
|
||||
// Render any leftovers as roots rather than recursing forever.
|
||||
emit(worktree, 0, [], true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the flat row list consumed by the virtualizer.
|
||||
* Extracted here to keep WorktreeList.tsx under the line-count lint limit.
|
||||
@@ -151,11 +340,24 @@ export function buildRows(
|
||||
repoMap: Map<string, Repo>,
|
||||
prCache: Record<string, unknown> | null,
|
||||
collapsedGroups: Set<string>,
|
||||
repoOrder?: Map<string, number>
|
||||
repoOrder?: Map<string, number>,
|
||||
lineageById: Record<string, WorktreeLineage> = {},
|
||||
worktreeMap: Map<string, Worktree> = new Map(
|
||||
worktrees.map((worktree) => [worktree.id, worktree])
|
||||
),
|
||||
nestLineage = false
|
||||
): Row[] {
|
||||
const result: Row[] = []
|
||||
|
||||
const pinnedIds = emitPinnedGroup(worktrees, repoMap, collapsedGroups, result)
|
||||
const pinnedIds = emitPinnedGroup(
|
||||
worktrees,
|
||||
repoMap,
|
||||
lineageById,
|
||||
worktreeMap,
|
||||
collapsedGroups,
|
||||
result,
|
||||
nestLineage
|
||||
)
|
||||
const unpinned = pinnedIds.size > 0 ? worktrees.filter((w) => !pinnedIds.has(w.id)) : worktrees
|
||||
|
||||
if (groupBy === 'none') {
|
||||
@@ -175,9 +377,11 @@ export function buildRows(
|
||||
return result
|
||||
}
|
||||
}
|
||||
for (const w of unpinned) {
|
||||
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
|
||||
}
|
||||
appendWorktreeRows(result, unpinned, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
showLineageContext: nestLineage,
|
||||
collapsedGroups
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -264,9 +468,11 @@ export function buildRows(
|
||||
|
||||
result.push(header)
|
||||
if (!isCollapsed) {
|
||||
for (const w of group.items) {
|
||||
result.push({ type: 'item', worktree: w, repo: repoMap.get(w.repoId) })
|
||||
}
|
||||
appendWorktreeRows(result, group.items, repoMap, lineageById, worktreeMap, {
|
||||
nestLineage,
|
||||
showLineageContext: nestLineage,
|
||||
collapsedGroups
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -63,6 +63,7 @@ export function useIpcEvents(): void {
|
||||
const state = useAppStore.getState()
|
||||
const before = new Set((state.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
|
||||
await state.fetchWorktrees(data.repoId)
|
||||
await useAppStore.getState().fetchWorktreeLineage()
|
||||
const afterState = useAppStore.getState()
|
||||
const after = new Set((afterState.worktreesByRepo[data.repoId] ?? []).map((w) => w.id))
|
||||
const removed: string[] = []
|
||||
|
||||
@@ -35,6 +35,7 @@ export function getStartupErrorFallbackUI(uiHydrated: boolean): PersistedUIState
|
||||
sidebarWidth: 280,
|
||||
rightSidebarWidth: 350,
|
||||
groupBy: 'none',
|
||||
showWorkspaceLineage: false,
|
||||
sortBy: 'name',
|
||||
showActiveOnly: false,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
|
||||
@@ -292,6 +292,8 @@ export type UISlice = {
|
||||
clearOrcaHookTrustForRepo: (repoId: string) => void
|
||||
groupBy: 'none' | 'repo' | 'pr-status'
|
||||
setGroupBy: (g: UISlice['groupBy']) => void
|
||||
showWorkspaceLineage: boolean
|
||||
setShowWorkspaceLineage: (v: boolean) => void
|
||||
sortBy: 'name' | 'smart' | 'recent' | 'repo'
|
||||
setSortBy: (s: UISlice['sortBy']) => void
|
||||
showActiveOnly: boolean
|
||||
@@ -625,6 +627,9 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
set({ groupBy: g, collapsedGroups: new Set<string>() })
|
||||
},
|
||||
|
||||
showWorkspaceLineage: false,
|
||||
setShowWorkspaceLineage: (v) => set({ showWorkspaceLineage: v }),
|
||||
|
||||
sortBy: 'recent',
|
||||
setSortBy: (s) => set({ sortBy: s }),
|
||||
|
||||
@@ -788,7 +793,8 @@ export const createUISlice: StateCreator<AppState, [], [], UISlice> = (set, get)
|
||||
s.rightSidebarWidth,
|
||||
MAX_RIGHT_SIDEBAR_WIDTH
|
||||
),
|
||||
groupBy: ui.groupBy,
|
||||
groupBy: (ui.groupBy as UISlice['groupBy'] | 'parent') === 'parent' ? 'repo' : ui.groupBy,
|
||||
showWorkspaceLineage: ui.showWorkspaceLineage ?? false,
|
||||
sortBy,
|
||||
// Why: "Active only" is part of the user's sidebar working set, not a
|
||||
// transient render detail. Restoring it on launch keeps the filtered
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
WorkspaceCreateTelemetrySource,
|
||||
Worktree,
|
||||
WorktreeBaseStatusEvent,
|
||||
WorktreeLineage,
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeMeta
|
||||
} from '../../../../shared/types'
|
||||
@@ -20,6 +21,7 @@ export type WorktreeDeleteState = {
|
||||
|
||||
export type WorktreeSlice = {
|
||||
worktreesByRepo: Record<string, Worktree[]>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
activeWorktreeId: string | null
|
||||
deleteStateByWorktreeId: Record<string, WorktreeDeleteState>
|
||||
baseStatusByWorktreeId: Record<string, WorktreeBaseStatusEvent>
|
||||
@@ -62,6 +64,11 @@ export type WorktreeSlice = {
|
||||
hasHydratedWorktreePurge: boolean
|
||||
fetchWorktrees: (repoId: string) => Promise<void>
|
||||
fetchAllWorktrees: () => Promise<void>
|
||||
fetchWorktreeLineage: () => Promise<void>
|
||||
updateWorktreeLineage: (
|
||||
worktreeId: string,
|
||||
args: { parentWorktreeId?: string; noParent?: boolean }
|
||||
) => Promise<void>
|
||||
createWorktree: (
|
||||
repoId: string,
|
||||
name: string,
|
||||
|
||||
@@ -31,6 +31,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
|
||||
const candidate = next[index]
|
||||
return (
|
||||
worktree.id === candidate.id &&
|
||||
worktree.instanceId === candidate.instanceId &&
|
||||
worktree.repoId === candidate.repoId &&
|
||||
worktree.path === candidate.path &&
|
||||
worktree.head === candidate.head &&
|
||||
@@ -70,6 +71,7 @@ function toVisibleTabType(contentType: string): WorkspaceVisibleTabType {
|
||||
|
||||
export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice> = (set, get) => ({
|
||||
worktreesByRepo: {},
|
||||
worktreeLineageById: {},
|
||||
activeWorktreeId: null,
|
||||
deleteStateByWorktreeId: {},
|
||||
baseStatusByWorktreeId: {},
|
||||
@@ -176,6 +178,33 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
set({ hasHydratedWorktreePurge: true })
|
||||
},
|
||||
|
||||
fetchWorktreeLineage: async () => {
|
||||
try {
|
||||
const lineage = await window.api.worktrees.listLineage()
|
||||
set({ worktreeLineageById: lineage })
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch worktree lineage:', err)
|
||||
}
|
||||
},
|
||||
|
||||
updateWorktreeLineage: async (worktreeId, args) => {
|
||||
try {
|
||||
const lineage = await window.api.worktrees.updateLineage({ worktreeId, ...args })
|
||||
set((s) => {
|
||||
const next = { ...s.worktreeLineageById }
|
||||
if (lineage) {
|
||||
next[worktreeId] = lineage
|
||||
} else {
|
||||
delete next[worktreeId]
|
||||
}
|
||||
return { worktreeLineageById: next, sortEpoch: s.sortEpoch + 1 }
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update worktree lineage:', err)
|
||||
void get().fetchWorktreeLineage()
|
||||
}
|
||||
},
|
||||
|
||||
updateWorktreeGitIdentity: (worktreeId, identity) => {
|
||||
set((s) => {
|
||||
const repoId = getRepoIdFromWorktreeId(worktreeId)
|
||||
@@ -359,6 +388,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
}
|
||||
const nextDeleteState = { ...s.deleteStateByWorktreeId }
|
||||
delete nextDeleteState[worktreeId]
|
||||
const nextLineage = { ...s.worktreeLineageById }
|
||||
delete nextLineage[worktreeId]
|
||||
// Clean up editor files belonging to this worktree
|
||||
const newOpenFiles = s.openFiles.filter((f) => f.worktreeId !== worktreeId)
|
||||
const nextBrowserTabsByWorktree = { ...s.browserTabsByWorktree }
|
||||
@@ -456,6 +487,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
: s.lastVisitedAtByWorktreeId
|
||||
return {
|
||||
worktreesByRepo: next,
|
||||
worktreeLineageById: nextLineage,
|
||||
tabsByWorktree: nextTabs,
|
||||
ptyIdsByTabId: nextPtyIdsByTabId,
|
||||
runtimePaneTitlesByTabId: nextRuntimePaneTitlesByTabId,
|
||||
@@ -1043,6 +1075,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
|
||||
|
||||
return {
|
||||
// Worktree-scoped terminal/tab state
|
||||
worktreeLineageById: omitByWorktree(s.worktreeLineageById),
|
||||
tabsByWorktree: omitByWorktree(s.tabsByWorktree),
|
||||
terminalLayoutsByTabId: omitByTabId(s.terminalLayoutsByTabId),
|
||||
ptyIdsByTabId: omitByTabId(s.ptyIdsByTabId),
|
||||
|
||||
@@ -294,6 +294,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
|
||||
repos: [],
|
||||
sparsePresetsByRepo: {},
|
||||
worktreeMeta: {},
|
||||
worktreeLineageById: {},
|
||||
settings: getDefaultSettings(homedir),
|
||||
ui: getDefaultUIState(),
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
@@ -313,6 +314,7 @@ export function getDefaultUIState(): PersistedUIState {
|
||||
sidebarWidth: 280,
|
||||
rightSidebarWidth: 350,
|
||||
groupBy: 'repo',
|
||||
showWorkspaceLineage: false,
|
||||
sortBy: 'recent',
|
||||
showActiveOnly: false,
|
||||
hideDefaultBranchWorkspace: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/* eslint-disable max-lines -- Why: shared type definitions for all runtime RPC methods live in one file for discoverability and import simplicity. */
|
||||
import type { TerminalPaneLayoutNode } from './types'
|
||||
import type { TerminalPaneLayoutNode, WorktreeLineage, WorktreeLineageWarning } from './types'
|
||||
import type { BrowserSessionProfile, GitWorktreeInfo, Repo } from './types'
|
||||
import type {
|
||||
RuntimeMarkdownReadTabResult,
|
||||
@@ -294,6 +294,7 @@ export type RuntimeWorktreeStatus = 'active' | 'working' | 'permission' | 'done'
|
||||
|
||||
export type RuntimeWorktreeRecord = {
|
||||
id: string
|
||||
instanceId?: string
|
||||
repoId: string
|
||||
path: string
|
||||
branch: string
|
||||
@@ -303,6 +304,13 @@ export type RuntimeWorktreeRecord = {
|
||||
comment: string
|
||||
}
|
||||
|
||||
export type RuntimeWorktreeCreateResult = {
|
||||
worktree: RuntimeWorktreeRecord
|
||||
lineage: WorktreeLineage | null
|
||||
warnings: WorktreeLineageWarning[]
|
||||
warning?: string
|
||||
}
|
||||
|
||||
export type RuntimeWorktreePsResult = {
|
||||
worktrees: RuntimeWorktreePsSummary[]
|
||||
totalCount: number
|
||||
|
||||
@@ -108,6 +108,7 @@ export type GitWorktreeInfo = {
|
||||
// ─── Worktree (app-level, enriched) ──────────────────────────────────
|
||||
export type Worktree = {
|
||||
id: string // `${repoId}::${path}`
|
||||
instanceId?: string
|
||||
repoId: string
|
||||
displayName: string
|
||||
comment: string
|
||||
@@ -158,6 +159,8 @@ export type GitPushTarget = {
|
||||
|
||||
// ─── Worktree metadata (persisted user-authored fields only) ─────────
|
||||
export type WorktreeMeta = {
|
||||
/** Immutable per-workspace-instance ID used to reject stale lineage after path reuse. */
|
||||
instanceId?: string
|
||||
displayName: string
|
||||
comment: string
|
||||
linkedIssue: number | null
|
||||
@@ -186,6 +189,44 @@ export type WorktreeMeta = {
|
||||
diffComments?: DiffComment[]
|
||||
}
|
||||
|
||||
export type WorktreeLineageOrigin = 'orchestration' | 'cli' | 'manual'
|
||||
export type WorktreeLineageCaptureConfidence = 'explicit' | 'inferred'
|
||||
export type WorktreeLineageCaptureSource =
|
||||
| 'explicit-cli-flag'
|
||||
| 'terminal-context'
|
||||
| 'orchestration-context'
|
||||
| 'manual-action'
|
||||
|
||||
export type WorktreeLineageCapture = {
|
||||
source: WorktreeLineageCaptureSource
|
||||
confidence: WorktreeLineageCaptureConfidence
|
||||
}
|
||||
|
||||
export type WorktreeLineage = {
|
||||
worktreeId: string
|
||||
worktreeInstanceId: string
|
||||
parentWorktreeId: string
|
||||
parentWorktreeInstanceId: string
|
||||
origin: WorktreeLineageOrigin
|
||||
capture: WorktreeLineageCapture
|
||||
orchestrationRunId?: string
|
||||
taskId?: string
|
||||
coordinatorHandle?: string
|
||||
createdByTerminalHandle?: string
|
||||
createdAt: number
|
||||
}
|
||||
|
||||
export type WorktreeLineageWarningCode =
|
||||
| 'LINEAGE_PARENT_CONTEXT_MISSING'
|
||||
| 'LINEAGE_PARENT_CONTEXT_CONFLICT'
|
||||
| 'LINEAGE_PARENT_INSTANCE_STALE'
|
||||
|
||||
export type WorktreeLineageWarning = {
|
||||
code: WorktreeLineageWarningCode
|
||||
message: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// ─── Diff line comments ──────────────────────────────────────────────
|
||||
// Why: users leave review notes on specific lines of the modified side of
|
||||
// a diff so they can be handed back to an AI agent (pasted into a terminal
|
||||
@@ -955,6 +996,8 @@ export type CreateWorktreeArgs = {
|
||||
|
||||
export type CreateWorktreeResult = {
|
||||
worktree: Worktree
|
||||
lineage?: WorktreeLineage | null
|
||||
warnings?: WorktreeLineageWarning[]
|
||||
setup?: WorktreeSetupLaunch
|
||||
warning?: string
|
||||
initialBaseStatus?: WorktreeBaseStatusEvent
|
||||
@@ -1554,6 +1597,7 @@ export type PersistedUIState = {
|
||||
sidebarWidth: number
|
||||
rightSidebarWidth: number
|
||||
groupBy: 'none' | 'repo' | 'pr-status'
|
||||
showWorkspaceLineage?: boolean
|
||||
sortBy: 'name' | 'smart' | 'recent' | 'repo'
|
||||
showActiveOnly: boolean
|
||||
/** Hide the repo's original checked-out branch from workspace navigation
|
||||
@@ -1741,6 +1785,7 @@ export type PersistedState = {
|
||||
* presets are managed from the new-workspace composer and repo settings. */
|
||||
sparsePresetsByRepo: Record<string, SparsePreset[]>
|
||||
worktreeMeta: Record<string, WorktreeMeta>
|
||||
worktreeLineageById: Record<string, WorktreeLineage>
|
||||
settings: GlobalSettings
|
||||
ui: PersistedUIState
|
||||
githubCache: {
|
||||
|
||||
Reference in New Issue
Block a user