mirror of
https://github.com/lobehub/lobe-chat.git
synced 2026-06-14 03:30:19 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a52669fee5 | |||
| eb256c0c1c | |||
| 3ce25eafd4 | |||
| 88bc6fc250 | |||
| 91484953ff | |||
| 639ff347ae |
@@ -200,20 +200,85 @@ The base directory (`~/.lobehub/`) can be overridden with the `LOBEHUB_CLI_HOME`
|
||||
|
||||
## Development
|
||||
|
||||
### Running in Dev Mode
|
||||
|
||||
Dev mode uses `LOBEHUB_CLI_HOME=.lobehub-dev` to isolate credentials from the global `~/.lobehub/` directory, so dev and production configs never conflict.
|
||||
|
||||
```bash
|
||||
# Run directly (dev mode, uses ~/.lobehub-dev for credentials)
|
||||
# Run a command in dev mode (from apps/cli/)
|
||||
cd apps/cli && bun run dev -- <command>
|
||||
|
||||
# Build
|
||||
# This is equivalent to:
|
||||
LOBEHUB_CLI_HOME=.lobehub-dev bun src/index.ts <command>
|
||||
```
|
||||
|
||||
### Connecting to Local Dev Server
|
||||
|
||||
To test CLI against a local dev server (e.g. `localhost:3011`):
|
||||
|
||||
**Step 1: Start the local server**
|
||||
|
||||
```bash
|
||||
# From cloud repo root
|
||||
bun run dev
|
||||
# Server starts on http://localhost:3011 (or configured port)
|
||||
```
|
||||
|
||||
**Step 2: Login to local server via Device Code Flow**
|
||||
|
||||
```bash
|
||||
cd apps/cli && bun run dev -- login --server http://localhost:3011
|
||||
```
|
||||
|
||||
This will:
|
||||
|
||||
1. Call `POST http://localhost:3011/oidc/device/auth` to get a device code
|
||||
2. Print a URL like `http://localhost:3011/oidc/device?user_code=XXXX-YYYY`
|
||||
3. Open the URL in your browser — log in and authorize
|
||||
4. Save credentials to `apps/cli/.lobehub-dev/credentials.json`
|
||||
5. Save server URL to `apps/cli/.lobehub-dev/settings.json`
|
||||
|
||||
After login, all subsequent `bun run dev -- <command>` calls will use the local server.
|
||||
|
||||
**Step 3: Run commands against local server**
|
||||
|
||||
```bash
|
||||
cd apps/cli && bun run dev -- task list
|
||||
cd apps/cli && bun run dev -- task create -i "Test task" -n "My Task"
|
||||
cd apps/cli && bun run dev -- agent list
|
||||
```
|
||||
|
||||
**Troubleshooting:**
|
||||
|
||||
- If login returns `invalid_grant`, make sure the local OIDC provider is properly configured (check `OIDC_*` env vars in `.env`)
|
||||
- If you get `UNAUTHORIZED` on API calls, your token may have expired — run `bun run dev -- login --server http://localhost:3011` again
|
||||
- Dev credentials are stored in `apps/cli/.lobehub-dev/` (gitignored), not in `~/.lobehub/`
|
||||
|
||||
### Switching Between Local and Production
|
||||
|
||||
```bash
|
||||
# Dev mode (local server) — uses .lobehub-dev/
|
||||
cd apps/cli && bun run dev -- <command>
|
||||
|
||||
# Production (app.lobehub.com) — uses ~/.lobehub/
|
||||
lh <command>
|
||||
```
|
||||
|
||||
The two environments are completely isolated by different credential directories.
|
||||
|
||||
### Build & Test
|
||||
|
||||
```bash
|
||||
# Build CLI
|
||||
cd apps/cli && bun run build
|
||||
|
||||
# Test (unit tests)
|
||||
# Unit tests
|
||||
cd apps/cli && bun run test
|
||||
|
||||
# E2E tests (requires authenticated CLI)
|
||||
cd apps/cli && bunx vitest run e2e/kb.e2e.test.ts
|
||||
|
||||
# Link globally for testing
|
||||
# Link globally for testing (installs lh/lobe/lobehub commands)
|
||||
cd apps/cli && bun run cli:link
|
||||
```
|
||||
|
||||
|
||||
@@ -101,10 +101,6 @@ DROP TABLE "old_table";
|
||||
CREATE INDEX "users_email_idx" ON "users" ("email");
|
||||
```
|
||||
|
||||
## Step 4: Regenerate Client After SQL Edits
|
||||
## Step 4: Update Journal Tag
|
||||
|
||||
After modifying the generated SQL (e.g., adding `IF NOT EXISTS`), regenerate the client:
|
||||
|
||||
```bash
|
||||
bun run db:generate:client
|
||||
```
|
||||
After renaming the migration SQL file in Step 2, update the `tag` field in `packages/database/migrations/meta/_journal.json` to match the new filename (without `.sql` extension).
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
---
|
||||
name: trpc-router
|
||||
description: TRPC router development guide. Use when creating or modifying TRPC routers (src/server/routers/**), adding procedures, or working with server-side API endpoints. Triggers on TRPC router creation, procedure implementation, or API endpoint tasks.
|
||||
---
|
||||
|
||||
# TRPC Router Guide
|
||||
|
||||
## File Location
|
||||
|
||||
- Routers: `src/server/routers/lambda/<domain>.ts`
|
||||
- Helpers: `src/server/routers/lambda/_helpers/`
|
||||
- Schemas: `src/server/routers/lambda/_schema/`
|
||||
|
||||
## Router Structure
|
||||
|
||||
### Imports
|
||||
|
||||
```typescript
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SomeModel } from '@/database/models/some';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
```
|
||||
|
||||
### Middleware: Inject Models into ctx
|
||||
|
||||
**Always use middleware to inject models into `ctx`** instead of creating `new Model(ctx.serverDB, ctx.userId)` inside every procedure.
|
||||
|
||||
```typescript
|
||||
const domainProcedure = authedProcedure.use(serverDatabase).use(async (opts) => {
|
||||
const { ctx } = opts;
|
||||
return opts.next({
|
||||
ctx: {
|
||||
fooModel: new FooModel(ctx.serverDB, ctx.userId),
|
||||
barModel: new BarModel(ctx.serverDB, ctx.userId),
|
||||
},
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Then use `ctx.fooModel` in procedures:
|
||||
|
||||
```typescript
|
||||
// Good
|
||||
const model = ctx.fooModel;
|
||||
|
||||
// Bad - don't create models inside procedures
|
||||
const model = new FooModel(ctx.serverDB, ctx.userId);
|
||||
```
|
||||
|
||||
**Exception**: When a model needs a different `userId` (e.g., watchdog iterating over multiple users' tasks), create it inline.
|
||||
|
||||
### Procedure Pattern
|
||||
|
||||
```typescript
|
||||
export const fooRouter = router({
|
||||
// Query
|
||||
find: domainProcedure.input(z.object({ id: z.string() })).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const item = await ctx.fooModel.findById(input.id);
|
||||
if (!item) throw new TRPCError({ code: 'NOT_FOUND', message: 'Not found' });
|
||||
return { data: item, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[foo:find]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find item',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
// Mutation
|
||||
create: domainProcedure.input(createSchema).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const item = await ctx.fooModel.create(input);
|
||||
return { data: item, message: 'Created', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[foo:create]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to create',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
### Aggregated Detail Endpoint
|
||||
|
||||
For views that need multiple related data, create a single `detail` procedure that fetches everything in parallel:
|
||||
|
||||
```typescript
|
||||
detail: domainProcedure.input(idInput).query(async ({ input, ctx }) => {
|
||||
const item = await resolveOrThrow(ctx.fooModel, input.id);
|
||||
|
||||
const [children, related] = await Promise.all([
|
||||
ctx.fooModel.findChildren(item.id),
|
||||
ctx.barModel.findByFooId(item.id),
|
||||
]);
|
||||
|
||||
return {
|
||||
data: { ...item, children, related },
|
||||
success: true,
|
||||
};
|
||||
}),
|
||||
```
|
||||
|
||||
This avoids the CLI or frontend making N sequential requests.
|
||||
|
||||
## Conventions
|
||||
|
||||
- Return shape: `{ data, success: true }` for queries, `{ data?, message, success: true }` for mutations
|
||||
- Error handling: re-throw `TRPCError`, wrap others with `console.error` + new `TRPCError`
|
||||
- Input validation: use `zod` schemas, define at file top
|
||||
- Router name: `export const fooRouter = router({ ... })`
|
||||
- Procedure names: alphabetical order within the router object
|
||||
- Log prefix: `[domain:procedure]` format, e.g. `[task:create]`
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@lobehub/cli",
|
||||
"version": "0.0.1-canary.12",
|
||||
"version": "0.0.1-canary.14",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
"lh": "./dist/index.js",
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../api/client';
|
||||
import { outputJson, printTable, timeAgo, truncate } from '../utils/format';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
export function registerBriefCommand(program: Command) {
|
||||
const brief = program.command('brief').description('Manage briefs (Agent reports)');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('list')
|
||||
.description('List briefs')
|
||||
.option('--unresolved', 'Only show unresolved briefs (default)')
|
||||
.option('--all', 'Show all briefs')
|
||||
.option('--type <type>', 'Filter by type (decision/result/insight/error)')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
all?: boolean;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
type?: string;
|
||||
unresolved?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let items: any[];
|
||||
|
||||
if (options.all) {
|
||||
const input: Record<string, any> = {};
|
||||
if (options.type) input.type = options.type;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
const result = await client.brief.list.query(input as any);
|
||||
items = result.data;
|
||||
} else {
|
||||
const result = await client.brief.listUnresolved.query();
|
||||
items = result.data;
|
||||
}
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
log.info('No briefs found.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = items.map((b: any) => [
|
||||
typeBadge(b.type, b.priority),
|
||||
truncate(b.title, 40),
|
||||
truncate(b.summary, 50),
|
||||
b.taskId ? pc.dim(b.taskId) : b.cronJobId ? pc.dim(b.cronJobId) : '-',
|
||||
b.resolvedAt ? pc.green('resolved') : b.readAt ? pc.dim('read') : 'new',
|
||||
timeAgo(b.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['TYPE', 'TITLE', 'SUMMARY', 'SOURCE', 'STATUS', 'CREATED']);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('view <id>')
|
||||
.description('View brief details (auto marks as read)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.brief.find.query({ id });
|
||||
const b = result.data;
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(b, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!b) {
|
||||
log.error('Brief not found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Auto mark as read
|
||||
if (!b.readAt) {
|
||||
await client.brief.markRead.mutate({ id });
|
||||
}
|
||||
|
||||
const resolvedLabel = b.resolvedAt
|
||||
? (() => {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
const matched = actions.find((a: any) => a.key === (b as any).resolvedAction);
|
||||
return pc.green(` ${matched?.label || '✓ resolved'}`);
|
||||
})()
|
||||
: '';
|
||||
|
||||
console.log(`\n${typeBadge(b.type, b.priority)} ${pc.bold(b.title)}${resolvedLabel}`);
|
||||
console.log(`${pc.dim('Type:')} ${b.type} ${pc.dim('Created:')} ${timeAgo(b.createdAt)}`);
|
||||
if (b.agentId) console.log(`${pc.dim('Agent:')} ${b.agentId}`);
|
||||
if (b.taskId) console.log(`${pc.dim('Task:')} ${b.taskId}`);
|
||||
if (b.cronJobId) console.log(`${pc.dim('CronJob:')} ${b.cronJobId}`);
|
||||
if (b.topicId) console.log(`${pc.dim('Topic:')} ${b.topicId}`);
|
||||
console.log(`\n${b.summary}`);
|
||||
|
||||
if (b.artifacts && (b.artifacts as string[]).length > 0) {
|
||||
console.log(`\n${pc.dim('Artifacts:')}`);
|
||||
for (const a of b.artifacts as string[]) {
|
||||
console.log(` 📎 ${a}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
if (!b.resolvedAt) {
|
||||
const actions = (b.actions as any[]) || [];
|
||||
if (actions.length > 0) {
|
||||
console.log('Actions:');
|
||||
for (const a of actions) {
|
||||
const cmd =
|
||||
a.type === 'comment'
|
||||
? `lh brief resolve ${b.id} --action ${a.key} -m "内容"`
|
||||
: `lh brief resolve ${b.id} --action ${a.key}`;
|
||||
console.log(` ${a.label} ${pc.dim(cmd)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(pc.dim('Actions:'));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} # 确认通过`));
|
||||
console.log(pc.dim(` lh brief resolve ${b.id} --reply "修改意见" # 反馈修改`));
|
||||
}
|
||||
} else if ((b as any).resolvedComment) {
|
||||
console.log(`${pc.dim('Comment:')} ${(b as any).resolvedComment}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── resolve ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('resolve <id>')
|
||||
.description('Resolve a brief (approve, reply, or custom action)')
|
||||
.option('--action <key>', 'Execute a specific action (e.g. approve, feedback)')
|
||||
.option('--reply <text>', 'Reply with feedback')
|
||||
.option('-m, --message <text>', 'Message for comment-type actions')
|
||||
.action(async (id: string, options: { action?: string; message?: string; reply?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const actionKey = options.action || (options.reply ? 'feedback' : 'approve');
|
||||
const actionMessage = options.message || options.reply;
|
||||
|
||||
const briefResult = await client.brief.find.query({ id });
|
||||
const b = briefResult.data;
|
||||
|
||||
// For comment-type actions, add comment to task
|
||||
if (actionMessage && b?.taskId) {
|
||||
await client.task.addComment.mutate({
|
||||
briefId: id,
|
||||
content: actionMessage,
|
||||
id: b.taskId,
|
||||
});
|
||||
}
|
||||
|
||||
await client.brief.resolve.mutate({
|
||||
action: actionKey,
|
||||
comment: actionMessage,
|
||||
id,
|
||||
});
|
||||
|
||||
const actions = (b?.actions as any[]) || [];
|
||||
const matchedAction = actions.find((a: any) => a.key === actionKey);
|
||||
const label = matchedAction?.label || actionKey;
|
||||
|
||||
log.info(`${label} — Brief ${pc.dim(id)} resolved.`);
|
||||
});
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
brief
|
||||
.command('delete <id>')
|
||||
.description('Delete a brief')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.brief.delete.mutate({ id });
|
||||
log.info(`Brief ${pc.dim(id)} deleted.`);
|
||||
});
|
||||
}
|
||||
|
||||
function typeBadge(type: string, priority?: string): string {
|
||||
if (priority === 'urgent') {
|
||||
return pc.red('🔴');
|
||||
}
|
||||
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return pc.yellow('🟡');
|
||||
}
|
||||
case 'result': {
|
||||
return pc.green('✅');
|
||||
}
|
||||
case 'insight': {
|
||||
return '💬';
|
||||
}
|
||||
case 'error': {
|
||||
return pc.red('❌');
|
||||
}
|
||||
default: {
|
||||
return '·';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerCheckpointCommands(task: Command) {
|
||||
// ── checkpoint ──────────────────────────────────────────────
|
||||
|
||||
const cp = task.command('checkpoint').description('Manage task checkpoints');
|
||||
|
||||
cp.command('view <id>')
|
||||
.description('View checkpoint config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getCheckpoint.query({ id });
|
||||
const c = result.data as any;
|
||||
|
||||
console.log(`\n${pc.bold('Checkpoint config:')}`);
|
||||
console.log(` onAgentRequest: ${c.onAgentRequest ?? pc.dim('not set (default: true)')}`);
|
||||
if (c.topic) {
|
||||
console.log(` topic.before: ${c.topic.before ?? false}`);
|
||||
console.log(` topic.after: ${c.topic.after ?? false}`);
|
||||
}
|
||||
if (c.tasks?.beforeIds?.length > 0) {
|
||||
console.log(` tasks.beforeIds: ${c.tasks.beforeIds.join(', ')}`);
|
||||
}
|
||||
if (c.tasks?.afterIds?.length > 0) {
|
||||
console.log(` tasks.afterIds: ${c.tasks.afterIds.join(', ')}`);
|
||||
}
|
||||
if (
|
||||
!c.topic &&
|
||||
!c.tasks?.beforeIds?.length &&
|
||||
!c.tasks?.afterIds?.length &&
|
||||
c.onAgentRequest === undefined
|
||||
) {
|
||||
console.log(` ${pc.dim('(no checkpoints configured)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
cp.command('set <id>')
|
||||
.description('Configure checkpoints')
|
||||
.option('--on-agent-request <bool>', 'Allow agent to request review (true/false)')
|
||||
.option('--topic-before <bool>', 'Pause before each topic (true/false)')
|
||||
.option('--topic-after <bool>', 'Pause after each topic (true/false)')
|
||||
.option('--before <ids>', 'Pause before these subtask identifiers (comma-separated)')
|
||||
.option('--after <ids>', 'Pause after these subtask identifiers (comma-separated)')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
after?: string;
|
||||
before?: string;
|
||||
onAgentRequest?: string;
|
||||
topicAfter?: string;
|
||||
topicBefore?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Get current config first
|
||||
const current = (await client.task.getCheckpoint.query({ id })).data as any;
|
||||
const checkpoint: any = { ...current };
|
||||
|
||||
if (options.onAgentRequest !== undefined) {
|
||||
checkpoint.onAgentRequest = options.onAgentRequest === 'true';
|
||||
}
|
||||
if (options.topicBefore !== undefined || options.topicAfter !== undefined) {
|
||||
checkpoint.topic = { ...checkpoint.topic };
|
||||
if (options.topicBefore !== undefined)
|
||||
checkpoint.topic.before = options.topicBefore === 'true';
|
||||
if (options.topicAfter !== undefined)
|
||||
checkpoint.topic.after = options.topicAfter === 'true';
|
||||
}
|
||||
if (options.before !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.beforeIds = options.before
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
if (options.after !== undefined) {
|
||||
checkpoint.tasks = { ...checkpoint.tasks };
|
||||
checkpoint.tasks.afterIds = options.after
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
await client.task.updateCheckpoint.mutate({ checkpoint, id });
|
||||
log.info('Checkpoint updated.');
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Command } from 'commander';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { outputJson, printTable, timeAgo } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDepCommands(task: Command) {
|
||||
// ── dep ──────────────────────────────────────────────
|
||||
|
||||
const dep = task.command('dep').description('Manage task dependencies');
|
||||
|
||||
dep
|
||||
.command('add <taskId> <dependsOnId>')
|
||||
.description('Add dependency (taskId blocks on dependsOnId)')
|
||||
.option('--type <type>', 'Dependency type (blocks/relates)', 'blocks')
|
||||
.action(async (taskId: string, dependsOnId: string, options: { type?: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addDependency.mutate({
|
||||
dependsOnId,
|
||||
taskId,
|
||||
type: (options.type || 'blocks') as any,
|
||||
});
|
||||
log.info(`Dependency added: ${taskId} ${options.type || 'blocks'} on ${dependsOnId}`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('rm <taskId> <dependsOnId>')
|
||||
.description('Remove dependency')
|
||||
.action(async (taskId: string, dependsOnId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.removeDependency.mutate({ dependsOnId, taskId });
|
||||
log.info(`Dependency removed.`);
|
||||
});
|
||||
|
||||
dep
|
||||
.command('list <taskId>')
|
||||
.description('List dependencies for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (taskId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getDependencies.query({ id: taskId });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No dependencies.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((d: any) => [d.type, d.dependsOnId, timeAgo(d.createdAt)]);
|
||||
printTable(rows, ['TYPE', 'DEPENDS ON', 'CREATED']);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerDocCommands(task: Command) {
|
||||
// ── doc ──────────────────────────────────────────────
|
||||
|
||||
const dc = task.command('doc').description('Manage task workspace documents');
|
||||
|
||||
dc.command('create <id>')
|
||||
.description('Create a document and pin it to the task')
|
||||
.requiredOption('-t, --title <title>', 'Document title')
|
||||
.option('-b, --body <content>', 'Document content')
|
||||
.option('--parent <docId>', 'Parent document/folder ID')
|
||||
.option('--folder', 'Create as folder')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: { body?: string; folder?: boolean; parent?: string; title: string },
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Create document
|
||||
const fileType = options.folder ? 'custom/folder' : undefined;
|
||||
const content = options.body || '';
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content,
|
||||
editorData: options.folder ? undefined : JSON.stringify({ content, type: 'doc' }),
|
||||
fileType,
|
||||
parentId: options.parent,
|
||||
title: options.title,
|
||||
});
|
||||
|
||||
// Pin to task
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
|
||||
const icon = options.folder ? '📁' : '📄';
|
||||
log.info(`${icon} Created & pinned: ${pc.bold(options.title)} ${pc.dim(result.id)}`);
|
||||
},
|
||||
);
|
||||
|
||||
dc.command('pin <id> <documentId>')
|
||||
.description('Pin an existing document to a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.pinDocument.mutate({ documentId, pinnedBy: 'user', taskId: id });
|
||||
log.info(`Pinned ${pc.dim(documentId)} to ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('unpin <id> <documentId>')
|
||||
.description('Unpin a document from a task')
|
||||
.action(async (id: string, documentId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.unpinDocument.mutate({ documentId, taskId: id });
|
||||
log.info(`Unpinned ${pc.dim(documentId)} from ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
dc.command('mv <id> <documentId> <folder>')
|
||||
.description('Move a document into a folder (auto-creates folder if not found)')
|
||||
.action(async (id: string, documentId: string, folder: string) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if folder is a document ID or a folder name
|
||||
let folderId = folder;
|
||||
if (!folder.startsWith('docs_')) {
|
||||
// folder is a name, find or create it
|
||||
const detail = await client.task.detail.query({ id });
|
||||
const folders = detail.data.workspace || [];
|
||||
|
||||
// Search for existing folder by name
|
||||
const existingFolder = folders.find((f) => f.title === folder);
|
||||
|
||||
if (existingFolder) {
|
||||
folderId = existingFolder.documentId;
|
||||
} else {
|
||||
// Create folder and pin to task
|
||||
const result = await client.document.createDocument.mutate({
|
||||
content: '',
|
||||
fileType: 'custom/folder',
|
||||
title: folder,
|
||||
});
|
||||
await client.task.pinDocument.mutate({
|
||||
documentId: result.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: id,
|
||||
});
|
||||
folderId = result.id;
|
||||
log.info(`📁 Created folder: ${pc.bold(folder)} ${pc.dim(folderId)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Move document into folder
|
||||
await client.document.updateDocument.mutate({ id: documentId, parentId: folderId });
|
||||
log.info(`Moved ${pc.dim(documentId)} → 📁 ${pc.bold(folder)}`);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import pc from 'picocolors';
|
||||
|
||||
export function statusBadge(status: string): string {
|
||||
const pad = (s: string) => s.padEnd(9);
|
||||
switch (status) {
|
||||
case 'backlog': {
|
||||
return pc.dim(`○ ${pad('backlog')}`);
|
||||
}
|
||||
case 'blocked': {
|
||||
return pc.red(`◉ ${pad('blocked')}`);
|
||||
}
|
||||
case 'running': {
|
||||
return pc.blue(`● ${pad('running')}`);
|
||||
}
|
||||
case 'paused': {
|
||||
return pc.yellow(`◐ ${pad('paused')}`);
|
||||
}
|
||||
case 'completed': {
|
||||
return pc.green(`✓ ${pad('completed')}`);
|
||||
}
|
||||
case 'failed': {
|
||||
return pc.red(`✗ ${pad('failed')}`);
|
||||
}
|
||||
case 'timeout': {
|
||||
return pc.red(`⏱ ${pad('timeout')}`);
|
||||
}
|
||||
case 'canceled': {
|
||||
return pc.dim(`⊘ ${pad('canceled')}`);
|
||||
}
|
||||
default: {
|
||||
return status;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function briefIcon(type: string): string {
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return '📋';
|
||||
}
|
||||
case 'result': {
|
||||
return '✅';
|
||||
}
|
||||
case 'insight': {
|
||||
return '💡';
|
||||
}
|
||||
case 'error': {
|
||||
return '❌';
|
||||
}
|
||||
default: {
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function priorityLabel(priority: number | null | undefined): string {
|
||||
switch (priority) {
|
||||
case 1: {
|
||||
return pc.red('urgent');
|
||||
}
|
||||
case 2: {
|
||||
return pc.yellow('high');
|
||||
}
|
||||
case 3: {
|
||||
return 'normal';
|
||||
}
|
||||
case 4: {
|
||||
return pc.dim('low');
|
||||
}
|
||||
default: {
|
||||
return pc.dim('-');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import {
|
||||
confirm,
|
||||
displayWidth,
|
||||
outputJson,
|
||||
printTable,
|
||||
timeAgo,
|
||||
truncate,
|
||||
} from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { registerCheckpointCommands } from './checkpoint';
|
||||
import { registerDepCommands } from './dep';
|
||||
import { registerDocCommands } from './doc';
|
||||
import { briefIcon, priorityLabel, statusBadge } from './helpers';
|
||||
import { registerLifecycleCommands } from './lifecycle';
|
||||
import { registerReviewCommands } from './review';
|
||||
import { registerTopicCommands } from './topic';
|
||||
|
||||
export function registerTaskCommand(program: Command) {
|
||||
const task = program.command('task').description('Manage agent tasks');
|
||||
|
||||
// ── list ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('list')
|
||||
.description('List tasks')
|
||||
.option(
|
||||
'--status <status>',
|
||||
'Filter by status (pending/running/paused/completed/failed/canceled)',
|
||||
)
|
||||
.option('--root', 'Only show root tasks (no parent)')
|
||||
.option('--parent <id>', 'Filter by parent task ID')
|
||||
.option('--agent <id>', 'Filter by assignee agent ID')
|
||||
.option('-L, --limit <n>', 'Page size', '50')
|
||||
.option('--offset <n>', 'Offset', '0')
|
||||
.option('--tree', 'Display as tree structure')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
json?: string | boolean;
|
||||
limit?: string;
|
||||
offset?: string;
|
||||
parent?: string;
|
||||
root?: boolean;
|
||||
status?: string;
|
||||
tree?: boolean;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {};
|
||||
if (options.status) input.status = options.status;
|
||||
if (options.root) input.parentTaskId = null;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.limit) input.limit = Number.parseInt(options.limit, 10);
|
||||
if (options.offset) input.offset = Number.parseInt(options.offset, 10);
|
||||
|
||||
// For tree mode, fetch all tasks (no pagination limit)
|
||||
if (options.tree) {
|
||||
input.limit = 100;
|
||||
delete input.offset;
|
||||
}
|
||||
|
||||
const result = await client.task.list.query(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.tree) {
|
||||
// Build tree display
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const roots = result.data.filter((t: any) => !t.parentTaskId);
|
||||
const children = new Map<string, any[]>();
|
||||
for (const t of result.data) {
|
||||
if (t.parentTaskId) {
|
||||
const list = children.get(t.parentTaskId) || [];
|
||||
list.push(t);
|
||||
children.set(t.parentTaskId, list);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort children by sortOrder first, then seq
|
||||
for (const [, list] of children) {
|
||||
list.sort(
|
||||
(a: any, b: any) =>
|
||||
(a.sortOrder ?? 0) - (b.sortOrder ?? 0) || (a.seq ?? 0) - (b.seq ?? 0),
|
||||
);
|
||||
}
|
||||
|
||||
const printNode = (t: any, prefix: string, isLast: boolean, isRoot: boolean) => {
|
||||
const connector = isRoot ? '' : isLast ? '└── ' : '├── ';
|
||||
const name = truncate(t.name || t.instruction, 40);
|
||||
console.log(
|
||||
`${prefix}${connector}${pc.dim(t.identifier)} ${statusBadge(t.status)} ${name}`,
|
||||
);
|
||||
const childList = children.get(t.id) || [];
|
||||
const newPrefix = isRoot ? '' : prefix + (isLast ? ' ' : '│ ');
|
||||
childList.forEach((child: any, i: number) => {
|
||||
printNode(child, newPrefix, i === childList.length - 1, false);
|
||||
});
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
printNode(root, '', true, true);
|
||||
}
|
||||
log.info(`Total: ${result.total}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
pc.dim(t.identifier),
|
||||
truncate(t.name || t.instruction, 40),
|
||||
statusBadge(t.status),
|
||||
priorityLabel(t.priority),
|
||||
t.assigneeAgentId ? pc.dim(t.assigneeAgentId) : '-',
|
||||
t.parentTaskId ? pc.dim('↳ subtask') : '',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['ID', 'NAME', 'STATUS', 'PRI', 'AGENT', 'TYPE', 'CREATED']);
|
||||
log.info(`Total: ${result.total}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── view ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('view <id>')
|
||||
.description('View task details (by ID or identifier like TASK-1)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const result = await client.task.detail.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
const t = result.data;
|
||||
|
||||
// ── Header ──
|
||||
console.log(`\n${pc.bold(t.identifier)} ${t.name || ''}`);
|
||||
console.log(
|
||||
`${pc.dim('Status:')} ${statusBadge(t.status)} ${pc.dim('Priority:')} ${priorityLabel(t.priority)}`,
|
||||
);
|
||||
console.log(`${pc.dim('Instruction:')} ${t.instruction}`);
|
||||
if (t.description) console.log(`${pc.dim('Description:')} ${t.description}`);
|
||||
if (t.agentId) console.log(`${pc.dim('Agent:')} ${t.agentId}`);
|
||||
if (t.userId) console.log(`${pc.dim('User:')} ${t.userId}`);
|
||||
if (t.parent) {
|
||||
console.log(`${pc.dim('Parent:')} ${t.parent.identifier} ${t.parent.name || ''}`);
|
||||
}
|
||||
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.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 lookup: which subtasks are completed
|
||||
const completedIdentifiers = new Set(
|
||||
t.subtasks.filter((s) => s.status === 'completed').map((s) => s.identifier),
|
||||
);
|
||||
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Dependencies ──
|
||||
if (t.dependencies && t.dependencies.length > 0) {
|
||||
console.log(`\n${pc.bold('Dependencies:')}`);
|
||||
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 || {};
|
||||
console.log(`\n${pc.bold('Checkpoint:')}`);
|
||||
const hasConfig =
|
||||
cp.onAgentRequest !== undefined ||
|
||||
cp.topic?.before ||
|
||||
cp.topic?.after ||
|
||||
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)
|
||||
console.log(` tasks.before: ${cp.tasks.beforeIds.join(', ')}`);
|
||||
if (cp.tasks?.afterIds?.length)
|
||||
console.log(` tasks.after: ${cp.tasks.afterIds.join(', ')}`);
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured, default: onAgentRequest=true)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Review ──
|
||||
{
|
||||
const rv = t.review as any;
|
||||
console.log(`\n${pc.bold('Review:')}`);
|
||||
if (rv && rv.enabled) {
|
||||
console.log(
|
||||
` judge: ${rv.judge?.model || 'default'}${rv.judge?.provider ? ` (${rv.judge.provider})` : ''}`,
|
||||
);
|
||||
console.log(` maxIterations: ${rv.maxIterations} autoRetry: ${rv.autoRetry}`);
|
||||
if (rv.rubrics?.length > 0) {
|
||||
for (let i = 0; i < rv.rubrics.length; i++) {
|
||||
const rb = rv.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(` ${pc.dim('(not configured)')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Workspace ──
|
||||
{
|
||||
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)}):`)}`);
|
||||
|
||||
const formatSize = (chars: number | null | undefined) => {
|
||||
if (!chars) return '';
|
||||
if (chars >= 10_000) return `${(chars / 1000).toFixed(1)}k`;
|
||||
return `${chars}`;
|
||||
};
|
||||
|
||||
const LEFT_COL = 56;
|
||||
const FROM_WIDTH = 10;
|
||||
|
||||
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 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 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' : '';
|
||||
|
||||
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, ' ');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Activities ──
|
||||
{
|
||||
const tl = t.timeline;
|
||||
const activities: { text: string; time: string }[] = [];
|
||||
|
||||
for (const tp of tl?.topics || []) {
|
||||
const sBadge = statusBadge(tp.status || 'running');
|
||||
activities.push({
|
||||
text: ` 💬 ${pc.dim((tp.time || '').padStart(7))} Topic #${tp.seq || '?'} ${tp.title || 'Untitled'} ${sBadge} ${pc.dim(tp.id || '')}`,
|
||||
time: tp.time || '',
|
||||
});
|
||||
}
|
||||
|
||||
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({
|
||||
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 || '',
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`\n${pc.bold('Activities:')}`);
|
||||
if (activities.length === 0) {
|
||||
console.log(` ${pc.dim('No activities yet.')}`);
|
||||
} else {
|
||||
// Activities are already sorted by the service
|
||||
for (const act of activities) {
|
||||
console.log(act.text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log();
|
||||
});
|
||||
|
||||
// ── create ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('create')
|
||||
.description('Create a new task')
|
||||
.requiredOption('-i, --instruction <text>', 'Task instruction')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--parent <id>', 'Parent task ID')
|
||||
.option('--priority <n>', 'Priority (0=none, 1=urgent, 2=high, 3=normal, 4=low)', '0')
|
||||
.option('--prefix <prefix>', 'Identifier prefix', 'TASK')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (options: {
|
||||
agent?: string;
|
||||
instruction: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
parent?: string;
|
||||
prefix?: string;
|
||||
priority?: string;
|
||||
}) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = {
|
||||
instruction: options.instruction,
|
||||
};
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.parent) input.parentTaskId = options.parent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.prefix) input.identifierPrefix = options.prefix;
|
||||
|
||||
const result = await client.task.create.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task created: ${pc.bold(result.data.identifier)} ${result.data.name || ''}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── edit ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('edit <id>')
|
||||
.description('Update a task')
|
||||
.option('-n, --name <name>', 'Task name')
|
||||
.option('-i, --instruction <text>', 'Task instruction')
|
||||
.option('--agent <id>', 'Assign to agent')
|
||||
.option('--priority <n>', 'Priority (0-4)')
|
||||
.option('--heartbeat-interval <n>', 'Heartbeat interval in seconds')
|
||||
.option('--heartbeat-timeout <n>', 'Heartbeat timeout in seconds (0 to disable)')
|
||||
.option('--description <text>', 'Task description')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
agent?: string;
|
||||
description?: string;
|
||||
heartbeatInterval?: string;
|
||||
heartbeatTimeout?: string;
|
||||
instruction?: string;
|
||||
json?: string | boolean;
|
||||
name?: string;
|
||||
priority?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const input: Record<string, any> = { id };
|
||||
if (options.name) input.name = options.name;
|
||||
if (options.instruction) input.instruction = options.instruction;
|
||||
if (options.description) input.description = options.description;
|
||||
if (options.agent) input.assigneeAgentId = options.agent;
|
||||
if (options.priority) input.priority = Number.parseInt(options.priority, 10);
|
||||
if (options.heartbeatInterval)
|
||||
input.heartbeatInterval = Number.parseInt(options.heartbeatInterval, 10);
|
||||
if (options.heartbeatTimeout !== undefined) {
|
||||
const val = Number.parseInt(options.heartbeatTimeout, 10);
|
||||
input.heartbeatTimeout = val === 0 ? null : val;
|
||||
}
|
||||
|
||||
const result = await client.task.update.mutate(input as any);
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, typeof options.json === 'string' ? options.json : undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`Task updated: ${pc.bold(result.data.identifier)}`);
|
||||
},
|
||||
);
|
||||
|
||||
// ── delete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('delete <id>')
|
||||
.description('Delete a task')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (id: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete task ${pc.bold(id)}?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.delete.mutate({ id });
|
||||
log.info(`Task ${pc.bold(id)} deleted.`);
|
||||
});
|
||||
|
||||
// ── clear ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('clear')
|
||||
.description('Delete all tasks')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete ${pc.red('ALL')} tasks? This cannot be undone.`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.clearAll.mutate()) as any;
|
||||
log.info(`${result.count} task(s) deleted.`);
|
||||
});
|
||||
|
||||
// ── tree ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('tree <id>')
|
||||
.description('Show task tree (subtasks + dependencies)')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTaskTree.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No tasks found.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Build tree display (raw SQL returns snake_case)
|
||||
const taskMap = new Map<string, any>();
|
||||
for (const t of result.data) taskMap.set(t.id, t);
|
||||
|
||||
const printNode = (taskId: string, indent: number) => {
|
||||
const t = taskMap.get(taskId);
|
||||
if (!t) return;
|
||||
|
||||
const prefix = indent === 0 ? '' : ' '.repeat(indent) + '├── ';
|
||||
const name = t.name || t.identifier || '';
|
||||
const status = t.status || 'pending';
|
||||
const identifier = t.identifier || t.id;
|
||||
console.log(`${prefix}${pc.dim(identifier)} ${statusBadge(status)} ${name}`);
|
||||
|
||||
// Print children (handle both camelCase and snake_case)
|
||||
for (const child of result.data) {
|
||||
const childParent = child.parentTaskId || child.parent_task_id;
|
||||
if (childParent === taskId) {
|
||||
printNode(child.id, indent + 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Find root - resolve identifier first
|
||||
const resolved = await client.task.find.query({ id });
|
||||
const rootId = resolved.data.id;
|
||||
const root = result.data.find((t: any) => t.id === rootId);
|
||||
if (root) printNode(root.id, 0);
|
||||
else log.info('Root task not found in tree.');
|
||||
});
|
||||
|
||||
// Register subcommand groups
|
||||
registerLifecycleCommands(task);
|
||||
registerCheckpointCommands(task);
|
||||
registerReviewCommands(task);
|
||||
registerDepCommands(task);
|
||||
registerTopicCommands(task);
|
||||
registerDocCommands(task);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { getAuthInfo } from '../../api/http';
|
||||
import { streamAgentEvents } from '../../utils/agentStream';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerLifecycleCommands(task: Command) {
|
||||
// ── start ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('start <id>')
|
||||
.description('Start a task (pending → running)')
|
||||
.option('--no-run', 'Only update status, do not trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
run?: boolean;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Check if already running
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
|
||||
if (taskDetail.data.status === 'running') {
|
||||
log.info(`Task ${pc.bold(taskDetail.data.identifier)} is already running.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const statusResult = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(statusResult.data.identifier)} started.`);
|
||||
|
||||
// Auto-run unless --no-run
|
||||
if (options.run === false) return;
|
||||
|
||||
// Default agent to inbox if not assigned
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(options.prompt && { prompt: options.prompt }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`Operation: ${pc.dim(result.operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`,
|
||||
);
|
||||
|
||||
if (!options.follow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(result.operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Send heartbeat after completion
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── run ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('run <id>')
|
||||
.description('Run a task — trigger agent execution')
|
||||
.option('-p, --prompt <text>', 'Additional context for the agent')
|
||||
.option('-c, --continue <topicId>', 'Continue running on an existing topic')
|
||||
.option('-f, --follow', 'Follow agent output in real-time (default: run in background)')
|
||||
.option('--topics <n>', 'Run N topics in sequence (default: 1, implies --follow)', '1')
|
||||
.option('--delay <s>', 'Delay between topics in seconds', '0')
|
||||
.option('--json', 'Output full JSON event stream')
|
||||
.option('-v, --verbose', 'Show detailed tool call info')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
continue?: string;
|
||||
delay?: string;
|
||||
follow?: boolean;
|
||||
json?: boolean;
|
||||
prompt?: string;
|
||||
topics?: string;
|
||||
verbose?: boolean;
|
||||
},
|
||||
) => {
|
||||
const topicCount = Number.parseInt(options.topics || '1', 10);
|
||||
const delaySec = Number.parseInt(options.delay || '0', 10);
|
||||
|
||||
// --topics > 1 implies --follow
|
||||
const shouldFollow = options.follow || topicCount > 1;
|
||||
|
||||
for (let i = 0; i < topicCount; i++) {
|
||||
if (i > 0) {
|
||||
log.info(`\n${'─'.repeat(60)}`);
|
||||
log.info(`Topic ${i + 1}/${topicCount}`);
|
||||
if (delaySec > 0) {
|
||||
log.info(`Waiting ${delaySec}s before next topic...`);
|
||||
await new Promise((r) => setTimeout(r, delaySec * 1000));
|
||||
}
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Auto-assign inbox agent on first topic if not assigned
|
||||
if (i === 0) {
|
||||
const taskDetail = await client.task.find.query({ id });
|
||||
if (!taskDetail.data.assigneeAgentId) {
|
||||
await client.task.update.mutate({ assigneeAgentId: 'inbox', id });
|
||||
log.info(`Assigned default agent: ${pc.dim('inbox')}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Only pass extra prompt and continue on first topic
|
||||
const result = (await client.task.run.mutate({
|
||||
id,
|
||||
...(i === 0 && options.prompt && { prompt: options.prompt }),
|
||||
...(i === 0 && options.continue && { continueTopicId: options.continue }),
|
||||
})) as any;
|
||||
|
||||
if (!result.success) {
|
||||
log.error(`Failed to run task: ${result.error || result.message || 'Unknown error'}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const operationId = result.operationId;
|
||||
if (i === 0) {
|
||||
log.info(`Task ${pc.bold(result.taskIdentifier)} running`);
|
||||
}
|
||||
log.info(`Operation: ${pc.dim(operationId)} · Topic: ${pc.dim(result.topicId || 'n/a')}`);
|
||||
|
||||
if (!shouldFollow) {
|
||||
log.info(
|
||||
`Agent running in background. Use ${pc.dim(`lh task view ${id}`)} to check status.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Connect to SSE stream and wait for completion
|
||||
const { serverUrl, headers } = await getAuthInfo();
|
||||
const streamUrl = `${serverUrl}/api/agent/stream?operationId=${encodeURIComponent(operationId)}`;
|
||||
|
||||
await streamAgentEvents(streamUrl, headers, {
|
||||
json: options.json,
|
||||
verbose: options.verbose,
|
||||
});
|
||||
|
||||
// Update heartbeat after each topic
|
||||
try {
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
} catch {
|
||||
// ignore heartbeat errors
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── comment ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('comment <id>')
|
||||
.description('Add a comment to a task')
|
||||
.requiredOption('-m, --message <text>', 'Comment content')
|
||||
.action(async (id: string, options: { message: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.addComment.mutate({ content: options.message, id });
|
||||
log.info('Comment added.');
|
||||
});
|
||||
|
||||
// ── pause ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('pause <id>')
|
||||
.description('Pause a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'paused' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} paused.`);
|
||||
});
|
||||
|
||||
// ── resume ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('resume <id>')
|
||||
.description('Resume a paused task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'running' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} resumed.`);
|
||||
});
|
||||
|
||||
// ── complete ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('complete <id>')
|
||||
.description('Mark a task as completed')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.updateStatus.mutate({ id, status: 'completed' })) as any;
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} completed.`);
|
||||
if (result.unlocked?.length > 0) {
|
||||
log.info(`Unlocked: ${result.unlocked.map((id: string) => pc.bold(id)).join(', ')}`);
|
||||
}
|
||||
if (result.paused?.length > 0) {
|
||||
log.info(
|
||||
`Paused (checkpoint): ${result.paused.map((id: string) => pc.yellow(id)).join(', ')}`,
|
||||
);
|
||||
}
|
||||
if (result.checkpointTriggered) {
|
||||
log.info(`${pc.yellow('Checkpoint triggered')} — parent task paused for review.`);
|
||||
}
|
||||
if (result.allSubtasksDone) {
|
||||
log.info(`All subtasks of parent task completed.`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── cancel ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('cancel <id>')
|
||||
.description('Cancel a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.updateStatus.mutate({ id, status: 'canceled' });
|
||||
log.info(`Task ${pc.bold(result.data.identifier)} canceled.`);
|
||||
});
|
||||
|
||||
// ── sort ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('sort <id> <identifiers...>')
|
||||
.description('Reorder subtasks (e.g. lh task sort TASK-1 TASK-2 TASK-4 TASK-3)')
|
||||
.action(async (id: string, identifiers: string[]) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.reorderSubtasks.mutate({
|
||||
id,
|
||||
order: identifiers,
|
||||
})) as any;
|
||||
|
||||
log.info('Subtasks reordered:');
|
||||
for (const item of result.data) {
|
||||
console.log(` ${pc.dim(`#${item.sortOrder}`)} ${item.identifier}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ── heartbeat ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('heartbeat <id>')
|
||||
.description('Manually send heartbeat for a running task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.heartbeat.mutate({ id });
|
||||
log.info(`Heartbeat sent for ${pc.bold(id)}.`);
|
||||
});
|
||||
|
||||
// ── watchdog ──────────────────────────────────────────────
|
||||
|
||||
task
|
||||
.command('watchdog')
|
||||
.description('Run watchdog check — detect and fail stuck tasks')
|
||||
.action(async () => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.watchdog.mutate()) as any;
|
||||
|
||||
if (result.failed?.length > 0) {
|
||||
log.info(
|
||||
`${pc.red('Stuck tasks failed:')} ${result.failed.map((id: string) => pc.bold(id)).join(', ')}`,
|
||||
);
|
||||
} else {
|
||||
log.info('No stuck tasks found.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,306 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { printTable, truncate } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
export function registerReviewCommands(task: Command) {
|
||||
// ── review ──────────────────────────────────────────────
|
||||
|
||||
const rv = task.command('review').description('Manage task review (LLM-as-Judge)');
|
||||
|
||||
rv.command('view <id>')
|
||||
.description('View review config for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
|
||||
if (!r || !r.enabled) {
|
||||
log.info('Review not configured for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`\n${pc.bold('Review config:')}`);
|
||||
console.log(` enabled: ${r.enabled}`);
|
||||
if (r.judge?.model)
|
||||
console.log(` judge: ${r.judge.model}${r.judge.provider ? ` (${r.judge.provider})` : ''}`);
|
||||
console.log(` maxIterations: ${r.maxIterations}`);
|
||||
console.log(` autoRetry: ${r.autoRetry}`);
|
||||
if (r.rubrics?.length > 0) {
|
||||
console.log(` rubrics:`);
|
||||
for (let i = 0; i < r.rubrics.length; i++) {
|
||||
const rb = r.rubrics[i];
|
||||
const threshold = rb.threshold ? ` ≥ ${Math.round(rb.threshold * 100)}%` : '';
|
||||
const typeTag = pc.dim(`[${rb.type}]`);
|
||||
let configInfo = '';
|
||||
if (rb.type === 'llm-rubric') configInfo = rb.config?.criteria || '';
|
||||
else if (rb.type === 'contains' || rb.type === 'equals')
|
||||
configInfo = `value="${rb.config?.value}"`;
|
||||
else if (rb.type === 'regex') configInfo = `pattern="${rb.config?.pattern}"`;
|
||||
console.log(` ${i + 1}. ${rb.name} ${typeTag}${threshold} ${pc.dim(configInfo)}`);
|
||||
}
|
||||
} else {
|
||||
console.log(` rubrics: ${pc.dim('(none)')}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
|
||||
rv.command('set <id>')
|
||||
.description('Enable review and configure judge settings')
|
||||
.option('--model <model>', 'Judge model')
|
||||
.option('--provider <provider>', 'Judge provider')
|
||||
.option('--max-iterations <n>', 'Max review iterations', '3')
|
||||
.option('--no-auto-retry', 'Disable auto retry on failure')
|
||||
.option('--recursive', 'Apply to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
autoRetry?: boolean;
|
||||
maxIterations?: string;
|
||||
model?: string;
|
||||
provider?: string;
|
||||
recursive?: boolean;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Read current review config to preserve rubrics
|
||||
const current = (await client.task.getReview.query({ id })).data as any;
|
||||
const existingRubrics = current?.rubrics || [];
|
||||
|
||||
const review = {
|
||||
autoRetry: options.autoRetry !== false,
|
||||
enabled: true,
|
||||
judge: {
|
||||
...(options.model && { model: options.model }),
|
||||
...(options.provider && { provider: options.provider }),
|
||||
},
|
||||
maxIterations: Number.parseInt(options.maxIterations || '3', 10),
|
||||
rubrics: existingRubrics,
|
||||
};
|
||||
|
||||
await client.task.updateReview.mutate({ id, review });
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
const subCurrent = (await client.task.getReview.query({ id: s.id })).data as any;
|
||||
await client.task.updateReview.mutate({
|
||||
id: s.id,
|
||||
review: { ...review, rubrics: subCurrent?.rubrics || existingRubrics },
|
||||
});
|
||||
}
|
||||
log.info(
|
||||
`Review enabled for ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info('Review enabled.');
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// ── review criteria ──────────────────────────────────────
|
||||
|
||||
const rc = rv.command('criteria').description('Manage review rubrics');
|
||||
|
||||
rc.command('list <id>')
|
||||
.description('List review rubrics for a task')
|
||||
.action(async (id: string) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getReview.query({ id });
|
||||
const r = result.data as any;
|
||||
const rubrics = r?.rubrics || [];
|
||||
|
||||
if (rubrics.length === 0) {
|
||||
log.info('No rubrics configured.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = rubrics.map((r: any, i: number) => {
|
||||
const config = r.config || {};
|
||||
const configStr =
|
||||
r.type === 'llm-rubric'
|
||||
? config.criteria || ''
|
||||
: r.type === 'contains' || r.type === 'equals'
|
||||
? `value: "${config.value}"`
|
||||
: r.type === 'regex'
|
||||
? `pattern: "${config.pattern}"`
|
||||
: JSON.stringify(config);
|
||||
|
||||
return [
|
||||
String(i + 1),
|
||||
r.name,
|
||||
r.type,
|
||||
r.threshold ? `≥ ${Math.round(r.threshold * 100)}%` : '-',
|
||||
String(r.weight ?? 1),
|
||||
truncate(configStr, 40),
|
||||
];
|
||||
});
|
||||
|
||||
printTable(rows, ['#', 'NAME', 'TYPE', 'THRESHOLD', 'WEIGHT', 'CONFIG']);
|
||||
});
|
||||
|
||||
rc.command('add <id>')
|
||||
.description('Add a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name (e.g. "内容准确性")')
|
||||
.option('--type <type>', 'Rubric type (default: llm-rubric)', 'llm-rubric')
|
||||
.option('-t, --threshold <n>', 'Pass threshold 0-100 (converted to 0-1)')
|
||||
.option('-d, --description <text>', 'Criteria description (for llm-rubric type)')
|
||||
.option('--value <value>', 'Expected value (for contains/equals type)')
|
||||
.option('--pattern <pattern>', 'Regex pattern (for regex type)')
|
||||
.option('-w, --weight <n>', 'Weight for scoring (default: 1)')
|
||||
.option('--recursive', 'Add to all subtasks as well')
|
||||
.action(
|
||||
async (
|
||||
id: string,
|
||||
options: {
|
||||
description?: string;
|
||||
name: string;
|
||||
pattern?: string;
|
||||
recursive?: boolean;
|
||||
threshold?: string;
|
||||
type: string;
|
||||
value?: string;
|
||||
weight?: string;
|
||||
},
|
||||
) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
// Build rubric config based on type
|
||||
const buildConfig = (): Record<string, any> | null => {
|
||||
switch (options.type) {
|
||||
case 'llm-rubric': {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
case 'contains':
|
||||
case 'equals':
|
||||
case 'starts-with':
|
||||
case 'ends-with': {
|
||||
if (!options.value) {
|
||||
log.error(`--value is required for type "${options.type}"`);
|
||||
return null;
|
||||
}
|
||||
return { value: options.value };
|
||||
}
|
||||
case 'regex': {
|
||||
if (!options.pattern) {
|
||||
log.error('--pattern is required for type "regex"');
|
||||
return null;
|
||||
}
|
||||
return { pattern: options.pattern };
|
||||
}
|
||||
default: {
|
||||
return { criteria: options.description || options.name };
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const config = buildConfig();
|
||||
if (!config) return;
|
||||
|
||||
const rubric: Record<string, any> = {
|
||||
config,
|
||||
id: `rubric-${Date.now()}`,
|
||||
name: options.name,
|
||||
type: options.type,
|
||||
weight: options.weight ? Number.parseFloat(options.weight) : 1,
|
||||
};
|
||||
if (options.threshold) {
|
||||
rubric.threshold = Number.parseInt(options.threshold, 10) / 100;
|
||||
}
|
||||
|
||||
const addToTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
const rubrics = current?.rubrics || [];
|
||||
|
||||
// Replace if same name exists, otherwise append
|
||||
const filtered = rubrics.filter((r: any) => r.name !== options.name);
|
||||
filtered.push(rubric);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: {
|
||||
autoRetry: current?.autoRetry ?? true,
|
||||
enabled: current?.enabled ?? true,
|
||||
judge: current?.judge ?? {},
|
||||
maxIterations: current?.maxIterations ?? 3,
|
||||
rubrics: filtered,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
await addToTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await addToTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" [${options.type}] added to ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" [${options.type}] added.`);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
rc.command('rm <id>')
|
||||
.description('Remove a review rubric')
|
||||
.requiredOption('-n, --name <name>', 'Rubric name to remove')
|
||||
.option('--recursive', 'Remove from all subtasks as well')
|
||||
.action(async (id: string, options: { name: string; recursive?: boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
const removeFromTask = async (taskId: string) => {
|
||||
const current = (await client.task.getReview.query({ id: taskId })).data as any;
|
||||
if (!current) return;
|
||||
|
||||
const rubrics = (current.rubrics || []).filter((r: any) => r.name !== options.name);
|
||||
|
||||
await client.task.updateReview.mutate({
|
||||
id: taskId,
|
||||
review: { ...current, rubrics },
|
||||
});
|
||||
};
|
||||
|
||||
await removeFromTask(id);
|
||||
|
||||
if (options.recursive) {
|
||||
const subtasks = await client.task.getSubtasks.query({ id });
|
||||
for (const s of subtasks.data || []) {
|
||||
await removeFromTask(s.id);
|
||||
}
|
||||
log.info(
|
||||
`Rubric "${options.name}" removed from ${pc.bold(id)} + ${(subtasks.data || []).length} subtask(s).`,
|
||||
);
|
||||
} else {
|
||||
log.info(`Rubric "${options.name}" removed.`);
|
||||
}
|
||||
});
|
||||
|
||||
rv.command('run <id>')
|
||||
.description('Manually run review on content')
|
||||
.requiredOption('--content <text>', 'Content to review')
|
||||
.action(async (id: string, options: { content: string }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = (await client.task.runReview.mutate({
|
||||
content: options.content,
|
||||
id,
|
||||
})) as any;
|
||||
const r = result.data;
|
||||
|
||||
console.log(
|
||||
`\n${r.passed ? pc.green('✓ Review passed') : pc.red('✗ Review failed')} (${r.overallScore}%)`,
|
||||
);
|
||||
for (const s of r.rubricResults || []) {
|
||||
const icon = s.passed ? pc.green('✓') : pc.red('✗');
|
||||
const pct = Math.round(s.score * 100);
|
||||
console.log(` ${icon} ${s.rubricId}: ${pct}%${s.reason ? ` — ${s.reason}` : ''}`);
|
||||
}
|
||||
console.log();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import type { Command } from 'commander';
|
||||
import pc from 'picocolors';
|
||||
|
||||
import { getTrpcClient } from '../../api/client';
|
||||
import { confirm, outputJson, printTable, timeAgo, truncate } from '../../utils/format';
|
||||
import { log } from '../../utils/logger';
|
||||
import { statusBadge } from './helpers';
|
||||
|
||||
export function registerTopicCommands(task: Command) {
|
||||
// ── topic ──────────────────────────────────────────────
|
||||
|
||||
const tp = task.command('topic').description('Manage task topics');
|
||||
|
||||
tp.command('list <id>')
|
||||
.description('List topics for a task')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
const result = await client.task.getTopics.query({ id });
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(result.data, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.data || result.data.length === 0) {
|
||||
log.info('No topics found for this task.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = result.data.map((t: any) => [
|
||||
`#${t.seq}`,
|
||||
t.id,
|
||||
statusBadge(t.status || 'running'),
|
||||
truncate(t.title || 'Untitled', 40),
|
||||
t.operationId ? pc.dim(truncate(t.operationId, 20)) : '-',
|
||||
timeAgo(t.createdAt),
|
||||
]);
|
||||
|
||||
printTable(rows, ['SEQ', 'TOPIC ID', 'STATUS', 'TITLE', 'OPERATION', 'CREATED']);
|
||||
});
|
||||
|
||||
tp.command('view <id> <topicId>')
|
||||
.description('View messages of a topic (topicId can be a seq number like "1")')
|
||||
.option('--json [fields]', 'Output JSON')
|
||||
.action(async (id: string, topicId: string, options: { json?: string | boolean }) => {
|
||||
const client = await getTrpcClient();
|
||||
|
||||
let resolvedTopicId = topicId;
|
||||
|
||||
// If it's a number, treat as seq index
|
||||
const seqNum = Number.parseInt(topicId, 10);
|
||||
if (!Number.isNaN(seqNum) && String(seqNum) === topicId) {
|
||||
const topicsResult = await client.task.getTopics.query({ id });
|
||||
const match = (topicsResult.data || []).find((t: any) => t.seq === seqNum);
|
||||
if (!match) {
|
||||
log.error(`Topic #${seqNum} not found for this task.`);
|
||||
return;
|
||||
}
|
||||
resolvedTopicId = match.id;
|
||||
log.info(
|
||||
`Topic #${seqNum}: ${pc.bold(match.title || 'Untitled')} ${pc.dim(resolvedTopicId)}`,
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await client.message.getMessages.query({ topicId: resolvedTopicId });
|
||||
const items = Array.isArray(messages) ? messages : [];
|
||||
|
||||
if (options.json !== undefined) {
|
||||
outputJson(items, options.json);
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
log.info('No messages in this topic.');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log();
|
||||
for (const msg of items) {
|
||||
const role =
|
||||
msg.role === 'assistant'
|
||||
? pc.green('Assistant')
|
||||
: msg.role === 'user'
|
||||
? pc.blue('User')
|
||||
: pc.dim(msg.role);
|
||||
|
||||
console.log(`${pc.bold(role)} ${pc.dim(timeAgo(msg.createdAt))}`);
|
||||
if (msg.content) {
|
||||
console.log(msg.content);
|
||||
}
|
||||
console.log();
|
||||
}
|
||||
});
|
||||
|
||||
tp.command('cancel <topicId>')
|
||||
.description('Cancel a running topic and pause the task')
|
||||
.action(async (topicId: string) => {
|
||||
const client = await getTrpcClient();
|
||||
await client.task.cancelTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} canceled. Task paused.`);
|
||||
});
|
||||
|
||||
tp.command('delete <topicId>')
|
||||
.description('Delete a topic and its messages')
|
||||
.option('-y, --yes', 'Skip confirmation')
|
||||
.action(async (topicId: string, options: { yes?: boolean }) => {
|
||||
if (!options.yes) {
|
||||
const ok = await confirm(`Delete topic ${pc.bold(topicId)} and all its messages?`);
|
||||
if (!ok) return;
|
||||
}
|
||||
|
||||
const client = await getTrpcClient();
|
||||
await client.task.deleteTopic.mutate({ topicId });
|
||||
log.info(`Topic ${pc.bold(topicId)} deleted.`);
|
||||
});
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Command } from 'commander';
|
||||
import { registerAgentCommand } from './commands/agent';
|
||||
import { registerAgentGroupCommand } from './commands/agent-group';
|
||||
import { registerBotCommand } from './commands/bot';
|
||||
import { registerBriefCommand } from './commands/brief';
|
||||
import { registerConfigCommand } from './commands/config';
|
||||
import { registerConnectCommand } from './commands/connect';
|
||||
import { registerCronCommand } from './commands/cron';
|
||||
@@ -25,6 +26,7 @@ import { registerSearchCommand } from './commands/search';
|
||||
import { registerSessionGroupCommand } from './commands/session-group';
|
||||
import { registerSkillCommand } from './commands/skill';
|
||||
import { registerStatusCommand } from './commands/status';
|
||||
import { registerTaskCommand } from './commands/task';
|
||||
import { registerThreadCommand } from './commands/thread';
|
||||
import { registerTopicCommand } from './commands/topic';
|
||||
import { registerUserCommand } from './commands/user';
|
||||
@@ -51,11 +53,13 @@ registerMemoryCommand(program);
|
||||
registerAgentCommand(program);
|
||||
registerAgentGroupCommand(program);
|
||||
registerBotCommand(program);
|
||||
registerBriefCommand(program);
|
||||
registerCronCommand(program);
|
||||
registerGenerateCommand(program);
|
||||
registerFileCommand(program);
|
||||
registerSkillCommand(program);
|
||||
registerSessionGroupCommand(program);
|
||||
registerTaskCommand(program);
|
||||
registerThreadCommand(program);
|
||||
registerTopicCommand(program);
|
||||
registerMessageCommand(program);
|
||||
@@ -66,4 +70,32 @@ registerUserCommand(program);
|
||||
registerConfigCommand(program);
|
||||
registerEvalCommand(program);
|
||||
|
||||
// Global error handler for clean TRPC error output
|
||||
process.on('uncaughtException', (error: any) => {
|
||||
if (error?.name === 'TRPCClientError' || error?.constructor?.name?.includes('TRPCClientError')) {
|
||||
const message = error.message || 'Unknown error';
|
||||
const code = error.data?.code || error.shape?.data?.code || '';
|
||||
const path = error.data?.path || error.shape?.data?.path || '';
|
||||
console.error(`\x1B[31mError${path ? ` [${path}]` : ''}: ${message}\x1B[0m`);
|
||||
if (code) console.error(`\x1B[2mCode: ${code}\x1B[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
// Re-throw non-TRPC errors
|
||||
console.error(error?.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (error: any) => {
|
||||
if (error?.name === 'TRPCClientError' || error?.constructor?.name?.includes('TRPCClientError')) {
|
||||
const message = error.message || 'Unknown error';
|
||||
const code = error.data?.code || error.shape?.data?.code || '';
|
||||
const path = error.data?.path || error.shape?.data?.path || '';
|
||||
console.error(`\x1B[31mError${path ? ` [${path}]` : ''}: ${message}\x1B[0m`);
|
||||
if (code) console.error(`\x1B[2mCode: ${code}\x1B[0m`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.error(error?.message || error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@@ -87,7 +87,7 @@ function stripAnsi(s: string): string {
|
||||
* Calculate the display width of a string in the terminal.
|
||||
* CJK characters and fullwidth symbols occupy 2 columns.
|
||||
*/
|
||||
function displayWidth(s: string): number {
|
||||
export function displayWidth(s: string): number {
|
||||
const plain = stripAnsi(s);
|
||||
let width = 0;
|
||||
for (const char of plain) {
|
||||
|
||||
@@ -465,6 +465,37 @@ table verifications {
|
||||
}
|
||||
}
|
||||
|
||||
table briefs {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
user_id text [not null]
|
||||
task_id text
|
||||
cron_job_id text
|
||||
topic_id text
|
||||
agent_id text
|
||||
type text [not null]
|
||||
priority text [default: 'info']
|
||||
title text [not null]
|
||||
summary text [not null]
|
||||
artifacts jsonb
|
||||
actions jsonb
|
||||
comment_type text
|
||||
resolved_action text
|
||||
resolved_comment text
|
||||
read_at "timestamp with time zone"
|
||||
resolved_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
user_id [name: 'briefs_user_id_idx']
|
||||
task_id [name: 'briefs_task_id_idx']
|
||||
cron_job_id [name: 'briefs_cron_job_id_idx']
|
||||
agent_id [name: 'briefs_agent_id_idx']
|
||||
type [name: 'briefs_type_idx']
|
||||
priority [name: 'briefs_priority_idx']
|
||||
(user_id, resolved_at) [name: 'briefs_unresolved_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table chat_groups {
|
||||
id text [pk, not null]
|
||||
title text
|
||||
@@ -1340,6 +1371,130 @@ table sessions {
|
||||
}
|
||||
}
|
||||
|
||||
table task_comments {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
user_id text
|
||||
agent_id text
|
||||
content text [not null]
|
||||
editor_data jsonb
|
||||
brief_id uuid
|
||||
topic_id text
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
task_id [name: 'task_comments_task_id_idx']
|
||||
user_id [name: 'task_comments_user_id_idx']
|
||||
agent_id [name: 'task_comments_agent_id_idx']
|
||||
brief_id [name: 'task_comments_brief_id_idx']
|
||||
topic_id [name: 'task_comments_topic_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_dependencies {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
depends_on_id text [not null]
|
||||
type text [not null, default: 'blocks']
|
||||
condition jsonb
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, depends_on_id) [name: 'task_deps_unique_idx', unique]
|
||||
task_id [name: 'task_deps_task_id_idx']
|
||||
depends_on_id [name: 'task_deps_depends_on_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_documents {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
document_id text [not null]
|
||||
pinned_by text [not null, default: 'agent']
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, document_id) [name: 'task_docs_unique_idx', unique]
|
||||
task_id [name: 'task_docs_task_id_idx']
|
||||
document_id [name: 'task_docs_document_id_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table task_topics {
|
||||
id uuid [pk, not null, default: `gen_random_uuid()`]
|
||||
task_id text [not null]
|
||||
topic_id text [not null]
|
||||
user_id text [not null]
|
||||
seq integer [not null]
|
||||
operation_id text
|
||||
status text [not null, default: 'running']
|
||||
handoff_title text
|
||||
handoff_summary text
|
||||
handoff_key_findings jsonb
|
||||
handoff_next_action text
|
||||
review_passed integer
|
||||
review_score integer
|
||||
review_scores jsonb
|
||||
review_iteration integer
|
||||
reviewed_at "timestamp with time zone"
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(task_id, topic_id) [name: 'task_topics_unique_idx', unique]
|
||||
task_id [name: 'task_topics_task_id_idx']
|
||||
topic_id [name: 'task_topics_topic_id_idx']
|
||||
user_id [name: 'task_topics_user_id_idx']
|
||||
(task_id, status) [name: 'task_topics_status_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table tasks {
|
||||
id text [pk, not null]
|
||||
identifier text [not null]
|
||||
seq integer [not null]
|
||||
created_by_user_id text [not null]
|
||||
created_by_agent_id text
|
||||
assignee_user_id text
|
||||
assignee_agent_id text
|
||||
parent_task_id text
|
||||
name text
|
||||
description varchar(255)
|
||||
instruction text [not null]
|
||||
status text [not null, default: 'backlog']
|
||||
priority integer [default: 0]
|
||||
sort_order integer [default: 0]
|
||||
heartbeat_interval integer [default: 300]
|
||||
heartbeat_timeout integer
|
||||
last_heartbeat_at "timestamp with time zone"
|
||||
schedule_pattern text
|
||||
schedule_timezone text [default: 'UTC']
|
||||
total_topics integer [default: 0]
|
||||
max_topics integer
|
||||
current_topic_id text
|
||||
context jsonb [default: `{}`]
|
||||
config jsonb [default: `{}`]
|
||||
error text
|
||||
started_at "timestamp with time zone"
|
||||
completed_at "timestamp with time zone"
|
||||
accessed_at "timestamp with time zone" [not null, default: `now()`]
|
||||
created_at "timestamp with time zone" [not null, default: `now()`]
|
||||
updated_at "timestamp with time zone" [not null, default: `now()`]
|
||||
|
||||
indexes {
|
||||
(identifier, created_by_user_id) [name: 'tasks_identifier_idx', unique]
|
||||
created_by_user_id [name: 'tasks_created_by_user_id_idx']
|
||||
created_by_agent_id [name: 'tasks_created_by_agent_id_idx']
|
||||
assignee_user_id [name: 'tasks_assignee_user_id_idx']
|
||||
assignee_agent_id [name: 'tasks_assignee_agent_id_idx']
|
||||
parent_task_id [name: 'tasks_parent_task_id_idx']
|
||||
status [name: 'tasks_status_idx']
|
||||
priority [name: 'tasks_priority_idx']
|
||||
(status, last_heartbeat_at) [name: 'tasks_heartbeat_idx']
|
||||
}
|
||||
}
|
||||
|
||||
table threads {
|
||||
id text [pk, not null]
|
||||
title text
|
||||
|
||||
@@ -204,6 +204,7 @@
|
||||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-management": "workspace:*",
|
||||
"@lobechat/builtin-tool-brief": "workspace:*",
|
||||
"@lobechat/builtin-tool-calculator": "workspace:*",
|
||||
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
||||
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
|
||||
@@ -217,6 +218,7 @@
|
||||
"@lobechat/builtin-tool-remote-device": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-store": "workspace:*",
|
||||
"@lobechat/builtin-tool-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-task": "workspace:*",
|
||||
"@lobechat/builtin-tool-tools": "workspace:*",
|
||||
"@lobechat/builtin-tool-topic-reference": "workspace:*",
|
||||
"@lobechat/builtin-tool-web-browsing": "workspace:*",
|
||||
|
||||
@@ -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('');
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@lobechat/builtin-tool-brief",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { BriefIdentifier, BriefManifest } from './manifest';
|
||||
export { BriefApiName } from './types';
|
||||
@@ -0,0 +1,83 @@
|
||||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { BriefApiName } from './types';
|
||||
|
||||
export const BriefIdentifier = 'lobe-brief';
|
||||
|
||||
export const BriefManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
{
|
||||
description:
|
||||
"Create a brief to report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. Default actions are auto-generated based on type, but you can customize them.",
|
||||
name: BriefApiName.createBrief,
|
||||
parameters: {
|
||||
properties: {
|
||||
actions: {
|
||||
description:
|
||||
'Custom action buttons for the user. If omitted, defaults are generated based on type. Each action has key (identifier), label (display text), and type ("resolve" to close, "comment" to prompt feedback).',
|
||||
items: {
|
||||
properties: {
|
||||
key: { description: 'Action identifier, e.g. "approve", "split"', type: 'string' },
|
||||
label: { description: 'Display label, e.g. "✅ 同意拆分"', type: 'string' },
|
||||
type: {
|
||||
description: '"resolve" closes the brief, "comment" prompts for text input',
|
||||
enum: ['resolve', 'comment'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['key', 'label', 'type'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
priority: {
|
||||
description: "Priority of the brief. Default is 'normal'.",
|
||||
enum: ['urgent', 'normal', 'info'],
|
||||
type: 'string',
|
||||
},
|
||||
summary: {
|
||||
description: 'Detailed summary content of the brief.',
|
||||
type: 'string',
|
||||
},
|
||||
title: {
|
||||
description: 'A short title for the brief.',
|
||||
type: 'string',
|
||||
},
|
||||
type: {
|
||||
description:
|
||||
"The type of brief: 'decision' for user input needed, 'result' for deliverables, 'insight' for observations.",
|
||||
enum: ['decision', 'result', 'insight'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['type', 'title', 'summary'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'Pause execution and request the user to review your work before continuing. Use at natural review points.',
|
||||
humanIntervention: 'required',
|
||||
name: BriefApiName.requestCheckpoint,
|
||||
parameters: {
|
||||
properties: {
|
||||
reason: {
|
||||
description: 'The reason for requesting a checkpoint.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['reason'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
identifier: BriefIdentifier,
|
||||
meta: {
|
||||
avatar: '📋',
|
||||
description: 'Report progress, deliver results, and request user decisions',
|
||||
title: 'Brief Tools',
|
||||
},
|
||||
systemRole: systemPrompt,
|
||||
type: 'builtin',
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
export const systemPrompt = `You have access to Brief communication tools. Use them to interact with the user:
|
||||
|
||||
- **createBrief**: Report progress, deliver results, or request decisions from the user. Use type 'decision' when you need user input, 'result' for deliverables, 'insight' for observations. You can define custom action buttons for the user to respond with
|
||||
- **requestCheckpoint**: Pause execution and ask the user to review your work before continuing. Use at natural review points
|
||||
|
||||
When communicating:
|
||||
1. Use createBrief to deliver results and request feedback at key milestones
|
||||
2. Use requestCheckpoint when you need explicit approval before proceeding
|
||||
3. For decision briefs, provide clear action options (e.g. approve, reject, modify)`;
|
||||
@@ -0,0 +1,9 @@
|
||||
export const BriefApiName = {
|
||||
/** Create a brief to report progress, results, or request decisions */
|
||||
createBrief: 'createBrief',
|
||||
|
||||
/** Pause execution and request user review */
|
||||
requestCheckpoint: 'requestCheckpoint',
|
||||
} as const;
|
||||
|
||||
export type BriefApiNameType = (typeof BriefApiName)[keyof typeof BriefApiName];
|
||||
@@ -27,6 +27,11 @@ interface DocumentServiceResult {
|
||||
}
|
||||
|
||||
interface NotebookService {
|
||||
/**
|
||||
* Associate a document with a task (optional, for task execution context)
|
||||
*/
|
||||
associateDocumentWithTask?: (documentId: string, taskId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Associate a document with a topic
|
||||
*/
|
||||
@@ -115,11 +120,18 @@ export class NotebookExecutionRuntime {
|
||||
*/
|
||||
async createDocument(
|
||||
args: CreateDocumentArgs,
|
||||
options?: { topicId?: string | null },
|
||||
options?: { taskId?: string | null; topicId?: string | null },
|
||||
): Promise<BuiltinServerRuntimeOutput> {
|
||||
try {
|
||||
const { title, content, type = 'markdown' } = args;
|
||||
|
||||
if (!content) {
|
||||
return {
|
||||
content: 'Error: Missing content. The document content is required.',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!options?.topicId) {
|
||||
return {
|
||||
content: 'Error: No topic context. Documents must be created within a topic.',
|
||||
@@ -141,6 +153,11 @@ export class NotebookExecutionRuntime {
|
||||
// Associate with topic
|
||||
await this.notebookService.associateDocumentWithTopic(doc.id, options.topicId);
|
||||
|
||||
// Associate with task if in task execution context
|
||||
if (options.taskId && this.notebookService.associateDocumentWithTask) {
|
||||
await this.notebookService.associateDocumentWithTask(doc.id, options.taskId);
|
||||
}
|
||||
|
||||
const notebookDoc = toNotebookDocument(doc);
|
||||
const state: CreateDocumentState = { document: notebookDoc };
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@lobechat/builtin-tool-task",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"devDependencies": {
|
||||
"@lobechat/types": "workspace:*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { TaskIdentifier, TaskManifest } from './manifest';
|
||||
export { TaskApiName } from './types';
|
||||
@@ -0,0 +1,209 @@
|
||||
import type { BuiltinToolManifest } from '@lobechat/types';
|
||||
|
||||
import { systemPrompt } from './systemRole';
|
||||
import { TaskApiName } from './types';
|
||||
|
||||
export const TaskIdentifier = 'lobe-task';
|
||||
|
||||
export const TaskManifest: BuiltinToolManifest = {
|
||||
api: [
|
||||
// ==================== Task CRUD ====================
|
||||
{
|
||||
description:
|
||||
'Create a new task. Optionally attach it as a subtask by specifying parentIdentifier. Review config is inherited from parent task by default.',
|
||||
name: TaskApiName.createTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
instruction: {
|
||||
description: 'Detailed instruction for what the task should accomplish.',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description: 'A short, descriptive name for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
parentIdentifier: {
|
||||
description:
|
||||
'Identifier of the parent task (e.g. "TASK-1"). If provided, the new task becomes a subtask. Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
priority: {
|
||||
description: 'Priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low. Default is 0.',
|
||||
type: 'number',
|
||||
},
|
||||
sortOrder: {
|
||||
description:
|
||||
'Sort order within parent task. Lower values appear first. Use to control display order (e.g. chapter 1=0, chapter 2=1, etc.).',
|
||||
type: 'number',
|
||||
},
|
||||
review: {
|
||||
description:
|
||||
'Review config. If omitted, inherits from parent task. Set to configure LLM-as-Judge auto-review.',
|
||||
properties: {
|
||||
autoRetry: {
|
||||
description: 'Auto-retry on failure. Default true.',
|
||||
type: 'boolean',
|
||||
},
|
||||
criteria: {
|
||||
description: 'Review criteria with name and threshold (0-100).',
|
||||
items: {
|
||||
properties: {
|
||||
name: { description: 'Criterion name, e.g. "内容准确性"', type: 'string' },
|
||||
threshold: { description: 'Pass threshold (0-100)', type: 'number' },
|
||||
},
|
||||
required: ['name', 'threshold'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
enabled: { description: 'Enable review. Default false.', type: 'boolean' },
|
||||
maxIterations: {
|
||||
description: 'Max review iterations. Default 3.',
|
||||
type: 'number',
|
||||
},
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['name', 'instruction'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'List tasks with optional filters. Without filters, lists subtasks of the current task.',
|
||||
name: TaskApiName.listTasks,
|
||||
parameters: {
|
||||
properties: {
|
||||
parentIdentifier: {
|
||||
description:
|
||||
'List subtasks of a specific parent task. Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
status: {
|
||||
description: 'Filter by status.',
|
||||
enum: ['backlog', 'running', 'paused', 'completed', 'failed', 'canceled'],
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
'View details of a specific task. If no identifier is provided, returns the current task.',
|
||||
name: TaskApiName.viewTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description:
|
||||
'The task identifier to view (e.g. "TASK-1"). Defaults to the current task if omitted.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: [],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
{
|
||||
description:
|
||||
"Edit a task's name, instruction, priority, or dependencies. Use addDependency/removeDependency to manage execution order.",
|
||||
name: TaskApiName.editTask,
|
||||
parameters: {
|
||||
properties: {
|
||||
addDependency: {
|
||||
description:
|
||||
'Add a dependency — this task will block until the specified task completes. Provide the identifier (e.g. "TASK-2").',
|
||||
type: 'string',
|
||||
},
|
||||
identifier: {
|
||||
description: 'The identifier of the task to edit.',
|
||||
type: 'string',
|
||||
},
|
||||
instruction: {
|
||||
description: 'Updated instruction for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
name: {
|
||||
description: 'Updated name for the task.',
|
||||
type: 'string',
|
||||
},
|
||||
priority: {
|
||||
description: 'Updated priority level: 0=none, 1=urgent, 2=high, 3=normal, 4=low.',
|
||||
type: 'number',
|
||||
},
|
||||
removeDependency: {
|
||||
description: 'Remove a dependency. Provide the identifier of the dependency to remove.',
|
||||
type: 'string',
|
||||
},
|
||||
review: {
|
||||
description: 'Update review config.',
|
||||
properties: {
|
||||
autoRetry: { type: 'boolean' },
|
||||
criteria: {
|
||||
items: {
|
||||
properties: {
|
||||
name: { type: 'string' },
|
||||
threshold: { type: 'number' },
|
||||
},
|
||||
required: ['name', 'threshold'],
|
||||
type: 'object',
|
||||
},
|
||||
type: 'array',
|
||||
},
|
||||
enabled: { type: 'boolean' },
|
||||
maxIterations: { type: 'number' },
|
||||
},
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
required: ['identifier'],
|
||||
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,
|
||||
parameters: {
|
||||
properties: {
|
||||
identifier: {
|
||||
description: 'The identifier of the task to delete.',
|
||||
type: 'string',
|
||||
},
|
||||
},
|
||||
required: ['identifier'],
|
||||
type: 'object',
|
||||
},
|
||||
},
|
||||
],
|
||||
identifier: TaskIdentifier,
|
||||
meta: {
|
||||
avatar: '\uD83D\uDCCB',
|
||||
description: 'Create, list, edit, delete tasks with dependencies and review config',
|
||||
title: 'Task Tools',
|
||||
},
|
||||
systemRole: systemPrompt,
|
||||
type: 'builtin',
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
export const systemPrompt = `You have access to Task management tools. Use them to:
|
||||
|
||||
- **createTask**: Create a new task. Use parentIdentifier to make it a subtask. Review config is inherited from parent by default, or specify custom review criteria
|
||||
- **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
|
||||
4. Use updateTaskStatus to mark the current task as completed when you finish all work`;
|
||||
@@ -0,0 +1,21 @@
|
||||
export const TaskApiName = {
|
||||
/** Create a new task, optionally as a subtask of another task */
|
||||
createTask: 'createTask',
|
||||
|
||||
/** Delete a task */
|
||||
deleteTask: 'deleteTask',
|
||||
|
||||
/** Edit a task's name, instruction, priority, dependencies, or review config */
|
||||
editTask: 'editTask',
|
||||
|
||||
/** 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;
|
||||
|
||||
export type TaskApiNameType = (typeof TaskApiName)[keyof typeof TaskApiName];
|
||||
@@ -18,6 +18,7 @@
|
||||
"dependencies": {
|
||||
"@lobechat/builtin-tool-agent-builder": "workspace:*",
|
||||
"@lobechat/builtin-tool-agent-documents": "workspace:*",
|
||||
"@lobechat/builtin-tool-brief": "workspace:*",
|
||||
"@lobechat/builtin-tool-cloud-sandbox": "workspace:*",
|
||||
"@lobechat/builtin-tool-group-agent-builder": "workspace:*",
|
||||
"@lobechat/builtin-tool-group-management": "workspace:*",
|
||||
@@ -30,6 +31,7 @@
|
||||
"@lobechat/builtin-tool-remote-device": "workspace:*",
|
||||
"@lobechat/builtin-tool-skill-store": "workspace:*",
|
||||
"@lobechat/builtin-tool-skills": "workspace:*",
|
||||
"@lobechat/builtin-tool-task": "workspace:*",
|
||||
"@lobechat/builtin-tool-tools": "workspace:*",
|
||||
"@lobechat/builtin-tool-topic-reference": "workspace:*",
|
||||
"@lobechat/builtin-tool-web-browsing": "workspace:*",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { AgentBuilderManifest } from '@lobechat/builtin-tool-agent-builder';
|
||||
import { AgentDocumentsManifest } from '@lobechat/builtin-tool-agent-documents';
|
||||
import { AgentManagementManifest } from '@lobechat/builtin-tool-agent-management';
|
||||
import { BriefManifest } from '@lobechat/builtin-tool-brief';
|
||||
import { CalculatorManifest } from '@lobechat/builtin-tool-calculator';
|
||||
import { CloudSandboxManifest } from '@lobechat/builtin-tool-cloud-sandbox';
|
||||
import { GroupAgentBuilderManifest } from '@lobechat/builtin-tool-group-agent-builder';
|
||||
@@ -14,6 +15,7 @@ import { PageAgentManifest } from '@lobechat/builtin-tool-page-agent';
|
||||
import { RemoteDeviceManifest } from '@lobechat/builtin-tool-remote-device';
|
||||
import { SkillStoreManifest } from '@lobechat/builtin-tool-skill-store';
|
||||
import { SkillsManifest } from '@lobechat/builtin-tool-skills';
|
||||
import { TaskManifest } from '@lobechat/builtin-tool-task';
|
||||
import { LobeToolsManifest } from '@lobechat/builtin-tool-tools';
|
||||
import { TopicReferenceManifest } from '@lobechat/builtin-tool-topic-reference';
|
||||
import { WebBrowsingManifest } from '@lobechat/builtin-tool-web-browsing';
|
||||
@@ -163,4 +165,18 @@ export const builtinTools: LobeBuiltinTool[] = [
|
||||
manifest: TopicReferenceManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
discoverable: false,
|
||||
hidden: true,
|
||||
identifier: TaskManifest.identifier,
|
||||
manifest: TaskManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
{
|
||||
discoverable: false,
|
||||
hidden: true,
|
||||
identifier: BriefManifest.identifier,
|
||||
manifest: BriefManifest,
|
||||
type: 'builtin',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
CREATE TABLE IF NOT EXISTS "briefs" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"task_id" text,
|
||||
"cron_job_id" text,
|
||||
"topic_id" text,
|
||||
"agent_id" text,
|
||||
"type" text NOT NULL,
|
||||
"priority" text DEFAULT 'info',
|
||||
"title" text NOT NULL,
|
||||
"summary" text NOT NULL,
|
||||
"artifacts" jsonb,
|
||||
"actions" jsonb,
|
||||
"comment_type" text,
|
||||
"resolved_action" text,
|
||||
"resolved_comment" text,
|
||||
"read_at" timestamp with time zone,
|
||||
"resolved_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "task_comments" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"user_id" text,
|
||||
"agent_id" text,
|
||||
"content" text NOT NULL,
|
||||
"editor_data" jsonb,
|
||||
"brief_id" uuid,
|
||||
"topic_id" text,
|
||||
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "task_dependencies" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"depends_on_id" text NOT NULL,
|
||||
"type" text DEFAULT 'blocks' NOT NULL,
|
||||
"condition" jsonb,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "task_documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"document_id" text NOT NULL,
|
||||
"pinned_by" text DEFAULT 'agent' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "task_topics" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"task_id" text NOT NULL,
|
||||
"topic_id" text NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"operation_id" text,
|
||||
"status" text DEFAULT 'running' NOT NULL,
|
||||
"review_passed" integer,
|
||||
"review_score" integer,
|
||||
"review_scores" jsonb,
|
||||
"review_iteration" integer,
|
||||
"reviewed_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "tasks" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"identifier" text NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"created_by_user_id" text NOT NULL,
|
||||
"created_by_agent_id" text,
|
||||
"assignee_user_id" text,
|
||||
"assignee_agent_id" text,
|
||||
"parent_task_id" text,
|
||||
"name" text,
|
||||
"description" varchar(255),
|
||||
"instruction" text NOT NULL,
|
||||
"status" text DEFAULT 'backlog' NOT NULL,
|
||||
"priority" integer DEFAULT 0,
|
||||
"sort_order" integer DEFAULT 0,
|
||||
"heartbeat_interval" integer DEFAULT 300,
|
||||
"heartbeat_timeout" integer,
|
||||
"last_heartbeat_at" timestamp with time zone,
|
||||
"schedule_pattern" text,
|
||||
"schedule_timezone" text DEFAULT 'UTC',
|
||||
"total_topics" integer DEFAULT 0,
|
||||
"max_topics" integer,
|
||||
"current_topic_id" text,
|
||||
"context" jsonb DEFAULT '{}'::jsonb,
|
||||
"config" jsonb DEFAULT '{}'::jsonb,
|
||||
"error" text,
|
||||
"started_at" timestamp with time zone,
|
||||
"completed_at" timestamp with time zone,
|
||||
"accessed_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_user_id_users_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_task_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "briefs" DROP CONSTRAINT IF EXISTS "briefs_cron_job_id_agent_cron_jobs_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "briefs" ADD CONSTRAINT "briefs_cron_job_id_agent_cron_jobs_id_fk" FOREIGN KEY ("cron_job_id") REFERENCES "public"."agent_cron_jobs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_comments" DROP CONSTRAINT IF EXISTS "task_comments_task_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_comments" ADD CONSTRAINT "task_comments_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" DROP CONSTRAINT IF EXISTS "task_dependencies_task_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" DROP CONSTRAINT IF EXISTS "task_dependencies_depends_on_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_dependencies" ADD CONSTRAINT "task_dependencies_depends_on_id_tasks_id_fk" FOREIGN KEY ("depends_on_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_documents" DROP CONSTRAINT IF EXISTS "task_documents_task_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_documents" ADD CONSTRAINT "task_documents_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_documents" DROP CONSTRAINT IF EXISTS "task_documents_document_id_documents_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_documents" ADD CONSTRAINT "task_documents_document_id_documents_id_fk" FOREIGN KEY ("document_id") REFERENCES "public"."documents"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" DROP CONSTRAINT IF EXISTS "task_topics_task_id_tasks_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD CONSTRAINT "task_topics_task_id_tasks_id_fk" FOREIGN KEY ("task_id") REFERENCES "public"."tasks"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tasks" DROP CONSTRAINT IF EXISTS "tasks_created_by_user_id_users_id_fk";--> statement-breakpoint
|
||||
ALTER TABLE "tasks" ADD CONSTRAINT "tasks_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_user_id_idx" ON "briefs" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_task_id_idx" ON "briefs" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_cron_job_id_idx" ON "briefs" USING btree ("cron_job_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_agent_id_idx" ON "briefs" USING btree ("agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_type_idx" ON "briefs" USING btree ("type");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_priority_idx" ON "briefs" USING btree ("priority");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "briefs_unresolved_idx" ON "briefs" USING btree ("user_id","resolved_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_comments_task_id_idx" ON "task_comments" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_comments_user_id_idx" ON "task_comments" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_comments_agent_id_idx" ON "task_comments" USING btree ("agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_comments_brief_id_idx" ON "task_comments" USING btree ("brief_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_comments_topic_id_idx" ON "task_comments" USING btree ("topic_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "task_deps_unique_idx" ON "task_dependencies" USING btree ("task_id","depends_on_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_deps_task_id_idx" ON "task_dependencies" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_deps_depends_on_id_idx" ON "task_dependencies" USING btree ("depends_on_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "task_docs_unique_idx" ON "task_documents" USING btree ("task_id","document_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_docs_task_id_idx" ON "task_documents" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_docs_document_id_idx" ON "task_documents" USING btree ("document_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "task_topics_unique_idx" ON "task_topics" USING btree ("task_id","topic_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_topics_task_id_idx" ON "task_topics" USING btree ("task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_topics_topic_id_idx" ON "task_topics" USING btree ("topic_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "task_topics_status_idx" ON "task_topics" USING btree ("task_id","status");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS "tasks_identifier_idx" ON "tasks" USING btree ("identifier","created_by_user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_created_by_user_id_idx" ON "tasks" USING btree ("created_by_user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_created_by_agent_id_idx" ON "tasks" USING btree ("created_by_agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_assignee_user_id_idx" ON "tasks" USING btree ("assignee_user_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_assignee_agent_id_idx" ON "tasks" USING btree ("assignee_agent_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_parent_task_id_idx" ON "tasks" USING btree ("parent_task_id");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_status_idx" ON "tasks" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_priority_idx" ON "tasks" USING btree ("priority");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "tasks_heartbeat_idx" ON "tasks" USING btree ("status","last_heartbeat_at");
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "task_topics" ADD COLUMN "user_id" text NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD COLUMN "handoff_title" text;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD COLUMN "handoff_summary" text;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD COLUMN "handoff_key_findings" jsonb;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD COLUMN "handoff_next_action" text;--> statement-breakpoint
|
||||
ALTER TABLE "task_topics" ADD CONSTRAINT "task_topics_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "task_topics_user_id_idx" ON "task_topics" USING btree ("user_id");
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -665,6 +665,20 @@
|
||||
"when": 1773764776073,
|
||||
"tag": "0094_agent_bot_providers_add_settings",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 95,
|
||||
"version": "7",
|
||||
"when": 1774180022586,
|
||||
"tag": "0095_add_agent_task_system",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 96,
|
||||
"version": "7",
|
||||
"when": 1774189914853,
|
||||
"tag": "0096_lush_praxagora",
|
||||
"breakpoints": true
|
||||
}
|
||||
],
|
||||
"version": "6"
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { BriefModel } from '../brief';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'brief-test-user-id';
|
||||
const userId2 = 'brief-test-user-id-2';
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('BriefModel', () => {
|
||||
describe('create', () => {
|
||||
it('should create a brief', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({
|
||||
summary: 'Outline is ready for review',
|
||||
title: 'Outline completed',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
expect(brief).toBeDefined();
|
||||
expect(brief.id).toBeDefined();
|
||||
expect(brief.userId).toBe(userId);
|
||||
expect(brief.type).toBe('decision');
|
||||
expect(brief.priority).toBe('info');
|
||||
expect(brief.readAt).toBeNull();
|
||||
expect(brief.resolvedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('should create a brief with all fields', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({
|
||||
actions: [{ label: 'Approve', type: 'approve' }],
|
||||
agentId: 'agent-1',
|
||||
artifacts: ['doc-1', 'doc-2'],
|
||||
commentType: 'suggestion',
|
||||
priority: 'urgent',
|
||||
summary: 'Chapter too long, suggest splitting',
|
||||
taskId: null,
|
||||
title: 'Chapter 4 needs split',
|
||||
topicId: 'topic-1',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
expect(brief.priority).toBe('urgent');
|
||||
expect(brief.agentId).toBe('agent-1');
|
||||
expect(brief.commentType).toBe('suggestion');
|
||||
expect(brief.actions).toEqual([{ label: 'Approve', type: 'approve' }]);
|
||||
expect(brief.artifacts).toEqual(['doc-1', 'doc-2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find brief by id', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const created = await model.create({
|
||||
summary: 'Test',
|
||||
title: 'Test brief',
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
const found = await model.findById(created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('should not find brief owned by another user', async () => {
|
||||
const model1 = new BriefModel(serverDB, userId);
|
||||
const model2 = new BriefModel(serverDB, userId2);
|
||||
|
||||
const brief = await model1.create({
|
||||
summary: 'Test',
|
||||
title: 'Test',
|
||||
type: 'result',
|
||||
});
|
||||
|
||||
const found = await model2.findById(brief.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list briefs for user', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
|
||||
|
||||
const { briefs, total } = await model.list();
|
||||
expect(total).toBe(2);
|
||||
expect(briefs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by type', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'decision' });
|
||||
|
||||
const { briefs } = await model.list({ type: 'decision' });
|
||||
expect(briefs).toHaveLength(1);
|
||||
expect(briefs[0].type).toBe('decision');
|
||||
});
|
||||
});
|
||||
|
||||
describe('listUnresolved', () => {
|
||||
it('should return unresolved briefs sorted by priority', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
await model.create({ priority: 'info', summary: 'Low', title: 'Info', type: 'result' });
|
||||
await model.create({
|
||||
priority: 'urgent',
|
||||
summary: 'High',
|
||||
title: 'Urgent',
|
||||
type: 'decision',
|
||||
});
|
||||
await model.create({
|
||||
priority: 'normal',
|
||||
summary: 'Mid',
|
||||
title: 'Normal',
|
||||
type: 'insight',
|
||||
});
|
||||
|
||||
const unresolved = await model.listUnresolved();
|
||||
expect(unresolved).toHaveLength(3);
|
||||
expect(unresolved[0].priority).toBe('urgent');
|
||||
expect(unresolved[1].priority).toBe('normal');
|
||||
expect(unresolved[2].priority).toBe('info');
|
||||
});
|
||||
|
||||
it('should exclude resolved briefs', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const b1 = await model.create({ summary: 'A', title: 'Brief 1', type: 'result' });
|
||||
await model.create({ summary: 'B', title: 'Brief 2', type: 'result' });
|
||||
|
||||
await model.resolve(b1.id);
|
||||
|
||||
const unresolved = await model.listUnresolved();
|
||||
expect(unresolved).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('markRead', () => {
|
||||
it('should mark brief as read', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
|
||||
|
||||
const updated = await model.markRead(brief.id);
|
||||
expect(updated!.readAt).toBeDefined();
|
||||
expect(updated!.resolvedAt).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolve', () => {
|
||||
it('should mark brief as resolved and read', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'decision' });
|
||||
|
||||
const updated = await model.resolve(brief.id);
|
||||
expect(updated!.readAt).toBeDefined();
|
||||
expect(updated!.resolvedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete brief', async () => {
|
||||
const model = new BriefModel(serverDB, userId);
|
||||
const brief = await model.create({ summary: 'A', title: 'Test', type: 'result' });
|
||||
|
||||
const deleted = await model.delete(brief.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await model.findById(brief.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,827 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { documents, users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { TaskModel } from '../task';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'task-test-user-id';
|
||||
const userId2 = 'task-test-user-id-2';
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('TaskModel', () => {
|
||||
describe('constructor', () => {
|
||||
it('should create model with db and userId', () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
expect(model).toBeInstanceOf(TaskModel);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a task with auto-generated identifier', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const result = await model.create({
|
||||
instruction: 'Write a book about AI agents',
|
||||
name: 'Write AI Book',
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result.identifier).toBe('TASK-1');
|
||||
expect(result.seq).toBe(1);
|
||||
expect(result.name).toBe('Write AI Book');
|
||||
expect(result.instruction).toBe('Write a book about AI agents');
|
||||
expect(result.status).toBe('backlog');
|
||||
expect(result.createdByUserId).toBe(userId);
|
||||
});
|
||||
|
||||
it('should auto-increment seq for same user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
|
||||
const task1 = await model.create({ instruction: 'Task 1' });
|
||||
const task2 = await model.create({ instruction: 'Task 2' });
|
||||
const task3 = await model.create({ instruction: 'Task 3' });
|
||||
|
||||
expect(task1.seq).toBe(1);
|
||||
expect(task2.seq).toBe(2);
|
||||
expect(task3.seq).toBe(3);
|
||||
expect(task1.identifier).toBe('TASK-1');
|
||||
expect(task2.identifier).toBe('TASK-2');
|
||||
expect(task3.identifier).toBe('TASK-3');
|
||||
});
|
||||
|
||||
it('should support custom identifier prefix', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const result = await model.create({
|
||||
identifierPrefix: 'PROJ',
|
||||
instruction: 'Build WAKE system',
|
||||
});
|
||||
|
||||
expect(result.identifier).toBe('PROJ-1');
|
||||
});
|
||||
|
||||
it('should create task with all optional fields', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const result = await model.create({
|
||||
assigneeAgentId: 'agent-1',
|
||||
assigneeUserId: userId,
|
||||
description: 'A detailed description',
|
||||
instruction: 'Do something',
|
||||
name: 'Full Task',
|
||||
priority: 2,
|
||||
});
|
||||
|
||||
expect(result.assigneeAgentId).toBe('agent-1');
|
||||
expect(result.assigneeUserId).toBe(userId);
|
||||
expect(result.priority).toBe(2);
|
||||
});
|
||||
|
||||
it('should create subtask with parentTaskId', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent task' });
|
||||
const child = await model.create({
|
||||
instruction: 'Child task',
|
||||
parentTaskId: parent.id,
|
||||
});
|
||||
|
||||
expect(child.parentTaskId).toBe(parent.id);
|
||||
});
|
||||
|
||||
it('should isolate seq between users', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task1 = await model1.create({ instruction: 'User 1 task' });
|
||||
const task2 = await model2.create({ instruction: 'User 2 task' });
|
||||
|
||||
expect(task1.seq).toBe(1);
|
||||
expect(task2.seq).toBe(1);
|
||||
});
|
||||
|
||||
it('should handle concurrent creates without seq collision', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
|
||||
// Create 5 tasks concurrently (simulates parallel tool calls)
|
||||
const results = await Promise.all([
|
||||
model.create({ instruction: 'Concurrent 1' }),
|
||||
model.create({ instruction: 'Concurrent 2' }),
|
||||
model.create({ instruction: 'Concurrent 3' }),
|
||||
model.create({ instruction: 'Concurrent 4' }),
|
||||
model.create({ instruction: 'Concurrent 5' }),
|
||||
]);
|
||||
|
||||
// All should succeed with unique seqs
|
||||
const seqs = results.map((r) => r.seq);
|
||||
const uniqueSeqs = new Set(seqs);
|
||||
expect(uniqueSeqs.size).toBe(5);
|
||||
|
||||
// All identifiers should be unique
|
||||
const identifiers = results.map((r) => r.identifier);
|
||||
const uniqueIdentifiers = new Set(identifiers);
|
||||
expect(uniqueIdentifiers.size).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findById', () => {
|
||||
it('should find task by id', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const created = await model.create({ instruction: 'Test task' });
|
||||
|
||||
const found = await model.findById(created.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.id).toBe(created.id);
|
||||
});
|
||||
|
||||
it('should not find task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const found = await model2.findById(task.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('findByIdentifier', () => {
|
||||
it('should find task by identifier', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Test task' });
|
||||
|
||||
const found = await model.findByIdentifier('TASK-1');
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.identifier).toBe('TASK-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update task fields', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Original' });
|
||||
|
||||
const updated = await model.update(task.id, {
|
||||
instruction: 'Updated instruction',
|
||||
name: 'Updated name',
|
||||
});
|
||||
|
||||
expect(updated!.instruction).toBe('Updated instruction');
|
||||
expect(updated!.name).toBe('Updated name');
|
||||
});
|
||||
|
||||
it('should not update task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const result = await model2.update(task.id, { name: 'Hacked' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete task', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'To be deleted' });
|
||||
|
||||
const deleted = await model.delete(task.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found).toBeNull();
|
||||
});
|
||||
|
||||
it('should not delete task owned by another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
const task = await model1.create({ instruction: 'User 1 task' });
|
||||
const deleted = await model2.delete(task.id);
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('should list tasks for user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Task 1' });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
|
||||
const { tasks, total } = await model.list();
|
||||
expect(total).toBe(2);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should filter by status', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Task 1' });
|
||||
await model.updateStatus(task.id, 'running', { startedAt: new Date() });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
|
||||
const { tasks } = await model.list({ status: 'running' });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].status).toBe('running');
|
||||
});
|
||||
|
||||
it('should filter root tasks only', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
await model.create({ instruction: 'Child', parentTaskId: parent.id });
|
||||
|
||||
const { tasks } = await model.list({ parentTaskId: null });
|
||||
expect(tasks).toHaveLength(1);
|
||||
expect(tasks[0].parentTaskId).toBeNull();
|
||||
});
|
||||
|
||||
it('should paginate results', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await model.create({ instruction: `Task ${i}` });
|
||||
}
|
||||
|
||||
const { tasks, total } = await model.list({ limit: 2, offset: 0 });
|
||||
expect(total).toBe(5);
|
||||
expect(tasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findSubtasks', () => {
|
||||
it('should find direct subtasks', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
|
||||
await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
|
||||
|
||||
const subtasks = await model.findSubtasks(parent.id);
|
||||
expect(subtasks).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTaskTree', () => {
|
||||
it('should return full task tree recursively', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const root = await model.create({ instruction: 'Root' });
|
||||
const child = await model.create({ instruction: 'Child', parentTaskId: root.id });
|
||||
await model.create({ instruction: 'Grandchild', parentTaskId: child.id });
|
||||
|
||||
const tree = await model.getTaskTree(root.id);
|
||||
expect(tree).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update status with timestamps', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const startedAt = new Date();
|
||||
const updated = await model.updateStatus(task.id, 'running', { startedAt });
|
||||
expect(updated!.status).toBe('running');
|
||||
expect(updated!.startedAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat', () => {
|
||||
it('should update heartbeat timestamp', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateHeartbeat(task.id);
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.lastHeartbeatAt).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dependencies', () => {
|
||||
it('should add and query dependencies', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
|
||||
const deps = await model.getDependencies(taskB.id);
|
||||
expect(deps).toHaveLength(1);
|
||||
expect(deps[0].dependsOnId).toBe(taskA.id);
|
||||
});
|
||||
|
||||
it('should check all dependencies completed', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
// Neither completed
|
||||
let allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(false);
|
||||
|
||||
// Complete A only
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(false);
|
||||
|
||||
// Complete B too
|
||||
await model.updateStatus(taskB.id, 'completed');
|
||||
allDone = await model.areAllDependenciesCompleted(taskC.id);
|
||||
expect(allDone).toBe(true);
|
||||
});
|
||||
|
||||
it('should remove dependency', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.removeDependency(taskB.id, taskA.id);
|
||||
|
||||
const deps = await model.getDependencies(taskB.id);
|
||||
expect(deps).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should get dependents (reverse lookup)', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
|
||||
const dependents = await model.getDependents(taskA.id);
|
||||
expect(dependents).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find unlocked tasks after dependency completes', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
const taskC = await model.create({ instruction: 'Task C' });
|
||||
|
||||
// C blocks on A and B
|
||||
await model.addDependency(taskC.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
// Complete A — C still blocked by B
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
let unlocked = await model.getUnlockedTasks(taskA.id);
|
||||
expect(unlocked).toHaveLength(0);
|
||||
|
||||
// Complete B — C now unlocked
|
||||
await model.updateStatus(taskB.id, 'completed');
|
||||
unlocked = await model.getUnlockedTasks(taskB.id);
|
||||
expect(unlocked).toHaveLength(1);
|
||||
expect(unlocked[0].id).toBe(taskC.id);
|
||||
});
|
||||
|
||||
it('should not unlock tasks that are not in backlog', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'Task A' });
|
||||
const taskB = await model.create({ instruction: 'Task B' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
// Move B to running manually (not backlog)
|
||||
await model.updateStatus(taskB.id, 'running', { startedAt: new Date() });
|
||||
|
||||
await model.updateStatus(taskA.id, 'completed');
|
||||
const unlocked = await model.getUnlockedTasks(taskA.id);
|
||||
expect(unlocked).toHaveLength(0); // B is already running, not unlocked
|
||||
});
|
||||
|
||||
it('should check all subtasks completed', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
const child1 = await model.create({ instruction: 'Child 1', parentTaskId: parent.id });
|
||||
const child2 = await model.create({ instruction: 'Child 2', parentTaskId: parent.id });
|
||||
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
|
||||
|
||||
await model.updateStatus(child1.id, 'completed');
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(false);
|
||||
|
||||
await model.updateStatus(child2.id, 'completed');
|
||||
expect(await model.areAllSubtasksCompleted(parent.id)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('documents', () => {
|
||||
it('should pin and get documents', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// Create a test document
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(1);
|
||||
expect(pinned[0].documentId).toBe(doc.id);
|
||||
});
|
||||
|
||||
it('should unpin document', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
await model.unpinDocument(task.id, doc.id);
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not duplicate pin', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: '',
|
||||
fileType: 'text/plain',
|
||||
source: 'test',
|
||||
sourceType: 'file',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 0,
|
||||
totalLineCount: 0,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
await model.pinDocument(task.id, doc.id);
|
||||
await model.pinDocument(task.id, doc.id); // duplicate
|
||||
|
||||
const pinned = await model.getPinnedDocuments(task.id);
|
||||
expect(pinned).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkpoint', () => {
|
||||
it('should get and update checkpoint config', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// Initially empty
|
||||
const empty = model.getCheckpointConfig(task);
|
||||
expect(empty).toEqual({});
|
||||
|
||||
// Set checkpoint
|
||||
const updated = await model.updateCheckpointConfig(task.id, {
|
||||
onAgentRequest: true,
|
||||
tasks: { afterIds: ['TASK-2'], beforeIds: ['TASK-3'] },
|
||||
topic: { after: true },
|
||||
});
|
||||
|
||||
const config = model.getCheckpointConfig(updated!);
|
||||
expect(config.onAgentRequest).toBe(true);
|
||||
expect(config.topic?.after).toBe(true);
|
||||
expect(config.tasks?.beforeIds).toEqual(['TASK-3']);
|
||||
expect(config.tasks?.afterIds).toEqual(['TASK-2']);
|
||||
});
|
||||
|
||||
it('should check shouldPauseBeforeStart', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
|
||||
await model.updateCheckpointConfig(parent.id, {
|
||||
tasks: { beforeIds: ['TASK-5'] },
|
||||
});
|
||||
|
||||
const parentUpdated = (await model.findById(parent.id))!;
|
||||
expect(model.shouldPauseBeforeStart(parentUpdated, 'TASK-5')).toBe(true);
|
||||
expect(model.shouldPauseBeforeStart(parentUpdated, 'TASK-6')).toBe(false);
|
||||
});
|
||||
|
||||
it('should pause on topic complete by default (no config)', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
// No checkpoint configured → should pause (default behavior)
|
||||
expect(model.shouldPauseOnTopicComplete(task)).toBe(true);
|
||||
});
|
||||
|
||||
it('should pause on topic complete when topic.after is true', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
topic: { after: true },
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(true);
|
||||
});
|
||||
|
||||
it('should not pause on topic complete when only onAgentRequest is set', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
onAgentRequest: true,
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
// Has explicit config but topic.after is not true → don't auto-pause
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
|
||||
});
|
||||
|
||||
it('should not pause on topic complete when topic.after is false', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCheckpointConfig(task.id, {
|
||||
topic: { after: false },
|
||||
});
|
||||
|
||||
const updated = (await model.findById(task.id))!;
|
||||
expect(model.shouldPauseOnTopicComplete(updated)).toBe(false);
|
||||
});
|
||||
|
||||
it('should check shouldPauseAfterComplete', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const parent = await model.create({ instruction: 'Parent' });
|
||||
|
||||
await model.updateCheckpointConfig(parent.id, {
|
||||
tasks: { afterIds: ['TASK-2', 'TASK-3'] },
|
||||
});
|
||||
|
||||
const parentUpdated = (await model.findById(parent.id))!;
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-2')).toBe(true);
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-3')).toBe(true);
|
||||
expect(model.shouldPauseAfterComplete(parentUpdated, 'TASK-4')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('topic management', () => {
|
||||
it('should increment topic count', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.incrementTopicCount(task.id);
|
||||
await model.incrementTopicCount(task.id);
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.totalTopics).toBe(2);
|
||||
});
|
||||
|
||||
it('should update current topic', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.updateCurrentTopic(task.id, 'topic-123');
|
||||
|
||||
const found = await model.findById(task.id);
|
||||
expect(found!.currentTopicId).toBe('topic-123');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteAll', () => {
|
||||
it('should delete all tasks for user', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
await model.create({ instruction: 'Task 1' });
|
||||
await model.create({ instruction: 'Task 2' });
|
||||
await model.create({ instruction: 'Task 3' });
|
||||
|
||||
const count = await model.deleteAll();
|
||||
expect(count).toBe(3);
|
||||
|
||||
const { total } = await model.list();
|
||||
expect(total).toBe(0);
|
||||
});
|
||||
|
||||
it('should not delete tasks of other users', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
|
||||
await model1.create({ instruction: 'User 1 task' });
|
||||
await model2.create({ instruction: 'User 2 task' });
|
||||
|
||||
await model1.deleteAll();
|
||||
|
||||
const { total: total1 } = await model1.list();
|
||||
const { total: total2 } = await model2.list();
|
||||
expect(total1).toBe(0);
|
||||
expect(total2).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getDependenciesByTaskIds', () => {
|
||||
it('should get dependencies for multiple tasks', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const taskA = await model.create({ instruction: 'A' });
|
||||
const taskB = await model.create({ instruction: 'B' });
|
||||
const taskC = await model.create({ instruction: 'C' });
|
||||
|
||||
await model.addDependency(taskB.id, taskA.id);
|
||||
await model.addDependency(taskC.id, taskB.id);
|
||||
|
||||
const deps = await model.getDependenciesByTaskIds([taskB.id, taskC.id]);
|
||||
expect(deps).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should return empty for empty input', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const deps = await model.getDependenciesByTaskIds([]);
|
||||
expect(deps).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should add and get comments', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.addComment({ content: 'First comment', taskId: task.id, userId });
|
||||
await model.addComment({ content: 'Second comment', taskId: task.id, userId });
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(2);
|
||||
expect(comments[0].content).toBe('First comment');
|
||||
expect(comments[1].content).toBe('Second comment');
|
||||
});
|
||||
|
||||
it('should add comment with briefId and topicId', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model.addComment({
|
||||
briefId: '00000000-0000-0000-0000-000000000001',
|
||||
content: 'Reply to brief',
|
||||
taskId: task.id,
|
||||
topicId: 'tpc_abc',
|
||||
userId,
|
||||
});
|
||||
|
||||
expect(comment.briefId).toBe('00000000-0000-0000-0000-000000000001');
|
||||
expect(comment.topicId).toBe('tpc_abc');
|
||||
});
|
||||
|
||||
it('should add comment from agent', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model.addComment({
|
||||
agentId: 'agt_xxx',
|
||||
content: 'Agent observation',
|
||||
taskId: task.id,
|
||||
});
|
||||
|
||||
expect(comment.agentId).toBe('agt_xxx');
|
||||
expect(comment.userId).toBeNull();
|
||||
});
|
||||
|
||||
it('should delete own comment', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model.addComment({
|
||||
content: 'To be deleted',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const deleted = await model.deleteComment(comment.id);
|
||||
expect(deleted).toBe(true);
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not delete comment from another user', async () => {
|
||||
const model1 = new TaskModel(serverDB, userId);
|
||||
const model2 = new TaskModel(serverDB, userId2);
|
||||
const task = await model1.create({ instruction: 'Test' });
|
||||
|
||||
const comment = await model1.addComment({
|
||||
content: 'User 1 comment',
|
||||
taskId: task.id,
|
||||
userId,
|
||||
});
|
||||
|
||||
const deleted = await model2.deleteComment(comment.id);
|
||||
expect(deleted).toBe(false);
|
||||
});
|
||||
|
||||
it('should return comments ordered by createdAt', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({ instruction: 'Test' });
|
||||
|
||||
await model.addComment({ content: 'First', taskId: task.id, userId });
|
||||
await model.addComment({ content: 'Second', taskId: task.id, userId });
|
||||
await model.addComment({ content: 'Third', taskId: task.id, userId });
|
||||
|
||||
const comments = await model.getComments(task.id);
|
||||
expect(comments).toHaveLength(3);
|
||||
expect(comments[0].content).toBe('First');
|
||||
expect(comments[2].content).toBe('Third');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review rubrics', () => {
|
||||
it('should store EvalBenchmarkRubric format in config', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const task = await model.create({
|
||||
config: {
|
||||
review: {
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
rubrics: [
|
||||
{
|
||||
config: { criteria: '技术概念是否准确' },
|
||||
id: 'r1',
|
||||
name: '内容准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
config: { value: '```' },
|
||||
id: 'r2',
|
||||
name: '包含代码示例',
|
||||
type: 'contains',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
instruction: 'Test with rubrics',
|
||||
});
|
||||
|
||||
const review = model.getReviewConfig(task);
|
||||
expect(review).toBeDefined();
|
||||
expect(review!.enabled).toBe(true);
|
||||
expect(review!.rubrics).toHaveLength(2);
|
||||
expect(review!.rubrics[0].type).toBe('llm-rubric');
|
||||
expect(review!.rubrics[0].threshold).toBe(0.8);
|
||||
expect(review!.rubrics[1].type).toBe('contains');
|
||||
expect(review!.rubrics[1].config.value).toBe('```');
|
||||
});
|
||||
|
||||
it('should inherit rubrics from parent when creating subtask', async () => {
|
||||
const model = new TaskModel(serverDB, userId);
|
||||
const rubrics = [
|
||||
{
|
||||
config: { criteria: '准确性检查' },
|
||||
id: 'r1',
|
||||
name: '准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
];
|
||||
|
||||
const parent = await model.create({
|
||||
config: { review: { enabled: true, rubrics } },
|
||||
instruction: 'Parent with rubrics',
|
||||
});
|
||||
|
||||
const parentConfig = parent.config as Record<string, any>;
|
||||
const child = await model.create({
|
||||
config: parentConfig?.review ? { review: parentConfig.review } : undefined,
|
||||
instruction: 'Child task',
|
||||
parentTaskId: parent.id,
|
||||
});
|
||||
|
||||
const childReview = model.getReviewConfig(child);
|
||||
expect(childReview).toBeDefined();
|
||||
expect(childReview!.rubrics).toHaveLength(1);
|
||||
expect(childReview!.rubrics[0].type).toBe('llm-rubric');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,167 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { getTestDB } from '../../core/getTestDB';
|
||||
import { users } from '../../schemas';
|
||||
import type { LobeChatDatabase } from '../../type';
|
||||
import { TaskModel } from '../task';
|
||||
import { TaskTopicModel } from '../taskTopic';
|
||||
|
||||
const serverDB: LobeChatDatabase = await getTestDB();
|
||||
|
||||
const userId = 'task-topic-test-user-id';
|
||||
const userId2 = 'task-topic-test-user-id-2';
|
||||
|
||||
beforeEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
await serverDB.insert(users).values([{ id: userId }, { id: userId2 }]);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await serverDB.delete(users);
|
||||
});
|
||||
|
||||
describe('TaskTopicModel', () => {
|
||||
describe('add and findByTaskId', () => {
|
||||
it('should add topic and get topics', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { operationId: 'op_1', seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_bbb', { operationId: 'op_2', seq: 2 });
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(2);
|
||||
expect(topics[0].seq).toBe(2); // ordered by seq desc
|
||||
expect(topics[1].seq).toBe(1);
|
||||
expect(topics[0].operationId).toBe('op_2');
|
||||
expect(topics[0].userId).toBe(userId);
|
||||
});
|
||||
|
||||
it('should not duplicate topic (onConflictDoNothing)', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 }); // duplicate
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateStatus', () => {
|
||||
it('should update topic status', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('timeoutRunning', () => {
|
||||
it('should timeout running topics only', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.add(task.id, 'tpc_bbb', { seq: 2 });
|
||||
await topicModel.updateStatus(task.id, 'tpc_aaa', 'completed');
|
||||
|
||||
const count = await topicModel.timeoutRunning(task.id);
|
||||
expect(count).toBe(1);
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
const tpcA = topics.find((t) => t.topicId === 'tpc_aaa');
|
||||
const tpcB = topics.find((t) => t.topicId === 'tpc_bbb');
|
||||
expect(tpcA!.status).toBe('completed');
|
||||
expect(tpcB!.status).toBe('timeout');
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateHandoff', () => {
|
||||
it('should store handoff data', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
await topicModel.updateHandoff(task.id, 'tpc_aaa', {
|
||||
keyFindings: ['Finding 1', 'Finding 2'],
|
||||
nextAction: 'Continue writing',
|
||||
summary: 'Completed chapter 1',
|
||||
title: '第1章完成',
|
||||
});
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].handoffTitle).toBe('第1章完成');
|
||||
expect(topics[0].handoffSummary).toBe('Completed chapter 1');
|
||||
expect(topics[0].handoffNextAction).toBe('Continue writing');
|
||||
expect(topics[0].handoffKeyFindings).toEqual(['Finding 1', 'Finding 2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateReview', () => {
|
||||
it('should store review results', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_review', { seq: 1 });
|
||||
await topicModel.updateReview(task.id, 'tpc_review', {
|
||||
iteration: 1,
|
||||
passed: true,
|
||||
score: 85,
|
||||
scores: [
|
||||
{ passed: true, reason: 'Good accuracy', rubricId: 'r1', score: 0.88 },
|
||||
{ passed: true, reason: 'Code found', rubricId: 'r2', score: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics[0].reviewPassed).toBe(1);
|
||||
expect(topics[0].reviewScore).toBe(85);
|
||||
expect(topics[0].reviewIteration).toBe(1);
|
||||
expect(topics[0].reviewedAt).toBeDefined();
|
||||
|
||||
const scores = topics[0].reviewScores as any[];
|
||||
expect(scores).toHaveLength(2);
|
||||
expect(scores[0].rubricId).toBe('r1');
|
||||
expect(scores[1].score).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('should remove topic association', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel = new TaskTopicModel(serverDB, userId);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
const removed = await topicModel.remove(task.id, 'tpc_aaa');
|
||||
expect(removed).toBe(true);
|
||||
|
||||
const topics = await topicModel.findByTaskId(task.id);
|
||||
expect(topics).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should not remove topics of other users', async () => {
|
||||
const taskModel = new TaskModel(serverDB, userId);
|
||||
const topicModel1 = new TaskTopicModel(serverDB, userId);
|
||||
const topicModel2 = new TaskTopicModel(serverDB, userId2);
|
||||
const task = await taskModel.create({ instruction: 'Test' });
|
||||
|
||||
await topicModel1.add(task.id, 'tpc_aaa', { seq: 1 });
|
||||
const removed = await topicModel2.remove(task.id, 'tpc_aaa');
|
||||
expect(removed).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import { and, desc, eq, isNull, sql } from 'drizzle-orm';
|
||||
|
||||
import type { BriefItem, NewBrief } from '../schemas/brief';
|
||||
import { briefs } from '../schemas/brief';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class BriefModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async create(data: Omit<NewBrief, 'id' | 'userId'>): Promise<BriefItem> {
|
||||
const result = await this.db
|
||||
.insert(briefs)
|
||||
.values({ ...data, userId: this.userId })
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async list(options?: {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
type?: string;
|
||||
}): Promise<{ briefs: BriefItem[]; total: number }> {
|
||||
const { type, limit = 50, offset = 0 } = options || {};
|
||||
|
||||
const conditions = [eq(briefs.userId, this.userId)];
|
||||
if (type) conditions.push(eq(briefs.type, type));
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(briefs)
|
||||
.where(where);
|
||||
|
||||
const items = await this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(where)
|
||||
.orderBy(desc(briefs.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { briefs: items, total: Number(countResult[0].count) };
|
||||
}
|
||||
|
||||
// For Daily Brief homepage — unresolved briefs sorted by priority
|
||||
async listUnresolved(): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.userId, this.userId), isNull(briefs.resolvedAt)))
|
||||
.orderBy(
|
||||
sql`CASE
|
||||
WHEN ${briefs.priority} = 'urgent' THEN 0
|
||||
WHEN ${briefs.priority} = 'normal' THEN 1
|
||||
ELSE 2
|
||||
END`,
|
||||
desc(briefs.createdAt),
|
||||
);
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.taskId, taskId), eq(briefs.userId, this.userId)))
|
||||
.orderBy(desc(briefs.createdAt));
|
||||
}
|
||||
|
||||
async findByCronJobId(cronJobId: string): Promise<BriefItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(briefs)
|
||||
.where(and(eq(briefs.cronJobId, cronJobId), eq(briefs.userId, this.userId)))
|
||||
.orderBy(desc(briefs.createdAt));
|
||||
}
|
||||
|
||||
async markRead(id: string): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.update(briefs)
|
||||
.set({ readAt: new Date() })
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async resolve(
|
||||
id: string,
|
||||
options?: { action?: string; comment?: string },
|
||||
): Promise<BriefItem | null> {
|
||||
const result = await this.db
|
||||
.update(briefs)
|
||||
.set({
|
||||
readAt: new Date(),
|
||||
resolvedAction: options?.action,
|
||||
resolvedAt: new Date(),
|
||||
resolvedComment: options?.comment,
|
||||
})
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(briefs)
|
||||
.where(and(eq(briefs.id, id), eq(briefs.userId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,522 @@
|
||||
import type {
|
||||
CheckpointConfig,
|
||||
WorkspaceData,
|
||||
WorkspaceDocNode,
|
||||
WorkspaceTreeNode,
|
||||
} from '@lobechat/types';
|
||||
import { and, desc, eq, inArray, isNotNull, isNull, ne, sql } from 'drizzle-orm';
|
||||
|
||||
import type { NewTask, NewTaskComment, TaskCommentItem, TaskItem } from '../schemas/task';
|
||||
import { taskComments, taskDependencies, taskDocuments, tasks } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class TaskModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
// ========== CRUD ==========
|
||||
|
||||
async create(
|
||||
data: Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'> & {
|
||||
identifierPrefix?: string;
|
||||
},
|
||||
): Promise<TaskItem> {
|
||||
const { identifierPrefix = 'TASK', ...rest } = data;
|
||||
|
||||
// Retry loop to handle concurrent creates (parallel tool calls)
|
||||
const maxRetries = 5;
|
||||
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
||||
try {
|
||||
// Get next seq for this user
|
||||
const seqResult = await this.db
|
||||
.select({ maxSeq: sql<number>`COALESCE(MAX(${tasks.seq}), 0)` })
|
||||
.from(tasks)
|
||||
.where(eq(tasks.createdByUserId, this.userId));
|
||||
|
||||
const nextSeq = Number(seqResult[0].maxSeq) + 1;
|
||||
const identifier = `${identifierPrefix}-${nextSeq}`;
|
||||
|
||||
const result = await this.db
|
||||
.insert(tasks)
|
||||
.values({
|
||||
...rest,
|
||||
createdByUserId: this.userId,
|
||||
identifier,
|
||||
seq: nextSeq,
|
||||
} as NewTask)
|
||||
.returning();
|
||||
|
||||
return result[0];
|
||||
} catch (error: any) {
|
||||
// Retry on unique constraint violation (concurrent seq conflict)
|
||||
// Check error itself, cause, and stringified message for PG error code 23505
|
||||
const errStr =
|
||||
String(error?.message || '') +
|
||||
String(error?.cause?.code || '') +
|
||||
String(error?.code || '');
|
||||
const isUniqueViolation =
|
||||
errStr.includes('23505') || errStr.includes('unique') || errStr.includes('duplicate');
|
||||
if (isUniqueViolation && attempt < maxRetries - 1) {
|
||||
continue;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Failed to create task after max retries');
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
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);
|
||||
return this.findByIdentifier(idOrIdentifier.toUpperCase());
|
||||
}
|
||||
|
||||
async findByIdentifier(identifier: string): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.identifier, identifier), eq(tasks.createdByUserId, this.userId)))
|
||||
.limit(1);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<NewTask, 'id' | 'identifier' | 'seq' | 'createdByUserId'>>,
|
||||
): Promise<TaskItem | null> {
|
||||
const result = await this.db
|
||||
.update(tasks)
|
||||
.set({ ...data, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(tasks)
|
||||
.where(and(eq(tasks.id, id), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
|
||||
async deleteAll(): Promise<number> {
|
||||
const result = await this.db
|
||||
.delete(tasks)
|
||||
.where(eq(tasks.createdByUserId, this.userId))
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
// ========== Query ==========
|
||||
|
||||
async list(options?: {
|
||||
assigneeAgentId?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
parentTaskId?: string | null;
|
||||
status?: string;
|
||||
}): Promise<{ tasks: TaskItem[]; total: number }> {
|
||||
const { status, parentTaskId, assigneeAgentId, limit = 50, offset = 0 } = options || {};
|
||||
|
||||
const conditions = [eq(tasks.createdByUserId, this.userId)];
|
||||
|
||||
if (status) conditions.push(eq(tasks.status, status));
|
||||
if (assigneeAgentId) conditions.push(eq(tasks.assigneeAgentId, assigneeAgentId));
|
||||
|
||||
if (parentTaskId === null) {
|
||||
conditions.push(isNull(tasks.parentTaskId));
|
||||
} else if (parentTaskId) {
|
||||
conditions.push(eq(tasks.parentTaskId, parentTaskId));
|
||||
}
|
||||
|
||||
const where = and(...conditions);
|
||||
|
||||
const countResult = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(where);
|
||||
|
||||
const taskList = await this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(where)
|
||||
.orderBy(desc(tasks.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset);
|
||||
|
||||
return { tasks: taskList, total: Number(countResult[0].count) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch update sortOrder for multiple tasks.
|
||||
* @param order Array of { id, sortOrder } pairs
|
||||
*/
|
||||
async reorder(order: Array<{ id: string; sortOrder: number }>): Promise<void> {
|
||||
for (const item of order) {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ sortOrder: item.sortOrder, updatedAt: new Date() })
|
||||
.where(and(eq(tasks.id, item.id), eq(tasks.createdByUserId, this.userId)));
|
||||
}
|
||||
}
|
||||
|
||||
async findSubtasks(parentTaskId: string): Promise<TaskItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(and(eq(tasks.parentTaskId, parentTaskId), eq(tasks.createdByUserId, this.userId)))
|
||||
.orderBy(tasks.sortOrder, tasks.seq);
|
||||
}
|
||||
|
||||
// Recursive query to get full task tree
|
||||
async getTaskTree(rootTaskId: string): Promise<TaskItem[]> {
|
||||
const result = await this.db.execute(sql`
|
||||
WITH RECURSIVE task_tree AS (
|
||||
SELECT * FROM tasks WHERE id = ${rootTaskId} AND created_by_user_id = ${this.userId}
|
||||
UNION ALL
|
||||
SELECT t.* FROM tasks t
|
||||
JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
)
|
||||
SELECT * FROM task_tree
|
||||
`);
|
||||
|
||||
return result.rows as TaskItem[];
|
||||
}
|
||||
|
||||
// ========== Status ==========
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
status: string,
|
||||
extra?: { completedAt?: Date; error?: string | null; startedAt?: Date },
|
||||
): Promise<TaskItem | null> {
|
||||
return this.update(id, { status, ...extra });
|
||||
}
|
||||
|
||||
async batchUpdateStatus(ids: string[], status: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(tasks)
|
||||
.set({ status, updatedAt: new Date() })
|
||||
.where(and(inArray(tasks.id, ids), eq(tasks.createdByUserId, this.userId)))
|
||||
.returning();
|
||||
|
||||
return result.length;
|
||||
}
|
||||
|
||||
// ========== Checkpoint ==========
|
||||
|
||||
getCheckpointConfig(task: TaskItem): CheckpointConfig {
|
||||
return (task.config as Record<string, any>)?.checkpoint || {};
|
||||
}
|
||||
|
||||
async updateCheckpointConfig(id: string, checkpoint: CheckpointConfig): Promise<TaskItem | null> {
|
||||
const task = await this.findById(id);
|
||||
if (!task) return null;
|
||||
|
||||
const config = { ...(task.config as Record<string, any>), checkpoint };
|
||||
return this.update(id, { config });
|
||||
}
|
||||
|
||||
// ========== Review Config ==========
|
||||
|
||||
getReviewConfig(task: TaskItem): Record<string, any> | undefined {
|
||||
return (task.config as Record<string, any>)?.review;
|
||||
}
|
||||
|
||||
async updateReviewConfig(id: string, review: Record<string, any>): Promise<TaskItem | null> {
|
||||
const task = await this.findById(id);
|
||||
if (!task) return null;
|
||||
|
||||
const config = { ...(task.config as Record<string, any>), review };
|
||||
return this.update(id, { config });
|
||||
}
|
||||
|
||||
// Check if a task should pause after a topic completes
|
||||
// Default: pause (when no checkpoint config is set)
|
||||
// Explicit: pause only if topic.after is true
|
||||
shouldPauseOnTopicComplete(task: TaskItem): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(task);
|
||||
const hasAnyConfig = Object.keys(checkpoint).length > 0;
|
||||
return hasAnyConfig ? !!checkpoint.topic?.after : true;
|
||||
}
|
||||
|
||||
// Check if a task should be paused before starting (parent's tasks.beforeIds)
|
||||
shouldPauseBeforeStart(parentTask: TaskItem, childIdentifier: string): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(parentTask);
|
||||
return checkpoint.tasks?.beforeIds?.includes(childIdentifier) ?? false;
|
||||
}
|
||||
|
||||
// Check if a task should be paused after completing (parent's tasks.afterIds)
|
||||
shouldPauseAfterComplete(parentTask: TaskItem, childIdentifier: string): boolean {
|
||||
const checkpoint = this.getCheckpointConfig(parentTask);
|
||||
return checkpoint.tasks?.afterIds?.includes(childIdentifier) ?? false;
|
||||
}
|
||||
|
||||
// ========== Heartbeat ==========
|
||||
|
||||
async updateHeartbeat(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ lastHeartbeatAt: new Date(), updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// Find stuck tasks (running but heartbeat timed out)
|
||||
// Only checks tasks that have both lastHeartbeatAt and heartbeatTimeout set
|
||||
static async findStuckTasks(db: LobeChatDatabase): Promise<TaskItem[]> {
|
||||
return db
|
||||
.select()
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.status, 'running'),
|
||||
isNotNull(tasks.lastHeartbeatAt),
|
||||
isNotNull(tasks.heartbeatTimeout),
|
||||
sql`${tasks.lastHeartbeatAt} < now() - make_interval(secs => ${tasks.heartbeatTimeout})`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ========== Dependencies ==========
|
||||
|
||||
async addDependency(taskId: string, dependsOnId: string, type: string = 'blocks'): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskDependencies)
|
||||
.values({ dependsOnId, taskId, type })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async removeDependency(taskId: string, dependsOnId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(taskDependencies)
|
||||
.where(
|
||||
and(eq(taskDependencies.taskId, taskId), eq(taskDependencies.dependsOnId, dependsOnId)),
|
||||
);
|
||||
}
|
||||
|
||||
async getDependencies(taskId: string) {
|
||||
return this.db.select().from(taskDependencies).where(eq(taskDependencies.taskId, taskId));
|
||||
}
|
||||
|
||||
async getDependenciesByTaskIds(taskIds: string[]) {
|
||||
if (taskIds.length === 0) return [];
|
||||
return this.db.select().from(taskDependencies).where(inArray(taskDependencies.taskId, taskIds));
|
||||
}
|
||||
|
||||
async getDependents(taskId: string) {
|
||||
return this.db.select().from(taskDependencies).where(eq(taskDependencies.dependsOnId, taskId));
|
||||
}
|
||||
|
||||
// Check if all dependencies of a task are completed
|
||||
async areAllDependenciesCompleted(taskId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(taskDependencies)
|
||||
.innerJoin(tasks, eq(taskDependencies.dependsOnId, tasks.id))
|
||||
.where(
|
||||
and(
|
||||
eq(taskDependencies.taskId, taskId),
|
||||
eq(taskDependencies.type, 'blocks'),
|
||||
ne(tasks.status, 'completed'),
|
||||
),
|
||||
);
|
||||
|
||||
return Number(result[0].count) === 0;
|
||||
}
|
||||
|
||||
// Find tasks that are now unblocked after a dependency completes
|
||||
async getUnlockedTasks(completedTaskId: string): Promise<TaskItem[]> {
|
||||
// Find all tasks that depend on the completed task
|
||||
const dependents = await this.getDependents(completedTaskId);
|
||||
const unlocked: TaskItem[] = [];
|
||||
|
||||
for (const dep of dependents) {
|
||||
if (dep.type !== 'blocks') continue;
|
||||
|
||||
// Check if ALL dependencies of this task are now completed
|
||||
const allDone = await this.areAllDependenciesCompleted(dep.taskId);
|
||||
if (!allDone) continue;
|
||||
|
||||
// Get the task itself — only unlock if it's in backlog
|
||||
const task = await this.findById(dep.taskId);
|
||||
if (task && task.status === 'backlog') {
|
||||
unlocked.push(task);
|
||||
}
|
||||
}
|
||||
|
||||
return unlocked;
|
||||
}
|
||||
|
||||
// Check if all subtasks of a parent task are completed
|
||||
async areAllSubtasksCompleted(parentTaskId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.select({ count: sql<number>`count(*)` })
|
||||
.from(tasks)
|
||||
.where(
|
||||
and(
|
||||
eq(tasks.parentTaskId, parentTaskId),
|
||||
ne(tasks.status, 'completed'),
|
||||
eq(tasks.createdByUserId, this.userId),
|
||||
),
|
||||
);
|
||||
|
||||
return Number(result[0].count) === 0;
|
||||
}
|
||||
|
||||
// ========== Documents (MVP Workspace) ==========
|
||||
|
||||
async pinDocument(taskId: string, documentId: string, pinnedBy: string = 'agent'): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskDocuments)
|
||||
.values({ documentId, pinnedBy, taskId })
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async unpinDocument(taskId: string, documentId: string): Promise<void> {
|
||||
await this.db
|
||||
.delete(taskDocuments)
|
||||
.where(and(eq(taskDocuments.taskId, taskId), eq(taskDocuments.documentId, documentId)));
|
||||
}
|
||||
|
||||
async getPinnedDocuments(taskId: string) {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskDocuments)
|
||||
.where(eq(taskDocuments.taskId, taskId))
|
||||
.orderBy(taskDocuments.createdAt);
|
||||
}
|
||||
|
||||
// Get all pinned docs from a task tree (recursive), returns nodeMap + tree structure
|
||||
async getTreePinnedDocuments(rootTaskId: string): Promise<WorkspaceData> {
|
||||
const result = await this.db.execute(sql`
|
||||
WITH RECURSIVE task_tree AS (
|
||||
SELECT id, identifier FROM tasks WHERE id = ${rootTaskId}
|
||||
UNION ALL
|
||||
SELECT t.id, t.identifier FROM tasks t
|
||||
JOIN task_tree tt ON t.parent_task_id = tt.id
|
||||
)
|
||||
SELECT td.*, tt.id as source_task_id, tt.identifier as source_task_identifier,
|
||||
d.title as document_title, d.file_type as document_file_type, d.parent_id as document_parent_id,
|
||||
d.total_char_count as document_char_count, d.updated_at as document_updated_at
|
||||
FROM task_documents td
|
||||
JOIN task_tree tt ON td.task_id = tt.id
|
||||
LEFT JOIN documents d ON td.document_id = d.id
|
||||
ORDER BY td.created_at
|
||||
`);
|
||||
|
||||
// Build nodeMap
|
||||
const nodeMap: Record<string, WorkspaceDocNode> = {};
|
||||
|
||||
const docIds = new Set<string>();
|
||||
|
||||
for (const row of result.rows as any[]) {
|
||||
const docId = row.document_id;
|
||||
docIds.add(docId);
|
||||
nodeMap[docId] = {
|
||||
charCount: row.document_char_count,
|
||||
createdAt: row.created_at,
|
||||
fileType: row.document_file_type,
|
||||
parentId: row.document_parent_id,
|
||||
pinnedBy: row.pinned_by,
|
||||
sourceTaskIdentifier: row.source_task_id !== rootTaskId ? row.source_task_identifier : null,
|
||||
title: row.document_title || 'Untitled',
|
||||
updatedAt: row.document_updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
// Build tree (children as id references)
|
||||
type TreeNode = WorkspaceTreeNode;
|
||||
|
||||
const childrenMap = new Map<string | null, TreeNode[]>();
|
||||
for (const docId of docIds) {
|
||||
const node = nodeMap[docId];
|
||||
const parentId = node.parentId && docIds.has(node.parentId) ? node.parentId : null;
|
||||
const list = childrenMap.get(parentId) || [];
|
||||
list.push({ children: [], id: docId });
|
||||
childrenMap.set(parentId, list);
|
||||
}
|
||||
|
||||
const buildTree = (parentId: string | null): TreeNode[] => {
|
||||
const nodes = childrenMap.get(parentId) || [];
|
||||
for (const node of nodes) {
|
||||
node.children = buildTree(node.id);
|
||||
}
|
||||
return nodes;
|
||||
};
|
||||
|
||||
return { nodeMap, tree: buildTree(null) };
|
||||
}
|
||||
|
||||
// ========== Topic Management ==========
|
||||
|
||||
async incrementTopicCount(id: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
totalTopics: sql`${tasks.totalTopics} + 1`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
async updateCurrentTopic(id: string, topicId: string): Promise<void> {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({ currentTopicId: topicId, updatedAt: new Date() })
|
||||
.where(eq(tasks.id, id));
|
||||
}
|
||||
|
||||
// ========== Comments ==========
|
||||
|
||||
async addComment(data: Omit<NewTaskComment, 'id'>): Promise<TaskCommentItem> {
|
||||
const [comment] = await this.db.insert(taskComments).values(data).returning();
|
||||
return comment;
|
||||
}
|
||||
|
||||
// ========== Comments ==========
|
||||
|
||||
async getComments(taskId: string): Promise<TaskCommentItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskComments)
|
||||
.where(eq(taskComments.taskId, taskId))
|
||||
.orderBy(taskComments.createdAt);
|
||||
}
|
||||
|
||||
async deleteComment(id: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(taskComments)
|
||||
.where(and(eq(taskComments.id, id), eq(taskComments.userId, this.userId)))
|
||||
.returning();
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { and, desc, eq, sql } from 'drizzle-orm';
|
||||
|
||||
import type { TaskTopicItem } from '../schemas/task';
|
||||
import { tasks, taskTopics } from '../schemas/task';
|
||||
import type { LobeChatDatabase } from '../type';
|
||||
|
||||
export class TaskTopicModel {
|
||||
private readonly userId: string;
|
||||
private readonly db: LobeChatDatabase;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async add(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
params: { operationId?: string; seq: number },
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.insert(taskTopics)
|
||||
.values({
|
||||
operationId: params.operationId,
|
||||
seq: params.seq,
|
||||
taskId,
|
||||
topicId,
|
||||
userId: this.userId,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
async updateStatus(taskId: string, topicId: string, status: string): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({ status })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateHandoff(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
handoff: {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({
|
||||
handoffKeyFindings: handoff.keyFindings,
|
||||
handoffNextAction: handoff.nextAction,
|
||||
handoffSummary: handoff.summary,
|
||||
handoffTitle: handoff.title,
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async updateReview(
|
||||
taskId: string,
|
||||
topicId: string,
|
||||
review: {
|
||||
iteration: number;
|
||||
passed: boolean;
|
||||
score: number;
|
||||
scores: any[];
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.update(taskTopics)
|
||||
.set({
|
||||
reviewIteration: review.iteration,
|
||||
reviewPassed: review.passed ? 1 : 0,
|
||||
reviewScore: review.score,
|
||||
reviewScores: review.scores,
|
||||
reviewedAt: new Date(),
|
||||
})
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async timeoutRunning(taskId: string): Promise<number> {
|
||||
const result = await this.db
|
||||
.update(taskTopics)
|
||||
.set({ status: 'timeout' })
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.status, 'running'),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
return result.length;
|
||||
}
|
||||
|
||||
async findByTopicId(topicId: string): Promise<TaskTopicItem | null> {
|
||||
const result = await this.db
|
||||
.select()
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.topicId, topicId), eq(taskTopics.userId, this.userId)))
|
||||
.limit(1);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
async findByTaskId(taskId: string): Promise<TaskTopicItem[]> {
|
||||
return this.db
|
||||
.select()
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq));
|
||||
}
|
||||
|
||||
async findWithDetails(taskId: string) {
|
||||
const { topics } = await import('../schemas/topic');
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: topics.createdAt,
|
||||
handoffKeyFindings: taskTopics.handoffKeyFindings,
|
||||
handoffNextAction: taskTopics.handoffNextAction,
|
||||
handoffSummary: taskTopics.handoffSummary,
|
||||
handoffTitle: taskTopics.handoffTitle,
|
||||
id: topics.id,
|
||||
metadata: topics.metadata,
|
||||
operationId: taskTopics.operationId,
|
||||
reviewIteration: taskTopics.reviewIteration,
|
||||
reviewPassed: taskTopics.reviewPassed,
|
||||
reviewScore: taskTopics.reviewScore,
|
||||
reviewScores: taskTopics.reviewScores,
|
||||
reviewedAt: taskTopics.reviewedAt,
|
||||
seq: taskTopics.seq,
|
||||
status: taskTopics.status,
|
||||
title: topics.title,
|
||||
updatedAt: topics.updatedAt,
|
||||
})
|
||||
.from(taskTopics)
|
||||
.innerJoin(topics, eq(taskTopics.topicId, topics.id))
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq));
|
||||
}
|
||||
|
||||
async findWithHandoff(taskId: string, limit = 4) {
|
||||
return this.db
|
||||
.select({
|
||||
createdAt: taskTopics.createdAt,
|
||||
handoffKeyFindings: taskTopics.handoffKeyFindings,
|
||||
handoffNextAction: taskTopics.handoffNextAction,
|
||||
handoffSummary: taskTopics.handoffSummary,
|
||||
handoffTitle: taskTopics.handoffTitle,
|
||||
seq: taskTopics.seq,
|
||||
status: taskTopics.status,
|
||||
topicId: taskTopics.topicId,
|
||||
})
|
||||
.from(taskTopics)
|
||||
.where(and(eq(taskTopics.taskId, taskId), eq(taskTopics.userId, this.userId)))
|
||||
.orderBy(desc(taskTopics.seq))
|
||||
.limit(limit);
|
||||
}
|
||||
|
||||
async remove(taskId: string, topicId: string): Promise<boolean> {
|
||||
const result = await this.db
|
||||
.delete(taskTopics)
|
||||
.where(
|
||||
and(
|
||||
eq(taskTopics.taskId, taskId),
|
||||
eq(taskTopics.topicId, topicId),
|
||||
eq(taskTopics.userId, this.userId),
|
||||
),
|
||||
)
|
||||
.returning();
|
||||
|
||||
if (result.length > 0) {
|
||||
await this.db
|
||||
.update(tasks)
|
||||
.set({
|
||||
totalTopics: sql`GREATEST(${tasks.totalTopics} - 1, 0)`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(tasks.id, taskId));
|
||||
}
|
||||
|
||||
return result.length > 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { index, jsonb, pgTable, text, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { createdAt, timestamptz } from './_helpers';
|
||||
import { agentCronJobs } from './agentCronJob';
|
||||
import { tasks } from './task';
|
||||
import { users } from './user';
|
||||
|
||||
export const briefs = pgTable(
|
||||
'briefs',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey().notNull(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Source (polymorphic, fill as needed)
|
||||
taskId: text('task_id').references(() => tasks.id, { onDelete: 'cascade' }),
|
||||
cronJobId: text('cron_job_id').references(() => agentCronJobs.id, { onDelete: 'cascade' }),
|
||||
topicId: text('topic_id'),
|
||||
agentId: text('agent_id'),
|
||||
|
||||
// Content
|
||||
type: text('type').notNull(), // 'decision' | 'result' | 'insight' | 'error'
|
||||
priority: text('priority').default('info'), // 'urgent' | 'normal' | 'info'
|
||||
title: text('title').notNull(),
|
||||
summary: text('summary').notNull(),
|
||||
artifacts: jsonb('artifacts'), // document ids
|
||||
actions: jsonb('actions'), // BriefAction[]
|
||||
commentType: text('comment_type'), // 'summary' | 'suggestion' | 'motion'
|
||||
|
||||
// Resolution
|
||||
resolvedAction: text('resolved_action'), // action key chosen by user (e.g. 'approve', 'feedback')
|
||||
resolvedComment: text('resolved_comment'), // user feedback text (for 'comment' type actions)
|
||||
readAt: timestamptz('read_at'),
|
||||
resolvedAt: timestamptz('resolved_at'),
|
||||
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
index('briefs_user_id_idx').on(t.userId),
|
||||
index('briefs_task_id_idx').on(t.taskId),
|
||||
index('briefs_cron_job_id_idx').on(t.cronJobId),
|
||||
index('briefs_agent_id_idx').on(t.agentId),
|
||||
index('briefs_type_idx').on(t.type),
|
||||
index('briefs_priority_idx').on(t.priority),
|
||||
index('briefs_unresolved_idx').on(t.userId, t.resolvedAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewBrief = typeof briefs.$inferInsert;
|
||||
export type BriefItem = typeof briefs.$inferSelect;
|
||||
@@ -8,6 +8,7 @@ export * from './aiInfra';
|
||||
export * from './apiKey';
|
||||
export * from './asyncTask';
|
||||
export * from './betterAuth';
|
||||
export * from './brief';
|
||||
export * from './chatGroup';
|
||||
export * from './file';
|
||||
export * from './generation';
|
||||
@@ -19,6 +20,7 @@ export * from './ragEvals';
|
||||
export * from './rbac';
|
||||
export * from './relations';
|
||||
export * from './session';
|
||||
export * from './task';
|
||||
export * from './topic';
|
||||
export * from './user';
|
||||
export * from './userMemories';
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import { index, integer, jsonb, pgTable, text, uniqueIndex, uuid } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { idGenerator } from '../utils/idGenerator';
|
||||
import { createdAt, timestamps, timestamptz, varchar255 } from './_helpers';
|
||||
import { documents } from './file';
|
||||
import { users } from './user';
|
||||
|
||||
// ── Tasks ────────────────────────────────────────────────
|
||||
|
||||
export const tasks = pgTable(
|
||||
'tasks',
|
||||
{
|
||||
id: text('id')
|
||||
.primaryKey()
|
||||
.$defaultFn(() => idGenerator('tasks'))
|
||||
.notNull(),
|
||||
|
||||
// Workspace-level identifier (e.g. 'TASK-1', 'PROJ-42')
|
||||
identifier: text('identifier').notNull(),
|
||||
seq: integer('seq').notNull(),
|
||||
// Creator (user or agent)
|
||||
createdByUserId: text('created_by_user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
createdByAgentId: text('created_by_agent_id'),
|
||||
|
||||
// Assignee (user and agent can coexist, both nullable)
|
||||
assigneeUserId: text('assignee_user_id'),
|
||||
assigneeAgentId: text('assignee_agent_id'),
|
||||
|
||||
// Tree structure (self-referencing, no depth limit)
|
||||
parentTaskId: text('parent_task_id'),
|
||||
|
||||
// Task definition
|
||||
name: text('name'),
|
||||
description: varchar255('description'),
|
||||
instruction: text('instruction').notNull(),
|
||||
|
||||
// Lifecycle (same state machine for user and agent)
|
||||
// 'backlog' | 'running' | 'paused' | 'completed' | 'failed' | 'canceled'
|
||||
status: text('status').notNull().default('backlog'),
|
||||
priority: integer('priority').default(0), // 'no' | 'urgent' | 'high' | 'normal' | 'low'
|
||||
sortOrder: integer('sort_order').default(0), // manual sort within parent, lower = higher
|
||||
|
||||
// Heartbeat
|
||||
heartbeatInterval: integer('heartbeat_interval').default(300), // seconds
|
||||
heartbeatTimeout: integer('heartbeat_timeout'), // seconds, null = disabled (default off)
|
||||
lastHeartbeatAt: timestamptz('last_heartbeat_at'),
|
||||
|
||||
// Schedule (optional)
|
||||
schedulePattern: text('schedule_pattern'),
|
||||
scheduleTimezone: text('schedule_timezone').default('UTC'),
|
||||
|
||||
// Topic management
|
||||
totalTopics: integer('total_topics').default(0),
|
||||
maxTopics: integer('max_topics'), // null = unlimited
|
||||
currentTopicId: text('current_topic_id'),
|
||||
|
||||
// Context & config (each task independent, no inheritance from parent)
|
||||
context: jsonb('context').default({}),
|
||||
config: jsonb('config').default({}), // CheckpointConfig, ReviewConfig, etc.
|
||||
error: text('error'),
|
||||
|
||||
// Timestamps
|
||||
startedAt: timestamptz('started_at'),
|
||||
completedAt: timestamptz('completed_at'),
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('tasks_identifier_idx').on(t.identifier, t.createdByUserId),
|
||||
index('tasks_created_by_user_id_idx').on(t.createdByUserId),
|
||||
index('tasks_created_by_agent_id_idx').on(t.createdByAgentId),
|
||||
index('tasks_assignee_user_id_idx').on(t.assigneeUserId),
|
||||
index('tasks_assignee_agent_id_idx').on(t.assigneeAgentId),
|
||||
index('tasks_parent_task_id_idx').on(t.parentTaskId),
|
||||
index('tasks_status_idx').on(t.status),
|
||||
index('tasks_priority_idx').on(t.priority),
|
||||
index('tasks_heartbeat_idx').on(t.status, t.lastHeartbeatAt),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTask = typeof tasks.$inferInsert;
|
||||
export type TaskItem = typeof tasks.$inferSelect;
|
||||
|
||||
// ── Task Dependencies ────────────────────────────────────
|
||||
|
||||
export const taskDependencies = pgTable(
|
||||
'task_dependencies',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey().notNull(),
|
||||
|
||||
taskId: text('task_id')
|
||||
.references(() => tasks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
dependsOnId: text('depends_on_id')
|
||||
.references(() => tasks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// 'blocks' | 'relates'
|
||||
type: text('type').notNull().default('blocks'),
|
||||
|
||||
// Reserved for conditional dependencies: {"on": "success"} / {"on": "failure"}
|
||||
condition: jsonb('condition'),
|
||||
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('task_deps_unique_idx').on(t.taskId, t.dependsOnId),
|
||||
index('task_deps_task_id_idx').on(t.taskId),
|
||||
index('task_deps_depends_on_id_idx').on(t.dependsOnId),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTaskDependency = typeof taskDependencies.$inferInsert;
|
||||
export type TaskDependencyItem = typeof taskDependencies.$inferSelect;
|
||||
|
||||
// ── Task Documents (MVP Workspace) ───────────────────────
|
||||
|
||||
export const taskDocuments = pgTable(
|
||||
'task_documents',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey().notNull(),
|
||||
taskId: text('task_id')
|
||||
.references(() => tasks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
documentId: text('document_id')
|
||||
.references(() => documents.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// 'agent' | 'user' | 'system'
|
||||
pinnedBy: text('pinned_by').notNull().default('agent'),
|
||||
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('task_docs_unique_idx').on(t.taskId, t.documentId),
|
||||
index('task_docs_task_id_idx').on(t.taskId),
|
||||
index('task_docs_document_id_idx').on(t.documentId),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTaskDocument = typeof taskDocuments.$inferInsert;
|
||||
export type TaskDocumentItem = typeof taskDocuments.$inferSelect;
|
||||
|
||||
// ── Task Topics ─────────────────────────────────────────
|
||||
|
||||
export const taskTopics = pgTable(
|
||||
'task_topics',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey().notNull(),
|
||||
taskId: text('task_id')
|
||||
.references(() => tasks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
topicId: text('topic_id').notNull(),
|
||||
userId: text('user_id')
|
||||
.references(() => users.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
seq: integer('seq').notNull(), // topic sequence within task (1, 2, 3...)
|
||||
operationId: text('operation_id'), // agent execution operation ID
|
||||
// 'running' | 'completed' | 'failed' | 'timeout' | 'canceled'
|
||||
status: text('status').notNull().default('running'),
|
||||
|
||||
// Handoff (populated after topic completes via LLM summarization)
|
||||
handoffTitle: text('handoff_title'),
|
||||
handoffSummary: text('handoff_summary'),
|
||||
handoffKeyFindings: jsonb('handoff_key_findings'), // string[]
|
||||
handoffNextAction: text('handoff_next_action'),
|
||||
|
||||
// Review results (populated after topic completes + review runs)
|
||||
reviewPassed: integer('review_passed'), // 1 = passed, 0 = failed, null = not reviewed
|
||||
reviewScore: integer('review_score'), // overall score 0-100
|
||||
reviewScores: jsonb('review_scores'), // [{rubricId, score, passed, reason}]
|
||||
reviewIteration: integer('review_iteration'), // which iteration (1, 2, 3...)
|
||||
reviewedAt: timestamptz('reviewed_at'),
|
||||
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('task_topics_unique_idx').on(t.taskId, t.topicId),
|
||||
index('task_topics_task_id_idx').on(t.taskId),
|
||||
index('task_topics_topic_id_idx').on(t.topicId),
|
||||
index('task_topics_user_id_idx').on(t.userId),
|
||||
index('task_topics_status_idx').on(t.taskId, t.status),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTaskTopic = typeof taskTopics.$inferInsert;
|
||||
export type TaskTopicItem = typeof taskTopics.$inferSelect;
|
||||
|
||||
// ── Task Comments ───────────────────────────────────────
|
||||
|
||||
export const taskComments = pgTable(
|
||||
'task_comments',
|
||||
{
|
||||
id: uuid('id').defaultRandom().primaryKey().notNull(),
|
||||
taskId: text('task_id')
|
||||
.references(() => tasks.id, { onDelete: 'cascade' })
|
||||
.notNull(),
|
||||
|
||||
// Author (one of)
|
||||
userId: text('user_id'),
|
||||
agentId: text('agent_id'),
|
||||
|
||||
// Content
|
||||
content: text('content').notNull(),
|
||||
editorData: jsonb('editor_data'),
|
||||
|
||||
// Optional references
|
||||
briefId: uuid('brief_id'), // reply to a brief
|
||||
topicId: text('topic_id'), // related topic
|
||||
|
||||
...timestamps,
|
||||
},
|
||||
(t) => [
|
||||
index('task_comments_task_id_idx').on(t.taskId),
|
||||
index('task_comments_user_id_idx').on(t.userId),
|
||||
index('task_comments_agent_id_idx').on(t.agentId),
|
||||
index('task_comments_brief_id_idx').on(t.briefId),
|
||||
index('task_comments_topic_id_idx').on(t.topicId),
|
||||
],
|
||||
);
|
||||
|
||||
export type NewTaskComment = typeof taskComments.$inferInsert;
|
||||
export type TaskCommentItem = typeof taskComments.$inferSelect;
|
||||
@@ -8,6 +8,7 @@ export const createNanoId = (size = 8) =>
|
||||
const prefixes = {
|
||||
agentCronJobs: 'cron',
|
||||
agentSkills: 'skl',
|
||||
tasks: 'task',
|
||||
agents: 'agt',
|
||||
budget: 'bgt',
|
||||
chatGroups: 'cg',
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -10,4 +10,5 @@ export * from './summaryGenerationTitle';
|
||||
export * from './summaryHistory';
|
||||
export * from './summaryTags';
|
||||
export * from './summaryTitle';
|
||||
export * from './taskTopicHandoff';
|
||||
export * from './translate';
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import type { ChatStreamPayload } from '@lobechat/types';
|
||||
|
||||
/**
|
||||
* Generate a handoff summary for a completed task topic.
|
||||
* Returns both a title and structured handoff data for the next topic.
|
||||
*
|
||||
* Input: the last assistant message content from the topic.
|
||||
* Output: JSON with { title, summary, keyFindings?, nextAction? }
|
||||
*/
|
||||
export const chainTaskTopicHandoff = (params: {
|
||||
lastAssistantContent: string;
|
||||
taskInstruction: string;
|
||||
taskName: string;
|
||||
}): Partial<ChatStreamPayload> => ({
|
||||
messages: [
|
||||
{
|
||||
content: `You are a task execution summarizer. A topic (one round of agent execution) has just completed within a task. Generate a handoff summary for the next topic to read.
|
||||
|
||||
Output a JSON object with these fields:
|
||||
- "title": A concise title summarizing what this topic accomplished (max 50 chars, same language as content)
|
||||
- "summary": A 1-3 sentence summary of what was done and the key outcome
|
||||
- "keyFindings": An array of key findings or decisions made (optional, max 5 items)
|
||||
- "nextAction": What the next topic should do (optional, 1 sentence)
|
||||
|
||||
Rules:
|
||||
- Focus on WHAT WAS ACCOMPLISHED, not what was asked
|
||||
- Use the same language as the content
|
||||
- Keep title short and specific (e.g. "制定8章书籍大纲" not "完成任务")
|
||||
- summary should capture the essential outcome a new topic needs to know
|
||||
- Output ONLY the JSON object, no markdown fences or explanations`,
|
||||
role: 'system',
|
||||
},
|
||||
{
|
||||
content: `Task: ${params.taskName}
|
||||
Task instruction: ${params.taskInstruction}
|
||||
|
||||
Last assistant response:
|
||||
${params.lastAssistantContent}`,
|
||||
role: 'user',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export const TASK_TOPIC_HANDOFF_SCHEMA = {
|
||||
additionalProperties: false,
|
||||
properties: {
|
||||
keyFindings: {
|
||||
items: { type: 'string' },
|
||||
type: 'array',
|
||||
},
|
||||
nextAction: { type: 'string' },
|
||||
summary: { type: 'string' },
|
||||
title: { type: 'string' },
|
||||
},
|
||||
required: ['title', 'summary'],
|
||||
type: 'object',
|
||||
};
|
||||
@@ -15,5 +15,6 @@ export * from './search';
|
||||
export * from './skills';
|
||||
export * from './speaker';
|
||||
export * from './systemRole';
|
||||
export * from './task';
|
||||
export * from './toolDiscovery';
|
||||
export * from './userMemory';
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`buildTaskRunPrompt > should build prompt with only task instruction 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 帮我写一本 AI Agent 技术书籍
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should build prompt with task description + instruction 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 帮我写一本 AI Agent 技术书籍,目标 8 章
|
||||
Description: 面向开发者的技术书籍
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should handle empty activities gracefully 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should handle full scenario with all sections 1`] = `
|
||||
"<high_priority_instruction>
|
||||
这次直接开始写第1章
|
||||
</high_priority_instruction>
|
||||
|
||||
<user_feedback>
|
||||
<comment time="3h ago">第5章后移,增加评测章节</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
Description: 面向开发者的 AI Agent 技术书籍
|
||||
Topics: 1
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_001
|
||||
📋 18h ago Brief [decision] 大纲完成 ✅ approve brief_001
|
||||
💭 3h ago 👤 user 第5章后移,增加评测章节
|
||||
💭 2h ago 🤖 agent 已调整大纲
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include activity history with topics and briefs in CLI style 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
Topics: 2
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💬 19h ago Topic #1 制定大纲 ✓ completed tpc_aaa
|
||||
📋 18h ago Brief [decision] 大纲完成 [urgent] ✅ approve brief_abc123
|
||||
💬 18h ago Topic #2 修订大纲 ✓ completed tpc_bbb
|
||||
📋 18h ago Brief [decision] 建议拆分第4章 [normal] brief_def456
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include agent comments with author label and time 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">确认,开始写</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 3h ago 🤖 agent 大纲已完成,请确认
|
||||
💭 2h ago 👤 user 确认,开始写
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include parentTask context for subtasks 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-4 第2章 核心架构
|
||||
Status: ● running Priority: -
|
||||
Instruction: 撰写第2章
|
||||
Parent: TASK-1
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
<parentTask identifier="TASK-1" name="写一本书">
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
Subtasks (3):
|
||||
TASK-2 ✓ completed 第1章 概述
|
||||
TASK-4 ● running 第2章 核心架构 ← blocks: TASK-2 ◀ current
|
||||
TASK-6 ○ backlog 第3章 手写 Agent ← blocks: TASK-4
|
||||
</parentTask>
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include subtasks in task section 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Subtasks:
|
||||
TASK-2 ✓ completed 第1章 Agent 概述
|
||||
TASK-3 ○ backlog 第2章 快速上手 ← blocks: TASK-2
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should include subtasks in task tag when provided 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ● running Priority: -
|
||||
Instruction: 写一本 AI Agent 书,目标 8 章
|
||||
|
||||
Subtasks:
|
||||
TASK-2 ● running 第1章 AI Agent 概述
|
||||
TASK-3 ○ backlog 第2章 核心架构
|
||||
TASK-4 ○ backlog 第3章 手写 Agent
|
||||
|
||||
Review: (not configured)
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should only include user comments in user_feedback, not agent comments 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">用户反馈</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 2h ago 👤 user 用户反馈
|
||||
💭 1h ago 🤖 agent Agent 回复
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should place high_priority_instruction first, then feedback 1`] = `
|
||||
"<high_priority_instruction>
|
||||
这次重点关注第3章
|
||||
</high_priority_instruction>
|
||||
|
||||
<user_feedback>
|
||||
<comment time="1h ago">用户反馈</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 1h ago 👤 user 用户反馈
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should prioritize user feedback at the top 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="2h ago">第2章改为先上手再讲原理</comment>
|
||||
<comment time="1h ago">增加评测章节</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 2h ago 👤 user 第2章改为先上手再讲原理
|
||||
💭 1h ago 👤 user 增加评测章节
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should show resolved action and comment on briefs 1`] = `
|
||||
"<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-2 第2章
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写第2章
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
✅ 19h ago Brief [result] 第2章完成 ✏️ 第2章需要更多实例
|
||||
</task>"
|
||||
`;
|
||||
|
||||
exports[`buildTaskRunPrompt > should truncate comments to 80 chars in activities but keep full in user_feedback 1`] = `
|
||||
"<user_feedback>
|
||||
<comment time="1h ago">AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA — this part should be truncated in activities</comment>
|
||||
</user_feedback>
|
||||
|
||||
<task>
|
||||
<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>
|
||||
TASK-1 写一本书
|
||||
Status: ? undefined Priority: -
|
||||
Instruction: 写书
|
||||
|
||||
Review: (not configured)
|
||||
|
||||
Activities:
|
||||
💭 1h ago 👤 user AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA...
|
||||
</task>"
|
||||
`;
|
||||
@@ -0,0 +1,452 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTaskRunPrompt } from './index';
|
||||
|
||||
// Fixed reference time for stable timeAgo output
|
||||
const NOW = new Date('2026-03-22T12:00:00Z');
|
||||
|
||||
describe('buildTaskRunPrompt', () => {
|
||||
it('should build prompt with only task instruction', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '帮我写一本 AI Agent 技术书籍',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should build prompt with task description + instruction', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
description: '面向开发者的技术书籍',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '帮我写一本 AI Agent 技术书籍,目标 8 章',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it('should prioritize user feedback at the top', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{ content: '第2章改为先上手再讲原理', createdAt: '2026-03-22T10:00:00Z' },
|
||||
{ content: '增加评测章节', createdAt: '2026-03-22T10:30:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify feedback comes before task
|
||||
const feedbackIdx = result.indexOf('<user_feedback>');
|
||||
const taskIdx = result.indexOf('<task');
|
||||
expect(feedbackIdx).toBeLessThan(taskIdx);
|
||||
});
|
||||
|
||||
it('should include agent comments with author label and time', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{
|
||||
agentId: 'agt_xxx',
|
||||
content: '大纲已完成,请确认',
|
||||
createdAt: '2026-03-22T09:00:00Z',
|
||||
},
|
||||
{ content: '确认,开始写', createdAt: '2026-03-22T10:00:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('🤖 agent');
|
||||
expect(result).toContain('👤 user');
|
||||
expect(result).toContain('3h ago');
|
||||
expect(result).toContain('2h ago');
|
||||
});
|
||||
|
||||
it('should place high_priority_instruction first, then feedback', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [{ content: '用户反馈', createdAt: '2026-03-22T11:00:00Z' }],
|
||||
},
|
||||
extraPrompt: '这次重点关注第3章',
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
const feedbackIdx = result.indexOf('<user_feedback>');
|
||||
const extraIdx = result.indexOf('<high_priority_instruction>');
|
||||
const taskIdx = result.indexOf('<task');
|
||||
expect(extraIdx).toBeLessThan(feedbackIdx);
|
||||
expect(feedbackIdx).toBeLessThan(taskIdx);
|
||||
});
|
||||
|
||||
it('should include activity history with topics and briefs in CLI style', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:05:00Z',
|
||||
id: 'brief_abc123',
|
||||
priority: 'urgent',
|
||||
resolvedAction: 'approve',
|
||||
resolvedAt: '2026-03-21T17:30:00Z',
|
||||
summary: '8章大纲已制定完成',
|
||||
title: '大纲完成',
|
||||
type: 'decision',
|
||||
},
|
||||
{
|
||||
createdAt: '2026-03-21T18:00:00Z',
|
||||
id: 'brief_def456',
|
||||
priority: 'normal',
|
||||
resolvedAt: null,
|
||||
summary: '第4章内容过多,建议拆分',
|
||||
title: '建议拆分第4章',
|
||||
type: 'decision',
|
||||
},
|
||||
],
|
||||
topics: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
id: 'tpc_aaa',
|
||||
metadata: { handoff: { summary: '完成了大纲制定' } },
|
||||
seq: 1,
|
||||
status: 'completed',
|
||||
title: '制定大纲',
|
||||
},
|
||||
{
|
||||
createdAt: '2026-03-21T17:31:00Z',
|
||||
id: 'tpc_bbb',
|
||||
metadata: { handoff: { summary: '修订了大纲并拆分子任务' } },
|
||||
seq: 2,
|
||||
status: 'completed',
|
||||
title: '修订大纲',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify timeline is sorted chronologically (oldest first)
|
||||
// Data: topic1(17:00), brief1(17:05), topic2(17:31), brief2(18:00)
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
const topic1Idx = taskSection.indexOf('Topic #1');
|
||||
const brief1Idx = taskSection.indexOf('brief_abc123');
|
||||
const topic2Idx = taskSection.indexOf('Topic #2');
|
||||
const brief2Idx = taskSection.indexOf('brief_def456');
|
||||
expect(topic1Idx).toBeLessThan(brief1Idx);
|
||||
expect(brief1Idx).toBeLessThan(topic2Idx);
|
||||
expect(topic2Idx).toBeLessThan(brief2Idx);
|
||||
});
|
||||
|
||||
it('should show resolved action and comment on briefs', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
resolvedAction: 'feedback',
|
||||
resolvedAt: '2026-03-21T18:00:00Z',
|
||||
resolvedComment: '第2章需要更多实例',
|
||||
summary: '第2章初稿完成',
|
||||
title: '第2章完成',
|
||||
type: 'result',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-2',
|
||||
instruction: '写第2章',
|
||||
name: '第2章',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('第2章需要更多实例');
|
||||
});
|
||||
|
||||
it('should handle full scenario with all sections', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:05:00Z',
|
||||
id: 'brief_001',
|
||||
resolvedAction: 'approve',
|
||||
resolvedAt: '2026-03-21T17:30:00Z',
|
||||
summary: '大纲已完成',
|
||||
title: '大纲完成',
|
||||
type: 'decision',
|
||||
},
|
||||
],
|
||||
comments: [
|
||||
{ content: '第5章后移,增加评测章节', createdAt: '2026-03-22T09:00:00Z' },
|
||||
{ agentId: 'agt_inbox', content: '已调整大纲', createdAt: '2026-03-22T09:05:00Z' },
|
||||
],
|
||||
topics: [
|
||||
{
|
||||
createdAt: '2026-03-21T17:00:00Z',
|
||||
id: 'tpc_001',
|
||||
metadata: { handoff: { summary: '完成大纲' } },
|
||||
seq: 1,
|
||||
status: 'completed',
|
||||
title: '制定大纲',
|
||||
},
|
||||
],
|
||||
},
|
||||
extraPrompt: '这次直接开始写第1章',
|
||||
task: {
|
||||
description: '面向开发者的 AI Agent 技术书籍',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
|
||||
// Verify order: instruction → feedback → task (activities now inside task)
|
||||
const tags = ['<high_priority_instruction>', '<user_feedback>', '<task>'];
|
||||
let lastIdx = -1;
|
||||
for (const tag of tags) {
|
||||
const idx = result.indexOf(tag);
|
||||
expect(idx).toBeGreaterThan(lastIdx);
|
||||
lastIdx = idx;
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle empty activities gracefully', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
briefs: [],
|
||||
comments: [],
|
||||
topics: [],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).not.toContain('<user_feedback>');
|
||||
expect(result).not.toContain('<activities>');
|
||||
});
|
||||
|
||||
it('should include subtasks in task section', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
id: 'task_root',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
status: 'running',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 Agent 概述', priority: 3, status: 'completed' },
|
||||
{
|
||||
blockedBy: 'TASK-2',
|
||||
identifier: 'TASK-3',
|
||||
name: '第2章 快速上手',
|
||||
priority: 3,
|
||||
status: 'backlog',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
expect(result).toContain('TASK-2');
|
||||
expect(result).toContain('TASK-3');
|
||||
expect(result).toContain('← blocks: TASK-2');
|
||||
});
|
||||
|
||||
it('should truncate comments to 80 chars in activities but keep full in user_feedback', () => {
|
||||
const longContent = 'A'.repeat(90) + ' — this part should be truncated in activities';
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [{ content: longContent, createdAt: '2026-03-22T11:00:00Z' }],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// user_feedback should have full content
|
||||
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
|
||||
expect(feedbackSection).toContain(longContent);
|
||||
// activities comment should be truncated
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('...');
|
||||
expect(taskSection).not.toContain(longContent);
|
||||
});
|
||||
|
||||
it('should include subtasks in task tag when provided', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
task: {
|
||||
id: 'task_001',
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
status: 'running',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 AI Agent 概述', priority: 3, status: 'running' },
|
||||
{ identifier: 'TASK-3', name: '第2章 核心架构', priority: 3, status: 'backlog' },
|
||||
{ identifier: 'TASK-4', name: '第3章 手写 Agent', priority: 2, status: 'backlog' },
|
||||
],
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify subtasks appear between <task> and </task>
|
||||
const taskMatch = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskMatch).toContain('Subtasks:');
|
||||
expect(taskMatch).toContain('TASK-2');
|
||||
expect(taskMatch).toContain('TASK-3');
|
||||
expect(taskMatch).toContain('TASK-4');
|
||||
// Verify hint is present
|
||||
expect(taskMatch).toContain('Do NOT call viewTask');
|
||||
});
|
||||
|
||||
it('should include parentTask context for subtasks', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
parentTask: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写一本 AI Agent 书,目标 8 章',
|
||||
name: '写一本书',
|
||||
subtasks: [
|
||||
{ identifier: 'TASK-2', name: '第1章 概述', priority: 3, status: 'completed' },
|
||||
{
|
||||
blockedBy: 'TASK-2',
|
||||
identifier: 'TASK-4',
|
||||
name: '第2章 核心架构',
|
||||
priority: 3,
|
||||
status: 'running',
|
||||
},
|
||||
{
|
||||
blockedBy: 'TASK-4',
|
||||
identifier: 'TASK-6',
|
||||
name: '第3章 手写 Agent',
|
||||
priority: 2,
|
||||
status: 'backlog',
|
||||
},
|
||||
],
|
||||
},
|
||||
task: {
|
||||
id: 'task_004',
|
||||
identifier: 'TASK-4',
|
||||
instruction: '撰写第2章',
|
||||
name: '第2章 核心架构',
|
||||
parentIdentifier: 'TASK-1',
|
||||
status: 'running',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
// Verify parentTask block exists inside <task>
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('<parentTask');
|
||||
expect(taskSection).toContain('TASK-1');
|
||||
expect(taskSection).toContain('写一本 AI Agent 书');
|
||||
// Current task should be marked
|
||||
expect(taskSection).toContain('TASK-4');
|
||||
expect(taskSection).toContain('◀ current');
|
||||
// Dependency info
|
||||
expect(taskSection).toContain('← blocks: TASK-2');
|
||||
expect(taskSection).toContain('← blocks: TASK-4');
|
||||
});
|
||||
|
||||
it('should only include user comments in user_feedback, not agent comments', () => {
|
||||
const result = buildTaskRunPrompt(
|
||||
{
|
||||
activities: {
|
||||
comments: [
|
||||
{ content: '用户反馈', createdAt: '2026-03-22T10:00:00Z' },
|
||||
{ agentId: 'agt_xxx', content: 'Agent 回复', createdAt: '2026-03-22T10:05:00Z' },
|
||||
],
|
||||
},
|
||||
task: {
|
||||
identifier: 'TASK-1',
|
||||
instruction: '写书',
|
||||
name: '写一本书',
|
||||
},
|
||||
},
|
||||
NOW,
|
||||
);
|
||||
|
||||
expect(result).toMatchSnapshot();
|
||||
const feedbackSection = result.split('<user_feedback>')[1]?.split('</user_feedback>')[0] || '';
|
||||
expect(feedbackSection).toContain('用户反馈');
|
||||
expect(feedbackSection).not.toContain('Agent 回复');
|
||||
// But activities should have both
|
||||
const taskSection = result.match(/<task>[\s\S]*<\/task>/)?.[0] || '';
|
||||
expect(taskSection).toContain('👤 user');
|
||||
expect(taskSection).toContain('🤖 agent');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
import type { TaskDetailData, TaskDetailWorkspaceNode } from '@lobechat/types';
|
||||
|
||||
// ── Formatting helpers for Task tool responses ──
|
||||
|
||||
const priorityLabel = (p?: number | null): string => {
|
||||
switch (p) {
|
||||
case 1: {
|
||||
return 'urgent';
|
||||
}
|
||||
case 2: {
|
||||
return 'high';
|
||||
}
|
||||
case 3: {
|
||||
return 'normal';
|
||||
}
|
||||
case 4: {
|
||||
return 'low';
|
||||
}
|
||||
default: {
|
||||
return '-';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const statusIcon = (s: string): string => {
|
||||
switch (s) {
|
||||
case 'backlog': {
|
||||
return '○';
|
||||
}
|
||||
case 'running': {
|
||||
return '●';
|
||||
}
|
||||
case 'paused': {
|
||||
return '◐';
|
||||
}
|
||||
case 'completed': {
|
||||
return '✓';
|
||||
}
|
||||
case 'failed': {
|
||||
return '✗';
|
||||
}
|
||||
case 'canceled': {
|
||||
return '⊘';
|
||||
}
|
||||
default: {
|
||||
return '?';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export interface TaskSummary {
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
priority?: number | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
// 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
|
||||
*/
|
||||
export const formatTaskLine = (t: TaskSummary): string =>
|
||||
`${t.identifier} ${statusIcon(t.status)} ${t.status} ${t.name || '(unnamed)'} [${priorityLabel(t.priority)}]`;
|
||||
|
||||
/**
|
||||
* Format createTask response
|
||||
*/
|
||||
export const formatTaskCreated = (
|
||||
t: TaskSummary & { instruction: string; parentLabel?: string },
|
||||
): string => {
|
||||
const lines = [
|
||||
`Task created: ${t.identifier} "${t.name}"`,
|
||||
` Status: ${statusIcon(t.status)} ${t.status}`,
|
||||
` Priority: ${priorityLabel(t.priority)}`,
|
||||
];
|
||||
if (t.parentLabel) lines.push(` Parent: ${t.parentLabel}`);
|
||||
lines.push(` Instruction: ${t.instruction}`);
|
||||
return lines.join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format task list response
|
||||
*/
|
||||
export const formatTaskList = (
|
||||
tasks: TaskSummary[],
|
||||
parentLabel: string,
|
||||
filter?: string,
|
||||
): string => {
|
||||
if (tasks.length === 0) {
|
||||
const filterNote = filter ? ` with status "${filter}"` : '';
|
||||
return `No subtasks found under ${parentLabel}${filterNote}.`;
|
||||
}
|
||||
|
||||
return [
|
||||
`${tasks.length} task(s) under ${parentLabel}:`,
|
||||
...tasks.map((t) => ` ${formatTaskLine(t)}`),
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format viewTask response
|
||||
*/
|
||||
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}`,
|
||||
];
|
||||
|
||||
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('');
|
||||
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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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');
|
||||
};
|
||||
|
||||
/**
|
||||
* Format editTask response
|
||||
*/
|
||||
export const formatTaskEdited = (identifier: string, changes: string[]): string =>
|
||||
`Task ${identifier} updated:\n ${changes.join('\n ')}`;
|
||||
|
||||
/**
|
||||
* Format dependency change response
|
||||
*/
|
||||
export const formatDependencyAdded = (task: string, dependsOn: string): string =>
|
||||
`Dependency added: ${task} now blocks on ${dependsOn}.\n${task} will not start until ${dependsOn} is completed.`;
|
||||
|
||||
export const formatDependencyRemoved = (task: string, dependsOn: string): string =>
|
||||
`Dependency removed: ${task} no longer blocks on ${dependsOn}.`;
|
||||
|
||||
/**
|
||||
* Format brief created response
|
||||
*/
|
||||
export const formatBriefCreated = (args: {
|
||||
id: string;
|
||||
priority: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}): string =>
|
||||
`Brief created (${args.type}, ${args.priority}):\n "${args.title}"\n ${args.summary}\n\nBrief ID: ${args.id}`;
|
||||
|
||||
/**
|
||||
* Format checkpoint response
|
||||
*/
|
||||
export const formatCheckpointCreated = (reason: string): string =>
|
||||
`Checkpoint created. Task is now paused and waiting for user review.\n\nReason: ${reason}\n\nThe user will see this as a "decision" brief and can resume the task after review.`;
|
||||
|
||||
// ── Task Run Prompt Builder ──
|
||||
|
||||
export interface TaskRunPromptComment {
|
||||
agentId?: string | null;
|
||||
content: string;
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptTopic {
|
||||
createdAt: string;
|
||||
handoff?: {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
} | null;
|
||||
id?: string;
|
||||
seq?: number | null;
|
||||
status?: string | null;
|
||||
title?: string | null;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptBrief {
|
||||
createdAt: string;
|
||||
id?: string;
|
||||
priority?: string | null;
|
||||
resolvedAction?: string | null;
|
||||
resolvedAt?: string | null;
|
||||
resolvedComment?: string | null;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptSubtask {
|
||||
createdAt?: string;
|
||||
id?: string;
|
||||
identifier: string;
|
||||
name?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptWorkspaceNode {
|
||||
children?: TaskRunPromptWorkspaceNode[];
|
||||
createdAt?: string;
|
||||
documentId: string;
|
||||
fileType?: string;
|
||||
size?: number;
|
||||
sourceTaskIdentifier?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export interface TaskRunPromptInput {
|
||||
/** Activity data (all optional) */
|
||||
activities?: {
|
||||
briefs?: TaskRunPromptBrief[];
|
||||
comments?: TaskRunPromptComment[];
|
||||
subtasks?: TaskRunPromptSubtask[];
|
||||
topics?: TaskRunPromptTopic[];
|
||||
};
|
||||
/** --prompt flag content */
|
||||
extraPrompt?: string;
|
||||
/** Parent task context (when current task is a subtask) */
|
||||
parentTask?: {
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
|
||||
};
|
||||
/** Task data */
|
||||
task: {
|
||||
assigneeAgentId?: string | null;
|
||||
dependencies?: Array<{ dependsOn: string; type: string }>;
|
||||
description?: string | null;
|
||||
id: string;
|
||||
identifier: string;
|
||||
instruction: string;
|
||||
name?: string | null;
|
||||
parentIdentifier?: string | null;
|
||||
priority?: number | null;
|
||||
review?: {
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
rubrics?: Array<{ name: string; threshold?: number; type: string }>;
|
||||
} | null;
|
||||
status: string;
|
||||
subtasks?: Array<TaskSummary & { blockedBy?: string }>;
|
||||
};
|
||||
/** Pinned documents (workspace) */
|
||||
workspace?: TaskRunPromptWorkspaceNode[];
|
||||
}
|
||||
|
||||
// ── Relative time helper ──
|
||||
|
||||
const timeAgo = (dateStr: string, now?: Date): string => {
|
||||
const date = new Date(dateStr);
|
||||
const ref = now || new Date();
|
||||
const diffMs = ref.getTime() - date.getTime();
|
||||
const diffMin = Math.floor(diffMs / 60_000);
|
||||
if (diffMin < 1) return 'just now';
|
||||
if (diffMin < 60) return `${diffMin}m ago`;
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
return `${diffDay}d ago`;
|
||||
};
|
||||
|
||||
// ── Brief icon ──
|
||||
|
||||
const briefIcon = (type: string): string => {
|
||||
switch (type) {
|
||||
case 'decision': {
|
||||
return '📋';
|
||||
}
|
||||
case 'result': {
|
||||
return '✅';
|
||||
}
|
||||
case 'insight': {
|
||||
return '💡';
|
||||
}
|
||||
case 'error': {
|
||||
return '❌';
|
||||
}
|
||||
default: {
|
||||
return '📌';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Build the prompt for task.run — injected as user message to the Agent.
|
||||
*
|
||||
* Priority order:
|
||||
* 1. High Priority Instruction (--prompt) — the most important directive for this run
|
||||
* 2. User Feedback (user comments only, full content) — what the user wants
|
||||
* 3. Activities (topics + briefs + comments + subtasks, chronological) — full timeline
|
||||
* 4. Original Task (instruction + description) — the base requirement
|
||||
*/
|
||||
export const buildTaskRunPrompt = (input: TaskRunPromptInput, now?: Date): string => {
|
||||
const { task, activities, extraPrompt, workspace, parentTask } = input;
|
||||
const sections: string[] = [];
|
||||
|
||||
// ── 1. High Priority Instruction ──
|
||||
if (extraPrompt) {
|
||||
sections.push(`<high_priority_instruction>\n${extraPrompt}\n</high_priority_instruction>`);
|
||||
}
|
||||
|
||||
// ── 2. User Feedback (user comments only, full content) ──
|
||||
const userComments = activities?.comments?.filter((c) => !c.agentId);
|
||||
if (userComments && userComments.length > 0) {
|
||||
const lines = userComments.map((c) => {
|
||||
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
|
||||
const timeAttr = ago ? ` time="${ago}"` : '';
|
||||
const idAttr = c.id ? ` id="${c.id}"` : '';
|
||||
return `<comment${idAttr}${timeAttr}>${c.content}</comment>`;
|
||||
});
|
||||
sections.push(`<user_feedback>\n${lines.join('\n')}\n</user_feedback>`);
|
||||
}
|
||||
|
||||
// ── 3. Task context (full detail so agent doesn't need to call viewTask) ──
|
||||
const taskLines = [
|
||||
`<task>`,
|
||||
`<hint>This tag contains the complete task context. Do NOT call viewTask to re-fetch it.</hint>`,
|
||||
`${task.identifier} ${task.name || task.identifier}`,
|
||||
`Status: ${statusIcon(task.status)} ${task.status} Priority: ${priorityLabel(task.priority)}`,
|
||||
`Instruction: ${task.instruction}`,
|
||||
];
|
||||
if (task.description) taskLines.push(`Description: ${task.description}`);
|
||||
if (task.assigneeAgentId) taskLines.push(`Agent: ${task.assigneeAgentId}`);
|
||||
if (task.parentIdentifier) taskLines.push(`Parent: ${task.parentIdentifier}`);
|
||||
|
||||
const topicCount = activities?.topics?.length ?? 0;
|
||||
if (topicCount > 0) taskLines.push(`Topics: ${topicCount}`);
|
||||
|
||||
if (task.dependencies && task.dependencies.length > 0) {
|
||||
taskLines.push(
|
||||
`Dependencies: ${task.dependencies.map((d) => `${d.type}: ${d.dependsOn}`).join(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Subtasks
|
||||
if (task.subtasks && task.subtasks.length > 0) {
|
||||
taskLines.push('');
|
||||
taskLines.push('Subtasks:');
|
||||
for (const s of task.subtasks) {
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
taskLines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Review
|
||||
taskLines.push('');
|
||||
if (task.review?.enabled && task.review.rubrics && task.review.rubrics.length > 0) {
|
||||
taskLines.push(`Review (maxIterations: ${task.review.maxIterations || 3}):`);
|
||||
for (const r of task.review.rubrics) {
|
||||
taskLines.push(
|
||||
` - ${r.name} [${r.type}]${r.threshold ? ` ≥ ${Math.round(r.threshold * 100)}%` : ''}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
taskLines.push('Review: (not configured)');
|
||||
}
|
||||
|
||||
// Workspace
|
||||
if (workspace && workspace.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 (${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(
|
||||
`${indent}${icon} ${node.title || 'Untitled'} (${node.documentId})${source}${sizeStr}${ago}`,
|
||||
);
|
||||
if (node.children) {
|
||||
renderNodes(node.children, indent + ' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
renderNodes(workspace, ' ');
|
||||
}
|
||||
|
||||
// Activities (chronological, flat list)
|
||||
const timelineEntries: { text: string; time: number }[] = [];
|
||||
|
||||
if (activities?.topics) {
|
||||
for (const t of activities.topics) {
|
||||
const ago = timeAgo(t.createdAt, now);
|
||||
const status = t.status || 'completed';
|
||||
const title = t.title || t.handoff?.title || 'Untitled';
|
||||
const idSuffix = t.id ? ` ${t.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` 💬 ${ago} Topic #${t.seq || '?'} ${title} ${statusIcon(status)} ${status}${idSuffix}`,
|
||||
time: new Date(t.createdAt).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activities?.briefs) {
|
||||
for (const b of activities.briefs) {
|
||||
const ago = timeAgo(b.createdAt, now);
|
||||
let resolved = '';
|
||||
if (b.resolvedAt && b.resolvedAction) {
|
||||
resolved = b.resolvedComment ? ` ✏️ ${b.resolvedComment}` : ` ✅ ${b.resolvedAction}`;
|
||||
}
|
||||
const priStr = b.priority ? ` [${b.priority}]` : '';
|
||||
const idSuffix = b.id ? ` ${b.id}` : '';
|
||||
timelineEntries.push({
|
||||
text: ` ${briefIcon(b.type)} ${ago} Brief [${b.type}] ${b.title}${priStr}${resolved}${idSuffix}`,
|
||||
time: new Date(b.createdAt).getTime(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (activities?.comments) {
|
||||
for (const c of activities.comments) {
|
||||
const author = c.agentId ? '🤖 agent' : '👤 user';
|
||||
const ago = c.createdAt ? timeAgo(c.createdAt, now) : '';
|
||||
const truncated = c.content.length > 80 ? c.content.slice(0, 80) + '...' : c.content;
|
||||
timelineEntries.push({
|
||||
text: ` 💭 ${ago} ${author} ${truncated}`,
|
||||
time: c.createdAt ? new Date(c.createdAt).getTime() : 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (timelineEntries.length > 0) {
|
||||
timelineEntries.sort((a, b) => a.time - b.time);
|
||||
taskLines.push('');
|
||||
taskLines.push('Activities:');
|
||||
taskLines.push(...timelineEntries.map((e) => e.text));
|
||||
}
|
||||
|
||||
// Parent task context
|
||||
if (parentTask) {
|
||||
taskLines.push('');
|
||||
taskLines.push(
|
||||
`<parentTask identifier="${parentTask.identifier}" name="${parentTask.name || parentTask.identifier}">`,
|
||||
);
|
||||
taskLines.push(` Instruction: ${parentTask.instruction}`);
|
||||
if (parentTask.subtasks && parentTask.subtasks.length > 0) {
|
||||
taskLines.push(` Subtasks (${parentTask.subtasks.length}):`);
|
||||
for (const s of parentTask.subtasks) {
|
||||
const dep = s.blockedBy ? ` ← blocks: ${s.blockedBy}` : '';
|
||||
const marker = s.identifier === task.identifier ? ' ◀ current' : '';
|
||||
taskLines.push(
|
||||
` ${s.identifier} ${statusIcon(s.status)} ${s.status} ${s.name || '(unnamed)'}${dep}${marker}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
taskLines.push('</parentTask>');
|
||||
}
|
||||
|
||||
taskLines.push('</task>');
|
||||
sections.push(taskLines.join('\n'));
|
||||
|
||||
return sections.join('\n\n');
|
||||
};
|
||||
|
||||
export { priorityLabel, statusIcon };
|
||||
@@ -203,7 +203,7 @@ export const AgentChatConfigSchema = z
|
||||
thinkingLevel3: z.enum(['low', 'medium', 'high']).optional(),
|
||||
thinkingLevel4: z.enum(['minimal', 'high']).optional(),
|
||||
thinkingLevel5: z.enum(['minimal', 'low', 'medium', 'high']).optional(),
|
||||
toolResultMaxLength: z.number().default(6000),
|
||||
toolResultMaxLength: z.number().default(25000),
|
||||
urlContext: z.boolean().optional(),
|
||||
useModelBuiltinSearch: z.boolean().optional(),
|
||||
})
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
export interface BriefAction {
|
||||
/** Action identifier, e.g. 'approve', 'reject', 'feedback' */
|
||||
key: string;
|
||||
/** Display label, e.g. "✅ 确认开始", "💬 修改意见" */
|
||||
label: string;
|
||||
/**
|
||||
* Action type:
|
||||
* - 'resolve': directly mark brief as resolved
|
||||
* - 'comment': prompt for text input, then resolve
|
||||
* - 'link': navigate to a URL (no resolution)
|
||||
*/
|
||||
type: 'resolve' | 'comment' | 'link';
|
||||
/** URL for 'link' type actions */
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/** Default actions by brief type */
|
||||
export const DEFAULT_BRIEF_ACTIONS: Record<string, BriefAction[]> = {
|
||||
decision: [
|
||||
{ key: 'approve', label: '✅ 确认', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
|
||||
],
|
||||
error: [
|
||||
{ key: 'retry', label: '🔄 重试', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 反馈', type: 'comment' },
|
||||
],
|
||||
insight: [{ key: 'acknowledge', label: '👍 知悉', type: 'resolve' }],
|
||||
result: [
|
||||
{ key: 'approve', label: '✅ 通过', type: 'resolve' },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' },
|
||||
],
|
||||
};
|
||||
@@ -6,6 +6,7 @@ export * from './aiProvider';
|
||||
export * from './artifact';
|
||||
export * from './asyncTask';
|
||||
export * from './auth';
|
||||
export * from './brief';
|
||||
export * from './chunk';
|
||||
export * from './clientDB';
|
||||
export * from './conversation';
|
||||
@@ -31,6 +32,7 @@ export * from './service';
|
||||
export * from './session';
|
||||
export * from './skill';
|
||||
export * from './stepContext';
|
||||
export * from './task';
|
||||
export * from './tool';
|
||||
export * from './topic';
|
||||
export * from './user';
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
export interface CheckpointConfig {
|
||||
onAgentRequest?: boolean;
|
||||
tasks?: {
|
||||
afterIds?: string[];
|
||||
beforeIds?: string[];
|
||||
};
|
||||
topic?: {
|
||||
after?: boolean;
|
||||
before?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WorkspaceDocNode {
|
||||
charCount: number | null;
|
||||
createdAt: string;
|
||||
fileType: string;
|
||||
parentId: string | null;
|
||||
pinnedBy: string;
|
||||
sourceTaskIdentifier: string | null;
|
||||
title: string;
|
||||
updatedAt: string | null;
|
||||
}
|
||||
|
||||
export interface WorkspaceTreeNode {
|
||||
children: WorkspaceTreeNode[];
|
||||
id: string;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -79,7 +79,7 @@ const ToolTitle = memo<ToolTitleProps>(
|
||||
|
||||
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
|
||||
const isBuiltinPlugin = builtinToolIdentifiers.includes(identifier);
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
|
||||
|
||||
const params = useMemo(() => {
|
||||
const argsToUse = args || partialArgs || {};
|
||||
|
||||
@@ -18,7 +18,7 @@ const PluginAvatar = memo<PluginAvatarProps>(({ identifier, size = 32 }) => {
|
||||
|
||||
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
|
||||
const pluginAvatar = pluginHelpers.getPluginAvatar(pluginMeta);
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
|
||||
|
||||
return pluginAvatar ? (
|
||||
<Avatar alt={pluginTitle} avatar={pluginAvatar} size={size} />
|
||||
|
||||
@@ -28,7 +28,7 @@ const ArtifactItem = memo<ArtifactItemProps>(({ payload, messageId, identifier =
|
||||
const pluginMeta = useToolStore(toolSelectors.getMetaById(identifier), isEqual);
|
||||
const isToolHasUI = useToolStore(toolSelectors.isToolHasUI(identifier));
|
||||
const openToolUI = useChatStore((s) => s.openToolUI);
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? identifier;
|
||||
|
||||
return (
|
||||
<Flexbox
|
||||
|
||||
@@ -18,7 +18,7 @@ const Title = () => {
|
||||
|
||||
const { t } = useTranslation('plugin');
|
||||
const pluginMeta = useToolStore(toolSelectors.getMetaById(toolUIIdentifier), isEqual);
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? t('unknownPlugin');
|
||||
const pluginTitle = pluginHelpers.getPluginTitle(pluginMeta) ?? toolUIIdentifier;
|
||||
|
||||
if (toolUIIdentifier === WebBrowsingManifest.identifier) {
|
||||
return (
|
||||
|
||||
@@ -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)`,
|
||||
@@ -976,6 +985,7 @@ export const createRuntimeExecutors = (
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
memoryToolPermission: agentConfig?.chatConfig?.memory?.toolPermission,
|
||||
serverDB: ctx.serverDB,
|
||||
taskId: state.metadata?.taskId,
|
||||
toolManifestMap: effectiveManifestMap,
|
||||
toolResultMaxLength,
|
||||
topicId: ctx.topicId,
|
||||
@@ -1192,6 +1202,7 @@ export const createRuntimeExecutors = (
|
||||
activeDeviceId: state.metadata?.activeDeviceId,
|
||||
memoryToolPermission: batchAgentConfig?.chatConfig?.memory?.toolPermission,
|
||||
serverDB: ctx.serverDB,
|
||||
taskId: state.metadata?.taskId,
|
||||
toolManifestMap: batchManifestMap,
|
||||
toolResultMaxLength: batchAgentConfig?.chatConfig?.toolResultMaxLength,
|
||||
topicId: ctx.topicId,
|
||||
|
||||
@@ -328,7 +328,7 @@ describe('createServerAgentToolsEngine', () => {
|
||||
});
|
||||
|
||||
describe('LocalSystem tool enable rules', () => {
|
||||
it('should disable LocalSystem tool when no device context is provided', () => {
|
||||
it('should disable LocalSystem when no device context is provided', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
@@ -345,7 +345,25 @@ describe('createServerAgentToolsEngine', () => {
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('should enable LocalSystem tool when gateway configured AND device online', () => {
|
||||
it('should enable LocalSystem when gateway configured, device online AND auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: true, autoActivated: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable LocalSystem when device online but NOT auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
@@ -360,14 +378,35 @@ describe('createServerAgentToolsEngine', () => {
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable LocalSystem tool when gateway configured but device offline', () => {
|
||||
it('should disable LocalSystem when gateway configured but device offline', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [LocalSystemManifest.identifier] },
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: false },
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: false, autoActivated: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable LocalSystem when runtimeMode is explicitly set to cloud', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: {
|
||||
plugins: [LocalSystemManifest.identifier],
|
||||
chatConfig: { runtimeEnv: { runtimeMode: { desktop: 'cloud' } } },
|
||||
},
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: true, autoActivated: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
@@ -383,7 +422,7 @@ describe('createServerAgentToolsEngine', () => {
|
||||
});
|
||||
|
||||
describe('RemoteDevice tool enable rules', () => {
|
||||
it('should enable RemoteDevice tool when gateway configured', () => {
|
||||
it('should enable RemoteDevice when gateway configured and no device auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
@@ -401,7 +440,7 @@ describe('createServerAgentToolsEngine', () => {
|
||||
expect(result.enabledToolIds).toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice tool when gateway not configured', () => {
|
||||
it('should disable RemoteDevice when gateway not configured', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
@@ -418,5 +457,67 @@ describe('createServerAgentToolsEngine', () => {
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should disable RemoteDevice when device is already auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: { plugins: [RemoteDeviceManifest.identifier] },
|
||||
deviceContext: { gatewayConfigured: true, autoActivated: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
});
|
||||
|
||||
describe('LocalSystem + RemoteDevice interaction', () => {
|
||||
it('should enable only RemoteDevice (not LocalSystem) when device online but not auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: {
|
||||
plugins: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
},
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).not.toContain(LocalSystemManifest.identifier);
|
||||
expect(result.enabledToolIds).toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
|
||||
it('should enable only LocalSystem (not RemoteDevice) when device auto-activated', () => {
|
||||
const context = createMockContext();
|
||||
const engine = createServerAgentToolsEngine(context, {
|
||||
agentConfig: {
|
||||
plugins: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
},
|
||||
deviceContext: { gatewayConfigured: true, deviceOnline: true, autoActivated: true },
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
const result = engine.generateToolsDetailed({
|
||||
toolIds: [LocalSystemManifest.identifier, RemoteDeviceManifest.identifier],
|
||||
model: 'gpt-4',
|
||||
provider: 'openai',
|
||||
});
|
||||
|
||||
expect(result.enabledToolIds).toContain(LocalSystemManifest.identifier);
|
||||
expect(result.enabledToolIds).not.toContain(RemoteDeviceManifest.identifier);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -135,7 +135,8 @@ export const createServerAgentToolsEngine = (
|
||||
[LocalSystemManifest.identifier]:
|
||||
runtimeMode === 'local' &&
|
||||
!!deviceContext?.gatewayConfigured &&
|
||||
!!deviceContext?.deviceOnline,
|
||||
!!deviceContext?.deviceOnline &&
|
||||
!!deviceContext?.autoActivated,
|
||||
[MemoryManifest.identifier]: globalMemoryEnabled,
|
||||
[RemoteDeviceManifest.identifier]:
|
||||
!!deviceContext?.gatewayConfigured && !deviceContext?.autoActivated,
|
||||
|
||||
@@ -0,0 +1,368 @@
|
||||
// @vitest-environment node
|
||||
import { type LobeChatDatabase } from '@lobechat/database';
|
||||
import { getTestDB } from '@lobechat/database/test-utils';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { taskRouter } from '../../task';
|
||||
import { cleanupTestUser, createTestContext, createTestUser } from './setup';
|
||||
|
||||
// Mock getServerDB
|
||||
let testDB: LobeChatDatabase;
|
||||
vi.mock('@/database/core/db-adaptor', () => ({
|
||||
getServerDB: vi.fn(() => testDB),
|
||||
}));
|
||||
|
||||
// Mock AiAgentService
|
||||
const mockExecAgent = vi.fn().mockResolvedValue({
|
||||
operationId: 'op_test',
|
||||
success: true,
|
||||
topicId: 'tpc_test',
|
||||
});
|
||||
const mockInterruptTask = vi.fn().mockResolvedValue({ success: true });
|
||||
vi.mock('@/server/services/aiAgent', () => ({
|
||||
AiAgentService: vi.fn().mockImplementation(() => ({
|
||||
execAgent: mockExecAgent,
|
||||
interruptTask: mockInterruptTask,
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock TaskLifecycleService
|
||||
vi.mock('@/server/services/taskLifecycle', () => ({
|
||||
TaskLifecycleService: vi.fn().mockImplementation(() => ({
|
||||
onTopicComplete: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock TaskReviewService
|
||||
vi.mock('@/server/services/taskReview', () => ({
|
||||
TaskReviewService: vi.fn().mockImplementation(() => ({
|
||||
review: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock initModelRuntimeFromDB
|
||||
vi.mock('@/server/modules/ModelRuntime', () => ({
|
||||
initModelRuntimeFromDB: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('Task Router Integration', () => {
|
||||
let serverDB: LobeChatDatabase;
|
||||
let userId: string;
|
||||
let caller: ReturnType<typeof taskRouter.createCaller>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
serverDB = await getTestDB();
|
||||
testDB = serverDB;
|
||||
userId = await createTestUser(serverDB);
|
||||
caller = taskRouter.createCaller(createTestContext(userId));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await cleanupTestUser(serverDB, userId);
|
||||
});
|
||||
|
||||
describe('create + find + detail', () => {
|
||||
it('should create a task and retrieve it', async () => {
|
||||
const result = await caller.create({
|
||||
instruction: 'Write a book',
|
||||
name: 'Write Book',
|
||||
});
|
||||
|
||||
expect(result.data.identifier).toBe('TASK-1');
|
||||
expect(result.data.name).toBe('Write Book');
|
||||
expect(result.data.status).toBe('backlog');
|
||||
|
||||
// find
|
||||
const found = await caller.find({ id: 'TASK-1' });
|
||||
expect(found.data.id).toBe(result.data.id);
|
||||
|
||||
// detail
|
||||
const detail = await caller.detail({ id: 'TASK-1' });
|
||||
expect(detail.data.identifier).toBe('TASK-1');
|
||||
expect(detail.data.subtasks).toHaveLength(0);
|
||||
expect(detail.data.timeline).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('subtasks + dependencies', () => {
|
||||
it('should create subtasks and set dependencies', async () => {
|
||||
const parent = await caller.create({
|
||||
instruction: 'Write a book',
|
||||
name: 'Book',
|
||||
});
|
||||
|
||||
const ch1 = await caller.create({
|
||||
instruction: 'Write chapter 1',
|
||||
name: 'Chapter 1',
|
||||
parentTaskId: parent.data.id,
|
||||
});
|
||||
const ch2 = await caller.create({
|
||||
instruction: 'Write chapter 2',
|
||||
name: 'Chapter 2',
|
||||
parentTaskId: parent.data.id,
|
||||
});
|
||||
|
||||
// Add dependency: ch2 blocks on ch1
|
||||
await caller.addDependency({
|
||||
dependsOnId: ch1.data.id,
|
||||
taskId: ch2.data.id,
|
||||
});
|
||||
|
||||
const detail = await caller.detail({ id: parent.data.identifier });
|
||||
expect(detail.data.subtasks).toHaveLength(2);
|
||||
// ch2 should have blockedBy pointing to ch1's identifier
|
||||
const ch2Sub = detail.data.subtasks!.find((s) => s.name === 'Chapter 2');
|
||||
expect(ch2Sub?.blockedBy).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('status transitions', () => {
|
||||
it('should transition backlog → running → paused → completed', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
// backlog → running
|
||||
const running = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'running',
|
||||
});
|
||||
expect(running.data.status).toBe('running');
|
||||
|
||||
// running → paused
|
||||
const paused = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'paused',
|
||||
});
|
||||
expect(paused.data.status).toBe('paused');
|
||||
|
||||
// paused → completed
|
||||
const completed = await caller.updateStatus({
|
||||
id: task.data.id,
|
||||
status: 'completed',
|
||||
});
|
||||
expect(completed.data.status).toBe('completed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comments', () => {
|
||||
it('should add and retrieve comments', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.addComment({
|
||||
content: 'First comment',
|
||||
id: task.data.id,
|
||||
});
|
||||
await caller.addComment({
|
||||
content: 'Second comment',
|
||||
id: task.data.id,
|
||||
});
|
||||
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.timeline?.comments).toHaveLength(2);
|
||||
expect(detail.data.timeline?.comments?.[0].content).toBe('First comment');
|
||||
});
|
||||
});
|
||||
|
||||
describe('review config', () => {
|
||||
it('should set and retrieve review rubrics', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
await caller.updateReview({
|
||||
id: task.data.id,
|
||||
review: {
|
||||
autoRetry: true,
|
||||
enabled: true,
|
||||
maxIterations: 3,
|
||||
rubrics: [
|
||||
{
|
||||
config: { criteria: '内容准确性' },
|
||||
id: 'r1',
|
||||
name: '准确性',
|
||||
threshold: 0.8,
|
||||
type: 'llm-rubric',
|
||||
weight: 1,
|
||||
},
|
||||
{
|
||||
config: { value: '```' },
|
||||
id: 'r2',
|
||||
name: '包含代码',
|
||||
type: 'contains',
|
||||
weight: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
describe('run idempotency', () => {
|
||||
it('should reject run when a topic is already running', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
// First run succeeds
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Second run should fail with CONFLICT
|
||||
await expect(caller.run({ id: task.data.id })).rejects.toThrow(/already has a running topic/);
|
||||
});
|
||||
|
||||
it('should reject continue on already running topic', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
const result = await caller.run({ id: task.data.id });
|
||||
|
||||
await expect(caller.run({ continueTopicId: 'tpc_test', id: task.data.id })).rejects.toThrow(
|
||||
/already running/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('run error rollback', () => {
|
||||
it('should rollback task status to paused on run failure', async () => {
|
||||
mockExecAgent.mockRejectedValueOnce(new Error('LLM failed'));
|
||||
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await expect(caller.run({ id: task.data.id })).rejects.toThrow();
|
||||
|
||||
// Task should be rolled back to paused with error
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.status).toBe('paused');
|
||||
expect(found.data.error).toContain('LLM failed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearAll', () => {
|
||||
it('should delete all tasks for user', async () => {
|
||||
await caller.create({ instruction: 'Task 1' });
|
||||
await caller.create({ instruction: 'Task 2' });
|
||||
await caller.create({ instruction: 'Task 3' });
|
||||
|
||||
const result = await caller.clearAll();
|
||||
expect(result.count).toBe(3);
|
||||
|
||||
const list = await caller.list({});
|
||||
expect(list.data).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cancelTopic', () => {
|
||||
it('should cancel a running topic and pause task', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Cancel the topic
|
||||
await caller.cancelTopic({ topicId: 'tpc_test' });
|
||||
|
||||
// Task should be paused
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.status).toBe('paused');
|
||||
});
|
||||
|
||||
it('should reject cancel on non-running topic', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
await caller.cancelTopic({ topicId: 'tpc_test' });
|
||||
|
||||
// Try to cancel again — should fail
|
||||
await expect(caller.cancelTopic({ topicId: 'tpc_test' })).rejects.toThrow(/not running/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('workspace documents', () => {
|
||||
it('should pin and show documents in detail', async () => {
|
||||
const task = await caller.create({ instruction: 'Test' });
|
||||
|
||||
// Create a document via the documents table directly
|
||||
const { documents } = await import('@/database/schemas');
|
||||
const [doc] = await serverDB
|
||||
.insert(documents)
|
||||
.values({
|
||||
content: 'Test content',
|
||||
fileType: 'markdown',
|
||||
source: 'test',
|
||||
sourceType: 'api',
|
||||
title: 'Test Doc',
|
||||
totalCharCount: 12,
|
||||
totalLineCount: 1,
|
||||
userId,
|
||||
})
|
||||
.returning();
|
||||
|
||||
// Pin to task
|
||||
await caller.pinDocument({
|
||||
documentId: doc.id,
|
||||
pinnedBy: 'user',
|
||||
taskId: task.data.id,
|
||||
});
|
||||
|
||||
// Check detail workspace
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
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({
|
||||
documentId: doc.id,
|
||||
taskId: task.data.id,
|
||||
});
|
||||
|
||||
const detail2 = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail2.data.workspace).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('heartbeat timeout detection', () => {
|
||||
it('should auto-detect timeout on detail and pause task', async () => {
|
||||
const task = await caller.create({
|
||||
assigneeAgentId: 'test-agent',
|
||||
instruction: 'Test',
|
||||
});
|
||||
|
||||
// Start running with very short timeout
|
||||
await caller.update({
|
||||
heartbeatTimeout: 1,
|
||||
id: task.data.id,
|
||||
});
|
||||
|
||||
await caller.run({ id: task.data.id });
|
||||
|
||||
// Wait for timeout
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
|
||||
// detail should auto-detect timeout and pause
|
||||
const detail = await caller.detail({ id: task.data.identifier });
|
||||
expect(detail.data.status).toBe('paused');
|
||||
// Verify stale timeout error gets cleared via find
|
||||
const found = await caller.find({ id: task.data.id });
|
||||
expect(found.data.error).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { authedProcedure, router } from '@/libs/trpc/lambda';
|
||||
import { serverDatabase } from '@/libs/trpc/lambda/middleware';
|
||||
|
||||
const briefProcedure = authedProcedure.use(serverDatabase);
|
||||
|
||||
const idInput = z.object({ id: z.string().uuid() });
|
||||
|
||||
const createSchema = z.object({
|
||||
actions: z.array(z.record(z.unknown())).optional(),
|
||||
agentId: z.string().optional(),
|
||||
artifacts: z.array(z.string()).optional(),
|
||||
commentType: z.enum(['summary', 'suggestion', 'motion']).optional(),
|
||||
cronJobId: z.string().optional(),
|
||||
priority: z.enum(['urgent', 'normal', 'info']).default('info'),
|
||||
summary: z.string().min(1),
|
||||
taskId: z.string().optional(),
|
||||
title: z.string().min(1),
|
||||
topicId: z.string().optional(),
|
||||
type: z.enum(['decision', 'result', 'insight', 'error']),
|
||||
});
|
||||
|
||||
const listSchema = z.object({
|
||||
limit: z.number().min(1).max(100).default(50),
|
||||
offset: z.number().min(0).default(0),
|
||||
type: z.string().optional(),
|
||||
});
|
||||
|
||||
export const briefRouter = router({
|
||||
create: briefProcedure.input(createSchema).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const createData = { ...input };
|
||||
|
||||
// Resolve taskId if it's an identifier
|
||||
if (createData.taskId) {
|
||||
const taskModel = new TaskModel(ctx.serverDB, ctx.userId);
|
||||
const task = await taskModel.resolve(createData.taskId);
|
||||
if (task) createData.taskId = task.id;
|
||||
}
|
||||
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.create(createData);
|
||||
return { data: brief, message: 'Brief created', success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:create]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to create brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
delete: briefProcedure.input(idInput).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const deleted = await model.delete(input.id);
|
||||
if (!deleted) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { message: 'Brief deleted', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:delete]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to delete brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
find: briefProcedure.input(idInput).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.findById(input.id);
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:find]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
findByTaskId: briefProcedure
|
||||
.input(z.object({ taskId: z.string() }))
|
||||
.query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const items = await model.findByTaskId(input.taskId);
|
||||
return { data: items, success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:findByTaskId]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to find briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
list: briefProcedure.input(listSchema).query(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const result = await model.list(input);
|
||||
return { data: result.briefs, success: true, total: result.total };
|
||||
} catch (error) {
|
||||
console.error('[brief:list]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to list briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
listUnresolved: briefProcedure.query(async ({ ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const items = await model.listUnresolved();
|
||||
return { data: items, success: true };
|
||||
} catch (error) {
|
||||
console.error('[brief:listUnresolved]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to list unresolved briefs',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
markRead: briefProcedure.input(idInput).mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.markRead(input.id);
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, message: 'Brief marked as read', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:markRead]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to mark brief as read',
|
||||
});
|
||||
}
|
||||
}),
|
||||
|
||||
resolve: briefProcedure
|
||||
.input(
|
||||
idInput.merge(
|
||||
z.object({
|
||||
action: z.string().optional(),
|
||||
comment: z.string().optional(),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
try {
|
||||
const model = new BriefModel(ctx.serverDB, ctx.userId);
|
||||
const brief = await model.resolve(input.id, {
|
||||
action: input.action,
|
||||
comment: input.comment,
|
||||
});
|
||||
if (!brief) throw new TRPCError({ code: 'NOT_FOUND', message: 'Brief not found' });
|
||||
return { data: brief, message: 'Brief resolved', success: true };
|
||||
} catch (error) {
|
||||
if (error instanceof TRPCError) throw error;
|
||||
console.error('[brief:resolve]', error);
|
||||
throw new TRPCError({
|
||||
cause: error,
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
message: 'Failed to resolve brief',
|
||||
});
|
||||
}
|
||||
}),
|
||||
});
|
||||
@@ -27,7 +27,7 @@ export const documentRouter = router({
|
||||
.input(
|
||||
z.object({
|
||||
content: z.string().optional(),
|
||||
editorData: z.string(),
|
||||
editorData: z.string().optional(),
|
||||
fileType: z.string().optional(),
|
||||
knowledgeBaseId: z.string().optional(),
|
||||
metadata: z.record(z.any()).optional(),
|
||||
@@ -47,7 +47,7 @@ export const documentRouter = router({
|
||||
}
|
||||
|
||||
// Parse editorData from JSON string to object
|
||||
const editorData = JSON.parse(input.editorData);
|
||||
const editorData = input.editorData ? JSON.parse(input.editorData) : undefined;
|
||||
return ctx.documentService.createDocument({
|
||||
...input,
|
||||
editorData,
|
||||
|
||||
@@ -21,6 +21,7 @@ import { aiChatRouter } from './aiChat';
|
||||
import { aiModelRouter } from './aiModel';
|
||||
import { aiProviderRouter } from './aiProvider';
|
||||
import { apiKeyRouter } from './apiKey';
|
||||
import { briefRouter } from './brief';
|
||||
import { chunkRouter } from './chunk';
|
||||
import { comfyuiRouter } from './comfyui';
|
||||
import { configRouter } from './config';
|
||||
@@ -47,6 +48,7 @@ import { searchRouter } from './search';
|
||||
import { sessionRouter } from './session';
|
||||
import { sessionGroupRouter } from './sessionGroup';
|
||||
import { shareRouter } from './share';
|
||||
import { taskRouter } from './task';
|
||||
import { threadRouter } from './thread';
|
||||
import { topicRouter } from './topic';
|
||||
import { uploadRouter } from './upload';
|
||||
@@ -64,6 +66,8 @@ export const lambdaRouter = router({
|
||||
agentEval: agentEvalRouter,
|
||||
agentEvalExternal: agentEvalExternalRouter,
|
||||
agentSkills: agentSkillsRouter,
|
||||
task: taskRouter,
|
||||
brief: briefRouter,
|
||||
aiAgent: aiAgentRouter,
|
||||
aiChat: aiChatRouter,
|
||||
aiModel: aiModelRouter,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -183,12 +183,7 @@ export class AgentRuntimeService {
|
||||
if (impl instanceof LocalQueueServiceImpl) {
|
||||
log('Setting up local execution callback');
|
||||
impl.setExecutionCallback(async (operationId, stepIndex, context) => {
|
||||
log('[%s][%d] Local step executing...', operationId, stepIndex);
|
||||
await this.executeStep({
|
||||
context,
|
||||
operationId,
|
||||
stepIndex,
|
||||
});
|
||||
await this.executeStep({ context, operationId, stepIndex });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,7 @@ export interface OperationCreationParams {
|
||||
appContext: {
|
||||
agentId?: string;
|
||||
groupId?: string | null;
|
||||
taskId?: string;
|
||||
threadId?: string | null;
|
||||
topicId?: string | null;
|
||||
trigger?: string;
|
||||
|
||||
@@ -81,6 +81,8 @@ function formatErrorForMetadata(error: unknown): Record<string, any> | undefined
|
||||
* This extends the public ExecAgentParams with server-side only options
|
||||
*/
|
||||
interface InternalExecAgentParams extends ExecAgentParams {
|
||||
/** Additional plugin IDs to inject (e.g., task tool during task execution) */
|
||||
additionalPluginIds?: string[];
|
||||
/** Bot context for topic metadata (platform, applicationId, platformThreadId) */
|
||||
botContext?: ChatTopicBotContext;
|
||||
/**
|
||||
@@ -123,13 +125,15 @@ interface InternalExecAgentParams extends ExecAgentParams {
|
||||
* Defaults to true. Set to false for non-streaming scenarios (e.g., bot integrations).
|
||||
*/
|
||||
stream?: boolean;
|
||||
/** Task ID that triggered this execution (if trigger is 'task') */
|
||||
taskId?: string;
|
||||
/**
|
||||
* Custom title for the topic.
|
||||
* When provided (including empty string), overrides the default prompt-based title.
|
||||
* When undefined, falls back to prompt.slice(0, 50).
|
||||
*/
|
||||
title?: string;
|
||||
/** Topic creation trigger source ('cron' | 'chat' | 'api') */
|
||||
/** Topic creation trigger source ('cron' | 'chat' | 'api' | 'task') */
|
||||
trigger?: string;
|
||||
/**
|
||||
* User intervention configuration
|
||||
@@ -198,6 +202,7 @@ export class AiAgentService {
|
||||
*/
|
||||
async execAgent(params: InternalExecAgentParams): Promise<ExecAgentResult> {
|
||||
const {
|
||||
additionalPluginIds,
|
||||
agentId,
|
||||
slug,
|
||||
prompt,
|
||||
@@ -214,6 +219,7 @@ export class AiAgentService {
|
||||
title,
|
||||
trigger,
|
||||
cronJobId,
|
||||
taskId,
|
||||
evalContext,
|
||||
maxSteps,
|
||||
userInterventionConfig,
|
||||
@@ -283,10 +289,10 @@ export class AiAgentService {
|
||||
// 3. Handle topic creation: if no topicId provided, create a new topic; otherwise reuse existing
|
||||
let topicId = appContext?.topicId;
|
||||
if (!topicId) {
|
||||
// Prepare metadata with cronJobId and botContext if provided
|
||||
// Prepare metadata with cronJobId, taskId, and botContext if provided
|
||||
const metadata =
|
||||
cronJobId || botContext
|
||||
? { bot: botContext, cronJobId: cronJobId || undefined }
|
||||
cronJobId || taskId || botContext
|
||||
? { bot: botContext, cronJobId: cronJobId || undefined, taskId: taskId || undefined }
|
||||
: undefined;
|
||||
|
||||
const newTopic = await this.topicModel.create({
|
||||
@@ -391,6 +397,7 @@ export class AiAgentService {
|
||||
const hasTopicReference = /refer_topic/.test(prompt ?? '');
|
||||
const agentPlugins = [
|
||||
...(agentConfig?.plugins ?? []),
|
||||
...(additionalPluginIds || []),
|
||||
...(hasTopicReference ? ['lobe-topic-reference'] : []),
|
||||
];
|
||||
|
||||
@@ -429,6 +436,7 @@ export class AiAgentService {
|
||||
// Include device tool IDs so ToolsEngine can process them via enableChecker
|
||||
const pluginIds = [
|
||||
...(agentConfig.plugins || []),
|
||||
...(additionalPluginIds || []),
|
||||
LocalSystemManifest.identifier,
|
||||
RemoteDeviceManifest.identifier,
|
||||
];
|
||||
@@ -785,6 +793,7 @@ export class AiAgentService {
|
||||
appContext: {
|
||||
agentId: resolvedAgentId,
|
||||
groupId: appContext?.groupId,
|
||||
taskId,
|
||||
threadId: appContext?.threadId,
|
||||
topicId,
|
||||
trigger,
|
||||
@@ -1330,11 +1339,9 @@ export class AiAgentService {
|
||||
// We'll gracefully handle this case by only updating thread status
|
||||
try {
|
||||
// Check if the method exists before calling (using type assertion for future method)
|
||||
const service = this.agentRuntimeService as any;
|
||||
const service = this.agentRuntimeService;
|
||||
if (typeof service.interruptOperation === 'function') {
|
||||
await service.interruptOperation({
|
||||
operationId: resolvedOperationId,
|
||||
});
|
||||
await service.interruptOperation(resolvedOperationId);
|
||||
} else {
|
||||
log('interruptTask: interruptOperation method not available, only updating thread status');
|
||||
}
|
||||
|
||||
@@ -46,14 +46,24 @@ const toServiceResult = (doc: {
|
||||
});
|
||||
|
||||
export class NotebookRuntimeService {
|
||||
private db: LobeChatDatabase;
|
||||
private documentModel: DocumentModel;
|
||||
private topicDocumentModel: TopicDocumentModel;
|
||||
private userId: string;
|
||||
|
||||
constructor(options: NotebookRuntimeServiceOptions) {
|
||||
this.db = options.serverDB;
|
||||
this.userId = options.userId;
|
||||
this.documentModel = new DocumentModel(options.serverDB, options.userId);
|
||||
this.topicDocumentModel = new TopicDocumentModel(options.serverDB, options.userId);
|
||||
}
|
||||
|
||||
associateDocumentWithTask = async (documentId: string, taskId: string): Promise<void> => {
|
||||
const { TaskModel } = await import('@/database/models/task');
|
||||
const taskModel = new TaskModel(this.db, this.userId);
|
||||
await taskModel.pinDocument(taskId, documentId, 'agent');
|
||||
};
|
||||
|
||||
associateDocumentWithTopic = async (documentId: string, topicId: string): Promise<void> => {
|
||||
await this.topicDocumentModel.associate({ documentId, topicId });
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import { chainTaskTopicHandoff, TASK_TOPIC_HANDOFF_SCHEMA } from '@lobechat/prompts';
|
||||
import { DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskTopicModel } from '@/database/models/taskTopic';
|
||||
import { TopicModel } from '@/database/models/topic';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
import { SystemAgentService } from '@/server/services/systemAgent';
|
||||
import { TaskReviewService } from '@/server/services/taskReview';
|
||||
|
||||
const log = debug('task-lifecycle');
|
||||
|
||||
export interface TopicCompleteParams {
|
||||
errorMessage?: string;
|
||||
lastAssistantContent?: string;
|
||||
operationId: string;
|
||||
reason: string; // 'done' | 'error' | 'interrupted' | ...
|
||||
taskId: string;
|
||||
taskIdentifier: string;
|
||||
topicId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* TaskLifecycleService handles task state transitions triggered by topic completion.
|
||||
* Used by both local onComplete hooks and production webhook callbacks.
|
||||
*/
|
||||
export class TaskLifecycleService {
|
||||
private briefModel: BriefModel;
|
||||
private db: LobeChatDatabase;
|
||||
private systemAgentService: SystemAgentService;
|
||||
private taskModel: TaskModel;
|
||||
private taskTopicModel: TaskTopicModel;
|
||||
private topicModel: TopicModel;
|
||||
private userId: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
this.taskModel = new TaskModel(db, userId);
|
||||
this.taskTopicModel = new TaskTopicModel(db, userId);
|
||||
this.briefModel = new BriefModel(db, userId);
|
||||
this.topicModel = new TopicModel(db, userId);
|
||||
this.systemAgentService = new SystemAgentService(db, userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle topic completion — the core lifecycle method.
|
||||
*
|
||||
* Flow: updateHeartbeat → updateTopicStatus → handoff → review → checkpoint
|
||||
*/
|
||||
async onTopicComplete(params: TopicCompleteParams): Promise<void> {
|
||||
const { taskId, taskIdentifier, topicId, reason, lastAssistantContent, errorMessage } = params;
|
||||
|
||||
log('onTopicComplete: task=%s topic=%s reason=%s', taskIdentifier, topicId, reason);
|
||||
|
||||
await this.taskModel.updateHeartbeat(taskId);
|
||||
|
||||
const currentTask = await this.taskModel.findById(taskId);
|
||||
|
||||
if (reason === 'done') {
|
||||
// 1. Update topic status
|
||||
if (topicId) await this.taskTopicModel.updateStatus(taskId, topicId, 'completed');
|
||||
|
||||
// 2. Generate handoff summary + topic title
|
||||
if (topicId && lastAssistantContent) {
|
||||
await this.generateHandoff(
|
||||
taskId,
|
||||
taskIdentifier,
|
||||
topicId,
|
||||
lastAssistantContent,
|
||||
currentTask,
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Auto-review (if configured)
|
||||
if (currentTask && topicId && lastAssistantContent) {
|
||||
const shouldSkipPause = await this.runAutoReview(
|
||||
taskId,
|
||||
taskIdentifier,
|
||||
topicId,
|
||||
lastAssistantContent,
|
||||
currentTask,
|
||||
);
|
||||
if (shouldSkipPause) return; // auto-retry in progress, don't pause
|
||||
}
|
||||
|
||||
// 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 });
|
||||
}
|
||||
} else if (reason === 'error') {
|
||||
if (topicId) await this.taskTopicModel.updateStatus(taskId, topicId, 'failed');
|
||||
|
||||
const topicSeq = currentTask?.totalTopics || '?';
|
||||
const topicRef = topicId ? ` #${topicSeq} (${topicId})` : '';
|
||||
|
||||
await this.briefModel.create({
|
||||
actions: DEFAULT_BRIEF_ACTIONS['error'],
|
||||
priority: 'urgent',
|
||||
summary: `Execution failed: ${errorMessage || 'Unknown error'}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} topic${topicRef} error`,
|
||||
type: 'error',
|
||||
});
|
||||
|
||||
await this.taskModel.updateStatus(taskId, 'paused');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate handoff summary and update topic title via LLM.
|
||||
* Writes to task_topics handoff fields + updates topic title.
|
||||
*/
|
||||
private async generateHandoff(
|
||||
taskId: string,
|
||||
taskIdentifier: string,
|
||||
topicId: string,
|
||||
lastAssistantContent: string,
|
||||
currentTask: any,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { model, provider } = await (this.systemAgentService as any).getTaskModelConfig(
|
||||
'topic',
|
||||
);
|
||||
|
||||
const payload = chainTaskTopicHandoff({
|
||||
lastAssistantContent,
|
||||
taskInstruction: currentTask?.instruction || '',
|
||||
taskName: currentTask?.name || taskIdentifier,
|
||||
});
|
||||
|
||||
const modelRuntime = await initModelRuntimeFromDB(this.db, this.userId, provider);
|
||||
const result = await modelRuntime.generateObject(
|
||||
{
|
||||
messages: payload.messages as any[],
|
||||
model,
|
||||
schema: { name: 'task_topic_handoff', schema: TASK_TOPIC_HANDOFF_SCHEMA },
|
||||
},
|
||||
{ metadata: { trigger: 'task-handoff' } },
|
||||
);
|
||||
|
||||
const handoff = result as {
|
||||
keyFindings?: string[];
|
||||
nextAction?: string;
|
||||
summary?: string;
|
||||
title?: string;
|
||||
};
|
||||
|
||||
// Update topic title
|
||||
if (handoff.title) {
|
||||
await this.topicModel.update(topicId, { title: handoff.title });
|
||||
}
|
||||
|
||||
// Store handoff in task_topics dedicated fields
|
||||
await this.taskTopicModel.updateHandoff(taskId, topicId, handoff);
|
||||
|
||||
log('handoff generated for topic %s: title=%s', topicId, handoff.title);
|
||||
} catch (e) {
|
||||
console.warn('[TaskLifecycle] handoff generation failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Run auto-review if configured.
|
||||
* @returns true if auto-retry is in progress (caller should skip pause)
|
||||
*/
|
||||
private async runAutoReview(
|
||||
taskId: string,
|
||||
taskIdentifier: string,
|
||||
topicId: string,
|
||||
content: string,
|
||||
currentTask: any,
|
||||
): Promise<boolean> {
|
||||
const reviewConfig = this.taskModel.getReviewConfig(currentTask);
|
||||
if (!reviewConfig?.enabled || !reviewConfig.rubrics?.length) return false;
|
||||
|
||||
try {
|
||||
const topicLinks = await this.taskTopicModel.findByTaskId(taskId);
|
||||
const targetTopic = topicLinks.find((t) => t.topicId === topicId);
|
||||
const iteration = (targetTopic?.reviewIteration || 0) + 1;
|
||||
|
||||
const reviewService = new TaskReviewService(this.db, this.userId);
|
||||
const reviewResult = await reviewService.review({
|
||||
content,
|
||||
iteration,
|
||||
judge: reviewConfig.judge || {},
|
||||
rubrics: reviewConfig.rubrics,
|
||||
taskName: currentTask.name || taskIdentifier,
|
||||
});
|
||||
|
||||
log(
|
||||
'review result: task=%s passed=%s score=%d iteration=%d/%d',
|
||||
taskIdentifier,
|
||||
reviewResult.passed,
|
||||
reviewResult.overallScore,
|
||||
iteration,
|
||||
reviewConfig.maxIterations,
|
||||
);
|
||||
|
||||
// Save review result to task_topics
|
||||
await this.taskTopicModel.updateReview(taskId, topicId, {
|
||||
iteration,
|
||||
passed: reviewResult.passed,
|
||||
score: reviewResult.overallScore,
|
||||
scores: reviewResult.rubricResults,
|
||||
});
|
||||
|
||||
if (reviewResult.passed) {
|
||||
await this.briefModel.create({
|
||||
priority: 'info',
|
||||
summary: `Review passed (score: ${reviewResult.overallScore}%, iteration: ${iteration}). ${content.slice(0, 150)}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review passed`,
|
||||
type: 'result',
|
||||
});
|
||||
return false; // proceed to checkpoint
|
||||
}
|
||||
|
||||
if (reviewConfig.autoRetry && iteration < reviewConfig.maxIterations) {
|
||||
await this.briefModel.create({
|
||||
priority: 'normal',
|
||||
summary: `Review failed (score: ${reviewResult.overallScore}%, iteration ${iteration}/${reviewConfig.maxIterations}). Auto-retrying...`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review failed, retrying`,
|
||||
type: 'insight',
|
||||
});
|
||||
return true; // skip pause — scheduler will run next topic
|
||||
}
|
||||
|
||||
// Max iterations reached
|
||||
await this.briefModel.create({
|
||||
actions: [
|
||||
{ key: 'retry', label: '🔄 重试', type: 'resolve' as const },
|
||||
{ key: 'approve', label: '✅ 强制通过', type: 'resolve' as const },
|
||||
{ key: 'feedback', label: '💬 修改意见', type: 'comment' as const },
|
||||
],
|
||||
priority: 'urgent',
|
||||
summary: `Review failed after ${iteration} iteration(s) (score: ${reviewResult.overallScore}%). Suggestions: ${reviewResult.suggestions?.join('; ') || 'none'}`,
|
||||
taskId,
|
||||
title: `${taskIdentifier} review failed — needs attention`,
|
||||
type: 'decision',
|
||||
});
|
||||
return false; // proceed to checkpoint (will pause)
|
||||
} catch (e) {
|
||||
console.warn('[TaskLifecycle] auto-review failed:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { DEFAULT_SYSTEM_AGENT_CONFIG } from '@lobechat/const';
|
||||
import { evaluate, type EvaluateResult, type RubricResult } from '@lobechat/eval-rubric';
|
||||
import type { EvalBenchmarkRubric } from '@lobechat/types';
|
||||
import debug from 'debug';
|
||||
|
||||
import { UserModel } from '@/database/models/user';
|
||||
import type { LobeChatDatabase } from '@/database/type';
|
||||
import { initModelRuntimeFromDB } from '@/server/modules/ModelRuntime';
|
||||
|
||||
const log = debug('task-review');
|
||||
|
||||
export interface ReviewConfig {
|
||||
autoRetry: boolean;
|
||||
enabled: boolean;
|
||||
judge: ReviewJudge;
|
||||
maxIterations: number;
|
||||
rubrics: EvalBenchmarkRubric[];
|
||||
}
|
||||
|
||||
export interface ReviewJudge {
|
||||
model?: string;
|
||||
prompt?: string;
|
||||
provider?: string;
|
||||
}
|
||||
|
||||
export interface ReviewResult {
|
||||
iteration: number;
|
||||
overallScore: number;
|
||||
passed: boolean;
|
||||
rubricResults: RubricResult[];
|
||||
suggestions: string[];
|
||||
}
|
||||
|
||||
export class TaskReviewService {
|
||||
private db: LobeChatDatabase;
|
||||
private userId: string;
|
||||
|
||||
constructor(db: LobeChatDatabase, userId: string) {
|
||||
this.db = db;
|
||||
this.userId = userId;
|
||||
}
|
||||
|
||||
async review(params: {
|
||||
content: string;
|
||||
iteration?: number;
|
||||
judge: ReviewJudge;
|
||||
rubrics: EvalBenchmarkRubric[];
|
||||
taskName: string;
|
||||
}): Promise<ReviewResult> {
|
||||
const { content, rubrics, judge, taskName, iteration = 1 } = params;
|
||||
|
||||
// 1. Resolve model/provider
|
||||
const { model, provider } = await this.resolveModelConfig(judge);
|
||||
|
||||
log(
|
||||
'Starting review for task %s (iteration %d, model=%s, provider=%s, rubrics=%d)',
|
||||
taskName,
|
||||
iteration,
|
||||
model,
|
||||
provider,
|
||||
rubrics.length,
|
||||
);
|
||||
|
||||
// 2. Initialize ModelRuntime for LLM-based rubrics
|
||||
const modelRuntime = await initModelRuntimeFromDB(this.db, this.userId, provider);
|
||||
|
||||
// 3. Run evaluate() from @lobechat/eval-rubric
|
||||
const result: EvaluateResult = await evaluate(
|
||||
{
|
||||
actual: content,
|
||||
rubrics,
|
||||
testCase: { input: taskName },
|
||||
},
|
||||
{
|
||||
matchContext: {
|
||||
generateObject: async (payload) => {
|
||||
return (modelRuntime as any).generateObject(
|
||||
{
|
||||
messages: payload.messages as any[],
|
||||
model: payload.model || model,
|
||||
schema: { name: 'judge_score', schema: payload.schema },
|
||||
},
|
||||
{ metadata: { trigger: 'task-review' } },
|
||||
);
|
||||
},
|
||||
judgeModel: model,
|
||||
},
|
||||
passThreshold: 0.6,
|
||||
},
|
||||
);
|
||||
|
||||
log('Review complete: %s (score: %.2f, passed: %s)', taskName, result.score, result.passed);
|
||||
|
||||
return {
|
||||
iteration,
|
||||
overallScore: Math.round(result.score * 100),
|
||||
passed: result.passed,
|
||||
rubricResults: result.rubricResults,
|
||||
suggestions: [],
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveModelConfig(
|
||||
judge: ReviewJudge,
|
||||
): Promise<{ model: string; provider: string }> {
|
||||
if (judge.model && judge.provider) {
|
||||
return { model: judge.model, provider: judge.provider };
|
||||
}
|
||||
|
||||
const userModel = new UserModel(this.db, this.userId);
|
||||
const settings = await userModel.getUserSettings();
|
||||
const systemAgent = settings?.systemAgent as Record<string, any> | undefined;
|
||||
const topicConfig = systemAgent?.topic;
|
||||
const defaults = DEFAULT_SYSTEM_AGENT_CONFIG.topic;
|
||||
|
||||
return {
|
||||
model: judge.model || topicConfig?.model || defaults.model,
|
||||
provider: judge.provider || topicConfig?.provider || defaults.provider,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { appEnv } from '@/envs/app';
|
||||
|
||||
import { LocalTaskScheduler } from './local';
|
||||
import type { TaskSchedulerImpl } from './type';
|
||||
|
||||
// QStash implementation will be added later
|
||||
// import { QStashTaskScheduler } from './qstash';
|
||||
|
||||
/**
|
||||
* Create task scheduler module
|
||||
*
|
||||
* When AGENT_RUNTIME_MODE=queue: QStash (production)
|
||||
* When default: Local (setTimeout-based)
|
||||
*/
|
||||
export const createTaskSchedulerModule = (): TaskSchedulerImpl => {
|
||||
if (appEnv.enableQueueAgentRuntime) {
|
||||
// TODO: QStash implementation
|
||||
// return new QStashTaskScheduler({ qstashToken });
|
||||
}
|
||||
|
||||
return new LocalTaskScheduler();
|
||||
};
|
||||
|
||||
export { LocalTaskScheduler } from './local';
|
||||
export type { TaskSchedulerImpl } from './type';
|
||||
@@ -0,0 +1,55 @@
|
||||
import debug from 'debug';
|
||||
|
||||
import type { ScheduleNextTopicParams, TaskSchedulerImpl } from './type';
|
||||
|
||||
const log = debug('task-scheduler:local');
|
||||
|
||||
export type TaskExecutionCallback = (taskId: string, userId: string) => Promise<void>;
|
||||
|
||||
/**
|
||||
* Local task scheduler using setTimeout
|
||||
* For local development without QStash
|
||||
*/
|
||||
export class LocalTaskScheduler implements TaskSchedulerImpl {
|
||||
private executionCallback: TaskExecutionCallback | null = null;
|
||||
private pendingSchedules: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
setExecutionCallback(callback: TaskExecutionCallback): void {
|
||||
this.executionCallback = callback;
|
||||
}
|
||||
|
||||
async scheduleNextTopic(params: ScheduleNextTopicParams): Promise<string> {
|
||||
const { taskId, userId, delay = 0 } = params;
|
||||
const scheduleId = `local-task-${taskId}-${Date.now()}`;
|
||||
|
||||
log('Scheduling next topic for task %s (delay: %ds)', taskId, delay);
|
||||
|
||||
const timer = setTimeout(async () => {
|
||||
this.pendingSchedules.delete(scheduleId);
|
||||
|
||||
if (!this.executionCallback) {
|
||||
log('Warning: No execution callback set');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
log('Executing next topic for task %s', taskId);
|
||||
await this.executionCallback(taskId, userId);
|
||||
} catch (error) {
|
||||
log('Failed to execute next topic for task %s: %O', taskId, error);
|
||||
}
|
||||
}, delay * 1000);
|
||||
|
||||
this.pendingSchedules.set(scheduleId, timer);
|
||||
return scheduleId;
|
||||
}
|
||||
|
||||
async cancelScheduled(scheduleId: string): Promise<void> {
|
||||
const timer = this.pendingSchedules.get(scheduleId);
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
this.pendingSchedules.delete(scheduleId);
|
||||
log('Canceled schedule %s', scheduleId);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export interface ScheduleNextTopicParams {
|
||||
delay?: number; // delay in seconds, default 0
|
||||
taskId: string;
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface TaskSchedulerImpl {
|
||||
cancelScheduled: (scheduleId: string) => Promise<void>;
|
||||
|
||||
scheduleNextTopic: (params: ScheduleNextTopicParams) => Promise<string>;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { createTaskSchedulerModule, LocalTaskScheduler } from './impls';
|
||||
export type { ScheduleNextTopicParams, TaskSchedulerImpl } from './impls/type';
|
||||
@@ -0,0 +1,78 @@
|
||||
import { BriefIdentifier } from '@lobechat/builtin-tool-brief';
|
||||
import { formatBriefCreated, formatCheckpointCreated } from '@lobechat/prompts';
|
||||
import { DEFAULT_BRIEF_ACTIONS } from '@lobechat/types';
|
||||
|
||||
import { BriefModel } from '@/database/models/brief';
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createBriefRuntime = ({
|
||||
briefModel,
|
||||
taskId,
|
||||
taskModel,
|
||||
}: {
|
||||
briefModel: BriefModel;
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
}) => ({
|
||||
createBrief: async (args: {
|
||||
actions?: Array<{ key: string; label: string; type: string }>;
|
||||
priority?: string;
|
||||
summary: string;
|
||||
title: string;
|
||||
type: string;
|
||||
}) => {
|
||||
const actions = args.actions || DEFAULT_BRIEF_ACTIONS[args.type] || [];
|
||||
|
||||
const brief = await briefModel.create({
|
||||
actions,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
taskId,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
});
|
||||
|
||||
return {
|
||||
content: formatBriefCreated({
|
||||
id: brief.id,
|
||||
priority: args.priority || 'info',
|
||||
summary: args.summary,
|
||||
title: args.title,
|
||||
type: args.type,
|
||||
}),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
requestCheckpoint: async (args: { reason: string }) => {
|
||||
if (taskId) {
|
||||
await taskModel.updateStatus(taskId, 'paused');
|
||||
}
|
||||
|
||||
await briefModel.create({
|
||||
priority: 'normal',
|
||||
summary: args.reason,
|
||||
taskId,
|
||||
title: 'Checkpoint requested',
|
||||
type: 'decision',
|
||||
});
|
||||
|
||||
return { content: formatCheckpointCreated(args.reason), success: true };
|
||||
},
|
||||
});
|
||||
|
||||
export const briefRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Brief tool execution');
|
||||
}
|
||||
|
||||
const briefModel = new BriefModel(context.serverDB, context.userId);
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
|
||||
return createBriefRuntime({ briefModel, taskId: context.taskId, taskModel });
|
||||
},
|
||||
identifier: BriefIdentifier,
|
||||
};
|
||||
@@ -8,6 +8,7 @@
|
||||
*/
|
||||
import { type ToolExecutionContext } from '../types';
|
||||
import { agentDocumentsRuntime } from './agentDocuments';
|
||||
import { briefRuntime } from './brief';
|
||||
import { calculatorRuntime } from './calculator';
|
||||
import { cloudSandboxRuntime } from './cloudSandbox';
|
||||
import { localSystemRuntime } from './localSystem';
|
||||
@@ -16,6 +17,7 @@ import { notebookRuntime } from './notebook';
|
||||
import { remoteDeviceRuntime } from './remoteDevice';
|
||||
import { skillsRuntime } from './skills';
|
||||
import { skillStoreRuntime } from './skillStore';
|
||||
import { taskRuntime } from './task';
|
||||
import { toolsActivatorRuntime } from './tools';
|
||||
import { topicReferenceRuntime } from './topicReference';
|
||||
import { type ServerRuntimeFactory, type ServerRuntimeRegistration } from './types';
|
||||
@@ -48,6 +50,8 @@ registerRuntimes([
|
||||
toolsActivatorRuntime,
|
||||
localSystemRuntime,
|
||||
remoteDeviceRuntime,
|
||||
briefRuntime,
|
||||
taskRuntime,
|
||||
topicReferenceRuntime,
|
||||
]);
|
||||
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import { TaskIdentifier } from '@lobechat/builtin-tool-task';
|
||||
import {
|
||||
formatDependencyAdded,
|
||||
formatDependencyRemoved,
|
||||
formatTaskCreated,
|
||||
formatTaskDetail,
|
||||
formatTaskEdited,
|
||||
formatTaskList,
|
||||
priorityLabel,
|
||||
} from '@lobechat/prompts';
|
||||
|
||||
import { TaskModel } from '@/database/models/task';
|
||||
import { TaskService } from '@/server/services/task';
|
||||
|
||||
import { type ServerRuntimeRegistration } from './types';
|
||||
|
||||
const createTaskRuntime = ({
|
||||
taskId,
|
||||
taskModel,
|
||||
taskService,
|
||||
}: {
|
||||
taskId?: string;
|
||||
taskModel: TaskModel;
|
||||
taskService: TaskService;
|
||||
}) => ({
|
||||
createTask: async (args: {
|
||||
instruction: string;
|
||||
name: string;
|
||||
parentIdentifier?: string;
|
||||
priority?: number;
|
||||
sortOrder?: number;
|
||||
review?: {
|
||||
autoRetry?: boolean;
|
||||
criteria?: Array<{ name: string; threshold: number }>;
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
};
|
||||
}) => {
|
||||
let parentTaskId: string | undefined;
|
||||
let parentLabel: string | undefined;
|
||||
let parentConfig: Record<string, any> | undefined;
|
||||
|
||||
if (args.parentIdentifier) {
|
||||
const parent = await taskModel.resolve(args.parentIdentifier);
|
||||
if (!parent)
|
||||
return { content: `Parent task not found: ${args.parentIdentifier}`, success: false };
|
||||
parentTaskId = parent.id;
|
||||
parentLabel = parent.identifier;
|
||||
parentConfig = parent.config as Record<string, any>;
|
||||
} else if (taskId) {
|
||||
parentTaskId = taskId;
|
||||
const current = await taskModel.findById(taskId);
|
||||
parentLabel = current?.identifier || taskId;
|
||||
parentConfig = current?.config as Record<string, any>;
|
||||
}
|
||||
|
||||
// Build config: explicit review > inherited from parent
|
||||
let config: Record<string, any> | undefined;
|
||||
if (args.review) {
|
||||
config = { review: { enabled: true, ...args.review } };
|
||||
} else if (parentConfig?.review) {
|
||||
config = { review: parentConfig.review };
|
||||
}
|
||||
|
||||
const task = await taskModel.create({
|
||||
...(config && { config }),
|
||||
instruction: args.instruction,
|
||||
name: args.name,
|
||||
parentTaskId,
|
||||
priority: args.priority,
|
||||
sortOrder: args.sortOrder,
|
||||
});
|
||||
|
||||
return {
|
||||
content: formatTaskCreated({
|
||||
identifier: task.identifier,
|
||||
instruction: args.instruction,
|
||||
name: task.name,
|
||||
parentLabel,
|
||||
priority: task.priority,
|
||||
status: task.status,
|
||||
}),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
deleteTask: async (args: { identifier: string }) => {
|
||||
const task = await taskModel.resolve(args.identifier);
|
||||
if (!task) return { content: `Task not found: ${args.identifier}`, success: false };
|
||||
|
||||
await taskModel.delete(task.id);
|
||||
|
||||
return {
|
||||
content: `Task ${task.identifier} "${task.name || ''}" has been deleted.`,
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
editTask: async (args: {
|
||||
addDependency?: string;
|
||||
identifier: string;
|
||||
instruction?: string;
|
||||
name?: string;
|
||||
priority?: number;
|
||||
removeDependency?: string;
|
||||
review?: {
|
||||
autoRetry?: boolean;
|
||||
criteria?: Array<{ name: string; threshold: number }>;
|
||||
enabled?: boolean;
|
||||
maxIterations?: number;
|
||||
};
|
||||
}) => {
|
||||
const task = await taskModel.resolve(args.identifier);
|
||||
if (!task) return { content: `Task not found: ${args.identifier}`, success: false };
|
||||
|
||||
const updateData: Record<string, any> = {};
|
||||
const changes: string[] = [];
|
||||
|
||||
if (args.name !== undefined) {
|
||||
updateData.name = args.name;
|
||||
changes.push(`name → "${args.name}"`);
|
||||
}
|
||||
if (args.instruction !== undefined) {
|
||||
updateData.instruction = args.instruction;
|
||||
changes.push(`instruction updated`);
|
||||
}
|
||||
if (args.priority !== undefined) {
|
||||
updateData.priority = args.priority;
|
||||
changes.push(`priority → ${priorityLabel(args.priority)}`);
|
||||
}
|
||||
if (args.review) {
|
||||
const currentConfig = (task.config as Record<string, any>) || {};
|
||||
updateData.config = { ...currentConfig, review: { enabled: true, ...args.review } };
|
||||
changes.push('review config updated');
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length > 0) {
|
||||
await taskModel.update(task.id, updateData);
|
||||
}
|
||||
|
||||
// Handle dependencies
|
||||
if (args.addDependency) {
|
||||
const dep = await taskModel.resolve(args.addDependency);
|
||||
if (!dep)
|
||||
return { content: `Dependency task not found: ${args.addDependency}`, success: false };
|
||||
await taskModel.addDependency(task.id, dep.id);
|
||||
changes.push(formatDependencyAdded(task.identifier, dep.identifier));
|
||||
}
|
||||
|
||||
if (args.removeDependency) {
|
||||
const dep = await taskModel.resolve(args.removeDependency);
|
||||
if (!dep)
|
||||
return { content: `Dependency task not found: ${args.removeDependency}`, success: false };
|
||||
await taskModel.removeDependency(task.id, dep.id);
|
||||
changes.push(formatDependencyRemoved(task.identifier, dep.identifier));
|
||||
}
|
||||
|
||||
return { content: formatTaskEdited(task.identifier, changes), success: true };
|
||||
},
|
||||
|
||||
listTasks: async (args: { parentIdentifier?: string; status?: string }) => {
|
||||
let parentId: string | undefined;
|
||||
let parentLabel = 'current task';
|
||||
|
||||
if (args.parentIdentifier) {
|
||||
const parent = await taskModel.resolve(args.parentIdentifier);
|
||||
if (!parent)
|
||||
return { content: `Parent task not found: ${args.parentIdentifier}`, success: false };
|
||||
parentId = parent.id;
|
||||
parentLabel = parent.identifier;
|
||||
} else {
|
||||
parentId = taskId;
|
||||
}
|
||||
|
||||
if (!parentId) return { content: 'No task context available.', success: false };
|
||||
|
||||
const subtasks = await taskModel.findSubtasks(parentId);
|
||||
let filtered = subtasks;
|
||||
if (args.status) {
|
||||
filtered = subtasks.filter((t) => t.status === args.status);
|
||||
}
|
||||
|
||||
return {
|
||||
content: formatTaskList(filtered, parentLabel, args.status),
|
||||
success: true,
|
||||
};
|
||||
},
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
const task = await taskModel.resolve(id);
|
||||
if (!task) return { content: `Task not found: ${id}`, success: false };
|
||||
|
||||
const updated = await taskModel.updateStatus(task.id, args.status);
|
||||
if (!updated) return { content: `Failed to update task ${task.identifier}`, success: false };
|
||||
|
||||
return {
|
||||
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,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const taskRuntime: ServerRuntimeRegistration = {
|
||||
factory: (context) => {
|
||||
if (!context.userId || !context.serverDB) {
|
||||
throw new Error('userId and serverDB are required for Task tool execution');
|
||||
}
|
||||
|
||||
const taskModel = new TaskModel(context.serverDB, context.userId);
|
||||
const taskService = new TaskService(context.serverDB, context.userId);
|
||||
|
||||
return createTaskRuntime({ taskId: context.taskId, taskModel, taskService });
|
||||
},
|
||||
identifier: TaskIdentifier,
|
||||
};
|
||||
@@ -9,6 +9,8 @@ export interface ToolExecutionContext {
|
||||
memoryToolPermission?: 'read-only' | 'read-write';
|
||||
/** Server database for LobeHub Skills execution */
|
||||
serverDB?: LobeChatDatabase;
|
||||
/** Task ID when executing within the Task system */
|
||||
taskId?: string;
|
||||
toolManifestMap: Record<string, LobeToolManifest>;
|
||||
/**
|
||||
* Maximum length for tool execution result content (in characters)
|
||||
|
||||
Reference in New Issue
Block a user