moving
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled

This commit is contained in:
Oleg Maslov
2026-09-02 10:10:29 +02:00
commit 0c3e2ead3b
3841 changed files with 970576 additions and 0 deletions

View File

@@ -0,0 +1,424 @@
/**
* Automations alias REST API Route Tests (UX-Refactor Phase 3, S11/S20 / B4).
*
* Covers the 7 routes in routes/automations.ts, asserting alias delegation onto
* the REAL cron handlers (cronRoutes + the history route from the REAL
* notificationRoutes are registered on the test server, backed by a real
* CronStore + a real LocalScheduler with a recording fake executor):
* GET /api/automations alias GET /api/cron, reshaped
* POST /api/automations alias POST /api/cron (C24/C25 in job_config)
* PATCH /api/automations/:id alias PATCH /api/cron/:id (jobConfig merge,
* stored trigger type preserved)
* POST /api/automations/:id/run alias /api/cron/:id/trigger (auto-enable
* kept for schedules; manual rows re-disable)
* POST /api/automations/:id/pause NET-NEW thin (enabled=0 + failure reset)
* GET /api/automations/:id/logs alias /api/cron/:id/history, camelCased
* POST /api/automations/test C26 VALIDATION-ONLY preview (no execution)
*
* The onJobComplete callback IS the prod wiring: makeRecordExecutionCallback
* (cron.ts) — the same closure local/index.ts installs — so the logs alias is
* tested against the real persistence path, not a hand-copied mirror.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import os from 'node:os';
import Fastify from 'fastify';
import { EventEmitter } from 'node:events';
import { MindDB, CronStore, type CronSchedule } from '@waggle/core';
import { LocalScheduler, makeRecordExecutionCallback } from '../../src/local/cron.js';
import { cronRoutes } from '../../src/local/routes/cron.js';
import { notificationRoutes } from '../../src/local/routes/notifications.js';
import { automationRoutes } from '../../src/local/routes/automations.js';
describe('Automations alias routes (Phase 3)', () => {
let db: MindDB;
let cronStore: CronStore;
let scheduler: LocalScheduler;
let executed: CronSchedule[];
let failNext: boolean;
let server: ReturnType<typeof Fastify>;
beforeEach(async () => {
db = new MindDB(':memory:');
cronStore = new CronStore(db);
executed = [];
failNext = false;
scheduler = new LocalScheduler(
cronStore,
async (schedule) => {
executed.push(schedule);
if (failNext) throw new Error('boom: executor failed');
},
// F2: the REAL prod history-persistence closure (also installed by
// local/index.ts) — not a hand-copied mirror.
makeRecordExecutionCallback(cronStore),
);
server = Fastify({ logger: false });
server.decorate('localConfig', { dataDir: '' });
server.decorate('cronStore', cronStore);
server.decorate('scheduler', scheduler);
server.decorate('eventBus', new EventEmitter());
// Minimal workspace index for the /test workspaceId validation: 'ws-test'
// is the only known workspace.
server.decorate('workspaceManager', {
get: (id: string) => (id === 'ws-test' ? { id, name: 'Test WS' } : undefined),
});
await server.register(cronRoutes);
// F4: the real /api/cron/:id/history route (notifications.ts) — its only
// decoration needs are cronStore + eventBus, both provided above.
await server.register(notificationRoutes);
await server.register(automationRoutes);
});
afterEach(async () => {
await server.close();
db.close();
});
async function createAutomation(body: Record<string, unknown>) {
const res = await server.inject({ method: 'POST', url: '/api/automations', payload: body });
expect(res.statusCode).toBe(201);
return res.json().automation;
}
it('GET /api/automations/engine returns liveness + sovereignty identity', async () => {
const res = await server.inject({ method: 'GET', url: '/api/automations/engine' });
expect(res.statusCode).toBe(200);
const { engine } = res.json();
expect(engine.host).toBe(os.hostname());
expect(engine.sovereign).toBe(true);
expect(engine.device).toBe('this device');
expect(engine.consecutiveFailureCap).toBe(5);
expect(engine.running).toBe(false); // scheduler decorated but not started
});
it('engine reports running once the scheduler starts', async () => {
scheduler.start(60_000);
const res = await server.inject({ method: 'GET', url: '/api/automations/engine' });
expect(res.json().engine.running).toBe(true);
scheduler.stop();
});
const SCHEDULE_BODY = {
name: 'Nightly digest',
trigger: { type: 'schedule', cron: '0 2 * * *' },
condition: 'only if new memories exist',
actions: ['workspace_health'],
workspaceId: 'ws-test',
};
it('POST creates a cron row carrying trigger/condition/actions in job_config (C25 advisory)', async () => {
const automation = await createAutomation(SCHEDULE_BODY);
expect(automation.triggerType).toBe('schedule');
expect(automation.schedule).toBe('0 2 * * *');
expect(automation.condition).toBe('only if new memories exist');
expect(automation.actions).toEqual(['workspace_health']);
expect(automation.status).toBe('active');
// The substrate row is a plain cron schedule — alias, not a new store (B4).
const row = cronStore.getById(parseInt(automation.id, 10))!;
expect(row.job_type).toBe('workspace_health'); // recognized action → job type
const jc = JSON.parse(row.job_config);
expect(jc.trigger).toEqual({ type: 'schedule' });
expect(jc.condition).toBe('only if new memories exist');
expect(jc.actions).toEqual(['workspace_health']);
});
it('C24: rejects event triggers (schedule-only v1)', async () => {
const res = await server.inject({
method: 'POST', url: '/api/automations',
payload: { ...SCHEDULE_BODY, trigger: { type: 'event' } },
});
expect(res.statusCode).toBe(400);
expect(res.json().error).toMatch(/schedule-only/i);
});
it('POST validation: name required; schedule trigger requires a cron expression', async () => {
expect((await server.inject({ method: 'POST', url: '/api/automations', payload: { trigger: { type: 'schedule', cron: '* * * * *' } } })).statusCode).toBe(400);
expect((await server.inject({ method: 'POST', url: '/api/automations', payload: { name: 'X', trigger: { type: 'schedule' } } })).statusCode).toBe(400);
// Invalid cron expression → the existing cron handler's 400 passes through.
expect((await server.inject({ method: 'POST', url: '/api/automations', payload: { ...SCHEDULE_BODY, trigger: { type: 'schedule', cron: 'not-cron' } } })).statusCode).toBe(400);
});
it('manual trigger maps to a DISABLED schedule that only fires via /run', async () => {
const automation = await createAutomation({ name: 'On demand', trigger: { type: 'manual' }, actions: ['workspace_health'] });
expect(automation.triggerType).toBe('manual');
expect(automation.status).toBe('paused');
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(0);
});
it('agent_task create accepts the "*" fan-out workspace sentinel (Builder "All workspaces")', async () => {
// The store REQUIRES a workspaceId for agent_task; '*' is the executor's
// fan-out-to-all sentinel the Builder sends (2026-06-10 live-smoke fix).
const automation = await createAutomation({
name: 'Smoke parity', trigger: { type: 'manual' },
jobType: 'agent_task', jobConfig: { prompt: 'do the thing' }, workspaceId: '*',
});
expect(cronStore.getById(parseInt(automation.id, 10))!.workspace_id).toBe('*');
});
it('GET reshapes existing cron rows onto the Automation contract', async () => {
await createAutomation(SCHEDULE_BODY);
// A pre-existing plain cron job (no automation vocabulary) still projects.
cronStore.create({ name: 'legacy', cronExpr: '0 3 * * *', jobType: 'memory_consolidation' });
const res = await server.inject({ method: 'GET', url: '/api/automations' });
expect(res.statusCode).toBe(200);
const { automations, count } = res.json();
expect(count).toBe(2);
const legacy = automations.find((a: { name: string }) => a.name === 'legacy');
expect(legacy.triggerType).toBe('schedule');
expect(legacy.actions).toEqual(['memory_consolidation']); // falls back to jobType
});
it('PATCH merges jobConfig-borne fields over the stored blob', async () => {
const automation = await createAutomation(SCHEDULE_BODY);
const res = await server.inject({
method: 'PATCH', url: `/api/automations/${automation.id}`,
payload: { name: 'Renamed', condition: 'new condition', enabled: false },
});
expect(res.statusCode).toBe(200);
const updated = res.json().automation;
expect(updated.name).toBe('Renamed');
expect(updated.condition).toBe('new condition');
expect(updated.status).toBe('paused');
// actions persisted earlier survive the merge.
expect(updated.actions).toEqual(['workspace_health']);
expect((await server.inject({ method: 'PATCH', url: '/api/automations/9999', payload: { name: 'X' } })).statusCode).toBe(404);
});
it('PATCH without trigger keeps a manual automation manual + disabled (no clobber)', async () => {
const automation = await createAutomation({ name: 'Manual job', trigger: { type: 'manual' }, actions: ['workspace_health'] });
// An unrelated PATCH (condition only) used to flip trigger → 'schedule'.
const res = await server.inject({
method: 'PATCH', url: `/api/automations/${automation.id}`,
payload: { condition: 'only weekdays' },
});
expect(res.statusCode).toBe(200);
const updated = res.json().automation;
expect(updated.triggerType).toBe('manual');
expect(updated.status).toBe('paused');
expect(updated.condition).toBe('only weekdays');
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(0);
});
it('PATCH trigger to manual on an enabled schedule disables it (mirrors the POST rule)', async () => {
const automation = await createAutomation(SCHEDULE_BODY);
expect(automation.status).toBe('active');
const res = await server.inject({
method: 'PATCH', url: `/api/automations/${automation.id}`,
payload: { trigger: { type: 'manual' } },
});
expect(res.statusCode).toBe(200);
expect(res.json().automation.triggerType).toBe('manual');
expect(res.json().automation.status).toBe('paused');
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(0);
});
it('run aliases the trigger (executes + auto-enables a disabled SCHEDULE job, by design)', async () => {
const automation = await createAutomation({ ...SCHEDULE_BODY, enabled: false });
const res = await server.inject({ method: 'POST', url: `/api/automations/${automation.id}/run` });
expect(res.statusCode).toBe(200);
expect(res.json().triggered).toBe(true);
expect(res.json().runId).toBe(automation.id);
expect(res.json().autoEnabled).toBe(true);
expect(executed).toHaveLength(1);
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(1);
});
it('run on a MANUAL automation executes but re-disables it (placeholder cron never goes live)', async () => {
const automation = await createAutomation({ name: 'On demand', trigger: { type: 'manual' }, actions: ['workspace_health'] });
const res = await server.inject({ method: 'POST', url: `/api/automations/${automation.id}/run` });
expect(res.statusCode).toBe(200);
expect(res.json().triggered).toBe(true);
expect(res.json().autoEnabled).toBe(false); // never reported as enabled
expect(executed).toHaveLength(1); // the run DID execute
// The row stays disabled — its '0 0 1 1 *' placeholder must not become a
// live yearly Jan-1 schedule after "Run now" (the M-43 auto-enable is
// reverted for manual rows).
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(0);
const list = (await server.inject({ method: 'GET', url: '/api/automations' })).json();
const row = list.automations.find((a: { id: string }) => a.id === automation.id);
expect(row.status).toBe('paused');
});
it('pause disables the schedule without the enabled-flag trick', async () => {
const automation = await createAutomation(SCHEDULE_BODY);
const res = await server.inject({ method: 'POST', url: `/api/automations/${automation.id}/pause` });
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true, id: parseInt(automation.id, 10), enabled: false });
expect(cronStore.getById(parseInt(automation.id, 10))!.enabled).toBe(0);
expect((await server.inject({ method: 'POST', url: '/api/automations/9999/pause' })).statusCode).toBe(404);
expect((await server.inject({ method: 'POST', url: '/api/automations/abc/pause' })).statusCode).toBe(400);
});
it('logs alias the execution history, camelCased (C27 substrate)', async () => {
const automation = await createAutomation(SCHEDULE_BODY);
await server.inject({ method: 'POST', url: `/api/automations/${automation.id}/run` });
failNext = true;
await server.inject({ method: 'POST', url: `/api/automations/${automation.id}/run` });
failNext = false;
const res = await server.inject({ method: 'GET', url: `/api/automations/${automation.id}/logs` });
expect(res.statusCode).toBe(200);
const { logs, count } = res.json();
expect(count).toBe(2);
const ok = logs.filter((l: { success: boolean }) => l.success);
const failed = logs.filter((l: { success: boolean }) => !l.success);
expect(ok).toHaveLength(1);
expect(failed).toHaveLength(1);
expect(failed[0].error).toMatch(/boom/);
expect(typeof logs[0].executedAt).toBe('string');
});
it('C26: test is a VALIDATION-ONLY preview — ok draft, zero execution, zero persistence', async () => {
// A pre-existing disabled job proves the enable flag is untouched too.
const disabled = cronStore.create({ name: 'sleepy', cronExpr: '0 4 * * *', jobType: 'workspace_health', enabled: false });
const res = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: {
name: 'Draft automation',
trigger: { type: 'schedule', cron: '0 5 * * *' },
actions: ['workspace_health'],
condition: 'advisory only',
workspaceId: 'ws-test',
},
});
expect(res.statusCode).toBe(200);
const { previewResult } = res.json();
expect(previewResult).toMatchObject({
ok: true,
jobType: 'workspace_health',
triggerType: 'schedule',
issues: [],
executed: false,
condition: 'advisory only', // echoed as advisory (C25), never evaluated
});
expect(previewResult.wouldRun).toContain('workspace_health');
expect(previewResult.wouldRun).toContain('0 5 * * *');
// NOTHING ran or persisted: executor untouched, no schedule row, no
// history row, no notification row, no enable-flag mutation.
expect(executed).toHaveLength(0);
expect(cronStore.list()).toHaveLength(1); // only the pre-existing disabled job
expect(cronStore.getExecutionHistory(disabled.id)).toHaveLength(0);
expect(cronStore.getNotifications()).toHaveLength(0);
expect(cronStore.getById(disabled.id)!.enabled).toBe(0);
});
it('C26: bad cron / missing agent_task prompt / unknown workspace surface as issues (ok:false)', async () => {
const badCron = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: 'not-cron' }, actions: ['workspace_health'] },
});
expect(badCron.statusCode).toBe(200);
expect(badCron.json().previewResult.ok).toBe(false);
expect(badCron.json().previewResult.issues.join(' ')).toMatch(/cron/i);
// agent_task with no jobConfig.prompt — the executor would skip the run.
const noPrompt = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 5 * * *' }, jobType: 'agent_task' },
});
expect(noPrompt.json().previewResult.ok).toBe(false);
expect(noPrompt.json().previewResult.issues.join(' ')).toMatch(/prompt/);
// agent_task with a prompt but NO workspaceId — CronStore.create would
// throw ('agent_task jobs require a workspace ID'), so the preview must
// flag it instead of saying "valid" (2026-06-10 live-smoke regression).
const noWs = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 5 * * *' }, jobType: 'agent_task', jobConfig: { prompt: 'Summarize the day' } },
});
expect(noWs.json().previewResult.ok).toBe(false);
expect(noWs.json().previewResult.issues.join(' ')).toMatch(/workspaceId/);
// ...and WITH a prompt + the '*' fan-out sentinel the draft previews clean.
const withPrompt = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 5 * * *' }, jobType: 'agent_task', jobConfig: { prompt: 'Summarize the day' }, workspaceId: '*' },
});
expect(withPrompt.json().previewResult.ok).toBe(true);
const badWs = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 5 * * *' }, actions: ['workspace_health'], workspaceId: 'ws-nope' },
});
expect(badWs.json().previewResult.ok).toBe(false);
expect(badWs.json().previewResult.issues.join(' ')).toMatch(/workspaceId/);
// An empty draft resolves to agent_task with no prompt → issues, not a 400.
const empty = await server.inject({ method: 'POST', url: '/api/automations/test', payload: {} });
expect(empty.statusCode).toBe(200);
expect(empty.json().previewResult.ok).toBe(false);
// A 'loop' draft with no jobConfig.prompt — the loop executor would skip it,
// so the preview must flag it (parity with the agent_task prompt check).
const loopNoPrompt = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 8 * * *' }, jobType: 'loop' },
});
expect(loopNoPrompt.json().previewResult.ok).toBe(false);
expect(loopNoPrompt.json().previewResult.issues.join(' ')).toMatch(/prompt/);
// ...and a loop WITH a prompt previews clean (no workspaceId required).
const loopOk = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'schedule', cron: '0 8 * * *' }, jobType: 'loop', jobConfig: { prompt: 'Brief me' } },
});
expect(loopOk.json().previewResult.ok).toBe(true);
// Across ALL previews: nothing executed, nothing persisted.
expect(executed).toHaveLength(0);
expect(cronStore.list()).toHaveLength(0);
});
it('C26 (edit mode): a draft carrying the stored row id is judged against the REAL job, not the agent_task fallback', async () => {
// A stored agent_task WITH a prompt: the bare edit-mode draft (no jobType/
// jobConfig) used to phantom-flag "agent_task requires jobConfig.prompt".
const agentTask = await createAutomation({
name: 'Daily summary',
trigger: { type: 'schedule', cron: '0 6 * * *' },
jobType: 'agent_task',
jobConfig: { prompt: 'Summarize the day' },
workspaceId: 'ws-test', // agent_task creates require a workspace
});
const res = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { id: agentTask.id, name: 'Daily summary', trigger: { type: 'schedule', cron: '0 7 * * *' } },
});
expect(res.statusCode).toBe(200);
expect(res.json().previewResult.ok).toBe(true);
expect(res.json().previewResult.jobType).toBe('agent_task');
// Stored non-agent_task rows resolve their real jobType (correct wouldRun).
const consolidation = await createAutomation({
name: 'Nightly consolidation',
trigger: { type: 'schedule', cron: '0 3 * * *' },
jobType: 'memory_consolidation',
});
const res2 = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { id: consolidation.id, name: 'Nightly consolidation', trigger: { type: 'schedule', cron: '0 3 * * *' } },
});
expect(res2.json().previewResult.ok).toBe(true);
expect(res2.json().previewResult.jobType).toBe('memory_consolidation');
// An unknown id degrades to the plain draft preview (agent_task fallback).
const res3 = await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { id: '99999', name: 'ghost', trigger: { type: 'manual' } },
});
expect(res3.statusCode).toBe(200);
expect(res3.json().previewResult.ok).toBe(false);
// Still validation-only: nothing executed.
expect(executed).toHaveLength(0);
});
it('C24: test rejects event triggers (contract-level 400)', async () => {
expect((await server.inject({
method: 'POST', url: '/api/automations/test',
payload: { trigger: { type: 'event' }, actions: ['workspace_health'] },
})).statusCode).toBe(400);
});
});