From d4508c999783c0dcf4a702831ee229f65ca228ac Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sat, 31 Oct 2026 16:59:49 -0700 Subject: [PATCH] fix(activity): show a maintenance job's record rows (#550) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A row with no path (a record such as "Prompt 12: category “night”; tags a") was dropped, so a Pair category and tags job showed "Changed 7" but an empty group. Record rows now show their detail as plain text with no open action, and the filter reads it. A maintenance job of records says "No records in this group." and "Filter records…"; file jobs are unchanged. --- TESTING.md | 4 ++ .../workspace-system-maintenance.spec.mjs | 12 +++++ tests/js/workspace-system-maintenance.test.js | 37 +++++++++++++++ web/js/workspace/activity-job-view.mjs | 45 +++++++++++++++---- 4 files changed, 89 insertions(+), 9 deletions(-) diff --git a/TESTING.md b/TESTING.md index a7a2132f..101ad57f 100644 --- a/TESTING.md +++ b/TESTING.md @@ -4845,6 +4845,10 @@ follows the safety backup policy like them (no forced backup). Rules writes `prompt_pairs`, `prompt_pair_tags`, `updated_at`, a tag or Library state. - The job lists 200 changed rows ("Prompt 12: category “night”; tags a, b"); its Download report lists every one (`truncated: false`, `Job.record_counted(complete=True)`). +- Activity shows a row with no path as a record: its detail as plain text, never an open + or locate action, and the filter reads the detail. A maintenance job of records (every + operation but Missing-file links) says "No records in this group.", "No listed record + matches this filter." and "Filter records…" (`groupCopy`); file jobs keep "files". Tests: `tests/test_maintenance_pair_metadata.py`, `tests/test_api_maintenance_pair_metadata.py`, `tests/test_jobs.py`, `tests/js/workspace-system-maintenance.test.js`, diff --git a/tests/browser/workspace-system-maintenance.spec.mjs b/tests/browser/workspace-system-maintenance.spec.mjs index 27759468..be893bcc 100644 --- a/tests/browser/workspace-system-maintenance.spec.mjs +++ b/tests/browser/workspace-system-maintenance.spec.mjs @@ -216,6 +216,18 @@ test('D192: Pair category and tags reviews old Pair prompts, keeps an edited one const main = page.locator('#workspace-main'); await expect(main.locator('h1')).toHaveText('Pair category and tags'); await expect(main.getByRole('button', { name: 'Download report', exact: true })).toBeVisible(); + // #550 live test: the Changed group lists the path-less record rows as plain text, never an open action. + const results = main.getByRole('region', { name: 'Results', exact: true }); + await results.locator('.outcome-list > button').filter({ hasText: 'Changed' }).click(); + const records = results.locator('.outcome-files > .file-row'); + await expect(records).toHaveCount(1); + await expect(records.first()).toHaveText(/^Prompt \d+: category “night”; tags s550-glow, s550-warm$/); + expect(await records.first().evaluate(node => node.localName)).toBe('div'); + await expect(results.locator('.outcome-files button')).toHaveCount(0); + await expect(results.getByRole('searchbox', { name: 'Filter results' })).toHaveAttribute('placeholder', 'Filter records…'); + // Protected is counted, not listed: it says records, not files. + await results.locator('.outcome-list > button').filter({ hasText: 'Protected' }).click(); + await expect(results.locator('.outcome-files > p.empty-state')).toHaveText('No records in this group.'); const library = await serverJson(request, '/prompt_manager/library'); const lantern = library.items.find(item => item.text === 'S550 lantern'); const harbor = library.items.find(item => item.text === 'S550 harbor'); diff --git a/tests/js/workspace-system-maintenance.test.js b/tests/js/workspace-system-maintenance.test.js index 86f9ce53..78545ea1 100644 --- a/tests/js/workspace-system-maintenance.test.js +++ b/tests/js/workspace-system-maintenance.test.js @@ -225,3 +225,40 @@ test('a rotated-log cleanup the log overtook offers Review again (S12)', async ( assert.deepEqual(reviewAgainOf({ kind: 'maintenance', state: 'failed', scope: { operation: 'rotated_logs', failure: 'plan-changed' } }), { operation: 'rotated_logs' }); }); + +// #550 live test: a records operation's rows have no path, only a detail ("Prompt 12: category …"). +const RECORD_JOB = Object.freeze({ + id: 'a'.repeat(32), kind: 'maintenance', title: 'Pair category and tags', state: 'finished', + admission: 'records', cancellable: false, progress: 100, processed: 0, total: 0, status_line: 'Filled 2 prompts', + scope: { operation: 'pair_metadata' }, counters: { changed: 2, protected: 0, errors: 0 }, + started_at: 1, finished_at: 2, +}); + +test('#550: path-less record rows are kept with their detail; file rows are unchanged', async () => { + const { parseDetail, filterRows } = await jobView; + const parsed = parseDetail({ ...RECORD_JOB, outcomes: { changed: [ + { path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian' }, + { path: '', root: '', root_index: null, detail: '' }, + { path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed' }, + ] }, log: [] }); + assert.deepEqual(parsed.outcomes.changed, [ + { path: '', root: '', root_index: null, detail: 'Prompt 2325: category “asian”; tags asian', record: true }, + { path: 'a/b.png', root: 'output', root_index: 0, detail: 'Link removed', record: false }, + ]); + // The filter reads a record's detail and a file's path. + assert.deepEqual(filterRows(parsed.outcomes.changed, 'asian').map(row => row.detail), + ['Prompt 2325: category “asian”; tags asian']); + assert.deepEqual(filterRows(parsed.outcomes.changed, 'a/b').map(row => row.path), ['a/b.png']); +}); + +test('#550: a records operation says records, not files, for an empty group and a filter', async () => { + const { groupCopy, EMPTY_GROUP, NO_MATCH } = await jobView; + assert.deepEqual(groupCopy(RECORD_JOB), { empty: 'No records in this group.', noMatch: 'No listed record matches this filter.', + placeholder: 'Filter records…' }); + for (const operation of ['duplicate_prompts', 'unused_components']) { + assert.equal(groupCopy({ ...RECORD_JOB, scope: { operation } }).empty, 'No records in this group.'); + } + const files = { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: 'Filter by file path…' }; + assert.deepEqual(groupCopy({ ...RECORD_JOB, scope: { operation: 'missing_links' } }), files); + assert.deepEqual(groupCopy({ ...RECORD_JOB, kind: 'scan', scope: {} }), files); +}); diff --git a/web/js/workspace/activity-job-view.mjs b/web/js/workspace/activity-job-view.mjs index 5d125636..cd2edf01 100644 --- a/web/js/workspace/activity-job-view.mjs +++ b/web/js/workspace/activity-job-view.mjs @@ -11,6 +11,9 @@ * - Results (`section.surface`): the outcome list (`button[aria-pressed]` with counts, in the mockup's order), a path * filter, and the selected outcome's listed rows (at most 200 per outcome, so "Showing N of M"). Rows under Added and * Already known open the file in the existing viewer through its listing identity (D57); the others are plain text. + * - #550: a row with no path is a record (a maintenance apply's "Prompt 12: category …"): its detail is shown as plain + * text, never opened; the filter reads it. A maintenance plan of records (every operation but Missing-file links) + * says "records" where a file job says "files" (`groupCopy`). * - Log: `details.term` "Scan log", collapsed, holding the raw timestamped lines in `pre[tabindex=0]`. * - D89: the tiles, the outcome list, the openable rows and the log and cancel labels are the job kind's (`jobView`): * an image tagging job shows Tagged, Unchanged, Skipped and Errors, opens Tagged and Unchanged rows, and reads @@ -44,6 +47,10 @@ export const DETAIL_INTERVAL_MS = 1000; export const NOT_FOUND = 'This job is not in this session’s Activity. Jobs are kept until ComfyUI restarts.'; export const EMPTY_GROUP = 'No files in this group.'; export const NO_MATCH = 'No listed file matches this filter.'; +export const EMPTY_RECORDS = 'No records in this group.'; +export const NO_RECORD_MATCH = 'No listed record matches this filter.'; +const FILE_FILTER = 'Filter by file path…'; +const RECORD_FILTER = 'Filter records…'; export const VIEWER_HINT = 'each row opens the image in the viewer'; export const FILE_GONE = 'This file is no longer in Images. Rescan from Import and try again.'; export const OPEN_FAILED = 'The file could not be opened. Try again.'; @@ -68,10 +75,11 @@ export function parseDetail(raw) { const groups = raw.outcomes !== null && typeof raw.outcomes === 'object' ? raw.outcomes : {}; for (const [group, rows] of Object.entries(groups)) { if (!Array.isArray(rows)) continue; - outcomes[group] = rows.filter(row => row && isText(row.path) && row.path) + // A file row has a path; a record row (#550) has none, only its detail. + outcomes[group] = rows.filter(row => row && isText(row.path) && (row.path || (isText(row.detail) && row.detail))) .map(row => ({ path: row.path, root: isText(row.root) ? row.root : '', root_index: Number.isSafeInteger(row.root_index) && row.root_index >= 0 ? row.root_index : null, - detail: isText(row.detail) ? row.detail : '' })); + detail: isText(row.detail) ? row.detail : '', record: !row.path })); } const log = Array.isArray(raw.log) ? raw.log.filter(isText) : []; return { job, outcomes, log }; @@ -129,11 +137,23 @@ export function resultsNote(shown, total, openable) { return parts.length === 1 && openable && shown >= total ? `${text[0].toUpperCase()}${text.slice(1)}.` : text; } -/** Rows whose path (or root label and path) holds the case-folded filter. */ +/** Rows whose path (or root label and path), or a record's detail (#550), holds the case-folded filter. */ export function filterRows(rows, filter) { const wanted = filter.trim().toLocaleLowerCase(); if (!wanted) return rows; - return rows.filter(row => `${row.root}/${row.path}`.toLocaleLowerCase().includes(wanted)); + return rows.filter(row => (row.record ? row.detail : `${row.root}/${row.path}`).toLocaleLowerCase().includes(wanted)); +} + +/** #550: whether a job lists records (a maintenance plan of records), not files. */ +export function listsRecords(job) { + return job?.kind === 'maintenance' && job.scope?.operation !== 'missing_links'; +} + +/** The empty-group, no-match and filter copy: "records" for a records job, "files" otherwise (#550). */ +export function groupCopy(job) { + return listsRecords(job) + ? { empty: EMPTY_RECORDS, noMatch: NO_RECORD_MATCH, placeholder: RECORD_FILTER } + : { empty: EMPTY_GROUP, noMatch: NO_MATCH, placeholder: FILE_FILTER }; } /** The first outcome with files, so a view opens on what happened; the first outcome when nothing has yet. */ @@ -176,7 +196,7 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const search = element('label', undefined, 'search-field'); const input = element('input'); input.type = 'search'; - input.placeholder = 'Filter by file path…'; + input.placeholder = FILE_FILTER; input.setAttribute('aria-label', 'Filter results'); search.append(input); resultsPart.heading.append(search); @@ -280,9 +300,13 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { // Listed rows only ever grow, so a row keeps its node across reads: a focused row (or the viewer's trigger) stays. let rowNodes = new Map(); function rowNode(row, index, openable) { - const key = `${selected}\n${index}\n${row.root}\n${row.path}`; + const key = `${selected}\n${index}\n${row.root}\n${row.path}\n${row.record ? row.detail : ''}`; let node = rowNodes.get(key); - if (!node) { + if (!node && row.record) { + // #550: a record is its detail, plain text, with no open or locate action. + node = element('div', undefined, 'file-row record-row'); + node.append(element('span', row.detail, 'why')); + } else if (!node) { node = openable ? createButton('', 'file-row') : element('div', undefined, 'file-row'); node.append(element('span', row.path, 'path'), element('span', row.detail, 'why')); if (openable) node.row = row; @@ -300,8 +324,11 @@ export function mountActivityJobView(main, { store, scope, api, jobId }) { const nodes = [...kept.values()]; // The counter is exact; at most 200 of its files are listed, and the filter narrows those. const total = Math.max(job.counters[selected] ?? 0, rows.length); - if (!nodes.length) nodes.push(element('p', rows.length ? NO_MATCH : EMPTY_GROUP, 'empty-state')); - const note = wanted.length ? resultsNote(wanted.length, total, openable) : ''; + const copy = groupCopy(job); + input.placeholder = copy.placeholder; + if (!nodes.length) nodes.push(element('p', rows.length ? copy.noMatch : copy.empty, 'empty-state')); + const opens = openable && wanted.some(row => !row.record); + const note = wanted.length ? resultsNote(wanted.length, total, opens) : ''; if (note) nodes.push(element('p', note, 'small')); for (const child of [...files.children]) if (!nodes.includes(child)) child.remove(); nodes.forEach((node, index) => {