feat(activity): per-task timeline endpoint, board expand, --task/--tokens/--duration/--by flags
GET /tasks/:id/activity returns a time-sorted ActivityItem[] assembled from existing data (task created, handoffs by taskId, memory by relatedTasks, current status). memory add + task done gain optional tokens/duration/by metadata surfaced in the timeline. Board cards expand inline to show the timeline. Index gains taskId + relatedTasks columns with migrations. 74 -> 97 tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
4e5e5ae6bb
commit
9c749beacb
@ -2,7 +2,20 @@ import { input, select } from '@inquirer/prompts';
|
||||
import { addMemory, searchMemory, listMemory } from '../../core/services/memoryService.js';
|
||||
import type { Memory } from '../../core/schema.js';
|
||||
|
||||
export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Promise<void> {
|
||||
export interface MemoryAddOptions {
|
||||
title?: string;
|
||||
category?: Memory['category'];
|
||||
content?: string;
|
||||
/** Raw --task option value: single ID, comma-sep IDs, or already an array. */
|
||||
task?: string | string[];
|
||||
/** Pre-resolved array of task IDs (takes precedence over task). */
|
||||
relatedTasks?: string[];
|
||||
tokens?: number;
|
||||
duration?: number;
|
||||
by?: string;
|
||||
}
|
||||
|
||||
export async function memoryAdd(cwd: string, options: MemoryAddOptions = {}): Promise<void> {
|
||||
const title = options.title ?? await input({ message: 'Memory title:' });
|
||||
const category = options.category ?? await select({
|
||||
message: 'Category:',
|
||||
@ -16,7 +29,28 @@ export async function memoryAdd(cwd: string, options: Partial<Memory> = {}): Pro
|
||||
});
|
||||
const content = options.content ?? await input({ message: 'Content:' });
|
||||
|
||||
const memory = addMemory(cwd, { title, category, content });
|
||||
// Resolve relatedTasks: explicit array wins; otherwise parse --task string.
|
||||
const relatedTasks: string[] =
|
||||
options.relatedTasks ??
|
||||
(options.task
|
||||
? (Array.isArray(options.task)
|
||||
? options.task
|
||||
: options.task
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean))
|
||||
: []);
|
||||
|
||||
const memory = addMemory(cwd, {
|
||||
title,
|
||||
category,
|
||||
content,
|
||||
relatedTasks,
|
||||
tokens: options.tokens,
|
||||
duration: options.duration,
|
||||
by: options.by,
|
||||
} as Partial<Memory>);
|
||||
|
||||
console.log(`Memory saved as ${memory.id}.`);
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,15 @@ export function taskClaim(cwd: string, id: string, agentName: string): void {
|
||||
console.log(`${id} claimed by ${agentName}.`);
|
||||
}
|
||||
|
||||
export function taskDone(cwd: string, id: string): void {
|
||||
doneTask(cwd, id);
|
||||
export function taskDone(
|
||||
cwd: string,
|
||||
id: string,
|
||||
meta?: { tokens?: number; duration?: number; by?: string },
|
||||
): void {
|
||||
doneTask(cwd, id, {
|
||||
doneBy: meta?.by,
|
||||
doneTokens: meta?.tokens,
|
||||
doneDuration: meta?.duration,
|
||||
});
|
||||
console.log(`${id} marked as done.`);
|
||||
}
|
||||
|
||||
@ -108,15 +108,50 @@ export function createProgram(cwd: string): Command {
|
||||
.option('--title <title>', 'Title')
|
||||
.option('--category <category>', 'Category')
|
||||
.option('--content <content>', 'Content')
|
||||
.option(
|
||||
'--task <ids>',
|
||||
'Link to task IDs (comma-separated, e.g. TSK-0001 or TSK-0001,TSK-0002)',
|
||||
)
|
||||
.option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)')
|
||||
.option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)')
|
||||
.option('--by <agent>', 'Agent that produced this result (optional)')
|
||||
.action(async (options) => {
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
|
||||
// Parse CLI string values into the types the service expects.
|
||||
const relatedTasks: string[] = options.task
|
||||
? (Array.isArray(options.task)
|
||||
? options.task
|
||||
: options.task
|
||||
.split(',')
|
||||
.map((s: string) => s.trim())
|
||||
.filter(Boolean))
|
||||
: [];
|
||||
const tokens = options.tokens != null ? Number(options.tokens) : undefined;
|
||||
const duration = options.duration != null ? Number(options.duration) : undefined;
|
||||
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
const memory = await remoteClient.addMemory(serverUrl, options);
|
||||
const memory = await remoteClient.addMemory(serverUrl, {
|
||||
title: options.title as string | undefined,
|
||||
category: options.category as string | undefined,
|
||||
content: options.content as string | undefined,
|
||||
relatedTasks,
|
||||
tokens,
|
||||
duration,
|
||||
by: options.by as string | undefined,
|
||||
} as Partial<import('../core/schema.js').Memory>);
|
||||
console.log(`Memory saved as ${memory.id}.`);
|
||||
});
|
||||
} else {
|
||||
await memoryAdd(projectCwd, options);
|
||||
await memoryAdd(projectCwd, {
|
||||
title: options.title as string | undefined,
|
||||
content: options.content as string | undefined,
|
||||
relatedTasks,
|
||||
tokens,
|
||||
duration,
|
||||
by: options.by as string | undefined,
|
||||
});
|
||||
}
|
||||
});
|
||||
memoryCmd
|
||||
@ -220,15 +255,23 @@ export function createProgram(cwd: string): Command {
|
||||
taskCmd
|
||||
.command('done <id>')
|
||||
.description('Mark a task as done')
|
||||
.action(async (id) => {
|
||||
.option('--tokens <n>', 'Tokens consumed (optional, for activity timeline)')
|
||||
.option('--duration <ms>', 'Duration in milliseconds (optional, for activity timeline)')
|
||||
.option('--by <agent>', 'Agent that completed the task (optional)')
|
||||
.action(async (id, options) => {
|
||||
const meta = {
|
||||
tokens: options.tokens != null ? Number(options.tokens) : undefined,
|
||||
duration: options.duration != null ? Number(options.duration) : undefined,
|
||||
by: options.by as string | undefined,
|
||||
};
|
||||
const { serverUrl, projectCwd } = await resolveContext(program, cwd);
|
||||
if (serverUrl) {
|
||||
await runRemote(serverUrl, async () => {
|
||||
await remoteClient.doneTask(serverUrl, id);
|
||||
await remoteClient.doneTask(serverUrl, id, meta);
|
||||
console.log(`${id} marked as done.`);
|
||||
});
|
||||
} else {
|
||||
taskDone(projectCwd, id);
|
||||
taskDone(projectCwd, id, meta);
|
||||
}
|
||||
});
|
||||
program.addCommand(taskCmd);
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import type { Task, Handoff, Decision, Memory } from '../core/schema.js';
|
||||
import type { Task, Handoff, Decision, Memory, ActivityItem } from '../core/schema.js';
|
||||
import type { IndexEntry } from '../core/index.js';
|
||||
|
||||
export class RemoteError extends Error {
|
||||
@ -58,8 +58,21 @@ export const remoteClient = {
|
||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'in_progress', assignedTo: agentName });
|
||||
},
|
||||
|
||||
async doneTask(baseUrl: string, id: string): Promise<Task> {
|
||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, { status: 'done' });
|
||||
async doneTask(
|
||||
baseUrl: string,
|
||||
id: string,
|
||||
meta?: { tokens?: number; duration?: number; by?: string },
|
||||
): Promise<Task> {
|
||||
return request<Task>(baseUrl, 'PATCH', `/tasks/${id}`, {
|
||||
status: 'done',
|
||||
doneTokens: meta?.tokens,
|
||||
doneDuration: meta?.duration,
|
||||
doneBy: meta?.by,
|
||||
});
|
||||
},
|
||||
|
||||
async getTaskActivity(baseUrl: string, id: string): Promise<ActivityItem[]> {
|
||||
return request<ActivityItem[]>(baseUrl, 'GET', `/tasks/${id}/activity`);
|
||||
},
|
||||
|
||||
async createHandoff(baseUrl: string, options: Partial<Handoff>): Promise<Handoff> {
|
||||
|
||||
@ -20,6 +20,10 @@ export interface IndexEntry {
|
||||
toRole?: string;
|
||||
fromAgent?: string;
|
||||
toAgent?: string;
|
||||
// Handoff → task link
|
||||
taskId?: string;
|
||||
// Memory → task links (JSON array string, e.g. '["TSK-0001"]')
|
||||
relatedTasks?: string;
|
||||
}
|
||||
|
||||
export class Index {
|
||||
@ -45,16 +49,18 @@ export class Index {
|
||||
fromRole TEXT,
|
||||
toRole TEXT,
|
||||
fromAgent TEXT,
|
||||
toAgent TEXT
|
||||
toAgent TEXT,
|
||||
taskId TEXT,
|
||||
relatedTasks TEXT
|
||||
);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS search USING fts5(id, title, content);
|
||||
`);
|
||||
|
||||
// Migration: add handoff-routing columns to existing DBs that pre-date this schema.
|
||||
// Migration: add columns to existing DBs that pre-date this schema.
|
||||
const existingCols = new Set(
|
||||
(this.db.pragma('table_info(entities)') as Array<{ name: string }>).map((r) => r.name),
|
||||
);
|
||||
for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent']) {
|
||||
for (const col of ['fromRole', 'toRole', 'fromAgent', 'toAgent', 'taskId', 'relatedTasks']) {
|
||||
if (!existingCols.has(col)) {
|
||||
this.db.exec(`ALTER TABLE entities ADD COLUMN ${col} TEXT`);
|
||||
}
|
||||
@ -71,17 +77,20 @@ export class Index {
|
||||
toRole: null,
|
||||
fromAgent: null,
|
||||
toAgent: null,
|
||||
taskId: null,
|
||||
relatedTasks: null,
|
||||
...entry,
|
||||
};
|
||||
|
||||
const insert = this.db.prepare(`
|
||||
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent)
|
||||
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent)
|
||||
INSERT INTO entities (id, type, title, content, filePath, createdAt, updatedAt, status, role, assignedTo, tags, fromRole, toRole, fromAgent, toAgent, taskId, relatedTasks)
|
||||
VALUES (@id, @type, @title, @content, @filePath, @createdAt, @updatedAt, @status, @role, @assignedTo, @tags, @fromRole, @toRole, @fromAgent, @toAgent, @taskId, @relatedTasks)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
type=@type, title=@title, content=@content, filePath=@filePath,
|
||||
createdAt=@createdAt, updatedAt=@updatedAt, status=@status, role=@role,
|
||||
assignedTo=@assignedTo, tags=@tags,
|
||||
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent
|
||||
fromRole=@fromRole, toRole=@toRole, fromAgent=@fromAgent, toAgent=@toAgent,
|
||||
taskId=@taskId, relatedTasks=@relatedTasks
|
||||
`);
|
||||
insert.run(params);
|
||||
|
||||
@ -129,6 +138,26 @@ export class Index {
|
||||
return stmt.all(params) as IndexEntry[];
|
||||
}
|
||||
|
||||
/** Returns all handoff entries whose taskId matches the given task ID. */
|
||||
listHandoffsByTask(taskId: string): IndexEntry[] {
|
||||
const stmt = this.db.prepare(
|
||||
"SELECT * FROM entities WHERE type = 'handoff' AND taskId = @taskId ORDER BY createdAt ASC",
|
||||
);
|
||||
return stmt.all({ taskId }) as IndexEntry[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all memory entries whose relatedTasks JSON array contains the given
|
||||
* task ID. Uses a LIKE search on the serialised JSON string — safe because
|
||||
* task IDs follow the form TSK-NNNN and cannot be substrings of each other.
|
||||
*/
|
||||
listMemoryByTask(taskId: string): IndexEntry[] {
|
||||
const stmt = this.db.prepare(
|
||||
"SELECT * FROM entities WHERE type = 'memory' AND relatedTasks LIKE @pattern ORDER BY createdAt ASC",
|
||||
);
|
||||
return stmt.all({ pattern: `%${taskId}%` }) as IndexEntry[];
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.db.close();
|
||||
}
|
||||
|
||||
@ -21,6 +21,10 @@ export const TaskSchema = z.object({
|
||||
tags: z.array(z.string()).default([]),
|
||||
acceptanceCriteria: z.array(z.string()).default([]),
|
||||
sourceHandoff: z.string().optional(),
|
||||
// Optional completion metadata (set via `task done --tokens --duration --by`)
|
||||
doneBy: z.string().optional(),
|
||||
doneTokens: z.number().int().nonnegative().optional(),
|
||||
doneDuration: z.number().int().nonnegative().optional(),
|
||||
});
|
||||
|
||||
export const HandoffSchema = z.object({
|
||||
@ -61,8 +65,23 @@ export const MemorySchema = z.object({
|
||||
relatedDecisions: z.array(z.string()).default([]),
|
||||
createdAt: z.string().datetime(),
|
||||
updatedAt: z.string().datetime(),
|
||||
// Optional agent-supplied metadata (set via `memory add --tokens --duration --by`)
|
||||
tokens: z.number().int().nonnegative().optional(),
|
||||
duration: z.number().int().nonnegative().optional(),
|
||||
by: z.string().optional(),
|
||||
});
|
||||
|
||||
// Activity timeline item returned by GET /tasks/:id/activity
|
||||
export const ActivityItemSchema = z.object({
|
||||
at: z.string().datetime(),
|
||||
kind: z.enum(['created', 'handoff', 'result', 'status']),
|
||||
actor: z.string(),
|
||||
summary: z.string(),
|
||||
meta: z.record(z.unknown()).optional(),
|
||||
});
|
||||
|
||||
export type ActivityItem = z.infer<typeof ActivityItemSchema>;
|
||||
|
||||
export const StatusSchema = z.object({
|
||||
generatedAt: z.string().datetime(),
|
||||
activeTasks: z.array(z.string()).default([]),
|
||||
|
||||
137
src/core/services/activityService.ts
Normal file
137
src/core/services/activityService.ts
Normal file
@ -0,0 +1,137 @@
|
||||
import { getTask } from './taskService.js';
|
||||
import { Index } from '../index.js';
|
||||
import { readEntity } from '../files.js';
|
||||
import { MemorySchema } from '../schema.js';
|
||||
import type { ActivityItem } from '../schema.js';
|
||||
|
||||
/**
|
||||
* Build a time-sorted activity timeline for a single task.
|
||||
*
|
||||
* Sources (all assembled without rebuilding existing data):
|
||||
* - Task file → "created" event + final status/assignee event
|
||||
* - Handoffs → "handoff" events (filtered from SQLite by taskId)
|
||||
* - Memory → "result" events (filtered from SQLite by relatedTasks)
|
||||
*
|
||||
* Each item: { at, kind, actor, summary, meta? }
|
||||
* The meta object carries tokens/duration badges when the agent supplied them,
|
||||
* and routing details (fromRole/toRole) for handoff items.
|
||||
*/
|
||||
export function getTaskActivity(cwd: string, taskId: string): ActivityItem[] {
|
||||
const { task } = getTask(cwd, taskId);
|
||||
|
||||
const items: ActivityItem[] = [];
|
||||
|
||||
// ── 1. Created event ─────────────────────────────────────────────────────
|
||||
items.push({
|
||||
at: task.createdAt,
|
||||
kind: 'created',
|
||||
actor: task.role ?? 'user',
|
||||
summary: 'Task created',
|
||||
});
|
||||
|
||||
// ── 2. Handoff events ────────────────────────────────────────────────────
|
||||
const index = new Index(cwd);
|
||||
const handoffEntries = index.listHandoffsByTask(taskId);
|
||||
index.close();
|
||||
|
||||
for (const h of handoffEntries) {
|
||||
const from = h.fromRole ?? '?';
|
||||
const to = h.toRole ?? '?';
|
||||
const meta: Record<string, unknown> = {
|
||||
handoffId: h.id,
|
||||
fromRole: h.fromRole,
|
||||
toRole: h.toRole,
|
||||
};
|
||||
if (h.fromAgent) meta.fromAgent = h.fromAgent;
|
||||
if (h.toAgent) meta.toAgent = h.toAgent;
|
||||
|
||||
items.push({
|
||||
at: h.createdAt,
|
||||
kind: 'handoff',
|
||||
actor: `${from} → ${to}`,
|
||||
summary: h.title,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 3. Memory / result events ────────────────────────────────────────────
|
||||
const memIndex = new Index(cwd);
|
||||
const memEntries = memIndex.listMemoryByTask(taskId);
|
||||
memIndex.close();
|
||||
|
||||
for (const entry of memEntries) {
|
||||
// Read the file to get category, tokens, duration, by (not stored in index)
|
||||
let category: string = 'technical';
|
||||
let tokens: number | undefined;
|
||||
let duration: number | undefined;
|
||||
let by: string | undefined;
|
||||
|
||||
try {
|
||||
const { frontmatter } = readEntity(entry.filePath);
|
||||
const parsed = MemorySchema.safeParse(frontmatter);
|
||||
if (parsed.success) {
|
||||
category = parsed.data.category;
|
||||
tokens = parsed.data.tokens;
|
||||
duration = parsed.data.duration;
|
||||
by = parsed.data.by;
|
||||
}
|
||||
} catch {
|
||||
// File unreadable — use index-level data and defaults
|
||||
}
|
||||
|
||||
const meta: Record<string, unknown> = { memoryId: entry.id, category };
|
||||
if (tokens != null) meta.tokens = tokens;
|
||||
if (duration != null) meta.duration = duration;
|
||||
if (by != null) meta.by = by;
|
||||
|
||||
items.push({
|
||||
at: entry.createdAt,
|
||||
kind: 'result',
|
||||
actor: category,
|
||||
summary: entry.title,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
// ── 4. Current status event (only when task has progressed past "open") ──
|
||||
// We use updatedAt as the timestamp; this is a best-effort reconstruction
|
||||
// since we don't yet have a persistent per-transition audit log.
|
||||
if (task.status !== 'open') {
|
||||
let summary: string;
|
||||
switch (task.status) {
|
||||
case 'in_progress':
|
||||
summary = task.assignedTo ? `Claimed by ${task.assignedTo}` : 'Claimed';
|
||||
break;
|
||||
case 'review':
|
||||
summary = 'Submitted for review';
|
||||
break;
|
||||
case 'done':
|
||||
summary = 'Task completed';
|
||||
break;
|
||||
case 'cancelled':
|
||||
summary = 'Task cancelled';
|
||||
break;
|
||||
default:
|
||||
summary = `Status: ${task.status}`;
|
||||
}
|
||||
|
||||
const meta: Record<string, unknown> = { status: task.status };
|
||||
if (task.assignedTo) meta.assignedTo = task.assignedTo;
|
||||
if (task.doneBy) meta.by = task.doneBy;
|
||||
if (task.doneTokens != null) meta.tokens = task.doneTokens;
|
||||
if (task.doneDuration != null) meta.duration = task.doneDuration;
|
||||
|
||||
items.push({
|
||||
at: task.updatedAt,
|
||||
kind: 'status',
|
||||
actor: task.doneBy ?? task.assignedTo ?? task.role ?? 'unknown',
|
||||
summary,
|
||||
meta,
|
||||
});
|
||||
}
|
||||
|
||||
// ── Sort by timestamp, ascending ──────────────────────────────────────────
|
||||
items.sort((a, b) => a.at.localeCompare(b.at));
|
||||
|
||||
return items;
|
||||
}
|
||||
@ -38,6 +38,7 @@ export function createHandoff(cwd: string, options: Partial<Handoff> = {}): Hand
|
||||
toRole: handoff.toRole,
|
||||
fromAgent: handoff.fromAgent,
|
||||
toAgent: handoff.toAgent,
|
||||
taskId: handoff.taskId,
|
||||
});
|
||||
index.close();
|
||||
|
||||
|
||||
@ -18,6 +18,9 @@ export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory {
|
||||
relatedDecisions: options.relatedDecisions ?? [],
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
tokens: options.tokens,
|
||||
duration: options.duration,
|
||||
by: options.by,
|
||||
});
|
||||
|
||||
const filePath = join(getEntityDir(cwd, 'memory'), `${memory.id}.md`);
|
||||
@ -33,6 +36,7 @@ export function addMemory(cwd: string, options: Partial<Memory> = {}): Memory {
|
||||
createdAt: memory.createdAt,
|
||||
updatedAt: memory.updatedAt,
|
||||
tags: JSON.stringify(memory.tags),
|
||||
relatedTasks: JSON.stringify(memory.relatedTasks),
|
||||
});
|
||||
index.close();
|
||||
|
||||
|
||||
@ -59,8 +59,17 @@ export function claimTask(cwd: string, id: string, agentName: string): Task {
|
||||
return updateTask(cwd, id, { status: 'in_progress', assignedTo: agentName });
|
||||
}
|
||||
|
||||
export function doneTask(cwd: string, id: string): Task {
|
||||
return updateTask(cwd, id, { status: 'done' });
|
||||
export function doneTask(
|
||||
cwd: string,
|
||||
id: string,
|
||||
meta?: { doneBy?: string; doneTokens?: number; doneDuration?: number },
|
||||
): Task {
|
||||
return updateTask(cwd, id, {
|
||||
status: 'done',
|
||||
doneBy: meta?.doneBy,
|
||||
doneTokens: meta?.doneTokens,
|
||||
doneDuration: meta?.doneDuration,
|
||||
});
|
||||
}
|
||||
|
||||
export function reviewTask(cwd: string, id: string): Task {
|
||||
|
||||
@ -4,12 +4,10 @@
|
||||
* Design constraints (MVP — maximum simplicity):
|
||||
* - One static HTML page: inline CSS + JS, no build step, no framework, no npm deps.
|
||||
* - Reads only the existing same-origin endpoints (`/tasks`, `/handoffs`,
|
||||
* `/decisions`). It never mutates state — purely an observability surface.
|
||||
* `/decisions`, `/tasks/:id/activity`). It never mutates state.
|
||||
* - Auto-refreshes on a small interval via `setInterval` + `fetch`.
|
||||
*
|
||||
* The column skeleton is rendered server-side so the markup (and its
|
||||
* `data-column` markers) exist even before the client JS runs; JS only fills
|
||||
* the cards.
|
||||
* - Each task card is clickable: expanding it fetches and renders the
|
||||
* per-task activity timeline (created → handoff → result → status).
|
||||
*/
|
||||
|
||||
export interface BoardColumn {
|
||||
@ -118,7 +116,10 @@ export function renderBoardHtml(): string {
|
||||
border-left: 3px solid var(--accent);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.card:hover { border-color: rgba(139,148,158,.5); }
|
||||
.column[data-column="open"] .card { border-left-color: var(--open); }
|
||||
.column[data-column="in_progress"] .card { border-left-color: var(--in_progress); }
|
||||
.column[data-column="review"] .card { border-left-color: var(--review); }
|
||||
@ -136,6 +137,35 @@ export function renderBoardHtml(): string {
|
||||
.badge.agent { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); }
|
||||
.empty { color: var(--muted); font-size: 12px; padding: 4px 2px; }
|
||||
|
||||
/* ── Activity timeline ─────────────────────────────────────────────────── */
|
||||
.timeline {
|
||||
display: none;
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.card.expanded .timeline { display: block; }
|
||||
.tl-row {
|
||||
display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
|
||||
padding: 4px 0; border-top: 1px solid rgba(48,54,61,.5); font-size: 12px;
|
||||
}
|
||||
.tl-row:first-child { border-top: 0; }
|
||||
.tl-when { color: var(--muted); font-size: 11px; white-space: nowrap; min-width: 52px; }
|
||||
.tl-kind {
|
||||
font-size: 10px; border-radius: 999px; padding: 1px 6px;
|
||||
border: 1px solid transparent; white-space: nowrap;
|
||||
}
|
||||
.tl-kind-created { background: rgba(48,54,61,.6); color: var(--muted); border-color: var(--border); }
|
||||
.tl-kind-handoff { background: rgba(88,166,255,.12); color: var(--accent); border-color: rgba(88,166,255,.25); }
|
||||
.tl-kind-result { background: rgba(63,185,80,.12); color: var(--done); border-color: rgba(63,185,80,.25); }
|
||||
.tl-kind-status { background: rgba(210,153,34,.12); color: var(--review); border-color: rgba(210,153,34,.25); }
|
||||
.tl-actor { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.tl-summary { flex: 1; min-width: 0; word-break: break-word; }
|
||||
.tl-meta { display: flex; gap: 4px; flex-wrap: wrap; }
|
||||
.badge.tl-tokens { background: rgba(163,113,247,.12); color: #a371f7; border-color: rgba(163,113,247,.25); }
|
||||
.badge.tl-dur { background: rgba(255,166,87,.12); color: #ffa657; border-color: rgba(255,166,87,.25); }
|
||||
.tl-loading, .tl-empty { color: var(--muted); font-size: 12px; padding: 2px 0; }
|
||||
|
||||
.panels {
|
||||
display: grid; grid-template-columns: 1fr 1fr; gap: 12px;
|
||||
margin-top: 20px;
|
||||
@ -199,19 +229,73 @@ ${columnSkeleton()}
|
||||
if (s < 86400) return Math.floor(s / 3600) + 'h ago';
|
||||
return Math.floor(s / 86400) + 'd ago';
|
||||
}
|
||||
function msToHuman(ms) {
|
||||
if (ms < 1000) return ms + 'ms';
|
||||
if (ms < 60000) return Math.round(ms / 1000) + 's';
|
||||
if (ms < 3600000) return Math.round(ms / 60000) + 'm';
|
||||
return Math.round(ms / 3600000) + 'h';
|
||||
}
|
||||
async function getJSON(path) {
|
||||
var res = await fetch(path, { headers: { accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(path + ' -> ' + res.status);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// ── Activity timeline ──────────────────────────────────────────────────
|
||||
function renderTimelineItems(items) {
|
||||
if (!items || !items.length) return '<div class="tl-empty">no activity yet</div>';
|
||||
return items.map(function(item) {
|
||||
var meta = '';
|
||||
if (item.meta) {
|
||||
if (item.meta.tokens != null) {
|
||||
meta += '<span class="badge tl-tokens">' + esc(item.meta.tokens) + ' tok</span>';
|
||||
}
|
||||
if (item.meta.duration != null) {
|
||||
meta += '<span class="badge tl-dur">' + esc(msToHuman(item.meta.duration)) + '</span>';
|
||||
}
|
||||
}
|
||||
return '<div class="tl-row">' +
|
||||
'<span class="tl-when">' + esc(ago(item.at)) + '</span>' +
|
||||
'<span class="tl-kind tl-kind-' + esc(item.kind) + '">' + esc(item.kind) + '</span>' +
|
||||
'<span class="tl-actor">' + esc(item.actor) + '</span>' +
|
||||
'<span class="tl-summary">' + esc(item.summary) + '</span>' +
|
||||
(meta ? '<span class="tl-meta">' + meta + '</span>' : '') +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function loadTimeline(card, id) {
|
||||
var tl = card.querySelector('.timeline');
|
||||
if (!tl) return;
|
||||
tl.innerHTML = '<div class="tl-loading">loading…</div>';
|
||||
getJSON('/tasks/' + encodeURIComponent(id) + '/activity')
|
||||
.then(function(items) { tl.innerHTML = renderTimelineItems(items); })
|
||||
.catch(function() { tl.innerHTML = '<div class="tl-empty">could not load activity</div>'; });
|
||||
}
|
||||
|
||||
// Delegate click handling to the board container so it survives re-renders.
|
||||
document.getElementById('board').addEventListener('click', function(e) {
|
||||
var card = e.target.closest('.card[data-id]');
|
||||
if (!card) return;
|
||||
var id = card.dataset.id;
|
||||
var wasExpanded = card.classList.contains('expanded');
|
||||
card.classList.toggle('expanded');
|
||||
// Only fetch when opening (not on every toggle so cached content stays).
|
||||
if (!wasExpanded) loadTimeline(card, id);
|
||||
});
|
||||
|
||||
// ── Task card rendering ────────────────────────────────────────────────
|
||||
function taskCard(t) {
|
||||
var tags = '';
|
||||
if (t.role) tags += '<span class="badge role">' + esc(t.role) + '</span>';
|
||||
if (t.assignedTo) tags += '<span class="badge agent">@' + esc(t.assignedTo) + '</span>';
|
||||
return '<div class="card">' +
|
||||
return '<div class="card" data-id="' + esc(t.id) + '">' +
|
||||
'<div class="card-head">' +
|
||||
'<div class="id">' + esc(t.id) + '</div>' +
|
||||
'<div class="title">' + esc(t.title) + '</div>' +
|
||||
(tags ? '<div class="tags">' + tags + '</div>' : '') +
|
||||
'</div>' +
|
||||
'<div class="timeline"></div>' +
|
||||
'</div>';
|
||||
}
|
||||
function renderBoard(tasks) {
|
||||
@ -223,17 +307,29 @@ ${columnSkeleton()}
|
||||
});
|
||||
COLUMNS.forEach(function (k) {
|
||||
var list = byCol[k];
|
||||
var cards = document.querySelector('[data-cards="' + k + '"]');
|
||||
var cardsEl = document.querySelector('[data-cards="' + k + '"]');
|
||||
var count = document.querySelector('[data-count="' + k + '"]');
|
||||
if (count) count.textContent = String(list.length);
|
||||
if (!cards) return;
|
||||
cards.innerHTML = list.length
|
||||
if (!cardsEl) return;
|
||||
|
||||
// Preserve expanded state: collect which IDs are expanded before re-render.
|
||||
var expanded = {};
|
||||
cardsEl.querySelectorAll('.card.expanded[data-id]').forEach(function(c) {
|
||||
expanded[c.dataset.id] = true;
|
||||
});
|
||||
|
||||
cardsEl.innerHTML = list.length
|
||||
? list.map(taskCard).join('')
|
||||
: '<div class="empty">—</div>';
|
||||
|
||||
// Re-expand any cards that were open, but do NOT re-fetch; keep old content.
|
||||
Object.keys(expanded).forEach(function(id) {
|
||||
var card = cardsEl.querySelector('.card[data-id="' + id + '"]');
|
||||
if (card) card.classList.add('expanded');
|
||||
});
|
||||
});
|
||||
}
|
||||
function handoffRoute(h) {
|
||||
// Build "fromRole[@fromAgent] → toRole[@toAgent]" label
|
||||
var from = esc(h.fromRole || '?');
|
||||
var to = esc(h.toRole || '?');
|
||||
if (h.fromAgent) from += '<span class="badge agent">@' + esc(h.fromAgent) + '</span>';
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { FastifyInstance, FastifyReply } from 'fastify';
|
||||
import { createTask, listTasks, getTask, claimTask, doneTask, reviewTask, cancelTask, reopenTask } from '../core/services/taskService.js';
|
||||
import { getTaskActivity } from '../core/services/activityService.js';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../core/services/handoffService.js';
|
||||
import { createDecision, listDecisions } from '../core/services/decisionService.js';
|
||||
import { addMemory, searchMemory, listMemory } from '../core/services/memoryService.js';
|
||||
@ -115,6 +116,15 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/tasks/:id/activity', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
try {
|
||||
return getTaskActivity(cwd, id);
|
||||
} catch {
|
||||
return notFound(reply, 'Task');
|
||||
}
|
||||
});
|
||||
|
||||
app.patch('/tasks/:id', async (request, reply) => {
|
||||
const { id } = request.params as { id: string };
|
||||
const patch = request.body as Partial<Task>;
|
||||
@ -126,7 +136,11 @@ export async function registerRoutes(app: FastifyInstance, cwd: string): Promise
|
||||
task = claimTask(cwd, id, patch.assignedTo);
|
||||
break;
|
||||
case 'done':
|
||||
task = doneTask(cwd, id);
|
||||
task = doneTask(cwd, id, {
|
||||
doneBy: patch.doneBy as string | undefined,
|
||||
doneTokens: patch.doneTokens as number | undefined,
|
||||
doneDuration: patch.doneDuration as number | undefined,
|
||||
});
|
||||
break;
|
||||
case 'review':
|
||||
task = reviewTask(cwd, id);
|
||||
|
||||
148
tests/activityService.test.ts
Normal file
148
tests/activityService.test.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createTask, claimTask, doneTask } from '../src/core/services/taskService.js';
|
||||
import { createHandoff } from '../src/core/services/handoffService.js';
|
||||
import { addMemory } from '../src/core/services/memoryService.js';
|
||||
import { getTaskActivity } from '../src/core/services/activityService.js';
|
||||
|
||||
describe('activityService', () => {
|
||||
let cwd: string;
|
||||
|
||||
beforeEach(() => { cwd = mkdtempSync(join(tmpdir(), 'ah-act-')); });
|
||||
afterEach(() => { rmSync(cwd, { recursive: true, force: true }); });
|
||||
|
||||
it('returns a "created" event for every task', () => {
|
||||
const task = createTask(cwd, { title: 'Auth refactor', role: 'implementer' });
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
expect(items.length).toBeGreaterThanOrEqual(1);
|
||||
const created = items.find((i) => i.kind === 'created');
|
||||
expect(created).toBeDefined();
|
||||
expect(created!.at).toBe(task.createdAt);
|
||||
expect(created!.summary).toBe('Task created');
|
||||
});
|
||||
|
||||
it('does not include a status event for an open task', () => {
|
||||
const task = createTask(cwd, { title: 'Open task', role: 'architect' });
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
expect(items.every((i) => i.kind !== 'status')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes a status event once a task is claimed', () => {
|
||||
const task = createTask(cwd, { title: 'Claim me', role: 'implementer' });
|
||||
claimTask(cwd, task.id, 'codex');
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
const status = items.find((i) => i.kind === 'status');
|
||||
expect(status).toBeDefined();
|
||||
expect(status!.summary).toBe('Claimed by codex');
|
||||
expect(status!.meta?.assignedTo).toBe('codex');
|
||||
});
|
||||
|
||||
it('includes a status event with tokens/duration when task is done with metadata', () => {
|
||||
const task = createTask(cwd, { title: 'Finish me', role: 'implementer' });
|
||||
doneTask(cwd, task.id, { doneBy: 'claude', doneTokens: 8500, doneDuration: 120_000 });
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
const status = items.find((i) => i.kind === 'status');
|
||||
expect(status).toBeDefined();
|
||||
expect(status!.summary).toBe('Task completed');
|
||||
expect(status!.meta?.tokens).toBe(8500);
|
||||
expect(status!.meta?.duration).toBe(120_000);
|
||||
expect(status!.meta?.by).toBe('claude');
|
||||
});
|
||||
|
||||
it('includes handoff events linked by taskId', () => {
|
||||
const task = createTask(cwd, { title: 'Delegated task', role: 'architect' });
|
||||
createHandoff(cwd, {
|
||||
fromRole: 'architect',
|
||||
toRole: 'implementer',
|
||||
fromAgent: 'claude',
|
||||
toAgent: 'codex',
|
||||
taskId: task.id,
|
||||
summary: 'Design is ready',
|
||||
});
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
const handoff = items.find((i) => i.kind === 'handoff');
|
||||
expect(handoff).toBeDefined();
|
||||
expect(handoff!.summary).toBe('Design is ready');
|
||||
expect(handoff!.actor).toBe('architect → implementer');
|
||||
expect(handoff!.meta?.fromAgent).toBe('claude');
|
||||
expect(handoff!.meta?.toAgent).toBe('codex');
|
||||
});
|
||||
|
||||
it('does NOT include handoffs for other tasks', () => {
|
||||
const taskA = createTask(cwd, { title: 'A', role: 'implementer' });
|
||||
const taskB = createTask(cwd, { title: 'B', role: 'architect' });
|
||||
createHandoff(cwd, {
|
||||
fromRole: 'architect',
|
||||
toRole: 'implementer',
|
||||
taskId: taskB.id,
|
||||
summary: 'For B only',
|
||||
});
|
||||
const items = getTaskActivity(cwd, taskA.id);
|
||||
expect(items.every((i) => i.kind !== 'handoff')).toBe(true);
|
||||
});
|
||||
|
||||
it('includes memory/result events linked via relatedTasks', () => {
|
||||
const task = createTask(cwd, { title: 'Build DNS cache', role: 'implementer' });
|
||||
addMemory(cwd, {
|
||||
title: 'DNS TTL observation',
|
||||
category: 'technical',
|
||||
content: 'Default TTL is 300s',
|
||||
relatedTasks: [task.id],
|
||||
});
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
const result = items.find((i) => i.kind === 'result');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.summary).toBe('DNS TTL observation');
|
||||
expect(result!.actor).toBe('technical');
|
||||
expect(result!.meta?.category).toBe('technical');
|
||||
});
|
||||
|
||||
it('surfaces tokens and duration from memory in the result meta', () => {
|
||||
const task = createTask(cwd, { title: 'Perf tuning', role: 'implementer' });
|
||||
addMemory(cwd, {
|
||||
title: 'Profiling results',
|
||||
category: 'implementation',
|
||||
content: '50ms p99',
|
||||
relatedTasks: [task.id],
|
||||
tokens: 2000,
|
||||
duration: 45_000,
|
||||
by: 'kimi',
|
||||
});
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
const result = items.find((i) => i.kind === 'result');
|
||||
expect(result!.meta?.tokens).toBe(2000);
|
||||
expect(result!.meta?.duration).toBe(45_000);
|
||||
expect(result!.meta?.by).toBe('kimi');
|
||||
});
|
||||
|
||||
it('does NOT include memory linked to other tasks', () => {
|
||||
const taskA = createTask(cwd, { title: 'A', role: 'implementer' });
|
||||
const taskB = createTask(cwd, { title: 'B', role: 'implementer' });
|
||||
addMemory(cwd, {
|
||||
title: 'About B',
|
||||
category: 'lesson',
|
||||
content: 'lesson',
|
||||
relatedTasks: [taskB.id],
|
||||
});
|
||||
const items = getTaskActivity(cwd, taskA.id);
|
||||
expect(items.every((i) => i.kind !== 'result')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns items sorted by timestamp ascending', () => {
|
||||
const task = createTask(cwd, { title: 'Sorted timeline', role: 'implementer' });
|
||||
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: task.id, summary: 'H1' });
|
||||
addMemory(cwd, { title: 'M1', category: 'lesson', content: 'x', relatedTasks: [task.id] });
|
||||
claimTask(cwd, task.id, 'codex');
|
||||
|
||||
const items = getTaskActivity(cwd, task.id);
|
||||
for (let i = 1; i < items.length; i++) {
|
||||
expect(items[i].at >= items[i - 1].at).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('throws when the task does not exist', () => {
|
||||
expect(() => getTaskActivity(cwd, 'TSK-9999')).toThrow();
|
||||
});
|
||||
});
|
||||
@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { createHandoff, listHandoffs, getHandoff } from '../src/core/services/handoffService.js';
|
||||
import { Index } from '../src/core/index.js';
|
||||
|
||||
describe('handoffService', () => {
|
||||
let cwd: string;
|
||||
@ -46,4 +47,25 @@ describe('handoffService', () => {
|
||||
expect(items[0].fromAgent).toBe('claude');
|
||||
expect(items[0].toAgent).toBe('codex');
|
||||
});
|
||||
|
||||
it('listHandoffsByTask returns only handoffs linked to that taskId', () => {
|
||||
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0001', summary: 'For 0001' });
|
||||
createHandoff(cwd, { fromRole: 'reviewer', toRole: 'implementer', taskId: 'TSK-0002', summary: 'For 0002' });
|
||||
createHandoff(cwd, { fromRole: 'tester', toRole: 'implementer', summary: 'No task' });
|
||||
|
||||
const index = new Index(cwd);
|
||||
const results = index.listHandoffsByTask('TSK-0001');
|
||||
index.close();
|
||||
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('For 0001');
|
||||
});
|
||||
|
||||
it('listHandoffsByTask returns empty when no handoff links to the task', () => {
|
||||
createHandoff(cwd, { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0002', summary: 'Other' });
|
||||
const index = new Index(cwd);
|
||||
const results = index.listHandoffsByTask('TSK-0001');
|
||||
index.close();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -3,6 +3,7 @@ import { mkdtempSync, rmSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
import { join } from 'path';
|
||||
import { addMemory, searchMemory, listMemory } from '../src/core/services/memoryService.js';
|
||||
import { Index } from '../src/core/index.js';
|
||||
|
||||
describe('memoryService', () => {
|
||||
let cwd: string;
|
||||
@ -16,4 +17,46 @@ describe('memoryService', () => {
|
||||
expect(searchMemory(cwd, 'TTL')).toHaveLength(1);
|
||||
expect(listMemory(cwd)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('stores relatedTasks in the memory object', () => {
|
||||
const m = addMemory(cwd, {
|
||||
title: 'TTL lesson',
|
||||
category: 'lesson',
|
||||
content: 'Use short TTL',
|
||||
relatedTasks: ['TSK-0001', 'TSK-0002'],
|
||||
});
|
||||
expect(m.relatedTasks).toEqual(['TSK-0001', 'TSK-0002']);
|
||||
});
|
||||
|
||||
it('stores optional tokens, duration, by fields', () => {
|
||||
const m = addMemory(cwd, {
|
||||
title: 'Perf stats',
|
||||
category: 'implementation',
|
||||
content: 'p99=50ms',
|
||||
tokens: 1500,
|
||||
duration: 30_000,
|
||||
by: 'kimi',
|
||||
});
|
||||
expect(m.tokens).toBe(1500);
|
||||
expect(m.duration).toBe(30_000);
|
||||
expect(m.by).toBe('kimi');
|
||||
});
|
||||
|
||||
it('listMemoryByTask returns entries linked to a given task', () => {
|
||||
addMemory(cwd, { title: 'Related', category: 'technical', content: 'x', relatedTasks: ['TSK-0042'] });
|
||||
addMemory(cwd, { title: 'Unrelated', category: 'technical', content: 'y', relatedTasks: ['TSK-0099'] });
|
||||
const index = new Index(cwd);
|
||||
const results = index.listMemoryByTask('TSK-0042');
|
||||
index.close();
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].title).toBe('Related');
|
||||
});
|
||||
|
||||
it('listMemoryByTask returns empty when no memory is linked', () => {
|
||||
addMemory(cwd, { title: 'Other', category: 'technical', content: 'y', relatedTasks: ['TSK-0099'] });
|
||||
const index = new Index(cwd);
|
||||
const results = index.listMemoryByTask('TSK-0001');
|
||||
index.close();
|
||||
expect(results).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@ -121,6 +121,64 @@ describe('server routes', () => {
|
||||
expect(html).toContain('setInterval(refresh');
|
||||
});
|
||||
|
||||
// ── Activity timeline endpoint ─────────────────────────────────────────
|
||||
it('GET /tasks/:id/activity returns 404 for unknown task', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-9999/activity' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('GET /tasks/:id/activity returns a created event', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Auth', role: 'implementer' } });
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>;
|
||||
expect(items.some((i) => i.kind === 'created')).toBe(true);
|
||||
});
|
||||
|
||||
it('GET /tasks/:id/activity includes handoff events linked by taskId', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Auth', role: 'implementer' } });
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/handoffs',
|
||||
payload: { fromRole: 'architect', toRole: 'implementer', taskId: 'TSK-0001', summary: 'Design done' },
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' });
|
||||
const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>;
|
||||
const handoff = items.find((i) => i.kind === 'handoff');
|
||||
expect(handoff).toBeDefined();
|
||||
expect(handoff!.summary).toBe('Design done');
|
||||
});
|
||||
|
||||
it('GET /tasks/:id/activity includes memory result events linked by relatedTasks', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'DNS task', role: 'implementer' } });
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/memory',
|
||||
payload: { title: 'TTL insight', category: 'technical', content: 'Use 300s', relatedTasks: ['TSK-0001'] },
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' });
|
||||
const items = JSON.parse(res.payload) as Array<{ kind: string; summary: string }>;
|
||||
const result = items.find((i) => i.kind === 'result');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.summary).toBe('TTL insight');
|
||||
});
|
||||
|
||||
it('GET /tasks/:id/activity includes tokens/duration from done metadata', async () => {
|
||||
await app.inject({ method: 'POST', url: '/tasks', payload: { title: 'Work', role: 'implementer' } });
|
||||
await app.inject({
|
||||
method: 'PATCH',
|
||||
url: '/tasks/TSK-0001',
|
||||
payload: { status: 'done', doneTokens: 5000, doneDuration: 60000, doneBy: 'claude' },
|
||||
});
|
||||
const res = await app.inject({ method: 'GET', url: '/tasks/TSK-0001/activity' });
|
||||
const items = JSON.parse(res.payload) as Array<{ kind: string; meta?: Record<string, unknown> }>;
|
||||
const status = items.find((i) => i.kind === 'status');
|
||||
expect(status).toBeDefined();
|
||||
expect(status!.meta?.tokens).toBe(5000);
|
||||
expect(status!.meta?.duration).toBe(60000);
|
||||
expect(status!.meta?.by).toBe('claude');
|
||||
});
|
||||
|
||||
it('board HTML contains the who-arrow rendering logic', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/board' });
|
||||
const html = res.payload;
|
||||
@ -131,6 +189,23 @@ describe('server routes', () => {
|
||||
expect(html).toContain('who-cell');
|
||||
});
|
||||
|
||||
it('board HTML contains activity timeline expansion logic', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/board' });
|
||||
const html = res.payload;
|
||||
// Cards must be clickable and carry a data-id attribute
|
||||
expect(html).toContain('data-id');
|
||||
// The timeline div must be present inside cards
|
||||
expect(html).toContain('class="timeline"');
|
||||
// The activity fetch must call the /activity endpoint
|
||||
expect(html).toContain('/activity');
|
||||
// The expansion toggle CSS class must exist
|
||||
expect(html).toContain('expanded');
|
||||
// Token and duration badges must be defined
|
||||
expect(html).toContain('tl-tokens');
|
||||
expect(html).toContain('tl-dur');
|
||||
expect(html).toContain('msToHuman');
|
||||
});
|
||||
|
||||
it('GET /handoffs returns fromRole and toRole fields', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user