Compare commits

..
Author SHA1 Message Date
Vito Sansevero 499a6db0da test(collections): order the review M1 reads by events, not the 300 ms timer (#459)
The M1 test let the rename answer at once and changed the hash only after the
toast, so on a loaded host the rename's 300 ms count read could start before
Fresh's page read; the page then waited on that one read and the poll for two
held reads failed. Hold the rename's answer until Fresh's read is held.
2026-10-28 20:41:29 -07:00
Vito dff08bc161 fix(images): open Masonry by default, keep a saved Default layout, crop portraits from the top (#451) (#455)
- A Default layout or Thumbnail size saved in System now reaches the next Images
  visit in an open tab (this tab or another): the last visit records the
  defaults it started from, and a value its toolbar never changed follows newer
  defaults. A toolbar choice stays the current view's own.
- Images opens in Masonry when the browser has no stored Default layout
  (gallery-settings.js DEFAULT_SETTINGS.workspaceLayout); stored choices are kept.
- Grid/List card art, Library card art, the inspector cover and the Tag image
  preview crop with object-position 50% 20%, so portrait images keep their top.
- D157 in TESTING.md; concept comparisons start from the concept's stored Grid.
2026-10-29 02:55:55 +00:00
Vito aad3a37bcf fix(inspector): give image tags their own labelled section (#449) (#454)
The inspector drew a file's tags as a bare chip row right under the negative
prompt, so they read as part of it. The side panel, the viewer details and
History's current file now draw them in labelled sections in the prompt
sections' frame: "Image tags · N" for the file's own tags, then "Prompt tags
· N" or "Recipe tags · N" for the linked tags. Each count is true: repeats are
removed within a source only. Recorded as D154.
2026-10-29 02:44:42 +00:00
Vito d03d0ed1c5 fix(history): count cached re-runs of runtime-linked text as tracked runs (#403) (#445)
* fix(history): count cached re-runs of runtime-linked text as tracked runs (#403)

Since #347 a cached re-run of runtime-linked prompt text (PreviewAny or an
LLM into a PromptManager text input) counts in the Library, but its history
capture had no raw text, so the history page's "tracked runs" stayed at the
number of jobs in which the node executed.

The #347 receipt now also keeps the raw, prepend and append text the node
gave the history capture. When a cached node's graph inference leaves a field
unknown, the capture takes it from the receipt that matches the cache entry,
labeled inferred/graph_semantics, so the job joins the prompt's history scope
and gets its prompt hint. No schema change and no new evidence vocabulary;
no backfill. Fails closed on no receipt, a disagreeing known field, equal
outputs with different fields, oversized or invalid text and a full budget
(least recently used receipts give up their fields first; counting stays).

* docs(testing): link the whitespace history gap to #438
2026-10-29 01:40:02 +00:00
24 changed files with 1223 additions and 150 deletions
+59 -7
View File
@@ -731,8 +731,9 @@ The shared inline and collection inspector reads the current rooted file's suppo
embedded records, exact generation facts and recorded or inferred prompt evidence.
It renders in two concept surfaces (#249 D20–D22, D38). The Images side panel shows
only the concept content: cover, title, filename, the evidence badge with a category
chip naming its origin (D146), the + and − prompt blocks with copy and send, tag chips (the file's own image
tags first, D88, then the linked tags; image tags and the linked positive's own tags
chip naming its origin (D146), the + and − prompt blocks with copy and send, the tag chips in labelled
sections after the negative prompt (D154: "Image tags · N" for the file's own image tags, D88, then "Prompt
tags · N" or "Recipe tags · N" for the linked tags; image tags and the linked positive's own tags
filter Images, D21), the six facts, and a footer with "Send to ComfyUI" and
"Prompt history" only when prompt roles resolved or a link is saved; the sticky panel
scrolls inside the window. The viewer's details (`#viewer-details`) repeat that
@@ -1523,6 +1524,9 @@ Recorded deviations on this page (spec §8):
filters, while recipe, recorded-run and embedded tags stay plain `span.chip`.
Amended by D88 (#249 PR C): the file's own current image tags lead the same row
and are filters too; a linked tag already shown (case-insensitive) is left out.
Amended by D154 (#449): the image tags and the linked tags each sit in their own
labelled section, and a linked tag stays in its section even when an image tag
has the same name.
Amended by D146 (#332): the category chip also names its origin in visible text
after the category (" · this image", " · prompt", " · recipe"), and an image's own
category shows for an unlinked or ambiguous file too.
@@ -1810,6 +1814,30 @@ Inspect plan keeps D60–D66 (plan D60 → D83, D61 → D84, D65 → D88, D68
concept image's tags saved as its image tags production still equals the concept.
After a Tag save the selected file's inspection is re-read, so the panel redraws
the row without reselecting and the viewer, which shares that inspection, shows it.
Amended by D154 (#449): the one row is now two labelled sections.
- **D154** (#449, beyond the concept): the inspector's tags no longer follow the
negative prompt as a bare `.tags` row, where they read as part of it. Every surface of
the shared inspector (the Images side panel, the viewer details, History's inline
current file, the Library-embedded gallery and Activity) draws them in
`section.tag-section` regions in the concept's prompt-section frame (the
`.prompt-section` top rule with 15px padding and margin, a `.prompt-heading` with a
`.role-label`, the row's own 12px top margin dropped; workspace.css). The label is
muted (`--ca-muted`, as `.meta-grid dt`), because tags are neither prompt role. First
`section.tag-section.image-tags` "Image tags · N" (the file's current image tags, D88),
then `section.tag-section.linked-tags` "Prompt tags · N" for the linked positive's
tags or "Recipe tags · N" for a recipe's own (`tags_scope`, named as D146's category
chip names its origin). Each region is named without its count and with the prompt
blocks' context ("Current file image tags" in History). An empty source draws no
section. Repeats are removed within a source only, so a linked tag that is also an
image tag shows in both sections and each count is true. D21's filter rules are
unchanged. History's current-file read carries no `image_tags` facet (D88), so there
only the linked section shows. `tests/browser/inspector-concept.spec.mjs` wraps the
concept's row in the same sections at runtime (`withTagSections`, the D154 rule
injected) and compares the measured sections (frame, heading, label, row); a
mutation of the label color, the row margin or the top rule fails it. Browser tests:
"D154: image tags sit in their own labelled section after the negative prompt, in
the panel and the viewer" and "the inline current file labels its image tags after
its negative prompt".
- **D115** (beyond the plan's list): the concept always has a selected image, so
its "Auto-tag images" always opens the dialog. Production opens it for the image
selected in Images when it was last left; with none it is `aria-disabled` and
@@ -1895,6 +1923,7 @@ production-versus-concept difference of PR C and its record:
| Suggestions count field | D91 (ruling T3-bulk) |
| No prototype note, "Suggestions from …", no " in this tab" | D29 |
| Image tags first in the inspector and viewer chip row | D88 (ruling T7/C2) |
| Image tags and linked tags in labelled sections after the negative prompt | D154 (#449) |
| Auto-tag images `aria-disabled` without a selected image | D115 |
| Retry leads the actions when the controller allows it | D116 (ruling T5) |
| States the concept lacks (busy, conflict, unavailable model, video) | Global Constraints: `#tag-generation-status` text only |
@@ -5928,8 +5957,26 @@ decide through the same `own_counts`.
- A cached node counts at job start like the hook: a job failing later still counts it.
A node that executes after its replay in the same job (RAM-pressure freed the entry)
skips its own count but still consumes the pending first-use catch-up.
- No backfill: earlier cached jobs keep their counts. The history page's "tracked runs"
can stay below the Library count for these prompts (#403).
- No backfill: earlier cached jobs keep their counts.
- History (#403): the receipt also keeps the raw, prepend and append text the node gave
the history capture. For a cached node whose graph inference leaves a field unknown,
the capture takes it from the receipt matching the cache entry
(`CachedRuns.recorded_fields`), as `inferred` / `graph_semantics`: the node did not run
in this job, the value follows from the run key (the whole upstream closure) plus the
cache proof. The job then joins the prompt's history scope, gets the
`positive_prompt_id` (and Pair) hint, and an image of it can be attributed through its
run, so the history page's "tracked runs" equals the Library count from the restart on.
Fail closed (fields stay unknown, as before): no matching receipt, a receipt that
disagrees with a field the graph knows, executions with equal outputs that recorded
different fields (a conflict that stays for that run key), a field over 64 KiB or
invalid Unicode, more than one run's history (1 MiB) in one receipt, or a full 8 MiB
field budget (least recently used receipts give up their fields first; their counting
stays). No new evidence vocabulary, so older builds still open the database.
- Still different: jobs before the restart (no backfill); list batches (per item in the
Library, once per job in history); text with leading or trailing whitespace (the
Library stores it stripped, history matches the exact raw text, #438); a job in which the
node executes after its replay (RAM-pressure freed the entry: two samples, an
ambiguous capture); jobs while the history writer is fenced (restore, library move).
- Tests: `tests/test_cached_runs.py` (whole jobs with the real nodes, a real database,
fake host caches); `tests/comfyui/execution-text-link.spec.mjs` "#347" cases on the
managed host: PreviewAny into each node kind, 3 jobs changing only the sampler seed,
@@ -5937,7 +5984,10 @@ decide through the same `own_counts`.
modes, A, B, A from the owned `PMCycledText` under LRU, typed text, a downstream
failure, a "run selected output" job that does not need the node, and a rewire to the
negative input. The RAM-pressure eviction path cannot be forced on a real host and is
covered by the Python tests.
covered by the Python tests. Since #403 these cases also check the history page's
tracked runs equal the Library count, and `tests/test_execution_history_hooks.py`
(`CachedRuntimeHistoryTests`) runs the real history hook and writer with a real
`CachedRuns`.
```sh
python3 -m unittest discover -s tests -p "test_cached_runs.py"
@@ -6036,10 +6086,12 @@ Context cases verify A,A,B as three jobs/two combinations/one positive snapshot
when every positive field matches; all five registered PM variants; exact raw
whitespace and Unicode; known empty negatives; Text negative roles and crossed
pairs; fully cached repeats after trigger configuration changes; successful source
text before downstream failure/interruption; unknown cached dynamic raw; unsupported
text before downstream failure/interruption; cached dynamic raw from the reused execution (#403); unsupported
transforms and duplicate list invocation ambiguity. Fresh values use
`runtime_return`, cached effective STRING outputs use `host_cache_output`, and
submitted literals use graph inference. SQL NULL means unknown; empty text is
submitted literals use graph inference. A cached dynamic raw is unknown unless the
#347 receipt of the execution the cache entry comes from proves it; it is then
`inferred` / `graph_semantics` (graph identity plus a same-process cache receipt, #403). SQL NULL means unknown; empty text is
known. Text is never trimmed or normalized by capture. Cache availability and
inferred sampler roles prove neither sampler execution nor consumption, and list
indices do not identify generated images. External trigger configuration changes
+6 -1
View File
@@ -275,7 +275,12 @@ class PromptManager(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, encoding_text
)
self._record_cached_item(unique_id, (encoding_text,), storage_text)
self._record_cached_item(
unique_id,
(encoding_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
return result
@classmethod
+13 -3
View File
@@ -243,14 +243,24 @@ class PromptManagerBase:
return False
def _record_cached_item(
self, unique_id: Any, outputs: tuple, text: Any, negative_text: Any = None
self,
unique_id: Any,
outputs: tuple,
text: Any,
negative_text: Any = None,
fields: Optional[tuple] = None,
) -> None:
"""Remember this execution's outputs and stored text for cached jobs (#347)."""
"""Remember this execution's outputs and stored text for cached jobs (#347).
``fields`` are the ``(raw, prepend, append)`` texts this execution gave
``record_runtime_context`` for each output, so a cached job's history
can record them too (#403).
"""
if unique_id is None:
return
try:
self._cached_runs().record_item(
unique_id, outputs, stored_facts(text, negative_text)
unique_id, outputs, stored_facts(text, negative_text), fields
)
except Exception as error:
self.logger.debug(f"Could not record the cached-run receipt: {error}")
+5 -1
View File
@@ -198,7 +198,11 @@ class PromptManagerPair(PromptManagerFocused):
type(self).__name__, negative_text, "", "", negative_text, role="negative"
)
self._record_cached_item(
unique_id, (positive, negative_text), text, negative_text
unique_id,
(positive, negative_text),
text,
negative_text,
fields=((text, prepend_text, append_text), (negative_text, "", "")),
)
return result
+6 -1
View File
@@ -246,7 +246,12 @@ class PromptManagerText(PromptManagerBase, ComfyNodeABC):
record_runtime_context(
type(self).__name__, text, prepend_text, append_text, final_text
)
self._record_cached_item(unique_id, (final_text,), storage_text)
self._record_cached_item(
unique_id,
(final_text,),
storage_text,
fields=((text, prepend_text, append_text),),
)
return result
@classmethod
+44
View File
@@ -232,6 +232,41 @@ export async function withChipSource(page,root,origin,category=null) {
};
}
/**
* #449 D154: the inspector's tag sections in the concept's prompt-section frame (workspace.css `.tag-section`). The concept
* has one bare `.tags` row after its prompt blocks (app.js a402), so the recorded rule is added and that row wrapped at
* runtime, the browser laying out what follows: `sections` lists `[className, label, name]` per section, the first
* holding the concept's row and each further one a copy of it. Returns the undo.
*/
export const D154_TAG_SECTION=`.tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.tag-section .role-label { color: var(--ca-muted); }
.tag-section > .tags { margin-top: 0; }`;
export const PROMPT_TAGS=[['linked-tags','Prompt tags','Prompt tags']];
export async function withTagSections(page,root,sections=PROMPT_TAGS) {
const restoreRule=await mutate(page,{css:D154_TAG_SECTION});
const token=await page.evaluate(({root,sections})=>{
const container=document.querySelector(root);
const inner=container?.querySelector(':scope > .detail-inner')??container?.querySelector(':scope > div');
const row=inner&&[...inner.querySelectorAll(':scope > .tags')].find(node=>node.previousElementSibling?.matches('section.prompt-section'));
if(!row)throw new Error(`withTagSections: no tag row after the prompt blocks under ${root}`);
const anchor=row.nextSibling,parent=row.parentNode,added=[];
sections.forEach(([className,label,name],index)=>{
const section=document.createElement('section');section.className=`tag-section ${className}`;section.setAttribute('aria-label',name);
const heading=document.createElement('div');heading.className='prompt-heading';
const text=document.createElement('span');text.className='role-label';text.textContent=`${label} · ${row.children.length}`;
heading.append(text);section.append(heading,index?row.cloneNode(true):row);
parent.insertBefore(section,anchor);added.push(section);
});
const token=`tag-sections-${Math.random().toString(36).slice(2)}`;
(window.__tagSections??=new Map()).set(token,()=>{parent.insertBefore(row,added[0]);added.forEach(section=>section.remove());});
return token;
},{root,sections});
return async()=>{
await page.evaluate(token=>{window.__tagSections.get(token)();window.__tagSections.delete(token);},token);
await restoreRule();
};
}
/**
* Inventory sections: {chrome, heading, toolbar, filterRow, bulkbar, caption, grid, cards, continuation, panel, library,
* history, noPageScroll}. A section is null when its element is absent. The Images sections (toolbar … panel, #249 C7)
@@ -680,6 +715,13 @@ export function measureInspector(selector) {
text:when(text,()=>({tag:text.tagName.toLowerCase(),className:classes(text),text:text.textContent,tabIndex:text.tabIndex,...font(text),...edges(text),
...css(text,'whiteSpace','overflowWrap'),...box(text),lines:px(rect(text).height/parseFloat(getComputedStyle(text).lineHeight))}))};
}
function tagSectionOf(section) {
const heading=section.querySelector(':scope > .prompt-heading'),label=heading?.querySelector('.role-label'),row=section.querySelector(':scope > .tags');
return {className:classes(section),name:section.getAttribute('aria-label'),...edges(section),...box(section),
heading:when(heading,()=>({...layout(heading),...edges(heading),...box(heading)})),
label:when(label,()=>({tag:label.tagName.toLowerCase(),text:label.textContent,...font(label),...box(label)})),
row:when(row,rowOf)};
}
function factsOf(list) {
const style=getComputedStyle(list);
return {...css(list,'display','columnGap','rowGap','fontSize'),...edges(list),columns:style.gridTemplateColumns.split(/\s+/).length,...box(list),
@@ -713,6 +755,8 @@ export function measureInspector(selector) {
badges:when(rows.find(row=>parts.indexOf(row)<firstSection),rowOf),
sections:sections.map(sectionOf),
tags:when(rows.find(row=>parts.indexOf(row)>firstSection),rowOf),
// #449 D154: the labelled tag sections after the prompt blocks, each with its frame, heading, label and chip row.
tagSections:parts.filter(item=>item.matches('section.tag-section')).map(tagSectionOf),
facts:when(parts.find(item=>item.matches('dl.meta-grid')),factsOf),
// Disclosures (viewer only) by their summary and frame; their content is the D20 production facts.
details:parts.filter(item=>item.localName==='details').map(item=>{const summary=item.querySelector(':scope > summary');
+20 -12
View File
@@ -130,7 +130,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Linked, workflow inferred: every concept element.
await expect(panel.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);
expect(await panelStructure(panel)).toEqual({outer:['img.detail-cover','div.detail-inner','div.detail-footer'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid']});
await expect(panel.locator('img.detail-cover')).toHaveAttribute('alt','');
await expect(panel.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(panel.locator('.detail-inner > p.small')).toHaveText('complete.png');
@@ -139,9 +139,13 @@ test('panel matches the concept order and copy',async({page,request})=>{
// #332 MC8: the chip names where its value came from, in visible text after the category.
await expect(badges.locator('span.chip:not(.blue)')).toHaveText('Recipe category: Inspection · recipe');
await expect(badges.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling).
await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
// Recipe tags are not the positive tags the Images Tags filter matches, so they stay plain chips (D21 ruling); D154
// (#449) labels their own section after the negative prompt, named for the recipe as D146's category chip is.
const recipeTags=panel.getByRole('region',{name:'Recipe tags',exact:true});
await expect(recipeTags).toHaveClass('tag-section linked-tags');
await expect(recipeTags.locator('.prompt-heading .role-label')).toHaveText('Recipe tags · 2');
await expect(recipeTags.locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(panel.locator('.tag-section button')).toHaveCount(0);
await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);
await expect(panel.locator('.meta-grid dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
const footer=panel.locator('.detail-footer');
@@ -149,7 +153,7 @@ test('panel matches the concept order and copy',async({page,request})=>{
await expect(footer.locator('button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
await expect(footer.locator('button.send')).toHaveAccessibleName(`Send prompt pair to ComfyUI: ${POSITIVE_TITLE}`);
await expect(footer.locator('button.send svg.icon')).toHaveCount(1);await expect(footer.locator('button:not(.send) svg.icon')).toHaveCount(2);
await expect(panel).not.toContainText(/prompt evidence|Recipe tags|Positive prompt tags/);
await expect(panel).not.toContainText(/prompt evidence|Positive prompt tags/);
// Execution recorded and embedded parameters (D21), with the extension chip naming an unverified format.
reads.patch=data=>{for (const role of ['positive','negative']) data.prompts[role].source='execution';};
await selectFile(page,'explicit-empty.png');await expect(panel.locator('.panel-heading h2')).toHaveText('explicit-empty.png');
@@ -168,18 +172,20 @@ test('panel matches the concept order and copy',async({page,request})=>{
// Ambiguous saved association: the same honest absence.
reads.patch=data=>{data.library={...data.library,status:'ambiguous',positive_id:null,pair_id:null,title:null,category:null,tags:[],category_scope:null,tags_scope:null};};
await selectFile(page,'workflow-only.png');await expect(panel.locator('.panel-heading h2')).toHaveText('workflow-only.png');
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.detail-inner > .tags button')).toHaveCount(0);
await expect(panel.locator('.chip:not(.blue)')).toHaveCount(1);await expect(panel.locator('.tag-section')).toHaveCount(0);
await expect(panel.getByRole('button',{name:'Prompt history',exact:true})).toHaveCount(0);
// D39 mixed scopes: each field follows its own source, so the prefix and the tag chips can disagree.
const tagRow=panel.locator('.detail-inner > .tags').last();
const tagRow=panel.locator('.tag-section.linked-tags > .tags');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'pair',tags_scope:'positive',tags:['alpha','beta']};};
await selectFile(page,'complete.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Recipe category: ');
await expect(tagRow.getByRole('button')).toHaveText(['alpha','beta']);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Prompt tags · 2');
reads.patch=data=>{data.library={...data.library,status:'linked',category:'Subject',category_scope:'positive',tags_scope:'pair',tags:['alpha','beta']};};
await selectFile(page,'workflow-only.png');
await expect(panel.locator('span.chip:not(.blue) .ca-visually-hidden')).toHaveText('Positive prompt category: ');
await expect(tagRow.locator('span.chip')).toHaveText(['alpha','beta']);await expect(tagRow.getByRole('button')).toHaveCount(0);
await expect(panel.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Loading and error states render in `.detail-inner` as `.small` text with the existing buttons.
reads.patch=null;await page.unroute('**/images/inspect/**');
await page.route('**/images/inspect/**',route=>route.fulfill({status:500,json:{success:false,error:'broken'}}));
@@ -243,7 +249,7 @@ test('tag chip filters Images by that tag',async({page,request})=>{
await page.goto('/prompt_manager/workspace#/images');await selectFile(page,'workflow-only.png');
const panel=page.getByRole('complementary',{name:'Selected image details',exact:true});
const listing=page.waitForRequest(item=>item.url().includes('/images/output?')&&new URL(item.url()).searchParams.has('tags'));
await panel.locator('.detail-inner > .tags').getByRole('button',{name:'geometric',exact:true}).click();
await panel.getByRole('region',{name:'Prompt tags',exact:true}).getByRole('button',{name:'geometric',exact:true}).click();
expect(new URL((await listing).url()).searchParams.get('tags')).toBe('geometric');
await expect(page.locator('article[data-image-key="0:inspector/workflow-only.png"]')).toBeVisible();
await expect(page.locator('article[data-image-key="0:inspector/complete.png"]')).toBeVisible();
@@ -290,14 +296,14 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
// The panel content without the cover, D117's Copy settings summary after the facts, then the two disclosures,
// then the pair send and history buttons (D20) and D139's Collections….
expect(await viewerStructure(details)).toEqual({outer:['div','button.send','button','button'],
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','div.tags','dl.meta-grid','button.settings-summary','details','details']});
inner:['div.panel-heading','p.small','div.tags','section.prompt-section.positive','section.prompt-section.negative','section.tag-section.linked-tags','dl.meta-grid','button.settings-summary','details','details']});
await expect(details.locator(':scope > div > button')).toHaveText('Copy settings summary');
await expect(details.locator(':scope > div > button.settings-summary')).toHaveCSS('white-space','normal');
await expect(details.locator('.detail-cover,.detail-inner,.detail-footer')).toHaveCount(0);
await expect(details.locator('.panel-heading h2')).toHaveText(POSITIVE_TITLE);await expect(details.locator('.panel-heading .chip')).toHaveText('PNG');
await expect(details.locator(':scope > div > p.small')).toHaveText('complete.png');
await expect(details.locator('span.chip.blue')).toHaveText('Workflow inferred');
await expect(details.locator(':scope > div > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);
await expect(details.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);
await expect(details.locator('.meta-grid').first().locator('dd')).toHaveText(['9007199254740993','28','6.5','dpmpp_2m','karras','fixture-model.safetensors']);
await expect(details.locator('summary')).toHaveText(['File & provenance','Workflow & raw metadata']);
await expect(details.locator(':scope > button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
@@ -311,7 +317,9 @@ test('viewer details hold provenance, raw records and downloads',async({page,req
await expect(provenance.getByText('Prompt evidence: workflow inferred',{exact:true})).toBeVisible();
await expect(provenance.getByText('Saved association; not verified against current file',{exact:true})).toBeVisible();
await expect(provenance.getByRole('link',{name:'Download original',exact:true})).toBeVisible();
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension|Recipe tags/);
// D154 (#449) labels the recipe's tags in their own section; the other legacy labels stay gone.
await expect(details).not.toContainText(/Configuration evidence|Embedded parameter evidence|prompt evidence|extension/);
await expect(details.locator('.tag-section.linked-tags .role-label')).toHaveText('Recipe tags · 2');
// Workflow & raw metadata: the concept's focusable `pre`, then the downloads with their unavailable reasons.
const raw=details.locator('details').last();await raw.locator('summary').click();
await expect(raw.locator('pre[tabindex="0"] > code').first()).toBeVisible();
@@ -467,7 +475,7 @@ test('full inspector keeps long literal content reachable on mobile',async({page
test('embedded gallery uses the same full inspector',async({page,request})=>{
await expect(await request.post('/__test__/reset',{data:{full_inspector:true}})).toBeOK();const list=await(await request.get('/prompt_manager/library')).json();const id=list.items.find(item=>item.text.startsWith('Inspector portrait')).id;
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.locator('.detail-inner > .tags').last().locator('span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
await page.goto(`/prompt_manager/workspace#/library/${id}`);await selectFile(page,'complete.png');const panel=inspector(page);await expect(panel.getByRole('heading',{name:POSITIVE_TITLE,exact:true})).toBeVisible();await expect(panel.locator('.meta-grid dt')).toHaveText(['Seed','Steps','CFG','Sampler','Scheduler','Model']);await expect(panel.getByRole('region',{name:'Recipe tags',exact:true}).locator('.tags > span.chip')).toHaveText(['fixture','geometric']);await expect(panel).not.toHaveAttribute('id','image-inspector');await openSelected(page);await expect(page.locator('#viewer-dialog .file-inspector')).toHaveCount(1);
});
test('Prompt history to the shown route closes the viewer and re-renders it, as the concept go() does',async({page,request})=>{
+52 -26
View File
@@ -1,7 +1,7 @@
import {writeFile} from 'node:fs/promises';
import {test,expect} from './fixtures.mjs';
import {captureCanonicalState} from './history-canonical-comparison.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource} from './concept-inventory.mjs';
import {SIZES,THEMES,seedConcept,openConcept,openProduction,inspectorOf,diffInventory,mutate,countCopy,saveImageTags,withChipSource,withTagSections,PROMPT_TAGS} from './concept-inventory.mjs';
// Task D4 of #249: the Images side panel (app.js imageDetails a400–414) and the viewer's full details (imageDetails(i,
// true)) against the immutable concept, on the concept-shaped real fixture, at 1600x1100 and 390x844 in both themes.
@@ -51,7 +51,8 @@ function withCategoryScope(inventory) {
}
/**
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139 and D146); everything else must
* Recorded inspector deviations only (spec §8, TESTING.md D3, D20–D22, D34, D38, D117, D139, D146 and D154, whose labelled
* tag sections the concept page gets at runtime from `withTagSections`); everything else must
* equal production. `reference.viewerPlain` is the concept's own full details
* with its tag chips made plain (D20), D117's summary button added and, under a coarse pointer, D34's targets, all at
* runtime: the browser lays out what follows them, as the Images spec's removed toolbar items do. D139's
@@ -108,7 +109,8 @@ async function openProductionPanel(page,theme) {
await expect(page.locator(CARDS).first()).toBeVisible();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText(TITLE);
await expect(panelOf(page).locator('.detail-footer')).toBeVisible();
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(2);
await expect(panelOf(page).locator('.detail-inner > .tags')).toHaveCount(1);
await expect(panelOf(page).locator('.detail-inner > .tag-section.linked-tags')).toBeVisible();
await settle(page);
}
@@ -129,13 +131,14 @@ async function withCollectionsButton(page,selector) {
}
/**
* The concept's tag chips in the open viewer as plain `span.chip`s (D20), D34's coarse targets and D117's "Copy
* settings summary" after the six facts (LI-4), drawn as the concept's own plain button (its "Download sample JSON",
* without its action); returns an undo.
* The concept's tag chips in the open viewer in D154's labelled sections (`sections`) as plain `span.chip`s (D20), D34's
* coarse targets and D117's "Copy settings summary" after the six facts (LI-4), drawn as the concept's own plain button
* (its "Download sample JSON", without its action); returns an undo.
*/
async function plainViewerChips(page) {
async function plainViewerChips(page,sections) {
const restoreTargets=await mutate(page,{css:D34_COARSE_TARGETS});
const restoreSource=await withChipSource(page,'#viewer-details',ORIGIN);
const restoreSections=await withTagSections(page,'#viewer-details',sections);
await page.evaluate(()=>{
window.__plainChips=[...document.querySelectorAll('#viewer-details .tags > button.chip')].map(button=>{
const span=document.createElement('span');span.className='chip';span.textContent=button.textContent;
@@ -151,7 +154,7 @@ async function plainViewerChips(page) {
await restoreCollections();
await page.evaluate(()=>{for(const [span,button] of window.__plainChips)span.replaceWith(button);delete window.__plainChips;
window.__summaryButton.remove();delete window.__summaryButton;});
await restoreSource();await restoreTargets();
await restoreSections();await restoreSource();await restoreTargets();
};
}
@@ -169,7 +172,7 @@ async function fileControlsOf(page) {
* The first card's viewer: its full details and open "File & provenance" measured (in the concept also with plain
* chips), then closed again. `mutation` (production) is applied once the viewer is open and undone before it closes.
*/
async function viewerOf(page,concept,mutation=null) {
async function viewerOf(page,concept,mutation=null,sections=PROMPT_TAGS) {
await page.locator(CARDS).first().locator('.image-open').click();
await expect(page.locator('#viewer-dialog[open] #viewer-details .panel-heading h2')).toHaveText(TITLE);
await settle(page);
@@ -178,7 +181,7 @@ async function viewerOf(page,concept,mutation=null) {
const {viewerDetails}=await inspectorOf(page);
const fileControls=await fileControlsOf(page);
let viewerPlain=null;
if(concept) {const restore=await plainViewerChips(page);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
if(concept) {const restore=await plainViewerChips(page,sections);viewerPlain=(await inspectorOf(page)).viewerDetails;await restore();}
await restoreMutation?.();
await page.keyboard.press('Escape');
await expect(page.locator('#viewer-dialog[open]')).toHaveCount(0);
@@ -186,15 +189,18 @@ async function viewerOf(page,concept,mutation=null) {
await page.evaluate(()=>new Promise(resolve=>setTimeout(resolve,0)));await settle(page);
return {viewerDetails,viewerPlain,fileControls};
}
async function inventory(page,concept=false) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history.
async function inventory(page,concept=false,sections=PROMPT_TAGS) {
// D146: the concept panel's category chip with its visible origin; D139: Collections… after Prompt history; D154's
// labelled tag sections (by default the fixture's D39 positive tags under "Prompt tags"), laid out by the concept page.
const restoreSource=concept?await withChipSource(page,'#image-inspector',ORIGIN):null;
const restoreCollections=concept?await withCollectionsButton(page,'#image-inspector > .detail-footer'):null;
const restoreSections=concept?await withTagSections(page,'#image-inspector',sections):null;
if(concept)await settle(page);
const {panel}=await inspectorOf(page);
await restoreSections?.();
await restoreCollections?.();
await restoreSource?.();
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept);
const {viewerDetails,viewerPlain,fileControls}=await viewerOf(page,concept,null,sections);
return concept?{panel,viewerDetails,viewerPlain,fileControls}:{panel,viewerDetails,fileControls};
}
@@ -258,7 +264,8 @@ async function productionSendOf(page,context) {
/** A tag chip: the collection it leaves (the count line), then the chip pressed again for the same result. */
async function tagOf(page) {
const before=await page.locator('#result-count').textContent();
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// The concept's bare row, or production's linked-tags section (D154): the last chip row either way.
await panelOf(page).locator('.detail-inner .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
// Production's caption reads "Loading images…" until the tag's listing answers (#391); the concept's search is instant.
await expect.poll(async()=>{const text=await page.locator('#result-count').textContent();return text!==before&&text!=='Loading images…';}).toBe(true);
await settle(page);
@@ -323,6 +330,9 @@ for(const [width,height] of SIZES)for(const theme of THEMES)test(`inspector pane
expect.soft(panel.badges.chips.map(chip=>chip.text),'concept badges').toEqual(['Execution recorded','Landscapes · prompt']);
expect.soft(reference.viewerDetails.structure.inner.slice(-2),'concept full details').toEqual(['details','details']);
// D154: both documents measured the labelled section, so the comparison covers it (never two absent sections).
for(const [name,inventory] of [['concept panel',reference.panel],['concept viewer',reference.viewerPlain],['panel',actual.panel],['viewer',actual.viewerDetails]])
expect.soft(inventory.tagSections.map(section=>section.label.text),`${name} tag sections`).toEqual(['Prompt tags · 3']);
expect.soft(diff,'differences after the recorded deviations').toEqual([]);
// Copy: the concept's toast "Prompt copied." (or its dialog), in both documents.
expect.soft(production.copy,'copy').toEqual(concept.copy);
@@ -352,14 +362,17 @@ test('a recipe-linked image without recipe metadata shows its positive prompt me
await openProductionPanel(page,'dark');
await page.locator(CARDS).nth(1).locator('[aria-label^="Inspect"]').click();
await expect(panelOf(page).locator('.panel-heading h2')).toHaveText('Quiet geometry');
const rows=panelOf(page).locator('.detail-inner > .tags');
await expect(rows).toHaveCount(2);
await expect(rows.first().locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(rows.first().locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
const badges=panelOf(page).locator('.detail-inner > .tags');
await expect(badges).toHaveCount(1);
await expect(badges.locator('.chip')).toHaveText(['Execution recorded','Positive prompt category: Architecture · prompt']);
await expect(badges.locator('.chip .ca-visually-hidden')).toHaveText('Positive prompt category: ');
// D146 (#332): the visible origin names where the value came from.
await expect(rows.first().locator('.chip .chip-source')).toHaveText(' · prompt');
await expect(rows.nth(1).locator('button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(rows.nth(1).locator('span.chip')).toHaveCount(0);
await expect(badges.locator('.chip .chip-source')).toHaveText(' · prompt');
// D154: the positive's tags, so the section is "Prompt tags", not "Recipe tags".
const tags=panelOf(page).getByRole('region',{name:'Prompt tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Prompt tags · 3');
await expect(tags.locator('.tags > button.chip')).toHaveText(['minimal','warm_light','editorial']);
await expect(tags.locator('span.chip')).toHaveCount(0);
await expect(panelOf(page).locator('.detail-footer button')).toHaveText(['Send to ComfyUI','Prompt history','Collections…']);
});
@@ -373,7 +386,7 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
await page.route(url=>url.pathname==='/prompt_manager/images/output'&&url.searchParams.get('tags')==='cinematic',
async route=>{arrived();await held;await route.continue();},{times:1});
const count=page.locator('#result-count');
await panelOf(page).locator('.detail-inner > .tags').last().getByRole('button',{name:'cinematic',exact:true}).click();
await panelOf(page).locator('.tag-section.linked-tags').getByRole('button',{name:'cinematic',exact:true}).click();
await reached;
await expect(count).toHaveText('Loading images…');
await expect(page.locator(CARDS)).toHaveCount(0);
@@ -383,8 +396,9 @@ test('a tag chip collection reads as loading, not empty, until its listing answe
});
// Task C3 (D88, #249 PR C): the concept's chip row is the image's own tags (`i.tags`, app.js a402). With those names
// saved as the file's current image tags, production draws the row from them (Tags filter buttons in the panel, plain
// in the viewer, the linked positive's equal tags not repeated) and still equals the concept, in both sizes.
// saved as the file's current image tags, production draws them in D154's "Image tags" section (Tags filter buttons in
// the panel, plain in the viewer) and the linked positive's equal tags in its "Prompt tags" section after it (#449),
// and equals the concept with its row in those two sections, in both sizes.
for(const [width,height] of SIZES)test(`image tags saved on the concept image keep the inspector equal to the concept ${width}x${height} (D88)`,async({page,request},testInfo)=>{
test.setTimeout(120_000);
const viewport={width,height};await page.setViewportSize(viewport);
@@ -392,13 +406,14 @@ for(const [width,height] of SIZES)test(`image tags saved on the concept image ke
const names=await panelOf(page).locator('.detail-inner > .tags').last().locator('button.chip').allTextContents();
expect(names).toEqual(['cinematic','blue_hour','architecture']);
await captureCanonicalState(page,testInfo,`concept-image-tags-${width}`,'#image-inspector');
const reference=await inventory(page,true);
const reference=await inventory(page,true,[['image-tags','Image tags','Image tags'],...PROMPT_TAGS]);
const {path}=testInfo.manifest.images.find(item=>item.filename==='alpine_00001.png');
expect(await saveImageTags(request,path,names)).toEqual(names);
const read=page.waitForResponse(item=>new URL(item.url()).pathname===`/prompt_manager/images/inspect/${path}`);
await openProductionPanel(page,'dark');
expect((await (await read).json()).image_tags).toEqual({state:'current',tags:names});
await expect(panelOf(page).locator('.detail-inner > .tags').last().locator('.chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.image-tags .chip')).toHaveText(names);
await expect(panelOf(page).locator('.tag-section.linked-tags .chip')).toHaveText(names);
await captureCanonicalState(page,testInfo,`production-image-tags-${width}`,'#image-inspector');
assertInspectorMatch(reference,await inventory(page),viewport);
});
@@ -423,6 +438,17 @@ test('inspector comparison detects mutations',async({page})=>{
await restoreCss();await settle(page);
assertInspectorMatch(reference,await measure(),viewport);
// D154: the tag section's muted label and its row's spacing are part of the mapped concept.
for(const [css,path] of [['#image-inspector .tag-section .role-label { color: rgb(255, 0, 0) }',/panel\.tagSections\.0\.label\.color/],
['#image-inspector .tag-section > .tags { margin-top: 12px }',/panel\.tagSections\.0\.row\.marginTop/],
['#image-inspector .tag-section { border-top-width: 0 }',/panel\.tagSections\.0\.borderTopWidth/]]) {
const restore=await mutate(page,{css});
const mutated=await measure();
expect(()=>assertInspectorMatch(reference,mutated,viewport)).toThrow(path);
await restore();await settle(page);
}
assertInspectorMatch(reference,await measure(),viewport);
const panel=(await inspectorOf(page)).panel;
const viewerMutated=async mutation=>{const {viewerDetails,fileControls}=await viewerOf(page,false,mutation);return {panel,viewerDetails,fileControls};};
const withoutActions=await viewerMutated({remove:['#viewer-details > button']});
+16 -4
View File
@@ -363,14 +363,26 @@ test('a list read superseded by another read still opens a collection made since
const fresh = (await added.json()).collection.id;
await expect(rows(page)).toHaveCount(2);
const hold = await holdListReads(page);
// A rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The timer's read
// supersedes that one while both are held.
// A confirmed rename schedules the 300 ms count re-read; opening "Fresh" first reads the list once more. The
// timer's read supersedes that one while both are held. The rename's answer is held until "Fresh"'s read is, so
// the timer starts only then: with the answer let through at once, a slow host could fire the timer before the
// hash change, and the page would then wait on the timer's read alone (#459).
let releaseRename;
const renameHeld = new Promise(resolve => { releaseRename = resolve; });
await page.route(`**${API}/${manifest.collections.Landscapes.id}`, async route => {
if (route.request().method() !== 'PATCH') return route.fallback();
await renameHeld;
return route.fallback();
});
await editButton(page).click();
await nameField(page).fill('Landscape grids');
await editDialog(page).getByRole('button', { name: 'Save', exact: true }).click();
await expect(toast(page)).toHaveText('Collection renamed.');
await page.evaluate(hash => { window.location.hash = hash; }, `#/collection/${fresh}`);
await expect.poll(() => hold.held.length).toBeGreaterThanOrEqual(2);
await expect.poll(() => hold.held.length).toBe(1);
releaseRename();
// The rename landed (the sidebar row has the new name), so the count read is now due.
await expect(row(page, 'Landscape grids')).toHaveCount(1);
await expect.poll(() => hold.held.length).toBe(2);
await expect(main(page)).not.toContainText('Collections could not be loaded.');
await hold.release();
await expect(heading(page)).toHaveText('Fresh');
+19
View File
@@ -495,6 +495,25 @@ test('the inline current file names its prompts apart from the frozen run fields
expect(await exact(dialog(page).getByRole('region',{name:'Positive prompt',exact:true}).locator('p.prompt-text'))).toBe(before);
});
// #449 (D154): History's inline current file draws tags in the shared labelled sections, named with its context so they
// stay apart from the frozen run's fields. The server sends History no image_tags facet today (D88), so the read is
// given one here to prove the shared render in History.
test('the inline current file labels its image tags after its negative prompt',async({page})=>{
await page.route(url=>url.pathname.endsWith('/inspection'),async route=>{const response=await route.fetch();const body=await response.json();
body.prompts={positive:{status:'resolved',text:'current file positive',source:'graph-inferred'},negative:{status:'resolved',text:'current file negative',source:'graph-inferred'}};
body.image_tags={state:'current',tags:['history_tag']};
await route.fulfill({response,json:body});});
await open(page,page.fixture.detail_id);await marker(page,page.fixture.runs.shared).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);
await dialog(page).getByRole('button',{name:'Raw',exact:true}).click();
await dialog(page).locator('[data-receipt]').first().getByRole('button',{name:'View current file',exact:true}).click();
await expect(page.locator('dialog#viewer-dialog')).toBeVisible();await page.keyboard.press('Escape');await expect(dialog(page)).toBeVisible();
const current=dialog(page).getByRole('region',{name:'Current reported file',exact:true});
const tags=current.getByRole('region',{name:'Current file image tags',exact:true});
await expect(tags.locator('.role-label')).toHaveText('Image tags · 1');await expect(tags.locator('.tags > .chip')).toHaveText(['history_tag']);
const negative=current.getByRole('region',{name:'Current file negative prompt',exact:true});
expect(await negative.evaluate((node,section)=>({after:Boolean(node.compareDocumentPosition(section)&Node.DOCUMENT_POSITION_FOLLOWING),inside:node.contains(section)}),await tags.elementHandle())).toEqual({after:true,inside:false});
});
// #332 CUJ 5 (MC8, §5.3): History's current-file inspector reads the file's own category with the Images key rule, so
// the inline current file and its viewer show "Portraits · this image"; current.png has two saved links, so it inherits
// nothing and shows no chip before.
@@ -16,7 +16,7 @@ const toasts = page => page.evaluate(() => window.__toasts);
const chips = page => modal(page).locator('#tag-suggestions .chip.tag-suggestion');
const actionButtons = page => modal(page).locator('.dialog-actions button');
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const NOT_DOWNLOADED = 'WD14 SwinV2 is not downloaded. Download it in System › Auto-tagging.';
test.beforeEach(async ({ page, request, browserName }) => {
@@ -110,7 +110,7 @@ test('Add tags runs a job and the cards show the tags after it finishes', async
await expect(bar(page).locator('strong')).toHaveText('2 selected');
expect((await ended(request, job.id)).counters).toEqual({ tagged: 2, unchanged: 0, skipped: 0, errors: 0 });
// The finished job's files are read again: the selected card's panel shows its new image tags.
await expect(tagRow(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['sunset', 'blue_hour']);
expect(await savedTags(request, SECOND)).toEqual(['sunset', 'blue_hour']);
expect(await savedTags(request, UNLINKED)).toEqual([]);
});
@@ -275,7 +275,7 @@ test('Review each image walks the selection one dialog at a time and only approv
expect(await savedTags(request, SECOND)).toEqual([]);
expect(await savedTags(request, UNLINKED)).toEqual(['sandstone', 'desert']);
// The reviewed file's panel shows its saved tags without reselecting.
await expect(tagRow(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['mine', 'sandstone']);
await expect.poll(async () => (await (await request.get('/__test__/tagging/captures')).json()).captures).toBe(0);
});
+62 -20
View File
@@ -458,27 +458,32 @@ test('virtualized cards release listeners on unmount', async ({ page, request })
expect(await count()).toBe(mounted);
});
// Task C3 (D88): the side panel and the viewer details draw the file's own current image tags first, then D21's linked
// tags not already shown (case-insensitive). Panel image tags are Tags filter buttons; the viewer's chips are plain.
// Task C3 (D88), #449 (D154): the side panel and the viewer details draw the file's own current image tags in their own
// "Image tags" section, then D21's linked tags in a "Prompt tags" or "Recipe tags" section after it. Panel image tags are
// Tags filter buttons; the viewer's chips are plain.
const panel = page => page.getByRole('complementary', { name: 'Selected image details', exact: true });
const tagRow = page => panel(page).locator('.detail-inner > .tags').last();
const imageTags = page => panel(page).getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
const linkedTags = page => panel(page).locator('.tag-section.linked-tags > .tags');
const inspected = (page, path) => page.waitForResponse(item => new URL(item.url()).pathname === `/prompt_manager/images/inspect/${path}`);
const infoButton = (page, path) => card(page, path).getByRole('button', { name: `Inspect ${path.split('/').pop()}`, exact: true });
test('inspector shows saved image tags after Save without reselecting', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
// D21/D39: the linked recipe's own tag, plain.
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(tagRow(page).locator('button.chip')).toHaveCount(0);
// D21/D39: the linked recipe's own tag, plain, and no image tags yet.
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('button.chip')).toHaveCount(0);
await expect(panel(page).locator('.tag-section.image-tags')).toHaveCount(0);
await openTag(page, FIRST, { goto: false });
await manual(page).fill('dunes_tag, Editorial');
const reread = inspected(page, FIRST);
await dialog(page).getByRole('button', { name: 'Save manual tags', exact: true }).click();
expect((await (await reread).json()).image_tags).toEqual({ state: 'current', tags: ['dunes_tag', 'Editorial'] });
// The image tags lead as filter buttons; the recipe's "editorial" is already shown as "Editorial".
await expect(tagRow(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
// The image tags lead as filter buttons in their own section; the recipe keeps its "editorial" in its own (D154).
await expect(imageTags(page).locator('.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial']);
await expect(panel(page).locator('.tag-section.image-tags .role-label')).toHaveText('Image tags · 2');
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
});
@@ -490,9 +495,10 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
await infoButton(page, UNLINKED).click();
await read;
await expect(panel(page).locator('.panel-heading h2')).toHaveText('unlinked.png');
await expect(tagRow(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(imageTags(page).locator('.chip')).toHaveText(['loose_tag']);
await expect(panel(page).locator('.tag-section.linked-tags')).toHaveCount(0);
const listing = page.waitForRequest(item => item.url().includes('/images/output?') && new URL(item.url()).searchParams.get('tags') === 'loose_tag');
await tagRow(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'loose_tag', exact: true }).click();
await listing;
await expect(page.locator('.gallery-body article[data-image-key]')).toHaveCount(1);
await expect(card(page, UNLINKED)).toBeVisible();
@@ -502,18 +508,53 @@ test('clicking an image tag chip filters Images to the tagged unlinked file', as
test('viewer details show the same chips', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'Editorial', 'sunlit']);
await page.goto(images);
await expect(tagRow(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(imageTags(page).locator('button.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const row = details.locator('.tags').last();
const row = details.getByRole('region', { name: 'Image tags', exact: true }).locator('.tags');
await expect(row.locator('.chip')).toHaveText(['dunes_tag', 'Editorial', 'sunlit']);
await expect(row.locator('span.chip')).toHaveCount(3);
await expect(row.locator('button')).toHaveCount(0);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
// A file without image tags: the viewer shows only D21's linked tags.
await page.getByRole('button', { name: 'Next media', exact: true }).click();
await expect(page.locator('#viewer-dialog[open] #viewer-title')).toHaveText(`${manifest.title} · dunes_00002.png`);
await expect(details.locator('.tags').last().locator('.chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.linked-tags .chip')).toHaveText(manifest.pair_tags);
await expect(details.locator('.tag-section.image-tags')).toHaveCount(0);
});
// #449 (D154): the image tags are a labelled region of their own after the negative prompt, framed like a prompt section,
// so they never read as part of it; the same in the side panel and the viewer details.
async function tagSectionPlacement(root) {
return root.evaluate(node => {
const negative = node.querySelector('section.prompt-section.negative'), tags = node.querySelector('section.tag-section.image-tags');
const style = getComputedStyle(tags), label = getComputedStyle(tags.querySelector('.prompt-heading .role-label'));
return {
after: Boolean(negative.compareDocumentPosition(tags) & Node.DOCUMENT_POSITION_FOLLOWING), inside: negative.contains(tags),
below: tags.getBoundingClientRect().top >= negative.getBoundingClientRect().bottom,
border: `${style.borderTopWidth} ${style.borderTopStyle}`, paddingTop: style.paddingTop, marginTop: style.marginTop,
label: {transform: label.textTransform, size: label.fontSize, sameColorAsNegative: label.color === getComputedStyle(negative.querySelector('.role-label')).color},
};
});
}
const PLACED = {after: true, inside: false, below: true, border: '1px solid', paddingTop: '15px', marginTop: '15px',
label: {transform: 'uppercase', size: '10px', sameColorAsNegative: false}};
test('D154: image tags sit in their own labelled section after the negative prompt, in the panel and the viewer', async ({ page, request }) => {
await saveImageTags(request, FIRST, ['dunes_tag', 'sunlit']);
await page.goto(images);
const section = panel(page).getByRole('region', { name: 'Image tags', exact: true });
await expect(section.locator('.role-label')).toHaveText('Image tags · 2');
await expect(section.locator('.tags > button.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(panel(page))).toEqual(PLACED);
await card(page, FIRST).locator('.image-open').click();
const details = page.locator('#viewer-dialog[open] #viewer-details');
await expect(details.locator('.panel-heading h2')).toHaveText(manifest.title);
const viewerSection = details.getByRole('region', { name: 'Image tags', exact: true });
await expect(viewerSection.locator('.role-label')).toHaveText('Image tags · 2');
await expect(viewerSection.locator('.tags > span.chip')).toHaveText(['dunes_tag', 'sunlit']);
expect(await tagSectionPlacement(details)).toEqual(PLACED);
});
test('search finds an image by its image tag', async ({ page, request }) => {
@@ -570,7 +611,7 @@ const listingOf = (page, check) => page.waitForResponse(item => item.url().inclu
test('the post-save re-read keeps the panel content until the new read lands', async ({ page }) => {
await page.goto(images);
await expect(card(page, FIRST)).toHaveClass(/\bselected\b/);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
await page.evaluate(() => {
const node = document.querySelector('aside[aria-label="Selected image details"]');
window.__loadingSeen = false;
@@ -589,12 +630,13 @@ test('the post-save re-read keeps the panel content until the new read lands', a
await expect(toast(page)).toHaveText('1 tags saved for dunes_00001.png.');
// While the re-read is held, the panel still shows the file's details.
await expect(panel(page).locator('.panel-heading h2')).toHaveText(manifest.title);
await expect(tagRow(page).locator('.chip')).toHaveText(manifest.pair_tags);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => window.__loadingSeen)).toBe(false);
const landed = inspected(page, FIRST);
release();
await landed;
await expect(tagRow(page).locator('.chip')).toHaveText(['held_tag', ...manifest.pair_tags]);
await expect(imageTags(page).locator('.chip')).toHaveText(['held_tag']);
await expect(linkedTags(page).locator('.chip')).toHaveText(manifest.pair_tags);
expect(await page.evaluate(() => { window.__loadingWatch.disconnect(); return window.__loadingSeen; })).toBe(false);
await page.unroute(matcher);
});
@@ -607,7 +649,7 @@ test('a save under a Tags filter re-reads the listing: the selection stays while
await infoButton(page, SECOND).click();
await read;
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
// The newest match (SECOND) is selected once the filter applies.
@@ -767,7 +809,7 @@ test('a tag save from Tags reloads a filtered Images visit instead of restoring
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await expect(card(page, SECOND)).toHaveClass(/\bselected\b/);
@@ -792,7 +834,7 @@ test('a failed listing re-read after a save says so and keeps the cards (M2)', a
await page.goto(images);
await infoButton(page, SECOND).click();
const filtered = listingOf(page, query => query.get('tags') === 'shared');
await tagRow(page).getByRole('button', { name: 'shared', exact: true }).click();
await imageTags(page).getByRole('button', { name: 'shared', exact: true }).click();
await filtered;
await expect(cards(page)).toHaveCount(2);
await openTag(page, SECOND, { goto: false });
+13 -9
View File
@@ -263,15 +263,18 @@ test.describe('public chronological history with the actual acknowledged host br
});
});
test('a cached dynamic unknown positive stays readable by run UUID and outside the known raw Library scope', async ({ execution }) => {
const text = `unknown positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
// #403: the graph cannot show a dynamic positive's raw text; a cached job takes it from the execution it reuses.
test('a cached dynamic positive takes its raw text from the execution it reuses and joins the Library scope', async ({ execution }) => {
const text = `dynamic positive ${randomUUID()}`, graph = recipeGraph({ text, dynamic: true });
const first = await submit(execution.request, graph);await history(execution.request, first);const known = (await capture(execution, first)).contexts[0];
const second = await submit(execution.request, graph);await history(execution.request, second);const unknown = (await capture(execution, second)).contexts[0];
expect(unknown.positive.raw).toBeNull();expect(unknown.positive.effective).toBe(known.positive.effective);
const second = await submit(execution.request, graph);await history(execution.request, second);const reused = await capture(execution, second);const cached = reused.contexts[0];
expect(cached.evidence.positive.effective.source).toBe('host_cache_output');expect(cached.positive.effective).toBe(known.positive.effective);
expect(cached.positive.raw).toBe(known.positive.raw);expect(cached.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cached.positive_prompt_id).toBe(known.positive_prompt_id);
const result = await readExecutionRuns(execution.databasePath, second), runId = result.rows[0].run_id;
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBeNull();
const detail = await execution.request.get(`/prompt_manager/history/runs/${runId}`);expect(detail.ok()).toBe(true);expect((await detail.json()).contexts[0].positive.raw).toBe(known.positive.raw);
const response = await execution.request.get(`/prompt_manager/library/${known.positive_prompt_id}/history`);expect(response.ok()).toBe(true);const window = await response.json();
expect(window.recorded_run_count).toBe(1);expect(window.items.map(item => item.run_id)).not.toContain(runId);
expect(window.recorded_run_count).toBe(2);expect(window.items.map(item => item.run_id)).toContain(runId);
});
test('actual PM A,A,B jobs persist two combinations and one identical positive snapshot', async ({ execution }) => {
@@ -369,7 +372,7 @@ test('successful source capture survives downstream failure and real interruptio
}
});
test('cached dynamic raw stays unknown while literal affixes and effective cache text survive', async ({ execution }) => {
test('cached dynamic raw comes from the reused execution while literal affixes and effective cache text survive (#403)', async ({ execution }) => {
const graph = recipeGraph({ dynamic: true, text: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ' });
const first = await submit(execution.request, graph);
await history(execution.request, first);
@@ -377,8 +380,9 @@ test('cached dynamic raw stays unknown while literal affixes and effective cache
const second = await submit(execution.request, graph);
await history(execution.request, second);
const value = (await capture(execution, second)).contexts[0];
expect(value.positive).toEqual({ raw: null, prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw.provenance).toBe('unknown');
expect(value.positive).toEqual({ raw: 'runtime dynamic recipe', prepend: ' pre ', append: ' post ', effective: 'pre runtime dynamic recipe post' });
expect(value.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(value.evidence.positive.prepend.source).toBe('graph_literal');
expect(value.evidence.positive.effective.source).toBe('host_cache_output');
});
+20 -2
View File
@@ -287,6 +287,14 @@ async function capturedContext(execution, id) {
return result.captures[result.rows[0].run_id].contexts[0];
}
// #403: the prompt's history "tracked runs", once every job's capture reached SQLite.
async function expectTrackedRuns(execution, promptId, expected) {
await expect.poll(async () => {
const response = await execution.request.get(`/prompt_manager/library/${promptId}/history`);
return response.ok() ? (await response.json()).recorded_run_count : null;
}, { timeout: 10000, message: 'history tracked runs equal the Library run count' }).toBe(expected);
}
async function firstRun(execution, page, testCase) {
const prefix = `textlink/${randomUUID()}`;
const built = await build(page, { ...testCase, prefix });
@@ -309,6 +317,8 @@ for (const kind of ['PromptManager', 'PromptManagerFocused', 'PromptManagerSplit
const counts = await runCounts(execution, first.context);
expect(counts.positive).toBe(3);
if (pair) expect(counts).toMatchObject({ negative: 0, pair: 3 });
// #403: the cached jobs are tracked runs of the prompt too
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
});
}
@@ -329,6 +339,7 @@ test.describe('cached runs in one renderer (#347)', () => {
else expect(again.cached).toContain(first.promptNode);
}
expect((await runCounts(execution, first.context)).positive).toBe(3);
await expectTrackedRuns(execution, first.context.positive_prompt_id, 3);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
}
@@ -352,6 +363,9 @@ test.describe('cached runs in one renderer (#347)', () => {
const bContext = await capturedContext(execution, second.id);
expect(bContext.positive.raw).toBe(b);
expect((await runCounts(execution, bContext)).positive).toBe(1);
// #403: the third job reuses A's execution, not the newest (B's)
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
await expectTrackedRuns(execution, bContext.positive_prompt_id, 1);
} finally { await setCache(execution, 'RAM_PRESSURE'); }
});
@@ -390,10 +404,14 @@ test.describe('cached runs in one renderer (#347)', () => {
} });
expect(rewired.cached).toContain(first.promptNode);
expect((await runCounts(execution, first.context)).positive).toBe(1);
// The history capture of a cached job is unchanged: effective text from the cache, no stored-prompt hint
// #403: a cached job's history takes the raw text of the execution it reuses and names the prompt
const cachedContext = await capturedContext(execution, (await queueAgain(execution, first, { seed: 3 })).id);
expect(cachedContext.positive.effective).toBe(first.context.positive.effective);
expect(cachedContext.positive_prompt_id).toBeNull();
expect(cachedContext.positive.raw).toBe(first.context.positive.raw);
expect(cachedContext.evidence.positive.raw).toMatchObject({ provenance: 'inferred', source: 'graph_semantics' });
expect(cachedContext.positive_prompt_id).toBe(first.context.positive_prompt_id);
expect((await runCounts(execution, first.context)).positive).toBe(2);
// The rewired job used the prompt as its negative: neither number counts it
await expectTrackedRuns(execution, first.context.positive_prompt_id, 2);
});
});
+62 -5
View File
@@ -209,16 +209,18 @@ test('a branch named by recorded run history shows the Recorded run badge', asyn
function tagged(imageTags, library = {status:'linked', tags:['Minimal', 'warm_light', 'editorial'], tags_scope:'positive'}) {
return {...record(), imageTags, library};
}
test('renders current image tags before linked tags without duplicates', async () => {
test('renders current image tags before linked tags, each without repeats within its source', async () => {
const {inspectorTagChips} = await load();
assert.equal(typeof inspectorTagChips, 'function', 'inspectorTagChips is exported');
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}),
[{name:'dunes', filter:true}, {name:'minimal', filter:true}, {name:'warm_light', filter:true}, {name:'editorial', filter:true}]);
// D154 amends D88: a linked tag that is also an image tag stays in its own labelled section.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal', 'Dunes']}), {filterable:true}),
[{name:'dunes', filter:true, source:'image'}, {name:'minimal', filter:true, source:'image'},
{name:'Minimal', filter:true, source:'linked'}, {name:'warm_light', filter:true, source:'linked'}, {name:'editorial', filter:true, source:'linked'}]);
// D21's rule still decides the linked chips: a recipe's own tags stay plain, an image tag stays a filter.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['Editorial']}, {status:'linked', tags:['editorial', 'cover'], tags_scope:'pair'}), {filterable:true}),
[{name:'Editorial', filter:true}, {name:'cover', filter:false}]);
[{name:'Editorial', filter:true, source:'image'}, {name:'editorial', filter:false, source:'linked'}, {name:'cover', filter:false, source:'linked'}]);
// An unlinked file shows only its own tags; a facet-less record (history, local) only the linked ones.
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true}]);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), {filterable:true}), [{name:'loose', filter:true, source:'image'}]);
assert.deepEqual(inspectorTagChips(tagged(null), {filterable:true}).map(chip => chip.name), ['Minimal', 'warm_light', 'editorial']);
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:[]}, {status:'ambiguous', tags:['x'], tags_scope:'positive'}), {filterable:true}), []);
});
@@ -236,6 +238,61 @@ test('none and unavailable render no image chips', async () => {
}
});
// #449 (D154): the image's own tags and the linked prompt's or recipe's tags each get a labelled section after the
// prompt blocks, so per-image tags never read as part of the negative prompt.
function renderVariant(record, variant, options = {}) {
const f = mountedDom(), scope = createScope();
const view = mountFileInspector(f.container, {sender:{open() {}}, parentScope:scope, variant, ...options});
view.setState({loading:false, error:false, record});
return {f, done() {view.dispose(); scope.dispose(); f.restore();}};
}
const tagSections = f => f.walk(f.container, n => n.tagName === 'section' && /(^| )tag-section( |$)/.test(n.className ?? ''));
function sectionSummary(f, section) {
const label = f.walk(section, n => /(^| )role-label( |$)/.test(n.className ?? ''))[0];
const chips = f.walk(section, n => /(^| )chip( |$)/.test(n.className ?? ''));
return {className:section.className, name:section.getAttribute('aria-label'), label:label?.textContent, chips:chips.map(chip => `${chip.tagName}:${chip.textContent}`)};
}
test('chips name their source: the image or the linked prompt', async () => {
const {inspectorTagChips, tagSectionLabel} = await load();
assert.deepEqual(inspectorTagChips(tagged({state:'current', tags:['dunes', 'minimal']}), {filterable:true}).map(chip => chip.source),
['image', 'image', 'linked', 'linked', 'linked']);
assert.equal(tagSectionLabel('image', {tags_scope:'pair'}), 'Image tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'positive'}), 'Prompt tags');
assert.equal(tagSectionLabel('linked', {tags_scope:'pair'}), 'Recipe tags');
});
test('image tags and linked tags render in their own labelled sections', async () => {
for (const [variant, options, tag] of [['panel', {onTagFilter() {}}, 'button'], ['full', {}, 'span']]) {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes', 'minimal']}), variant, options);
try {
const sections = tagSections(f).map(section => sectionSummary(f, section));
assert.deepEqual(sections, [
{className:'tag-section image-tags', name:'Image tags', label:'Image tags · 2', chips:[`${tag}:dunes`, `${tag}:minimal`]},
{className:'tag-section linked-tags', name:'Prompt tags', label:'Prompt tags · 3', chips:[`${tag}:Minimal`, `${tag}:warm_light`, `${tag}:editorial`]},
], variant);
// No bare chip row follows the prompt blocks any more.
const inner = f.container.children.find(n => n.tagName === 'div');
assert.equal(inner.children.filter(n => n.className === 'tags').length, 0, variant);
} finally { done(); }
}
});
test('a recipe names its tags and the context names the regions', async () => {
const {f, done} = renderVariant(tagged({state:'current', tags:['dunes']}, {status:'linked', tags:['cover'], tags_scope:'pair'}), 'panel', {promptContext:'Current file'});
try {
assert.deepEqual(tagSections(f).map(section => sectionSummary(f, section)), [
{className:'tag-section image-tags', name:'Current file image tags', label:'Image tags · 1', chips:['span:dunes']},
{className:'tag-section linked-tags', name:'Current file recipe tags', label:'Recipe tags · 1', chips:['span:cover']},
]);
} finally { done(); }
});
test('an empty tag source draws no section', async () => {
let view = renderVariant(tagged(null), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Prompt tags · 3']); } finally { view.done(); }
view = renderVariant(tagged({state:'current', tags:['loose']}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.deepEqual(tagSections(view.f).map(section => sectionSummary(view.f, section).label), ['Image tags · 1']); } finally { view.done(); }
view = renderVariant(tagged({state:'none', tags:[]}, {status:'none', tags:[], tags_scope:null}), 'full');
try { assert.equal(tagSections(view.f).length, 0); } finally { view.done(); }
});
// LI-4 (D117): "Copy settings summary" after the six facts of the full inspector consolidates the legacy metadata
// page's COPY ALL (web/metadata.html copyAllData). The legacy template is read from that page itself and run on the
// legacy parse, so the summary's text is the legacy page's, line for line, wherever the two agree on the values.
+193 -1
View File
@@ -666,7 +666,7 @@ class TestOwnCounts(unittest.TestCase):
return counts
class TestReceipts(unittest.TestCase):
class ReceiptCase(unittest.TestCase):
def setUp(self):
self.context = ("j1", None)
self.runs = CachedRuns(
@@ -686,6 +686,8 @@ class TestReceipts(unittest.TestCase):
def keys(self):
return list(self.runs._receipts)
class TestReceipts(ReceiptCase):
def test_least_recently_used_run_keys_are_dropped(self):
self.begin(1)
for name in ("a", "b", "c"):
@@ -726,6 +728,196 @@ class TestReceipts(unittest.TestCase):
self.assertEqual(list(self.runs._attempt.nodes), ["b"])
class TestRecordedFields(ReceiptCase):
"""The texts a receipt keeps for the history capture of a cached job (#403)."""
F = (("raw a", " pre ", ""),)
def record(self, item_id, name="a", output="x", fields=F, facts=None):
self.begin(item_id)
self.runs.record_item(name, (output,), facts or Facts("h"), fields)
self.runs.end_attempt(item_id)
def lookup(self, name="a", values=(("x",),), prompt_id="j9", item_id=9):
self.begin(item_id, prompt_id)
return self.runs.recorded_fields(prompt_id, name, values)
def test_a_cached_job_reads_the_fields_of_the_execution_it_reuses(self):
self.record(1)
self.assertEqual(self.lookup(), [self.F])
def test_list_items_return_their_own_fields_in_order(self):
self.begin(1)
for index, text in enumerate(("x", "y")):
self.context = ("j1", index)
self.runs.record_item("a", (text,), Facts("h"), ((text, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(
self.lookup(values=(("x", "y"),)), [(("x", "", ""),), (("y", "", ""),)]
)
def test_fails_closed(self):
self.record(1)
for case, kwargs in (
("other output", dict(values=(("changed",),))),
("other node", dict(name="b")),
("unknown node", dict(name="zzz")),
("no values", dict(values=())),
("malformed values", dict(values="x")),
):
with self.subTest(case=case):
self.assertIsNone(self.lookup(**kwargs))
with self.subTest(case="another job"):
self.begin(9, "j9")
self.assertIsNone(self.runs.recorded_fields("j8", "a", (("x",),)))
with self.subTest(case="no job"):
self.runs.end_attempt(9)
self.assertIsNone(self.runs.recorded_fields("j9", "a", (("x",),)))
def test_an_execution_without_fields_gives_none(self):
for fields in (None, (("a", "b"),), ((1, "", ""),), (), "abc"):
with self.subTest(fields=fields):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=fields)
self.assertIsNone(self.lookup())
def test_a_lookup_never_counts_or_marks_the_node_replayed(self):
self.record(1)
self.lookup()
self.assertFalse(self.runs.replayed("a"))
def test_equal_outputs_with_other_raw_text_conflict_and_still_count(self):
key = prompt_node_key(self.g, "a")
self.record(1, fields=(("x ", "", ""),))
self.record(2, fields=(("x", "", ""),))
# Counting dedupes by outputs as before; the fields cannot name either
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertIsNone(self.lookup())
self.assertIsNotNone(self.runs._match(key, (("x",),)))
# The conflict stays: a later execution cannot tell which text it was
self.record(3, fields=(("x", "", ""),))
self.assertIsNone(self.lookup())
def test_equal_outputs_with_other_facts_still_count_the_newest(self):
key = prompt_node_key(self.g, "a")
self.record(1, facts=Facts("old"), fields=(("a x", "", ""),))
self.record(2, facts=Facts("new"), fields=(("x", "a ", ""),))
items = self.runs._match(key, (("x",),))
self.assertEqual([item.facts for item in items], [Facts("new")])
def test_equal_outputs_and_fields_still_replace(self):
key = prompt_node_key(self.g, "a")
self.record(1)
self.record(2)
self.assertEqual([r.item_id for r in self.runs._receipts[key]], [2])
self.assertEqual(self.lookup(), [self.F])
def test_fields_dropped_by_the_budget_do_not_conflict(self):
self.runs = CachedRuns(context=lambda: self.context, max_field_bytes=0)
self.record(1)
self.runs._max_field_bytes = 100
self.record(2)
self.assertEqual(self.lookup(), [self.F])
def test_oversized_or_invalid_fields_are_not_kept(self):
for raw in ("x" * (module.MAX_FIELD_BYTES + 1), "\ud800"):
with self.subTest(size=len(raw)):
self.runs = CachedRuns(context=lambda: self.context)
self.record(1, fields=((raw, "", ""),))
self.assertIsNone(self.lookup())
self.assertEqual(self.runs._field_bytes, 0)
def test_old_receipts_give_up_their_fields_to_new_ones(self):
self.runs = CachedRuns(
context=lambda: self.context, max_keys=3, max_field_bytes=10
)
self.record(1, "a", fields=(("123456", "", ""),))
self.record(2, "b", fields=(("123456", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertIsNone(self.lookup("a"))
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
# "a" still counts
self.assertIsNotNone(self.runs._match(prompt_node_key(self.g, "a"), (("x",),)))
# Larger than the whole budget: not kept, nothing else given up
self.record(3, "c", fields=(("12345678901", "", ""),))
self.assertEqual(self.runs._field_bytes, 6)
self.assertEqual(self.lookup("b"), [(("123456", "", ""),)])
def test_one_receipt_holds_at_most_one_runs_history(self):
with mock.patch.object(module, "MAX_RECEIPT_FIELD_BYTES", 8):
self.begin(1)
for index, raw in enumerate(("12345", "12345")):
self.context = ("j1", index)
self.runs.record_item("a", (raw,), Facts("h"), ((raw, "", ""),))
self.runs.end_attempt(1)
self.assertEqual(self.runs._field_bytes, 5)
self.assertIsNone(self.lookup(values=(("12345", "12345"),)))
def test_the_byte_count_follows_replacement_and_eviction(self):
self.begin(1)
self.runs.record_item("a", ("x",), Facts("h"), (("12345", "", ""),))
self.runs.record_item("a", ("x",), Facts("h"), (("123", "", ""),))
self.assertEqual(self.runs._field_bytes, 3)
self.runs.end_attempt(1)
for item_id, raw in ((2, "1"), (3, "22"), (4, "333")):
self.record(item_id, output=raw, fields=((raw, "", ""),))
# max_receipts=2: only the receipts of jobs 4 and 3 remain
self.assertEqual(self.runs._field_bytes, 5)
for item_id, name in ((5, "b"), (6, "c")): # max_keys=2: "a" is dropped
self.record(item_id, name, fields=(("1", "", ""),))
self.assertEqual(self.runs._field_bytes, 2)
self.assertEqual(
self.runs._field_bytes,
sum(module._bytes(r) for r in self.runs._receipts.values()),
)
class TestCachedJobsRecordFields(JobCase):
"""The real nodes keep the texts they gave the history capture (#403)."""
def lookup(self, g, node_id="pm", prompt_id="job-x"):
self.item_id += 1
self.runs.begin_attempt(
((self.item_id, prompt_id, g, {}, ["save"], {}), self.item_id)
)
values = cached_string_outputs(self.executor, node_id, g[node_id]["class_type"])
found = self.runs.recorded_fields(prompt_id, node_id, values)
self.runs.end_attempt(self.item_id)
return found
def test_each_node_kind_keeps_its_runtime_fields(self):
for kind in (
"PromptManager",
"PromptManagerFocused",
"PromptManagerSplit",
"PromptManagerText",
):
with self.subTest(kind=kind):
g = graph(kind)
self.first(g, text=f" {LLM} ")
self.assertEqual(
self.lookup(graph(kind, seed=2)), [((f" {LLM} ", "", ""),)]
)
def test_pair_keeps_positive_and_negative_fields(self):
g = graph("PromptManagerPair", negative_text="typed negative")
self.first(g, negative="typed negative")
self.assertEqual(
self.lookup(
graph("PromptManagerPair", negative_text="typed negative", seed=2)
),
[((LLM, "", ""), ("typed negative", "", ""))],
)
def test_a_disabled_node_keeps_no_fields(self):
g = graph()
g["pm"]["inputs"]["save_to_library"] = False
self.first(g)
g2 = graph(seed=2)
g2["pm"]["inputs"]["save_to_library"] = False
self.assertIsNone(self.lookup(g2))
class TestCacheReader(unittest.TestCase):
def setUp(self):
patcher = mock.patch.dict(sys.modules, host_modules())
+143 -3
View File
@@ -100,14 +100,19 @@ class HostTests(unittest.TestCase):
self.executor.caches = types.SimpleNamespace(outputs=HierarchicalCache())
def begin(
self, kind="PromptManager", raw=" raw ", prepend=" pre ", append=" post "
self,
kind="PromptManager",
raw=" raw ",
prepend=" pre ",
append=" post ",
negative="negative",
):
g = {
"p": {
"class_type": kind,
"inputs": {
"text": raw,
"negative_text": "negative",
"negative_text": negative,
"prepend_text": prepend,
"append_text": append,
},
@@ -125,7 +130,7 @@ class HostTests(unittest.TestCase):
},
},
}
if type(raw) is list or type(prepend) is list:
if any(type(value) is list for value in (raw, prepend, negative)):
g["dynamic"] = {"class_type": "RuntimeGenerator", "inputs": {}}
g["stateful"] = {
"class_type": "PromptManagerText",
@@ -445,3 +450,138 @@ class HostTests(unittest.TestCase):
ref = install_and_remove()
gc.collect()
self.assertIsNone(ref())
# -- #403: a cached runtime text takes its fields from the reused execution
class Receipts:
"""A fake ``CachedRuns.recorded_fields``: fixed fields, calls recorded."""
def __init__(self, fields):
self.fields, self.calls = fields, []
def recorded_fields(self, prompt_id, node_id, values):
self.calls.append((prompt_id, node_id, values))
return self.fields
def observe_with(self, receipts, outputs, **begin):
self.begin(**begin)
self.executor.caches.outputs.entries["p"] = Entry(None, outputs)
self.observe("execution_start")
host.observe_cached_context(
self.executor,
"execution_cached",
{"prompt_id": JOB, "nodes": ["p"]},
receipts=receipts,
)
return self.seal()
def test_cached_runtime_raw_comes_from_the_matching_receipt(self):
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertEqual(receipts.calls, [(JOB, "p", (("cached",),))])
self.assertEqual(result.raw.value, "LLM raw")
self.assertEqual(
(result.raw.provenance, result.raw.source), ("inferred", "graph_semantics")
)
self.assertEqual(result.effective.value, "cached")
self.assertEqual(result.effective.source, "host_cache_output")
# Fields the graph already knows are never replaced
self.assertEqual(result.prepend.value, " pre ")
self.assertEqual(result.prepend.source, "graph_literal")
self.assertEqual(result.append.value, " post ")
def test_cached_runtime_prepend_comes_from_the_matching_receipt(self):
receipts = self.Receipts([((" raw ", "LLM pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], prepend=["dynamic", 0]
).positive
self.assertEqual(result.prepend.value, "LLM pre")
self.assertEqual(result.prepend.source, "graph_semantics")
self.assertEqual(result.raw.value, " raw ")
self.assertEqual(result.raw.source, "graph_literal")
def test_a_receipt_disagreeing_with_a_known_field_fills_nothing(self):
receipts = self.Receipts([(("LLM raw", "other pre", " post "),)])
result = self.observe_with(
receipts, [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.prepend.value, " pre ")
def test_pair_runtime_negative_takes_the_negative_fields(self):
receipts = self.Receipts(
[((" raw ", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["LLM negative"]],
kind="PromptManagerPair",
negative=["dynamic", 0],
)
self.assertEqual(result.negative.raw.value, "LLM negative")
self.assertEqual(result.negative.raw.source, "graph_semantics")
self.assertEqual(result.positive.raw.value, " raw ")
self.assertEqual(result.positive.raw.source, "graph_literal")
def test_known_typed_fields_are_never_replaced_by_a_receipt(self):
receipts = self.Receipts([(("other", "other", "other"),)])
result = self.observe_with(receipts, [object(), ["cached"]]).positive
self.assertEqual(
(result.raw.value, result.prepend.value, result.append.value),
(" raw ", " pre ", " post "),
)
self.assertEqual(result.raw.source, "graph_literal")
def test_no_matching_receipt_keeps_the_cached_raw_unknown(self):
for fields in (None, [None], [], "bad", [(("a", "b"),)], [((1, "", ""),)]):
with self.subTest(fields=fields):
result = self.observe_with(
self.Receipts(fields), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_a_failing_receipt_lookup_keeps_the_capture(self):
class Broken:
def recorded_fields(self, *args):
raise RuntimeError("boom")
result = self.observe_with(
Broken(), [object(), ["cached"]], raw=["dynamic", 0]
).positive
self.assertIsNone(result.raw.value)
self.assertEqual(result.effective.value, "cached")
def test_pair_slots_take_their_own_receipt_fields(self):
receipts = self.Receipts(
[(("LLM positive", " pre ", " post "), ("LLM negative", "", ""))]
)
result = self.observe_with(
receipts,
[object(), object(), ["cached positive"], ["cached negative"]],
kind="PromptManagerPair",
raw=["dynamic", 0],
)
self.assertEqual(
receipts.calls[0], (JOB, "p", (("cached positive",), ("cached negative",)))
)
self.assertEqual(result.positive.raw.value, "LLM positive")
self.assertEqual(result.positive.effective.value, "cached positive")
# The negative editor is typed: the graph's value stays
self.assertEqual(result.negative.raw.value, "negative")
self.assertEqual(result.negative.raw.source, "graph_literal")
def test_default_receipts_are_the_process_cached_runs(self):
from utils import cached_runs
receipts = self.Receipts([(("LLM raw", " pre ", " post "),)])
with patch.object(cached_runs, "CACHED_RUNS", receipts):
self.begin(raw=["dynamic", 0])
self.executor.caches.outputs.entries["p"] = Entry(
None, [object(), ["cached"]]
)
self.observe("execution_start")
self.observe("execution_cached", nodes=["p"])
self.assertEqual(self.seal().positive.raw.value, "LLM raw")
+150 -1
View File
@@ -54,7 +54,7 @@ def status(kind="execution_success", job=JOB):
return Status("success", True, [(kind, {"prompt_id": job, "timestamp": 20})])
class HistoryHooksTests(unittest.TestCase):
class HooksCase(unittest.TestCase):
def setUp(self):
self.directory = tempfile.TemporaryDirectory()
self.addCleanup(self.directory.cleanup)
@@ -122,6 +122,8 @@ class HistoryHooksTests(unittest.TestCase):
def drain(self, registration):
drain_writer(registration.writer, clock=time.monotonic)
class HistoryHooksTests(HooksCase):
def test_package_history_initializes_when_monitor_and_usage_setup_fail(self):
try:
from tests.test_init_package import (
@@ -767,3 +769,150 @@ class HistoryHooksTests(unittest.TestCase):
self.assertEqual(
calls, ["execution_start", "execution_cached", "execution_cached"]
)
class CachedRuntimeHistoryTests(HooksCase):
"""A cached runtime-linked prompt is a tracked run, like an executed one (#403).
The real history hook and writer, a real ``CachedRuns`` and the real host
adapter over fakes of ComfyUI's executor, cache and executing context.
"""
TEXT = "a lighthouse the LLM wrote"
GRAPH = {
"str": {"class_type": "PrimitiveString", "inputs": {"value": "seed"}},
"llm": {"class_type": "PreviewAny", "inputs": {"source": ["str", 0]}},
"p": {"class_type": "PromptManager", "inputs": {"text": ["llm", 0]}},
"s": {"class_type": "KSampler", "inputs": {"positive": ["p", 0]}},
}
def setUp(self):
super().setUp()
from utils import cached_runs
from utils.cached_runs import CachedRuns
self.context = None
self.runs = CachedRuns(context=lambda: self.context or (None, None))
self.cache_entries = {}
entries = self.cache_entries
execution = types.ModuleType("execution")
caching = types.ModuleType("comfy_execution.caching")
package = types.ModuleType("comfy_execution")
utils_module = types.ModuleType("comfy_execution.utils")
entry_type = namedtuple("CacheEntry", "ui outputs")
class PromptExecutor:
def add_message(self, event, data, broadcast):
return None
class HierarchicalCache:
def get_local(self, node_id):
return entries.get(node_id)
execution.PromptExecutor, execution.CacheEntry = PromptExecutor, entry_type
caching.HierarchicalCache = HierarchicalCache
package.caching, package.utils = caching, utils_module
utils_module.get_executing_context = lambda: (
SimpleNamespace(prompt_id=self.context[0], node_id="p", list_index=0)
if self.context
else None
)
self.entry_type = entry_type
modules = patch.dict(
sys.modules,
{
"execution": execution,
"comfy_execution": package,
"comfy_execution.caching": caching,
"comfy_execution.utils": utils_module,
},
)
modules.start()
self.addCleanup(modules.stop)
swap = patch.object(cached_runs, "CACHED_RUNS", self.runs)
swap.start()
self.addCleanup(swap.stop)
self.executor = PromptExecutor()
self.executor.server = self.server
self.executor.caches = SimpleNamespace(outputs=HierarchicalCache())
self.prompt_id = self.db.save_prompt(text=self.TEXT)
self.item_id = 0
def job(self, cached):
"""One job; the node executes unless ``cached`` (ComfyUI's cache serves it)."""
from uuid import uuid4
from utils.cached_runs import stored_facts
from utils.execution_context_capture import record_runtime_context
self.item_id += 1
job = str(uuid4())
result = ((0, job, self.GRAPH, {}, ["s"]), self.item_id)
self.queue.results.append(result)
self.queue.get()
self.runs.begin_attempt(result)
self.context = (job, 0)
self.executor.add_message("execution_start", {"prompt_id": job}, False)
if cached:
self.executor.add_message(
"execution_cached", {"prompt_id": job, "nodes": ["p"]}, False
)
else:
# What PromptManager.encode_prompt records for this text
record_runtime_context("PromptManager", self.TEXT, "", "", self.TEXT)
self.runs.record_item(
"p", (self.TEXT,), stored_facts(self.TEXT), ((self.TEXT, "", ""),)
)
self.cache_entries["p"] = self.entry_type(None, [object(), [self.TEXT]])
self.context = None
self.queue.task_done(self.item_id, {}, status(job=job))
self.runs.end_attempt(self.item_id)
def tracked_runs(self):
with self.db.recorded_history_snapshot():
return self.db.list_history_window(self.prompt_id)["recorded_run_count"]
def test_three_jobs_of_runtime_text_are_three_tracked_runs(self):
registration = self.register()
for cached in (False, True, True):
self.job(cached)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 3)
rows = self.rows()
for row in rows:
contexts = self.db.get_execution_contexts(row["run_id"])["contexts"]
self.assertEqual(contexts[0]["positive"]["raw"], self.TEXT)
self.assertEqual(contexts[0]["positive_prompt_id"], self.prompt_id)
cached = self.db.get_execution_contexts(rows[1]["run_id"])["contexts"][0]
self.assertEqual(
cached["evidence"]["positive"]["raw"],
{"provenance": "inferred", "source": "graph_semantics", "reason": None},
)
self.assertEqual(
cached["evidence"]["positive"]["effective"]["source"], "host_cache_output"
)
# The stored history passes the whole-database check older builds run too
from database.execution_history_schema import history_schema_error
self.assertIsNone(history_schema_error(self.db.model.get_connection()))
# An image of a cached job can now be attributed through its run (#342)
from database.run_attribution import named_prompt
self.assertEqual(
[named_prompt(self.db, row["run_id"]) for row in rows],
[self.prompt_id] * 3,
)
def test_without_a_receipt_a_cached_job_stays_untracked(self):
registration = self.register()
self.job(False)
self.runs._receipts.clear() # as after a restart with a provider entry
self.job(True)
self.drain(registration)
self.assertEqual(self.tracked_runs(), 1)
cached = self.db.get_execution_contexts(self.rows()[1]["run_id"])["contexts"]
self.assertIsNone(cached[0]["positive"]["raw"])
self.assertIsNone(cached[0]["positive_prompt_id"])
from database.run_attribution import named_prompt
self.assertIsNone(named_prompt(self.db, self.rows()[1]["run_id"]))
+4 -1
View File
@@ -99,7 +99,10 @@ class TestPairUsageTracking(unittest.TestCase):
unique_id="1",
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("cat", ""), Facts(usage_tracking.generate_prompt_hash("cat"), None)
"1",
("cat", ""),
Facts(usage_tracking.generate_prompt_hash("cat"), None),
(("cat", "", ""), ("", "", "")),
)
def test_validation_rejection_never_counts_or_saves(self):
+8 -2
View File
@@ -308,7 +308,11 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
"typed text", prepend_text="pre", prompt=self.GRAPH, unique_id="1"
)
node.cached_runs.record_item.assert_called_once_with(
"1", ("pre typed text",), Facts(generate_prompt_hash("typed text"))
"1",
("pre typed text",),
Facts(generate_prompt_hash("typed text")),
# #403: the texts the history capture got, for a cached job's history
(("typed text", "pre", ""),),
)
# Hook-visible text: the node counts nothing itself
row = self.db.get_prompt_by_hash(generate_prompt_hash("typed text"))
@@ -319,7 +323,9 @@ class TestNodeRecordsCachedRunReceipt(UsageTestCase):
node = self._node()
node.process_text(" ", prompt=self.GRAPH, unique_id="1")
node.cached_runs.record_item.assert_called_once_with("1", (" ",), Facts(None))
node.cached_runs.record_item.assert_called_once_with(
"1", (" ",), Facts(None), ((" ", "", ""),)
)
if __name__ == "__main__":
+201 -28
View File
@@ -15,9 +15,14 @@ role (positive, negative, which Pair outputs are used) and what the hook
already saw are recomputed from that job's own graph, and both paths count
through the same ``own_counts`` so they cannot drift.
The receipt also keeps the raw, prepend and append text the node gave the
execution history (#403), so the history capture of a cached job can record
the raw text the graph cannot show (``recorded_fields``) and the job becomes a
tracked run of its prompt, as it is a counted one.
Fail closed: when the cached text cannot be matched to a receipt recorded in
this process, nothing is counted (the behavior before #347). In memory only and
bounded; starts no threads.
this process, nothing is counted (the behavior before #347) and the history
fields stay unknown. In memory only and bounded; starts no threads.
"""
import hashlib
@@ -39,6 +44,7 @@ try:
from .queue_observers import subscribe_dequeue, subscribe_task_done
from .run_keys import prompt_node_key, upstream_nodes
from .usage_tracking import find_pair_id_by_hashes, survive_switch
from .execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
except ImportError:
from utils.hashing import generate_prompt_hash
from utils.logging_config import get_logger
@@ -53,6 +59,7 @@ except ImportError:
from utils.queue_observers import subscribe_dequeue, subscribe_task_done
from utils.run_keys import prompt_node_key, upstream_nodes
from utils.usage_tracking import find_pair_id_by_hashes, survive_switch
from utils.execution_contexts import MAX_ATTEMPT_BYTES, MAX_FIELD_BYTES, text_size
logger = get_logger("prompt_manager.cached_runs")
@@ -61,9 +68,15 @@ MAX_KEYS = 256 # run keys remembered (the same bound as MAX_KEYED_RUNS)
MAX_RECEIPTS = 4 # receipts per run key: a recurring upstream value (A, B, A)
MAX_ITEMS = 64 # list items per receipt (the cache reader's bound)
MAX_CACHED_NODES = 500 # the execution_cached guard the history capture uses
MAX_FIELD_TEXT_BYTES = 8 * 1024 * 1024 # history fields all receipts keep
MAX_RECEIPT_FIELD_BYTES = MAX_ATTEMPT_BYTES # one receipt: what one run's history holds
_EMPTY_HASH = generate_prompt_hash("")
Count = Tuple[Any, ...] # ("prompt", hash) or ("pair", positive, negative or None)
# (raw, prepend, append) per STRING slot, as given to record_runtime_context
Fields = Tuple[Tuple[str, str, str], ...]
# Executions with equal outputs recorded different fields: none can be named
_CONFLICT: Fields = ()
class Facts(NamedTuple):
@@ -96,6 +109,8 @@ class Seen(NamedTuple):
class _Item(NamedTuple):
outputs: Tuple[str, ...]
facts: Facts
fields: Optional[Fields] = None
field_bytes: int = 0
class _Receipt:
@@ -108,6 +123,18 @@ class _Receipt:
def outputs(self) -> Dict[int, Tuple[str, ...]]:
return {index: item.outputs for index, item in self.items.items()}
def field_bytes(self) -> int:
return sum(item.field_bytes for item in self.items.values())
def strip_fields(self) -> int:
"""Drop the history fields (counting is unchanged); the bytes freed."""
freed = 0
for index, item in list(self.items.items()):
if item.field_bytes:
freed += item.field_bytes
self.items[index] = item._replace(fields=None, field_bytes=0)
return freed
class _Attempt(NamedTuple):
item_id: int
@@ -117,6 +144,22 @@ class _Attempt(NamedTuple):
replayed: set
def _field_size(fields: Any, slots: int) -> Optional[int]:
"""UTF-8 size of well-formed fields for ``slots`` outputs, else None."""
if type(fields) is not tuple or len(fields) != slots:
return None
size = 0
for slot in fields:
if type(slot) is not tuple or len(slot) != 3:
return None
for value in slot:
try:
size += text_size(value, MAX_FIELD_BYTES) # the capture's own check
except ValueError:
return None
return size
def text_digest(value: str) -> str:
"""Digest of one returned STRING value, exactly as returned."""
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
@@ -245,11 +288,14 @@ class CachedRuns:
max_keys: int = MAX_KEYS,
max_receipts: int = MAX_RECEIPTS,
max_items: int = MAX_ITEMS,
max_field_bytes: int = MAX_FIELD_TEXT_BYTES,
context: Callable[[], Tuple[Optional[str], Optional[int]]] = _host_context,
) -> None:
self._max_keys = max_keys
self._max_receipts = max_receipts
self._max_items = max_items
self._max_field_bytes = max_field_bytes
self._field_bytes = 0 # UTF-8 bytes of the fields all receipts keep
self._context = context
self._receipts: "OrderedDict[str, List[_Receipt]]" = OrderedDict()
self._attempt: Optional[_Attempt] = None
@@ -284,7 +330,10 @@ class CachedRuns:
self._attempt = None
for run_key, receipts in self._receipts.items():
if receipts and receipts[0].item_id == item_id:
self._receipts[run_key] = _deduplicated(receipts)
before = _bytes(receipts)
deduplicated = _deduplicated(receipts)
self._field_bytes += _bytes(deduplicated) - before
self._receipts[run_key] = deduplicated
def _current(self) -> Tuple[Optional[_Attempt], Optional[int]]:
"""The attempt the executing node belongs to, and its list index."""
@@ -305,8 +354,19 @@ class CachedRuns:
with self._lock:
return str(node_id) in attempt.replayed
def record_item(self, node_id: Any, outputs: Tuple[Any, ...], facts: Facts) -> None:
"""Remember what this execution returned and was entitled to count."""
def record_item(
self,
node_id: Any,
outputs: Tuple[Any, ...],
facts: Facts,
fields: Optional[Fields] = None,
) -> None:
"""Remember what this execution returned and was entitled to count.
``fields`` are the texts the node gave the execution history, one
``(raw, prepend, append)`` per output; kept only when well formed,
each within the history's field limit and the store within its budget.
"""
try:
attempt, index = self._current()
node = attempt.nodes.get(str(node_id)) if attempt else None
@@ -317,14 +377,20 @@ class CachedRuns:
return
if any(not isinstance(value, str) for value in outputs):
return
item = _Item(tuple(text_digest(value) for value in outputs), facts)
size = _field_size(fields, len(outputs)) if fields is not None else None
item = _Item(
tuple(text_digest(value) for value in outputs),
facts,
fields if size is not None else None,
size or 0,
)
self._store(node[1], attempt.item_id, index, item)
except Exception as error:
logger.debug(f"Could not record a cached-run receipt: {error}")
def _store(self, run_key: str, item_id: int, index: int, item: _Item) -> None:
with self._lock:
receipts = self._receipts.pop(run_key, [])
receipts = self._take(run_key)
if receipts and receipts[0].item_id == item_id:
receipt = receipts[0]
else:
@@ -334,9 +400,44 @@ class CachedRuns:
receipt = _Receipt(item_id)
receipts.insert(0, receipt)
receipt.items[index] = item
self._receipts[run_key] = receipts[: self._max_receipts]
receipts = receipts[: self._max_receipts]
if item.field_bytes and (
receipt.field_bytes() > MAX_RECEIPT_FIELD_BYTES
or not self._make_room(_bytes(receipts))
):
# Counting is as before; this job's history fields stay unknown
receipt.items[index] = item._replace(fields=None, field_bytes=0)
self._put(run_key, receipts)
while len(self._receipts) > self._max_keys:
self._receipts.popitem(last=False)
_, dropped = self._receipts.popitem(last=False)
self._field_bytes -= _bytes(dropped)
def _make_room(self, needed: int) -> bool:
"""Free the fields of the least recently used receipts (under the lock).
Old receipts of workflows never run again must not hold the budget:
their counting stays, only their history fields go. False when
``needed`` does not fit even then.
"""
if needed > self._max_field_bytes:
return False
for receipts in self._receipts.values(): # least recently used first
if self._field_bytes + needed <= self._max_field_bytes:
break
for receipt in receipts:
self._field_bytes -= receipt.strip_fields()
return self._field_bytes + needed <= self._max_field_bytes
def _take(self, run_key: str) -> List[_Receipt]:
"""Remove a key's receipts and their bytes from the store (under the lock)."""
receipts = self._receipts.pop(run_key, [])
self._field_bytes -= _bytes(receipts)
return list(receipts)
def _put(self, run_key: str, receipts: List[_Receipt]) -> None:
"""Store a key's receipts as the most recently used (under the lock)."""
self._receipts[run_key] = receipts
self._field_bytes += _bytes(receipts)
# -- cached nodes -------------------------------------------------------
@@ -393,25 +494,11 @@ class CachedRuns:
def _match(self, run_key: str, values: Any) -> Optional[List[_Item]]:
"""The items of the one receipt whose outputs equal ``values``, in order."""
if not values:
return None
length = len(values[0])
if length == 0 or length > self._max_items:
return None
if any(len(slot) != length for slot in values):
return None
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
with self._lock:
receipts = self._receipts.get(run_key)
if not receipts:
return None
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
matches = self._matching(run_key, values)
if not matches:
return None
self._receipts.move_to_end(run_key)
length = len(values[0])
facts = {
tuple(receipt.items[index].facts for index in range(length))
for receipt in matches
@@ -420,13 +507,99 @@ class CachedRuns:
return None
return [matches[0].items[index] for index in range(length)]
def _matching(self, run_key: str, values: Any) -> List[_Receipt]:
"""Receipts of ``run_key`` whose outputs equal ``values`` (under the lock)."""
if type(values) not in (list, tuple) or not values:
return []
if any(type(slot) not in (list, tuple) for slot in values):
return []
length = len(values[0])
if length == 0 or length > self._max_items:
return []
if any(len(slot) != length for slot in values):
return []
if any(type(value) is not str for slot in values for value in slot):
return []
wanted = {
index: tuple(text_digest(slot[index]) for slot in values)
for index in range(length)
}
receipts = self._receipts.get(run_key)
if not receipts:
return []
matches = [receipt for receipt in receipts if receipt.outputs() == wanted]
if matches:
self._receipts.move_to_end(run_key)
return matches
# -- the history capture (#403) -------------------------------------------
def recorded_fields(
self, prompt_id: Any, node_id: Any, values: Any
) -> Optional[List[Fields]]:
"""The history fields of the execution a cached node reuses, per list item.
``values`` are the node's cached STRING outputs (``cached_string_outputs``).
Only for a node of the job currently executing, and only when every
receipt matching ``values`` kept the same fields; None otherwise. Never
counts and never marks the node replayed.
"""
try:
with self._lock:
attempt = self._attempt
if (
attempt is None
or type(prompt_id) is not str
or prompt_id != attempt.prompt_id
or type(node_id) is not str
or node_id not in attempt.nodes
):
return None
matches = self._matching(attempt.nodes[node_id][1], values)
if not matches:
return None
found = []
for index in range(len(values[0])):
fields = {receipt.items[index].fields for receipt in matches}
if len(fields) != 1 or None in fields or _CONFLICT in fields:
return None
found.append(fields.pop())
return found
except Exception as error:
logger.debug(f"Could not read a cached run's history fields: {error}")
return None
def _deduplicated(receipts: List[_Receipt]) -> List[_Receipt]:
"""The newest receipt replaces older ones with the same outputs."""
"""The newest receipt replaces older ones with the same outputs.
Counting needs only the outputs. When a replaced receipt recorded other
history fields for equal outputs (a raw text the outputs do not show),
the newest item's fields become a conflict, so no cached job of it is
given either text (#403). A conflict stays until the run key is dropped.
"""
if not receipts:
return receipts
outputs = receipts[0].outputs()
return [receipts[0]] + [r for r in receipts[1:] if r.outputs() != outputs]
newest, outputs = receipts[0], receipts[0].outputs()
kept = [newest]
for receipt in receipts[1:]:
if receipt.outputs() != outputs:
kept.append(receipt)
continue
for index, item in list(newest.items.items()):
older = receipt.items[index].fields
if older == _CONFLICT or (
older is not None and item.fields is not None and older != item.fields
):
newest.items[index] = item._replace(fields=_CONFLICT, field_bytes=0)
return kept
def _bytes(receipts: List[_Receipt]) -> int:
"""UTF-8 bytes of the history fields ``receipts`` keep."""
return sum(
item.field_bytes for receipt in receipts for item in receipt.items.values()
)
def _job_nodes(graph: Dict[str, Any], outputs: Any) -> Dict[str, Tuple[str, str]]:
+83 -5
View File
@@ -151,10 +151,77 @@ def cached_string_outputs(
return None
def _receipt_fields(
receipts: object, executor: object, job_id: str, source: object
) -> list | None:
"""Per list index, the ``(raw, prepend, append)`` of the execution reused (#403).
From the #347 receipt whose digests equal every STRING slot of the cache
entry; None when there is none or the answer is malformed.
"""
try:
if receipts is None:
try:
from .cached_runs import CACHED_RUNS as receipts
except ImportError:
from utils.cached_runs import CACHED_RUNS as receipts
values = cached_string_outputs(executor, source.node_id, source.node_type)
if values is None:
return None
found = receipts.recorded_fields(job_id, source.node_id, values)
if type(found) is not list or len(found) != len(values[0]):
return None
position = (
(1 if source.output_slot in (1, 3) else 0)
if source.node_type == "PromptManagerPair"
else 0
)
fields = []
for slots in found:
slot = slots[position] if type(slots) is tuple else None
if not (
type(slot) is tuple
and len(slot) == 3
and all(type(value) is str for value in slot)
):
return None
fields.append(slot)
return fields
except Exception:
return None
def _agrees(inferred: PromptSnapshot, fields: tuple) -> bool:
"""Every field the graph knows equals the receipt's; else fill nothing."""
known = (inferred.raw, inferred.prepend, inferred.append)
return all(
field.value is None or field.value == value
for field, value in zip(known, fields)
)
def _filled(inferred: TextEvidence, value: str) -> TextEvidence:
"""A field the graph cannot show, taken from the reused execution (#403)."""
if inferred.value is not None:
return inferred
return TextEvidence(value, "inferred", "graph_semantics", None)
def observe_cached_context(
executor: object, message_type: object, message_data: object
executor: object,
message_type: object,
message_data: object,
*,
receipts: object = None,
) -> None:
"""Copy bounded local strings without cache/provider execution or retention."""
"""Copy bounded local strings without cache/provider execution or retention.
Raw, prepend and append come from graph inference; a field it leaves
unknown (runtime-only text) is taken from the receipt of the execution the
cache entry is proven to come from (``CachedRuns.recorded_fields``), so the
cached job is a tracked run of its prompt (#403). ``receipts`` defaults to
the process ``CACHED_RUNS``.
"""
try:
binding = active_capture()
if binding is None or type(message_type) is not str:
@@ -198,18 +265,29 @@ def observe_cached_context(
inferred = manager.source_snapshot(cap, source)
if inferred is None:
continue
fields = None
if any(
field.value is None
for field in (inferred.raw, inferred.prepend, inferred.append)
):
fields = _receipt_fields(receipts, executor, cap.job_id, source)
for index, value in enumerate(values):
if type(value) is not str:
continue
raw, prepend, append = inferred.raw, inferred.prepend, inferred.append
if fields is not None and _agrees(inferred, fields[index]):
raw = _filled(raw, fields[index][0])
prepend = _filled(prepend, fields[index][1])
append = _filled(append, fields[index][2])
manager.observe(
cap,
source,
index,
PromptSnapshot(
inferred.raw,
raw,
TextEvidence(value, "recorded", "host_cache_output", None),
inferred.prepend,
inferred.append,
prepend,
append,
),
)
except Exception:
+36 -15
View File
@@ -176,24 +176,32 @@ function extensionOf(record) {
return (record.file?.extension || record.identity.path.split('.').at(-1)).replace(/^\./, '').toUpperCase();
}
/**
* The chip row after the prompt blocks (D88): the file's current image tags first, then D21's linked tags not already
* shown (case-insensitive). With `filterable` (a Tags filter exists, as in the Images panel) every image tag sets it, and
* a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip is plain.
* The tag chips after the prompt blocks (D88, D154): the file's current image tags (`source: 'image'`), then D21's linked
* tags (`source: 'linked'`), each without case-insensitive repeats within its own source (D154: a linked tag that is also
* an image tag stays in both labelled sections). With `filterable` (a Tags filter exists, as in the Images panel) every
* image tag sets it, and a linked tag only when it is the positive's own (D21); otherwise, as in the viewer, every chip
* is plain.
*/
export function inspectorTagChips(record, {filterable}) {
const shown = new Set(), chips = [];
const add = (names, filter) => {
const chips = [];
const add = (names, filter, source) => {
const shown = new Set();
for (const name of names) {
const key = name.toLowerCase();
if (shown.has(key)) continue;
shown.add(key); chips.push({name, filter});
shown.add(key); chips.push({name, filter, source});
}
};
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable);
if (record.imageTags?.state === 'current') add(record.imageTags.tags, filterable, 'image');
const {library} = record;
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive');
if (library?.status === 'linked') add(library.tags, filterable && library.tags_scope === 'positive', 'linked');
return chips;
}
/** D154 (#449): a tag section's visible heading; linked tags name their owner as D146's category chip does. */
export function tagSectionLabel(source, library) {
if (source === 'image') return 'Image tags';
return library?.tags_scope === 'pair' ? 'Recipe tags' : 'Prompt tags';
}
/** A concept chip whose visible text carries a visually hidden prefix naming what it is. */
/**
* The category chip (#332 D146): the hidden prefix, the category, then `span.chip-source` " · this image", " · prompt"
@@ -295,14 +303,11 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
// not what it matches, so they (and chips where no Tags filter exists, as in the viewer) stay plain. With the
// D39 fallback a recipe without tags shows its positive's, which are filter buttons again. D88: the file's own
// image tags lead the row and, where a Tags filter exists, always set it (it matches them too).
// D154 (#449): each source gets its own labelled section, so image tags never read as part of the negative prompt.
const chips = inspectorTagChips(record, {filterable:!!onTagFilter});
if (chips.length) {
const tags = el('div', undefined, 'tags');
for (const {name, filter} of chips) {
if (!filter) {tags.append(el('span', name, 'chip')); continue;}
const chip = createButton(name, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(name)); tags.append(chip);
}
inner.append(tags);
for (const source of ['image', 'linked']) {
const own = chips.filter(chip => chip.source === source);
if (own.length) inner.append(tagSection(source, tagSectionLabel(source, library), own));
}
inner.append(...generationFacts(record, 'meta-grid', 'small'));
if (full) inner.append(summaryButton(record), provenance(record, badge), rawMetadata(record));
@@ -336,6 +341,22 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
}
if (!full && footer.childNodes.length) container.append(footer);
}
/**
* D154: `section.tag-section` in the prompt sections' frame, its `.role-label` heading naming the source and count.
* The region's name leaves the count out and carries the prompt blocks' context ("Current file image tags").
*/
function tagSection(source, label, chips) {
const section = el('section', undefined, `tag-section ${source}-tags`), heading = el('div', undefined, 'prompt-heading');
section.setAttribute('aria-label', promptContext ? `${promptContext} ${label.toLowerCase()}` : label);
heading.append(el('span', `${label} · ${chips.length}`, 'role-label'));
const tags = el('div', undefined, 'tags');
for (const {name: text, filter} of chips) {
if (!filter) {tags.append(el('span', text, 'chip')); continue;}
const chip = createButton(text, 'chip'); contentScope.listen(chip, 'click', () => onTagFilter(text)); tags.append(chip);
}
section.append(heading, tags);
return section;
}
/**
* D117 (LI-4): "Copy settings summary" after the six facts, the legacy COPY ALL. It copies as the concept's prompt
* copy does: the shared toast, or the select-to-copy dialog where the clipboard is unavailable or refused.
+5
View File
@@ -237,6 +237,11 @@ main:focus-visible { outline: 2px solid var(--ca-focus); outline-offset: 4px; }
details, and an unavailable download stays visibly inert next to its reason. */
.file-inspector { min-inline-size: 0; overflow-wrap: anywhere; }
.file-inspector [aria-disabled="true"] { opacity: .6; cursor: not-allowed; }
/* #449 D154: the image's own tags and the linked prompt's or recipe's tags sit in the prompt sections' frame (concept
.prompt-section), under a muted .role-label; the heading's 8px spaces the chips, as it spaces a prompt's text. */
.file-inspector .tag-section { border-top: 1px solid var(--line); padding-top: 15px; margin-top: 15px; }
.file-inspector .tag-section .role-label { color: var(--ca-muted); }
.file-inspector .tag-section > .tags { margin-top: 0; }
/* #249 D20: the viewer details' pair send and history buttons wrap inside the narrow rail at 200% zoom with wider fonts,
where the toolkit's `nowrap` would push them past its edge. */
.ca-tools #viewer-details > button { white-space: normal; /* D20 */ }