323 lines
12 KiB
TypeScript
323 lines
12 KiB
TypeScript
/**
|
|
* #17 ai_task scheduler mode — executor branch integration.
|
|
*
|
|
* The loopback fetch is stubbed, so the test asserts the executor's contract
|
|
* with the chat route: full-agent turns go to POST /api/chat with
|
|
* origin:'automation' (#13), a dedicated `schedule-<id>` session, and
|
|
* proposeHeld; legacy rows (no mode) keep the toolless /v1/chat/completions
|
|
* path; `once` disables the row after a successful run; the daily cap skips
|
|
* execution entirely.
|
|
*/
|
|
import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest';
|
|
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import os from 'node:os';
|
|
import { MindDB } from '@waggle/core';
|
|
import { buildLocalServer } from '../../src/local/index.js';
|
|
import type { FastifyInstance } from 'fastify';
|
|
import { injectWithAuth } from '../test-utils.js';
|
|
|
|
function sseDone(content: string): Response {
|
|
const body = `event: done\ndata: ${JSON.stringify({ content, toolsUsed: [] })}\n\n`;
|
|
return new Response(body, { status: 200 });
|
|
}
|
|
|
|
function sseError(error: string, content = ''): Response {
|
|
const body =
|
|
`event: error\ndata: ${JSON.stringify({ error })}\n\n` +
|
|
`event: done\ndata: ${JSON.stringify({ content, toolsUsed: [] })}\n\n`;
|
|
return new Response(body, { status: 200 });
|
|
}
|
|
|
|
describe('cron ai_task executor (#17)', () => {
|
|
let server: FastifyInstance;
|
|
let tmpDir: string;
|
|
let wsId: string;
|
|
|
|
beforeAll(async () => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-ai-task-'));
|
|
const mind = new MindDB(path.join(tmpDir, 'personal.mind'));
|
|
mind.close();
|
|
server = await buildLocalServer({ dataDir: tmpDir });
|
|
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'AI Task WS', group: 'Test' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
wsId = JSON.parse(res.body).id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await server.close();
|
|
await new Promise(r => setTimeout(r, 100));
|
|
try {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
} catch { /* EBUSY on Windows */ }
|
|
});
|
|
|
|
let fetchMock: ReturnType<typeof vi.fn>;
|
|
beforeEach(() => {
|
|
fetchMock = vi.fn(async () => sseDone('scheduled result'));
|
|
vi.stubGlobal('fetch', fetchMock);
|
|
});
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.restoreAllMocks();
|
|
});
|
|
|
|
function chatCalls() {
|
|
return fetchMock.mock.calls.filter(c => String(c[0]).includes('/api/chat'));
|
|
}
|
|
|
|
it('mode:ai_task runs a full agent turn — /api/chat with origin:automation, schedule session, proposeHeld', async () => {
|
|
const schedule = server.cronStore.create({
|
|
name: 'Morning digest',
|
|
cronExpr: '0 8 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Summarize yesterday', mode: 'ai_task' },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await server.scheduler.executeJob(schedule);
|
|
|
|
const calls = chatCalls();
|
|
expect(calls).toHaveLength(1);
|
|
const body = JSON.parse((calls[0][1] as RequestInit).body as string);
|
|
expect(body.origin).toBe('automation');
|
|
expect(body.session).toBe(`schedule-${schedule.id}`);
|
|
expect(body.proposeHeld).toBe(true);
|
|
expect(body.workspace).toBe(wsId);
|
|
expect(body.message).toContain('Summarize yesterday');
|
|
// still enabled — not a one-shot
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
});
|
|
|
|
it('once:true disables the schedule after a successful run (row kept)', async () => {
|
|
const schedule = server.cronStore.create({
|
|
name: 'One shot',
|
|
cronExpr: '0 9 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Do the thing', mode: 'ai_task', once: true },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await server.scheduler.executeJob(schedule);
|
|
|
|
const row = server.cronStore.getById(schedule.id);
|
|
expect(row).toBeDefined();
|
|
expect(row?.enabled).toBe(0);
|
|
});
|
|
|
|
it('records a failed full-agent turn and keeps a one-shot schedule enabled', async () => {
|
|
fetchMock.mockResolvedValue(sseError('INCOMPLETE_COMPLETION', 'partial answer'));
|
|
const sendSpy = vi.spyOn(server.channelManager!, 'sendTo').mockResolvedValue(true);
|
|
const schedule = server.cronStore.create({
|
|
name: 'Failed one shot',
|
|
cronExpr: '0 9 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: {
|
|
prompt: 'Do the complete thing',
|
|
mode: 'ai_task',
|
|
once: true,
|
|
deliverTo: { platform: 'telegram', chatId: 'chat-1' },
|
|
},
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await expect(server.scheduler.executeJob(schedule)).rejects.toThrow(/INCOMPLETE_COMPLETION/);
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
expect(sendSpy).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('treats stream EOF without done as incomplete and keeps the one-shot retryable', async () => {
|
|
fetchMock.mockResolvedValue(new Response(
|
|
'event: token\ndata: {"content":"partial"}\n\n',
|
|
{ status: 200 },
|
|
));
|
|
const schedule = server.cronStore.create({
|
|
name: 'Disconnected one shot',
|
|
cronExpr: '0 9 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Finish despite disconnects', mode: 'ai_task', once: true },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await expect(server.scheduler.executeJob(schedule)).rejects.toThrow(/before the done event/i);
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
});
|
|
|
|
it('legacy agent_task without mode keeps the toolless /v1/chat/completions path', async () => {
|
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
|
choices: [{ finish_reason: 'stop', message: { content: 'legacy output' } }],
|
|
}), { status: 200 }));
|
|
|
|
const schedule = server.cronStore.create({
|
|
name: 'Legacy task',
|
|
cronExpr: '0 10 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Old style' },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await server.scheduler.executeJob(schedule);
|
|
|
|
expect(chatCalls()).toHaveLength(0);
|
|
const legacy = fetchMock.mock.calls.filter(c => String(c[0]).includes('/v1/chat/completions'));
|
|
expect(legacy).toHaveLength(1);
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
});
|
|
|
|
it('records a legacy task as failed instead of delivering a truncated completion', async () => {
|
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
|
choices: [{ finish_reason: 'length', message: { content: 'partial legacy output' } }],
|
|
}), { status: 200 }));
|
|
const schedule = server.cronStore.create({
|
|
name: 'Truncated legacy task',
|
|
cronExpr: '0 11 * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Old style but complete it' },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await expect(server.scheduler.executeJob(schedule)).rejects.toMatchObject({
|
|
code: 'INCOMPLETE_COMPLETION',
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
|
});
|
|
|
|
it('does not persist a truncated Loop report or record the run as successful', async () => {
|
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
|
choices: [{ finish_reason: 'length', message: { content: 'partial loop report' } }],
|
|
}), { status: 200 }));
|
|
const schedule = server.cronStore.create({
|
|
name: 'Integrity loop',
|
|
cronExpr: '0 12 * * *',
|
|
jobType: 'loop',
|
|
jobConfig: { prompt: 'Inspect the workspace' },
|
|
workspaceId: wsId,
|
|
});
|
|
|
|
await expect(server.scheduler.executeJob(schedule)).rejects.toMatchObject({
|
|
code: 'INCOMPLETE_COMPLETION',
|
|
});
|
|
expect(fetchMock).toHaveBeenCalledOnce();
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(1);
|
|
const mind = server.agentState.getWorkspaceMindDb(wsId);
|
|
const persisted = mind?.getDatabase().prepare(
|
|
`SELECT COUNT(*) AS count FROM memory_frames WHERE content LIKE '[Loop:%'`
|
|
).get() as { count: number } | undefined;
|
|
expect(persisted?.count ?? 0).toBe(0);
|
|
});
|
|
|
|
it('automatic tick refuses a workspace Loop after the role is downgraded to viewer', async () => {
|
|
fetchMock.mockResolvedValue(new Response(JSON.stringify({
|
|
choices: [{ finish_reason: 'stop', message: { content: 'NOTHING_TO_DO' } }],
|
|
}), { status: 200 }));
|
|
const schedule = server.cronStore.create({
|
|
name: 'Role downgrade guard',
|
|
cronExpr: '*/5 * * * *',
|
|
jobType: 'loop',
|
|
jobConfig: { prompt: 'Inspect the workspace' },
|
|
workspaceId: wsId,
|
|
});
|
|
server.multiMind.personal.getDatabase().prepare(
|
|
"UPDATE cron_schedules SET next_run_at = datetime('now', '-1 minute') WHERE id = ?",
|
|
).run(schedule.id);
|
|
const workspaceMind = server.agentState.getWorkspaceMindDb(wsId)!;
|
|
const countFrames = () => (workspaceMind.getDatabase().prepare(
|
|
'SELECT COUNT(*) AS count FROM memory_frames',
|
|
).get() as { count: number }).count;
|
|
const framesBefore = countFrames();
|
|
server.workspaceManager.update(wsId, { teamId: 'team-1', teamRole: 'viewer' });
|
|
const leaseSpy = vi.spyOn(server.cronStore, 'acquireRunLease');
|
|
const historyBefore = server.cronStore.getExecutionHistory(schedule.id).length;
|
|
|
|
try {
|
|
const executed = await server.scheduler.tick();
|
|
|
|
expect(executed).toBe(0);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(countFrames()).toBe(framesBefore);
|
|
expect(leaseSpy).not.toHaveBeenCalled();
|
|
expect(server.cronStore.getExecutionHistory(schedule.id)).toHaveLength(historyBefore);
|
|
expect(server.cronStore.getById(schedule.id)?.last_run_at).toBeNull();
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(0);
|
|
|
|
server.workspaceManager.update(wsId, { teamId: 'team-1', teamRole: 'member' });
|
|
expect(await server.scheduler.tick()).toBe(1);
|
|
expect(fetchMock).toHaveBeenCalled();
|
|
expect(leaseSpy).toHaveBeenCalledOnce();
|
|
expect(server.cronStore.getById(schedule.id)?.last_run_at).not.toBeNull();
|
|
} finally {
|
|
server.cronStore.update(schedule.id, { enabled: false });
|
|
server.workspaceManager.update(wsId, { teamId: undefined, teamRole: undefined });
|
|
}
|
|
});
|
|
|
|
it('blocks a global agent task atomically when any target is a viewer', async () => {
|
|
const res = await injectWithAuth(server, {
|
|
method: 'POST',
|
|
url: '/api/workspaces',
|
|
payload: { name: 'Viewer target', group: 'work' },
|
|
});
|
|
expect(res.statusCode).toBe(201);
|
|
const viewerWorkspaceId = JSON.parse(res.body).id as string;
|
|
const schedule = server.cronStore.create({
|
|
name: 'Global role guard',
|
|
cronExpr: '*/5 * * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Inspect every workspace', mode: 'ai_task' },
|
|
workspaceId: '*',
|
|
});
|
|
server.multiMind.personal.getDatabase().prepare(
|
|
"UPDATE cron_schedules SET next_run_at = datetime('now', '-1 minute') WHERE id = ?",
|
|
).run(schedule.id);
|
|
server.workspaceManager.update(viewerWorkspaceId, {
|
|
teamId: 'team-1',
|
|
teamRole: 'viewer',
|
|
});
|
|
const leaseSpy = vi.spyOn(server.cronStore, 'acquireRunLease');
|
|
|
|
try {
|
|
expect(await server.scheduler.tick()).toBe(0);
|
|
expect(fetchMock).not.toHaveBeenCalled();
|
|
expect(leaseSpy).not.toHaveBeenCalled();
|
|
expect(server.cronStore.getExecutionHistory(schedule.id)).toEqual([]);
|
|
expect(server.cronStore.getById(schedule.id)?.last_run_at).toBeNull();
|
|
expect(server.cronStore.getById(schedule.id)?.enabled).toBe(1);
|
|
expect(server.scheduler.getFailCount(schedule.id)).toBe(0);
|
|
expect(server.cronStore.getDue().some((due) => due.id === schedule.id)).toBe(true);
|
|
} finally {
|
|
server.cronStore.update(schedule.id, { enabled: false });
|
|
server.workspaceManager.update(viewerWorkspaceId, {
|
|
teamId: undefined,
|
|
teamRole: undefined,
|
|
});
|
|
}
|
|
});
|
|
|
|
it('daily cap (24) skips execution before any agent turn', async () => {
|
|
const schedule = server.cronStore.create({
|
|
name: 'Capped',
|
|
cronExpr: '*/5 * * * *',
|
|
jobType: 'agent_task',
|
|
jobConfig: { prompt: 'Spin', mode: 'ai_task' },
|
|
workspaceId: wsId,
|
|
});
|
|
for (let i = 0; i < 24; i++) {
|
|
server.cronStore.recordExecution(schedule.id, schedule.name, { success: true });
|
|
}
|
|
|
|
await server.scheduler.executeJob(schedule);
|
|
|
|
expect(chatCalls()).toHaveLength(0);
|
|
});
|
|
});
|