mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
* feat: implement media tasks persistence
* fix(daemon): satisfy exactOptionalPropertyTypes in media-tasks-routes test
`process.env.OD_DATA_DIR` is `string | undefined`, but `openDatabase`'s
options accept `{ dataDir?: string }`. Under the daemon tsconfig's
exactOptionalPropertyTypes the two are not assignable. Spread the key
in only when defined.
* fix(daemon): restore mcp-config / mcp-oauth / mcp-tokens imports in server.ts
The earlier 'Merge branch main into main' resolved the import-block
conflict by keeping only the new media-tasks import and dropping the
three pre-existing import blocks. server.ts still uses 13+ symbols
from those modules (PendingAuthCache, MCP_TEMPLATES, beginAuth,
readMcpConfig, getToken, etc.), so the daemon crashed at startup with
'ReferenceError: PendingAuthCache is not defined' the moment Playwright
booted it. Restore the three import blocks verbatim from main.
---------
Co-authored-by: lefarcen <935902669@qq.com>
196 lines
5.5 KiB
TypeScript
196 lines
5.5 KiB
TypeScript
import { beforeEach, describe, expect, it } from 'vitest';
|
|
import Database from 'better-sqlite3';
|
|
import {
|
|
deleteMediaTask,
|
|
getMediaTask,
|
|
insertMediaTask,
|
|
listMediaTasksByProject,
|
|
listRecentMediaTasks,
|
|
migrateMediaTasks,
|
|
reconcileMediaTasksOnBoot,
|
|
updateMediaTask,
|
|
} from '../src/media-tasks.js';
|
|
|
|
function freshDb(): Database.Database {
|
|
const db = new Database(':memory:');
|
|
db.pragma('journal_mode = WAL');
|
|
db.pragma('foreign_keys = ON');
|
|
db.exec(`
|
|
CREATE TABLE projects (
|
|
id TEXT PRIMARY KEY,
|
|
name TEXT NOT NULL,
|
|
created_at INTEGER NOT NULL,
|
|
updated_at INTEGER NOT NULL
|
|
);
|
|
INSERT INTO projects (id, name, created_at, updated_at) VALUES ('p1', 'p1', 0, 0);
|
|
INSERT INTO projects (id, name, created_at, updated_at) VALUES ('p2', 'p2', 0, 0);
|
|
`);
|
|
migrateMediaTasks(db);
|
|
return db;
|
|
}
|
|
|
|
describe('media task persistence', () => {
|
|
let db: Database.Database;
|
|
|
|
beforeEach(() => {
|
|
db = freshDb();
|
|
});
|
|
|
|
it('migrates idempotently', () => {
|
|
expect(() => {
|
|
migrateMediaTasks(db);
|
|
migrateMediaTasks(db);
|
|
}).not.toThrow();
|
|
const row = db
|
|
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='media_tasks'`)
|
|
.get() as { name: string } | undefined;
|
|
expect(row?.name).toBe('media_tasks');
|
|
});
|
|
|
|
it('round-trips task metadata and updates progress, file, and error fields', () => {
|
|
insertMediaTask(db, {
|
|
id: 'task_1',
|
|
projectId: 'p1',
|
|
status: 'queued',
|
|
surface: 'video',
|
|
model: 'seedance-2',
|
|
startedAt: 100,
|
|
createdAt: 100,
|
|
updatedAt: 100,
|
|
});
|
|
|
|
updateMediaTask(db, 'task_1', {
|
|
status: 'running',
|
|
progress: ['accepted', 'polling'],
|
|
updatedAt: 150,
|
|
});
|
|
updateMediaTask(db, 'task_1', {
|
|
status: 'done',
|
|
file: {
|
|
name: 'clip.mp4',
|
|
size: 1234,
|
|
mime: 'video/mp4',
|
|
},
|
|
endedAt: 200,
|
|
updatedAt: 200,
|
|
});
|
|
|
|
const row = getMediaTask(db, 'task_1');
|
|
expect(row).toMatchObject({
|
|
id: 'task_1',
|
|
projectId: 'p1',
|
|
status: 'done',
|
|
surface: 'video',
|
|
model: 'seedance-2',
|
|
progress: ['accepted', 'polling'],
|
|
file: {
|
|
name: 'clip.mp4',
|
|
size: 1234,
|
|
mime: 'video/mp4',
|
|
},
|
|
startedAt: 100,
|
|
endedAt: 200,
|
|
});
|
|
});
|
|
|
|
it('lists active tasks by default and includes terminal tasks on request', () => {
|
|
insertMediaTask(db, { id: 'running', projectId: 'p1', status: 'running', startedAt: 100 });
|
|
insertMediaTask(db, { id: 'done', projectId: 'p1', status: 'done', startedAt: 200, endedAt: 250 });
|
|
insertMediaTask(db, { id: 'other-project', projectId: 'p2', status: 'running', startedAt: 300 });
|
|
|
|
expect(listMediaTasksByProject(db, 'p1').map((task) => task.id)).toEqual(['running']);
|
|
expect(
|
|
listMediaTasksByProject(db, 'p1', { includeTerminal: true }).map((task) => task.id),
|
|
).toEqual(['done', 'running']);
|
|
});
|
|
|
|
it('marks queued and running tasks interrupted on daemon boot', () => {
|
|
insertMediaTask(db, {
|
|
id: 'queued',
|
|
projectId: 'p1',
|
|
status: 'queued',
|
|
progress: ['queued provider request'],
|
|
startedAt: 100,
|
|
updatedAt: 100,
|
|
});
|
|
insertMediaTask(db, {
|
|
id: 'running',
|
|
projectId: 'p1',
|
|
status: 'running',
|
|
startedAt: 200,
|
|
updatedAt: 200,
|
|
});
|
|
insertMediaTask(db, {
|
|
id: 'done',
|
|
projectId: 'p1',
|
|
status: 'done',
|
|
startedAt: 300,
|
|
endedAt: 350,
|
|
updatedAt: 350,
|
|
});
|
|
|
|
const result = reconcileMediaTasksOnBoot(db, {
|
|
terminalTtlMs: 10_000,
|
|
now: 1_000,
|
|
});
|
|
expect(result).toEqual({ interrupted: 2, deleted: 0 });
|
|
|
|
const queued = getMediaTask(db, 'queued');
|
|
const running = getMediaTask(db, 'running');
|
|
const done = getMediaTask(db, 'done');
|
|
expect(queued).toMatchObject({
|
|
status: 'interrupted',
|
|
endedAt: 1_000,
|
|
progress: ['queued provider request'],
|
|
error: {
|
|
message: 'media task interrupted by daemon restart',
|
|
status: 5,
|
|
code: 'DAEMON_RESTART',
|
|
},
|
|
});
|
|
expect(running?.status).toBe('interrupted');
|
|
expect(done?.status).toBe('done');
|
|
});
|
|
|
|
it('keeps recent terminal rows and deletes expired terminal rows by TTL', () => {
|
|
insertMediaTask(db, {
|
|
id: 'recent',
|
|
projectId: 'p1',
|
|
status: 'done',
|
|
startedAt: 9_000,
|
|
endedAt: 9_500,
|
|
updatedAt: 9_500,
|
|
});
|
|
insertMediaTask(db, {
|
|
id: 'expired',
|
|
projectId: 'p1',
|
|
status: 'failed',
|
|
startedAt: 1_000,
|
|
endedAt: 1_500,
|
|
updatedAt: 1_500,
|
|
error: { message: 'provider failed' },
|
|
});
|
|
|
|
const result = reconcileMediaTasksOnBoot(db, {
|
|
terminalTtlMs: 5_000,
|
|
now: 10_000,
|
|
});
|
|
expect(result).toEqual({ interrupted: 0, deleted: 1 });
|
|
expect(getMediaTask(db, 'recent')?.status).toBe('done');
|
|
expect(getMediaTask(db, 'expired')).toBeNull();
|
|
expect(listRecentMediaTasks(db, { terminalTtlMs: 5_000, now: 10_000 }).map((t) => t.id))
|
|
.toEqual(['recent']);
|
|
});
|
|
|
|
it('cascades task deletion when a project is deleted', () => {
|
|
insertMediaTask(db, { id: 'doomed', projectId: 'p2', status: 'running' });
|
|
db.prepare(`DELETE FROM projects WHERE id = ?`).run('p2');
|
|
expect(getMediaTask(db, 'doomed')).toBeNull();
|
|
});
|
|
|
|
it('deletes tasks explicitly after the retention window', () => {
|
|
insertMediaTask(db, { id: 'task_delete', projectId: 'p1', status: 'failed' });
|
|
deleteMediaTask(db, 'task_delete');
|
|
expect(getMediaTask(db, 'task_delete')).toBeNull();
|
|
});
|
|
});
|