* feat(images): store images' own categories in the library database (#332) One new table, image_categories, and its category index, created empty by a forward-only migration that reads and writes only its own objects. Startup checks the structure; restore also checks the values. The store sets, clears and reads own categories by file key (the deepest output source holding the file and the path below it), and copies a removed duplicate's category to a kept copy that has none. * feat(images): duplicate cleanup keeps images' own categories (#332) The kept copy's own category wins; a kept copy without one receives the removed copy's, keyed by the deepest output source holding each file. The removed copy's row is never deleted, so a give-back or a failed delete loses nothing. The job log reports how many were kept. * feat(images): set, remove and list images' own categories (#332) PUT /prompt_manager/images/category sets the own category of up to 500 listed files, DELETE removes it (listed or not), and GET /prompt_manager/images/categories lists the values whose file is listed. Targets are the Images listing identity of an output source, checked by media_target (shared with Collections). The routes never touch prompts, recipes or links; POST /prompt_manager/bulk/category is unchanged. * fix(images): key symlinked output folders and keep key work off the event loop (#332) Review fixes: a source matches by its configured spelling too, so a detected output folder that is a symlink keys its files (routes and duplicate cleanup). File keys are computed in the executor. A path no source holds is a plain 403, a lone surrogate a 400. The install repairs a table that lacks its category index instead of refusing to open. * feat(images): set or remove a category on all N matching images (#332) The category routes also accept the Images listing filter of "Select all N matching" (#392) instead of explicit items, resolved now and capped at 500 images like an explicit selection. * test(vendor): count the #332 host case in the execution matrix image-attribution.spec.mjs gains the image category route case, so the execution config lists 92 tests (3 attribution cases per project). * test(legacy): record the image category routes in the legacy contract snapshot (#332) #332 adds PUT/DELETE /prompt_manager/images/category and GET /prompt_manager/images/categories; the legacy pages' routes are unchanged.
166 lines
9.6 KiB
JavaScript
166 lines
9.6 KiB
JavaScript
// #342 on the real pinned host: the image monitor links an output only to a prompt
|
|
// that provably produced it. An unknown node (PMHistoryCached) stands in for the
|
|
// maintainer's LLM; KSampler is the fixture's deterministic stand-in.
|
|
import { randomUUID } from 'node:crypto';
|
|
import { execFile } from 'node:child_process';
|
|
import { promisify } from 'node:util';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { expect } from '../../tools/vendor/node_modules/@playwright/test/index.mjs';
|
|
import { test } from './execution-fixture.mjs';
|
|
|
|
const run = promisify(execFile);
|
|
const reader = fileURLToPath(new URL('./read_execution_history.py', import.meta.url));
|
|
|
|
async function readLinks(databasePath, filenames, texts = []) {
|
|
const { stdout } = await run(process.env.PM_TEST_PYTHON || 'python',
|
|
[reader, databasePath, '--links', ...filenames, '--texts', ...texts], { timeout: 5000, maxBuffer: 1024 * 1024 });
|
|
return JSON.parse(stdout);
|
|
}
|
|
|
|
// kind: the positive node; text: a typed editor or 'llm' (linked from the unknown
|
|
// node); prepend: 'llm' links the unknown node into prepend_text.
|
|
function graph({ kind = 'PromptManagerPair', text = '', prepend = '', llm = '', seed = 1, prefix }) {
|
|
const linked = value => value === 'llm' ? ['11', 0] : value;
|
|
const nodes = {
|
|
'10': { class_type: 'PMHistoryInputs', inputs: {} },
|
|
'20': { class_type: kind, inputs: { text: linked(text), clip: ['10', 0], append_text: '',
|
|
...(prepend ? { prepend_text: linked(prepend) } : {}),
|
|
...(kind === 'PromptManagerPair' ? { negative_text: '' } : {}) } },
|
|
'30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } },
|
|
'40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2],
|
|
positive: ['20', 0], negative: kind === 'PromptManagerPair' ? ['20', 1] : ['30', 0],
|
|
seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } },
|
|
'60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } },
|
|
'70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } },
|
|
};
|
|
if (llm) nodes['11'] = { class_type: 'PMHistoryCached', inputs: { value: llm } };
|
|
return nodes;
|
|
}
|
|
|
|
async function saved(request, prompt, history = {}) {
|
|
const id = randomUUID();
|
|
const response = await request.post('/prompt', { data: { prompt, prompt_id: id } });
|
|
expect(response.ok()).toBe(true);
|
|
let item;
|
|
await expect.poll(async () => {
|
|
item = (await (await request.get(`/history/${id}`)).json())[id];
|
|
return item?.status?.messages?.some(([kind]) => kind === 'execution_success') || false;
|
|
}, { timeout: 15000 }).toBe(true);
|
|
const [image] = item.outputs['70'].images;
|
|
expect(image.type).toBe('output');
|
|
history.item = item;
|
|
return image.filename;
|
|
}
|
|
|
|
async function linkedTo(execution, filename, expected) {
|
|
await expect.poll(async () => (await readLinks(execution.databasePath, [filename])).links[filename],
|
|
{ timeout: 20000, message: `${filename} is linked by the image monitor` }).toBe(expected);
|
|
}
|
|
|
|
async function monitoring(execution, enabled) {
|
|
const response = await execution.request.post('/pm_execution_fixture/monitor', { data: { enabled } });
|
|
expect(response.ok()).toBe(true);
|
|
expect((await response.json()).monitoring).toBe(enabled);
|
|
}
|
|
|
|
async function cacheMode(execution, mode) {
|
|
expect((await execution.request.post('/pm_execution_fixture/cache', { data: { mode } })).ok()).toBe(true);
|
|
}
|
|
|
|
test.beforeEach(async ({ execution }) => {
|
|
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
|
|
await cacheMode(execution, 'CLASSIC'); // keeps the re-run's PromptManager node cached
|
|
await monitoring(execution, true);
|
|
});
|
|
test.afterEach(async ({ execution }) => {
|
|
await monitoring(execution, false);
|
|
await cacheMode(execution, 'RAM_PRESSURE');
|
|
});
|
|
|
|
test('an output whose positive is only linked prepend text stays unlinked, never given an earlier prompt', async ({ execution }, testInfo) => {
|
|
const tag = `${testInfo.project.name} ${randomUUID()}`;
|
|
const earlier = `a woman holding a coffee cup ${tag}`;
|
|
const prepended = `llm prepend landscape ${tag}`, llmText = `llm text landscape ${tag}`;
|
|
|
|
// A typed prompt runs first: before #342 the next unidentifiable image took it.
|
|
const first = await saved(execution.request, graph({ text: earlier, prefix: 'misslink/earlier' }));
|
|
await linkedTo(execution, first, earlier);
|
|
|
|
// The maintainer's workflow: empty editor, the generated text arrives in prepend.
|
|
const orphan = await saved(execution.request,
|
|
graph({ prepend: 'llm', llm: prepended, prefix: 'misslink/prepend' }));
|
|
|
|
// An LLM feeding the editor itself is stored and links through its run key; the
|
|
// monitor works in creation order, so the prepend image was processed first.
|
|
const llmGraph = graph({ kind: 'PromptManager', text: 'llm', llm: llmText, prefix: 'misslink/text' });
|
|
const proven = await saved(execution.request, llmGraph);
|
|
await linkedTo(execution, proven, llmText);
|
|
const state = await readLinks(execution.databasePath, [orphan], [prepended]);
|
|
expect(state).toEqual({ links: { [orphan]: null }, stored: { [prepended]: false } });
|
|
|
|
// Only the sampler seed changes: the PromptManager node is served from the cache
|
|
// without running (the host says so), so no queue entry exists for this run and
|
|
// only the snapshot's run match can link it.
|
|
const rerunHistory = {};
|
|
const rerun = await saved(execution.request, { ...llmGraph, '40': { ...llmGraph['40'],
|
|
inputs: { ...llmGraph['40'].inputs, seed: 2 } } }, rerunHistory);
|
|
const cached = rerunHistory.item.status.messages.find(([kind]) => kind === 'execution_cached');
|
|
expect(cached?.[1].nodes).toEqual(expect.arrayContaining(['11', '20']));
|
|
await linkedTo(execution, rerun, llmText);
|
|
});
|
|
|
|
test('an output saved without metadata links once its recorded run names a stored prompt', async ({ execution }, testInfo) => {
|
|
const llmText = `bare output landscape ${testInfo.project.name} ${randomUUID()}`;
|
|
const request = execution.request;
|
|
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: true } })).ok()).toBe(true);
|
|
try {
|
|
// No embedded graph: no run key can prove the tracker entry, so only the
|
|
// run's recorded history can, and only once the run's outputs are sealed.
|
|
// The gate after SaveImage holds the run open while the monitor looks.
|
|
const prompt = graph({ kind: 'PromptManager', text: 'llm', llm: llmText, prefix: 'misslink/bare' });
|
|
prompt['80'] = { class_type: 'PMHistoryImageOutput', inputs: { images: ['70', 0], mode: 'gate' } };
|
|
expect((await request.post('/pm_execution_fixture/gate/hold')).ok()).toBe(true);
|
|
const id = randomUUID();
|
|
try {
|
|
expect((await request.post('/prompt', { data: { prompt, prompt_id: id } })).ok()).toBe(true);
|
|
let waiting = [];
|
|
await expect.poll(async () => {
|
|
waiting = (await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting;
|
|
return waiting.filter(name => name.startsWith('bare_')).length;
|
|
}, { timeout: 20000, message: 'the monitor processed the bare output while its run was open' }).toBe(1);
|
|
const [bare] = waiting.filter(name => name.startsWith('bare_'));
|
|
expect((await readLinks(execution.databasePath, [bare])).links[bare]).toBeNull();
|
|
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
|
|
await linkedTo(execution, bare, llmText);
|
|
expect((await (await request.get('/pm_execution_fixture/monitor')).json()).awaiting).not.toContain(bare);
|
|
} finally {
|
|
expect((await request.post('/pm_execution_fixture/gate/release')).ok()).toBe(true);
|
|
}
|
|
} finally {
|
|
expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
|
|
}
|
|
});
|
|
|
|
// #332 on the real host: the migration ran at startup and the image category routes
|
|
// set, list and remove an own category on a generated output.
|
|
test('an output gets its own image category and loses it again', async ({ execution }, testInfo) => {
|
|
const request = execution.request;
|
|
const filename = await saved(request, graph({ text: `category host ${randomUUID()}`, prefix: 'pm332/category' }));
|
|
let image;
|
|
await expect.poll(async () => {
|
|
const listing = await (await request.get('/prompt_manager/images/output?mode=workspace&q=pm332&rescan=1')).json();
|
|
image = (listing.images || []).find(entry => entry.filename === filename || entry.path?.endsWith(`/${filename}`));
|
|
return Boolean(image);
|
|
}, { timeout: 20000, message: `${filename} is listed by Images` }).toBe(true);
|
|
const items = [{ root_index: image.root_index, root_scope: image.root_scope, path: image.path }];
|
|
const value = `PM332 host ${testInfo.project.name}`;
|
|
const set = await request.put('/prompt_manager/images/category', { data: { items, category: value } });
|
|
expect(set.status()).toBe(200);
|
|
expect(await set.json()).toEqual({ success: true, category: value, updated: 1, unchanged: 0, not_found: 0 });
|
|
expect((await (await request.get('/prompt_manager/images/categories')).json()).categories).toContain(value);
|
|
const cleared = await request.delete('/prompt_manager/images/category', { data: { items } });
|
|
expect(cleared.status()).toBe(200);
|
|
expect(await cleared.json()).toEqual({ success: true, cleared: 1 });
|
|
expect((await (await request.get('/prompt_manager/images/categories')).json()).categories).not.toContain(value);
|
|
});
|