🔨 chore: support nested subtask tree in task.detail (#13625)

*  feat: support nested subtask tree in task.detail

Replace flat subtask list with recursive nested tree structure.
Backend builds the complete subtask tree in one response,
eliminating the need for separate getTaskTree API calls.

Fixes LOBE-6814

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 🐛 fix: return empty array for root subtasks instead of undefined

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* 📝 docs: add cli-backend-testing skill

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Arvin Xu 2026-04-08 12:49:26 +08:00 committed by GitHub
parent 12ee7c9e9a
commit 81ab8aa07b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 430 additions and 56 deletions

View file

@ -0,0 +1,218 @@
---
name: cli-backend-testing
description: >
CLI + Backend integration testing workflow. Use when verifying backend API changes
(TRPC routers, services, models) via the LobeHub CLI against a local dev server.
Triggers on 'cli test', 'test with cli', 'verify with cli', 'local cli test',
'backend test with cli', or when needing to validate server-side changes end-to-end.
---
# CLI + Backend Integration Testing
Standard workflow for verifying backend changes using the LobeHub CLI (`lh`) against a local dev server.
## When to Use
- Verifying TRPC router / service / model changes end-to-end
- Testing new API fields or response structure changes
- Validating CLI command output after backend modifications
- Debugging data flow issues between server and CLI
## Prerequisites
| Requirement | Details |
| ------------ | ------------------------------------------------------------- |
| Dev server | `localhost:3011` (Next.js) |
| CLI source | `lobehub/apps/cli/` |
| CLI dev mode | Uses `LOBEHUB_CLI_HOME=.lobehub-dev` for isolated credentials |
| Auth | Device Code Flow login to local server |
## Quick Reference
All CLI dev commands run from `lobehub/apps/cli/`:
```bash
# Shorthand for all commands below
CLI="LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts"
```
## Workflow
### Step 1: Ensure Dev Server is Running
Check if the dev server is already running:
```bash
curl -s -o /dev/null -w '%{http_code}' http://localhost:3011/ 2> /dev/null
```
- **If reachable** (returns any HTTP status): server is running. Skip to Step 2.
- **If unreachable**: start the server:
```bash
# From cloud repo root
pnpm run dev:next
```
To **restart** (pick up server-side code changes):
```bash
lsof -ti:3011 | xargs kill
pnpm run dev:next
```
**Important:** Server-side code changes in the submodule (`lobehub/src/server/`, `lobehub/packages/`) require a server restart. Next.js hot-reload may not pick up changes in submodule packages.
### Step 2: Check CLI Authentication
Check if dev credentials already exist:
```bash
cat lobehub/apps/cli/.lobehub-dev/settings.json 2> /dev/null
```
- **If file exists and contains `"serverUrl": "http://localhost:3011"`**: already authenticated. Skip to Step 3.
- **If file missing or points to wrong server**: login is needed. Ask the user to run:
```bash
! cd lobehub/apps/cli && LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts login --server http://localhost:3011
```
> Login requires interactive browser authorization (OIDC Device Code Flow), so the user must run it themselves via `!` prefix. After login, credentials are saved to `lobehub/apps/cli/.lobehub-dev/` and persist across sessions.
### Step 3: Test with CLI Commands
CLI runs from source (`bun src/index.ts`), so CLI-side code changes take effect immediately without rebuilding.
```bash
cd lobehub/apps/cli
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
```
### Step 4: Clean Up Test Data
Delete any test data created during verification:
```bash
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts task delete < id > -y
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts agent delete < id > -y
```
## Common Testing Patterns
### Task System
```bash
# List tasks
$CLI task list
# Create test data with nesting
$CLI task create -n "Root Task" -i "Test instruction"
$CLI task create -n "Child Task" -i "Sub instruction" --parent T-1
# View task detail (tests getTaskDetail service)
$CLI task view T-1
# View task tree
$CLI task tree T-1
# Test lifecycle
$CLI task edit T-1 --status running
$CLI task comment T-1 -m "Test comment"
# Clean up
$CLI task delete T-1 -y
```
### Agent System
```bash
# List agents
$CLI agent list
# View agent detail
$CLI agent view <agent-id>
# Run agent (tests agent execution pipeline)
$CLI agent run <agent-id> -m "Test prompt"
```
### Document & Knowledge Base
```bash
# List documents
$CLI doc list
# Create and view
$CLI doc create -t "Test Doc" -c "Content here"
$CLI doc view <doc-id>
# Knowledge base
$CLI kb list
$CLI kb tree <kb-id>
```
### Model & Provider
```bash
# List models and providers
$CLI model list
$CLI provider list
# Test provider connectivity
$CLI provider test <provider-id>
```
## Dev-Test Cycle
The standard cycle for backend development:
```
1. Make code changes (service/model/router/type)
|
2. Run unit tests (fast feedback)
bunx vitest run --silent='passed-only' '<test-file>'
|
3. Restart dev server (if server-side changes)
lsof -ti:3011 | xargs kill && pnpm run dev:next
|
4. CLI verification (end-to-end)
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
5. Clean up test data
```
### When Server Restart is Needed
| Change Location | Restart? |
| ----------------------------------------- | -------- |
| `lobehub/src/server/` (routers, services) | Yes |
| `lobehub/packages/database/` (models) | Yes |
| `lobehub/packages/types/` | Yes |
| `lobehub/packages/prompts/` | Yes |
| `lobehub/apps/cli/` (CLI code) | No |
| `src/` (cloud overrides) | Yes |
### When Server Restart is NOT Needed
CLI runs from source via `bun src/index.ts`, so any changes to `lobehub/apps/cli/src/` take effect immediately on next command invocation.
## Troubleshooting
| Issue | Solution |
| --------------------------- | --------------------------------------------------------------------- |
| `No authentication found` | Run `login --server http://localhost:3011` |
| `UNAUTHORIZED` on API calls | Token expired; re-run login |
| `ECONNREFUSED` | Dev server not running; start with `pnpm run dev:next` |
| CLI shows old data/behavior | Server needs restart to pick up code changes |
| `EADDRINUSE` on port 3011 | Server already running; kill with `lsof -ti:3011 \| xargs kill` |
| Login opens wrong server | Must use `--server http://localhost:3011` flag (env var doesn't work) |
## Credential Isolation
| Mode | Credential Dir | Server |
| ---------- | -------------------------------- | ----------------- |
| Dev | `lobehub/apps/cli/.lobehub-dev/` | `localhost:3011` |
| Production | `~/.lobehub/` | `app.lobehub.com` |
The two environments are completely isolated. Dev mode credentials are gitignored.

View file

@ -296,23 +296,34 @@ export function registerTaskCommand(program: Command) {
}
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
// ── Subtasks ──
// ── Subtasks (nested tree) ──
if (t.subtasks && t.subtasks.length > 0) {
// Build lookup: which subtasks are completed
const completedIdentifiers = new Set(
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
);
// Build lookup: which subtasks are completed (flatten tree)
const collectCompleted = (nodes: typeof t.subtasks, set: Set<string>): Set<string> => {
for (const s of nodes!) {
if (s.status === 'completed') set.add(s.identifier);
if (s.children) collectCompleted(s.children, set);
}
return set;
};
const completedIdentifiers = collectCompleted(t.subtasks, new Set());
const renderSubtasks = (nodes: typeof t.subtasks, indent: string) => {
for (const s of nodes!) {
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
console.log(
`${indent}${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
);
if (s.children && s.children.length > 0) {
renderSubtasks(s.children, indent + ' ');
}
}
};
console.log(`\n${pc.bold('Subtasks:')}`);
for (const s of t.subtasks) {
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
const isBlocked = s.blockedBy && !completedIdentifiers.has(s.blockedBy);
const displayStatus = s.status === 'backlog' && isBlocked ? 'blocked' : s.status;
console.log(
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
);
}
renderSubtasks(t.subtasks, ' ');
}
// ── Dependencies ──

View file

@ -271,6 +271,30 @@ export class TaskModel {
.orderBy(tasks.sortOrder, tasks.seq);
}
/**
* Fetch all descendants of a root task using Drizzle select() (returns camelCase fields).
* Uses breadth-first traversal with O(depth) queries.
*/
async findAllDescendants(rootTaskId: string): Promise<TaskItem[]> {
const all: TaskItem[] = [];
let parentIds = [rootTaskId];
while (parentIds.length > 0) {
const children = await this.db
.select()
.from(tasks)
.where(and(inArray(tasks.parentTaskId, parentIds), eq(tasks.createdByUserId, this.userId)))
.orderBy(tasks.sortOrder, tasks.seq);
if (children.length === 0) break;
all.push(...children);
parentIds = children.map((c) => c.id);
}
return all;
}
// Recursive query to get full task tree
async getTaskTree(rootTaskId: string): Promise<TaskItem[]> {
const result = await this.db.execute(sql`

View file

@ -125,16 +125,22 @@ export const formatTaskDetail = (t: TaskDetailData): string => {
);
}
// Subtasks
// Subtasks (nested tree)
if (t.subtasks && t.subtasks.length > 0) {
lines.push('');
lines.push('Subtasks:');
for (const s of t.subtasks) {
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
lines.push(
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
);
}
const renderSubtasks = (nodes: NonNullable<typeof t.subtasks>, indent: string) => {
for (const s of nodes) {
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
lines.push(
`${indent}${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
);
if (s.children && s.children.length > 0) {
renderSubtasks(s.children, indent + ' ');
}
}
};
renderSubtasks(t.subtasks, ' ');
}
// Checkpoint

View file

@ -42,6 +42,7 @@ export interface TaskTopicHandoff {
export interface TaskDetailSubtask {
blockedBy?: string;
children?: TaskDetailSubtask[];
identifier: string;
name?: string | null;
priority?: number | null;

View file

@ -27,7 +27,7 @@ describe('TaskService', () => {
const mockTaskModel = {
findById: vi.fn(),
findByIds: vi.fn(),
findSubtasks: vi.fn(),
findAllDescendants: vi.fn(),
getCheckpointConfig: vi.fn(),
getComments: vi.fn(),
getDependencies: vi.fn(),
@ -84,7 +84,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -136,7 +136,7 @@ describe('TaskService', () => {
const parentTask = { id: 'task_001', identifier: 'TASK-1', name: 'Parent Task' };
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -175,7 +175,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -213,11 +213,19 @@ describe('TaskService', () => {
};
const subtasks = [
{ id: 'task_002', identifier: 'TASK-2', name: 'Sub 1', priority: 'normal', status: 'todo' },
{
id: 'task_002',
identifier: 'TASK-2',
name: 'Sub 1',
parentTaskId: 'task_001',
priority: 'normal',
status: 'todo',
},
{
id: 'task_003',
identifier: 'TASK-3',
name: 'Sub 2',
parentTaskId: 'task_001',
priority: 'high',
status: 'in_progress',
},
@ -227,7 +235,7 @@ describe('TaskService', () => {
const subtaskDeps = [{ dependsOnId: 'task_002', taskId: 'task_003' }];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue(subtasks);
mockTaskModel.findAllDescendants.mockResolvedValue(subtasks);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -244,6 +252,7 @@ describe('TaskService', () => {
expect(result?.subtasks).toHaveLength(2);
expect(result?.subtasks?.[0]).toEqual({
blockedBy: undefined,
children: undefined,
identifier: 'TASK-2',
name: 'Sub 1',
priority: 'normal',
@ -251,6 +260,7 @@ describe('TaskService', () => {
});
expect(result?.subtasks?.[1]).toEqual({
blockedBy: 'TASK-2',
children: undefined,
identifier: 'TASK-3',
name: 'Sub 2',
priority: 'high',
@ -258,6 +268,91 @@ describe('TaskService', () => {
});
});
it('should build nested subtask tree with grandchildren', async () => {
const task = {
assigneeAgentId: null,
assigneeUserId: null,
createdAt: null,
description: null,
error: null,
heartbeatInterval: null,
heartbeatTimeout: null,
id: 'task_001',
identifier: 'TASK-1',
instruction: null,
lastHeartbeatAt: null,
name: 'Root Task',
parentTaskId: null,
priority: 'normal',
status: 'todo',
totalTopics: 0,
};
// 3-level tree: TASK-1 → TASK-2 → TASK-4, TASK-1 → TASK-3
const allDescendants = [
{
id: 'task_002',
identifier: 'TASK-2',
name: 'Child 1',
parentTaskId: 'task_001',
priority: 'normal',
status: 'todo',
},
{
id: 'task_003',
identifier: 'TASK-3',
name: 'Child 2',
parentTaskId: 'task_001',
priority: 'high',
status: 'completed',
},
{
id: 'task_004',
identifier: 'TASK-4',
name: 'Grandchild 1',
parentTaskId: 'task_002',
priority: 'normal',
status: 'running',
},
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findAllDescendants.mockResolvedValue(allDescendants);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
mockTaskModel.getComments.mockResolvedValue([]);
mockTaskModel.getTreePinnedDocuments.mockResolvedValue({ nodeMap: {}, tree: [] });
mockTaskModel.getDependenciesByTaskIds.mockResolvedValue([]);
mockTaskModel.findByIds.mockResolvedValue([]);
mockTaskModel.getCheckpointConfig.mockReturnValue({});
mockTaskModel.getReviewConfig.mockReturnValue(undefined);
const service = new TaskService(db, userId);
const result = await service.getTaskDetail('TASK-1');
// Root has 2 direct children
expect(result?.subtasks).toHaveLength(2);
// Child 1 has 1 grandchild
const child1 = result?.subtasks?.[0];
expect(child1?.identifier).toBe('TASK-2');
expect(child1?.children).toHaveLength(1);
expect(child1?.children?.[0]).toEqual({
blockedBy: undefined,
children: undefined,
identifier: 'TASK-4',
name: 'Grandchild 1',
priority: 'normal',
status: 'running',
});
// Child 2 has no children
const child2 = result?.subtasks?.[1];
expect(child2?.identifier).toBe('TASK-3');
expect(child2?.children).toBeUndefined();
});
it('should include dependencies with identifier and name', async () => {
const task = {
assigneeAgentId: null,
@ -282,7 +377,7 @@ describe('TaskService', () => {
const depTasks = [{ id: 'task_002', identifier: 'TASK-2', name: 'Task 2' }];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue(dependencies);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -323,7 +418,7 @@ describe('TaskService', () => {
const dependencies = [{ dependsOnId: 'task_missing', taskId: 'task_003', type: 'blocks' }];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue(dependencies);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -393,7 +488,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue(topics);
mockBriefModel.findByTaskId.mockResolvedValue(briefs);
@ -439,7 +534,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue(topics);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -476,7 +571,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -538,7 +633,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -585,7 +680,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -626,7 +721,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -663,7 +758,7 @@ describe('TaskService', () => {
};
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
// Simulate optional calls failing
mockTaskTopicModel.findWithHandoff.mockRejectedValue(new Error('DB error'));
@ -718,7 +813,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue(briefs);
@ -776,7 +871,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue(briefs);
@ -833,7 +928,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue(topics);
mockBriefModel.findByTaskId.mockResolvedValue([]);
@ -893,7 +988,7 @@ describe('TaskService', () => {
];
mockTaskModel.resolve.mockResolvedValue(task);
mockTaskModel.findSubtasks.mockResolvedValue([]);
mockTaskModel.findAllDescendants.mockResolvedValue([]);
mockTaskModel.getDependencies.mockResolvedValue([]);
mockTaskTopicModel.findWithHandoff.mockResolvedValue([]);
mockBriefModel.findByTaskId.mockResolvedValue(briefs);

View file

@ -1,6 +1,7 @@
import type {
TaskDetailActivity,
TaskDetailData,
TaskDetailSubtask,
TaskDetailWorkspaceNode,
TaskTopicHandoff,
WorkspaceData,
@ -28,8 +29,8 @@ export class TaskService {
const task = await this.taskModel.resolve(taskIdOrIdentifier);
if (!task) return null;
const [subtasks, dependencies, topics, briefs, comments, workspace] = await Promise.all([
this.taskModel.findSubtasks(task.id),
const [allDescendants, dependencies, topics, briefs, comments, workspace] = await Promise.all([
this.taskModel.findAllDescendants(task.id),
this.taskModel.getDependencies(task.id),
this.taskTopicModel.findWithHandoff(task.id).catch(() => []),
this.briefModel.findByTaskId(task.id).catch(() => []),
@ -37,19 +38,43 @@ export class TaskService {
this.taskModel.getTreePinnedDocuments(task.id).catch(() => emptyWorkspace),
]);
// Build subtask dependency map
const subtaskIds = subtasks.map((s) => s.id);
const subtaskDeps =
subtaskIds.length > 0
? await this.taskModel.getDependenciesByTaskIds(subtaskIds).catch(() => [])
// Build dependency map for all descendants
const allDescendantIds = allDescendants.map((s) => s.id);
const allDescendantDeps =
allDescendantIds.length > 0
? await this.taskModel.getDependenciesByTaskIds(allDescendantIds).catch(() => [])
: [];
const idToIdentifier = new Map(subtasks.map((s) => [s.id, s.identifier]));
const idToIdentifier = new Map(allDescendants.map((s) => [s.id, s.identifier]));
const depMap = new Map<string, string>();
for (const dep of subtaskDeps) {
for (const dep of allDescendantDeps) {
const depId = idToIdentifier.get(dep.dependsOnId);
if (depId) depMap.set(dep.taskId, depId);
}
// Build nested subtask tree
const childrenMap = new Map<string, typeof allDescendants>();
for (const t of allDescendants) {
const parentId = t.parentTaskId!;
if (!childrenMap.has(parentId)) childrenMap.set(parentId, []);
childrenMap.get(parentId)!.push(t);
}
const buildSubtaskTree = (parentId: string): TaskDetailSubtask[] | undefined => {
const children = childrenMap.get(parentId);
if (!children || children.length === 0) return undefined;
return children.map((s) => ({
blockedBy: depMap.get(s.id),
children: buildSubtaskTree(s.id),
identifier: s.identifier,
name: s.name,
priority: s.priority,
status: s.status,
}));
};
// Root level: always return array (empty [] when no subtasks) for consistent API shape
const subtasks = buildSubtaskTree(task.id) ?? [];
// Resolve dependency task identifiers
const depTaskIds = [...new Set(dependencies.map((d) => d.dependsOnId))];
const depTasks = await this.taskModel.findByIds(depTaskIds);
@ -153,13 +178,7 @@ export class TaskService {
review: this.taskModel.getReviewConfig(task),
status: task.status,
userId: task.assigneeUserId,
subtasks: subtasks.map((s) => ({
blockedBy: depMap.get(s.id),
identifier: s.identifier,
name: s.name,
priority: s.priority,
status: s.status,
})),
subtasks,
activities: activities.length > 0 ? activities : undefined,
topicCount: topics.length > 0 ? topics.length : undefined,
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,