mirror of
https://github.com/lobehub/lobehub.git
synced 2026-03-26 13:19:34 +07:00
refactor
This commit is contained in:
@@ -71,16 +71,13 @@ export function registerDocCommands(task: Command) {
|
||||
if (!folder.startsWith('docs_')) {
|
||||
// folder is a name, find or create it
|
||||
const detail = await client.task.detail.query({ id });
|
||||
const workspace = detail.data.documents as any;
|
||||
const nodeMap = workspace?.nodeMap || {};
|
||||
const folders = detail.data.workspace || [];
|
||||
|
||||
// Search for existing folder by name
|
||||
const existing = Object.entries(nodeMap).find(
|
||||
([, doc]: [string, any]) => doc.title === folder && doc.fileType === 'custom/folder',
|
||||
);
|
||||
const existingFolder = folders.find((f) => f.title === folder);
|
||||
|
||||
if (existing) {
|
||||
folderId = existing[0];
|
||||
if (existingFolder) {
|
||||
folderId = existingFolder.documentId;
|
||||
} else {
|
||||
// Create folder and pin to task
|
||||
const result = await client.document.createDocument.mutate({
|
||||
|
||||
@@ -160,62 +160,40 @@ export function registerTaskCommand(program: Command) {
|
||||
);
|
||||
console.log(`${pc.dim('Instruction:')} ${t.instruction}`);
|
||||
if (t.description) console.log(`${pc.dim('Description:')} ${t.description}`);
|
||||
if (t.assigneeAgentId) console.log(`${pc.dim('Agent:')} ${t.assigneeAgentId}`);
|
||||
if (t.assigneeUserId) console.log(`${pc.dim('User:')} ${t.assigneeUserId}`);
|
||||
if (t.agentId) console.log(`${pc.dim('Agent:')} ${t.agentId}`);
|
||||
if (t.userId) console.log(`${pc.dim('User:')} ${t.userId}`);
|
||||
if (t.parent) {
|
||||
const p = t.parent as any;
|
||||
console.log(`${pc.dim('Parent:')} ${p.identifier} ${p.name || ''}`);
|
||||
console.log(`${pc.dim('Parent:')} ${t.parent.identifier} ${t.parent.name || ''}`);
|
||||
}
|
||||
console.log(
|
||||
`${pc.dim('Topics:')} ${t.totalTopics} ${pc.dim('Created:')} ${timeAgo(t.createdAt)}`,
|
||||
);
|
||||
if (t.heartbeatTimeout && t.lastHeartbeatAt) {
|
||||
const hb = timeAgo(t.lastHeartbeatAt);
|
||||
const interval = t.heartbeatInterval ? `${t.heartbeatInterval}s` : '-';
|
||||
const elapsed = (Date.now() - new Date(t.lastHeartbeatAt).getTime()) / 1000;
|
||||
const isStuck = t.status === 'running' && elapsed > t.heartbeatTimeout;
|
||||
const topicInfo = t.topicCount ? `${t.topicCount}` : '0';
|
||||
const createdInfo = t.createdAt ? timeAgo(t.createdAt) : '-';
|
||||
console.log(`${pc.dim('Topics:')} ${topicInfo} ${pc.dim('Created:')} ${createdInfo}`);
|
||||
if (t.heartbeat?.timeout && t.heartbeat.lastAt) {
|
||||
const hb = timeAgo(t.heartbeat.lastAt);
|
||||
const interval = t.heartbeat.interval ? `${t.heartbeat.interval}s` : '-';
|
||||
const elapsed = (Date.now() - new Date(t.heartbeat.lastAt).getTime()) / 1000;
|
||||
const isStuck = t.status === 'running' && elapsed > t.heartbeat.timeout;
|
||||
console.log(
|
||||
`${pc.dim('Heartbeat:')} ${isStuck ? pc.red(hb) : hb} ${pc.dim('interval:')} ${interval} ${pc.dim('timeout:')} ${t.heartbeatTimeout}s${isStuck ? pc.red(' ⚠ TIMEOUT') : ''}`,
|
||||
`${pc.dim('Heartbeat:')} ${isStuck ? pc.red(hb) : hb} ${pc.dim('interval:')} ${interval} ${pc.dim('timeout:')} ${t.heartbeat.timeout}s${isStuck ? pc.red(' ⚠ TIMEOUT') : ''}`,
|
||||
);
|
||||
}
|
||||
if (t.error) console.log(`${pc.red('Error:')} ${t.error}`);
|
||||
|
||||
// ── Subtasks ──
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
// Build dependency lookup: taskId → [dependsOnIdentifier, ...]
|
||||
const subtaskDeps = (t.subtaskDeps || []) as any[];
|
||||
const idToIdentifier = new Map<string, string>();
|
||||
for (const s of t.subtasks) idToIdentifier.set(s.id, s.identifier);
|
||||
|
||||
// Build lookup: which subtasks are completed
|
||||
const completedIds = new Set(
|
||||
t.subtasks.filter((s: any) => s.status === 'completed').map((s: any) => s.id),
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
|
||||
const blockedBy = new Map<string, string[]>();
|
||||
const hasUnresolvedDep = new Set<string>();
|
||||
for (const d of subtaskDeps) {
|
||||
if (d.type === 'blocks') {
|
||||
const list = blockedBy.get(d.taskId) || [];
|
||||
const depIdentifier = idToIdentifier.get(d.dependsOnId) || d.dependsOnId;
|
||||
list.push(depIdentifier);
|
||||
blockedBy.set(d.taskId, list);
|
||||
// Check if the dependency is NOT completed
|
||||
if (!completedIds.has(d.dependsOnId)) {
|
||||
hasUnresolvedDep.add(d.taskId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`\n${pc.bold('Subtasks:')}`);
|
||||
for (const s of t.subtasks) {
|
||||
const deps = blockedBy.get(s.id);
|
||||
const depInfo = deps ? pc.dim(` ← blocks: ${deps.join(', ')}`) : '';
|
||||
const depInfo = s.blockedBy ? pc.dim(` ← blocks: ${s.blockedBy}`) : '';
|
||||
// Show 'blocked' instead of 'backlog' if task has unresolved dependencies
|
||||
const displayStatus =
|
||||
s.status === 'backlog' && hasUnresolvedDep.has(s.id) ? 'blocked' : s.status;
|
||||
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 || s.instruction}${depInfo}`,
|
||||
` ${pc.dim(s.identifier)} ${statusBadge(displayStatus)} ${s.name || '(unnamed)'}${depInfo}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -223,30 +201,31 @@ export function registerTaskCommand(program: Command) {
|
||||
// ── Dependencies ──
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
console.log(`\n${pc.bold('Dependencies:')}`);
|
||||
for (const d of t.dependencies as any[]) {
|
||||
console.log(` ${pc.dim(d.type)}: ${d.dependsOnId}`);
|
||||
for (const d of t.dependencies) {
|
||||
const depName = d.name ? ` ${d.name}` : '';
|
||||
console.log(` ${pc.dim(d.type || 'blocks')}: ${d.dependsOn}${depName}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Checkpoint ──
|
||||
{
|
||||
const cp = t.checkpoint as any;
|
||||
const cp = t.checkpoint || {};
|
||||
console.log(`\n${pc.bold('Checkpoint:')}`);
|
||||
const hasConfig =
|
||||
cp.onAgentRequest !== undefined ||
|
||||
cp.topic?.before ||
|
||||
cp.topic?.after ||
|
||||
cp.tasks?.beforeIds?.length > 0 ||
|
||||
cp.tasks?.afterIds?.length > 0;
|
||||
cp.tasks?.beforeIds?.length ||
|
||||
cp.tasks?.afterIds?.length;
|
||||
|
||||
if (hasConfig) {
|
||||
if (cp.onAgentRequest !== undefined)
|
||||
console.log(` onAgentRequest: ${cp.onAgentRequest}`);
|
||||
if (cp.topic?.before) console.log(` topic.before: ${cp.topic.before}`);
|
||||
if (cp.topic?.after) console.log(` topic.after: ${cp.topic.after}`);
|
||||
if (cp.tasks?.beforeIds?.length > 0)
|
||||
if (cp.tasks?.beforeIds?.length)
|
||||
console.log(` tasks.before: ${cp.tasks.beforeIds.join(', ')}`);
|
||||
if (cp.tasks?.afterIds?.length > 0)
|
||||
if (cp.tasks?.afterIds?.length)
|
||||
console.log(` tasks.after: ${cp.tasks.afterIds.join(', ')}`);
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured, default: onAgentRequest=true)')}`);
|
||||
@@ -280,108 +259,93 @@ export function registerTaskCommand(program: Command) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activities (last section) ──
|
||||
// ── Workspace ──
|
||||
{
|
||||
// ── Workspace ──
|
||||
{
|
||||
const workspace = t.documents as any;
|
||||
const nodeMap = workspace?.nodeMap || {};
|
||||
const tree = workspace?.tree || [];
|
||||
const docCount = Object.keys(nodeMap).length;
|
||||
const nodes = t.workspace || [];
|
||||
if (nodes.length === 0) {
|
||||
console.log(`\n${pc.bold('Workspace:')}`);
|
||||
console.log(` ${pc.dim('No documents yet.')}`);
|
||||
} else {
|
||||
const countNodes = (list: typeof nodes): number =>
|
||||
list.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
console.log(`\n${pc.bold(`Workspace (${countNodes(nodes)}):`)}`);
|
||||
|
||||
if (docCount === 0) {
|
||||
console.log(`\n${pc.bold('Workspace:')}`);
|
||||
console.log(` ${pc.dim('No documents yet.')}`);
|
||||
} else {
|
||||
console.log(`\n${pc.bold(`Workspace (${docCount}):`)}`);
|
||||
const formatSize = (chars: number | null | undefined) => {
|
||||
if (!chars) return '';
|
||||
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
|
||||
return `${chars}`;
|
||||
};
|
||||
|
||||
const formatSize = (chars: number | null) => {
|
||||
if (!chars) return '';
|
||||
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
|
||||
return `${chars}`;
|
||||
};
|
||||
const LEFT_COL = 56;
|
||||
const FROM_WIDTH = 10;
|
||||
|
||||
const LEFT_COL = 56; // fixed display width from start to after title (including indent)
|
||||
const FROM_WIDTH = 10;
|
||||
|
||||
const printNode = (node: any, prefix: string, isLast: boolean, isRoot: boolean) => {
|
||||
const doc = nodeMap[node.id];
|
||||
if (!doc) return;
|
||||
const isFolder = doc.fileType === 'folder' || doc.fileType === 'custom/folder';
|
||||
const renderNodes = (list: typeof nodes, indent: string) => {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
const node = list[i];
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const isLast = i === list.length - 1;
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const connector = isRoot ? ' ' : isLast ? ' └── ' : ' ├── ';
|
||||
|
||||
// Prefix + connector + icon take variable width; pad title to fill LEFT_COL
|
||||
const leftPrefix = `${prefix}${connector}${icon} `;
|
||||
const prefixWidth = displayWidth(leftPrefix);
|
||||
const maxTitle = Math.max(10, LEFT_COL - prefixWidth);
|
||||
const titleStr = truncate(doc.title, maxTitle);
|
||||
const titleWidth = displayWidth(titleStr);
|
||||
const titlePad = ' '.repeat(Math.max(1, LEFT_COL - prefixWidth - titleWidth));
|
||||
|
||||
// Source column (fixed width)
|
||||
const fromStr = doc.sourceTaskIdentifier ? `← ${doc.sourceTaskIdentifier}` : '';
|
||||
const fromPad = ' '.repeat(Math.max(1, FROM_WIDTH - fromStr.length + 1));
|
||||
|
||||
// Right columns
|
||||
const size =
|
||||
!isFolder && doc.charCount
|
||||
? formatSize(doc.charCount).padStart(6) + ' chars'
|
||||
: ''.padStart(12);
|
||||
const updated = doc.updatedAt ? timeAgo(doc.updatedAt).padStart(7) : '';
|
||||
|
||||
console.log(
|
||||
`${leftPrefix}${titleStr}${titlePad}${pc.dim(`(${node.id})`)} ${fromStr}${fromPad}${pc.dim(`${size} ${updated}`)}`,
|
||||
const prefix = `${indent}${icon} `;
|
||||
const titleStr = truncate(node.title || 'Untitled', LEFT_COL - displayWidth(prefix));
|
||||
const titlePad = ' '.repeat(
|
||||
Math.max(1, LEFT_COL - displayWidth(prefix) - displayWidth(titleStr)),
|
||||
);
|
||||
|
||||
const kids = node.children || [];
|
||||
const childPrefix = isRoot ? '' : prefix + (isLast ? ' ' : ' │ ');
|
||||
kids.forEach((child: any, i: number) => {
|
||||
printNode(child, childPrefix, i === kids.length - 1, false);
|
||||
});
|
||||
};
|
||||
const fromStr = node.sourceTaskIdentifier ? `← ${node.sourceTaskIdentifier}` : '';
|
||||
const fromPad = fromStr
|
||||
? ' '.repeat(Math.max(1, FROM_WIDTH - fromStr.length + 1))
|
||||
: '';
|
||||
const size =
|
||||
!isFolder && node.size ? formatSize(node.size).padStart(6) + ' chars' : '';
|
||||
|
||||
tree.forEach((node: any, i: number) => {
|
||||
printNode(node, '', i === tree.length - 1, true);
|
||||
});
|
||||
}
|
||||
console.log(
|
||||
`${prefix}${titleStr}${titlePad}${pc.dim(`(${node.documentId})`)} ${fromStr}${fromPad}${pc.dim(size)}`,
|
||||
);
|
||||
|
||||
if (node.children && node.children.length > 0) {
|
||||
const childIndent = indent + (isLast ? ' ' : ' ');
|
||||
renderNodes(node.children, childIndent);
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(nodes, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
const activities: {
|
||||
data: any;
|
||||
time: number;
|
||||
type: 'topic' | 'brief' | 'comment' | 'review';
|
||||
}[] = [];
|
||||
// ── Activities ──
|
||||
{
|
||||
const tl = t.timeline;
|
||||
const activities: { text: string; time: string }[] = [];
|
||||
|
||||
for (const tp of t.topics || []) {
|
||||
for (const tp of tl?.topics || []) {
|
||||
const sBadge = statusBadge(tp.status || 'running');
|
||||
activities.push({
|
||||
data: tp,
|
||||
time: new Date(tp.createdAt).getTime(),
|
||||
type: 'topic',
|
||||
});
|
||||
// Add review as separate activity if topic has been reviewed
|
||||
if (tp.reviewedAt) {
|
||||
activities.push({
|
||||
data: tp,
|
||||
time: new Date(tp.reviewedAt).getTime(),
|
||||
type: 'review',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
for (const b of t.briefs || []) {
|
||||
activities.push({
|
||||
data: b,
|
||||
time: new Date(b.createdAt).getTime(),
|
||||
type: 'brief',
|
||||
text: ` 💬 ${pc.dim((tp.time || '').padStart(7))} Topic #${tp.seq || '?'} ${tp.title || 'Untitled'} ${sBadge} ${pc.dim(tp.id || '')}`,
|
||||
time: tp.time || '',
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of t.comments || []) {
|
||||
for (const b of tl?.briefs || []) {
|
||||
const icon = briefIcon(b.type);
|
||||
const pri =
|
||||
b.priority === 'urgent'
|
||||
? pc.red(' [urgent]')
|
||||
: b.priority === 'normal'
|
||||
? pc.yellow(' [normal]')
|
||||
: '';
|
||||
const resolved = b.resolvedAction ? pc.green(` ✏️ ${b.resolvedAction}`) : '';
|
||||
const typeLabel = pc.dim(`[${b.type}]`);
|
||||
activities.push({
|
||||
data: c,
|
||||
time: new Date(c.createdAt).getTime(),
|
||||
type: 'comment',
|
||||
text: ` ${icon} ${pc.dim((b.time || '').padStart(7))} Brief ${typeLabel} ${b.title}${pri}${resolved} ${pc.dim(b.id || '')}`,
|
||||
time: b.time || '',
|
||||
});
|
||||
}
|
||||
|
||||
for (const c of tl?.comments || []) {
|
||||
const author = c.agentId ? `🤖 ${c.agentId}` : '👤 user';
|
||||
activities.push({
|
||||
text: ` 💭 ${pc.dim((c.time || '').padStart(7))} ${pc.cyan(author)} ${c.content}`,
|
||||
time: c.time || '',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -389,66 +353,9 @@ export function registerTaskCommand(program: Command) {
|
||||
if (activities.length === 0) {
|
||||
console.log(` ${pc.dim('No activities yet.')}`);
|
||||
} else {
|
||||
activities.sort((a, b) => a.time - b.time);
|
||||
|
||||
const pad = (s: string, w: number) => s.padStart(w);
|
||||
|
||||
// Activities are already sorted by the service
|
||||
for (const act of activities) {
|
||||
const ago = pad(
|
||||
timeAgo(act.type === 'review' ? act.data.reviewedAt : act.data.createdAt),
|
||||
7,
|
||||
);
|
||||
|
||||
if (act.type === 'topic') {
|
||||
const tp = act.data;
|
||||
const sBadge = statusBadge(tp.status || 'running');
|
||||
console.log(
|
||||
` 💬 ${pc.dim(ago)} Topic #${tp.seq} ${tp.title || 'Untitled'} ${sBadge} ${pc.dim(tp.id)}`,
|
||||
);
|
||||
} else if (act.type === 'review') {
|
||||
const tp = act.data;
|
||||
const passed = tp.reviewPassed === 1;
|
||||
const icon = passed ? pc.green('✓') : pc.red('✗');
|
||||
const scoreText = ((tp.reviewScores as any[]) || [])
|
||||
.map((s: any) => {
|
||||
const pct = Math.round(s.score * 100);
|
||||
const sIcon = s.passed ? pc.green('✓') : pc.red('✗');
|
||||
return `${s.rubricId} ${pct}%${sIcon}`;
|
||||
})
|
||||
.join(' | ');
|
||||
const iter =
|
||||
tp.reviewIteration > 1 ? pc.dim(` (iteration ${tp.reviewIteration})`) : '';
|
||||
console.log(
|
||||
` 🔍 ${pc.dim(ago)} Review ${icon} ${passed ? 'passed' : 'failed'} (${tp.reviewScore}%) ${pc.dim(scoreText)}${iter}`,
|
||||
);
|
||||
} else if (act.type === 'comment') {
|
||||
const c = act.data;
|
||||
const author = c.agentId ? `🤖 ${c.agentId}` : '👤 user';
|
||||
console.log(` 💭 ${pc.dim(ago)} ${pc.cyan(author)} ${c.content}`);
|
||||
} else {
|
||||
const b = act.data;
|
||||
const icon = briefIcon(b.type);
|
||||
const pri =
|
||||
b.priority === 'urgent'
|
||||
? pc.red(' [urgent]')
|
||||
: b.priority === 'normal'
|
||||
? pc.yellow(' [normal]')
|
||||
: '';
|
||||
let statusTag: string;
|
||||
if (b.resolvedAt) {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
const matched = actions.find((a: any) => a.key === b.resolvedAction);
|
||||
statusTag = pc.green(` ${matched?.label || '✓'}`);
|
||||
} else if (b.readAt) {
|
||||
statusTag = pc.dim(' (read)');
|
||||
} else {
|
||||
statusTag = pc.yellow(' ●');
|
||||
}
|
||||
const typeLabel = pc.dim(`[${b.type}]`);
|
||||
console.log(
|
||||
` ${icon} ${pc.dim(ago)} Brief ${typeLabel} ${b.title}${pri}${statusTag} ${pc.dim(b.id)}`,
|
||||
);
|
||||
}
|
||||
console.log(act.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -818,6 +818,22 @@ export function renderStepDetail(
|
||||
}
|
||||
}
|
||||
|
||||
// Default view: show tool errors even without -t flag
|
||||
if (!hasSpecificFlag && step.toolsResult) {
|
||||
const failedResults = step.toolsResult.filter((tr) => tr.isSuccess === false);
|
||||
if (failedResults.length > 0) {
|
||||
lines.push('');
|
||||
lines.push(bold(red('Errors:')));
|
||||
for (const tr of failedResults) {
|
||||
lines.push(` ${red('✗')} ${cyan(tr.identifier || tr.apiName)}`);
|
||||
if (tr.output) {
|
||||
const output = tr.output.length > 500 ? tr.output.slice(0, 500) + '...' : tr.output;
|
||||
lines.push(` ${red(output)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.tools) {
|
||||
if (step.toolsCalling && step.toolsCalling.length > 0) {
|
||||
lines.push('');
|
||||
|
||||
@@ -162,6 +162,27 @@ export const TaskManifest: BuiltinToolManifest = {
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Update a task's status. Use to mark tasks as completed, canceled, or change lifecycle state. Defaults to the current task if no identifier provided.",
|
||||
name: TaskApiName.updateTaskStatus,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description:
|
||||
'The task identifier (e.g. "TASK-1"). Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
description: 'New status for the task.',
|
||||
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['status'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'Delete a task by identifier.',
|
||||
name: TaskApiName.deleteTask,
|
||||
|
||||
@@ -4,9 +4,11 @@ export const systemPrompt = `You have access to Task management tools. Use them
|
||||
- **listTasks**: List tasks, optionally filtered by parent or status
|
||||
- **viewTask**: View details of a specific task (defaults to your current task)
|
||||
- **editTask**: Modify a task's name, instruction, priority, dependencies (addDependency/removeDependency), or review config
|
||||
- **updateTaskStatus**: Change a task's status (e.g. mark as completed when done, or cancel if no longer needed)
|
||||
- **deleteTask**: Delete a task
|
||||
|
||||
When planning work:
|
||||
1. Create tasks for each major piece of work (use parentIdentifier to organize as subtasks)
|
||||
2. Use editTask with addDependency to control execution order
|
||||
3. Configure review criteria on tasks that need quality gates`;
|
||||
3. Configure review criteria on tasks that need quality gates
|
||||
4. Use updateTaskStatus to mark the current task as completed when you finish all work`;
|
||||
|
||||
@@ -11,6 +11,9 @@ export const TaskApiName = {
|
||||
/** List tasks with optional filters */
|
||||
listTasks: 'listTasks',
|
||||
|
||||
/** Update a task's status (e.g. complete, cancel) */
|
||||
updateTaskStatus: 'updateTaskStatus',
|
||||
|
||||
/** View details of a specific task */
|
||||
viewTask: 'viewTask',
|
||||
} as const;
|
||||
|
||||
@@ -81,6 +81,14 @@ export class TaskModel {
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByIds(ids: string[]): Promise<TaskItem[]> {
|
||||
if (ids.length === 0) return [];
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)));
|
||||
}
|
||||
|
||||
// Resolve id or identifier (e.g. 'TASK-1') to a task
|
||||
async resolve(idOrIdentifier: string): Promise<TaskItem | null> {
|
||||
if (idOrIdentifier.startsWith('task_')) return this.findById(idOrIdentifier);
|
||||
|
||||
@@ -32,5 +32,5 @@ export const resolveMaxTokens = async ({
|
||||
|
||||
const hasSmallContextWindow = smallContextWindowPatterns.some((pattern) => pattern.test(model));
|
||||
|
||||
return hasSmallContextWindow ? 4096 : 8192;
|
||||
return hasSmallContextWindow ? 4096 : 16_384;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { TaskDetailData, TaskDetailWorkspaceNode } from '@lobechat/types';
|
||||
|
||||
// ── Formatting helpers for Task tool responses ──
|
||||
|
||||
const priorityLabel = (p?: number | null): string => {
|
||||
@@ -53,12 +55,15 @@ export interface TaskSummary {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDetail extends TaskSummary {
|
||||
dependencies?: Array<{ dependsOn: string; type: string }>;
|
||||
instruction: string;
|
||||
parentTaskId?: string | null;
|
||||
subtasks?: TaskSummary[];
|
||||
}
|
||||
// Re-export shared types from @lobechat/types for backward compatibility
|
||||
export type {
|
||||
TaskDetailBrief,
|
||||
TaskDetailComment,
|
||||
TaskDetailData,
|
||||
TaskDetailSubtask,
|
||||
TaskDetailTopic,
|
||||
TaskDetailWorkspaceNode,
|
||||
} from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Format a single task as a one-line summary
|
||||
@@ -104,28 +109,131 @@ export const formatTaskList = (
|
||||
/**
|
||||
* Format viewTask response
|
||||
*/
|
||||
export const formatTaskDetail = (t: TaskDetail): string => {
|
||||
export const formatTaskDetail = (t: TaskDetailData): string => {
|
||||
const lines = [
|
||||
`${t.identifier} "${t.name || '(unnamed)'}"`,
|
||||
` Status: ${statusIcon(t.status)} ${t.status}`,
|
||||
` Priority: ${priorityLabel(t.priority)}`,
|
||||
` Instruction: ${t.instruction}`,
|
||||
`${t.identifier} ${t.name || '(unnamed)'}`,
|
||||
`Status: ${statusIcon(t.status)} ${t.status} Priority: ${priorityLabel(t.priority)}`,
|
||||
`Instruction: ${t.instruction}`,
|
||||
];
|
||||
|
||||
if (t.parentTaskId) lines.push(` Parent: ${t.parentTaskId}`);
|
||||
if (t.agentId) lines.push(`Agent: ${t.agentId}`);
|
||||
if (t.parent) lines.push(`Parent: ${t.parent.identifier}`);
|
||||
if (t.topicCount) lines.push(`Topics: ${t.topicCount}`);
|
||||
if (t.createdAt) lines.push(`Created: ${t.createdAt}`);
|
||||
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
lines.push(
|
||||
`Dependencies: ${t.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
if (t.subtasks && t.subtasks.length > 0) {
|
||||
lines.push(` Subtasks (${t.subtasks.length}):`);
|
||||
lines.push('');
|
||||
lines.push('Subtasks:');
|
||||
for (const s of t.subtasks) {
|
||||
lines.push(` ${formatTaskLine(s)}`);
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
lines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
lines.push(` Dependencies (${t.dependencies.length}):`);
|
||||
for (const d of t.dependencies) {
|
||||
lines.push(` ${d.type}: ${d.dependsOn}`);
|
||||
// Checkpoint
|
||||
lines.push('');
|
||||
if (t.checkpoint && Object.keys(t.checkpoint).length > 0) {
|
||||
lines.push(`Checkpoint: ${JSON.stringify(t.checkpoint)}`);
|
||||
} else {
|
||||
lines.push('Checkpoint: (not configured, default: onAgentRequest=true)');
|
||||
}
|
||||
|
||||
// Review
|
||||
lines.push('');
|
||||
if (t.review && Object.keys(t.review).length > 0) {
|
||||
const rubrics = (t.review as any).rubrics as
|
||||
| Array<{ name: string; threshold?: number; type: string }>
|
||||
| undefined;
|
||||
lines.push(`Review (maxIterations: ${(t.review as any).maxIterations || 3}):`);
|
||||
if (rubrics) {
|
||||
for (const r of rubrics) {
|
||||
lines.push(
|
||||
` - ${r.name} [${r.type}]${r.threshold ? ` ≥ ${Math.round(r.threshold * 100)}%` : ''}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
lines.push('Review: (not configured)');
|
||||
}
|
||||
|
||||
// Workspace
|
||||
if (t.workspace && t.workspace.length > 0) {
|
||||
const countNodes = (nodes: TaskDetailWorkspaceNode[]): number =>
|
||||
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
const total = countNodes(t.workspace);
|
||||
lines.push('');
|
||||
lines.push(`Workspace (${total}):`);
|
||||
|
||||
const renderNodes = (nodes: TaskDetailWorkspaceNode[], indent: string) => {
|
||||
for (const node of nodes) {
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const source = node.sourceTaskIdentifier ? ` ← ${node.sourceTaskIdentifier}` : '';
|
||||
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
|
||||
lines.push(
|
||||
`${indent}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}`,
|
||||
);
|
||||
if (node.children) {
|
||||
renderNodes(node.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(t.workspace, ' ');
|
||||
}
|
||||
|
||||
// Activities
|
||||
const timelineEntries: { text: string; time: number }[] = [];
|
||||
|
||||
if (t.timeline?.topics) {
|
||||
for (const tp of t.timeline.topics) {
|
||||
const status = tp.status || 'completed';
|
||||
const idSuffix = tp.id ? ` ${tp.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` 💬 ${tp.time || ''} Topic #${tp.seq || '?'} ${tp.title || 'Untitled'} ${statusIcon(status)} ${status}${idSuffix}`,
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (t.timeline?.briefs) {
|
||||
for (const b of t.timeline.briefs) {
|
||||
let resolved = '';
|
||||
if (b.resolvedAction) {
|
||||
resolved = ` ✏️ ${b.resolvedAction}`;
|
||||
}
|
||||
const priStr = b.priority ? ` [${b.priority}]` : '';
|
||||
const idSuffix = b.id ? ` ${b.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` ${briefIcon(b.type)} ${b.time || ''} Brief [${b.type}] ${b.title}${priStr}${resolved}${idSuffix}`,
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (t.timeline?.comments) {
|
||||
for (const c of t.timeline.comments) {
|
||||
const author = c.agentId ? '🤖 agent' : '👤 user';
|
||||
const truncated = c.content.length > 80 ? c.content.slice(0, 80) + '...' : c.content;
|
||||
timelineEntries.push({
|
||||
text: ` 💭 ${c.time || ''} ${author} ${truncated}`,
|
||||
time: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineEntries.length > 0) {
|
||||
lines.push('');
|
||||
lines.push('Activities:');
|
||||
lines.push(...timelineEntries.map((e) => e.text));
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
@@ -207,22 +315,16 @@ export interface TaskRunPromptSubtask {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptDocument {
|
||||
export interface TaskRunPromptWorkspaceNode {
|
||||
children?: TaskRunPromptWorkspaceNode[];
|
||||
createdAt?: string;
|
||||
documentId: string;
|
||||
/** Character count of the document content */
|
||||
fileType?: string;
|
||||
size?: number;
|
||||
sourceTaskIdentifier?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptFolder {
|
||||
children: TaskRunPromptDocument[];
|
||||
createdAt?: string;
|
||||
documentId: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptInput {
|
||||
/** Activity data (all optional) */
|
||||
activities?: {
|
||||
@@ -260,7 +362,7 @@ export interface TaskRunPromptInput {
|
||||
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
|
||||
};
|
||||
/** Pinned documents (workspace) */
|
||||
workspace?: TaskRunPromptFolder[];
|
||||
workspace?: TaskRunPromptWorkspaceNode[];
|
||||
}
|
||||
|
||||
// ── Relative time helper ──
|
||||
@@ -378,21 +480,28 @@ export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): strin
|
||||
|
||||
// Workspace
|
||||
if (workspace && workspace.length > 0) {
|
||||
const totalDocs = workspace.reduce((sum, f) => sum + f.children.length, 0);
|
||||
const countNodes = (nodes: TaskRunPromptWorkspaceNode[]): number =>
|
||||
nodes.reduce((sum, n) => sum + 1 + (n.children ? countNodes(n.children) : 0), 0);
|
||||
const total = countNodes(workspace);
|
||||
taskLines.push('');
|
||||
taskLines.push(`Workspace (${totalDocs + workspace.length}):`);
|
||||
for (const folder of workspace) {
|
||||
const folderAgo = folder.createdAt ? ` ${timeAgo(folder.createdAt, now)}` : '';
|
||||
taskLines.push(` 📁 ${folder.title || 'Untitled'} (${folder.documentId})${folderAgo}`);
|
||||
for (const d of folder.children) {
|
||||
const source = d.sourceTaskIdentifier ? ` ← ${d.sourceTaskIdentifier}` : '';
|
||||
const sizeStr = d.size ? ` ${d.size} chars` : '';
|
||||
const docAgo = d.createdAt ? ` ${timeAgo(d.createdAt, now)}` : '';
|
||||
taskLines.push(`Workspace (${total}):`);
|
||||
|
||||
const renderNodes = (nodes: TaskRunPromptWorkspaceNode[], indent: string) => {
|
||||
for (const node of nodes) {
|
||||
const isFolder = node.fileType === 'custom/folder';
|
||||
const icon = isFolder ? '📁' : '📄';
|
||||
const source = node.sourceTaskIdentifier ? ` ← ${node.sourceTaskIdentifier}` : '';
|
||||
const sizeStr = !isFolder && node.size ? ` ${node.size} chars` : '';
|
||||
const ago = node.createdAt ? ` ${timeAgo(node.createdAt, now)}` : '';
|
||||
taskLines.push(
|
||||
` └── 📄 ${d.title || 'Untitled'} (${d.documentId})${source}${sizeStr}${docAgo}`,
|
||||
`${indent}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}${ago}`,
|
||||
);
|
||||
if (node.children) {
|
||||
renderNodes(node.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(workspace, ' ');
|
||||
}
|
||||
|
||||
// Activities (chronological, flat list)
|
||||
|
||||
@@ -30,3 +30,76 @@ export interface WorkspaceData {
|
||||
nodeMap: Record<string, WorkspaceDocNode>;
|
||||
tree: WorkspaceTreeNode[];
|
||||
}
|
||||
|
||||
// ── Task Detail (shared across CLI, viewTask tool, task.detail router) ──
|
||||
|
||||
export interface TaskDetailSubtask {
|
||||
blockedBy?: string;
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailWorkspaceNode {
|
||||
children?: TaskDetailWorkspaceNode[];
|
||||
documentId: string;
|
||||
fileType?: string;
|
||||
size?: number | null;
|
||||
sourceTaskIdentifier?: string | null;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailTopic {
|
||||
id?: string;
|
||||
seq?: number | null;
|
||||
status?: string | null;
|
||||
time?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailBrief {
|
||||
id?: string;
|
||||
priority?: string | null;
|
||||
resolvedAction?: string | null;
|
||||
summary?: string;
|
||||
time?: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailComment {
|
||||
agentId?: string | null;
|
||||
content: string;
|
||||
time?: string;
|
||||
}
|
||||
|
||||
export interface TaskDetailData {
|
||||
agentId?: string | null;
|
||||
checkpoint?: CheckpointConfig;
|
||||
createdAt?: string;
|
||||
dependencies?: Array<{ dependsOn: string; type: string }>;
|
||||
description?: string | null;
|
||||
error?: string | null;
|
||||
heartbeat?: {
|
||||
interval?: number | null;
|
||||
lastAt?: string | null;
|
||||
timeout?: number | null;
|
||||
};
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
parent?: { identifier: string; name: string | null } | null;
|
||||
priority?: number | null;
|
||||
review?: Record<string, any> | null;
|
||||
status: string;
|
||||
subtasks?: TaskDetailSubtask[];
|
||||
timeline?: {
|
||||
briefs?: TaskDetailBrief[];
|
||||
comments?: TaskDetailComment[];
|
||||
topics?: TaskDetailTopic[];
|
||||
};
|
||||
topicCount?: number;
|
||||
userId?: string | null;
|
||||
workspace?: TaskDetailWorkspaceNode[];
|
||||
}
|
||||
|
||||
@@ -343,7 +343,16 @@ export const createRuntimeExecutors = (
|
||||
|
||||
// Construct ChatStreamPayload
|
||||
const stream = ctx.stream ?? true;
|
||||
const chatPayload = { messages: processedMessages, model, stream, tools };
|
||||
|
||||
// Resolve max_tokens from model bank to avoid truncation of long tool call arguments
|
||||
const { LOBE_DEFAULT_MODEL_LIST: modelList } = await import('model-bank');
|
||||
const modelInfo =
|
||||
modelList.find(
|
||||
(m: { id: string; providerId?: string }) => m.id === model && m.providerId === provider,
|
||||
) || modelList.find((m: { id: string }) => m.id === model);
|
||||
const max_tokens = modelInfo?.maxOutput;
|
||||
|
||||
const chatPayload = { max_tokens, messages: processedMessages, model, stream, tools };
|
||||
|
||||
log(
|
||||
`${stagePrefix} calling model-runtime chat (model: %s, messages: %d, tools: %d)`,
|
||||
|
||||
@@ -81,8 +81,7 @@ describe('Task Router Integration', () => {
|
||||
const detail = await caller.detail({ id: 'TASK-1' });
|
||||
expect(detail.data.identifier).toBe('TASK-1');
|
||||
expect(detail.data.subtasks).toHaveLength(0);
|
||||
expect(detail.data.topics).toHaveLength(0);
|
||||
expect(detail.data.comments).toHaveLength(0);
|
||||
expect(detail.data.timeline).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,7 +111,9 @@ describe('Task Router Integration', () => {
|
||||
|
||||
const detail = await caller.detail({ id: parent.data.identifier });
|
||||
expect(detail.data.subtasks).toHaveLength(2);
|
||||
expect(detail.data.subtaskDeps).toHaveLength(1);
|
||||
// ch2 should have blockedBy pointing to ch1's identifier
|
||||
const ch2Sub = detail.data.subtasks!.find((s) => s.name === 'Chapter 2');
|
||||
expect(ch2Sub?.blockedBy).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -157,8 +158,8 @@ describe('Task Router Integration', () => {
|
||||
});
|
||||
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.comments).toHaveLength(2);
|
||||
expect(detail.data.comments[0].content).toBe('First comment');
|
||||
expect(detail.data.timeline?.comments).toHaveLength(2);
|
||||
expect(detail.data.timeline?.comments?.[0].content).toBe('First comment');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -193,9 +194,9 @@ describe('Task Router Integration', () => {
|
||||
});
|
||||
|
||||
const review = await caller.getReview({ id: task.data.id });
|
||||
expect(review.data.enabled).toBe(true);
|
||||
expect(review.data.rubrics).toHaveLength(2);
|
||||
expect(review.data.rubrics[0].type).toBe('llm-rubric');
|
||||
expect(review.data!.enabled).toBe(true);
|
||||
expect(review.data!.rubrics).toHaveLength(2);
|
||||
expect(review.data!.rubrics[0].type).toBe('llm-rubric');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -319,9 +320,13 @@ describe('Task Router Integration', () => {
|
||||
|
||||
// Check detail workspace
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
const workspace = detail.data.documents;
|
||||
expect(Object.keys(workspace.nodeMap)).toHaveLength(1);
|
||||
expect(workspace.nodeMap[doc.id].title).toBe('Test Doc');
|
||||
expect(detail.data.workspace).toBeDefined();
|
||||
// Document should appear somewhere in the workspace tree
|
||||
const allDocs = detail.data.workspace!.flatMap((f) => [
|
||||
{ documentId: f.documentId, title: f.title },
|
||||
...f.children,
|
||||
]);
|
||||
expect(allDocs.find((d) => d.documentId === doc.id)?.title).toBe('Test Doc');
|
||||
|
||||
// Unpin
|
||||
await caller.unpinDocument({
|
||||
@@ -330,7 +335,7 @@ describe('Task Router Integration', () => {
|
||||
});
|
||||
|
||||
const detail2 = await caller.detail({ id: task.data.identifier });
|
||||
expect(Object.keys(detail2.data.documents.nodeMap)).toHaveLength(0);
|
||||
expect(detail2.data.workspace).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -352,10 +357,12 @@ describe('Task Router Integration', () => {
|
||||
// Wait for timeout
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// detail should auto-detect timeout
|
||||
// detail should auto-detect timeout and pause
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.status).toBe('paused');
|
||||
expect(detail.data.error).toBeNull(); // stale timeout error gets cleared
|
||||
// Verify stale timeout error gets cleared via find
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
|
||||
import { NotebookIdentifier } from '@lobechat/builtin-tool-notebook';
|
||||
import { TaskIdentifier } from '@lobechat/builtin-tool-task';
|
||||
import { buildTaskRunPrompt } from '@lobechat/prompts';
|
||||
import type { WorkspaceData } from '@lobechat/types';
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -12,6 +13,7 @@ import { TopicModel } from '@/database/models/topic';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
import { AiAgentService } from '@/server/services/aiAgent';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
import { TaskLifecycleService } from '@/server/services/taskLifecycle';
|
||||
import { TaskReviewService } from '@/server/services/taskReview';
|
||||
|
||||
@@ -22,6 +24,7 @@ const taskProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
briefModel: new BriefModel(ctx.serverDB, ctx.userId),
|
||||
taskLifecycle: new TaskLifecycleService(ctx.serverDB, ctx.userId),
|
||||
taskModel: new TaskModel(ctx.serverDB, ctx.userId),
|
||||
taskService: new TaskService(ctx.serverDB, ctx.userId),
|
||||
taskTopicModel: new TaskTopicModel(ctx.serverDB, ctx.userId),
|
||||
topicModel: new TopicModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
@@ -85,7 +88,9 @@ async function buildTaskPrompt(
|
||||
taskModel.getComments(task.id).catch(() => []),
|
||||
taskModel.findSubtasks(task.id).catch(() => []),
|
||||
taskModel.getDependencies(task.id).catch(() => []),
|
||||
taskModel.getTreePinnedDocuments(task.id).catch(() => []),
|
||||
taskModel
|
||||
.getTreePinnedDocuments(task.id)
|
||||
.catch((): WorkspaceData => ({ nodeMap: {}, tree: [] })),
|
||||
]);
|
||||
|
||||
// Batch-fetch dependencies for all subtasks to show blockedBy info
|
||||
@@ -102,6 +107,11 @@ async function buildTaskPrompt(
|
||||
if (depIdentifier) subtaskDepMap.set(dep.taskId, depIdentifier);
|
||||
}
|
||||
|
||||
// Resolve dependency task identifiers
|
||||
const depTaskIds = [...new Set(dependencies.map((d: any) => d.dependsOnId))];
|
||||
const depTasks = await taskModel.findByIds(depTaskIds);
|
||||
const depIdToIdentifier = new Map(depTasks.map((t: any) => [t.id, t.identifier]));
|
||||
|
||||
// Resolve parent task context (identifier + sibling subtasks)
|
||||
let parentIdentifier: string | null = null;
|
||||
let parentTaskContext:
|
||||
@@ -198,7 +208,10 @@ async function buildTaskPrompt(
|
||||
parentTask: parentTaskContext,
|
||||
task: {
|
||||
assigneeAgentId: task.assigneeAgentId,
|
||||
dependencies: dependencies.map((d: any) => ({ dependsOn: d.dependsOnId, type: d.type })),
|
||||
dependencies: dependencies.map((d: any) => ({
|
||||
dependsOn: depIdToIdentifier.get(d.dependsOnId) ?? d.dependsOnId,
|
||||
type: d.type,
|
||||
})),
|
||||
description: task.description,
|
||||
id: task.id,
|
||||
identifier: task.identifier,
|
||||
@@ -486,10 +499,8 @@ export const taskRouter = router({
|
||||
if (task.status === 'running' && task.heartbeatTimeout && task.lastHeartbeatAt) {
|
||||
const elapsed = (Date.now() - new Date(task.lastHeartbeatAt).getTime()) / 1000;
|
||||
if (elapsed > task.heartbeatTimeout) {
|
||||
// Mark task as paused and running topics as timeout
|
||||
await model.updateStatus(task.id, 'paused', { error: 'Heartbeat timeout' });
|
||||
await ctx.taskTopicModel.timeoutRunning(task.id);
|
||||
// Re-fetch updated task
|
||||
task = await resolveOrThrow(model, input.id);
|
||||
}
|
||||
}
|
||||
@@ -497,49 +508,14 @@ export const taskRouter = router({
|
||||
// Clear stale heartbeat timeout error if task is no longer running
|
||||
if (task.status !== 'running' && task.error === 'Heartbeat timeout') {
|
||||
await model.update(task.id, { error: null });
|
||||
task = { ...task, error: null };
|
||||
}
|
||||
|
||||
// Parallel fetch all related data
|
||||
const briefModel = ctx.briefModel;
|
||||
const [subtasks, dependencies, topics, briefs, comments, documents] = await Promise.all([
|
||||
model.findSubtasks(task.id),
|
||||
model.getDependencies(task.id),
|
||||
ctx.taskTopicModel.findWithDetails(task.id),
|
||||
briefModel.findByTaskId(task.id),
|
||||
model.getComments(task.id),
|
||||
model.getTreePinnedDocuments(task.id),
|
||||
]);
|
||||
|
||||
// Fetch dependencies between subtasks
|
||||
const subtaskIds = subtasks.map((s) => s.id);
|
||||
const subtaskDeps = await model.getDependenciesByTaskIds(subtaskIds);
|
||||
|
||||
// Resolve parent info
|
||||
let parent: { identifier: string; name: string | null } | null = null;
|
||||
if (task.parentTaskId) {
|
||||
const parentTask = await model.findById(task.parentTaskId);
|
||||
if (parentTask) {
|
||||
parent = { identifier: parentTask.identifier, name: parentTask.name };
|
||||
}
|
||||
const detail = await ctx.taskService.getTaskDetail(task.identifier);
|
||||
if (!detail) {
|
||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Task not found' });
|
||||
}
|
||||
|
||||
return {
|
||||
data: {
|
||||
...task,
|
||||
parent,
|
||||
briefs,
|
||||
checkpoint: model.getCheckpointConfig(task),
|
||||
comments,
|
||||
dependencies,
|
||||
documents,
|
||||
review: model.getReviewConfig(task),
|
||||
subtaskDeps,
|
||||
subtasks,
|
||||
topics,
|
||||
},
|
||||
success: true,
|
||||
};
|
||||
return { data: detail, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[task:detail]', error);
|
||||
|
||||
162
src/server/services/task/index.ts
Normal file
162
src/server/services/task/index.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import type { TaskDetailData, TaskDetailWorkspaceNode, WorkspaceData } from '@lobechat/types';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskTopicModel } from '@/database/models/taskTopic';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
|
||||
const emptyWorkspace: WorkspaceData = { nodeMap: {}, tree: [] };
|
||||
|
||||
export class TaskService {
|
||||
private briefModel: BriefModel;
|
||||
private taskModel: TaskModel;
|
||||
private taskTopicModel: TaskTopicModel;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.taskModel = new TaskModel(db, userId);
|
||||
this.taskTopicModel = new TaskTopicModel(db, userId);
|
||||
this.briefModel = new BriefModel(db, userId);
|
||||
}
|
||||
|
||||
async getTaskDetail(taskIdOrIdentifier: string): Promise<TaskDetailData | null> {
|
||||
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),
|
||||
this.taskModel.getDependencies(task.id),
|
||||
this.taskTopicModel.findWithHandoff(task.id).catch(() => []),
|
||||
this.briefModel.findByTaskId(task.id).catch(() => []),
|
||||
this.taskModel.getComments(task.id).catch(() => []),
|
||||
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(() => [])
|
||||
: [];
|
||||
const idToIdentifier = new Map(subtasks.map((s) => [s.id, s.identifier]));
|
||||
const depMap = new Map<string, string>();
|
||||
for (const dep of subtaskDeps) {
|
||||
const depId = idToIdentifier.get(dep.dependsOnId);
|
||||
if (depId) depMap.set(dep.taskId, depId);
|
||||
}
|
||||
|
||||
// Resolve dependency task identifiers
|
||||
const depTaskIds = [...new Set(dependencies.map((d) => d.dependsOnId))];
|
||||
const depTasks = await this.taskModel.findByIds(depTaskIds);
|
||||
const depIdToInfo = new Map(
|
||||
depTasks.map((t) => [t.id, { identifier: t.identifier, name: t.name }]),
|
||||
);
|
||||
|
||||
// Resolve parent
|
||||
let parent: { identifier: string; name: string | null } | null = null;
|
||||
if (task.parentTaskId) {
|
||||
const parentTask = await this.taskModel.findById(task.parentTaskId);
|
||||
if (parentTask) {
|
||||
parent = { identifier: parentTask.identifier, name: parentTask.name };
|
||||
}
|
||||
}
|
||||
|
||||
// Build workspace tree (recursive)
|
||||
const buildWorkspaceNodes = (treeNodes: typeof workspace.tree): TaskDetailWorkspaceNode[] =>
|
||||
treeNodes.map((node) => {
|
||||
const doc = workspace.nodeMap[node.id];
|
||||
return {
|
||||
children: node.children.length > 0 ? buildWorkspaceNodes(node.children) : undefined,
|
||||
documentId: node.id,
|
||||
fileType: doc?.fileType,
|
||||
size: doc?.charCount,
|
||||
sourceTaskIdentifier: doc?.sourceTaskIdentifier,
|
||||
title: doc?.title,
|
||||
};
|
||||
});
|
||||
|
||||
const workspaceFolders = buildWorkspaceNodes(workspace.tree);
|
||||
|
||||
// Build timeline
|
||||
const toISO = (d: Date | string | null | undefined) =>
|
||||
d ? new Date(d).toISOString() : undefined;
|
||||
|
||||
const timelineTopics = topics.map((t) => ({
|
||||
id: t.topicId,
|
||||
seq: t.seq,
|
||||
status: t.status,
|
||||
time: toISO(t.createdAt),
|
||||
title: t.handoffTitle || 'Untitled',
|
||||
}));
|
||||
|
||||
const timelineBriefs = briefs.map((b) => ({
|
||||
id: b.id,
|
||||
priority: b.priority,
|
||||
resolvedAction: b.resolvedAction
|
||||
? b.resolvedComment
|
||||
? `${b.resolvedAction}: ${b.resolvedComment}`
|
||||
: b.resolvedAction
|
||||
: undefined,
|
||||
summary: b.summary,
|
||||
time: toISO(b.createdAt),
|
||||
title: b.title,
|
||||
type: b.type,
|
||||
}));
|
||||
|
||||
const timelineComments = comments.map((c) => ({
|
||||
agentId: c.agentId,
|
||||
content: c.content,
|
||||
time: toISO(c.createdAt),
|
||||
}));
|
||||
|
||||
const hasTimeline =
|
||||
timelineTopics.length > 0 || timelineBriefs.length > 0 || timelineComments.length > 0;
|
||||
|
||||
return {
|
||||
agentId: task.assigneeAgentId,
|
||||
checkpoint: this.taskModel.getCheckpointConfig(task),
|
||||
createdAt: task.createdAt ? new Date(task.createdAt).toISOString() : undefined,
|
||||
dependencies: dependencies.map((d) => {
|
||||
const info = depIdToInfo.get(d.dependsOnId);
|
||||
return {
|
||||
dependsOn: info?.identifier ?? d.dependsOnId,
|
||||
name: info?.name,
|
||||
type: d.type,
|
||||
};
|
||||
}),
|
||||
description: task.description,
|
||||
error: task.error,
|
||||
heartbeat:
|
||||
task.heartbeatTimeout || task.lastHeartbeatAt
|
||||
? {
|
||||
interval: task.heartbeatInterval,
|
||||
lastAt: task.lastHeartbeatAt ? new Date(task.lastHeartbeatAt).toISOString() : null,
|
||||
timeout: task.heartbeatTimeout,
|
||||
}
|
||||
: undefined,
|
||||
identifier: task.identifier,
|
||||
instruction: task.instruction,
|
||||
name: task.name,
|
||||
parent,
|
||||
priority: task.priority,
|
||||
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,
|
||||
})),
|
||||
timeline: hasTimeline
|
||||
? {
|
||||
briefs: timelineBriefs.length > 0 ? timelineBriefs : undefined,
|
||||
comments: timelineComments.length > 0 ? timelineComments : undefined,
|
||||
topics: timelineTopics.length > 0 ? timelineTopics : undefined,
|
||||
}
|
||||
: undefined,
|
||||
topicCount: timelineTopics.length > 0 ? timelineTopics.length : undefined,
|
||||
workspace: workspaceFolders.length > 0 ? workspaceFolders : undefined,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,19 @@ export class TaskLifecycleService {
|
||||
if (shouldSkipPause) return; // auto-retry in progress, don't pause
|
||||
}
|
||||
|
||||
// 4. Checkpoint — pause for user review
|
||||
// 4. Check if agent delivered a result brief → auto-complete
|
||||
// If the latest brief is type 'result' and no review is configured, complete the task
|
||||
const reviewConfig = currentTask ? this.taskModel.getReviewConfig(currentTask) : null;
|
||||
if (!reviewConfig?.enabled) {
|
||||
const briefs = await this.briefModel.findByTaskId(taskId);
|
||||
const latestBrief = briefs[0]; // sorted by createdAt desc
|
||||
if (latestBrief?.type === 'result') {
|
||||
await this.taskModel.updateStatus(taskId, 'completed', { error: null });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Checkpoint — pause for user review
|
||||
if (currentTask && this.taskModel.shouldPauseOnTopicComplete(currentTask)) {
|
||||
await this.taskModel.updateStatus(taskId, 'paused', { error: null });
|
||||
}
|
||||
|
||||
@@ -7,7 +7,15 @@ import { TaskModel } from '@/database/models/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createBriefRuntime = (briefModel: BriefModel, taskModel: TaskModel, taskId?: string) => ({
|
||||
const createBriefRuntime = ({
|
||||
briefModel,
|
||||
taskId,
|
||||
taskModel,
|
||||
}: {
|
||||
briefModel: BriefModel;
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
}) => ({
|
||||
createBrief: async (args: {
|
||||
actions?: Array<{ key: string; label: string; type: string }>;
|
||||
priority?: string;
|
||||
@@ -64,7 +72,7 @@ export const briefRuntime: ServerRuntimeRegistration = {
|
||||
const briefModel = new BriefModel(context.serverDB, context.userId);
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
|
||||
return createBriefRuntime(briefModel, taskModel, context.taskId);
|
||||
return createBriefRuntime({ briefModel, taskId: context.taskId, taskModel });
|
||||
},
|
||||
identifier: BriefIdentifier,
|
||||
};
|
||||
|
||||
@@ -10,10 +10,19 @@ import {
|
||||
} from '@lobechat/prompts';
|
||||
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createTaskRuntime = (taskModel: TaskModel, taskId?: string) => ({
|
||||
const createTaskRuntime = ({
|
||||
taskId,
|
||||
taskModel,
|
||||
taskService,
|
||||
}: {
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
taskService: TaskService;
|
||||
}) => ({
|
||||
createTask: async (args: {
|
||||
instruction: string;
|
||||
name: string;
|
||||
@@ -177,36 +186,41 @@ const createTaskRuntime = (taskModel: TaskModel, taskId?: string) => ({
|
||||
};
|
||||
},
|
||||
|
||||
viewTask: async (args: { identifier?: string }) => {
|
||||
let task;
|
||||
|
||||
if (args.identifier) {
|
||||
task = await taskModel.resolve(args.identifier);
|
||||
} else if (taskId) {
|
||||
task = await taskModel.findById(taskId);
|
||||
} else {
|
||||
updateTaskStatus: async (args: { identifier?: string; status: string }) => {
|
||||
const id = args.identifier || taskId;
|
||||
if (!id) {
|
||||
return {
|
||||
content: 'No task identifier provided and no current task context.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!task) return { content: `Task not found: ${args.identifier || taskId}`, success: false };
|
||||
const task = await taskModel.resolve(id);
|
||||
if (!task) return { content: `Task not found: ${id}`, success: false };
|
||||
|
||||
const subtasks = await taskModel.findSubtasks(task.id);
|
||||
const deps = await taskModel.getDependencies(task.id);
|
||||
const updated = await taskModel.updateStatus(task.id, args.status);
|
||||
if (!updated) return { content: `Failed to update task ${task.identifier}`, success: false };
|
||||
|
||||
return {
|
||||
content: formatTaskDetail({
|
||||
dependencies: deps.map((d) => ({ dependsOn: d.dependsOnId, type: d.type })),
|
||||
identifier: task.identifier,
|
||||
instruction: task.instruction,
|
||||
name: task.name,
|
||||
parentTaskId: task.parentTaskId,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
subtasks,
|
||||
}),
|
||||
content: `Task ${task.identifier} status updated to ${args.status}.`,
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
viewTask: async (args: { identifier?: string }) => {
|
||||
const id = args.identifier || taskId;
|
||||
if (!id) {
|
||||
return {
|
||||
content: 'No task identifier provided and no current task context.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const detail = await taskService.getTaskDetail(id);
|
||||
if (!detail) return { content: `Task not found: ${id}`, success: false };
|
||||
|
||||
return {
|
||||
content: formatTaskDetail(detail),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
@@ -219,8 +233,9 @@ export const taskRuntime: ServerRuntimeRegistration = {
|
||||
}
|
||||
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
const taskService = new TaskService(context.serverDB, context.userId);
|
||||
|
||||
return createTaskRuntime(taskModel, context.taskId);
|
||||
return createTaskRuntime({ taskId: context.taskId, taskModel, taskService });
|
||||
},
|
||||
identifier: TaskIdentifier,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user