Files
waggle-os/packages/server/tests/local/harvest-runs.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

258 lines
9.6 KiB
TypeScript

/**
* Harvest Runs Routes Tests (M-08 — resumable harvest)
*
* Covers:
* POST /api/harvest/commit — now creates a harvest_runs row + caches the
* input payload + completes on success (deleting the cache).
* GET /api/harvest/runs — lists recent runs.
* GET /api/harvest/runs/latest-interrupted — single latest resume candidate.
* POST /api/harvest/runs/:id/abandon — marks abandoned + deletes cache.
* POST /api/harvest/commit with resumeFromRun — replays cached input.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import fs from 'node:fs';
import path from 'node:path';
import os from 'node:os';
import { MindDB, SessionStore, FrameStore, HarvestRunStore } from '@waggle/core';
import { buildLocalServer } from '../../src/local/index.js';
import type { FastifyInstance } from 'fastify';
import { injectWithAuth } from '../test-utils.js';
const CHATGPT_EXPORT = [
{
title: 'A small chat',
create_time: 1700000000,
mapping: {
node1: {
message: {
author: { role: 'user' },
content: { parts: ['My preferred editor is VSCode with vim bindings.'] },
create_time: 1700000001,
},
},
node2: {
message: {
author: { role: 'assistant' },
content: { parts: ['Got it — VSCode with vim is a solid setup.'] },
create_time: 1700000002,
},
},
},
},
];
describe('Harvest Run Routes (M-08)', () => {
let server: FastifyInstance;
let tmpDir: string;
beforeAll(async () => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-harvest-runs-test-'));
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const sessions = new SessionStore(mind);
const frames = new FrameStore(mind);
const s1 = sessions.create('run-test-seed');
frames.createIFrame(s1.gop_id, 'seed frame', 'normal');
mind.close();
server = await buildLocalServer({ dataDir: tmpDir });
});
afterAll(async () => {
await server.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('POST /api/harvest/commit + run lifecycle', () => {
it('creates a completed run + deletes the cache on success', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/commit',
payload: { data: CHATGPT_EXPORT, source: 'chatgpt' },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(typeof body.runId).toBe('number');
expect(body.saved).toBeGreaterThan(0);
// Confirm the run was marked completed + cache file was removed.
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
const run = runs.getById(body.runId);
expect(run?.status).toBe('completed');
expect(run?.inputCachePath).toBeTruthy();
if (run?.inputCachePath) {
expect(fs.existsSync(run.inputCachePath)).toBe(false);
}
mind.close();
});
});
describe('GET /api/harvest/runs', () => {
it('returns recent runs newest-first', async () => {
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(Array.isArray(body.runs)).toBe(true);
expect(body.runs.length).toBeGreaterThan(0);
});
});
describe('GET /api/harvest/runs/latest-interrupted', () => {
it('returns null when no runs are interrupted (all terminal)', async () => {
// After the previous test the only run is completed.
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.run).toBeNull();
});
it('surfaces a hand-seeded interrupted run with a surviving cache', async () => {
// Craft a 'running' row directly in the DB to simulate client disconnect
// mid-flight. The route requires the cache file to actually exist, so
// we also write one.
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
const cacheDir = path.join(tmpDir, 'harvest-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'seeded.json');
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
runs.start('chatgpt', 10, cacheFile);
mind.close();
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.run).not.toBeNull();
expect(body.run.status).toBe('running');
expect(body.run.source).toBe('chatgpt');
});
it('returns null when the row exists but the cache file is gone', async () => {
// Delete the seeded cache and refetch.
const cacheFile = path.join(tmpDir, 'harvest-cache', 'seeded.json');
if (fs.existsSync(cacheFile)) fs.unlinkSync(cacheFile);
const res = await injectWithAuth(server, { method: 'GET', url: '/api/harvest/runs/latest-interrupted' });
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.run).toBeNull();
});
});
describe('POST /api/harvest/runs/:id/abandon', () => {
it('marks a running run abandoned + deletes its cache', async () => {
// Seed another interrupted run with a real cache file.
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
const cacheDir = path.join(tmpDir, 'harvest-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'abandon-test.json');
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
const run = runs.start('chatgpt', 10, cacheFile);
mind.close();
const res = await injectWithAuth(server, {
method: 'POST',
url: `/api/harvest/runs/${run.id}/abandon`,
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.run.status).toBe('abandoned');
expect(fs.existsSync(cacheFile)).toBe(false);
});
it('returns 404 for a non-existent run id', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/runs/999999/abandon',
});
expect(res.statusCode).toBe(404);
});
});
describe('POST /api/harvest/commit { resumeFromRun }', () => {
it('replays the cached input (dedup produces a no-op count)', async () => {
// Seed an interrupted run backed by CHATGPT_EXPORT as the cache.
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
const cacheDir = path.join(tmpDir, 'harvest-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, 'resume-test.json');
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
const seed = runs.start('chatgpt', 1, cacheFile);
mind.close();
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/commit',
payload: { resumeFromRun: seed.id },
});
expect(res.statusCode).toBe(200);
const body = res.json();
expect(body.runId).toBe(seed.id);
// FrameStore dedup returns the existing frame from the original commit,
// so the saved counter is bumped but no new rows are created.
expect(body.saved).toBeGreaterThan(0);
// Run is now completed + cache deleted.
const mind2 = new MindDB(personalPath);
const runs2 = new HarvestRunStore(mind2);
expect(runs2.getById(seed.id)?.status).toBe('completed');
expect(fs.existsSync(cacheFile)).toBe(false);
mind2.close();
});
it('returns 404 for an unknown resumeFromRun id', async () => {
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/commit',
payload: { resumeFromRun: 999999 },
});
expect(res.statusCode).toBe(404);
});
it('returns 410 when the cache file has been deleted', async () => {
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
// Cache path that points nowhere.
const stale = runs.start('chatgpt', 5, path.join(tmpDir, 'harvest-cache', 'nope.json'));
mind.close();
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/commit',
payload: { resumeFromRun: stale.id },
});
expect(res.statusCode).toBe(410);
});
it('returns 409 when the run is already completed', async () => {
const personalPath = path.join(tmpDir, 'personal.mind');
const mind = new MindDB(personalPath);
const runs = new HarvestRunStore(mind);
const cacheDir = path.join(tmpDir, 'harvest-cache');
fs.mkdirSync(cacheDir, { recursive: true });
const cacheFile = path.join(cacheDir, '409-test.json');
fs.writeFileSync(cacheFile, JSON.stringify(CHATGPT_EXPORT));
const run = runs.start('chatgpt', 1, cacheFile);
runs.complete(run.id, 1);
mind.close();
const res = await injectWithAuth(server, {
method: 'POST',
url: '/api/harvest/commit',
payload: { resumeFromRun: run.id },
});
expect(res.statusCode).toBe(409);
});
});
});