mirror of
https://github.com/lobehub/lobehub
synced 2026-04-21 09:37:28 +00:00
🔨 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:
parent
12ee7c9e9a
commit
81ab8aa07b
7 changed files with 430 additions and 56 deletions
218
.agents/skills/cli-backend-testing/SKILL.md
Normal file
218
.agents/skills/cli-backend-testing/SKILL.md
Normal 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.
|
||||
|
|
@ -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 ──
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ export interface TaskTopicHandoff {
|
|||
|
||||
export interface TaskDetailSubtask {
|
||||
blockedBy?: string;
|
||||
children?: TaskDetailSubtask[];
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue